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
159 lines
5.4 KiB
Rust
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}");
|
|
}
|
|
}
|