|
| 1 | +# Grading Engine |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The grading engine is the core subsystem behind the [Grade pipeline step](../pipeline/04-grade.md). It takes a `CriteriaTree` (the rubric) and a student submission, executes every test function in the tree, and produces a `ResultTree` — a scored mirror of the criteria tree with actual results, reports, and aggregated scores at every level. |
| 6 | + |
| 7 | +This document covers the engine's internal mechanics: how it traverses the tree, executes tests, resolves files, handles weights, and calculates scores. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Tree Traversal |
| 12 | + |
| 13 | +The `GraderService.grade_from_tree()` method is the entry point. It processes the criteria tree top-down: |
| 14 | + |
| 15 | +``` |
| 16 | +CriteriaTree |
| 17 | +├── base (CategoryNode) ──▶ process_category() ──▶ CategoryResultNode |
| 18 | +├── bonus (CategoryNode) ──▶ process_category() ──▶ CategoryResultNode |
| 19 | +└── penalty (CategoryNode) ──▶ process_category() ──▶ CategoryResultNode |
| 20 | +``` |
| 21 | + |
| 22 | +Each `process_category()` call delegates to `__process_holder()`, a generic method that handles both `CategoryNode` and `SubjectNode` since they share the same structure (subjects + tests + optional `subjects_weight`). |
| 23 | + |
| 24 | +For each holder node: |
| 25 | +1. Process all child **subjects** recursively → list of `SubjectResultNode` |
| 26 | +2. Process all child **tests** → list of `TestResultNode` |
| 27 | +3. Balance weights for each group |
| 28 | +4. Return the corresponding result node |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## Test Execution |
| 33 | + |
| 34 | +When the engine reaches a `TestNode`, it calls `process_test()`: |
| 35 | + |
| 36 | +```python |
| 37 | +def process_test(self, test: TestNode) -> TestResultNode: |
| 38 | + file_target = self.get_file_target(test) |
| 39 | + test_params = test.parameters or {} |
| 40 | + if self._submission_language: |
| 41 | + test_params['__submission_language__'] = self._submission_language |
| 42 | + |
| 43 | + test_result = test.test_function.execute( |
| 44 | + files=file_target, sandbox=self._sandbox, **test_params |
| 45 | + ) |
| 46 | + return TestResultNode( |
| 47 | + name=test.name, |
| 48 | + test_node=test, |
| 49 | + score=test_result.score, |
| 50 | + report=test_result.report, |
| 51 | + parameters=test_result.parameters, |
| 52 | + ) |
| 53 | +``` |
| 54 | + |
| 55 | +Key details: |
| 56 | +- **File targeting**: If the `TestNode` specifies a `file_target`, only matching submission files are passed to the test function. Otherwise, `None` is passed and the test operates on the sandbox or all files. |
| 57 | +- **Language injection**: The submission language is injected as `__submission_language__` into the test parameters, enabling multi-language command resolution inside test functions (e.g., choosing `python3 main.py` vs `java Main`). |
| 58 | +- **Sandbox**: The sandbox container (if any) is passed to every test function. Templates that require sandbox execution (like `input_output`) use it to run student code. |
| 59 | + |
| 60 | +Each `TestFunction.execute()` returns a `TestResult` with: |
| 61 | +- `score` (0–100) |
| 62 | +- `report` (human-readable explanation) |
| 63 | +- `parameters` (optional, echoed back for transparency) |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Weight Balancing |
| 68 | + |
| 69 | +Weights determine how much each node contributes to its parent's score. The engine enforces that sibling weights always sum to 100 at every level. |
| 70 | + |
| 71 | +### Sibling Balancing |
| 72 | + |
| 73 | +The `__balance_nodes()` method normalizes weights: |
| 74 | + |
| 75 | +```python |
| 76 | +def __balance_nodes(self, nodes, factor): |
| 77 | + total_weight = sum(node.weight for node in nodes) * factor |
| 78 | + if total_weight == 0: |
| 79 | + equal_weight = 100.0 / len(nodes) |
| 80 | + for node in nodes: |
| 81 | + node.weight = equal_weight |
| 82 | + elif total_weight != 100: |
| 83 | + scale_factor = 100.0 / total_weight |
| 84 | + for node in nodes: |
| 85 | + node.weight *= scale_factor |
| 86 | +``` |
| 87 | + |
| 88 | +- If all weights are zero, they're distributed equally. |
| 89 | +- If they don't sum to 100 (after applying the factor), they're scaled proportionally. |
| 90 | + |
| 91 | +### Subject/Test Split |
| 92 | + |
| 93 | +When a node contains both subjects and direct tests, the `subjects_weight` field determines the split: |
| 94 | + |
| 95 | +``` |
| 96 | +subjects_weight = 70 |
| 97 | +├── Subjects group gets factor = 0.70 |
| 98 | +└── Tests group gets factor = 0.30 |
| 99 | +``` |
| 100 | + |
| 101 | +Each group's weights are balanced independently within their respective factor. This means subjects compete with subjects, and tests compete with tests, with the `subjects_weight` controlling the ratio between the two groups. |
| 102 | + |
| 103 | +If a node has only subjects or only tests, the factor is 1.0 (no split needed). |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +## Score Calculation |
| 108 | + |
| 109 | +After the entire tree is processed, `ResultTree.calculate_final_score()` aggregates scores bottom-up through the `RootResultNode`: |
| 110 | + |
| 111 | +``` |
| 112 | +final_score = base_score + bonus_contribution - penalty_deduction |
| 113 | +``` |
| 114 | + |
| 115 | +At each level, the score is a weighted average: |
| 116 | + |
| 117 | +```python |
| 118 | +# For a subject or category: |
| 119 | +score = sum(child.score * child.weight / 100 for child in children) |
| 120 | +``` |
| 121 | + |
| 122 | +The `RootResultNode.calculate_score()` combines the three categories: |
| 123 | +- **Base**: The primary score (0–100 scale, weighted by `base.weight`) |
| 124 | +- **Bonus**: Added on top (e.g., weight=10 means up to 10 extra points) |
| 125 | +- **Penalty**: Subtracted (e.g., weight=-20 means up to 20 points deducted) |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## File Targeting |
| 130 | + |
| 131 | +The `get_file_target()` method resolves which submission files a test should receive: |
| 132 | + |
| 133 | +```python |
| 134 | +def get_file_target(self, test_node: TestNode): |
| 135 | + if not test_node.file_target or not self.__submission_files: |
| 136 | + return None |
| 137 | + target_files = [] |
| 138 | + for file_name in self.__submission_files: |
| 139 | + if file_name in test_node.file_target: |
| 140 | + target_files.append(self.__submission_files[file_name]) |
| 141 | + return target_files |
| 142 | +``` |
| 143 | + |
| 144 | +This allows tests to operate on specific files (e.g., a `has_tag` test targeting only `index.html`) rather than the entire submission. If no `file_target` is specified, the test receives `None` and is expected to work with the sandbox or handle files internally. |
| 145 | + |
| 146 | +--- |
| 147 | + |
| 148 | +## AI Executor Integration |
| 149 | + |
| 150 | +Some templates use an `AiExecutor` for batch AI-powered test execution. After the tree traversal completes, the engine checks if the first test has an attached executor and calls `executor.stop()` to flush any pending AI batch requests: |
| 151 | + |
| 152 | +```python |
| 153 | +first_test = self.__find_first_test(criteria_tree.base) |
| 154 | +if first_test and hasattr(first_test, "test_function"): |
| 155 | + test_func = first_test.test_function |
| 156 | + if hasattr(test_func, "executor") and test_func.executor: |
| 157 | + test_func.executor.stop() |
| 158 | +``` |
| 159 | + |
| 160 | +This is a post-processing step that ensures all AI-batched results are resolved before the `ResultTree` is returned. |
| 161 | + |
| 162 | +--- |
| 163 | + |
| 164 | +## Result Tree Structure |
| 165 | + |
| 166 | +The output `ResultTree` mirrors the `CriteriaTree` but with actual scores: |
| 167 | + |
| 168 | +``` |
| 169 | +ResultTree |
| 170 | +├── root: RootResultNode |
| 171 | +│ ├── base: CategoryResultNode |
| 172 | +│ │ ├── subjects: [SubjectResultNode, ...] |
| 173 | +│ │ │ ├── tests: [TestResultNode, ...] |
| 174 | +│ │ │ └── subjects: [SubjectResultNode, ...] |
| 175 | +│ │ └── tests: [TestResultNode, ...] |
| 176 | +│ ├── bonus: CategoryResultNode (optional) |
| 177 | +│ └── penalty: CategoryResultNode (optional) |
| 178 | +├── template_name: str (optional) |
| 179 | +└── metadata: dict |
| 180 | +``` |
| 181 | + |
| 182 | +Each `TestResultNode` contains: |
| 183 | +- `name` — Test identifier |
| 184 | +- `score` — Achieved score (0–100) |
| 185 | +- `report` — Human-readable result explanation |
| 186 | +- `parameters` — Test parameters used |
| 187 | +- `test_node` — Reference back to the original `TestNode` |
| 188 | + |
| 189 | +The `ResultTree` also provides utility methods: |
| 190 | +- `get_all_test_results()` — Flat list of all test result nodes |
| 191 | +- `get_failed_tests()` — Tests with score < 100 |
| 192 | +- `get_passed_tests()` — Tests with score = 100 |
| 193 | +- `to_dict()` — Full serialization with summary statistics |
| 194 | + |
| 195 | +--- |
| 196 | + |
| 197 | +## Source Files |
| 198 | + |
| 199 | +| File | Contents | |
| 200 | +|------|----------| |
| 201 | +| `autograder/services/grader_service.py` | `GraderService` — tree traversal, test execution, weight balancing | |
| 202 | +| `autograder/models/result_tree.py` | `ResultTree`, `RootResultNode`, `CategoryResultNode`, `SubjectResultNode`, `TestResultNode` | |
| 203 | +| `autograder/models/criteria_tree.py` | `CriteriaTree`, `CategoryNode`, `SubjectNode`, `TestNode` | |
| 204 | +| `autograder/models/dataclass/grade_step_result.py` | `GradeStepResult` — wrapper for final score + result tree | |
| 205 | +| `autograder/models/dataclass/test_result.py` | `TestResult` — individual test execution output | |
| 206 | +| `autograder/services/command_resolver.py` | `CommandResolver` — multi-language command resolution | |
0 commit comments