MDL-19870 Random allocation - work in progress

Random allocation implemented. More testing needed.
This commit is contained in:
David Mudrak
2010-01-04 17:47:32 +00:00
parent 32e3858578
commit da92436b2d
8 changed files with 370 additions and 21 deletions
+2 -1
View File
@@ -54,7 +54,8 @@ interface workshop_allocator {
*
* If a form is part of the UI, the caller should have called $PAGE->set_url(...)
* The methods must produce output instead of just returning it so mform->display() can
* be used there.
* be used there. This should be changed once we make quickforms deprecated and then,
* this method will just return the required HTML code.
*
* @param object $wsoutput workshop module renderer can be used
* @return void
+234 -14
View File
@@ -54,7 +54,7 @@ class workshop_random_allocator implements workshop_allocator {
protected $mform;
/**
* @param stdClass $workshop Workshop record
* @param workshop $workshop Workshop API object
*/
public function __construct(workshop $workshop) {
$this->workshop = $workshop;
@@ -67,7 +67,7 @@ class workshop_random_allocator implements workshop_allocator {
global $PAGE;
$customdata = array();
// $customdata['workshop'] = $this->workshop;
$customdata['workshop'] = $this->workshop;
$this->mform = new workshop_random_allocator_form($PAGE->url, $customdata);
if ($this->mform->is_cancelled()) {
redirect($PAGE->url->out(false, array(), false));
@@ -91,7 +91,14 @@ class workshop_random_allocator implements workshop_allocator {
$newallocations = array(); // array of (reviewer,reviewee) tuples
if ($numofreviews) {
$randomallocations = $this->random_allocation($authors, $reviewers, $assessments, $numofreviews, $numper);
if ($removecurrent) {
// behave as if there were no current assessments
$curassessments = array();
} else {
$curassessments = $assessments;
}
$randomallocations = $this->random_allocation($authors, $reviewers, $curassessments, $numofreviews, $numper, $o);
$this->filter_current_assessments($randomallocations, $assessments);
$newallocations = array_merge($newallocations, $randomallocations);
$o[] = 'ok::' . get_string('numofrandomlyallocatedsubmissions', 'workshop', count($randomallocations));
unset($randomallocations);
@@ -111,7 +118,7 @@ class workshop_random_allocator implements workshop_allocator {
$a = new stdClass();
$a->reviewername = fullname($reviewers[0][$reviewerid]);
$a->authorname = fullname($authors[0][$authorid]);
$o[] = 'ok::ident::' . get_string('allocationaddeddetail', 'workshop', $a);
$o[] = 'ok::indent::' . get_string('allocationaddeddetail', 'workshop', $a);
}
}
if ($removecurrent) {
@@ -128,10 +135,10 @@ class workshop_random_allocator implements workshop_allocator {
'lastname' => $assessments[$delassessmentid]->reviewerlastname,
'firstname' => $assessments[$delassessmentid]->reviewerfirstname));
if (!is_null($assessments[$delassessmentid]->grade)) {
$o[] = 'error::ident::' . get_string('allocationdeallocategraded', 'workshop', $a);
$o[] = 'error::indent::' . get_string('allocationdeallocategraded', 'workshop', $a);
unset($delassessments[$delassessmentkey]);
} else {
$o[] = 'info::ident::' . get_string('assessmentdeleteddetail', 'workshop', $a);
$o[] = 'info::indent::' . get_string('assessmentdeleteddetail', 'workshop', $a);
}
}
$this->workshop->delete_assessment($delassessments);
@@ -310,16 +317,229 @@ class workshop_random_allocator implements workshop_allocator {
}
/**
* TODO: short description.
* Allocates submission reviews randomly
*
* @param array $authors
* @param resource $reviewers
* @param array $assessments
* @param mixed $numofreviews
* @param mixed $numper
* @return TODO
* The algorithm of this function has been described at http://moodle.org/mod/forum/discuss.php?d=128473
* Please see the PDF attached to the post before you study the implementation. The goal of the function
* is to connect each "circle" (circles are representing either authors or reviewers) with a required
* number of "squares" (the other type than circles are).
*
* @param array $authors structure of grouped authors
* @param resource $reviewers structure of grouped reviewers
* @param array $assessments currently assigned assessments to be kept
* @param mixed $numofreviews number of reviewes to be allocated to each circle
* @param mixed $numper what user type the circles represent
* @param array $o reference to an array of log messages
* @return array array of (reviewerid => authorid) pairs
*/
protected function random_allocation($authors, $reviewers, $assessments, $numofreviews, $numper) {
protected function random_allocation($authors, $reviewers, $assessments, $numofreviews, $numper, &$o) {
if (WORKSHOP_USERTYPE_AUTHOR == $numper) {
// circles are authors, squares are reviewers
$o[] = 'info::Trying to allocate ' . $numofreviews . ' review(s) per author'; // todo translate
$allcircles = $authors;
$allsquares = $reviewers;
// get current workload
list($circlelinks, $squarelinks) = $this->convert_assessments_to_links($assessments);
} elseif (WORKSHOP_USERTYPE_REVIEWER == $numper) {
// circles are reviewers, squares are authors
$o[] = 'info::trying to allocate ' . $numofreviews . ' review(s) per reviewer'; // todo translate
$allcircles = $reviewers;
$allsquares = $authors;
// get current workload
list($squarelinks, $circlelinks) = $this->convert_assessments_to_links($assessments);
} else {
throw new moodle_workshop_exception($this->workshop, 'unknown user type passed');
}
$o[] = 'debug::circle links = ' . json_encode($circlelinks);
$o[] = 'debug::square links = ' . json_encode($squarelinks);
$squareworkload = array(); // individual workload indexed by squareid
$squaregroupsworkload = array(); // group workload indexed by squaregroupid
foreach ($allsquares as $squaregroupid => $squares) {
$squaregroupsworkload[$squaregroupid] = 0;
foreach ($squares as $squareid => $square) {
if (!isset($squarelinks[$squareid])) {
$squarelinks[$squareid] = array();
}
$squareworkload[$squareid] = count($squarelinks[$squareid]);
$squaregroupsworkload[$squaregroupid] += $squareworkload[$squareid];
}
$squaregroupsworkload[$squaregroupid] /= count($squares);
}
unset($squaregroupsworkload[0]); // [0] is not real group, it contains all users
$o[] = 'debug::square workload = ' . json_encode($squareworkload);
$o[] = 'debug::square group workload = ' . json_encode($squaregroupsworkload);
$gmode = groups_get_activity_groupmode($this->workshop->cm);
if (SEPARATEGROUPS == $gmode) {
// shuffle all groups but [0] which means "all users"
$circlegroups = array_keys(array_diff_key($allcircles, array(0 => null)));
shuffle($circlegroups);
} else {
// all users will be processed at once
$circlegroups = array(0);
}
$this->shuffle_assoc($circlegroups);
$o[] = 'debug::circle groups = ' . json_encode($circlegroups);
foreach ($circlegroups as $circlegroupid) {
$o[] = 'debug::processing circle group id ' . $circlegroupid;
$circles = $allcircles[$circlegroupid];
$this->shuffle_assoc($circles);
foreach ($circles as $circleid => $circle) {
$o[] = 'debug::processing circle id ' . $circleid;
if (!isset($circlelinks[$circleid])) {
$circlelinks[$circleid] = array();
}
$keeptrying = true; // is there a chance to find a square for this circle?
$failedgroups = array(); // array of groupids where the square should be chosen from (because
// of their group workload) but it was not possible (for example there
// was the only square and it had been already connected
while ($keeptrying && (count($circlelinks[$circleid]) < $numofreviews)) {
// firstly, choose a group to pick the square from
if (NOGROUPS == $gmode) {
if (in_array(0, $failedgroups)) {
$keeptrying = false;
$o[] = 'error::indent::No more peers available'; // todo translate
break;
}
$targetgroup = 0;
} elseif (SEPARATEGROUPS == $gmode) {
if (in_array($circlegroupid, $failedgroups)) {
$keeptrying = false;
$o[] = 'error::indent::No more peers available in this separate group'; // todo translate
break;
}
$targetgroup = $circlegroupid;
} elseif (VISIBLEGROUPS == $gmode) {
$trygroups = array_diff_key($squaregroupsworkload, array(0 => null)); // all but [0]
$trygroups = array_diff_key($trygroups, array_flip($failedgroups)); // withou previous failures
$targetgroup = $this->get_element_with_lowest_workload($trygroups);
}
if ($targetgroup === false) {
$keeptrying = false;
$o[] = 'error::indent::Not enough peers available'; // todo translate
break;
}
$o[] = 'debug::indent::next square should be from group id ' . $targetgroup;
// now, choose a square from the target group
$trysquares = array_intersect_key($squareworkload, $allsquares[$targetgroup]);
$o[] = 'debug::indent::individual workloads in this group are ' . json_encode($trysquares);
unset($trysquares[$circleid]); // can't allocate to self
$trysquares = array_diff_key($trysquares, array_flip($circlelinks[$circleid])); // can't re-allocate the same
$targetsquare = $this->get_element_with_lowest_workload($trysquares);
if (false === $targetsquare) {
$o[] = 'debug::indent::unable to find an available square. trying another group';
$failedgroups[] = $targetgroup;
continue;
}
$o[] = 'debug::indent::target square = ' . $targetsquare;
// ok - we have found the square
$circlelinks[$circleid][] = $targetsquare;
$squarelinks[$targetsquare][] = $circleid;
$squareworkload[$targetsquare]++;
$o[] = 'debug::indent::increasing square workload to ' . $squareworkload[$targetsquare];
if ($targetgroup) {
// recalculate the group workload
$squaregroupsworkload[$targetgroup] = 0;
foreach ($allsquares[$targetgroup] as $squareid => $square) {
$squaregroupsworkload[$targetgroup] += $squareworkload[$squareid];
}
$squaregroupsworkload[$targetgroup] /= count($allsquares[$targetgroup]);
$o[] = 'debug::indent::increasing group workload to ' . $squaregroupsworkload[$targetgroup];
}
} // end of processing this circle
} // end of processing circles in the group
} // end of processing circle groups
$returned = array();
if (WORKSHOP_USERTYPE_AUTHOR == $numper) {
// circles are authors, squares are reviewers
foreach ($circlelinks as $circleid => $squares) {
foreach ($squares as $squareid) {
$returned[] = array($squareid => $circleid);
}
}
}
if (WORKSHOP_USERTYPE_REVIEWER == $numper) {
// circles are reviewers, squares are authors
foreach ($circlelinks as $circleid => $squares) {
foreach ($squares as $squareid) {
$returned[] = array($circleid => $squareid);
}
}
}
return $returned;
}
/**
* Extracts the information about reviews from the authors' and reviewers' perspectives
*
* @param array $assessments array of assessments as returned by {@link workshop_api::get_assessments()}
* @return array of two arrays
*/
protected function convert_assessments_to_links($assessments) {
$authorlinks = array(); // [authorid] => array(reviewerid, reviewerid, ...)
$reviewerlinks = array(); // [reviewerid] => array(authorid, authorid, ...)
foreach ($assessments as $assessment) {
if (!isset($authorlinks[$assessment->authorid])) {
$authorlinks[$assessment->authorid] = array();
}
if (!isset($reviewerlinks[$assessment->reviewerid])) {
$reviewerlinks[$assessment->reviewerid] = array();
}
$authorlinks[$assessment->authorid][] = $assessment->reviewerid;
$reviewerlinks[$assessment->reviewerid][] = $assessment->authorid;
}
return array($authorlinks, $reviewerlinks);
}
/**
* Selects an element with the lowest workload
*
* If there are more elements with the same workload, choose one of them randomly. This may be
* used to select a group or user.
*
* @param array $workload [groupid] => (int)workload
* @return mixed int|bool id of the selected element or false if it is impossible to choose
*/
protected function get_element_with_lowest_workload($workload) {
if (empty($workload)) {
return false;
}
$minload = min($workload);
$minkeys = array_filter($workload, create_function('$val', 'return $val == ' . $minload . ';'));
return array_rand($minkeys);
}
/**
* Shuffle the order of array elements preserving the key=>values
*
* @author rich at home dot nl
* @link http://php.net/manual/en/function.shuffle.php#80586
* @param array $array to be shuffled
* @return true
*/
protected function shuffle_assoc(&$array) {
if (count($array) > 1) {
// $keys needs to be an array, no need to shuffle 1 item or empty arrays, anyway
$keys = array_rand($array, count($array));
foreach($keys as $key) {
$new[$key] = $array[$key];
}
$array = $new;
}
return true; // because this behaves like in-built shuffle(), which returns true
}
/**
* Filter new allocations so that they do not contain an already existing assessment
*
* @param mixed $newallocations array of ('reviewerid' => 'authorid') tuples
* @param array $assessments array of assessment records
* @return void
*/
protected function filter_current_assessments(&$newallocations, $assessments) {
foreach ($assessments as $assessment) {
$allocation = array($assessment->reviewerid => $assessment->authorid);
$foundat = array_keys($newallocations, $allocation);
$newallocations = array_diff_key($newallocations, array_flip($foundat));
}
}
}
@@ -42,16 +42,29 @@ class workshop_random_allocator_form extends moodleform {
*/
public function definition() {
$mform = $this->_form;
//$workshop = $this->_customdata['workshop'];
$workshop = $this->_customdata['workshop'];
$mform->addElement('header', 'settings', get_string('allocationsettings', 'workshop'));
$options_numofreviewes = array(0=>0,1=>1, 2=>2, 3=>2, 4=>4);
switch ($workshop->cm->groupmode) {
case NOGROUPS:
$grouplabel = get_string('groupsnone', 'group');
break;
case VISIBLEGROUPS:
$grouplabel = get_string('groupsvisible', 'group');
break;
case SEPARATEGROUPS:
$grouplabel = get_string('groupsseparate', 'group');
break;
}
$mform->addElement('static', 'groupmode', get_string('groupmode', 'group'), $grouplabel);
$options_numofreviewes = array(0=>0,1=>1, 2=>2, 3=>3, 4=>4);
$options_numper = array(WORKSHOP_USERTYPE_AUTHOR => get_string('numperauthor', 'workshop'),
WORKSHOP_USERTYPE_REVIEWER => get_string('numperreviewer', 'workshop'));
$grpnumofreviews = array();
$grpnumofreviews[] =& $mform->createElement('select', 'numofreviews', '', $options_numofreviewes);
$mform->setDefault('numofreviews', 0);
$mform->setDefault('numofreviews', 4);
$grpnumofreviews[] =& $mform->createElement('select', 'numper', '', $options_numper);
$mform->setDefault('numper', WORKSHOP_USERTYPE_AUTHOR);
$mform->addGroup($grpnumofreviews, 'grpnumofreviews', get_string('numofreviews', 'workshop'), array(' '), false);
@@ -47,6 +47,15 @@ class testable_workshop_random_allocator extends workshop_random_allocator {
public function get_unkept_assessments($assessments, $newallocations, $keepselfassessments) {
return parent::get_unkept_assessments($assessments, $newallocations, $keepselfassessments);
}
public function convert_assessments_to_links($assessments) {
return parent::convert_assessments_to_links($assessments);
}
public function get_element_with_lowest_workload($workload) {
return parent::get_element_with_lowest_workload($workload);
}
public function filter_current_assessments(&$newallocations, $assessments) {
return parent::filter_current_assessments($newallocations, $assessments);
}
}
class workshop_allocation_random_test extends UnitTestCase {
@@ -177,5 +186,102 @@ class workshop_allocation_random_test extends UnitTestCase {
$this->assertEqual(array(12), $delassessments);
}
/**
* Aggregates assessment info per author and per reviewer
*/
public function test_convert_assessments_to_links() {
// fixture setup
$assessments = array(
23 => (object)array('authorid' => 3, 'reviewerid' => 3),
45 => (object)array('authorid' => 5, 'reviewerid' => 11),
12 => (object)array('authorid' => 5, 'reviewerid' => 3),
);
// exercise SUT
list($authorlinks, $reviewerlinks) = $this->allocator->convert_assessments_to_links($assessments);
// verify
$this->assertEqual(array(3 => array(3), 5 => array(11, 3)), $authorlinks);
$this->assertEqual(array(3 => array(3, 5), 11 => array(5)), $reviewerlinks);
}
/**
* Trivial case
*/
public function test_convert_assessments_to_links_empty() {
// fixture setup
$assessments = array();
// exercise SUT
list($authorlinks, $reviewerlinks) = $this->allocator->convert_assessments_to_links($assessments);
// verify
$this->assertEqual(array(), $authorlinks);
$this->assertEqual(array(), $reviewerlinks);
}
/**
* If there is a single element with the lowest workload, it should be chosen
*/
public function test_get_element_with_lowest_workload_deterministic() {
// fixture setup
$workload = array(4 => 6, 9 => 1, 10 => 2);
// exercise SUT
$chosen = $this->allocator->get_element_with_lowest_workload($workload);
// verify
$this->assertEqual(9, $chosen);
}
/**
* If there are no elements available, must return false
*/
public function test_get_element_with_lowest_workload_impossible() {
// fixture setup
$workload = array();
// exercise SUT
$chosen = $this->allocator->get_element_with_lowest_workload($workload);
// verify
$this->assertTrue($chosen === false);
}
/**
* If there are several elements with the lowest workload, one of them should be chosen randomly
*/
public function test_get_element_with_lowest_workload_random() {
// fixture setup
$workload = array(4 => 6, 9 => 2, 10 => 2);
// exercise SUT
$elements = $this->allocator->get_element_with_lowest_workload($workload);
// verify
// in theory, this test can fail even if the function works well. However, the probability of getting
// a row of a hundred same ids in this use case is 1/pow(2, 100)
// also, this just tests that each of the two elements has been chosen at least once. this is not to
// measure the quality or randomness of the algorithm
$counts = array(4 => 0, 9 => 0, 10 => 0);
for ($i = 0; $i < 100; $i++) {
$chosen = $this->allocator->get_element_with_lowest_workload($workload);
if (!in_array($chosen, array(4, 9, 10))) {
$this->fail('Invalid element chosen');
break;
} else {
$counts[$this->allocator->get_element_with_lowest_workload($workload)]++;
}
}
$this->assertTrue(($counts[9] > 0) && ($counts[10] > 0));
}
/**
* Filter new assessments so they do not contain existing
*/
public function test_filter_current_assessments() {
// fixture setup
$newallocations = array(array(3 => 5), array(11 => 5), array(2 => 9), array(3 => 5));
$assessments = array(
23 => (object)array('authorid' => 3, 'reviewerid' => 3),
45 => (object)array('authorid' => 5, 'reviewerid' => 11),
12 => (object)array('authorid' => 5, 'reviewerid' => 3),
);
// exercise SUT
$this->allocator->filter_current_assessments($newallocations, $assessments);
// verify
$this->assertEqual(array_values($newallocations), array(array(2 => 9)));
}
}