"""Phonetic Pattern Validators.
Each validator function takes a word and a claimed pattern string,
returns (is_valid: bool, message: str).
Validators catch structural mismatches BEFORE checking the word database.
A word can pass structural validation but still be wrong semantically
(e.g., 'notice' matches soft_c_ci structurally but the vowel claim was wrong in W2).
"""
from typing import Tuple
__all__ = ["validate_pattern", "ALL_VALIDATORS"]
# Registry: pattern_keyword -> validator_function
_ALL_VALIDATORS: dict = {}
def _register(patterns: list):
def decorator(func):
for p in patterns:
_ALL_VALIDATORS[p] = func
return func
return decorator
def validate_pattern(word: str, claimed_pattern: str) -> Tuple[bool, str]:
"""Validate a word against its claimed pattern.
Returns (valid, message). Message explains the issue if invalid.
"""
import re as _re
word = word.lower().strip()
pattern = claimed_pattern.lower().strip()
# Try most-specific patterns first to avoid substring collisions
sorted_validators = sorted(_ALL_VALIDATORS.items(),
key=lambda x: -len(x[0])) # longest keys first
for key, func in sorted_validators:
# For short keys (2-3 chars), require word boundary to avoid false matches
# e.g., "or" shouldn't match inside "short", "ar" shouldn't match in "farmer"
if len(key) <= 3:
if _re.search(r'\b' + _re.escape(key) + r'\b', pattern):
return func(word, pattern)
else:
if key in pattern:
return func(word, pattern)
# Unknown pattern — flag for review
return True, f"[REVIEW] Unknown pattern type: {claimed_pattern!r}"
# ---------------------------------------------------------------------------
# Short vowel validators
# ---------------------------------------------------------------------------
@_register(["short_a", "short_e", "short_i", "short_o", "short_u"])
def validate_short_vowel(word: str, claimed: str) -> Tuple[bool, str]:
"""Check that a word claiming a short vowel actually has one.
Rules:
- Word must have at least 2 letters
- The stressed syllable should contain the claimed short vowel
- CVC or CVCC patterns are typical (closed syllable)
"""
target = claimed.split("_")[1] # 'a', 'e', 'i', 'o', 'u'
if len(word) < 2:
return False, f"'{word}' is too short for any vowel pattern"
# The vowel should appear in the word
if target not in word:
return False, f"'{word}' claims short {target} but has no '{target}' in it"
# Check for CVCe pattern that would make it LONG
# If word ends in consonant + e and has single vowel before consonant, it's likely long
if len(word) >= 3 and word[-1] == 'e' and word[-2] != 'e':
# Check if it's a CVCe pattern (single vowel + consonant + e)
non_e = word[:-1] # remove trailing e
if len(non_e) == 3 and non_e[1] == target and non_e[2] != target:
return False, (
f"'{word}' has CVCe structure ({non_e[0]}-{target}-{non_e[2]}-e) "
f"which makes long {target}, not short {target}"
)
return True, f"OK: '{word}' contains short {target}"
# ---------------------------------------------------------------------------
# Silent E (CVCe / long vowel + e) validators
# ---------------------------------------------------------------------------
@_register(["long_a_a_e", "long_e_e_e", "long_i_i_e", "long_o_o_e", "long_u_u_e",
"silent_e", "long_a", "long_e", "long_i", "long_o", "long_u"])
def validate_silent_e(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate Silent E (Magic E) pattern.
Rules:
- Word must end in 'e'
- Word must have exactly one vowel before the final consonant(s)
- The vowel must match the claimed long vowel
- Minimum 4 letters (CVCe)
- The pattern is: VOWEL + consonant + E
"""
# Extract claimed vowel
vowel_map = {
"long_a": "a", "a_e": "a",
"long_e": "e", "e_e": "e",
"long_i": "i", "i_e": "i",
"long_o": "o", "o_e": "o",
"long_u": "u", "u_e": "u",
}
target_vowel = None
for key, val in vowel_map.items():
if key in claimed:
target_vowel = val
break
# Special cases — check BEFORE structural validation
exceptions = {
"strange": True, # str-a-ng-e (ng digraph)
"change": True, # ch-a-ng-e
"orange": True, # or-a-ng-e
# Multi-syllable words taught as CVCe patterns (final -e makes vowel long)
"rescue": True, # re-scue (ue → /juː/)
"invite": True, # in-vite (i_e pattern)
"relieve": True, # re-lieve (ie team)
"divide": True, # di-vide (i_e pattern)
}
if word in exceptions:
return True, f"OK: '{word}' is a valid silent_e exception"
if not target_vowel:
# Generic "silent_e" or "long X (silent e)" claim
# Try to extract from "(silent e)" pattern
return True, f"[REVIEW] Generic silent_e claim for '{word}': {claimed!r}"
if not word.endswith('e'):
return False, f"'{word}' claims silent_e/{target_vowel}_e but doesn't end in 'e'"
if len(word) < 4:
return False, f"'{word}' has {len(word)} letters — too short for CVCe pattern (need 4+)"
# Check CVCe structure: the vowel should appear before exactly one consonant before final 'e'
before_e = word[:-1] # everything before final e
# Count vowels in before_e
vowels_in = sum(1 for c in before_e if c in 'aeiou')
# The target vowel should appear exactly once in before_e
target_count = before_e.count(target_vowel)
if target_count == 0:
return False, f"'{word}' claims long {target_vowel} + silent e but '{target_vowel}' not found"
if target_count > 1:
return False, f"'{word}' has {target_count} '{target_vowel}'s — confusing for silent_e pattern"
# The vowel must come before the final consonant (not be the last letter before e)
vowel_pos = before_e.index(target_vowel)
if vowel_pos == len(before_e) - 1:
# Vowel is directly before the e — not CVCe, this is just Vowel+E
return False, f"'{word}' has '{target_vowel}' directly before final 'e' — not CVCe pattern"
# Check that there's exactly one consonant between vowel and final e (for strict CVCe)
# Allow for patterns like 'strange' (str-a-n-g-e) where there are 2 consonants
consonants_after = before_e[vowel_pos + 1:]
if not consonants_after:
return False, f"'{word}' has no consonant between vowel and final 'e'"
return True, f"OK: '{word}' matches long {target_vowel} + silent e"
# ---------------------------------------------------------------------------
# Vowel team validators
# ---------------------------------------------------------------------------
@_register(["long_a_ai", "long_a_ay", "long_e_ea", "long_e_ee",
"long_e_ew", "long_i_ee", "long_i_igh", "long_o_oe", "long_o_oa",
"long_o_ou", "long_o_ow", "long_u_ew", "long_u_oo",
"short_o_ow", "short_e_ew", "vowel_team"])
def validate_vowel_team(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate vowel team patterns.
Rules:
- The claimed digraph/team must appear in the word
- Examples: ai, ay, ea, ee, ie, igh, oe, oa, oo, ou, ow, ew
"""
teams = {
"long_a_ai": "ai",
"long_a_ay": "ay",
"long_e_ea": "ea",
"long_e_ee": "ee",
"long_e_ew": "ew",
"long_i_ee": "ie",
"long_i_igh": "igh",
"long_o_oe": "oe",
"long_o_oa": "oa",
"long_o_ou": "ou",
"long_o_ow": "ow",
"long_u_ew": "ew",
"long_u_oo": "oo",
"short_e_ew": "ew",
"short_o_ow": "ow",
"vowel_team_oa": "oa",
"vowel_team_ow": "ow",
"vowel_team_ou": "ou",
"vowel_team_ew": "ew",
}
team = None
for key, val in teams.items():
if key in claimed:
team = val
break
if not team:
return True, f"[REVIEW] Generic vowel_team claim for '{word}': {claimed!r}"
if team not in word:
return False, f"'{word}' claims {team} pattern but '{team}' not found in word"
return True, f"OK: '{word}' contains {team} team"
# ---------------------------------------------------------------------------
# Digraph validators
# ---------------------------------------------------------------------------
@_register(["digraph_sh", "digraph_ch", "digraph_th", "digraph_wh",
"digraph_ph", "digraph_ck", "sh sound", "ch sound",
"th sound", "wh sound", "ph sound", "ck sound"])
def validate_digraph(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate digraph patterns.
Rules:
- The claimed digraph must appear in the word
"""
digraph_map = {
"digraph_sh": "sh", "sh sound": "sh",
"digraph_ch": "ch", "ch sound": "ch",
"digraph_th": "th", "th sound": "th",
"digraph_wh": "wh", "wh sound": "wh",
"digraph_ph": "ph", "ph sound": "ph",
"digraph_ck": "ck", "ck sound": "ck",
}
digraph = None
for key, val in digraph_map.items():
if key in claimed:
digraph = val
break
if not digraph:
return True, f"[REVIEW] Generic digraph claim for '{word}': {claimed!r}"
if digraph not in word:
return False, f"'{word}' claims {digraph} digraph but '{digraph}' not found in word"
return True, f"OK: '{word}' contains {digraph} digraph"
# ---------------------------------------------------------------------------
# Soft C / Soft G validators
# ---------------------------------------------------------------------------
@_register(["soft_c", "soft_g"])
def validate_soft_cg(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate soft C and soft G patterns.
Rules:
- Soft C: c before e, i, y makes /s/ sound
- Soft G: g before e, i, y makes /dʒ/ sound
- The letter must actually appear before a triggering vowel
"""
is_c = "soft_c" in claimed
letter = "c" if is_c else "g"
triggers = ["e", "i", "y"]
if letter not in word:
return False, f"'{word}' claims soft {letter.upper()} but has no '{letter}'"
# Find each occurrence of the letter and check what follows
found_valid = False
for idx, char in enumerate(word):
if char == letter and idx + 1 < len(word):
next_char = word[idx + 1]
if next_char in triggers:
found_valid = True
break
elif next_char == "a" and letter == "g":
# g before a can be soft (garage, garment) — flag for review
return True, f"[REVIEW] '{word}': g before 'a' — may be soft or hard, needs check"
if not found_valid:
return False, (
f"'{word}' claims soft {letter.upper()} but '{letter}' is not before e/i/y "
f"(need: {letter}e, {letter}i, or {letter}y)"
)
return True, f"OK: '{word}' has soft {letter.upper()} before triggering vowel"
# ---------------------------------------------------------------------------
# R-controlled vowel validators
# ---------------------------------------------------------------------------
@_register(["r_control", "ar", "er", "ir", "or", "ur"])
def validate_r_controlled(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate R-controlled vowel patterns.
Rules:
- The claimed vowel+r combination must appear in the word
- Examples: ar, er, ir, or, ur
- Must be explicitly claimed (not matched as substring of other words)
"""
import re as _re
r_teams = ["ar", "er", "ir", "or", "ur"]
# Find target from claimed pattern
# Priority: standalone token (e.g., "ar" in "ar = /ar/") > phonetic in slashes
target = None
for team in r_teams:
# Match standalone token first (e.g., "ar" at start of "ar = /ar/")
if _re.search(r'\b' + team + r'\s*=', claimed):
target = team
break
# If no "team =" found, try standalone token
if not target:
for team in r_teams:
if _re.search(r'\b' + team + r'\b', claimed):
target = team
break
# Last resort: check slashes (but this catches the phonetic, not the pattern)
if not target:
for team in r_teams:
if f"/{team}/" in claimed:
target = team
break
if not target:
return True, f"[REVIEW] Generic r_control claim for '{word}': {claimed!r}"
# Check if the r-team appears in the WORD (no word boundary — it's embedded)
if target not in word:
return False, f"'{word}' claims {target} pattern but '{target}' not found in word"
return True, f"OK: '{word}' contains {target} r-controlled vowel"
# ---------------------------------------------------------------------------
# Double consonant validators
# ---------------------------------------------------------------------------
@_register(["double_consonant", "double"])
def validate_double_consonant(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate double consonant patterns.
Rules:
- The word must contain at least one double consonant
- The double consonant typically follows a short vowel
"""
has_double = False
for i in range(len(word) - 1):
if word[i] == word[i + 1] and word[i].isalpha() and word[i] not in 'aeiou':
has_double = True
break
if not has_double:
return False, f"'{word}' claims double consonant but has no double consonants"
return True, f"OK: '{word}' has double consonant"
# ---------------------------------------------------------------------------
# Irregular word validators
# ---------------------------------------------------------------------------
@_register(["irregular"])
def validate_irregular(word: str, claimed: str) -> Tuple[bool, str]:
"""Irregular words can't be structurally validated — they're flagged for review.
These words don't follow standard phonetic rules and must be verified
against the word database.
"""
return True, f"[REVIEW] '{word}' is claimed as irregular — verify against database"
# ---------------------------------------------------------------------------
# Multiple meaning word validators
# ---------------------------------------------------------------------------
@_register(["multiple_meaning"])
def validate_multiple_meaning(word: str, claimed: str) -> Tuple[bool, str]:
"""Multiple meaning words can't be structurally validated — they're flagged for review.
These words have two or more distinct meanings (e.g., bat = animal / sports equipment).
Validation requires checking the word database for documented meanings.
"""
return True, f"[REVIEW] '{word}' is claimed as multiple_meaning — verify definitions in database"
# ---------------------------------------------------------------------------
# Suffix validators (-s, -es, -ed, -ing, -er, -est, -ly, -ful, -less, etc.)
# ---------------------------------------------------------------------------
@_register(["prefix_un", "prefix_re", "prefix_dis", "prefix_im", "prefix_in", "prefix_mis",
"morphological"])
def validate_morphological(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate prefix/suffix patterns.
Rules:
- prefix_un: word ends in -un, base exists (un + happy)
- prefix_re: word starts with re- (re + do)
- prefix_dis: word starts with dis- (dis + agree)
- prefix_im: word starts with im- (im + possible)
- prefix_in: word starts with in- (in + visible)
- prefix_mis: word starts with mis- (mis + lead)
"""
word = word.lower()
prefix_map = {
"prefix_un": "un",
"prefix_re": "re",
"prefix_dis": "dis",
"prefix_im": "im",
"prefix_in": "in",
"prefix_mis": "mis",
}
if claimed not in prefix_map:
return True, f"morphological pattern '{claimed}' (manual check)"
expected = prefix_map[claimed]
if not word.startswith(expected):
return False, f"{word} doesn't start with '{expected}'"
return True, f"prefix: {claimed} ({expected} + {word[len(expected):]})"
@_register(["suffix_s", "suffix_es", "suffix_ed", "suffix_ing", "suffix_er", "suffix_est",
"suffix_ly", "suffix_ful", "suffix_less", "suffix_ment", "suffix_ion",
"suffix_able", "suffix_ible", "suffix_ive", "suffix_ous", "suffix_ious", "suffix_ent",
"suffix_ness", "suffix_ure", "suffix_y", "suffix_ile"])
def _validate_suffix_er_est_ly_ful_less_ment_ion_able_ive_ous_ious_ent_ness_ure_y_ile(word, pattern):
"""Validate suffix patterns.
Rules:
- Word must contain the claimed suffix
- Suffix: must appear at the end
"""
if "suffix" in pattern:
# Extract suffix name (e.g., "er" from "suffix_er")
suffix_name = pattern.split("_")[-1] if "_" in pattern else ""
if not suffix_name or not word.endswith(suffix_name):
return False, f"'{word}' claims suffix '{suffix_name}' but doesn't end with it"
return True, f"OK: '{word}' ends with suffix '{suffix_name}'"
return True, f"[REVIEW] Generic morphological claim for '{word}': {pattern!r}"
# ---------------------------------------------------------------------------
# Homophone validators
# ---------------------------------------------------------------------------
@_register(["homophone"])
def validate_homophone(word: str, claimed: str) -> Tuple[bool, str]:
"""Homophones can't be structurally validated — they're flagged for review.
These words sound the same but have different spellings/meanings.
Validation requires checking the word database for matching pairs.
"""
return True, f"[REVIEW] '{word}' is claimed as homophone — verify pair in database"
# ---------------------------------------------------------------------------
# IE rule validators (i before e / except after c)
# ---------------------------------------------------------------------------
@_register(["ie_rule"])
def validate_ie_rule(word: str, claimed: str) -> Tuple[bool, str]:
"""Validate 'i before e' rule patterns.
Rules:
- 'ie' should appear in the word (standard rule)
- 'ei' should appear after 'c' (exception rule)
"""
# Check for "ei" exceptions first (e.g., receive, ceiling, weird)
if "ei" in word:
ei_pos = word.find("ei")
if ei_pos > 0 and word[ei_pos - 1] == "c":
return True, f"OK: '{word}' has 'ei' after 'c' — except after c rule"
return True, f"[REVIEW] '{word}' has 'ei' — ie_rule exception"
# Standard "ie" rule
if "ie" in claimed or "i before e" in claimed:
if "ie" not in word:
return False, f"'{word}' claims 'ie' rule but has no 'ie' in it"
return True, f"OK: '{word}' has 'ie' — i before e rule"
elif "ei" in claimed or "except after c" in claimed:
if "ei" not in word:
return False, f"'{word}' claims 'ei' exception but has no 'ei' in it"
ei_pos = word.find("ei")
if ei_pos > 0 and word[ei_pos - 1] == "c":
return True, f"OK: '{word}' has 'ei' after 'c' — except after c rule"
return True, f"[REVIEW] '{word}' has 'ei' but not obviously after 'c'"
return True, f"[REVIEW] Generic ie_rule claim for '{word}': {claimed!r}"