inkhaven 1.6.11

Inkhaven — TUI literary work editor for Typst books
//! INNER-THEOLOGIAN-1 (IT-P6) — the fast-track scan pipeline. Walks a book's
//! chapters, runs the deterministic detector (IT-P3) over each chapter's prose
//! with the Characters roster (CHAR-1), and persists the findings to
//! `inner_theologian.db`. Zero-AI; safe on the review-pass hot path.

use std::collections::HashMap;
use std::hash::{Hash, Hasher};

use anyhow::Result;

use crate::config::Config;
use crate::project::ProjectLayout;
use crate::prose::resolve_prose_language;
use crate::store::NodeKind;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::Node;

use super::detect::{DetectWindows, detect_chapter};
use super::store::TheologianStore;

fn chapters_of<'a>(h: &'a Hierarchy, book: &Node) -> Vec<&'a Node> {
    h.children_of(Some(book.id))
        .into_iter()
        .filter(|n| n.kind == NodeKind::Chapter)
        .collect()
}

/// A chapter's ordered prose paragraphs `(para_id, stripped_text)` — Jinja
/// excluded, Typst stripped (mirrors CHAR-1's walk).
fn chapter_paragraphs(layout: &ProjectLayout, h: &Hierarchy, chapter_id: uuid::Uuid) -> Vec<(String, String)> {
    let mut out = Vec::new();
    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;
        }
        if let Some(rel) = p.file.as_ref() {
            if let Ok(raw) = std::fs::read_to_string(layout.root.join(rel)) {
                out.push((id.to_string(), crate::audiobook::typst_to_plain(&raw)));
            }
        }
    }
    out
}

fn hash_str(s: &str) -> u64 {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    s.hash(&mut h);
    h.finish()
}

/// Run the fast-track scan across the whole book. Replaces each chapter's prior
/// findings. Returns the number of findings emitted.
pub(crate) fn run_fast_scan(
    store: &TheologianStore,
    layout: &ProjectLayout,
    h: &Hierarchy,
    cfg: &Config,
    book: &Node,
    win: DetectWindows,
    sacred_levity: bool,
) -> Result<usize> {
    let (lang, _note) = resolve_prose_language(None, &cfg.language);
    let roster = crate::character::character_names(h);
    let now = chrono::Utc::now().to_rfc3339();

    let mut count = 0;
    // INNER-GROUND-1 — mirror of the emitted findings, written into the WORLD-6
    // coherence store's reserved `theological` domain below (closing the loop).
    let mut mirror: Vec<crate::world::utopia::UtopiaFinding> = Vec::new();
    for (idx, ch) in chapters_of(h, book).iter().enumerate() {
        let ord = (idx + 1) as u32;
        let paras = chapter_paragraphs(layout, h, ch.id);
        let findings = detect_chapter(ord, &paras, &roster, &lang, win, sacred_levity);
        store.clear_chapter(&book.slug, ord)?;
        let hashes: HashMap<&str, u64> =
            paras.iter().map(|(id, t)| (id.as_str(), hash_str(t))).collect();
        for f in &findings {
            let th = hashes.get(f.para_id.as_str()).copied().unwrap_or(0);
            store.upsert_finding(&book.slug, f, th, &now)?;
            count += 1;
            mirror.push(crate::world::utopia::UtopiaFinding {
                finding_id: format!("theo:{}:{}:{}", ord, f.para_id, f.signal_type.as_code()),
                premise_group: THEOLOGICAL_GROUP.to_string(),
                finding_type: crate::world::utopia::FindingType::ConsequenceGap,
                finding_domain: crate::world::utopia::FindingDomain::Theological,
                description: f.description.clone(),
                evidence: None,
                chapter_ord: Some(ord),
                para_id: Some(f.para_id.clone()),
                suppressed: f.suppressed,
                grounded_by_research: false,
            });
        }
    }

    // Close the WORLD-6 → Inner Theologian loop: mirror the findings into the
    // world-coherence store's reserved `theological` domain (the theologian's own
    // store already holds the authoritative copy, so this is best-effort). Both
    // the utopian-architect's grounding and the theologian's own read that domain,
    // which was always empty until now. Replaces the prior mirror wholesale — this
    // is a whole-book pass.
    if let Ok(us) = crate::world::utopia::UtopiaStore::open(&layout.root) {
        if us
            .clear_group_findings_by_domain(
                &book.slug,
                THEOLOGICAL_GROUP,
                crate::world::utopia::FindingDomain::Theological,
            )
            .is_ok()
        {
            for uf in &mirror {
                let _ = us.upsert_finding(&book.slug, uf, &now, None);
            }
        }
    }
    Ok(count)
}

/// The synthetic premise-group the theologian's mirrored findings live under in
/// the WORLD-6 store (they aren't tied to a declared premise), so the whole set
/// can be cleared and rewritten per scan.
const THEOLOGICAL_GROUP: &str = "theological";