use tonic::transport::Channel;
use tonic::{Request, Status};
use crate::Error;
use crate::eval::fhe::ClientContext;
use crate::eval::sigmoid;
use crate::model::Objective;
use super::rpc::inference_service_client::InferenceServiceClient;
use super::rpc::{InitSessionRequest, PredictRequest};
use super::{
MAX_GRPC_MESSAGE_BYTES, deserialize_score, serialize_feature, serialize_server_context,
};
pub struct WeirwoodClient {
grpc: InferenceServiceClient<Channel>,
fhe: ClientContext,
session_id: String,
num_features: usize,
objective: Objective,
}
impl WeirwoodClient {
pub async fn connect(dst: impl Into<String>) -> Result<Self, Error> {
let endpoint = tonic::transport::Endpoint::from_shared(dst.into())
.map_err(|e| Error::Transport(format!("invalid server endpoint: {e}")))?;
let channel = endpoint
.connect()
.await
.map_err(|e| Error::Transport(format!("failed to connect to inference server: {e}")))?;
let mut grpc = InferenceServiceClient::new(channel)
.max_decoding_message_size(MAX_GRPC_MESSAGE_BYTES)
.max_encoding_message_size(MAX_GRPC_MESSAGE_BYTES);
let fhe = ClientContext::generate()?;
let server_key_bytes = serialize_server_context(&fhe.server_context())?;
let req = Request::new(InitSessionRequest {
server_key: server_key_bytes,
});
let resp = grpc
.init_session(req)
.await
.map_err(status_to_error)?
.into_inner();
let info = resp.model_info.ok_or_else(|| {
Error::Transport(
"InitSessionResponse missing model_info — server is too old or out of spec"
.to_string(),
)
})?;
let objective = Objective::from_str(&info.objective_name, info.num_class as usize);
Ok(Self {
grpc,
fhe,
session_id: resp.session_id,
num_features: info.num_features as usize,
objective,
})
}
pub async fn predict_proba(&mut self, features: &[f32]) -> Result<f32, Error> {
let raw = self.predict_raw(features).await?;
match &self.objective {
Objective::BinaryLogistic => Ok(sigmoid(raw)),
Objective::RegSquaredError | Objective::Other(_) => Ok(raw),
Objective::MultiSoftmax { num_class } => Err(Error::Format(format!(
"WeirwoodClient::predict_proba does not support multi:softmax \
(num_class={num_class}); the FHE evaluator returns a single \
ensemble sum, not per-class scores"
))),
}
}
pub async fn predict_raw(&mut self, features: &[f32]) -> Result<f32, Error> {
if features.len() != self.num_features {
return Err(Error::Format(format!(
"model expects {} features, got {}",
self.num_features,
features.len()
)));
}
let encrypted = self.fhe.encrypt(features);
let mut feature_bytes = Vec::with_capacity(encrypted.len());
for feat in &encrypted {
feature_bytes.push(serialize_feature(feat)?);
}
let req = Request::new(PredictRequest {
session_id: self.session_id.clone(),
features: feature_bytes,
});
let resp = self
.grpc
.predict(req)
.await
.map_err(status_to_error)?
.into_inner();
let first = resp.encrypted_scores.first().ok_or_else(|| {
Error::Transport("server returned empty encrypted_scores".to_string())
})?;
let encrypted_score = deserialize_score(first)?;
Ok(self.fhe.decrypt_score(&encrypted_score))
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn num_features(&self) -> usize {
self.num_features
}
pub fn objective(&self) -> &Objective {
&self.objective
}
}
fn status_to_error(status: Status) -> Error {
Error::Transport(format!("gRPC error: {status}"))
}