DADOES / src /lib.rs
Ryan.K
Position DADOES around microsecond inference
f402098 unverified
Raw
History Blame Contribute Delete
30.4 kB
#![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<Self> {
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<Self> {
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<MoodScore>,
}
impl MoodAnalysis {
/// Creates an analysis from precomputed scores.
#[must_use]
pub fn new(scores: Vec<MoodScore>) -> 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<MoodScore> {
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<Item = MoodScore> + '_ {
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<Mood>,
supervised_labels: Vec<Mood>,
}
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<Mood>) -> Result<Self, ModelError> {
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<Mood>,
supervised_labels: Vec<Mood>,
) -> Result<Self, ModelError> {
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<FeatureValue>,
}
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<Self, ModelError> {
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<usize, f32>) {
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<usize> {
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<LabelWeights>,
}
impl LinearMoodModel {
/// Trains a linear multi-label model using binary cross entropy.
pub fn train(
examples: &[TrainingExample<'_>],
config: TrainConfig,
) -> Result<Self, ModelError> {
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<TrainingOutcome, ModelError> {
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<EvaluationMetrics, ModelError> {
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<W: Write>(&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<R: Read>(reader: &mut R) -> Result<Self, ModelIoError> {
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<f32>,
}
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::<f32>();
weighted_sum + self.bias
}
}
fn initial_rows(feature_count: usize) -> Vec<LabelWeights> {
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<String> {
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<String>, current: &mut String) {
if current.is_empty() {
return;
}
tokens.push(std::mem::take(current));
}
fn normalized_sparse_values(values: BTreeMap<usize, f32>) -> Vec<FeatureValue> {
let norm = values
.values()
.map(|value| value * value)
.sum::<f32>()
.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<std::io::Error> for ModelIoError {
fn from(error: std::io::Error) -> Self {
ModelIoError::Io(error)
}
}
impl From<ModelError> 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<W: Write>(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<R: Read>(reader: &mut R) -> Result<usize, ModelIoError> {
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<R: Read>(reader: &mut R) -> Result<f32, ModelIoError> {
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::<f32>().unwrap_or(f32::INFINITY)
}