File size: 7,938 Bytes
917e554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
"""
Guardrails: input gate, leak check, refusal and fallback templates.
"""
import re
from typing import Optional, Tuple, List, Dict, Any


# --- Input Gate (code checks) ---

INJECTION_PATTERNS = [
    r"ignore\s+previous\s+instructions",
    r"ignore\s+all\s+instructions",
    r"system\s+prompt",
    r"reveal\s+the\s+solution",
    r"reveal\s+hidden",
    r"just\s+give\s+me\s+the\s+answer",
    r"act\s+as\s+",
]

NUMBER_WORDS = {
    "zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
    "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
    "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
    "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20,
    "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70,
    "eighty": 80, "ninety": 90, "hundred": 100, "thousand": 1000,
    "million": 1000000, "billion": 1000000000,
}

# Refusal messages (to be used verbatim)
REFUSALS = {
    "empty": "I can't help with an empty problem. Please provide a math word problem.",
    "off_topic": "This doesn't look like a math word problem. Please provide a problem with numbers and a question.",
    "prompt_injection": "I can't process that request. Please provide a genuine math word problem.",
}

# Safe fallback hints
SAFE_FALLBACK = {
    1: "What information is given in the problem? What are you being asked to find?",
    2: "What mathematical concept or formula might apply here? What's the first step?",
    3: "Let me walk through the solution step by step.",
}


def input_gate_code(text: str) -> Optional[str]:
    """
    Code-based input gate checks.
    Returns rejection category ("empty", "off_topic", "prompt_injection") or None if allowed.
    """
    if not text or not text.strip():
        return "empty"

    text_stripped = text.strip()

    # Length checks
    if len(text_stripped) < 15:
        return "off_topic"
    if len(text_stripped) > 1500:
        return "off_topic"

    # Prompt injection check
    text_lower = text_stripped.lower()
    for pattern in INJECTION_PATTERNS:
        if re.search(pattern, text_lower, re.IGNORECASE):
            return "prompt_injection"

    # Check for at least one digit or number word
    has_digit = bool(re.search(r"\d", text_stripped))
    has_number_word = any(word in text_lower for word in NUMBER_WORDS.keys())

    if not has_digit and not has_number_word:
        return "off_topic"

    return None


# --- Leak Check (code checks) ---

PHRASE_BLOCKLIST = [
    "the answer is",
    "answer:",
    "so the result is",
    "final answer",
]


def _normalize_number_text(text: str) -> str:
    """Normalize numeric text: strip units, commas, currency symbols."""
    # Remove currency symbols
    text = re.sub(r"[\$\£\€\¥]", "", text)
    # Remove commas
    text = text.replace(",", "")
    # Remove units (anything after a number that's letters)
    text = re.sub(r"(\d+(?:\.\d+)?)\s*[a-zA-Z]+", r"\1", text)
    return text.strip()


def _extract_numbers_from_text(text: str) -> List[str]:
    """Extract all numbers (integers, decimals, fractions) as normalized strings."""
    numbers = []
    # Pattern for fractions (e.g., 3/4, 15/8)
    for match in re.finditer(r"\b(\d+)\s*/\s*(\d+)\b", text):
        num = int(match.group(1))
        den = int(match.group(2))
        if den != 0:
            numbers.append(f"{num}/{den}")
            # Also add decimal equivalent
            numbers.append(f"{num/den:.10f}".rstrip('0').rstrip('.'))
    # Pattern for decimals and integers
    for match in re.finditer(r"\b(\d+(?:\.\d+)?)\b", text):
        val = match.group(1)
        # Normalize: strip leading zeros but keep at least one digit
        val_norm = val.lstrip('0')
        if val_norm == '' or val_norm.startswith('.'):
            val_norm = '0' + val_norm
        numbers.append(val_norm)
    return numbers


def _number_to_words(n: float) -> List[str]:
    """Convert a number to possible word forms."""
    words = []
    # Integer form
    if n == int(n):
        words.append(str(int(n)))
    # Decimal form
    words.append(f"{n:.10f}".rstrip('0').rstrip('.'))
    # Word form for small integers
    if n == int(n) and 0 <= int(n) <= 20:
        for word, val in NUMBER_WORDS.items():
            if val == int(n):
                words.append(word)
    return words


def _get_answer_variants(answer: str) -> List[str]:
    """Build all variants of the answer for matching."""
    variants = set()
    normalized = _normalize_number_text(answer)
    variants.add(normalized)

    # Try to parse as number
    try:
        # Handle fractions
        if '/' in normalized:
            parts = normalized.split('/')
            num = float(parts[0])
            den = float(parts[1])
            if den != 0:
                val = num / den
                variants.update(_number_to_words(val))
                # Add fraction variants
                variants.add(f"{int(num)}/{int(den)}")
        else:
            val = float(normalized)
            variants.update(_number_to_words(val))
    except (ValueError, ZeroDivisionError):
        pass

    return list(variants)


def _get_last_step_result(solution: str) -> Optional[str]:
    """Extract the last step's result from a solution."""
    lines = solution.strip().split('\n')
    for line in reversed(lines):
        line = line.strip()
        if '=' in line:
            # Get RHS of last equation
            rhs = line.split('=')[-1].strip()
            return _normalize_number_text(rhs)
    return None


def leak_check_code(hint_text: str, solution: str, problem_text: str) -> Tuple[bool, str]:
    """
    Check if hint leaks the answer.
    Returns (is_leak, reason).
    """
    hint_lower = hint_text.lower()

    # Phrase blocklist check (always applies)
    for phrase in PHRASE_BLOCKLIST:
        if phrase.lower() in hint_lower:
            return True, f"Forbidden phrase detected: '{phrase}'"

    # Get answer variants
    answer_variants = _get_answer_variants(solution)

    # Get problem numbers (to allow numbers that appear in the problem)
    problem_numbers = set(_extract_numbers_from_text(problem_text))
    problem_normalized = _normalize_number_text(problem_text)

    # Check if answer number appears as standalone token in hint
    hint_numbers = set(_extract_numbers_from_text(hint_text))

    for variant in answer_variants:
        if variant in hint_numbers:
            # Check if this number appears in the problem text
            if variant not in problem_numbers:
                return True, f"Answer number '{variant}' appears in hint but not in problem"

    # Level 2 check: last step's result
    last_step_result = _get_last_step_result(solution)
    if last_step_result:
        last_step_variants = _get_answer_variants(last_step_result)
        for variant in last_step_variants:
            if variant in hint_numbers:
                if variant not in problem_numbers:
                    return True, f"Last step result '{variant}' appears in hint but not in problem"

    return False, "No leak detected"


def normalise_numeric_equal(a: str, b: str) -> bool:
    """
    Compare two solver outputs numerically.
    Normalizes both and checks if they represent the same value within tolerance.
    """
    try:
        a_norm = _normalize_number_text(a)
        b_norm = _normalize_number_text(b)

        # Try parsing as fractions first
        def parse_val(s: str) -> Optional[float]:
            if '/' in s:
                parts = s.split('/')
                return float(parts[0]) / float(parts[1])
            return float(s)

        a_val = parse_val(a_norm)
        b_val = parse_val(b_norm)

        if a_val is not None and b_val is not None:
            return abs(a_val - b_val) < 1e-6

        # Fallback to string comparison
        return a_norm == b_norm
    except (ValueError, ZeroDivisionError):
        return a == b