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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ scriptmark grade submissions/ -t tests/ -r roster.csv --db grades.db -a archive/
# Run tests only (raw JSON output)
scriptmark run submissions/ -t tests/ -o results.json

# Preview student/file/function matching; edit assignment.toml to resolve candidates
scriptmark match submissions/ -t tests/ -o output/matches.json

# Inputs and oracle answers are frozen beside the results (output/results.cases.json);
# grade late submissions on the same tests and answers, into results of their own
scriptmark grade late/ -t tests/ --replay output/results.cases.json -o output/late.json
Expand Down Expand Up @@ -172,6 +175,11 @@ print(spec.name, spec.function, spec.num_cases)

## TOML Test Specs

Teachers can configure student ownership, item file patterns, function aliases and
per-student overrides in `assignment.toml`. Preview decisions and candidates with
`scriptmark match`; unresolved conflicts withhold grades. See
[matching rules](https://github.com/Acture/obsidian-vault/blob/project/scriptmark/scriptmark/docs/matching.md).

```toml
[meta]
name = "find_max"
Expand Down
39 changes: 33 additions & 6 deletions crates/scriptmark-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,26 +288,32 @@ impl Freezing<'_> {
/// `freeze` writes generated inputs and oracle answers; `replay` verifies and reuses
/// a frozen bundle without recomputing its answers.
#[pyfunction]
#[pyo3(signature = (submissions, tests, *, timeout=10, python="python3", freeze=None, replay=None))]
#[pyo3(signature = (submissions, tests, *, timeout=10, python="python3", assignment=None, freeze=None, replay=None))]
fn run(
py: Python<'_>,
submissions: Vec<String>,
tests: String,
timeout: u64,
python: &str,
assignment: Option<String>,
freeze: Option<String>,
replay: Option<String>,
) -> PyResult<PyObject> {
let specs = load_specs_from_dir(Path::new(&tests)).map_err(spec_error)?;
let mut declared =
scriptmark::assignment::load(assignment.as_deref().map(Path::new), Path::new(&tests))
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
scriptmark::assignment::settle(&mut declared.assignment, &declared.grading, &specs)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
let freezing = Freezing {
freeze: freeze.as_deref(),
replay: replay.as_deref(),
};
let (results, inputs) = run_grading(&submissions, specs, timeout, python, &freezing)?;
let (results, inputs) =
run_grading(&submissions, specs, timeout, python, &freezing, &declared)?;
freezing.save(&inputs)?;
let json_val = serde_json::to_value(&results)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
json_to_py(py, &json_val)
Python::with_gil(|py| json_to_py(py, &json_val))
}

/// Grade all students: run the tests, then score each item under the assignment's
Expand Down Expand Up @@ -341,7 +347,8 @@ fn grade(
freeze: freeze.as_deref(),
replay: replay.as_deref(),
};
let (mut reports, inputs) = run_grading(&submissions, specs, timeout, python, &freezing)?;
let (mut reports, inputs) =
run_grading(&submissions, specs, timeout, python, &freezing, &declared)?;
grade_all(&mut reports, &declared.assignment.items, &policy).map_err(value_error)?;
freezing.save(&inputs)?;

Expand All @@ -364,7 +371,12 @@ fn run_grading(
timeout: u64,
python: &str,
freezing: &Freezing,
declared: &scriptmark::assignment::Declared,
) -> PyResult<(Vec<StudentReport>, Frozen)> {
declared
.matching
.validate(&specs)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
let generation = match freezing.replay {
Some(path) => Generation::Replay(Frozen::load(Path::new(path)).map_err(frozen_error)?),
None => Generation::fresh(),
Expand All @@ -381,10 +393,25 @@ fn run_grading(
frozen::writable(Path::new(path))
.map_err(|e| pyo3::exceptions::PyOSError::new_err(format!("{path}: {e}")))?;
}
let input = local_input(submissions)?;
let paths: Vec<&Path> = submissions.iter().map(|p| Path::new(p.as_str())).collect();
let input = load_local_input(
&paths,
LocalInputOptions {
assignment: declared.assignment.clone(),
attempt_policy: declared.attempt_policy,
matching: Some(&declared.matching),
..Default::default()
},
)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
let errors: Vec<String> = input.errors().map(ToString::to_string).collect();
if !errors.is_empty() {
return Err(pyo3::exceptions::PyValueError::new_err(errors.join("; ")));
}

let executor = Arc::new(PythonExecutor::with_python_cmd(python));
let options = RunOptions {
matching: declared.matching.clone(),
concurrency: None,
python: executor.python_cmd().to_string(),
};
Expand Down
3 changes: 3 additions & 0 deletions crates/scriptmark/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub struct Declared {
pub assignment: Assignment,
pub attempt_policy: AttemptPolicy,
pub grading: GradingConfig,
pub matching: crate::matching::Config,
/// Where it was read from; `None` when there was no file.
pub path: Option<PathBuf>,
}
Expand Down Expand Up @@ -44,6 +45,7 @@ pub fn load(explicit: Option<&Path>, tests_dir: &Path) -> Result<Declared> {
assignment: Assignment::named(name),
attempt_policy: AttemptPolicy::default(),
grading: GradingConfig::default(),
matching: crate::matching::Config::default(),
path: None,
});
};
Expand All @@ -59,6 +61,7 @@ pub fn load(explicit: Option<&Path>, tests_dir: &Path) -> Result<Declared> {
},
attempt_policy: config.assignment.attempt_policy,
grading: config.grading,
matching: config.matching,
path: Some(path),
})
}
Expand Down
111 changes: 89 additions & 22 deletions crates/scriptmark/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub(crate) fn detect_language(ext: &str) -> Option<&'static str> {
/// Extract a student key from a filename.
///
/// Convention: `{key}_{rest}.ext` (e.g. `alice_Lab5_1.py` → `alice`). This split is a
/// placeholder — teacher-configurable matching is P-673 — so the key it yields is only
/// source default; the key it yields is only
/// ever an unconfirmed [`crate::models::StudentKey::Extracted`] until a roster vouches
/// for it.
fn extract_sid(filename: &str) -> Option<String> {
Expand Down Expand Up @@ -112,6 +112,7 @@ pub struct LocalInputOptions<'a> {
/// The roster of record. Without one, membership is unknown rather than negative.
pub roster: Option<&'a Roster>,
pub attempt_policy: AttemptPolicy,
pub matching: Option<&'a crate::matching::Config>,
}

impl Default for LocalInputOptions<'_> {
Expand All @@ -120,6 +121,7 @@ impl Default for LocalInputOptions<'_> {
assignment: Assignment::default(),
roster: None,
attempt_policy: AttemptPolicy::Latest,
matching: None,
}
}
}
Expand All @@ -139,6 +141,11 @@ pub fn load_local_input(
let mut by_key: BTreeMap<String, Vec<StudentFile>> = BTreeMap::new();
// A student who sent only unusable files still submitted something.
let mut seen_keys: std::collections::BTreeSet<String> = Default::default();
let default_matching = crate::matching::Config::default();
let matching = options.matching.unwrap_or(&default_matching);
matching
.validate_owners()
.map_err(|e| DiscoveryError::Matching(e.to_string()))?;

for path in paths {
let path = path.as_ref();
Expand Down Expand Up @@ -166,11 +173,54 @@ pub fn load_local_input(
}
}

let all_paths: Vec<PathBuf> = paths
let mut all_paths: Vec<PathBuf> = paths
.iter()
.map(|p| p.as_ref().to_path_buf())
.chain(extra_dirs)
.collect();
// Walk nested submissions; never revisit the extraction cache or directory symlinks.
let mut index = 0;
while index < all_paths.len() {
let dir = &all_paths[index];
let mut children = Vec::new();
for entry in std::fs::read_dir(dir).map_err(|e| DiscoveryError::IoError(dir.clone(), e))? {
let entry = entry.map_err(|e| DiscoveryError::IoError(dir.clone(), e))?;
if entry
.file_type()
.map_err(|e| DiscoveryError::IoError(dir.clone(), e))?
.is_dir() && !crate::archive::is_noise(&entry.file_name().to_string_lossy())
&& entry.file_name() != EXTRACT_DIR
{
children.push(entry.path());
}
}
children.sort();
for child in children {
if !all_paths.contains(&child) {
if !child
.components()
.any(|part| part.as_os_str() == EXTRACT_DIR)
{
for extracted in extract_archives(&child, &mut diagnostics) {
if let Some(parent) = extracted.out_path.parent()
&& !all_paths.contains(&parent.to_path_buf())
{
all_paths.push(parent.to_path_buf());
}
origins.insert(
extracted.out_path,
FileOrigin::Archive {
archive: extracted.archive,
entry: extracted.entry,
},
);
}
}
all_paths.push(child);
}
}
index += 1;
}

for dir_path in &all_paths {
let entries = std::fs::read_dir(dir_path)
Expand Down Expand Up @@ -219,11 +269,39 @@ pub fn load_local_input(
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
// Archives are inputs to extraction, not submissions in their own right — but
// the upload still happened. Registering the owner here is what stops a
// truncated or empty archive being reported as 缺交.
// Inside an extraction directory the archive stem carries the key; the files
// within it are named by the student.
let default_key = if let Some(FileOrigin::Archive { archive, .. }) = origins.get(&path)
{
archive
.file_name()
.and_then(|n| n.to_str())
.and_then(extract_sid)
} else {
extract_sid(filename)
};
let owner_path = match origins.get(&path) {
Some(FileOrigin::Archive { archive, .. }) => archive,
_ => &path,
};
let relative = paths
.iter()
.filter_map(|root| owner_path.strip_prefix(root.as_ref()).ok())
.min_by_key(|p| p.components().count())
.unwrap_or(owner_path);
let owner = matching
.owner(relative, owner_path, default_key)
.map_err(|e| DiscoveryError::Matching(e.to_string()))?;
if owner.state == crate::matching::State::Ambiguous {
diagnostics.push(InputDiagnostic::error(DiagnosticKind::AmbiguousOwner {
path: path.clone(),
decision: owner.clone(),
}));
}
let key = owner.selected.clone();
// Even an empty/unreadable archive records receipt under the teacher's owner rule.
if !is_extracted && crate::archive::format_of(&path).is_some() {
match extract_sid(filename) {
match key {
Some(key) => {
seen_keys.insert(key);
}
Expand All @@ -235,28 +313,15 @@ pub fn load_local_input(
continue;
}

// Inside an extraction directory the archive stem carries the key; the files
// within it are named by the student.
let key = if is_extracted {
dir_path
.file_name()
.and_then(|n| n.to_str())
.and_then(extract_sid)
.or_else(|| extract_sid(filename))
} else {
extract_sid(filename)
};

let language = detect_language(&ext);

match (key, language) {
(Some(key), Some(language)) => {
seen_keys.insert(key.clone());
let origin = origins.get(&path).cloned().unwrap_or(FileOrigin::Direct);
by_key
.entry(key)
.or_default()
.push(StudentFile::direct(path.clone(), language).with_origin(origin));
let mut file = StudentFile::direct(path.clone(), language).with_origin(origin);
file.owner = Some(owner);
by_key.entry(key).or_default().push(file);
}
(Some(key), None) => {
// Owner known, type unusable: the student submitted, just not code.
Expand Down Expand Up @@ -375,6 +440,8 @@ pub fn load_local_input(

#[derive(Debug, thiserror::Error)]
pub enum DiscoveryError {
#[error("invalid matching rules: {0}")]
Matching(String),
#[error("not a directory: {0}")]
NotADirectory(std::path::PathBuf),
#[error("IO error reading {0}: {1}")]
Expand Down
1 change: 1 addition & 0 deletions crates/scriptmark/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod discovery;
pub mod export;
pub mod grading;
pub mod input;
pub mod matching;
pub mod roster;
pub mod similarity;
pub mod spec_loader;
Expand Down
Loading
Loading