## 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