fix(deep-learning-book): break lane-scoring ties by keyword specificity

Second independent review on PR #994 observed that reading_path_planner.py's
score_lanes() broke equal-hit ties alphabetically by lane key, so SKILL.md's own
documented example "train a transformer" resolved to the practitioner lane rather
than sequence.

Reproduced: the goal hits practitioner on "train" and sequence on "transformer",
one keyword each, and alphabetical ordering picked practitioner.

Fixed the cause rather than the example. Ties now break on keyword specificity —
the lane whose longest matched keyword is longest wins — because an equal hit
count between a generic term and a discriminating one should not be settled by
luck. Lane key remains the final tie-break so ordering stays deterministic.

Regression battery over eight goals: "train a transformer" now routes to sequence;
vision, generative, foundations, practitioner, complete, representation and
sequence goals all route exactly as before. Refusal paths unchanged (out-of-scope
exit 3, unroutable exit 4, sample exit 0).

Gates green after the change: compileall, check_paths, check_frontmatter,
check_dual_publish, check_model_freshness, smoke_scripts (696 passed),
derive_counters --check, check_skill_names, check_plugin_json, book_skill_validator,
and --help + --sample --output json on all four tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BswsZp5zrJWFAGU6KWNA1s
This commit is contained in:
Claude 2026-08-25 19:07:14 +00:00
parent c75500f804
commit 1366714fdc
No known key found for this signature in database

View file

@ -225,15 +225,23 @@ def order_path(chapters: list[int]) -> list[int]:
def score_lanes(goal: str) -> list[tuple[str, int]]:
"""Score every lane by keyword hits in the goal text, best first."""
"""Score every lane by keyword hits in the goal text, best first.
Ties are broken by keyword specificity the lane whose longest matched
keyword is longest wins because an equal hit count between a generic term
and a discriminating one should not be settled by luck. "train a transformer"
hits `practitioner` on "train" and `sequence` on "transformer", one each; the
longer, more specific match is the one that names the subject. Lane key is the
final tie-break so the ordering stays deterministic.
"""
text = goal.lower()
scored = []
for key, lane in LANES.items():
hits = sum(1 for kw in lane["keywords"] if kw in text)
if hits:
scored.append((key, hits))
scored.sort(key=lambda pair: (-pair[1], pair[0]))
return scored
matched = [kw for kw in lane["keywords"] if kw in text]
if matched:
scored.append((key, len(matched), max(len(kw) for kw in matched)))
scored.sort(key=lambda row: (-row[1], -row[2], row[0]))
return [(key, hits) for key, hits, _ in scored]
def out_of_scope_hits(goal: str) -> list[str]: