mod vocab;
use crate::config::{Config, RigorConfig};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RigorSignal {
FalseDichotomy,
QuestionBegging,
StrawMan,
Overgeneralization,
NonSequitur,
Equivocation,
}
impl RigorSignal {
pub fn as_code(self) -> &'static str {
match self {
RigorSignal::FalseDichotomy => "false-dichotomy",
RigorSignal::QuestionBegging => "question-begging",
RigorSignal::StrawMan => "straw-man",
RigorSignal::Overgeneralization => "overgeneralization",
RigorSignal::NonSequitur => "non-sequitur",
RigorSignal::Equivocation => "equivocation",
}
}
pub fn from_code(code: &str) -> Option<RigorSignal> {
Some(match code {
"false-dichotomy" => RigorSignal::FalseDichotomy,
"question-begging" => RigorSignal::QuestionBegging,
"straw-man" => RigorSignal::StrawMan,
"overgeneralization" => RigorSignal::Overgeneralization,
"non-sequitur" => RigorSignal::NonSequitur,
"equivocation" => RigorSignal::Equivocation,
_ => return None,
})
}
pub fn label(self) -> &'static str {
match self {
RigorSignal::FalseDichotomy => "false dichotomy",
RigorSignal::QuestionBegging => "question-begging",
RigorSignal::StrawMan => "straw man",
RigorSignal::Overgeneralization => "overgeneralization",
RigorSignal::NonSequitur => "non-sequitur",
RigorSignal::Equivocation => "equivocation",
}
}
}
#[derive(Debug, Clone)]
pub struct WatchedTerm {
pub term: String,
pub forms: Vec<String>,
}
pub fn watched_terms_from_glossary(entries: &[crate::glossary::GlossaryEntry]) -> Vec<WatchedTerm> {
entries
.iter()
.filter(|e| e.is_valid() && e.is_equivocation_watched())
.map(|e| WatchedTerm { term: e.term.trim().to_string(), forms: e.surface_forms() })
.collect()
}
#[derive(Debug, Clone)]
pub struct RigorFinding {
pub signal: RigorSignal,
pub para_id: String,
pub chapter_ord: u32,
pub description: String,
}
#[derive(Debug, Clone, Copy)]
pub struct RigorCats {
pub false_dichotomy: bool,
pub question_begging: bool,
pub straw_man: bool,
pub overgeneralization: bool,
pub non_sequitur: bool,
pub equivocation: bool,
}
impl RigorCats {
pub fn from_config(cfg: &RigorConfig) -> RigorCats {
RigorCats {
false_dichotomy: cfg.false_dichotomy,
question_begging: cfg.question_begging,
straw_man: cfg.straw_man,
overgeneralization: cfg.overgeneralization,
non_sequitur: cfg.non_sequitur,
equivocation: cfg.equivocation,
}
}
#[cfg(test)]
pub fn all() -> RigorCats {
RigorCats {
false_dichotomy: true,
question_begging: true,
straw_man: true,
overgeneralization: true,
non_sequitur: true,
equivocation: true,
}
}
}
pub fn detect_paragraph(
text: &str,
lang: &crate::prose::ProseLanguage,
cats: &RigorCats,
watched: &[WatchedTerm],
) -> Vec<(RigorSignal, String)> {
let lc = text.to_lowercase();
let v = vocab::lists_for(lang);
let t = vocab::text_for(lang);
let mut out = Vec::new();
if cats.false_dichotomy {
if let Some(cue) = vocab::matches_pair(&lc, v.either_or) {
out.push((RigorSignal::FalseDichotomy, t.false_dichotomy.replace("{cue}", &cue)));
} else if let Some(cue) = vocab::matches_any(&lc, v.dichotomy) {
out.push((RigorSignal::FalseDichotomy, t.false_dichotomy.replace("{cue}", cue)));
}
}
if cats.question_begging {
if let Some(cue) = vocab::matches_any(&lc, v.assertion) {
out.push((RigorSignal::QuestionBegging, t.question_begging.replace("{cue}", cue)));
}
}
if cats.straw_man {
if let Some(cue) = vocab::matches_any(&lc, v.straw_man) {
out.push((RigorSignal::StrawMan, t.straw_man.replace("{cue}", cue)));
}
}
if cats.overgeneralization {
if let Some(cue) = vocab::matches_any(&lc, v.overgeneralization) {
out.push((RigorSignal::Overgeneralization, t.overgeneralization.replace("{cue}", cue)));
}
}
if cats.non_sequitur {
if let Some(cue) = vocab::matches_any(&lc, v.conclusion) {
if vocab::matches_any(&lc, v.warrant).is_none() {
out.push((RigorSignal::NonSequitur, t.non_sequitur.replace("{cue}", cue)));
}
}
}
if cats.equivocation {
for wt in watched {
let uses: usize = wt.forms.iter().map(|f| vocab::count_word(&lc, f)).sum();
if uses >= 2 {
out.push((RigorSignal::Equivocation, t.equivocation.replace("{cue}", &wt.term)));
}
}
}
out
}
pub fn scan_book(
layout: &ProjectLayout,
h: &Hierarchy,
cfg: &Config,
book: &Node,
watched: &[WatchedTerm],
) -> Vec<RigorFinding> {
let (lang, _note) = crate::prose::resolve_prose_language(cfg.rigor.language.as_deref(), &cfg.language);
let cats = RigorCats::from_config(&cfg.rigor);
let mut out = Vec::new();
let chapters: Vec<&Node> = h
.children_of(Some(book.id))
.into_iter()
.filter(|n| n.kind == NodeKind::Chapter)
.collect();
for (idx, chapter) in chapters.iter().enumerate() {
let ord = idx as u32 + 1;
for id in h.collect_subtree(chapter.id) {
let Some(p) = h.get(id) else { continue };
if p.kind != NodeKind::Paragraph || p.content_type.as_deref() == Some("jinja") {
continue;
}
let Some(rel) = p.file.as_ref() else { continue };
let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) else { continue };
let plain = crate::audiobook::typst_to_plain(&raw);
for (signal, description) in detect_paragraph(&plain, &lang, &cats, watched) {
out.push(RigorFinding { signal, para_id: id.to_string(), chapter_ord: ord, description });
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prose::ProseLanguage;
fn en(text: &str) -> Vec<(RigorSignal, String)> {
detect_paragraph(text, &ProseLanguage::En, &RigorCats::all(), &[])
}
fn watched(term: &str) -> WatchedTerm {
WatchedTerm { term: term.into(), forms: vec![term.to_lowercase()] }
}
fn sigs(text: &str) -> Vec<RigorSignal> {
en(text).into_iter().map(|(s, _)| s).collect()
}
#[test]
fn detects_each_category() {
assert!(sigs("It must be either freedom or determinism.").contains(&RigorSignal::FalseDichotomy));
assert!(sigs("Obviously the soul is immortal.").contains(&RigorSignal::QuestionBegging));
assert!(sigs("The so-called realist misses the point.").contains(&RigorSignal::StrawMan));
assert!(sigs("Philosophers never agree on anything.").contains(&RigorSignal::Overgeneralization));
assert!(sigs("The tower is tall. Therefore the architect was wise.").contains(&RigorSignal::NonSequitur));
}
#[test]
fn non_sequitur_suppressed_when_a_warrant_is_present() {
let s = sigs("The architect was wise, because the tower still stands; therefore we trust him.");
assert!(!s.contains(&RigorSignal::NonSequitur));
}
#[test]
fn clean_prose_is_silent() {
assert!(en("The tower stood for three centuries and drew many pilgrims.").is_empty());
}
#[test]
fn whole_word_only_no_false_positive() {
assert!(!sigs("The corridor was narrow.").contains(&RigorSignal::FalseDichotomy));
}
#[test]
fn category_toggle_respected() {
let cats = RigorCats { question_begging: false, ..RigorCats::all() };
let s: Vec<_> = detect_paragraph("Obviously true.", &ProseLanguage::En, &cats, &[])
.into_iter()
.map(|(s, _)| s)
.collect();
assert!(!s.contains(&RigorSignal::QuestionBegging));
}
#[test]
fn russian_markers_detected_and_localized() {
let out = detect_paragraph(
"Очевидно, что душа бессмертна.",
&ProseLanguage::Ru,
&RigorCats::all(),
&[],
);
let qb = out.iter().find(|(s, _)| *s == RigorSignal::QuestionBegging).expect("question-begging");
assert!(qb.1.contains("самоочевидное") && qb.1.contains("очевидно"));
}
#[test]
fn equivocation_flags_repeated_watched_term() {
let terms = [watched("reason")];
let out = detect_paragraph(
"Pure reason demands the unconditioned, so reason requires an object beyond sense.",
&ProseLanguage::En,
&RigorCats::all(),
&terms,
);
let eq = out.iter().find(|(s, _)| *s == RigorSignal::Equivocation).expect("equivocation");
assert!(eq.1.contains("reason"));
let single = detect_paragraph("Reason is a faculty.", &ProseLanguage::En, &RigorCats::all(), &terms);
assert!(!single.iter().any(|(s, _)| *s == RigorSignal::Equivocation));
}
#[test]
fn equivocation_needs_a_watched_multi_sense_term() {
let out = en("Reason and reason and reason again.");
assert!(!out.iter().any(|(s, _)| *s == RigorSignal::Equivocation));
}
#[test]
fn watched_terms_filters_to_multi_sense_watched() {
use crate::glossary::{GlossaryEntry, Sense};
let s = |g: &str| Sense { label: String::new(), gloss: g.into() };
let entries = vec![
GlossaryEntry {
term: "reason".into(),
senses: vec![s("Vernunft"), s("Verstand")],
watch_equivocation: true,
..Default::default()
},
GlossaryEntry {
term: "grace".into(),
senses: vec![s("favor")],
watch_equivocation: true,
..Default::default()
},
GlossaryEntry {
term: "being".into(),
senses: vec![s("existence"), s("essence")],
watch_equivocation: false,
..Default::default()
},
];
let w = watched_terms_from_glossary(&entries);
assert_eq!(w.len(), 1);
assert_eq!(w[0].term, "reason");
}
#[test]
fn signal_codes_round_trip() {
for s in [
RigorSignal::FalseDichotomy,
RigorSignal::QuestionBegging,
RigorSignal::StrawMan,
RigorSignal::Overgeneralization,
RigorSignal::NonSequitur,
RigorSignal::Equivocation,
] {
assert_eq!(RigorSignal::from_code(s.as_code()), Some(s));
}
assert_eq!(RigorSignal::from_code("nope"), None);
}
}