#!/usr/bin/env python3 """ Validate that all must-preserve constraints survived transformation. Compares original text constraints against transformed text. Exit code 0 = all constraints preserved, 1 = missing constraints. Usage: python validate_preservation.py original.txt transformed.txt python validate_preservation.py original.txt transformed.txt constraints.json """ from __future__ import annotations import sys import json import re from typing import TypedDict # Import constraint extraction from extract_constraints import extract_constraints, Constraint class ValidationResult(TypedDict): passed: bool total_constraints: int preserved: int missing: list[Constraint] warnings: list[str] _MAGNITUDES = { "k": 1e3, "thousand": 1e3, "m": 1e6, "million": 1e6, "b": 1e9, "billion": 1e9, "trillion": 1e12, } _MONTHS = [ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", ] def normalize_value(value: str) -> str: """Normalize a constraint value for comparison.""" # Remove extra whitespace normalized = re.sub(r'\s+', ' ', value.strip()) # Lowercase for comparison (preserve original for display) return normalized.lower() def parse_money(token: str) -> float | None: """Parse a currency token into an absolute amount, honoring magnitude words. '$47.3M' and '$47.3 million' -> 47_300_000; '$47.3 billion' -> 47_300_000_000. Magnitude is part of the fact, so the two must not compare equal. """ m = re.search( r'([\d,]+\.?\d*)\s*(k|m|b|thousand|million|billion)?', token.lower().replace("$", ""), ) if not m or not m.group(1).strip(","): return None amount = float(m.group(1).replace(",", "")) if m.group(2): amount *= _MAGNITUDES[m.group(2)] return amount def parse_magnitude_number(token: str) -> float | None: m = re.search( r'\b([\d,]+\.?\d*)\s*(thousand|million|billion|trillion)?\b', token.lower(), ) if not m or not m.group(1).strip(","): return None amount = float(m.group(1).replace(",", "")) if m.group(2): amount *= _MAGNITUDES[m.group(2)] return amount def _numbers_match_exactly(value: str, text: str, pattern: str) -> bool: """True iff the numeric value appears as a whole quantity in text. Guards against the substring trap where '12' is 'found' inside '120'. """ want = re.findall(r'\d+\.?\d*', value) have = set(re.findall(pattern, text.lower())) return all(num in have for num in want) and bool(want) _UNIT_SYNONYMS = { "km": {"km", "kilometer", "kilometers", "kilometre", "kilometres"}, "mi": {"mi", "mile", "miles"}, "kg": {"kg", "kilogram", "kilograms"}, "g": {"g", "gram", "grams"}, "ms": {"ms", "millisecond", "milliseconds"}, "s": {"s", "sec", "second", "seconds"}, "m": {"m", "meter", "meters", "metre", "metres"}, "cm": {"cm", "centimeter", "centimeters", "centimetre", "centimetres"}, "mm": {"mm", "millimeter", "millimeters", "millimetre", "millimetres"}, "ft": {"ft", "foot", "feet"}, "in": {"in", "inch", "inches"}, "lb": {"lb", "lbs", "pound", "pounds"}, "oz": {"oz", "ounce", "ounces"}, "°c": {"°c", "c", "celsius"}, "°f": {"°f", "f", "fahrenheit"}, "min": {"min", "mins", "minute", "minutes"}, "hr": {"hr", "hrs", "hour", "hours"}, "day": {"day", "days"}, "week": {"week", "weeks", "wk", "wks"}, "month": {"month", "months", "mo"}, "year": {"year", "years", "yr", "yrs"}, "kb": {"kb", "kilobyte", "kilobytes"}, "mb": {"mb", "megabyte", "megabytes"}, "gb": {"gb", "gigabyte", "gigabytes"}, "tb": {"tb", "terabyte", "terabytes"}, "pb": {"pb", "petabyte", "petabytes"}, "px": {"px", "pixel", "pixels"}, } def _measurement_parts(value: str) -> tuple[str, set[str]] | None: m = re.search(r'(\d+\.?\d*)\s*([^\d\s]+|degrees?(?:\s+\w+)?)', value, re.I) if not m: return None unit = m.group(2).lower().strip() unit = re.sub(r'^degrees?\s*', '', unit) or "degree" aliases = _UNIT_SYNONYMS.get(unit) if aliases is None: for family in _UNIT_SYNONYMS.values(): if unit in family: aliases = family break else: aliases = {unit} return m.group(1), aliases def _quote_core(value: str) -> str: return value.strip().strip('"“”').lower() def _time_variants(value: str) -> set[str]: m = re.search(r'\b(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*(am|pm)?\b', value, re.I) if not m: return set() hour = str(int(m.group(1))) minute = m.group(2) or "00" suffix = (m.group(3) or "").lower() spaced = f" {suffix}" if suffix else "" compact = suffix variants = {f"{hour}:{minute}{spaced}".strip(), f"{hour}:{minute}{compact}".strip()} if minute == "00": variants.update({f"{hour}{spaced}".strip(), f"{hour}{compact}".strip()}) return variants def find_constraint_in_text(constraint: Constraint, text: str) -> bool: """Check if a constraint value exists in text.""" value = constraint["value"] ctype = constraint["type"] normalized_value = normalize_value(value) normalized_text = normalize_value(text) # Direct match if normalized_value in normalized_text: return True # and/or: the disjunction must survive; "or" (or "or both") is faithful, # a bare conjunction is not. if ctype == "and_or": return bool(re.search(r"\band/or\b|\bor\b", text, re.IGNORECASE)) # Currency: compare absolute amounts so a magnitude swap (M -> billion) fails. if ctype == "currency": target = parse_money(value) if target is None: return False for token in re.findall( r'\$?[\d,]+\.?\d*\s*(?:k|m|b|thousand|million|billion)?', text, re.IGNORECASE ): amount = parse_money(token) if amount is not None and abs(amount - target) < 0.01: return True return False if ctype == "magnitude_number": target = parse_magnitude_number(value) if target is None: return False for token in re.findall( r'\b[\d,]+\.?\d*\s*(?:thousand|million|billion|trillion)?\b', text, re.IGNORECASE, ): amount = parse_magnitude_number(token) if amount is not None and abs(amount - target) < 0.01: return True return False # Percentage: require an exact numeric match (12% != 120%). if ctype == "percentage": return _numbers_match_exactly(value, text, r'(\d+\.?\d*)\s*(?:%|percent)') if ctype == "measurement": parts = _measurement_parts(value) if not parts: return False number, units = parts clean_text = text.replace(",", "").lower() if not re.search(r'(? list[str]: """Warn when meaning-bearing words present in the original vanish in the rewrite.""" out: list[str] = [] o, t = original.lower(), transformed.lower() on, tn = len(_NEGATION_RE.findall(o)), len(_NEGATION_RE.findall(t)) if on > tn: out.append( f"Negation count dropped {on}->{tn}. Verify no claim was inverted or " "weakened (e.g. 'does not support' -> 'supports').") missing_scope = sorted({w for w in _SCOPE_RE.findall(o)} - {w for w in _SCOPE_RE.findall(t)}) for w in missing_scope: out.append(f"Scope/precision word '{w}' not in output. Verify the claim's scope is unchanged.") oc, tc = len(_CONDITIONAL_RE.findall(o)), len(_CONDITIONAL_RE.findall(t)) if oc > tc: out.append( f"Conditional count dropped {oc}->{tc}. Verify a conditional claim " "wasn't turned into an unconditional one.") ow, tw = len(_WEAK_MODAL_RE.findall(o)), len(_WEAK_MODAL_RE.findall(t)) os, ts = len(_STRONG_MODAL_RE.findall(o)), len(_STRONG_MODAL_RE.findall(t)) if ow > tw and ts > os: out.append("Hedged claim may have been strengthened. Verify uncertainty was not turned into certainty.") return out def validate_preservation( original_text: str, transformed_text: str, constraints: list[Constraint] | None = None ) -> ValidationResult: """Validate that all constraints are preserved in transformed text.""" if constraints is None: constraints = extract_constraints(original_text) missing: list[Constraint] = [] warnings: list[str] = [] for constraint in constraints: if not find_constraint_in_text(constraint, transformed_text): missing.append(constraint) # Generate warnings for near-misses for m in missing: if m["type"] == "percentage": # Check if number exists without % num = re.search(r'[\d.]+', m["value"]) if num and num.group() in transformed_text: warnings.append(f"Number {num.group()} found but missing '%' symbol") # Semantic drift warnings. These are NOT numbers/names — they are the # meaning-bearing words (negations, scope, conditionals) that the skill's # de-hedging rules are most likely to delete and that pure fact-matching # cannot see. Surfaced as warnings so the rewrite gets a human re-check; # de-slopping legitimately removes some, so they don't hard-fail on their own. warnings.extend(semantic_drift_warnings(original_text, transformed_text)) preserved = len(constraints) - len(missing) return { "passed": len(missing) == 0, "total_constraints": len(constraints), "preserved": preserved, "missing": missing, "warnings": warnings } def main() -> None: args = sys.argv[1:] strict = False if args and args[0] == "--strict": strict = True args = args[1:] if len(args) < 2: print("Usage: validate_preservation.py [constraints.json]") sys.exit(1) # Read inputs try: with open(args[0], 'r') as f: original_text = f.read() with open(args[1], 'r') as f: transformed_text = f.read() except OSError as e: print(json.dumps({"error": f"Could not read input: {e}"})) sys.exit(2) # Optionally read pre-computed constraints constraints = None if len(args) > 2: with open(args[2], 'r') as f: data = json.load(f) constraints = data.get("constraints", []) result = validate_preservation(original_text, transformed_text, constraints) # Output result output = { "passed": result["passed"], "total_constraints": result["total_constraints"], "preserved": result["preserved"], "missing_count": len(result["missing"]), "missing": result["missing"], "warnings": result["warnings"] } print(json.dumps(output, indent=2)) # Exit with appropriate code sys.exit(0 if result["passed"] and not (strict and result["warnings"]) else 1) if __name__ == "__main__": main()