use std::path::Path;
use std::sync::Arc;
use anyhow::Result;
use duckdb::types::Value as DuckValue;
use uuid::Uuid;
use crate::storage::engine::StorageEngine;
use crate::world::proposals::now_secs;
use super::intent::{IntentEntry, IntentKind, IntentLedger, IntentScope, ScopeLevel};
use super::types::{Category, Severity, SocraticFinding, Track};
const INIT_SQL: &str = "
CREATE TABLE IF NOT EXISTS socratic_findings (
id TEXT NOT NULL PRIMARY KEY,
paragraph_id TEXT,
chapter_id TEXT,
track TEXT NOT NULL,
category TEXT NOT NULL,
severity TEXT NOT NULL,
persona_id TEXT NOT NULL,
question TEXT NOT NULL,
question_en TEXT NOT NULL,
suppressed_by TEXT,
emitted_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sf_para ON socratic_findings(paragraph_id);
-- Dismissals — each time the author dismisses a finding (the raw material the
-- promotion mechanism aggregates).
CREATE TABLE IF NOT EXISTS socratic_dismissals (
id TEXT NOT NULL PRIMARY KEY,
category TEXT NOT NULL,
chapter_id TEXT NOT NULL,
dismissed_at BIGINT NOT NULL
);
-- Promotion suggestions the author refused (won't re-suggest).
CREATE TABLE IF NOT EXISTS socratic_promotion_refused (
category TEXT NOT NULL,
chapter_id TEXT NOT NULL,
PRIMARY KEY (category, chapter_id)
);
-- The active Reader Persona for this project (one row).
CREATE TABLE IF NOT EXISTS active_persona (
singleton INTEGER NOT NULL PRIMARY KEY DEFAULT 1,
persona_id TEXT NOT NULL,
selected_at BIGINT NOT NULL
);
-- Slow-track LLM usage, per day per sub-budget (RFC §3.14 — separate from
-- WORLD-4's tally).
CREATE TABLE IF NOT EXISTS inner_socrates_llm_usage (
day TEXT NOT NULL,
sub_budget TEXT NOT NULL,
calls BIGINT NOT NULL,
PRIMARY KEY (day, sub_budget)
);
-- The intent ledger — deliberate authorial choices the interrogator respects.
CREATE TABLE IF NOT EXISTS intent_entries (
id TEXT NOT NULL PRIMARY KEY,
kind TEXT NOT NULL,
description TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_data TEXT NOT NULL,
coverage TEXT NOT NULL,
scope_level TEXT NOT NULL,
created_at BIGINT NOT NULL
);
";
fn text(v: Option<&DuckValue>) -> String {
match v {
Some(DuckValue::Text(s)) => s.clone(),
_ => String::new(),
}
}
fn opt_text(v: Option<&DuckValue>) -> Option<String> {
match v {
Some(DuckValue::Text(s)) if !s.is_empty() => Some(s.clone()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PromotionCandidate {
pub category: Category,
pub chapter_id: String,
pub count: i64,
}
#[derive(Debug, Clone)]
pub struct RawIntentRow {
pub kind: String,
pub description: String,
pub scope: IntentScope,
pub coverage: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct StoredFinding {
pub id: Uuid,
pub paragraph_id: Option<Uuid>,
pub finding: SocraticFinding,
}
#[derive(Clone)]
pub struct InnerSocratesStore {
engine: Arc<StorageEngine>,
}
impl InnerSocratesStore {
pub fn open(path: &Path) -> Result<Self> {
Ok(Self { engine: Arc::new(StorageEngine::new(path, INIT_SQL, 2)?) })
}
pub fn open_for_project(project_root: &Path) -> Result<Self> {
Self::open(&project_root.join("inner_socrates.db"))
}
pub fn insert_finding(
&self,
f: &SocraticFinding,
paragraph_id: Option<Uuid>,
chapter_id: Option<&str>,
) -> Result<Uuid> {
let id = Uuid::new_v4();
let track = match f.category.track() {
Track::Fast => "fast",
Track::Slow => "slow",
};
self.engine.execute_with(
"INSERT INTO socratic_findings \
(id, paragraph_id, chapter_id, track, category, severity, persona_id, \
question, question_en, suppressed_by, emitted_at) \
VALUES (?,?,?,?,?,?,?,?,?,?,?)",
&[
&id.to_string(),
¶graph_id.map(|p| p.to_string()).unwrap_or_default(),
&chapter_id.unwrap_or_default(),
&track,
&f.category.id(),
&severity_id(f.severity),
&f.persona_id,
&f.question,
&f.question_en,
&f.suppressed_by.clone().unwrap_or_default(),
&now_secs(),
],
)?;
Ok(id)
}
pub fn clear_findings_for_paragraph(&self, paragraph_id: Uuid) -> Result<()> {
self.engine.execute_with(
"DELETE FROM socratic_findings WHERE paragraph_id = ?",
&[¶graph_id.to_string()],
)
}
pub fn list_findings(&self) -> Result<Vec<StoredFinding>> {
let rows = self.engine.select_all(
"SELECT id, paragraph_id, category, severity, persona_id, question, question_en, suppressed_by \
FROM socratic_findings ORDER BY emitted_at DESC, id",
)?;
Ok(rows
.iter()
.filter_map(|r| {
let category = Category::from_id(&text(r.get(2)))?;
Some(StoredFinding {
id: Uuid::parse_str(&text(r.first())).ok()?,
paragraph_id: opt_text(r.get(1)).and_then(|s| Uuid::parse_str(&s).ok()),
finding: SocraticFinding {
category,
severity: severity_from_id(&text(r.get(3))),
persona_id: text(r.get(4)),
question: text(r.get(5)),
question_en: text(r.get(6)),
suppressed_by: opt_text(r.get(7)),
},
})
})
.collect())
}
pub fn findings_history(&self, paragraph_id: Uuid) -> Result<Vec<(i64, SocraticFinding)>> {
let rows = self.engine.select_all_with(
"SELECT emitted_at, category, severity, persona_id, question, question_en, suppressed_by \
FROM socratic_findings WHERE paragraph_id = ? ORDER BY emitted_at ASC, id",
&[¶graph_id.to_string()],
)?;
Ok(rows
.iter()
.filter_map(|r| {
let category = Category::from_id(&text(r.get(1)))?;
Some((
int(r.first()),
SocraticFinding {
category,
severity: severity_from_id(&text(r.get(2))),
persona_id: text(r.get(3)),
question: text(r.get(4)),
question_en: text(r.get(5)),
suppressed_by: opt_text(r.get(6)),
},
))
})
.collect())
}
pub fn add_intent(&self, e: &IntentEntry) -> Result<()> {
let (scope_type, scope_data) = scope_to_row(&e.scope);
let coverage = serde_json::to_string(&e.coverage.iter().map(|c| c.id()).collect::<Vec<_>>())
.unwrap_or_else(|_| "[]".into());
self.engine.execute_with(
"INSERT OR REPLACE INTO intent_entries \
(id, kind, description, scope_type, scope_data, coverage, scope_level, created_at) \
VALUES (?,?,?,?,?,?,?,?)",
&[
&e.id,
&e.kind.id(),
&e.description,
&scope_type,
&scope_data,
&coverage,
&scope_level_id(e.scope_level),
&now_secs(),
],
)
}
#[allow(clippy::too_many_arguments)]
pub fn add_intent_raw(
&self,
id: &str,
kind: &IntentKind,
description: &str,
scope: &IntentScope,
coverage: &[String],
scope_level: ScopeLevel,
) -> Result<()> {
let (scope_type, scope_data) = scope_to_row(scope);
let coverage = serde_json::to_string(coverage).unwrap_or_else(|_| "[]".into());
self.engine.execute_with(
"INSERT OR REPLACE INTO intent_entries \
(id, kind, description, scope_type, scope_data, coverage, scope_level, created_at) \
VALUES (?,?,?,?,?,?,?,?)",
&[
&id,
&kind.id(),
&description,
&scope_type,
&scope_data,
&coverage,
&scope_level_id(scope_level),
&now_secs(),
],
)
}
pub fn remove_intent(&self, id: &str) -> Result<()> {
self.engine.execute_with("DELETE FROM intent_entries WHERE id = ?", &[&id])
}
pub fn list_intents(&self) -> Result<Vec<IntentEntry>> {
let rows = self.engine.select_all(
"SELECT id, kind, description, scope_type, scope_data, coverage, scope_level \
FROM intent_entries ORDER BY created_at DESC, id",
)?;
Ok(rows
.iter()
.filter_map(|r| {
let kind = IntentKind::from_id(&text(r.get(1)))?;
let scope = row_to_scope(&text(r.get(3)), &text(r.get(4)))?;
let coverage: Vec<Category> =
serde_json::from_str::<Vec<String>>(&text(r.get(5)))
.unwrap_or_default()
.iter()
.filter_map(|s| Category::from_id(s))
.collect();
Some(IntentEntry {
id: text(r.first()),
kind,
description: text(r.get(2)),
scope,
coverage,
scope_level: scope_level_from_id(&text(r.get(6))),
})
})
.collect())
}
pub fn load_ledger(&self) -> Result<IntentLedger> {
Ok(IntentLedger { entries: self.list_intents()? })
}
pub fn list_intent_rows_raw(&self) -> Result<Vec<RawIntentRow>> {
let rows = self.engine.select_all(
"SELECT kind, description, scope_type, scope_data, coverage \
FROM intent_entries ORDER BY created_at DESC, id",
)?;
Ok(rows
.iter()
.filter_map(|r| {
let scope = row_to_scope(&text(r.get(2)), &text(r.get(3)))?;
let coverage =
serde_json::from_str::<Vec<String>>(&text(r.get(4))).unwrap_or_default();
Some(RawIntentRow {
kind: text(r.first()),
description: text(r.get(1)),
scope,
coverage,
})
})
.collect())
}
pub fn record_dismissal(&self, category: Category, chapter_id: &str) -> Result<()> {
self.engine.execute_with(
"INSERT INTO socratic_dismissals (id, category, chapter_id, dismissed_at) VALUES (?,?,?,?)",
&[&Uuid::new_v4().to_string(), &category.id(), &chapter_id, &now_secs()],
)
}
pub fn refuse_promotion(&self, category: Category, chapter_id: &str) -> Result<()> {
self.engine.execute_with(
"INSERT OR REPLACE INTO socratic_promotion_refused (category, chapter_id) VALUES (?,?)",
&[&category.id(), &chapter_id],
)
}
pub fn promotion_candidates(&self, threshold: i64) -> Result<Vec<PromotionCandidate>> {
let rows = self.engine.select_all_with(
"SELECT d.category, d.chapter_id, COUNT(*) AS n \
FROM socratic_dismissals d \
WHERE NOT EXISTS ( \
SELECT 1 FROM socratic_promotion_refused r \
WHERE r.category = d.category AND r.chapter_id = d.chapter_id) \
GROUP BY d.category, d.chapter_id \
HAVING COUNT(*) >= ? \
ORDER BY n DESC",
&[&threshold],
)?;
Ok(rows
.iter()
.filter_map(|r| {
Some(PromotionCandidate {
category: Category::from_id(&text(r.first()))?,
chapter_id: text(r.get(1)),
count: int(r.get(2)),
})
})
.collect())
}
pub fn active_persona_id(&self) -> Result<Option<String>> {
let rows = self.engine.select_all("SELECT persona_id FROM active_persona WHERE singleton = 1")?;
Ok(rows.first().map(|r| text(r.first())).filter(|s| !s.is_empty()))
}
pub fn set_active_persona(&self, persona_id: &str) -> Result<()> {
self.engine.execute_with(
"INSERT OR REPLACE INTO active_persona (singleton, persona_id, selected_at) VALUES (1, ?, ?)",
&[&persona_id, &now_secs()],
)
}
pub const DAILY_CALL_CAP: i64 = 150;
pub const SLOW_SUB_BUDGET: &'static str = "slow_track";
pub fn record_llm_call(&self, day: &str, sub_budget: &str) -> Result<i64> {
self.engine.execute_with(
"INSERT INTO inner_socrates_llm_usage (day, sub_budget, calls) VALUES (?, ?, 1) \
ON CONFLICT (day, sub_budget) DO UPDATE SET calls = calls + 1",
&[&day, &sub_budget],
)?;
self.llm_calls_today(day, sub_budget)
}
pub fn llm_calls_today(&self, day: &str, sub_budget: &str) -> Result<i64> {
let rows = self.engine.select_all_with(
"SELECT calls FROM inner_socrates_llm_usage WHERE day = ? AND sub_budget = ?",
&[&day, &sub_budget],
)?;
Ok(rows.first().map(|r| int(r.first())).unwrap_or(0))
}
pub fn llm_usage_today(&self, day: &str) -> Result<Vec<(String, i64)>> {
let rows = self.engine.select_all_with(
"SELECT sub_budget, calls FROM inner_socrates_llm_usage WHERE day = ? ORDER BY sub_budget",
&[&day],
)?;
Ok(rows.iter().map(|r| (text(r.get(0)), int(r.get(1)))).collect())
}
}
fn int(v: Option<&DuckValue>) -> i64 {
match v {
Some(DuckValue::BigInt(i)) => *i,
Some(DuckValue::Int(i)) => *i as i64,
Some(DuckValue::HugeInt(i)) => *i as i64,
_ => 0,
}
}
fn severity_id(s: Severity) -> &'static str {
match s {
Severity::Notice => "notice",
Severity::Inquiry => "inquiry",
Severity::Probe => "probe",
}
}
fn severity_from_id(s: &str) -> Severity {
match s {
"probe" => Severity::Probe,
"inquiry" => Severity::Inquiry,
_ => Severity::Notice,
}
}
fn scope_level_id(s: ScopeLevel) -> &'static str {
match s {
ScopeLevel::Project => "project",
ScopeLevel::Series => "series",
}
}
fn scope_level_from_id(s: &str) -> ScopeLevel {
match s {
"series" => ScopeLevel::Series,
_ => ScopeLevel::Project,
}
}
fn scope_to_row(scope: &IntentScope) -> (String, String) {
use serde_json::json;
let (ty, data) = match scope {
IntentScope::Project => ("project", json!({})),
IntentScope::Chapter(c) => ("chapter", json!({ "chapter": c })),
IntentScope::ParagraphRange { from, to } => {
("paragraph_range", json!({ "from": from, "to": to }))
}
IntentScope::Character(id) => ("character", json!({ "character": id })),
IntentScope::Scene(s) => ("scene", json!({ "scene": s })),
IntentScope::TimelineRange { from, to } => {
("timeline_range", json!({ "from": from, "to": to }))
}
};
(ty.to_string(), data.to_string())
}
fn row_to_scope(scope_type: &str, scope_data: &str) -> Option<IntentScope> {
let v: serde_json::Value = serde_json::from_str(scope_data).unwrap_or(serde_json::json!({}));
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
Some(match scope_type {
"project" => IntentScope::Project,
"chapter" => IntentScope::Chapter(s("chapter")),
"paragraph_range" => IntentScope::ParagraphRange { from: s("from"), to: s("to") },
"character" => IntentScope::Character(s("character")),
"scene" => IntentScope::Scene(s("scene")),
"timeline_range" => IntentScope::TimelineRange { from: s("from"), to: s("to") },
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> InnerSocratesStore {
InnerSocratesStore::open(Path::new(":memory:")).unwrap()
}
fn finding() -> SocraticFinding {
SocraticFinding {
category: Category::ModalClaims,
severity: Severity::Inquiry,
persona_id: "inner-socrates".into(),
question: "What alternatives did you leave out?".into(),
question_en: "What alternatives did you leave out?".into(),
suppressed_by: None,
}
}
#[test]
fn terms_deliberate_variant_intent_round_trips() {
use crate::inner_socrates::intent::{IntentKind, IntentScope, ScopeLevel};
let s = store();
s.add_intent_raw(
"t1",
&IntentKind::DeliberateVariant,
"access token",
&IntentScope::Project,
&["banned_synonym".to_string()],
ScopeLevel::Project,
)
.unwrap();
let rows = s.list_intent_rows_raw().unwrap();
let row = rows
.iter()
.find(|r| r.coverage.iter().any(|c| c == "banned_synonym"))
.expect("banned_synonym intent row present");
assert_eq!(row.kind, "deliberate_variant");
assert_eq!(row.description, "access token");
}
#[test]
fn findings_roundtrip_and_clear() {
let s = store();
let p = Uuid::new_v4();
s.insert_finding(&finding(), Some(p), Some("ch07")).unwrap();
let listed = s.list_findings().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].paragraph_id, Some(p));
assert_eq!(listed[0].finding.category, Category::ModalClaims);
assert_eq!(listed[0].finding.severity, Severity::Inquiry);
s.clear_findings_for_paragraph(p).unwrap();
assert!(s.list_findings().unwrap().is_empty());
}
#[test]
fn intent_entries_roundtrip_with_scope_and_coverage() {
let s = store();
let e = IntentEntry {
id: "e1".into(),
kind: IntentKind::DeliberateAmbiguity,
description: "Mara's loyalty is intentionally unresolved".into(),
scope: IntentScope::ParagraphRange { from: "ch07-p042".into(), to: "ch07-p051".into() },
coverage: vec![Category::AssumptionSurfacing, Category::TensionDetection],
scope_level: ScopeLevel::Series,
};
s.add_intent(&e).unwrap();
let back = s.list_intents().unwrap();
assert_eq!(back.len(), 1);
let r = &back[0];
assert_eq!(r.id, "e1");
assert_eq!(r.kind, IntentKind::DeliberateAmbiguity);
assert_eq!(r.scope_level, ScopeLevel::Series);
assert_eq!(r.coverage, vec![Category::AssumptionSurfacing, Category::TensionDetection]);
assert!(matches!(&r.scope, IntentScope::ParagraphRange { from, to }
if from == "ch07-p042" && to == "ch07-p051"));
let ledger = s.load_ledger().unwrap();
let ctx = super::super::intent::FindingContext {
paragraph_id: Some("ch07-p045".into()),
..Default::default()
};
assert!(matches!(
ledger.consult(Category::AssumptionSurfacing, &ctx),
super::super::intent::ConsultationResult::Suppress { .. }
));
s.remove_intent("e1").unwrap();
assert!(s.list_intents().unwrap().is_empty());
}
#[test]
fn dismissals_become_promotion_candidates() {
let s = store();
for _ in 0..4 {
s.record_dismissal(Category::FramingInterrogation, "ch12").unwrap();
}
assert!(s.promotion_candidates(5).unwrap().is_empty());
s.record_dismissal(Category::FramingInterrogation, "ch12").unwrap();
let cands = s.promotion_candidates(5).unwrap();
assert_eq!(cands.len(), 1);
assert_eq!(cands[0].category, Category::FramingInterrogation);
assert_eq!(cands[0].chapter_id, "ch12");
assert_eq!(cands[0].count, 5);
s.refuse_promotion(Category::FramingInterrogation, "ch12").unwrap();
assert!(s.promotion_candidates(5).unwrap().is_empty());
}
#[test]
fn active_persona_roundtrips() {
let s = store();
assert_eq!(s.active_persona_id().unwrap(), None);
s.set_active_persona("skeptical-reader").unwrap();
assert_eq!(s.active_persona_id().unwrap().as_deref(), Some("skeptical-reader"));
}
#[test]
fn timeline_scope_roundtrips() {
let s = store();
let e = IntentEntry {
id: "t1".into(),
kind: IntentKind::DeliberateTemporalAmbiguity,
description: "Years 90-95 intentionally unresolved".into(),
scope: IntentScope::TimelineRange { from: "1A.090".into(), to: "1A.095".into() },
coverage: vec![Category::DramatizationGap],
scope_level: ScopeLevel::Project,
};
s.add_intent(&e).unwrap();
let back = s.list_intents().unwrap();
assert!(matches!(&back[0].scope, IntentScope::TimelineRange { from, to }
if from == "1A.090" && to == "1A.095"));
}
}