#[cfg(not(feature = "std"))]
use alloc::string::String;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::collections::{HashMap, new_map};
use crate::rst::RstRelation;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Verbosity {
Terse,
#[default]
Neutral,
Verbose,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct LengthDistribution {
pub short: f32,
pub medium: f32,
pub long: f32,
pub short_max_words: u16,
pub medium_max_words: u16,
}
impl LengthDistribution {
pub fn neutral() -> Self {
Self {
short: 1.0 / 3.0,
medium: 1.0 / 3.0,
long: 1.0 / 3.0,
short_max_words: 8,
medium_max_words: 18,
}
}
pub fn is_neutral(&self) -> bool {
let neutral = Self::neutral();
self.short.to_bits() == neutral.short.to_bits()
&& self.medium.to_bits() == neutral.medium.to_bits()
&& self.long.to_bits() == neutral.long.to_bits()
&& self.short_max_words == neutral.short_max_words
&& self.medium_max_words == neutral.medium_max_words
}
}
impl Default for LengthDistribution {
fn default() -> Self {
Self::neutral()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ConnectivePreferences {
pub allowed: HashMap<RstRelation, Vec<String>>,
pub preferred: HashMap<RstRelation, Vec<(String, f32)>>,
}
impl ConnectivePreferences {
pub fn neutral() -> Self {
Self {
allowed: new_map(),
preferred: new_map(),
}
}
pub fn is_neutral(&self) -> bool {
self.allowed.is_empty() && self.preferred.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ListStyleBias {
#[default]
Auto,
Including,
SuchAs,
Dash,
Bracketed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum PronounDensity {
Low,
#[default]
Default,
High,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HedgingCalibration {
pub offset: i8,
pub forbid: Vec<String>,
}
impl HedgingCalibration {
pub fn neutral() -> Self {
Self {
offset: 0,
forbid: Vec::new(),
}
}
pub fn is_neutral(&self) -> bool {
self.offset == 0 && self.forbid.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum SalienceBias {
Lower,
#[default]
Auto,
Higher,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum StyleProfileError {
EmptyAllowedPool { relation: RstRelation },
InvalidLengthBoundaries {
short_max_words: u16,
medium_max_words: u16,
},
HedgingOffsetOutOfRange { offset: i8 },
InvalidLengthProportion { which: &'static str, value: f32 },
}
impl core::fmt::Display for StyleProfileError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
StyleProfileError::EmptyAllowedPool { relation } => write!(
f,
"style profile: connectives.allowed[{relation:?}] is an explicit empty pool"
),
StyleProfileError::InvalidLengthBoundaries {
short_max_words,
medium_max_words,
} => write!(
f,
"style profile: medium_max_words ({medium_max_words}) must be >= short_max_words ({short_max_words})"
),
StyleProfileError::HedgingOffsetOutOfRange { offset } => write!(
f,
"style profile: hedging.offset {offset} outside documented range -50..=+50"
),
StyleProfileError::InvalidLengthProportion { which, value } => write!(
f,
"style profile: length_distribution.{which} = {value} is negative or non-finite"
),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for StyleProfileError {}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct StyleProfile {
pub name: String,
pub verbosity: Verbosity,
pub sentence_length: LengthDistribution,
pub connectives: ConnectivePreferences,
pub list_style_bias: ListStyleBias,
pub pronoun_density: PronounDensity,
pub hedging: HedgingCalibration,
pub salience: SalienceBias,
}
impl StyleProfile {
pub fn neutral() -> Self {
Self {
name: String::from("neutral"),
verbosity: Verbosity::default(),
sentence_length: LengthDistribution::neutral(),
connectives: ConnectivePreferences::neutral(),
list_style_bias: ListStyleBias::default(),
pronoun_density: PronounDensity::default(),
hedging: HedgingCalibration::neutral(),
salience: SalienceBias::default(),
}
}
pub fn builder(name: impl Into<String>) -> StyleProfileBuilder {
StyleProfileBuilder {
profile: Self {
name: name.into(),
..Self::neutral()
},
}
}
pub fn is_neutral(&self) -> bool {
self.verbosity == Verbosity::default()
&& self.sentence_length.is_neutral()
&& self.connectives.is_neutral()
&& self.list_style_bias == ListStyleBias::default()
&& self.pronoun_density == PronounDensity::default()
&& self.hedging.is_neutral()
&& self.salience == SalienceBias::default()
}
pub fn validate(&self) -> Result<(), StyleProfileError> {
for (relation, pool) in &self.connectives.allowed {
if pool.is_empty() {
return Err(StyleProfileError::EmptyAllowedPool {
relation: *relation,
});
}
}
if self.sentence_length.medium_max_words < self.sentence_length.short_max_words {
return Err(StyleProfileError::InvalidLengthBoundaries {
short_max_words: self.sentence_length.short_max_words,
medium_max_words: self.sentence_length.medium_max_words,
});
}
for (which, value) in [
("short", self.sentence_length.short),
("medium", self.sentence_length.medium),
("long", self.sentence_length.long),
] {
if !value.is_finite() || value < 0.0 {
return Err(StyleProfileError::InvalidLengthProportion { which, value });
}
}
if !(-50..=50).contains(&self.hedging.offset) {
return Err(StyleProfileError::HedgingOffsetOutOfRange {
offset: self.hedging.offset,
});
}
Ok(())
}
}
impl Default for StyleProfile {
fn default() -> Self {
Self::neutral()
}
}
#[derive(Debug, Clone)]
pub struct StyleProfileBuilder {
profile: StyleProfile,
}
impl StyleProfileBuilder {
pub fn verbosity(mut self, v: Verbosity) -> Self {
self.profile.verbosity = v;
self
}
pub fn sentence_length(mut self, distribution: LengthDistribution) -> Self {
self.profile.sentence_length = distribution;
self
}
pub fn connectives(mut self, prefs: ConnectivePreferences) -> Self {
self.profile.connectives = prefs;
self
}
pub fn allow_connectives(
mut self,
relation: RstRelation,
pool: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let pool: Vec<String> = pool.into_iter().map(Into::into).collect();
self.profile.connectives.allowed.insert(relation, pool);
self
}
pub fn prefer_connectives(
mut self,
relation: RstRelation,
weights: impl IntoIterator<Item = (impl Into<String>, f32)>,
) -> Self {
let weights: Vec<(String, f32)> = weights.into_iter().map(|(s, w)| (s.into(), w)).collect();
self.profile.connectives.preferred.insert(relation, weights);
self
}
pub fn list_style_bias(mut self, bias: ListStyleBias) -> Self {
self.profile.list_style_bias = bias;
self
}
pub fn pronoun_density(mut self, density: PronounDensity) -> Self {
self.profile.pronoun_density = density;
self
}
pub fn hedging(mut self, calibration: HedgingCalibration) -> Self {
self.profile.hedging = calibration;
self
}
pub fn forbid_hedge(mut self, hedge: impl Into<String>) -> Self {
self.profile.hedging.forbid.push(hedge.into());
self
}
pub fn hedging_offset(mut self, offset: i8) -> Self {
self.profile.hedging.offset = offset;
self
}
pub fn salience(mut self, bias: SalienceBias) -> Self {
self.profile.salience = bias;
self
}
pub fn build(self) -> Result<StyleProfile, StyleProfileError> {
self.profile.validate()?;
Ok(self.profile)
}
pub fn build_or_panic(self) -> StyleProfile {
self.profile.validate().expect("style profile validation");
self.profile
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_round_trips_through_default() {
let n = StyleProfile::neutral();
let d = StyleProfile::default();
assert_eq!(n, d);
assert!(n.is_neutral());
}
#[test]
fn builder_named_profile_with_no_dial_changes_is_neutral_in_effect() {
let p = StyleProfile::builder("custom").build().unwrap();
assert_eq!(p.name, "custom");
assert!(p.is_neutral());
}
#[test]
fn builder_with_changed_dial_is_not_neutral() {
let p = StyleProfile::builder("terse")
.verbosity(Verbosity::Terse)
.build()
.unwrap();
assert!(!p.is_neutral());
}
#[test]
fn empty_allowed_pool_is_rejected() {
let p = StyleProfile::builder("bad")
.allow_connectives(RstRelation::Contrast, Vec::<String>::new())
.build();
assert!(matches!(
p,
Err(StyleProfileError::EmptyAllowedPool {
relation: RstRelation::Contrast
})
));
}
#[test]
fn allow_connectives_with_entries_validates() {
let p = StyleProfile::builder("ok")
.allow_connectives(RstRelation::Contrast, ["However", "Conversely"])
.build()
.unwrap();
assert_eq!(
p.connectives
.allowed
.get(&RstRelation::Contrast)
.map(Vec::len),
Some(2)
);
}
#[test]
fn invalid_length_boundaries_rejected() {
let bad = LengthDistribution {
short_max_words: 20,
medium_max_words: 10,
..LengthDistribution::neutral()
};
let result = StyleProfile::builder("bad").sentence_length(bad).build();
assert!(matches!(
result,
Err(StyleProfileError::InvalidLengthBoundaries { .. })
));
}
#[test]
fn invalid_length_proportion_rejected() {
let bad = LengthDistribution {
short: -0.1,
..LengthDistribution::neutral()
};
let result = StyleProfile::builder("bad").sentence_length(bad).build();
assert!(matches!(
result,
Err(StyleProfileError::InvalidLengthProportion { which: "short", .. })
));
}
#[test]
fn hedging_offset_out_of_range_rejected() {
let result = StyleProfile::builder("bad").hedging_offset(75).build();
assert!(matches!(
result,
Err(StyleProfileError::HedgingOffsetOutOfRange { offset: 75 })
));
}
#[test]
fn neutral_validates() {
StyleProfile::neutral()
.validate()
.expect("neutral must validate");
}
#[test]
fn dial_changes_independent() {
let v = StyleProfile::builder("v")
.verbosity(Verbosity::Verbose)
.build()
.unwrap();
assert_eq!(v.verbosity, Verbosity::Verbose);
assert!(v.connectives.is_neutral());
assert!(v.hedging.is_neutral());
assert_eq!(v.salience, SalienceBias::Auto);
}
}