Skip to main content

prosaic_core/
style.rs

1//! `StyleProfile` — the deterministic dial layer that biases the engine's
2//! existing rendering choices toward a target voice.
3//!
4//! A profile is a struct of small, orthogonal dials. Each dial maps to one
5//! or two specific decision sites the engine already makes; setting a dial
6//! shifts the bias of that decision rather than replacing it. The profile
7//! is immutable for the lifetime of an [`Engine`](crate::Engine) and is
8//! consulted at decision time — there is no learned state, no per-render
9//! mutation, and no profile-conditional template content.
10//!
11//! See `docs/superpowers/specs/2026-05-09-style-profile-design.md` for the
12//! full design rationale and the resolved decisions on each dial.
13
14#[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/// Verbosity dial — biases salience-tier preference at variant selection.
23///
24/// `Terse` prefers the lowest-detail variant available among the candidates
25/// already filtered for the rendering context's salience. `Verbose` prefers
26/// the highest. `Neutral` defers to the engine's existing salience-from-
27/// context logic with no bias.
28#[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/// Sentence-length distribution target.
39///
40/// Used as a soft prior — the rhythm scorer treats the profile's distribution
41/// as a nudge alongside its existing repetition + cadence terms; the
42/// Self-Refine retro-pass uses it as one signal among several rather than
43/// a hard constraint. Values are interpreted as proportions and need not
44/// sum to 1.0; the scorer normalizes internally. Boundary thresholds in
45/// words are configurable so a profile can declare what "short" means for
46/// its target voice.
47#[derive(Debug, Clone, PartialEq)]
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub struct LengthDistribution {
50    /// Target proportion of sentences with word count `<= short_max_words`.
51    pub short: f32,
52    /// Target proportion of sentences with word count `<= medium_max_words`
53    /// but `> short_max_words`.
54    pub medium: f32,
55    /// Target proportion of sentences with word count `> medium_max_words`.
56    pub long: f32,
57    /// Inclusive upper bound, in words, that classifies a sentence as short.
58    pub short_max_words: u16,
59    /// Inclusive upper bound, in words, that classifies a sentence as
60    /// medium. Must be `>= short_max_words` (validated at construction).
61    pub medium_max_words: u16,
62}
63
64impl LengthDistribution {
65    /// Neutral distribution — uniform target with no shape preference.
66    /// Equivalent to "no profile target," and what `StyleProfile::neutral()`
67    /// returns. Boundary defaults (`short_max_words = 8`,
68    /// `medium_max_words = 18`) match the rhythm scorer's working ranges.
69    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    /// Returns `true` when this distribution is the neutral default —
80    /// the rhythm scorer's profile-aware path short-circuits when it is,
81    /// preserving byte-for-byte equivalence with the no-profile path.
82    pub fn is_neutral(&self) -> bool {
83        // Compare on raw bits to avoid f32 epsilon drift across rebuilds.
84        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/// Per-RST-relation connective preferences.
100///
101/// `allowed` restricts which connectives the engine may pick for a given
102/// RST relation. A missing key means the engine's default pool for that
103/// relation is used unmodified. An explicit empty `Vec` for a key is
104/// rejected at validation time — empty pools are a footgun that would
105/// silently force fallback every time.
106///
107/// `preferred` is a tie-breaker layer applied within whatever candidate
108/// set survives `allowed`-filtering: connectives that match the
109/// `preferred` list for a relation get their weights summed into the
110/// scorer; connectives without an entry score 0 (uniform). Weights are
111/// additive and do not normalize.
112///
113/// Family-budget enforcement (the existing trailing-window cap on emissions
114/// per connector family) runs unchanged. The profile narrows the candidate
115/// set; the budget governs rotation within whatever set survives.
116#[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    /// An empty preferences struct — every relation falls through to the
125    /// engine's default pool and uniform weights.
126    pub fn neutral() -> Self {
127        Self {
128            allowed: new_map(),
129            preferred: new_map(),
130        }
131    }
132
133    /// `true` when no relation has an explicit entry in either map.
134    pub fn is_neutral(&self) -> bool {
135        self.allowed.is_empty() && self.preferred.is_empty()
136    }
137}
138
139/// List-style cycle tiebreaker.
140///
141/// When the engine's `{items|join}` rotation has multiple candidates that
142/// haven't been used recently, this dial breaks the tie. `Auto` (default)
143/// preserves the existing rotation. The other variants nudge toward a
144/// specific opener while still respecting the anti-repeat rule — you cannot
145/// use a profile to force one style every time; that's what
146/// `{items|join:bracketed}` is for. The profile sets the prior, not the
147/// verdict.
148#[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/// Pronoun density dial — adjusts the threshold at which `{name|refer}`
161/// switches from full form to short form to pronoun.
162///
163/// `Low` keeps full forms longer (formal register). `High` switches to
164/// pronouns earlier (conversational register). Implementation is a small
165/// offset on the existing centering-theory transition rules; the rules
166/// themselves don't change.
167#[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/// Hedging calibration — shifts the deterministic confidence-to-hedge
178/// mapping.
179///
180/// `offset` is added to the input confidence (clamped to `0..=100`) before
181/// the bucket lookup. `forbid` removes specific hedges from the available
182/// set; per the resolved decision in the design spec, `forbid` falls
183/// through *toward more confident* — forbidding a hedge usually expresses
184/// "I don't want wishy-washy framing here," and the firmer-fallback
185/// matches that intent.
186#[derive(Debug, Clone, Default, PartialEq)]
187#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
188pub struct HedgingCalibration {
189    /// `-50..=+50` added to confidence before hedge mapping.
190    pub offset: i8,
191    /// Hedge words/phrases to never emit (case-insensitive). Match is
192    /// against the raw hedge string the engine would otherwise return
193    /// (`"perhaps"`, `"likely"`, `"it is certain that"`, etc.).
194    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/// Salience-threshold bias.
211///
212/// Composes with [`Verbosity`]; the documented composition order is
213/// `SalienceBias` runs first (changing tier classification), then
214/// `Verbosity` runs (preferring within-tier or cross-tier per its
215/// setting). `Lower` shifts the engine's salience thresholds *down* so
216/// the same numeric inputs land in *higher* tiers; `Higher` shifts them
217/// *up* so inputs land in *lower* tiers. Both produce qualitatively
218/// different prose without touching the underlying classification logic.
219#[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 thresholds → contexts land in higher tiers more often.
224    Lower,
225    #[default]
226    Auto,
227    /// Higher thresholds → contexts land in lower tiers more often.
228    Higher,
229}
230
231/// Validation error returned by [`StyleProfile::validate`] and
232/// `StyleProfileBuilder::build`.
233#[derive(Debug, Clone, PartialEq)]
234#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
235pub enum StyleProfileError {
236    /// `connectives.allowed[relation] = []` was set explicitly. An empty
237    /// pool would force fallback every time — almost always a footgun.
238    EmptyAllowedPool { relation: RstRelation },
239    /// `length_distribution.medium_max_words < length_distribution.short_max_words`.
240    InvalidLengthBoundaries {
241        short_max_words: u16,
242        medium_max_words: u16,
243    },
244    /// `hedging.offset` outside the documented `-50..=+50` range.
245    HedgingOffsetOutOfRange { offset: i8 },
246    /// Length-distribution proportion was negative or non-finite.
247    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/// A declarative voice configuration for the engine.
280///
281/// Profiles are immutable per engine. Build one through
282/// [`StyleProfile::builder`], a serde-deserialized `prosaic.toml`
283/// `[style_profile]` section, or one of the catalog presets. Apply via
284/// [`Engine::style_profile`](crate::Engine::style_profile).
285///
286/// `StyleProfile::neutral()` is the byte-for-byte-equivalent baseline:
287/// constructing an engine with `.style_profile(StyleProfile::neutral())`
288/// produces output identical to constructing it with no profile at all.
289/// This invariant is the backwards-compatibility gate for the entire
290/// design and is asserted by the round-trip tests at the workspace level.
291#[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    /// The byte-for-byte-equivalent baseline. Every dial in its neutral
307    /// position; constructing an engine with this profile produces the
308    /// same output as constructing it with no profile at all.
309    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    /// Start a builder rooted at `neutral()` with the given name.
323    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    /// `true` when this profile is byte-for-byte equivalent to the neutral
333    /// baseline — i.e., when every decision site can short-circuit its
334    /// profile-aware path. The `name` field is ignored: a custom name
335    /// applied to neutral dials is still an effective no-op.
336    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    /// Validate the profile against the documented invariants. Called by
347    /// the builder's `build()` and at the engine boundary; consumers
348    /// passing a deserialized profile should call this before applying
349    /// it to an engine.
350    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/// Builder for [`StyleProfile`].
389///
390/// Construct via [`StyleProfile::builder`] and chain dial setters; finalize
391/// with `build()`, which runs [`StyleProfile::validate`] before returning
392/// the profile so misconfigurations surface at construction rather than at
393/// render time.
394#[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    /// Append an `allowed`-pool entry for one RST relation.
416    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    /// Append a `preferred`-weight entry for one RST relation.
427    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    /// Validate and finalize. Returns [`StyleProfileError`] if any dial
468    /// violates the documented invariants.
469    pub fn build(self) -> Result<StyleProfile, StyleProfileError> {
470        self.profile.validate()?;
471        Ok(self.profile)
472    }
473
474    /// Validate-or-panic finalize; useful in const-style top-level catalogs
475    /// where a panic is preferable to a `?` chain. Production builders
476    /// should prefer [`Self::build`].
477    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        // Spec invariant: a profile whose dials are all at their neutral
498        // values is byte-for-byte equivalent to no profile, regardless of
499        // name. `is_neutral()` ignores name.
500        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        // Each dial moves out of neutral on its own, leaving the others
588        // alone — guards against accidentally coupled defaults.
589        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}