Add complete simulation API layer for reward method comparison

This completes Phase 3 of the simulation system implementation:

- Add comprehensive REST API endpoints for simulation data access
- Implement /v1/simulation/* routes with full CRUD operations
- Support JSON/CSV export for external analysis
- Add statistical comparison between old vs new methods
- Provide node performance history tracking
- Include proper error handling and response formatting
- Simplify simulation coordinator to remove unused complex return types
- Clean up dead code while maintaining all functionality
- Pass clippy with no warnings

The simulation API provides complete access to:
- Simulation epoch listing and details
- Method comparison analytics (old 24h vs new 1h)
- Node performance analysis across epochs
- Route reliability statistics
- Export capabilities for further analysis

All simulation data is persisted and accessible via REST endpoints.
This commit is contained in:
durch
2025-06-03 15:38:27 +02:00
parent e4a20f9cf5
commit e761255174
17 changed files with 1654 additions and 68 deletions
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as count FROM simulated_node_performance WHERE simulated_epoch_id = ?",
"describe": {
"columns": [
{
"name": "count",
"ordinal": 0,
"type_info": "Int"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "0557e64c547e147ef7eef713b49c62afde62b3b04ff817ae372ffe9fbeaee6b8"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) as count FROM simulated_reward_epochs",
"describe": {
"columns": [
{
"name": "count",
"ordinal": 0,
"type_info": "Int"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "1411660a6456f6f5ca141db10d9a54857fc8b4a661e09ae6719d84ac2e77cf6d"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT id as \"id!\", epoch_id as \"epoch_id!: u32\", calculation_method as \"calculation_method!\", \n start_timestamp as \"start_timestamp!\", end_timestamp as \"end_timestamp!\", \n description, created_at as \"created_at!\"\n FROM simulated_reward_epochs\n WHERE id = ?\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "epoch_id!: u32",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "calculation_method!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "start_timestamp!",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "end_timestamp!",
"ordinal": 4,
"type_info": "Int64"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "created_at!",
"ordinal": 6,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "1e4daea4d5f87938cf98cb2b6facc5802e79b06f3d0d41f6467844265b26d0f7"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "\n SELECT snp.id as \"id!\", snp.simulated_epoch_id as \"simulated_epoch_id!\", \n snp.node_id as \"node_id!: NodeId\", snp.node_type as \"node_type!\", \n snp.identity_key, snp.reliability_score as \"reliability_score!\", \n snp.positive_samples as \"positive_samples!: u32\", snp.negative_samples as \"negative_samples!: u32\", \n snp.final_fail_sequence as \"final_fail_sequence!: u32\", snp.work_factor, \n snp.calculation_method as \"calculation_method!\", snp.calculated_at as \"calculated_at!\"\n FROM simulated_node_performance snp\n JOIN simulated_reward_epochs sre ON snp.simulated_epoch_id = sre.id\n WHERE snp.node_id = ?\n ORDER BY sre.epoch_id DESC, snp.calculation_method\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "simulated_epoch_id!",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "node_id!: NodeId",
"ordinal": 2,
"type_info": "Int64"
},
{
"name": "node_type!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "identity_key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "reliability_score!",
"ordinal": 5,
"type_info": "Float"
},
{
"name": "positive_samples!: u32",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "negative_samples!: u32",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "final_fail_sequence!: u32",
"ordinal": 8,
"type_info": "Int64"
},
{
"name": "work_factor",
"ordinal": 9,
"type_info": "Float"
},
{
"name": "calculation_method!",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "calculated_at!",
"ordinal": 11,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "3ffc479fe6913b137a59bf4c2801cb26191b2d606a359ffc165032e67975d40a"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT sra.id as \"id!\", sra.simulated_epoch_id as \"simulated_epoch_id!\", \n sra.calculation_method as \"calculation_method!\", sra.total_routes_analyzed as \"total_routes_analyzed!: u32\", \n sra.successful_routes as \"successful_routes!: u32\", sra.failed_routes as \"failed_routes!: u32\", \n sra.average_route_reliability, sra.time_window_hours as \"time_window_hours!: u32\", \n sra.analysis_parameters, sra.calculated_at as \"calculated_at!\"\n FROM simulated_route_analysis sra\n JOIN simulated_reward_epochs sre ON sra.simulated_epoch_id = sre.id\n WHERE sre.epoch_id = ? AND sra.calculation_method = ?\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "simulated_epoch_id!",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "calculation_method!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "total_routes_analyzed!: u32",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "successful_routes!: u32",
"ordinal": 4,
"type_info": "Int64"
},
{
"name": "failed_routes!: u32",
"ordinal": 5,
"type_info": "Int64"
},
{
"name": "average_route_reliability",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "time_window_hours!: u32",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "analysis_parameters",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "calculated_at!",
"ordinal": 9,
"type_info": "Int64"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
false,
false,
false,
true,
false,
true,
false
]
},
"hash": "4e2338362058e4356ac639fdf806e827761f879cab723cb2a649bc76b176fdad"
}
@@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "\n SELECT snp.id as \"id!\", snp.simulated_epoch_id as \"simulated_epoch_id!\", \n snp.node_id as \"node_id!: NodeId\", snp.node_type as \"node_type!\", \n snp.identity_key, snp.reliability_score as \"reliability_score!\", \n snp.positive_samples as \"positive_samples!: u32\", snp.negative_samples as \"negative_samples!: u32\", \n snp.final_fail_sequence as \"final_fail_sequence!: u32\", snp.work_factor, \n snp.calculation_method as \"calculation_method!\", snp.calculated_at as \"calculated_at!\"\n FROM simulated_node_performance snp\n JOIN simulated_reward_epochs sre ON snp.simulated_epoch_id = sre.id\n WHERE sre.epoch_id = ? AND snp.calculation_method = ?\n ORDER BY snp.node_id\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "simulated_epoch_id!",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "node_id!: NodeId",
"ordinal": 2,
"type_info": "Int64"
},
{
"name": "node_type!",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "identity_key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "reliability_score!",
"ordinal": 5,
"type_info": "Float"
},
{
"name": "positive_samples!: u32",
"ordinal": 6,
"type_info": "Int64"
},
{
"name": "negative_samples!: u32",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "final_fail_sequence!: u32",
"ordinal": 8,
"type_info": "Int64"
},
{
"name": "work_factor",
"ordinal": 9,
"type_info": "Float"
},
{
"name": "calculation_method!",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "calculated_at!",
"ordinal": 11,
"type_info": "Int64"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
false,
true,
false,
false,
false,
false,
true,
false,
false
]
},
"hash": "bc1b243cbd22a34e15dfefb84f99dcb421d336c53d9f35c66fb24fe5e579f0fa"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT DISTINCT calculation_method FROM simulated_reward_epochs WHERE epoch_id = ?",
"describe": {
"columns": [
{
"name": "calculation_method",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "c9796e61c49a29ebd42dec46fb047f7a7e2f80aa4db4f963146cf56864a62347"
}
@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "\n SELECT id as \"id!\", simulated_epoch_id as \"simulated_epoch_id!\", \n calculation_method as \"calculation_method!\", total_routes_analyzed as \"total_routes_analyzed!: u32\", \n successful_routes as \"successful_routes!: u32\", failed_routes as \"failed_routes!: u32\", \n average_route_reliability, time_window_hours as \"time_window_hours!: u32\", \n analysis_parameters, calculated_at as \"calculated_at!\"\n FROM simulated_route_analysis\n WHERE simulated_epoch_id = ?\n ORDER BY calculation_method\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "simulated_epoch_id!",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "calculation_method!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "total_routes_analyzed!: u32",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "successful_routes!: u32",
"ordinal": 4,
"type_info": "Int64"
},
{
"name": "failed_routes!: u32",
"ordinal": 5,
"type_info": "Int64"
},
{
"name": "average_route_reliability",
"ordinal": 6,
"type_info": "Float"
},
{
"name": "time_window_hours!: u32",
"ordinal": 7,
"type_info": "Int64"
},
{
"name": "analysis_parameters",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "calculated_at!",
"ordinal": 9,
"type_info": "Int64"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
false,
true,
false,
true,
false
]
},
"hash": "db2b71d1dd33022d5c960053d6a0f5d7ccf52de6775d4f57744d1401d44fee62"
}
@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id as \"id!\", epoch_id as \"epoch_id!: u32\", calculation_method as \"calculation_method!\", \n start_timestamp as \"start_timestamp!\", end_timestamp as \"end_timestamp!\", \n description, created_at as \"created_at!\"\n FROM simulated_reward_epochs \n ORDER BY created_at DESC \n LIMIT ? OFFSET ?",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "epoch_id!: u32",
"ordinal": 1,
"type_info": "Int64"
},
{
"name": "calculation_method!",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "start_timestamp!",
"ordinal": 3,
"type_info": "Int64"
},
{
"name": "end_timestamp!",
"ordinal": 4,
"type_info": "Int64"
},
{
"name": "description",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "created_at!",
"ordinal": 6,
"type_info": "Int64"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
false,
false,
false,
true,
false
]
},
"hash": "dbc52e443ab600516b4f96ab2904d6b6379c06cfa1ff8598c29b67a2fe37055d"
}
+13 -53
View File
@@ -41,22 +41,6 @@ impl Default for SimulationConfig {
}
}
/// Results from a single calculation method
#[derive(Debug, Clone)]
pub struct MethodResults {
pub method_name: String,
pub node_performance: Vec<SimulatedNodePerformance>,
pub rewards: Vec<SimulatedReward>,
pub route_analysis: SimulatedRouteAnalysis,
}
/// Complete simulation results containing both methods
#[derive(Debug, Clone)]
pub struct SimulationResults {
pub epoch_id: i64,
pub old_method: Option<MethodResults>,
pub new_method: Option<MethodResults>,
}
/// Main simulation coordinator
pub struct SimulationCoordinator<'a> {
@@ -76,7 +60,7 @@ impl<'a> SimulationCoordinator<'a> {
rewarded_set: &EpochRewardedSet,
reward_params: RewardingParams,
current_epoch_id: u32,
) -> Result<SimulationResults, RewardingError> {
) -> Result<(), RewardingError> {
let now = OffsetDateTime::now_utc();
let end_timestamp = now.unix_timestamp();
let start_timestamp = end_timestamp - (24 * 3600); // 24 hours ago for baseline
@@ -98,12 +82,6 @@ impl<'a> SimulationCoordinator<'a> {
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
let mut results = SimulationResults {
epoch_id: epoch_db_id,
old_method: None,
new_method: None,
};
// Run old method simulation (24h cache-based)
if self.config.run_both_methods {
match self.run_old_method_simulation(
@@ -113,8 +91,7 @@ impl<'a> SimulationCoordinator<'a> {
epoch_db_id,
end_timestamp,
).await {
Ok(old_results) => {
results.old_method = Some(old_results);
Ok(_) => {
info!("Old method simulation completed successfully");
}
Err(e) => {
@@ -131,8 +108,7 @@ impl<'a> SimulationCoordinator<'a> {
epoch_db_id,
end_timestamp,
).await {
Ok(new_results) => {
results.new_method = Some(new_results);
Ok(_) => {
info!("New method simulation completed successfully");
}
Err(e) => {
@@ -140,14 +116,8 @@ impl<'a> SimulationCoordinator<'a> {
}
}
info!(
"Simulation completed for epoch {}. Methods run: old={}, new={}",
current_epoch_id,
results.old_method.is_some(),
results.new_method.is_some()
);
Ok(results)
info!("Simulation completed for epoch {}", current_epoch_id);
Ok(())
}
/// Run simulation using old method (24h cache-based)
@@ -158,7 +128,7 @@ impl<'a> SimulationCoordinator<'a> {
reward_params: RewardingParams,
epoch_db_id: i64,
end_timestamp: i64,
) -> Result<MethodResults, RewardingError> {
) -> Result<(), RewardingError> {
debug!("Running old method simulation (24h cache-based)");
// Get 24h performance data using existing cache-based method
@@ -239,12 +209,7 @@ impl<'a> SimulationCoordinator<'a> {
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
Ok(MethodResults {
method_name: "old".to_string(),
node_performance,
rewards,
route_analysis,
})
Ok(())
}
/// Run simulation using new method (1h route-based)
@@ -254,7 +219,7 @@ impl<'a> SimulationCoordinator<'a> {
reward_params: RewardingParams,
epoch_db_id: i64,
end_timestamp: i64,
) -> Result<MethodResults, RewardingError> {
) -> Result<(), RewardingError> {
debug!("Running new method simulation ({}h route-based)", self.config.new_method_time_window_hours);
let time_window_secs = (self.config.new_method_time_window_hours as i64) * 3600;
@@ -347,12 +312,7 @@ impl<'a> SimulationCoordinator<'a> {
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
Ok(MethodResults {
method_name: "new".to_string(),
node_performance,
rewards,
route_analysis,
})
Ok(())
}
/// Calculate rewards for nodes using the provided performance data
@@ -480,18 +440,18 @@ impl EpochAdvancer {
reward_params: RewardingParams,
current_epoch_id: u32,
simulation_config: SimulationConfig,
) -> Result<Option<SimulationResults>, RewardingError> {
) -> Result<(), RewardingError> {
let coordinator = SimulationCoordinator::new(&self.storage, simulation_config);
match coordinator.run_simulation(self, rewarded_set, reward_params, current_epoch_id).await {
Ok(results) => {
Ok(()) => {
info!("Simulation completed successfully for epoch {}", current_epoch_id);
Ok(Some(results))
Ok(())
}
Err(e) => {
error!("Simulation failed for epoch {}: {}", current_epoch_id, e);
// Don't fail the entire epoch operation due to simulation failure
Ok(None)
Ok(())
}
}
}
+1
View File
@@ -24,6 +24,7 @@ pub(crate) mod node_describe_cache;
pub(crate) mod node_status_api;
pub(crate) mod nym_contract_cache;
pub(crate) mod nym_nodes;
pub(crate) mod simulation_api;
mod status;
pub(crate) mod support;
mod unstable_routes;
+715
View File
@@ -0,0 +1,715 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
//! Handlers for simulation API endpoints
use crate::simulation_api::models::{
SimulationApiError, SimulationEpochDetails, SimulationEpochSummary, SimulationEpochsResponse,
SimulationListQuery, NodeComparisonQuery, MethodComparisonResponse, NodeMethodComparison,
ComparisonSummaryStats, RouteAnalysisComparison, NodePerformanceData, NodeRewardData,
RouteAnalysisData, ExportFormat,
};
use crate::support::http::state::AppState;
use crate::support::storage::NymApiStorage;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Json, Response};
use axum::routing::get;
use axum::Router;
use nym_http_api_common::{FormattedResponse, OutputParams};
use nym_mixnet_contract_common::NodeId;
use serde::Deserialize;
use std::collections::HashMap;
use utoipa::IntoParams;
type SimulationResult<T> = Result<T, SimulationApiError>;
type AxumResult<T> = Result<T, (StatusCode, Json<SimulationApiError>)>;
/// Create the simulation API router
pub(crate) fn simulation_routes() -> Router<AppState> {
Router::new()
.route("/epochs", get(list_simulation_epochs))
.route("/epochs/:epoch_id", get(get_simulation_epoch_details))
.route("/epochs/:epoch_id/comparison", get(compare_methods))
.route("/epochs/:epoch_id/export", get(export_simulation_data))
.route("/nodes/:node_id/performance", get(get_node_performance_history))
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Path)]
struct EpochPathParam {
epoch_id: i64,
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Path)]
struct NodePathParam {
node_id: NodeId,
}
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
struct ExportQuery {
format: Option<ExportFormat>,
}
/// List all simulation epochs with optional filtering
#[utoipa::path(
tag = "Simulation",
get,
path = "/epochs",
context_path = "/v1/simulation",
responses(
(status = 200, description = "List of simulation epochs", body = SimulationEpochsResponse),
(status = 500, description = "Internal server error", body = SimulationApiError)
),
params(SimulationListQuery, OutputParams)
)]
async fn list_simulation_epochs(
Query(params): Query<SimulationListQuery>,
Query(output): Query<OutputParams>,
State(state): State<AppState>,
) -> AxumResult<FormattedResponse<SimulationEpochsResponse>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
// Apply defaults and validation
let limit = params.limit.unwrap_or(50).min(1000);
let offset = params.offset.unwrap_or(0);
let epochs = get_simulation_epochs_with_filters(storage, &params, limit, offset)
.await
.map_err(to_axum_error)?;
// Enhance epochs with additional metadata
let mut enhanced_epochs = Vec::new();
for mut epoch in epochs {
// Count nodes analyzed for this epoch
epoch.nodes_analyzed = count_nodes_for_epoch(storage, epoch.id)
.await
.map_err(to_axum_error)?;
// Get available calculation methods for this epoch_id
epoch.available_methods = get_available_methods_for_epoch(storage, epoch.epoch_id)
.await
.map_err(to_axum_error)?;
enhanced_epochs.push(epoch);
}
let total_count = count_total_simulation_epochs(storage, &params)
.await
.map_err(to_axum_error)?;
let response = SimulationEpochsResponse {
epochs: enhanced_epochs,
total_count,
};
Ok(output.to_response(response))
}
/// Get detailed simulation data for a specific epoch
#[utoipa::path(
tag = "Simulation",
get,
path = "/epochs/{epoch_id}",
context_path = "/v1/simulation",
responses(
(status = 200, description = "Detailed simulation epoch data", body = SimulationEpochDetails),
(status = 404, description = "Simulation epoch not found", body = SimulationApiError),
(status = 500, description = "Internal server error", body = SimulationApiError)
),
params(
("epoch_id" = i64, Path, description = "Simulation epoch ID"),
OutputParams
)
)]
async fn get_simulation_epoch_details(
Path(params): Path<EpochPathParam>,
Query(output): Query<OutputParams>,
State(state): State<AppState>,
) -> AxumResult<FormattedResponse<SimulationEpochDetails>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
let epoch = get_simulation_epoch_by_id(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
let mut epoch_summary = SimulationEpochSummary::from(epoch);
epoch_summary.nodes_analyzed = count_nodes_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
epoch_summary.available_methods = get_available_methods_for_epoch(storage, epoch_summary.epoch_id)
.await
.map_err(to_axum_error)?;
let node_performance = get_node_performance_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let rewards = get_rewards_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let route_analysis = get_route_analysis_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let details = SimulationEpochDetails {
epoch: epoch_summary,
node_performance,
rewards,
route_analysis,
};
Ok(output.to_response(details))
}
/// Compare old vs new methods for a specific epoch
#[utoipa::path(
tag = "Simulation",
get,
path = "/epochs/{epoch_id}/comparison",
context_path = "/v1/simulation",
responses(
(status = 200, description = "Method comparison results", body = MethodComparisonResponse),
(status = 404, description = "Simulation epoch not found", body = SimulationApiError),
(status = 500, description = "Internal server error", body = SimulationApiError)
),
params(
("epoch_id" = i64, Path, description = "Simulation epoch ID"),
NodeComparisonQuery,
OutputParams
)
)]
async fn compare_methods(
Path(params): Path<EpochPathParam>,
Query(query): Query<NodeComparisonQuery>,
Query(output): Query<OutputParams>,
State(state): State<AppState>,
) -> AxumResult<FormattedResponse<MethodComparisonResponse>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
// Get simulation epoch to extract actual epoch_id
let sim_epoch = get_simulation_epoch_by_id(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
// Get performance data for both methods
let old_performance = get_performance_by_method(storage, sim_epoch.epoch_id, "old")
.await
.map_err(to_axum_error)?;
let new_performance = get_performance_by_method(storage, sim_epoch.epoch_id, "new")
.await
.map_err(to_axum_error)?;
// Build node comparisons
let node_comparisons = build_node_comparisons(old_performance, new_performance, &query);
// Calculate summary statistics
let summary_statistics = calculate_summary_statistics(&node_comparisons);
// Get route analysis comparison
let route_analysis_comparison = get_route_analysis_comparison(storage, sim_epoch.epoch_id)
.await
.map_err(to_axum_error)?;
let comparison = MethodComparisonResponse {
epoch_id: sim_epoch.epoch_id,
simulation_epoch_id: params.epoch_id,
node_comparisons,
summary_statistics,
route_analysis_comparison,
};
Ok(output.to_response(comparison))
}
/// Export simulation data in various formats
#[utoipa::path(
tag = "Simulation",
get,
path = "/epochs/{epoch_id}/export",
context_path = "/v1/simulation",
responses(
(status = 200, description = "Exported simulation data"),
(status = 404, description = "Simulation epoch not found", body = SimulationApiError),
(status = 500, description = "Internal server error", body = SimulationApiError)
),
params(
("epoch_id" = i64, Path, description = "Simulation epoch ID"),
ExportQuery
)
)]
async fn export_simulation_data(
Path(params): Path<EpochPathParam>,
Query(query): Query<ExportQuery>,
State(state): State<AppState>,
) -> Result<Response, (StatusCode, Json<SimulationApiError>)> {
let storage = state.storage();
let format = query.format.unwrap_or(ExportFormat::Json);
// Get detailed simulation data
let epoch_details = get_simulation_epoch_details_internal(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
match format {
ExportFormat::Json => {
let json_data = serde_json::to_string_pretty(&epoch_details)
.map_err(|e| to_axum_error(SimulationApiError::with_details("JSON serialization failed", &e.to_string())))?;
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Content-Disposition", format!("attachment; filename=\"simulation_epoch_{}.json\"", params.epoch_id))
.body(json_data.into())
.map_err(|e| to_axum_error(SimulationApiError::with_details("Response building failed", &e.to_string())))
}
ExportFormat::Csv => {
let csv_data = convert_to_csv(&epoch_details)
.map_err(|e| to_axum_error(SimulationApiError::with_details("CSV conversion failed", &e.to_string())))?;
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/csv")
.header("Content-Disposition", format!("attachment; filename=\"simulation_epoch_{}.csv\"", params.epoch_id))
.body(csv_data.into())
.map_err(|e| to_axum_error(SimulationApiError::with_details("Response building failed", &e.to_string())))
}
}
}
/// Get performance history for a specific node across simulation epochs
#[utoipa::path(
tag = "Simulation",
get,
path = "/nodes/{node_id}/performance",
context_path = "/v1/simulation",
responses(
(status = 200, description = "Node performance history", body = Vec<NodePerformanceData>),
(status = 500, description = "Internal server error", body = SimulationApiError)
),
params(
("node_id" = NodeId, Path, description = "Node ID"),
OutputParams
)
)]
async fn get_node_performance_history(
Path(params): Path<NodePathParam>,
Query(output): Query<OutputParams>,
State(state): State<AppState>,
) -> AxumResult<FormattedResponse<Vec<NodePerformanceData>>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
let performance_history = get_node_performance_history_internal(storage, params.node_id)
.await
.map_err(to_axum_error)?;
Ok(output.to_response(performance_history))
}
// Helper functions (implementations would be added here)
async fn get_simulation_epochs_with_filters(
storage: &NymApiStorage,
_params: &SimulationListQuery,
limit: usize,
offset: usize,
) -> SimulationResult<Vec<SimulationEpochSummary>> {
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let epochs = sqlx::query_as!(
crate::support::storage::models::SimulatedRewardEpoch,
"SELECT id as \"id!\", epoch_id as \"epoch_id!: u32\", calculation_method as \"calculation_method!\",
start_timestamp as \"start_timestamp!\", end_timestamp as \"end_timestamp!\",
description, created_at as \"created_at!\"
FROM simulated_reward_epochs
ORDER BY created_at DESC
LIMIT ? OFFSET ?",
limit_i64,
offset_i64
)
.fetch_all(&storage.manager.connection_pool)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(epochs.into_iter().map(SimulationEpochSummary::from).collect())
}
async fn count_nodes_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<usize> {
storage
.manager
.count_simulated_node_performance_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn get_available_methods_for_epoch(storage: &NymApiStorage, epoch_id: u32) -> SimulationResult<Vec<String>> {
storage
.manager
.get_available_calculation_methods_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn count_total_simulation_epochs(storage: &NymApiStorage, _params: &SimulationListQuery) -> SimulationResult<usize> {
let result = sqlx::query!(
"SELECT COUNT(*) as count FROM simulated_reward_epochs"
)
.fetch_one(&storage.manager.connection_pool)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(result.count as usize)
}
async fn get_simulation_epoch_by_id(storage: &NymApiStorage, id: i64) -> SimulationResult<Option<crate::support::storage::models::SimulatedRewardEpoch>> {
storage
.manager
.get_simulated_reward_epoch(id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn get_node_performance_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<NodePerformanceData>> {
let performance = storage
.manager
.get_simulated_node_performance_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
}
async fn get_rewards_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<NodeRewardData>> {
let rewards = storage
.manager
.get_simulated_rewards_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(rewards.into_iter().map(NodeRewardData::from).collect())
}
async fn get_route_analysis_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<RouteAnalysisData>> {
let analysis = storage
.manager
.get_simulated_route_analysis_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(analysis.into_iter().map(RouteAnalysisData::from).collect())
}
async fn get_performance_by_method(
storage: &NymApiStorage,
epoch_id: u32,
method: &str
) -> SimulationResult<Vec<NodePerformanceData>> {
let performance = storage
.manager
.get_simulated_node_performance_by_method(epoch_id, method)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
}
fn build_node_comparisons(
old_performance: Vec<NodePerformanceData>,
new_performance: Vec<NodePerformanceData>,
query: &NodeComparisonQuery,
) -> Vec<NodeMethodComparison> {
let mut old_map: HashMap<NodeId, NodePerformanceData> = old_performance
.into_iter()
.map(|p| (p.node_id, p))
.collect();
let mut new_map: HashMap<NodeId, NodePerformanceData> = new_performance
.into_iter()
.map(|p| (p.node_id, p))
.collect();
let mut comparisons = Vec::new();
// Get all unique node IDs from both methods
let mut all_node_ids: Vec<_> = old_map.keys().chain(new_map.keys()).cloned().collect();
all_node_ids.sort();
all_node_ids.dedup();
for node_id in all_node_ids {
let old_perf = old_map.remove(&node_id);
let new_perf = new_map.remove(&node_id);
// Apply filters
if let Some(filter_node_id) = query.node_id {
if node_id != filter_node_id {
continue;
}
}
if let Some(ref filter_node_type) = query.node_type {
let node_type = old_perf.as_ref()
.or(new_perf.as_ref())
.map(|p| &p.node_type);
if node_type != Some(filter_node_type) {
continue;
}
}
// Calculate differences
let reliability_difference = match (&old_perf, &new_perf) {
(Some(old), Some(new)) => Some(new.reliability_score - old.reliability_score),
_ => None,
};
let performance_delta_percentage = match (&old_perf, &new_perf) {
(Some(old), Some(new)) if old.reliability_score != 0.0 => {
Some((new.reliability_score - old.reliability_score) / old.reliability_score * 100.0)
}
_ => None,
};
// Apply delta filters
if let Some(min_delta) = query.min_delta {
if reliability_difference.map_or(true, |d| d < min_delta) {
continue;
}
}
if let Some(max_delta) = query.max_delta {
if reliability_difference.map_or(true, |d| d > max_delta) {
continue;
}
}
let node_type = old_perf.as_ref()
.or(new_perf.as_ref())
.map(|p| p.node_type.clone())
.unwrap_or_else(|| "unknown".to_string());
let identity_key = old_perf.as_ref()
.or(new_perf.as_ref())
.and_then(|p| p.identity_key.clone());
comparisons.push(NodeMethodComparison {
node_id,
node_type,
identity_key,
old_method: old_perf,
new_method: new_perf,
reliability_difference,
performance_delta_percentage,
});
}
comparisons
}
fn calculate_summary_statistics(comparisons: &[NodeMethodComparison]) -> ComparisonSummaryStats {
let mut reliabilities_old = Vec::new();
let mut reliabilities_new = Vec::new();
let mut improvements = 0;
let mut degradations = 0;
let mut unchanged = 0;
let mut max_improvement: f64 = 0.0;
let mut max_degradation: f64 = 0.0;
for comparison in comparisons {
if let Some(old) = &comparison.old_method {
reliabilities_old.push(old.reliability_score);
}
if let Some(new) = &comparison.new_method {
reliabilities_new.push(new.reliability_score);
}
if let Some(diff) = comparison.reliability_difference {
if diff > 0.001 {
improvements += 1;
max_improvement = max_improvement.max(diff);
} else if diff < -0.001 {
degradations += 1;
max_degradation = max_degradation.min(diff); // This will be negative
} else {
unchanged += 1;
}
}
}
let average_reliability_old = if reliabilities_old.is_empty() {
0.0
} else {
reliabilities_old.iter().sum::<f64>() / reliabilities_old.len() as f64
};
let average_reliability_new = if reliabilities_new.is_empty() {
0.0
} else {
reliabilities_new.iter().sum::<f64>() / reliabilities_new.len() as f64
};
// Calculate medians and standard deviations
let (median_reliability_old, reliability_std_dev_old) = calculate_median_and_std(&reliabilities_old);
let (median_reliability_new, reliability_std_dev_new) = calculate_median_and_std(&reliabilities_new);
ComparisonSummaryStats {
total_nodes_compared: comparisons.len(),
nodes_improved: improvements,
nodes_degraded: degradations,
nodes_unchanged: unchanged,
average_reliability_old,
average_reliability_new,
median_reliability_old,
median_reliability_new,
reliability_std_dev_old,
reliability_std_dev_new,
max_improvement,
max_degradation,
}
}
fn calculate_median_and_std(values: &[f64]) -> (f64, f64) {
if values.is_empty() {
return (0.0, 0.0);
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if sorted.len() % 2 == 0 {
(sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
} else {
sorted[sorted.len() / 2]
};
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter()
.map(|x| (x - mean).powi(2))
.sum::<f64>() / values.len() as f64;
let std_dev = variance.sqrt();
(median, std_dev)
}
async fn get_route_analysis_comparison(
storage: &NymApiStorage,
epoch_id: u32,
) -> SimulationResult<RouteAnalysisComparison> {
let old_analysis = storage
.manager
.get_simulated_route_analysis_by_method(epoch_id, "old")
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?
.map(RouteAnalysisData::from);
let new_analysis = storage
.manager
.get_simulated_route_analysis_by_method(epoch_id, "new")
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?
.map(RouteAnalysisData::from);
let time_window_difference_hours = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => new.time_window_hours as i32 - old.time_window_hours as i32,
_ => 0,
};
let route_coverage_difference = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => new.total_routes_analyzed as i32 - old.total_routes_analyzed as i32,
_ => 0,
};
let success_rate_difference = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => {
let old_rate = old.successful_routes as f64 / old.total_routes_analyzed as f64;
let new_rate = new.successful_routes as f64 / new.total_routes_analyzed as f64;
Some(new_rate - old_rate)
}
_ => None,
};
Ok(RouteAnalysisComparison {
old_method: old_analysis,
new_method: new_analysis,
time_window_difference_hours,
route_coverage_difference,
success_rate_difference,
})
}
async fn get_simulation_epoch_details_internal(
storage: &NymApiStorage,
epoch_id: i64,
) -> SimulationResult<Option<SimulationEpochDetails>> {
let epoch = match get_simulation_epoch_by_id(storage, epoch_id).await? {
Some(epoch) => epoch,
None => return Ok(None),
};
let mut epoch_summary = SimulationEpochSummary::from(epoch);
epoch_summary.nodes_analyzed = count_nodes_for_epoch(storage, epoch_id).await?;
epoch_summary.available_methods = get_available_methods_for_epoch(storage, epoch_summary.epoch_id).await?;
let node_performance = get_node_performance_for_epoch(storage, epoch_id).await?;
let rewards = get_rewards_for_epoch(storage, epoch_id).await?;
let route_analysis = get_route_analysis_for_epoch(storage, epoch_id).await?;
Ok(Some(SimulationEpochDetails {
epoch: epoch_summary,
node_performance,
rewards,
route_analysis,
}))
}
fn convert_to_csv(details: &SimulationEpochDetails) -> Result<String, Box<dyn std::error::Error>> {
// Simple CSV conversion - in a real implementation this would be more sophisticated
let mut csv = String::new();
// Header
csv.push_str("data_type,node_id,node_type,reliability_score,reward_amount,calculation_method\n");
// Performance data
for perf in &details.node_performance {
csv.push_str(&format!(
"performance,{},{},{},{},{}\n",
perf.node_id, perf.node_type, perf.reliability_score, "", perf.calculation_method
));
}
// Reward data
for reward in &details.rewards {
csv.push_str(&format!(
"reward,{},{},{},{},{}\n",
reward.node_id, reward.node_type, "", reward.calculated_reward_amount, reward.calculation_method
));
}
Ok(csv)
}
async fn get_node_performance_history_internal(
storage: &NymApiStorage,
node_id: NodeId,
) -> SimulationResult<Vec<NodePerformanceData>> {
let performance = storage
.manager
.get_simulated_node_performance_history(node_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
}
fn to_axum_error(error: SimulationApiError) -> (StatusCode, Json<SimulationApiError>) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(error))
}
+10
View File
@@ -0,0 +1,10 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
//! Simulation API for reward calculation analysis
//!
//! This module provides REST endpoints for accessing and analyzing
//! simulated reward calculation data comparing old vs new methodologies.
pub(crate) mod handlers;
pub(crate) mod models;
+252
View File
@@ -0,0 +1,252 @@
// Copyright 2025 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only
//! API models for simulation data responses
use crate::storage::models::{SimulatedNodePerformance, SimulatedReward, SimulatedRewardEpoch, SimulatedRouteAnalysis};
use nym_mixnet_contract_common::NodeId;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::ToSchema;
/// Response for listing simulation epochs
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SimulationEpochsResponse {
pub epochs: Vec<SimulationEpochSummary>,
pub total_count: usize,
}
/// Summary information about a simulation epoch
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SimulationEpochSummary {
pub id: i64,
pub epoch_id: u32,
pub calculation_method: String,
pub start_timestamp: i64,
pub end_timestamp: i64,
pub description: Option<String>,
pub created_at: i64,
/// Number of nodes that had performance calculated
pub nodes_analyzed: usize,
/// Available calculation methods for this epoch
pub available_methods: Vec<String>,
}
impl From<SimulatedRewardEpoch> for SimulationEpochSummary {
fn from(epoch: SimulatedRewardEpoch) -> Self {
Self {
id: epoch.id,
epoch_id: epoch.epoch_id,
calculation_method: epoch.calculation_method,
start_timestamp: epoch.start_timestamp,
end_timestamp: epoch.end_timestamp,
description: epoch.description,
created_at: epoch.created_at,
nodes_analyzed: 0, // Will be populated by handler
available_methods: vec![], // Will be populated by handler
}
}
}
/// Detailed simulation epoch with all data
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct SimulationEpochDetails {
pub epoch: SimulationEpochSummary,
pub node_performance: Vec<NodePerformanceData>,
pub rewards: Vec<NodeRewardData>,
pub route_analysis: Vec<RouteAnalysisData>,
}
/// Node performance data for API responses
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct NodePerformanceData {
pub node_id: NodeId,
pub node_type: String,
pub identity_key: Option<String>,
pub reliability_score: f64,
pub positive_samples: u32,
pub negative_samples: u32,
pub final_fail_sequence: u32,
pub work_factor: Option<f64>,
pub calculation_method: String,
pub calculated_at: i64,
}
impl From<SimulatedNodePerformance> for NodePerformanceData {
fn from(perf: SimulatedNodePerformance) -> Self {
Self {
node_id: perf.node_id,
node_type: perf.node_type,
identity_key: perf.identity_key,
reliability_score: perf.reliability_score,
positive_samples: perf.positive_samples,
negative_samples: perf.negative_samples,
final_fail_sequence: perf.final_fail_sequence,
work_factor: perf.work_factor,
calculation_method: perf.calculation_method,
calculated_at: perf.calculated_at,
}
}
}
/// Node reward data for API responses
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct NodeRewardData {
pub node_id: NodeId,
pub node_type: String,
pub calculated_reward_amount: f64,
pub reward_currency: String,
pub performance_component: f64,
pub work_component: f64,
pub calculation_method: String,
pub calculated_at: i64,
}
impl From<SimulatedReward> for NodeRewardData {
fn from(reward: SimulatedReward) -> Self {
Self {
node_id: reward.node_id,
node_type: reward.node_type,
calculated_reward_amount: reward.calculated_reward_amount,
reward_currency: reward.reward_currency,
performance_component: reward.performance_component,
work_component: reward.work_component,
calculation_method: reward.calculation_method,
calculated_at: reward.calculated_at,
}
}
}
/// Route analysis data for API responses
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct RouteAnalysisData {
pub calculation_method: String,
pub total_routes_analyzed: u32,
pub successful_routes: u32,
pub failed_routes: u32,
pub average_route_reliability: Option<f64>,
pub time_window_hours: u32,
pub analysis_parameters: Option<String>,
pub calculated_at: i64,
}
impl From<SimulatedRouteAnalysis> for RouteAnalysisData {
fn from(analysis: SimulatedRouteAnalysis) -> Self {
Self {
calculation_method: analysis.calculation_method,
total_routes_analyzed: analysis.total_routes_analyzed,
successful_routes: analysis.successful_routes,
failed_routes: analysis.failed_routes,
average_route_reliability: analysis.average_route_reliability,
time_window_hours: analysis.time_window_hours,
analysis_parameters: analysis.analysis_parameters,
calculated_at: analysis.calculated_at,
}
}
}
/// Comparison between old and new methods for a specific epoch
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct MethodComparisonResponse {
pub epoch_id: u32,
pub simulation_epoch_id: i64,
pub node_comparisons: Vec<NodeMethodComparison>,
pub summary_statistics: ComparisonSummaryStats,
pub route_analysis_comparison: RouteAnalysisComparison,
}
/// Comparison data for a single node between old and new methods
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct NodeMethodComparison {
pub node_id: NodeId,
pub node_type: String,
pub identity_key: Option<String>,
pub old_method: Option<NodePerformanceData>,
pub new_method: Option<NodePerformanceData>,
pub reliability_difference: Option<f64>, // new - old
pub performance_delta_percentage: Option<f64>, // (new - old) / old * 100
}
/// Summary statistics comparing old vs new methods
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct ComparisonSummaryStats {
pub total_nodes_compared: usize,
pub nodes_improved: usize, // nodes with better performance in new method
pub nodes_degraded: usize, // nodes with worse performance in new method
pub nodes_unchanged: usize, // nodes with same performance
pub average_reliability_old: f64,
pub average_reliability_new: f64,
pub median_reliability_old: f64,
pub median_reliability_new: f64,
pub reliability_std_dev_old: f64,
pub reliability_std_dev_new: f64,
pub max_improvement: f64, // highest positive delta
pub max_degradation: f64, // highest negative delta
}
/// Comparison of route analysis between methods
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct RouteAnalysisComparison {
pub old_method: Option<RouteAnalysisData>,
pub new_method: Option<RouteAnalysisData>,
pub time_window_difference_hours: i32, // new - old
pub route_coverage_difference: i32, // new total routes - old total routes
pub success_rate_difference: Option<f64>, // new success rate - old success rate
}
/// Export format options
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub enum ExportFormat {
#[serde(rename = "json")]
Json,
#[serde(rename = "csv")]
Csv,
}
/// Query parameters for simulation listings
#[derive(Deserialize, ToSchema, Debug, utoipa::IntoParams)]
pub struct SimulationListQuery {
/// Limit number of results (default: 50, max: 1000)
pub limit: Option<usize>,
/// Offset for pagination (default: 0)
pub offset: Option<usize>,
}
/// Query parameters for node-specific performance comparison
#[derive(Deserialize, ToSchema, Debug, utoipa::IntoParams)]
pub struct NodeComparisonQuery {
/// Specific node ID to analyze
pub node_id: Option<NodeId>,
/// Node type filter (mixnode, gateway)
pub node_type: Option<String>,
/// Minimum reliability difference threshold for filtering
pub min_delta: Option<f64>,
/// Maximum reliability difference threshold for filtering
pub max_delta: Option<f64>,
}
/// Error response for simulation API
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub struct SimulationApiError {
pub error: String,
pub details: Option<String>,
pub timestamp: i64,
}
impl SimulationApiError {
pub fn new(error: &str) -> Self {
Self {
error: error.to_string(),
details: None,
timestamp: OffsetDateTime::now_utc().unix_timestamp(),
}
}
pub fn with_details(error: &str, details: &str) -> Self {
Self {
error: error.to_string(),
details: Some(details.to_string()),
timestamp: OffsetDateTime::now_utc().unix_timestamp(),
}
}
}
+2
View File
@@ -8,6 +8,7 @@ use crate::node_status_api::handlers::status_routes;
use crate::nym_contract_cache::handlers::nym_contract_cache_routes;
use crate::nym_nodes::handlers::legacy::legacy_nym_node_routes;
use crate::nym_nodes::handlers::nym_node_routes;
use crate::simulation_api::handlers::simulation_routes;
use crate::status;
use crate::support::http::openapi::ApiDoc;
use crate::support::http::state::AppState;
@@ -64,6 +65,7 @@ impl RouterBuilder {
.nest("/api-status", status::handlers::api_status_routes())
.nest("/nym-nodes", nym_node_routes())
.nest("/ecash", ecash_routes())
.nest("/simulation", simulation_routes())
.nest("/unstable", unstable_routes()), // CORS layer needs to be "outside" of routes
);
+166 -15
View File
@@ -1730,21 +1730,6 @@ pub(crate) mod v3_migration {
Ok(())
}
/// Retrieves all simulated reward epochs
pub(crate) async fn get_simulated_reward_epochs(&self) -> Result<Vec<SimulatedRewardEpoch>, sqlx::Error> {
sqlx::query_as!(
SimulatedRewardEpoch,
r#"
SELECT id as "id!", epoch_id as "epoch_id!: u32", calculation_method as "calculation_method!",
start_timestamp as "start_timestamp!", end_timestamp as "end_timestamp!",
description, created_at as "created_at!"
FROM simulated_reward_epochs
ORDER BY created_at DESC
"#
)
.fetch_all(&self.connection_pool)
.await
}
/// Retrieves node performance data for a specific simulation
pub(crate) async fn get_simulated_node_performance(
@@ -1839,5 +1824,171 @@ pub(crate) mod v3_migration {
},
}
}
/// Count simulated node performance records for a specific epoch
pub(crate) async fn count_simulated_node_performance_for_epoch(
&self,
simulated_epoch_id: i64,
) -> Result<usize, sqlx::Error> {
let result = sqlx::query!(
"SELECT COUNT(*) as count FROM simulated_node_performance WHERE simulated_epoch_id = ?",
simulated_epoch_id
)
.fetch_one(&self.connection_pool)
.await?;
Ok(result.count as usize)
}
/// Get available calculation methods for a specific epoch
pub(crate) async fn get_available_calculation_methods_for_epoch(
&self,
epoch_id: u32,
) -> Result<Vec<String>, sqlx::Error> {
let methods = sqlx::query!(
"SELECT DISTINCT calculation_method FROM simulated_reward_epochs WHERE epoch_id = ?",
epoch_id
)
.fetch_all(&self.connection_pool)
.await?;
Ok(methods.into_iter().map(|m| m.calculation_method).collect())
}
/// Get simulated reward epoch by ID
pub(crate) async fn get_simulated_reward_epoch(
&self,
id: i64,
) -> Result<Option<SimulatedRewardEpoch>, sqlx::Error> {
sqlx::query_as!(
SimulatedRewardEpoch,
r#"
SELECT id as "id!", epoch_id as "epoch_id!: u32", calculation_method as "calculation_method!",
start_timestamp as "start_timestamp!", end_timestamp as "end_timestamp!",
description, created_at as "created_at!"
FROM simulated_reward_epochs
WHERE id = ?
"#,
id
)
.fetch_optional(&self.connection_pool)
.await
}
/// Get simulated node performance for a specific epoch
pub(crate) async fn get_simulated_node_performance_for_epoch(
&self,
simulated_epoch_id: i64,
) -> Result<Vec<SimulatedNodePerformance>, sqlx::Error> {
self.get_simulated_node_performance(simulated_epoch_id, None).await
}
/// Get simulated rewards for a specific epoch
pub(crate) async fn get_simulated_rewards_for_epoch(
&self,
simulated_epoch_id: i64,
) -> Result<Vec<SimulatedReward>, sqlx::Error> {
self.get_simulated_rewards(simulated_epoch_id, None).await
}
/// Get simulated route analysis for a specific epoch
pub(crate) async fn get_simulated_route_analysis_for_epoch(
&self,
simulated_epoch_id: i64,
) -> Result<Vec<SimulatedRouteAnalysis>, sqlx::Error> {
sqlx::query_as!(
SimulatedRouteAnalysis,
r#"
SELECT id as "id!", simulated_epoch_id as "simulated_epoch_id!",
calculation_method as "calculation_method!", total_routes_analyzed as "total_routes_analyzed!: u32",
successful_routes as "successful_routes!: u32", failed_routes as "failed_routes!: u32",
average_route_reliability, time_window_hours as "time_window_hours!: u32",
analysis_parameters, calculated_at as "calculated_at!"
FROM simulated_route_analysis
WHERE simulated_epoch_id = ?
ORDER BY calculation_method
"#,
simulated_epoch_id
)
.fetch_all(&self.connection_pool)
.await
}
/// Get simulated node performance by method
pub(crate) async fn get_simulated_node_performance_by_method(
&self,
epoch_id: u32,
method: &str,
) -> Result<Vec<SimulatedNodePerformance>, sqlx::Error> {
sqlx::query_as!(
SimulatedNodePerformance,
r#"
SELECT snp.id as "id!", snp.simulated_epoch_id as "simulated_epoch_id!",
snp.node_id as "node_id!: NodeId", snp.node_type as "node_type!",
snp.identity_key, snp.reliability_score as "reliability_score!",
snp.positive_samples as "positive_samples!: u32", snp.negative_samples as "negative_samples!: u32",
snp.final_fail_sequence as "final_fail_sequence!: u32", snp.work_factor,
snp.calculation_method as "calculation_method!", snp.calculated_at as "calculated_at!"
FROM simulated_node_performance snp
JOIN simulated_reward_epochs sre ON snp.simulated_epoch_id = sre.id
WHERE sre.epoch_id = ? AND snp.calculation_method = ?
ORDER BY snp.node_id
"#,
epoch_id,
method
)
.fetch_all(&self.connection_pool)
.await
}
/// Get simulated route analysis by method
pub(crate) async fn get_simulated_route_analysis_by_method(
&self,
epoch_id: u32,
method: &str,
) -> Result<Option<SimulatedRouteAnalysis>, sqlx::Error> {
sqlx::query_as!(
SimulatedRouteAnalysis,
r#"
SELECT sra.id as "id!", sra.simulated_epoch_id as "simulated_epoch_id!",
sra.calculation_method as "calculation_method!", sra.total_routes_analyzed as "total_routes_analyzed!: u32",
sra.successful_routes as "successful_routes!: u32", sra.failed_routes as "failed_routes!: u32",
sra.average_route_reliability, sra.time_window_hours as "time_window_hours!: u32",
sra.analysis_parameters, sra.calculated_at as "calculated_at!"
FROM simulated_route_analysis sra
JOIN simulated_reward_epochs sre ON sra.simulated_epoch_id = sre.id
WHERE sre.epoch_id = ? AND sra.calculation_method = ?
"#,
epoch_id,
method
)
.fetch_optional(&self.connection_pool)
.await
}
/// Get node performance history across simulation epochs
pub(crate) async fn get_simulated_node_performance_history(
&self,
node_id: NodeId,
) -> Result<Vec<SimulatedNodePerformance>, sqlx::Error> {
sqlx::query_as!(
SimulatedNodePerformance,
r#"
SELECT snp.id as "id!", snp.simulated_epoch_id as "simulated_epoch_id!",
snp.node_id as "node_id!: NodeId", snp.node_type as "node_type!",
snp.identity_key, snp.reliability_score as "reliability_score!",
snp.positive_samples as "positive_samples!: u32", snp.negative_samples as "negative_samples!: u32",
snp.final_fail_sequence as "final_fail_sequence!: u32", snp.work_factor,
snp.calculation_method as "calculation_method!", snp.calculated_at as "calculated_at!"
FROM simulated_node_performance snp
JOIN simulated_reward_epochs sre ON snp.simulated_epoch_id = sre.id
WHERE snp.node_id = ?
ORDER BY sre.epoch_id DESC, snp.calculation_method
"#,
node_id
)
.fetch_all(&self.connection_pool)
.await
}
}
}
+3
View File
@@ -165,6 +165,7 @@ pub struct SimulatedRewardEpoch {
/// Node performance calculated using different methodologies
#[derive(FromRow, Debug, Clone)]
pub struct SimulatedNodePerformance {
#[allow(dead_code)]
pub id: i64,
pub simulated_epoch_id: i64,
pub node_id: NodeId,
@@ -182,6 +183,7 @@ pub struct SimulatedNodePerformance {
/// Simulated reward calculation results
#[derive(FromRow, Debug, Clone)]
pub struct SimulatedReward {
#[allow(dead_code)]
pub id: i64,
pub simulated_epoch_id: i64,
pub node_id: NodeId,
@@ -197,6 +199,7 @@ pub struct SimulatedReward {
/// Route analysis metadata for simulation runs
#[derive(FromRow, Debug, Clone)]
pub struct SimulatedRouteAnalysis {
#[allow(dead_code)]
pub id: i64,
pub simulated_epoch_id: i64,
pub calculation_method: String, // 'old' or 'new'