diff --git a/diagnostic_scripts/mastery.jme b/diagnostic_scripts/mastery.jme index 7d7550605..e3506d393 100644 --- a/diagnostic_scripts/mastery.jme +++ b/diagnostic_scripts/mastery.jme @@ -1,157 +1,235 @@ -pre_state: - [ - "topics": map( +// Mastery diagnostic script +// The student must answer every question correctly. +// They start with a topic that has no dependencies. +// After answering a question, if they get it correct, it's done forever. +// If it's incorrect, the question is put on the end of that topic's "queue", +// so they'll be asked it again later. +// Once all the questions in the topic are answered correctly, the next topic +// with no unmet dependencies is picked. + +////////////// +// Functions +////////////// + +update_where (Update items in a list which satisfy the given predicate, applying the given function to them): + ((predicate, action, list) -> (if(predicate(x), action(x), x) for: x of: list)) + + +question_queue_for_topic (When starting a topic, this function makes a queue of questions which must be answered): + (topic) -> ( + ["question": q, "status": "unknown"] + for: q + of: topic["topic"]["questions"] + ) + + +start_topic (A function to update the state, setting the current topic and filling the question queue from that topic): + (state,topic) -> + merge( + state, [ - "topic": topic, - "status": if(len(topic["questions"])=0,"passed","unknown") - ], - topic, - values(topics) - ), - "finished": false - ] + "current_topic": topic, + "question_queue": question_queue_for_topic(topic) + ] + ) + + +get_next_question (A function to get the next question from the queue): + (state) -> + let( + queue, state["question_queue"], + + if(len(queue)>0, + queue[0]["question"], + nothing + ) + ) + + +next_topic (The next topic to assess): + (state) -> + let( + topics, state["topics"], // List of the state object for each topic + + topicdict, dict([t["topic"]["name"],t] for: t of: topics), // A mapping from topic names to topic state objects + + available_topics, // Topics that we can move to next: either no dependencies, or all their dependencies have been passed. + filter( + t -> let( + all_deps_passed, all(topicdict[topicname]["status"]="passed" for: topicname of: t["topic"]["depends_on"]), + all_deps_passed and t["status"]<>"passed" + ) + , topics + ), + + if(len(available_topics)>0,available_topics[0],nothing) + ) + + +///////////////////// +// Initial variables +///////////////////// first_topic (The first topic to assess): + // Picks the first topic which doesn't depend on anything. let( topics, pre_state["topics"], - // - filter(len(t["topic"]["depends_on"])=0,t,topics)[0] + filter(t -> len(t["topic"]["depends_on"])=0, topics)[0] ) -state: - eval(start_topic,["state": pre_state, "topic": first_topic]) - -start_topic (An expression to make a question queue for the given topic): - expression(""" - state + [ - "current_topic": topic, - "question_queue": map( - ["question": q, "status": "unknown"], - q, - topic["topic"]["questions"] - ) - ] - """) +first_question (The first question to show the student): + get_next_question(state) + + +pre_state (A template for the `state` variable, which will be filled in with the chosen start topic): + [ + "topics": // For each topic, both the given info about that topic and a status, either "passed" or "unknown". + [ + "topic": topic, + "status": if(len(topic["questions"])=0,"passed","unknown") // A topic is "passed" if there are no questions left unasked. + ] + for: topic + of: values(topics) + , + "finished": false // Is the exam over? + ] + + +state (The initial state variable): + start_topic(pre_state, first_topic) -first_question: - eval(get_next_question) +///////////////////////////// +// Notes used when moving on +///////////////////////////// correct (Did the student get the current question right?): current_question["credit"]=1 -after_answering (Update the state after the student answers a question): + +after_answering (The state after the student answers a question): let( queue, state["question_queue"], - nq, state["question_queue"][0] + ["status": if(correct,"passed","failed")], - nqueue, queue[1..len(queue)] + if(correct,[],[nq]), + + nquestion, + // Set the status of this question in the queue. + merge( + queue[0], + ["status": if(correct,"passed","failed")] + ), + + nqueue, + // Change the queue: either remove the current question if correct, or add it to the end. + queue[1..len(queue)] + if(correct,[],[nquestion]), + ntopics, + // Update the list of topics, setting the current topic to "passed" if the queue is now empty. if(len(nqueue)=0, - map(if(t=state["current_topic"], t+["status":"passed"], t), t, state["topics"]), + update_where(t -> t=state["current_topic"], t -> t+["status": "passed"], state["topics"]), state["topics"] ), - nstate, state + ["topics": ntopics, "question_queue": nqueue], - // - nstate - ) -next_topic (The next topic to assess): - expression(""" - let( - topics, state["topics"], - topicdict, dict(map([t["topic"]["name"],t], t, topics)), - available_topics, - filter(let( - all_deps_passed, all(map(topicdict[tn]["status"]="passed",tn,t["topic"]["depends_on"])), - all_deps_passed and t["status"]<>"passed" - ),t,topics), - // - if(len(available_topics)>0,available_topics[0],nothing) + merge( + // Return a new state with the new list of topics and question queue + state, + ["topics": ntopics, "question_queue": nqueue] ) - """) + ) -get_next_question (An expression to get the next question from the queue): - expression(""" - let( - queue, state["question_queue"], - // - if(len(queue)>0,queue[0]["question"], nothing) - ) - """) + +/////////// +// Actions +/////////// action_next_question_same_topic (Move to the next question in the queue): [ "label": translate("diagnostic.move to next question in topic"), "state": after_answering, - "next_question": eval(get_next_question,["state": after_answering]) + "next_question": get_next_question(after_answering) ] action_next_topic (Move to the next topic): let( - state, after_answering, - topic, eval(next_topic), - nstate, if(topic<>nothing,eval(start_topic),state), - // + state, after_answering, // Start with the state we get from answering the question. + + topic, next_topic(state), // Pick a new topic. + + nstate, + if(topic <> nothing, + start_topic(state, topic) // Update the state with the new topic. + , + state // Otherwise, there's no next topic, so this action won't be used. + ), + [ "label": translate("diagnostic.move to next topic"), "state": nstate, - "next_question": eval(get_next_question,["state":nstate]) + "next_question": get_next_question(nstate) ] ) -next_actions: +next_actions (The list of possible actions after answering a question): let( state, after_answering, queue_empty, len(state["question_queue"])=0, actions, switch( not queue_empty, - [action_next_question_same_topic] - , eval(next_topic)<>nothing, - [action_next_topic] + [action_next_question_same_topic] // Move to the next question in the queue + , next_topic(state) <> nothing, + [action_next_topic] // Move to the next topic , - [] + [] // End the exam ), - // + [ "feedback": "", "actions": actions ] ) -progress: +after_exam_ended (The state after the exam has finished): + merge( + after_answering, + ["finished": true] + ) + + +////////////////// +// Feedback notes +////////////////// + +progress (Summarise the student's progress through the exam): let( - passed_topics, filter(t["status"]="passed",t,state["topics"]), - num_passed_topics, len(passed_topics), - num_topics, len(state["topics"]), - exam_progress, num_passed_topics/num_topics, - topic_credit, 1-len(state["question_queue"])/len(state["current_topic"]["topic"]["questions"]), - current_topic, state["current_topic"]["topic"]["name"], - lo_progress, map( + passed_topics, filter(t -> t["status"]="passed", state["topics"]) + , num_passed_topics, len(passed_topics) + , num_topics, len(state["topics"]) + , exam_progress, num_passed_topics/num_topics + , topic_credit, 1-len(state["question_queue"])/len(state["current_topic"]["topic"]["questions"]) + , current_topic, state["current_topic"]["topic"]["name"] + , lo_progress, let( - ltopics, filter(lo["name"] in t["topic"]["learning_objectives"], t, state["topics"]), - passed, filter(t["status"]="passed",t,ltopics), + ltopics, filter(t -> lo["name"] in t["topic"]["learning_objectives"], state["topics"]), + passed, filter(t -> t["status"]="passed", ltopics), p, len(passed)/len(topics), ["name": lo["name"], "progress": p, "credit": p] - ), - lo, - learning_objectives - ), - topic_progress, [["name": "Current topic: {current_topic}", "progress": topic_credit, "credit": topic_credit]], - // - topic_progress + lo_progress + - [ - ["name": translate("control.total"), "progress": exam_progress, "credit": exam_progress] - ] + ) + for: lo + of: learning_objectives + , topic_progress, [["name": "Current topic: {current_topic}", "progress": topic_credit, "credit": topic_credit]] + + , topic_progress + + lo_progress + + [ + ["name": translate("control.total"), "progress": exam_progress, "credit": exam_progress] + ] ) -feedback: +feedback (A text description of the current state): if(state["finished"], translate("diagnostic.complete") , translate("diagnostic.studying topic", ["topic": state["current_topic"]["topic"]["name"]]) ) -after_exam_ended: - after_answering + ["finished": true] diff --git a/tests/diagnostic_scripts.js b/tests/diagnostic_scripts.js index 2deafa3e8..95f44eaa7 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\"]\"passed\"\n ),t,topics),\n //\n if(len(available_topics)>0,available_topics[0],nothing)\n )\n \"\"\")\n\nget_next_question (An expression to get the next question from the queue):\n expression(\"\"\"\n let(\n queue, state[\"question_queue\"],\n //\n if(len(queue)>0,queue[0][\"question\"], nothing)\n )\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\": eval(get_next_question,[\"state\": after_answering])\n ]\n\naction_next_topic (Move to the next topic):\n let(\n state, after_answering,\n topic, eval(next_topic),\n nstate, if(topic<>nothing,eval(start_topic),state),\n //\n [\n \"label\": translate(\"diagnostic.move to next topic\"),\n \"state\": nstate,\n \"next_question\": eval(get_next_question,[\"state\":nstate])\n ]\n )\n\nnext_actions:\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]\n , eval(next_topic)<>nothing,\n [action_next_topic]\n ,\n []\n ),\n //\n [\n \"feedback\": \"\",\n \"actions\": actions\n ]\n )\n\nprogress:\n let(\n passed_topics, filter(t[\"status\"]=\"passed\",t,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, map(\n let(\n ltopics, filter(lo[\"name\"] in t[\"topic\"][\"learning_objectives\"], t, state[\"topics\"]),\n passed, filter(t[\"status\"]=\"passed\",t,ltopics),\n p, len(passed)/len(topics),\n [\"name\": lo[\"name\"], \"progress\": p, \"credit\": p]\n ),\n lo,\n learning_objectives\n ),\n topic_progress, [[\"name\": \"Current topic: {current_topic}\", \"progress\": topic_credit, \"credit\": topic_credit]],\n //\n topic_progress + lo_progress +\n [\n [\"name\": translate(\"control.total\"), \"progress\": exam_progress, \"credit\": exam_progress]\n ]\n )\n\nfeedback: \n if(state[\"finished\"],\n translate(\"diagnostic.complete\")\n ,\n translate(\"diagnostic.studying topic\", [\"topic\": state[\"current_topic\"][\"topic\"][\"name\"]])\n )\n\nafter_exam_ended:\n after_answering + [\"finished\": true]\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\"]=\"passed\" for: topicname of: t[\"topic\"][\"depends_on\"]),\n all_deps_passed and t[\"status\"]<>\"passed\"\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(topics),\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 , 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" }; });