Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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