Files
yvgude--lean-ctx/rust/src/core/learning_sync.rs
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

159 lines
5.4 KiB
Rust

//! Team sync of the learning layers (#550, VIS-4).
//!
//! Bundles the machine-local learning state — learned compression-threshold
//! deltas (#538) and LITM placement calibration (#539) — into a versioned,
//! secret-free JSON document that can be shared across a team and merged
//! back without double counting:
//!
//! - threshold deltas merge as **sample-weighted averages** (clamped),
//! - LITM counters merge as **element-wise maxima**,
//!
//! both of which make `export → import` on the same machine a no-op
//! (idempotent roundtrip). The bundle deliberately contains only file
//! extensions, client-profile names and aggregate numbers — no paths, no
//! file contents, no identifiers.
use serde::{Deserialize, Serialize};
pub const BUNDLE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LearningBundle {
pub schema_version: u32,
/// RFC3339 export timestamp (informational).
pub exported_at: String,
pub thresholds: crate::core::threshold_learning::ThresholdLearner,
pub litm: crate::core::litm_calibration::LitmCalibration,
}
#[derive(Debug, Clone, Default)]
pub struct MergeReport {
pub threshold_exts: usize,
pub litm_profiles: usize,
}
/// Snapshot the current learning state into a bundle.
pub fn export_bundle() -> LearningBundle {
LearningBundle {
schema_version: BUNDLE_SCHEMA_VERSION,
exported_at: chrono::Utc::now().to_rfc3339(),
thresholds: crate::core::threshold_learning::export_state(),
litm: crate::core::litm_calibration::export_state(),
}
}
/// Parse and merge a bundle into the local stores. Fails on schema mismatch
/// rather than guessing — a future schema bump must ship its own migration.
pub fn import_bundle(json: &str) -> Result<MergeReport, String> {
let bundle: LearningBundle =
serde_json::from_str(json).map_err(|e| format!("invalid learning bundle: {e}"))?;
if bundle.schema_version != BUNDLE_SCHEMA_VERSION {
return Err(format!(
"unsupported bundle schema {} (this build speaks {})",
bundle.schema_version, BUNDLE_SCHEMA_VERSION
));
}
crate::core::threshold_learning::merge_state(&bundle.thresholds);
crate::core::litm_calibration::merge_state(&bundle.litm);
Ok(MergeReport {
threshold_exts: bundle.thresholds.per_ext.len(),
litm_profiles: bundle.litm.per_profile.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::litm_calibration::LitmCalibration;
use crate::core::threshold_learning::{LearnedDelta, ThresholdLearner};
fn learner(ext: &str, delta: f64, samples: u32) -> ThresholdLearner {
let mut l = ThresholdLearner::default();
l.per_ext.insert(
ext.to_string(),
LearnedDelta {
delta_entropy: delta,
samples,
last_decay_day: 20_000,
},
);
l
}
#[test]
fn threshold_merge_is_sample_weighted_and_idempotent() {
let mut a = learner("rs", 0.10, 30);
let b = learner("rs", -0.05, 10);
a.merge_from(&b);
let d = &a.per_ext["rs"];
// (0.10*30 + -0.05*10) / 40 = 0.0625
assert!((d.delta_entropy - 0.0625).abs() < 1e-9);
assert_eq!(d.samples, 30);
// Re-merging the merged state with itself must not move anything.
let frozen = a.clone();
a.merge_from(&frozen);
assert!((a.per_ext["rs"].delta_entropy - 0.0625).abs() < 1e-9);
assert_eq!(a.per_ext["rs"].samples, 30);
}
#[test]
fn threshold_merge_clamps_foreign_deltas() {
let mut a = ThresholdLearner::default();
// A hand-edited or corrupt bundle cannot push past the clamp.
let b = learner("py", 9.0, 50);
a.merge_from(&b);
assert!(a.per_ext["py"].delta_entropy <= 0.15 + 1e-9);
}
#[test]
fn litm_merge_takes_elementwise_max() {
let mut a = LitmCalibration::default();
a.record(
"claude",
crate::core::litm_calibration::Position::Begin,
true,
);
let mut b = LitmCalibration::default();
for _ in 0..5 {
b.record(
"claude",
crate::core::litm_calibration::Position::Begin,
true,
);
}
a.merge_from(&b);
assert_eq!(a.per_profile["claude"].begin_hits, 5);
// Idempotent re-merge.
let frozen = a.clone();
a.merge_from(&frozen);
assert_eq!(a.per_profile["claude"].begin_hits, 5);
}
#[test]
fn import_rejects_wrong_schema() {
let bundle = LearningBundle {
schema_version: 99,
exported_at: "2026-06-11T00:00:00Z".to_string(),
thresholds: ThresholdLearner::default(),
litm: LitmCalibration::default(),
};
let json = serde_json::to_string(&bundle).unwrap();
assert!(import_bundle(&json).is_err());
}
#[test]
fn bundle_contains_no_paths() {
let bundle = LearningBundle {
schema_version: BUNDLE_SCHEMA_VERSION,
exported_at: "2026-06-11T00:00:00Z".to_string(),
thresholds: learner("rs", 0.05, 12),
litm: LitmCalibration::default(),
};
let json = serde_json::to_string(&bundle).unwrap();
// The bundle speaks in extensions and profiles only.
assert!(!json.contains('/'), "no path separators expected: {json}");
}
}