aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/tests/test_route_recommend.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-28 13:12:57 -0400
committerCraig Jennings <c@cjennings.net>2026-06-28 13:12:57 -0400
commit6be62aee7437fd8fe8d6eff991869b09529d3924 (patch)
tree0360a199e0152541a27c6db21daab88c1d657b5c /.ai/scripts/tests/test_route_recommend.py
parent6fb6797fb6dfa2d468f873c000b64eb6ef6accb6 (diff)
downloadrulesets-6be62aee7437fd8fe8d6eff991869b09529d3924.tar.gz
rulesets-6be62aee7437fd8fe8d6eff991869b09529d3924.zip
feat(scripts): add wrap-up routing recommendation engine
I added route_recommend.py, a pure recommend(item, projects) → (destination, confidence). It has strong, weak, and none tiers, word-boundary literal matching that also handles dot-stripped name aliases, and a deterministic tie-break that downgrades an ambiguous top-tier tie to weak. An empty candidate list yields none. The CLI reuses inbox-send's discover_projects, so the candidate set is the same project universe inbox-send already knows. This covers Phases 1 and 3 of the wrap-up routing spec. The marker and router sub-tasks call it next.
Diffstat (limited to '.ai/scripts/tests/test_route_recommend.py')
-rw-r--r--.ai/scripts/tests/test_route_recommend.py124
1 files changed, 124 insertions, 0 deletions
diff --git a/.ai/scripts/tests/test_route_recommend.py b/.ai/scripts/tests/test_route_recommend.py
new file mode 100644
index 0000000..acc4755
--- /dev/null
+++ b/.ai/scripts/tests/test_route_recommend.py
@@ -0,0 +1,124 @@
+"""Tests for route_recommend.py — the wrap-up routing recommendation engine.
+
+The core is a pure function recommend(item, projects) -> (destination, confidence):
+- strong: a project's name (or its dot-stripped form) appears literally in the item
+- weak: a distinctive name token overlaps, but the full name doesn't
+- none: no overlap; the item stays put (destination is None)
+
+A multi-way tie at the top tier downgrades to weak with a deterministic pick.
+An empty project list yields none.
+
+The CLI wires this to inbox-send.py's discover_projects (sandboxed here via the
+INBOX_SEND_ROOTS env var, the same hook inbox-send's own tests use).
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "route_recommend.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import route_recommend as rr # noqa: E402
+
+
+# --- pure function: the five spec'd cases -----------------------------------
+
+def test_strong_match_named_literally():
+ dest, conf = rr.recommend("fix the rulesets refactor command", ["rulesets", "home", "work"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_match_via_dot_stripped_name():
+ # ".emacs.d" addressed as "emacsd" in the item is still a literal hit.
+ dest, conf = rr.recommend("update the emacsd ai-term module", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_strong_match_dotted_name_verbatim():
+ dest, conf = rr.recommend("patch .emacs.d startup", [".emacs.d", "rulesets"])
+ assert (dest, conf) == (".emacs.d", "strong")
+
+
+def test_weak_match_topic_token_only():
+ # "wttrin" is a token of "emacs-wttrin" but the full name isn't present.
+ dest, conf = rr.recommend("the wttrin weather bug", ["emacs-wttrin", "rulesets"])
+ assert (dest, conf) == ("emacs-wttrin", "weak")
+
+
+def test_no_match_stays_put():
+ dest, conf = rr.recommend("calibrate the telescope mount", ["rulesets", "deepsat"])
+ assert dest is None
+ assert conf == "none"
+
+
+def test_two_project_strong_tie_downgrades_to_weak():
+ # Both named literally → ambiguous → weak, deterministic tie-break (alphabetical).
+ dest, conf = rr.recommend("sync rulesets and home configs", ["rulesets", "home", "work"])
+ assert conf == "weak"
+ assert dest == "home" # tie-break: most-overlap then alphabetical
+
+
+def test_empty_project_list_is_none():
+ assert rr.recommend("anything at all", []) == (None, "none")
+
+
+# --- boundary / robustness --------------------------------------------------
+
+def test_literal_name_requires_word_boundary():
+ # "home" must not match inside "homeowner".
+ dest, conf = rr.recommend("the homeowner association meeting", ["home", "rulesets"])
+ assert dest is None and conf == "none"
+
+
+def test_path_mention_counts_as_literal():
+ dest, conf = rr.recommend("edit ~/code/rulesets/Makefile", ["rulesets", "home"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+def test_strong_beats_weak_when_both_present():
+ # "rulesets" named literally (strong) outranks an emacs-wttrin token hit (weak).
+ dest, conf = rr.recommend("the wttrin fix belongs in rulesets", ["rulesets", "emacs-wttrin"])
+ assert (dest, conf) == ("rulesets", "strong")
+
+
+# --- CLI + discovery reuse (sandboxed roots) --------------------------------
+
+def _run(args, roots, item):
+ import os
+ env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "/tmp"),
+ "INBOX_SEND_ROOTS": ":".join(str(r) for r in roots)}
+ return subprocess.run([sys.executable, str(SCRIPT), "--item", item, *args],
+ capture_output=True, text=True, env=env)
+
+
+def _mk_project(tmp_path, name):
+ proj = tmp_path / "projects" / name
+ (proj / ".ai").mkdir(parents=True, exist_ok=True)
+ (proj / "inbox").mkdir(exist_ok=True)
+ return proj
+
+
+def test_cli_discovers_and_recommends(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ r = _run([], roots=[tmp_path / "projects"], item="fix the foo widget")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "foo\tstrong"
+
+
+def test_cli_no_match_prints_none(tmp_path):
+ _mk_project(tmp_path, "foo")
+ r = _run([], roots=[tmp_path / "projects"], item="unrelated grocery list")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "none"
+
+
+def test_cli_exclude_drops_current_project(tmp_path):
+ _mk_project(tmp_path, "foo")
+ _mk_project(tmp_path, "bar")
+ # Item names foo, but foo is excluded as the current project → no other match.
+ r = _run(["--exclude", "foo"], roots=[tmp_path / "projects"], item="fix the foo widget")
+ assert r.returncode == 0
+ assert r.stdout.strip() == "none"