Skip to content
Draft
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: 3 additions & 3 deletions backup/moodle2/backup_qtype_formulas_plugin.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ protected function define_question_plugin_structure() {

$formulasanswers = new backup_nested_element('formulas_answers');
$formulasanswer = new backup_nested_element('formulas_answer', ['id'], [
'partindex', 'placeholder', 'answermark', 'answertype', 'numbox', 'vars1', 'answer', 'answernotunique', 'vars2',
'correctness', 'unitpenalty', 'postunit', 'ruleid', 'otherrule', 'subqtext', 'subqtextformat', 'feedback',
'partindex', 'placeholder', 'answermark', 'answertype', 'numbox', 'vars1', 'answer', 'answernotunique', 'emptyallowed',
'vars2', 'correctness', 'unitpenalty', 'postunit', 'ruleid', 'otherrule', 'subqtext', 'subqtextformat', 'feedback',
'feedbackformat', 'partcorrectfb', 'partcorrectfbformat', 'partpartiallycorrectfb', 'partpartiallycorrectfbformat',
'partincorrectfb', 'partincorrectfbformat',
'partincorrectfb', 'partincorrectfbformat'
]);

// Don't need to annotate ids nor files.
Expand Down
8 changes: 8 additions & 0 deletions backup/moodle2/restore_qtype_formulas_plugin.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ public function process_formulas_answer($data) {
if (!isset($data->answernotunique)) {
$data->answernotunique = '1';
}
// Backups prior to 6.2 do not yet have the emptyallowed field. In that case, we set it
// to false. It should default to true for *new* questions only.
if (!isset($data->emptyallowed)) {
$data->emptyallowed = '0';
}
// Insert record.
$newitemid = $DB->insert_record('qtype_formulas_answers', $data);
// Create mapping.
Expand Down Expand Up @@ -194,6 +199,9 @@ public static function convert_backup_to_questiondata(array $backupdata): stdCla
if (!key_exists('answernotunique', $answer)) {
$answer['answernotunique'] = '1';
}
if (!key_exists('emptyallowed', $answer)) {
$answer['emptyallowed'] = '0';
}
if (!key_exists('partindex', $answer)) {
$answer['partindex'] = $i;
}
Expand Down
23 changes: 22 additions & 1 deletion classes/local/answer_parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ public function __construct(
}
}

// If we only have one single token and it is an empty string, we set it to the $EMPTY token.
$firsttoken = reset($tokenlist);
if (count($tokenlist) === 1 && $firsttoken->value === '') {
// FIXME: temporarily disabling this
// $tokenlist[0] = new token(token::EMPTY, '$EMPTY', $firsttoken->row, $firsttoken->column);
}

// Once this is done, we can parse the expression normally.
parent::__construct($tokenlist, $knownvariables);
}
Expand All @@ -88,7 +95,18 @@ public function __construct(
* @param int $type the answer type, a constant from the qtype_formulas class
* @return bool
*/
public function is_acceptable_for_answertype(int $type): bool {
public function is_acceptable_for_answertype(int $type, bool $acceptempty = false): bool {
// An empty answer is never acceptable regardless of the answer type, unless empty fields
// are explicitly allowed for a question's part.
// FIXME: this can be removed later
if (empty($this->tokenlist)) {
return $acceptempty;
}
$firsttoken = reset($this->tokenlist);
if (count($this->tokenlist) === 1 && $firsttoken->type === token::EMPTY) {
return $acceptempty;
}

if ($type === qtype_formulas::ANSWER_TYPE_NUMBER) {
return $this->is_acceptable_number();
}
Expand All @@ -102,6 +120,9 @@ public function is_acceptable_for_answertype(int $type): bool {
}

if ($type === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
if (count($this->tokenlist) === 1 && $this->tokenlist[0]->value === '') {
return $acceptempty;
}
return $this->is_acceptable_algebraic_formula();
}

Expand Down
69 changes: 60 additions & 9 deletions classes/local/evaluator.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,20 @@ public function export_single_variable(string $varname, bool $exportasvariable =
return $result;
}

/**
* FIXME
*
* @param string $name name of the variable
* @param variable $variable variable instance
*/
public function import_single_variable(string $name, variable $variable, bool $overwrite = false): void {
if (array_key_exists($name, $this->variables) && !$overwrite) {
return;
}

$this->variables[$name] = $variable;
}

/**
* Calculate the number of possible variants according to the defined random variables.
*
Expand Down Expand Up @@ -776,18 +790,31 @@ public function diff($first, $second, ?int $n = null) {
// This is needed for the diff() function, because strings are evaluated as algebraic
// formulas, i. e. in a completely different way. Also, both lists must have the same data
// type.
$type = $first[0]->type;
if (!in_array($type, [token::NUMBER, token::STRING])) {
throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
}
$type = token::EMPTY;
for ($i = 0; $i < $count; $i++) {
if ($first[$i]->type !== $type) {
// As long as we have not found a "real" (i. e. non-empty) element, we update the type.
if ($type === token::EMPTY) {
$type = $first[$i]->type;
}
// If the current element's type does not match, we throw an error, unless it is the
// $EMPTY token, because it may appear in a list of numbers or strings.
if ($first[$i]->type !== $type && $first[$i]->type !== token::EMPTY) {
throw new Exception(get_string('error_diff_firstlist_mismatch', 'qtype_formulas', $i));
}
if ($second[$i]->type !== $type) {
if ($second[$i]->type !== $type && $second[$i]->type !== token::EMPTY) {
throw new Exception(get_string('error_diff_secondlist_mismatch', 'qtype_formulas', $i));
}
}
// If all elements of the first list are $EMPTY, we treat the list as a list of numbers, because
// that's the most straightforward way to calculate the difference. There's probably no real use
// case to have only empty answers in a question, but there's no reason to forbid it, either.
if ($type === token::EMPTY) {
$type = token::NUMBER;
}
// If the type is not valid, we throw an error.
if (!in_array($type, [token::NUMBER, token::STRING])) {
throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
}

// If we are working with numbers, we can directly calculate the differences and return.
if ($type === token::NUMBER) {
Expand All @@ -798,8 +825,17 @@ public function diff($first, $second, ?int $n = null) {

$result = [];
for ($i = 0; $i < $count; $i++) {
$diff = abs($first[$i]->value - $second[$i]->value);
$result[$i] = token::wrap($diff);
// This function is also used to calculate the difference between the model answers
// and the student's response. In that case, the difference between an $EMPTY answer
// and any other value shall always be PHP_FLOAT_MAX. The difference between an
// $EMPTY answer and an empty response shall, of course, be 0. For "real" values,
// the difference is calculated normally.
if ($first[$i]->type === token::EMPTY || $second[$i]->type === token::EMPTY) {
$diff = ($second[$i]->type === $first[$i]->type ? 0 : PHP_FLOAT_MAX);
} else {
$diff = abs($first[$i]->value - $second[$i]->value);
}
$result[$i] = token::wrap($diff, token::NUMBER);
}
return $result;
}
Expand All @@ -812,8 +848,18 @@ public function diff($first, $second, ?int $n = null) {
$result = [];
// Iterate over all strings and calculate the root mean square difference between the two expressions.
for ($i = 0; $i < $count; $i++) {
// If both list elements are the $EMPTY token, the difference is zero and we do not have to
// do any more calculations. Otherwise, we just carry on. The calculation will fail later
// and the difference will automatically be PHP_FLOAT_MAX.
if ($first[$i]->type === token::EMPTY && $second[$i]->type === token::EMPTY) {
$result[$i] = token::wrap(0, token::NUMBER);
continue;
}

$result[$i] = 0;
$expression = "({$first[$i]}) - ({$second[$i]})";
// FIXME: get rid of this again
$expression = str_replace('"', '', $expression);

// Flag that we will set to TRUE if a difference cannot be evaluated. This
// is to make sure that the difference will be PHP_FLOAT_MAX and not
Expand Down Expand Up @@ -867,14 +913,19 @@ private function evaluate_the_right_thing($input, bool $godmode = false) {
/**
* Evaluate a single expression or an array of expressions.
*
* @param expression|for_loop|array $input
* @param expression|for_loop|array|false $input
* @param bool $godmode whether to run the evaluation in god mode
* @return token|array
*/
public function evaluate($input, bool $godmode = false) {
if (($input instanceof expression) || ($input instanceof for_loop)) {
return $this->evaluate_the_right_thing($input, $godmode);
}
// For convenience, the evaluator accepts FALSE as an input, This allows
// passing reset($array) with a possibly empty array.
if ($input === false) {
return new token(token::EMPTY, '$EMPTY');
}
if (!is_array($input)) {
throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate()'));
}
Expand Down
50 changes: 43 additions & 7 deletions classes/local/formulas_part.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ class formulas_part {
/** @var int whether there are multiple possible answers */
public int $answernotunique;

/** @var int whether students can leave one or more fields empty */
public int $emptyallowed;

/** @var string definition of the grading criterion */
public string $correctness;

Expand Down Expand Up @@ -418,6 +421,11 @@ public function normalize_response(array $response): array {
* @return bool
*/
public function is_gradable_response(array $response): bool {
// If the part allows empty fields, we do not have to check anything; the response would be
// gradable even if all fields were empty.
if ($this->emptyallowed) {
return true;
}
return !$this->is_unanswered($response);
}

Expand All @@ -431,6 +439,11 @@ public function is_gradable_response(array $response): bool {
* @return bool
*/
public function is_complete_response(array $response): bool {
// If the part allows empty fields, we do not have to check anything; the response can be
// considered complete even if all fields are empty.
if ($this->emptyallowed) {
return true;
}
// First, we check if there is a combined unit field. In that case, there will
// be only one field to verify.
if ($this->has_combined_unit_field()) {
Expand Down Expand Up @@ -463,6 +476,9 @@ public function is_complete_response(array $response): bool {
* @return bool
*/
public function is_unanswered(array $response): bool {
if (array_key_exists('_seed', $response)) {
return true;
}
if (!array_key_exists('normalized', $response)) {
$response = $this->normalize_response($response);
}
Expand Down Expand Up @@ -528,6 +544,10 @@ public function get_evaluated_answers(): array {
// their numerical value.
if ($isalgebraic) {
foreach ($this->evaluatedanswers as &$answer) {
// If the answer is $EMPTY, there is nothing to do.
if ($answer === '$EMPTY') {
continue;
}
$answer = $this->evaluator->substitute_variables_in_algebraic_formula($answer);
}
// In case we later write to $answer, this would alter the last entry of the $modelanswers
Expand All @@ -547,6 +567,11 @@ public function get_evaluated_answers(): array {
*/
private static function wrap_algebraic_formulas_in_quotes(array $formulas): array {
foreach ($formulas as &$formula) {
// We do not have to wrap the $EMPTY token in quotes.
if ($formula === '$EMPTY') {
continue;
}

// If the formula is aready wrapped in quotes, we throw an Exception, because that
// should not happen. It will happen, if the student puts quotes around their response, but
// we want that to be graded wrong. The exception will be caught and dealt with upstream,
Expand Down Expand Up @@ -631,7 +656,7 @@ public function add_special_variables(array $studentanswers, float $conversionfa
foreach ($studentanswers as $i => &$studentanswer) {
// We only do the calculation if the answer type is not algebraic. For algebraic
// answers, we don't do anything, because quotes have already been added.
if (!$isalgebraic) {
if (!$isalgebraic && $studentanswer !== '$EMPTY') {
$studentanswer = $conversionfactor * $studentanswer;
$ssqstudentanswer += $studentanswer ** 2;
}
Expand All @@ -643,16 +668,19 @@ public function add_special_variables(array $studentanswers, float $conversionfa
// The variable _d will contain the absolute differences between the model answer
// and the student's response. Using the parser's diff() function will make sure
// that algebraic answers are correctly evaluated.
// Note: We *must* send the model answer first, because the function has a special check for the
// EMPTY token.
$command .= '_d = diff(_a, _r);';

// Prepare the variable _err which is the root of the sum of squared differences.
$command .= "_err = sqrt(sum(map('*', _d, _d)));";

// Finally, calculate the relative error, unless the question uses an algebraic answer.
if (!$isalgebraic) {
// We calculate the sum of squares of all model answers.
$ssqmodelanswer = 0;
foreach ($this->get_evaluated_answers() as $answer) {
if ($answer === '$EMPTY') {
continue;
}
$ssqmodelanswer += $answer ** 2;
}
// If the sum of squares is 0 (i.e. all answers are 0), then either the student
Expand Down Expand Up @@ -731,16 +759,20 @@ public function grade(array $response, bool $finalsubmit = false): array {
// Check whether the answer is valid for the given answer type. If it is not,
// we just throw an exception to make use of the catch block. Note that if the
// student's answer was empty, it will fail in this check.
if (!$parser->is_acceptable_for_answertype($this->answertype)) {
if (!$parser->is_acceptable_for_answertype($this->answertype, $this->emptyallowed)) {
throw new Exception();
}

// Make sure the stack is empty, as there might be left-overs from a previous
// failed evaluation, e.g. caused by an invalid answer.
$this->evaluator->clear_stack();

$evaluated = $this->evaluator->evaluate($parser->get_statements())[0];
$evaluatedresponse[] = token::unpack($evaluated);
// Evaluate. If the answer was empty (an empty string or the '$EMPTY'), the parser
// will create an appropriate evaluable statement or return an empty array. The evaluator,
// on the other hand, will know how to deal with the "false" return value from reset()
// and return the $EMPTY token.
$statements = $parser->get_statements();
$evaluatedresponse[] = token::unpack($this->evaluator->evaluate(reset($statements)));
} catch (Throwable $t) {
// TODO: convert to non-capturing catch
// If parsing, validity check or evaluation fails, we consider the answer as wrong.
Expand Down Expand Up @@ -828,8 +860,12 @@ public function get_correct_response(bool $forfeedback = false): array {
$answers = $this->get_evaluated_answers();

// Numeric answers should be localized, if that functionality is enabled.
// Empty answers should be just the empty string; a more user-friendly
// output will be created in the renderer.
foreach ($answers as &$answer) {
if (is_numeric($answer)) {
if ($answer === '$EMPTY') {
$answer = '';
} else if (is_numeric($answer)) {
$answer = qtype_formulas::format_float($answer);
}
}
Expand Down
25 changes: 25 additions & 0 deletions classes/local/lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ private function read_next_token(): ?token {
if ($currentchar === input_stream::EOF) {
return self::EOF;
}
// If we have a $ character, this could introduce the $EMPTY token.
if ($currentchar === '$') {
return $this->read_empty_token();
}
// If we have a " or ' character, this is the start of a string.
if ($currentchar === '"' || $currentchar === "'") {
return $this->read_string();
Expand Down Expand Up @@ -454,6 +458,27 @@ private function read_identifier(): token {
return new token($type, $result, $startingposition['row'], $startingposition['column']);
}

/**
* Read the special $EMPTY token from the input stream.
*
* @return token the $EMPTY token
*/
private function read_empty_token(): token {
// Start by reading the first char. If we are here, that means it was a $ symbol.
$currentchar = $this->inputstream->read();
$result = $currentchar;

// Record position of the $ symbol.
$startingposition = $this->inputstream->get_position();

$identifier = $this->read_identifier();
if ($identifier->value === 'EMPTY') {
return new token(token::EMPTY, '$EMPTY', $startingposition['row'], $startingposition['column']);
}

$this->inputstream->die(get_string('error_invalid_dollar', 'qtype_formulas'));
}

/**
* Read an operator token from the input stream.
*
Expand Down
3 changes: 2 additions & 1 deletion classes/local/shunting_yard.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,11 +307,12 @@ public static function infix_to_rpn(array $tokens): array {
}
}
switch ($type) {
// Literals (numbers or strings), constants and variable names go straight to the output queue.
// Literals (numbers or strings), constants, the $EMPTY token and variable names go straight to the output queue.
case token::NUMBER:
case token::STRING:
case token::VARIABLE:
case token::CONSTANT:
case token::EMPTY:
$output[] = $token;
break;
// If we encounter an argument separator (,) *and* there is a pending function or array,
Expand Down
Loading
Loading