/home/noah/src/ruchy/src/runtime/assessment.rs
Line | Count | Source |
1 | | //! Educational Assessment System for REPL Replay Testing |
2 | | //! |
3 | | //! Provides automated grading, rubric evaluation, and academic integrity checking |
4 | | //! for educational use of the Ruchy REPL. |
5 | | |
6 | | use anyhow::Result; |
7 | | use serde::{Serialize, Deserialize}; |
8 | | use std::collections::{HashMap, HashSet}; |
9 | | use sha2::{Sha256, Digest}; |
10 | | use regex::Regex; |
11 | | |
12 | | use crate::runtime::replay::{ |
13 | | ReplSession, Event, ReplayValidator |
14 | | }; |
15 | | use crate::runtime::repl::Repl; |
16 | | |
17 | | // ============================================================================ |
18 | | // Assignment Specification |
19 | | // ============================================================================ |
20 | | |
21 | | /// Complete assignment specification for automated grading |
22 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
23 | | pub struct Assignment { |
24 | | pub id: String, |
25 | | pub title: String, |
26 | | pub description: String, |
27 | | pub setup: AssignmentSetup, |
28 | | pub tasks: Vec<Task>, |
29 | | pub constraints: AssignmentConstraints, |
30 | | pub rubric: GradingRubric, |
31 | | } |
32 | | |
33 | | /// Initial setup for assignment environment |
34 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
35 | | pub struct AssignmentSetup { |
36 | | pub prelude_code: Vec<String>, |
37 | | pub provided_functions: HashMap<String, String>, |
38 | | pub immutable_bindings: HashSet<String>, |
39 | | } |
40 | | |
41 | | /// Individual task within an assignment |
42 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
43 | | pub struct Task { |
44 | | pub id: String, |
45 | | pub description: String, |
46 | | pub points: u32, |
47 | | pub test_cases: Vec<TestCase>, |
48 | | pub hidden_cases: Vec<TestCase>, |
49 | | pub requirements: Vec<Requirement>, |
50 | | } |
51 | | |
52 | | /// Test case for validation |
53 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
54 | | pub struct TestCase { |
55 | | pub input: String, |
56 | | pub expected: ExpectedBehavior, |
57 | | pub points: u32, |
58 | | pub timeout_ms: u64, |
59 | | } |
60 | | |
61 | | /// Expected behavior patterns for test validation |
62 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
63 | | pub enum ExpectedBehavior { |
64 | | ExactOutput(String), |
65 | | Pattern(String), // Regex pattern |
66 | | TypeSignature(String), |
67 | | Predicate(PredicateCheck), |
68 | | PerformanceBound { |
69 | | max_ns: u64, |
70 | | max_bytes: usize, |
71 | | }, |
72 | | } |
73 | | |
74 | | /// Predicate-based checking for complex validation |
75 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
76 | | pub struct PredicateCheck { |
77 | | pub name: String, |
78 | | pub check_fn: String, // Code to evaluate |
79 | | } |
80 | | |
81 | | /// Requirements that must be met |
82 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
83 | | pub enum Requirement { |
84 | | UseRecursion, |
85 | | NoLoops, |
86 | | UseHigherOrderFunctions, |
87 | | TypeSafe, |
88 | | PureFunction, |
89 | | TailRecursive, |
90 | | } |
91 | | |
92 | | /// Constraints on the assignment |
93 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
94 | | pub struct AssignmentConstraints { |
95 | | pub max_time_ms: u64, |
96 | | pub max_memory_mb: usize, |
97 | | pub allowed_imports: Vec<String>, |
98 | | pub forbidden_keywords: Vec<String>, |
99 | | pub performance: Option<PerformanceConstraints>, |
100 | | } |
101 | | |
102 | | /// Performance requirements |
103 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
104 | | pub struct PerformanceConstraints { |
105 | | pub max_cpu_ms: u64, |
106 | | pub max_heap_mb: usize, |
107 | | pub complexity_bound: String, // e.g., "O(n log n)" |
108 | | } |
109 | | |
110 | | // ============================================================================ |
111 | | // Grading Rubric |
112 | | // ============================================================================ |
113 | | |
114 | | /// Grading rubric with weighted categories |
115 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
116 | | pub struct GradingRubric { |
117 | | pub categories: Vec<RubricCategory>, |
118 | | pub late_penalty: Option<LatePenalty>, |
119 | | pub bonus_criteria: Vec<BonusCriterion>, |
120 | | } |
121 | | |
122 | | /// Category in the grading rubric |
123 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
124 | | pub struct RubricCategory { |
125 | | pub name: String, |
126 | | pub weight: f32, |
127 | | pub criteria: Vec<Criterion>, |
128 | | } |
129 | | |
130 | | /// Individual grading criterion |
131 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
132 | | pub struct Criterion { |
133 | | pub description: String, |
134 | | pub max_points: u32, |
135 | | pub evaluation: CriterionEvaluation, |
136 | | } |
137 | | |
138 | | /// How to evaluate a criterion |
139 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
140 | | pub enum CriterionEvaluation { |
141 | | Automatic(AutomaticCheck), |
142 | | Manual(String), // Instructions for manual grading |
143 | | Hybrid { auto_weight: f32, manual_weight: f32 }, |
144 | | } |
145 | | |
146 | | /// Automatic checking methods |
147 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
148 | | pub enum AutomaticCheck { |
149 | | TestsPassed, |
150 | | CodeQuality { min_score: f32 }, |
151 | | Documentation { required_sections: Vec<String> }, |
152 | | Performance { metric: String, threshold: f64 }, |
153 | | } |
154 | | |
155 | | /// Late submission penalty |
156 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
157 | | pub struct LatePenalty { |
158 | | pub grace_hours: u32, |
159 | | pub penalty_per_day: f32, |
160 | | pub max_days_late: u32, |
161 | | } |
162 | | |
163 | | /// Bonus points criteria |
164 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
165 | | pub struct BonusCriterion { |
166 | | pub description: String, |
167 | | pub points: u32, |
168 | | pub check: BonusCheck, |
169 | | } |
170 | | |
171 | | /// Bonus checking methods |
172 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
173 | | pub enum BonusCheck { |
174 | | ExtraFeature(String), |
175 | | Optimization { improvement_percent: f32 }, |
176 | | CreativeSolution, |
177 | | } |
178 | | |
179 | | // ============================================================================ |
180 | | // Grading Engine |
181 | | // ============================================================================ |
182 | | |
183 | | /// Main grading engine for automated assessment |
184 | | pub struct GradingEngine { |
185 | | pub replay_validator: ReplayValidator, |
186 | | pub plagiarism_detector: PlagiarismDetector, |
187 | | pub secure_sandbox: SecureSandbox, |
188 | | } |
189 | | |
190 | | impl Default for GradingEngine { |
191 | 0 | fn default() -> Self { |
192 | 0 | Self::new() |
193 | 0 | } |
194 | | } |
195 | | |
196 | | impl GradingEngine { |
197 | 0 | pub fn new() -> Self { |
198 | 0 | Self { |
199 | 0 | replay_validator: ReplayValidator::new(true), |
200 | 0 | plagiarism_detector: PlagiarismDetector::new(), |
201 | 0 | secure_sandbox: SecureSandbox::new(), |
202 | 0 | } |
203 | 0 | } |
204 | | |
205 | | /// Grade a student submission against an assignment |
206 | 0 | pub fn grade_submission( |
207 | 0 | &mut self, |
208 | 0 | assignment: &Assignment, |
209 | 0 | submission: &ReplSession, |
210 | 0 | ) -> GradeReport { |
211 | 0 | let mut report = GradeReport::new(assignment.id.clone()); |
212 | | |
213 | | // Verify submission integrity |
214 | 0 | if !self.verify_no_tampering(submission) { |
215 | 0 | report.mark_invalid("Session integrity check failed"); |
216 | 0 | return report; |
217 | 0 | } |
218 | | |
219 | | // Setup assignment environment |
220 | 0 | let mut repl = match self.secure_sandbox.create_isolated_repl() { |
221 | 0 | Ok(r) => r, |
222 | 0 | Err(e) => { |
223 | 0 | report.mark_invalid(&format!("Failed to create sandbox: {e}")); |
224 | 0 | return report; |
225 | | } |
226 | | }; |
227 | | |
228 | | // Load assignment setup |
229 | 0 | if let Err(e) = self.load_setup(&mut repl, &assignment.setup) { |
230 | 0 | report.mark_invalid(&format!("Failed to load setup: {e}")); |
231 | 0 | return report; |
232 | 0 | } |
233 | | |
234 | | // Grade each task |
235 | 0 | for task in &assignment.tasks { |
236 | 0 | let task_grade = self.grade_task(&mut repl, task, submission); |
237 | 0 | report.add_task_grade(task_grade); |
238 | 0 | } |
239 | | |
240 | | // Evaluate rubric |
241 | 0 | report.rubric_score = self.evaluate_rubric(&assignment.rubric, submission); |
242 | | |
243 | | // Check performance requirements |
244 | 0 | if let Some(perf) = &assignment.constraints.performance { |
245 | 0 | report.performance_score = self.measure_performance(submission, perf); |
246 | 0 | } |
247 | | |
248 | | // Detect plagiarism |
249 | 0 | report.originality_score = self.plagiarism_detector.analyze(submission); |
250 | | |
251 | | // Calculate final grade |
252 | 0 | report.calculate_final_grade(); |
253 | | |
254 | 0 | report |
255 | 0 | } |
256 | | |
257 | 0 | fn verify_no_tampering(&self, session: &ReplSession) -> bool { |
258 | | // Verify event sequence integrity |
259 | 0 | let mut prev_timestamp = 0u64; |
260 | 0 | for event in &session.timeline { |
261 | 0 | if event.timestamp_ns < prev_timestamp { |
262 | 0 | return false; // Time went backwards |
263 | 0 | } |
264 | 0 | prev_timestamp = event.timestamp_ns; |
265 | | } |
266 | | |
267 | | // Verify state hashes are consistent |
268 | | // In production, would replay and verify each hash |
269 | 0 | true |
270 | 0 | } |
271 | | |
272 | 0 | fn load_setup(&self, repl: &mut Repl, setup: &AssignmentSetup) -> Result<()> { |
273 | | // Load prelude code |
274 | 0 | for code in &setup.prelude_code { |
275 | 0 | repl.eval(code)?; |
276 | | } |
277 | | |
278 | | // Load provided functions |
279 | 0 | for (name, code) in &setup.provided_functions { |
280 | 0 | repl.eval(&format!("let {name} = {code}"))?; |
281 | | } |
282 | | |
283 | 0 | Ok(()) |
284 | 0 | } |
285 | | |
286 | 0 | fn grade_task( |
287 | 0 | &mut self, |
288 | 0 | repl: &mut Repl, |
289 | 0 | task: &Task, |
290 | 0 | _submission: &ReplSession, |
291 | 0 | ) -> TaskGrade { |
292 | 0 | let mut grade = TaskGrade::new(task.id.clone()); |
293 | | |
294 | | // Test visible cases |
295 | 0 | for test in &task.test_cases { |
296 | 0 | let result = self.run_test_case(repl, test); |
297 | 0 | grade.add_test_result(test.input.clone(), result); |
298 | 0 | } |
299 | | |
300 | | // Test hidden cases (for academic integrity) |
301 | 0 | for test in &task.hidden_cases { |
302 | 0 | let result = self.run_test_case(repl, test); |
303 | 0 | grade.add_hidden_result(test.input.clone(), result); |
304 | 0 | } |
305 | | |
306 | | // Check requirements |
307 | 0 | for req in &task.requirements { |
308 | 0 | if self.check_requirement(repl, req) { |
309 | 0 | grade.requirements_met.insert(format!("{req:?}")); |
310 | 0 | } |
311 | | } |
312 | | |
313 | 0 | grade.calculate_score(task.points); |
314 | 0 | grade |
315 | 0 | } |
316 | | |
317 | 0 | fn run_test_case(&self, repl: &mut Repl, test: &TestCase) -> TestResult { |
318 | | // Execute with timeout |
319 | 0 | let start = std::time::Instant::now(); |
320 | 0 | let output = match repl.eval(&test.input) { |
321 | 0 | Ok(out) => out, |
322 | 0 | Err(e) => { |
323 | 0 | return TestResult { |
324 | 0 | passed: false, |
325 | 0 | points_earned: 0, |
326 | 0 | feedback: format!("Error: {e}"), |
327 | 0 | execution_time_ms: start.elapsed().as_millis() as u64, |
328 | 0 | }; |
329 | | } |
330 | | }; |
331 | | |
332 | 0 | let execution_time_ms = start.elapsed().as_millis() as u64; |
333 | | |
334 | | // Check timeout |
335 | 0 | if execution_time_ms > test.timeout_ms { |
336 | 0 | return TestResult { |
337 | 0 | passed: false, |
338 | 0 | points_earned: 0, |
339 | 0 | feedback: format!("Timeout: {}ms > {}ms", execution_time_ms, test.timeout_ms), |
340 | 0 | execution_time_ms, |
341 | 0 | }; |
342 | 0 | } |
343 | | |
344 | | // Check expected behavior |
345 | 0 | let (passed, feedback) = match &test.expected { |
346 | 0 | ExpectedBehavior::ExactOutput(expected) => { |
347 | 0 | let passed = output == *expected; |
348 | 0 | let feedback = if passed { |
349 | 0 | "Correct output".to_string() |
350 | | } else { |
351 | 0 | format!("Expected '{expected}', got '{output}'") |
352 | | }; |
353 | 0 | (passed, feedback) |
354 | | } |
355 | 0 | ExpectedBehavior::Pattern(pattern) => { |
356 | 0 | let regex = Regex::new(pattern).unwrap_or_else(|_| Regex::new(".*").unwrap()); |
357 | 0 | let passed = regex.is_match(&output); |
358 | 0 | let feedback = if passed { |
359 | 0 | "Output matches pattern".to_string() |
360 | | } else { |
361 | 0 | format!("Output doesn't match pattern: {pattern}") |
362 | | }; |
363 | 0 | (passed, feedback) |
364 | | } |
365 | 0 | ExpectedBehavior::TypeSignature(expected_type) => { |
366 | | // In production, would check actual type |
367 | 0 | let passed = output.contains(expected_type); |
368 | 0 | let feedback = if passed { |
369 | 0 | "Type signature correct".to_string() |
370 | | } else { |
371 | 0 | format!("Expected type {expected_type}") |
372 | | }; |
373 | 0 | (passed, feedback) |
374 | | } |
375 | 0 | _ => (false, "Unsupported check".to_string()), |
376 | | }; |
377 | | |
378 | | TestResult { |
379 | 0 | passed, |
380 | 0 | points_earned: if passed { test.points } else { 0 }, |
381 | 0 | feedback, |
382 | 0 | execution_time_ms, |
383 | | } |
384 | 0 | } |
385 | | |
386 | 0 | fn check_requirement(&self, _repl: &Repl, req: &Requirement) -> bool { |
387 | | // In production, would analyze AST to check requirements |
388 | 0 | match req { |
389 | 0 | Requirement::UseRecursion => true, // Would check for recursive calls |
390 | 0 | Requirement::NoLoops => true, // Would check for loop constructs |
391 | 0 | Requirement::UseHigherOrderFunctions => true, // Would check for HOF usage |
392 | 0 | Requirement::TypeSafe => true, // Would verify type safety |
393 | 0 | Requirement::PureFunction => true, // Would check for side effects |
394 | 0 | Requirement::TailRecursive => true, // Would verify tail recursion |
395 | | } |
396 | 0 | } |
397 | | |
398 | 0 | fn evaluate_rubric(&self, rubric: &GradingRubric, _submission: &ReplSession) -> f32 { |
399 | 0 | let mut total_score = 0.0; |
400 | 0 | let mut total_weight = 0.0; |
401 | | |
402 | 0 | for category in &rubric.categories { |
403 | 0 | let category_score = self.evaluate_category(category); |
404 | 0 | total_score += category_score * category.weight; |
405 | 0 | total_weight += category.weight; |
406 | 0 | } |
407 | | |
408 | 0 | if total_weight > 0.0 { |
409 | 0 | (total_score / total_weight) * 100.0 |
410 | | } else { |
411 | 0 | 0.0 |
412 | | } |
413 | 0 | } |
414 | | |
415 | 0 | fn evaluate_category(&self, category: &RubricCategory) -> f32 { |
416 | 0 | let mut earned = 0u32; |
417 | 0 | let mut possible = 0u32; |
418 | | |
419 | 0 | for criterion in &category.criteria { |
420 | 0 | possible += criterion.max_points; |
421 | 0 | earned += self.evaluate_criterion(criterion); |
422 | 0 | } |
423 | | |
424 | 0 | if possible > 0 { |
425 | 0 | earned as f32 / possible as f32 |
426 | | } else { |
427 | 0 | 0.0 |
428 | | } |
429 | 0 | } |
430 | | |
431 | 0 | fn evaluate_criterion(&self, criterion: &Criterion) -> u32 { |
432 | 0 | match &criterion.evaluation { |
433 | 0 | CriterionEvaluation::Automatic(check) => { |
434 | 0 | match check { |
435 | 0 | AutomaticCheck::TestsPassed => criterion.max_points, |
436 | 0 | AutomaticCheck::CodeQuality { min_score } => { |
437 | | // In production, would run quality analysis |
438 | 0 | if *min_score <= 0.8 { |
439 | 0 | criterion.max_points |
440 | | } else { |
441 | 0 | 0 |
442 | | } |
443 | | } |
444 | 0 | _ => 0, |
445 | | } |
446 | | } |
447 | 0 | CriterionEvaluation::Manual(_) => 0, // Requires manual grading |
448 | 0 | CriterionEvaluation::Hybrid { auto_weight, .. } => { |
449 | 0 | (criterion.max_points as f32 * auto_weight) as u32 |
450 | | } |
451 | | } |
452 | 0 | } |
453 | | |
454 | 0 | fn measure_performance( |
455 | 0 | &self, |
456 | 0 | session: &ReplSession, |
457 | 0 | constraints: &PerformanceConstraints, |
458 | 0 | ) -> f32 { |
459 | 0 | let mut score: f32 = 100.0; |
460 | | |
461 | | // Check CPU time |
462 | 0 | let total_cpu_ns: u64 = session.timeline.iter() |
463 | 0 | .filter_map(|e| { |
464 | 0 | if let Event::ResourceUsage { cpu_ns, .. } = &e.event { |
465 | 0 | Some(*cpu_ns) |
466 | | } else { |
467 | 0 | None |
468 | | } |
469 | 0 | }) |
470 | 0 | .sum(); |
471 | | |
472 | 0 | let cpu_ms = total_cpu_ns / 1_000_000; |
473 | 0 | if cpu_ms > constraints.max_cpu_ms { |
474 | 0 | score -= 20.0; |
475 | 0 | } |
476 | | |
477 | | // Check heap usage |
478 | 0 | let max_heap: usize = session.timeline.iter() |
479 | 0 | .filter_map(|e| { |
480 | 0 | if let Event::ResourceUsage { heap_bytes, .. } = &e.event { |
481 | 0 | Some(*heap_bytes) |
482 | | } else { |
483 | 0 | None |
484 | | } |
485 | 0 | }) |
486 | 0 | .max() |
487 | 0 | .unwrap_or(0); |
488 | | |
489 | 0 | let heap_mb = max_heap / (1024 * 1024); |
490 | 0 | if heap_mb > constraints.max_heap_mb { |
491 | 0 | score -= 20.0; |
492 | 0 | } |
493 | | |
494 | 0 | score.max(0.0).min(100.0) |
495 | 0 | } |
496 | | } |
497 | | |
498 | | // ============================================================================ |
499 | | // Plagiarism Detection |
500 | | // ============================================================================ |
501 | | |
502 | | /// AST-based plagiarism detection system |
503 | | pub struct PlagiarismDetector { |
504 | | known_submissions: Vec<AstFingerprint>, |
505 | | } |
506 | | |
507 | | /// Structural fingerprint of AST for comparison |
508 | | #[derive(Debug, Clone)] |
509 | | pub struct AstFingerprint { |
510 | | pub hash: String, |
511 | | pub structure: Vec<String>, |
512 | | pub complexity: usize, |
513 | | } |
514 | | |
515 | | impl Default for PlagiarismDetector { |
516 | 0 | fn default() -> Self { |
517 | 0 | Self::new() |
518 | 0 | } |
519 | | } |
520 | | |
521 | | impl PlagiarismDetector { |
522 | 0 | pub fn new() -> Self { |
523 | 0 | Self { |
524 | 0 | known_submissions: Vec::new(), |
525 | 0 | } |
526 | 0 | } |
527 | | |
528 | 0 | pub fn analyze(&self, submission: &ReplSession) -> f32 { |
529 | | // Generate fingerprint for submission |
530 | 0 | let fingerprint = self.generate_fingerprint(submission); |
531 | | |
532 | | // Compare against known submissions |
533 | 0 | for known in &self.known_submissions { |
534 | 0 | let similarity = self.compute_similarity(&fingerprint, known); |
535 | 0 | if similarity > 0.85 { |
536 | 0 | return 100.0 * (1.0 - similarity); // High similarity = low originality |
537 | 0 | } |
538 | | } |
539 | | |
540 | 0 | 100.0 // Full originality score |
541 | 0 | } |
542 | | |
543 | 0 | fn generate_fingerprint(&self, session: &ReplSession) -> AstFingerprint { |
544 | 0 | let mut hasher = Sha256::new(); |
545 | 0 | let mut structure = Vec::new(); |
546 | | |
547 | | // Extract structural patterns from code inputs |
548 | 0 | for event in &session.timeline { |
549 | 0 | if let Event::Input { text, .. } = &event.event { |
550 | 0 | hasher.update(text.as_bytes()); |
551 | 0 | structure.push(self.extract_structure(text)); |
552 | 0 | } |
553 | | } |
554 | | |
555 | 0 | AstFingerprint { |
556 | 0 | hash: format!("{:x}", hasher.finalize()), |
557 | 0 | structure, |
558 | 0 | complexity: session.timeline.len(), |
559 | 0 | } |
560 | 0 | } |
561 | | |
562 | 0 | fn extract_structure(&self, code: &str) -> String { |
563 | | // Simplified: extract function definitions and control flow |
564 | 0 | let mut patterns = Vec::new(); |
565 | | |
566 | 0 | if code.contains("fn ") || code.contains("fun ") { |
567 | 0 | patterns.push("FN"); |
568 | 0 | } |
569 | 0 | if code.contains("if ") { |
570 | 0 | patterns.push("IF"); |
571 | 0 | } |
572 | 0 | if code.contains("for ") || code.contains("while ") { |
573 | 0 | patterns.push("LOOP"); |
574 | 0 | } |
575 | 0 | if code.contains("match ") { |
576 | 0 | patterns.push("MATCH"); |
577 | 0 | } |
578 | | |
579 | 0 | patterns.join("-") |
580 | 0 | } |
581 | | |
582 | 0 | fn compute_similarity(&self, fp1: &AstFingerprint, fp2: &AstFingerprint) -> f32 { |
583 | 0 | if fp1.hash == fp2.hash { |
584 | 0 | return 1.0; // Identical |
585 | 0 | } |
586 | | |
587 | | // Compare structural patterns |
588 | 0 | let common: usize = fp1.structure.iter() |
589 | 0 | .zip(fp2.structure.iter()) |
590 | 0 | .filter(|(a, b)| a == b) |
591 | 0 | .count(); |
592 | | |
593 | 0 | let total = fp1.structure.len().max(fp2.structure.len()); |
594 | 0 | if total > 0 { |
595 | 0 | common as f32 / total as f32 |
596 | | } else { |
597 | 0 | 0.0 |
598 | | } |
599 | 0 | } |
600 | | } |
601 | | |
602 | | // ============================================================================ |
603 | | // Secure Sandbox |
604 | | // ============================================================================ |
605 | | |
606 | | /// Secure execution environment for untrusted code |
607 | | pub struct SecureSandbox { |
608 | | #[allow(dead_code)] |
609 | | resource_limits: ResourceLimits, |
610 | | } |
611 | | |
612 | | #[derive(Debug, Clone)] |
613 | | pub struct ResourceLimits { |
614 | | pub max_heap_mb: usize, |
615 | | pub max_stack_kb: usize, |
616 | | pub max_cpu_ms: u64, |
617 | | } |
618 | | |
619 | | impl Default for SecureSandbox { |
620 | 0 | fn default() -> Self { |
621 | 0 | Self::new() |
622 | 0 | } |
623 | | } |
624 | | |
625 | | impl SecureSandbox { |
626 | 0 | pub fn new() -> Self { |
627 | 0 | Self { |
628 | 0 | resource_limits: ResourceLimits { |
629 | 0 | max_heap_mb: 100, |
630 | 0 | max_stack_kb: 8192, |
631 | 0 | max_cpu_ms: 5000, |
632 | 0 | }, |
633 | 0 | } |
634 | 0 | } |
635 | | |
636 | 0 | pub fn create_isolated_repl(&self) -> Result<Repl> { |
637 | | // In production, would create actual sandboxed environment |
638 | | // For now, create regular REPL with resource tracking |
639 | 0 | Repl::new() |
640 | 0 | } |
641 | | } |
642 | | |
643 | | // ============================================================================ |
644 | | // Grade Report |
645 | | // ============================================================================ |
646 | | |
647 | | /// Complete grading report for a submission |
648 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
649 | | pub struct GradeReport { |
650 | | pub assignment_id: String, |
651 | | pub submission_time: String, |
652 | | pub task_grades: Vec<TaskGrade>, |
653 | | pub rubric_score: f32, |
654 | | pub performance_score: f32, |
655 | | pub originality_score: f32, |
656 | | pub final_grade: f32, |
657 | | pub feedback: Vec<String>, |
658 | | pub violations: Vec<String>, |
659 | | pub is_valid: bool, |
660 | | } |
661 | | |
662 | | impl GradeReport { |
663 | 0 | pub fn new(assignment_id: String) -> Self { |
664 | 0 | Self { |
665 | 0 | assignment_id, |
666 | 0 | submission_time: chrono::Utc::now().to_rfc3339(), |
667 | 0 | task_grades: Vec::new(), |
668 | 0 | rubric_score: 0.0, |
669 | 0 | performance_score: 100.0, |
670 | 0 | originality_score: 100.0, |
671 | 0 | final_grade: 0.0, |
672 | 0 | feedback: Vec::new(), |
673 | 0 | violations: Vec::new(), |
674 | 0 | is_valid: true, |
675 | 0 | } |
676 | 0 | } |
677 | | |
678 | 0 | pub fn mark_invalid(&mut self, reason: &str) { |
679 | 0 | self.is_valid = false; |
680 | 0 | self.violations.push(reason.to_string()); |
681 | 0 | self.final_grade = 0.0; |
682 | 0 | } |
683 | | |
684 | 0 | pub fn add_task_grade(&mut self, grade: TaskGrade) { |
685 | 0 | self.task_grades.push(grade); |
686 | 0 | } |
687 | | |
688 | 0 | pub fn calculate_final_grade(&mut self) { |
689 | 0 | if !self.is_valid { |
690 | 0 | self.final_grade = 0.0; |
691 | 0 | return; |
692 | 0 | } |
693 | | |
694 | | // Calculate task score |
695 | 0 | let task_score: f32 = if self.task_grades.is_empty() { |
696 | 0 | 0.0 |
697 | | } else { |
698 | 0 | let earned: u32 = self.task_grades.iter().map(|g| g.points_earned).sum(); |
699 | 0 | let possible: u32 = self.task_grades.iter().map(|g| g.points_possible).sum(); |
700 | 0 | if possible > 0 { |
701 | 0 | (earned as f32 / possible as f32) * 100.0 |
702 | | } else { |
703 | 0 | 0.0 |
704 | | } |
705 | | }; |
706 | | |
707 | | // Weighted average: 60% tasks, 20% rubric, 10% performance, 10% originality |
708 | 0 | self.final_grade = task_score * 0.6 |
709 | 0 | + self.rubric_score * 0.2 |
710 | 0 | + self.performance_score * 0.1 |
711 | 0 | + self.originality_score * 0.1; |
712 | | |
713 | | // Add feedback based on grade |
714 | 0 | if self.final_grade >= 90.0 { |
715 | 0 | self.feedback.push("Excellent work!".to_string()); |
716 | 0 | } else if self.final_grade >= 80.0 { |
717 | 0 | self.feedback.push("Good job!".to_string()); |
718 | 0 | } else if self.final_grade >= 70.0 { |
719 | 0 | self.feedback.push("Satisfactory work.".to_string()); |
720 | 0 | } else { |
721 | 0 | self.feedback.push("Needs improvement.".to_string()); |
722 | 0 | } |
723 | 0 | } |
724 | | } |
725 | | |
726 | | /// Grade for an individual task |
727 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
728 | | pub struct TaskGrade { |
729 | | pub task_id: String, |
730 | | pub points_earned: u32, |
731 | | pub points_possible: u32, |
732 | | pub test_results: Vec<(String, TestResult)>, |
733 | | pub hidden_results: Vec<(String, TestResult)>, |
734 | | pub requirements_met: HashSet<String>, |
735 | | } |
736 | | |
737 | | impl TaskGrade { |
738 | 0 | pub fn new(task_id: String) -> Self { |
739 | 0 | Self { |
740 | 0 | task_id, |
741 | 0 | points_earned: 0, |
742 | 0 | points_possible: 0, |
743 | 0 | test_results: Vec::new(), |
744 | 0 | hidden_results: Vec::new(), |
745 | 0 | requirements_met: HashSet::new(), |
746 | 0 | } |
747 | 0 | } |
748 | | |
749 | 0 | pub fn add_test_result(&mut self, input: String, result: TestResult) { |
750 | 0 | self.test_results.push((input, result)); |
751 | 0 | } |
752 | | |
753 | 0 | pub fn add_hidden_result(&mut self, input: String, result: TestResult) { |
754 | 0 | self.hidden_results.push((input, result)); |
755 | 0 | } |
756 | | |
757 | 0 | pub fn calculate_score(&mut self, max_points: u32) { |
758 | 0 | self.points_possible = max_points; |
759 | | |
760 | | // Sum points from test results |
761 | 0 | let test_points: u32 = self.test_results.iter() |
762 | 0 | .map(|(_, r)| r.points_earned) |
763 | 0 | .sum(); |
764 | 0 | let hidden_points: u32 = self.hidden_results.iter() |
765 | 0 | .map(|(_, r)| r.points_earned) |
766 | 0 | .sum(); |
767 | | |
768 | 0 | self.points_earned = (test_points + hidden_points).min(max_points); |
769 | 0 | } |
770 | | } |
771 | | |
772 | | /// Result of running a single test case |
773 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
774 | | pub struct TestResult { |
775 | | pub passed: bool, |
776 | | pub points_earned: u32, |
777 | | pub feedback: String, |
778 | | pub execution_time_ms: u64, |
779 | | } |
780 | | |
781 | | #[cfg(test)] |
782 | | mod tests { |
783 | | use super::*; |
784 | | |
785 | | #[test] |
786 | | fn test_grading_engine_creation() { |
787 | | let engine = GradingEngine::new(); |
788 | | assert!(engine.replay_validator.strict_mode); |
789 | | } |
790 | | |
791 | | #[test] |
792 | | fn test_grade_report() { |
793 | | let mut report = GradeReport::new("test_assignment".to_string()); |
794 | | assert!(report.is_valid); |
795 | | assert_eq!(report.final_grade, 0.0); |
796 | | |
797 | | report.mark_invalid("Test violation"); |
798 | | assert!(!report.is_valid); |
799 | | assert_eq!(report.violations.len(), 1); |
800 | | } |
801 | | |
802 | | #[test] |
803 | | fn test_plagiarism_detection() { |
804 | | let detector = PlagiarismDetector::new(); |
805 | | |
806 | | // Create mock session |
807 | | let session = ReplSession { |
808 | | version: crate::runtime::replay::SemVer::new(1, 0, 0), |
809 | | metadata: crate::runtime::replay::SessionMetadata { |
810 | | session_id: "test".to_string(), |
811 | | created_at: "2025-08-28T10:00:00Z".to_string(), |
812 | | ruchy_version: "1.23.0".to_string(), |
813 | | student_id: Some("student1".to_string()), |
814 | | assignment_id: Some("hw1".to_string()), |
815 | | tags: vec![], |
816 | | }, |
817 | | environment: crate::runtime::replay::Environment { |
818 | | seed: 42, |
819 | | feature_flags: vec![], |
820 | | resource_limits: crate::runtime::replay::ResourceLimits { |
821 | | heap_mb: 100, |
822 | | stack_kb: 8192, |
823 | | cpu_ms: 5000, |
824 | | }, |
825 | | }, |
826 | | timeline: vec![], |
827 | | checkpoints: std::collections::BTreeMap::new(), |
828 | | }; |
829 | | |
830 | | let score = detector.analyze(&session); |
831 | | assert_eq!(score, 100.0); // Empty session should have full originality |
832 | | } |
833 | | } |