#!/usr/bin/env python3
"""
Shared Validation Module for ELA Week Generators
Usage in your week generator:
from validate import validate_week
errors = validate_week(
words=WORDS,
word_names=WORD_NAMES,
fill_data=fill_data,
detective_words=detective_words,
detective_answers=detective_answers,
opposite_pairs=opposite_pairs,
learning_examples=learning_examples,
breakdowns=breakdowns,
generate_friday=generate_friday, # Pass the function for Friday check
)
if errors:
for e in errors:
print(f" {e}")
exit(1)
print("[ok] All validation passed")
"""
import os
import re
import sys
import inspect
# Phonetic audit integration
_AUDIT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "audit")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def _phonetic_audit(words):
"""Run phonetic pattern audit on word list against verified database.
Returns list of error strings. Empty = all passed.
Integrates with the centralized audit system (word_db + validators).
"""
errors = []
try:
from audit.word_db import get_word as lookup
from audit.validators import validate_pattern
except ImportError:
return errors # Skip if audit system unavailable
for word, pattern, definition in words:
# 1. Structural validation (pattern matches word structure)
valid, msg = validate_pattern(word, pattern)
if not valid:
errors.append(f"PHONETIC: '{word}' ā {msg}")
continue
# 2. Database verification (word exists in verified DB)
db_entry = lookup(word)
if not db_entry:
errors.append(f"MISSING: '{word}' not in verified word database ā add to audit/word_db.py")
continue
# 3. Pattern consistency (claimed pattern matches DB pattern)
if db_entry.pattern != _normalize_pattern(pattern):
errors.append(
f"MISMATCH: '{word}' claims '{pattern}' but database has '{db_entry.pattern}'"
)
continue
return errors
def _normalize_pattern(claimed):
"""Normalize a claimed pattern to database pattern code."""
claimed = claimed.lower().strip()
silent_e_map = {
"long a (silent e)": "long_a_cvc e",
"long e (silent e)": "long_e_cvc e",
"long i (silent e)": "long_i_cvc e",
"long o (silent e)": "long_o_cvc e",
"long u (silent e)": "long_u_cvc e",
}
if claimed in silent_e_map:
return silent_e_map[claimed]
short_map = {
"short a": "short_a", "short e": "short_e", "short i": "short_i",
"short o": "short_o", "short u": "short_u",
}
if claimed in short_map:
return short_map[claimed]
team_map = {
"long a (ai)": "long_a_ai", "long a (ay)": "long_a_ay",
"long e (ee)": "long_e_ee", "long e (ea)": "long_e_ea",
"long i (igh)": "long_i_igh", "long o (oa)": "long_o_oa",
"long o (ow)": "long_o_ow", "long u (ew)": "long_u_ew",
"long u (oo)": "long_u_oo", "long o (oe)": "long_o_oe",
"long i (ee)": "long_i_ee",
}
if claimed in team_map:
return team_map[claimed]
digraph_map = {
"sh sound (sh)": "digraph_sh", "ch sound (ch)": "digraph_ch",
"th sound (th)": "digraph_th", "wh sound (wh)": "digraph_wh",
"ph sound (ph)": "digraph_ph", "ck sound (ck)": "digraph_ck",
}
if claimed in digraph_map:
return digraph_map[claimed]
r_map = {
"ar = /ar/": "r_control_ar", "or = /or/": "r_control_or",
"er = /er/": "r_control_er", "ir = /er/": "r_control_ir",
"ur = /er/": "r_control_ur",
}
if claimed in r_map:
return r_map[claimed]
# Try partial matches
if "soft c" in claimed:
if "ci" in claimed: return "soft_c_ci"
if "ce" in claimed: return "soft_c_ce"
if "cy" in claimed: return "soft_c_cy"
if "ey" in claimed: return "soft_c_ey"
if "soft g" in claimed:
if "gi" in claimed: return "soft_g_gi"
if "ge" in claimed: return "soft_g_ge"
if "gy" in claimed: return "soft_g_gy"
if "ey" in claimed: return "soft_g_ey"
if "double" in claimed:
return "double_consonant"
if "irregular" in claimed:
return "irregular"
return claimed
def validate_word_length(words):
"""Check all words are 6+ letters."""
errors = []
for word, pattern, _ in words:
if len(word) < 6:
errors.append(f"Word '{word}' has {len(word)} letters (need 6+)")
return errors
def validate_phonetic_patterns(words):
"""Check phonetic patterns match actual pronunciation."""
errors = []
pattern_checks = {
"long a": lambda w: re.search(r'a.{0,3}e$|a.{0,1}[aeiou]', w, re.I),
"long e": lambda w: re.search(r'e.{0,3}e$|ea|e.{0,1}[aeiou]', w, re.I),
"long i": lambda w: re.search(r'i.{0,3}e$|i.{0,1}[aeiou]', w, re.I),
"long o": lambda w: re.search(r'o.{0,3}e$|o.{0,1}[aeiou]', w, re.I),
}
for word, pattern, _ in words:
for vowel_type, check_fn in pattern_checks.items():
if vowel_type in pattern.lower():
if not check_fn(word):
errors.append(f"'{word}' claims {vowel_type} but doesn't match pattern")
break
return errors
def validate_fill_data(fill_data, word_names):
"""Validate fill-in-blank answers are in word list."""
errors = []
for sentence, answer in fill_data:
if answer not in word_names:
errors.append(f"Fill-in answer '{answer}' not in word list")
filled = sentence.replace("____", answer)
if "the" not in filled.lower() and "i" not in filled.lower():
errors.append(f"Fill-in sentence may be ungrammatical: {filled}")
return errors
def validate_detective(detective_words, detective_answers, word_names):
"""Validate Spelling Detective misspellings."""
errors = []
if len(detective_words) != len(detective_answers):
errors.append(f"Detective words ({len(detective_words)}) != answers ({len(detective_answers)})")
return errors
for i, (misspelled, original) in enumerate(zip(detective_words, detective_answers)):
if misspelled == original:
errors.append(f"Detective word {i+1}: '{misspelled}' is identical to original (must be misspelled)")
if original not in word_names:
errors.append(f"Detective answer '{original}' not in word list")
return errors
def validate_opposites(opposite_pairs, word_names):
"""Validate opposite pairs reference words in word list."""
errors = []
for word, opp in opposite_pairs:
if word not in word_names:
errors.append(f"Opposite word '{word}' not in word list")
return errors
def validate_learning_examples(learning_examples, word_names):
"""Validate learning examples are in word list."""
errors = []
for example in learning_examples:
if example not in word_names:
errors.append(f"Learning example '{example}' not in word list")
return errors
def validate_breakdowns(breakdowns):
"""Validate letter breakdowns spell the correct words."""
errors = []
for word, breakdown in breakdowns.items():
spelled = breakdown.replace("-", "").lower()
if spelled != word.lower():
errors.append(f"Breakdown '{breakdown}' spells '{spelled}', not '{word}'")
return errors
def validate_friday_no_boxes(generate_friday):
"""Validate Friday has no learning/remember boxes."""
errors = []
try:
friday_source = inspect.getsource(generate_friday)
if "Here's What We're Learning" in friday_source:
errors.append("Friday contains learning box (should be assessment only)")
if "Remember This" in friday_source:
errors.append("Friday contains remember box (should be assessment only)")
except Exception:
pass # If we can't inspect, skip this check
return errors
def validate_week(words, word_names, fill_data=None, detective_words=None,
detective_answers=None, opposite_pairs=None,
learning_examples=None, breakdowns=None,
generate_friday=None, phonetic_check=True):
"""
Run all validation checks on a week's content.
Args:
words: List of (word, pattern, definition) tuples
word_names: List of word strings
fill_data: List of (sentence, answer) tuples for fill-in-blank
detective_words: List of misspelled words
detective_answers: List of correct spellings
opposite_pairs: List of (word, opposite) tuples
learning_examples: List of example words for "Here's What We're Learning"
breakdowns: Dict of word -> "l-e-t-t-e-r" breakdown strings
generate_friday: The Friday generator function (for box check)
phonetic_check: Run phonetic audit against verified word database (default: True)
Returns:
List of error strings. Empty list = all passed.
"""
all_errors = []
# 0. Phonetic audit (runs first ā catch pattern issues early)
if phonetic_check:
phonetic_errors = _phonetic_audit(words)
all_errors.extend(phonetic_errors)
# 1. Word length
all_errors.extend(validate_word_length(words))
# 2. Phonetic patterns
all_errors.extend(validate_phonetic_patterns(words))
# 3. Fill-in-blank
if fill_data:
all_errors.extend(validate_fill_data(fill_data, word_names))
# 4. Spelling Detective
if detective_words and detective_answers:
all_errors.extend(validate_detective(detective_words, detective_answers, word_names))
# 5. Opposite pairs
if opposite_pairs:
all_errors.extend(validate_opposites(opposite_pairs, word_names))
# 6. Learning examples
if learning_examples:
all_errors.extend(validate_learning_examples(learning_examples, word_names))
# 7. Letter breakdowns
if breakdowns:
all_errors.extend(validate_breakdowns(breakdowns))
# 8. Friday no boxes
if generate_friday:
all_errors.extend(validate_friday_no_boxes(generate_friday))
return all_errors
def report_validation(errors):
"""Pretty-print validation results."""
if errors:
print(f"\nā VALIDATION FAILED ā Fix these errors before generating:")
for i, error in enumerate(errors, 1):
print(f" {i}. {error}")
return False
else:
print("[ok] All validation passed")
return True