fix(deep-learning-book): word-boundary keyword matching and typed input validation

Fourth review on PR #994 raised two findings against this plugin's scripts. Both
reproduced before fixing; the review's other findings are against marketing/linkedin,
which this branch carries from main but does not touch (see the PR comment).

1. reading_path_planner.py matched keywords by bare substring, so "rag" matched
   inside "storage", "lora" inside "exploratory", "conv" inside "converge" and
   "text" inside "context". Confirmed: --goal "train models for image storage and
   retrieval" exited 3, confidently refused as out-of-scope RAG work, and
   "an exploratory look at optimization" exited 3 citing LoRA. A tool whose stated
   design is to refuse rather than guess was guessing, and doing it with certainty.

   Matching is now word-boundary anchored with an optional plural, plus an explicit
   surface-form table for the few tokens whose inflections a word-boundary match
   would otherwise miss (fine-tuning, prompting, agentic). Verified: both goals above
   now route correctly (exit 0 / the optimization lane), "converge" reaches the
   optimization lane rather than vision, and the real refusals still refuse — RLHF,
   LoRA fine-tuning, RAG pipelines and prompt/agent goals all still exit 3.

2. model_arithmetic.py documented exit 4 for a spec it cannot parse but only caught
   SpecError and ShapeError, so malformed input escaped as a traceback with exit 1.
   Confirmed across five cases: a non-dict top-level JSON, a non-dict layer entry,
   stride 0, groups 0, and a non-numeric filters value. Numeric fields now go through
   a checked accessor that rejects non-integer and non-positive values, the input
   layer's shape is validated, and the top-level spec and every layer entry are
   type-checked. All five now exit 4 with a message naming the layer and field.

Regression battery over eight goals routes exactly as before; the convnet sample
still reports 545,098 parameters and the transformer asset 7,087,872.

Gates green: 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:33:25 +00:00
parent eeb3cb9ad6
commit e923237360
No known key found for this signature in database
2 changed files with 81 additions and 14 deletions

View file

@ -57,11 +57,37 @@ class ShapeError(ValueError):
def _require(layer: dict, key: str, index: int):
if not isinstance(layer, dict):
raise SpecError(f"layer {index} is {type(layer).__name__}, not an object")
if key not in layer:
raise SpecError(f"layer {index} ({layer.get('type', '?')}) is missing '{key}'")
return layer[key]
def _positive_int(layer: dict, key: str, index: int, default: int | None = None) -> int:
"""Read an integer field, rejecting non-numeric and non-positive values.
Without this, a non-numeric "filters" raised ValueError and a zero "stride" or
"groups" raised ZeroDivisionError both escaping as tracebacks rather than the
documented exit 4.
"""
raw = layer.get(key, default) if isinstance(layer, dict) else default
if raw is None:
raise SpecError(f"layer {index} ({layer.get('type', '?')}) is missing '{key}'")
try:
value = int(raw)
except (TypeError, ValueError):
raise SpecError(
f"layer {index} ({layer.get('type', '?')}): '{key}' must be an integer, "
f"got {raw!r}"
) from None
if value <= 0:
raise SpecError(
f"layer {index} ({layer.get('type', '?')}): '{key}' must be positive, got {value}"
)
return value
def _prod(shape: tuple[int, ...]) -> int:
total = 1
for dim in shape:
@ -74,10 +100,24 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
kind = _require(layer, "type", index)
if kind == "input":
return tuple(_require(layer, "shape", index)), 0, 0
raw_shape = _require(layer, "shape", index)
if not isinstance(raw_shape, (list, tuple)) or not raw_shape:
raise SpecError(f"layer {index} (input): 'shape' must be a non-empty list")
dims = []
for dim in raw_shape:
try:
dim = int(dim)
except (TypeError, ValueError):
raise SpecError(
f"layer {index} (input): shape entries must be integers, got {dim!r}"
) from None
if dim <= 0:
raise SpecError(f"layer {index} (input): shape entries must be positive")
dims.append(dim)
return tuple(dims), 0, 0
if kind == "linear":
units = int(_require(layer, "units", index))
units = _positive_int(layer, "units", index)
bias = bool(layer.get("bias", True))
if len(shape) == 2:
# Per-token (position-wise) linear over a (seq, features) sequence: one
@ -97,9 +137,9 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
return (units,), params, shape[0] * units
if kind == "conv2d":
filters = int(_require(layer, "filters", index))
kernel = int(_require(layer, "kernel", index))
stride = int(layer.get("stride", 1))
filters = _positive_int(layer, "filters", index)
kernel = _positive_int(layer, "kernel", index)
stride = _positive_int(layer, "stride", index, 1)
padding = layer.get("padding", "same")
if len(shape) != 3:
raise ShapeError(
@ -121,7 +161,7 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
f"reduces {height}x{width} to {out_h}x{out_w} — the kernel is larger "
"than the feature map."
)
groups = int(layer.get("groups", 1))
groups = _positive_int(layer, "groups", index, 1)
if channels % groups or filters % groups:
raise SpecError(
f"layer {index} (conv2d): groups={groups} does not divide "
@ -133,8 +173,8 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
return (filters, out_h, out_w), params, macs
if kind == "pool2d":
size = int(layer.get("size", 2))
stride = int(layer.get("stride", size))
size = _positive_int(layer, "size", index, 2)
stride = _positive_int(layer, "stride", index, size)
if len(shape) != 3:
raise ShapeError(f"layer {index} (pool2d) needs a 3-D input, got {shape}")
channels, height, width = shape
@ -150,8 +190,8 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
return (_prod(shape),), 0, 0
if kind == "embedding":
vocab = int(_require(layer, "vocab", index))
dim = int(_require(layer, "dim", index))
vocab = _positive_int(layer, "vocab", index)
dim = _positive_int(layer, "dim", index)
seq = int(layer.get("seq_len", shape[0] if shape else 1))
return (seq, dim), vocab * dim, 0 # a lookup, not a matmul
@ -169,7 +209,7 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
f"layer {index} (mha) needs a 2-D input (seq_len, d_model), got {shape}"
)
seq, d_model = shape
heads = int(layer.get("heads", 8))
heads = _positive_int(layer, "heads", index, 8)
if d_model % heads:
raise SpecError(
f"layer {index} (mha): d_model={d_model} is not divisible by heads={heads}"
@ -187,7 +227,7 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
f"layer {index} ({kind}) needs a 2-D input (seq_len, features), got {shape}"
)
seq, features = shape
units = int(_require(layer, "units", index))
units = _positive_int(layer, "units", index)
gates = 4 if kind == "lstm" else 3
params = gates * (features * units + units * units + 2 * units)
macs = seq * gates * (features * units + units * units)
@ -198,9 +238,14 @@ def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ..
def analyse(spec: dict, dtype: str, convention: str) -> dict:
if not isinstance(spec, dict):
raise SpecError(f"spec must be a JSON object, got {type(spec).__name__}")
layers = spec.get("layers")
if not isinstance(layers, list) or not layers:
raise SpecError("spec must contain a non-empty 'layers' list")
for index, layer in enumerate(layers):
if not isinstance(layer, dict):
raise SpecError(f"layer {index} is {type(layer).__name__}, not an object")
if layers[0].get("type") != "input":
raise SpecError("the first layer must be of type 'input'")

View file

@ -25,6 +25,7 @@ from __future__ import annotations
import argparse
import json
import re
import sys
# --------------------------------------------------------------------------- data
@ -186,6 +187,27 @@ OUT_OF_SCOPE: dict[str, str] = {
"reinforcement": "Reinforcement learning — mentioned only in passing (ch12).",
}
# Tokens whose real surface forms a word-boundary match would otherwise miss.
# Everything else matches itself, optionally pluralized.
SURFACE_FORMS: dict[str, tuple[str, ...]] = {
"fine-tun": ("fine-tuning", "fine-tune", "fine-tuned", "finetuning", "finetune"),
"prompt": ("prompt", "prompting", "prompts"),
"agent": ("agent", "agents", "agentic"),
}
def _matches(token: str, text: str) -> bool:
"""True when token appears in text as a whole word (optionally pluralized).
Substring matching is wrong here and was a real defect: "rag" appears inside
"storage", "lora" inside "exploratory", "conv" inside "converge", and "text"
inside "context" each one producing a confident false refusal or a wrong lane.
"""
for form in SURFACE_FORMS.get(token, (token,)):
if re.search(rf"\b{re.escape(form)}(?:s|es)?\b", text):
return True
return False
# --------------------------------------------------------------------------- logic
@ -237,7 +259,7 @@ def score_lanes(goal: str) -> list[tuple[str, int]]:
text = goal.lower()
scored = []
for key, lane in LANES.items():
matched = [kw for kw in lane["keywords"] if kw in text]
matched = [kw for kw in lane["keywords"] if _matches(kw, 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]))
@ -246,7 +268,7 @@ def score_lanes(goal: str) -> list[tuple[str, int]]:
def out_of_scope_hits(goal: str) -> list[str]:
text = goal.lower()
return [note for token, note in OUT_OF_SCOPE.items() if token in text]
return [note for token, note in OUT_OF_SCOPE.items() if _matches(token, text)]
def plan(goal: str, background: str, hours_per_week: float,