#![allow(clippy::arithmetic_side_effects)]
use serde::{Deserialize, Serialize};
use super::session::{TuneRunRecord, TuneSessionStore};
use super::{TuneBudget, TuneReport};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FedHit {
pub id: String,
pub score: f32,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedTuneParams {
pub session_id: String,
#[serde(default)]
pub agent_id: Option<String>,
#[serde(default)]
pub context_id: Option<String>,
pub hits: Vec<FedHit>,
#[serde(default)]
pub local_weight_sweep: Option<Vec<f32>>,
#[serde(default)]
pub top_k: Option<usize>,
#[serde(default)]
pub budget: TuneBudget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedTuneCandidate {
pub local_weight: f32,
pub ordered_ids: Vec<String>,
pub local_count: usize,
pub remote_count: usize,
pub top_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FederatedTuneReport {
pub tool: String,
pub session_id: String,
pub run_id: String,
pub candidates: Vec<FederatedTuneCandidate>,
pub warnings: Vec<String>,
pub report: TuneReport,
}
pub fn federated_tune(
store: &TuneSessionStore,
params: FederatedTuneParams,
) -> Result<FederatedTuneReport, String> {
let _sess = store
.get(
¶ms.session_id,
params.agent_id.as_deref(),
params.context_id.as_deref(),
)
.ok_or_else(|| "session not found".to_string())?;
if params.hits.is_empty() {
return Err(
"hits must not be empty — pass [{id, score, source}] where source is \"local\" or \"remote\"; \
federated_tune does not fetch upstream. Call search first, then tag each \
hit with its origin."
.into(),
);
}
let top_k = params
.top_k
.unwrap_or(10)
.clamp(1, params.budget.max_results);
let mut weights = params.local_weight_sweep.clone().unwrap_or_default();
if weights.is_empty() {
weights = vec![0.3, 0.5, 0.7];
}
if weights.len() > params.budget.max_sweep_points {
return Err(format!(
"sweep has {} points; max is {}",
weights.len(),
params.budget.max_sweep_points
));
}
let mut candidates = Vec::with_capacity(weights.len());
for &w in &weights {
let w = w.clamp(0.0, 1.0);
let mut scored: Vec<(String, f32, bool)> = params
.hits
.iter()
.map(|h| {
let is_local = h.source.eq_ignore_ascii_case("local");
let adj = if is_local {
h.score * w
} else {
h.score * (1.0 - w)
};
(h.id.clone(), adj, is_local)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(top_k);
let local_count = scored.iter().filter(|(_, _, l)| *l).count();
let remote_count = scored.len() - local_count;
let top_id = scored.first().map(|(id, _, _)| id.clone());
let ordered_ids = scored.into_iter().map(|(id, _, _)| id).collect();
candidates.push(FederatedTuneCandidate {
local_weight: w,
ordered_ids,
local_count,
remote_count,
top_id,
});
}
let run_id = new_run_id();
let params_json = serde_json::json!({
"local_weight_sweep": weights,
"top_k": top_k,
"hit_count": params.hits.len(),
});
let mut report = TuneReport::new("federated_tune", ¶ms.session_id, &run_id);
report.budget = params.budget;
report.params_used = params_json.clone();
report.candidates = candidates
.iter()
.filter_map(|c| serde_json::to_value(c).ok())
.collect();
report.metrics = serde_json::json!({ "candidates": candidates.len() });
store.append_run(
¶ms.session_id,
params.agent_id.as_deref(),
TuneRunRecord {
run_id: run_id.clone(),
tool: "federated_tune".into(),
params: params_json,
metrics: report.metrics.clone(),
selected: false,
},
)?;
Ok(FederatedTuneReport {
tool: "federated_tune".into(),
session_id: params.session_id,
run_id,
candidates,
warnings: Vec::new(),
report,
})
}
fn new_run_id() -> String {
format!(
"run-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
)
}