From 16c2061f5ca8dc7325df9927ac0fc71955573f31 Mon Sep 17 00:00:00 2001 From: Christian Lawson-Perfect Date: Wed, 4 Dec 2024 11:29:00 +0000 Subject: [PATCH] fix mastery script when the question queue is empty This can't happen in the standard script, but I wrote an extension that ended up emptying the queue in the current topic, and I would have had to rewrite the `after_answering` note. I think just adding some handling for this edge case in the built-in script is the right thing to do. --- diagnostic_scripts/mastery.jme | 10 +++++++--- tests/diagnostic_scripts.js | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/diagnostic_scripts/mastery.jme b/diagnostic_scripts/mastery.jme index 9690e0e54..0a01fed12 100644 --- a/diagnostic_scripts/mastery.jme +++ b/diagnostic_scripts/mastery.jme @@ -111,17 +111,21 @@ correct (Did the student get the current question right?): after_answering (The state after the student answers a question): let( queue, state["question_queue"], + empty_queue, len(queue) = 0, nquestion, // Set the status of this question in the queue. - merge( + if(not empty_queue, + merge( queue[0], ["status": if(correct,"passed","failed")] - ), + ), + nothing + ), nqueue, // Change the queue: either remove the current question if correct, or add it to the end. - queue[1..len(queue)] + if(correct,[],[nquestion]), + queue[1..len(queue)] + if(correct or empty_queue, [], [nquestion]), ntopics, // Update the list of topics, setting the current topic to "passed" if the queue is now empty. diff --git a/tests/diagnostic_scripts.js b/tests/diagnostic_scripts.js index 845a6df5d..b375ff641 100644 --- a/tests/diagnostic_scripts.js +++ b/tests/diagnostic_scripts.js @@ -3,6 +3,6 @@ Numbas.queueScript('diagnostic_scripts',[],function() { "diagnosys": "state (Produces the initial value of the state object): // should be renamed \"initial_state\"\n [\n \"topics\": map(\n [\n \"topic\": topic,\n \"status\": \"unknown\" // \"unknown\", \"passed\", or \"failed\"\n ],\n topic,\n values(topics)\n ),\n \"retries\": 3,\n \"finished\": false,\n ]\n\ntopics_by_objective (A dictionary mapping a learning objective name to a list of indices of topics):\n dict(map(\n let(\n ltopics, values(topics),\n indices, filter(lo[\"name\"] in ltopics[j][\"learning_objectives\"], j, 0..len(ltopics)-1),\n [lo[\"name\"],indices]\n ),\n lo,\n learning_objectives\n ))\n\nunknown_topics (Which topics are still unknown?): \n map(x[\"topic\"],x,filter(x[\"status\"]=\"unknown\",x,state[\"topics\"]))\n\nfirst_topic (The first topic to pick a question on):\n unknown_topics[floor(len(unknown_topics)/2)][\"name\"]\n\nfirst_question (The first question to show the student):\n random(topics[first_topic][\"questions\"])\n\nget_dependents (An expression which gets the topics to update after answering a question):\n expression(\"\"\"\n [target] + flatten(map(eval(get_dependents,[\"target\":t,\"correct\":correct]),t,topics[target][if(correct,\"depends_on\",\"leads_to\")]))\n \"\"\")\n\ncorrect (Did the student get the current question right?):\n current_question[\"credit\"]=1\n\nafter_answering (Update the state after the student answers a question):\n let(\n ntopics, eval(get_dependents,[\"target\":current_topic,\"correct\":correct])\n , nstate, state + ['topics': map(\n if(tstate[\"topic\"][\"name\"] in ntopics, tstate + [\"status\":if(correct,\"passed\",\"failed\")], tstate),\n tstate,\n state[\"topics\"]\n )]\n , nstate\n )\n\naction_retry (Use up one retry and visit the same topic again):\n [\n \"label\": translate(\"diagnostic.use retry\"),\n \"state\": state + [\"retries\": state[\"retries\"]-1],\n \"next_question\": random(topics[current_topic][\"questions\"])\n ]\n\naction_stop (Stop the exam):\n [\n \"label\": translate(\"diagnostic.end test\"),\n \"state\": state,\n \"next_question\": nothing\n ]\n\naction_move_on (Move to the next topic, or end the exam if there are no more):\n let(\n state, after_answering,\n immediate_next_topics, topics[current_topic][if(correct, \"leads_to\", \"depends_on\")],\n unknown_topics, map(x[\"topic\"],x,filter(x[\"status\"]=\"unknown\",x,state[\"topics\"])),\n unknown_immediate_topics, filter(x[\"name\"] in immediate_next_topics,x,unknown_topics),\n next_topics, if(len(unknown_immediate_topics), unknown_immediate_topics, unknown_topics),\n finished, len(next_topics)=0 or state[\"finished\"],\n topic,\n if(not finished,\n next_topics[floor(len(next_topics)/2)][\"name\"]\n ,\n nothing\n ),\n [\n \"label\": translate(\"diagnostic.move to next topic\"),\n \"state\": after_answering,\n \"next_question\": if(not finished, random(topics[topic][\"questions\"]), nothing)\n ]\n )\n\ncan_move_on:\n action_move_on[\"next_question\"]<>nothing\n\nnext_actions (Actions to offer to the student when they ask to move on):\n let(\n feedback, retries_feedback+\"\\n\\n\"+translate(\"diagnostic.next step question\")\n , [\n \"feedback\": feedback,\n \"actions\": if(not correct and state[\"retries\"]>0, [action_retry], []) + if(can_move_on,[action_move_on],[action_stop])\n ]\n )\n\nafter_exam_ended (Update the state after the exam ends):\n let(\n state, after_answering,\n ntopics, map(t+[\"status\": if(t[\"status\"]=\"unknown\",\"failed\",t[\"status\"])],t,state[\"topics\"]),\n state+[\"finished\": true]\n )\n\nfinished (Is the test finished? True if there are no unknown topics):\n len(unknown_topics)=0 or state[\"finished\"]\n\ntotal_progress:\n let(\n num_topics, len(state[\"topics\"]),\n known, filter(tstate[\"status\"]<>\"unknown\",tstate,state[\"topics\"]),\n passed, filter(tstate[\"status\"]=\"passed\",tstate,known),\n num_known, len(known),\n num_passed, len(passed),\n [\n \"name\": translate(\"control.total\"),\n \"progress\": if(num_topics>0,num_known/num_topics,0), \n \"credit\": if(num_known>0,num_passed/num_topics,0)\n ]\n )\n\nlearning_objective_progress:\n map(\n let(\n tstates, map(state[\"topics\"][j],j,topics_by_objective[lo[\"name\"]]),\n known, filter(tstate[\"status\"]<>\"unknown\",tstate,tstates),\n passed, filter(tstate[\"status\"]=\"passed\",tstate,known),\n num_topics, len(tstates),\n num_known, len(known),\n num_passed, len(passed),\n [\"name\": lo[\"name\"], \"progress\": if(num_topics>0,num_known/num_topics,0), \"credit\": if(finished,num_passed/num_topics,if(num_known>0,num_passed/num_known,0))]\n ),\n lo,\n learning_objectives\n )\n\nprogress (Progress on each of the learning objectives, plus total progress):\n learning_objective_progress+\n total_progress\n\nretries_feedback:\n translate(\"diagnostic.now assessing topic\", [\"current_topic\": current_topic]) + \" \" +\n let(\n retries, state[\"retries\"], \n pluralise(retries, translate(\"diagnostic.one retry left\"), translate(\"diagnostic.retries left\", [\"retries\": retries ]))\n )\n + \" \" +\n let(\n p,total_progress[\"progress\"],\n percentage, dpformat(100p, 0),\n translate(\"diagnostic.percentage completed\", [\"percentage\": percentage])\n )\n\nweak_objective_threshold (The amount of credit below which a learning objective is considered weak):\n 0.6\n\nfinished_feedback:\n let(\n weak_objectives, filter(p[\"credit\"] (if(predicate(x), action(x), x) for: x of: list))\n\n\nquestion_queue_for_topic (When starting a topic, this function makes a queue of questions which must be answered):\n (topic) -> (\n [\"question\": q, \"status\": \"unknown\"]\n for: q\n of: topic[\"topic\"][\"questions\"]\n )\n\n\nstart_topic (A function to update the state, setting the current topic and filling the question queue from that topic):\n (state,topic) -> \n merge(\n state,\n [\n \"current_topic\": topic,\n \"question_queue\": question_queue_for_topic(topic)\n ]\n )\n\n\nget_next_question (A function to get the next question from the queue):\n (state) -> \n let(\n queue, state[\"question_queue\"],\n\n if(len(queue)>0,\n queue[0][\"question\"], \n nothing\n )\n )\n\n\nnext_topic (The next topic to assess):\n (state) ->\n let(\n topics, state[\"topics\"], // List of the state object for each topic\n\n topicdict, dict([t[\"topic\"][\"name\"],t] for: t of: topics), // A mapping from topic names to topic state objects\n\n available_topics, // Topics that we can move to next: either no dependencies, or all their dependencies have been passed.\n filter(\n t -> let(\n all_deps_passed, all(topicdict[topicname][\"status\"] <> \"unknown\" for: topicname of: t[\"topic\"][\"depends_on\"]),\n all_deps_passed and t[\"status\"]=\"unknown\"\n )\n , topics\n ),\n\n if(len(available_topics)>0,available_topics[0],nothing)\n )\n\n\n/////////////////////\n// Initial variables\n/////////////////////\n\nfirst_topic (The first topic to assess):\n // Picks the first topic which doesn't depend on anything.\n let(\n topics, pre_state[\"topics\"],\n filter(t -> len(t[\"topic\"][\"depends_on\"])=0, topics)[0]\n )\n\n\nfirst_question (The first question to show the student):\n get_next_question(state)\n\n\npre_state (A template for the `state` variable, which will be filled in with the chosen start topic):\n [\n \"topics\": // For each topic, both the given info about that topic and a status, either \"passed\" or \"unknown\".\n [\n \"topic\": topic,\n \"status\": if(len(topic[\"questions\"])=0,\"passed\",\"unknown\") // A topic is \"passed\" if there are no questions left unasked.\n ]\n for: topic\n of: values(topics)\n ,\n \"finished\": false // Is the exam over?\n ]\n\n\nstate (The initial state variable):\n start_topic(pre_state, first_topic)\n\n\n/////////////////////////////\n// Notes used when moving on\n/////////////////////////////\n\ncorrect (Did the student get the current question right?):\n current_question[\"credit\"]=1\n\n\nafter_answering (The state after the student answers a question):\n let(\n queue, state[\"question_queue\"],\n\n nquestion, \n // Set the status of this question in the queue.\n merge(\n queue[0],\n [\"status\": if(correct,\"passed\",\"failed\")]\n ), \n\n nqueue, \n // Change the queue: either remove the current question if correct, or add it to the end.\n queue[1..len(queue)] + if(correct,[],[nquestion]), \n\n ntopics,\n // Update the list of topics, setting the current topic to \"passed\" if the queue is now empty.\n if(len(nqueue)=0,\n update_where(t -> t=state[\"current_topic\"], t -> t+[\"status\": \"passed\"], state[\"topics\"]),\n state[\"topics\"]\n ),\n\n merge(\n // Return a new state with the new list of topics and question queue\n state,\n [\"topics\": ntopics, \"question_queue\": nqueue]\n )\n )\n\n\n///////////\n// Actions\n///////////\n\naction_next_question_same_topic (Move to the next question in the queue):\n [\n \"label\": translate(\"diagnostic.move to next question in topic\"),\n \"state\": after_answering,\n \"next_question\": get_next_question(after_answering)\n ]\n\naction_next_topic (Move to the next topic):\n let(\n state, after_answering, // Start with the state we get from answering the question.\n\n topic, next_topic(state), // Pick a new topic.\n\n nstate, \n if(topic <> nothing, \n start_topic(state, topic) // Update the state with the new topic.\n , \n state // Otherwise, there's no next topic, so this action won't be used.\n ),\n\n [\n \"label\": translate(\"diagnostic.move to next topic\"),\n \"state\": nstate,\n \"next_question\": get_next_question(nstate)\n ]\n )\n\nnext_actions (The list of possible actions after answering a question):\n let(\n state, after_answering,\n queue_empty, len(state[\"question_queue\"])=0,\n actions, \n switch(\n not queue_empty,\n [action_next_question_same_topic] // Move to the next question in the queue\n , next_topic(state) <> nothing,\n [action_next_topic] // Move to the next topic\n ,\n [] // End the exam\n ),\n\n [\n \"feedback\": \"\",\n \"actions\": actions\n ]\n )\n\nafter_exam_ended (The state after the exam has finished):\n merge(\n after_answering,\n [\"finished\": true]\n )\n\n\n//////////////////\n// Feedback notes\n//////////////////\n\nprogress (Summarise the student's progress through the exam):\n let(\n passed_topics, filter(t -> t[\"status\"] = \"passed\", state[\"topics\"])\n , num_passed_topics, len(passed_topics)\n , num_topics, len(state[\"topics\"])\n , exam_progress, num_passed_topics/num_topics\n , topic_credit, 1-len(state[\"question_queue\"])/len(state[\"current_topic\"][\"topic\"][\"questions\"])\n , current_topic, state[\"current_topic\"][\"topic\"][\"name\"]\n , lo_progress,\n let(\n ltopics, filter(t -> lo[\"name\"] in t[\"topic\"][\"learning_objectives\"], state[\"topics\"]),\n passed, filter(t -> t[\"status\"]=\"passed\", ltopics),\n p, len(passed)/len(ltopics),\n [\"name\": lo[\"name\"], \"progress\": p, \"credit\": p]\n )\n for: lo\n of: learning_objectives\n , topic_progress, [[\"name\": \"Current topic: {current_topic}\", \"progress\": topic_credit, \"credit\": topic_credit]]\n\n , if(state[\"finished\"], [], topic_progress)\n + lo_progress\n + [\n [\"name\": translate(\"control.total\"), \"progress\": exam_progress, \"credit\": exam_progress]\n ]\n )\n\nfeedback (A text description of the current state): \n if(state[\"finished\"],\n translate(\"diagnostic.complete\")\n ,\n translate(\"diagnostic.studying topic\", [\"topic\": state[\"current_topic\"][\"topic\"][\"name\"]])\n )\n\n" +"// Mastery diagnostic script\n// The student must answer every question correctly.\n// They start with a topic that has no dependencies.\n// After answering a question, if they get it correct, it's done forever.\n// If it's incorrect, the question is put on the end of that topic's \"queue\", \n// so they'll be asked it again later.\n// Once all the questions in the topic are answered correctly, the next topic\n// with no unmet dependencies is picked.\n\n//////////////\n// Functions\n//////////////\n\nupdate_where (Update items in a list which satisfy the given predicate, applying the given function to them):\n ((predicate, action, list) -> (if(predicate(x), action(x), x) for: x of: list))\n\n\nquestion_queue_for_topic (When starting a topic, this function makes a queue of questions which must be answered):\n (topic) -> (\n [\"question\": q, \"status\": \"unknown\"]\n for: q\n of: topic[\"topic\"][\"questions\"]\n )\n\n\nstart_topic (A function to update the state, setting the current topic and filling the question queue from that topic):\n (state,topic) -> \n merge(\n state,\n [\n \"current_topic\": topic,\n \"question_queue\": question_queue_for_topic(topic)\n ]\n )\n\n\nget_next_question (A function to get the next question from the queue):\n (state) -> \n let(\n queue, state[\"question_queue\"],\n\n if(len(queue)>0,\n queue[0][\"question\"], \n nothing\n )\n )\n\n\nnext_topic (The next topic to assess):\n (state) ->\n let(\n topics, state[\"topics\"], // List of the state object for each topic\n\n topicdict, dict([t[\"topic\"][\"name\"],t] for: t of: topics), // A mapping from topic names to topic state objects\n\n available_topics, // Topics that we can move to next: either no dependencies, or all their dependencies have been passed.\n filter(\n t -> let(\n all_deps_passed, all(topicdict[topicname][\"status\"] <> \"unknown\" for: topicname of: t[\"topic\"][\"depends_on\"]),\n all_deps_passed and t[\"status\"]=\"unknown\"\n )\n , topics\n ),\n\n if(len(available_topics)>0,available_topics[0],nothing)\n )\n\n\n/////////////////////\n// Initial variables\n/////////////////////\n\nfirst_topic (The first topic to assess):\n // Picks the first topic which doesn't depend on anything.\n let(\n topics, pre_state[\"topics\"],\n filter(t -> len(t[\"topic\"][\"depends_on\"])=0, topics)[0]\n )\n\n\nfirst_question (The first question to show the student):\n get_next_question(state)\n\n\npre_state (A template for the `state` variable, which will be filled in with the chosen start topic):\n [\n \"topics\": // For each topic, both the given info about that topic and a status, either \"passed\" or \"unknown\".\n [\n \"topic\": topic,\n \"status\": if(len(topic[\"questions\"])=0,\"passed\",\"unknown\") // A topic is \"passed\" if there are no questions left unasked.\n ]\n for: topic\n of: values(topics)\n ,\n \"finished\": false // Is the exam over?\n ]\n\n\nstate (The initial state variable):\n start_topic(pre_state, first_topic)\n\n\n/////////////////////////////\n// Notes used when moving on\n/////////////////////////////\n\ncorrect (Did the student get the current question right?):\n current_question[\"credit\"]=1\n\n\nafter_answering (The state after the student answers a question):\n let(\n queue, state[\"question_queue\"],\n empty_queue, len(queue) = 0,\n\n nquestion, \n // Set the status of this question in the queue.\n if(not empty_queue, \n merge(\n queue[0],\n [\"status\": if(correct,\"passed\",\"failed\")]\n ), \n nothing\n ),\n\n nqueue, \n // Change the queue: either remove the current question if correct, or add it to the end.\n queue[1..len(queue)] + if(correct or empty_queue,[],[nquestion]), \n\n ntopics,\n // Update the list of topics, setting the current topic to \"passed\" if the queue is now empty.\n if(len(nqueue)=0,\n update_where(t -> t=state[\"current_topic\"], t -> t+[\"status\": \"passed\"], state[\"topics\"]),\n state[\"topics\"]\n ),\n\n merge(\n // Return a new state with the new list of topics and question queue\n state,\n [\"topics\": ntopics, \"question_queue\": nqueue]\n )\n )\n\n\n///////////\n// Actions\n///////////\n\naction_next_question_same_topic (Move to the next question in the queue):\n [\n \"label\": translate(\"diagnostic.move to next question in topic\"),\n \"state\": after_answering,\n \"next_question\": get_next_question(after_answering)\n ]\n\naction_next_topic (Move to the next topic):\n let(\n state, after_answering, // Start with the state we get from answering the question.\n\n topic, next_topic(state), // Pick a new topic.\n\n nstate, \n if(topic <> nothing, \n start_topic(state, topic) // Update the state with the new topic.\n , \n state // Otherwise, there's no next topic, so this action won't be used.\n ),\n\n [\n \"label\": translate(\"diagnostic.move to next topic\"),\n \"state\": nstate,\n \"next_question\": get_next_question(nstate)\n ]\n )\n\nnext_actions (The list of possible actions after answering a question):\n let(\n state, after_answering,\n queue_empty, len(state[\"question_queue\"])=0,\n actions, \n switch(\n not queue_empty,\n [action_next_question_same_topic] // Move to the next question in the queue\n , next_topic(state) <> nothing,\n [action_next_topic] // Move to the next topic\n ,\n [] // End the exam\n ),\n\n [\n \"feedback\": \"\",\n \"actions\": actions\n ]\n )\n\nafter_exam_ended (The state after the exam has finished):\n merge(\n after_answering,\n [\"finished\": true]\n )\n\n\n//////////////////\n// Feedback notes\n//////////////////\n\nprogress (Summarise the student's progress through the exam):\n let(\n passed_topics, filter(t -> t[\"status\"] = \"passed\", state[\"topics\"])\n , num_passed_topics, len(passed_topics)\n , num_topics, len(state[\"topics\"])\n , exam_progress, num_passed_topics/num_topics\n , topic_credit, 1-len(state[\"question_queue\"])/len(state[\"current_topic\"][\"topic\"][\"questions\"])\n , current_topic, state[\"current_topic\"][\"topic\"][\"name\"]\n , lo_progress,\n let(\n ltopics, filter(t -> lo[\"name\"] in t[\"topic\"][\"learning_objectives\"], state[\"topics\"]),\n passed, filter(t -> t[\"status\"]=\"passed\", ltopics),\n p, len(passed)/len(ltopics),\n [\"name\": lo[\"name\"], \"progress\": p, \"credit\": p]\n )\n for: lo\n of: learning_objectives\n , topic_progress, [[\"name\": \"Current topic: {current_topic}\", \"progress\": topic_credit, \"credit\": topic_credit]]\n\n , if(state[\"finished\"], [], topic_progress)\n + lo_progress\n + [\n [\"name\": translate(\"control.total\"), \"progress\": exam_progress, \"credit\": exam_progress]\n ]\n )\n\nfeedback (A text description of the current state): \n if(state[\"finished\"],\n translate(\"diagnostic.complete\")\n ,\n translate(\"diagnostic.studying topic\", [\"topic\": state[\"current_topic\"][\"topic\"][\"name\"]])\n )\n\n" }; });