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

1"""Compare graph2d-react exported Graph2D payloads (same rules as checkGraphData). 

2 

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

7 

8from __future__ import annotations 

9 

10import copy 

11import hashlib 

12import json 

13from typing import Any, Optional 

14 

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) 

26 

27 

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) 

33 

34 

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 

67 

68 

69_LOOKS_LIKE_GRAPH_KEYS = _ELEMENT_KEYS + ( 

70 "interactiveDots", 

71 "interactiveDotLimits", 

72 "canvasTexts", 

73 "attributes", 

74) 

75 

76 

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} 

80 

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 

91 

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

96 

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 

105 

106 

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())) 

111 

112 

113def looks_like_graph_payload(raw: Any) -> bool: 

114 return _parse_payload(raw) is not None 

115 

116 

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 

129 

130 

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 

144 

145 

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 

158 

159 

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 

174 

175 

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

180 

181 

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 

187 

188 

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. 

191 

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 

205 

206 

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 

233 

234 

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 } 

268 

269 

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 

274 

275 

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

280 

281 

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 

297 

298 

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 

314 

315 

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 

367 

368 

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" 

374 

375 

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

382 

383 

384 

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. 

392 

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 

412 

413 

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) 

417 

418 

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 

431 

432 

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) 

447 

448 

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 

475 

476 

477def to_student_prompt_graph(raw: Any) -> Optional[dict]: 

478 """Authored graph with every interactive dot forced inactive. 

479 

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 

498 

499 

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) 

507 

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 

514 

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 

524 

525 

526def to_student_prompt_drawing_graph(raw: Any) -> Optional[dict]: 

527 """Empty Graph canvas that keeps the authored axis window. 

528 

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 } 

548 

549 

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