use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Track {
Fast,
Slow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Notice,
Inquiry,
Probe,
}
impl Severity {
pub fn pane_level(self) -> &'static str {
match self {
Severity::Notice => "info",
Severity::Inquiry => "warning",
Severity::Probe => "contradiction",
}
}
pub fn label(self) -> &'static str {
match self {
Severity::Notice => "Notice",
Severity::Inquiry => "Inquiry",
Severity::Probe => "Probe",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
ModalClaims,
HedgedUncertainty,
StructuralPatterns,
UnattributedDialogue,
PronounAmbiguity,
TenseVoiceShifts,
SentenceLengthAnomalies,
AssumptionSurfacing,
TensionDetection,
FramingInterrogation,
SignificanceProbing,
ImplicitComparison,
DramatizationGap,
ImplicationTracing,
TemporalDensity,
}
impl Category {
pub const FAST: [Category; 7] = [
Category::ModalClaims,
Category::HedgedUncertainty,
Category::StructuralPatterns,
Category::UnattributedDialogue,
Category::PronounAmbiguity,
Category::TenseVoiceShifts,
Category::SentenceLengthAnomalies,
];
pub const SLOW: [Category; 8] = [
Category::AssumptionSurfacing,
Category::TensionDetection,
Category::FramingInterrogation,
Category::SignificanceProbing,
Category::ImplicitComparison,
Category::DramatizationGap,
Category::ImplicationTracing,
Category::TemporalDensity,
];
pub fn id(self) -> &'static str {
match self {
Category::ModalClaims => "modal_claims",
Category::HedgedUncertainty => "hedged_uncertainty",
Category::StructuralPatterns => "structural_patterns",
Category::UnattributedDialogue => "unattributed_dialogue",
Category::PronounAmbiguity => "pronoun_ambiguity",
Category::TenseVoiceShifts => "tense_voice_shifts",
Category::SentenceLengthAnomalies => "sentence_length_anomalies",
Category::AssumptionSurfacing => "assumption_surfacing",
Category::TensionDetection => "tension_detection",
Category::FramingInterrogation => "framing_interrogation",
Category::SignificanceProbing => "significance_probing",
Category::ImplicitComparison => "implicit_comparison",
Category::DramatizationGap => "dramatization_gap",
Category::ImplicationTracing => "implication_tracing",
Category::TemporalDensity => "temporal_density",
}
}
pub fn label(self) -> &'static str {
match self {
Category::ModalClaims => "Asserted Necessity",
Category::HedgedUncertainty => "Hedging",
Category::StructuralPatterns => "Pattern",
Category::UnattributedDialogue => "Speaker",
Category::PronounAmbiguity => "Reference",
Category::TenseVoiceShifts => "Tense Shift",
Category::SentenceLengthAnomalies => "Length",
Category::AssumptionSurfacing => "Hidden Assumption",
Category::TensionDetection => "Internal Tension",
Category::FramingInterrogation => "Framing",
Category::SignificanceProbing => "Significance",
Category::ImplicitComparison => "Echo",
Category::DramatizationGap => "Dramatization Gap",
Category::ImplicationTracing => "Implication",
Category::TemporalDensity => "Temporal Density",
}
}
pub fn track(self) -> Track {
if Category::FAST.contains(&self) {
Track::Fast
} else {
Track::Slow
}
}
pub fn from_id(s: &str) -> Option<Category> {
Category::FAST
.into_iter()
.chain(Category::SLOW)
.find(|c| c.id() == s)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SocraticFinding {
pub category: Category,
pub severity: Severity,
pub persona_id: String,
pub question: String,
pub question_en: String,
pub suppressed_by: Option<String>,
}
impl SocraticFinding {
pub fn visible_at(&self, threshold: Severity) -> bool {
self.severity >= threshold
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Stance {
#[default]
Question,
Praise,
Concern,
}
impl Stance {
pub fn is_verdict(self) -> bool {
!matches!(self, Stance::Question)
}
pub fn from_id(s: &str) -> Option<Stance> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "question" | "socratic" => Some(Stance::Question),
"praise" | "defend" | "defender" | "defence" | "defense" => Some(Stance::Praise),
"concern" | "prosecute" | "prosecutor" | "prosecution" => Some(Stance::Concern),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Persona {
pub id: String,
pub name: String,
pub description: String,
pub voice_summary: String,
pub voice_notes: String,
pub emphasis: HashMap<Category, f32>,
pub stance: Stance,
}
impl Persona {
pub fn emphasis_for(&self, c: Category) -> f32 {
self.emphasis.get(&c).copied().unwrap_or(1.0)
}
pub fn mutes(&self, c: Category) -> bool {
self.emphasis_for(c) <= 0.0
}
pub fn default_inner_socrates() -> Persona {
let mut emphasis = HashMap::new();
emphasis.insert(Category::AssumptionSurfacing, 1.2);
emphasis.insert(Category::FramingInterrogation, 1.1);
emphasis.insert(Category::SignificanceProbing, 1.1);
Persona {
id: "inner-socrates".into(),
name: "Inner Socrates".into(),
description: "The classical interrogator: brief, direct, never reassuring, never \
prescriptive. Asks what the prose presupposes and what it leaves out."
.into(),
voice_summary: "Every question opens what the prose has closed.".into(),
voice_notes: String::new(),
emphasis,
stance: Stance::Question,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_orders_and_maps() {
assert!(Severity::Notice < Severity::Inquiry);
assert!(Severity::Inquiry < Severity::Probe);
assert_eq!(Severity::Notice.pane_level(), "info");
assert_eq!(Severity::Inquiry.pane_level(), "warning");
assert_eq!(Severity::Probe.pane_level(), "contradiction");
}
#[test]
fn categories_partition_into_tracks() {
assert_eq!(Category::FAST.len(), 7);
assert_eq!(Category::SLOW.len(), 8);
for c in Category::FAST {
assert_eq!(c.track(), Track::Fast, "{}", c.id());
}
for c in Category::SLOW {
assert_eq!(c.track(), Track::Slow, "{}", c.id());
}
}
#[test]
fn category_ids_roundtrip_and_are_unique() {
let all: Vec<Category> = Category::FAST.into_iter().chain(Category::SLOW).collect();
let mut ids: Vec<&str> = all.iter().map(|c| c.id()).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), 15, "all 15 ids distinct");
for c in all {
assert_eq!(Category::from_id(c.id()), Some(c));
}
assert_eq!(Category::from_id("not_a_category"), None);
}
#[test]
fn persona_emphasis_defaults_and_mutes() {
let p = Persona::default_inner_socrates();
assert_eq!(p.emphasis_for(Category::AssumptionSurfacing), 1.2);
assert_eq!(p.emphasis_for(Category::TenseVoiceShifts), 1.0); assert!(!p.mutes(Category::AssumptionSurfacing));
let mut muted = p.clone();
muted.emphasis.insert(Category::HedgedUncertainty, 0.0);
assert!(muted.mutes(Category::HedgedUncertainty));
}
#[test]
fn visibility_threshold() {
let f = SocraticFinding {
category: Category::ModalClaims,
severity: Severity::Notice,
persona_id: "inner-socrates".into(),
question: "?".into(),
question_en: "?".into(),
suppressed_by: None,
};
assert!(f.visible_at(Severity::Notice));
assert!(!f.visible_at(Severity::Inquiry));
}
}