#![deny(missing_docs)] //! DADOES: Do Androids Dream of Electric Sheep. //! //! The crate provides microsecond-level Rust-native text-to-mood inference, //! using hashed lexical features and a multi-label linear model. use std::collections::BTreeMap; use std::collections::hash_map::DefaultHasher; use std::error::Error; use std::fmt::{Display, Formatter}; use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; use std::num::NonZeroUsize; /// Public library API for downstream Rust projects. pub mod api; /// External dataset parsers and source-specific DADOES label mappings. pub mod datasets; /// GoEmotions dataset parsing and DADOES mood mapping. pub mod goemotions; /// Built-in seed examples for smoke tests and domain augmentation. pub mod seed; pub use api::{DEFAULT_ACTIVE_MOOD_THRESHOLD, DadoesClassifier, default_checkpoint_bytes}; pub use seed::{owned_seed_training_examples, seed_training_examples, train_seed_model}; /// A finite mood label used by the DADOES classifier. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub enum Mood { /// Cheerful or pleased affect. Happy, /// Calm positive closure after useful progress. Satisfied, /// High-energy positive anticipation. Excited, /// Interest in discovery or unanswered questions. Curious, /// Fearful, worried, or tense affect. Anxious, /// Blocked progress, irritation, or discouragement. Frustrated, /// Loss, low mood, or disappointment. Sad, /// Hostile or indignant affect. Angry, /// Isolation or lack of social contact. Lonely, /// Low stimulation or repetitive monotony. Bored, /// Fatigue, depletion, or need for rest. Tired, /// Positive expectation despite uncertainty. Hopeful, /// No strong emotional signal. Neutral, } impl Mood { /// All supported mood labels in stable output order. pub const ALL: [Mood; 13] = [ Mood::Happy, Mood::Satisfied, Mood::Excited, Mood::Curious, Mood::Anxious, Mood::Frustrated, Mood::Sad, Mood::Angry, Mood::Lonely, Mood::Bored, Mood::Tired, Mood::Hopeful, Mood::Neutral, ]; /// Returns the stable lowercase label used in JSON and datasets. #[must_use] pub fn as_str(self) -> &'static str { match self { Mood::Happy => "happy", Mood::Satisfied => "satisfied", Mood::Excited => "excited", Mood::Curious => "curious", Mood::Anxious => "anxious", Mood::Frustrated => "frustrated", Mood::Sad => "sad", Mood::Angry => "angry", Mood::Lonely => "lonely", Mood::Bored => "bored", Mood::Tired => "tired", Mood::Hopeful => "hopeful", Mood::Neutral => "neutral", } } /// Parses a stable lowercase DADOES mood label. #[must_use] pub fn from_label(label: &str) -> Option { match label { "happy" => Some(Mood::Happy), "satisfied" => Some(Mood::Satisfied), "excited" => Some(Mood::Excited), "curious" => Some(Mood::Curious), "anxious" => Some(Mood::Anxious), "frustrated" => Some(Mood::Frustrated), "sad" => Some(Mood::Sad), "angry" => Some(Mood::Angry), "lonely" => Some(Mood::Lonely), "bored" => Some(Mood::Bored), "tired" => Some(Mood::Tired), "hopeful" => Some(Mood::Hopeful), "neutral" => Some(Mood::Neutral), _ => None, } } fn ordinal(self) -> u8 { match self { Mood::Happy => 0, Mood::Satisfied => 1, Mood::Excited => 2, Mood::Curious => 3, Mood::Anxious => 4, Mood::Frustrated => 5, Mood::Sad => 6, Mood::Angry => 7, Mood::Lonely => 8, Mood::Bored => 9, Mood::Tired => 10, Mood::Hopeful => 11, Mood::Neutral => 12, } } fn from_ordinal(ordinal: u8) -> Option { match ordinal { 0 => Some(Mood::Happy), 1 => Some(Mood::Satisfied), 2 => Some(Mood::Excited), 3 => Some(Mood::Curious), 4 => Some(Mood::Anxious), 5 => Some(Mood::Frustrated), 6 => Some(Mood::Sad), 7 => Some(Mood::Angry), 8 => Some(Mood::Lonely), 9 => Some(Mood::Bored), 10 => Some(Mood::Tired), 11 => Some(Mood::Hopeful), 12 => Some(Mood::Neutral), _ => None, } } } /// One mood score in a multi-label prediction. #[derive(Debug, Copy, Clone, PartialEq)] pub struct MoodScore { /// The mood being scored. pub mood: Mood, /// Sigmoid probability in the closed interval `[0, 1]`. pub score: f32, } /// Complete classifier output for one text. #[derive(Debug, Clone, PartialEq)] pub struct MoodAnalysis { scores: Vec, } impl MoodAnalysis { /// Creates an analysis from precomputed scores. #[must_use] pub fn new(scores: Vec) -> Self { Self { scores } } /// Returns all scores in stable label order. #[must_use] pub fn scores(&self) -> &[MoodScore] { &self.scores } /// Returns the highest-scoring mood, if the analysis contains scores. #[must_use] pub fn primary_mood(&self) -> Option { self.scores .iter() .copied() .max_by(|left, right| left.score.total_cmp(&right.score)) } /// Returns mood scores whose score is at least `threshold`. pub fn active_moods(&self, threshold: f32) -> impl Iterator + '_ { self.scores .iter() .copied() .filter(move |score| score.score >= threshold) } } /// A classifier that maps input text to a multi-label mood analysis. pub trait EmotionClassifier { /// Classifies one text input. fn classify(&self, text: &str) -> MoodAnalysis; } /// One supervised training example. #[derive(Debug, Copy, Clone)] pub struct TrainingExample<'a> { /// Input text. pub text: &'a str, /// The moods present in the record. /// /// This may be empty for partial-supervision records where all supervised /// labels are known negatives. pub labels: &'a [Mood], } impl TrainingExample<'_> { fn has_label(&self, mood: Mood) -> bool { self.labels.iter().copied().any(|label| label == mood) } } /// An owned supervised training example loaded from a dataset file. #[derive(Debug, Clone, Eq, PartialEq)] pub struct OwnedTrainingExample { text: String, labels: Vec, supervised_labels: Vec, } impl OwnedTrainingExample { /// Creates an owned example with non-empty text and full mood supervision. /// /// The example supervises every DADOES mood label. pub fn new(text: String, labels: Vec) -> Result { Self::new_with_supervised(text, labels, Mood::ALL.to_vec()) } /// Creates an owned example with an explicit set of supervised labels. pub fn new_with_supervised( text: String, labels: Vec, supervised_labels: Vec, ) -> Result { if text.trim().is_empty() { return Err(ModelError::EmptyExampleText); } if supervised_labels.is_empty() { return Err(ModelError::EmptySupervisedLabels); } let mut unique_labels = Vec::new(); for label in labels { if !unique_labels.contains(&label) { unique_labels.push(label); } } let mut unique_supervised_labels = Vec::new(); for label in supervised_labels { if !unique_supervised_labels.contains(&label) { unique_supervised_labels.push(label); } } if unique_labels .iter() .any(|label| !unique_supervised_labels.contains(label)) { return Err(ModelError::LabelOutsideSupervision); } Ok(Self { text, labels: unique_labels, supervised_labels: unique_supervised_labels, }) } /// Returns the report text. #[must_use] pub fn text(&self) -> &str { &self.text } /// Returns the mood labels present in this example. /// /// The slice may be empty when the example only contributes negative /// labels for its supervised dimensions. #[must_use] pub fn labels(&self) -> &[Mood] { &self.labels } /// Returns labels whose true/false state is known for this example. #[must_use] pub fn supervised_labels(&self) -> &[Mood] { &self.supervised_labels } } /// Hyperparameters for the built-in linear mood model. #[derive(Debug, Copy, Clone)] pub struct TrainConfig { /// Number of hashed text features. pub feature_count: usize, /// Number of full passes over the training examples. pub epochs: usize, /// Stochastic gradient descent learning rate. pub learning_rate: f32, /// L2 weight decay applied on each update. pub l2: f32, /// Number of non-improving validation epochs tolerated before stopping. pub patience: usize, /// Minimum validation loss decrease required to reset patience. pub min_delta: f32, /// Probability threshold used for multi-label evaluation metrics. pub threshold: f32, } impl Default for TrainConfig { fn default() -> Self { Self { feature_count: 32768, epochs: 40, learning_rate: 0.18, l2: 0.0002, patience: 4, min_delta: 0.0001, threshold: 0.35, } } } /// Recoverable errors from model construction or training. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum ModelError { /// The configured feature count was zero. ZeroFeatureCount, /// The configured epoch count was zero. ZeroEpochs, /// Validation-based early stopping was requested with zero patience. ZeroPatience, /// No training examples were provided. EmptyTrainingSet, /// No validation examples were provided. EmptyValidationSet, /// No evaluation examples were provided. EmptyEvaluationSet, /// A dataset row had empty report text. EmptyExampleText, /// A dataset row had no mood labels. EmptyLabels, /// A dataset row had no supervised labels. EmptySupervisedLabels, /// A positive label was outside the example's supervised label set. LabelOutsideSupervision, } impl Display for ModelError { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { match self { ModelError::ZeroFeatureCount => formatter.write_str("feature_count must be positive"), ModelError::ZeroEpochs => formatter.write_str("epochs must be positive"), ModelError::ZeroPatience => formatter.write_str("patience must be positive"), ModelError::EmptyTrainingSet => formatter.write_str("training set must not be empty"), ModelError::EmptyValidationSet => { formatter.write_str("validation set must not be empty") } ModelError::EmptyEvaluationSet => { formatter.write_str("evaluation set must not be empty") } ModelError::EmptyExampleText => formatter.write_str("example text must not be empty"), ModelError::EmptyLabels => formatter.write_str("example labels must not be empty"), ModelError::EmptySupervisedLabels => { formatter.write_str("example supervised labels must not be empty") } ModelError::LabelOutsideSupervision => { formatter.write_str("example label is outside supervised label set") } } } } impl Error for ModelError {} /// A fixed-size hashed feature vector for one text. #[derive(Debug, Clone, PartialEq)] pub struct FeatureVector { active: Vec, } impl FeatureVector { /// Returns the active hashed feature values. #[must_use] pub fn active_values(&self) -> &[FeatureValue] { &self.active } } /// One non-zero hashed text feature. #[derive(Debug, Copy, Clone, PartialEq)] pub struct FeatureValue { /// Hashed feature index. pub index: usize, /// Normalized feature value. pub value: f32, } /// Converts English report text into hashed lexical features. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub struct FeatureHasher { dimension: NonZeroUsize, } impl FeatureHasher { /// Creates a hasher with a positive feature dimension. /// /// Returns [`ModelError::ZeroFeatureCount`] when `dimension` is zero. pub fn new(dimension: usize) -> Result { let Some(dimension) = NonZeroUsize::new(dimension) else { return Err(ModelError::ZeroFeatureCount); }; Ok(Self { dimension }) } /// Encodes one report into a normalized hashed feature vector. #[must_use] pub fn encode(&self, text: &str) -> FeatureVector { let mut values = BTreeMap::new(); let tokens = tokenize(text); let mut previous: Option<&str> = None; for token in &tokens { self.add_feature(token, &mut values); if previous.is_some_and(is_negator) { let negated_feature = format!("negated_{token}"); self.add_feature(&negated_feature, &mut values); } previous = Some(token); } FeatureVector { active: normalized_sparse_values(values), } } fn add_feature(&self, feature: &str, values: &mut BTreeMap) { let Some(index) = self.feature_index(feature) else { return; }; let value = values.entry(index).or_insert(0.0); *value += 1.0; } fn feature_index(&self, feature: &str) -> Option { let mut hasher = DefaultHasher::new(); feature.hash(&mut hasher); let dimension = u64::try_from(self.dimension.get()).ok()?; let index = hasher.finish() % dimension; usize::try_from(index).ok() } fn dimension(&self) -> usize { self.dimension.get() } } /// A trained multi-label linear mood model. #[derive(Debug, Clone, PartialEq)] pub struct LinearMoodModel { hasher: FeatureHasher, rows: Vec, } impl LinearMoodModel { /// Trains a linear multi-label model using binary cross entropy. pub fn train( examples: &[TrainingExample<'_>], config: TrainConfig, ) -> Result { if examples.is_empty() { return Err(ModelError::EmptyTrainingSet); } if config.epochs == 0 { return Err(ModelError::ZeroEpochs); } let hasher = FeatureHasher::new(config.feature_count)?; let mut rows = initial_rows(config.feature_count); for _ in 0..config.epochs { for example in examples { let features = hasher.encode(example.text); update_rows( &mut rows, &features, example, config.learning_rate, config.l2, ); } } Ok(Self { hasher, rows }) } /// Trains a model from owned examples and stops on validation loss. pub fn train_with_validation( train_examples: &[OwnedTrainingExample], validation_examples: &[OwnedTrainingExample], config: TrainConfig, ) -> Result { if train_examples.is_empty() { return Err(ModelError::EmptyTrainingSet); } if validation_examples.is_empty() { return Err(ModelError::EmptyValidationSet); } if config.epochs == 0 { return Err(ModelError::ZeroEpochs); } if config.patience == 0 { return Err(ModelError::ZeroPatience); } let hasher = FeatureHasher::new(config.feature_count)?; let mut model = Self { hasher, rows: initial_rows(config.feature_count), }; let mut best_rows = model.rows.clone(); let mut best_validation_loss = f32::INFINITY; let mut best_epoch = 0; let mut epochs_trained = 0; let mut stale_epochs = 0; for epoch in 1..=config.epochs { for example in train_examples { let features = model.hasher.encode(example.text()); update_rows_owned( &mut model.rows, &features, example, config.learning_rate, config.l2, ); } epochs_trained = epoch; let metrics = model.evaluate(validation_examples, config.threshold)?; if improved_validation_loss(metrics.loss, best_validation_loss, config.min_delta) { best_validation_loss = metrics.loss; best_epoch = epoch; best_rows = model.rows.clone(); stale_epochs = 0; } else { stale_epochs += 1; if stale_epochs >= config.patience { break; } } } model.rows = best_rows; let validation = model.evaluate(validation_examples, config.threshold)?; Ok(TrainingOutcome { model, epochs_trained, best_epoch, best_validation_loss, validation, }) } /// Evaluates a trained model on owned examples. pub fn evaluate( &self, examples: &[OwnedTrainingExample], threshold: f32, ) -> Result { if examples.is_empty() { return Err(ModelError::EmptyEvaluationSet); } let mut loss = 0.0; let mut true_positive = 0_usize; let mut false_positive = 0_usize; let mut false_negative = 0_usize; let mut exact_matches = 0_usize; let mut supervised_label_count = 0_usize; for example in examples { let analysis = self.classify(example.text()); let mut exact_match = true; for score in analysis.scores() { if !example.supervised_labels().contains(&score.mood) { continue; } supervised_label_count += 1; let target = example .labels() .iter() .copied() .any(|label| label == score.mood); loss += binary_cross_entropy(score.score, target); let predicted = score.score >= threshold; match (predicted, target) { (true, true) => true_positive += 1, (true, false) => { false_positive += 1; exact_match = false; } (false, true) => { false_negative += 1; exact_match = false; } (false, false) => {} } } if exact_match { exact_matches += 1; } } let average_loss = loss / usize_to_f32(supervised_label_count); let precision = ratio(true_positive, true_positive + false_positive); let recall = ratio(true_positive, true_positive + false_negative); let micro_f1 = harmonic_mean(precision, recall); let exact_match = ratio(exact_matches, examples.len()); Ok(EvaluationMetrics { examples: examples.len(), loss: average_loss, micro_precision: precision, micro_recall: recall, micro_f1, exact_match, }) } /// Saves the model in the DADOES binary checkpoint format. pub fn save_to_writer(&self, writer: &mut W) -> Result<(), ModelIoError> { writer.write_all(MODEL_MAGIC)?; write_u64(writer, self.hasher.dimension())?; write_u64(writer, self.rows.len())?; for row in &self.rows { writer.write_all(&[row.mood.ordinal()])?; writer.write_all(&row.bias.to_le_bytes())?; for weight in &row.weights { writer.write_all(&weight.to_le_bytes())?; } } Ok(()) } /// Loads a model from the DADOES binary checkpoint format. pub fn load_from_reader(reader: &mut R) -> Result { let mut magic = [0_u8; MODEL_MAGIC_LEN]; reader.read_exact(&mut magic)?; if magic != *MODEL_MAGIC { return Err(ModelIoError::InvalidMagic); } let feature_count = read_usize(reader)?; let row_count = read_usize(reader)?; let hasher = FeatureHasher::new(feature_count)?; let mut rows = Vec::with_capacity(row_count); for _ in 0..row_count { let mut mood = [0_u8; 1]; reader.read_exact(&mut mood)?; let Some(mood) = Mood::from_ordinal(mood[0]) else { return Err(ModelIoError::UnknownMoodOrdinal(mood[0])); }; let bias = read_f32(reader)?; let mut weights = Vec::with_capacity(feature_count); for _ in 0..feature_count { weights.push(read_f32(reader)?); } rows.push(LabelWeights { mood, bias, weights, }); } Ok(Self { hasher, rows }) } } impl EmotionClassifier for LinearMoodModel { fn classify(&self, text: &str) -> MoodAnalysis { let features = self.hasher.encode(text); let scores = self .rows .iter() .map(|row| MoodScore { mood: row.mood, score: sigmoid(row.logit(&features)), }) .collect(); MoodAnalysis::new(scores) } } #[derive(Debug, Clone, PartialEq)] struct LabelWeights { mood: Mood, bias: f32, weights: Vec, } impl LabelWeights { fn logit(&self, features: &FeatureVector) -> f32 { let weighted_sum = features .active_values() .iter() .filter_map(|feature| { self.weights .get(feature.index) .map(|weight| weight * feature.value) }) .sum::(); weighted_sum + self.bias } } fn initial_rows(feature_count: usize) -> Vec { Mood::ALL .iter() .copied() .map(|mood| LabelWeights { mood, bias: 0.0, weights: vec![0.0; feature_count], }) .collect() } fn update_rows( rows: &mut [LabelWeights], features: &FeatureVector, example: &TrainingExample<'_>, learning_rate: f32, l2: f32, ) { for row in rows { let target = if example.has_label(row.mood) { 1.0 } else { 0.0 }; let prediction = sigmoid(row.logit(features)); let gradient = prediction - target; for feature in features.active_values() { if let Some(weight) = row.weights.get_mut(feature.index) { let regularized_gradient = gradient * feature.value + l2 * *weight; *weight -= learning_rate * regularized_gradient; } } row.bias -= learning_rate * gradient; } } fn update_rows_owned( rows: &mut [LabelWeights], features: &FeatureVector, example: &OwnedTrainingExample, learning_rate: f32, l2: f32, ) { for row in rows { if !example.supervised_labels().contains(&row.mood) { continue; } let target = example .labels() .iter() .copied() .any(|label| label == row.mood); let prediction = sigmoid(row.logit(features)); let gradient = prediction - if target { 1.0 } else { 0.0 }; for feature in features.active_values() { if let Some(weight) = row.weights.get_mut(feature.index) { let regularized_gradient = gradient * feature.value + l2 * *weight; *weight -= learning_rate * regularized_gradient; } } row.bias -= learning_rate * gradient; } } fn tokenize(text: &str) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); for character in text.chars() { if character.is_alphanumeric() { current.extend(character.to_lowercase()); } else { finish_token(&mut tokens, &mut current); } } finish_token(&mut tokens, &mut current); tokens } fn finish_token(tokens: &mut Vec, current: &mut String) { if current.is_empty() { return; } tokens.push(std::mem::take(current)); } fn normalized_sparse_values(values: BTreeMap) -> Vec { let norm = values .values() .map(|value| value * value) .sum::() .sqrt(); if norm <= f32::EPSILON { return Vec::new(); } values .into_iter() .map(|(index, value)| FeatureValue { index, value: value / norm, }) .collect() } fn is_negator(token: &str) -> bool { matches!(token, "not" | "never" | "no" | "hardly" | "barely") } fn sigmoid(value: f32) -> f32 { if value >= 0.0 { 1.0 / (1.0 + (-value).exp()) } else { let exp = value.exp(); exp / (1.0 + exp) } } /// A completed training run with its best validation checkpoint. #[derive(Debug, Clone, PartialEq)] pub struct TrainingOutcome { /// Best model selected by validation loss. pub model: LinearMoodModel, /// Number of epochs executed before stopping. pub epochs_trained: usize, /// Epoch where the best validation loss was observed. pub best_epoch: usize, /// Best validation loss observed during training. pub best_validation_loss: f32, /// Metrics for the restored best validation checkpoint. pub validation: EvaluationMetrics, } /// Multi-label evaluation metrics. #[derive(Debug, Copy, Clone, PartialEq)] pub struct EvaluationMetrics { /// Number of examples evaluated. pub examples: usize, /// Mean binary cross entropy over examples and labels. pub loss: f32, /// Micro-averaged precision at the evaluation threshold. pub micro_precision: f32, /// Micro-averaged recall at the evaluation threshold. pub micro_recall: f32, /// Micro-averaged F1 at the evaluation threshold. pub micro_f1: f32, /// Exact multi-label match rate. pub exact_match: f32, } /// Recoverable checkpoint read/write errors. #[derive(Debug)] pub enum ModelIoError { /// The underlying IO operation failed. Io(std::io::Error), /// The checkpoint header was not a DADOES linear model. InvalidMagic, /// A serialized count did not fit in the current platform's `usize`. CountTooLarge(u64), /// A checkpoint referenced an unsupported mood ordinal. UnknownMoodOrdinal(u8), /// The checkpoint had an invalid model shape. Model(ModelError), } impl Display for ModelIoError { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { match self { ModelIoError::Io(error) => write!(formatter, "checkpoint IO failed: {error}"), ModelIoError::InvalidMagic => formatter.write_str("invalid DADOES checkpoint header"), ModelIoError::CountTooLarge(count) => { write!(formatter, "checkpoint count does not fit in usize: {count}") } ModelIoError::UnknownMoodOrdinal(ordinal) => { write!(formatter, "unknown mood ordinal in checkpoint: {ordinal}") } ModelIoError::Model(error) => write!(formatter, "invalid checkpoint model: {error}"), } } } impl Error for ModelIoError {} impl From for ModelIoError { fn from(error: std::io::Error) -> Self { ModelIoError::Io(error) } } impl From for ModelIoError { fn from(error: ModelError) -> Self { ModelIoError::Model(error) } } const MODEL_MAGIC: &[u8; MODEL_MAGIC_LEN] = b"DADOES_LINEAR_V1"; const MODEL_MAGIC_LEN: usize = 16; fn improved_validation_loss(candidate: f32, best: f32, min_delta: f32) -> bool { candidate + min_delta < best } fn binary_cross_entropy(score: f32, target: bool) -> f32 { let clamped = score.clamp(0.000001, 0.999999); if target { -clamped.ln() } else { -(1.0 - clamped).ln() } } fn ratio(numerator: usize, denominator: usize) -> f32 { if denominator == 0 { return 0.0; } usize_to_f32(numerator) / usize_to_f32(denominator) } fn harmonic_mean(left: f32, right: f32) -> f32 { let denominator = left + right; if denominator <= f32::EPSILON { return 0.0; } 2.0 * left * right / denominator } fn write_u64(writer: &mut W, value: usize) -> Result<(), ModelIoError> { let value = u64::try_from(value).map_err(|_| ModelIoError::CountTooLarge(u64::MAX))?; writer.write_all(&value.to_le_bytes())?; Ok(()) } fn read_usize(reader: &mut R) -> Result { let mut bytes = [0_u8; 8]; reader.read_exact(&mut bytes)?; let value = u64::from_le_bytes(bytes); usize::try_from(value).map_err(|_| ModelIoError::CountTooLarge(value)) } fn read_f32(reader: &mut R) -> Result { let mut bytes = [0_u8; 4]; reader.read_exact(&mut bytes)?; Ok(f32::from_le_bytes(bytes)) } fn usize_to_f32(value: usize) -> f32 { value.to_string().parse::().unwrap_or(f32::INFINITY) }