Skip to main content

lean_ctx/core/
learning_sync.rs

1//! Team sync of the learning layers (#550, VIS-4).
2//!
3//! Bundles the machine-local learning state — learned compression-threshold
4//! deltas (#538) and LITM placement calibration (#539) — into a versioned,
5//! secret-free JSON document that can be shared across a team and merged
6//! back without double counting:
7//!
8//! - threshold deltas merge as **sample-weighted averages** (clamped),
9//! - LITM counters merge as **element-wise maxima**,
10//!
11//! both of which make `export → import` on the same machine a no-op
12//! (idempotent roundtrip). The bundle deliberately contains only file
13//! extensions, client-profile names and aggregate numbers — no paths, no
14//! file contents, no identifiers.
15
16use 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    /// RFC3339 export timestamp (informational).
24    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
35/// Snapshot the current learning state into a bundle.
36pub 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
45/// Parse and merge a bundle into the local stores. Fails on schema mismatch
46/// rather than guessing — a future schema bump must ship its own migration.
47pub 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        // (0.10*30 + -0.05*10) / 40 = 0.0625
90        assert!((d.delta_entropy - 0.0625).abs() < 1e-9);
91        assert_eq!(d.samples, 30);
92
93        // Re-merging the merged state with itself must not move anything.
94        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        // A hand-edited or corrupt bundle cannot push past the clamp.
104        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        // Idempotent re-merge.
129        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        // The bundle speaks in extensions and profiles only.
156        assert!(!json.contains('/'), "no path separators expected: {json}");
157    }
158}