1#[cfg(not(feature = "std"))]
15use alloc::string::String;
16#[cfg(not(feature = "std"))]
17use alloc::vec::Vec;
18
19use crate::collections::{HashMap, new_map};
20use crate::rst::RstRelation;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
31pub enum Verbosity {
32 Terse,
33 #[default]
34 Neutral,
35 Verbose,
36}
37
38#[derive(Debug, Clone, PartialEq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub struct LengthDistribution {
50 pub short: f32,
52 pub medium: f32,
55 pub long: f32,
57 pub short_max_words: u16,
59 pub medium_max_words: u16,
62}
63
64impl LengthDistribution {
65 pub fn neutral() -> Self {
70 Self {
71 short: 1.0 / 3.0,
72 medium: 1.0 / 3.0,
73 long: 1.0 / 3.0,
74 short_max_words: 8,
75 medium_max_words: 18,
76 }
77 }
78
79 pub fn is_neutral(&self) -> bool {
83 let neutral = Self::neutral();
85 self.short.to_bits() == neutral.short.to_bits()
86 && self.medium.to_bits() == neutral.medium.to_bits()
87 && self.long.to_bits() == neutral.long.to_bits()
88 && self.short_max_words == neutral.short_max_words
89 && self.medium_max_words == neutral.medium_max_words
90 }
91}
92
93impl Default for LengthDistribution {
94 fn default() -> Self {
95 Self::neutral()
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub struct ConnectivePreferences {
119 pub allowed: HashMap<RstRelation, Vec<String>>,
120 pub preferred: HashMap<RstRelation, Vec<(String, f32)>>,
121}
122
123impl ConnectivePreferences {
124 pub fn neutral() -> Self {
127 Self {
128 allowed: new_map(),
129 preferred: new_map(),
130 }
131 }
132
133 pub fn is_neutral(&self) -> bool {
135 self.allowed.is_empty() && self.preferred.is_empty()
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
149#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
150#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
151pub enum ListStyleBias {
152 #[default]
153 Auto,
154 Including,
155 SuchAs,
156 Dash,
157 Bracketed,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
168#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
169#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
170pub enum PronounDensity {
171 Low,
172 #[default]
173 Default,
174 High,
175}
176
177#[derive(Debug, Clone, Default, PartialEq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct HedgingCalibration {
189 pub offset: i8,
191 pub forbid: Vec<String>,
195}
196
197impl HedgingCalibration {
198 pub fn neutral() -> Self {
199 Self {
200 offset: 0,
201 forbid: Vec::new(),
202 }
203 }
204
205 pub fn is_neutral(&self) -> bool {
206 self.offset == 0 && self.forbid.is_empty()
207 }
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
220#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
221#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
222pub enum SalienceBias {
223 Lower,
225 #[default]
226 Auto,
227 Higher,
229}
230
231#[derive(Debug, Clone, PartialEq)]
234#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
235pub enum StyleProfileError {
236 EmptyAllowedPool { relation: RstRelation },
239 InvalidLengthBoundaries {
241 short_max_words: u16,
242 medium_max_words: u16,
243 },
244 HedgingOffsetOutOfRange { offset: i8 },
246 InvalidLengthProportion { which: &'static str, value: f32 },
248}
249
250impl core::fmt::Display for StyleProfileError {
251 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
252 match self {
253 StyleProfileError::EmptyAllowedPool { relation } => write!(
254 f,
255 "style profile: connectives.allowed[{relation:?}] is an explicit empty pool"
256 ),
257 StyleProfileError::InvalidLengthBoundaries {
258 short_max_words,
259 medium_max_words,
260 } => write!(
261 f,
262 "style profile: medium_max_words ({medium_max_words}) must be >= short_max_words ({short_max_words})"
263 ),
264 StyleProfileError::HedgingOffsetOutOfRange { offset } => write!(
265 f,
266 "style profile: hedging.offset {offset} outside documented range -50..=+50"
267 ),
268 StyleProfileError::InvalidLengthProportion { which, value } => write!(
269 f,
270 "style profile: length_distribution.{which} = {value} is negative or non-finite"
271 ),
272 }
273 }
274}
275
276#[cfg(feature = "std")]
277impl std::error::Error for StyleProfileError {}
278
279#[derive(Debug, Clone, PartialEq)]
292#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
293#[non_exhaustive]
294pub struct StyleProfile {
295 pub name: String,
296 pub verbosity: Verbosity,
297 pub sentence_length: LengthDistribution,
298 pub connectives: ConnectivePreferences,
299 pub list_style_bias: ListStyleBias,
300 pub pronoun_density: PronounDensity,
301 pub hedging: HedgingCalibration,
302 pub salience: SalienceBias,
303}
304
305impl StyleProfile {
306 pub fn neutral() -> Self {
310 Self {
311 name: String::from("neutral"),
312 verbosity: Verbosity::default(),
313 sentence_length: LengthDistribution::neutral(),
314 connectives: ConnectivePreferences::neutral(),
315 list_style_bias: ListStyleBias::default(),
316 pronoun_density: PronounDensity::default(),
317 hedging: HedgingCalibration::neutral(),
318 salience: SalienceBias::default(),
319 }
320 }
321
322 pub fn builder(name: impl Into<String>) -> StyleProfileBuilder {
324 StyleProfileBuilder {
325 profile: Self {
326 name: name.into(),
327 ..Self::neutral()
328 },
329 }
330 }
331
332 pub fn is_neutral(&self) -> bool {
337 self.verbosity == Verbosity::default()
338 && self.sentence_length.is_neutral()
339 && self.connectives.is_neutral()
340 && self.list_style_bias == ListStyleBias::default()
341 && self.pronoun_density == PronounDensity::default()
342 && self.hedging.is_neutral()
343 && self.salience == SalienceBias::default()
344 }
345
346 pub fn validate(&self) -> Result<(), StyleProfileError> {
351 for (relation, pool) in &self.connectives.allowed {
352 if pool.is_empty() {
353 return Err(StyleProfileError::EmptyAllowedPool {
354 relation: *relation,
355 });
356 }
357 }
358 if self.sentence_length.medium_max_words < self.sentence_length.short_max_words {
359 return Err(StyleProfileError::InvalidLengthBoundaries {
360 short_max_words: self.sentence_length.short_max_words,
361 medium_max_words: self.sentence_length.medium_max_words,
362 });
363 }
364 for (which, value) in [
365 ("short", self.sentence_length.short),
366 ("medium", self.sentence_length.medium),
367 ("long", self.sentence_length.long),
368 ] {
369 if !value.is_finite() || value < 0.0 {
370 return Err(StyleProfileError::InvalidLengthProportion { which, value });
371 }
372 }
373 if !(-50..=50).contains(&self.hedging.offset) {
374 return Err(StyleProfileError::HedgingOffsetOutOfRange {
375 offset: self.hedging.offset,
376 });
377 }
378 Ok(())
379 }
380}
381
382impl Default for StyleProfile {
383 fn default() -> Self {
384 Self::neutral()
385 }
386}
387
388#[derive(Debug, Clone)]
395pub struct StyleProfileBuilder {
396 profile: StyleProfile,
397}
398
399impl StyleProfileBuilder {
400 pub fn verbosity(mut self, v: Verbosity) -> Self {
401 self.profile.verbosity = v;
402 self
403 }
404
405 pub fn sentence_length(mut self, distribution: LengthDistribution) -> Self {
406 self.profile.sentence_length = distribution;
407 self
408 }
409
410 pub fn connectives(mut self, prefs: ConnectivePreferences) -> Self {
411 self.profile.connectives = prefs;
412 self
413 }
414
415 pub fn allow_connectives(
417 mut self,
418 relation: RstRelation,
419 pool: impl IntoIterator<Item = impl Into<String>>,
420 ) -> Self {
421 let pool: Vec<String> = pool.into_iter().map(Into::into).collect();
422 self.profile.connectives.allowed.insert(relation, pool);
423 self
424 }
425
426 pub fn prefer_connectives(
428 mut self,
429 relation: RstRelation,
430 weights: impl IntoIterator<Item = (impl Into<String>, f32)>,
431 ) -> Self {
432 let weights: Vec<(String, f32)> = weights.into_iter().map(|(s, w)| (s.into(), w)).collect();
433 self.profile.connectives.preferred.insert(relation, weights);
434 self
435 }
436
437 pub fn list_style_bias(mut self, bias: ListStyleBias) -> Self {
438 self.profile.list_style_bias = bias;
439 self
440 }
441
442 pub fn pronoun_density(mut self, density: PronounDensity) -> Self {
443 self.profile.pronoun_density = density;
444 self
445 }
446
447 pub fn hedging(mut self, calibration: HedgingCalibration) -> Self {
448 self.profile.hedging = calibration;
449 self
450 }
451
452 pub fn forbid_hedge(mut self, hedge: impl Into<String>) -> Self {
453 self.profile.hedging.forbid.push(hedge.into());
454 self
455 }
456
457 pub fn hedging_offset(mut self, offset: i8) -> Self {
458 self.profile.hedging.offset = offset;
459 self
460 }
461
462 pub fn salience(mut self, bias: SalienceBias) -> Self {
463 self.profile.salience = bias;
464 self
465 }
466
467 pub fn build(self) -> Result<StyleProfile, StyleProfileError> {
470 self.profile.validate()?;
471 Ok(self.profile)
472 }
473
474 pub fn build_or_panic(self) -> StyleProfile {
478 self.profile.validate().expect("style profile validation");
479 self.profile
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn neutral_round_trips_through_default() {
489 let n = StyleProfile::neutral();
490 let d = StyleProfile::default();
491 assert_eq!(n, d);
492 assert!(n.is_neutral());
493 }
494
495 #[test]
496 fn builder_named_profile_with_no_dial_changes_is_neutral_in_effect() {
497 let p = StyleProfile::builder("custom").build().unwrap();
501 assert_eq!(p.name, "custom");
502 assert!(p.is_neutral());
503 }
504
505 #[test]
506 fn builder_with_changed_dial_is_not_neutral() {
507 let p = StyleProfile::builder("terse")
508 .verbosity(Verbosity::Terse)
509 .build()
510 .unwrap();
511 assert!(!p.is_neutral());
512 }
513
514 #[test]
515 fn empty_allowed_pool_is_rejected() {
516 let p = StyleProfile::builder("bad")
517 .allow_connectives(RstRelation::Contrast, Vec::<String>::new())
518 .build();
519 assert!(matches!(
520 p,
521 Err(StyleProfileError::EmptyAllowedPool {
522 relation: RstRelation::Contrast
523 })
524 ));
525 }
526
527 #[test]
528 fn allow_connectives_with_entries_validates() {
529 let p = StyleProfile::builder("ok")
530 .allow_connectives(RstRelation::Contrast, ["However", "Conversely"])
531 .build()
532 .unwrap();
533 assert_eq!(
534 p.connectives
535 .allowed
536 .get(&RstRelation::Contrast)
537 .map(Vec::len),
538 Some(2)
539 );
540 }
541
542 #[test]
543 fn invalid_length_boundaries_rejected() {
544 let bad = LengthDistribution {
545 short_max_words: 20,
546 medium_max_words: 10,
547 ..LengthDistribution::neutral()
548 };
549 let result = StyleProfile::builder("bad").sentence_length(bad).build();
550 assert!(matches!(
551 result,
552 Err(StyleProfileError::InvalidLengthBoundaries { .. })
553 ));
554 }
555
556 #[test]
557 fn invalid_length_proportion_rejected() {
558 let bad = LengthDistribution {
559 short: -0.1,
560 ..LengthDistribution::neutral()
561 };
562 let result = StyleProfile::builder("bad").sentence_length(bad).build();
563 assert!(matches!(
564 result,
565 Err(StyleProfileError::InvalidLengthProportion { which: "short", .. })
566 ));
567 }
568
569 #[test]
570 fn hedging_offset_out_of_range_rejected() {
571 let result = StyleProfile::builder("bad").hedging_offset(75).build();
572 assert!(matches!(
573 result,
574 Err(StyleProfileError::HedgingOffsetOutOfRange { offset: 75 })
575 ));
576 }
577
578 #[test]
579 fn neutral_validates() {
580 StyleProfile::neutral()
581 .validate()
582 .expect("neutral must validate");
583 }
584
585 #[test]
586 fn dial_changes_independent() {
587 let v = StyleProfile::builder("v")
590 .verbosity(Verbosity::Verbose)
591 .build()
592 .unwrap();
593 assert_eq!(v.verbosity, Verbosity::Verbose);
594 assert!(v.connectives.is_neutral());
595 assert!(v.hedging.is_neutral());
596 assert_eq!(v.salience, SalienceBias::Auto);
597 }
598}