Skip to main content

logicaffeine_web/
grader.rs

1
2#[derive(Debug, Clone)]
3pub struct GradeResult {
4    pub correct: bool,
5    pub partial: bool,
6    pub score: u32,
7    pub feedback: String,
8}
9
10impl GradeResult {
11    pub fn correct() -> Self {
12        Self {
13            correct: true,
14            partial: false,
15            score: 100,
16            feedback: "Correct!".to_string(),
17        }
18    }
19
20    pub fn partial(feedback: String, score: u32) -> Self {
21        Self {
22            correct: false,
23            partial: true,
24            score,
25            feedback,
26        }
27    }
28
29    pub fn incorrect(feedback: String) -> Self {
30        Self {
31            correct: false,
32            partial: false,
33            score: 0,
34            feedback,
35        }
36    }
37}
38
39pub fn check_answer(user_input: &str, expected: &str) -> GradeResult {
40    let user_normalized = normalize_logic(user_input);
41    let expected_normalized = normalize_logic(expected);
42
43    if user_normalized == expected_normalized {
44        return GradeResult::correct();
45    }
46
47    let user_parsed = parse_to_normalized_ast(user_input);
48    let expected_parsed = parse_to_normalized_ast(expected);
49
50    match (user_parsed, expected_parsed) {
51        (Some(user_ast), Some(expected_ast)) => {
52            if structural_eq(&user_ast, &expected_ast) {
53                return GradeResult::correct();
54            }
55
56            let similarity = structural_similarity(&user_ast, &expected_ast);
57            if similarity > 0.7 {
58                GradeResult::partial(
59                    "Close! Check your quantifier or connective structure.".to_string(),
60                    (similarity * 50.0) as u32,
61                )
62            } else if similarity > 0.4 {
63                GradeResult::partial(
64                    "Partially correct. Review the logical structure.".to_string(),
65                    (similarity * 30.0) as u32,
66                )
67            } else {
68                GradeResult::incorrect(
69                    "Not quite. Consider the relationship between subject and predicate.".to_string(),
70                )
71            }
72        }
73        (None, _) => GradeResult::incorrect(
74            "Could not parse your answer. Check syntax.".to_string(),
75        ),
76        (_, None) => GradeResult::incorrect(
77            "Internal error: could not parse expected answer.".to_string(),
78        ),
79    }
80}
81
82fn normalize_logic(input: &str) -> String {
83    let mut result = input.to_string();
84
85    result = result.replace("\\forall", "∀");
86    result = result.replace("\\exists", "∃");
87    result = result.replace("\\neg", "¬");
88    result = result.replace("\\land", "∧");
89    result = result.replace("\\lor", "∨");
90    result = result.replace("\\supset", "→");
91    result = result.replace("\\equiv", "↔");
92    result = result.replace("\\Box", "□");
93    result = result.replace("\\Diamond", "◇");
94
95    // Order matters: replace <-> before ->
96    result = result.replace("<->", "↔");
97    result = result.replace("->", "→");
98    result = result.replace("&", "∧");
99    result = result.replace("|", "∨");
100    result = result.replace("~", "¬");
101    result = result.replace("!", "¬");
102
103    result = result.chars().filter(|c| !c.is_whitespace()).collect();
104
105    result
106}
107
108#[derive(Debug, Clone)]
109struct NormalizedExpr {
110    kind: NormalizedKind,
111}
112
113#[derive(Debug, Clone)]
114enum NormalizedKind {
115    Predicate { name: String, arity: usize },
116    Quantifier { kind: String, body: Box<NormalizedExpr> },
117    Binary { op: String, left: Box<NormalizedExpr>, right: Box<NormalizedExpr> },
118    Unary { op: String, operand: Box<NormalizedExpr> },
119    Atom(String),
120}
121
122fn parse_to_normalized_ast(input: &str) -> Option<NormalizedExpr> {
123    let normalized = normalize_logic(input);
124
125    if normalized.starts_with('∀') || normalized.starts_with('∃') {
126        let quantifier = if normalized.starts_with('∀') { "∀" } else { "∃" };
127        let rest = &normalized[quantifier.len()..];
128
129        if let Some(paren_start) = rest.find('(') {
130            let body = &rest[paren_start..];
131            if let Some(inner) = extract_balanced(body) {
132                return Some(NormalizedExpr {
133                    kind: NormalizedKind::Quantifier {
134                        kind: quantifier.to_string(),
135                        body: Box::new(parse_to_normalized_ast(&inner)?),
136                    },
137                });
138            }
139        }
140    }
141
142    if let Some(impl_pos) = find_main_connective(&normalized, "→") {
143        let left = &normalized[..impl_pos];
144        let right = &normalized[impl_pos + "→".len()..];
145        return Some(NormalizedExpr {
146            kind: NormalizedKind::Binary {
147                op: "→".to_string(),
148                left: Box::new(parse_to_normalized_ast(left)?),
149                right: Box::new(parse_to_normalized_ast(right)?),
150            },
151        });
152    }
153
154    if let Some(and_pos) = find_main_connective(&normalized, "∧") {
155        let left = &normalized[..and_pos];
156        let right = &normalized[and_pos + "∧".len()..];
157        return Some(NormalizedExpr {
158            kind: NormalizedKind::Binary {
159                op: "∧".to_string(),
160                left: Box::new(parse_to_normalized_ast(left)?),
161                right: Box::new(parse_to_normalized_ast(right)?),
162            },
163        });
164    }
165
166    if normalized.starts_with('¬') {
167        let operand = &normalized["¬".len()..];
168        return Some(NormalizedExpr {
169            kind: NormalizedKind::Unary {
170                op: "¬".to_string(),
171                operand: Box::new(parse_to_normalized_ast(operand)?),
172            },
173        });
174    }
175
176    if let Some(paren_pos) = normalized.find('(') {
177        let name = &normalized[..paren_pos];
178        let args = &normalized[paren_pos..];
179        let arity = args.matches(',').count() + 1;
180        return Some(NormalizedExpr {
181            kind: NormalizedKind::Predicate {
182                name: name.to_string(),
183                arity,
184            },
185        });
186    }
187
188    Some(NormalizedExpr {
189        kind: NormalizedKind::Atom(normalized),
190    })
191}
192
193fn extract_balanced(s: &str) -> Option<String> {
194    if !s.starts_with('(') {
195        return None;
196    }
197
198    let mut depth = 0;
199    let mut end = 0;
200
201    for (i, c) in s.chars().enumerate() {
202        match c {
203            '(' => depth += 1,
204            ')' => {
205                depth -= 1;
206                if depth == 0 {
207                    end = i;
208                    break;
209                }
210            }
211            _ => {}
212        }
213    }
214
215    if depth == 0 && end > 0 {
216        Some(s[1..end].to_string())
217    } else {
218        None
219    }
220}
221
222fn find_main_connective(s: &str, connective: &str) -> Option<usize> {
223    let mut depth = 0;
224    let mut byte_idx = 0;
225
226    for c in s.chars() {
227        match c {
228            '(' => depth += 1,
229            ')' => depth -= 1,
230            _ if depth == 0 && s[byte_idx..].starts_with(connective) => {
231                return Some(byte_idx);
232            }
233            _ => {}
234        }
235        byte_idx += c.len_utf8();
236    }
237
238    None
239}
240
241fn structural_eq(a: &NormalizedExpr, b: &NormalizedExpr) -> bool {
242    match (&a.kind, &b.kind) {
243        (NormalizedKind::Predicate { name: n1, arity: a1 }, NormalizedKind::Predicate { name: n2, arity: a2 }) => {
244            n1 == n2 && a1 == a2
245        }
246        (NormalizedKind::Quantifier { kind: k1, body: b1 }, NormalizedKind::Quantifier { kind: k2, body: b2 }) => {
247            k1 == k2 && structural_eq(b1, b2)
248        }
249        (NormalizedKind::Binary { op: o1, left: l1, right: r1 }, NormalizedKind::Binary { op: o2, left: l2, right: r2 }) => {
250            if o1 != o2 {
251                return false;
252            }
253            if structural_eq(l1, l2) && structural_eq(r1, r2) {
254                return true;
255            }
256            if o1 == "∧" || o1 == "∨" {
257                structural_eq(l1, r2) && structural_eq(r1, l2)
258            } else {
259                false
260            }
261        }
262        (NormalizedKind::Unary { op: o1, operand: op1 }, NormalizedKind::Unary { op: o2, operand: op2 }) => {
263            o1 == o2 && structural_eq(op1, op2)
264        }
265        (NormalizedKind::Atom(a1), NormalizedKind::Atom(a2)) => a1 == a2,
266        _ => false,
267    }
268}
269
270fn structural_similarity(a: &NormalizedExpr, b: &NormalizedExpr) -> f64 {
271    match (&a.kind, &b.kind) {
272        (NormalizedKind::Predicate { name: n1, arity: a1 }, NormalizedKind::Predicate { name: n2, arity: a2 }) => {
273            let name_match = if n1 == n2 { 0.7 } else { 0.0 };
274            let arity_match = if a1 == a2 { 0.3 } else { 0.0 };
275            name_match + arity_match
276        }
277        (NormalizedKind::Quantifier { kind: k1, body: b1 }, NormalizedKind::Quantifier { kind: k2, body: b2 }) => {
278            let kind_match = if k1 == k2 { 0.4 } else { 0.0 };
279            let body_sim = structural_similarity(b1, b2);
280            kind_match + body_sim * 0.6
281        }
282        (NormalizedKind::Binary { op: o1, left: l1, right: r1 }, NormalizedKind::Binary { op: o2, left: l2, right: r2 }) => {
283            let op_match = if o1 == o2 { 0.3 } else { 0.0 };
284            let left_sim = structural_similarity(l1, l2);
285            let right_sim = structural_similarity(r1, r2);
286            op_match + (left_sim + right_sim) * 0.35
287        }
288        (NormalizedKind::Unary { op: o1, operand: op1 }, NormalizedKind::Unary { op: o2, operand: op2 }) => {
289            let op_match = if o1 == o2 { 0.3 } else { 0.0 };
290            op_match + structural_similarity(op1, op2) * 0.7
291        }
292        (NormalizedKind::Atom(a1), NormalizedKind::Atom(a2)) => {
293            if a1 == a2 { 1.0 } else { 0.0 }
294        }
295        _ => 0.0,
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_exact_match() {
305        let result = check_answer("∀x(D(x) → B(x))", "∀x(D(x) → B(x))");
306        assert!(result.correct, "Exact match should be correct");
307    }
308
309    #[test]
310    fn test_whitespace_normalization() {
311        let result = check_answer("∀x( D(x) → B(x) )", "∀x(D(x)→B(x))");
312        assert!(result.correct, "Whitespace should be normalized");
313    }
314
315    #[test]
316    fn test_latex_to_unicode() {
317        let result = check_answer("\\forall x(D(x) \\supset B(x))", "∀x(D(x) → B(x))");
318        assert!(result.correct, "LaTeX should normalize to Unicode");
319    }
320
321    #[test]
322    fn test_ascii_shortcuts() {
323        let result = check_answer("D(x) & B(x)", "D(x) ∧ B(x)");
324        assert!(result.correct, "ASCII & should match ∧");
325    }
326
327    #[test]
328    fn test_commutative_conjunction() {
329        let result = check_answer("∃x(B(x) ∧ D(x))", "∃x(D(x) ∧ B(x))");
330        assert!(result.correct, "Conjunction should be commutative");
331    }
332
333    #[test]
334    fn test_wrong_quantifier() {
335        let result = check_answer("∃x(D(x) → B(x))", "∀x(D(x) → B(x))");
336        assert!(!result.correct, "Wrong quantifier should not match");
337        assert!(result.partial, "Should get partial credit");
338    }
339
340    #[test]
341    fn test_wrong_connective() {
342        let result = check_answer("∀x(D(x) ∧ B(x))", "∀x(D(x) → B(x))");
343        assert!(!result.correct, "Wrong connective should not match");
344        assert!(result.partial, "Should get partial credit for structure");
345    }
346
347    #[test]
348    fn test_completely_wrong() {
349        let result = check_answer("P(a)", "∀x(D(x) → B(x))");
350        assert!(!result.correct);
351        assert!(!result.partial);
352    }
353
354    #[test]
355    fn test_normalize_arrow() {
356        let normalized = normalize_logic("A -> B");
357        assert_eq!(normalized, "A→B");
358    }
359
360    #[test]
361    fn test_normalize_biconditional() {
362        let normalized = normalize_logic("A <-> B");
363        assert_eq!(normalized, "A↔B");
364    }
365}