Skip to content

Commit

Permalink
fix: made learning and testing work fully
Browse files Browse the repository at this point in the history
  • Loading branch information
arctic-hen7 committed May 8, 2023
1 parent 77999b1 commit 560e7d1
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 78 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ serde_json = "1"
anyhow = "1"
rand = "0.8"
lazy_static = "1"
rhai = { version = "1.14.0", features = ["serde", "sync"] }
rhai = { version = "1.14.0", features = ["serde", "sync", "internals"] }
include_dir = "0.7.3"
uuid = { version = "1.3.2", features = ["v4", "serde"] }

Expand Down
96 changes: 62 additions & 34 deletions src/bin/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,41 @@ fn main() -> anyhow::Result<()> {

println!("New set created!");
},
Command::Learn { set: set_file, method, ty, count } => {
Command::Learn { set: set_file, method, ty, count, reset } => {
let json = fs::read_to_string(&set_file).with_context(|| "failed to read from set file")?;
let set = Set::from_json(&json)?;
let mut california = California::from_set(set);
let method = method_from_string(method)?;
if reset && confirm("Are you absolutely certain you want to reset your learn progress? This action is IRREVERSIBLE!!!")? {
california.reset_learn(method.clone())?;
} else {
println!("Continuing with previous progress...");
}
let mut driver = california
.learn(method)?;
driver.set_target(ty)
.set_max_count(count);
driver.set_target(ty);
if let Some(count) = count {
driver.set_max_count(count);
}

let num_reviewed = drive(driver, &set_file)?;
println!("\nLearn session complete! You reviewed {} cards.", num_reviewed);
println!("\nLearn session complete! You reviewed {} card(s).", num_reviewed);
},
Command::Test { set: set_file, static_test, no_star, no_unstar, ty, count } => {
Command::Test { set: set_file, static_test, no_star, no_unstar, ty, count, reset } => {
let json = fs::read_to_string(&set_file).with_context(|| "failed to read from set file")?;
let set = Set::from_json(&json)?;
let mut california = California::from_set(set);
if reset && confirm("Are you sure you want to reset your test progress?")? {
california.reset_test();
} else {
println!("Continuing with previous progress...");
}
let mut driver = california
.test();
driver.set_target(ty)
.set_max_count(count);
driver.set_target(ty);
if let Some(count) = count {
driver.set_max_count(count);
}
if static_test {
driver.no_mark_starred().no_mark_unstarred();
} else if no_star {
Expand All @@ -54,7 +68,7 @@ fn main() -> anyhow::Result<()> {
}

let num_reviewed = drive(driver, &set_file)?;
println!("\nTest complete! You reviewed {} cards.", num_reviewed);
println!("\nTest complete! You reviewed {} card(s).", num_reviewed);

},
Command::List { set, ty } => {
Expand Down Expand Up @@ -185,10 +199,42 @@ fn drive<'a>(mut driver: california::Driver<'a, 'a>, set_file: &str) -> anyhow::
// This will adjust weights etc. and get us a new card, if one exists
card_option = driver.next(res)?;
}
stdout.reset()?;

let json = driver.save_set_to_json()?;
fs::write(set_file, json).with_context(|| "failed to save set to json (progress up to the previous card was saved though)")?;
Ok(driver.get_count())
}

/// Asks the user to confirm something with the given message.
#[cfg(feature = "cli")]
fn confirm(message: &str) -> anyhow::Result<bool> {
use std::io::{self, Write};
use anyhow::bail;

let stdin = io::stdin();
let mut stdout = io::stdout();
print!("{} [y/n] ", message);
stdout.flush()?;
let mut input = String::new();
let res = match stdin.read_line(&mut input) {
Ok(_) => {
let input = input.strip_suffix("\n").unwrap_or(&input);
if input == "y" {
true
} else if input == "n" {
false
} else {
println!("Invalid option!");
confirm(message)?
}
}
Err(_) => bail!("failed to read from stdin"),
};

Ok(res)
}

#[cfg(feature = "cli")]
mod opts {
use std::path::PathBuf;
Expand Down Expand Up @@ -231,7 +277,10 @@ mod opts {
ty: CardType,
/// Limit the number of terms studied to the given amount (useful for consistent long-term learning); your progress will be saved
#[arg(short, long)]
count: u32,
count: Option<u32>,
/// Starts a new learn session from scratch, irretrievably deleting any progress in a previous session
#[arg(long)]
reset: bool,
},
/// Starts or resumes a test on the given set
Test {
Expand All @@ -252,7 +301,10 @@ mod opts {
ty: CardType,
/// Limit the number of terms studied to the given amount (useful for consistent long-term learning); your progress will be saved
#[arg(short, long)]
count: u32,
count: Option<u32>,
/// Starts a new test from scratch, irretrievably deleting any progress in a previous test
#[arg(long)]
reset: bool,
},
/// Lists all the terms in the given set
List {
Expand All @@ -263,7 +315,6 @@ mod opts {
ty: CardType,
},
}

}


Expand Down Expand Up @@ -596,28 +647,5 @@ impl Set {
}
}
/// Asks the user to confirm something with the given message.
fn confirm(message: &str) -> Result<bool> {
let stdin = io::stdin();
let mut stdout = io::stdout();
print!("{} [y/n] ", message);
stdout.flush()?;
let mut input = String::new();
let res = match stdin.read_line(&mut input) {
Ok(_) => {
let input = input.strip_suffix("\n").unwrap_or(&input);
if input == "y" {
true
} else if input == "n" {
false
} else {
println!("Invalid option!");
confirm(message)?
}
}
Err(_) => bail!("failed to read from stdin"),
};
Ok(res)
}
*/
21 changes: 11 additions & 10 deletions src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,10 @@ impl<'e, 's> Driver<'e, 's> {
let mut cards_with_ids = self.set.cards.iter().collect::<Vec<_>>();
let (card_id, card) = match cards_with_ids.choose_weighted_mut(&mut rng, |(_, card): &(&Uuid, &Card)| {
if let Some(method) = &self.method {
let card = (*card).clone();
let res = match &self.target {
CardType::All => (method.get_weight)(card),
CardType::Starred if card.starred => (method.get_weight)(card),
CardType::Difficult if card.difficult => (method.get_weight)(card),
CardType::All => (method.get_weight)(card.method_data.clone(), card.difficult),
CardType::Starred if card.starred => (method.get_weight)(card.method_data.clone(), card.difficult),
CardType::Difficult if card.difficult => (method.get_weight)(card.method_data.clone(), card.difficult),
_ => Ok(0.0)
};
// TODO handle errors (very realistic that they would occur with custom scripts!)
Expand All @@ -184,11 +183,13 @@ impl<'e, 's> Driver<'e, 's> {
Ok(data) => data,
// We're done!
Err(WeightedError::AllWeightsZero) => {
// If we've genuinely finished, say so (but tests will never finish a set in this way)
if self.method.is_some() {
// If we've genuinely finished, say so
if let Some(method) = &self.method {
self.set.run_state = None;
self.set.reset_learn((method.get_default_metadata)()?);
} else {
self.set.test_in_progress = false;
self.set.reset_test();
}

return Ok(None);
Expand Down Expand Up @@ -224,27 +225,27 @@ impl<'e, 's> Driver<'e, 's> {
/// you should call `.first()` instead, as calling this will lead to an error. Note that the provided response
/// must be *identical* to one of the responses defined by the method in use (these can be found with `.allowed_responses()`).
pub fn next(&mut self, response: String) -> Result<Option<SlimCard>> {
if self.allowed_responses().iter().any(|x| x == &response) {
if !self.allowed_responses().iter().any(|x| x == &response) {
bail!("invalid user response to card");
}

if let Some(card_id) = self.latest_card.as_mut() {
// We know this element exists (we hold the only mutable reference to the set)
let card = self.set.cards.get_mut(card_id).unwrap();
if let Some(method) = &self.method {
let (method_data, difficult) = (method.adjust_card)(response, card.clone())?;
let (method_data, difficult) = (method.adjust_card)(response, card.method_data.clone(), card.difficult)?;
card.method_data = method_data;
if self.mutate_difficulty {
card.difficult = difficult;
}
} else {
card.seen_in_test = true;
// TODO Allow this behaviour to be disabled

if response == "n" && self.mark_starred {
card.starred = true;
} else if response == "y" && self.mark_unstarred {
card.starred = false;
} else { unreachable!() }
}

// Prevent this card from being double-adjusted if there's an error later
self.latest_card = None;
Expand Down
16 changes: 14 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,24 @@ impl California {
pub fn save_set(&self) -> Result<String> {
self.set.save()
}
/// Resets all cards in a learn session back to the default metadata values prescribed by the learning method.
pub fn reset_learn(&mut self, method: RawMethod) -> Result<()> {
let method = method.into_method(&self.rhai_engine)?;
self.set.reset_learn((method.get_default_metadata)()?);

Ok(())
}
/// Resets all test progress for this set. This is irreversible!
///
/// This will not change whether or not cards are starred.
pub fn reset_test(&mut self) {
self.set.reset_test();
}

/// Creates a Rhai engine with the utilities California provides all pre-registered.
fn create_engine() -> Engine {
// TODO regexp utilities
let mut engine = Engine::new();
engine.register_type_with_name::<Card>("Card");
let engine = Engine::new();
engine
}
}
Expand Down
63 changes: 40 additions & 23 deletions src/methods/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use anyhow::{Result, Context, bail};
use anyhow::{Result, Context, bail, anyhow};
use include_dir::{Dir, include_dir};
use rhai::{Dynamic, Scope, Engine, AST};
use crate::set::Card;
use rhai::{Dynamic, Scope, Engine, AST, Array};

/// The `src/methods` directory that includes this file.
static METHODS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/methods");
Expand All @@ -16,18 +15,18 @@ pub struct Method<'e> {
/// A list of responses the user can give after having been shown the answer to a card. These will
/// be displayed as options in the order they are provided in here.
pub responses: Vec<String>,
/// A closure that, given a card, produces a weight. This weight represents how
/// likely the card is to be presented to the user in the next random choice. When a card is finished
/// A closure that, given a card's metadata and whether or not it has been marked as difficult, produces a weight.
/// This weight represents how likely the card is to be presented to the user in the next random choice. When a card is finished
/// with, this should be set to 0.0. When all cards have a weight 0.0, the run will naturally terminate.
///
/// Any cards not part of the relevant run target will not be presented to this function in the first
/// place.
pub get_weight: Box<dyn Fn(Card) -> Result<f32> + Send + Sync + 'e>,
/// A closure that, given a card and the user's response to it, returns the new dynamic method state and
/// whether or not this card should be marked as difficult.
pub get_weight: Box<dyn Fn(Dynamic, bool) -> Result<f64> + Send + Sync + 'e>,
/// A closure that, given the user's response to a card, the card's metadata, and whether or not the card has been marked
/// as difficult, returns new metadata and whether or not the card should now be marked as difficult.
///
/// Note that learn runs do not have the authority to mark cards as starred.
pub adjust_card: Box<dyn Fn(String, Card) -> Result<(Dynamic, bool)> + Send + Sync + 'e>,
/// Note that learn runs do not have the authority to mark cards as starred, or even determine whether or not they are.
pub adjust_card: Box<dyn Fn(String, Dynamic, bool) -> Result<(Dynamic, bool)> + Send + Sync + 'e>,
/// A closure that produces the default metadata for this method. This is used when a new set is created for
/// this method to initialise all its cards with metadata that is appropriate to this method. Generally,
/// methods should keep this as small as possible to minimise the size of sets on-disk.
Expand Down Expand Up @@ -81,25 +80,42 @@ impl<'e> Method<'e> {
// Extract the closures directly (using the shared engine)
let ast1 = ast.clone();
let ast2 = ast.clone();
let get_weight = Box::new(move |card| {
engine.call_fn(&mut Scope::new(), &ast, "get_weight", (card,)).with_context(|| "failed to get weight for card (this is a bug in the selected learning method)")
let ast3 = ast.clone();
let get_weight = Box::new(move |method_data, difficult| {
engine.call_fn(&mut Scope::new(), &ast, "get_weight", (method_data, difficult)).with_context(|| "failed to get weight for card (this is a bug in the selected learning method)")
});
let adjust_card = Box::new(move |res, card| {
engine.call_fn(&mut Scope::new(), &ast1, "adjust_card", (res, card)).with_context(|| "failed to adjust card data for last card (this is a bug in the selected learning method)")
let adjust_card = Box::new(move |res, method_data, difficult| {
let res: Array = engine.call_fn(&mut Scope::new(), &ast1, "adjust_card", (res, method_data, difficult)).with_context(|| "failed to adjust card data for last card (this is a bug in the selected learning method)")?;
let method_data = res.get(0).ok_or(anyhow!("no method data provided from card adjustment (this is a bug in the selected learning method)"))?;
let difficult = res.get(1).ok_or(anyhow!("no difficulty boolean provided from card adjustment (this is a bug in the selected learning method)"))?.as_bool().map_err(|_| anyhow!("invalid difficulty boolean provided from card adjustment (this is a bug in the selected learning method)"))?;

Ok((method_data.clone(), difficult))
});
let get_default_metadata = Box::new(move || {
engine.call_fn(&mut Scope::new(), &ast2, "get_default_metadata", ()).with_context(|| "failed to get default metadata for a new card (this is a bug in the selected learning method)")
});

// Assemble all that into a method
Ok(Method {
name: method_name.to_string(),
// TODO
responses: Vec::new(),
get_weight,
adjust_card,
get_default_metadata,
})
// Iterate through all literal constants and find `RESPONSES`
let mut responses = None;
for (name, _, value) in ast3.iter_literal_variables(true, false) {
if name == "RESPONSES" {
let value = value.into_typed_array().map_err(|_| anyhow!("required constant `RESPONSES` in method script was not an array of strings"))?;
responses = Some(value);
}
}

if let Some(responses) = responses {
// Assemble all that into a method
Ok(Method {
name: method_name.to_string(),
responses,
get_weight,
adjust_card,
get_default_metadata,
})
} else {
bail!("method script did not define required constant `RESPONSES`");
}
}
/// Determines if the given method name is inbuilt. This may be unwittingly provided a full method script as well.
fn is_inbuilt(method: &str) -> bool {
Expand All @@ -112,6 +128,7 @@ impl<'e> Method<'e> {
}

/// A representation of a method that has not yet been created.
#[derive(Clone, Debug)]
pub enum RawMethod {
/// An inbuilt method, with the name attached.
Inbuilt(String),
Expand Down
12 changes: 6 additions & 6 deletions src/methods/speed-v1.rhai
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
const RESPONSES = ["y", "n"];

fn get_weight(card) {
return card.method_data.weight;
fn get_weight(data, difficult) {
return data.weight;
}
fn adjust_card(res, card) {
fn adjust_card(res, data, difficult) {
if res == "y" {
card.method_data.weight -= 0.5;
data.weight -= 0.5;
} else {
card.method_data.weight += 0.5;
data.weight += 0.5;
}

return [card, false];
return [data, false];
}
fn get_default_metadata() {
return #{ weight: 1.0 };
Expand Down
Loading

0 comments on commit 560e7d1

Please sign in to comment.