Coverage for server / utilities / graph_data_checker.py: 75%
374 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""Compare graph2d-react exported Graph2D payloads (same rules as checkGraphData).
3Port of React-Graph-2D-Mongo ``src/lib/drawing/dataManagement.js``:
4canonicalizeGraphData + checkGraphData. Used to grade drawing-graph answers
5without requiring string-identical exports (checksum, exportedAt, draw order).
6"""
8from __future__ import annotations
10import copy
11import hashlib
12import json
13from typing import Any, Optional
15COORDINATE_PRECISION = 8
16DEFAULT_TOLERANCE = 0.0001
17_ELEMENT_KEYS = (
18 "dots",
19 "labeldots",
20 "pointValueDots",
21 "odots",
22 "lines",
23 "tplines",
24 "ineqlines",
25)
28def normalize_coordinate(value: Any) -> float:
29 rounded = round(float(value), COORDINATE_PRECISION)
30 if rounded == round(rounded):
31 return float(round(rounded))
32 return float(rounded)
35def _parse_payload(raw: Any) -> Optional[dict]:
36 """Unwrap a Graph2D export string/object to the inner data dict."""
37 if raw is None:
38 return None
39 if isinstance(raw, list):
40 if not raw:
41 return None
42 first = raw[0]
43 if isinstance(first, dict) and "answer" in first:
44 return _parse_payload(first.get("answer"))
45 if len(raw) == 1:
46 return _parse_payload(first)
47 return None
48 if isinstance(raw, dict) and "answer" in raw and not _looks_like_inner_graph(raw):
49 return _parse_payload(raw.get("answer"))
50 if isinstance(raw, str):
51 text = raw.strip()
52 if not text:
53 return None
54 try:
55 raw = json.loads(text)
56 except (json.JSONDecodeError, TypeError):
57 return None
58 if not isinstance(raw, dict):
59 return None
60 if isinstance(raw.get("data"), dict) and (
61 raw.get("format") == "Graph2D" or _looks_like_inner_graph(raw["data"])
62 ):
63 return raw["data"]
64 if _looks_like_inner_graph(raw):
65 return raw
66 return None
69_LOOKS_LIKE_GRAPH_KEYS = _ELEMENT_KEYS + (
70 "interactiveDots",
71 "interactiveDotLimits",
72 "canvasTexts",
73 "attributes",
74)
77def parse_interactive_dot_limits(raw: Any) -> dict:
78 """Return ``{"min": int|None, "max": int|None}`` from a Graph2D export or limits object."""
79 empty = {"min": None, "max": None}
81 def to_limit(value: Any) -> Optional[int]:
82 if value is None or value == "":
83 return None
84 try:
85 number = float(value)
86 except (TypeError, ValueError):
87 return None
88 if number != number: # NaN
89 return None
90 return int(number) if number == int(number) else None
92 def from_limits_obj(limits: Any) -> dict:
93 if not isinstance(limits, dict):
94 return empty
95 return {"min": to_limit(limits.get("min")), "max": to_limit(limits.get("max"))}
97 inner = _parse_payload(raw)
98 if isinstance(inner, dict) and "interactiveDotLimits" in inner:
99 return from_limits_obj(inner.get("interactiveDotLimits"))
100 if isinstance(raw, dict) and "interactiveDotLimits" in raw:
101 return from_limits_obj(raw.get("interactiveDotLimits"))
102 if isinstance(raw, dict) and ("min" in raw or "max" in raw) and not _looks_like_inner_graph(raw):
103 return from_limits_obj(raw)
104 return empty
107def _looks_like_inner_graph(obj: Any) -> bool:
108 if not isinstance(obj, dict):
109 return False
110 return bool(set(_LOOKS_LIKE_GRAPH_KEYS) & set(obj.keys()))
113def looks_like_graph_payload(raw: Any) -> bool:
114 return _parse_payload(raw) is not None
117def _sort_dots(arr: Any) -> list[dict]:
118 if not isinstance(arr, list):
119 return []
120 out = []
121 for item in arr:
122 if not isinstance(item, dict):
123 continue
124 x = normalize_coordinate(item["x"]) if isinstance(item.get("x"), (int, float)) else 0.0
125 y = normalize_coordinate(item["y"]) if isinstance(item.get("y"), (int, float)) else 0.0
126 out.append({"x": x, "y": y})
127 out.sort(key=lambda p: (p["x"], p["y"]))
128 return out
131def _sort_interactive_dots(arr: Any) -> list[dict]:
132 """Sort interactive dots by (x, y, active) — mirrors JS sortInteractiveDots."""
133 if not isinstance(arr, list):
134 return []
135 out = []
136 for item in arr:
137 if not isinstance(item, dict):
138 continue
139 x = normalize_coordinate(item["x"]) if isinstance(item.get("x"), (int, float)) else 0.0
140 y = normalize_coordinate(item["y"]) if isinstance(item.get("y"), (int, float)) else 0.0
141 out.append({"x": x, "y": y, "active": bool(item.get("active"))})
142 out.sort(key=lambda p: (p["x"], p["y"], p["active"]))
143 return out
146def _sort_label_dots(arr: Any) -> list[dict]:
147 if not isinstance(arr, list):
148 return []
149 out = []
150 for item in arr:
151 if not isinstance(item, dict):
152 continue
153 x = normalize_coordinate(item["x"]) if isinstance(item.get("x"), (int, float)) else 0.0
154 y = normalize_coordinate(item["y"]) if isinstance(item.get("y"), (int, float)) else 0.0
155 out.append({"x": x, "y": y, "label": str(item.get("label") or "")})
156 out.sort(key=lambda p: (p["x"], p["y"], p["label"]))
157 return out
160def _sort_lines(arr: Any) -> list[dict]:
161 if not isinstance(arr, list):
162 return []
163 out = []
164 for line in arr:
165 if not isinstance(line, dict) or not isinstance(line.get("dotData"), list):
166 continue
167 dots = _sort_dots(line["dotData"])
168 if not dots:
169 continue
170 mode = line["mode"] if isinstance(line.get("mode"), (int, float)) else 0
171 out.append({"mode": mode, "dotData": dots})
172 out.sort(key=lambda line: (line["mode"], json.dumps(line["dotData"], separators=(",", ":"))))
173 return out
176def _cross_z(p1: dict, p2: dict, p3: dict) -> float:
177 return (p2.get("x", 0) - p1.get("x", 0)) * (p3.get("y", 0) - p1.get("y", 0)) - (
178 p2.get("y", 0) - p1.get("y", 0)
179 ) * (p3.get("x", 0) - p1.get("x", 0))
182def _ordered_endpoints(p1: dict, p2: dict) -> tuple[dict, dict, bool]:
183 """Return endpoints in a stable (x, y) order and whether they were swapped."""
184 if (p1.get("x", 0), p1.get("y", 0)) <= (p2.get("x", 0), p2.get("y", 0)):
185 return p1, p2, False
186 return p2, p1, True
189def _ineq_shade_sign(dots: list, side: Any, flipped: bool, tolerance: float) -> int:
190 """Half-plane of a linear inequality: +1 / -1, or 0 if on the line / unknown.
192 For 3-point inequalities the third click only selects the shaded side, not a
193 required coordinate. ``side`` is relative to the original P1→P2 direction.
194 """
195 if len(dots) >= 3:
196 a, b, _ = _ordered_endpoints(dots[0], dots[1])
197 z = _cross_z(a, b, dots[2])
198 if abs(z) <= tolerance:
199 return 0
200 return 1 if z > 0 else -1
201 if side is not None:
202 sign = 1 if side else -1
203 return -sign if flipped else sign
204 return 0
207def _canonicalize_ineq_lines(arr: Any) -> list[dict]:
208 if not isinstance(arr, list):
209 return []
210 out = []
211 for line in arr:
212 if not isinstance(line, dict) or not isinstance(line.get("dotData"), list):
213 continue
214 dots = []
215 for item in line["dotData"]:
216 if not isinstance(item, dict):
217 continue
218 x = normalize_coordinate(item["x"]) if isinstance(item.get("x"), (int, float)) else 0.0
219 y = normalize_coordinate(item["y"]) if isinstance(item.get("y"), (int, float)) else 0.0
220 dots.append({"x": x, "y": y})
221 if len(dots) < 2:
222 continue
223 mode = line["mode"] if isinstance(line.get("mode"), (int, float)) else 0
224 a, b, flipped = _ordered_endpoints(dots[0], dots[1])
225 rec: dict[str, Any] = {
226 "mode": mode,
227 "dotData": [a, b],
228 "shade": _ineq_shade_sign(dots, line.get("side"), flipped, DEFAULT_TOLERANCE),
229 }
230 out.append(rec)
231 out.sort(key=lambda line: (line["mode"], json.dumps(line, separators=(",", ":"))))
232 return out
235def canonicalize_graph_data(obj: Optional[dict]) -> dict:
236 if not obj or not isinstance(obj, dict):
237 return {
238 "dots": [],
239 "labeldots": [],
240 "pointValueDots": [],
241 "odots": [],
242 "interactiveDots": [],
243 "canvasTexts": [],
244 "lines": [],
245 "tplines": [],
246 "ineqlines": [],
247 "attributes": {},
248 }
249 attrs = obj.get("attributes") if isinstance(obj.get("attributes"), dict) else {}
250 normalized_attrs = {}
251 for key, value in attrs.items():
252 if isinstance(value, (int, float)):
253 normalized_attrs[key] = normalize_coordinate(value)
254 else:
255 normalized_attrs[key] = value
256 return {
257 "dots": _sort_dots(obj.get("dots")),
258 "labeldots": _sort_label_dots(obj.get("labeldots")),
259 "pointValueDots": _sort_dots(obj.get("pointValueDots")),
260 "odots": _sort_dots(obj.get("odots")),
261 "interactiveDots": _sort_interactive_dots(obj.get("interactiveDots")),
262 "canvasTexts": [],
263 "lines": _sort_lines(obj.get("lines")),
264 "tplines": _sort_lines(obj.get("tplines")),
265 "ineqlines": _canonicalize_ineq_lines(obj.get("ineqlines")),
266 "attributes": normalized_attrs,
267 }
270def _coords_equal(a: dict, b: dict, tolerance: float) -> bool:
271 if not a or not b:
272 return a == b
273 return abs(a.get("x", 0) - b.get("x", 0)) <= tolerance and abs(a.get("y", 0) - b.get("y", 0)) <= tolerance
276def _compare_coord_arrays(arr1: list, arr2: list, tolerance: float) -> bool:
277 if len(arr1) != len(arr2):
278 return False
279 return all(_coords_equal(arr1[i], arr2[i], tolerance) for i in range(len(arr1)))
282def _compare_lines(arr1: list, arr2: list, tolerance: float) -> bool:
283 if len(arr1) != len(arr2):
284 return False
285 for line1, line2 in zip(arr1, arr2):
286 if line1.get("mode") != line2.get("mode"):
287 return False
288 if line1.get("side") is not None and line2.get("side") is not None:
289 if (1 if line1["side"] else 0) != (1 if line2["side"] else 0):
290 return False
291 d1, d2 = line1.get("dotData") or [], line2.get("dotData") or []
292 if len(d1) != len(d2):
293 return False
294 if not all(_coords_equal(d1[j], d2[j], tolerance) for j in range(len(d1))):
295 return False
296 return True
299def _compare_ineq_lines(arr1: list, arr2: list, tolerance: float) -> bool:
300 """Match linear inequalities as undirected boundary + shaded half-plane."""
301 if len(arr1) != len(arr2):
302 return False
303 for line1, line2 in zip(arr1, arr2):
304 if line1.get("mode") != line2.get("mode"):
305 return False
306 if line1.get("shade", 0) != line2.get("shade", 0):
307 return False
308 d1, d2 = line1.get("dotData") or [], line2.get("dotData") or []
309 if len(d1) != 2 or len(d2) != 2:
310 return False
311 if not (_coords_equal(d1[0], d2[0], tolerance) and _coords_equal(d1[1], d2[1], tolerance)):
312 return False
313 return True
316def check_graph_data(
317 data1: Any,
318 data2: Any,
319 *,
320 tolerance: float = DEFAULT_TOLERANCE,
321 ignore_attributes: bool = True,
322) -> bool:
323 """Return True when two Graph2D exports represent the same drawing."""
324 inner1 = _parse_payload(data1)
325 inner2 = _parse_payload(data2)
326 if inner1 is None or inner2 is None:
327 return False
328 try:
329 c1 = canonicalize_graph_data(inner1)
330 c2 = canonicalize_graph_data(inner2)
331 for key in _ELEMENT_KEYS:
332 if len(c1.get(key) or []) != len(c2.get(key) or []):
333 return False
334 if not _compare_coord_arrays(c1["dots"], c2["dots"], tolerance):
335 return False
336 labels1, labels2 = c1["labeldots"], c2["labeldots"]
337 if len(labels1) != len(labels2):
338 return False
339 for ld1, ld2 in zip(labels1, labels2):
340 if not _coords_equal(ld1, ld2, tolerance) or str(ld1.get("label") or "") != str(
341 ld2.get("label") or ""
342 ):
343 return False
344 if not _compare_coord_arrays(c1["pointValueDots"], c2["pointValueDots"], tolerance):
345 return False
346 if not _compare_coord_arrays(c1["odots"], c2["odots"], tolerance):
347 return False
348 if not _compare_lines(c1["lines"], c2["lines"], tolerance):
349 return False
350 if not _compare_lines(c1["tplines"], c2["tplines"], tolerance):
351 return False
352 if not _compare_ineq_lines(c1["ineqlines"], c2["ineqlines"], tolerance):
353 return False
354 if not ignore_attributes:
355 attrs1, attrs2 = c1.get("attributes") or {}, c2.get("attributes") or {}
356 keys = set(attrs1) | set(attrs2)
357 for key in keys:
358 v1, v2 = attrs1.get(key), attrs2.get(key)
359 if isinstance(v1, (int, float)) and isinstance(v2, (int, float)):
360 if abs(v1 - v2) > tolerance:
361 return False
362 elif v1 != v2:
363 return False
364 return True
365 except (TypeError, ValueError, KeyError):
366 return False
369def is_graph_question_type(question_type: Any) -> bool:
370 """True for staff/teacher ``Graph`` question types (any common spelling)."""
371 if question_type is None:
372 return False
373 return str(question_type).strip().lower().replace("_", "-") == "graph"
376def is_interactive_dots_question_type(question_type: Any) -> bool:
377 """True for ``Graph-Multiple-Select``."""
378 if question_type is None:
379 return False
380 normalized = str(question_type).strip().lower().replace("_", "-")
381 return normalized in {"graph-multiple-select", "graph-multiple-choice"}
385def check_interactive_dots(
386 student: Any,
387 correct: Any,
388 *,
389 tolerance: float = DEFAULT_TOLERANCE,
390) -> bool:
391 """Compare only interactiveDots (positions + active). Background shapes are ignored.
393 Port of React-Graph-2D-Mongo ``checkInteractiveDots``.
394 """
395 inner1 = _parse_payload(student)
396 inner2 = _parse_payload(correct)
397 if inner1 is None or inner2 is None:
398 return False
399 try:
400 arr1 = canonicalize_graph_data(inner1).get("interactiveDots") or []
401 arr2 = canonicalize_graph_data(inner2).get("interactiveDots") or []
402 if len(arr1) != len(arr2):
403 return False
404 for d1, d2 in zip(arr1, arr2):
405 if not _coords_equal(d1, d2, tolerance):
406 return False
407 if bool(d1.get("active")) != bool(d2.get("active")):
408 return False
409 return True
410 except (TypeError, ValueError, KeyError):
411 return False
414def grade_interactive_dots_answer(student: Any, correct: Any) -> bool:
415 """Grade a Graph-Multiple-Select answer against the author answer key."""
416 return check_interactive_dots(student, correct)
419def canonical_graph_fingerprint(raw: Any) -> Optional[str]:
420 """SHA-256 of canonical drawing elements (excludes checksum, exportedAt, attributes)."""
421 inner = _parse_payload(raw)
422 if inner is None:
423 return None
424 try:
425 canonical = canonicalize_graph_data(inner)
426 payload = {key: canonical.get(key) or [] for key in _ELEMENT_KEYS}
427 blob = json.dumps(payload, separators=(",", ":"), sort_keys=True)
428 return hashlib.sha256(blob.encode("utf-8")).hexdigest()
429 except (TypeError, ValueError, KeyError):
430 return None
433def grade_graph_answer(
434 student: Any,
435 correct: Any,
436 *,
437 stored_fingerprint: Optional[str] = None,
438) -> bool:
439 """Grade a graph answer: fingerprint fast-path, then geometric ``check_graph_data``."""
440 student_fp = canonical_graph_fingerprint(student)
441 if stored_fingerprint and student_fp and student_fp == stored_fingerprint:
442 return True
443 correct_fp = canonical_graph_fingerprint(correct)
444 if student_fp and correct_fp and student_fp == correct_fp:
445 return True
446 return check_graph_data(student, correct)
449def attach_graph_fingerprint(question: dict) -> dict:
450 """Set ``correctAnswer.graphFingerprint`` on Graph questions. No-op otherwise."""
451 if not isinstance(question, dict):
452 return question
453 qtype = question.get("questionType")
454 if not (is_graph_question_type(qtype) or is_interactive_dots_question_type(qtype)):
455 return question
456 correct = question.get("correctAnswer")
457 if not isinstance(correct, dict):
458 return question
459 # Graph-Multiple-Select fingerprint is dots-only so decorative shapes don't affect it.
460 if is_interactive_dots_question_type(qtype):
461 inner = _parse_payload(correct.get("answers"))
462 if inner is None:
463 return question
464 try:
465 dots = canonicalize_graph_data(inner).get("interactiveDots") or []
466 blob = json.dumps(dots, separators=(",", ":"), sort_keys=True)
467 correct["graphFingerprint"] = hashlib.sha256(blob.encode("utf-8")).hexdigest()
468 except (TypeError, ValueError, KeyError):
469 return question
470 return question
471 fingerprint = canonical_graph_fingerprint(correct.get("answers"))
472 if fingerprint:
473 correct["graphFingerprint"] = fingerprint
474 return question
477def to_student_prompt_graph(raw: Any) -> Optional[dict]:
478 """Authored graph with every interactive dot forced inactive.
480 Students need the dots, axes, limits, and background drawings — not which
481 dots were marked correct. Take-assignment strips ``correctAnswer`` for
482 every other type; Graph-Multiple-Select has to keep this prompt or the
483 canvas is an empty default-range Graph.
484 """
485 inner = _parse_payload(raw)
486 if inner is None:
487 return None
488 content = copy.deepcopy(inner)
489 dots = content.get("interactiveDots")
490 if isinstance(dots, list):
491 content["interactiveDots"] = [
492 {**dot, "active": False} if isinstance(dot, dict) else dot for dot in dots
493 ]
494 # Always re-attach min/max so the student client can enforce them even
495 # when the Graph2D envelope was unwrapped (teacher preview keeps the envelope).
496 content["interactiveDotLimits"] = parse_interactive_dot_limits(inner)
497 return content
500def parse_graph_axis_bounds(raw: Any) -> dict:
501 """Authored cartesian window, or the Graph2D default −10…10."""
502 defaults = {"xMin": -10.0, "xMax": 10.0, "yMin": -10.0, "yMax": 10.0}
503 inner = _parse_payload(raw)
504 attrs = inner.get("attributes") if isinstance(inner, dict) else None
505 if not isinstance(attrs, dict):
506 return dict(defaults)
508 def _num(key: str, fallback: float) -> float:
509 try:
510 value = float(attrs[key])
511 except (TypeError, ValueError, KeyError):
512 return fallback
513 return value if value == value else fallback
515 bounds = {
516 "xMin": _num("xMin", defaults["xMin"]),
517 "xMax": _num("xMax", defaults["xMax"]),
518 "yMin": _num("yMin", defaults["yMin"]),
519 "yMax": _num("yMax", defaults["yMax"]),
520 }
521 if bounds["xMin"] >= bounds["xMax"] or bounds["yMin"] >= bounds["yMax"]:
522 return dict(defaults)
523 return bounds
526def to_student_prompt_drawing_graph(raw: Any) -> Optional[dict]:
527 """Empty Graph canvas that keeps the authored axis window.
529 Take-assignment must not leak the answer-key drawing, but without these
530 attributes the student canvas opens on the default −10…10 plane.
531 """
532 inner = _parse_payload(raw)
533 if inner is None:
534 return None
535 attrs = copy.deepcopy(inner["attributes"]) if isinstance(inner.get("attributes"), dict) else {}
536 attrs.update(parse_graph_axis_bounds(inner))
537 return {
538 "dots": [],
539 "labeldots": [],
540 "pointValueDots": [],
541 "odots": [],
542 "lines": [],
543 "tplines": [],
544 "ineqlines": [],
545 "canvasTexts": [],
546 "attributes": attrs,
547 }
550def student_prompt_correct_answer(question: dict) -> Optional[dict]:
551 """Student-visible ``correctAnswer`` for Graph / Graph-Multiple-Select, or None."""
552 if not isinstance(question, dict):
553 return None
554 correct = question.get("correctAnswer")
555 if not isinstance(correct, dict):
556 return None
557 qtype = question.get("questionType")
558 if is_interactive_dots_question_type(qtype):
559 prompt = to_student_prompt_graph(correct.get("answers"))
560 if prompt is None:
561 return None
562 return {
563 "answers": prompt,
564 "interactiveDotLimits": parse_interactive_dot_limits(prompt),
565 }
566 if is_graph_question_type(qtype):
567 prompt = to_student_prompt_drawing_graph(correct.get("answers"))
568 if prompt is None:
569 return None
570 return {"answers": prompt}
571 return None