From e761255174fcb457c334a4582a0439f26697e312 Mon Sep 17 00:00:00 2001 From: durch Date: Tue, 3 Jun 2025 15:38:27 +0200 Subject: [PATCH] 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. --- ...c62afde62b3b04ff817ae372ffe9fbeaee6b8.json | 20 + ...a54857fc8b4a661e09ae6719d84ac2e77cf6d.json | 20 + ...cc5802e79b06f3d0d41f6467844265b26d0f7.json | 56 ++ ...1cb26191b2d606a359ffc165032e67975d40a.json | 86 +++ ...6e827761f879cab723cb2a649bc76b176fdad.json | 74 ++ ...9dcb421d336c53d9f35c66fb24fe5e579f0fa.json | 86 +++ ...47f7a7e2f80aa4db4f963146cf56864a62347.json | 20 + ...0f5d7ccf52de6775d4f57744d1401d44fee62.json | 74 ++ ...4d6b6379c06cfa1ff8598c29b67a2fe37055d.json | 56 ++ nym-api/src/epoch_operations/simulation.rs | 66 +- nym-api/src/main.rs | 1 + nym-api/src/simulation_api/handlers/mod.rs | 715 ++++++++++++++++++ nym-api/src/simulation_api/mod.rs | 10 + nym-api/src/simulation_api/models.rs | 252 ++++++ nym-api/src/support/http/router.rs | 2 + nym-api/src/support/storage/manager.rs | 181 ++++- nym-api/src/support/storage/models.rs | 3 + 17 files changed, 1654 insertions(+), 68 deletions(-) create mode 100644 nym-api/.sqlx/query-0557e64c547e147ef7eef713b49c62afde62b3b04ff817ae372ffe9fbeaee6b8.json create mode 100644 nym-api/.sqlx/query-1411660a6456f6f5ca141db10d9a54857fc8b4a661e09ae6719d84ac2e77cf6d.json create mode 100644 nym-api/.sqlx/query-1e4daea4d5f87938cf98cb2b6facc5802e79b06f3d0d41f6467844265b26d0f7.json create mode 100644 nym-api/.sqlx/query-3ffc479fe6913b137a59bf4c2801cb26191b2d606a359ffc165032e67975d40a.json create mode 100644 nym-api/.sqlx/query-4e2338362058e4356ac639fdf806e827761f879cab723cb2a649bc76b176fdad.json create mode 100644 nym-api/.sqlx/query-bc1b243cbd22a34e15dfefb84f99dcb421d336c53d9f35c66fb24fe5e579f0fa.json create mode 100644 nym-api/.sqlx/query-c9796e61c49a29ebd42dec46fb047f7a7e2f80aa4db4f963146cf56864a62347.json create mode 100644 nym-api/.sqlx/query-db2b71d1dd33022d5c960053d6a0f5d7ccf52de6775d4f57744d1401d44fee62.json create mode 100644 nym-api/.sqlx/query-dbc52e443ab600516b4f96ab2904d6b6379c06cfa1ff8598c29b67a2fe37055d.json create mode 100644 nym-api/src/simulation_api/handlers/mod.rs create mode 100644 nym-api/src/simulation_api/mod.rs create mode 100644 nym-api/src/simulation_api/models.rs diff --git a/nym-api/.sqlx/query-0557e64c547e147ef7eef713b49c62afde62b3b04ff817ae372ffe9fbeaee6b8.json b/nym-api/.sqlx/query-0557e64c547e147ef7eef713b49c62afde62b3b04ff817ae372ffe9fbeaee6b8.json new file mode 100644 index 0000000000..357d54635d --- /dev/null +++ b/nym-api/.sqlx/query-0557e64c547e147ef7eef713b49c62afde62b3b04ff817ae372ffe9fbeaee6b8.json @@ -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" +} diff --git a/nym-api/.sqlx/query-1411660a6456f6f5ca141db10d9a54857fc8b4a661e09ae6719d84ac2e77cf6d.json b/nym-api/.sqlx/query-1411660a6456f6f5ca141db10d9a54857fc8b4a661e09ae6719d84ac2e77cf6d.json new file mode 100644 index 0000000000..a046895356 --- /dev/null +++ b/nym-api/.sqlx/query-1411660a6456f6f5ca141db10d9a54857fc8b4a661e09ae6719d84ac2e77cf6d.json @@ -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" +} diff --git a/nym-api/.sqlx/query-1e4daea4d5f87938cf98cb2b6facc5802e79b06f3d0d41f6467844265b26d0f7.json b/nym-api/.sqlx/query-1e4daea4d5f87938cf98cb2b6facc5802e79b06f3d0d41f6467844265b26d0f7.json new file mode 100644 index 0000000000..6d5a056ac8 --- /dev/null +++ b/nym-api/.sqlx/query-1e4daea4d5f87938cf98cb2b6facc5802e79b06f3d0d41f6467844265b26d0f7.json @@ -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" +} diff --git a/nym-api/.sqlx/query-3ffc479fe6913b137a59bf4c2801cb26191b2d606a359ffc165032e67975d40a.json b/nym-api/.sqlx/query-3ffc479fe6913b137a59bf4c2801cb26191b2d606a359ffc165032e67975d40a.json new file mode 100644 index 0000000000..0bc2e9ba7f --- /dev/null +++ b/nym-api/.sqlx/query-3ffc479fe6913b137a59bf4c2801cb26191b2d606a359ffc165032e67975d40a.json @@ -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" +} diff --git a/nym-api/.sqlx/query-4e2338362058e4356ac639fdf806e827761f879cab723cb2a649bc76b176fdad.json b/nym-api/.sqlx/query-4e2338362058e4356ac639fdf806e827761f879cab723cb2a649bc76b176fdad.json new file mode 100644 index 0000000000..38832a9177 --- /dev/null +++ b/nym-api/.sqlx/query-4e2338362058e4356ac639fdf806e827761f879cab723cb2a649bc76b176fdad.json @@ -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" +} diff --git a/nym-api/.sqlx/query-bc1b243cbd22a34e15dfefb84f99dcb421d336c53d9f35c66fb24fe5e579f0fa.json b/nym-api/.sqlx/query-bc1b243cbd22a34e15dfefb84f99dcb421d336c53d9f35c66fb24fe5e579f0fa.json new file mode 100644 index 0000000000..4ed21e223f --- /dev/null +++ b/nym-api/.sqlx/query-bc1b243cbd22a34e15dfefb84f99dcb421d336c53d9f35c66fb24fe5e579f0fa.json @@ -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" +} diff --git a/nym-api/.sqlx/query-c9796e61c49a29ebd42dec46fb047f7a7e2f80aa4db4f963146cf56864a62347.json b/nym-api/.sqlx/query-c9796e61c49a29ebd42dec46fb047f7a7e2f80aa4db4f963146cf56864a62347.json new file mode 100644 index 0000000000..9f5963d082 --- /dev/null +++ b/nym-api/.sqlx/query-c9796e61c49a29ebd42dec46fb047f7a7e2f80aa4db4f963146cf56864a62347.json @@ -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" +} diff --git a/nym-api/.sqlx/query-db2b71d1dd33022d5c960053d6a0f5d7ccf52de6775d4f57744d1401d44fee62.json b/nym-api/.sqlx/query-db2b71d1dd33022d5c960053d6a0f5d7ccf52de6775d4f57744d1401d44fee62.json new file mode 100644 index 0000000000..c48e521c5b --- /dev/null +++ b/nym-api/.sqlx/query-db2b71d1dd33022d5c960053d6a0f5d7ccf52de6775d4f57744d1401d44fee62.json @@ -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" +} diff --git a/nym-api/.sqlx/query-dbc52e443ab600516b4f96ab2904d6b6379c06cfa1ff8598c29b67a2fe37055d.json b/nym-api/.sqlx/query-dbc52e443ab600516b4f96ab2904d6b6379c06cfa1ff8598c29b67a2fe37055d.json new file mode 100644 index 0000000000..4eab5d8fe3 --- /dev/null +++ b/nym-api/.sqlx/query-dbc52e443ab600516b4f96ab2904d6b6379c06cfa1ff8598c29b67a2fe37055d.json @@ -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" +} diff --git a/nym-api/src/epoch_operations/simulation.rs b/nym-api/src/epoch_operations/simulation.rs index bdf3dd30ac..628df34639 100644 --- a/nym-api/src/epoch_operations/simulation.rs +++ b/nym-api/src/epoch_operations/simulation.rs @@ -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, - pub rewards: Vec, - pub route_analysis: SimulatedRouteAnalysis, -} - -/// Complete simulation results containing both methods -#[derive(Debug, Clone)] -pub struct SimulationResults { - pub epoch_id: i64, - pub old_method: Option, - pub new_method: Option, -} /// 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 { + ) -> 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 { + ) -> 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 { + ) -> 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, 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(()) } } } diff --git a/nym-api/src/main.rs b/nym-api/src/main.rs index faf39f2c14..d3acd887a8 100644 --- a/nym-api/src/main.rs +++ b/nym-api/src/main.rs @@ -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; diff --git a/nym-api/src/simulation_api/handlers/mod.rs b/nym-api/src/simulation_api/handlers/mod.rs new file mode 100644 index 0000000000..aa0ece46a3 --- /dev/null +++ b/nym-api/src/simulation_api/handlers/mod.rs @@ -0,0 +1,715 @@ +// Copyright 2025 - Nym Technologies SA +// 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 = Result; +type AxumResult = Result)>; + +/// Create the simulation API router +pub(crate) fn simulation_routes() -> Router { + 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, +} + +/// 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, + Query(output): Query, + State(state): State, +) -> AxumResult> { + 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, ¶ms, 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, ¶ms) + .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, + Query(output): Query, + State(state): State, +) -> AxumResult> { + 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, + Query(query): Query, + Query(output): Query, + State(state): State, +) -> AxumResult> { + 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, + Query(query): Query, + State(state): State, +) -> Result)> { + 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), + (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, + Query(output): Query, + State(state): State, +) -> AxumResult>> { + 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> { + 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 { + 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> { + 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 { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, + new_performance: Vec, + query: &NodeComparisonQuery, +) -> Vec { + let mut old_map: HashMap = old_performance + .into_iter() + .map(|p| (p.node_id, p)) + .collect(); + + let mut new_map: HashMap = 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::() / reliabilities_old.len() as f64 + }; + + let average_reliability_new = if reliabilities_new.is_empty() { + 0.0 + } else { + reliabilities_new.iter().sum::() / 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::() / values.len() as f64; + let variance = values.iter() + .map(|x| (x - mean).powi(2)) + .sum::() / values.len() as f64; + let std_dev = variance.sqrt(); + + (median, std_dev) +} + +async fn get_route_analysis_comparison( + storage: &NymApiStorage, + epoch_id: u32, +) -> SimulationResult { + 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> { + 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> { + // 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> { + 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) { + (StatusCode::INTERNAL_SERVER_ERROR, Json(error)) +} \ No newline at end of file diff --git a/nym-api/src/simulation_api/mod.rs b/nym-api/src/simulation_api/mod.rs new file mode 100644 index 0000000000..be339c0736 --- /dev/null +++ b/nym-api/src/simulation_api/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2025 - Nym Technologies SA +// 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; \ No newline at end of file diff --git a/nym-api/src/simulation_api/models.rs b/nym-api/src/simulation_api/models.rs new file mode 100644 index 0000000000..b8e9982868 --- /dev/null +++ b/nym-api/src/simulation_api/models.rs @@ -0,0 +1,252 @@ +// Copyright 2025 - Nym Technologies SA +// 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, + 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, + 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, +} + +impl From 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, + pub rewards: Vec, + pub route_analysis: Vec, +} + +/// 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, + pub reliability_score: f64, + pub positive_samples: u32, + pub negative_samples: u32, + pub final_fail_sequence: u32, + pub work_factor: Option, + pub calculation_method: String, + pub calculated_at: i64, +} + +impl From 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 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, + pub time_window_hours: u32, + pub analysis_parameters: Option, + pub calculated_at: i64, +} + +impl From 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, + 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, + pub old_method: Option, + pub new_method: Option, + pub reliability_difference: Option, // new - old + pub performance_delta_percentage: Option, // (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, + pub new_method: Option, + pub time_window_difference_hours: i32, // new - old + pub route_coverage_difference: i32, // new total routes - old total routes + pub success_rate_difference: Option, // 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, + /// Offset for pagination (default: 0) + pub offset: Option, +} + +/// 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, + /// Node type filter (mixnode, gateway) + pub node_type: Option, + /// Minimum reliability difference threshold for filtering + pub min_delta: Option, + /// Maximum reliability difference threshold for filtering + pub max_delta: Option, +} + +/// Error response for simulation API +#[derive(Serialize, Deserialize, ToSchema, Debug)] +pub struct SimulationApiError { + pub error: String, + pub details: Option, + 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(), + } + } +} \ No newline at end of file diff --git a/nym-api/src/support/http/router.rs b/nym-api/src/support/http/router.rs index 588b0e4134..79befebe5a 100644 --- a/nym-api/src/support/http/router.rs +++ b/nym-api/src/support/http/router.rs @@ -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 ); diff --git a/nym-api/src/support/storage/manager.rs b/nym-api/src/support/storage/manager.rs index 493876685a..4ac95783e4 100644 --- a/nym-api/src/support/storage/manager.rs +++ b/nym-api/src/support/storage/manager.rs @@ -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, 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 { + 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, 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, 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, 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, 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, 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, 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, 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, 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 + } } } diff --git a/nym-api/src/support/storage/models.rs b/nym-api/src/support/storage/models.rs index 181e9dfcf2..3a6b093ef3 100644 --- a/nym-api/src/support/storage/models.rs +++ b/nym-api/src/support/storage/models.rs @@ -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'