diff --git a/src/openhuman/learning/README.md b/src/openhuman/learning/README.md index 17d482c86c..e99f190c6b 100644 --- a/src/openhuman/learning/README.md +++ b/src/openhuman/learning/README.md @@ -62,7 +62,7 @@ Namespace `learning` (wired into `src/core/all.rs`; 11 controllers). Methods: | `learning.rebuild_cache` | Manually trigger a `StabilityDetector` rebuild; returns added/evicted/kept/total_size. | | `learning.cache_stats` | Cache totals + per-state and per-class breakdown. | | `learning.list_facets` | List Active + Provisional facets, optional `class` filter. | -| `learning.get_facet` | Fetch one facet by `class` + `key` suffix. | +| `learning.get_facet` | Fetch one facet by `class` + `key` suffix. Each returned facet carries its provenance (`evidence_refs`, `cue_families`) alongside the value/state fields. | | `learning.update_facet` | Set a facet value and pin it (`user_state = Pinned`). | | `learning.pin_facet` / `learning.unpin_facet` | Toggle `user_state` Pinned ↔ Auto. | | `learning.forget_facet` | Mark `Dropped` + `user_state = Forgotten` (blocks re-promotion). | diff --git a/src/openhuman/learning/schemas.rs b/src/openhuman/learning/schemas.rs index eb5ac116e1..2e581df69e 100644 --- a/src/openhuman/learning/schemas.rs +++ b/src/openhuman/learning/schemas.rs @@ -475,6 +475,54 @@ mod tests { assert_eq!(s.function, "unknown"); } + #[test] + fn facet_to_json_includes_cue_families_and_evidence_refs() { + use crate::openhuman::learning::candidate::EvidenceRef; + use crate::openhuman::memory_store::profile::{ + FacetState, FacetType, ProfileFacet, UserState, + }; + use std::collections::HashMap; + + let mut cue_families = HashMap::new(); + cue_families.insert("explicit".to_string(), 3u32); + cue_families.insert("structural".to_string(), 1u32); + + let facet = ProfileFacet { + facet_id: "f1".into(), + facet_type: FacetType::Preference, + key: "style/verbosity".into(), + value: "terse".into(), + confidence: 0.8, + evidence_count: 4, + source_segment_ids: None, + first_seen_at: 1000.0, + last_seen_at: 1200.0, + state: FacetState::Active, + stability: 0.9, + user_state: UserState::Auto, + evidence_refs: vec![EvidenceRef::Episodic { episodic_id: 42 }], + class: Some("style".into()), + cue_families: Some(cue_families), + }; + + // Populated provenance round-trips through the serializer. + let json = facet_to_json(&facet); + assert_eq!(json["cue_families"]["explicit"].as_u64(), Some(3)); + assert_eq!(json["cue_families"]["structural"].as_u64(), Some(1)); + assert_eq!(json["evidence_refs"][0]["type"].as_str(), Some("episodic")); + assert_eq!(json["evidence_refs"][0]["episodic_id"].as_i64(), Some(42)); + + // Empty/None provenance serializes to []/null (present, not dropped). + let bare = ProfileFacet { + evidence_refs: vec![], + cue_families: None, + ..facet + }; + let json = facet_to_json(&bare); + assert_eq!(json["evidence_refs"].as_array().map(Vec::len), Some(0)); + assert!(json["cue_families"].is_null()); + } + #[test] fn schemas_and_controllers_match() { let s = all_learning_controller_schemas(); @@ -735,6 +783,12 @@ fn facet_to_json(f: &crate::openhuman::memory_store::profile::ProfileFacet) -> s "first_seen_at": f.first_seen_at, "last_seen_at": f.last_seen_at, "class": f.class, + // Provenance the store already persists and row_to_facet hydrates, but + // the serializer previously dropped: the citations behind the facet and + // the per-cue-family evidence counts. `None`/empty serialize to + // `null`/`[]`. + "cue_families": f.cue_families, + "evidence_refs": f.evidence_refs, }) }