From 5511408e83769f33a6ef69c47c29692109f542ad Mon Sep 17 00:00:00 2001 From: Darshan vichhi Date: Fri, 3 Jul 2026 10:05:15 +0530 Subject: [PATCH] Implement Stage 12 freshness system --- ARCHITECTURE.md | 21 ++- CHANGELOG.md | 10 +- README.md | 30 +++- ROADMAP.md | 25 ++- manas-cli/src/main.rs | 112 +++++++++++-- manas-cli/tests/cli.rs | 33 ++++ manas-learn/src/freshness.rs | 265 +++++++++++++++++++++++++++++++ manas-learn/src/lib.rs | 5 + manas-learn/src/trainer.rs | 159 +++++++++++++++++-- manas-store/tests/persistence.rs | 25 ++- 10 files changed, 648 insertions(+), 37 deletions(-) create mode 100644 manas-learn/src/freshness.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e309cac..a4c0482 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -744,6 +744,16 @@ impl Trainer { source: Source, ) -> Result { ... } + // Teach one fact with explicit freshness metadata. + pub fn learn_with_source_and_freshness( + &mut self, + network: &mut Network, + input: &str, + target: &str, + source: Source, + freshness: FreshnessCategory, + ) -> Result { ... } + // Ask the network a question. Returns best answer from weights. pub fn query(&mut self, network: &Network, question: &str) -> Result { ... } @@ -767,6 +777,12 @@ pub struct QueryResult { pub answer: String, pub confidence: f32, // 0.0 → 1.0 pub answered_from: AnswerSource, + pub freshness_warning: Option, +} + +pub struct FreshnessWarning { + pub category: FreshnessCategory, + pub age_days: u64, } pub enum AnswerSource { @@ -1096,7 +1112,7 @@ Every neuron has a `freshness_category: u8`: The freshness category is detected automatically from the text content during `teach`: ```rust -pub fn detect_freshness(text: &str) -> u8 { +pub fn detect_freshness(text: &str) -> FreshnessCategory { // keywords like "theorem", "law", "always" → 0 (Timeless) // keywords like "today", "breaking", "live" → 3 (Realtime) // keywords like "released", "version" → 2 (Fast) @@ -1114,6 +1130,9 @@ Answer Confidence 0.81 +Answered from + neural weights + Note This knowledge may be outdated (Fast freshness, learned 47 days ago). ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 7134715..0d7471a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Manas uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Stage 9 — `manas-cli` v1: teach and ask. - Stage 10 — File and folder ingestion. - Stage 11 — Importance scoring and promotion. +- Stage 12 — Freshness system. ### Added @@ -66,10 +67,17 @@ Manas uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Replaced activation-count-only promotion with importance-driven `Open -> Guarded -> Frozen` promotion and preserved importance metadata through `.manas` save/load. +- Added `manas-learn::freshness` with Timeless, Slow, Fast, and Realtime + categories, keyword detection, age thresholds, and stale-neuron warnings. +- `manas teach` now stamps freshness metadata on learned neurons, and + `manas ask` appends an outdated-knowledge note when the retrieved neuron is + stale. +- Added freshness tests for detection, staleness, trainer query warnings, CLI + rendering, CLI teach metadata, and `.manas` persistence. ### Next -- Stage 12 — Freshness system. +- Stage 13 — The real demo. --- diff --git a/README.md b/README.md index b366a8a..f1ffa46 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,9 @@ Manas v2 is in active development. The roadmap follows a strict rule: | Stage 9 | `manas teach` and `manas ask` | Complete | | Stage 10 | File and folder ingestion | Complete | | Stage 11 | Importance scoring and promotion | Complete | -| Stage 12 | Freshness system | Next | -| Stage 13+ | The real demo, inspect, benchmarks, layer growth | Planned | +| Stage 12 | Freshness system | Complete | +| Stage 13 | The real demo | Next | +| Stage 14+ | Inspect, benchmarks, layer growth | Planned | Stages 1 and 2 are preserved as a standalone proof in `manas-core/src/experiment.rs`. Stage 3 promotes the proven engine into @@ -151,7 +152,9 @@ Stage 10 completes local ingestion so `manas teach` accepts raw text, a supporte file, or a folder of supported files while preserving local file source metadata inside the learned neurons. Stage 11 replaces activation-count-only promotion with weighted importance scoring based on frequency, recency, weight magnitude, -and smooth age grace. +and smooth age grace. Stage 12 classifies learned knowledge as Timeless, Slow, +Fast, or Realtime and warns during `manas ask` when the answer comes from stale +neuron metadata. Run the proof: @@ -326,6 +329,27 @@ The entire brain — weights, vocab, metadata — is in this one file. --- +## Freshness + +`manas teach` detects freshness from the taught text and stores the category on +the best matching learned neuron: + +| Category | Examples | Stale after | +|---|---|---| +| Timeless | Definitions, proofs, laws | Never | +| Slow | Historical facts, biographies | 365 days | +| Fast | Software versions, news | 30 days | +| Realtime | Stock prices, live scores | 1 day | + +When `manas ask` answers from a stale neuron, it appends a note such as: + +```text +Note + This knowledge may be outdated (Fast freshness, learned 47 days ago). +``` + +--- + ## Crate Structure ``` diff --git a/ROADMAP.md b/ROADMAP.md index 1d18c20..9cc316b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,8 +80,8 @@ from Stage 2 onward.** | Stage 9 | `manas-cli` v1 — teach and ask | Complete | | Stage 10 | File and folder ingestion | Complete | | Stage 11 | Importance scoring and promotion | Complete | -| Stage 12 | Freshness system | Next | -| Stage 13 | The real demo | Planned | +| Stage 12 | Freshness system | Complete | +| Stage 13 | The real demo | Next | | Stage 14 | Inspect, neurons, and debug commands | Planned | | Stage 15 | Compression and forget command | Planned | | Stage 16 | Benchmarks and test suite | Planned | @@ -1569,6 +1569,10 @@ Completion note: **Goal:** Every fact knows how time-sensitive it is. Stale facts are flagged. +**Status:** Complete. Freshness is detected during `teach`, stored on learned +neurons, persisted in `.manas`, and surfaced by `manas ask` when an answer comes +from stale neuron metadata. + ### What to Build ```rust @@ -1638,10 +1642,19 @@ fn fast_fact_stale_after_30_days() { ### Done When -- [ ] Keyword detection tests pass for all 4 categories -- [ ] Staleness detection tests pass for all 4 categories -- [ ] `manas ask` appends a "Note: may be outdated" line when answering from a stale neuron -- [ ] `cargo test -p manas-learn freshness` passes clean +- [x] Keyword detection tests pass for all 4 categories +- [x] Staleness detection tests pass for all 4 categories +- [x] `manas ask` appends a "Note: may be outdated" line when answering from a stale neuron +- [x] `cargo test -p manas-learn freshness` passes clean + +### Stage 12 Implementation Notes + +- Added `manas-learn::freshness` with `FreshnessCategory`, + `FreshnessWarning`, `detect_freshness`, and `is_stale` +- Added `Trainer::learn_with_source_and_freshness` while keeping + `learn_with_source` backward compatible +- `QueryResult` now carries optional freshness warning metadata +- Added freshness coverage in `manas-learn`, `manas-cli`, and `manas-store` --- diff --git a/manas-cli/src/main.rs b/manas-cli/src/main.rs index 620af37..2d7735c 100644 --- a/manas-cli/src/main.rs +++ b/manas-cli/src/main.rs @@ -5,7 +5,9 @@ use std::process; use manas_core::Network; use manas_ingest::{IngestSource, ingest}; -use manas_learn::{AnswerSource, EncoderVocabEntry, LearnReport, Trainer}; +use manas_learn::{ + AnswerSource, EncoderVocabEntry, FreshnessWarning, LearnReport, Trainer, detect_freshness, +}; use manas_store::{BrainState, ManasBrain, VocabEntry}; const DEFAULT_BRAIN_PATH: &str = "brain.manas"; @@ -53,8 +55,15 @@ fn teach(brain_path: &Path, args: &[String]) -> Result<(), String> { for chunk in &chunks { for unit in teachable_units(&chunk.text) { let (input, target) = extract_association(&unit)?; + let freshness = detect_freshness(&unit); let report = trainer - .learn_with_source(&mut network, &input, &target, chunk.source.clone()) + .learn_with_source_and_freshness( + &mut network, + &input, + &target, + chunk.source.clone(), + freshness, + ) .map_err(|error| error.to_string())?; summary.record(&input, &target, &report); } @@ -75,7 +84,12 @@ fn ask(brain_path: &Path, args: &[String]) -> Result<(), String> { let brain = ManasBrain::new(brain_path); if !brain.exists() { - print_answer("Not enough knowledge yet.", 0.0, AnswerSource::NotEnough); + print_answer( + "Not enough knowledge yet.", + 0.0, + AnswerSource::NotEnough, + None, + ); return Ok(()); } @@ -93,7 +107,12 @@ fn ask(brain_path: &Path, args: &[String]) -> Result<(), String> { let result = trainer .query(&state.network, &question) .map_err(|error| error.to_string())?; - print_answer(&result.answer, result.confidence, result.answered_from); + print_answer( + &result.answer, + result.confidence, + result.answered_from, + result.freshness_warning.as_ref(), + ); Ok(()) } @@ -218,15 +237,50 @@ fn print_teach_report(summary: &TeachSummary) { ); } -fn print_answer(answer: &str, confidence: f32, source: AnswerSource) { - println!("Answer"); - println!(" {answer}"); - println!(); - println!("Confidence"); - println!(" {:.2}", confidence); - println!(); - println!("Answered from"); - println!(" {}", answer_source_label(source)); +fn print_answer( + answer: &str, + confidence: f32, + source: AnswerSource, + freshness_warning: Option<&FreshnessWarning>, +) { + print!( + "{}", + render_answer(answer, confidence, source, freshness_warning) + ); +} + +fn render_answer( + answer: &str, + confidence: f32, + source: AnswerSource, + freshness_warning: Option<&FreshnessWarning>, +) -> String { + use std::fmt::Write as _; + + let mut output = String::new(); + writeln!(&mut output, "Answer").expect("writing to String should not fail"); + writeln!(&mut output, " {answer}").expect("writing to String should not fail"); + writeln!(&mut output).expect("writing to String should not fail"); + writeln!(&mut output, "Confidence").expect("writing to String should not fail"); + writeln!(&mut output, " {:.2}", confidence).expect("writing to String should not fail"); + writeln!(&mut output).expect("writing to String should not fail"); + writeln!(&mut output, "Answered from").expect("writing to String should not fail"); + writeln!(&mut output, " {}", answer_source_label(source)) + .expect("writing to String should not fail"); + + if let Some(warning) = freshness_warning { + writeln!(&mut output).expect("writing to String should not fail"); + writeln!(&mut output, "Note").expect("writing to String should not fail"); + writeln!( + &mut output, + " This knowledge may be outdated ({} freshness, learned {} days ago).", + warning.category.label(), + warning.age_days + ) + .expect("writing to String should not fail"); + } + + output } fn print_help() { @@ -540,6 +594,7 @@ fn known_sidecar_paths(brain_path: &Path) -> Vec { #[cfg(test)] mod tests { use super::*; + use manas_learn::FreshnessCategory; #[test] fn extracts_simple_is_association() { @@ -558,4 +613,35 @@ mod tests { assert_eq!(input, "Eiffel Tower"); assert_eq!(target, "located in Paris France"); } + + #[test] + fn render_answer_omits_note_without_freshness_warning() { + let output = render_answer("small animal", 0.91, AnswerSource::NeuralWeights, None); + + assert!(output.contains("Answer\n small animal")); + assert!(output.contains("Answered from\n neural weights")); + assert!(!output.contains("Note")); + } + + #[test] + fn render_answer_appends_stale_freshness_note() { + let warning = FreshnessWarning { + category: FreshnessCategory::Fast, + age_days: 47, + }; + + let output = render_answer( + "Rust 2.0 was released last month", + 0.88, + AnswerSource::NeuralWeights, + Some(&warning), + ); + + assert!(output.contains("Note\n")); + assert!( + output.contains( + " This knowledge may be outdated (Fast freshness, learned 47 days ago)." + ) + ); + } } diff --git a/manas-cli/tests/cli.rs b/manas-cli/tests/cli.rs index 9f437fa..eb67bc0 100644 --- a/manas-cli/tests/cli.rs +++ b/manas-cli/tests/cli.rs @@ -4,6 +4,7 @@ use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; use manas_core::Source; +use manas_learn::FreshnessCategory; use manas_store::ManasBrain; #[test] @@ -122,6 +123,38 @@ fn cli_teach_folder_walks_supported_files_recursively() { fs::remove_dir_all(dir).unwrap(); } +#[test] +fn cli_teach_stamps_realtime_freshness_metadata() { + let dir = temp_dir("teach-freshness"); + + let teach = run( + &dir, + &["teach", "Breaking news: the stock market fell today."], + ); + assert_success(&teach); + + let state = ManasBrain::new(dir.join("brain.manas")) + .load_state() + .unwrap(); + let has_realtime_freshness = state + .network + .layers + .first() + .map(|layer| { + layer + .neurons + .iter() + .any(|neuron| neuron.freshness_category == FreshnessCategory::Realtime as u8) + }) + .unwrap_or(false); + assert!( + has_realtime_freshness, + "expected realtime freshness metadata" + ); + + fs::remove_dir_all(dir).unwrap(); +} + #[test] fn cli_ask_without_brain_returns_not_enough() { let dir = temp_dir("empty-ask"); diff --git a/manas-learn/src/freshness.rs b/manas-learn/src/freshness.rs new file mode 100644 index 0000000..93f7f99 --- /dev/null +++ b/manas-learn/src/freshness.rs @@ -0,0 +1,265 @@ +use manas_core::Neuron; + +const SECONDS_PER_DAY: u64 = 86_400; + +const TIMELESS_WORDS: &[&str] = &[ + "theorem", + "law", + "always", + "definition", + "formula", + "proof", + "principle", +]; +const TIMELESS_PHRASES: &[&str] = &["defined as"]; +const FAST_WORDS: &[&str] = &[ + "released", "release", "version", "launched", "update", "updated", "news", +]; +const FAST_PHRASES: &[&str] = &["last month", "this year"]; +const REALTIME_WORDS: &[&str] = &[ + "today", "breaking", "live", "latest", "now", "current", "score", "scores", "stock", "price", + "market", +]; + +#[repr(u8)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FreshnessCategory { + Timeless = 0, + Slow = 1, + Fast = 2, + Realtime = 3, +} + +impl FreshnessCategory { + pub fn from_u8(value: u8) -> Self { + match value { + 0 => Self::Timeless, + 2 => Self::Fast, + 3 => Self::Realtime, + _ => Self::Slow, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Timeless => "Timeless", + Self::Slow => "Slow", + Self::Fast => "Fast", + Self::Realtime => "Realtime", + } + } +} + +impl From for FreshnessCategory { + fn from(value: u8) -> Self { + Self::from_u8(value) + } +} + +impl From for u8 { + fn from(category: FreshnessCategory) -> Self { + category as u8 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FreshnessWarning { + pub category: FreshnessCategory, + pub age_days: u64, +} + +pub fn detect_freshness(text: &str) -> FreshnessCategory { + let normalized = normalize_text(text); + + if has_any_word(&normalized, REALTIME_WORDS) { + return FreshnessCategory::Realtime; + } + + if has_any_word(&normalized, FAST_WORDS) || has_any_phrase(&normalized, FAST_PHRASES) { + return FreshnessCategory::Fast; + } + + if has_any_word(&normalized, TIMELESS_WORDS) || has_any_phrase(&normalized, TIMELESS_PHRASES) { + return FreshnessCategory::Timeless; + } + + FreshnessCategory::Slow +} + +pub fn freshness_age_days(neuron: &Neuron, now_secs: u64) -> u64 { + now_secs.saturating_sub(neuron.born_at) / SECONDS_PER_DAY +} + +pub fn is_stale(neuron: &Neuron, now_secs: u64) -> bool { + let age_days = freshness_age_days(neuron, now_secs); + match FreshnessCategory::from(neuron.freshness_category) { + FreshnessCategory::Timeless => false, + FreshnessCategory::Slow => age_days > 365, + FreshnessCategory::Fast => age_days > 30, + FreshnessCategory::Realtime => age_days > 1, + } +} + +pub fn staleness_warning(neuron: &Neuron, now_secs: u64) -> Option { + if !is_stale(neuron, now_secs) { + return None; + } + + Some(FreshnessWarning { + category: FreshnessCategory::from(neuron.freshness_category), + age_days: freshness_age_days(neuron, now_secs), + }) +} + +fn normalize_text(text: &str) -> String { + let mut normalized = String::with_capacity(text.len()); + let mut previous_was_space = true; + + for ch in text.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + previous_was_space = false; + } else if !previous_was_space { + normalized.push(' '); + previous_was_space = true; + } + } + + normalized.trim().to_string() +} + +fn has_any_word(normalized: &str, keywords: &[&str]) -> bool { + normalized + .split_whitespace() + .any(|word| keywords.contains(&word)) +} + +fn has_any_phrase(normalized: &str, phrases: &[&str]) -> bool { + let bounded = format!(" {normalized} "); + phrases + .iter() + .any(|phrase| bounded.contains(&format!(" {phrase} "))) +} + +#[cfg(test)] +mod tests { + use super::*; + use manas_core::Network; + + const NOW: u64 = 1_800_000_000; + const DAY: u64 = 86_400; + + #[test] + fn detects_timeless_keywords() { + assert_eq!( + detect_freshness("The Pythagorean theorem states that a²+b²=c²"), + FreshnessCategory::Timeless + ); + assert_eq!( + detect_freshness("Water is always composed of hydrogen and oxygen"), + FreshnessCategory::Timeless + ); + } + + #[test] + fn detects_realtime_keywords() { + assert_eq!( + detect_freshness("Breaking news: the stock market fell today"), + FreshnessCategory::Realtime + ); + assert_eq!( + detect_freshness("Live scores updated every minute"), + FreshnessCategory::Realtime + ); + } + + #[test] + fn detects_fast_keywords() { + assert_eq!( + detect_freshness("Rust 2.0 was released last month"), + FreshnessCategory::Fast + ); + } + + #[test] + fn defaults_to_slow() { + assert_eq!( + detect_freshness("Paris is a city in France"), + FreshnessCategory::Slow + ); + } + + #[test] + fn word_matching_does_not_use_substrings() { + assert_eq!( + detect_freshness("Stockholm is the capital of Sweden"), + FreshnessCategory::Slow + ); + } + + #[test] + fn unknown_category_defaults_to_slow() { + assert_eq!(FreshnessCategory::from(99), FreshnessCategory::Slow); + } + + #[test] + fn timeless_fact_never_stale() { + let mut neuron = neuron(); + neuron.freshness_category = FreshnessCategory::Timeless as u8; + neuron.born_at = 0; + + assert!(!is_stale(&neuron, NOW)); + } + + #[test] + fn slow_fact_stale_after_365_days() { + let mut neuron = neuron(); + neuron.freshness_category = FreshnessCategory::Slow as u8; + neuron.born_at = NOW - 365 * DAY; + assert!(!is_stale(&neuron, NOW)); + + neuron.born_at = NOW - 366 * DAY; + assert!(is_stale(&neuron, NOW)); + } + + #[test] + fn fast_fact_stale_after_30_days() { + let mut neuron = neuron(); + neuron.freshness_category = FreshnessCategory::Fast as u8; + neuron.born_at = NOW - 30 * DAY; + assert!(!is_stale(&neuron, NOW)); + + neuron.born_at = NOW - 31 * DAY; + assert!(is_stale(&neuron, NOW)); + } + + #[test] + fn realtime_fact_stale_after_1_day() { + let mut neuron = neuron(); + neuron.freshness_category = FreshnessCategory::Realtime as u8; + neuron.born_at = NOW - DAY; + assert!(!is_stale(&neuron, NOW)); + + neuron.born_at = NOW - 2 * DAY; + assert!(is_stale(&neuron, NOW)); + } + + #[test] + fn staleness_warning_includes_category_and_age() { + let mut neuron = neuron(); + neuron.freshness_category = FreshnessCategory::Fast as u8; + neuron.born_at = NOW - 47 * DAY; + + assert_eq!( + staleness_warning(&neuron, NOW), + Some(FreshnessWarning { + category: FreshnessCategory::Fast, + age_days: 47, + }) + ); + } + + fn neuron() -> manas_core::Neuron { + Network::new(32, 1, 32).layers[0].neurons[0].clone() + } +} diff --git a/manas-learn/src/lib.rs b/manas-learn/src/lib.rs index bb9f540..c522e1a 100644 --- a/manas-learn/src/lib.rs +++ b/manas-learn/src/lib.rs @@ -5,12 +5,17 @@ pub mod decoder; pub mod embedder; pub mod encoder; pub mod fixtures; +pub mod freshness; pub mod importance; pub mod tokenizer; pub mod trainer; pub use embedder::Embedder; pub use encoder::{Encoder, EncoderVocabEntry}; +pub use freshness::{ + FreshnessCategory, FreshnessWarning, detect_freshness, freshness_age_days, is_stale, + staleness_warning, +}; pub use importance::{GUARDED_TO_FROZEN_IMPORTANCE, OPEN_TO_GUARDED_IMPORTANCE}; pub use tokenizer::Tokenizer; pub use trainer::{AnswerSource, EncodedFact, LearnReport, ProtectionReport, QueryResult, Trainer}; diff --git a/manas-learn/src/trainer.rs b/manas-learn/src/trainer.rs index 55ac922..72b772d 100644 --- a/manas-learn/src/trainer.rs +++ b/manas-learn/src/trainer.rs @@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::backprop::{compute_gradients, cosine, mse_loss}; use crate::decoder::decode_answer; use crate::encoder::Encoder; +use crate::freshness::{FreshnessCategory, FreshnessWarning, detect_freshness, staleness_warning}; use crate::importance; const DEFAULT_EMBED_TABLE_SIZE: usize = 8192; @@ -39,6 +40,7 @@ pub struct QueryResult { pub answer: String, pub confidence: f32, pub answered_from: AnswerSource, + pub freshness_warning: Option, } /// Growth-aware result from a single learn call. @@ -117,6 +119,23 @@ impl Trainer { input: &str, target: &str, source: Source, + ) -> Result { + self.learn_with_source_and_freshness( + network, + input, + target, + source, + detected_freshness(input, target), + ) + } + + pub fn learn_with_source_and_freshness( + &mut self, + network: &mut Network, + input: &str, + target: &str, + source: Source, + freshness: FreshnessCategory, ) -> Result { let fact = self.encode_fact(input, target); let now_secs = unix_now_secs(); @@ -160,7 +179,7 @@ impl Trainer { refresh_learning_metadata(network, &activation_counts_before, now_secs); let protection_report = self.update_protection_levels_at(network, now_secs); - assign_source_to_best_hidden(network, &fact.input, source); + assign_metadata_to_best_hidden(network, &fact.input, source, freshness); Ok(LearnReport { loss_before, @@ -264,11 +283,17 @@ impl Trainer { let output = network.forward(&input); Ok(match decode_answer(&output, &self.encoder, question) { - Some(decoded) => QueryResult { - answer: decoded.answer, - confidence: decoded.confidence, - answered_from: AnswerSource::NeuralWeights, - }, + Some(decoded) => { + let freshness_warning = best_hidden_neuron(network, &input) + .and_then(|neuron| staleness_warning(neuron, unix_now_secs())); + + QueryResult { + answer: decoded.answer, + confidence: decoded.confidence, + answered_from: AnswerSource::NeuralWeights, + freshness_warning, + } + } None => not_enough(), }) } @@ -297,6 +322,14 @@ fn loss_for_fact(network: &Network, fact: &EncodedFact) -> Result FreshnessCategory { + let mut text = String::with_capacity(input.len() + target.len() + 1); + text.push_str(input); + text.push(' '); + text.push_str(target); + detect_freshness(&text) +} + fn unix_now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -342,6 +375,7 @@ fn not_enough() -> QueryResult { answer: "Not enough knowledge yet.".to_string(), confidence: 0.0, answered_from: AnswerSource::NotEnough, + freshness_warning: None, } } @@ -357,8 +391,22 @@ fn grow_for_fact(network: &mut Network, fact: &EncodedFact) -> Result<(), ManasE ) } -fn assign_source_to_best_hidden(network: &mut Network, input: &[f32], source: Source) { - let Some((index, _)) = network.layers.first().and_then(|layer| { +fn assign_metadata_to_best_hidden( + network: &mut Network, + input: &[f32], + source: Source, + freshness: FreshnessCategory, +) { + let Some(index) = best_open_hidden_index(network, input) else { + return; + }; + + network.layers[0].neurons[index].source = source; + network.layers[0].neurons[index].freshness_category = freshness as u8; +} + +fn best_open_hidden_index(network: &Network, input: &[f32]) -> Option { + network.layers.first().and_then(|layer| { layer .neurons .iter() @@ -370,11 +418,19 @@ fn assign_source_to_best_hidden(network: &mut Network, input: &[f32], source: So .partial_cmp(&right.1) .unwrap_or(std::cmp::Ordering::Equal) }) - }) else { - return; - }; + .map(|(index, _)| index) + }) +} - network.layers[0].neurons[index].source = source; +fn best_hidden_neuron<'a>(network: &'a Network, input: &[f32]) -> Option<&'a manas_core::Neuron> { + network.layers.first().and_then(|layer| { + layer.neurons.iter().max_by(|left, right| { + left.activate(input) + .abs() + .partial_cmp(&right.activate(input).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }) } #[cfg(test)] @@ -631,6 +687,7 @@ mod tests { assert_eq!(result.answered_from, AnswerSource::NeuralWeights); assert!(result.confidence > 0.0); + assert_eq!(result.freshness_warning, None); assert!( result.answer.contains("animal") || result.answer.contains("fur"), "answer was '{}'", @@ -651,6 +708,7 @@ mod tests { assert_eq!(result.answered_from, AnswerSource::NotEnough); assert_eq!(result.confidence, 0.0); + assert_eq!(result.freshness_warning, None); } #[test] @@ -673,6 +731,83 @@ mod tests { ); } + #[test] + fn learn_with_source_detects_freshness_metadata() { + let mut network = Network::new_empty(32); + let mut trainer = Trainer::new(0.01); + + trainer + .learn_with_source( + &mut network, + "market", + "Breaking news: the stock market fell today", + Source::RawText, + ) + .unwrap(); + + assert!( + network.layers[0] + .neurons + .iter() + .any(|neuron| neuron.freshness_category == FreshnessCategory::Realtime as u8) + ); + } + + #[test] + fn learn_with_source_and_freshness_uses_explicit_category() { + let mut network = Network::new_empty(32); + let mut trainer = Trainer::new(0.01); + + trainer + .learn_with_source_and_freshness( + &mut network, + "water", + "Water is always composed of hydrogen and oxygen", + Source::RawText, + FreshnessCategory::Timeless, + ) + .unwrap(); + + assert!( + network.layers[0] + .neurons + .iter() + .any(|neuron| neuron.freshness_category == FreshnessCategory::Timeless as u8) + ); + } + + #[test] + fn query_returns_warning_for_stale_neural_answer() { + let mut network = Network::new_empty(32); + let mut trainer = Trainer::new(0.01); + + trainer + .learn_with_source_and_freshness( + &mut network, + "cat", + "small animal with fur", + Source::RawText, + FreshnessCategory::Fast, + ) + .unwrap(); + + let query_input = trainer.encoder.encode_deterministic("What is a cat?"); + let hidden_index = best_open_hidden_index(&network, &query_input).unwrap(); + network.layers[0].neurons[hidden_index].born_at = unix_now_secs() - 31 * 86_400; + network.layers[0].neurons[hidden_index].freshness_category = FreshnessCategory::Fast as u8; + + let result = trainer.query(&network, "What is a cat?").unwrap(); + + assert_eq!(result.answered_from, AnswerSource::NeuralWeights); + assert_eq!( + result.freshness_warning, + Some(FreshnessWarning { + category: FreshnessCategory::Fast, + age_days: 31, + }) + ); + } + fn make_neuron_high_importance(neuron: &mut manas_core::Neuron, now_secs: u64) { neuron.activation_count = 10_000; neuron.last_activated = now_secs; diff --git a/manas-store/tests/persistence.rs b/manas-store/tests/persistence.rs index c6d6ec4..dbca012 100644 --- a/manas-store/tests/persistence.rs +++ b/manas-store/tests/persistence.rs @@ -7,7 +7,7 @@ use manas_learn::fixtures::{ ANCHOR_FACTS, ANCHOR_NEURONS_PER_FACT, ANCHOR_TRAIN_EPOCHS, EMBED_DIM, HIDDEN_DIM, LEARNING_RATE, OUTPUT_DIM, }; -use manas_learn::{EncodedFact, Trainer}; +use manas_learn::{EncodedFact, FreshnessCategory, Trainer}; use manas_store::{BrainState, ManasBrain, VocabEntry}; const CRC32_POLYNOMIAL: u32 = 0xEDB8_8320; @@ -136,6 +136,29 @@ fn importance_metadata_survives_save_load() { cleanup(&path); } +#[test] +fn freshness_category_survives_save_load() { + let path = temp_path("freshness"); + let mut network = Network::new(32, 64, 32); + network.layers[0].neurons[0].freshness_category = FreshnessCategory::Realtime as u8; + network.layers[0].neurons[1].freshness_category = FreshnessCategory::Timeless as u8; + + let brain = ManasBrain::new(&path); + brain.save(&network).unwrap(); + let loaded = brain.load().unwrap(); + + assert_eq!( + loaded.layers[0].neurons[0].freshness_category, + FreshnessCategory::Realtime as u8 + ); + assert_eq!( + loaded.layers[0].neurons[1].freshness_category, + FreshnessCategory::Timeless as u8 + ); + + cleanup(&path); +} + #[test] fn vocab_entries_survive_save_load() { let path = temp_path("vocab");