use log::debug;
use rusqlite::{Connection, OptionalExtension, params};
use crate::bible::{Bible, HebrewWord};
type GlyphRow = Option<(String, i64)>;
type WordRow = Option<(String, i64)>;
const DEFAULT_EASE: f64 = 2.5;
const MIN_EASE: f64 = 1.3;
const LEARN_STEPS_MIN: [i64; 2] = [1, 10];
const SECONDS_PER_DAY: i64 = 86_400;
const SECONDS_PER_MIN: i64 = 60;
const READING_MARKS: [char; 2] = ['\u{05C3}', '\u{05BE}'];
const FINAL_FORMS_CONCEPT: &str = "final_forms";
const INTRO_CONCEPTS: [&str; 3] = ["intro_rtl", "intro_alphabet", "intro_vowels"];
const SILENT_HOSTS: [&str; 2] = ["א", "ע"];
const AUDIBLE_GUTTURALS: [&str; 2] = ["ה", "ח"];
const CLEAR_HOSTS: [&str; 6] = ["מ", "ל", "נ", "ר", "ת", "ב"];
fn is_silent_host(cons: &str) -> bool {
SILENT_HOSTS.contains(&cons)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
Again,
Hard,
Good,
Easy,
}
impl Grade {
pub fn from_i64(n: i64) -> Option<Grade> {
match n {
0 => Some(Grade::Again),
1 => Some(Grade::Hard),
2 => Some(Grade::Good),
3 => Some(Grade::Easy),
_ => None,
}
}
pub fn from_confidence(confidence: u8, correct: Option<bool>) -> Grade {
if correct == Some(false) {
return Grade::Again;
}
match confidence {
0..=24 => Grade::Again,
25..=54 => Grade::Hard,
55..=84 => Grade::Good,
_ => Grade::Easy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Track {
Glyph,
Word,
Form,
Suffix,
}
#[derive(Debug, Clone, Copy)]
struct Srs {
ease: f64,
interval_days: i64,
reps: i64,
lapses: i64,
}
impl Default for Srs {
fn default() -> Self {
Srs {
ease: DEFAULT_EASE,
interval_days: 0,
reps: 0,
lapses: 0,
}
}
}
impl Srs {
fn graded(self, grade: Grade) -> Srs {
let mut s = self;
let steps = LEARN_STEPS_MIN.len() as i64;
match grade {
Grade::Again => {
s.ease = (s.ease - 0.20).max(MIN_EASE);
s.reps = 0;
s.lapses += 1;
s.interval_days = 0;
}
Grade::Hard => {
s.ease = (s.ease - 0.15).max(MIN_EASE);
if self.interval_days > 0 {
s.interval_days = ((self.interval_days as f64 * 1.2).round() as i64).max(1);
}
}
Grade::Good => {
s.reps = self.reps + 1;
if s.reps <= steps {
s.interval_days = 0; } else {
s.interval_days = match self.interval_days {
0 => 1,
1 => 6,
n => (n as f64 * self.ease).round() as i64,
};
}
}
Grade::Easy => {
s.ease = self.ease + 0.15;
s.reps = (self.reps + 1).max(steps + 1); s.interval_days = match self.interval_days {
0 => 4,
1 => 6,
n => (n as f64 * self.ease * 1.3).round() as i64,
};
}
}
s
}
fn graduated(&self) -> bool {
self.interval_days >= 1
}
fn due_at(&self, now: i64) -> i64 {
if self.interval_days > 0 {
now + self.interval_days * SECONDS_PER_DAY
} else {
let idx = (self.reps.max(1) - 1).clamp(0, LEARN_STEPS_MIN.len() as i64 - 1) as usize;
now + LEARN_STEPS_MIN[idx] * SECONDS_PER_MIN
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GlyphCard {
pub glyph: String,
pub is_consonant: bool,
pub host: Option<String>,
pub voiced: String,
pub distractors: Vec<String>,
pub voiced_distractors: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct WordCard {
pub surface_id: i64,
pub surface: String,
pub occurrences: i64,
pub translit: String,
pub gloss: String,
pub root_gloss: String,
pub note: String,
pub root: String,
pub morph: String,
pub distractors: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SuffixCard {
pub key: String,
pub meaning: String,
pub surface: String,
pub translit: String,
pub stem: String,
pub suffix: String,
pub gloss: String,
pub distractors: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct VerseCard {
pub book: u8,
pub chapter: u8,
pub verse: u8,
pub examples: Vec<(u8, u8, u8)>,
pub words: Vec<String>,
pub names: Vec<bool>,
}
#[derive(Debug, Clone)]
pub struct GrammarCard {
pub concept: String,
pub title: String,
pub explanation: String,
pub formula: String,
pub examples: Vec<String>,
pub example: WordCard,
}
#[derive(Debug, Clone)]
pub struct SeenConcept {
pub kind: String,
pub key: String,
pub title: String,
pub explanation: String,
pub formula: String,
pub examples: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum StudyItem {
NewGlyph(GlyphCard),
ReviewGlyph(GlyphCard),
NewWord(WordCard),
ReviewWord(WordCard),
NewFormDrill(WordCard),
ReviewFormDrill(WordCard),
NewSuffixDrill(SuffixCard),
ReviewSuffixDrill(SuffixCard),
ExplainMark(GlyphCard),
ExplainGrammar(GrammarCard),
ExplainFinalForms(GlyphCard),
ExplainIntro(String),
ReadVerse(VerseCard),
Done,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TutorProgress {
pub letters_known: i64,
pub letters_total: i64,
pub vowels_known: i64,
pub vowels_total: i64,
pub grammar_known: i64,
pub grammar_total: i64,
pub words_known: i64,
pub verses_grammar_unlocked: i64,
pub verses_readable: i64,
pub total_verses: i64,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TutorStats {
pub letters_seen: i64,
pub letters_learning: i64,
pub letters_mature: i64,
pub vowels_seen: i64,
pub vowels_learning: i64,
pub vowels_mature: i64,
pub words_seen: i64,
pub words_learning: i64,
pub words_mature: i64,
pub grammar_seen: i64,
pub grammar_total: i64,
pub glyphs_due: i64,
pub words_due: i64,
pub reviews_today: i64,
pub reviews_total: i64,
pub streak_days: i64,
pub accuracy_pct: i64,
pub verses_readable: i64,
pub total_verses: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TutorSettings {
pub letters_per_batch: u8,
pub words_per_batch: u8,
pub grammar_gating: bool,
pub vocab_priority: u8,
pub grammar_priority: u8,
pub verse_priority: u8,
pub letters_ratio: u8,
}
impl Default for TutorSettings {
fn default() -> Self {
TutorSettings {
letters_per_batch: 3,
words_per_batch: 8,
grammar_gating: true,
vocab_priority: 75,
grammar_priority: 25,
verse_priority: 25,
letters_ratio: 30,
}
}
}
impl TutorSettings {
fn words_per_concept(&self) -> i64 {
30 - self.grammar_priority as i64 * 27 / 100
}
}
const DONE_SURFACES: &str = "SELECT DISTINCT sm_done.vkey AS vkey \
FROM progress.word_srs ws_done \
JOIN progress.surface_meta sm_done ON sm_done.surface_id = ws_done.surface_id \
WHERE ws_done.interval_days >= 1";
const KNOWN_ROOTS: &str = "SELECT DISTINCT sm.root FROM progress.surface_meta sm \
JOIN progress.word_srs ws ON ws.surface_id = sm.surface_id \
WHERE ws.interval_days >= 1 AND sm.root <> '' AND sm.is_name = 0";
const KNOWN_QAL_ROOTS: &str = "SELECT DISTINCT sm.root FROM progress.surface_meta sm \
JOIN progress.word_srs ws ON ws.surface_id = sm.surface_id \
WHERE ws.interval_days >= 1 AND sm.root <> '' AND sm.is_name = 0 \
AND sm.is_qal = 1 AND sm.family_base = 1";
const FAMILY_READY: &str = "(sm.root = '' OR sm.is_name = 1 OR sm.form_tier < 5 \
OR sm.family_base = 1 OR kqr.root IS NOT NULL)";
const LEXICAL_BASE_READY: &str =
"(sm.base_surface_id = sm.surface_id OR base_ws.surface_id IS NOT NULL)";
const SURFACE_META_VERSION: i64 = 29;
const UNTEACHABLE_MASK: i64 = 1 << 62;
const ALPHABET_CONSONANT_TARGET: i64 = 22;
const ALPHABET_VOWEL_TARGET: i64 = 10;
const LETTER_GLYPH_TOTAL: i64 = 27;
const VOWEL_GLYPH_TOTAL: i64 = 11;
const WORD_FREQ: &str = "CASE WHEN sm.family_base = 1 AND sm.form_tier >= 5 \
THEN COALESCE(r.n_occurrences, s.occurrences) ELSE s.occurrences END";
const NOT_NAME: &str = "COALESCE(sm.is_name, 0) = 0";
fn word_order() -> String {
format!(
"ORDER BY {WORD_FREQ} DESC, \
COALESCE(sm.form_tier, 0) ASC, \
s.occurrences DESC"
)
}
fn letter_learning_score(seen_mask_param: &str) -> String {
format!("({WORD_FREQ} >> (2 * popcount(sm.glyph_mask & ~{seen_mask_param})))")
}
const DIFFICULTY: &str = "MIN(s.occurrences)";
const NOT_ARAMAIC: &str = "SUM(CASE WHEN s.language = 'aramaic' THEN 1 ELSE 0 END) = 0";
const NAME_GLOSS: &str = "(a name)";
fn seeded_known_srs() -> Srs {
Srs {
ease: DEFAULT_EASE,
interval_days: 14,
reps: 3,
lapses: 0,
}
}
#[derive(Debug, Clone)]
pub struct CalibrationProbe {
pub book: u8,
pub chapter: u8,
pub verse: u8,
pub text: String,
pub tier: u32,
pub min_occurrences: i64,
}
pub fn init_progress_schema(db: &Connection) -> rusqlite::Result<()> {
let word_sql: Option<String> = db
.query_row(
"SELECT sql FROM progress.sqlite_master WHERE type='table' AND name='word_srs'",
[],
|r| r.get(0),
)
.optional()?;
if let Some(sql) = word_sql
&& sql.contains("aspect")
{
db.execute_batch("DROP TABLE progress.word_srs")?;
}
let sm_sql: Option<String> = db
.query_row(
"SELECT sql FROM progress.sqlite_master WHERE type='table' AND name='surface_meta'",
[],
|r| r.get(0),
)
.optional()?;
if let Some(sql) = sm_sql
&& !(sql.contains("concept_mask") && sql.contains("glyph_mask") && sql.contains("vkey"))
{
db.execute_batch("DROP TABLE progress.surface_meta")?;
}
db.execute_batch(
"CREATE TABLE IF NOT EXISTS progress.glyph_srs(
glyph TEXT PRIMARY KEY,
ease REAL NOT NULL,
interval_days INTEGER NOT NULL,
due_epoch INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
introduced_epoch INTEGER NOT NULL,
last_grade INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.word_srs(
surface TEXT PRIMARY KEY,
surface_id INTEGER NOT NULL,
ease REAL NOT NULL,
interval_days INTEGER NOT NULL,
due_epoch INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
introduced_epoch INTEGER NOT NULL,
last_grade INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS progress.idx_word_srs_id ON word_srs(surface_id);
CREATE TABLE IF NOT EXISTS progress.form_srs(
surface TEXT PRIMARY KEY,
surface_id INTEGER NOT NULL,
ease REAL NOT NULL,
interval_days INTEGER NOT NULL,
due_epoch INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
introduced_epoch INTEGER NOT NULL,
last_grade INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.suffix_srs(
key TEXT PRIMARY KEY,
ease REAL NOT NULL,
interval_days INTEGER NOT NULL,
due_epoch INTEGER NOT NULL,
reps INTEGER NOT NULL,
lapses INTEGER NOT NULL,
introduced_epoch INTEGER NOT NULL,
last_grade INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.surface_progress(
surface_id INTEGER PRIMARY KEY,
graduated INTEGER NOT NULL DEFAULT 0,
graduated_epoch INTEGER
);
CREATE TABLE IF NOT EXISTS progress.verse_progress(
book INTEGER NOT NULL,
chapter INTEGER NOT NULL,
verse INTEGER NOT NULL,
unknown_words INTEGER NOT NULL,
min_root_frequency INTEGER NOT NULL,
mean_root_frequency REAL NOT NULL,
max_root_frequency INTEGER NOT NULL,
last_read_epoch INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (book, chapter, verse)
);
CREATE TABLE IF NOT EXISTS progress.meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.reviews(
epoch INTEGER NOT NULL,
day INTEGER NOT NULL,
track TEXT NOT NULL,
grade INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS progress.idx_reviews_day ON reviews(day);
CREATE TABLE IF NOT EXISTS progress.marks_seen(
mark TEXT PRIMARY KEY,
introduced_epoch INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.surface_meta(
surface_id INTEGER PRIMARY KEY,
root TEXT NOT NULL,
form_tier INTEGER NOT NULL,
concept_mask INTEGER NOT NULL,
glyph_mask INTEGER NOT NULL,
is_name INTEGER NOT NULL,
is_qal INTEGER NOT NULL,
family_base INTEGER NOT NULL,
vkey TEXT NOT NULL,
base_vkey TEXT NOT NULL,
base_surface_id INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS progress.idx_surface_meta_vkey ON surface_meta(vkey);
CREATE INDEX IF NOT EXISTS progress.idx_surface_meta_root ON surface_meta(root);
CREATE TABLE IF NOT EXISTS progress.verse_meta(
book INTEGER NOT NULL,
chapter INTEGER NOT NULL,
verse INTEGER NOT NULL,
concept_mask INTEGER NOT NULL,
PRIMARY KEY (book, chapter, verse)
);
CREATE TABLE IF NOT EXISTS progress.concepts_seen(
concept TEXT PRIMARY KEY,
introduced_epoch INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress.concepts_unlocked(
concept TEXT PRIMARY KEY,
unlocked_epoch INTEGER NOT NULL
);",
)?;
let verse_progress_is_readability = {
let mut stmt = db.prepare("PRAGMA progress.table_info(verse_progress)")?;
stmt.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?
.iter()
.any(|name| name == "unknown_words")
};
if !verse_progress_is_readability {
db.execute_batch(
"DROP TABLE progress.verse_progress;
CREATE TABLE progress.verse_progress(
book INTEGER NOT NULL, chapter INTEGER NOT NULL, verse INTEGER NOT NULL,
unknown_words INTEGER NOT NULL,
min_root_frequency INTEGER NOT NULL,
mean_root_frequency REAL NOT NULL,
max_root_frequency INTEGER NOT NULL,
last_read_epoch INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(book, chapter, verse));",
)?;
}
db.execute_batch(
"CREATE INDEX IF NOT EXISTS progress.idx_surface_progress_graduated
ON surface_progress(graduated);
CREATE INDEX IF NOT EXISTS progress.idx_verse_progress_unknown
ON verse_progress(unknown_words);",
)?;
let has_is_qal = {
let mut stmt = db.prepare("PRAGMA progress.table_info(surface_meta)")?;
stmt.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?
.iter()
.any(|name| name == "is_qal")
};
if !has_is_qal {
db.execute(
"ALTER TABLE progress.surface_meta ADD COLUMN is_qal INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
let has_family_base = {
let mut stmt = db.prepare("PRAGMA progress.table_info(surface_meta)")?;
stmt.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?
.iter()
.any(|name| name == "family_base")
};
if !has_family_base {
db.execute(
"ALTER TABLE progress.surface_meta ADD COLUMN family_base INTEGER NOT NULL DEFAULT 1",
[],
)?;
}
let has_base_vkey = {
let mut stmt = db.prepare("PRAGMA progress.table_info(surface_meta)")?;
stmt.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?
.iter()
.any(|name| name == "base_vkey")
};
if !has_base_vkey {
db.execute(
"ALTER TABLE progress.surface_meta ADD COLUMN base_vkey TEXT NOT NULL DEFAULT ''",
[],
)?;
}
let has_base_surface_id = {
let mut stmt = db.prepare("PRAGMA progress.table_info(surface_meta)")?;
stmt.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?
.iter()
.any(|name| name == "base_surface_id")
};
if !has_base_surface_id {
db.execute(
"ALTER TABLE progress.surface_meta ADD COLUMN base_surface_id INTEGER NOT NULL DEFAULT 0",
[],
)?;
}
for mark in READING_MARKS {
db.execute(
"DELETE FROM progress.glyph_srs WHERE glyph = ?1",
params![mark.to_string()],
)?;
}
db.execute(
"DELETE FROM progress.glyph_srs WHERE glyph = ?1",
params![SHIN.to_string()],
)?;
Ok(())
}
fn is_consonant(c: char) -> bool {
(0x05D0..=0x05EA).contains(&(c as u32))
}
fn is_final_form(c: char) -> bool {
matches!(
c,
'\u{05DA}' | '\u{05DD}' | '\u{05DF}' | '\u{05E3}' | '\u{05E5}'
)
}
fn is_vowel_point(c: char) -> bool {
matches!(c as u32, 0x05B0..=0x05B9 | 0x05BB | 0x05C7)
}
fn is_hataf(vowel: char) -> bool {
matches!(vowel as u32, 0x05B1..=0x05B3)
}
fn glyph_bit(glyph: &str) -> Option<u32> {
let mut chars = glyph.chars();
let first = chars.next()?;
let second = chars.next();
match (first, second) {
('\u{05D1}', Some('\u{05BC}')) => Some(27), ('\u{05E4}', Some('\u{05BC}')) => Some(28), ('\u{05E9}', Some('\u{05C1}')) => Some(29), ('\u{05E9}', Some('\u{05C2}')) => Some(30), ('\u{05D5}', Some('\u{05BC}')) => Some(43), (c, None) if is_consonant(c) => Some(c as u32 - 0x05D0), (c, None) if is_vowel_point(c) => Some(31 + vowel_slot(c)), _ => None,
}
}
fn vowel_slot(c: char) -> u32 {
match c as u32 {
0x05BB => 10, 0x05C7 => 11, u => u - 0x05B0, }
}
fn surface_glyph_mask(surface: &str) -> i64 {
let mut mask = 0i64;
for g in decompose_glyphs(surface) {
if let Some(b) = glyph_bit(&g.glyph) {
mask |= 1i64 << b;
}
}
mask
}
const DAGESH_LETTERS: [char; 2] = ['\u{05D1}', '\u{05E4}'];
const SHIN: char = '\u{05E9}';
const VAV: char = '\u{05D5}';
fn is_dagesh(c: char) -> bool {
c as u32 == 0x05BC
}
fn is_shin_sin_dot(c: char) -> bool {
matches!(c as u32, 0x05C1 | 0x05C2)
}
fn letter_cluster(letter: char, rest: &[char]) -> (String, Vec<char>, usize) {
let mut vowels = Vec::new();
let mut dagesh = None;
let mut dot = None;
let mut consumed = 0;
for &c in rest {
if is_vowel_point(c) {
vowels.push(c);
} else if is_dagesh(c) && dagesh.is_none() {
dagesh = Some(c);
} else if is_shin_sin_dot(c) && dot.is_none() {
dot = Some(c);
} else {
break;
}
consumed += 1;
}
let key = if DAGESH_LETTERS.contains(&letter) {
dagesh.map_or_else(|| letter.to_string(), |m| format!("{letter}{m}"))
} else if letter == SHIN {
let m = dot.unwrap_or('\u{05C1}');
format!("{letter}{m}")
} else if letter == VAV && vowels.is_empty() {
dagesh.map_or_else(|| letter.to_string(), |m| format!("{letter}{m}"))
} else {
letter.to_string()
};
(key, vowels, consumed)
}
fn valid_host_prefs(vowel: char) -> &'static [&'static str] {
if is_hataf(vowel) {
&AUDIBLE_GUTTURALS
} else {
&CLEAR_HOSTS
}
}
fn contextual_host(surface: &str, vowel: char) -> Option<String> {
let chars: Vec<char> = surface.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if is_consonant(c) {
let (key, vowels, consumed) = letter_cluster(c, &chars[i + 1..]);
if vowels.contains(&vowel) {
return Some(key);
}
i += consumed;
}
i += 1;
}
None
}
fn host_to_teach(surface: &str, vowel: char) -> String {
contextual_host(surface, vowel)
.filter(|c| !is_silent_host(c))
.unwrap_or_else(|| valid_host_prefs(vowel)[0].to_string())
}
fn split_glyph_key(key: &str) -> Vec<String> {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 1 {
return vec![key.to_string()];
}
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if is_consonant(c) {
let (tok, vowels, consumed) = letter_cluster(c, &chars[i + 1..]);
out.push(tok);
out.extend(vowels.into_iter().map(|v| v.to_string()));
i += 1 + consumed;
} else {
out.push(c.to_string());
i += 1;
}
}
out
}
fn decompose_glyphs(surface: &str) -> Vec<GlyphCard> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
let chars: Vec<char> = surface.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if is_consonant(c) {
let (key, vowels, consumed) = letter_cluster(c, &chars[i + 1..]);
if seen.insert(key.clone()) {
out.push(GlyphCard {
glyph: key,
is_consonant: true,
..Default::default()
});
}
for v in vowels {
let vk = v.to_string();
if seen.insert(vk.clone()) {
out.push(GlyphCard {
glyph: vk,
..Default::default()
});
}
}
i += 1 + consumed;
continue;
}
if is_vowel_point(c) {
let key = c.to_string();
if seen.insert(key.clone()) {
out.push(GlyphCard {
glyph: key,
..Default::default()
});
}
}
i += 1;
}
out
}
const TIER_MAX: u8 = 13;
fn form_tier(w: &HebrewWord) -> u8 {
let base = match &w.form {
Some(binyan) => {
let stem = match binyan.as_str() {
"Qal" | "Qal passive" => 0,
"Niphal" | "Piel" | "Pual" | "Hiphil" | "Hophal" | "Hithpael" => 2,
_ => 3, };
let is_3ms = w.person.as_deref() == Some("Third")
&& w.gender.as_deref() == Some("Masculine")
&& w.number.as_deref() == Some("Singular");
let conj = match w.tense.as_deref() {
Some("Perfect") if is_3ms => 0,
Some("Perfect") => 1,
Some("Participle (act.)")
| Some("Participle (pas.)")
| Some("Participle (pass.)")
| Some("Participle") => 1,
Some("Imperfect") | Some("Wayyiqtol") | Some("Jussive") | Some("Cohortative") => 2,
Some("Imperative") | Some("Inf. Construct") | Some("Inf. Absolute") => 3,
_ => 2,
};
5 + stem + conj
}
None if w.tense.is_none() && (w.number.is_some() || w.state.is_some()) => {
match w.state.as_deref() {
Some(s) if s.contains('+') => 4,
Some("Construct") | Some("Directional") => 3,
_ => match w.number.as_deref() {
Some("Plural") | Some("Dual") => 2,
_ => 1, },
}
}
None => return 0,
};
let extra = w.prefix.is_some() as u8 + w.obj_suffix.is_some() as u8;
(base + extra).min(TIER_MAX)
}
fn lexicalized_verb_tier(surface: &str) -> Option<u8> {
matches!(surface, "לִקְרַאת" | "לִקְרָאתוֹ" | "לִקְרַאתְכֶם").then_some(8)
}
impl Bible {
fn glyph_srs(&self, glyph: &str) -> rusqlite::Result<Option<Srs>> {
self.conn()
.query_row(
"SELECT ease, interval_days, reps, lapses FROM progress.glyph_srs WHERE glyph = ?1",
params![glyph],
|r| {
Ok(Srs {
ease: r.get(0)?,
interval_days: r.get(1)?,
reps: r.get(2)?,
lapses: r.get(3)?,
})
},
)
.optional()
}
fn word_srs(&self, surface: &str) -> rusqlite::Result<Option<Srs>> {
self.conn()
.query_row(
"SELECT ease, interval_days, reps, lapses FROM progress.word_srs \
WHERE surface = ?1",
params![surface],
|r| {
Ok(Srs {
ease: r.get(0)?,
interval_days: r.get(1)?,
reps: r.get(2)?,
lapses: r.get(3)?,
})
},
)
.optional()
}
fn form_srs(&self, surface: &str) -> rusqlite::Result<Option<Srs>> {
self.conn()
.query_row(
"SELECT ease, interval_days, reps, lapses FROM progress.form_srs \
WHERE surface = ?1",
params![surface],
|r| {
Ok(Srs {
ease: r.get(0)?,
interval_days: r.get(1)?,
reps: r.get(2)?,
lapses: r.get(3)?,
})
},
)
.optional()
}
fn suffix_srs(&self, key: &str) -> rusqlite::Result<Option<Srs>> {
self.conn()
.query_row(
"SELECT ease, interval_days, reps, lapses FROM progress.suffix_srs \
WHERE key = ?1",
params![key],
|r| {
Ok(Srs {
ease: r.get(0)?,
interval_days: r.get(1)?,
reps: r.get(2)?,
lapses: r.get(3)?,
})
},
)
.optional()
}
fn glyph_known(&self, glyph: &str) -> rusqlite::Result<bool> {
Ok(self.glyph_srs(glyph)?.is_some())
}
fn all_glyphs_graduated(&self, surface: &str) -> rusqlite::Result<bool> {
for g in decompose_glyphs(surface) {
match self.glyph_srs(&g.glyph)? {
Some(s) if s.graduated() => {}
_ => return Ok(false),
}
}
Ok(true)
}
fn all_glyphs_seen(&self, surface: &str) -> rusqlite::Result<bool> {
for g in decompose_glyphs(surface) {
if !self.glyph_known(&g.glyph)? {
return Ok(false);
}
}
Ok(true)
}
pub fn tutor_settings(&self) -> rusqlite::Result<TutorSettings> {
let d = TutorSettings::default();
let get = |key: &str| -> rusqlite::Result<Option<i64>> {
self.conn()
.query_row(
"SELECT CAST(value AS INTEGER) FROM progress.meta WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()
};
Ok(TutorSettings {
letters_per_batch: get("setting.letters_per_batch")?
.map_or(d.letters_per_batch, |n| n.clamp(1, 255) as u8),
words_per_batch: get("setting.words_per_batch")?
.map_or(d.words_per_batch, |n| n.clamp(1, 255) as u8),
grammar_gating: get("setting.grammar_gating")?.map_or(d.grammar_gating, |n| n != 0),
vocab_priority: get("setting.vocab_priority")?
.or(get("setting.vocab_ratio")?)
.map_or(d.vocab_priority, |n| n.clamp(0, 100) as u8),
grammar_priority: get("setting.grammar_priority")?
.or(get("setting.vocab_ratio")?.map(|n| 100 - n))
.map_or(d.grammar_priority, |n| n.clamp(0, 100) as u8),
verse_priority: get("setting.verse_priority")?
.map_or(d.verse_priority, |n| n.clamp(0, 100) as u8),
letters_ratio: get("setting.letters_ratio")?
.map_or(d.letters_ratio, |n| n.clamp(0, 100) as u8),
})
}
pub fn set_tutor_settings(&self, s: &TutorSettings) -> rusqlite::Result<()> {
let put = |key: &str, val: i64| -> rusqlite::Result<()> {
self.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES (?1, ?2) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, val.to_string()],
)?;
Ok(())
};
put("setting.letters_per_batch", s.letters_per_batch as i64)?;
put("setting.words_per_batch", s.words_per_batch as i64)?;
put("setting.grammar_gating", s.grammar_gating as i64)?;
put("setting.vocab_priority", s.vocab_priority as i64)?;
put("setting.grammar_priority", s.grammar_priority as i64)?;
put("setting.verse_priority", s.verse_priority as i64)?;
put("setting.letters_ratio", s.letters_ratio as i64)?;
Ok(())
}
fn intro_counts(&self) -> rusqlite::Result<(i64, i64)> {
let get = |key: &str| -> rusqlite::Result<i64> {
Ok(self
.conn()
.query_row(
"SELECT CAST(value AS INTEGER) FROM progress.meta WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()?
.unwrap_or(0))
};
Ok((get("intro.letters")?, get("intro.words")?))
}
fn bump_intro_counter(&self, key: &str) -> rusqlite::Result<()> {
self.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES (?1, 1) \
ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + 1",
params![key],
)?;
Ok(())
}
fn glyphs_in_learning(&self) -> rusqlite::Result<i64> {
self.conn().query_row(
"SELECT COUNT(*) FROM progress.glyph_srs WHERE interval_days = 0",
[],
|r| r.get(0),
)
}
fn words_in_learning(&self) -> rusqlite::Result<i64> {
self.conn().query_row(
"SELECT COUNT(*) FROM progress.word_srs WHERE interval_days = 0",
[],
|r| r.get(0),
)
}
fn words_mature(&self) -> rusqlite::Result<i64> {
self.conn().query_row(
"SELECT COUNT(*) FROM progress.word_srs WHERE interval_days >= 1",
[],
|r| r.get(0),
)
}
fn all_letters_known(&self) -> rusqlite::Result<bool> {
let consonants: i64 = self.conn().query_row(
"SELECT COUNT(DISTINCT unicode(substr(glyph, 1, 1))) FROM progress.glyph_srs \
WHERE interval_days >= 1 AND unicode(substr(glyph, 1, 1)) BETWEEN 1488 AND 1514 \
AND unicode(substr(glyph, 1, 1)) NOT IN (1498, 1501, 1503, 1507, 1509)",
[],
|r| r.get(0),
)?;
let vowels: i64 = self.conn().query_row(
"SELECT COUNT(*) FROM progress.glyph_srs \
WHERE interval_days >= 1 \
AND (unicode(glyph) BETWEEN 1456 AND 1465 OR unicode(glyph) IN (1467, 1479))",
[],
|r| r.get(0),
)?;
Ok(consonants >= ALPHABET_CONSONANT_TARGET && vowels >= ALPHABET_VOWEL_TARGET)
}
fn seen_glyph_mask(&self) -> rusqlite::Result<i64> {
let mut mask = 0i64;
let mut stmt = self
.conn()
.prepare("SELECT glyph FROM progress.glyph_srs")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
for g in rows {
if let Some(b) = glyph_bit(&g?) {
mask |= 1i64 << b;
}
}
Ok(mask)
}
fn unlocked_concepts(&self, s: &TutorSettings, now: i64) -> rusqlite::Result<i64> {
let total = crate::grammar::concept_count() as i64;
if !s.grammar_gating {
return Ok(crate::grammar::all_concepts_mask());
}
let mut target = 0;
if self.all_letters_known()? {
target = 1 + self.words_mature()? / s.words_per_concept();
}
let target = target.min(total);
let mut mask = 0i64;
{
let mut stmt = self
.conn()
.prepare("SELECT concept FROM progress.concepts_unlocked")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
for key in rows {
if let Some(bit) = crate::grammar::concept_bit(&key?) {
mask |= bit;
}
}
}
while (mask.count_ones() as i64) < target {
let Some(next) = self.next_concept_to_unlock(mask)? else {
break;
};
debug!("unlocked_concepts: unlocking [{next}] (mask={mask:#x})");
self.conn().execute(
"INSERT INTO progress.concepts_unlocked(concept, unlocked_epoch) \
VALUES (?1, ?2) ON CONFLICT(concept) DO NOTHING",
params![next, now],
)?;
mask |= crate::grammar::concept_bit(next).unwrap_or(0);
}
Ok(mask)
}
fn next_concept_to_unlock(&self, unlocked: i64) -> rusqlite::Result<Option<&'static str>> {
let locked: Vec<&'static crate::grammar::GrammarConcept> = crate::grammar::concepts()
.iter()
.filter(|c| crate::grammar::concept_bit(c.key).is_some_and(|b| unlocked & b == 0))
.collect();
let Some(bucket) = locked.iter().map(|c| c.bucket).min() else {
return Ok(None);
};
let mut best: Option<(&'static str, f64)> = None;
for c in locked.iter().filter(|c| c.bucket == bucket) {
let bit = crate::grammar::concept_bit(c.key).unwrap_or(0);
let frontier = unlocked | bit;
let gain: f64 = self.conn().query_row(
"SELECT COALESCE(SUM(1.0 / (1 + popcount(concept_mask & ~?1))), 0) \
FROM progress.verse_meta",
params![frontier],
|r| r.get(0),
)?;
if best.is_none_or(|(_, g)| gain > g) {
best = Some((c.key, gain));
}
}
Ok(best.map(|(k, _)| k))
}
fn known_vowel_host(&self, surface: &str, vowel: char) -> rusqlite::Result<Option<String>> {
if let Some(ctx) = contextual_host(surface, vowel)
&& !is_silent_host(&ctx)
&& self.glyph_known(&ctx)?
{
return Ok(Some(ctx));
}
for g in valid_host_prefs(vowel) {
if self.glyph_known(g)? {
return Ok(Some(g.to_string()));
}
}
if is_hataf(vowel) {
return Ok(None);
}
self.conn()
.query_row(
"SELECT glyph FROM progress.glyph_srs \
WHERE unicode(glyph) BETWEEN 1488 AND 1514 \
AND glyph NOT IN ('א','ע','וּ') LIMIT 1",
[],
|r| r.get(0),
)
.optional()
}
fn new_glyph_item(
&self,
surface: &str,
g: &GlyphCard,
now: i64,
) -> rusqlite::Result<StudyItem> {
let ch = g.glyph.chars().next().unwrap_or(' ');
if !is_vowel_point(ch) {
return self.final_form_gated(g.clone(), now);
}
match self.known_vowel_host(surface, ch)? {
Some(host) => Ok(StudyItem::NewGlyph(GlyphCard {
voiced: crate::romanize::voiced_syllable(&host, ch),
host: Some(host),
..g.clone()
})),
None => self.final_form_gated(
GlyphCard {
glyph: host_to_teach(surface, ch),
is_consonant: true,
..Default::default()
},
now,
),
}
}
fn final_form_gated(&self, g: GlyphCard, now: i64) -> rusqlite::Result<StudyItem> {
if g.glyph.chars().next().is_some_and(is_final_form)
&& !self.concept_seen(FINAL_FORMS_CONCEPT)?
{
self.conn().execute(
"INSERT INTO progress.concepts_seen(concept, introduced_epoch) VALUES (?1, ?2) \
ON CONFLICT(concept) DO NOTHING",
params![FINAL_FORMS_CONCEPT, now],
)?;
return Ok(StudyItem::ExplainFinalForms(g));
}
Ok(StudyItem::NewGlyph(g))
}
fn review_glyph_card(&self, glyph: String) -> rusqlite::Result<GlyphCard> {
let ch = glyph.chars().next();
match ch {
Some(c) if is_vowel_point(c) => {
let host = self.random_vowel_host(c)?;
let distractors = match &host {
Some(h) => self.syllable_distractors(h, c)?,
None => Vec::new(),
};
Ok(GlyphCard {
is_consonant: false,
voiced: host
.as_deref()
.map(|h| crate::romanize::voiced_syllable(h, c))
.unwrap_or_default(),
voiced_distractors: distractors
.iter()
.map(|s| crate::romanize::voiced_syllable_str(s))
.collect(),
glyph,
host,
distractors,
})
}
_ => {
let distractors = self.glyph_distractors(&glyph)?;
Ok(GlyphCard {
is_consonant: ch.is_some_and(is_consonant),
glyph,
distractors,
..Default::default()
})
}
}
}
fn random_vowel_host(&self, vowel: char) -> rusqlite::Result<Option<String>> {
let sql = if is_hataf(vowel) {
"SELECT glyph FROM progress.glyph_srs \
WHERE glyph IN ('ה','ח') ORDER BY RANDOM() LIMIT 1"
} else {
"SELECT glyph FROM progress.glyph_srs \
WHERE unicode(glyph) BETWEEN 1488 AND 1514 \
AND glyph NOT IN ('א','ע','וּ') ORDER BY RANDOM() LIMIT 1"
};
match self.conn().query_row(sql, [], |r| r.get(0)).optional()? {
Some(h) => Ok(Some(h)),
None => self.known_vowel_host("", vowel),
}
}
fn upcoming_glyphs(
&self,
want: usize,
keep: impl Fn(&str) -> bool,
) -> rusqlite::Result<Vec<String>> {
self.ensure_surface_meta()?;
let seen = self.seen_glyph_mask()?;
let mut out: Vec<String> = Vec::new();
let mut stmt = self.conn().prepare(
"SELECT s.text FROM hebrewdb.surface s \
JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id \
WHERE popcount(sm.glyph_mask & ~?1) > 0 \
AND COALESCE(s.language, '') <> 'aramaic' \
ORDER BY s.occurrences DESC LIMIT 60",
)?;
let rows = stmt.query_map(params![seen], |r| r.get::<_, String>(0))?;
'rows: for row in rows {
for g in decompose_glyphs(&row?) {
if out.len() >= want {
break 'rows;
}
if !keep(&g.glyph) || out.contains(&g.glyph) || self.glyph_known(&g.glyph)? {
continue;
}
out.push(g.glyph);
}
}
Ok(out)
}
fn syllable_distractors(&self, host: &str, vowel: char) -> rusqlite::Result<Vec<String>> {
const WANT: usize = 6;
let mut out = Vec::new();
let mut stmt = self.conn().prepare(
"SELECT c.glyph || v.glyph \
FROM progress.glyph_srs c \
JOIN progress.glyph_srs v \
WHERE unicode(c.glyph) BETWEEN 1488 AND 1514 \
AND c.glyph NOT IN ('א','ע','וּ') \
AND (unicode(v.glyph) BETWEEN 1456 AND 1465 \
OR unicode(v.glyph) IN (1467, 1479)) \
AND NOT (unicode(v.glyph) BETWEEN 1457 AND 1459 \
AND c.glyph NOT IN ('ה','ח')) \
AND NOT (c.glyph = ?1 AND v.glyph = ?2) \
ORDER BY RANDOM() LIMIT ?3",
)?;
let rows = stmt.query_map(params![host, vowel.to_string(), WANT as i64], |r| {
r.get::<_, String>(0)
})?;
for row in rows {
out.push(row?);
}
if out.len() >= WANT {
return Ok(out);
}
let audible =
|g: &str| g.chars().next().is_some_and(is_consonant) && !is_silent_host(g) && g != "וּ";
let proper_vowel = |g: &str| {
let mut cs = g.chars();
cs.next().is_some_and(is_vowel_point) && cs.next().is_none()
};
let mut cons: Vec<String> = Vec::new();
let mut vows: Vec<String> = Vec::new();
{
let mut stmt = self
.conn()
.prepare("SELECT glyph FROM progress.glyph_srs")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
for row in rows {
let g = row?;
if audible(&g) {
cons.push(g);
} else if proper_vowel(&g) {
vows.push(g);
}
}
}
for g in self.upcoming_glyphs(12, |g| audible(g) || proper_vowel(g))? {
if audible(&g) {
cons.push(g);
} else {
vows.push(g);
}
}
for offset in 0..vows.len() {
for (ci, c) in cons.iter().enumerate() {
if out.len() >= WANT {
return Ok(out);
}
let v = &vows[(ci + offset) % vows.len()];
let vc = v.chars().next().expect("vowel glyph nonempty");
if is_hataf(vc) && !AUDIBLE_GUTTURALS.contains(&c.as_str()) {
continue;
}
if c == host && vc == vowel {
continue;
}
let syl = format!("{c}{v}");
if !out.contains(&syl) {
out.push(syl);
}
}
}
Ok(out)
}
fn glyph_distractors(&self, glyph: &str) -> rusqlite::Result<Vec<String>> {
const WANT: usize = 3;
let Some(ch) = glyph.chars().next() else {
return Ok(Vec::new());
};
let cons = is_consonant(ch);
let vowel = is_vowel_point(ch);
let same_kind = |g: &str| {
let Some(gc) = g.chars().next() else {
return false;
};
if cons {
is_consonant(gc)
} else if vowel {
is_vowel_point(gc)
} else {
!is_consonant(gc) && !is_vowel_point(gc)
}
};
let mut out = Vec::new();
let mut stmt = self.conn().prepare(
"SELECT glyph FROM progress.glyph_srs WHERE glyph != ?1 \
ORDER BY introduced_epoch DESC",
)?;
let rows = stmt.query_map(params![glyph], |r| r.get::<_, String>(0))?;
for row in rows {
if out.len() >= WANT {
break;
}
let g = row?;
if same_kind(&g) {
out.push(g);
}
}
if out.len() < WANT {
out.extend(self.upcoming_glyphs(WANT - out.len(), same_kind)?);
}
Ok(out)
}
fn meaning_distractors(&self, surface: &str, gloss: &str) -> rusqlite::Result<Vec<String>> {
const WANT: usize = 3;
let mut out: Vec<String> = Vec::new();
if gloss.trim().is_empty() {
return Ok(out);
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
seen.insert(gloss.trim().to_lowercase());
let mut candidates: Vec<String> = Vec::new();
{
let mut stmt = self.conn().prepare(
"SELECT s.text FROM progress.word_srs ws \
JOIN hebrewdb.surface s ON s.surface_id = ws.surface_id \
WHERE s.text != ?1 \
ORDER BY ws.introduced_epoch DESC LIMIT 60",
)?;
let rows = stmt.query_map(params![surface], |r| r.get::<_, String>(0))?;
for row in rows {
candidates.push(row?);
}
}
{
let mut stmt = self.conn().prepare(
"SELECT text FROM hebrewdb.surface \
WHERE text != ?1 AND n_candidates > 0 \
ORDER BY occurrences DESC LIMIT 80",
)?;
let rows = stmt.query_map(params![surface], |r| r.get::<_, String>(0))?;
for row in rows {
candidates.push(row?);
}
}
for cand in candidates {
if out.len() >= WANT {
break;
}
let (g, curated) = match crate::vocab_gloss::curated_gloss(&cand) {
Some(c) => (c.gloss.trim().to_string(), true),
None => match self.hebrew_word_info(&cand) {
Some(w) if w.is_name => continue,
Some(w) => (crate::bible::inflected_gloss(&w).trim().to_string(), false),
None => continue,
},
};
if g.is_empty() || crate::bible::is_name_gloss(&g) {
continue;
}
let g = if curated {
g
} else {
crate::bible::leading_sense(&g)
};
if seen.insert(g.to_lowercase()) {
out.push(g);
}
}
Ok(out)
}
fn word_card(&self, surface: &str) -> rusqlite::Result<Option<WordCard>> {
let row: Option<(i64, i64)> = self
.conn()
.query_row(
"SELECT surface_id, occurrences FROM hebrewdb.surface WHERE text = ?1",
params![surface],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((surface_id, occurrences)) = row else {
return Ok(None);
};
let meta_name: bool = self
.conn()
.query_row(
"SELECT is_name FROM progress.surface_meta WHERE surface_id = ?1",
params![surface_id],
|r| Ok(r.get::<_, i64>(0)? != 0),
)
.optional()?
.unwrap_or(false);
let (mut root, gloss, inflected, mut morph, resolved_name) =
match self.hebrew_word_info(surface) {
Some(w) => {
let morph = [
w.form.as_deref(),
w.tense.as_deref(),
w.person.as_deref(),
w.gender.as_deref(),
w.number.as_deref(),
w.state.as_deref(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
let inflected = crate::bible::inflected_gloss(&w);
(w.root, w.gloss, inflected, morph, w.is_name)
}
None => (
String::new(),
String::new(),
String::new(),
String::new(),
false,
),
};
let (gloss, root_gloss, note, curated) = match crate::vocab_gloss::curated_gloss(surface) {
Some(c) => (
c.gloss.to_string(),
String::new(),
c.note.unwrap_or_default().to_string(),
true,
),
None => match crate::bible::prefixed_name_gloss(surface) {
Some((g, note)) => (g, String::new(), note, false),
None if (!crate::vocab_gloss::curated_name(surface)
&& (meta_name || resolved_name))
|| crate::bible::is_name_gloss(&gloss) =>
{
let note = crate::bible::name_description(&gloss);
root.clear();
morph.clear();
(NAME_GLOSS.to_string(), String::new(), note, false)
}
None if !inflected.is_empty()
&& inflected.to_lowercase() != gloss.to_lowercase() =>
{
(inflected, gloss, String::new(), false)
}
None => (gloss, String::new(), String::new(), false),
},
};
let gloss = if curated {
gloss
} else {
crate::bible::leading_sense(&gloss)
};
let distractors = if gloss == NAME_GLOSS {
Vec::new()
} else {
self.meaning_distractors(surface, &gloss)?
};
Ok(Some(WordCard {
surface_id,
surface: surface.to_string(),
occurrences,
translit: crate::romanize::romanize(surface),
gloss,
root_gloss,
note,
root,
morph,
distractors,
}))
}
fn form_card(&self, surface: &str) -> rusqlite::Result<Option<WordCard>> {
let row: Option<(i64, i64)> = self
.conn()
.query_row(
"SELECT surface_id, occurrences FROM hebrewdb.surface WHERE text = ?1",
params![surface],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((surface_id, occurrences)) = row else {
return Ok(None);
};
let Some(w) = self.hebrew_word_info(surface) else {
return Ok(None);
};
let inflected = crate::bible::inflected_gloss(&w);
let morph = [
w.form.as_deref(),
w.tense.as_deref(),
w.person.as_deref(),
w.gender.as_deref(),
w.number.as_deref(),
w.state.as_deref(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
Ok(Some(WordCard {
surface_id,
surface: surface.to_string(),
occurrences,
translit: crate::romanize::romanize(surface),
gloss: inflected,
root_gloss: String::new(),
note: String::new(),
root: w.root.clone(),
morph,
distractors: crate::bible::form_distractors(&w),
}))
}
fn ensure_surface_meta(&self) -> rusqlite::Result<()> {
let stamp: Option<i64> = self
.conn()
.query_row(
"SELECT CAST(value AS INTEGER) FROM progress.meta WHERE key = 'surface_meta_v'",
[],
|r| r.get(0),
)
.optional()?;
let expected = self.surface_count()?;
let current: i64 =
self.conn()
.query_row("SELECT COUNT(*) FROM progress.surface_meta", [], |r| {
r.get(0)
})?;
if stamp == Some(SURFACE_META_VERSION) && current == expected {
return Ok(());
}
let surfaces: Vec<(i64, String, Option<String>)> = {
let mut stmt = self.conn().prepare(
"SELECT surface_id, text, lexical_class FROM hebrewdb.surface \
WHERE language IS NULL",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<rusqlite::Result<_>>()?
};
self.conn().execute_batch("BEGIN")?;
self.conn()
.execute("DELETE FROM progress.surface_meta", [])?;
let mut concept_masks: std::collections::HashMap<i64, i64> =
std::collections::HashMap::new();
{
let mut ins = self.conn().prepare(
"INSERT INTO progress.surface_meta(surface_id, root, form_tier, concept_mask, \
glyph_mask, is_name, is_qal, family_base, vkey, base_vkey, base_surface_id) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?9, ?1)",
)?;
for (surface_id, text, lexical_class) in surfaces {
let mask = surface_glyph_mask(&text);
let parsed = self.hebrew_word_by_surface_id(surface_id, text.clone());
let cmask = crate::grammar::concept_mask_for_surface(&text, parsed.as_ref());
let lexicalized_verb_tier = lexicalized_verb_tier(&text);
let (root, tier) = match &parsed {
Some(w) => (
w.root.clone(),
lexicalized_verb_tier.unwrap_or_else(|| form_tier(w)) as i64,
),
None => (String::new(), 0),
};
let is_qal = lexicalized_verb_tier.is_some()
|| parsed
.as_ref()
.and_then(|w| w.form.as_deref())
.is_some_and(|form| form == "Qal" || form == "Qal passive");
let is_name = if crate::vocab_gloss::curated_name(&text) {
true
} else if crate::vocab_gloss::curated_gloss(&text).is_some() {
false
} else {
parsed
.as_ref()
.is_some_and(|w| w.is_name || crate::bible::is_name_gloss(&w.gloss))
|| crate::bible::prefixed_name_gloss(&text).is_some()
|| (lexical_class.as_deref() == Some("proper")
&& !self.bdb_exact_vocab_match(
&text,
parsed.as_ref().and_then(|w| w.prefix.as_deref()),
))
};
let blank = parsed.as_ref().is_none_or(|w| w.gloss.trim().is_empty())
&& crate::vocab_gloss::curated_gloss(&text).is_none()
&& crate::bible::prefixed_name_gloss(&text).is_none()
&& !is_name;
let cmask = if blank { UNTEACHABLE_MASK } else { cmask };
concept_masks.insert(surface_id, cmask);
let vkey = crate::vocab_gloss::vocab_key(&text);
let base_vkey = parsed
.as_ref()
.and_then(|w| w.prefix.as_deref())
.and_then(|prefix| crate::bible::strip_proclitic(&text, prefix))
.map(|base| crate::vocab_gloss::vocab_key(&base))
.unwrap_or_else(|| vkey.clone());
ins.execute(params![
surface_id,
root,
tier,
cmask,
mask,
is_name as i64,
is_qal as i64,
vkey,
base_vkey
])?;
}
}
self.conn().execute(
"UPDATE progress.surface_meta AS sm SET base_vkey = vkey \
WHERE base_vkey <> vkey AND NOT EXISTS ( \
SELECT 1 FROM progress.surface_meta base \
WHERE base.vkey = sm.base_vkey)",
[],
)?;
self.conn().execute(
"UPDATE progress.surface_meta AS sm SET base_surface_id = ( \
SELECT MIN(base.surface_id) FROM progress.surface_meta base \
WHERE base.vkey = sm.base_vkey)",
[],
)?;
self.conn().execute(
"UPDATE progress.surface_meta AS sm SET family_base = CASE \
WHEN sm.root = '' THEN 1 \
WHEN EXISTS (SELECT 1 FROM progress.surface_meta q \
WHERE q.root = sm.root AND q.is_qal = 1) THEN \
CASE WHEN sm.is_qal = 1 AND sm.form_tier = \
(SELECT MIN(q.form_tier) FROM progress.surface_meta q \
WHERE q.root = sm.root AND q.is_qal = 1) THEN 1 ELSE 0 END \
WHEN sm.form_tier = (SELECT MIN(q.form_tier) FROM progress.surface_meta q \
WHERE q.root = sm.root) THEN 1 ELSE 0 END",
[],
)?;
self.conn().execute("DELETE FROM progress.verse_meta", [])?;
{
let verse_masks = {
let mut stmt = self.conn().prepare(
"SELECT vw.book, vw.chapter, vw.verse, vw.surface_id \
FROM hebrewdb.verse_word vw ORDER BY vw.book, vw.chapter, vw.verse",
)?;
let mut acc: std::collections::HashMap<(u8, u8, u8), Option<i64>> =
std::collections::HashMap::new();
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, u8>(0)?,
r.get::<_, u8>(1)?,
r.get::<_, u8>(2)?,
r.get::<_, i64>(3)?,
))
})?;
for row in rows {
let (b, c, v, sid) = row?;
let entry = acc.entry((b, c, v)).or_insert(Some(0));
*entry = match (entry.as_ref(), concept_masks.get(&sid)) {
(Some(m), Some(w)) => Some(m | w),
_ => None, };
}
acc
};
let mut ins = self.conn().prepare(
"INSERT INTO progress.verse_meta(book, chapter, verse, concept_mask) \
VALUES (?1, ?2, ?3, ?4)",
)?;
for ((b, c, v), mask) in verse_masks {
if let Some(m) = mask {
ins.execute(params![b, c, v, m])?;
}
}
}
self.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES ('surface_meta_v', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![SURFACE_META_VERSION.to_string()],
)?;
self.conn().execute_batch("COMMIT")?;
Ok(())
}
fn ensure_readability_progress(&self) -> rusqlite::Result<()> {
let expected: i64 =
self.conn()
.query_row("SELECT COUNT(*) FROM hebrewdb.surface", [], |r| r.get(0))?;
let actual: i64 =
self.conn()
.query_row("SELECT COUNT(*) FROM progress.surface_progress", [], |r| {
r.get(0)
})?;
let materialized_done: i64 = self.conn().query_row(
"SELECT COUNT(*) FROM progress.surface_progress WHERE graduated = 1",
[],
|r| r.get(0),
)?;
let actual_done: i64 = self.conn().query_row(
&format!(
"SELECT COUNT(*) FROM progress.surface_meta sm JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey"
),
[],
|r| r.get(0),
)?;
if actual == expected && materialized_done == actual_done {
return Ok(());
}
self.rebuild_readability_progress()
}
fn rebuild_readability_progress(&self) -> rusqlite::Result<()> {
self.conn().execute_batch("BEGIN IMMEDIATE")?;
let result = self.conn().execute_batch(&format!(
"DELETE FROM progress.surface_progress;
INSERT INTO progress.surface_progress(surface_id, graduated, graduated_epoch)
SELECT s.surface_id, CASE WHEN done.vkey IS NULL THEN 0 ELSE 1 END,
CASE WHEN done.vkey IS NULL THEN NULL ELSE 0 END
FROM hebrewdb.surface s
LEFT JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey
;
DELETE FROM progress.verse_progress;
INSERT INTO progress.verse_progress(
book, chapter, verse, unknown_words, min_root_frequency,
mean_root_frequency, max_root_frequency)
SELECT vw.book, vw.chapter, vw.verse,
SUM(CASE WHEN sp.graduated = 0 THEN 1 ELSE 0 END),
COALESCE(MIN(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0),
COALESCE(AVG(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0),
COALESCE(MAX(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0)
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
JOIN progress.surface_progress sp ON sp.surface_id = vw.surface_id
LEFT JOIN hebrewdb.roots r ON r.root = sm.root
GROUP BY vw.book, vw.chapter, vw.verse;"
));
match result {
Ok(()) => self.conn().execute_batch("COMMIT"),
Err(e) => {
let _ = self.conn().execute_batch("ROLLBACK");
Err(e)
}
}
}
fn record_surface_graduation(&self, surface_id: i64, now: i64) -> rusqlite::Result<()> {
let changed = self.conn().execute(
"UPDATE progress.surface_progress SET graduated = 1, graduated_epoch = ?2
WHERE surface_id IN (
SELECT twin.surface_id FROM progress.surface_meta source
JOIN progress.surface_meta twin ON twin.vkey = source.vkey
WHERE source.surface_id = ?1) AND graduated = 0",
params![surface_id, now],
)?;
if changed > 0 {
self.conn().execute_batch(&format!(
"INSERT OR REPLACE INTO progress.verse_progress(
book, chapter, verse, unknown_words, min_root_frequency,
mean_root_frequency, max_root_frequency, last_read_epoch)
SELECT vw.book, vw.chapter, vw.verse,
SUM(CASE WHEN sp.graduated = 0 THEN 1 ELSE 0 END),
COALESCE(MIN(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0),
COALESCE(AVG(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0),
COALESCE(MAX(CASE WHEN sp.graduated = 0 THEN
CASE WHEN sm.is_name = 1 THEN s.occurrences
ELSE COALESCE(r.n_occurrences, s.occurrences) END END), 0),
COALESCE(old.last_read_epoch, 0)
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
JOIN progress.surface_progress sp ON sp.surface_id = vw.surface_id
LEFT JOIN hebrewdb.roots r ON r.root = sm.root
LEFT JOIN progress.verse_progress old ON old.book = vw.book
AND old.chapter = vw.chapter AND old.verse = vw.verse
WHERE (vw.book, vw.chapter, vw.verse) IN (
SELECT DISTINCT hit.book, hit.chapter, hit.verse
FROM hebrewdb.verse_word hit
JOIN progress.surface_meta hit_sm ON hit_sm.surface_id = hit.surface_id
WHERE hit_sm.vkey = (
SELECT vkey FROM progress.surface_meta WHERE surface_id = {surface_id}))
GROUP BY vw.book, vw.chapter, vw.verse;"
))?;
}
Ok(())
}
fn surface_count(&self) -> rusqlite::Result<i64> {
self.conn().query_row(
"SELECT COUNT(*) FROM hebrewdb.surface WHERE language IS NULL",
[],
|r| r.get(0),
)
}
fn next_target_verse(
&self,
unlocked: i64,
seen_mask: i64,
letter_learning: bool,
) -> rusqlite::Result<Option<(u8, u8, u8)>> {
self.next_target_verse_excluding(None, unlocked, seen_mask, letter_learning)
}
fn next_target_verse_excluding(
&self,
exclude: Option<(u8, u8, u8)>,
unlocked: i64,
seen_mask: i64,
letter_learning: bool,
) -> rusqlite::Result<Option<(u8, u8, u8)>> {
let intro = if letter_learning {
format!(
"done.vkey IS NULL AND (COALESCE(sm.concept_mask, 0) & ~?1) = 0 \
AND (COALESCE(sm.concept_mask, 0) & {UNTEACHABLE_MASK}) = 0 \
AND {FAMILY_READY} AND {LEXICAL_BASE_READY} AND {NOT_NAME}"
)
} else {
format!(
"done.vkey IS NULL AND (COALESCE(sm.concept_mask, 0) & ~?1) = 0 \
AND (COALESCE(sm.concept_mask, 0) & {UNTEACHABLE_MASK}) = 0 \
AND {FAMILY_READY} AND {LEXICAL_BASE_READY}"
)
};
let unteachable_gate = String::new();
let settings = self.tutor_settings()?;
let order = if letter_learning {
let score = letter_learning_score("?2");
format!(
"MAX(CASE WHEN {intro} THEN {score} END) DESC, \
vw.book, vw.chapter, vw.verse"
)
} else {
format!(
"(MIN(MAX(CASE WHEN {intro} THEN {WORD_FREQ} END), 10000) * {} \
+ (1000 / MAX(COUNT(DISTINCT CASE WHEN done.vkey IS NULL \
THEN sm.vkey END), 1)) * {}) DESC, \
MAX(CASE WHEN {intro} THEN {WORD_FREQ} END) DESC, \
MIN(CASE WHEN {intro} THEN COALESCE(sm.form_tier, 0) END) ASC, \
vw.book, vw.chapter, vw.verse",
settings.vocab_priority, settings.verse_priority
)
};
let exclude_base = if letter_learning { 3 } else { 2 };
let exclude_where = if exclude.is_some() {
format!(
"WHERE NOT (vw.book = ?{b} AND vw.chapter = ?{c} AND vw.verse = ?{v})",
b = exclude_base,
c = exclude_base + 1,
v = exclude_base + 2
)
} else {
String::new()
};
let sql = format!(
"SELECT vw.book, vw.chapter, vw.verse
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey
LEFT JOIN progress.word_srs base_ws ON base_ws.surface_id = sm.base_surface_id
LEFT JOIN hebrewdb.roots r ON r.root = sm.root
LEFT JOIN ({KNOWN_ROOTS}) kr ON kr.root = sm.root
LEFT JOIN ({KNOWN_QAL_ROOTS}) kqr ON kqr.root = sm.root
{exclude_where}
GROUP BY vw.book, vw.chapter, vw.verse
HAVING SUM(CASE WHEN s.language = 'aramaic' THEN 1 ELSE 0 END) = 0
AND COUNT(DISTINCT CASE WHEN {intro} THEN vw.surface_id END) >= 1
AND (COUNT(DISTINCT CASE WHEN {intro} AND {NOT_NAME}
THEN vw.surface_id END) >= 1
OR SUM(CASE WHEN done.vkey IS NULL
AND (COALESCE(sm.concept_mask, 0) & ~?1) != 0
THEN 1 ELSE 0 END) = 0)
{unteachable_gate}
ORDER BY {order}
LIMIT 1"
);
let mut p: Vec<&dyn rusqlite::ToSql> = vec![&unlocked];
if letter_learning {
p.push(&seen_mask);
}
let (eb, ec, ev);
if let Some((b, c, v)) = &exclude {
eb = *b;
ec = *c;
ev = *v;
p.push(&eb);
p.push(&ec);
p.push(&ev);
}
self.conn()
.query_row(&sql, p.as_slice(), |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.optional()
}
fn first_unfinished_word(&self, b: u8, c: u8, v: u8) -> rusqlite::Result<Option<String>> {
let word_order = word_order();
self.conn()
.query_row(
&format!(
"SELECT s.text
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey
LEFT JOIN hebrewdb.roots r ON r.root = sm.root
LEFT JOIN ({KNOWN_ROOTS}) kr ON kr.root = sm.root
WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3
AND done.vkey IS NULL
{word_order}
LIMIT 1"
),
params![b, c, v],
|r| r.get(0),
)
.optional()
}
fn verse_done(&self, b: u8, c: u8, v: u8) -> rusqlite::Result<bool> {
Ok(self.first_unfinished_word(b, c, v)?.is_none())
}
fn unfinished_words(
&self,
(b, c, v): (u8, u8, u8),
unlocked: i64,
seen_mask: i64,
letter_learning: bool,
include_names: bool,
) -> rusqlite::Result<Vec<String>> {
let order = if letter_learning {
let score = letter_learning_score("?5");
format!("ORDER BY {score} DESC, s.occurrences DESC")
} else {
word_order()
};
let name_gate = if include_names {
String::new()
} else {
format!("AND {NOT_NAME}")
};
let sql = format!(
"SELECT s.text
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey
LEFT JOIN progress.word_srs base_ws ON base_ws.surface_id = sm.base_surface_id
LEFT JOIN hebrewdb.roots r ON r.root = sm.root
LEFT JOIN ({KNOWN_ROOTS}) kr ON kr.root = sm.root
LEFT JOIN ({KNOWN_QAL_ROOTS}) kqr ON kqr.root = sm.root
WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3
AND done.vkey IS NULL
AND (COALESCE(sm.concept_mask, 0) & ~?4) = 0
AND {FAMILY_READY}
AND {LEXICAL_BASE_READY}
{name_gate}
{order}
LIMIT 1"
);
let mut stmt = self.conn().prepare(&sql)?;
let mut p: Vec<&dyn rusqlite::ToSql> = vec![&b, &c, &v, &unlocked];
if letter_learning {
p.push(&seen_mask);
}
stmt.query_map(p.as_slice(), |r| r.get(0))?.collect()
}
fn verse_completable(&self, b: u8, c: u8, v: u8, unlocked: i64) -> rusqlite::Result<bool> {
let locked: i64 = self.conn().query_row(
&format!(
"SELECT COUNT(*)
FROM hebrewdb.verse_word vw
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm.vkey
WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3
AND done.vkey IS NULL
AND (COALESCE(sm.concept_mask, 0) & ~?4) != 0"
),
params![b, c, v, unlocked],
|r| r.get(0),
)?;
Ok(locked == 0)
}
fn next_introduction(
&self,
(b, c, v): (u8, u8, u8),
now: i64,
unlocked: i64,
seen_mask: i64,
letter_learning: bool,
) -> rusqlite::Result<Option<StudyItem>> {
let settings = self.tutor_settings()?;
let glyph_budget = self.glyphs_in_learning()? < settings.letters_per_batch as i64;
let word_budget = self.words_in_learning()? < settings.words_per_batch as i64;
let include_names = !letter_learning && self.verse_completable(b, c, v, unlocked)?;
let surfaces = self.unfinished_words(
(b, c, v),
unlocked,
seen_mask,
letter_learning,
include_names,
)?;
let mut ready_word: Option<String> = None;
let mut word_within_reach = false;
for s in &surfaces {
if self.word_srs(s)?.is_some() {
continue;
}
if self.all_glyphs_graduated(s)? {
word_within_reach = true;
if word_budget && ready_word.is_none() {
ready_word = Some(s.clone());
}
} else if !word_within_reach && self.all_glyphs_seen(s)? {
word_within_reach = true;
}
if ready_word.is_some() {
break;
}
}
let mut new_letter: Option<(String, GlyphCard)> = None;
if glyph_budget {
'outer: for s in &surfaces {
for g in decompose_glyphs(s) {
if !self.glyph_known(&g.glyph)? {
new_letter = Some((s.clone(), g));
break 'outer;
}
}
}
if new_letter.is_none() && self.prefer_new_letter(&settings)? {
new_letter = self.alphabet_frontier_glyph(unlocked)?;
}
}
let do_letter = match (ready_word.is_some(), new_letter.is_some()) {
(true, true) => self.prefer_new_letter(&settings)?,
(false, true) => self.prefer_new_letter(&settings)? || !word_within_reach,
(true, false) => false,
(false, false) => {
debug!(
"next_introduction: no candidate (glyph_budget={glyph_budget} \
word_budget={word_budget})"
);
return Ok(None);
}
};
debug!(
"next_introduction: ready_word={ready_word:?} new_letter={:?} do_letter={do_letter}",
new_letter.as_ref().map(|(s, g)| (s, &g.glyph))
);
if do_letter {
let (surface, g) = new_letter.expect("letter candidate present");
self.bump_intro_counter("intro.letters")?;
return Ok(Some(self.new_glyph_item(&surface, &g, now)?));
}
let Some(surface) = ready_word else {
debug!("next_introduction: letter deferred by focus gate");
return Ok(None);
};
if let Some(card) = self.next_grammar_card(&surface, now)? {
return Ok(Some(card));
}
self.bump_intro_counter("intro.words")?;
let Some(card) = self.word_card(&surface)? else {
return Ok(None);
};
if card.gloss == NAME_GLOSS {
let seeded = seeded_known_srs();
self.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2) \
ON CONFLICT(surface) DO NOTHING",
params![
surface,
card.surface_id,
seeded.ease,
seeded.interval_days,
seeded.due_at(now),
seeded.reps,
seeded.lapses,
now
],
)?;
}
Ok(Some(StudyItem::NewWord(card)))
}
fn prefer_new_letter(&self, s: &TutorSettings) -> rusqlite::Result<bool> {
let (letters, words) = self.intro_counts()?;
Ok(letters * 100 < s.letters_ratio as i64 * (letters + words + 1))
}
fn alphabet_frontier_glyph(
&self,
unlocked: i64,
) -> rusqlite::Result<Option<(String, GlyphCard)>> {
let seen = self.seen_glyph_mask()?;
let surface: Option<String> = self
.conn()
.query_row(
"SELECT s.text FROM hebrewdb.surface s
JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id
WHERE (COALESCE(sm.concept_mask, 0) & ~?1) = 0
AND popcount(sm.glyph_mask & ~?2) > 0
AND COALESCE(s.language, '') <> 'aramaic'
ORDER BY s.occurrences DESC LIMIT 1",
params![unlocked, seen],
|r| r.get(0),
)
.optional()?;
let Some(s) = surface else {
return Ok(None);
};
for g in decompose_glyphs(&s) {
if !self.glyph_known(&g.glyph)? {
return Ok(Some((s, g)));
}
}
Ok(None)
}
fn concept_seen(&self, key: &str) -> rusqlite::Result<bool> {
Ok(self
.conn()
.query_row(
"SELECT 1 FROM progress.concepts_seen WHERE concept = ?1",
params![key],
|_| Ok(()),
)
.optional()?
.is_some())
}
fn grammar_concepts_seen(&self) -> rusqlite::Result<i64> {
let keys: Vec<&str> = crate::grammar::concepts().iter().map(|c| c.key).collect();
let placeholders = keys.iter().map(|_| "?").collect::<Vec<_>>().join(",");
self.conn().query_row(
&format!(
"SELECT COUNT(*) FROM progress.concepts_seen WHERE concept IN ({placeholders})"
),
rusqlite::params_from_iter(keys.iter()),
|r| r.get(0),
)
}
fn grammar_example_surface(&self, key: &str, trigger: &str) -> rusqlite::Result<String> {
let Some(rank) = crate::grammar::concept_index(key) else {
return Ok(trigger.to_string());
};
for studied_only in [true, false] {
let studied = if studied_only {
"JOIN progress.word_srs ws ON ws.surface_id = sm.surface_id"
} else {
""
};
let found: Option<String> = self
.conn()
.query_row(
&format!(
"SELECT s.text FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
{studied} \
WHERE (sm.concept_mask & ?1) != 0 AND (sm.concept_mask >> (?2 + 1)) = 0 \
AND sm.is_name = 0 \
ORDER BY s.occurrences DESC LIMIT 1"
),
params![1i64 << rank, rank],
|r| r.get(0),
)
.optional()?;
if let Some(s) = found {
return Ok(s);
}
}
Ok(trigger.to_string())
}
fn next_grammar_card(&self, surface: &str, now: i64) -> rusqlite::Result<Option<StudyItem>> {
let w = self.hebrew_word_info(surface);
for key in crate::grammar::concepts_for_surface(surface, w.as_ref()) {
if self.concept_seen(key)? {
continue;
}
let Some(c) = crate::grammar::concept(key) else {
continue;
};
let example_surface = self.grammar_example_surface(key, surface)?;
let Some(example) = self
.word_card(&example_surface)?
.or(self.word_card(surface)?)
else {
return Ok(None);
};
self.conn().execute(
"INSERT INTO progress.concepts_seen(concept, introduced_epoch) VALUES (?1, ?2) \
ON CONFLICT(concept) DO NOTHING",
params![key, now],
)?;
return Ok(Some(StudyItem::ExplainGrammar(GrammarCard {
concept: key.to_string(),
title: c.title.to_string(),
explanation: c.explanation.to_string(),
formula: c.formula.unwrap_or_default().to_string(),
examples: c.examples.iter().map(|s| s.to_string()).collect(),
example,
})));
}
Ok(None)
}
fn next_form_introduction(
&self,
b: u8,
c: u8,
v: u8,
_now: i64,
) -> rusqlite::Result<Option<StudyItem>> {
let surface: Option<String> = self
.conn()
.query_row(
"SELECT s.text
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
JOIN progress.word_srs ws
ON ws.surface_id = vw.surface_id AND ws.interval_days >= 1
JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
LEFT JOIN progress.form_srs fs ON fs.surface_id = vw.surface_id
WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3
AND sm.form_tier >= 2 AND fs.surface_id IS NULL
AND sm.is_name = 0
ORDER BY sm.form_tier ASC, s.occurrences DESC
LIMIT 1",
params![b, c, v],
|r| r.get(0),
)
.optional()?;
let Some(surface) = surface else {
return Ok(None);
};
Ok(self.form_card(&surface)?.map(StudyItem::NewFormDrill))
}
fn suffix_host(
&self,
key: &str,
graduated_only: bool,
random: bool,
) -> rusqlite::Result<Option<String>> {
let cond = if graduated_only {
"ws.interval_days >= 1"
} else {
"1=1"
};
let order = if random {
"RANDOM()"
} else {
"s.occurrences DESC"
};
let mut stmt = self.conn().prepare(&format!(
"SELECT ws.surface FROM progress.word_srs ws \
JOIN hebrewdb.surface s ON s.surface_id = ws.surface_id \
WHERE {cond} ORDER BY {order}"
))?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
for row in rows {
let surface = row?;
if !crate::grammar::pronoun_suffix_host(&surface) {
continue;
}
if let Some(split) = crate::pronoun_suffix::split_pronoun_suffix(&surface)
&& split.key == key
{
return Ok(Some(surface));
}
}
Ok(None)
}
fn suffix_card(&self, key: &str, host: &str) -> rusqlite::Result<Option<SuffixCard>> {
let Some(split) = crate::pronoun_suffix::split_pronoun_suffix(host) else {
return Ok(None);
};
if split.key != key {
return Ok(None);
}
let gloss = match crate::vocab_gloss::curated_gloss(host) {
Some(c) => c.gloss.to_string(),
None => self
.hebrew_word_info(host)
.map(|w| w.gloss.trim().to_string())
.unwrap_or_default(),
};
Ok(Some(SuffixCard {
key: key.to_string(),
meaning: split.meaning.to_string(),
surface: host.to_string(),
translit: crate::romanize::romanize(host),
stem: split.stem,
suffix: split.suffix,
gloss,
distractors: self.suffix_distractors(key)?,
}))
}
fn suffix_distractors(&self, key: &str) -> rusqlite::Result<Vec<String>> {
const WANT: usize = 3;
let own = crate::pronoun_suffix::pronoun_suffix(key).map(|p| p.meaning);
let mut out: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<&str> = own.into_iter().collect();
let introduced: Vec<String> = {
let mut stmt = self
.conn()
.prepare("SELECT key FROM progress.suffix_srs ORDER BY introduced_epoch")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
rows.collect::<Result<_, _>>()?
};
let inventory = crate::pronoun_suffix::PRONOUN_SUFFIXES
.iter()
.map(|p| p.key.to_string());
for k in introduced.into_iter().chain(inventory) {
if out.len() >= WANT {
break;
}
let Some(p) = crate::pronoun_suffix::pronoun_suffix(&k) else {
continue;
};
if seen.insert(p.meaning) {
out.push(p.meaning.to_string());
}
}
Ok(out)
}
fn next_suffix_introduction(&self, _now: i64) -> rusqlite::Result<Option<StudyItem>> {
if !self.concept_seen("prep-suffix")? && !self.concept_seen("object-marker")? {
return Ok(None);
}
for p in crate::pronoun_suffix::PRONOUN_SUFFIXES {
if self.suffix_srs(p.key)?.is_some() {
continue;
}
if let Some(host) = self.suffix_host(p.key, true, false)?
&& let Some(card) = self.suffix_card(p.key, &host)?
{
return Ok(Some(StudyItem::NewSuffixDrill(card)));
}
}
Ok(None)
}
fn next_review(&self, now: i64, pull_forward: bool) -> rusqlite::Result<Option<StudyItem>> {
let cond = if pull_forward {
"interval_days = 0"
} else {
"due_epoch <= ?1"
};
let order_col = if pull_forward {
"introduced_epoch"
} else {
"due_epoch"
};
let pick = |table: &str, key_col: &str| -> rusqlite::Result<Option<(String, i64)>> {
let sql = format!(
"SELECT {key_col}, {order_col} FROM progress.{table} WHERE {cond} \
ORDER BY {order_col} ASC LIMIT 1"
);
let map = |r: &rusqlite::Row| Ok((r.get(0)?, r.get(1)?));
if pull_forward {
self.conn().query_row(&sql, [], map).optional()
} else {
self.conn().query_row(&sql, params![now], map).optional()
}
};
let glyph: GlyphRow = pick("glyph_srs", "glyph")?;
let word: WordRow = pick("word_srs", "surface")?;
let form: WordRow = pick("form_srs", "surface")?;
let suffix: WordRow = pick("suffix_srs", "key")?;
let due_of = |r: &Option<(String, i64)>| r.as_ref().map(|(_, d)| *d);
let best = [
due_of(&word),
due_of(&form),
due_of(&suffix),
due_of(&glyph),
]
.into_iter()
.flatten()
.min();
let Some(best) = best else {
debug!("next_review: pull_forward={pull_forward} -> nothing due");
return Ok(None);
};
if due_of(&word) == Some(best) {
let (surface, _) = word.expect("min came from word");
debug!("next_review: pull_forward={pull_forward} -> word {surface:?} (due={best})");
if !pull_forward {
let reviews: i64 = self.conn().query_row(
"SELECT COUNT(*) FROM progress.reviews WHERE track = 'word'",
[],
|r| r.get(0),
)?;
let shown_at: Option<i64> = self.conn().query_row(
"SELECT CAST(value AS INTEGER) FROM progress.meta WHERE key = 'reading_review_at'",
[], |r| r.get(0),
).optional()?;
if reviews > 0 && reviews % 10 == 0 && shown_at != Some(reviews) {
self.ensure_surface_meta()?;
self.ensure_readability_progress()?;
let verse: Option<(u8, u8, u8)> = self
.conn()
.query_row(
"SELECT vp.book, vp.chapter, vp.verse
FROM progress.verse_progress vp
JOIN hebrewdb.verse_word vw ON vw.book = vp.book
AND vw.chapter = vp.chapter AND vw.verse = vp.verse
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
WHERE vp.unknown_words = 0 AND s.text = ?1
ORDER BY vp.last_read_epoch, vp.book, vp.chapter, vp.verse LIMIT 1",
params![surface],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
if let Some((b, c, v)) = verse {
self.conn().execute(
"UPDATE progress.verse_progress SET last_read_epoch = ?4
WHERE book = ?1 AND chapter = ?2 AND verse = ?3",
params![b, c, v, now],
)?;
self.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES ('reading_review_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![reviews.to_string()],
)?;
let examples = self.readable_examples(b, c, v, 3)?;
let (words, names) = self.verse_words_flagged(b, c, v)?.into_iter().unzip();
return Ok(Some(StudyItem::ReadVerse(VerseCard {
book: b,
chapter: c,
verse: v,
examples,
words,
names,
})));
}
}
}
return Ok(self.word_card(&surface)?.map(StudyItem::ReviewWord));
}
if due_of(&form) == Some(best) {
let (surface, _) = form.expect("min came from form");
debug!("next_review: pull_forward={pull_forward} -> form {surface:?} (due={best})");
return Ok(self.form_card(&surface)?.map(StudyItem::ReviewFormDrill));
}
if due_of(&suffix) == Some(best) {
let (key, _) = suffix.expect("min came from suffix");
debug!("next_review: pull_forward={pull_forward} -> suffix {key:?} (due={best})");
if let Some(host) = self.suffix_host(&key, false, true)?
&& let Some(card) = self.suffix_card(&key, &host)?
{
return Ok(Some(StudyItem::ReviewSuffixDrill(card)));
}
debug!("next_review: no host renders suffix {key:?}; dropping the row");
self.conn().execute(
"DELETE FROM progress.suffix_srs WHERE key = ?1",
params![key],
)?;
return self.next_review(now, pull_forward);
}
let (g, _) = glyph.expect("min came from glyph");
debug!("next_review: pull_forward={pull_forward} -> glyph {g:?} (due={best})");
Ok(Some(StudyItem::ReviewGlyph(self.review_glyph_card(g)?)))
}
fn mark_seen(&self, mark: &str) -> rusqlite::Result<bool> {
Ok(self
.conn()
.query_row(
"SELECT 1 FROM progress.marks_seen WHERE mark = ?1",
params![mark],
|_| Ok(()),
)
.optional()?
.is_some())
}
fn next_unseen_reading_mark(&self, b: u8, c: u8, v: u8) -> rusqlite::Result<Option<GlyphCard>> {
let text = self.get(b, c, v)?;
for mark in READING_MARKS {
if !text.contains(mark) {
continue;
}
let key = mark.to_string();
if !self.mark_seen(&key)? {
return Ok(Some(GlyphCard {
glyph: key,
..Default::default()
}));
}
}
Ok(None)
}
fn meta_target(&self) -> rusqlite::Result<Option<(u8, u8, u8)>> {
let v: Option<String> = self
.conn()
.query_row(
"SELECT value FROM progress.meta WHERE key = 'target'",
[],
|r| r.get(0),
)
.optional()?;
Ok(v.and_then(|s| {
let mut it = s.split('.').filter_map(|n| n.parse::<u8>().ok());
Some((it.next()?, it.next()?, it.next()?))
}))
}
fn set_meta_target(&self, t: Option<(u8, u8, u8)>) -> rusqlite::Result<()> {
match t {
Some((b, c, v)) => self.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES ('target', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![format!("{b}.{c}.{v}")],
),
None => self
.conn()
.execute("DELETE FROM progress.meta WHERE key = 'target'", []),
}
.map(|_| ())
}
pub fn next_study_item(&self, now: i64) -> rusqlite::Result<StudyItem> {
self.next_study_item_impl(now, false)
}
fn next_study_item_impl(
&self,
now: i64,
interleave_on_stall: bool,
) -> rusqlite::Result<StudyItem> {
debug!("next_study_item: now={now}");
for key in INTRO_CONCEPTS {
if !self.concept_seen(key)? {
self.conn().execute(
"INSERT INTO progress.concepts_seen(concept, introduced_epoch) VALUES (?1, ?2) \
ON CONFLICT(concept) DO NOTHING",
params![key, now],
)?;
debug!("next_study_item: intro card {key}");
return Ok(StudyItem::ExplainIntro(key.to_string()));
}
}
if let Some(review) = self.next_review(now, false)? {
debug!("next_study_item: due review -> {review:?}");
return Ok(review);
}
if let Some(item) = self.next_suffix_introduction(now)? {
debug!("next_study_item: suffix drill introduction -> {item:?}");
return Ok(item);
}
self.ensure_surface_meta()?;
self.ensure_readability_progress()?;
let settings = self.tutor_settings()?;
let unlocked = self.unlocked_concepts(&settings, now)?;
let letter_learning = settings.grammar_gating && unlocked == 0;
let seen_mask = if letter_learning {
self.seen_glyph_mask()?
} else {
0
};
debug!(
"next_study_item: unlocked={unlocked:#x} ({} concepts) \
letter_learning={letter_learning} seen_mask={seen_mask:#x}",
unlocked.count_ones()
);
let target = match self.meta_target()? {
Some(t) => {
debug!("next_study_item: resuming meta_target={t:?}");
t
}
None => match self.next_target_verse(unlocked, seen_mask, letter_learning)? {
Some(t) => {
debug!("next_study_item: new target verse={t:?}");
self.set_meta_target(Some(t))?;
t
}
None => {
debug!("next_study_item: no eligible target; consolidating");
return Ok(self.next_review(now, true)?.unwrap_or(StudyItem::Done));
}
},
};
let (b, c, v) = target;
if let Some(item) =
self.next_introduction((b, c, v), now, unlocked, seen_mask, letter_learning)?
{
debug!("next_study_item: introducing {item:?}");
return Ok(item);
}
if !self.verse_done(b, c, v)? {
if self
.unfinished_words((b, c, v), unlocked, seen_mask, letter_learning, true)?
.is_empty()
{
self.set_meta_target(None)?;
return self.next_study_item_impl(now, interleave_on_stall);
}
if interleave_on_stall
&& let Some((ab, ac, av)) = self.next_target_verse_excluding(
Some((b, c, v)),
unlocked,
seen_mask,
letter_learning,
)?
&& let Some(item) =
self.next_introduction((ab, ac, av), now, unlocked, seen_mask, letter_learning)?
{
debug!(
"next_study_item: verse {b}/{c}/{v} has nothing new; \
interleaving from {ab}/{ac}/{av} -> {item:?}"
);
return Ok(item);
}
if let Some(review) = self.next_review(now, true)? {
debug!(
"next_study_item: verse {b}/{c}/{v} not done; pull-forward review -> {review:?}"
);
return Ok(review);
}
debug!(
"next_study_item: verse {b}/{c}/{v} stuck behind locked grammar; dropping target"
);
self.set_meta_target(None)?;
return self.next_study_item_impl(now, interleave_on_stall);
}
if let Some(form) = self.next_form_introduction(b, c, v, now)? {
debug!("next_study_item: form drill introduction -> {form:?}");
return Ok(form);
}
if let Some(mark) = self.next_unseen_reading_mark(b, c, v)? {
debug!("next_study_item: explaining unseen reading mark {mark:?}");
self.conn().execute(
"INSERT INTO progress.marks_seen(mark, introduced_epoch) VALUES (?1, ?2) \
ON CONFLICT(mark) DO NOTHING",
params![mark.glyph, now],
)?;
return Ok(StudyItem::ExplainMark(mark));
}
debug!("next_study_item: verse {b}/{c}/{v} fully learnt -> offering reading review");
self.conn().execute(
"UPDATE progress.verse_progress SET last_read_epoch = ?4
WHERE book = ?1 AND chapter = ?2 AND verse = ?3 AND unknown_words = 0",
params![b, c, v, now],
)?;
self.set_meta_target(None)?;
let examples = self.readable_examples(b, c, v, 3)?;
let (words, names) = self.verse_words_flagged(b, c, v)?.into_iter().unzip();
Ok(StudyItem::ReadVerse(VerseCard {
book: b,
chapter: c,
verse: v,
examples,
words,
names,
}))
}
pub fn submit_review(
&self,
track: Track,
key: &str,
grade: Grade,
now: i64,
) -> rusqlite::Result<StudyItem> {
debug!("submit_review: track={track:?} key={key:?} grade={grade:?} now={now}");
let grade_i = grade as i64;
match track {
Track::Glyph => {
for glyph in split_glyph_key(key) {
if glyph.chars().count() == 1
&& READING_MARKS.contains(&glyph.chars().next().unwrap())
{
continue;
}
let next = self.glyph_srs(&glyph)?.unwrap_or_default().graded(grade);
self.conn().execute(
"INSERT INTO progress.glyph_srs(glyph, ease, interval_days, due_epoch, \
reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
ON CONFLICT(glyph) DO UPDATE SET ease=excluded.ease, \
interval_days=excluded.interval_days, due_epoch=excluded.due_epoch, \
reps=excluded.reps, lapses=excluded.lapses, last_grade=excluded.last_grade",
params![
glyph,
next.ease,
next.interval_days,
next.due_at(now),
next.reps,
next.lapses,
now,
grade_i
],
)?;
}
}
Track::Word => {
let previous = self.word_srs(key)?.unwrap_or_default();
let next = previous.graded(grade);
let due = next.due_at(now);
let surface_id: i64 = self.conn().query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = ?1",
params![key],
|r| r.get(0),
)?;
self.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, \
interval_days, due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \
ON CONFLICT(surface) DO UPDATE SET ease=excluded.ease, \
interval_days=excluded.interval_days, due_epoch=excluded.due_epoch, \
reps=excluded.reps, lapses=excluded.lapses, last_grade=excluded.last_grade",
params![
key,
surface_id,
next.ease,
next.interval_days,
due,
next.reps,
next.lapses,
now,
grade_i
],
)?;
if !previous.graduated() && next.graduated() {
self.ensure_surface_meta()?;
self.ensure_readability_progress()?;
self.record_surface_graduation(surface_id, now)?;
self.set_meta_target(None)?;
}
}
Track::Form => {
let next = self.form_srs(key)?.unwrap_or_default().graded(grade);
let due = next.due_at(now);
let surface_id: i64 = self.conn().query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = ?1",
params![key],
|r| r.get(0),
)?;
self.conn().execute(
"INSERT INTO progress.form_srs(surface, surface_id, ease, \
interval_days, due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \
ON CONFLICT(surface) DO UPDATE SET ease=excluded.ease, \
interval_days=excluded.interval_days, due_epoch=excluded.due_epoch, \
reps=excluded.reps, lapses=excluded.lapses, last_grade=excluded.last_grade",
params![
key,
surface_id,
next.ease,
next.interval_days,
due,
next.reps,
next.lapses,
now,
grade_i
],
)?;
}
Track::Suffix => {
if crate::pronoun_suffix::pronoun_suffix(key).is_some() {
let next = self.suffix_srs(key)?.unwrap_or_default().graded(grade);
self.conn().execute(
"INSERT INTO progress.suffix_srs(key, ease, interval_days, due_epoch, \
reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
ON CONFLICT(key) DO UPDATE SET ease=excluded.ease, \
interval_days=excluded.interval_days, due_epoch=excluded.due_epoch, \
reps=excluded.reps, lapses=excluded.lapses, last_grade=excluded.last_grade",
params![
key,
next.ease,
next.interval_days,
next.due_at(now),
next.reps,
next.lapses,
now,
grade_i
],
)?;
}
}
}
let track_str = match track {
Track::Glyph => "glyph",
Track::Word => "word",
Track::Form => "form",
Track::Suffix => "suffix",
};
self.conn().execute(
"INSERT INTO progress.reviews(epoch, day, track, grade) VALUES (?1, ?2, ?3, ?4)",
params![now, now.div_euclid(SECONDS_PER_DAY), track_str, grade_i],
)?;
self.next_study_item_impl(now, grade == Grade::Again)
}
pub fn verse_words(&self, b: u8, c: u8, v: u8) -> rusqlite::Result<Vec<String>> {
Ok(self
.verse_words_flagged(b, c, v)?
.into_iter()
.map(|(w, _)| w)
.collect())
}
pub fn verse_words_flagged(
&self,
b: u8,
c: u8,
v: u8,
) -> rusqlite::Result<Vec<(String, bool)>> {
let mut stmt = self.conn().prepare(
"SELECT s.text, COALESCE(sm.is_name, 0)
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
LEFT JOIN progress.surface_meta sm ON sm.surface_id = vw.surface_id
WHERE vw.book = ?1 AND vw.chapter = ?2 AND vw.verse = ?3
ORDER BY vw.position",
)?;
stmt.query_map(params![b, c, v], |r| {
Ok((r.get(0)?, r.get::<_, i64>(1)? != 0))
})?
.collect()
}
pub fn readable_examples(
&self,
b: u8,
c: u8,
v: u8,
limit: i64,
) -> rusqlite::Result<Vec<(u8, u8, u8)>> {
let mut stmt = self.conn().prepare(&format!(
"SELECT DISTINCT vw2.book, vw2.chapter, vw2.verse
FROM hebrewdb.verse_word vw1
JOIN hebrewdb.verse_word vw2 ON vw2.surface_id = vw1.surface_id
WHERE vw1.book = ?1 AND vw1.chapter = ?2 AND vw1.verse = ?3
AND NOT (vw2.book = ?1 AND vw2.chapter = ?2 AND vw2.verse = ?3)
AND NOT EXISTS (
SELECT 1 FROM hebrewdb.verse_word w3
LEFT JOIN progress.surface_meta sm3 ON sm3.surface_id = w3.surface_id
LEFT JOIN ({DONE_SURFACES}) done ON done.vkey = sm3.vkey
WHERE w3.book = vw2.book AND w3.chapter = vw2.chapter
AND w3.verse = vw2.verse AND done.vkey IS NULL)
LIMIT ?4"
))?;
let rows = stmt.query_map(params![b, c, v, limit], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})?;
rows.collect()
}
pub fn tutor_progress(&self) -> rusqlite::Result<TutorProgress> {
self.ensure_surface_meta()?;
self.ensure_readability_progress()?;
let letters_known = self.conn().query_row(
"SELECT COUNT(DISTINCT unicode(substr(glyph, 1, 1))) FROM progress.glyph_srs \
WHERE interval_days >= 1 AND unicode(substr(glyph, 1, 1)) BETWEEN 1488 AND 1514",
[],
|r| r.get(0),
)?;
let vowels_known = self.conn().query_row(
"SELECT COUNT(*) FROM progress.glyph_srs \
WHERE interval_days >= 1 \
AND (unicode(glyph) BETWEEN 1456 AND 1465 OR unicode(glyph) IN (1467, 1479))",
[],
|r| r.get(0),
)?;
let words_known = self.conn().query_row(
&format!("SELECT COUNT(*) FROM ({DONE_SURFACES})"),
[],
|r| r.get(0),
)?;
let grammar_known = self.grammar_concepts_seen()?;
let verses_grammar_unlocked = self.verses_grammar_unlocked()?;
let verses_readable = self.conn().query_row(
"SELECT COUNT(*) FROM progress.verse_progress WHERE unknown_words = 0",
[],
|r| r.get(0),
)?;
let total_verses =
self.conn()
.query_row("SELECT COUNT(*) FROM progress.verse_progress", [], |r| {
r.get(0)
})?;
Ok(TutorProgress {
letters_known,
letters_total: LETTER_GLYPH_TOTAL,
vowels_known,
vowels_total: VOWEL_GLYPH_TOTAL,
grammar_known,
grammar_total: crate::grammar::concept_count() as i64,
words_known,
verses_grammar_unlocked,
verses_readable,
total_verses,
})
}
fn verses_grammar_unlocked(&self) -> rusqlite::Result<i64> {
let settings = self.tutor_settings()?;
if !settings.grammar_gating {
return self
.conn()
.query_row("SELECT COUNT(*) FROM hebrewdb.verse_stats", [], |r| {
r.get(0)
});
}
let mut unlocked = 0i64;
let mut stmt = self
.conn()
.prepare("SELECT concept FROM progress.concepts_unlocked")?;
for key in stmt.query_map([], |r| r.get::<_, String>(0))? {
if let Some(bit) = crate::grammar::concept_bit(&key?) {
unlocked |= bit;
}
}
let locked_requirements = crate::grammar::concepts()
.iter()
.filter(|concept| {
crate::grammar::concept_bit(concept.key).is_some_and(|bit| unlocked & bit == 0)
})
.map(|concept| format!("{} = 0", concept.key.replace('-', "_")))
.collect::<Vec<_>>();
let where_clause = if locked_requirements.is_empty() {
String::new()
} else {
format!(" WHERE {}", locked_requirements.join(" AND "))
};
self.conn().query_row(
&format!("SELECT COUNT(*) FROM hebrewdb.verse_stats{where_clause}"),
[],
|r| r.get(0),
)
}
pub fn seen_concepts(&self) -> rusqlite::Result<Vec<SeenConcept>> {
let script = |kind: &str, key: &str| SeenConcept {
kind: kind.to_string(),
key: key.to_string(),
title: String::new(),
explanation: String::new(),
formula: String::new(),
examples: Vec::new(),
};
let mut out = Vec::new();
for key in INTRO_CONCEPTS {
if self.concept_seen(key)? {
out.push(script("intro", key));
}
}
if self.concept_seen(FINAL_FORMS_CONCEPT)? {
out.push(script("final_forms", FINAL_FORMS_CONCEPT));
}
let mut stmt = self
.conn()
.prepare("SELECT mark FROM progress.marks_seen ORDER BY introduced_epoch")?;
let marks: Vec<String> = stmt
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
out.extend(marks.iter().map(|m| script("mark", m)));
for c in crate::grammar::concepts() {
if self.concept_seen(c.key)? {
out.push(SeenConcept {
kind: "grammar".to_string(),
key: c.key.to_string(),
title: c.title.to_string(),
explanation: c.explanation.to_string(),
formula: c.formula.unwrap_or_default().to_string(),
examples: c.examples.iter().map(|s| s.to_string()).collect(),
});
}
}
Ok(out)
}
pub fn tutor_stats(&self, now: i64) -> rusqlite::Result<TutorStats> {
self.ensure_surface_meta()?;
self.ensure_readability_progress()?;
let conn = self.conn();
let count = |sql: &str| -> rusqlite::Result<i64> { conn.query_row(sql, [], |r| r.get(0)) };
const LETTER: &str = "unicode(substr(glyph, 1, 1)) BETWEEN 1488 AND 1514";
const VOWEL: &str =
"(unicode(glyph) BETWEEN 1456 AND 1465 OR unicode(glyph) IN (1467, 1479))";
let letters_seen = count(&format!(
"SELECT COUNT(DISTINCT unicode(substr(glyph, 1, 1))) FROM progress.glyph_srs \
WHERE {LETTER}"
))?;
let letters_mature = count(&format!(
"SELECT COUNT(DISTINCT unicode(substr(glyph, 1, 1))) FROM progress.glyph_srs \
WHERE {LETTER} AND interval_days >= 1"
))?;
let vowels_seen = count(&format!(
"SELECT COUNT(*) FROM progress.glyph_srs WHERE {VOWEL}"
))?;
let vowels_mature = count(&format!(
"SELECT COUNT(*) FROM progress.glyph_srs WHERE {VOWEL} AND interval_days >= 1"
))?;
let words_seen = count("SELECT COUNT(*) FROM progress.word_srs")?;
let words_mature =
count("SELECT COUNT(*) FROM progress.word_srs WHERE interval_days >= 1")?;
let grammar_seen = self.grammar_concepts_seen()?;
let glyphs_due = conn.query_row(
"SELECT COUNT(*) FROM progress.glyph_srs WHERE due_epoch <= ?1",
params![now],
|r| r.get(0),
)?;
let words_due = conn.query_row(
"SELECT COUNT(*) FROM progress.word_srs WHERE due_epoch <= ?1",
params![now],
|r| r.get(0),
)?;
let day_now = now.div_euclid(SECONDS_PER_DAY);
let reviews_today = conn.query_row(
"SELECT COUNT(*) FROM progress.reviews WHERE day = ?1",
params![day_now],
|r| r.get(0),
)?;
let reviews_total = count("SELECT COUNT(*) FROM progress.reviews")?;
let recalled = count("SELECT COUNT(*) FROM progress.reviews WHERE grade > 0")?;
let accuracy_pct = if reviews_total > 0 {
recalled * 100 / reviews_total
} else {
0
};
let verses_readable =
count("SELECT COUNT(*) FROM progress.verse_progress WHERE unknown_words = 0")?;
let total_verses = count("SELECT COUNT(*) FROM progress.verse_progress")?;
Ok(TutorStats {
letters_seen,
letters_learning: letters_seen - letters_mature,
letters_mature,
vowels_seen,
vowels_learning: vowels_seen - vowels_mature,
vowels_mature,
words_seen,
words_learning: words_seen - words_mature,
words_mature,
grammar_seen,
grammar_total: crate::grammar::concept_count() as i64,
glyphs_due,
words_due,
reviews_today,
reviews_total,
streak_days: self.review_streak(day_now)?,
accuracy_pct,
verses_readable,
total_verses,
})
}
fn review_streak(&self, day_now: i64) -> rusqlite::Result<i64> {
let mut stmt = self
.conn()
.prepare("SELECT DISTINCT day FROM progress.reviews ORDER BY day DESC")?;
let days: Vec<i64> = stmt
.query_map([], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
let mut expected = match days.first() {
Some(&d) if d == day_now => day_now,
_ => day_now - 1,
};
let mut streak = 0;
for d in days {
if d == expected {
streak += 1;
expected -= 1;
} else if d < expected {
break;
}
}
Ok(streak)
}
pub fn needs_onboarding(&self) -> rusqlite::Result<bool> {
let glyphs: i64 =
self.conn()
.query_row("SELECT COUNT(*) FROM progress.glyph_srs", [], |r| r.get(0))?;
if glyphs > 0 {
return Ok(false);
}
let words: i64 =
self.conn()
.query_row("SELECT COUNT(*) FROM progress.word_srs", [], |r| r.get(0))?;
Ok(words == 0)
}
pub fn calibration_tier_count(&self) -> rusqlite::Result<u32> {
self.conn().query_row(
&format!(
"SELECT COUNT(*) FROM (SELECT DISTINCT {DIFFICULTY} AS diff \
FROM hebrewdb.verse_word vw \
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id \
GROUP BY vw.book, vw.chapter, vw.verse \
HAVING {NOT_ARAMAIC})"
),
[],
|r| r.get(0),
)
}
pub fn seed_known_alphabet(&self, now: i64) -> rusqlite::Result<()> {
for key in INTRO_CONCEPTS.iter().copied().chain([FINAL_FORMS_CONCEPT]) {
self.conn().execute(
"INSERT INTO progress.concepts_seen(concept, introduced_epoch) VALUES (?1, ?2) \
ON CONFLICT(concept) DO NOTHING",
params![key, now],
)?;
}
let mut glyphs = std::collections::HashSet::new();
{
let mut stmt = self
.conn()
.prepare("SELECT DISTINCT text FROM hebrewdb.surface WHERE language IS NULL")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let text: String = row.get(0)?;
for g in decompose_glyphs(&text) {
glyphs.insert(g.glyph);
}
}
}
let seeded = seeded_known_srs();
for glyph in glyphs {
self.conn().execute(
"INSERT INTO progress.glyph_srs(glyph, ease, interval_days, due_epoch, \
reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 2) \
ON CONFLICT(glyph) DO NOTHING",
params![
glyph,
seeded.ease,
seeded.interval_days,
seeded.due_at(now),
seeded.reps,
seeded.lapses,
now
],
)?;
}
Ok(())
}
pub fn calibration_probe(&self, tier: u32) -> rusqlite::Result<Option<CalibrationProbe>> {
let min_occurrences: Option<i64> = self
.conn()
.query_row(
&format!(
"SELECT diff FROM (SELECT DISTINCT {DIFFICULTY} AS diff \
FROM hebrewdb.verse_word vw \
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id \
GROUP BY vw.book, vw.chapter, vw.verse \
HAVING {NOT_ARAMAIC}) \
ORDER BY diff DESC LIMIT 1 OFFSET ?1"
),
params![tier],
|r| r.get(0),
)
.optional()?;
let Some(min_occurrences) = min_occurrences else {
return Ok(None);
};
let found: Option<(u8, u8, u8)> = self
.conn()
.query_row(
&format!(
"SELECT vw.book, vw.chapter, vw.verse
FROM hebrewdb.verse_word vw
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id
GROUP BY vw.book, vw.chapter, vw.verse
HAVING {NOT_ARAMAIC} AND {DIFFICULTY} = ?1
ORDER BY vw.book, vw.chapter, vw.verse
LIMIT 1"
),
params![min_occurrences],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.optional()?;
let Some((b, c, v)) = found else {
return Ok(None);
};
let text = self.get(b, c, v)?;
Ok(Some(CalibrationProbe {
book: b,
chapter: c,
verse: v,
text,
tier,
min_occurrences,
}))
}
pub fn seed_known_vocab(&self, min_occurrences: i64, now: i64) -> rusqlite::Result<()> {
if min_occurrences <= 0 {
return Ok(());
}
let seeded = seeded_known_srs();
let due = seeded.due_at(now);
let mut stmt = self.conn().prepare(
"SELECT text, surface_id FROM hebrewdb.surface \
WHERE language IS NULL AND occurrences >= ?1",
)?;
let rows: Vec<(String, i64)> = stmt
.query_map(params![min_occurrences], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<_>>()?;
for (surface, surface_id) in rows {
self.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2) \
ON CONFLICT(surface) DO NOTHING",
params![
surface,
surface_id,
seeded.ease,
seeded.interval_days,
due,
seeded.reps,
seeded.lapses,
now
],
)?;
}
Ok(())
}
pub fn reset_tutor(&self) -> rusqlite::Result<()> {
self.conn().execute_batch(
"DELETE FROM progress.glyph_srs;
DELETE FROM progress.word_srs;
DELETE FROM progress.surface_progress;
DELETE FROM progress.verse_progress;
DELETE FROM progress.meta WHERE key != 'surface_meta_v';
DELETE FROM progress.form_srs;
DELETE FROM progress.suffix_srs;
DELETE FROM progress.reviews;
DELETE FROM progress.marks_seen;
DELETE FROM progress.concepts_seen;
DELETE FROM progress.concepts_unlocked;",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sm2_learning_steps_then_graduation() {
let s = Srs::default();
let s = s.graded(Grade::Good);
assert_eq!((s.reps, s.interval_days), (1, 0));
let s = s.graded(Grade::Good);
assert_eq!((s.reps, s.interval_days), (2, 0));
let s = s.graded(Grade::Good);
assert_eq!((s.reps, s.interval_days), (3, 1)); assert!(s.graduated());
let s = s.graded(Grade::Good);
assert_eq!(s.interval_days, 6);
let s = s.graded(Grade::Again);
assert_eq!((s.reps, s.interval_days), (0, 0));
assert_eq!(s.lapses, 1);
assert!(!s.graduated());
}
#[test]
fn cold_start_opens_on_content_words_not_genealogy() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.seed_known_alphabet(1_700_000_000)?; bible.set_tutor_settings(&TutorSettings {
grammar_gating: false,
..Default::default()
})?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut rooted = 0;
let mut total = 0;
for _ in 0..4000 {
if total >= 12 {
break;
}
now += 5;
item = match item {
StudyItem::NewWord(w) => {
total += 1;
if !w.root.is_empty() {
rooted += 1;
}
bible.submit_review(Track::Word, &w.surface, Grade::Easy, now)?
}
StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Easy, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Easy, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Easy, now)?
}
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Easy, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_)
| StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
}
assert_eq!(total, 12, "should introduce a dozen words quickly");
assert!(
rooted >= 8,
"early vocabulary should be mostly root-bearing content words, \
got only {rooted}/12 (a genealogy-of-proper-names regression)"
);
Ok(())
}
#[test]
fn root_frequency_only_boosts_verbal_family_openers() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.ensure_surface_meta()?;
let frequency = |surface: &str| -> rusqlite::Result<(i64, i64)> {
bible.conn().query_row(
&format!(
"SELECT s.occurrences, {WORD_FREQ} \
FROM hebrewdb.surface s \
JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id \
LEFT JOIN hebrewdb.roots r ON r.root = sm.root \
WHERE s.text = ?1"
),
params![surface],
|r| Ok((r.get(0)?, r.get(1)?)),
)
};
let (surface_frequency, curriculum_frequency) = frequency("נְכֹאת")?;
assert_eq!(surface_frequency, 2);
assert_eq!(curriculum_frequency, surface_frequency);
let (surface, surface_frequency, root_frequency): (String, i64, i64) =
bible.conn().query_row(
"SELECT s.text, s.occurrences, r.n_occurrences \
FROM hebrewdb.surface s \
JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id \
JOIN hebrewdb.roots r ON r.root = sm.root \
WHERE sm.family_base = 1 AND sm.form_tier >= 5 \
AND r.n_occurrences > s.occurrences \
ORDER BY r.n_occurrences DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
let (_, curriculum_frequency) = frequency(&surface)?;
assert_eq!(curriculum_frequency, root_frequency);
assert!(curriculum_frequency > surface_frequency);
let (later_qal, later_frequency): (String, i64) = bible.conn().query_row(
"SELECT s.text, s.occurrences \
FROM hebrewdb.surface s \
JOIN progress.surface_meta sm ON sm.surface_id = s.surface_id \
JOIN hebrewdb.roots r ON r.root = sm.root \
WHERE sm.is_qal = 1 AND sm.family_base = 0 \
AND r.n_occurrences > s.occurrences \
ORDER BY r.n_occurrences DESC LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let (_, curriculum_frequency) = frequency(&later_qal)?;
assert_eq!(curriculum_frequency, later_frequency);
Ok(())
}
#[test]
fn lexicalized_liqrat_waits_for_qal_base() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.ensure_surface_meta()?;
let (liqrat_root, tier, is_qal, family_base): (String, i64, bool, bool) =
bible.conn().query_row(
"SELECT sm.root, sm.form_tier, sm.is_qal, sm.family_base \
FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
WHERE s.text = 'לִקְרַאת'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)?;
assert_eq!(liqrat_root, "קרא");
assert!(
tier >= 5,
"lexicalized verb must pass through the family gate"
);
assert!(is_qal);
assert!(!family_base, "the infinitive must not open the family");
let ready = || -> rusqlite::Result<bool> {
bible.conn().query_row(
&format!(
"SELECT {FAMILY_READY} FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
LEFT JOIN ({KNOWN_QAL_ROOTS}) kqr ON kqr.root = sm.root \
WHERE s.text = 'לִקְרַאת'"
),
[],
|r| r.get(0),
)
};
assert!(!ready()?, "לִקְרַאת must initially be held back");
let qara_id: i64 = bible.conn().query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = 'קָרָא'",
[],
|r| r.get(0),
)?;
bible.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES ('קָרָא', ?1, 2.5, 1, 0, 3, 0, 0, 2)",
params![qara_id],
)?;
assert!(ready()?, "learning קָרָא must unlock לִקְרַאת");
Ok(())
}
#[test]
fn name_cards_show_names_not_bdb_citations() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let card = bible.word_card("אֶזְבָּי")?.expect("Ezbai has a surface");
assert_eq!(card.gloss, NAME_GLOSS);
assert!(
card.note.contains("father of one of David"),
"citation kept as note, got {:?}",
card.note
);
assert!(
card.root.is_empty(),
"spurious root hidden: {:?}",
card.root
);
assert!(card.distractors.is_empty(), "name cards self-grade");
let card = bible.word_card("אֶלְיַחְבָּא")?.expect("Eliahba exists");
assert_eq!(card.gloss, NAME_GLOSS);
assert!(
card.root.is_empty(),
"spurious root hidden: {:?}",
card.root
);
let card = bible.word_card("הַשַּׁעַלְבֹנִי")?.expect("gentilic exists");
assert_eq!(card.gloss, NAME_GLOSS);
assert!(!card.note.contains("adj.gent"), "note: {:?}", card.note);
for (surface, gloss) in [
("מֹשֶׁה", "Moses"),
("אַבְרָהָם", "Abraham"),
("שְׁלֹמֹה", "Solomon"),
("נֹחַ", "Noah"),
] {
let card = bible.word_card(surface)?.expect("surface exists");
assert_eq!(card.gloss, gloss, "curated name gloss for {surface}");
}
let card = bible.word_card("אַבְרָהָם")?.unwrap();
assert!(!card.distractors.is_empty(), "curated names quiz normally");
assert!(card.distractors.iter().all(|d| !d.contains("n.pr")));
let card = bible.word_card("לְיַעֲקֹב")?.expect("surface exists");
assert_eq!(card.gloss, "to Jacob");
Ok(())
}
#[test]
fn surface_meta_flags_proper_names() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.ensure_surface_meta()?;
let flag = |s: &str| -> bool {
bible
.conn()
.query_row(
"SELECT sm.is_name FROM progress.surface_meta sm \
JOIN hebrewdb.surface su ON su.surface_id = sm.surface_id \
WHERE su.text = ?1",
params![s],
|r| r.get::<_, i64>(0),
)
.expect("surface in meta")
!= 0
};
for name in [
"אֶזְבָּי",
"חֶצְרוֹ",
"נַעֲרַי",
"מֹשֶׁה",
"לְיַעֲקֹב",
"אֶלְיַחְבָּא",
"הַשַּׁעַלְבֹנִי",
"אֲבִישׁוּעַ",
"אֶלְעָזָר",
"פִּינְחָס",
"אַהֲרֹן",
"עִדּוֹא",
"חִלְקִיָּה",
"אֱלִישָׁמָע",
] {
assert!(flag(name), "{name} should be flagged as a name");
}
for word in ["דָּבָר", "אָדָם", "הָאָרֶץ", "זָהָב", "אֶלֶף", "בְּנֵי", "הָרֹאשׁ"]
{
assert!(!flag(word), "{word} should not be flagged as a name");
}
let card = bible.word_card("אַהֲרֹן")?.expect("Aaron has a surface");
assert_eq!(card.gloss, "Aaron");
let (b, c, v): (u8, u8, u8) = bible.conn().query_row(
"SELECT vw.book, vw.chapter, vw.verse FROM hebrewdb.verse_word vw \
JOIN hebrewdb.surface s ON s.surface_id = vw.surface_id \
WHERE s.text = 'אֶזְבָּי' LIMIT 1",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)?;
let words = bible.verse_words_flagged(b, c, v)?;
assert!(
words.iter().any(|(w, is_name)| w == "אֶזְבָּי" && *is_name),
"verse words should flag the name: {words:?}"
);
let example = bible.grammar_example_surface("article", "הַכַּרְמְלִי")?;
assert_ne!(example, "הַכַּרְמְלִי");
let card = bible.word_card(&example)?.expect("example has a card");
eprintln!("article example: {example} -> {}", card.gloss);
assert!(!card.gloss.is_empty());
Ok(())
}
#[test]
fn prefixed_word_waits_for_bare_lexical_form() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.ensure_surface_meta()?;
let (bare_id, bare_key): (i64, String) = bible.conn().query_row(
"SELECT sm.surface_id, sm.vkey FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
WHERE s.text = 'עִם'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
let (prefixed_key, base_key): (String, String) = bible.conn().query_row(
"SELECT sm.vkey, sm.base_vkey FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
WHERE s.text = 'וְעִם'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
assert_ne!(prefixed_key, bare_key);
assert_eq!(base_key, bare_key);
let ready = || -> rusqlite::Result<bool> {
bible.conn().query_row(
&format!(
"SELECT {LEXICAL_BASE_READY} FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
LEFT JOIN progress.word_srs base_ws ON base_ws.surface_id = sm.base_surface_id \
WHERE s.text = 'וְעִם'"
),
[],
|r| r.get(0),
)
};
assert!(!ready()?, "prefixed form must initially be held back");
bible.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES ('עִם', ?1, 2.5, 1, 0, 1, 0, 0, 2)",
params![bare_id],
)?;
assert!(
ready()?,
"introducing the bare form unlocks its prefixed form"
);
Ok(())
}
#[test]
fn grammar_concepts_explained_once_before_words() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.seed_known_alphabet(1_700_000_000)?; bible.set_tutor_settings(&TutorSettings {
grammar_gating: false,
..Default::default()
})?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut concepts: Vec<String> = Vec::new();
let mut saw_read = false;
for _ in 0..6000 {
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainGrammar(card) => {
assert!(!card.title.is_empty() && !card.explanation.is_empty());
assert!(!card.example.surface.is_empty());
concepts.push(card.concept.clone());
bible.next_study_item(now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) => {
saw_read = true;
break;
}
StudyItem::Done => break,
};
}
assert!(saw_read, "grammar cards must not block reaching a read");
assert!(
!concepts.is_empty(),
"some grammar concept should be taught"
);
let mut seen = std::collections::HashSet::new();
for c in &concepts {
assert!(
seen.insert(c.clone()),
"concept {c:?} explained more than once"
);
}
let reference = bible.seen_concepts()?;
let grammar: Vec<&SeenConcept> = reference.iter().filter(|c| c.kind == "grammar").collect();
assert_eq!(grammar.len(), concepts.len());
for c in &grammar {
assert!(seen.contains(&c.key), "unshown concept {:?} listed", c.key);
assert!(!c.title.is_empty() && !c.explanation.is_empty());
}
let order: Vec<usize> = grammar
.iter()
.map(|c| {
crate::grammar::concepts()
.iter()
.position(|g| g.key == c.key)
.unwrap()
})
.collect();
assert!(order.is_sorted(), "reference not in teaching order");
Ok(())
}
#[test]
fn intro_deck_served_first_and_once() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
assert!(bible.seen_concepts()?.is_empty(), "nothing unlocked yet");
for key in INTRO_CONCEPTS {
match bible.next_study_item(now)? {
StudyItem::ExplainIntro(k) => assert_eq!(k, key, "deck order"),
other => panic!("expected intro card {key:?}, got {other:?}"),
}
}
assert!(
!matches!(bible.next_study_item(now)?, StudyItem::ExplainIntro(_)),
"intro deck must not repeat"
);
let reference = bible.seen_concepts()?;
let intro: Vec<&str> = reference
.iter()
.filter(|c| c.kind == "intro")
.map(|c| c.key.as_str())
.collect();
assert_eq!(intro, INTRO_CONCEPTS);
Ok(())
}
#[test]
fn word_card_never_shows_cross_reference_stub() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let card = bible
.word_card("אֵלָי")?
.expect("pausal אֵלָי is a corpus surface");
assert_eq!(card.gloss, "to me");
assert_ne!(card.root, "אלח");
for d in &card.distractors {
assert!(!d.starts_with("see "), "stub distractor {d:?}");
}
Ok(())
}
#[test]
fn word_card_headlines_the_surface_meaning() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let card = bible
.word_card("וְלַבָּיִת")?
.expect("וְלַבָּיִת is a corpus surface");
assert_eq!(card.gloss, "and to the house");
assert_eq!(card.root_gloss, "house");
let card = bible.word_card("בַּיִת")?.expect("בַּיִת is a surface");
assert_eq!(card.root_gloss, "");
let card = bible.word_card("וַאֲשֶׁר")?.expect("וַאֲשֶׁר is a surface");
assert_eq!(card.gloss, "and who");
assert_eq!(card.root_gloss, "who; which; that");
let card = bible.word_card("אֵין")?.expect("אֵין is a surface");
assert_eq!(card.gloss, "there is not, without");
let card = bible.word_card("כִּי")?.expect("כִּי is a surface");
assert_eq!(card.gloss, "for, because, that, when");
Ok(())
}
#[test]
fn conjunctive_imperfect_cards_and_teaches_its_form() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let surface = "וְיִבְחָר";
let info = bible
.hebrew_word_info(surface)
.expect("conjunctive imperfect has a verb analysis");
assert_eq!(info.tense.as_deref(), Some("Imperfect"));
assert_eq!(crate::bible::inflected_gloss(&info), "and he will choose");
assert!(crate::grammar::concepts_for(&info).contains(&"imperfect"));
let card = bible.word_card(surface)?.expect("surface has a word card");
assert_eq!(card.gloss, "and he will choose");
assert_eq!(card.root_gloss, "choose");
Ok(())
}
#[test]
fn word_card_segolate_noun_beats_verb_group_order() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let card = bible.word_card("מֶלֶךְ")?.expect("מֶלֶךְ is a surface");
assert_eq!(card.gloss, "king", "got {card:?}");
let card = bible.word_card("חֹדֶשׁ")?.expect("חֹדֶשׁ is a surface");
assert_eq!(card.gloss, "month", "got {card:?}");
Ok(())
}
#[test]
fn form_is_drilled_after_meaning() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.seed_known_alphabet(1_700_000_000)?;
bible.set_tutor_settings(&TutorSettings {
grammar_gating: false,
..Default::default()
})?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut drilled = 0;
for _ in 0..8000 {
if drilled >= 3 {
break;
}
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Easy, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Easy, now)?
}
StudyItem::NewFormDrill(w) => {
assert!(!w.gloss.is_empty(), "form drill has an inflected answer");
assert!(
bible.form_srs(&w.surface)?.is_none(),
"row created on grade"
);
drilled += 1;
let after = bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?;
assert!(bible.form_srs(&w.surface)?.is_some());
after
}
StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
}
assert!(
drilled >= 3,
"form drills should appear once meanings are known"
);
Ok(())
}
#[test]
fn form_tier_orders_forms_by_simplicity() {
let verb = |binyan: &str, tense: &str, pgn: (&str, &str, &str)| HebrewWord {
form: Some(binyan.to_string()),
tense: Some(tense.to_string()),
person: (!pgn.0.is_empty()).then(|| pgn.0.to_string()),
gender: (!pgn.1.is_empty()).then(|| pgn.1.to_string()),
number: (!pgn.2.is_empty()).then(|| pgn.2.to_string()),
..Default::default()
};
let noun = |number: Option<&str>, state: Option<&str>| HebrewWord {
number: number.map(str::to_string),
state: state.map(str::to_string),
..Default::default()
};
assert_eq!(form_tier(&HebrewWord::default()), 0);
let sg = form_tier(&noun(Some("Singular"), Some("Absolute")));
let pl = form_tier(&noun(Some("Plural"), Some("Absolute")));
let cs = form_tier(&noun(Some("Singular"), Some("Construct")));
let sfx = form_tier(&noun(None, Some("Sg + 3ms")));
assert!(sg < pl && pl < cs && cs < sfx, "{sg} {pl} {cs} {sfx}");
let qal_perf_3ms = form_tier(&verb("Qal", "Perfect", ("Third", "Masculine", "Singular")));
assert!(sfx < qal_perf_3ms, "nouns rank below verbs");
let qal_perf_2ms = form_tier(&verb("Qal", "Perfect", ("Second", "Masculine", "Singular")));
let qal_impf = form_tier(&verb(
"Qal",
"Imperfect",
("Third", "Masculine", "Singular"),
));
let qal_impv = form_tier(&verb(
"Qal",
"Imperative",
("Second", "Masculine", "Singular"),
));
let piel_perf = form_tier(&verb("Piel", "Perfect", ("Third", "Masculine", "Singular")));
assert!(qal_perf_3ms < qal_perf_2ms, "3ms is the base perfect");
assert!(qal_perf_2ms < qal_impf && qal_impf < qal_impv);
assert!(qal_perf_3ms < piel_perf, "derived stem is harder than Qal");
let mut with_suffix = verb("Qal", "Perfect", ("Third", "Masculine", "Singular"));
with_suffix.obj_suffix = Some("3ms".to_string());
assert!(
form_tier(&with_suffix) > qal_perf_3ms,
"object suffix adds a step"
);
}
#[test]
fn confidence_maps_to_grades_and_quiz_gates() {
use Grade::*;
assert_eq!(Grade::from_confidence(0, None), Again);
assert_eq!(Grade::from_confidence(24, None), Again);
assert_eq!(Grade::from_confidence(25, None), Hard);
assert_eq!(Grade::from_confidence(54, None), Hard);
assert_eq!(Grade::from_confidence(55, None), Good);
assert_eq!(Grade::from_confidence(84, None), Good);
assert_eq!(Grade::from_confidence(85, None), Easy);
assert_eq!(Grade::from_confidence(100, None), Easy);
assert_eq!(Grade::from_confidence(100, Some(false)), Again);
assert_eq!(Grade::from_confidence(90, Some(true)), Easy);
assert_eq!(Grade::from_confidence(10, Some(true)), Again);
}
#[test]
fn grammar_priority_paces_concept_unlock_spacing() {
let mut s = TutorSettings {
grammar_priority: 100,
..TutorSettings::default()
};
let fast = s.words_per_concept();
s.grammar_priority = 0;
let slow = s.words_per_concept();
assert!(fast >= 1 && fast < slow, "fast {fast} slow {slow}");
}
fn open_with_progress() -> Option<Bible> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return None;
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")
.unwrap();
init_progress_schema(bible.conn()).unwrap();
Some(bible)
}
#[test]
fn settings_default_and_round_trip() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
assert_eq!(bible.tutor_settings()?, TutorSettings::default());
bible.conn().execute(
"INSERT INTO progress.meta(key, value) VALUES ('setting.vocab_ratio', '80')",
[],
)?;
let migrated = bible.tutor_settings()?;
assert_eq!(migrated.vocab_priority, 80);
assert_eq!(migrated.grammar_priority, 20);
assert_eq!(
migrated.verse_priority,
TutorSettings::default().verse_priority
);
bible.conn().execute(
"DELETE FROM progress.meta WHERE key = 'setting.vocab_ratio'",
[],
)?;
let s = TutorSettings {
letters_per_batch: 1,
words_per_batch: 4,
grammar_gating: false,
vocab_priority: 10,
grammar_priority: 90,
verse_priority: 80,
letters_ratio: 55,
};
bible.set_tutor_settings(&s)?;
assert_eq!(bible.tutor_settings()?, s);
bible.reset_tutor()?;
assert_eq!(bible.tutor_settings()?, TutorSettings::default());
Ok(())
}
#[test]
fn progress_counts_verses_inside_grammar_frontier() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
for concept in crate::grammar::concepts()
.iter()
.filter(|concept| concept.key != "article")
{
bible.conn().execute(
"INSERT INTO progress.concepts_unlocked(concept, unlocked_epoch) VALUES (?1, 0)",
params![concept.key],
)?;
}
let without_article: i64 = bible.conn().query_row(
"SELECT COUNT(*) FROM hebrewdb.verse_stats WHERE article = 0",
[],
|r| r.get(0),
)?;
assert_eq!(
bible.tutor_progress()?.verses_grammar_unlocked,
without_article
);
bible.set_tutor_settings(&TutorSettings {
grammar_gating: false,
..TutorSettings::default()
})?;
let progress = bible.tutor_progress()?;
assert_eq!(progress.verses_grammar_unlocked, progress.total_verses);
Ok(())
}
#[test]
fn grammar_frontier_expands_with_vocab_when_gated() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let now = 1_700_000_000;
let total = crate::grammar::concept_count() as i64;
let off = TutorSettings {
grammar_gating: false,
..Default::default()
};
assert_eq!(
bible.unlocked_concepts(&off, now)?.count_ones() as i64,
total
);
let s = TutorSettings::default();
assert_eq!(bible.unlocked_concepts(&s, now)?, 0);
bible.seed_known_alphabet(now)?;
let first = bible.unlocked_concepts(&s, now)?;
assert_eq!(first.count_ones(), 1);
let per = s.words_per_concept();
for i in 0..per {
bible.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, 2.5, 1, 0, 3, 0, 0, 2)",
params![format!("seed{i}"), 1_000_000 + i],
)?;
}
let second = bible.unlocked_concepts(&s, now)?;
assert_eq!(second.count_ones(), 2);
assert_eq!(second & first, first, "unlocked set only grows");
Ok(())
}
#[test]
fn target_does_not_pin_a_locked_verse_for_its_name_alone() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let now = 1_700_000_000;
bible.seed_known_alphabet(now)?;
bible.ensure_surface_meta()?;
bible.conn().execute_batch(
"INSERT INTO progress.concepts_unlocked(concept, unlocked_epoch)
VALUES ('conj-ve', 0);
INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days,
due_epoch, reps, lapses, introduced_epoch, last_grade)
SELECT text, surface_id, 2.5, 1, 0, 3, 0, 0, 3
FROM hebrewdb.surface WHERE text IN ('כִּי', 'לֹא', 'וְלֹא');",
)?;
bible.ensure_readability_progress()?;
bible.set_tutor_settings(&TutorSettings {
vocab_priority: 25,
grammar_priority: 25,
verse_priority: 75,
..TutorSettings::default()
})?;
let settings = bible.tutor_settings()?;
let unlocked = bible.unlocked_concepts(&settings, now)?;
assert_ne!(
bible.next_target_verse(unlocked, 0, false)?,
Some((4, 21, 31))
);
Ok(())
}
#[test]
fn grammar_locked_until_letters_known() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let s = TutorSettings::default();
let now = 1_700_000_000;
assert_eq!(bible.unlocked_concepts(&s, now)?, 0);
for g in ["א", "ל", "מ", "ר", "ב", "ן"] {
bible.submit_review(Track::Glyph, g, Grade::Easy, now)?;
}
assert_eq!(
bible.unlocked_concepts(&s, now)?,
0,
"grammar stays locked mid-alphabet"
);
Ok(())
}
#[test]
fn letters_batch_throttles_new_glyphs() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let s = TutorSettings {
letters_per_batch: 2,
..Default::default()
};
bible.set_tutor_settings(&s)?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut max_in_learning = 0i64;
let mut taught_glyphs = 0;
for _ in 0..500 {
max_in_learning = max_in_learning.max(bible.glyphs_in_learning()?);
now += 5;
item = match item {
StudyItem::NewGlyph(g) => {
taught_glyphs += 1;
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_)
| StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
}
assert!(taught_glyphs >= 3, "should have taught several letters");
assert!(
max_in_learning <= 2,
"letters batch (2) exceeded: {max_in_learning} glyphs in learning at once"
);
Ok(())
}
#[test]
fn grammar_gate_only_introduces_unlocked_forms() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let now0 = 1_700_000_000;
bible.seed_known_alphabet(now0)?; let s = TutorSettings::default(); bible.set_tutor_settings(&s)?;
let mut now = now0;
let mut item = bible.next_study_item(now)?;
let mut checked = 0;
for _ in 0..800 {
if checked >= 15 {
break;
}
now += 5;
item = match item {
StudyItem::NewWord(w) => {
let unlocked = bible.unlocked_concepts(&s, now)?;
let mask: i64 = bible.conn().query_row(
"SELECT concept_mask FROM progress.surface_meta WHERE surface_id = ?1",
params![w.surface_id],
|r| r.get(0),
)?;
assert!(
mask & !unlocked == 0,
"introduced a locked-rule word (mask {mask:#x} outside frontier {unlocked:#x})"
);
checked += 1;
bible.submit_review(Track::Word, &w.surface, Grade::Easy, now)?
}
StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Easy, now)?
}
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Easy, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Easy, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Easy, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_)
| StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
}
assert!(checked >= 5, "should introduce several words to check");
Ok(())
}
#[test]
fn letters_ratio_bounds_are_word_or_letter_forward() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
assert!(bible.prefer_new_letter(&TutorSettings {
letters_ratio: 100,
..Default::default()
})?);
assert!(!bible.prefer_new_letter(&TutorSettings {
letters_ratio: 0,
..Default::default()
})?);
Ok(())
}
#[test]
fn letters_ratio_prefers_words_over_racing_through_letters() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let glyphs_before_first_word = |ratio: u8| -> rusqlite::Result<i64> {
bible.reset_tutor()?;
bible.set_tutor_settings(&TutorSettings {
letters_ratio: ratio,
..Default::default()
})?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut new_glyphs = 0;
for _ in 0..3000 {
now += 5;
item = match item {
StudyItem::NewGlyph(g) => {
new_glyphs += 1;
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(_) => break, StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
_ => bible.next_study_item(now)?,
};
}
Ok(new_glyphs)
};
let word_forward = glyphs_before_first_word(0)?;
let letter_forward = glyphs_before_first_word(100)?;
assert!(
word_forward >= 1,
"some letters must precede the very first word"
);
assert!(
word_forward < letter_forward,
"word-forward should reach a word with fewer new letters \
({word_forward}) than letter-forward ({letter_forward})"
);
Ok(())
}
#[test]
fn letters_ratio_shifts_the_introduction_mix() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
let letters_at_five_words = |ratio: u8| -> rusqlite::Result<i64> {
bible.reset_tutor()?;
bible.set_tutor_settings(&TutorSettings {
letters_ratio: ratio,
..Default::default()
})?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
for _ in 0..2000 {
if bible.tutor_progress()?.words_known >= 5 {
return bible.conn().query_row(
"SELECT COUNT(*) FROM progress.glyph_srs",
[],
|r| r.get(0),
);
}
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_)
| StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
}
panic!("ratio {ratio}: never reached 5 known words (flow stalled?)");
};
let word_forward = letters_at_five_words(0)?;
let letter_forward = letters_at_five_words(100)?;
eprintln!(
"glyphs seen at 5 words: words-forward={word_forward} letters-forward={letter_forward}"
);
assert!(
word_forward < letter_forward,
"words-forward should know 5 words with fewer glyphs seen \
({word_forward}) than letters-forward ({letter_forward})"
);
Ok(())
}
#[test]
fn early_reviews_fall_back_on_upcoming_glyph_distractors() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
bible.submit_review(Track::Glyph, "ס", Grade::Good, now)?;
let card = bible.review_glyph_card("ס".to_string())?;
assert_eq!(card.distractors.len(), 3, "topped up from upcoming glyphs");
for d in &card.distractors {
assert_ne!(d, "ס");
assert!(
d.chars().next().is_some_and(is_consonant),
"same kind: {d:?}"
);
assert!(
!bible.glyph_known(d)?,
"upcoming = not yet introduced: {d:?}"
);
}
bible.submit_review(Track::Glyph, "ֶ", Grade::Good, now)?;
let card = bible.review_glyph_card("ֶ".to_string())?;
let host = card.host.clone().expect("vowel gets a host");
assert!(card.distractors.len() >= 3, "enough syllables for a quiz");
for d in &card.distractors {
let cps: Vec<char> = d.chars().collect();
assert!(is_consonant(cps[0]) && !is_silent_host(&cps[0].to_string()));
assert!(is_vowel_point(*cps.last().unwrap()));
assert_ne!(*d, format!("{host}ֶ"), "excludes the correct syllable");
}
Ok(())
}
#[test]
fn vowel_review_builds_random_syllable_distractors() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
for g in ["מ", "ל", "ר", "ה", "ח", "ע", "ַ", "ֶ", "ֲ"] {
bible.submit_review(Track::Glyph, g, Grade::Easy, now)?;
}
let card = bible.review_glyph_card("ַ".to_string())?;
let host = card.host.clone().expect("vowel gets a host");
assert!(!is_silent_host(&host), "host voices a syllable: {host}");
assert!(!card.distractors.is_empty(), "should offer syllables");
for d in &card.distractors {
let cps: Vec<char> = d.chars().collect();
assert_eq!(cps.len(), 2, "syllable is consonant+vowel: {d:?}");
assert!(is_consonant(cps[0]) && !is_silent_host(&cps[0].to_string()));
assert!(is_vowel_point(cps[1]));
assert_ne!(*d, format!("{host}ַ"), "excludes the correct syllable");
}
let hataf = bible.syllable_distractors("ה", 'ֲ')?;
assert!(!hataf.is_empty(), "hataf card should still offer syllables");
for d in &hataf {
let cps: Vec<char> = d.chars().collect();
assert!(!is_silent_host(&cps[0].to_string()));
if is_hataf(cps[1]) {
assert!(AUDIBLE_GUTTURALS.contains(&cps[0].to_string().as_str()));
}
}
Ok(())
}
#[test]
fn tutor_stats_track_activity_streak_and_accuracy() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let day = SECONDS_PER_DAY;
bible.submit_review(Track::Glyph, "מ", Grade::Good, 0)?;
bible.submit_review(Track::Glyph, "ל", Grade::Again, 100)?;
bible.submit_review(Track::Glyph, "מ", Grade::Good, day)?;
bible.submit_review(Track::Glyph, "מ", Grade::Good, 2 * day)?;
let s = bible.tutor_stats(2 * day)?;
assert_eq!(s.reviews_total, 4);
assert_eq!(s.reviews_today, 1, "only the day-2 answer counts as today");
assert_eq!(s.streak_days, 3, "days 0, 1 and 2 are consecutive");
assert_eq!(s.accuracy_pct, 75, "3 of 4 answers recalled");
assert_eq!(s.letters_seen, 2, "two distinct consonants introduced");
assert_eq!(bible.tutor_stats(4 * day)?.streak_days, 0);
bible.submit_review(Track::Glyph, "ל", Grade::Good, 3 * day)?;
assert_eq!(bible.tutor_stats(4 * day)?.streak_days, 4);
Ok(())
}
#[test]
fn grading_a_syllable_credits_every_glyph() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
bible.submit_review(Track::Glyph, "מַ", Grade::Good, now)?;
let m = bible.glyph_srs("מ")?.expect("consonant credited");
let a = bible.glyph_srs("ַ")?.expect("vowel credited");
assert_eq!(m.reps, 1);
assert_eq!(a.reps, 1);
assert_eq!(split_glyph_key("ל"), vec!["ל".to_string()]);
assert_eq!(split_glyph_key("ךַ"), vec!["ך".to_string(), "ַ".to_string()]);
Ok(())
}
#[test]
fn glyph_decomposition_keeps_finals_distinct_and_dedups() {
let g = decompose_glyphs("מֶלֶךְ");
let cons: Vec<&str> = g
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(cons, vec!["מ", "ל", "ך"]);
assert!(g.iter().any(|c| !c.is_consonant));
}
#[test]
fn dagesh_and_shin_sin_dot_fold_into_letter_identity() {
const BET: char = '\u{05D1}';
const GIMEL: char = '\u{05D2}';
const NUN: char = '\u{05E0}';
const RESH: char = '\u{05E8}';
const ALEF: char = '\u{05D0}';
const SHIN: char = '\u{05E9}';
const MEM: char = '\u{05DE}';
const DAGESH: char = '\u{05BC}';
const SHIN_DOT: char = '\u{05C1}';
const SIN_DOT: char = '\u{05C2}';
const QAMATS: char = '\u{05B8}';
const PATAH: char = '\u{05B7}';
let bet = decompose_glyphs(&format!("{BET}{DAGESH}{QAMATS}{RESH}{QAMATS}{ALEF}"));
let cons: Vec<&str> = bet
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(
cons,
vec![format!("{BET}{DAGESH}"), RESH.to_string(), ALEF.to_string()]
);
let sin = decompose_glyphs(&format!("{SHIN}{SIN_DOT}{QAMATS}{MEM}"));
assert!(
sin.iter()
.any(|c| c.is_consonant && c.glyph == format!("{SHIN}{SIN_DOT}"))
);
let shin = decompose_glyphs(&format!("{SHIN}{SHIN_DOT}{QAMATS}{MEM}"));
assert!(
shin.iter()
.any(|c| c.is_consonant && c.glyph == format!("{SHIN}{SHIN_DOT}"))
);
const YOD: char = '\u{05D9}';
const KAF: char = '\u{05DB}';
const HIRIQ: char = '\u{05B4}';
let issachar = decompose_glyphs(&format!(
"{YOD}{HIRIQ}{SHIN}{QAMATS}{DAGESH}{SIN_DOT}{SHIN}{KAF}{QAMATS}{RESH}"
));
let cons: Vec<&str> = issachar
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(
cons,
vec![
YOD.to_string(),
format!("{SHIN}{SIN_DOT}"),
format!("{SHIN}{SHIN_DOT}"),
KAF.to_string(),
RESH.to_string()
],
"dotless shin folds to שׁ, never a bare ש glyph"
);
let geminated = decompose_glyphs(&format!(
"{ALEF}{PATAH}{SHIN}{DAGESH}{SHIN_DOT}{QAMATS}{RESH}"
));
let cons: Vec<&str> = geminated
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(
cons,
vec![
ALEF.to_string(),
format!("{SHIN}{SHIN_DOT}"),
RESH.to_string()
]
);
let real_order = decompose_glyphs(&format!(
"{}{PATAH}{SHIN}{QAMATS}{DAGESH}{SHIN_DOT}{MEM}{PATAH}{RESH}",
ALEF
));
let cons: Vec<&str> = real_order
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(
cons,
vec![
ALEF.to_string(),
format!("{SHIN}{SHIN_DOT}"),
MEM.to_string(),
RESH.to_string()
],
"vowel-before-dagesh/dot ordering must still fold the shin/sin dot in"
);
assert!(
real_order
.iter()
.any(|c| !c.is_consonant && c.glyph == QAMATS.to_string()),
"the vowel sitting between the letter and its dot is still taught"
);
const VAV: char = '\u{05D5}';
const SAMEKH: char = '\u{05E1}';
let sus = decompose_glyphs(&format!("{SAMEKH}{VAV}{DAGESH}{SAMEKH}")); let glyphs: Vec<&str> = sus.iter().map(|c| c.glyph.as_str()).collect();
assert_eq!(glyphs, vec![SAMEKH.to_string(), format!("{VAV}{DAGESH}")]);
const HET: char = '\u{05D7}';
const HE: char = '\u{05D4}';
let gem_vav = decompose_glyphs(&format!("{HET}{PATAH}{VAV}{QAMATS}{DAGESH}{HE}{QAMATS}"));
assert!(gem_vav.iter().any(|c| c.glyph == VAV.to_string()));
assert!(!gem_vav.iter().any(|c| c.glyph == format!("{VAV}{DAGESH}")));
assert_eq!(
split_glyph_key(&format!("{VAV}{DAGESH}")),
vec![format!("{VAV}{DAGESH}")]
);
let gem = decompose_glyphs(&format!("{GIMEL}{DAGESH}{PATAH}{NUN}")); let cons: Vec<&str> = gem
.iter()
.filter(|c| c.is_consonant)
.map(|c| c.glyph.as_str())
.collect();
assert_eq!(cons, vec![GIMEL.to_string(), NUN.to_string()]);
assert!(
!gem.iter().any(|c| c.glyph == DAGESH.to_string()),
"dagesh is never taught as a standalone glyph"
);
let bet_dagesh = format!("{BET}{DAGESH}");
assert_eq!(split_glyph_key(&bet_dagesh), vec![bet_dagesh.clone()]);
let sin_letter = format!("{SHIN}{SIN_DOT}");
assert_eq!(split_glyph_key(&sin_letter), vec![sin_letter.clone()]);
assert_eq!(
split_glyph_key(&format!("{bet_dagesh}{PATAH}")),
vec![bet_dagesh, PATAH.to_string()]
);
}
#[test]
fn cold_start_reaches_a_read() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
for key in INTRO_CONCEPTS {
match bible.next_study_item(now)? {
StudyItem::ExplainIntro(k) => assert_eq!(k, key),
other => panic!("expected intro card {key:?}, got {other:?}"),
}
}
let mut item = bible.next_study_item(now)?;
assert!(matches!(
item,
StudyItem::NewGlyph(_) | StudyItem::NewWord(_)
));
assert!(bible.meta_target()?.is_some());
let mut saw_read = false;
let mut saw_word = false;
let mut saw_mark = false;
for _ in 0..4000 {
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
saw_word = true;
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_) => {
saw_mark = true;
bible.next_study_item(now)?
}
StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) => {
saw_read = true;
break;
}
StudyItem::Done => break,
};
}
assert!(saw_word, "should drill word meaning via SRS");
assert!(saw_mark, "should explain the sof pasuq before reading");
assert!(saw_read, "should finish and read the first verse");
Ok(())
}
#[test]
fn misread_word_does_not_re_serve_the_same_verse_forever() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let verse = loop {
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(v) => break v,
StudyItem::Done => panic!("ran out of curriculum before a read"),
};
};
let misread = verse.words.first().cloned().expect("verse has words");
now += 5;
let after = bible.submit_review(Track::Word, &misread, Grade::Again, now)?;
let same_verse = |item: &StudyItem| match item {
StudyItem::ReadVerse(v) => {
(v.book, v.chapter, v.verse) == (verse.book, verse.chapter, verse.verse)
}
_ => false,
};
assert!(
!same_verse(&after),
"flagging a word should not immediately re-serve the same verse"
);
let mut saw_misread_review =
matches!(&after, StudyItem::ReviewWord(w) if w.surface == misread);
let mut item = after;
for _ in 0..500 {
if saw_misread_review {
break;
}
assert!(
!same_verse(&item),
"verse re-appeared before the misread word was ever reviewed"
);
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
saw_misread_review |= w.surface == misread;
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) | StudyItem::Done => break,
};
}
assert!(
saw_misread_review,
"the misread word should be pulled forward for review, not stranded"
);
Ok(())
}
#[test]
fn stuck_word_does_not_block_introducing_other_words() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let mut now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut new_words = std::collections::HashSet::new();
let mut stuck: Option<String> = None;
for _ in 0..2000 {
if new_words.len() >= 2 {
break;
}
now += 5;
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) => {
new_words.insert(w.surface.clone());
if stuck.is_none() {
stuck = Some(w.surface.clone());
}
bible.submit_review(Track::Word, &w.surface, Grade::Hard, now)?
}
StudyItem::ReviewWord(w) => {
let grade = if stuck.as_deref() == Some(w.surface.as_str()) {
Grade::Hard
} else {
Grade::Good
};
bible.submit_review(Track::Word, &w.surface, grade, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) | StudyItem::Done => break,
};
}
assert!(
new_words.len() >= 2,
"a second word should be introduced while the first is stuck mid-learning"
);
Ok(())
}
#[test]
fn lapsed_word_is_not_immediately_re_served() -> rusqlite::Result<()> {
let Some(bible) = open_with_progress() else {
return Ok(());
};
bible.seed_known_alphabet(1_700_000_000)?; bible.set_tutor_settings(&TutorSettings {
grammar_gating: false, ..Default::default()
})?;
let now0 = 1_700_000_000;
bible.ensure_surface_meta()?;
let all_grammar = crate::grammar::all_concepts_mask();
let (b, c, v) = bible
.next_target_verse(all_grammar, 0, false)?
.expect("a target verse exists");
let words = bible.unfinished_words((b, c, v), all_grammar, 0, false, true)?;
assert!(
!words.is_empty(),
"target verse should have unfinished words"
);
let (last, rest) = words.split_last().expect("at least one word");
for surface in rest {
let surface_id: i64 = bible.conn().query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = ?1",
params![surface],
|r| r.get(0),
)?;
bible.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, 2.5, 10, ?3, 3, 0, ?4, 3)",
params![surface, surface_id, now0 + 10 * SECONDS_PER_DAY, now0],
)?;
}
bible.set_meta_target(Some((b, c, v)))?;
let mut now = now0;
let mut item = bible.next_study_item(now)?;
let mut hops = 0;
let failed_surface = loop {
hops += 1;
assert!(hops < 20, "should reach the remaining word quickly");
match item {
StudyItem::NewWord(w) if w.surface == *last => break w.surface,
StudyItem::ExplainGrammar(_)
| StudyItem::ExplainMark(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => {
item = bible.next_study_item(now)?;
}
other => panic!("unexpected item before the target word: {other:?}"),
}
};
now += 5;
let next = bible.submit_review(Track::Word, &failed_surface, Grade::Again, now)?;
match next {
StudyItem::ReviewWord(w) | StudyItem::NewWord(w) => assert_ne!(
w.surface, failed_surface,
"a just-lapsed word shouldn't be re-served before its due time"
),
_ => {}
}
Ok(())
}
#[test]
fn reading_mark_is_explained_once_and_never_drilled() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut explained: Vec<String> = Vec::new();
let mut reads = 0;
for _ in 0..8000 {
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(g) => {
explained.push(g.glyph.clone());
bible.next_study_item(now)?
}
StudyItem::ExplainGrammar(_)
| StudyItem::ExplainFinalForms(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) => {
reads += 1;
if reads >= 3 {
break;
}
bible.next_study_item(now)?
}
StudyItem::Done => break,
};
}
assert!(
!explained.is_empty(),
"some reading mark should be explained"
);
let mut seen = std::collections::HashSet::new();
for mark in &explained {
assert!(
seen.insert(mark.clone()),
"reading mark {mark:?} was explained more than once"
);
}
for mark in READING_MARKS {
assert!(!bible.glyph_known(&mark.to_string())?);
}
Ok(())
}
#[test]
fn function_word_prepositions_issue_grammar_cards() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
let w = bible.hebrew_word_info("עַל").expect("bridge gloss");
assert!(w.form.is_none() && w.tense.is_none() && w.state.is_none());
match bible.next_grammar_card("עַל", now)? {
Some(StudyItem::ExplainGrammar(card)) => {
assert_eq!(card.concept, "preposition");
assert_eq!(card.example.surface, "עַל");
}
other => panic!("expected the preposition card for עַל, got {other:?}"),
}
assert!(bible.next_grammar_card("אֶל", now)?.is_none());
match bible.next_grammar_card("לוֹ", now)? {
Some(StudyItem::ExplainGrammar(card)) => assert_eq!(card.concept, "prep-le"),
other => panic!("expected the prep-le card for לוֹ, got {other:?}"),
}
match bible.next_grammar_card("לוֹ", now)? {
Some(StudyItem::ExplainGrammar(card)) => assert_eq!(card.concept, "prep-suffix"),
other => panic!("expected the prep-suffix card for לוֹ, got {other:?}"),
}
assert!(bible.next_grammar_card("אֵלָי", now)?.is_none());
Ok(())
}
#[test]
fn final_forms_explained_once_before_first_final_glyph() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
let mut item = bible.next_study_item(now)?;
let mut explains = 0;
let mut finals_introduced = 0;
for _ in 0..4000 {
item = match item {
StudyItem::NewGlyph(g) | StudyItem::ReviewGlyph(g) => {
if g.glyph.chars().next().is_some_and(is_final_form) {
assert_eq!(
explains, 1,
"final form {:?} introduced without the concept card",
g.glyph
);
finals_introduced += 1;
}
bible.submit_review(Track::Glyph, &g.glyph, Grade::Good, now)?
}
StudyItem::ExplainFinalForms(g) => {
explains += 1;
assert!(
g.glyph.chars().next().is_some_and(is_final_form),
"concept card should carry the final form about to be taught, got {:?}",
g.glyph
);
assert_eq!(finals_introduced, 0, "explanation must come first");
bible.next_study_item(now)?
}
StudyItem::NewWord(w) | StudyItem::ReviewWord(w) => {
bible.submit_review(Track::Word, &w.surface, Grade::Good, now)?
}
StudyItem::NewFormDrill(w) | StudyItem::ReviewFormDrill(w) => {
bible.submit_review(Track::Form, &w.surface, Grade::Good, now)?
}
StudyItem::NewSuffixDrill(s) | StudyItem::ReviewSuffixDrill(s) => {
bible.submit_review(Track::Suffix, &s.key, Grade::Good, now)?
}
StudyItem::ExplainMark(_)
| StudyItem::ExplainGrammar(_)
| StudyItem::ExplainIntro(_) => bible.next_study_item(now)?,
StudyItem::ReadVerse(_) => bible.next_study_item(now)?,
StudyItem::Done => break,
};
if finals_introduced >= 2 {
break;
}
}
assert_eq!(explains, 1, "final-forms card should be shown exactly once");
assert!(
finals_introduced >= 1,
"a final-form glyph should still be introduced after the card"
);
Ok(())
}
#[test]
fn stale_reading_mark_glyph_row_is_purged_on_init() -> rusqlite::Result<()> {
let db = Connection::open_in_memory()?;
db.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(&db)?;
db.execute(
"INSERT INTO progress.glyph_srs(glyph, ease, interval_days, due_epoch, \
reps, lapses, introduced_epoch, last_grade) \
VALUES ('\u{05C3}', 2.5, 0, 0, 1, 0, 0, 2)",
[],
)?;
init_progress_schema(&db)?;
let count: i64 = db.query_row(
"SELECT COUNT(*) FROM progress.glyph_srs WHERE glyph = '\u{05C3}'",
[],
|r| r.get(0),
)?;
assert_eq!(count, 0, "stale sof-pasuq glyph_srs row must be purged");
Ok(())
}
#[test]
fn submit_review_ignores_reading_mark_glyph_keys() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.submit_review(Track::Glyph, "\u{05C3}", Grade::Good, 1_700_000_000)?;
assert!(!bible.glyph_known("\u{05C3}")?);
Ok(())
}
#[test]
fn needs_onboarding_only_before_any_progress() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
assert!(bible.needs_onboarding()?);
bible.submit_review(Track::Glyph, "מ", Grade::Good, 1_700_000_000)?;
assert!(!bible.needs_onboarding()?);
Ok(())
}
#[test]
fn seed_known_alphabet_skips_glyph_teaching() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let mut now = 1_700_000_000;
bible.seed_known_alphabet(now)?;
assert!(bible.glyph_known("א")?, "aleph should be seeded");
assert!(bible.all_glyphs_graduated("בְּרֵאשִׁית")?);
for key in INTRO_CONCEPTS.iter().copied().chain([FINAL_FORMS_CONCEPT]) {
assert!(bible.concept_seen(key)?, "{key} should be seeded as seen");
}
let mut item = bible.next_study_item(now)?;
for _ in 0..30 {
match item {
StudyItem::NewWord(_) => return Ok(()),
StudyItem::ExplainGrammar(_) => {
now += 5;
item = bible.next_study_item(now)?;
}
StudyItem::ExplainIntro(_) => {
panic!("a seeded alphabet must never show an intro card, got {item:?}")
}
StudyItem::NewGlyph(_) | StudyItem::ReviewGlyph(_) => {
panic!("a seeded alphabet must never teach a glyph, got {item:?}")
}
other => panic!("expected a word or grammar card, got {other:?}"),
}
}
panic!("did not reach a word after seeding the alphabet");
}
#[test]
fn calibration_probe_difficulty_tracks_tier_with_no_plateau() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let count = bible.calibration_tier_count()?;
assert!(count > 1, "corpus should have multiple difficulty tiers");
let mut prev = bible.calibration_probe(0)?.expect("tier 0 should resolve");
for tier in 1..count {
let probe = bible
.calibration_probe(tier)?
.unwrap_or_else(|| panic!("tier {tier} should resolve"));
assert!(
probe.min_occurrences < prev.min_occurrences,
"tier {tier} must be strictly harder than the previous tier"
);
prev = probe;
}
assert!(bible.calibration_probe(count)?.is_none());
Ok(())
}
#[test]
fn seed_known_vocab_marks_words_at_or_above_the_threshold_known() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
let probe = bible
.calibration_probe(5)?
.expect("tier 5 should resolve on real data");
let threshold = probe.min_occurrences;
bible.seed_known_vocab(threshold, now)?;
bible.ensure_surface_meta()?;
let known: i64 = bible.conn().query_row(
&format!("SELECT COUNT(*) FROM ({DONE_SURFACES})"),
[],
|r| r.get(0),
)?;
let expected: i64 = bible.conn().query_row(
"SELECT COUNT(DISTINCT sm.vkey) FROM progress.surface_meta sm \
JOIN hebrewdb.surface s ON s.surface_id = sm.surface_id \
WHERE s.occurrences >= ?1",
params![threshold],
|r| r.get(0),
)?;
assert_eq!(known, expected);
assert!(known > 0);
let rarest = bible
.conn()
.query_row(
"SELECT text FROM hebrewdb.surface WHERE language IS NULL \
ORDER BY occurrences ASC LIMIT 1",
[],
|r| r.get::<_, String>(0),
)
.optional()?;
if let Some(rarest) = rarest {
assert!(bible.word_srs(&rarest)?.is_none());
}
Ok(())
}
#[test]
fn seed_known_vocab_with_zero_threshold_seeds_nothing() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
bible.seed_known_vocab(0, 1_700_000_000)?;
let known: i64 = bible.conn().query_row(
&format!("SELECT COUNT(*) FROM ({DONE_SURFACES})"),
[],
|r| r.get(0),
)?;
assert_eq!(known, 0);
Ok(())
}
#[test]
fn article_words_gate_behind_the_article_card() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
for s in ["הַמֶּלֶךְ", "הָאָרֶץ", "הַשָּׁמַיִם", "הָעָם", "הַיּוֹם", "הַדָּבָר"]
{
let w = bible.hebrew_word_info(s);
let parsed = w.as_ref().unwrap_or_else(|| panic!("{s} should parse"));
assert!(
parsed.form.is_none() && parsed.tense.is_none(),
"{s} should read as article + noun, got verb {:?} {:?}",
parsed.form,
parsed.tense
);
let concepts = crate::grammar::concepts_for_surface(s, w.as_ref());
assert_eq!(concepts, vec!["article"], "concepts for {s}");
let article_rank = crate::grammar::concepts()
.iter()
.position(|c| c.key == "article")
.expect("article concept exists") as i64;
assert_eq!(
crate::grammar::concept_rank_for_surface(s, w.as_ref()),
article_rank,
"rank for {s}"
);
match bible.next_grammar_card(s, now)? {
Some(StudyItem::ExplainGrammar(card)) => {
assert_eq!(card.concept, "article", "card for {s}");
assert_eq!(card.example.surface, s);
}
other => panic!("expected the article card for {s}, got {other:?}"),
}
assert!(bible.next_grammar_card(s, now)?.is_none());
bible
.conn()
.execute("DELETE FROM progress.concepts_seen", [])?;
}
let w = bible.hebrew_word_info("הַיֹּשֵׁב").expect("participle parses");
assert!(
w.tense.as_deref().is_some_and(|t| t.contains("Participle")),
"הַיֹּשֵׁב should keep its participle reading, got {w:?}"
);
let w = bible
.hebrew_word_info("הֲתֵלֵךְ")
.expect("interrogative parses");
assert_eq!(
(w.root.as_str(), w.tense.as_deref()),
("הלכ", Some("Imperfect")),
"הֲתֵלֵךְ should keep its interrogative verb reading, got {w:?}"
);
Ok(())
}
#[test]
fn object_marker_gates_behind_its_grammar_card() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
for s in ["אֶת", "אֵת", "אֹתוֹ", "אוֹתָם", "אֶתְכֶם", "אֹתָהּ"]
{
assert_eq!(
crate::grammar::concepts_for_surface(s, None),
vec!["object-marker"],
"concepts for {s}"
);
}
assert_eq!(
crate::grammar::concepts_for_surface("וְאֶת", None),
vec!["conj-ve", "object-marker"]
);
let w = bible.hebrew_word_info("אֶת");
assert_eq!(
crate::grammar::concept_rank_for_surface("אֶת", w.as_ref()),
0,
"the object marker should be the first-ranked concept"
);
let now = 1_700_000_000;
match bible.next_grammar_card("אֶת", now)? {
Some(StudyItem::ExplainGrammar(card)) => {
assert_eq!(card.concept, "object-marker");
assert_eq!(card.example.surface, "אֶת");
}
other => panic!("expected the object-marker card for אֶת, got {other:?}"),
}
assert!(bible.next_grammar_card("אֹתוֹ", now)?.is_none());
assert!(bible.next_grammar_card("אֵת", now)?.is_none());
assert_eq!(
crate::grammar::concepts_for_surface("אִתְּכֶם", None),
vec!["preposition", "prep-suffix"]
);
Ok(())
}
#[test]
fn pronoun_endings_drill_on_known_hosts() -> rusqlite::Result<()> {
let data = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data");
if !data.join("hebrew.db").exists() {
return Ok(());
}
let bible = Bible::open(&data).expect("open data dbs");
bible
.conn()
.execute_batch("ATTACH DATABASE ':memory:' AS progress")?;
init_progress_schema(bible.conn())?;
let now = 1_700_000_000;
for surface in ["לוֹ", "אֵלַי"] {
let id: i64 = bible.conn().query_row(
"SELECT surface_id FROM hebrewdb.surface WHERE text = ?1",
params![surface],
|r| r.get(0),
)?;
bible.conn().execute(
"INSERT INTO progress.word_srs(surface, surface_id, ease, interval_days, \
due_epoch, reps, lapses, introduced_epoch, last_grade) \
VALUES (?1, ?2, 2.5, 6, ?3, 3, 0, ?4, 2)",
params![surface, id, now + 999_999, now],
)?;
}
assert!(bible.next_suffix_introduction(now)?.is_none());
bible.conn().execute(
"INSERT INTO progress.concepts_seen(concept, introduced_epoch) VALUES ('prep-suffix', ?1)",
params![now],
)?;
let card = match bible.next_suffix_introduction(now)? {
Some(StudyItem::NewSuffixDrill(c)) => c,
other => panic!("expected a new suffix drill, got {other:?}"),
};
assert_eq!(card.key, "3ms");
assert_eq!(card.surface, "לוֹ");
assert_eq!(format!("{}{}", card.stem, card.suffix), card.surface);
assert_eq!(card.meaning, "him");
assert_eq!(card.distractors.len(), 3);
assert!(!card.distractors.contains(&"him".to_string()));
bible.submit_review(Track::Suffix, "3ms", Grade::Good, now)?;
assert!(bible.suffix_srs("3ms")?.is_some());
let card = match bible.next_suffix_introduction(now)? {
Some(StudyItem::NewSuffixDrill(c)) => c,
other => panic!("expected the 1cs drill next, got {other:?}"),
};
assert_eq!((card.key.as_str(), card.surface.as_str()), ("1cs", "אֵלַי"));
assert_eq!(card.stem, "אֵל");
bible
.conn()
.execute("UPDATE progress.suffix_srs SET due_epoch = 0", [])?;
match bible.next_review(now, false)? {
Some(StudyItem::ReviewSuffixDrill(c)) => {
assert_eq!(c.key, "3ms");
assert_eq!(c.surface, "לוֹ");
}
other => panic!("expected a suffix review, got {other:?}"),
}
Ok(())
}
}