lean_ctx/core/
learning_sync.rs1use serde::{Deserialize, Serialize};
17
18pub const BUNDLE_SCHEMA_VERSION: u32 = 1;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LearningBundle {
22 pub schema_version: u32,
23 pub exported_at: String,
25 pub thresholds: crate::core::threshold_learning::ThresholdLearner,
26 pub litm: crate::core::litm_calibration::LitmCalibration,
27}
28
29#[derive(Debug, Clone, Default)]
30pub struct MergeReport {
31 pub threshold_exts: usize,
32 pub litm_profiles: usize,
33}
34
35pub fn export_bundle() -> LearningBundle {
37 LearningBundle {
38 schema_version: BUNDLE_SCHEMA_VERSION,
39 exported_at: chrono::Utc::now().to_rfc3339(),
40 thresholds: crate::core::threshold_learning::export_state(),
41 litm: crate::core::litm_calibration::export_state(),
42 }
43}
44
45pub fn import_bundle(json: &str) -> Result<MergeReport, String> {
48 let bundle: LearningBundle =
49 serde_json::from_str(json).map_err(|e| format!("invalid learning bundle: {e}"))?;
50 if bundle.schema_version != BUNDLE_SCHEMA_VERSION {
51 return Err(format!(
52 "unsupported bundle schema {} (this build speaks {})",
53 bundle.schema_version, BUNDLE_SCHEMA_VERSION
54 ));
55 }
56 crate::core::threshold_learning::merge_state(&bundle.thresholds);
57 crate::core::litm_calibration::merge_state(&bundle.litm);
58 Ok(MergeReport {
59 threshold_exts: bundle.thresholds.per_ext.len(),
60 litm_profiles: bundle.litm.per_profile.len(),
61 })
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use crate::core::litm_calibration::LitmCalibration;
68 use crate::core::threshold_learning::{LearnedDelta, ThresholdLearner};
69
70 fn learner(ext: &str, delta: f64, samples: u32) -> ThresholdLearner {
71 let mut l = ThresholdLearner::default();
72 l.per_ext.insert(
73 ext.to_string(),
74 LearnedDelta {
75 delta_entropy: delta,
76 samples,
77 last_decay_day: 20_000,
78 },
79 );
80 l
81 }
82
83 #[test]
84 fn threshold_merge_is_sample_weighted_and_idempotent() {
85 let mut a = learner("rs", 0.10, 30);
86 let b = learner("rs", -0.05, 10);
87 a.merge_from(&b);
88 let d = &a.per_ext["rs"];
89 assert!((d.delta_entropy - 0.0625).abs() < 1e-9);
91 assert_eq!(d.samples, 30);
92
93 let frozen = a.clone();
95 a.merge_from(&frozen);
96 assert!((a.per_ext["rs"].delta_entropy - 0.0625).abs() < 1e-9);
97 assert_eq!(a.per_ext["rs"].samples, 30);
98 }
99
100 #[test]
101 fn threshold_merge_clamps_foreign_deltas() {
102 let mut a = ThresholdLearner::default();
103 let b = learner("py", 9.0, 50);
105 a.merge_from(&b);
106 assert!(a.per_ext["py"].delta_entropy <= 0.15 + 1e-9);
107 }
108
109 #[test]
110 fn litm_merge_takes_elementwise_max() {
111 let mut a = LitmCalibration::default();
112 a.record(
113 "claude",
114 crate::core::litm_calibration::Position::Begin,
115 true,
116 );
117 let mut b = LitmCalibration::default();
118 for _ in 0..5 {
119 b.record(
120 "claude",
121 crate::core::litm_calibration::Position::Begin,
122 true,
123 );
124 }
125 a.merge_from(&b);
126 assert_eq!(a.per_profile["claude"].begin_hits, 5);
127
128 let frozen = a.clone();
130 a.merge_from(&frozen);
131 assert_eq!(a.per_profile["claude"].begin_hits, 5);
132 }
133
134 #[test]
135 fn import_rejects_wrong_schema() {
136 let bundle = LearningBundle {
137 schema_version: 99,
138 exported_at: "2026-06-11T00:00:00Z".to_string(),
139 thresholds: ThresholdLearner::default(),
140 litm: LitmCalibration::default(),
141 };
142 let json = serde_json::to_string(&bundle).unwrap();
143 assert!(import_bundle(&json).is_err());
144 }
145
146 #[test]
147 fn bundle_contains_no_paths() {
148 let bundle = LearningBundle {
149 schema_version: BUNDLE_SCHEMA_VERSION,
150 exported_at: "2026-06-11T00:00:00Z".to_string(),
151 thresholds: learner("rs", 0.05, 12),
152 litm: LitmCalibration::default(),
153 };
154 let json = serde_json::to_string(&bundle).unwrap();
155 assert!(!json.contains('/'), "no path separators expected: {json}");
157 }
158}