Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions autograder/models/dataclass/structural_analysis_result.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Dict, Optional, TYPE_CHECKING
from dataclasses import dataclass, field
from typing import Dict, Optional, Set, TYPE_CHECKING

if TYPE_CHECKING:
from ast_grep_py import SgRoot
Expand All @@ -12,9 +12,11 @@ class StructuralAnalysisResult:
Attributes:
roots: A dictionary mapping filenames to their corresponding ast-grep root nodes.
If a file could not be parsed, the value is None.
changed_lines: Changed line numbers supplied for each parsed file.
available: Whether structural analysis infrastructure was available and attempted.
reason: Optional reason explaining why analysis was unavailable/skipped.
"""
roots: Dict[str, Optional['SgRoot']]
available: bool = True
reason: Optional[str] = None
changed_lines: Dict[str, Set[int]] = field(default_factory=dict)
27 changes: 25 additions & 2 deletions autograder/models/dataclass/submission.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,42 @@
from typing import Dict, Optional
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Set

from sandbox_manager.models.sandbox_models import Language


@dataclass
class SubmissionFile:
"""Represents a single file in a submission."""
filename: str
content: str
changed_lines: Optional[Set[int]] = None
metadata: Optional[Dict[str, Any]] = None

@property
def is_contribution_aware(self) -> bool:
"""Return whether changed-line information was supplied for this file."""
return self.changed_lines is not None


@dataclass
class EvaluationScope:
"""
Defines which files are the primary subject of an evaluation.

When present, pipeline analysis steps restrict their work to these files.
When absent, all submission files are treated equally.
"""

scoped_files: List[str]


@dataclass
class Submission:
"""Represents a student's submission for an assignment."""
username: str
user_id: int
assignment_id: int
submission_files: Dict[str,SubmissionFile]
submission_files: Dict[str, SubmissionFile]
language: Optional[Language] = None
locale: str = "en"
evaluation_scope: Optional[EvaluationScope] = None
6 changes: 6 additions & 0 deletions autograder/models/pipeline_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
if TYPE_CHECKING:
from autograder.models.abstract.template import Template
from autograder.models.criteria_tree import CriteriaTree
from autograder.models.dataclass.submission import EvaluationScope
from autograder.models.dataclass.focus import Focus
from autograder.models.dataclass.grade_step_result import GradeStepResult
from autograder.models.result_tree import ResultTree
Expand Down Expand Up @@ -50,6 +51,11 @@ def locale(self) -> str:
"""Returns the locale for this pipeline execution, sourced from the submission."""
return self.submission.locale

@property
def evaluation_scope(self) -> Optional["EvaluationScope"]:
"""Return the optional evaluation scope supplied with the submission."""
return self.submission.evaluation_scope

def add_step_result(self, step_result: StepResult) -> 'PipelineExecution':
"""
Adds a single StepResult to the list of execution results.
Expand Down
13 changes: 12 additions & 1 deletion autograder/services/grader/criteria_grader.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
SubjectNode,
TestNode,
)
from autograder.models.dataclass.submission import SubmissionFile
from autograder.models.dataclass.submission import EvaluationScope, SubmissionFile
from autograder.models.dataclass.test_result import TestResult
from autograder.models.result_tree import (
CategoryResultNode,
Expand All @@ -32,6 +32,7 @@ def __init__(
locale: str = "en",
pre_computed_results: Optional[Dict[str, TestResult]] = None,
structural_analysis=None,
evaluation_scope: Optional[EvaluationScope] = None,
):
self.logger = logging.getLogger("SubmissionGrader")
self.submission_files = submission_files
Expand All @@ -41,6 +42,7 @@ def __init__(
self.locale = locale
self.pre_computed_results = pre_computed_results
self.structural_analysis = structural_analysis
self.evaluation_scope = evaluation_scope

def __balance_nodes(
self,
Expand Down Expand Up @@ -147,6 +149,13 @@ def process_test(self, test: TestNode) -> TestResultNode:
if self.submission_language is not None
else config_submission_language
)
test_params.pop("evaluation_scope", None)
test_params.pop("file_metadata", None)

file_metadata = {
sub_file.filename: sub_file.metadata
for sub_file in file_target or []
}

test_result = test.test_function.execute(
files=file_target,
Expand All @@ -155,6 +164,8 @@ def process_test(self, test: TestNode) -> TestResultNode:
pre_computed_results=self.pre_computed_results,
structural_analysis=self.structural_analysis,
submission_language=effective_submission_language,
evaluation_scope=self.evaluation_scope,
file_metadata=file_metadata,
**test_params,
)
return TestResultNode(
Expand Down
4 changes: 3 additions & 1 deletion autograder/services/grader/grader_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Dict, Optional

from autograder.models.criteria_tree import CriteriaTree
from autograder.models.dataclass.submission import SubmissionFile
from autograder.models.dataclass.submission import EvaluationScope, SubmissionFile
from autograder.models.dataclass.test_result import TestResult
from autograder.models.result_tree import (
ResultTree,
Expand All @@ -28,6 +28,7 @@ def grade_from_tree(
locale: str = "en",
pre_computed_results: Optional[Dict[str, TestResult]] = None,
structural_analysis=None,
evaluation_scope: Optional[EvaluationScope] = None,
) -> ResultTree:
"""Traverse the generic built criteria tree to resolve inputs, grades and report to ResultTree."""
grader = SubmissionGrader(
Expand All @@ -38,6 +39,7 @@ def grade_from_tree(
locale=locale,
pre_computed_results=pre_computed_results,
structural_analysis=structural_analysis,
evaluation_scope=evaluation_scope,
)

base_result = grader.process_category(criteria_tree.base)
Expand Down
1 change: 1 addition & 0 deletions autograder/steps/grade_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def _execute(self, pipeline_exec: PipelineExecution) -> PipelineExecution:
locale=pipeline_exec.locale,
pre_computed_results=pre_computed_results,
structural_analysis=structural_analysis,
evaluation_scope=pipeline_exec.evaluation_scope,
)

# Create grading result
Expand Down
21 changes: 19 additions & 2 deletions autograder/steps/structural_analysis_step.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Dict, Optional
from typing import Dict, Optional, Set

from autograder.models.abstract.step import Step
from autograder.models.pipeline_execution import PipelineExecution
Expand Down Expand Up @@ -69,7 +69,17 @@ def _execute(self, pipeline_exec: PipelineExecution) -> PipelineExecution:
)

roots: Dict[str, Optional[SgRoot]] = {}
changed_lines: Dict[str, Set[int]] = {}
scoped_files = (
set(pipeline_exec.evaluation_scope.scoped_files)
if pipeline_exec.evaluation_scope is not None
else None
)

for filename, sub_file in submission.submission_files.items():
if scoped_files is not None and filename not in scoped_files:
continue

# Only parse files that likely contain code
if not self._is_code_file(filename):
continue
Expand All @@ -80,7 +90,14 @@ def _execute(self, pipeline_exec: PipelineExecution) -> PipelineExecution:
logger.warning("Failed to parse %s with ast-grep: %s", filename, str(e))
roots[filename] = None

result = StructuralAnalysisResult(roots=roots, available=True)
if sub_file.changed_lines is not None:
changed_lines[filename] = set(sub_file.changed_lines)

result = StructuralAnalysisResult(
roots=roots,
changed_lines=changed_lines,
available=True,
)
return pipeline_exec.add_step_result(StepResult.success(self.step_name, result))

def _map_language(self, language: Language) -> Optional[str]:
Expand Down
26 changes: 22 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,20 @@ Content-Type: application/json
"files": [
{
"filename": "main.py",
"content": "print('Hello World')"
"content": "print('Hello World')",
"changed_lines": [1],
"file_metadata": {
"change_status": "modified"
}
}
],
"evaluation_scope": {
"scoped_files": ["main.py"]
},
"language": "python",
"metadata": {
"attempt": 1
"attempt": 1,
"source_revision": "example-revision"
}
}
```
Expand All @@ -395,11 +403,21 @@ Content-Type: application/json
| `external_assignment_id` | string | ✓ | External assignment ID (must match an existing grading config) |
| `external_user_id` | string | ✓ | External user ID from your platform |
| `username` | string | ✓ | Username of the submitter |
| `files` | list[object] | ✓ | List of files with `filename` and `content` |
| `files` | list[object] | ✓ | Files with `filename`, full `content`, and optional evaluation context |
| `files[].changed_lines` | list[integer] | ✗ | One-indexed line numbers added or modified in the file |
| `files[].file_metadata` | object | ✗ | Opaque per-file context passed through to test functions |
| `evaluation_scope` | object | ✗ | Optional file scope for pipeline analysis |
| `evaluation_scope.scoped_files` | list[string] | ✓ when scope is present | Filenames that are the primary evaluation subject |
| `language` | string | ✗ | Language override (defaults to first language in config) |
| `metadata` | object | ✗ | Optional metadata to attach to the submission |
| `metadata` | object | ✗ | Passive submission-level provenance stored for audit/correlation; the pipeline never reads it |
| `baseline_result_tree` | object | ✗ | Serialised `result_tree` from a previous submission response to calculate a `ComparisonResult` |

`evaluation_scope` and `changed_lines` are typed pipeline inputs.
`file_metadata` is stored as `SubmissionFile.metadata` and remains opaque to
the pipeline, while top-level `metadata` is stored as `submission_metadata`
and is not passed into core grading. If `evaluation_scope` is omitted,
structural analysis continues to consider all eligible submitted files.

**Response (200 OK):**
```json
{
Expand Down
43 changes: 39 additions & 4 deletions docs/architecture/core_structures.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,18 +371,54 @@ class Submission:
username: str # Student identifier
user_id: int # Student ID
assignment_id: int # Assignment identifier
submission_files: Dict[str, SubmissionFile] # Uploaded files keyed by filename
submission_files: Dict[str, SubmissionFile] # Uploaded files keyed by filename
language: Optional[Language] = None # Programming language
locale: str = "en" # Feedback locale
evaluation_scope: Optional[EvaluationScope] = None
```

### Submission File

```python
class SubmissionFile:
filename: str # Name of file
content: str # File contents (text)
filename: str # Name of file
content: str # Full file contents (text)
changed_lines: Optional[Set[int]] = None # One-indexed added/modified lines
metadata: Optional[Dict[str, Any]] = None # Opaque per-file context
```

`SubmissionFile.is_contribution_aware` is `True` when `changed_lines` was
provided, including when the caller explicitly provides an empty set.

### Evaluation Scope

```python
class EvaluationScope:
scoped_files: List[str] # Files that are the primary evaluation subject
```

When a scope is present, scope-aware pipeline steps restrict their work to the
listed filenames. Currently, structural analysis uses the scope to avoid
parsing unrelated files. Omitting the scope preserves snapshot behavior and
analyzes all eligible submission files.

### Metadata Ownership

Evaluation context is deliberately split by responsibility:

| Field | Pipeline behavior | Intended use |
|-------|-------------------|--------------|
| `EvaluationScope.scoped_files` | Read by scope-aware steps | Select files that analysis should focus on |
| `SubmissionFile.changed_lines` | Read by structural analysis and available to tests | Identify added or modified lines |
| `SubmissionFile.metadata` | Never interpreted; forwarded to test functions | Carry arbitrary per-file context |
| API `metadata` / database `submission_metadata` | Never read by the core pipeline | Preserve submission-level provenance and audit context |

Platform- or domain-specific data belongs in the open-ended metadata fields,
not in new typed core model fields. Test functions receive `evaluation_scope`
and a filename-keyed `file_metadata` mapping through `execute(**kwargs)`. They
also receive the original `SubmissionFile` objects, including `changed_lines`
and `metadata`, through the `files` argument.

## Template

Template provides test functions for a specific assignment type.
Expand Down Expand Up @@ -412,4 +448,3 @@ class TestFunction:
"""Execute the test and return result"""
pass
```

18 changes: 12 additions & 6 deletions docs/pipeline/04.8-structural-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ The Structural Analysis step parses student submission files into Abstract Synta
## How It Works

1. **Detect Language** — The step identifies the submission language (Python, Java, Node.js, C++, or C).
2. **Heuristic File Filtering** — It scans the submission files and identifies those likely to contain source code, skipping binary files, images, and non-code configurations (e.g., `.png`, `.json`, `.yaml`, `.md`).
3. **Parse ASTs** — For each identified code file, it uses `ast-grep-py` to parse the content into an `SgRoot` object.
4. **Store Results** — The resulting mapping of `Dict[filename, SgRoot]` is stored in `StepResult.data` under `StepName.STRUCTURAL_ANALYSIS`.
2. **Apply Evaluation Scope** — When the submission includes an `EvaluationScope`, only filenames in `scoped_files` are considered. Without a scope, all files are considered for backward compatibility.
3. **Heuristic File Filtering** — It identifies files likely to contain source code, skipping binary files, images, and non-code configurations (e.g., `.png`, `.json`, `.yaml`, `.md`).
4. **Parse ASTs** — For each identified code file, it uses `ast-grep-py` to parse the content into an `SgRoot` object.
5. **Store Results** — Parsed roots and any caller-supplied changed-line sets are stored in `StructuralAnalysisResult`.

If `ast-grep-py` is not installed or the language is not supported, the step logs a warning and proceeds with an empty result set, allowing the pipeline to continue.

Expand All @@ -23,24 +24,29 @@ If `ast-grep-py` is not installed or the language is not supported, the step log

| Source | Data |
|--------|------|
| Pipeline | `pipeline_exec.submission` → submission files and language |
| Pipeline | `pipeline_exec.submission` → submission files, language, and optional evaluation scope |

## Output

| Field | Type | Description |
|-------|------|-------------|
| `data` | `StructuralAnalysisResult` | Contains a mapping of filenames to `ast-grep` `SgRoot` objects |
| `data` | `StructuralAnalysisResult` | Contains filename-keyed `roots` and `changed_lines` mappings |
| `status` | `StepStatus.SUCCESS` | Usually succeeds even if parsing fails for some files (stores `None` for those files) |

## How It Integrates with Grade

The `GradeStep` reads the `STRUCTURAL_ANALYSIS` step result from the pipeline and passes it as `structural_analysis` to `GraderService.grade_from_tree()`. The grader threads this object through the tree traversal into every `process_test()` call, which forwards it as a kwarg to `test_function.execute()`.

Test functions like `ForbiddenKeywordTest` can then use this pre-computed AST to perform efficient and accurate queries using `ast-grep`'s pattern matching syntax.
Test functions like `ForbiddenKeywordTest` can then use this pre-computed AST
to perform efficient queries using `ast-grep`'s pattern matching syntax.
Contribution-aware tests can use `StructuralAnalysisResult.changed_lines` to
limit findings to added or modified lines.

## Key Design Decisions

- **Pre-computed ASTs** — Parsing is done once at the pipeline level rather than inside individual tests to ensure efficiency when multiple structural tests are defined.
- **Optional scope** — `EvaluationScope` selects the files to parse without introducing platform-specific concepts into the core engine. An explicit empty scope parses no files.
- **Changed-line passthrough** — Changed lines are typed because pipeline and test implementations can act on them; opaque domain context remains in `SubmissionFile.metadata`.
- **Fail-safe** — If parsing fails for a specific file (e.g., due to syntax errors), the step stores `None` for that file instead of failing the entire pipeline.
- **Library Choice** — `ast-grep` was chosen for its performance (Rust-based) and its ability to provide high-level, language-agnostic pattern matching queries.

Expand Down
Loading
Loading