Coverage for little_loops / cli / verify_triggers.py: 18%
238 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:11 -0500
1"""ll-verify-triggers: Validate skill description trigger accuracy.
3Empirically validates whether each skill's ``description`` field fires correctly:
4it should trigger on a set of realistic should-fire phrasings and stay silent on
5a set of near-miss should-NOT-fire phrasings. Reports per-skill precision/recall
6and a cross-skill collision matrix. Exits non-zero when any skill falls below
7threshold or collides with another skill.
8"""
10from __future__ import annotations
12import argparse
13import json
14import re
15import sys
16from collections import defaultdict
17from dataclasses import dataclass, field
18from pathlib import Path
20from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
22# ---------------------------------------------------------------------------
23# Data types
24# ---------------------------------------------------------------------------
26STOPWORDS: frozenset[str] = frozenset(
27 {
28 "the",
29 "a",
30 "an",
31 "is",
32 "are",
33 "was",
34 "were",
35 "be",
36 "been",
37 "being",
38 "have",
39 "has",
40 "had",
41 "do",
42 "does",
43 "did",
44 "will",
45 "would",
46 "could",
47 "should",
48 "may",
49 "might",
50 "can",
51 "shall",
52 "to",
53 "of",
54 "in",
55 "for",
56 "on",
57 "with",
58 "at",
59 "by",
60 "from",
61 "as",
62 "or",
63 "and",
64 "not",
65 "no",
66 "but",
67 "if",
68 "then",
69 "else",
70 "when",
71 "where",
72 "how",
73 "all",
74 "each",
75 "every",
76 "both",
77 "few",
78 "more",
79 "most",
80 "other",
81 "some",
82 "such",
83 "only",
84 "own",
85 "same",
86 "so",
87 "than",
88 "too",
89 "very",
90 "just",
91 "about",
92 "above",
93 "after",
94 "again",
95 "against",
96 "any",
97 "because",
98 "before",
99 "between",
100 "into",
101 "through",
102 "during",
103 "under",
104 "over",
105 "its",
106 "it",
107 "this",
108 "that",
109 "these",
110 "those",
111 "use",
112 "used",
113 "using",
114 "also",
115 "get",
116 "got",
117 "make",
118 "made",
119 "see",
120 }
121)
123_MIN_WORD_LEN = 3
126@dataclass
127class TriggerFixtures:
128 """Parsed trigger fixture data from a skill's frontmatter."""
130 should_fire: list[str]
131 should_not_fire: list[str]
134@dataclass
135class PrecisionRecall:
136 """Precision and recall metrics for a single skill."""
138 precision: float
139 recall: float
142@dataclass
143class SkillTriggerResult:
144 """Per-skill validation result."""
146 skill_name: str
147 description: str
148 tp: int = 0
149 fp: int = 0
150 fn: int = 0
151 precision: float = 0.0
152 recall: float = 0.0
153 false_positive_phrasings: list[str] = field(default_factory=list)
154 false_negative_phrasings: list[str] = field(default_factory=list)
157# Naming aliases for test compatibility
158PhrasingFixture = TriggerFixtures
161# ---------------------------------------------------------------------------
162# Keyword extraction
163# ---------------------------------------------------------------------------
166def _extract_keywords(description: str) -> set[str]:
167 """Extract matchable keywords from a skill description.
169 Looks for an explicit ``Trigger keywords:`` line first; falls back to
170 extracting all substantive words from the description body, filtering
171 stopwords and short tokens.
172 """
173 for line in description.splitlines():
174 stripped = line.strip()
175 if stripped.lower().startswith("trigger keywords"):
176 # Everything after the colon
177 if ":" in stripped:
178 trigger_text = stripped.split(":", 1)[1].strip()
179 else:
180 trigger_text = ""
181 return _tokenize(trigger_text)
183 # Fallback: tokenize the full description
184 return _tokenize(description)
187def _tokenize(text: str) -> set[str]:
188 """Tokenize text into a set of normalized keywords."""
189 tokens: set[str] = set()
190 for word in re.findall(r"[a-zA-Z][a-zA-Z0-9_-]*", text.lower()):
191 if len(word) >= _MIN_WORD_LEN and word not in STOPWORDS:
192 tokens.add(word)
193 return tokens
196# ---------------------------------------------------------------------------
197# Matching
198# ---------------------------------------------------------------------------
201def _match_phrasing(phrasing: str, keywords: set[str]) -> bool:
202 """Return True if *phrasing* contains at least one keyword token."""
203 phrasing_tokens = _tokenize(phrasing)
204 return bool(phrasing_tokens & keywords)
207def _match_score(phrasing: str, keywords: set[str]) -> int:
208 """Number of keyword tokens found in the phrasing."""
209 phrasing_tokens = _tokenize(phrasing)
210 return len(phrasing_tokens & keywords)
213# ---------------------------------------------------------------------------
214# Fixture loading
215# ---------------------------------------------------------------------------
218def _load_trigger_fixtures(skill_md_path: Path) -> TriggerFixtures | None:
219 """Parse trigger_fixtures from a SKILL.md frontmatter block.
221 Returns None if the file has no trigger_fixtures or empty lists.
222 """
223 try:
224 text = skill_md_path.read_text()
225 except OSError:
226 return None
228 if not text.startswith("---"):
229 return None
231 end = text.find("---", 3)
232 if end == -1:
233 return None
235 # Quick substring check before attempting YAML parse
236 fm_text = text[3:end]
237 if "trigger_fixtures" not in fm_text:
238 return None
240 import yaml
242 try:
243 fm = yaml.safe_load(fm_text)
244 except yaml.YAMLError:
245 return None
247 if not isinstance(fm, dict):
248 return None
250 fixtures = fm.get("trigger_fixtures")
251 if not isinstance(fixtures, dict):
252 return None
254 should_fire = fixtures.get("should_fire", [])
255 should_not_fire = fixtures.get("should_not_fire", [])
257 if not isinstance(should_fire, list) or not isinstance(should_not_fire, list):
258 return None
260 if not should_fire and not should_not_fire:
261 return None
263 return TriggerFixtures(
264 should_fire=[str(s) for s in should_fire],
265 should_not_fire=[str(s) for s in should_not_fire],
266 )
269# ---------------------------------------------------------------------------
270# Skill description loading
271# ---------------------------------------------------------------------------
274def _load_skill_descriptions(skills_dir: Path) -> dict[str, tuple[str, Path]]:
275 """Load (description, path) for all SKILL.md files under *skills_dir*.
277 Returns a dict mapping skill name → (description, path).
278 Skills without frontmatter or a description are skipped.
279 """
280 skills: dict[str, tuple[str, Path]] = {}
282 if not skills_dir.is_dir():
283 return skills
285 for skill_md in sorted(skills_dir.glob("*/SKILL.md")):
286 try:
287 text = skill_md.read_text()
288 except OSError:
289 continue
291 if not text.startswith("---"):
292 continue
294 end = text.find("---", 3)
295 if end == -1:
296 continue
298 fm_text = text[3:end]
300 import yaml
302 try:
303 fm = yaml.safe_load(fm_text)
304 except yaml.YAMLError:
305 continue
307 if not isinstance(fm, dict):
308 continue
310 name = fm.get("name") or skill_md.parent.name
311 desc = fm.get("description", "")
312 if desc:
313 skills[name] = (str(desc), skill_md)
315 return skills
318# ---------------------------------------------------------------------------
319# Metrics computation
320# ---------------------------------------------------------------------------
323def _compute_precision_recall(tp: int, fp: int, fn: int) -> PrecisionRecall:
324 """Return (precision, recall) from confusion-matrix counts."""
325 precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
326 recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
327 return PrecisionRecall(precision=precision, recall=recall)
330def _detect_collisions(
331 results: dict[str, SkillTriggerResult],
332 phrasing_matches: dict[str, set[str]],
333) -> list[dict]:
334 """Detect cross-skill collisions from phrase-level match data.
336 A collision occurs when a single phrasing triggered more than one skill.
337 """
338 collisions: list[dict] = []
339 for phrasing, matched_skills in sorted(phrasing_matches.items()):
340 if len(matched_skills) > 1:
341 collisions.append(
342 {
343 "phrasing": phrasing,
344 "skills": sorted(matched_skills),
345 }
346 )
347 return collisions
350# ---------------------------------------------------------------------------
351# Core validation
352# ---------------------------------------------------------------------------
355def _run_validation(
356 skills_dir: Path,
357 precision_threshold: float = 0.5,
358 recall_threshold: float = 0.5,
359) -> tuple[dict[str, SkillTriggerResult], list[dict], dict]:
360 """Run full trigger validation across all skills.
362 Returns (results_by_skill, collisions, thresholds_dict).
363 """
364 # 1. Load skill descriptions and extract keywords
365 skill_descs = _load_skill_descriptions(skills_dir)
366 skill_keywords: dict[str, set[str]] = {
367 name: _extract_keywords(desc) for name, (desc, _) in skill_descs.items()
368 }
370 # 2. Load trigger fixtures per skill
371 skill_fixtures: dict[str, TriggerFixtures] = {}
372 for name, (_, path) in skill_descs.items():
373 fixtures = _load_trigger_fixtures(path)
374 if fixtures is not None:
375 skill_fixtures[name] = fixtures
377 # 3. Evaluate each skill with fixtures
378 results: dict[str, SkillTriggerResult] = {}
379 phrasing_matches: dict[str, set[str]] = defaultdict(set)
381 for skill_name, (desc, _) in skill_descs.items():
382 fixtures = skill_fixtures.get(skill_name)
383 if fixtures is None:
384 results[skill_name] = SkillTriggerResult(
385 skill_name=skill_name,
386 description=desc,
387 )
388 continue
390 tp = 0
391 fp = 0
392 fn = 0
393 fp_phrasings: list[str] = []
394 fn_phrasings: list[str] = []
396 # Evaluate should_fire phrasings
397 for phrasing in fixtures.should_fire:
398 # Check which skills this phrasing matches
399 matched: list[str] = []
400 for other_name, other_kw in skill_keywords.items():
401 if _match_phrasing(phrasing, other_kw):
402 matched.append(other_name)
404 if matched:
405 phrasing_matches[phrasing] |= set(matched)
407 if skill_name in matched:
408 tp += 1
409 else:
410 fn += 1
411 fn_phrasings.append(phrasing)
413 # Evaluate should_not_fire phrasings
414 for phrasing in fixtures.should_not_fire:
415 matched_not: list[str] = []
416 for other_name, other_kw in skill_keywords.items():
417 if _match_phrasing(phrasing, other_kw):
418 matched_not.append(other_name)
420 if matched_not:
421 phrasing_matches[phrasing] |= set(matched_not)
423 if skill_name in matched_not:
424 fp += 1
425 fp_phrasings.append(phrasing)
427 pr = _compute_precision_recall(tp, fp, fn)
428 results[skill_name] = SkillTriggerResult(
429 skill_name=skill_name,
430 description=desc,
431 tp=tp,
432 fp=fp,
433 fn=fn,
434 precision=pr.precision,
435 recall=pr.recall,
436 false_positive_phrasings=fp_phrasings,
437 false_negative_phrasings=fn_phrasings,
438 )
440 # 4. Detect collisions
441 collisions = _detect_collisions(results, dict(phrasing_matches))
443 thresholds = {
444 "precision_threshold": precision_threshold,
445 "recall_threshold": recall_threshold,
446 }
448 return results, collisions, thresholds
451# ---------------------------------------------------------------------------
452# Formatting
453# ---------------------------------------------------------------------------
456def _format_text_report(
457 results: dict[str, SkillTriggerResult],
458 collisions: list[dict],
459 thresholds: dict,
460) -> str:
461 """Format a human-readable text report."""
462 lines: list[str] = []
463 lines.append("Skill Trigger Validation Report")
464 lines.append("=" * 50)
465 lines.append(
466 f"Thresholds: precision ≥ {thresholds['precision_threshold']:.0%}, "
467 f"recall ≥ {thresholds['recall_threshold']:.0%}"
468 )
469 lines.append("")
471 # Per-skill table
472 if results:
473 lines.append(f"{'Skill':<40} {'Precision':>10} {'Recall':>10}")
474 lines.append(f"{'':-<40} {'':->10} {'':->10}")
475 for name in sorted(results):
476 r = results[name]
477 lines.append(f"{name:<40} {r.precision:>10.0%} {r.recall:>10.0%}")
478 lines.append("")
480 # Collisions
481 if collisions:
482 lines.append("Cross-Skill Collisions")
483 lines.append("=" * 50)
484 for c in collisions:
485 skills = ", ".join(c["skills"])
486 lines.append(f' Phrasing: "{c["phrasing"]}"')
487 lines.append(f" Colliding skills: {skills}")
488 lines.append("")
489 else:
490 lines.append("No cross-skill collisions detected.")
491 lines.append("")
493 # Failures
494 failures: list[str] = []
495 for name in sorted(results):
496 r = results[name]
497 if r.precision < thresholds["precision_threshold"]:
498 failures.append(
499 f" {name}: precision {r.precision:.0%} < {thresholds['precision_threshold']:.0%}"
500 )
501 if r.recall < thresholds["recall_threshold"]:
502 failures.append(
503 f" {name}: recall {r.recall:.0%} < {thresholds['recall_threshold']:.0%}"
504 )
506 if collisions:
507 failures.append(" Collisions detected")
508 if failures:
509 lines.append("FAILURES")
510 lines.append("=" * 50)
511 for f in failures:
512 lines.append(f)
514 return "\n".join(lines)
517def _format_json_report(
518 results: dict[str, SkillTriggerResult],
519 collisions: list[dict],
520 thresholds: dict,
521) -> str:
522 """Format results as JSON."""
523 skills_list = []
524 for name in sorted(results):
525 r = results[name]
526 skills_list.append(
527 {
528 "name": r.skill_name,
529 "description": r.description,
530 "precision": r.precision,
531 "recall": r.recall,
532 "tp": r.tp,
533 "fp": r.fp,
534 "fn": r.fn,
535 "false_positive_phrasings": r.false_positive_phrasings,
536 "false_negative_phrasings": r.false_negative_phrasings,
537 }
538 )
540 return json.dumps(
541 {
542 "thresholds": thresholds,
543 "skills": skills_list,
544 "collisions": collisions,
545 },
546 indent=2,
547 )
550# ---------------------------------------------------------------------------
551# Orchestration
552# ---------------------------------------------------------------------------
555def _any_failures(
556 results: dict[str, SkillTriggerResult],
557 collisions: list[dict],
558 precision_threshold: float,
559 recall_threshold: float,
560) -> bool:
561 """True when any skill is below threshold or collisions exist."""
562 if collisions:
563 return True
564 for r in results.values():
565 if r.precision < precision_threshold:
566 return True
567 if r.recall < recall_threshold:
568 return True
569 return False
572# ---------------------------------------------------------------------------
573# Entry point
574# ---------------------------------------------------------------------------
577def main_verify_triggers() -> int:
578 """Entry point for ll-verify-triggers CLI.
580 Returns 0 when all skills meet threshold and there are no collisions;
581 returns 1 otherwise.
582 """
583 with cli_event_context(DEFAULT_DB_PATH, "ll-verify-triggers", sys.argv[1:]):
584 parser = argparse.ArgumentParser(
585 prog="ll-verify-triggers",
586 description=(
587 "Validate skill description trigger accuracy against should-fire "
588 "and should-NOT-fire phrasings. Reports per-skill precision/recall "
589 "and cross-skill collision matrix."
590 ),
591 formatter_class=argparse.RawDescriptionHelpFormatter,
592 epilog="""\
593Examples:
594 %(prog)s # Validate all skills against default thresholds
595 %(prog)s --json # Machine-readable JSON output
596 %(prog)s --precision-threshold 0.8 --recall-threshold 0.6
598Exit codes:
599 0 - All skills meet thresholds, no collisions
600 1 - One or more skills below threshold, or collisions detected
601""",
602 )
603 parser.add_argument(
604 "-C",
605 "--directory",
606 type=Path,
607 default=None,
608 help="Base directory (default: current directory)",
609 )
610 parser.add_argument(
611 "--json",
612 action="store_true",
613 default=False,
614 help="Output results as JSON",
615 )
616 parser.add_argument(
617 "--precision-threshold",
618 type=float,
619 default=0.5,
620 metavar="F",
621 help="Minimum precision required (default: 0.5)",
622 )
623 parser.add_argument(
624 "--recall-threshold",
625 type=float,
626 default=0.5,
627 metavar="F",
628 help="Minimum recall required (default: 0.5)",
629 )
631 args = parser.parse_args()
633 base_dir = args.directory or Path.cwd()
634 skills_dir = base_dir / "skills"
636 if not skills_dir.exists():
637 print(
638 f"ERROR: skills directory not found: {skills_dir}",
639 file=sys.stderr,
640 )
641 return 1
643 results, collisions, thresholds = _run_validation(
644 skills_dir,
645 precision_threshold=args.precision_threshold,
646 recall_threshold=args.recall_threshold,
647 )
649 if args.json:
650 print(_format_json_report(results, collisions, thresholds))
651 else:
652 print(_format_text_report(results, collisions, thresholds))
654 if _any_failures(
655 results,
656 collisions,
657 args.precision_threshold,
658 args.recall_threshold,
659 ):
660 return 1
661 return 0