use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::node::NodeId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningIndex {
topic_paths: HashMap<String, Vec<TopicEntry>>,
summary_shortcut: Option<SummaryShortcut>,
#[serde(with = "super::serde_helpers")]
hot_nodes: HashMap<NodeId, HotNodeEntry>,
section_map: HashMap<String, NodeId>,
#[serde(default)]
config_hash: u64,
}
impl ReasoningIndex {
pub fn new() -> Self {
Self {
topic_paths: HashMap::new(),
summary_shortcut: None,
hot_nodes: HashMap::new(),
section_map: HashMap::new(),
config_hash: 0,
}
}
pub fn builder() -> ReasoningIndexBuilder {
ReasoningIndexBuilder::new()
}
pub fn topic_entries(&self, keyword: &str) -> Option<&[TopicEntry]> {
self.topic_paths.get(keyword).map(Vec::as_slice)
}
pub fn summary_shortcut(&self) -> Option<&SummaryShortcut> {
self.summary_shortcut.as_ref()
}
pub fn is_hot(&self, node_id: NodeId) -> bool {
self.hot_nodes
.get(&node_id)
.map(|e| e.is_hot)
.unwrap_or(false)
}
pub fn hot_entry(&self, node_id: NodeId) -> Option<&HotNodeEntry> {
self.hot_nodes.get(&node_id)
}
pub fn find_section(&self, title: &str) -> Option<NodeId> {
self.section_map.get(&title.to_lowercase()).copied()
}
pub fn all_topic_entries(&self) -> impl Iterator<Item = (&String, &[TopicEntry])> {
self.topic_paths.iter().map(|(k, v)| (k, v.as_slice()))
}
pub fn topic_count(&self) -> usize {
self.topic_paths.len()
}
pub fn section_count(&self) -> usize {
self.section_map.len()
}
pub fn hot_node_count(&self) -> usize {
self.hot_nodes.iter().filter(|(_, e)| e.is_hot).count()
}
pub fn update_hot_nodes(&mut self, hits: &[(NodeId, f32)], hot_threshold: u32) {
for &(node_id, score) in hits {
let entry = self.hot_nodes.entry(node_id).or_insert(HotNodeEntry {
hit_count: 0,
avg_score: 0.0,
is_hot: false,
});
entry.hit_count += 1;
entry.avg_score += (score - entry.avg_score) / entry.hit_count as f32;
if entry.hit_count >= hot_threshold {
entry.is_hot = true;
}
}
}
}
impl Default for ReasoningIndex {
fn default() -> Self {
Self::new()
}
}
pub struct ReasoningIndexBuilder {
topic_paths: HashMap<String, Vec<TopicEntry>>,
summary_shortcut: Option<SummaryShortcut>,
hot_nodes: HashMap<NodeId, HotNodeEntry>,
section_map: HashMap<String, NodeId>,
config_hash: u64,
}
impl ReasoningIndexBuilder {
pub fn new() -> Self {
Self {
topic_paths: HashMap::new(),
summary_shortcut: None,
hot_nodes: HashMap::new(),
section_map: HashMap::new(),
config_hash: 0,
}
}
pub fn add_topic_entry(&mut self, keyword: impl Into<String>, entry: TopicEntry) {
self.topic_paths
.entry(keyword.into())
.or_default()
.push(entry);
}
pub fn summary_shortcut(mut self, shortcut: SummaryShortcut) -> Self {
self.summary_shortcut = Some(shortcut);
self
}
pub fn add_section(&mut self, title: impl Into<String>, node_id: NodeId) {
self.section_map
.insert(title.into().to_lowercase(), node_id);
}
pub fn config_hash(mut self, hash: u64) -> Self {
self.config_hash = hash;
self
}
pub fn sort_and_trim(&mut self, max_entries: usize) {
for entries in self.topic_paths.values_mut() {
entries.sort_by(|a, b| {
b.weight
.partial_cmp(&a.weight)
.unwrap_or(std::cmp::Ordering::Equal)
});
entries.truncate(max_entries);
}
}
pub fn build(self) -> ReasoningIndex {
ReasoningIndex {
topic_paths: self.topic_paths,
summary_shortcut: self.summary_shortcut,
hot_nodes: self.hot_nodes,
section_map: self.section_map,
config_hash: self.config_hash,
}
}
}
impl Default for ReasoningIndexBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicEntry {
pub node_id: NodeId,
pub weight: f32,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryShortcut {
pub root_node: NodeId,
pub section_summaries: Vec<SectionSummary>,
pub document_summary: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SectionSummary {
pub node_id: NodeId,
pub title: String,
pub summary: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotNodeEntry {
pub hit_count: u32,
pub avg_score: f32,
pub is_hot: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningIndexConfig {
pub enabled: bool,
pub hot_node_threshold: u32,
pub max_topic_entries: usize,
pub max_keyword_entries: usize,
pub min_keyword_length: usize,
pub build_summary_shortcut: bool,
pub enable_synonym_expansion: bool,
}
impl Default for ReasoningIndexConfig {
fn default() -> Self {
Self {
enabled: true,
hot_node_threshold: 3,
max_topic_entries: 20,
max_keyword_entries: 5000,
min_keyword_length: 2,
build_summary_shortcut: true,
enable_synonym_expansion: true,
}
}
}
impl ReasoningIndexConfig {
pub fn new() -> Self {
Self::default()
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
pub fn with_hot_threshold(mut self, threshold: u32) -> Self {
self.hot_node_threshold = threshold;
self
}
pub fn with_summary_shortcut(mut self, build: bool) -> Self {
self.build_summary_shortcut = build;
self
}
pub fn with_synonym_expansion(mut self, enable: bool) -> Self {
self.enable_synonym_expansion = enable;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reasoning_index_default() {
let index = ReasoningIndex::default();
assert_eq!(index.topic_count(), 0);
assert_eq!(index.section_count(), 0);
assert_eq!(index.hot_node_count(), 0);
assert!(index.summary_shortcut().is_none());
}
#[test]
fn test_builder_basic() {
let mut tree = crate::document::DocumentTree::new("Root", "root content");
let child1 = tree.add_child(tree.root(), "Introduction", "intro content");
let child2 = tree.add_child(tree.root(), "Methods", "methods content");
let mut builder = ReasoningIndexBuilder::new();
builder.add_section("Introduction", child1);
builder.add_section("Methods", child2);
let index = builder.build();
assert_eq!(index.section_count(), 2);
assert!(index.find_section("introduction").is_some());
assert!(index.find_section("INTRODUCTION").is_some());
assert!(index.find_section("methods").is_some());
}
#[test]
fn test_config_default() {
let config = ReasoningIndexConfig::default();
assert!(config.enabled);
assert_eq!(config.hot_node_threshold, 3);
assert!(config.build_summary_shortcut);
}
#[test]
fn test_config_disabled() {
let config = ReasoningIndexConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn test_serialization_roundtrip_empty() {
let mut tree = crate::document::DocumentTree::new("Root", "content");
let child = tree.add_child(tree.root(), "Section 1", "s1 content");
let mut builder = ReasoningIndexBuilder::new();
builder.add_section("Section 1", child);
builder.add_topic_entry(
"section",
TopicEntry {
node_id: child,
weight: 0.8,
depth: 1,
},
);
let index = builder.build();
let json = serde_json::to_string(&index).unwrap();
let restored: ReasoningIndex = serde_json::from_str(&json).unwrap();
assert_eq!(restored.topic_count(), 1);
assert_eq!(restored.section_count(), 1);
assert_eq!(restored.hot_node_count(), 0);
}
#[test]
fn test_serialization_roundtrip_with_hot_nodes() {
let mut tree = crate::document::DocumentTree::new("Root", "");
let root = tree.root();
let c1 = tree.add_child(root, "S1", "content 1");
let c2 = tree.add_child(root, "S2", "content 2");
let mut index = ReasoningIndex::new();
index.update_hot_nodes(&[(c1, 0.9), (c2, 0.7), (c1, 0.8)], 2);
assert!(index.is_hot(c1));
assert!(!index.is_hot(c2));
let json = serde_json::to_string(&index).unwrap();
assert!(!json.contains("\"hot_nodes\":{}"));
assert!(json.contains("\"hot_nodes\":["));
let restored: ReasoningIndex = serde_json::from_str(&json).unwrap();
assert!(restored.is_hot(c1));
assert!(!restored.is_hot(c2));
let entry = restored.hot_entry(c1).unwrap();
assert_eq!(entry.hit_count, 2);
assert!(entry.avg_score > 0.0);
}
#[test]
fn test_backward_compat_hot_nodes_empty_object() {
let mut tree = crate::document::DocumentTree::new("Root", "");
let child = tree.add_child(tree.root(), "S1", "c");
let mut builder = ReasoningIndexBuilder::new();
builder.add_section("s1", child);
let index = builder.build();
let json = serde_json::to_string(&index).unwrap();
let old_json = json.replace("\"hot_nodes\":[]", "\"hot_nodes\":{}");
let restored: ReasoningIndex = serde_json::from_str(&old_json).unwrap();
assert_eq!(restored.hot_node_count(), 0);
}
}