Commit 25215446 authored by 0815-xyz's avatar 0815-xyz
Browse files

Initial commit

parents
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Activity custom completion subclass for the adaptive quiz activity.
*
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\completion;
use core_completion\activity_custom_completion;
use mod_adaptivequiz\local\attempt;
class custom_completion extends activity_custom_completion {
/**
* @inheritDoc
*/
public function get_state(string $rule): int {
$this->validate_rule($rule);
return attempt::user_has_completed_on_quiz($this->cm->instance, $this->userid)
? COMPLETION_COMPLETE
: COMPLETION_INCOMPLETE;
}
/**
* @inheritDoc
*/
public static function get_defined_custom_rules(): array {
return ['completionattemptcompleted'];
}
/**
* @inheritDoc
*/
public function get_custom_rule_descriptions(): array {
return ['completionattemptcompleted' => get_string('completionattemptcompletedcminfo', 'adaptivequiz')];
}
/**
* @inheritDoc
*/
public function get_sort_order(): array {
return ['completionview', 'completionusegrade', 'completionattemptcompleted'];
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Event which is triggered when a user completes an attempt on adaptive quiz.
*
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\event;
use core\event\base;
use moodle_exception;
use moodle_url;
class attempt_completed extends base {
/**
* @inheritDoc
*/
public static function get_name() {
return get_string('eventattemptcompleted', 'adaptivequiz');
}
/**
* @inheritDoc
*/
public function get_description() {
return "The user with id '$this->userid' has completed the attempt with id '$this->objectid' for the " .
"adaptive quiz with course module id '$this->contextinstanceid'.";
}
/**
* Returns related URL where result of the event can be observed.
*
* @throws moodle_exception
* @return moodle_url
*/
public function get_url() {
return new moodle_url('/mod/adaptivequiz/reviewattempt.php', ['attempt' => $this->objectid]);
}
/**
* @inheritDoc
*/
public static function get_objectid_mapping() {
return ['db' => 'adaptivequiz_attempt', 'restore' => 'adaptiveattempts'];
}
/**
* @inheritDoc
*/
protected function init() {
$this->data['objecttable'] = 'adaptivequiz_attempt';
$this->data['crud'] = 'u';
$this->data['edulevel'] = self::LEVEL_PARTICIPATING;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* The mod_peerassess instance list viewed event.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\event;
class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed {
/**
* Create the event from course record.
*
* @param \stdClass $course
* @return course_module_instance_list_viewed
*/
public static function create_from_course(\stdClass $course) {
$params = array(
'context' => \context_course::instance($course->id)
);
$event = self::create($params);
$event->add_record_snapshot('course', $course);
return $event;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Defines the course module viewed event.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\event;
class course_module_viewed extends \core\event\course_module_viewed {
protected function init() {
$this->data['objecttable'] = 'adaptivequiz';
parent::init();
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Adaptivequiz required password form
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\form;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/formslib.php');
use moodleform;
use html_writer;
class requiredpassword extends moodleform {
/** @var string $passwordmessage a string containing text for a failed password attempt */
public $passwordmessage = '';
/**
* This method is refactored code from the quiz's password rule add_preflight_check_form_fields() method.
* It prints a form for the user to enter a password
*/
protected function definition() {
$mform = $this->_form;
foreach ($this->_customdata['hidden'] as $name => $value) {
if ($name === 'sesskey') {
continue;
}
if ($name === 'cmid' || $name === 'uniqueid') {
$mform->setType($name, PARAM_INT);
}
$mform->addElement('hidden', $name, $value);
}
$mform->addElement('header', 'passwordheader', get_string('password'));
$mform->addElement('static', 'passwordmessage', '', get_string('requirepasswordmessage', 'adaptivequiz'));
$attr = array('style' => 'color:red;', 'class' => 'wrongpassword');
$html = html_writer::start_tag('div', $attr);
$mform->addElement('html', $html);
$mform->addElement('static', 'message');
$html = html_writer::end_tag('div');
$mform->addElement('html', $html);
// Don't use the 'proper' field name of 'password' since that get's
// Firefox's password auto-complete over-excited.
$mform->addElement('password', 'quizpassword', get_string('enterrequiredpassword', 'adaptivequiz'));
$this->add_action_buttons(true, get_string('continue'));
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class contains information about the attempt parameters
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_exception;
use mod_adaptivequiz\local\attempt\attempt_state;
use moodle_exception;
use question_bank;
use question_engine;
use question_state_gaveup;
use question_state_gradedpartial;
use question_state_gradedright;
use question_state_gradedwrong;
use question_usage_by_activity;
use stdClass;
class attempt {
private const TABLE = 'adaptivequiz_attempt';
/**
* The name of the module
*/
const MODULENAME = 'mod_adaptivequiz';
/**
* The behaviour to use be default
*/
const ATTEMPTBEHAVIOUR = 'deferredfeedback';
/**
* @var attempt_state $attemptstate
*/
private $attemptstate;
/**
* Flag to denote developer debugging is enabled and this class should write message to the debug
* wrap on multiple lines
* @var bool
*/
protected $debugenabled = false;
/** @var array $debug debugging array of messages */
protected $debug = array();
/**
* @var stdClass $adaptivequiz object, properties come from the adaptivequiz table.
* This property also contains the context and cm objects
*/
protected $adaptivequiz;
/** @var stdClass $adpqattempt object, properties come from the adaptivequiz_attempt table */
protected $adpqattempt;
/** @var int $userid user id */
protected $userid;
/** @var int $uniqueid a unique number identifying the activity usage of questions */
protected $uniqueid;
/** @var int $questionsattempted the total of question attempted */
protected $questionsattempted;
/** @var float $standarderror the standard error of the attempt */
protected $standarderror;
/** @var question_usage_by_activity $quba - A question usage by activity object */
protected $quba = null;
/** @var int $slot - a question slot number */
protected $slot = 0;
/** @var array $tags an array of tags that used to identify eligible questions for the attempt */
protected $tags = array();
/** @var array $status status message storing the reason why the attempt was stopped */
protected $status = '';
/** @var int $level the difficulty level the attempt is currently set at */
protected $level = 0;
/** @var int $lastdifficultylevel the last difficulty level used in the attempt if any */
protected $lastdifficultylevel = null;
/**
* Constructor initializes required data to process the attempt
* @param stdClass $adaptivequiz adaptivequiz record object from adaptivequiz table
* @param int $userid user id
* @param array $tags an array of acceptible tags
*/
public function __construct($adaptivequiz, $userid, $tags = array()) {
$this->adaptivequiz = $adaptivequiz;
$this->userid = $userid;
$this->tags = $tags;
$this->tags[] = ADAPTIVEQUIZ_QUESTION_TAG;
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function returns the debug array
* @return array array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This function returns the adaptivequiz property
* @return stdClass adaptivequiz record
*/
public function get_adaptivequiz() {
return $this->adaptivequiz;
}
/**
* This function returns the $level property
* @return int level property
*/
public function get_level() {
return $this->level;
}
/**
* This function sets the $level property
* @param int $level difficulty level to fetch
*/
public function set_level($level) {
$this->level = $level;
}
/**
* Set the last difficulty level that was used.
* This may influence the next question chosing process.
*
* @param int $lastdifficultylevel
* @return void
*/
public function set_last_difficulty_level($lastdifficultylevel) {
if (is_null($lastdifficultylevel)) {
$this->lastdifficultylevel = null;
} else {
$this->lastdifficultylevel = (int) $lastdifficultylevel;
}
}
/**
* This function returns the current slot number set for the attempt
* @return int question slot number
*/
public function get_question_slot_number() {
return $this->slot;
}
/**
* This function sets the current slot number set for the attempt
* @throws coding_exception - exception is thrown the argument is not a positive integer
* @param int $slot slot number
*/
public function set_question_slot_number($slot) {
if (!is_int($slot) || 0 >= $slot) {
throw new coding_exception('adaptiveattempt: Argument 1 is not an positive integer', 'Slot must be a positive integer');
}
$this->slot = $slot;
}
/**
* This function returns the current question usage by activity object
* @return question_usage_by_activity a question usage by activity object loaded with the attempt unique id
*/
public function get_quba() {
return $this->quba;
}
/**
* This function sets the current question usage by activity object.
* @throws coding_exception - exception is thrown argument is not an instance of question_usage_by_activity class
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
*/
public function set_quba($quba) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('adaptiveattempt: Argument 1 is not a question_usage_by_activity object',
'Question usage by activity must be an instance of question_usage_by_activity');
}
$this->quba = $quba;
}
/**
* This function checks to see if the difficulty level is out of the boundries set for the attempt
* @param int $level the difficulty level requested
* @param stdClass $adaptivequiz an adaptivequiz record
* @return bool true if the level is in bounds, otherwise false
*/
public function level_in_bounds($level, $adaptivequiz) {
if ($adaptivequiz->lowestlevel <= $level && $adaptivequiz->highestlevel >= $level) {
return true;
}
return false;
}
/**
* This function returns the currently set status message.
*
* @return string The status message property.
*/
public function get_status() {
return $this->status;
}
/**
* This function does the work of initializing data required to fetch a new question for the attempt.
*
* @return bool True if attempt started okay otherwise false.
*/
public function start_attempt() {
// Get most recent attempt or start a new one.
$adpqattempt = $this->get_attempt();
// Check if the level requested is out of the minimum/maximum boundries for the attempt.
if (!$this->level_in_bounds($this->level, $this->adaptivequiz)) {
$var = new stdClass();
$var->level = $this->level;
$this->status = get_string('leveloutofbounds', 'adaptivequiz', $var);
return false;
}
// Check if the attempt has reached the maximum number of questions attempted.
if ($this->max_questions_answered()) {
$this->status = get_string('maxquestattempted', 'adaptivequiz');
return false;
}
// Initialize the question usage by activity property.
$this->initialize_quba();
// Find the last question viewed/answered by the user.
$this->slot = $this->find_last_quest_used_by_attempt($this->quba);
// Create a an instance of the fetchquestion class.
$fetchquestion = new fetchquestion($this->adaptivequiz, 1, $this->adaptivequiz->lowestlevel,
$this->adaptivequiz->highestlevel);
// Check if this is the beginning of an attempt (and pass the starting level) or the continuation of an attempt.
if (empty($this->slot) && 0 == $adpqattempt->questionsattempted) {
// Set the starting difficulty level.
$fetchquestion->set_level((int) $this->adaptivequiz->startinglevel);
// Sets the level class property.
$this->level = $this->adaptivequiz->startinglevel;
// Set the rebuild flag for fetchquestion class.
$fetchquestion->rebuild = true;
$this->print_debug("start_attempt() - Brand new attempt. Set starting level: {$this->adaptivequiz->startinglevel}.");
} else if (!empty($this->slot) && $this->was_answer_submitted_to_question($this->quba, $this->slot)) {
// If the attempt already has a question attached to it, check if an answer was submitted to the question.
// If so fetch a new question.
// Provide the question-fetching process with limits based on our last question.
//KNIGHT: Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
// If the last question was correct...
if ($this->is_question_marked_correct($this->quba, $this->slot)) {
$this->print_debug("start_attempt() - Last question was correct");
// Only ask questions harder than the last question unless we are already at the top of the ability scale.
if (!is_null($this->lastdifficultylevel) && $this->lastdifficultylevel < $this->adaptivequiz->highestlevel) {
$fetchquestion->set_minimum_level($this->lastdifficultylevel + 1);
// Do not ask a question of the same level unless we are already at the max.
if ($this->lastdifficultylevel == $this->level) {
$this->print_debug("start_attempt() - Last difficulty is the same as the new difficulty, ".
"incrementing level from {$this->level} to ".($this->level + 1).".");
$this->level++;
}
}
} else {
$this->print_debug("start_attempt() - Last question was wrong ");
// If the last question was wrong...
// Only ask questions easier than the last question unless we are already at the bottom of the ability scale.
if (!is_null($this->lastdifficultylevel) && $this->lastdifficultylevel > $this->adaptivequiz->lowestlevel) {
$fetchquestion->set_maximum_level($this->lastdifficultylevel - 1);
// Do not ask a question of the same level unless we are already at the min.
if ($this->lastdifficultylevel == $this->level) {
$this->print_debug("start_attempt() - Last difficulty is the same as the new difficulty, ".
"decrementing level from {$this->level} to ".($this->level - 1).".");
$this->level--;
}
}
}
// Reset the slot number back to zero, since we are going to fetch a new question.
$this->slot = 0;
// Set the level of difficulty to fetch.
$fetchquestion->set_level((int) $this->level);
$this->print_debug("start_attempt() - Continuing attempt. Set level: {$this->level}.");
} else if (empty($this->slot) && 0 < $adpqattempt->questionsattempted) {
// If this condition is met, then something went wrong because the slot id is empty BUT the questions attempted is
// Greater than zero. Stop attempt.
$this->print_debug('start_attempt() - something went horribly wrong since the quba has no slot number AND the number '.
'of question answered is greater than 0');
$this->status = get_string('errorattemptstate', 'adaptivequiz');
return false;
}
// If the slot property is set, then we have a question that is ready to be attempted. No more process is required.
if (!empty($this->slot)) {
return true;
}
// If we are here, then the slot property was unset and a new question needs to prepared for display.
$status = $this->get_question_ready($fetchquestion);
if (empty($status)) {
$var = new stdClass();
$var->level = $this->level;
$this->status = get_string('errorfetchingquest', 'adaptivequiz', $var);
return false;
}
return $status;
}
/**
* This function returns a random array element
* @param array $questions an array of question ids. Array key values are question ids
* @return int a question id
*/
public function return_random_question($questions) {
if (empty($questions)) {
return 0;
}
$questionid = array_rand($questions);
$this->print_debug('return_random_question() - random question chosen questionid: '.$questionid);
return (int) $questionid;
}
/**
* This function checks to see if the student answered the maximum number of questions
* @return bool true if the attempt is starting for the first time. Otherwise false
*/
public function max_questions_answered() {
if ($this->adpqattempt->questionsattempted >= $this->adaptivequiz->maximumquestions) {
$this->print_debug('max_questions_answered() - maximum number of questions answered');
return true;
}
return false;
}
/**
* This function checks to see if the student answered the minimum number of questions
* @return bool true if the attempt is starting for the first time. Otherwise false
*/
public function min_questions_answered() {
if ($this->adpqattempt->questionsattempted > $this->adaptivequiz->minimumquestions) {
$this->print_debug('min_questions_answered() - minimum number of questions answered');
return true;
}
return false;
}
/**
* This function retrieves the last question that was used in the attempt
* @throws moodle_exception - exception is thrown function parameter is not an instance of question_usage_by_activity class
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @return int question slot or 0 if no unmarked question could be found
*/
public function find_last_quest_used_by_attempt($quba) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('find_last_quest_used_by_attempt() - Argument was not a question_usage_by_activity object',
$this->vardump($quba));
}
// The last slot in the array should be the last question that was attempted (meaning it was either shown to the user
// or the user submitted an answer to it).
$questslots = $quba->get_slots();
if (empty($questslots) || !is_array($questslots)) {
$this->print_debug('find_last_quest_used_by_attempt() - No question slots found for this '.
'question_usage_by_activity object');
return 0;
}
$questslot = end($questslots);
$this->print_debug('find_last_quest_used_by_attempt() - Found a question slot: '.$questslot);
return $questslot;
}
/**
* This function determines if the user submitted an answer to the question
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @param int $slot question slot id
* @return bool true if an answer to the question was submitted, otherwise false
*/
public function was_answer_submitted_to_question($quba, $slotid) {
$state = $quba->get_question_state($slotid);
// Check if the state of the quesiton attempted was graded right, partially right, wrong or gave up, count the question has
// having an answer submitted.
$marked = $state instanceof question_state_gradedright || $state instanceof question_state_gradedpartial
|| $state instanceof question_state_gradedwrong || $state instanceof question_state_gaveup;
if ($marked) {
return true;
} else {
// Save some debugging information.
$this->print_debug('was_answer_submitted_to_question() - question state is unrecognized state: '.get_class($state).'
question slotid: '.$slotid.' quba id: '.$quba->get_id());
}
return false;
}
/**
* This function initializes the question_usage_by_activity object. If an attempt unfinished attempt
* has a usage id, a question_usage_by_activity object will be loaded using the usage id. Otherwise a new
* question_usage_by_activity object is created.
*
* @throws moodle_exception Exception is thrown when required behaviour could not be found.
* @return question_usage_by_activity|null Returns a question usage by activity object or null.
*/
public function initialize_quba() {
if (!$this->behaviour_exists()) {
throw new moodle_exception('Missing '.self::ATTEMPTBEHAVIOUR.' behaviour', 'Behaviour: '.self::ATTEMPTBEHAVIOUR.
' must exist in order to use this activity');
}
if (0 == $this->adpqattempt->uniqueid) {
// Init question usage and set default behaviour of usage.
$quba = question_engine::make_questions_usage_by_activity(self::MODULENAME, $this->adaptivequiz->context);
$quba->set_preferred_behaviour(self::ATTEMPTBEHAVIOUR);
$this->quba = $quba;
$this->print_debug('initialized_quba() - question usage created');
} else {
// Load a previously used question by usage object.
$quba = question_engine::load_questions_usage_by_activity($this->adpqattempt->uniqueid);
$this->print_debug('initialized_quba() - Re-using unfinishd attempt');
}
// Set class property.
$this->quba = $quba;
return $quba;
}
/**
* This function retrieves the most recent attempt, whose state is 'inprogress'. If no attempt is found
* it creates a new attempt. Lastly $adpqattempt instance property gets set.
*
* @return stdClass adaptivequiz_attempt data object
*/
public function get_attempt() {
global $DB;
$param = ['instance' => $this->adaptivequiz->id, 'userid' => $this->userid, 'attemptstate' => attempt_state::IN_PROGRESS];
$attempt = $DB->get_records(self::TABLE, $param, 'timemodified DESC', '*', 0, 1);
if (empty($attempt)) {
$time = time();
$attempt = new stdClass();
$attempt->instance = $this->adaptivequiz->id;
$attempt->userid = $this->userid;
$attempt->uniqueid = 0;
$attempt->attemptstate = attempt_state::IN_PROGRESS;
$attempt->questionsattempted = 0;
$attempt->standarderror = 999;
$attempt->timecreated = $time;
$attempt->timemodified = $time;
$id = $DB->insert_record(self::TABLE, $attempt);
$attempt->id = $id;
$this->adpqattempt = $attempt;
$this->print_debug('get_attempt() - new attempt created: '.$this->vardump($attempt));
} else {
$attempt = current($attempt);
$this->adpqattempt = $attempt;
$this->print_debug('get_attempt() - previous attempt loaded: '.$this->vardump($attempt));
}
return $attempt;
}
//KNIGHT
/**
* This function returns a boolean to indicate whether the user answered the question correctly or incorrectly.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* This method is parallel to question_was_marked_correct_by_id in catalgo.class
*
* @param question_usage_by_activity $quba an object loaded with the unique id of the attempt
* @param int $slotid the slot id of the question
* @return boolean true if the question was answered at least at the acceptance threshold, false otherwise (or no answer), or null if there is no mark and fraction
*/
public function is_question_marked_correct($quba, $slotid) {
// Check if there was an answer at all
if (!$this->was_answer_submitted_to_question($quba, $slotid)) {
// If no answer was submitted then the question must be marked as incorrect.
return false;
}
// Retrieve the fraction (of correct answers) received.
$fraction = $quba->get_question_fraction($slotid);
// get $mark and scale it by maximum mark if $fraction is null
if (is_null($fraction)) {
$this->print_debug('question_was_marked_correct_by_id - fraction is null. Getting mark.');
$mark = $quba->get_question_mark($slotid);
// fraction and mark are both null
if (is_null($mark)) {
return null;
}
//Also retrieve maximum mark for scaling of mark
$maxmark = $quba->get_question_max_mark($slotid);
if (is_null($maxmark)) {
return null;
}
// compute the missing value for $fraction
$fraction = $mark/$maxmark;
}
// $fraction is now definitely set
$this->print_debug('question_was_marked_correct_by_id - Fraction returned is '.$fraction);
// The question is assumed to be answered correctly if its fraction is
// equal to higher than the acceptance threshold
if ( $fraction >= $this->adaptivequiz->acceptancethreshold) {
$this->print_debug('question_was_marked_correct_by_id - mark is indeed correct');
return true;
}
$this->print_debug('question_was_marked_correct_by_id - mark is indeed incorrect');
return false;
}
/**
* This functions returns an array of all question ids that have been used in this attempt
*
* @return array an array of question ids
*/
public function get_all_questions_in_attempt($uniqueid) {
global $DB;
$questions = $DB->get_records_menu('question_attempts', array('questionusageid' => $uniqueid), 'id ASC', 'id,questionid');
return $questions;
}
/**
* @throws dml_exception
*/
public static function user_has_completed_on_quiz(int $adaptivequizid, int $userid): bool {
global $DB;
return $DB->record_exists(self::TABLE,
['userid' => $userid, 'instance' => $adaptivequizid, 'attemptstate' => attempt_state::COMPLETED]);
}
/**
* This function adds a message to the debugging array
* @param string $message details of the debugging message
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function gets the question ready for display to the user.
* @param fetchquestion $fetchquestion a fetchquestion object initialized to the activity instance of the attempt
* @return bool true if everything went okay, otherwise false
*/
protected function get_question_ready($fetchquestion) {
// Fetch questions already attempted.
$exclude = $this->get_all_questions_in_attempt($this->adpqattempt->uniqueid);
// Fetch questions for display.
$questionids = $fetchquestion->fetch_questions($exclude);
if (empty($questionids)) {
$this->print_debug('get_question_ready() - Unable to fetch a question $questionsids:'.$this->vardump($questionids));
return false;
}
// Select one random question.
$questiontodisplay = $this->return_random_question($questionids);
if (empty($questiontodisplay)) {
$this->print_debug('get_question_ready() - Unable to randomly select a question $questionstodisplay:'.
$questiontodisplay);
return false;
}
// Load basic question data.
$questionobj = question_preload_questions(array($questiontodisplay));
get_question_options($questionobj);
$this->print_debug('get_question_ready() - setup question options');
// Make a copy of the array and pop off the first (and only) element (current() didn't work for some reason).
$quest = $questionobj;
$quest = array_pop($quest);
// Create the question_definition object.
$question = question_bank::load_question($quest->id);
// Add the question to the usage question_usable_by_activity object.
$this->slot = $this->quba->add_question($question);
// Start the question attempt.
$this->quba->start_question($this->slot);
// Save the question usage and question attempt state to the DB.
question_engine::save_questions_usage_by_activity($this->quba);
// Update the attempt unique id.
$this->set_attempt_uniqueid();
// Set class level property to the difficulty level of the question returned from fetchquestion class.
$this->level = $fetchquestion->get_level();
$this->print_debug('get_question_ready() - Question: '.$this->vardump($question).' loaded and attempt started. '.
'Question_usage_by_activity saved.');
return true;
}
/**
* This function updates the current attempt with the question_usage_by_activity id.
*/
protected function set_attempt_uniqueid(): void {
global $DB;
$this->adpqattempt->uniqueid = $this->quba->get_id();
$DB->update_record(self::TABLE, $this->adpqattempt);
$this->print_debug('set_attempt_uniqueid() - attempt uniqueid set: '.$this->adpqattempt->uniqueid);
}
/**
* This function retrives archetypal behaviours and sets the attempt behavour to to manual grade
* @return bool true if the behaviour exists, else false
*/
protected function behaviour_exists() {
$exists = false;
$behaviours = question_engine::get_archetypal_behaviours();
if (!empty($behaviours)) {
foreach ($behaviours as $key => $behaviour) {
if (0 == strcmp(self::ATTEMPTBEHAVIOUR, $key)) {
// Behaviour found, exit the loop.
$exists = true;
break;
}
}
}
return $exists;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* A class to emulate enum type for attempt state.
*
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
declare(strict_types=1);
namespace mod_adaptivequiz\local\attempt;
final class attempt_state {
public const IN_PROGRESS = 'inprogress';
public const COMPLETED = 'complete';
/**
* @var string $stateasstring
*/
private $stateasstring;
private function __construct(string $state) {
$this->stateasstring = $state;
}
public function is_in_progress(): bool {
return self::IN_PROGRESS === $this->stateasstring;
}
public function is_completed(): bool {
return self::COMPLETED === $this->stateasstring;
}
public static function in_progress(): self {
return new self(self::IN_PROGRESS);
}
public static function completed(): self {
return new self(self::COMPLETED);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class performs the simple algorithm to determine the next level of difficulty a student should attempt.
* It also recommends whether the calculation has reached an acceptable level of error.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_missing_record_exception;
use moodle_exception;
use question_state_gradedpartial;
use question_state_gradedright;
use question_state_gradedwrong;
use question_state_todo;
use question_usage_by_activity;
use stdClass;
class catalgo {
/** @var $quba a question_usage_by_activity object */
protected $quba = null;
/** @var $attemptid an adaptivequiz_attempt attempt id */
protected $attemptid = 0;
/**
* @var bool $debugenabled flag to denote developer debugging is enabled and this class should write message to the debug array
*/
protected $debugenabled = false;
/** @var array $debug debugging array of messages */
protected $debug = array();
/** @var int $level level of difficulty of the most recently attempted question */
protected $level = 0;
/**
* @var float $levelogit the logit value of the difficulty level represented as a percentage of the minimum and maximum
* difficulty @see compute_next_difficulty()
*/
protected $levellogit = 0.0;
/** @var bool $readytostop flag to denote whether to assume the student has met the minimum requirements */
protected $readytostop = true;
/** @var int $questattempted the sum number of questions attempted */
protected $questattempted = 0;
/** @var $difficultysum the sum of the difficulty levels attempted */
protected $difficultysum = 0;
/** @var int $nextdifficulty the next dificulty level to administer */
protected $nextdifficulty = 0;
/** @var int $sumofcorrectanswers the sum of questions answered correctly */
protected $sumofcorrectanswers;
/** @var int @sumofincorrectanswers the sum of questions answered incorretly */
protected $sumofincorrectanswers;
/** @var float $measure the ability measure */
protected $measure = 0.0;
/** @var float $standarderror the standard error of the measure */
protected $standarderror = 0.0;
/** @var string $status status message storing the reason why the attempt needs to be stopped */
protected $status = '';
/**
* Constructor to initialize the parameters needed by the adaptive alrogithm
* @throws moodle_exception - exception is thrown if first argument is not an instance of question_usage_by_activity class or
* second argument is not a positive integer.
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $attemptid the adaptivequiz_attempt attempt id
* @param bool $readytostop true of the algo should assume the user has answered the minimum number of question and should
* compare the results againts the standard error
* @param int $level the level of difficulty for the most recently attempted question
* @return void
*/
public function __construct($quba, $attemptid, $readytostop = true, $level = 0) {
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('catalgo: Argument 1 is not a question_usage_by_activity object',
'Question usage by activity must be a question_usage_by_activity object');
}
if (!is_int($attemptid) || 0 >= $attemptid) {
throw new coding_exception('catalgo: Argument 2 not a positive integer',
'Attempt id argument must be a positive integer');
}
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('catalgo: Argument 4 not a positive integer', 'level must be a positive integer');
}
$this->quba = $quba;
$this->attemptid = $attemptid;
$this->readytostop = $readytostop;
$this->level = $level;
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function adds a message to the debugging array
* @param string $message details of the debugging message
* @return void
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function returns the debug array
* @return array array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This function returns the $difficultysum property
* @return int returns the $difficultysum property
*/
public function get_difficultysum() {
return $this->difficultysum;
}
/**
* This function returns the $levellogit property
* @return float retuns the $levellogit property
*/
public function get_levellogit() {
return $this->levellogit;
}
/**
* This function returns the $standarderror property
* @return float retuns the $standarderror property
*/
public function get_standarderror() {
return $this->standarderror;
}
/**
* This function returns the $measure property
* @return float retuns the $measure property
*/
public function get_measure() {
return $this->measure;
}
/**
* This functions retrieves the attempt record, the highest and lowest difficulty level set for the attempt
* @throws dml_missing_record_exception
* @param int $attemptid the attempt id record
* @return stdClass adaptivequiz_attempt record
*/
public function retrieve_attempt_record($attemptid) {
global $DB;
$param = array('id' => $attemptid);
$sql = "SELECT aa.id, aa.questionsattempted, aa.difficultysum, aa.standarderror, a.highestlevel, a.lowestlevel, a.acceptancethreshold, aa.measure
FROM {adaptivequiz_attempt} aa
JOIN {adaptivequiz} a ON a.id = aa.instance
WHERE aa.id = :id
ORDER BY id DESC";
$record = $DB->get_record_sql($sql, $param, MUST_EXIST);
return $record;
}
/**
* Refactored code from adaptiveattempt.class.php @see find_last_quest_used_by_attempt()
* This function retrieves the last question that was used in the attempt
* @return int question slot or 0 if no unmarked question could be found
*/
protected function find_last_quest_used_by_attempt() {
if (!$this->quba instanceof question_usage_by_activity) {
$this->print_debug('find_last_quest_used_by_attempt() - Argument was not a question_usage_by_activity object');
return 0;
}
// The last slot in the array should be the last question that was attempted (meaning it was either shown to the user or the
// user submitted an answer to it).
$questslots = $this->quba->get_slots();
if (empty($questslots) || !is_array($questslots)) {
$this->print_debug('find_last_quest_used_by_attempt() - No question slots found for this question_usage_by_activity '.
'object');
return 0;
}
$questslot = end($questslots);
$this->print_debug('find_last_quest_used_by_attempt() - Found a question slot: '.$questslot);
return $questslot;
}
/**
* Refactored code from adaptiveattempt.class.php @see was_answer_submitted_to_question()
* This function determines if the user submitted an answer to the question
* @param int $slot question slot id
* @return bool true if an answer to the question was submitted, otherwise false
*/
protected function was_answer_submitted_to_question($slotid) {
if (empty($slotid)) {
$this->print_debug('was_answer_submitted_to_question() refactored - slot id was zero');
return false;
}
$state = $this->quba->get_question_state($slotid);
// Check if the state of the quesiton attempted was graded right, partially right or wrong.
$marked = $state instanceof question_state_gradedright || $state instanceof question_state_gradedpartial
|| $state instanceof question_state_gradedwrong;
if ($marked) {
return true;
} else {
// Save some debugging information.
$debugmsg = 'was_answer_submitted_to_question() refactored - question state is unrecognized state: '.get_class($state);
$debugmsg .= ' questionslotid: '.$slotid.' quba id: '.$this->quba->get_id();
$this->print_debug($debugmsg);
}
return false;
}
/**
* This function retrieves the mark received from the student's submission to the question
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $slotid the slot id of the question
* @return float|null a float representing the user's mark. Or null if there was no mark
*/
public function get_question_mark($quba, $slotid) {
$mark = $quba->get_question_mark($slotid);
if (is_float($mark)) {
return $mark;
}
$this->print_debug('get_question_mark() - Question mark was not a float slot id: '.$slotid);
return null;
}
//KNIGHT (Ulrike Pado): For the purpose of comparison with the acceptance threshold (Depending on the type of question, sometimes mark and sometimes fraction are used by the system)
/**
* This function retrieves the point fraction received from the student's submission to the question
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $slotid the slot id of the question
* @return float|null a float representing the user's point fraction. Or null if there was no point fraction
*/
public function get_question_fraction($quba, $slotid) {
$fraction = $quba->get_question_fraction($slotid);
if (is_float($fraction)) {
return $fraction;
}
$this->print_debug('get_question_fraction() - Question fraction was not a float - slot id: '.$slotid);
return null;
}
/**
* This function retrieves the maximum mark that can be achieved by answering the question (in case a fraction is missing and has to be
* inferred from mark and maximum mark).
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $slotid the slot id of the question
* @return float|null a float representing the maximum mark. Or null if there was no maximum mark
*/
public function get_question_max_mark($quba, $slotid) {
$maxmark = $quba->get_question_max_mark($slotid);
if (is_float($maxmark)) {
return $maxmark;
}
$this->print_debug('get_question_max_mark() - Maximum question mark was not a float - slot id: '.$slotid);
return null;
}
/**
* This function determins whether the user answered the question correctly or incorrectly.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* This function determines the current slot id and then calls
* question_was_marked_correct_by_id($slotid)
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @param int $slotid the slot id of the question
* @return bool|null a boolean representing question correctness or null if there is no valid slot
*/
public function question_was_marked_correct() {
// Find the last question attempted by the user.
$slotid = $this->find_last_quest_used_by_attempt();
// if there is no valid slot, end here
if (empty($slotid)) {
return null;
}
// increment the number of attempted questions
$this->questattempted++;
$this->print_debug('question_was_marked_correct() - '.$this->questattempted.' questions attempted');
// pass on to parametrized version
return $this->question_was_marked_correct_by_id($slotid);
}
/**
* This function determines whether the question was answered correctly or incorrectly given a slot id.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* @param int $slotid question slot id
* @return boolean the correctness of the answer
*/
public function question_was_marked_correct_by_id($slotid) {
// always needs checking in case the method is called directly
// (not from question_was_marked_correct())
if (empty($slotid)) {
return null;
}
// Check if the question was answered. If not, it counts as answered wrong
if (!$this->was_answer_submitted_to_question($slotid)) {
return false;
}
// Retrieve the fraction of points received.
$fraction = $this->get_question_fraction($this->quba, $slotid);
// get $mark and scale it by maximum mark if $fraction is null
if (is_null($fraction)) {
$this->print_debug('question_was_marked_correct_by_id - fraction is null. Getting mark.');
$mark = $this->get_question_mark($this->quba, $slotid);
// fraction and mark are both null
if (is_null($mark)) {
return null;
}
//Also retrieve maximum mark for scaling of mark
$maxmark = $this->get_question_max_mark($this->quba, $slotid);
// compute the value that $fraction should have
$fraction = $mark/$maxmark;
}
// $fraction is now set
$this->print_debug('question_was_marked_correct_by_id - Fraction is '.$fraction);
// Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
$record = $this->retrieve_attempt_record($this->attemptid);
$this->acceptancethreshold = $record->acceptancethreshold;
if ( $fraction >= $this->acceptancethreshold) {
$this->print_debug('question_was_marked_correct_by_id - mark is indeed correct');
return true;
}
$this->print_debug('question_was_marked_correct_by_id - mark is indeed incorrect');
return false;
}
/**
* This function retrieves the allowed standard error (as a percent) for the attempt
* @throws dml_missing_record_exception
* @param int $attemptid adaptivequiz_attempt id
* @return float the standard error allowed
*/
public function retrieve_standard_error($attemptid) {
global $DB;
$param = array('aaid' => $attemptid);
$sql = "SELECT a.standarderror
FROM {adaptivequiz} a
JOIN {adaptivequiz_attempt} aa ON a.id = aa.instance
WHERE aa.id = :aaid
ORDER BY a.standarderror ASC";
return (float) $DB->get_field_sql($sql, $param, MUST_EXIST);
}
/**
* This function performs the different steps in the CAT simple algorithm
* @return int returns the next difficulty level or 0 if there was an error
*/
public function perform_calculation_steps() {
// Retrieve attempt record.
$record = $this->retrieve_attempt_record($this->attemptid);
$this->difficultysum = $record->difficultysum;
$this->questattempted = $record->questionsattempted;
// If the user answered the previous question correctly, calculate the sum of correct answers.
$correct = $this->question_was_marked_correct();
if (true === $correct) {
// Compute the next difficulty level for the next question.
$this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, true, $record);
} else if (false === $correct) {
// Compute the next difficulty level for the next question.
$this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, false, $record);
} else {
$this->status = get_string('errorlastattpquest', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - Last question attempted returned a null as an answer');
return 0;
}
// If he user hasn't met the minimum requirements to end the attempt, then return with the next difficulty level.
if (empty($this->readytostop)) {
$this->print_debug('perform_calculation_steps() - Not ready to stop the attempt, returning next difficulty number');
return $this->nextdifficulty;
}
// Calculate the sum of correct answers and the sum of incorrect answers.
$this->sumofcorrectanswers = $this->compute_right_answers($this->quba);
$this->sumofincorrectanswers = $this->compute_wrong_answers($this->quba);
if (0 == $this->questattempted) {
$this->status = get_string('errornumattpzero', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - number of questions attempted equals zero');
return 0;
}
// Test that the sum of incorrect and correct answers equal to the sum of question attempted.
$validatenumbers = $this->sumofcorrectanswers + $this->sumofincorrectanswers;
if ($validatenumbers != $this->questattempted) {
$this->status = get_string('errorsumrightwrong', 'adaptivequiz');
$this->print_debug('perform_calculation_steps() - Sum of correct and incorrect answers ('.$validatenumbers.') '.
'doesn\'t equals the total number of questions attempted ('.$this->questattempted.')');
return 0;
}
// Get the measure estimate.
$this->measure = self::estimate_measure($this->difficultysum, $this->questattempted, $this->sumofcorrectanswers,
$this->sumofincorrectanswers);
// Get the standard error estimate.
$this->standarderror = self::estimate_standard_error($this->questattempted, $this->sumofcorrectanswers,
$this->sumofincorrectanswers);
$this->print_debug('perform_calculation_steps() - difficultysum: '.$this->difficultysum.', questattempted: '.
$this->questattempted.', sumofcorrectanswers: '.$this->sumofcorrectanswers.', sumofincorrectanswers: '.
$this->sumofincorrectanswers.' =&gt; measure: '.$this->measure.', standard error: '.$this->standarderror);
// Retrieve the standard error (as a percent) set for the attempt, convert it into a decimal percent then
// convert to a logit.
$quizdefinederror = $this->retrieve_standard_error($this->attemptid);
$quizdefinederror = $quizdefinederror / 100;
$quizdefinederror = self::convert_percent_to_logit($quizdefinederror);
// If the calculated standard error is within the parameters of the attempt then populate the status message.
if ($this->standard_error_within_parameters($this->standarderror, $quizdefinederror)) {
// Convert logits to percent for display.
$val = new stdClass();
$val->calerror = self::convert_logit_to_percent($this->standarderror);
$val->calerror = 100 * round($val->calerror, 2);
$val->definederror = self::convert_logit_to_percent($quizdefinederror);
$val->definederror = 100 * round($val->definederror, 2);
$this->status = get_string('calcerrorwithinlimits', 'adaptivequiz', $val);
}
$this->print_debug('perform_calculation_steps() - measure: '.$this->measure.' standard error: '.$this->standarderror);
return $this->nextdifficulty;
}
/**
* This function returns the currently set status message
* @return string the status message property
*/
public function get_status() {
return $this->status;
}
/**
* This function takes a percent as a float between 0 and less than 0.5 and converts it into a logit value
* @throws coding_exception if percent is out of bounds
* @param float $percent percent represented as a decimal 15% = 0.15
* @return float logit value of percent
*/
public static function convert_percent_to_logit($percent) {
if ($percent < 0 || $percent >= 0.5) {
throw new coding_exception('convert_percent_to_logit: percent is out of bounds', 'Percent must be 0 >= and < 0.5');
}
return log( (0.5 + $percent) / (0.5 - $percent) );
}
/**
* This function takes a logit as a float greater than or equal to 0 and converts it into a percent
* @throws coding_exception if logit is out of bounds
* @param float $logit logit value
* @return float logit value of percent
*/
public static function convert_logit_to_percent($logit) {
if ($logit < 0) {
throw new coding_exception('convert_logit_to_percent: logit is out of bounds',
'logit must be greater than or equal to 0');
}
return ( 1 / ( 1 + exp(0 - $logit) ) ) - 0.5;
}
/**
* Convert a logit value to a fraction between 0 and 1.
* @param float $logit logit value
* @return float the logit value mapped as a fraction
*/
public static function convert_logit_to_fraction($logit) {
return exp($logit) / ( 1 + exp($logit) );
}
/**
* This function takes the inverse of a logit value, then maps the value onto the scale defined for the attempt
* @param float $logit logit value
* @param int $max the maximum value of the scale
* @param int $min the minimum value of the scale
* @return float the logit value mapped onto the scale
*/
public static function map_logit_to_scale($logit, $max, $min) {
$fraction = self::convert_logit_to_fraction($logit);
$scaledvalue = ( ( $max - $min ) * $fraction ) + $min;
return $scaledvalue;
}
/**
* This function compares the calulated standard error with the activity defined standard error allowd for the attempt
* @param float $calculatederror the error calculated from the parameters of the user's current attempt
* @param float $definederror the allowed error set for the activity instance
* @return bool true if the calulated error is less than or equal to the defined error, otherwise false
*/
public function standard_error_within_parameters($calculatederror, $definederror) {
if ($calculatederror <= $definederror) {
return true;
} else {
return false;
}
}
/**
* This function estimates the standard error in the measurement
* @param int $questattempt the number of question attempted
* @param int $sumcorrect the sum of correct answers
* @param int $sumincorrect the sum of incorrect answers
* @return float a decimal rounded to 5 places is returned
*/
public static function estimate_standard_error($questattempt, $sumcorrect, $sumincorrect) {
if ($sumincorrect == 0) {
$standarderror = sqrt($questattempt / ( ($sumcorrect - 0.5) * ($sumincorrect + 0.5) ) );
} else if ($sumcorrect == 0) {
$standarderror = sqrt($questattempt / ( ($sumcorrect + 0.5) * ($sumincorrect - 0.5) ) );
} else {
$standarderror = sqrt($questattempt / ( $sumcorrect * $sumincorrect ) );
}
return round($standarderror, 5);
}
/**
* This function estimates the measure of ability
* @param float $diffsum the sum of difficulty levels expressed as logits
* @param int $questattempt the number of question attempted
* @param int $sumcorrect the sum of correct answers
* @param int $sumincorrect the sum of incorrect answers
* @return float an estimate of the measure of ability
*/
public static function estimate_measure($diffsum, $questattempt, $sumcorrect, $sumincorrect) {
if ($sumincorrect == 0) {
$measure = ($diffsum / $questattempt) + log( ($sumcorrect - 0.5) / ($sumincorrect + 0.5) );
} else if ($sumcorrect == 0) {
$measure = ($diffsum / $questattempt) + log( ($sumcorrect + 0.5) / ($sumincorrect - 0.5) );
} else {
$measure = ($diffsum / $questattempt) + log( $sumcorrect / $sumincorrect );
}
return round($measure, 5, PHP_ROUND_HALF_UP);
}
/**
* This function counts the total number of correct answers for the attempt
* KNIGHT (Ulrike Pado): Use a question's fraction, not mark.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @return int the number of correct answer submission
*/
public function compute_right_answers($quba) {
$correctanswers = 0;
// Get question slots for the attempt.
$slots = $quba->get_slots();
// Iterate over slots and count correct answers.
foreach ($slots as $slot) {
if ($this->question_was_marked_correct_by_id($slot)) {
$correctanswers++;
}
}
$this->print_debug('compute_right_answers() - Sum of correct answers: '.$correctanswers);
return $correctanswers;
}
/**
* This function counts the total number of incorrect answers for the attempt
* KNIGHT (Ulrike Pado): Use a question's fraction, not mark.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* @param question_usage_by_activity $quba an object loaded using the unique id of the attempt
* @return int the number of correct answer submission
*/
public function compute_wrong_answers($quba) {
$incorrectanswers = 0;
// Get question slots for the attempt.
$slots = $quba->get_slots();
// Iterate over slots and count correct answers.
foreach ($slots as $slot) {
if (!$this->question_was_marked_correct_by_id($slot)) {
$incorrectanswers++;
}
}
$this->print_debug('compute_right_answers() - Sum of incorrect answers: '.$incorrectanswers);
return $incorrectanswers;
}
/**
* This function is a helper method to compute the current difficult level the attempt is at
* @throws coding_exception if any of the parameters contain invalid data
* @param question_usage_by_activity $quba a question usage by activity set to an attempt id
* @param int $startinglevel the starting level of difficulty for the attempt
* @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel
* @return int the current level of difficulty
*/
public function get_current_diff_level($quba, $level, $attemptobj) {
// Check if level is a positive integer.
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('get_current_diff_level: Arg 2 needs to be a positive integer',
'Invalid level of :'.$level.' was passed');
}
// Check if quba is a valid instance of question_usage_by_activity.
if (!$quba instanceof question_usage_by_activity) {
throw new coding_exception('get_current_diff_level: Arg 1 needs to be an instance of question_usage_by_activity',
'Invalid quba of :'.get_class($quba));
}
// Check if attempt object has required properties defined.
if (!isset($attemptobj->lowestlevel) || !isset($attemptobj->highestlevel)) {
throw new coding_exception('get_current_diff_level: Arg 3 needs to have lowestlevel and highestlevel properties',
'Invalid attemptobj of :'.$this->vardump($attemptobj));
}
// Check if attempt object has required property value types.
$conditions = !is_int($attemptobj->lowestlevel) || 0 >= $attemptobj->lowestlevel || !is_int($attemptobj->highestlevel)
|| 0 >= $attemptobj->highestlevel || $attemptobj->lowestlevel >= $attemptobj->highestlevel;
if ($conditions) {
throw new coding_exception('get_current_diff_level: Arg 3 lowestlevel and highestlevel properties must be positive '.
'integers', 'Invalid attemptobj of :'.$this->vardump($attemptobj));
}
return $this->return_current_diff_level($quba, $level, $attemptobj);
}
/**
* This function calculates the currently difficulty level of the attempt.
* @param question_usage_by_activity $quba a question usage by activity set to an attempt id
* KNIGHT (Ulrike Pado): Use a question's fraction, not mark.
* Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
* @param int $level the starting level of difficulty for the attempt
* @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel
* @return int the current level of difficulty
*/
protected function return_current_diff_level($quba, $level, $attemptobj) {
$questattempted = 0;
$correct = false;
// Set current difficulty to the starting level.
$currdiff = $level;
// Get question slots for the attempt.
$slots = $quba->get_slots();
if (empty($slots)) {
return 0;
}
// Get the last question's state.
$state = $quba->get_question_state(end($slots));
// If the state of the last question in the attempt is 'todo' remove it from the array, as the user never submitted their
// answer.
if ($state instanceof question_state_todo) {
array_pop($slots);
}
// Reset the array pointer back to the beginning.
reset($slots);
// Iterate over slots and count correct answers.
foreach ($slots as $slot) {
$corr = $this->question_was_marked_correct_by_id($slot);
if (is_null($corr)||!$corr) {
$correct = false;
} else {
$correct = true;
}
$questattempted++;
$currdiff = $this->compute_next_difficulty($currdiff, $questattempted, $correct, $attemptobj);
}
return $currdiff;
}
/**
* This function does the work to determine the next difficulty level
* @param int $level the difficulty level of the last question attempted
* @param int $questattempted the sum of questions attempted
* @param bool $correct true of the user got the previous question correct, otherwise false
* @param stdClass $attempt a data record returned from @see retrieve_attempt_record()
* @return int the next difficult level
*/
public function compute_next_difficulty($level, $questattempted, $correct, $attempt) {
$nextdifficulty = 0;
// Map the linear scale to a logrithmic logit scale.
$ls = self::convert_linear_to_logit($level, $attempt->lowestlevel, $attempt->highestlevel);
// Set the logit value of the previously attempted question's difficulty level.
$this->levellogit = $ls;
$this->difficultysum = $this->difficultysum + $this->levellogit;
// Check if the last question was marked correctly.
if ($correct) {
$nextdifficulty = $ls + 2 / $questattempted;
} else {
$nextdifficulty = $ls - 2 / $questattempted;
}
// Calculate the inverse to translate the value into a difficulty level.
$invps = 1 / ( 1 + exp( (-1 * $nextdifficulty) ) );
$invps = round($invps, 2);
$difflevel = $attempt->lowestlevel + ( $invps * ($attempt->highestlevel - $attempt->lowestlevel) );
$difflevel = round($difflevel);
$this->print_debug('compute_next_difficulty() - Next difficulty level is: '.$difflevel);
return (int) $difflevel;
}
/**
* Map an linear-scale difficulty/ability level to a logit scale
*
* @param int $level An integer level
* @param int $min The lower bound of the scale
* @param int $max The upper bound of the scale
* @return float
*/
public static function convert_linear_to_logit($level, $min, $max) {
// Map the level on a linear percentage scale.
$percent = ($level - $min) / ($max - $min);
// We will use a limit that is 1/2th the granularity of the question levels as our base.
// For example, for levels 1-100, we will use a base of 0.5% (5.3 logits),
// for levels 1-1000 we will use a base of 0.05% (7.6 logits).
//
// Note that the choice of 1/2 the granularity is somewhat arbitrary.
// The floor value for the ends of the scale is being chosen so that answers
// at the end of the scale do not excessively weight the ability measure
// in ways that are not recoverable by subsequent answers.
//
// For example, lets say that on a scale of 1-10, a user of level 5 makes
// a dumb mistake and answers two level 1 questions wrong, but then continues
// the test and answers 20 more questions with every question up to level 5
// right and those above wrong. The test should likely score the user somewhere
// a bit below 5 with 5 being included in the Standard Error.
//
// Several test runs with different floors showed that 1/1000 gave far too
// much weight to answers at the edge of the scale. 1/10 did ok, but
// 1/2 seemed to allow recovery from spurrious answers at the edges while
// still allowing consistent answers at the edges to trend the ability measure to
// the top/bottom level.
$granularity = 1 / ($max - $min);
$percentfloor = $granularity / 2;
// Avoid a division by zero error.
if ($percent == 1) {
$percent = 1 - $percentfloor;
}
// Map the percentage scale to a logrithmic logit scale.
$logit = log( $percent / (1 - $percent) );
// Check if result is inifinite.
if (is_infinite($logit)) {
$logitfloor = log( $percentfloor / (1 - $percentfloor) );
if ($logit > 0) {
return -1 * $logitfloor;
} else {
return $logitfloor;
}
}
return $logit;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class does the work of fetching a questions associated with a level of difficulty and within
* a question category.
*
* @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local;
use coding_exception;
use dml_exception;
use dml_read_exception;
use invalid_parameter_exception;
use mod_adaptivequiz\local\repository\questions_number_per_difficulty;
use mod_adaptivequiz\local\repository\questions_repository;
use mod_adaptivequiz\local\repository\tags_repository;
use moodle_exception;
use stdClass;
class fetchquestion {
/**
* The maximum number of attempts at finding a tag containing questions
*/
const MAXTAGRETRY = 5;
/**
* The maximum number of tries at finding avaiable questions
*/
const MAXNUMTRY = 100000;
/** @var stdClass $adaptivequiz object, properties come from the adaptivequiz table */
protected $adaptivequiz;
/**
* @var bool $debugenabled flag to denote developer debugging is enabled and this class should write message to the debug array
*/
protected $debugenabled = false;
/** @var array $debug array containing debugging information */
protected $debug = array();
/** @var array $tags an array of tags that used to identify eligible questions for the attempt */
protected $tags = array();
/** @var int $level the level of difficutly that will be used to fetch questions */
protected $level = 1;
/** @var string $questcatids a string of comma separated question category ids */
protected $questcatids = '';
/** @var int $minimumlevel the minimum level achievable in the attempt */
protected $minimumlevel;
/** @var int $maximumlevel the maximum level achievable in the attempt */
protected $maximumlevel;
/**
* @var array $tagquestsum an array whose keys are difficulty numbers and values are the sum of questions associated with the
* difficulty level
*/
protected $tagquestsum = array();
/** @var bool $rebuild a flag used to force the rebuilding of the $tagquestsum property */
public $rebuild = false;
/**
* The constructor.
*
* @param stdClass $adaptivequiz A record object from {adaptivequiz}.
* @param int $level Level of difficulty to look for when fetching a question.
* @param int $minimumlevel The minimum level the student can achieve.
* @param int $maximumlevel The maximum level the student can achieve.
* @param array $tags An array of accepted tags.
* @throws coding_exception
*/
public function __construct($adaptivequiz, $level, $minimumlevel, $maximumlevel, $tags = []) {
global $SESSION;
$this->adaptivequiz = $adaptivequiz;
$this->tags = $tags;
$this->tags[] = ADAPTIVEQUIZ_QUESTION_TAG;
$this->minimumlevel = $minimumlevel;
$this->maximumlevel = $maximumlevel;
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('Argument 2 is not an positive integer', 'Second parameter must be a positive integer');
}
if ($minimumlevel >= $maximumlevel) {
throw new coding_exception('Minimum level is greater than maximum level',
'Invalid minimum and maximum parameters passed');
}
$this->level = $level;
// Initialize $tagquestsum property.
if (!isset($SESSION->adpqtagquestsum)) {
$SESSION->adpqtagquestsum = array();
$this->tagquestsum = $SESSION->adpqtagquestsum;
} else {
$this->tagquestsum = $SESSION->adpqtagquestsum;
}
if (debugging('', DEBUG_DEVELOPER)) {
$this->debugenabled = true;
}
}
/**
* This function sets the level of difficulty property
* @param int $level level of difficulty
* @return void
*/
public function set_level($level = 1) {
if (!is_int($level) || 0 >= $level) {
throw new coding_exception('Argument 1 is not an positive integer', 'First parameter must be a positive integer');
}
$this->level = $level;
}
/**
* This function returns the level of difficulty property
* @return int - level of difficulty
*/
public function get_level() {
return $this->level;
}
/**
* Reset the maximum question level to search for to a new value
*
* @param int $maximumlevel
* @return void
* @throws coding_exception if the maximum level is less than minimum level
*/
public function set_maximum_level($maximumlevel) {
if ($maximumlevel < $this->minimumlevel) {
throw new coding_exception('Maximum level is less than minimum level', 'Invalid maximum level set.');
}
$this->maximumlevel = $maximumlevel;
}
/**
* Reset the maximum question level to search for to a new value
*
* @param int $maximumlevel
* @return void
* @throws coding_exception if the minimum level is less than maximum level
*/
public function set_minimum_level($minimumlevel) {
if ($minimumlevel > $this->maximumlevel) {
throw new coding_exception('Minimum level is less than maximum level', 'Invalid minimum level set.');
}
$this->minimumlevel = $minimumlevel;
}
/**
* This functions adds a message to the debugging array
* @param string $message: details of the debugging message
* @return void
*/
protected function print_debug($message = '') {
if ($this->debugenabled) {
$this->debug[] = $message;
}
}
/**
* Answer a string view of a variable for debugging purposes
* @param mixed $variable
*/
protected function vardump($variable) {
ob_start();
var_dump($variable);
return ob_get_clean();
}
/**
* This function returns the debug array
* @return array - array of debugging messages
*/
public function get_debug() {
return $this->debug;
}
/**
* This functions returns the $tagquestsum class property
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function get_tagquestsum() {
return $this->tagquestsum;
}
/**
* This functions sets the $tagquestsum class property
* @param array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function set_tagquestsum($tagquestsum) {
$this->tagquestsum = $tagquestsum;
}
/**
* This function decrements 1 from the sum of questions in a difficulty level
* @param array $tagquestsum an array equal to the $tagquestsum property, where the key is the difficulty level and the value
* is the total number of
* questions associated with it. This parameter will be modified.
* @param int $level the difficulty level
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function decrement_question_sum_from_difficulty($tagquestsum, $level) {
if (array_key_exists($level, $tagquestsum)) {
$tagquestsum[$level] -= 1;
}
return $tagquestsum;
}
/**
* This function first checks if the session variable already contains a mapping of difficulty levels and the number of
* questions associated with each level. Otherwise it constructos a mapping of difficulty levels and the number of questions
* in each difficulty level.
* @param array $tagquestsum an array equal to the $tagquestsum property, where the key is the difficulty level and the value
* is the total number of
* questions associated with it. This parameter will be modified.
* @param array $tags an array of tags used by the activity
* @param int $min the minimum difficulty allowed for the attempt
* @param int $max the maximum difficulty allowed for the attempt
* @param bool $rebuild true to force the rebuilding the difficulty question count array, otherwise false. Set to "true" only
* for brand new attempts
* @return array an array whose keys are difficulty levels and values are the sum of questions associated with the difficulty
*/
public function initalize_tags_with_quest_count($tagquestsum, $tags, $min, $max, $rebuild = false) {
global $SESSION;
// Check to see if the tagquestsum argument is initialized.
$count = count($tagquestsum);
if (empty($count) || !empty($rebuild)) {
$tagquestsum = array();
// Retrieve the question categories set for this activity.
$questcat = $this->retrieve_question_categories();
// Traverse through the array of configured tags used by the activity.
foreach ($tags as $tag) {
// Retrieve all of id for the configured tag.
$tagids = $this->retrieve_all_tag_ids($min, $max, $tag);
// Retrieve a count of all of the questions associated with each tag.
$difficultiesquestionsnumber = $this->retrieve_tags_with_question_count($tagids, $questcat);
// Traverse the $difficultiesquestionsnumber array and add the values with the values current in the
// $tagquestsum argument.
foreach ($difficultiesquestionsnumber as $questionsnumberperdifficulty) {
$difflevel = $questionsnumberperdifficulty->difficulty();
$totalquestindiff = $questionsnumberperdifficulty->questions_number();
// If the array key exists, then add the sum to what is already in the array.
if (array_key_exists($difflevel, $tagquestsum)) {
$tagquestsum[$difflevel] += $totalquestindiff;
} else {
$tagquestsum[$difflevel] = $totalquestindiff;
}
}
}
} else {
$tagquestsum = $SESSION->adpqtagquestsum;
}
return $tagquestsum;
}
/**
* This function retrieves a question associated with a Moodle tag level of difficulty. If the search for the tag turns up
* empty the function tries to find another tag whose difficulty level is either higher or lower
* @param array $excquestids an array of question ids to exclude from the search
* @return array an array of question ids
*/
public function fetch_questions($excquestids = array()) {
$questids = array();
// Initialize the difficulty tag question sum property for searching.
$this->tagquestsum = $this->initalize_tags_with_quest_count($this->tagquestsum, $this->tags, $this->minimumlevel,
$this->maximumlevel, $this->rebuild);
// If tagquestsum property ie empty then return with nothing.
if (empty($this->tagquestsum)) {
$this->print_debug('fetch_questions() - tagquestsum is empty');
return array();
}
// Check if the requested level has available questions.
if (array_key_exists($this->level, $this->tagquestsum) && 0 < $this->tagquestsum[$this->level]) {
$tagids = $this->retrieve_tag($this->level);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->print_debug('fetch_questions() - Requested level '.$this->level.' has available questions. '.
$this->tagquestsum[$this->level].' question remaining.');
return $questids;
}
// Look for a level that has avaialbe qustions.
$level = $this->level;
for ($i = 1; $i <= self::MAXNUMTRY; $i++) {
// Check if the offset level is now out of bounds and stop the loop.
if ($this->minimumlevel > $level - $i && $this->maximumlevel < $level + $i) {
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - searching levels has gone out of bounds of the min and max levels. '.
'No questions returned');
continue;
}
// First check a level higher than the originally requested level.
$newlevel = $level + $i;
/*
* If the level is within the boundries set for the attempt and the level exists and the count of question is greater
* than zero, retrieve the tag id and the questions available
*/
$condition = $newlevel <= $this->maximumlevel && array_key_exists($newlevel, $this->tagquestsum)
&& 0 < $this->tagquestsum[$newlevel];
if ($condition) {
$tagids = $this->retrieve_tag($newlevel);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->level = $newlevel;
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - original level could not be found. Returned a question from level '.
$newlevel.' instead');
continue;
}
// Check a level lower than the originally requested level.
$newlevel = $level - $i;
/*
* If the level is within the boundries set for the attempt and the level exists and the count of question is greater
* than zero, retrieve the tag id and thequestions available
*/
$condition = $newlevel >= $this->minimumlevel && array_key_exists($newlevel, $this->tagquestsum)
&& 0 < $this->tagquestsum[$newlevel];
if ($condition) {
$tagids = $this->retrieve_tag($newlevel);
$questids = $this->find_questions_with_tags($tagids, $excquestids);
$this->level = $newlevel;
$i += self::MAXNUMTRY + 1;
$this->print_debug('fetch_questions() - original level could not be found. Returned a question from level '
.$newlevel.' instead');
continue;
}
}
return $questids;
}
/**
* This function retrieves all the tag ids that can be used in this attempt.
*
* @param int $minimumlevel The minimum level the student can achieve.
* @param int $maximumlevel The maximum level the student can achieve.
* @param string $tagprefix The tag prefix used.
* @return array An array whose keys represent the difficulty level and values are tag ids.
* @throws coding_exception
* @throws dml_exception
* @throws moodle_exception
*/
public function retrieve_all_tag_ids(int $minimumlevel, int $maximumlevel, string $tagprefix): array {
if (empty(trim($tagprefix))) {
throw new invalid_parameter_exception('Tag prefix cannot be empty.');
}
$tags = array_map(function(int $level): string {
return ADAPTIVEQUIZ_QUESTION_TAG . $level;
}, range($minimumlevel, $maximumlevel));
if (!$leveltagidmap = tags_repository::get_question_level_to_tag_id_mapping_by_tag_names($tags)) {
return [];
}
return $leveltagidmap;
}
/**
* This function determines how many questions are associated with a tag, for questions contained in the category
* used by the activity.
*
* @param array $tagids an array whose key is the difficulty level and value is the tag id representing the difficulty level
* @param array $categories an array whose key and value is the question category id
* @return questions_number_per_difficulty[]
* @throws coding_exception
* @throws dml_read_exception
* @throws dml_exception
*/
public function retrieve_tags_with_question_count($tagids, $categories): array {
return questions_repository::count_questions_number_per_difficulty($tagids, $categories);
}
/**
* This function retrieves all tag ids, used by this activity and associated with a particular level of difficulty.
*
* @param int $level The level of difficulty (optional). If 0 is passed then the function will use the level class
* property, otherwise the argument value will be used.
* @return array An array whose keys represent the difficulty level and values are tag ids.
* @throws dml_exception
* @throws coding_exception
*/
public function retrieve_tag(int $level = 0): array {
$tags = array_map(function(string $tag) use($level): string {
return $tag . $level;
}, $this->tags);
if (!$tagidlist = tags_repository::get_tag_id_list_by_tag_names($tags)) {
return [];
}
return $tagidlist;
}
/**
* This function retrieves questions within the assigned question categories and
* questions associated with tagids
* @param array $tagids an array of tag is
* @param array $exclude an array of question ids to exclude from the search
* @return array an array whose keys are qustion ids and values are the question names
*/
public function find_questions_with_tags($tagids = [], $exclude = []) {
$questcat = $this->retrieve_question_categories();
return questions_repository::find_questions_with_tags($tagids, $questcat, $exclude);
}
/**
* This function retrieves all of the question categories used the activity.
* @return array an array of quesiton category ids
*/
protected function retrieve_question_categories() {
global $DB;
// Check cached result.
if (!empty($this->questcatids)) {
$this->print_debug('retrieve_question_categories() - question category ids (from cache): '.
$this->vardump($this->questcatids));
return $this->questcatids;
}
$param = array('instance' => $this->adaptivequiz->id);
$records = $DB->get_records_menu('adaptivequiz_question', $param, 'questioncategory ASC', 'id,questioncategory');
// Cache the results.
$this->questcatids = $records;
$this->print_debug('retrieve_question_categories() - question category ids: '.$this->vardump($records));
return $records;
}
/**
* The destruct method saves the difficult level and qustion number mapping to the session variable
*/
public function __destruct() {
global $SESSION;
$SESSION->adpqtagquestsum = $this->tagquestsum;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class provides access to various numeric representations of a score.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use mod_adaptivequiz\local\catalgo;
class attempt_score {
/** @var float $measuredabilitylogits The measured ability of the attempt in logits. */
protected $measuredabilitylogits = null;
/** @var float $standarderrorlogits The standard error in the score in logits. */
protected $standarderrorlogits = null;
/** @var float $lowestlevel The lowest level of question in the adaptive quiz. */
protected $lowestlevel = null;
/** @var float $highestlevel The highest level of question in the adaptive quiz. */
protected $highestlevel = null;
/**
* Constructor
*
* @return void
*/
public function __construct ($measuredabilitylogits, $standarderrorlogits, $lowestlevel, $highestlevel) {
$this->measuredabilitylogits = $measuredabilitylogits;
$this->standarderrorlogits = $standarderrorlogits;
$this->lowestlevel = $lowestlevel;
$this->highestlevel = $highestlevel;
}
/**
* Answer the measured ability in logits.
*
* @return float
*/
public function measured_ability_in_logits () {
return $this->measuredabilitylogits;
}
/**
* Answer the standard error in logits.
*
* @return float
*/
public function standard_error_in_logits () {
return $this->standarderrorlogits;
}
/**
* Answer the measured ability as a fraction 0-1.
*
* @return float
*/
public function measured_ability_in_fraction () {
return catalgo::convert_logit_to_fraction($this->measuredabilitylogits);
}
/**
* Answer the standard error a fraction 0-0.5.
*
* @return float
*/
public function standard_error_in_fraction () {
return catalgo::convert_logit_to_percent($this->standarderrorlogits);
}
/**
* Answer the measured ability on the adaptive quiz's scale
*
* @return float
*/
public function measured_ability_in_scale () {
return catalgo::map_logit_to_scale($this->measuredabilitylogits, $this->highestlevel, $this->lowestlevel);
}
/**
* Answer the standard error on the adaptive quiz's scale
*
* @return float
*/
public function standard_error_in_scale () {
return catalgo::convert_logit_to_percent($this->standarderrorlogits) * ($this->highestlevel - $this->lowestlevel);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class provides a mechanism for analysing the usage, performance, and efficacy
* of a single question in an adaptive quiz.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use context;
use InvalidArgumentException;
use mod_adaptivequiz\local\catalgo;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic_result;
use question_definition;
use stdClass;
class question_analyser {
/** @var context the context this usage belongs to. */
protected $context;
/** @var question_definition $definition The question */
protected $definition = null;
/** @var float $level The question level */
protected $level = null;
/** @var float $lowestlevel The lowest question-level in the adaptive quiz */
protected $lowestlevel = null;
/** @var float $highestlevel The highest question-level in the adaptive quiz */
protected $highestlevel = null;
/** @var array $results An array of the re objects */
protected $results = array();
/** @var array $statistics An array of the adaptivequiz_question_statistic added to this report */
protected $statistics = array();
/** @var array $statisticresults An array of the adaptivequiz_question_statistic_result added to this report */
protected $statisticresults = array();
/**
* Constructor - Create a new analyser.
*
* @param object $context
* @param question_definition $definition
* @param float $level The level (0-1) of the question.
* @return void
*/
public function __construct ($context, question_definition $definition, $level, $lowestlevel, $highestlevel) {
$this->context = $context;
$this->definition = $definition;
$this->level = $level;
$this->lowestlevel = $lowestlevel;
$this->highestlevel = $highestlevel;
}
/**
* Add an usage result for this question.
*
* @param attempt_score $score The user's score on this attempt.
* @param boolean $correct True if the user answered correctly.
* @param string $answer
* @return void
*/
public function add_result ($attemptid, $user, attempt_score $score, $correct, $answer) {
$result = new stdClass();
$result->attemptid = $attemptid;
$result->user = $user;
$result->score = $score;
$result->correct = $correct;
$result->answer = $answer;
$this->results[] = $result;
}
/**
* @return context the context this usage belongs to.
*/
public function get_owning_context() {
return $this->context;
}
/**
* Answer the question definition for this question.
*
* @return question_definition
*/
public function get_question_definition () {
return $this->definition;
}
/**
* Answer the question level for this question.
*
* @return int
*/
public function get_question_level () {
return $this->level;
}
/**
* Answer the question level for this question in logits.
*
* @return int
*/
public function get_question_level_in_logits () {
return catalgo::convert_linear_to_logit($this->level, $this->lowestlevel, $this->highestlevel);
}
/**
* Answer the results for this question
*
* @return array An array of stdClass objects.
*/
public function get_results () {
return $this->results;
}
/**
* Add and calculate a statistic.
*
* @param string $key A key to identify this statistic for sorting and printing.
* @param question_statistic $statistic
* @return void
*/
public function add_statistic ($key, question_statistic $statistic) {
if (!empty($this->statistics[$key])) {
throw new InvalidArgumentException("Statistic key '$key' is already in use.");
}
$this->statistics[$key] = $statistic;
$this->statisticresults[$key] = $statistic->calculate($this);
}
/**
* Answer a statistic result.
*
* @param string $key A key to identify this statistic.
* @return question_statistic_result
*/
public function get_statistic_result ($key) {
if (empty($this->statisticresults[$key])) {
throw new InvalidArgumentException("Unknown statistic key '$key'.");
}
return $this->statisticresults[$key];
}
/**
* Utility function to map a logit value to this question's scale
*
* @param $logit
* @return float Scaled value
*/
public function map_logit_to_scale ($logit) {
return catalgo::map_logit_to_scale($logit, $this->highestlevel, $this->lowestlevel);
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This class stores information about a particular attempt's result on a question
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis;
use Exception;
use InvalidArgumentException;
class question_result {
/** @var float $_measuredability The measured ability of the user who attempted this question */
protected $_measuredability = null;
/** @var boolean $_correct True if the user was correct in their answer */
protected $_correct = null;
/**
* Constructor - Create a new result.
*
* @param float $measuredability The measured ability (0-1) of the user in this attempt.
* @param boolean $correct
* @return void
*/
public function __construct ($measuredability, $correct) {
if (!is_numeric($measuredability) || $measuredability < 0 || $measuredability > 1) {
throw new InvalidArgumentException('$measuredability must be a float between 0 and 1.');
}
$this->_measuredability = $measuredability;
$this->_correct = (bool)$correct;
}
/**
* Magic method to provide read-only access to our parameters
*
* @param $key
* @return mixed
*/
public function __get ($key) {
$param = '$_'.$key;
if (isset($this->$param)) {
return $this->$param;
} else {
throw new Exception('Unknown property, '.get_class($this).'->'.$key.'.');
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\local\questionanalysis;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/tag/lib.php');
use core_tag_tag;
use Exception;
use InvalidArgumentException;
use mod_adaptivequiz\local\attempt\attempt_state;
use mod_adaptivequiz\local\questionanalysis\statistics\question_statistic;
use question_engine;
use stdClass;
/**
* Questions-analyser class.
*
* The class provides a mechanism for loading and analysing question usage, performance, and efficacy.
*
* @package mod_adaptivequiz
* @copyright 2013 Remote-Learner {@link http://www.remote-learner.ca/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class quiz_analyser {
/** @var array $questions An array of all questions loaded and their stats */
protected $questions = array();
/** @var array $statistics An array of the statistics added to this report */
protected $statistics = array();
/**
* Constructor - Create a new analyser.
*
* @return void
*/
public function __construct() {
}
/**
* Load attempts from an adaptive quiz instance.
*
* @param int $instance
*/
public function load_attempts(int $instance): void {
global $DB;
$adaptivequiz = $DB->get_record('adaptivequiz', ['id' => $instance], '*');
// Get all of the completed attempts for this adaptive quiz instance.
$attempts = $DB->get_records('adaptivequiz_attempt',
['instance' => $instance, 'attemptstate' => attempt_state::COMPLETED]);
foreach ($attempts as $attempt) {
if ($attempt->uniqueid == 0) {
continue;
}
$user = $DB->get_record('user', ['id' => $attempt->userid]);
if (!$user) {
$user = new stdClass();
$user->firstname = get_string('unknownuser', 'adaptivequiz');
$user->lastname = '#' . $attempt->userid;
}
// For each attempt, get the attempt's final score.
$score = new attempt_score($attempt->measure, $attempt->standarderror, $adaptivequiz->lowestlevel,
$adaptivequiz->highestlevel);
// For each attempt, loop through all questions asked and add that usage
// to the question.
$quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
foreach ($quba->get_slots() as $i => $slot) {
$question = $quba->get_question($slot);
// Create a question-analyser for the question.
if (empty($this->questions[$question->id])) {
$tags = core_tag_tag::get_item_tags_array('core_question', 'question', $question->id);
$difficulty = adaptivequiz_get_difficulty_from_tags($tags);
$this->questions[$question->id] = new question_analyser($quba->get_owning_context(), $question,
$difficulty, $adaptivequiz->lowestlevel, $adaptivequiz->highestlevel);
}
// Record the attempt score and the individual question result.
// KNIGHT: Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings
$correct = ($quba->get_question_fraction($slot) >= $adaptivequiz->acceptancethreshold);
$answer = $quba->get_response_summary($slot);
$this->questions[$question->id]->add_result($attempt->id, $user, $score, $correct, $answer);
}
}
}
/**
* Add a statistic to calculate.
*
* @param string $key A key to identify this statistic for sorting and printing.
* @param question_statistic $statistic
* @return void
*/
public function add_statistic($key, question_statistic $statistic) {
if (!empty($this->statistics[$key])) {
throw new InvalidArgumentException("Statistic key '$key' is already in use.");
}
$this->statistics[$key] = $statistic;
foreach ($this->questions as $question) {
$question->add_statistic($key, $statistic);
}
}
/**
* Answer a header row.
*
* @return array
*/
public function get_header() {
$header = array();
$header['id'] = get_string('id', 'adaptivequiz');
$header['name'] = get_string('adaptivequizname', 'adaptivequiz');
$header['level'] = get_string('attemptquestion_level', 'adaptivequiz');
foreach ($this->statistics as $key => $statistic) {
$header[$key] = $statistic->get_display_name();
}
return $header;
}
/**
* Return an array of table records, sorted by the statistics given.
*
* @param string $sort Which statistic to sort on.
* @param string $direction ASC or DESC.
* @return array
*/
public function get_records($sort = null, $direction = 'ASC') {
if (empty($this->questions)) {
return [];
}
$records = [];
foreach ($this->questions as $question) {
$record = [];
$record[] = $question->get_question_definition()->id;
$record[] = $question->get_question_definition()->name;
$record[] = $question->get_question_level();
foreach ($this->statistics as $key => $statistic) {
$record[] = $question->get_statistic_result($key)->printable();
}
$records[] = $record;
}
if ($direction != 'ASC' && $direction != 'DESC') {
throw new InvalidArgumentException('Invalid sort direction. Must be SORT_ASC or SORT_DESC, \''.$direction.'\' given.');
}
if ($direction == 'DESC') {
$direction = SORT_DESC;
} else {
$direction = SORT_ASC;
}
if (!is_null($sort)) {
$sortkeys = [];
foreach ($this->questions as $question) {
if ($sort == 'name') {
$sortkeys[] = $question->get_question_definition()->name;
$sorttype = SORT_REGULAR;
} else if ($sort == 'level') {
$sortkeys[] = $question->get_question_level();
$sorttype = SORT_NUMERIC;
} else {
$sortkeys[] = $question->get_statistic_result($sort)->sortable();
$sorttype = SORT_NUMERIC;
}
}
array_multisort($sortkeys, $direction, $sorttype, $records);
}
return $records;
}
/**
* Answer a question-analyzer for a particular question id analyze
*
* @param int $qid The question id
* @return question_analyser
* @throws Exception
*/
public function get_question_analyzer($qid) {
if (!isset($this->questions[$qid])) {
throw new Exception('Question-id not found.');
}
return $this->questions[$qid];
}
/**
* Answer the record for a single question
*
* @param int $qid The question id
* @return array
* @throws Exception
*/
public function get_record($qid) {
$question = $this->get_question_analyzer($qid);
$record = [];
$record[] = $question->get_question_definition()->id;
$record[] = $question->get_question_definition()->name;
$record[] = $question->get_question_level();
foreach ($this->statistics as $key => $statistic) {
$record[] = $question->get_statistic_result($key)->printable();
}
return $record;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use html_writer;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
use moodle_url;
use stdClass;
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @package mod_adaptivequiz
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class answers_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('answers_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results.
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate(question_analyser $analyser): question_statistic_result {
// Sort the results.
$results = $analyser->get_results();
foreach ($results as $result) {
$sortkeys[] = $result->score->measured_ability_in_logits();
}
array_multisort($sortkeys, SORT_NUMERIC, SORT_DESC, $results);
// Sort the results into three arrays based on how far above or below the question-level the users are.
$high = array();
$mid = array();
$low = array();
foreach ($results as $result) {
$ceiling = $result->score->measured_ability_in_logits() + $result->score->standard_error_in_logits();
$floor = $result->score->measured_ability_in_logits() - $result->score->standard_error_in_logits();
if ($analyser->get_question_level_in_logits() < $floor) {
// User is significantly above the question-level.
$high[] = $result;
} else if ($analyser->get_question_level_in_logits() > $ceiling) {
// User is significantly below the question-level.
$low[] = $result;
} else {
// User's ability overlaps the question level.
$mid[] = $result;
}
}
ob_start();
print html_writer::end_tag('tr');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('attemptquestion_ability', 'adaptivequiz'));
print html_writer::tag('th', get_string('user', 'adaptivequiz'));
print html_writer::tag('th', get_string('result', 'adaptivequiz'));
print html_writer::tag('th', get_string('answer', 'adaptivequiz'));
print html_writer::tag('th', '');
print html_writer::end_tag('tr');
$headings = ob_get_clean();
ob_start();
print html_writer::start_tag('table', array('class' => 'adpq_answers_table'));
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('highlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_highlevel'));
if (count($high)) {
foreach ($high as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('midlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_midlevel'));
if (count($mid)) {
foreach ($mid as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::start_tag('thead');
print html_writer::start_tag('tr');
print html_writer::tag('th', get_string('lowlevelusers', 'adaptivequiz').':',
array('colspan' => '5', 'class' => 'section'));
print $headings;
print html_writer::end_tag('thead');
print html_writer::start_tag('tbody', array('class' => 'adpq_lowlevel'));
if (count($low)) {
foreach ($low as $result) {
$this->print_user_result($result);
}
} else {
$this->print_empty_user_result();
}
print html_writer::end_tag('tbody');
print html_writer::end_tag('table');
return new answers_statistic_result(count($results), ob_get_clean());
}
/**
* Print out a user result.
*
* @param stdClass $result
*/
public function print_user_result(stdClass $result): void {
if ($result->correct) {
$class = 'adpq_correct';
} else {
$class = 'adpq_incorrect';
}
$url = new moodle_url('/mod/adaptivequiz/reviewattempt.php', ['attempt' => $result->attemptid]);
print html_writer::start_tag('tr', ['class' => $class]);
print html_writer::tag('td', round($result->score->measured_ability_in_scale(), 2));
print html_writer::tag('td', $result->user->firstname." ".$result->user->lastname);
print html_writer::tag('td', (($result->correct) ? "correct" : "incorrect"));
print html_writer::tag('td', $result->answer);
print html_writer::tag('td', html_writer::link($url, get_string('reviewattempt', 'adaptivequiz')));
print html_writer::end_tag('tr');
}
/**
* Print out an empty user-result row.
*
* @param question_analyser $analyser
* @param stdClass $result
* @return void
*/
public function print_empty_user_result () {
print html_writer::start_tag('tr');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::tag('td', '');
print html_writer::end_tag('tr');
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class answers_statistic_result implements question_statistic_result {
/** @var int $count */
protected $count = null;
/** @var string $printable */
protected $printable = null;
/**
* Constructor
*
* @param int $count
* @return void
*/
public function __construct ($count, $printable) {
$this->count = $count;
$this->printable = $printable;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
return $this->count;
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
return $this->printable;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
class discrimination_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('discrimination_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate (question_analyser $analyser) {
// Discrimination is generally defined as comparing the results of two sub-groups,
// the top 27% of test-takers (the upper group) and the bottom 27% of test-takers (the lower group),
// assuming a normal distribution of scores).
//
// Given that likely have a very sparse data-set we will instead categorize our
// responses into the upper group if the respondent's overall ability measure minus the measure's standard error
// is greater than the question's level. Likewise, responses will be categorized into the lower group if the respondent's
// ability measure plus the measure's standard error is less than the question's level.
// Responses where the user's ability measure and error-range include the question level will be ignored.
$level = $analyser->get_question_level_in_logits();
$uppergroupsize = 0;
$uppergroupcorrect = 0;
$lowergroupsize = 0;
$lowergroupcorrect = 0;
foreach ($analyser->get_results() as $result) {
if ($result->score->measured_ability_in_logits() - $result->score->standard_error_in_logits() > $level) {
// Upper group.
$uppergroupsize++;
if ($result->correct) {
$uppergroupcorrect++;
}
} else if ($result->score->measured_ability_in_logits() + $result->score->standard_error_in_logits() < $level) {
// Lower Group.
$lowergroupsize++;
if ($result->correct) {
$lowergroupcorrect++;
}
}
}
if ($uppergroupsize > 0 && $lowergroupsize > 0) {
// We need at least one result in the upper and lower groups.
$upperproportion = $uppergroupcorrect / $uppergroupsize;
$lowerproportion = $lowergroupcorrect / $lowergroupsize;
$discrimination = $upperproportion - $lowerproportion;
return new discrimination_statistic_result($discrimination);
} else {
// If we don't have any responses in the upper or lower group, then we don't have a meaningful result.
return new discrimination_statistic_result(null);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class discrimination_statistic_result implements question_statistic_result {
/** @var float $discrimination */
protected $discrimination = null;
/**
* Constructor
*
* @param float $discrimination
* @return void
*/
public function __construct ($discrimination) {
$this->discrimination = $discrimination;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
if (is_null($this->discrimination)) {
return -2;
} else {
return $this->discrimination;
}
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
if (is_null($this->discrimination)) {
return 'n/a';
} else {
return round($this->discrimination, 3);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
class percent_correct_statistic implements question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name () {
return get_string('percent_correct_display_name', 'adaptivequiz');
}
/**
* Calculate this statistic for a question's results
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate (question_analyser $analyser) {
$correct = 0;
$total = 0;
foreach ($analyser->get_results() as $result) {
$total++;
if ($result->correct) {
$correct++;
}
}
if ($total) {
return new percent_correct_statistic_result ($correct / $total);
} else {
return new percent_correct_statistic_result (0);
}
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistic-results that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
class percent_correct_statistic_result implements question_statistic_result {
/** @var float $fraction */
protected $fraction = null;
/**
* Constructor
*
* @param float $fraction
* @return void
*/
public function __construct ($fraction) {
$this->fraction = $fraction;
}
/**
* A sortable version of the result.
*
* @return mixed string or numeric
*/
public function sortable () {
return $this->fraction;
}
/**
* A printable version of the result.
*
* @param numeric $result
* @return mixed string or numeric
*/
public function printable () {
return round($this->fraction * 100).'%';
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* This interface defines the methods required for pluggable statistics that may be added to the question analysis.
*
* @copyright 2013 Middlebury College {@link http://www.middlebury.edu/}
* @copyright 2022 onwards Vitaly Potenko <potenkov@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_adaptivequiz\local\questionanalysis\statistics;
use mod_adaptivequiz\local\questionanalysis\question_analyser;
interface question_statistic {
/**
* Answer a display-name for this statistic.
*
* @return string
*/
public function get_display_name ();
/**
* Calculate this statistic for a question's results
*
* @param question_analyser $analyser
* @return question_statistic_result
*/
public function calculate (question_analyser $analyser);
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment