/home/noah/src/realizar/src/api/apr_handlers.rs
Line | Count | Source |
1 | | //! APR-specific API handlers |
2 | | //! |
3 | | //! Extracted from api/mod.rs (PMAT-802) to reduce module size. |
4 | | //! Contains prediction, explanation, and audit handlers for APR models. |
5 | | |
6 | | use axum::{ |
7 | | extract::{Path, State}, |
8 | | http::StatusCode, |
9 | | Json, |
10 | | }; |
11 | | |
12 | | use super::{ |
13 | | AppState, ErrorResponse, PredictRequest, PredictResponse, PredictionWithScore, |
14 | | ExplainRequest, ExplainResponse, AuditResponse, ShapExplanation, |
15 | | }; |
16 | | |
17 | | // ============================================================================ |
18 | | // APR-Specific API Handlers (spec ยง15.1) |
19 | | // ============================================================================ |
20 | | |
21 | | /// APR prediction handler (/v1/predict) |
22 | | /// |
23 | | /// Handles classification and regression predictions for APR models. |
24 | | /// APR v2 prediction handler - tensor-based inference |
25 | | /// |
26 | | /// Note: APR v2 uses tensor-based access rather than direct predict(). |
27 | | /// For LLM inference, use the /generate endpoint instead. |
28 | 2 | pub(crate) async fn apr_predict_handler( |
29 | 2 | State(state): State<AppState>, |
30 | 2 | Json(request): Json<PredictRequest>, |
31 | 2 | ) -> Result<Json<PredictResponse>, (StatusCode, Json<ErrorResponse>)> { |
32 | 2 | let start = std::time::Instant::now(); |
33 | | |
34 | | // Validate input features |
35 | 2 | if request.features.is_empty() { |
36 | 1 | return Err(( |
37 | 1 | StatusCode::BAD_REQUEST, |
38 | 1 | Json(ErrorResponse { |
39 | 1 | error: "Input features cannot be empty".to_string(), |
40 | 1 | }), |
41 | 1 | )); |
42 | 1 | } |
43 | | |
44 | | // Get APR model from state |
45 | 1 | let apr_model0 = state.apr_model.as_ref().ok_or_else(|| { |
46 | 1 | ( |
47 | 1 | StatusCode::SERVICE_UNAVAILABLE, |
48 | 1 | Json(ErrorResponse { |
49 | 1 | error: "No APR model loaded. Use AppState::demo() or load a .apr model." |
50 | 1 | .to_string(), |
51 | 1 | }), |
52 | 1 | ) |
53 | 1 | })?; |
54 | | |
55 | | // Log request to audit trail |
56 | 0 | let model_name = apr_model |
57 | 0 | .metadata() |
58 | 0 | .name |
59 | 0 | .clone() |
60 | 0 | .unwrap_or_else(|| "unknown".to_string()); |
61 | 0 | let request_id = state |
62 | 0 | .audit_logger |
63 | 0 | .log_request(&model_name, &[request.features.len()]); |
64 | | |
65 | | // APR v2 uses tensor-based inference |
66 | | // For simple regression/classification, we need a weights tensor |
67 | 0 | let output = apr_model |
68 | 0 | .get_tensor_f32("weights") |
69 | 0 | .or_else(|_| apr_model.get_tensor_f32("output")) |
70 | 0 | .map_err(|e| { |
71 | 0 | ( |
72 | 0 | StatusCode::BAD_REQUEST, |
73 | 0 | Json(ErrorResponse { |
74 | 0 | error: format!("Inference failed: {e}. Use /generate for LLM inference."), |
75 | 0 | }), |
76 | 0 | ) |
77 | 0 | })?; |
78 | | |
79 | | // Simple linear prediction: output = features * weights (demo only) |
80 | 0 | let output: Vec<f32> = if output.len() == request.features.len() { |
81 | 0 | vec![request |
82 | 0 | .features |
83 | 0 | .iter() |
84 | 0 | .zip(output.iter()) |
85 | 0 | .map(|(f, w)| f * w) |
86 | 0 | .sum()] |
87 | | } else { |
88 | | // Just return first few weights as output |
89 | 0 | output.into_iter().take(10).collect() |
90 | | }; |
91 | | |
92 | | // Convert output to prediction (regression or classification) |
93 | 0 | let prediction = if output.len() == 1 { |
94 | | // Regression: single value |
95 | 0 | serde_json::json!(output[0]) |
96 | | } else { |
97 | | // Classification: argmax for class label |
98 | 0 | let max_idx = output |
99 | 0 | .iter() |
100 | 0 | .enumerate() |
101 | 0 | .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) |
102 | 0 | .map_or(0, |(i, _)| i); |
103 | 0 | serde_json::json!(format!("class_{}", max_idx)) |
104 | | }; |
105 | | |
106 | | // Compute confidence (for classification: max probability after softmax) |
107 | 0 | let confidence = if output.len() > 1 { |
108 | | // Softmax then take max |
109 | 0 | let max_val = output.iter().copied().fold(f32::NEG_INFINITY, f32::max); |
110 | 0 | let exp_sum: f32 = output.iter().map(|x| (x - max_val).exp()).sum(); |
111 | 0 | let probs: Vec<f32> = output |
112 | 0 | .iter() |
113 | 0 | .map(|x| (x - max_val).exp() / exp_sum) |
114 | 0 | .collect(); |
115 | 0 | probs.into_iter().fold(0.0_f32, f32::max) |
116 | | } else { |
117 | | // Regression: use 1.0 confidence |
118 | 0 | 1.0 |
119 | | }; |
120 | | |
121 | | // Top-k predictions (for classification) |
122 | 0 | let top_k_predictions = request.top_k.map(|k| { |
123 | 0 | if output.len() > 1 { |
124 | | // Compute softmax |
125 | 0 | let max_val = output.iter().copied().fold(f32::NEG_INFINITY, f32::max); |
126 | 0 | let exp_sum: f32 = output.iter().map(|x| (x - max_val).exp()).sum(); |
127 | 0 | let mut probs: Vec<(usize, f32)> = output |
128 | 0 | .iter() |
129 | 0 | .enumerate() |
130 | 0 | .map(|(i, x)| (i, (x - max_val).exp() / exp_sum)) |
131 | 0 | .collect(); |
132 | 0 | probs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); |
133 | 0 | probs |
134 | 0 | .into_iter() |
135 | 0 | .take(k) |
136 | 0 | .map(|(i, score)| PredictionWithScore { |
137 | 0 | label: format!("class_{}", i), |
138 | 0 | score, |
139 | 0 | }) |
140 | 0 | .collect() |
141 | | } else { |
142 | | // Regression: no top-k |
143 | 0 | vec![PredictionWithScore { |
144 | 0 | label: format!("{:.4}", output[0]), |
145 | 0 | score: 1.0, |
146 | 0 | }] |
147 | | } |
148 | 0 | }); |
149 | | |
150 | 0 | let latency_ms = start.elapsed().as_secs_f64() * 1000.0; |
151 | | |
152 | | // Log response to audit trail |
153 | 0 | state.audit_logger.log_response( |
154 | 0 | request_id, |
155 | 0 | prediction.clone(), |
156 | 0 | start.elapsed(), |
157 | 0 | Some(confidence), |
158 | 0 | ); |
159 | | |
160 | | Ok(Json(PredictResponse { |
161 | 0 | request_id: request_id.to_string(), |
162 | 0 | model: request.model.unwrap_or_else(|| "default".to_string()), |
163 | 0 | prediction, |
164 | 0 | confidence: if request.include_confidence { |
165 | 0 | Some(confidence) |
166 | | } else { |
167 | 0 | None |
168 | | }, |
169 | 0 | top_k_predictions, |
170 | 0 | latency_ms, |
171 | | })) |
172 | 2 | } |
173 | | |
174 | | /// APR explanation handler (/v1/explain) |
175 | | /// |
176 | | /// Returns SHAP-based feature importance explanations for APR models. |
177 | 3 | pub(crate) async fn apr_explain_handler( |
178 | 3 | State(_state): State<AppState>, |
179 | 3 | Json(request): Json<ExplainRequest>, |
180 | 3 | ) -> Result<Json<ExplainResponse>, (StatusCode, Json<ErrorResponse>)> { |
181 | 3 | let start = std::time::Instant::now(); |
182 | 3 | let request_id = uuid::Uuid::new_v4().to_string(); |
183 | | |
184 | | // Validate inputs |
185 | 3 | if request.features.is_empty() { |
186 | 1 | return Err(( |
187 | 1 | StatusCode::BAD_REQUEST, |
188 | 1 | Json(ErrorResponse { |
189 | 1 | error: "Input features cannot be empty".to_string(), |
190 | 1 | }), |
191 | 1 | )); |
192 | 2 | } |
193 | | |
194 | 2 | if request.feature_names.len() != request.features.len() { |
195 | 1 | return Err(( |
196 | 1 | StatusCode::BAD_REQUEST, |
197 | 1 | Json(ErrorResponse { |
198 | 1 | error: format!( |
199 | 1 | "Feature names count ({}) must match features count ({})", |
200 | 1 | request.feature_names.len(), |
201 | 1 | request.features.len() |
202 | 1 | ), |
203 | 1 | }), |
204 | 1 | )); |
205 | 1 | } |
206 | | |
207 | | // Demo SHAP values (in production, would use ShapExplainer) |
208 | 1 | let shap_values: Vec<f32> = request |
209 | 1 | .features |
210 | 1 | .iter() |
211 | 1 | .enumerate() |
212 | 3 | .map1 (|(i, _)| 0.1 - (i as f32 * 0.02)) |
213 | 1 | .collect(); |
214 | | |
215 | 1 | let explanation = ShapExplanation { |
216 | 1 | base_value: 0.0, |
217 | 1 | shap_values: shap_values.clone(), |
218 | 1 | feature_names: request.feature_names.clone(), |
219 | 1 | prediction: 0.95, |
220 | 1 | }; |
221 | | |
222 | | // Build summary from top features |
223 | 1 | let mut feature_importance: Vec<_> = request |
224 | 1 | .feature_names |
225 | 1 | .iter() |
226 | 1 | .zip(shap_values.iter()) |
227 | 1 | .collect(); |
228 | 2 | feature_importance1 .sort_by1 (|a, b| { |
229 | 2 | b.1.abs() |
230 | 2 | .partial_cmp(&a.1.abs()) |
231 | 2 | .unwrap_or(std::cmp::Ordering::Equal) |
232 | 2 | }); |
233 | | |
234 | 1 | let top_features: Vec<_> = feature_importance |
235 | 1 | .iter() |
236 | 1 | .take(request.top_k_features) |
237 | 1 | .collect(); |
238 | | |
239 | 1 | let summary = if top_features.is_empty() { |
240 | 0 | "No significant features found.".to_string() |
241 | | } else { |
242 | 1 | let feature_strs: Vec<String> = top_features |
243 | 1 | .iter() |
244 | 2 | .map1 (|(name, val)| { |
245 | 2 | let direction = if **val > 0.0 { "+" } else { "-"0 }; |
246 | 2 | format!("{} ({})", name, direction) |
247 | 2 | }) |
248 | 1 | .collect(); |
249 | 1 | format!("Top contributing features: {}", feature_strs.join(", ")) |
250 | | }; |
251 | | |
252 | 1 | let latency_ms = start.elapsed().as_secs_f64() * 1000.0; |
253 | | |
254 | | Ok(Json(ExplainResponse { |
255 | 1 | request_id, |
256 | 1 | model: request.model.unwrap_or_else(|| "default".to_string()), |
257 | 1 | prediction: serde_json::json!(0.95), |
258 | 1 | confidence: Some(0.95), |
259 | 1 | explanation, |
260 | 1 | summary, |
261 | 1 | latency_ms, |
262 | | })) |
263 | 3 | } |
264 | | |
265 | | /// APR audit handler (/v1/audit/:request_id) |
266 | | /// |
267 | | /// Retrieves the audit record for a given request ID. |
268 | | /// Real implementation using AuditLogger - NOT a stub. |
269 | 2 | pub(crate) async fn apr_audit_handler( |
270 | 2 | State(state): State<AppState>, |
271 | 2 | Path(request_id): Path<String>, |
272 | 2 | ) -> Result<Json<AuditResponse>, (StatusCode, Json<ErrorResponse>)> { |
273 | | // Validate request_id format (should be UUID) |
274 | 2 | if uuid::Uuid::parse_str(&request_id).is_err() { |
275 | 1 | return Err(( |
276 | 1 | StatusCode::BAD_REQUEST, |
277 | 1 | Json(ErrorResponse { |
278 | 1 | error: format!("Invalid request ID format: {}", request_id), |
279 | 1 | }), |
280 | 1 | )); |
281 | 1 | } |
282 | | |
283 | | // Flush buffer to ensure all records are available |
284 | 1 | let _ = state.audit_logger.flush(); |
285 | | |
286 | | // Search for the record in the audit sink |
287 | 1 | let records = state.audit_sink.records(); |
288 | 1 | let record0 = records |
289 | 1 | .into_iter() |
290 | 1 | .find(|r| r.request_id0 == request_id0 ) |
291 | 1 | .ok_or_else(|| { |
292 | 1 | ( |
293 | 1 | StatusCode::NOT_FOUND, |
294 | 1 | Json(ErrorResponse { |
295 | 1 | error: format!("Audit record not found for request_id: {}", request_id), |
296 | 1 | }), |
297 | 1 | ) |
298 | 1 | })?; |
299 | | |
300 | 0 | Ok(Json(AuditResponse { record })) |
301 | 2 | } |
302 | | |