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 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}