Skip to content

Commit e954367

Browse files
authored
Improving sanity checks (#420)
* The checker must now output printable UTF-8 * fixed grader/stub difference in attachments * Added warning for st checks on samples * Removed st ordering check * clippy and format * fixed tests
1 parent 999dea5 commit e954367

7 files changed

Lines changed: 111 additions & 177 deletions

File tree

task-maker-format/src/ioi/dag/checker.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ impl Checker {
9696
group.priority = EVALUATION_PRIORITY - testcase_id.unwrap_or_default() as Priority;
9797
let sender = eval.sender.clone();
9898
eval.dag.on_execution_done(&group.uuid, move |results| {
99-
let res = &results[0];
99+
let res = &results[0];
100100
let stdout = res
101101
.stdout
102102
.as_ref()
@@ -105,8 +105,6 @@ impl Checker {
105105
.stderr
106106
.as_ref()
107107
.ok_or_else(|| anyhow!("Checker stderr not captured"))?;
108-
let message = String::from_utf8_lossy(stderr).trim().to_string();
109-
let message = Self::translate_checker_message(message);
110108
if !res.status.is_success() {
111109
let message = if let Some(testcase_id) = testcase_id {
112110
format!(
@@ -122,9 +120,30 @@ impl Checker {
122120
sender.add_diagnostic(diagnostic)?;
123121
return Ok(());
124122
}
125-
let score = String::from_utf8_lossy(stdout);
126-
let score: f64 = match score.trim().parse() {
127-
Ok(score) => score,
123+
let message = str::from_utf8(stderr)
124+
.map(|s| Self::translate_checker_message(s.trim().to_string()));
125+
let score = str::from_utf8(stdout)
126+
.map(|s| s.trim().parse::<f64>());
127+
128+
let Ok(score) = score else {
129+
let message = "The checked return a non UTF-8 score".to_string();
130+
let diagnostic = Diagnostic::error(message)
131+
.with_note(description)
132+
.with_help_attachment(stdout.clone());
133+
sender.add_diagnostic(diagnostic)?;
134+
return Ok(());
135+
};
136+
let Ok(message) = message else {
137+
let message = "The checked return a non UTF-8 message".to_string();
138+
let diagnostic = Diagnostic::error(message)
139+
.with_note(description)
140+
.with_help_attachment(stdout.clone());
141+
sender.add_diagnostic(diagnostic)?;
142+
return Ok(());
143+
};
144+
145+
let score: f64 = match &score {
146+
Ok(score) => *score,
128147
Err(e) => {
129148
let message = if let Some(testcase_id) = testcase_id {
130149
format!(
@@ -141,6 +160,16 @@ impl Checker {
141160
return Ok(());
142161
}
143162
};
163+
164+
if !Self::is_unicode_printable(&message) {
165+
let message = format!("The checked return a non printable message ({message})");
166+
let diagnostic = Diagnostic::error(message)
167+
.with_note(description)
168+
.with_help_attachment(stdout.clone());
169+
sender.add_diagnostic(diagnostic)?;
170+
return Ok(());
171+
}
172+
144173
callback(score, message)
145174
});
146175
Ok(group)
@@ -195,6 +224,11 @@ impl Checker {
195224
Ok(())
196225
}
197226

227+
/// Checks that a string is a printable message without control characters.
228+
fn is_unicode_printable(s: &str) -> bool {
229+
s.chars().all(|c| !c.is_control() || c.is_whitespace())
230+
}
231+
198232
/// The checker may return a message to be translated. This function maps the message
199233
/// placeholders to actual messages.
200234
pub fn translate_checker_message(message: String) -> String {

task-maker-format/src/ioi/sanity_checks/att.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::collections::{BTreeSet, HashMap};
2+
use std::ffi::OsStr;
23
use std::io::{Read, Seek, SeekFrom};
34
use std::path::PathBuf;
45
use std::sync::{Arc, Mutex};
@@ -552,11 +553,8 @@ impl SanityCheck for AttTemplatesShouldCompile {
552553
let att_name = format!("att/{}.{}", task.name, ext);
553554
let template = task.path.join(&att_name);
554555

555-
let grader_name = grader
556-
.file_name()
557-
.ok_or_else(|| anyhow!("Grader has no file name"))?
558-
.to_string_lossy();
559-
let att_grader_name = format!("att/{grader_name}");
556+
let grader_ext = grader.extension().and_then(OsStr::to_str).unwrap_or("");
557+
let att_grader_name = format!("att/grader.{grader_ext}",);
560558
let att_grader = task.path.join(&att_grader_name);
561559

562560
// Only run the check if the grader is not a symlink, as otherwise we are already

task-maker-format/src/ioi/sanity_checks/checker.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,13 @@ impl SanityCheck for FuzzCheckerWithJunkOutput {
8181
test_output_uuid,
8282
move |score, outcome| {
8383
if score != 0.0 {
84-
sender.add_diagnostic(Diagnostic::error(format!(
85-
"Junk file '{description}' scored {score} (with message '{outcome}')"
86-
)))?;
84+
sender.add_diagnostic(
85+
Diagnostic::warning(format!(
86+
"Junk file '{description}' scored {score} (with message '{outcome}')"
87+
)).with_note(
88+
"This almost certainly indicates a bug in the checker, unless it's intended that such a file scores points."
89+
)
90+
)?;
8791
}
8892
Ok(())
8993
},

task-maker-format/src/ioi/sanity_checks/mod.rs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,27 +45,20 @@ fn check_missing_graders<P: AsRef<Path>>(
4545
if !has_grader(task) {
4646
return Ok(());
4747
}
48-
// some task formats use stub.* others use grader.*
49-
// To avoid confusion emit warnings only either for stubs or graders.
50-
let is_stub = task
51-
.grader_map
52-
.all_paths()
53-
.filter_map(|p| p.file_stem())
54-
.any(|p| p == "stub");
5548
let mut by_ext = HashMap::new();
5649
for file in list_files(task.path.join(folder.as_ref()), vec!["*.*"]) {
5750
let file = task.path_of(&file);
58-
let stem = match file.file_stem() {
59-
Some(stem) => stem,
60-
None => continue,
51+
let Some(stem) = file.file_stem() else {
52+
continue;
6153
};
62-
// do not check the graders
63-
if stem == "grader" || stem == "stub" {
54+
// Only check if the file name is that of a template
55+
if *stem != *task.name {
6456
continue;
6557
}
6658
if let Some(lang) = LanguageManager::detect_language(file) {
6759
let ext = lang.extensions()[0];
68-
let name = format!("{}.{}", if is_stub { "stub" } else { "grader" }, ext);
60+
// Even if some tasks want `stubs`, we always check for `grader`s.
61+
let name = format!("grader.{ext}");
6962
let grader_name = file.with_file_name(name);
7063
let grader_path = task.path.join(&grader_name);
7164
by_ext.insert(ext, (grader_path, grader_name, file.to_owned()));

task-maker-format/src/ioi/sanity_checks/sol.rs

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,10 @@ impl SanityCheck for SolutionsWithNoChecks {
8181
}
8282

8383
fn pre_hook(&self, task: &IOITask, eval: &mut EvaluationData) -> Result<(), Error> {
84-
for subtask in task.subtasks.values() {
85-
if subtask.name.is_none() {
86-
// If not all the subtasks have a name, do not bother with the solutions, it's much
87-
// more important to give everything a name before.
88-
return Ok(());
89-
}
84+
// If not all the subtasks have a name, do not bother with the solutions, it's much
85+
// more important to give everything a name before.
86+
if task.subtasks.values().any(|st| st.name.is_none()) {
87+
return Ok(());
9088
}
9189

9290
let mut solutions = vec![];
@@ -105,13 +103,58 @@ impl SanityCheck for SolutionsWithNoChecks {
105103
solution.source_file.relative_path().display()
106104
))
107105
}
106+
if !solutions.is_empty() {
107+
eval.add_diagnostic(Diagnostic::warning(format!(
108+
"The following solutions are missing the subtask checks: {}",
109+
solutions.join(", ")
110+
)))?;
111+
}
112+
Ok(())
113+
}
114+
}
115+
116+
/// Check that all the solutions (that are not symlinks) contain at least one check.
117+
#[derive(Debug, Default)]
118+
pub struct SolutionsWithSamplesCheck;
119+
make_sanity_check!(SolutionsWithSamplesCheck);
120+
121+
impl SanityCheck for SolutionsWithSamplesCheck {
122+
type Task = IOITask;
123+
124+
fn name(&self) -> &'static str {
125+
"SolutionsWithSamplesChecks"
126+
}
127+
128+
fn category(&self) -> SanityCheckCategory {
129+
SanityCheckCategory::Solutions
130+
}
131+
132+
fn pre_hook(&self, task: &IOITask, eval: &mut EvaluationData) -> Result<(), Error> {
133+
// If no first subtask exists, or it has no name, skip this check.
134+
let Some(Some(samples)) = task.subtasks.get(&0).map(|st| st.name.clone()) else {
135+
return Ok(());
136+
};
137+
138+
let mut solutions = vec![];
139+
for solution in eval.solutions.iter() {
140+
let samples_checked = solution
141+
.checks
142+
.iter()
143+
.any(|check| check.subtask_name_pattern == samples);
144+
if samples_checked {
145+
solutions.push(format!(
146+
"{}",
147+
solution.source_file.relative_path().display()
148+
))
149+
}
150+
}
108151
if !solutions.is_empty() {
109152
eval.add_diagnostic(
110153
Diagnostic::warning(format!(
111-
"The following solutions are missing the subtask checks: {}",
154+
"The following solutions have solution checks for the samples: {}",
112155
solutions.join(", ")
113156
))
114-
.with_help("Try running task-maker-tools add-solution-checks"),
157+
.with_note("Subtask checks on samples are rarely useful."),
115158
)?;
116159
}
117160
Ok(())

task-maker-format/src/ioi/sanity_checks/task.rs

Lines changed: 1 addition & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use std::collections::{HashMap, HashSet};
21
use std::fs;
32

43
use anyhow::Error;
@@ -7,7 +6,7 @@ use task_maker_diagnostics::{CodeSpan, Diagnostic};
76

87
use crate::ioi::IOITask;
98
use crate::sanity_checks::{make_sanity_check, SanityCheck, SanityCheckCategory};
10-
use crate::{list_files, EvaluationData, SolutionCheckResult};
9+
use crate::{list_files, EvaluationData};
1110

1211
/// The default maximum score of a task.
1312
const DEFAULT_TASK_MAX_SCORE: f64 = 100.0;
@@ -129,143 +128,6 @@ impl SanityCheck for NoBitsStdCpp {
129128
}
130129
}
131130

132-
/// Check that "implied" subtasks dependecies do not form cycles.
133-
#[derive(Debug, Default)]
134-
pub struct SubtaskDependencies;
135-
make_sanity_check!(SubtaskDependencies);
136-
137-
impl SanityCheck for SubtaskDependencies {
138-
type Task = IOITask;
139-
140-
fn name(&self) -> &'static str {
141-
"SubtaskDependencies"
142-
}
143-
144-
fn category(&self) -> SanityCheckCategory {
145-
SanityCheckCategory::Task
146-
}
147-
148-
fn pre_hook(&self, task: &Self::Task, eval: &mut EvaluationData) -> Result<(), Error> {
149-
let non_zero_sts = task
150-
.subtasks
151-
.keys()
152-
.copied()
153-
.filter(|st| task.subtasks[st].max_score > 0.0)
154-
.collect::<Vec<_>>();
155-
156-
let mut st_dependencies = HashSet::new();
157-
for &st1 in &non_zero_sts {
158-
for &st2 in &non_zero_sts {
159-
if st1 != st2 {
160-
st_dependencies.insert((st1, st2));
161-
}
162-
}
163-
}
164-
165-
let mut any_st_check = false;
166-
for sol in &eval.solutions {
167-
let mut score_range = HashMap::new();
168-
for check in &sol.checks {
169-
any_st_check = true;
170-
171-
let val = match check.result {
172-
SolutionCheckResult::Accepted => (1.0, 1.0),
173-
SolutionCheckResult::PartialScore => (0.0, 1.0),
174-
_ => (0.0, 0.0),
175-
};
176-
177-
for subtask in task.find_subtasks_by_pattern_name(&check.subtask_name_pattern) {
178-
score_range.insert(subtask.id, val);
179-
}
180-
}
181-
182-
for &st1 in &non_zero_sts {
183-
for &st2 in &non_zero_sts {
184-
if let (Some(v1), Some(v2)) = (score_range.get(&st1), score_range.get(&st2)) {
185-
if v1.1 > v2.0 {
186-
st_dependencies.remove(&(st1, st2));
187-
}
188-
}
189-
}
190-
}
191-
}
192-
193-
if !any_st_check {
194-
return Ok(());
195-
}
196-
197-
'outer: for &st1 in &non_zero_sts {
198-
let mut sts = vec![st1];
199-
for &st2 in &non_zero_sts {
200-
if st_dependencies.contains(&(st1, st2)) && st_dependencies.contains(&(st2, st1)) {
201-
if st1 < st2 {
202-
sts.push(st2);
203-
} else {
204-
continue 'outer;
205-
}
206-
}
207-
}
208-
if sts.len() > 1 {
209-
sts.sort();
210-
let st_names: Vec<_> = sts
211-
.iter()
212-
.map(|st| {
213-
task.subtasks[st]
214-
.name
215-
.as_ref()
216-
.map(|s| s as &dyn std::fmt::Debug)
217-
.unwrap_or(st)
218-
})
219-
.collect();
220-
eval.add_diagnostic(
221-
Diagnostic::warning(format!(
222-
"Subtasks {st_names:?} are solved by the same set of solutions",
223-
))
224-
.with_note("Add a solution that solves only one of them"),
225-
)?;
226-
}
227-
}
228-
229-
let mut to_swap = Vec::new();
230-
for &st1 in &non_zero_sts {
231-
for &st2 in &non_zero_sts {
232-
if st1 < st2
233-
&& st_dependencies.contains(&(st1, st2))
234-
&& !st_dependencies.contains(&(st2, st1))
235-
{
236-
to_swap.push((st1, st2));
237-
}
238-
}
239-
}
240-
241-
if !to_swap.is_empty() {
242-
let to_swap_names = to_swap
243-
.iter()
244-
.map(|(st1, st2)| {
245-
let st1_name = task.subtasks[st1]
246-
.name
247-
.as_ref()
248-
.map(|s| s as &dyn std::fmt::Debug)
249-
.unwrap_or(st1);
250-
let st2_name = task.subtasks[st2]
251-
.name
252-
.as_ref()
253-
.map(|s| s as &dyn std::fmt::Debug)
254-
.unwrap_or(st2);
255-
(st1_name, st2_name)
256-
})
257-
.collect::<Vec<_>>();
258-
eval.add_diagnostic(
259-
Diagnostic::warning("Subtasks are not in order of difficulty").with_note(format!(
260-
"Based on the current solutions the following pairs of subtasks seems to be ordered incorrectly {to_swap_names:?}"
261-
)),
262-
)?;
263-
}
264-
265-
Ok(())
266-
}
267-
}
268-
269131
/// Check that the task title is not emtpy.
270132
#[derive(Debug, Default)]
271133
pub struct EmptyTitle;

0 commit comments

Comments
 (0)