use std::collections::{BTreeSet, HashMap};
use super::grants;
use super::walk::ParaRef;
use super::{
earliest_grant, Grant, GrantSource, KnowledgeFinding, KnowledgeItem, Severity, Use, UseVia,
};
use crate::config::Config;
use crate::prose::ProseLanguage;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;
pub(crate) fn known_topics(grants: &[Grant], secrets: &BTreeSet<String>) -> Vec<String> {
let mut set: BTreeSet<String> = grants.iter().map(|g| g.topic.clone()).collect();
set.extend(secrets.iter().cloned());
set.into_iter().collect()
}
fn concrete_pov(declared: &Option<String>) -> Option<String> {
let name = declared.as_deref()?.trim();
match name.to_lowercase().as_str() {
"" | "omniscient" | "omni" | "first" | "third" | "limited" => None,
_ => Some(grants::normalize_topic(name)),
}
}
pub(crate) fn detect_uses(
paras: &[ParaRef],
topics: &[String],
names: &[String],
lang: &ProseLanguage,
) -> Vec<Use> {
let convention = crate::dialogue::dialogue_convention(lang);
let lex = crate::dialogue::lexicon_for_with(lang, &[], &[]);
let windows = crate::dialogue::AttributionWindows::default();
let topics_lc: Vec<(String, String)> =
topics.iter().map(|t| (t.clone(), t.to_lowercase())).collect();
let mut uses = Vec::new();
let mut prev_named: Option<String> = None;
for p in paras {
let mut spans = crate::dialogue::detect_spans(&p.id.to_string(), &p.text, convention, lang);
crate::dialogue::attribute_spans(
&mut spans,
&p.text,
names,
prev_named.as_deref(),
lex,
lang,
windows,
);
for span in &spans {
let Some(speaker) = span.attribution_name.as_deref() else { continue };
let line_lc = span.speech_text.to_lowercase();
for (topic, topic_lc) in &topics_lc {
if crate::drift::mentions(&line_lc, topic_lc) {
uses.push(Use {
character: grants::normalize_topic(speaker),
topic: topic.clone(),
at: p.at,
via: UseVia::Dialogue,
anchor: p.id,
});
}
}
}
if let Some(last) = spans.iter().rev().find_map(|s| s.attribution_name.clone()) {
prev_named = Some(last);
}
if let Some(pov) = concrete_pov(&p.declared_pov) {
let text_lc = p.text.to_lowercase();
for (topic, topic_lc) in &topics_lc {
if crate::drift::mentions(&text_lc, topic_lc) {
uses.push(Use {
character: pov.clone(),
topic: topic.clone(),
at: p.at,
via: UseVia::Pov,
anchor: p.id,
});
}
}
}
}
uses
}
pub(crate) fn check(
grants: &[Grant],
uses: &[Use],
secrets: &BTreeSet<String>,
) -> Vec<KnowledgeFinding> {
let mut first: HashMap<(String, String), &Use> = HashMap::new();
for u in uses {
let premature = match earliest_grant(grants, &u.character, &u.topic) {
None => true,
Some(g) => g.at > u.at,
};
if !premature {
continue;
}
first
.entry((u.character.clone(), u.topic.clone()))
.and_modify(|e| {
if u.at < e.at {
*e = u;
}
})
.or_insert(u);
}
let mut out: Vec<KnowledgeFinding> = first
.into_values()
.map(|u| {
let grant = earliest_grant(grants, &u.character, &u.topic);
let kind = if secrets.contains(&u.topic) { "leaked_secret" } else { "premature_knowledge" };
let verb = match u.via {
UseVia::Dialogue => "speaks of",
UseVia::Pov => "references",
};
let message = match grant {
Some(g) => format!(
"{} {verb} \u{201c}{}\u{201d} in ch. {} — before learning it in ch. {}",
u.character, u.topic, u.at.chapter_ord, g.at.chapter_ord
),
None => format!(
"{} {verb} \u{201c}{}\u{201d} in ch. {} — never established to know it",
u.character, u.topic, u.at.chapter_ord
),
};
KnowledgeFinding {
kind,
severity: Severity::Break,
chapter: u.at.chapter_ord,
anchor: Some(u.anchor),
character: u.character.clone(),
topic: u.topic.clone(),
message,
}
})
.collect();
out.sort_by(|a, b| {
a.chapter
.cmp(&b.chapter)
.then(a.character.cmp(&b.character))
.then(a.topic.cmp(&b.topic))
});
out
}
pub(crate) fn dropped_reveals(grants: &[Grant], uses: &[Use]) -> Vec<KnowledgeFinding> {
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut out = Vec::new();
for g in grants.iter().filter(|g| g.source == GrantSource::Declared) {
if !seen.insert((g.character.clone(), g.topic.clone())) {
continue;
}
let surfaces = uses.iter().any(|u| u.topic == g.topic && u.at >= g.at);
if surfaces {
continue;
}
out.push(KnowledgeFinding {
kind: "dropped_reveal",
severity: Severity::Notice,
chapter: g.at.chapter_ord,
anchor: g.anchor,
character: g.character.clone(),
topic: g.topic.clone(),
message: format!(
"{} is told \u{201c}{}\u{201d} in ch. {} — it never surfaces again",
g.character, g.topic, g.at.chapter_ord
),
});
}
out
}
pub(crate) fn run(
layout: &ProjectLayout,
h: &Hierarchy,
cfg: &Config,
book: &Node,
) -> Vec<KnowledgeFinding> {
let (grants, items, paras) = grants::build_grants(layout, h, book);
let secrets: BTreeSet<String> =
items.iter().filter(|i: &&KnowledgeItem| i.secret).map(|i| i.topic.clone()).collect();
let topics = known_topics(&grants, &secrets);
if topics.is_empty() {
return Vec::new();
}
let lang = ProseLanguage::from_label(&cfg.language);
let names = crate::dialogue::character_names(h);
let uses = detect_uses(¶s, &topics, &names, &lang);
let mut findings = check(&grants, &uses, &secrets);
findings.extend(dropped_reveals(&grants, &uses));
findings.sort_by(|a, b| {
b.severity
.rank()
.cmp(&a.severity.rank())
.then(a.chapter.cmp(&b.chapter))
.then(a.character.cmp(&b.character))
.then(a.topic.cmp(&b.topic))
});
findings
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::{GrantSource, ScenePos};
use uuid::Uuid;
fn pos(ch: u32, sc: u32) -> ScenePos {
ScenePos { chapter_ord: ch, scene_index: sc }
}
fn grant(c: &str, t: &str, ch: u32) -> Grant {
Grant {
character: c.into(),
topic: t.into(),
at: pos(ch, 1),
source: GrantSource::Declared,
anchor: Some(Uuid::from_u128(1000 + ch as u128)),
}
}
fn usage(c: &str, t: &str, ch: u32) -> Use {
Use { character: c.into(), topic: t.into(), at: pos(ch, 1), via: UseVia::Dialogue, anchor: Uuid::from_u128(ch as u128) }
}
#[test]
fn flags_use_before_grant_and_never_granted() {
let grants = vec![grant("Mara", "the betrayal", 7)];
let uses = vec![
usage("Mara", "the betrayal", 4), usage("Bob", "the murder", 3), ];
let out = check(&grants, &uses, &BTreeSet::new());
assert_eq!(out.len(), 2);
let mara = out.iter().find(|f| f.character == "Mara").unwrap();
assert_eq!(mara.kind, "premature_knowledge");
assert_eq!(mara.chapter, 4);
assert!(mara.message.contains("before learning it in ch. 7"));
let bob = out.iter().find(|f| f.character == "Bob").unwrap();
assert!(bob.message.contains("never established"));
}
#[test]
fn granted_before_or_at_use_is_silent() {
let grants = vec![grant("Mara", "the betrayal", 4)];
let uses = vec![usage("Mara", "the betrayal", 4), usage("Mara", "the betrayal", 9)];
assert!(check(&grants, &uses, &BTreeSet::new()).is_empty());
}
#[test]
fn secret_topic_escalates_to_leaked_secret_and_dedups_to_earliest() {
let secrets: BTreeSet<String> = ["the heir".to_string()].into_iter().collect();
let uses = vec![usage("Sella", "the heir", 6), usage("Sella", "the heir", 5)];
let out = check(&[], &uses, &secrets);
assert_eq!(out.len(), 1, "deduped to one finding per (character, topic)");
assert_eq!(out[0].kind, "leaked_secret");
assert_eq!(out[0].chapter, 5, "the earliest premature use");
assert_eq!(out[0].severity, Severity::Break);
}
#[test]
fn dropped_reveal_flags_a_declared_topic_that_never_surfaces() {
let grants = vec![
grant("Mara", "the betrayal", 7), grant("Joren", "the map", 3), ];
let uses = vec![usage("Mara", "the betrayal", 9)];
let out = dropped_reveals(&grants, &uses);
assert_eq!(out.len(), 1);
assert_eq!(out[0].kind, "dropped_reveal");
assert_eq!((out[0].character.as_str(), out[0].topic.as_str()), ("Joren", "the map"));
assert_eq!(out[0].severity, Severity::Notice);
assert!(out[0].anchor.is_some());
}
#[test]
fn dropped_reveal_ignores_presence_grants() {
let g = Grant {
character: "Alice".into(),
topic: "the murder".into(),
at: pos(6, 1),
source: GrantSource::Presence, anchor: Some(Uuid::from_u128(1)),
};
assert!(dropped_reveals(&[g], &[]).is_empty());
}
#[test]
fn known_topics_unions_grants_and_secrets_deduped() {
let grants = vec![grant("A", "x", 1), grant("B", "x", 1), grant("A", "y", 1)];
let secrets: BTreeSet<String> = ["y".to_string(), "z".to_string()].into_iter().collect();
assert_eq!(known_topics(&grants, &secrets), vec!["x", "y", "z"]);
}
#[test]
fn detect_uses_finds_the_dialogue_speaker_and_the_pov_reference() {
use super::super::walk::ParaRef;
let lang = crate::prose::ProseLanguage::En;
let paras = vec![
ParaRef {
id: Uuid::from_u128(1),
at: pos(4, 1),
tags: vec![],
text: "\"I heard about the betrayal,\" Bob said, going pale.".into(),
declared_pov: None,
},
ParaRef {
id: Uuid::from_u128(2),
at: pos(5, 1),
tags: vec![],
text: "The betrayal haunted her all through the night.".into(),
declared_pov: Some("Mara".into()),
},
];
let uses = detect_uses(
¶s,
&["the betrayal".to_string()],
&["Bob".to_string(), "Mara".to_string()],
&lang,
);
assert!(
uses.iter().any(|u| u.character == "Bob" && u.topic == "the betrayal" && u.via == UseVia::Dialogue),
"Bob's spoken use of the topic is detected: {uses:?}"
);
assert!(
uses.iter().any(|u| u.character == "Mara" && u.topic == "the betrayal" && u.via == UseVia::Pov),
"Mara's POV reference is detected: {uses:?}"
);
}
#[test]
fn concrete_pov_skips_reserved_markers() {
assert_eq!(concrete_pov(&Some("Mara".into())).as_deref(), Some("Mara"));
assert!(concrete_pov(&Some("omniscient".into())).is_none());
assert!(concrete_pov(&Some("first".into())).is_none());
assert!(concrete_pov(&None).is_none());
}
}