From 629d7ddc743d51831ff231d53e31e80c5911d188 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Thu, 13 Aug 2026 17:17:47 +0200 Subject: [PATCH] Add standalone IntaRNA 4.0 implementation Introduce IntaRNAnew as an isolated C++23 implementation with native tools, correctness oracles, Legacy parity gates, benchmark fixtures, and an output-aware exact predictor optimized for ranked boundary and energy output. --- IntaRNAnew/.gitignore | 12 + IntaRNAnew/CMakeLists.txt | 276 +++ IntaRNAnew/IntaRNAnewConfig.cmake.in | 7 + IntaRNAnew/LICENSE | 12 + IntaRNAnew/MATHEMATICAL_AUDIT.md | 551 ++++++ IntaRNAnew/Makefile | 235 +++ IntaRNAnew/README.md | 134 ++ IntaRNAnew/app/main.cpp | 481 ++++++ IntaRNAnew/benchmarks/README.md | 83 + .../cases/fhlA-OxyS-basepair.parameter | 14 + .../cases/ilvE-GcvB-ST-basepair.parameter | 14 + .../cases/phoB-GcvB-basepair.parameter | 14 + IntaRNAnew/benchmarks/compare.cpp | 528 ++++++ .../include/intarnanew/accessibility.hpp | 165 ++ IntaRNAnew/include/intarnanew/cli.hpp | 41 + IntaRNAnew/include/intarnanew/compression.hpp | 35 + IntaRNAnew/include/intarnanew/config.hpp | 149 ++ IntaRNAnew/include/intarnanew/energy.hpp | 81 + IntaRNAnew/include/intarnanew/folding.hpp | 58 + .../include/intarnanew/helix_blocks.hpp | 43 + IntaRNAnew/include/intarnanew/output.hpp | 36 + IntaRNAnew/include/intarnanew/output_plan.hpp | 70 + IntaRNAnew/include/intarnanew/parallel.hpp | 88 + IntaRNAnew/include/intarnanew/predictor.hpp | 89 + IntaRNAnew/include/intarnanew/runner.hpp | 34 + IntaRNAnew/include/intarnanew/sequence.hpp | 60 + IntaRNAnew/include/intarnanew/tools/csv.hpp | 53 + .../include/intarnanew/tools/mutations.hpp | 73 + .../include/intarnanew/tools/pvalue.hpp | 38 + .../include/intarnanew/tools/statistics.hpp | 67 + IntaRNAnew/include/intarnanew/tools/svg.hpp | 79 + IntaRNAnew/include/intarnanew/types.hpp | 126 ++ IntaRNAnew/src/accessibility.cpp | 594 +++++++ IntaRNAnew/src/cli.cpp | 1444 ++++++++++++++++ IntaRNAnew/src/compression.cpp | 622 +++++++ IntaRNAnew/src/energy.cpp | 292 ++++ IntaRNAnew/src/folding.cpp | 945 ++++++++++ IntaRNAnew/src/folding_parameters.cpp | 396 +++++ IntaRNAnew/src/folding_parameters.hpp | 43 + IntaRNAnew/src/helix_blocks.cpp | 129 ++ IntaRNAnew/src/noncrossing_partition.hpp | 155 ++ IntaRNAnew/src/output.cpp | 966 +++++++++++ IntaRNAnew/src/output_plan.cpp | 463 +++++ IntaRNAnew/src/predictor.cpp | 1528 +++++++++++++++++ IntaRNAnew/src/runner.cpp | 81 + IntaRNAnew/src/sequence.cpp | 420 +++++ IntaRNAnew/src/thermo_parameters.cpp | 389 +++++ IntaRNAnew/src/thermo_parameters.hpp | 37 + IntaRNAnew/src/tools/csv.cpp | 393 +++++ IntaRNAnew/src/tools/mutations.cpp | 326 ++++ IntaRNAnew/src/tools/pvalue.cpp | 114 ++ IntaRNAnew/src/tools/statistics.cpp | 515 ++++++ IntaRNAnew/src/tools/svg.cpp | 420 +++++ IntaRNAnew/tests/accessibility_oracle.cpp | 364 ++++ IntaRNAnew/tests/cli_io_output.cpp | 550 ++++++ IntaRNAnew/tests/compression_io.cpp | 480 ++++++ IntaRNAnew/tests/config_registry.cpp | 330 ++++ IntaRNAnew/tests/consumer/CMakeLists.txt | 10 + IntaRNAnew/tests/consumer/main.cpp | 58 + IntaRNAnew/tests/execution_regions.cpp | 266 +++ .../tests/fixtures/config/base.parameter | 5 + .../tests/fixtures/config/conflict.parameter | 3 + .../tests/fixtures/config/duplicate.parameter | 3 + .../tests/fixtures/config/nested.parameter | 3 + .../energyB-accN-exact-seed-modelS.parameter | 14 + .../energyB-accN-exact-seed-modelX.parameter | 14 + .../parameters/energyB-accN-exact.parameter | 13 + ...ergyB-accN-heuristic-seed-modelS.parameter | 13 + ...ergyB-accN-heuristic-seed-modelX.parameter | 13 + .../energyB-accN-heuristic.parameter | 13 + ...rgyB-accN-noLP-exact-seed-modelS.parameter | 14 + ...rgyB-accN-noLP-exact-seed-modelX.parameter | 14 + .../energyB-accN-noLP-exact.parameter | 13 + ...-accN-noLP-heuristic-seed-modelS.parameter | 13 + ...-accN-noLP-heuristic-seed-modelX.parameter | 13 + .../energyB-accN-noLP-heuristic.parameter | 13 + .../energyB-accN-outNoGUend.parameter | 8 + ...N-overlapB-heuristic-seed-modelX.parameter | 15 + ...N-overlapN-heuristic-seed-modelX.parameter | 15 + ...N-overlapQ-heuristic-seed-modelX.parameter | 15 + ...N-overlapT-heuristic-seed-modelX.parameter | 15 + IntaRNAnew/tests/folding_oracle.cpp | 318 ++++ IntaRNAnew/tests/integration.cpp | 224 +++ IntaRNAnew/tests/model_semantics.cpp | 374 ++++ IntaRNAnew/tests/output_execution.cpp | 197 +++ IntaRNAnew/tests/predictor_oracle.cpp | 453 +++++ IntaRNAnew/tests/pvalue_executable.cpp | 244 +++ IntaRNAnew/tests/runner_contracts.cpp | 74 + IntaRNAnew/tests/tools_contracts.cpp | 376 ++++ IntaRNAnew/tools/README.md | 126 ++ IntaRNAnew/tools/intarnanew-csv.cpp | 130 ++ IntaRNAnew/tools/intarnanew-mutate.cpp | 139 ++ IntaRNAnew/tools/intarnanew-pvalue.cpp | 269 +++ IntaRNAnew/tools/intarnanew-stats.cpp | 298 ++++ IntaRNAnew/tools/intarnanew-svg.cpp | 232 +++ 95 files changed, 19772 insertions(+) create mode 100644 IntaRNAnew/.gitignore create mode 100644 IntaRNAnew/CMakeLists.txt create mode 100644 IntaRNAnew/IntaRNAnewConfig.cmake.in create mode 100644 IntaRNAnew/LICENSE create mode 100644 IntaRNAnew/MATHEMATICAL_AUDIT.md create mode 100644 IntaRNAnew/Makefile create mode 100644 IntaRNAnew/README.md create mode 100644 IntaRNAnew/app/main.cpp create mode 100644 IntaRNAnew/benchmarks/README.md create mode 100644 IntaRNAnew/benchmarks/cases/fhlA-OxyS-basepair.parameter create mode 100644 IntaRNAnew/benchmarks/cases/ilvE-GcvB-ST-basepair.parameter create mode 100644 IntaRNAnew/benchmarks/cases/phoB-GcvB-basepair.parameter create mode 100644 IntaRNAnew/benchmarks/compare.cpp create mode 100644 IntaRNAnew/include/intarnanew/accessibility.hpp create mode 100644 IntaRNAnew/include/intarnanew/cli.hpp create mode 100644 IntaRNAnew/include/intarnanew/compression.hpp create mode 100644 IntaRNAnew/include/intarnanew/config.hpp create mode 100644 IntaRNAnew/include/intarnanew/energy.hpp create mode 100644 IntaRNAnew/include/intarnanew/folding.hpp create mode 100644 IntaRNAnew/include/intarnanew/helix_blocks.hpp create mode 100644 IntaRNAnew/include/intarnanew/output.hpp create mode 100644 IntaRNAnew/include/intarnanew/output_plan.hpp create mode 100644 IntaRNAnew/include/intarnanew/parallel.hpp create mode 100644 IntaRNAnew/include/intarnanew/predictor.hpp create mode 100644 IntaRNAnew/include/intarnanew/runner.hpp create mode 100644 IntaRNAnew/include/intarnanew/sequence.hpp create mode 100644 IntaRNAnew/include/intarnanew/tools/csv.hpp create mode 100644 IntaRNAnew/include/intarnanew/tools/mutations.hpp create mode 100644 IntaRNAnew/include/intarnanew/tools/pvalue.hpp create mode 100644 IntaRNAnew/include/intarnanew/tools/statistics.hpp create mode 100644 IntaRNAnew/include/intarnanew/tools/svg.hpp create mode 100644 IntaRNAnew/include/intarnanew/types.hpp create mode 100644 IntaRNAnew/src/accessibility.cpp create mode 100644 IntaRNAnew/src/cli.cpp create mode 100644 IntaRNAnew/src/compression.cpp create mode 100644 IntaRNAnew/src/energy.cpp create mode 100644 IntaRNAnew/src/folding.cpp create mode 100644 IntaRNAnew/src/folding_parameters.cpp create mode 100644 IntaRNAnew/src/folding_parameters.hpp create mode 100644 IntaRNAnew/src/helix_blocks.cpp create mode 100644 IntaRNAnew/src/noncrossing_partition.hpp create mode 100644 IntaRNAnew/src/output.cpp create mode 100644 IntaRNAnew/src/output_plan.cpp create mode 100644 IntaRNAnew/src/predictor.cpp create mode 100644 IntaRNAnew/src/runner.cpp create mode 100644 IntaRNAnew/src/sequence.cpp create mode 100644 IntaRNAnew/src/thermo_parameters.cpp create mode 100644 IntaRNAnew/src/thermo_parameters.hpp create mode 100644 IntaRNAnew/src/tools/csv.cpp create mode 100644 IntaRNAnew/src/tools/mutations.cpp create mode 100644 IntaRNAnew/src/tools/pvalue.cpp create mode 100644 IntaRNAnew/src/tools/statistics.cpp create mode 100644 IntaRNAnew/src/tools/svg.cpp create mode 100644 IntaRNAnew/tests/accessibility_oracle.cpp create mode 100644 IntaRNAnew/tests/cli_io_output.cpp create mode 100644 IntaRNAnew/tests/compression_io.cpp create mode 100644 IntaRNAnew/tests/config_registry.cpp create mode 100644 IntaRNAnew/tests/consumer/CMakeLists.txt create mode 100644 IntaRNAnew/tests/consumer/main.cpp create mode 100644 IntaRNAnew/tests/execution_regions.cpp create mode 100644 IntaRNAnew/tests/fixtures/config/base.parameter create mode 100644 IntaRNAnew/tests/fixtures/config/conflict.parameter create mode 100644 IntaRNAnew/tests/fixtures/config/duplicate.parameter create mode 100644 IntaRNAnew/tests/fixtures/config/nested.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelS.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelS.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelS.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelS.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-outNoGUend.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapB-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapN-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapQ-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapT-heuristic-seed-modelX.parameter create mode 100644 IntaRNAnew/tests/folding_oracle.cpp create mode 100644 IntaRNAnew/tests/integration.cpp create mode 100644 IntaRNAnew/tests/model_semantics.cpp create mode 100644 IntaRNAnew/tests/output_execution.cpp create mode 100644 IntaRNAnew/tests/predictor_oracle.cpp create mode 100644 IntaRNAnew/tests/pvalue_executable.cpp create mode 100644 IntaRNAnew/tests/runner_contracts.cpp create mode 100644 IntaRNAnew/tests/tools_contracts.cpp create mode 100644 IntaRNAnew/tools/README.md create mode 100644 IntaRNAnew/tools/intarnanew-csv.cpp create mode 100644 IntaRNAnew/tools/intarnanew-mutate.cpp create mode 100644 IntaRNAnew/tools/intarnanew-pvalue.cpp create mode 100644 IntaRNAnew/tools/intarnanew-stats.cpp create mode 100644 IntaRNAnew/tools/intarnanew-svg.cpp diff --git a/IntaRNAnew/.gitignore b/IntaRNAnew/.gitignore new file mode 100644 index 0000000..9afca8b --- /dev/null +++ b/IntaRNAnew/.gitignore @@ -0,0 +1,12 @@ +IntaRNAnew +IntaRNAnewBenchmark +IntaRNAnewPvalue +IntaRNAnewSvg +IntaRNAnewTspot +*.a +*.d +*.o +*.dot +/Testing/ +/cmake-build-*/ +/build/ diff --git a/IntaRNAnew/CMakeLists.txt b/IntaRNAnew/CMakeLists.txt new file mode 100644 index 0000000..b6ea07d --- /dev/null +++ b/IntaRNAnew/CMakeLists.txt @@ -0,0 +1,276 @@ +cmake_minimum_required(VERSION 3.25) + +project(IntaRNAnew VERSION 4.0.0 LANGUAGES CXX) + +set(CMAKE_CXX_EXTENSIONS OFF) + +include(CTest) +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +find_package(Threads REQUIRED) + +option(INTARNANEW_ENABLE_SANITIZERS + "Enable address and undefined-behaviour sanitizers" OFF) +option(INTARNANEW_BUILD_BENCHMARKS + "Build the POSIX benchmark when BUILD_TESTING is disabled" OFF) +set(INTARNA_LEGACY_BIN "$ENV{INTARNA_LEGACY_BIN}" CACHE FILEPATH + "Optional IntaRNA Legacy executable for the 17-case black-box parity test") + +set(_intarnanew_local_parameter_dir + "${CMAKE_CURRENT_SOURCE_DIR}/../.conda-env/share/ViennaRNA") +if(NOT EXISTS "${_intarnanew_local_parameter_dir}") + set(_intarnanew_local_parameter_dir "") +endif() +set(INTARNANEW_TEST_PARAMETER_DIR "${_intarnanew_local_parameter_dir}" CACHE PATH + "Directory containing public ViennaRNA .par files for thermodynamic tests") +unset(_intarnanew_local_parameter_dir) + +function(intarnanew_enable_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /permissive-) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wshadow) + endif() +endfunction() + +function(intarnanew_enable_sanitizers target) + if(NOT INTARNANEW_ENABLE_SANITIZERS) + return() + endif() + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(${target} PRIVATE + -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(${target} PRIVATE -fsanitize=address,undefined) + else() + message(FATAL_ERROR + "INTARNANEW_ENABLE_SANITIZERS requires GCC, Clang, or AppleClang") + endif() +endfunction() + +function(intarnanew_set_parameter_test_environment test_name) + if(INTARNANEW_TEST_PARAMETER_DIR) + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT + "INTARNANEW_PARAMETER_DIR=${INTARNANEW_TEST_PARAMETER_DIR}") + endif() +endfunction() + +add_library(intarnanew STATIC + src/accessibility.cpp + src/cli.cpp + src/compression.cpp + src/energy.cpp + src/folding.cpp + src/folding_parameters.cpp + src/helix_blocks.cpp + src/output.cpp + src/output_plan.cpp + src/predictor.cpp + src/runner.cpp + src/sequence.cpp + src/thermo_parameters.cpp +) +add_library(IntaRNAnew::core ALIAS intarnanew) +set_target_properties(intarnanew PROPERTIES EXPORT_NAME core) +target_include_directories(intarnanew PUBLIC + $ + $ +) +target_compile_features(intarnanew PUBLIC cxx_std_23) +target_link_libraries(intarnanew PUBLIC Threads::Threads) +intarnanew_enable_warnings(intarnanew) +intarnanew_enable_sanitizers(intarnanew) + +add_library(intarnanew_tools STATIC + src/tools/csv.cpp + src/tools/mutations.cpp + src/tools/pvalue.cpp + src/tools/statistics.cpp + src/tools/svg.cpp +) +add_library(IntaRNAnew::tools ALIAS intarnanew_tools) +set_target_properties(intarnanew_tools PROPERTIES + EXPORT_NAME tools + OUTPUT_NAME intarnanew-tools +) +target_include_directories(intarnanew_tools PUBLIC + $ + $ +) +target_compile_features(intarnanew_tools PUBLIC cxx_std_23) +target_link_libraries(intarnanew_tools PUBLIC intarnanew Threads::Threads) +intarnanew_enable_warnings(intarnanew_tools) +intarnanew_enable_sanitizers(intarnanew_tools) + +add_executable(IntaRNAnew app/main.cpp) +target_link_libraries(IntaRNAnew PRIVATE intarnanew) +intarnanew_enable_warnings(IntaRNAnew) +intarnanew_enable_sanitizers(IntaRNAnew) + +foreach(tool IN ITEMS csv stats svg mutate pvalue) + add_executable(intarnanew-${tool} tools/intarnanew-${tool}.cpp) + target_link_libraries(intarnanew-${tool} PRIVATE intarnanew_tools) + intarnanew_enable_warnings(intarnanew-${tool}) + intarnanew_enable_sanitizers(intarnanew-${tool}) +endforeach() + +if(INTARNANEW_BUILD_BENCHMARKS AND NOT UNIX) + message(FATAL_ERROR "The black-box benchmark requires POSIX process APIs") +endif() +if(UNIX AND (INTARNANEW_BUILD_BENCHMARKS OR BUILD_TESTING)) + add_executable(IntaRNAnewBenchmark benchmarks/compare.cpp) + target_compile_features(IntaRNAnewBenchmark PRIVATE cxx_std_23) + intarnanew_enable_warnings(IntaRNAnewBenchmark) + intarnanew_enable_sanitizers(IntaRNAnewBenchmark) +endif() + +if(BUILD_TESTING) + add_executable(IntaRNAnewIntegrationTests tests/integration.cpp) + target_link_libraries(IntaRNAnewIntegrationTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewIntegrationTests) + intarnanew_enable_sanitizers(IntaRNAnewIntegrationTests) + add_test(NAME real_interactions COMMAND IntaRNAnewIntegrationTests) + intarnanew_set_parameter_test_environment(real_interactions) + + add_executable(IntaRNAnewCliIoOutputTests tests/cli_io_output.cpp) + target_link_libraries(IntaRNAnewCliIoOutputTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewCliIoOutputTests) + intarnanew_enable_sanitizers(IntaRNAnewCliIoOutputTests) + add_test(NAME cli_io_output_regressions COMMAND IntaRNAnewCliIoOutputTests) + + add_executable(IntaRNAnewConfigRegistryTests tests/config_registry.cpp) + target_link_libraries(IntaRNAnewConfigRegistryTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewConfigRegistryTests) + intarnanew_enable_sanitizers(IntaRNAnewConfigRegistryTests) + add_test(NAME config_registry COMMAND IntaRNAnewConfigRegistryTests) + set_tests_properties(config_registry PROPERTIES ENVIRONMENT + "INTARNANEW_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}") + + add_executable(IntaRNAnewAccessibilityOracleTests tests/accessibility_oracle.cpp) + target_link_libraries(IntaRNAnewAccessibilityOracleTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewAccessibilityOracleTests) + intarnanew_enable_sanitizers(IntaRNAnewAccessibilityOracleTests) + add_test(NAME accessibility_oracle COMMAND IntaRNAnewAccessibilityOracleTests) + + add_executable(IntaRNAnewFoldingOracleTests tests/folding_oracle.cpp) + target_link_libraries(IntaRNAnewFoldingOracleTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewFoldingOracleTests) + intarnanew_enable_sanitizers(IntaRNAnewFoldingOracleTests) + add_test(NAME folding_oracle COMMAND IntaRNAnewFoldingOracleTests) + intarnanew_set_parameter_test_environment(folding_oracle) + + add_executable(IntaRNAnewPredictorOracleTests tests/predictor_oracle.cpp) + target_link_libraries(IntaRNAnewPredictorOracleTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewPredictorOracleTests) + intarnanew_enable_sanitizers(IntaRNAnewPredictorOracleTests) + add_test(NAME predictor_oracle COMMAND IntaRNAnewPredictorOracleTests) + + add_executable(IntaRNAnewExecutionRegionsTests tests/execution_regions.cpp) + target_link_libraries(IntaRNAnewExecutionRegionsTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewExecutionRegionsTests) + intarnanew_enable_sanitizers(IntaRNAnewExecutionRegionsTests) + add_test(NAME region_window_execution COMMAND IntaRNAnewExecutionRegionsTests) + + add_executable(IntaRNAnewModelSemanticsTests tests/model_semantics.cpp) + target_link_libraries(IntaRNAnewModelSemanticsTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewModelSemanticsTests) + intarnanew_enable_sanitizers(IntaRNAnewModelSemanticsTests) + add_test(NAME interaction_model_semantics COMMAND IntaRNAnewModelSemanticsTests) + + add_executable(IntaRNAnewCompressionIoTests tests/compression_io.cpp) + target_link_libraries(IntaRNAnewCompressionIoTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewCompressionIoTests) + intarnanew_enable_sanitizers(IntaRNAnewCompressionIoTests) + add_test(NAME compression_and_gzip_io COMMAND IntaRNAnewCompressionIoTests) + + add_executable(IntaRNAnewOutputExecutionTests tests/output_execution.cpp) + target_link_libraries(IntaRNAnewOutputExecutionTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewOutputExecutionTests) + intarnanew_enable_sanitizers(IntaRNAnewOutputExecutionTests) + add_test(NAME output_planning_and_parallel_execution + COMMAND IntaRNAnewOutputExecutionTests) + + add_executable(IntaRNAnewRunnerContractTests tests/runner_contracts.cpp) + target_link_libraries(IntaRNAnewRunnerContractTests PRIVATE intarnanew) + intarnanew_enable_warnings(IntaRNAnewRunnerContractTests) + intarnanew_enable_sanitizers(IntaRNAnewRunnerContractTests) + add_test(NAME in_process_pair_runner COMMAND IntaRNAnewRunnerContractTests) + + add_executable(IntaRNAnewToolsContractTests tests/tools_contracts.cpp) + target_link_libraries(IntaRNAnewToolsContractTests PRIVATE intarnanew_tools) + intarnanew_enable_warnings(IntaRNAnewToolsContractTests) + intarnanew_enable_sanitizers(IntaRNAnewToolsContractTests) + add_test(NAME native_tools_contracts COMMAND IntaRNAnewToolsContractTests) + + if(UNIX) + add_executable(IntaRNAnewPvalueExecutableTests tests/pvalue_executable.cpp) + target_compile_features(IntaRNAnewPvalueExecutableTests PRIVATE cxx_std_23) + intarnanew_enable_warnings(IntaRNAnewPvalueExecutableTests) + intarnanew_enable_sanitizers(IntaRNAnewPvalueExecutableTests) + add_test(NAME pvalue_executable_contracts + COMMAND IntaRNAnewPvalueExecutableTests + "$") + set_tests_properties(pvalue_executable_contracts PROPERTIES TIMEOUT 120) + + set(_intarnanew_legacy_test_arguments) + if(INTARNA_LEGACY_BIN) + list(APPEND _intarnanew_legacy_test_arguments + --legacy "${INTARNA_LEGACY_BIN}") + endif() + add_test(NAME legacy_blackbox_17 + COMMAND IntaRNAnewBenchmark + --new "$" + --cases "${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/parameters" + --verify-only --expect-cases 17 --skip-without-legacy + ${_intarnanew_legacy_test_arguments}) + set_tests_properties(legacy_blackbox_17 PROPERTIES + SKIP_REGULAR_EXPRESSION "^SKIP:") + unset(_intarnanew_legacy_test_arguments) + endif() +endif() + +install(TARGETS intarnanew intarnanew_tools + EXPORT IntaRNAnewTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) +install(TARGETS IntaRNAnew + intarnanew-csv intarnanew-stats intarnanew-svg intarnanew-mutate + intarnanew-pvalue + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) +install(DIRECTORY include/intarnanew + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + FILES_MATCHING PATTERN "*.hpp" +) +install(FILES README.md MATHEMATICAL_AUDIT.md LICENSE + DESTINATION ${CMAKE_INSTALL_DOCDIR} +) +install(FILES tools/README.md + DESTINATION ${CMAKE_INSTALL_DOCDIR}/tools +) + +set(intarnanew_package_dir + "${CMAKE_INSTALL_LIBDIR}/cmake/IntaRNAnew") +configure_package_config_file( + IntaRNAnewConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/IntaRNAnewConfig.cmake" + INSTALL_DESTINATION "${intarnanew_package_dir}" +) +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/IntaRNAnewConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) +install(EXPORT IntaRNAnewTargets + FILE IntaRNAnewTargets.cmake + NAMESPACE IntaRNAnew:: + DESTINATION "${intarnanew_package_dir}" +) +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/IntaRNAnewConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/IntaRNAnewConfigVersion.cmake" + DESTINATION "${intarnanew_package_dir}" +) diff --git a/IntaRNAnew/IntaRNAnewConfig.cmake.in b/IntaRNAnew/IntaRNAnewConfig.cmake.in new file mode 100644 index 0000000..c10b0da --- /dev/null +++ b/IntaRNAnew/IntaRNAnewConfig.cmake.in @@ -0,0 +1,7 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(Threads) + +include("${CMAKE_CURRENT_LIST_DIR}/IntaRNAnewTargets.cmake") +check_required_components(IntaRNAnew) diff --git a/IntaRNAnew/LICENSE b/IntaRNAnew/LICENSE new file mode 100644 index 0000000..03aa4b4 --- /dev/null +++ b/IntaRNAnew/LICENSE @@ -0,0 +1,12 @@ +SPDX-License-Identifier: GPL-3.0-or-later + +Copyright (C) 2026 IntaRNAnew contributors + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See . diff --git a/IntaRNAnew/MATHEMATICAL_AUDIT.md b/IntaRNAnew/MATHEMATICAL_AUDIT.md new file mode 100644 index 0000000..9a27b32 --- /dev/null +++ b/IntaRNAnew/MATHEMATICAL_AUDIT.md @@ -0,0 +1,551 @@ +# Mathematical audit of IntaRNA recurrences and partition functions + +Audit date: 2026-08-13 + +## 1. Scope and evidence + +This report keeps the three implementations in this workspace distinct: + +- **Legacy** means the historical implementation in the [repository root](..). +- **Next** means a separately audited successor that retains much of the legacy predictor architecture; its source is not part of this repository. +- **IntaRNAnew** means this independent C++23 implementation. It is the only source tree modified by this audit. + +A finding is labelled as follows: + +- **reproduced**: a minimal executable case demonstrates the defect; +- **source-confirmed**: the recurrence or data flow proves the defect; +- **fixed with regression**: IntaRNAnew was changed and a focused oracle now protects the result; +- **proposed**: mathematically justified work that has not been implemented. + +The audit covered monomer folding, interval-unpaired probabilities, interaction-path dynamic programming, site and global interaction partition functions, heuristic pruning, seed/noLP/noGU constraints, numerical stability, and result materialization. + +## 2. Executive result + +The IntaRNAnew recurrences have a sound decomposition, but the audit found and fixed four scientific defects: + +1. both asymmetric Turner 1x2/2x1 internal-loop nucleotide orders were wrong in monomer and duplex scoring; +2. model-P interaction partition weights omitted terminal-pair and dangle energies; +3. valid Turner99 and Andronescu07 parameter files were rejected because their six-value Misc sections were parsed as exactly four values; +4. CSV P_E was reconstructed from a centikcal-truncated display energy and could exceed one instead of reporting the normalized site probability. + +The main algorithmic breakthrough is an inside/outside theorem that generates every interval-unpaired partition for the scalar noncrossing model in \(O(n^3)\) time and \(O(n^2)\) memory. The previous all-interval path performed \(O(n^2)\) separate \(O(n^3)\) constrained folds, hence \(O(n^5)\) worst-case work. Queries are now \(O(1)\) after construction. + +The performance investigation then found that the generalized interaction engine was doing path and ensemble work that the requested output could not observe. An output-aware scalar max-plus kernel now handles exact, unseeded, base-pair/model-S prediction when only ranked boundaries and energies are required; all broader configurations retain the full predictor. + +On the three parity-gated biological workloads, the optimized executable is faster than Legacy in every case: + +| Case | Legacy median | IntaRNAnew before | IntaRNAnew final | Final speedup | Final RSS | +|---|---:|---:|---:|---:|---:| +| fhlA/OxyS | 504.936 ms | 3409.157 ms | 36.066 ms | 14.000x | 5,120 KiB | +| ilvE/GcvB-ST | 4008.653 ms | 30093.911 ms | 208.473 ms | 19.229x | 5,120 KiB | +| phoB/GcvB | 3821.776 ms | 29566.255 ms | 205.155 ms | 18.629x | 5,120 KiB | + +The geometric-mean speedup is **17.117x over Legacy** and **125.285x over the pre-redesign IntaRNAnew**. Section 10 gives the protocol, root cause, and memory evidence. + +## 3. Notation and ensemble definitions + +Internal indices are zero-based. The public inclusive interval \([l,r]\) corresponds to the mathematical half-open interval \([l,r+1)\). + +For energy \(E\), temperature \(T\), and gas constant \(R\), define the Boltzmann map + +\[ +B(E)=\exp[-E/(RT)]. +\] + +The implementation stores partition values as logarithms and evaluates every addition with log-sum-exp: + +\[ +\operatorname{LSE}(x,y) +=\max(x,y)+\log\!\left(1+\exp[-|x-y|]\right). +\] + +Thus zero weight is represented by \(-\infty\), multiplication becomes addition, and no explicit partition scaling is necessary. + +A monomer partition includes the empty structure: + +\[ +Z_{\mathrm{mono}}=\sum_{\sigma\in\mathcal S}B(E(\sigma)), +\qquad +G_{\mathrm{mono}}=-RT\log Z_{\mathrm{mono}}. +\] + +An interaction partition does **not** add a null/no-interaction state. It sums only sites and paths admitted by the configured domains, seed, energy, accessibility, noLP/noGU, length, and output filters. Reported site probabilities are conditional on this admitted interaction ensemble. + +## 4. IntaRNAnew monomer recurrences + +### 4.1 Scalar additive-pair model + +Let \(Q[a,b)\) be the partition of noncrossing matchings on \([a,b)\), with + +\[ +Q[a,a)=1. +\] + +Let \(u_j\in\{0,1\}\) state whether nucleotide \(j\) may remain unpaired, and let \(W_{ij}\ge 0\) be the Boltzmann weight of pair \((i,j)\). Forbidden pairs have \(W_{ij}=0\). Pair compatibility, minimum hairpin distance, maximum span, and x/b constraints are all encoded in \(W\); a forced-paired p position sets its unpaired indicator to zero. + +Classifying a structure by the status of its rightmost nucleotide gives + +\[ +Q[a,b) += +u_{b-1}Q[a,b-1) ++ +\sum_{i=a}^{b-2} +Q[a,i)\,W_{i,b-1}\,Q[i+1,b-1). +\tag{1} +\] + +The first term contains structures where \(b-1\) is unpaired. In every structure in the second term, \(i\) is the unique partner of \(b-1\); noncrossing then separates the prefix and enclosed interval. These classes are disjoint and exhaustive, so Equation (1) neither omits nor double-counts a structure. + +IntaRNAnew uses this recurrence for NativeAccessibility and for base-pair folding without noLP/noGU-end state dependencies. Base-pair folding uses \(\log W_{ij}=1\), corresponding to an energy of \(-1\) at \(RT=1\); NativeAccessibility uses its sequence-dependent pair energy. + +### 4.2 noLP and noGU-end states + +Pair admissibility is not scalar under noLP or noGU-at-helix-ends. IntaRNAnew therefore retains two paired states: + +- \(B^{A}_{ij}\): any structure enclosed by pair \((i,j)\); +- \(B^{S}_{ij}\): the subset whose immediate inner pair is \((i+1,j-1)\). + +A pair needing right-stack support selects \(B^S\). An outer stack can instead certify its inner pair, which selects \(B^A\). A GU pair at a helix end is excluded unless both outer and inner stack context prove it is internal. + +This stateful recurrence is source-reviewed as correct. It deliberately remains the fallback for per-interval constrained folds because substituting a scalar pair token would lose the stack context. + +### 4.3 Turner nearest-neighbour folding + +Write \(\ell(E)=-E/(RT)\). For a closing pair \((i,j)\), the Turner recurrence has the schematic form + +\[ +B^A_{ij} += +\operatorname{LSE}\left( + H_{ij}, + \{\,\ell(E_{\mathrm{int}}(i,j;k,l))+B^{\mathrm{sel}}_{kl}\,\}_{k,l}, + \ell(E_{\mathrm{multi-close}}(i,j))+M^{\ge2}[i+1,j) +\right), +\tag{2} +\] + +where \(H_{ij}\) is the hairpin term and \(B^{\mathrm{sel}}\) chooses the state required by noLP/noGU context. The stack-only state is + +\[ +B^S_{ij} += +\ell(E_{\mathrm{stack}}(i,j;i+1,j-1)) ++ +B^{\mathrm{sel}}_{i+1,j-1}. +\tag{3} +\] + +The multiloop interval states \(M^0,M^1,M^{\ge2}\) record exactly zero, exactly one, or at least two branches. With \(U_a\) the multiloop-unpaired term and \(C_{a,r}\) a valid stem branch, + +\[ +M^0[a,b)=U_a+M^0[a+1,b), +\] + +\[ +M^1[a,b) += +\operatorname{LSE}\left( +U_a+M^1[a+1,b), +\{\,C_{a,r}+M^0[r+1,b)\,\}_r +\right), +\] + +\[ +M^{\ge2}[a,b) += +\operatorname{LSE}\left( +U_a+M^{\ge2}[a+1,b), +\{\,C_{a,r}+\operatorname{LSE}(M^1,M^{\ge2})[r+1,b)\,\}_r +\right). +\tag{4} +\] + +Consequently, a multiloop closure in Equation (2) contains at least two branches. The exterior recurrence has a unique left-to-right tokenization into an unpaired nucleotide or a complete stem: + +\[ +X[r+1] +\mathrel{\oplus}= +X[r]+\ell(E_{\mathrm{unpaired}}(r)), +\] + +\[ +X[j+1] +\mathrel{\oplus}= +X[i]+B^{\mathrm{sel}}_{ij}+\ell(E_{\mathrm{exterior}}(i,j)). +\tag{5} +\] + +Equations (2)--(5) are structurally sound. The implementation represents Vienna dangle-2 as direct-neighbour decoration and matches the ordinary unsmoothed Vienna partition convention, pf_smooth=0. ViennaRNA's default smoothed partition may therefore differ slightly even when MFE energies agree. + +## 5. All-interval unpaired-partition theorem + +### 5.1 Identity + +Define the pair token + +\[ +R(i,j)=W_{ij}Q[i+1,j) +\] + +and its outside context in ordinary arithmetic + +\[ +O_R(i,j) += +\frac{\partial Q[0,n)}{\partial R(i,j)}. +\] + +Let \(U=[a,b)\) be required to remain unpaired. With unit allowed-unpaired weights, its constrained partition is + +\[ +Z_U += +Q[0,a)Q[b,n) ++ +\sum_{\substack{i\cdots>q_m. +\] + +The core energy is + +\[ +E_{\mathrm{core}}(p) += +E_{\mathrm{init}} ++ +\sum_{k=1}^{m} +E_{\mathrm{loop}}\big((t_{k-1},q_{k-1}),(t_k,q_k)\big). +\tag{8} +\] + +A DP cell fixes the terminal pair. A state additionally records the starting pair, seed automaton state, the noLP left-stack bit, and any path suffix required by explicit seeds or helix-block validation. Two paths with the same cell and state key have the same future transition set and the same site-dependent exterior energy. They may therefore be merged by + +\[ +\log W_{\mathrm{merged}} += +\operatorname{LSE}(\log W_1,\log W_2), +\tag{9} +\] + +while retaining the lower-energy path as the deterministic representative. + +For site \(s\), let \(\mathcal P_s\) be its admitted paths and define + +\[ +E_{\mathrm{ext}}(s) += +E_{\mathrm{end,L}}+E_{\mathrm{end,R}} ++E_{\mathrm{dangle,L}}+E_{\mathrm{dangle,R}} ++ED_T(s)+ED_Q(s)+E_{\mathrm{add}}. +\tag{10} +\] + +Model P now computes + +\[ +Z_s += +\exp[-E_{\mathrm{ext}}(s)/(RT)] +\sum_{p\in\mathcal P_s} +\exp[-E_{\mathrm{core}}(p)/(RT)], +\tag{11} +\] + +\[ +Z_I=\sum_s Z_s,\qquad +G_I=-RT\log Z_I,\qquad +P(s)=Z_s/Z_I. +\tag{12} +\] + +The DP state's log weight contains Equation (8), so every term of Equation (10) must be applied exactly once at site completion. The pre-audit code omitted the four end/dangle terms. + +Exact mode M performs no state-count pruning. Heuristic mode H retains protected and boundary-best states plus up to 96 additional candidates; 96 is not a strict total-state cap. Whenever pruning removes a positive path weight in model P, + +\[ +Z_H\le Z_M,\qquad G_H\ge G_M. +\tag{13} +\] + +Thus model P is exact only in mode M for the represented constraints. IntaRNAens selects model P but otherwise inherits heuristic mode and default seed constraints; use mode M for an exact configured ensemble, and disable the seed too if an unconstrained all-interaction ensemble is intended. + +## 7. Model and mode semantics + +Model and prediction mode are independent; notably, model S and mode S do not mean the same thing. + +| Model | Site contribution | Global partition | +|---|---|---| +| S, single-site | one MFE path per site | sum of site-MFE Boltzmann weights | +| X, seed extension | one MFE extension per site | valid like S without a required seed; deliberately unavailable for seeded X | +| B, helix blocks | retained paths satisfying block decomposition | heuristic retained-path ensemble; approximate | +| P, ensemble | sum of retained path weights per site | exact in mode M; underestimated after H pruning | + +The accumulated log weight is not used to select or report representatives for models S and X. The selected-only scalar kernel avoids that work entirely for its supported model-S workload; the generic engine still retains it because the weight remains essential for model P and partitioned helix-block calculations. + +CSV P_E now reports the normalized stored \(P(s)\). Before this audit it recomputed + +\[ +\exp[-\operatorname{trunc}_{0.01}(E_s)/(RT)-\log Z_I], +\] + +which could exceed one and did not necessarily sum to one. + +## 8. IntaRNAnew defects fixed + +| Finding | Evidence before fix | Correction and regression | +|---|---|---| +| Turner int21 axes were permuted for both 1x2 and 2x1 orientations in monomer folding and duplex energy | RNAeval: AACGCAGCGU / pxxpxxxpxp should be 8.90, old result 7.80; mirrored AAUCGUAAGU case should be 10.10, old 9.00. Duplex UCGU&AAA and AAU&AAGU should have loop 3.70/full 8.80, old full 7.70. | Corrected nucleotide order in folding.cpp and energy.cpp; forced-structure and duplex tests added. | +| Model-P weights omitted terminal AU/end and dangle terms | One-pair A/U has \(E=4.10+0.50+0.50=5.10\), but old site \(G\) and log Z used 4.10. | Added both end and dangle sides at site completion; seed-only core normalization changed to init+loops to avoid double counting; single-AU and dangle-domain tests added. | +| Six-value Misc sections were rejected | Named Turner99 and Andronescu07 construction threw “Misc must contain 4 values.” | Parser accepts four or six values and reads LXC when present; exact GAAAC partition tests use hairpin 5.70 and 4.75 respectively. | +| CSV P_E used rounded display energy | A one-site AU ensemble could report 1.01636 although its normalized probability was exactly 1. | Text formatting and typed sorting now use Interaction::probability; a regression makes energy order disagree with probability order and verifies probability sorting. | + +After the int21 correction, 700 randomized dangle-0 folds agreed with Vienna's partition oracle within approximately \(4.3\times10^{-7}\) kcal/mol. Dangle-2 agreed when Vienna was configured with pf_smooth=0. + +## 9. Legacy and Next recurrence audit + +### 9.1 Ensemble interaction recurrence + +For fixed right boundary \(\mathbf j=(j_1,j_2)\), let \(H_{\mathbf j}(\mathbf i)\) be the hybrid partition beginning at \(\mathbf i=(i_1,i_2)\). Without noLP, + +\[ +H_{\mathbf j}(\mathbf j)=B(E_{\mathrm{init}}), +\] + +\[ +H_{\mathbf j}(\mathbf i) += +\sum_{\mathbf k\in K(\mathbf i,\mathbf j)} +B(E_{\mathrm{loop}}(\mathbf i,\mathbf k)) +H_{\mathbf j}(\mathbf k). +\tag{14} +\] + +The admissible set \(K\) enforces complementarity, internal-loop limits, boundaries, and the fixed right endpoint. The completed site weight multiplies Equation (14) by all exterior site factors, including accessibility, terminal/end, dangle, and additive terms. + +For noLP, a single pair is invalid, so \(H_{\mathbf j}(\mathbf j)\neq B(E_{\mathrm{init}})\). A clean two-state statement is: + +- \(H_{\mathbf j}(\mathbf i)\): the leftmost pair still needs a right stack; +- \(A_{\mathbf j}(\mathbf p)\): pair \(\mathbf p\) already has left-stack support. + +Then + +\[ +H_{\mathbf j}(\mathbf i) += +B(E_{\mathrm{stack}}(\mathbf i,\mathbf i+\mathbf1)) +A_{\mathbf j}(\mathbf i+\mathbf1), +\tag{15} +\] + +\[ +A_{\mathbf j}(\mathbf p) += +\mathbf1_{\mathbf p=\mathbf j}B(E_{\mathrm{init}}) ++ +H_{\mathbf j}(\mathbf p) ++ +\sum_{\mathbf k\in L(\mathbf p,\mathbf j)} +B(E_{\mathrm{loop}}(\mathbf p,\mathbf k)) +H_{\mathbf j}(\mathbf k), +\tag{16} +\] + +where \(L\) excludes a direct stack and requires at least one unpaired nucleotide. + +### 9.2 Confirmed remaining defects + +These findings affect both Legacy and Next unless marked otherwise. They were audited but not modified in this task. + +| Status | Finding | Consequence | +|---|---|---| +| reproduced, source-confirmed | Heuristic noLP counts the direct stacked continuation and also admits the equivalent w1=w2=1 loop continuation, violating Equation (16). | Base-pair examples: length 4 exact Eall=-5.30 vs heuristic -5.46; length 5 exact -6.50 vs heuristic -6.73. The heuristic improperly has more weight than the exact recurrence. | +| reproduced, source-confirmed | Ensemble updateZ bypasses the base predictor's noGU-end and maxED filters and directly increments Zall. | One-base G/U reports Eall=-1 with noGU-end both false and true, although the true ensemble is empty; unchecked arithmetic can also overflow or propagate non-finite weights. | +| source-confirmed | Base-pair \(E_S\) uses full \(Q\), although its API defines substructures with at least one intramolecular pair. | Correct substructure energy is \(E_S=-RT\log(Q-1)\); whole-monomer energy remains \(-RT\log Q\). | +| reproduced, source-confirmed | PredictorMfeEns retains an unordered map keyed by all four site boundaries while the exact 2-D matrix is advertised as \(O(n_1n_2)\). | Retained space is \(O(n_1n_2L_1L_2)\), quartic at full length. Complementary toys used 9.6 MiB at n=20 and 106.2 MiB at n=50. | +| source-confirmed | Some seed predictors return before initZ on reuse. | Z/site state can leak from an earlier prediction. | +| source-confirmed | Out-of-range Nussinov getQb returns one rather than zero. | An invalid paired state contributes multiplicative identity. | +| source-confirmed, Legacy only | Seed extension adds independent Boltzmann factors instead of multiplying them; also duplicates adjacent noLP stacks and contains unsigned-overlap, one-past-end, and energy-vs-partition comparison faults. | Incorrect seed ensemble weights and boundary behavior. These seed defects were corrected in Next. | + +For exact no-seed Legacy/Next prediction, each four-boundary site is completed once. A proposed optimization is to stream its energy directly into the top-k and Zall accumulators rather than retain the entire site map. This should restore the advertised \(O(n_1n_2)\) DP-space scale, but it has not been implemented here. + +## 10. Performance evidence + +### 10.1 All-interval accessibility + +A 120-nt base-pair all-interval tPu workload emits 7,260 nonempty interval values. + +| Version | Wall time | Peak RSS | +|---|---:|---:| +| repeated constrained folds | 4.35 s | 5,332 KiB | +| batched inside/outside recurrence | 0.01 s | 5,888 KiB | + +This is over 400x faster, with a small table-related memory increase. Both outputs have SHA-256 + +~~~text +f81bd2eae8f08476e6cbd6a749227926914af630b04003b03a965c6bba0be864 +~~~ + +The theoretical result is the more portable claim: all intervals changed from \(O(n^5)\) time to \(O(n^3)\) time and \(O(n^2)\) memory. + +### 10.2 Interaction predictor + +The durable biological matrix uses the public fhlA/OxyS, ilvE/GcvB-ST, and phoB/GcvB sequences with energy B, accessibility N, no seed, exact mode M, model S, interaction length at most 20, top three energy-sorted CSV rows, and one thread. The benchmark performs two warm-ups and nine alternating timed subprocess runs. It rejects a case before timing unless Legacy and IntaRNAnew stdout are byte-for-byte identical. Measurements below are medians on GCC 13.3.0 and an AMD Ryzen 5 7530U. + +Before the redesign, this workload exposed an architectural mismatch: + +- every DP state owned and repeatedly copied a full interaction path, seed vector, and log weight; +- every grid cell retained start-indexed states in vectors and rebuilt an unordered map while propagating them; +- model S paid for millions of log-sum-exp operations even though its representative ignores accumulated path weights; +- every terminal state was reevaluated and materialized into a map-held Interaction, followed by a global sort to return three rows; +- disabled accessibility eagerly constructed whole-monomer partition summaries even though the selected columns did not use them. + +For fhlA/OxyS this caused 14.50 million state merges, 14.02 million log additions, 30.36 million heap allocations, 3.505 GB of cumulative heap traffic, and 359.8 MB peak live heap. The two larger cases each retained more than four million PathStates and materialized more than three million sites, producing about 2.4 GiB RSS. + +For a fixed pairable start, write \(C[i,j]\) for the maximum number of pairs in a path ending at target offset \(i\) and reverse-query offset \(j\). In the base-pair energy model, + +\[ +C[0,0]=1,\qquad +C[i,j]=1+\max_{\substack{i-d_T\le a/dev/null; \ + for tool in $(TOOLS_APPS); do "$$stage/usr/bin/$$tool" --help >/dev/null; done; \ + $(CXX) $(CXXFLAGS) -I"$$stage/usr/include" \ + $(CONSUMER_SMOKE_SOURCE) -L"$$stage/usr/lib" \ + -lintarnanew-tools -lintarnanew $(LDLIBS) $(THREAD_FLAGS) \ + -o "$$stage/intarnanew-consumer"; \ + "$$stage/intarnanew-consumer" + +clean: + $(RM) $(ALL_OBJECTS) $(DEPENDENCIES) \ + $(CORE_LIBRARY) $(TOOLS_LIBRARY) IntaRNAnew $(TOOLS_APPS) \ + IntaRNAnewBenchmark \ + IntaRNAnewIntegrationTests IntaRNAnewCliIoOutputTests \ + IntaRNAnewConfigRegistryTests \ + IntaRNAnewAccessibilityOracleTests IntaRNAnewFoldingOracleTests \ + IntaRNAnewPredictorOracleTests \ + IntaRNAnewExecutionRegionsTests IntaRNAnewModelSemanticsTests \ + IntaRNAnewCompressionIoTests \ + IntaRNAnewOutputExecutionTests \ + IntaRNAnewRunnerContractTests \ + IntaRNAnewPvalueExecutableTests \ + IntaRNAnewToolsContractTests + +-include $(DEPENDENCIES) diff --git a/IntaRNAnew/README.md b/IntaRNAnew/README.md new file mode 100644 index 0000000..6a6babe --- /dev/null +++ b/IntaRNAnew/README.md @@ -0,0 +1,134 @@ +# IntaRNAnew + +IntaRNAnew is an independent, standalone C++23 implementation of RNA–RNA +interaction prediction. Its scientific core, command-line interface, FASTA +handling, accessibility calculation, thermodynamic scoring, ensemble arithmetic, +tracking output, and tests are all native C++. It has no Python, Boost, ViennaRNA, +zlib, or other runtime/library dependency beyond the C++ standard library. The +nearest-neighbor model reads public ViennaRNA v2 `.par` data files but does not +link to the ViennaRNA library. + +The implementation was designed from the public IntaRNA model and verified with +end-to-end RNA–RNA interaction behavior from IntaRNA Legacy. No source code was +copied from either historical implementation. + +## Build + +GCC 13 or Clang 17 and GNU Make are sufficient: + +```sh +make -j +make check +``` + +The project also provides a CMake build for systems with CMake 3.25 or newer. +Both build systems require a C++23 standard library and a working thread +implementation. The installed libraries are static, so the public ABI does not +depend on platform-specific shared-library export annotations. + +The Make build tracks included headers with compiler-generated dependency files. +Its debug and sanitizer targets run clean and build sequentially, avoiding a +parallel clean/build race: + +```sh +make debug +make sanitize +``` + +To stage an installation, smoke every installed executable, and compile a +standalone C++ consumer against only the installed headers and archives: + +```sh +make install-check +``` + +For a CMake build and install: + +```sh +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure +cmake --install build --prefix /desired/prefix +``` + +An installed CMake consumer uses `find_package(IntaRNAnew CONFIG REQUIRED)` and +links `IntaRNAnew::core` or `IntaRNAnew::tools`. A complete consumer smoke project +is provided in `tests/consumer`. + +## Quick start + +```sh +./IntaRNAnew --target=CCAACCCACC --query=GGGG \ + --energy=B --acc=N --seedBP=3 --mode=M --model=S \ + --outMode=C --outNumber=10 +``` + +Inputs may be literal IUPAC RNA sequences, FASTA files, multi-FASTA files, or +`STDIN`. Run `./IntaRNAnew --fullhelp` for the supported compatibility surface. + +## Architecture + +- `Sequence` and `SequenceReader`: validated IUPAC RNA and FASTA parsing. +- `AccessibilityProvider`: disabled, table-backed, and native log-space + pseudoknot-free accessibility models. +- `HybridEnergyModel`: Nussinov-like and independent nearest-neighbor models. +- `Predictor`: endpoint-aware antiparallel DAG dynamic programming with seed + automata, exact/heuristic/seed-only execution, helix filtering, k-best overlap + policies, and log-space ensemble aggregation. +- `predictPair`: reusable in-process evaluation with the same region/window + planning and global reduction contract as one command-line sequence pair. +- `OutputFormatter`: normal, detailed, CSV, ensemble, accessibility, profile, + matrix, and spot-probability outputs. +- `app/main.cpp`: immutable configuration, deterministic multi-sequence task + execution using `std::jthread`, and transactional result reduction. + +The recurrence definitions, partition semantics, correctness findings, proof of +the cubic all-interval accessibility algorithm, performance measurements, and +the separate Legacy/Next audit are documented in +[MATHEMATICAL_AUDIT.md](MATHEMATICAL_AUDIT.md). + +## Model scope + +The base-pair (`--energy=B`) model is the strongest cross-version compatibility +oracle because it is independent of thermodynamic tables and uses `RT=1`. The +native nearest-neighbor model parses the public ViennaRNA v2 stack, loop, +mismatch, dangle, terminal, and enthalpy sections and applies temperature +interpolation without linking ViennaRNA. `--energyVRNA` accepts `Turner04`, +`Turner99`, `Andronescu07`, or an explicit `.par` path. Named sets are searched +in `INTARNANEW_PARAMETER_DIR`, `VIENNA_RNA_DATAPATH`, `VRNA_DATAPATH`, the active +Conda prefix, common system data directories, and bounded project-relative data +directories. Missing or incomplete tables are reported; they are never replaced +with approximate built-ins. All energy components remain visible in CSV output. +For builds outside this source tree, provide public `.par` files through one of +the search locations above. CMake test builds can set +`-DINTARNANEW_TEST_PARAMETER_DIR=/path/to/ViennaRNA`; a sibling legacy test +environment is detected only as a local development convenience. + +Turner partition functions use the ordinary unsmoothed Boltzmann sum, equivalent +to ViennaRNA with `pf_smooth=0`. ViennaRNA's default smoothed dangle-2 +partition can therefore differ slightly even when MFE energies agree. + +Model P is an exact interaction partition only with exact prediction mode +(`--model=P --mode=M`) for the configured constraints. Heuristic mode H can +prune positive-weight paths and consequently underestimates Z. The +`IntaRNAens` personality selects model P but retains heuristic mode unless +`--mode=M` is also requested; disable the seed as well when an unconstrained +all-interaction ensemble is intended. + +## Validation + +`make check` runs real RNA–RNA interaction scenarios derived from the legacy +black-box integration suite—not tests of legacy internal classes. The suite covers +exact base-pair predictions, seed constraints, no-lonely-pair behavior, overlap +policies, coordinates, IUPAC input, and nearest-neighbor legacy-oracle motifs for +stacks, bulges, internal loops, exterior mismatches, temperature, and named sets. + +On POSIX systems, `make check` also contains an executable-level 17-case parity +gate. It is skipped when no legacy executable was supplied. Set +`INTARNA_LEGACY_BIN` to a runnable IntaRNA Legacy binary to run both programs on +the same public `.parameter` corpus and compare stdout byte-for-byte; legacy logs +and diagnostics are routed to `/dev/null`. The standalone `make legacy-check` +target runs the same gate. CMake captures this environment variable at configure +time; it can also be set explicitly with +`-DINTARNA_LEGACY_BIN=/path/to/IntaRNA`. No legacy source or internal classes are +used. diff --git a/IntaRNAnew/app/main.cpp b/IntaRNAnew/app/main.cpp new file mode 100644 index 0000000..6dbdcf4 --- /dev/null +++ b/IntaRNAnew/app/main.cpp @@ -0,0 +1,481 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/cli.hpp" +#include "intarnanew/output.hpp" +#include "intarnanew/output_plan.hpp" +#include "intarnanew/parallel.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct OutputGroup { + std::size_t targetIndex{}; + std::size_t queryIndex{}; + std::size_t targetRegionIndex{}; + std::size_t queryRegionIndex{}; + std::vector workIndices; +}; + +struct WorkItem { + std::size_t targetIndex{}; + std::size_t queryIndex{}; + intarnanew::Interval targetDomain; + intarnanew::Interval queryDomain; +}; + +struct WorkResult { + intarnanew::PredictionResult prediction; +}; + +[[nodiscard]] auto lowerAscii(const std::string_view text) -> std::string { + std::string result(text); + std::ranges::transform(result, result.begin(), [](const unsigned char character) { + return static_cast(std::tolower(character)); + }); + return result; +} + +[[nodiscard]] auto outputPrefix(const std::string_view descriptor) -> std::string { + return lowerAscii(descriptor.substr(0U, descriptor.find(':'))); +} + +[[nodiscard]] auto needsCompleteSiteEnsemble(const intarnanew::Config& config) -> bool { + return std::ranges::any_of(config.output.destinations, [](const std::string& descriptor) { + const auto prefix = outputPrefix(descriptor); + return prefix == "qmine" || prefix == "tmine" || prefix == "qspotprob" || + prefix == "tspotprob" || prefix == "pmine" || prefix == "spotprob"; + }); +} + +[[nodiscard]] auto csvHasColumn( + const std::string_view specification, + const std::string_view requested) -> bool { + if (specification.empty() || specification == "*") return true; + std::size_t begin{}; + while (begin <= specification.size()) { + const auto comma = specification.find(',', begin); + const auto end = comma == std::string_view::npos ? specification.size() : comma; + auto column = specification.substr(begin, end - begin); + while (!column.empty() && std::isspace(static_cast(column.front())) != 0) { + column.remove_prefix(1U); + } + while (!column.empty() && std::isspace(static_cast(column.back())) != 0) { + column.remove_suffix(1U); + } + if (lowerAscii(column) == lowerAscii(requested)) return true; + if (comma == std::string_view::npos) break; + begin = comma + 1U; + } + return false; +} + +[[nodiscard]] auto needsInteractionPartition(const intarnanew::Config& config) -> bool { + if (config.output.mode == intarnanew::OutputMode::ensemble) return true; + if (config.output.mode == intarnanew::OutputMode::csv && + (csvHasColumn(config.output.csvColumns, "Eall") || + csvHasColumn(config.output.csvColumns, "Zall") || + csvHasColumn(config.output.csvColumns, "EallTotal") || + csvHasColumn(config.output.csvColumns, "P_E"))) { + return true; + } + return std::ranges::any_of(config.output.destinations, [](const std::string& descriptor) { + const auto prefix = outputPrefix(descriptor); + return prefix == "qspotprob" || prefix == "tspotprob" || prefix == "spotprob"; + }); +} + +[[nodiscard]] auto needsMonomerPartition(const intarnanew::Config& config) -> bool { + if (config.output.mode == intarnanew::OutputMode::ensemble) return true; + return config.output.mode == intarnanew::OutputMode::csv && + (csvHasColumn(config.output.csvColumns, "Etotal") || + csvHasColumn(config.output.csvColumns, "Eall1") || + csvHasColumn(config.output.csvColumns, "Eall2") || + csvHasColumn(config.output.csvColumns, "Zall1") || + csvHasColumn(config.output.csvColumns, "Zall2") || + csvHasColumn(config.output.csvColumns, "EallTotal")); +} + +[[nodiscard]] auto needsInteractionTraceback(const intarnanew::Config& config) -> bool { + if (config.output.mode == intarnanew::OutputMode::normal || + config.output.mode == intarnanew::OutputMode::detailed) { + return true; + } + if (config.output.mode != intarnanew::OutputMode::csv) return false; + static constexpr std::string_view structuralColumns[]{ + "hybridDP", "hybridDB", "hybridDPfull", "hybridDBfull", "bpList", + "seedStart1", "seedEnd1", "seedStart2", "seedEnd2", + "seedE", "seedED1", "seedED2", "seedPu1", "seedPu2", + }; + return std::ranges::any_of(structuralColumns, [&](const std::string_view column) { + return csvHasColumn(config.output.csvColumns, column); + }); +} +} // namespace + +auto main(const int argc, char** argv) -> int { + try { + std::vector arguments; + arguments.reserve(static_cast(std::max(0, argc - 1))); + for (int index = 1; index < argc; ++index) arguments.emplace_back(argv[index]); + + auto configResult = intarnanew::Cli::parse(arguments, argv[0]); + if (!configResult) { + std::cerr << "IntaRNAnew: " << configResult.error() << "\nRun --help for usage.\n"; + return 2; + } + auto config = std::move(*configResult); + if (config.action == intarnanew::RunAction::help) { + std::cout << intarnanew::Cli::help(false); + return 0; + } + if (config.action == intarnanew::RunAction::fullHelp) { + std::cout << intarnanew::Cli::help(true); + return 0; + } + if (config.action == intarnanew::RunAction::version) { + std::cout << intarnanew::Cli::version(); + return 0; + } + + config.predictionRequirements.computeMonomerPartition = + needsMonomerPartition(config); + + auto targetResult = intarnanew::SequenceReader::read( + config.target.input, config.target.id, config.target.firstPosition, std::cin); + if (!targetResult) { + std::cerr << "IntaRNAnew: target input: " << targetResult.error() << '\n'; + return 2; + } + auto queryResult = intarnanew::SequenceReader::read( + config.query.input, config.query.id, config.query.firstPosition, std::cin); + if (!queryResult) { + std::cerr << "IntaRNAnew: query input: " << queryResult.error() << '\n'; + return 2; + } + auto targets = std::move(*targetResult); + auto queries = std::move(*queryResult); + auto selectedTargets = intarnanew::SequenceReader::select( + std::move(targets), config.target.subset); + if (!selectedTargets) { + std::cerr << "IntaRNAnew: target set: " << selectedTargets.error() << '\n'; + return 2; + } + auto selectedQueries = intarnanew::SequenceReader::select( + std::move(queries), config.query.subset); + if (!selectedQueries) { + std::cerr << "IntaRNAnew: query set: " << selectedQueries.error() << '\n'; + return 2; + } + targets = std::move(*selectedTargets); + queries = std::move(*selectedQueries); + if (config.output.pairwise && targets.size() != queries.size()) { + std::cerr << "IntaRNAnew: --outPairwise requires equal target and query record counts\n"; + return 2; + } + + std::vector> targetAccessibility; + targetAccessibility.reserve(targets.size()); + for (std::size_t index = 0U; index < targets.size(); ++index) { + auto accessibility = intarnanew::makeAccessibility( + targets[index], config.target, config); + if (!accessibility) { + std::cerr << "IntaRNAnew: target accessibility " << index + 1U << ": " + << accessibility.error() << '\n'; + return 2; + } + targetAccessibility.push_back(std::move(*accessibility)); + } + std::vector> queryAccessibility; + queryAccessibility.reserve(queries.size()); + for (std::size_t index = 0U; index < queries.size(); ++index) { + auto accessibility = intarnanew::makeAccessibility( + queries[index], config.query, config); + if (!accessibility) { + std::cerr << "IntaRNAnew: query accessibility " << index + 1U << ": " + << accessibility.error() << '\n'; + return 2; + } + queryAccessibility.push_back(std::move(*accessibility)); + } + + const auto planRegions = [&](const std::vector& sequences, + const intarnanew::SideConfig& side, + const auto& accessibility, + const std::string_view label) + -> std::expected>, std::string> { + std::vector> all; + all.reserve(sequences.size()); + for (std::size_t index = 0U; index < sequences.size(); ++index) { + auto configured = intarnanew::configuredRegions(sequences[index], side); + if (!configured) { + return std::unexpected(std::string(label) + " " + std::to_string(index + 1U) + + " regions: " + configured.error()); + } + try { + all.push_back(intarnanew::decomposeAccessibleRegions( + *configured, side.regionLengthMax, config.seed.basePairs, + *accessibility[index])); + } catch (const std::exception& exception) { + return std::unexpected(std::string(label) + " " + std::to_string(index + 1U) + + " automatic regions: " + exception.what()); + } + } + return all; + }; + auto targetRegionsResult = planRegions( + targets, config.target, targetAccessibility, "target"); + if (!targetRegionsResult) { + std::cerr << "IntaRNAnew: " << targetRegionsResult.error() << '\n'; + return 2; + } + auto queryRegionsResult = planRegions( + queries, config.query, queryAccessibility, "query"); + if (!queryRegionsResult) { + std::cerr << "IntaRNAnew: " << queryRegionsResult.error() << '\n'; + return 2; + } + const auto& targetRegions = *targetRegionsResult; + const auto& queryRegions = *queryRegionsResult; + + std::vector> pairs; + if (config.output.pairwise) { + for (std::size_t index = 0; index < targets.size(); ++index) pairs.emplace_back(index, index); + } else { + for (std::size_t targetIndex = 0; targetIndex < targets.size(); ++targetIndex) { + for (std::size_t queryIndex = 0; queryIndex < queries.size(); ++queryIndex) { + pairs.emplace_back(targetIndex, queryIndex); + } + } + } + + std::vector groups; + std::vector workItems; + const auto appendDomains = [&](const std::size_t groupIndex, + const std::size_t targetIndex, + const std::size_t queryIndex, + const intarnanew::Interval targetRegion, + const intarnanew::Interval queryRegion) { + const auto targetWindows = intarnanew::decomposeWindows( + targetRegion, config.windowWidth, config.windowOverlap); + const auto queryWindows = intarnanew::decomposeWindows( + queryRegion, config.windowWidth, config.windowOverlap); + for (const auto targetWindow : targetWindows) { + for (const auto queryWindow : queryWindows) { + const auto workIndex = workItems.size(); + workItems.push_back({targetIndex, queryIndex, + targetWindow, queryWindow}); + groups[groupIndex].workIndices.push_back(workIndex); + } + } + }; + for (const auto& [targetIndex, queryIndex] : pairs) { + if (!config.output.perRegion) { + const auto groupIndex = groups.size(); + groups.push_back({targetIndex, queryIndex, 0U, 0U, {}}); + for (std::size_t targetRegionIndex = 0U; + targetRegionIndex < targetRegions[targetIndex].size(); ++targetRegionIndex) { + for (std::size_t queryRegionIndex = 0U; + queryRegionIndex < queryRegions[queryIndex].size(); ++queryRegionIndex) { + appendDomains(groupIndex, targetIndex, queryIndex, + targetRegions[targetIndex][targetRegionIndex], + queryRegions[queryIndex][queryRegionIndex]); + } + } + continue; + } + if (targetRegions[targetIndex].empty() || queryRegions[queryIndex].empty()) { + groups.push_back({targetIndex, queryIndex, 0U, 0U, {}}); + continue; + } + for (std::size_t targetRegionIndex = 0U; + targetRegionIndex < targetRegions[targetIndex].size(); ++targetRegionIndex) { + for (std::size_t queryRegionIndex = 0U; + queryRegionIndex < queryRegions[queryIndex].size(); ++queryRegionIndex) { + const auto groupIndex = groups.size(); + groups.push_back({targetIndex, queryIndex, + targetRegionIndex, queryRegionIndex, {}}); + appendDomains(groupIndex, targetIndex, queryIndex, + targetRegions[targetIndex][targetRegionIndex], + queryRegions[queryIndex][queryRegionIndex]); + } + } + } + + std::vector outputGroups; + outputGroups.reserve(groups.size()); + for (const auto& group : groups) { + outputGroups.push_back({ + group.targetIndex, group.queryIndex, + group.targetRegionIndex, group.queryRegionIndex, + }); + } + auto outputPlan = intarnanew::planOutputs( + config, targets.size(), queries.size(), outputGroups); + if (!outputPlan) { + std::cerr << "IntaRNAnew: output plan: " << outputPlan.error() << '\n'; + return 2; + } + + // A complete site list is only needed by site/profile auxiliary + // outputs or to merge overlapping work domains. Ordinary primary + // output consumes the globally ranked interactions alone. This is the + // same output-sensitive distinction made by Legacy's needZall/needBPs + // flags, but direct Predictor users retain the conservative defaults. + const bool oneDomainPerGroup = std::ranges::all_of(groups, [](const OutputGroup& group) { + return group.workIndices.size() <= 1U; + }); + if (oneDomainPerGroup && + config.output.overlap == intarnanew::OverlapPolicy::both && + !needsCompleteSiteEnsemble(config)) { + config.predictionRequirements.retainAllSites = false; + } + config.predictionRequirements.computeInteractionPartition = + needsInteractionPartition(config); + config.predictionRequirements.traceback = + needsInteractionTraceback(config); + std::vector workResults(workItems.size()); + const auto workerCount = std::min( + std::max(1U, config.threads), std::max(1U, workItems.size())); + std::vector> predictors; + predictors.reserve(workerCount); + try { + for (std::size_t index = 0U; index < workerCount; ++index) { + predictors.push_back(std::make_unique(config)); + } + } catch (const std::exception& exception) { + std::cerr << "IntaRNAnew: failed to initialize prediction workers: " + << exception.what() << '\n'; + return 1; + } + + auto execution = intarnanew::runParallelIndexed( + workItems.size(), workerCount, + [&](const std::size_t workerIndex, + const std::size_t taskIndex, + const std::stop_token) { + const auto& task = workItems[taskIndex]; + workResults[taskIndex].prediction = predictors[workerIndex]->predict( + targets[task.targetIndex], queries[task.queryIndex], + *targetAccessibility[task.targetIndex], *queryAccessibility[task.queryIndex], + task.targetDomain, task.queryDomain); + }); + if (!execution) { + if (execution.error().taskIndex == std::numeric_limits::max()) { + std::cerr << "IntaRNAnew: " << execution.error().message << '\n'; + } else { + const auto& task = workItems[execution.error().taskIndex]; + std::cerr << "IntaRNAnew: prediction failed for target " << task.targetIndex + 1U + << ", query " << task.queryIndex + 1U << ": " + << execution.error().message << '\n'; + } + return 1; + } + + std::vector results; + results.reserve(groups.size()); + for (const auto& group : groups) { + std::vector predictions; + predictions.reserve(group.workIndices.size()); + for (const auto workIndex : group.workIndices) { + predictions.push_back(std::move(workResults[workIndex].prediction)); + } + intarnanew::PredictionResult prediction; + if (predictions.empty()) { + prediction.rt = predictors.front()->rt(); + } else if (predictions.size() == 1U) { + prediction = std::move(predictions.front()); + } else { + prediction = intarnanew::reducePredictions(predictions, config); + } + if (const auto value = targetAccessibility[group.targetIndex]->ensembleLogPartition()) { + prediction.targetLogPartition = *value; + } + if (const auto value = queryAccessibility[group.queryIndex]->ensembleLogPartition()) { + prediction.queryLogPartition = *value; + } + if (const auto value = targetAccessibility[group.targetIndex]->ensembleFreeEnergy()) { + prediction.targetEnsembleFreeEnergy = *value; + } + if (const auto value = queryAccessibility[group.queryIndex]->ensembleFreeEnergy()) { + prediction.queryEnsembleFreeEnergy = *value; + } + + results.push_back(std::move(prediction)); + } + + std::vector artifacts; + artifacts.reserve(outputPlan->publications.size()); + for (const auto& publication : outputPlan->publications) { + std::string content; + bool includeCsvHeader = true; + for (const auto& part : publication.parts) { + if (part.kind == intarnanew::OutputPartKind::primary) { + const auto& group = groups[part.groupIndex]; + auto formatted = intarnanew::OutputFormatter::primary( + config, targets[group.targetIndex], queries[group.queryIndex], + results[part.groupIndex], includeCsvHeader); + if (!formatted) { + std::cerr << "IntaRNAnew: output failed for target " + << group.targetIndex + 1U << ", query " + << group.queryIndex + 1U << ": " << formatted.error() << '\n'; + return 1; + } + content += *formatted; + includeCsvHeader = false; + continue; + } + + const auto& descriptor = config.output.destinations[part.descriptorIndex]; + std::size_t targetIndex{}; + std::size_t queryIndex{}; + const intarnanew::PredictionResult* prediction{}; + intarnanew::PredictionResult emptyPrediction; + if (part.kind == intarnanew::OutputPartKind::pairAuxiliary) { + const auto& group = groups[part.groupIndex]; + targetIndex = group.targetIndex; + queryIndex = group.queryIndex; + prediction = &results[part.groupIndex]; + } else if (part.kind == intarnanew::OutputPartKind::queryAccessibility) { + queryIndex = part.sequenceIndex; + prediction = &emptyPrediction; + } else { + targetIndex = part.sequenceIndex; + prediction = &emptyPrediction; + } + auto formatted = intarnanew::OutputFormatter::auxiliary( + descriptor, config, targets[targetIndex], queries[queryIndex], + *targetAccessibility[targetIndex], *queryAccessibility[queryIndex], + *prediction); + if (!formatted) { + std::cerr << "IntaRNAnew: auxiliary output failed for target " + << targetIndex + 1U << ", query " << queryIndex + 1U + << ": " << formatted.error() << '\n'; + return 1; + } + content += *formatted; + } + artifacts.push_back({publication.destination, std::move(content)}); + } + if (auto status = intarnanew::publishOutputs(artifacts); !status) { + std::cerr << "IntaRNAnew: " << status.error() << '\n'; + return 1; + } + return 0; + } catch (const std::exception& exception) { + std::cerr << "IntaRNAnew: unexpected failure: " << exception.what() << '\n'; + return 1; + } +} diff --git a/IntaRNAnew/benchmarks/README.md b/IntaRNAnew/benchmarks/README.md new file mode 100644 index 0000000..67be72b --- /dev/null +++ b/IntaRNAnew/benchmarks/README.md @@ -0,0 +1,83 @@ +# Native performance comparison + +`compare.cpp` is a standalone C++23 benchmark driver for fair black-box +comparisons between IntaRNA Legacy and IntaRNAnew. It does not import either +implementation and never inspects internal classes. + +The driver first compares stdout byte-for-byte for every parameter file. A case +with different scientific/output behavior is rejected and never contributes to +the reported speedup. It then performs configurable warm-ups and alternating +timed subprocess runs at the same thread count. The CSV report contains median +wall time, median child peak RSS, per-case legacy/new speedup, and a geometric +mean over compatible cases. + +Build and run from the repository root: + +```sh +g++ -std=c++23 -O3 -DNDEBUG -Wall -Wextra -Wpedantic -Wconversion -Wshadow \ + IntaRNAnew/benchmarks/compare.cpp -o IntaRNAnew/IntaRNAnewBenchmark + +LD_LIBRARY_PATH="$PWD/.conda-env/lib" \ + IntaRNAnew/IntaRNAnewBenchmark \ + --legacy "$PWD/src/bin/IntaRNA" \ + --new "$PWD/IntaRNAnew/IntaRNAnew" \ + --cases "$PWD/IntaRNAnew/tests/fixtures/parameters" \ + --warmups 2 --repetitions 9 --threads 1 +``` + +The legacy path can instead be supplied once through `INTARNA_LEGACY_BIN`. The +automated correctness gate is: + +```sh +INTARNA_LEGACY_BIN="$PWD/src/bin/IntaRNA" \ + make -C IntaRNAnew legacy-check +``` + +It asserts that the fixture directory contains exactly 17 cases. When +`INTARNA_LEGACY_BIN` is not set, the gate reports `SKIP` rather than making the +normal native test suite depend on a separately installed legacy executable. + +Each child run has a 300-second wall-time limit by default; use `--timeout N` +to select another positive limit in seconds. + +The Makefile also provides `make -C IntaRNAnew benchmark`. CMake test builds +compile the driver for the parity gate. If tests are disabled, configure with +`-DBUILD_TESTING=OFF -DINTARNANEW_BUILD_BENCHMARKS=ON` to build it explicitly. + +For a correctness-only executable gate, replace the warm-up/repetition options +with `--verify-only`. The driver routes legacy informational logs away from +stdout before comparing the scientific output. + +The runner uses POSIX process and resource-accounting APIs and is therefore a +development benchmark, not an installed portable library component. Keep +correctness and performance separate: never suppress the parity gate merely to +produce a faster number. + +`benchmarks/cases/` contains bounded biological workloads using the public +fhlA/OxyS, phoB/GcvB, and ilvE/GcvB-ST FASTA data shipped with this +repository. They select the table-independent base-pair model and compare +the first three exact-mode interactions byte-for-byte before timing. This +bounded cutoff avoids ending inside the workloads' large equal-energy tie groups. +The tiny unit-style +parameter corpus is useful as a compatibility gate, but its timings are mostly +process startup and should not be presented as algorithmic performance. +Run these biological cases from the repository root as in the command above. + +## Reference result after the output-aware redesign + +On 2026-08-13, using GCC 13.3.0 on an AMD Ryzen 5 7530U, the release build +produced these medians with two warm-ups, nine alternating repetitions, and one +thread: + +| Case | Legacy | IntaRNAnew | Legacy/new speedup | Legacy RSS | New RSS | +|---|---:|---:|---:|---:|---:| +| fhlA/OxyS | 504.936 ms | 36.066 ms | 14.000x | 7,424 KiB | 5,120 KiB | +| ilvE/GcvB-ST | 4008.653 ms | 208.473 ms | 19.229x | 7,364 KiB | 5,120 KiB | +| phoB/GcvB | 3821.776 ms | 205.155 ms | 18.629x | 7,364 KiB | 5,120 KiB | +| geometric mean | | | **17.117x** | | | + +All three cases passed the byte-for-byte gate before timing. These cases exercise +the output-aware exact/base-pair/model-S scalar kernel. Configurations requesting +partitions, complete sites, tracebacks, constraints, seeds, other models, or +multi-domain reduction deliberately use the general predictor and need separate +performance characterization. diff --git a/IntaRNAnew/benchmarks/cases/fhlA-OxyS-basepair.parameter b/IntaRNAnew/benchmarks/cases/fhlA-OxyS-basepair.parameter new file mode 100644 index 0000000..5154d1f --- /dev/null +++ b/IntaRNAnew/benchmarks/cases/fhlA-OxyS-basepair.parameter @@ -0,0 +1,14 @@ +target=doc/handson/fhlA.fasta +query=doc/handson/OxyS.fasta +tIdxPos0=-53 +energy=B +tAcc=N +qAcc=N +noSeed=true +mode=M +model=S +intLenMax=20 +outMode=C +outCsvCols=id1,start1,end1,id2,start2,end2,E +outCsvSort=E +outNumber=3 diff --git a/IntaRNAnew/benchmarks/cases/ilvE-GcvB-ST-basepair.parameter b/IntaRNAnew/benchmarks/cases/ilvE-GcvB-ST-basepair.parameter new file mode 100644 index 0000000..bc1036d --- /dev/null +++ b/IntaRNAnew/benchmarks/cases/ilvE-GcvB-ST-basepair.parameter @@ -0,0 +1,14 @@ +target=doc/handson/ilvE.fasta +query=doc/handson/GcvB.ST.fasta +tIdxPos0=-200 +energy=B +tAcc=N +qAcc=N +noSeed=true +mode=M +model=S +intLenMax=20 +outMode=C +outCsvCols=id1,start1,end1,id2,start2,end2,E +outCsvSort=E +outNumber=3 diff --git a/IntaRNAnew/benchmarks/cases/phoB-GcvB-basepair.parameter b/IntaRNAnew/benchmarks/cases/phoB-GcvB-basepair.parameter new file mode 100644 index 0000000..04addba --- /dev/null +++ b/IntaRNAnew/benchmarks/cases/phoB-GcvB-basepair.parameter @@ -0,0 +1,14 @@ +target=doc/handson/phoB.fasta +query=doc/handson/GcvB.fasta +tIdxPos0=-200 +energy=B +tAcc=N +qAcc=N +noSeed=true +mode=M +model=S +intLenMax=20 +outMode=C +outCsvCols=id1,start1,end1,id2,start2,end2,E +outCsvSort=E +outNumber=3 diff --git a/IntaRNAnew/benchmarks/compare.cpp b/IntaRNAnew/benchmarks/compare.cpp new file mode 100644 index 0000000..a05b54b --- /dev/null +++ b/IntaRNAnew/benchmarks/compare.cpp @@ -0,0 +1,528 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using Clock = std::chrono::steady_clock; + +struct Arguments { + std::filesystem::path legacy; + std::filesystem::path current; + std::filesystem::path cases; + std::size_t warmups{1U}; + std::size_t repetitions{7U}; + unsigned int threads{1U}; + std::size_t timeoutSeconds{300U}; + std::optional expectedCases; + bool verifyOnly{}; + bool skipWithoutLegacy{}; +}; + +struct RunResult { + std::chrono::nanoseconds elapsed{}; + long peakRssKiB{}; + int exitCode{}; + std::optional signal; + std::string output; +}; + +[[nodiscard]] auto usage() -> std::string_view { + return + "Usage: IntaRNAnewBenchmark [--legacy PATH] --new PATH --cases FILE|DIR\n" + " [--warmups N] [--repetitions N] [--threads N] [--timeout N]\n" + " [--verify-only] [--expect-cases N] [--skip-without-legacy]\n\n" + "--legacy may be omitted when INTARNA_LEGACY_BIN names the executable.\n" + "Every .parameter case is first run through both executables and compared\n" + "byte-for-byte. A mismatching case is never timed. Timed repetitions are\n" + "alternated to reduce order bias; stdout and stderr are discarded. Use\n" + "--verify-only for a compatibility gate without timing.\n"; +} + +template +[[nodiscard]] auto parseInteger( + const std::string_view text, + const std::string_view option, + const bool allowZero) -> std::expected { + Integer value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || error != std::errc{} || end != text.data() + text.size() || + (!allowZero && value == 0)) { + return std::unexpected(std::string(option) + " requires " + + (allowZero ? "a nonnegative" : "a positive") + " integer"); + } + return value; +} + +[[nodiscard]] auto parseArguments( + const int argc, + char** argv) -> std::expected { + Arguments result; + for (int index = 1; index < argc; ++index) { + const std::string_view option{argv[index]}; + const auto value = [&]() -> std::expected { + if (index + 1 >= argc) { + return std::unexpected("missing value for " + std::string(option)); + } + return std::string_view{argv[++index]}; + }; + if (option == "--help" || option == "-h") { + return std::unexpected(std::string{}); + } + if (option == "--verify-only") { + result.verifyOnly = true; + continue; + } + if (option == "--skip-without-legacy") { + result.skipWithoutLegacy = true; + continue; + } + if (option == "--legacy") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + result.legacy = *parsed; + } else if (option == "--new") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + result.current = *parsed; + } else if (option == "--cases") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + result.cases = *parsed; + } else if (option == "--warmups") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + auto number = parseInteger(*parsed, option, true); + if (!number) return std::unexpected(number.error()); + result.warmups = *number; + } else if (option == "--repetitions") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + auto number = parseInteger(*parsed, option, false); + if (!number) return std::unexpected(number.error()); + result.repetitions = *number; + } else if (option == "--threads") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + auto number = parseInteger(*parsed, option, false); + if (!number) return std::unexpected(number.error()); + result.threads = *number; + } else if (option == "--timeout") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + auto number = parseInteger(*parsed, option, false); + if (!number) return std::unexpected(number.error()); + result.timeoutSeconds = *number; + } else if (option == "--expect-cases") { + auto parsed = value(); + if (!parsed) return std::unexpected(parsed.error()); + auto number = parseInteger(*parsed, option, false); + if (!number) return std::unexpected(number.error()); + result.expectedCases = *number; + } else { + return std::unexpected("unknown option '" + std::string(option) + "'"); + } + } + if (result.legacy.empty()) { + if (const char* environment = std::getenv("INTARNA_LEGACY_BIN"); + environment != nullptr && *environment != '\0') { + result.legacy = environment; + } + } + if (result.current.empty() || result.cases.empty()) { + return std::unexpected("--new and --cases are required"); + } + if (result.legacy.empty() && !result.skipWithoutLegacy) { + return std::unexpected("--legacy or INTARNA_LEGACY_BIN is required"); + } + const auto executable = [](const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && + ::access(path.c_str(), X_OK) == 0; + }; + if (!result.legacy.empty() && !executable(result.legacy)) { + return std::unexpected("legacy executable is not a runnable regular file"); + } + if (!executable(result.current)) { + return std::unexpected("new executable is not a runnable regular file"); + } + return result; +} + +[[nodiscard]] auto benchmarkCases( + const std::filesystem::path& source) -> std::expected, std::string> { + std::error_code error; + const auto absolute = [](const std::filesystem::path& path) + -> std::expected { + std::error_code pathError; + auto result = std::filesystem::absolute(path, pathError); + if (pathError) { + return std::unexpected("cannot resolve benchmark case path: " + pathError.message()); + } + return result; + }; + if (std::filesystem::is_regular_file(source, error)) { + if (source.extension() != ".parameter") { + return std::unexpected("benchmark case must have a .parameter suffix"); + } + auto resolved = absolute(source); + if (!resolved) return std::unexpected(resolved.error()); + return std::vector{std::move(*resolved)}; + } + if (!std::filesystem::is_directory(source, error)) { + return std::unexpected("benchmark case path is neither a file nor a directory"); + } + std::vector result; + for (std::filesystem::directory_iterator iterator(source, error), end; + !error && iterator != end; iterator.increment(error)) { + if (iterator->is_regular_file(error) && iterator->path().extension() == ".parameter") { + auto resolved = absolute(iterator->path()); + if (!resolved) return std::unexpected(resolved.error()); + result.push_back(std::move(*resolved)); + } + } + if (error) return std::unexpected("cannot enumerate benchmark cases: " + error.message()); + std::ranges::sort(result); + if (result.empty()) return std::unexpected("no .parameter benchmark cases found"); + return result; +} + +[[nodiscard]] auto temporaryCapture() -> std::expected, std::string> { + std::error_code error; + const auto directory = std::filesystem::temp_directory_path(error); + if (error) { + return std::unexpected("cannot locate temporary directory: " + error.message()); + } + auto pattern = (directory / "intarnanew-benchmark-output-XXXXXX").string(); + std::vector writable(pattern.begin(), pattern.end()); + writable.push_back('\0'); + const int descriptor = ::mkstemp(writable.data()); + if (descriptor < 0) { + return std::unexpected("cannot create benchmark capture file: " + + std::error_code(errno, std::generic_category()).message()); + } + return std::pair{descriptor, std::filesystem::path{writable.data()}}; +} + +[[nodiscard]] auto readCapture(const std::filesystem::path& path) + -> std::expected { + std::ifstream input(path, std::ios::binary); + if (!input) return std::unexpected("cannot read benchmark capture file"); + std::string bytes( + std::istreambuf_iterator{input}, std::istreambuf_iterator{}); + if (input.bad()) return std::unexpected("failed while reading benchmark capture file"); + return bytes; +} + +[[nodiscard]] auto run( + const std::filesystem::path& executable, + const std::filesystem::path& parameterFile, + const unsigned int threads, + const std::size_t timeoutSeconds, + const bool capture) -> std::expected { + int outputDescriptor{-1}; + std::filesystem::path outputPath; + if (capture) { + auto temporary = temporaryCapture(); + if (!temporary) return std::unexpected(temporary.error()); + outputDescriptor = temporary->first; + outputPath = std::move(temporary->second); + } else { + outputDescriptor = ::open("/dev/null", O_WRONLY); + if (outputDescriptor < 0) return std::unexpected("cannot open /dev/null for benchmark output"); + } + const int errorDescriptor = ::open("/dev/null", O_WRONLY); + if (errorDescriptor < 0) { + ::close(outputDescriptor); + if (capture) std::filesystem::remove(outputPath); + return std::unexpected("cannot open /dev/null for benchmark diagnostics"); + } + + std::vector storage{ + executable.string(), + "--parameterFile=" + parameterFile.string(), + "--threads=" + std::to_string(threads), + "--default-log-file=/dev/null", + "--out=STDOUT", + }; + std::vector childArguments; + childArguments.reserve(storage.size() + 1U); + for (auto& argument : storage) childArguments.push_back(argument.data()); + childArguments.push_back(nullptr); + + const auto started = Clock::now(); + const auto child = ::fork(); + if (child < 0) { + const auto message = std::error_code(errno, std::generic_category()).message(); + ::close(outputDescriptor); + ::close(errorDescriptor); + if (capture) std::filesystem::remove(outputPath); + return std::unexpected("cannot fork benchmark process: " + message); + } + if (child == 0) { + if (::dup2(outputDescriptor, STDOUT_FILENO) < 0 || + ::dup2(errorDescriptor, STDERR_FILENO) < 0) { + ::_exit(126); + } + ::close(outputDescriptor); + ::close(errorDescriptor); + ::execv(storage.front().c_str(), childArguments.data()); + ::_exit(127); + } + ::close(outputDescriptor); + ::close(errorDescriptor); + + int status{}; + rusage resources{}; + pid_t waited{}; + const auto deadline = Clock::now() + std::chrono::seconds(timeoutSeconds); + do { + waited = ::wait4(child, &status, WNOHANG, &resources); + if (waited == 0 && Clock::now() >= deadline) { + static_cast(::kill(child, SIGKILL)); + do { + waited = ::wait4(child, &status, 0, &resources); + } while (waited < 0 && errno == EINTR); + break; + } + if (waited == 0) ::usleep(1'000U); + } while (waited == 0 || (waited < 0 && errno == EINTR)); + const auto stopped = Clock::now(); + if (waited < 0) { + if (capture) std::filesystem::remove(outputPath); + return std::unexpected("cannot wait for benchmark process: " + + std::error_code(errno, std::generic_category()).message()); + } + + RunResult result; + result.elapsed = std::chrono::duration_cast(stopped - started); +#if defined(__APPLE__) + result.peakRssKiB = resources.ru_maxrss / 1024L; +#else + result.peakRssKiB = resources.ru_maxrss; +#endif + if (WIFEXITED(status)) result.exitCode = WEXITSTATUS(status); + else if (WIFSIGNALED(status)) result.signal = WTERMSIG(status); + + if (capture) { + auto bytes = readCapture(outputPath); + std::error_code ignored; + std::filesystem::remove(outputPath, ignored); + if (!bytes) return std::unexpected(bytes.error()); + result.output = std::move(*bytes); + } + return result; +} + +[[nodiscard]] auto failure(const RunResult& result) -> std::optional { + if (result.signal) return "terminated by signal " + std::to_string(*result.signal); + if (result.exitCode != 0) return "exited with status " + std::to_string(result.exitCode); + return std::nullopt; +} + +template +[[nodiscard]] auto median(std::vector values) -> double { + std::ranges::sort(values); + const auto middle = values.size() / 2U; + if (values.size() % 2U != 0U) return static_cast(values[middle]); + return (static_cast(values[middle - 1U]) + static_cast(values[middle])) / 2.0; +} + +[[nodiscard]] auto medianMilliseconds( + const std::vector& values) -> double { + std::vector counts; + counts.reserve(values.size()); + for (const auto value : values) counts.push_back(value.count()); + return median(std::move(counts)) / 1'000'000.0; +} + +[[nodiscard]] auto firstDifference( + const std::string_view left, + const std::string_view right) noexcept -> std::size_t { + const auto common = std::min(left.size(), right.size()); + for (std::size_t index{}; index < common; ++index) { + if (left[index] != right[index]) return index; + } + return common; +} + +[[nodiscard]] auto csvField(const std::string_view value) -> std::string { + if (value.find_first_of(",\"\r\n") == std::string_view::npos) return std::string(value); + std::string result{"\""}; + for (const char character : value) { + if (character == '"') result.push_back('"'); + result.push_back(character); + } + result.push_back('"'); + return result; +} + +} // namespace + +auto main(const int argc, char** argv) -> int { + auto arguments = parseArguments(argc, argv); + if (!arguments) { + if (!arguments.error().empty()) std::cerr << "IntaRNAnewBenchmark: " << arguments.error() << '\n'; + std::cerr << usage(); + return arguments.error().empty() ? 0 : 2; + } + auto cases = benchmarkCases(arguments->cases); + if (!cases) { + std::cerr << "IntaRNAnewBenchmark: " << cases.error() << '\n'; + return 2; + } + if (arguments->expectedCases && cases->size() != *arguments->expectedCases) { + std::cerr << "IntaRNAnewBenchmark: expected " << *arguments->expectedCases + << " parameter cases but found " << cases->size() << '\n'; + return 2; + } + if (arguments->legacy.empty()) { + std::cout << "SKIP: INTARNA_LEGACY_BIN is not set\n"; + return 0; + } + + std::cout.imbue(std::locale::classic()); + if (!arguments->verifyOnly) { + std::cout << "case,legacy_median_ms,new_median_ms,speedup,legacy_peak_rss_kib,new_peak_rss_kib\n"; + } + std::vector speedups; + bool failed{}; + for (const auto& parameterFile : *cases) { + auto legacyOracle = run(arguments->legacy, parameterFile, arguments->threads, + arguments->timeoutSeconds, true); + auto newOracle = run(arguments->current, parameterFile, arguments->threads, + arguments->timeoutSeconds, true); + if (!legacyOracle || !newOracle) { + std::cerr << parameterFile.filename().string() << ": verification launch failed: " + << (!legacyOracle ? legacyOracle.error() : newOracle.error()) << '\n'; + failed = true; + continue; + } + if (const auto reason = failure(*legacyOracle)) { + std::cerr << parameterFile.filename().string() << ": legacy " << *reason << '\n'; + failed = true; + continue; + } + if (const auto reason = failure(*newOracle)) { + std::cerr << parameterFile.filename().string() << ": new " << *reason << '\n'; + failed = true; + continue; + } + if (legacyOracle->output != newOracle->output) { + const auto offset = firstDifference(legacyOracle->output, newOracle->output); + std::cerr << parameterFile.filename().string() + << ": output mismatch at byte " << offset + << " (legacy " << legacyOracle->output.size() + << " bytes, new " << newOracle->output.size() + << " bytes); case was not timed\n"; + failed = true; + continue; + } + + if (arguments->verifyOnly) { + std::cout << "PASS " << parameterFile.filename().string() << '\n'; + speedups.push_back(1.0); + continue; + } + + bool warmupFailed{}; + for (std::size_t iteration{}; iteration < arguments->warmups; ++iteration) { + const bool newFirst = iteration % 2U == 0U; + const auto& first = newFirst ? arguments->current : arguments->legacy; + const auto& second = newFirst ? arguments->legacy : arguments->current; + auto firstRun = run(first, parameterFile, arguments->threads, + arguments->timeoutSeconds, false); + auto secondRun = run(second, parameterFile, arguments->threads, + arguments->timeoutSeconds, false); + if (!firstRun || !secondRun || failure(*firstRun) || failure(*secondRun)) { + std::cerr << parameterFile.filename().string() << ": warm-up failed\n"; + failed = true; + warmupFailed = true; + break; + } + } + if (warmupFailed) continue; + + std::vector legacyTimes; + std::vector newTimes; + std::vector legacyMemory; + std::vector newMemory; + legacyTimes.reserve(arguments->repetitions); + newTimes.reserve(arguments->repetitions); + legacyMemory.reserve(arguments->repetitions); + newMemory.reserve(arguments->repetitions); + bool caseFailed{}; + for (std::size_t iteration{}; iteration < arguments->repetitions; ++iteration) { + const bool newFirst = iteration % 2U == 0U; + auto first = run(newFirst ? arguments->current : arguments->legacy, + parameterFile, arguments->threads, + arguments->timeoutSeconds, false); + auto second = run(newFirst ? arguments->legacy : arguments->current, + parameterFile, arguments->threads, + arguments->timeoutSeconds, false); + if (!first || !second || failure(*first) || failure(*second)) { + caseFailed = true; + break; + } + const auto& newRun = newFirst ? *first : *second; + const auto& legacyRun = newFirst ? *second : *first; + newTimes.push_back(newRun.elapsed); + legacyTimes.push_back(legacyRun.elapsed); + newMemory.push_back(newRun.peakRssKiB); + legacyMemory.push_back(legacyRun.peakRssKiB); + } + if (caseFailed) { + std::cerr << parameterFile.filename().string() << ": timed execution failed\n"; + failed = true; + continue; + } + + const auto legacyMedian = medianMilliseconds(legacyTimes); + const auto newMedian = medianMilliseconds(newTimes); + const auto speedup = legacyMedian / newMedian; + speedups.push_back(speedup); + std::cout << csvField(parameterFile.stem().string()) << ',' + << std::fixed << std::setprecision(3) << legacyMedian << ',' + << newMedian << ',' << speedup << ',' + << static_cast(std::llround(median(legacyMemory))) << ',' + << static_cast(std::llround(median(newMemory))) << '\n'; + } + + if (!arguments->verifyOnly && !speedups.empty()) { + const auto logSum = std::accumulate( + speedups.begin(), speedups.end(), 0.0, + [](const double sum, const double value) { return sum + std::log(value); }); + std::cout << "GEOMEAN,,," << std::fixed << std::setprecision(3) + << std::exp(logSum / static_cast(speedups.size())) << ",,\n"; + } + if (arguments->verifyOnly) { + std::cout << "COMPATIBLE " << speedups.size() << '/' << cases->size() << '\n'; + } + return failed || speedups.size() != cases->size() ? 1 : 0; +} diff --git a/IntaRNAnew/include/intarnanew/accessibility.hpp b/IntaRNAnew/include/intarnanew/accessibility.hpp new file mode 100644 index 0000000..525f620 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/accessibility.hpp @@ -0,0 +1,165 @@ +#pragma once + +#include "intarnanew/config.hpp" +#include "intarnanew/folding.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { + +class AccessibilityProvider { +public: + virtual ~AccessibilityProvider() = default; + + [[nodiscard]] virtual auto openingEnergy(Interval interval) const -> Energy = 0; + [[nodiscard]] virtual auto unpairedProbability(Interval interval) const -> double = 0; + [[nodiscard]] virtual auto positionUnpairedProbability(Index position) const -> double = 0; + [[nodiscard]] virtual auto blocked(Index position) const -> bool = 0; + [[nodiscard]] virtual auto ensembleLogPartition() const noexcept -> std::optional { + return std::nullopt; + } + [[nodiscard]] virtual auto ensembleFreeEnergy() const noexcept -> std::optional { + return std::nullopt; + } +}; + +class TurnerAccessibility final : public AccessibilityProvider { +public: + // Interval probabilities use either the configured global fold or the + // average over every full local folding window containing the interval. + // The ensemble accessors always describe a separate, unrestricted + // whole-sequence monomer ensemble, independent of accessibility windows. + static constexpr std::string_view modelName{"turner-nearest-neighbor-v1"}; + + TurnerAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig); + + [[nodiscard]] auto openingEnergy(Interval interval) const -> Energy override; + [[nodiscard]] auto unpairedProbability(Interval interval) const -> double override; + [[nodiscard]] auto positionUnpairedProbability(Index position) const -> double override; + [[nodiscard]] auto blocked(Index position) const -> bool override; + [[nodiscard]] auto ensembleLogPartition() const noexcept -> std::optional override; + [[nodiscard]] auto ensembleFreeEnergy() const noexcept -> std::optional override; + +private: + struct Window { + Index begin{}; + Index end{}; + std::shared_ptr ensemble; + }; + + Index length_{}; + Index stride_{}; + Index windowLength_{}; + double rt_{}; + double probabilityExponent_{1.0}; + std::vector blocked_; + std::shared_ptr summary_; + std::vector windows_; + mutable std::vector intervalProbabilities_; + mutable std::mutex cacheMutex_; +}; + +class DisabledAccessibility final : public AccessibilityProvider { +public: + explicit DisabledAccessibility(const Sequence& sequence, std::string_view constraint = {}); + DisabledAccessibility(const Sequence& sequence, const SideConfig& side, const Config& globalConfig); + + [[nodiscard]] auto openingEnergy(Interval interval) const -> Energy override; + [[nodiscard]] auto unpairedProbability(Interval interval) const -> double override; + [[nodiscard]] auto positionUnpairedProbability(Index position) const -> double override; + [[nodiscard]] auto blocked(Index position) const -> bool override; + [[nodiscard]] auto unconstrained(Interval interval) const noexcept -> bool; + [[nodiscard]] auto ensembleLogPartition() const noexcept -> std::optional override; + [[nodiscard]] auto ensembleFreeEnergy() const noexcept -> std::optional override; + +private: + Index length_{}; + std::vector blocked_; + std::unique_ptr ensemble_; +}; + +class NativeAccessibility final : public AccessibilityProvider { +public: + // Exact for the documented native model: a pseudoknot-free noncrossing + // matching ensemble with additive pair energies. This is deliberately not + // a Turner nearest-neighbour, RNAplfold-window, or ViennaRNA-compatible + // folding model. accessibilitySpan caps pair distance; accessibilityWindow + // does not alter this global ensemble. One inside/outside calculation and + // two log-semiring contractions generate every joint interval probability + // in O(n^3) time/O(n^2) memory; each query is then O(1). + static constexpr std::string_view modelName{"native-noncrossing-pair-v1"}; + + NativeAccessibility(const Sequence& sequence, const SideConfig& config, double temperatureCelsius); + + [[nodiscard]] auto openingEnergy(Interval interval) const -> Energy override; + [[nodiscard]] auto unpairedProbability(Interval interval) const -> double override; + [[nodiscard]] auto positionUnpairedProbability(Index position) const -> double override; + [[nodiscard]] auto blocked(Index position) const -> bool override; + +private: + [[nodiscard]] auto cacheOffset(Interval interval) const -> Index; + + Index length_{}; + Index stride_{}; + Index maxPairSpan_{}; + double rt_{}; + double logPartition_{}; + std::string sequence_; + std::vector constraints_; + std::vector blocked_; + std::vector intervalProbabilities_; +}; + +class TableAccessibility final : public AccessibilityProvider { +public: + TableAccessibility( + const Sequence& sequence, + const SideConfig& config, + double temperatureCelsius, + bool tableContainsProbabilities); + TableAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig, + bool tableContainsProbabilities); + + [[nodiscard]] auto openingEnergy(Interval interval) const -> Energy override; + [[nodiscard]] auto unpairedProbability(Interval interval) const -> double override; + [[nodiscard]] auto positionUnpairedProbability(Index position) const -> double override; + [[nodiscard]] auto blocked(Index position) const -> bool override; + [[nodiscard]] auto ensembleLogPartition() const noexcept -> std::optional override; + [[nodiscard]] auto ensembleFreeEnergy() const noexcept -> std::optional override; + +private: + [[nodiscard]] auto offset(Interval interval) const -> Index; + + Index length_{}; + Index stride_{}; + double rt_{}; + std::vector probabilities_; + std::vector blocked_; + std::unique_ptr ensemble_; +}; + +[[nodiscard]] auto makeAccessibility( + const Sequence& sequence, + const SideConfig& config, + double temperatureCelsius) -> std::expected, std::string>; + +[[nodiscard]] auto makeAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig) -> std::expected, std::string>; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/cli.hpp b/IntaRNAnew/include/intarnanew/cli.hpp new file mode 100644 index 0000000..7ef0cce --- /dev/null +++ b/IntaRNAnew/include/intarnanew/cli.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "intarnanew/config.hpp" + +#include +#include +#include +#include + +namespace intarnanew { + +enum class OptionGroup { query, target, seed, shape, interaction, helix, output, general }; +enum class OptionValueMode { none, required, optionalBoolean }; +enum class OptionSupport { implemented, compatibilityOnly, unavailable }; + +struct OptionSpec { + std::string_view longName; + char shortName{}; + OptionGroup group{OptionGroup::general}; + OptionValueMode valueMode{OptionValueMode::required}; + std::string_view valueName; + std::string_view defaultValue; + std::string_view description; + OptionSupport support{OptionSupport::implemented}; + bool repeatable{}; + bool basic{}; +}; + +class Cli { +public: + [[nodiscard]] static auto parse(std::span arguments) + -> std::expected; + [[nodiscard]] static auto parse( + std::span arguments, + std::string_view invocationName) -> std::expected; + [[nodiscard]] static auto optionRegistry() noexcept -> std::span; + [[nodiscard]] static auto help(bool full) -> std::string; + [[nodiscard]] static auto version() -> std::string; +}; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/compression.hpp b/IntaRNAnew/include/intarnanew/compression.hpp new file mode 100644 index 0000000..523da83 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/compression.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace intarnanew { + +struct GzipLimits { + // Byte limits apply to the complete input and concatenated output. Header + // and block limits apply independently to each member. + std::size_t maxCompressedBytes{1U << 30U}; + std::size_t maxDecompressedBytes{1U << 30U}; + std::size_t maxHeaderBytes{1U << 20U}; + std::size_t maxMembers{1024U}; + std::size_t maxDeflateBlocks{1U << 24U}; +}; + +[[nodiscard]] auto hasGzipMagic(std::string_view bytes) noexcept -> bool; + +// Decodes all concatenated RFC 1952 members. The returned string contains the +// members' uncompressed payloads in input order. +[[nodiscard]] auto gzipDecompress( + std::string_view bytes, + const GzipLimits& limits = {}) -> std::expected; + +// Emits one deterministic RFC 1952 member using stored RFC 1951 blocks. +[[nodiscard]] auto gzipCompress(std::string_view bytes) + -> std::expected; + +[[nodiscard]] auto crc32(std::string_view bytes) noexcept -> std::uint32_t; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/config.hpp b/IntaRNAnew/include/intarnanew/config.hpp new file mode 100644 index 0000000..bee3eb4 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/config.hpp @@ -0,0 +1,149 @@ +#pragma once + +#include "intarnanew/types.hpp" + +#include +#include +#include +#include + +namespace intarnanew { + +enum class RunAction { predict, help, fullHelp, version }; +enum class PredictionMode { heuristic, exact, seedOnly }; +enum class InteractionModel { singleSite, seedExtension, helixBlocks, ensemble }; +enum class EnergyKind { basePair, nearestNeighbor }; +enum class AccessibilityKind { disabled, compute, probabilitiesFile, energiesFile }; +enum class OutputMode { normal, detailed, csv, ensemble }; +enum class OverlapPolicy { neither, target, query, both }; +enum class ConfigSource { baseline, personality, parameterFile, commandLine }; + +struct ConfigOrigin { + ConfigSource source{ConfigSource::baseline}; + std::string detail{"built-in default"}; + std::size_t position{}; +}; + +struct ConfigAssignment { + std::string option; + std::string value; + ConfigOrigin origin; +}; + +struct SideConfig { + std::string input; + std::string id; + long long firstPosition{1}; + std::string subset; + AccessibilityKind accessibility{AccessibilityKind::compute}; + std::size_t accessibilityWindow{150}; + std::size_t accessibilitySpan{100}; + std::string accessibilityConstraint; + std::string accessibilityFile; + std::size_t interactionLengthMax{}; + std::size_t interactionLoopMax{}; + std::string regions; + std::size_t regionLengthMax{}; + double partitionScale{1.07}; + std::string shapeFile; + std::string shapeMethod; + std::string shapeConversion; +}; + +struct SeedConfig { + bool required{true}; + std::string explicitSeeds; + std::size_t basePairs{7}; + std::size_t maxUnpaired{}; + int queryMaxUnpaired{-1}; + int targetMaxUnpaired{-1}; + Energy maxEnergy{}; + Energy maxHybridEnergy{999.0}; + double minUnpairedProbability{}; + std::string queryRanges; + std::string targetRanges; + bool noGu{}; + bool noGuAtEnds{}; +}; + +struct HelixConfig { + std::size_t minBasePairs{2}; + std::size_t maxBasePairs{10}; + std::size_t maxInternalLoop{}; + double minUnpairedProbability{}; + Energy maxEnergy{}; + bool useFullEnergy{}; +}; + +struct OutputConfig { + std::vector destinations{"STDOUT"}; + char separator{';'}; + OutputMode mode{OutputMode::normal}; + std::size_t number{1}; + OverlapPolicy overlap{OverlapPolicy::both}; + Energy maxEnergy{}; + double minUnpairedProbability{}; + Energy deltaEnergy{100.0}; + bool bestSeedOnly{}; + bool noLonelyPairs{}; + bool noGuAtEnds{}; + std::string csvColumns{"id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E"}; + std::string csvSort; + bool perRegion{}; + bool pairwise{}; +}; + +// Internal execution requirements are conservative for library callers: a +// directly constructed Config retains the complete site ensemble and computes +// interaction and monomer partition functions as well as tracebacks. The +// executable may relax requirements after it has inspected the complete output +// plan. Keeping these separate from the public output options lets the +// predictor avoid work that no requested output can observe without weakening +// PredictionResult's default contract. +struct PredictionRequirements { + bool retainAllSites{true}; + bool computeInteractionPartition{true}; + bool computeMonomerPartition{true}; + bool traceback{true}; +}; + +struct Config { + RunAction action{RunAction::predict}; + SideConfig query = [] { + SideConfig side{}; + side.id = "query"; + side.interactionLoopMax = 16U; + return side; + }(); + SideConfig target = [] { + SideConfig side{}; + side.id = "target"; + side.interactionLoopMax = 10U; + return side; + }(); + SeedConfig seed; + HelixConfig helix; + PredictionMode mode{PredictionMode::heuristic}; + InteractionModel model{InteractionModel::seedExtension}; + EnergyKind energy{EnergyKind::nearestNeighbor}; + std::string energyParameters{"Turner04"}; + bool noDangles{}; + bool accessibilityNoLonelyPairs{}; + bool accessibilityNoGuAtEnds{}; + Energy additiveEnergy{}; + double temperatureCelsius{37.0}; + std::size_t windowWidth{}; + std::size_t windowOverlap{150}; + OutputConfig output; + unsigned int threads{1}; + std::string personality{"IntaRNA"}; + std::string parameterFile; + std::vector parameterFiles; + PredictionRequirements predictionRequirements; + bool verbose{}; + std::string logFile; + std::map> provenance; + std::vector assignmentHistory; +}; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/energy.hpp b/IntaRNAnew/include/intarnanew/energy.hpp new file mode 100644 index 0000000..5225458 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/energy.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "intarnanew/config.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/types.hpp" + +#include +#include +#include + +namespace intarnanew { + +class HybridEnergyModel { +public: + virtual ~HybridEnergyModel() = default; + + [[nodiscard]] virtual auto evaluate( + const Sequence& target, + const Sequence& query, + std::span pairs) const -> EnergyBreakdown = 0; + + [[nodiscard]] virtual auto transitionEnergy( + const Sequence& target, + const Sequence& query, + BasePair left, + BasePair right) const -> Energy = 0; + + [[nodiscard]] virtual auto initiationEnergy() const noexcept -> Energy = 0; + [[nodiscard]] virtual auto rt() const noexcept -> double = 0; +}; + +class BasePairEnergyModel final : public HybridEnergyModel { +public: + explicit BasePairEnergyModel(double temperatureCelsius); + + [[nodiscard]] auto evaluate( + const Sequence& target, + const Sequence& query, + std::span pairs) const -> EnergyBreakdown override; + [[nodiscard]] auto transitionEnergy( + const Sequence& target, + const Sequence& query, + BasePair left, + BasePair right) const -> Energy override; + [[nodiscard]] auto initiationEnergy() const noexcept -> Energy override { return -1.0; } + [[nodiscard]] auto rt() const noexcept -> double override { return 1.0; } +}; + +namespace detail { struct NearestNeighborParameters; } + +class NearestNeighborEnergyModel final : public HybridEnergyModel { +public: + NearestNeighborEnergyModel( + double temperatureCelsius, + std::string_view parameterSet, + bool includeDangles); + + [[nodiscard]] auto evaluate( + const Sequence& target, + const Sequence& query, + std::span pairs) const -> EnergyBreakdown override; + [[nodiscard]] auto transitionEnergy( + const Sequence& target, + const Sequence& query, + BasePair left, + BasePair right) const -> Energy override; + [[nodiscard]] auto initiationEnergy() const noexcept -> Energy override { return initiation_; } + [[nodiscard]] auto rt() const noexcept -> double override { return rt_; } + +private: + [[nodiscard]] auto terminalPenalty(char target, char query) const noexcept -> Energy; + + double rt_{}; + Energy initiation_{}; + bool includeDangles_{true}; + std::shared_ptr parameters_; +}; + +[[nodiscard]] auto makeEnergyModel(const Config& config) -> std::unique_ptr; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/folding.hpp b/IntaRNAnew/include/intarnanew/folding.hpp new file mode 100644 index 0000000..01b957e --- /dev/null +++ b/IntaRNAnew/include/intarnanew/folding.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "intarnanew/sequence.hpp" +#include "intarnanew/types.hpp" + +#include +#include +#include +#include + +namespace intarnanew { + +// Thermodynamic options for a single-RNA secondary-structure ensemble. +// A zero pair span means the whole sequence. The implementation uses +// log-space arithmetic, therefore partitionScale is validated for command-line +// compatibility but is not needed to keep the recurrence finite. +struct FoldingOptions { + double temperatureCelsius{37.0}; + std::string parameterSet{"Turner04"}; + Index maximumPairSpan{}; + Index maximumInternalLoop{30U}; + bool noLonelyPairs{}; + bool noGuHelixEnds{}; + bool includeDangles{true}; + double partitionScale{1.07}; + std::string constraint; + std::string shapeFile; + std::string shapeMethod; + std::string shapeConversion; +}; + +// Immutable exact global ensemble for the documented Turner nearest-neighbour +// model. Interval probabilities are joint probabilities, not products of +// single-position marginals. +class FoldingEnsemble { +public: + virtual ~FoldingEnsemble() = default; + + [[nodiscard]] virtual auto logPartition() const noexcept -> double = 0; + [[nodiscard]] virtual auto ensembleFreeEnergy() const noexcept -> Energy = 0; + [[nodiscard]] virtual auto jointUnpairedProbability(Interval interval) const -> double = 0; +}; + +[[nodiscard]] auto makeTurnerFoldingEnsemble( + const Sequence& sequence, + const FoldingOptions& options) -> std::unique_ptr; + +// Nussinov-style single-RNA ensemble used with the public base-pair energy +// model. Each intramolecular pair contributes -1 and RT is exactly 1. +[[nodiscard]] auto makeBasePairFoldingEnsemble( + const Sequence& sequence, + const FoldingOptions& options) -> std::unique_ptr; + +// Public validation helpers are shared by the folding and accessibility APIs. +// They throw std::invalid_argument with a stable diagnostic on malformed data. +void validateShapeEncoding(std::string_view method, std::string_view conversion); + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/helix_blocks.hpp b/IntaRNAnew/include/intarnanew/helix_blocks.hpp new file mode 100644 index 0000000..eef8680 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/helix_blocks.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/energy.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/types.hpp" + +#include +#include + +namespace intarnanew { + +struct HelixBlock { + Index firstPair{}; + Index lastPair{}; + Energy constraintEnergy{}; + + [[nodiscard]] constexpr auto basePairCount() const noexcept -> Index { + return lastPair - firstPair + 1U; + } + + friend constexpr auto operator==(const HelixBlock&, const HelixBlock&) -> bool = default; +}; + +// Partitions an antiparallel interaction path into admissible helix blocks. +// A block contains between helixMinBP and helixMaxBP pairs. Consecutive pairs +// inside a block may enclose at most helixMaxIL unpaired bases in total. Two +// blocks must be separated by a larger loop, matching the published numeric +// contract. The right-most initiation pair may terminate a composition after +// at least one admissible block; it is not itself a helix block. Every returned +// block passes the strict helixMaxE threshold and the per-sequence helixMinPu +// threshold. The returned partition is deterministic. +[[nodiscard]] auto decomposeHelixBlocks( + const Sequence& target, + const Sequence& query, + std::span path, + const HelixConfig& config, + const HybridEnergyModel& energy, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) -> std::vector; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/output.hpp b/IntaRNAnew/include/intarnanew/output.hpp new file mode 100644 index 0000000..aea8a95 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/output.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include + +namespace intarnanew { + +class OutputFormatter { +public: + [[nodiscard]] static auto primary( + const Config& config, + const Sequence& target, + const Sequence& query, + const PredictionResult& result, + bool includeCsvHeader = true) -> std::expected; + + [[nodiscard]] static auto auxiliary( + std::string_view descriptor, + const Config& config, + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility, + const PredictionResult& result) -> std::expected; +}; + +[[nodiscard]] auto writeOutput(std::string_view destination, std::string_view content) + -> std::expected; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/output_plan.hpp b/IntaRNAnew/include/intarnanew/output_plan.hpp new file mode 100644 index 0000000..8823f1d --- /dev/null +++ b/IntaRNAnew/include/intarnanew/output_plan.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "intarnanew/config.hpp" + +#include +#include +#include +#include +#include +#include + +namespace intarnanew { + +struct OutputGroupKey { + std::size_t targetIndex{}; + std::size_t queryIndex{}; + std::size_t targetRegionIndex{}; + std::size_t queryRegionIndex{}; + + [[nodiscard]] friend constexpr auto operator==( + const OutputGroupKey&, const OutputGroupKey&) noexcept -> bool = default; +}; + +enum class OutputPartKind { + primary, + pairAuxiliary, + queryAccessibility, + targetAccessibility, +}; + +struct OutputPart { + OutputPartKind kind{OutputPartKind::primary}; + std::size_t descriptorIndex{}; + std::size_t groupIndex{}; + std::size_t sequenceIndex{}; +}; + +struct OutputPublication { + std::string destination; + std::vector parts; +}; + +struct OutputPlan { + std::vector publications; +}; + +// Resolves every output destination before prediction starts. Parts belonging +// to one descriptor and one stream are deliberately collected into one writer; +// independent descriptors may never resolve to the same stream or real path. +[[nodiscard]] auto planOutputs( + const Config& config, + std::size_t targetCount, + std::size_t queryCount, + std::span groups) -> std::expected; + +[[nodiscard]] auto isAuxiliaryOutput(std::string_view descriptor) noexcept -> bool; +[[nodiscard]] auto auxiliaryOutputDestination(std::string_view descriptor) -> std::string; + +struct OutputArtifact { + std::string destination; + std::string content; +}; + +// Stages every regular-file artifact first, then commits all files as one +// rollback-capable batch. Standard streams are written only after file commit; +// streams themselves cannot be rolled back by an operating system. +[[nodiscard]] auto publishOutputs(std::span artifacts) + -> std::expected; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/parallel.hpp b/IntaRNAnew/include/intarnanew/parallel.hpp new file mode 100644 index 0000000..487cdd1 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/parallel.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { + +struct ParallelFailure { + std::size_t taskIndex{std::numeric_limits::max()}; + std::string message; +}; + +// Executes indices in increasing claim order. Once one callback fails, the +// shared stop source prevents workers from claiming further work. Callbacks +// already in flight may finish; failures are reported by the lowest task index +// so diagnostics do not depend on thread completion order. +template +[[nodiscard]] auto runParallelIndexed( + const std::size_t taskCount, + const std::size_t requestedWorkers, + Function&& function) -> std::expected { + if (taskCount == 0U) return {}; + + const auto workerCount = std::min( + taskCount, std::max(1U, requestedWorkers)); + std::atomic_size_t nextTask{}; + std::stop_source cancellation; + const auto cancellationToken = cancellation.get_token(); + std::vector failures(taskCount); + std::vector workers; + workers.reserve(workerCount); + + try { + for (std::size_t workerIndex = 0U; workerIndex < workerCount; ++workerIndex) { + workers.emplace_back([&, workerIndex](const std::stop_token threadToken) { + while (!cancellationToken.stop_requested() && + !threadToken.stop_requested()) { + const auto taskIndex = nextTask.fetch_add(1U, std::memory_order_relaxed); + if (taskIndex >= taskCount) return; + if (cancellationToken.stop_requested() || threadToken.stop_requested()) return; + try { + std::invoke(function, workerIndex, taskIndex, cancellationToken); + } catch (const std::exception& exception) { + failures[taskIndex] = exception.what(); + if (failures[taskIndex].empty()) { + failures[taskIndex] = "exception with no diagnostic"; + } + cancellation.request_stop(); + return; + } catch (...) { + failures[taskIndex] = "unknown exception"; + cancellation.request_stop(); + return; + } + } + }); + } + } catch (const std::exception& exception) { + cancellation.request_stop(); + return std::unexpected(ParallelFailure{ + std::numeric_limits::max(), + "failed to start worker threads: " + std::string(exception.what()), + }); + } + + // Explicit join avoids jthread destruction requesting an otherwise + // successful worker to stop before it has exhausted the task queue. + for (auto& worker : workers) worker.join(); + + for (std::size_t taskIndex = 0U; taskIndex < failures.size(); ++taskIndex) { + if (!failures[taskIndex].empty()) { + return std::unexpected(ParallelFailure{taskIndex, std::move(failures[taskIndex])}); + } + } + return {}; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/predictor.hpp b/IntaRNAnew/include/intarnanew/predictor.hpp new file mode 100644 index 0000000..d23565b --- /dev/null +++ b/IntaRNAnew/include/intarnanew/predictor.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/energy.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/types.hpp" + +#include +#include +#include +#include + +namespace intarnanew { + +struct PredictionResult { + std::vector interactions; + std::vector ensembleSites; + double rt{}; + double logPartition{-infinity}; + Energy ensembleFreeEnergy{infinity}; + double targetLogPartition{-infinity}; + double queryLogPartition{-infinity}; + Energy targetEnsembleFreeEnergy{infinity}; + Energy queryEnsembleFreeEnergy{infinity}; +}; + +class Predictor { +public: + explicit Predictor(Config config); + + [[nodiscard]] auto predict( + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) const -> PredictionResult; + + [[nodiscard]] auto predict( + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility, + Interval targetDomain, + Interval queryDomain) const -> PredictionResult; + + [[nodiscard]] auto rt() const noexcept -> double { return energy_->rt(); } + +private: + Config config_; + std::unique_ptr energy_; +}; + +[[nodiscard]] auto parseIntervals(const Sequence& sequence, const std::string& specification) + -> std::expected, std::string>; + +// Returns explicitly configured regions, or the whole sequence if no regions +// were specified. External coordinates are mapped with Sequence's skip-zero +// convention; malformed, empty, overlapping, and out-of-range intervals are +// rejected before prediction starts. +[[nodiscard]] auto configuredRegions(const Sequence& sequence, const SideConfig& config) + -> std::expected, std::string>; + +// Clean-room implementation of the documented accessible-region heuristic. +// Each range longer than regionLengthMax is split by removing the seed-length +// interval with the largest opening energy. Ties are resolved by the lowest +// internal index. Fragments shorter than seedLength are discarded. +[[nodiscard]] auto decomposeAccessibleRegions( + std::span input, + std::size_t regionLengthMax, + std::size_t seedLength, + const AccessibilityProvider& accessibility) -> std::vector; + +// Creates deterministic overlapping windows over a parent interval. The final +// window is clipped at the parent's end. Every interval of length <= overlap is +// contained in at least one window, including interactions crossing a step. +[[nodiscard]] auto decomposeWindows( + Interval parent, + std::size_t width, + std::size_t overlap) -> std::vector; + +// Reduces independently predicted domains into one deterministic result. +// Interaction sites are deduplicated before the partition function is rebuilt +// and one global energy, delta-energy, count, and overlap-policy reduction is +// applied. +[[nodiscard]] auto reducePredictions( + std::span predictions, + const Config& config) -> PredictionResult; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/runner.hpp b/IntaRNAnew/include/intarnanew/runner.hpp new file mode 100644 index 0000000..e9087e2 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/runner.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include + +namespace intarnanew { + +// Complete in-process evaluation of one target/query pair. The providers are +// retained so native ED/Pu profiles can be consumed by companion tools without +// rebuilding either folding ensemble. The referenced Sequence objects must +// outlive the returned providers. +struct PairPrediction { + PredictionResult prediction; + std::unique_ptr targetAccessibility; + std::unique_ptr queryAccessibility; +}; + +// Applies the same explicit/automatic region planning, bounded window +// decomposition, site deduplication, global output constraints, and monomer +// summaries as a single pair in the command-line application. Domain work is +// deliberately sequential: callers such as randomization and mutation tools +// parallelize independent pairs without creating nested worker pools. +[[nodiscard]] auto predictPair( + const Config& config, + const Sequence& target, + const Sequence& query) -> std::expected; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/sequence.hpp b/IntaRNAnew/include/intarnanew/sequence.hpp new file mode 100644 index 0000000..39d3bae --- /dev/null +++ b/IntaRNAnew/include/intarnanew/sequence.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "intarnanew/types.hpp" + +#include +#include +#include +#include +#include +#include + +namespace intarnanew { + +class Sequence { +public: + Sequence(std::string identifier, std::string nucleotides, long long firstPosition = 1); + + [[nodiscard]] auto id() const noexcept -> const std::string& { return identifier_; } + [[nodiscard]] auto str() const noexcept -> const std::string& { return nucleotides_; } + [[nodiscard]] auto size() const noexcept -> Index { return nucleotides_.size(); } + [[nodiscard]] auto empty() const noexcept -> bool { return nucleotides_.empty(); } + [[nodiscard]] auto operator[](Index index) const -> char { return nucleotides_.at(index); } + [[nodiscard]] auto externalIndex(Index index) const -> long long; + [[nodiscard]] auto internalIndex(long long external) const -> std::expected; + [[nodiscard]] auto firstPosition() const noexcept -> long long { return firstPosition_; } + +private: + std::string identifier_; + std::string nucleotides_; + long long firstPosition_; +}; + +class SequenceReader { +public: + // File and standard-input payloads are decoded as gzip when the RFC 1952 + // signature is present; a .gz file suffix requires that signature. + [[nodiscard]] static auto read( + std::string_view specification, + std::string_view fallbackId, + long long firstPosition, + std::istream& standardInput) -> std::expected, std::string>; + + [[nodiscard]] static auto parseFasta( + std::istream& input, + std::string_view fallbackId, + long long firstPosition) -> std::expected, std::string>; + + // Selects one-based FASTA record indices while preserving input order. + // The grammar is a comma-separated list of indices or closed ranges (e.g. 1,3-5). + [[nodiscard]] static auto select( + std::vector sequences, + std::string_view specification) -> std::expected, std::string>; +}; + +[[nodiscard]] auto nucleotideMask(char symbol) noexcept -> std::uint8_t; +[[nodiscard]] auto canPair(char target, char query, bool allowGu = true) noexcept -> bool; +[[nodiscard]] auto isGuPair(char target, char query) noexcept -> bool; +[[nodiscard]] auto reverseComplement(std::string_view sequence) -> std::string; + +} // namespace intarnanew diff --git a/IntaRNAnew/include/intarnanew/tools/csv.hpp b/IntaRNAnew/include/intarnanew/tools/csv.hpp new file mode 100644 index 0000000..2155b2d --- /dev/null +++ b/IntaRNAnew/include/intarnanew/tools/csv.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { + +struct CsvTable { + std::vector header; + std::vector> rows; + char separator{';'}; + + [[nodiscard]] auto column(std::string_view name) const noexcept -> std::optional; +}; + +struct CsvReadOptions { + // A null separator auto-detects ';', tab, or ',' from the first record. + std::optional separator; + bool requireHeader{true}; + bool allowEmptyLines{true}; +}; + +[[nodiscard]] auto readCsv(std::istream& input, CsvReadOptions options = {}) + -> std::expected; + +[[nodiscard]] auto writeCsv(const CsvTable& table, std::ostream& output) + -> std::expected; + +[[nodiscard]] auto csvText(const CsvTable& table) -> std::expected; + +struct CsvFusionOptions { + // If set, this column is appended and populated from sourceLabels. + std::optional sourceColumn; + // Exact duplicate output rows are removed while preserving first occurrence. + bool deduplicate{}; +}; + +// Produces the stable schema union of all inputs. Columns are ordered by first +// occurrence, and rows retain input-table and within-table order. +[[nodiscard]] auto fuseCsv( + std::span tables, + std::span sourceLabels = {}, + CsvFusionOptions options = {}) -> std::expected; + +[[nodiscard]] auto parseFiniteNumber(std::string_view text) + -> std::expected; + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/include/intarnanew/tools/mutations.hpp b/IntaRNAnew/include/intarnanew/tools/mutations.hpp new file mode 100644 index 0000000..51723a9 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/tools/mutations.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include + +namespace intarnanew::tools { + +enum class MutationGenerator { flip, any }; +enum class CandidateFilter { removeGu, removeAu, removeCg }; + +struct MutationCandidate { + Index queryIndex{}; + Index targetIndex{}; + char wildQuery{}; + char wildTarget{}; + char mutatedQuery{}; + char mutatedTarget{}; + + [[nodiscard]] auto encoding(const Sequence& query, const Sequence& target) const -> std::string; + friend auto operator==(const MutationCandidate&, const MutationCandidate&) -> bool = default; +}; + +// Enumerates compensatory point mutations for supplied intermolecular pairs. +// flip swaps the wild-type bases (GC -> CG); any enumerates every canonical or +// GU pair for which both bases differ from wild type, in lexical A,C,G,U order. +// Filters remove candidate wild-type pair classes, matching the public CopomuS +// terminology. +[[nodiscard]] auto enumerateMutations( + const Sequence& query, + const Sequence& target, + std::span interactionPairs, + MutationGenerator generator, + std::span filters = {}) + -> std::expected, std::string>; + +// Parses Q-base/index/mutant '&' target-base/index/mutant, e.g. G1C&U7G. +// The encoded wild-type bases must match the sequences at their external +// coordinates, and the two mutant bases must form a canonical or GU pair. +[[nodiscard]] auto parseMutationEncoding( + std::string_view encoding, + const Sequence& query, + const Sequence& target) -> std::expected; + +struct MutationSequences { + std::string wildQuery; + std::string wildTarget; + std::string mutatedQuery; + std::string mutatedTarget; +}; + +[[nodiscard]] auto applyMutation( + const Sequence& query, + const Sequence& target, + const MutationCandidate& mutation) -> std::expected; + +// Generates a mono-nucleotide preserving shuffle using a stable, specified +// SplitMix64/Fisher-Yates implementation; results are cross-platform for a seed. +[[nodiscard]] auto shuffleMononucleotides(std::string_view sequence, std::uint64_t seed) + -> std::expected; + +// Generates a dinucleotide-preserving random Euler trail over the sequence's +// directed adjacency multigraph. It preserves the first and last base plus +// exact directed dinucleotide counts. Outgoing edge choices are deterministically +// shuffled before a complete Euler trail is constructed. +[[nodiscard]] auto shuffleDinucleotides(std::string_view sequence, std::uint64_t seed) + -> std::expected; + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/include/intarnanew/tools/pvalue.hpp b/IntaRNAnew/include/intarnanew/tools/pvalue.hpp new file mode 100644 index 0000000..67d4b12 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/tools/pvalue.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { + +enum class ShuffleMode { query, target, both }; +enum class ShufflePreservation { mononucleotide, dinucleotide }; + +struct RandomScoreOptions { + std::size_t cardinality{}; + ShuffleMode mode{ShuffleMode::both}; + ShufflePreservation preservation{ShufflePreservation::dinucleotide}; + std::uint64_t randomSeed{}; + // Zero selects hardware_concurrency, bounded to cardinality. + std::size_t threads{}; +}; + +using InteractionScoreEvaluator = std::function< + std::expected(std::string_view query, std::string_view target)>; + +// Clean orchestration boundary for IntaRNApvalue-style sampling. It never +// starts an external process: callers inject the C++ prediction-library +// evaluator. Random inputs and result ordering are invariant to thread count. +// The evaluator must be safe for simultaneous calls when threads > 1. +[[nodiscard]] auto sampleRandomInteractionScores( + std::string_view query, + std::string_view target, + const RandomScoreOptions& options, + const InteractionScoreEvaluator& evaluator) -> std::expected, std::string>; + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/include/intarnanew/tools/statistics.hpp b/IntaRNAnew/include/intarnanew/tools/statistics.hpp new file mode 100644 index 0000000..0254593 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/tools/statistics.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { + +enum class DistributionKind { gaussian, gumbel, gev }; +enum class ProbabilityTail { lower, upper }; +enum class AdjustmentMethod { none, bonferroni, holm, hochberg, benjaminiHochberg, benjaminiYekutieli }; + +struct DistributionFit { + DistributionKind kind{DistributionKind::gev}; + double location{}; + double scale{1.0}; + // The GEV shape xi. It is exactly zero for Gaussian and Gumbel fits. + double shape{}; + double negativeLogLikelihood{}; + std::size_t observations{}; + bool converged{}; + std::size_t iterations{}; +}; + +[[nodiscard]] auto parseDistribution(std::string_view name) + -> std::expected; +[[nodiscard]] auto distributionName(DistributionKind kind) noexcept -> std::string_view; + +// Fits finite observations with deterministic maximum-likelihood estimation. +// Gaussian uses the population MLE. Gumbel and GEV use a bounded, deterministic +// Nelder-Mead search. GEV shape is constrained to [-1,1]. +[[nodiscard]] auto fitDistribution( + std::span observations, + DistributionKind kind) -> std::expected; + +[[nodiscard]] auto cumulativeProbability(double value, const DistributionFit& fit) + -> std::expected; +[[nodiscard]] auto tailProbability( + double value, + const DistributionFit& fit, + ProbabilityTail tail = ProbabilityTail::lower) -> std::expected; + +// For interaction energies, a smaller value is better. This convenience +// function therefore returns the fitted lower-tail probability P(X <= energy). +[[nodiscard]] auto interactionEnergyPValue(double energy, const DistributionFit& fit) + -> std::expected; + +// Exact empirical lower-tail estimate with the standard plus-one correction: +// (#{sample <= observed} + 1) / (sample size + 1). +[[nodiscard]] auto empiricalInteractionPValue( + double observed, + std::span randomScores) -> std::expected; + +[[nodiscard]] auto parseAdjustment(std::string_view name) + -> std::expected; +[[nodiscard]] auto adjustmentName(AdjustmentMethod method) noexcept -> std::string_view; + +// Adjusts p-values in their original order. Ties are ordered by original index, +// making every method bit-for-bit deterministic for a given standard library. +[[nodiscard]] auto adjustPValues( + std::span pValues, + AdjustmentMethod method) -> std::expected, std::string>; + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/include/intarnanew/tools/svg.hpp b/IntaRNAnew/include/intarnanew/tools/svg.hpp new file mode 100644 index 0000000..c63fd97 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/tools/svg.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { + +struct ProfilePoint { + double position{}; + std::optional value; +}; + +struct ProfileSvgOptions { + std::size_t width{960U}; + std::size_t height{480U}; + std::string title{"IntaRNA profile"}; + std::string xLabel{"sequence position"}; + std::string yLabel{"value"}; + std::string stroke{"#2166ac"}; + bool zeroLine{true}; +}; + +// Generates a self-contained, deterministic SVG. Missing profile points split +// the line into independent segments instead of being silently interpolated. +[[nodiscard]] auto profileSvg( + std::span points, + const ProfileSvgOptions& options = {}) -> std::expected; + +struct HeatmapData { + std::vector xLabels; + std::vector yLabels; + // Row-major: values[y * xLabels.size() + x]. Null values are missing. + std::vector> values; +}; + +struct HeatmapSvgOptions { + std::size_t width{960U}; + std::size_t height{720U}; + std::string title{"IntaRNA interaction-energy heatmap"}; + std::string xLabel{"query position"}; + std::string yLabel{"target position"}; + std::string missingColor{"#e6e6e6"}; + // Matches the documented pMinE visualization convention: positive + // energies are treated as zero unless this is false. + bool clampPositiveToZero{true}; +}; + +[[nodiscard]] auto heatmapSvg( + const HeatmapData& data, + const HeatmapSvgOptions& options = {}) -> std::expected; + +struct RegionSpan { + std::string id; + long long start{}; + long long end{}; +}; + +struct RegionSvgOptions { + std::size_t width{960U}; + std::size_t height{720U}; + std::string title{"IntaRNA interaction-covered regions"}; + std::string xLabel{"sequence position"}; + std::string fill{"#4393c3"}; +}; + +// Draws one labeled horizontal track per input span, preserving row order. +// Coordinates are inclusive and may be negative, but start must not exceed end. +[[nodiscard]] auto regionsSvg( + std::span regions, + const RegionSvgOptions& options = {}) -> std::expected; + +[[nodiscard]] auto xmlEscape(std::string_view text) -> std::string; + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/include/intarnanew/types.hpp b/IntaRNAnew/include/intarnanew/types.hpp new file mode 100644 index 0000000..7b99017 --- /dev/null +++ b/IntaRNAnew/include/intarnanew/types.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { + +using Index = std::size_t; +using Energy = double; + +inline constexpr Energy infinity = std::numeric_limits::infinity(); +// ViennaRNA/IntaRNA thermodynamic convention in kcal mol^-1 K^-1. +inline constexpr Energy gasConstantKcal = 0.00198717; + +struct Interval { + Index begin{}; + Index end{}; + + [[nodiscard]] constexpr auto size() const noexcept -> Index { + return end >= begin ? end - begin + 1U : 0U; + } + + [[nodiscard]] constexpr auto contains(const Index value) const noexcept -> bool { + return begin <= value && value <= end; + } + + [[nodiscard]] constexpr auto overlaps(const Interval& other) const noexcept -> bool { + return begin <= other.end && other.begin <= end; + } + + friend constexpr auto operator==(const Interval&, const Interval&) -> bool = default; +}; + +struct BasePair { + Index target{}; + Index query{}; + + friend constexpr auto operator==(const BasePair&, const BasePair&) -> bool = default; +}; + +struct EnergyBreakdown { + Energy openingTarget{}; + Energy openingQuery{}; + Energy initiation{}; + Energy loops{}; + Energy dangleLeft{}; + Energy dangleRight{}; + Energy endLeft{}; + Energy endRight{}; + Energy additive{}; + + [[nodiscard]] constexpr auto hybrid() const noexcept -> Energy { + return initiation + loops + dangleLeft + dangleRight + endLeft + endRight + additive; + } + + [[nodiscard]] constexpr auto total() const noexcept -> Energy { + return openingTarget + openingQuery + hybrid(); + } +}; + +struct SeedMatch { + Index firstPair{}; + Index lastPair{}; + Energy energy{}; + Energy openingTarget{}; + Energy openingQuery{}; + double unpairedTarget{1.0}; + double unpairedQuery{1.0}; + + friend auto operator==(const SeedMatch&, const SeedMatch&) -> bool = default; +}; + +[[nodiscard]] inline auto seedMatchLess( + const SeedMatch& left, + const SeedMatch& right) noexcept -> bool { + if (left.energy != right.energy) return left.energy < right.energy; + if (left.firstPair != right.firstPair) return left.firstPair < right.firstPair; + return left.lastPair < right.lastPair; +} + +struct Interaction { + std::string targetId; + std::string queryId; + std::vector pairs; + EnergyBreakdown energy; + // Preserve the accessibility provider's native probabilities. They must + // not be reconstructed from ED with the interaction energy model's RT: + // the pedagogical base-pair model deliberately uses RT=1 while folding + // accessibility remains a physical-temperature ensemble. + double unpairedTarget{1.0}; + double unpairedQuery{1.0}; + std::vector seeds; + Energy ensembleFreeEnergy{infinity}; + Energy probability{}; + + [[nodiscard]] auto bestSeed() const noexcept -> const SeedMatch* { + const auto best = std::ranges::min_element(seeds, seedMatchLess); + return best == seeds.end() ? nullptr : &*best; + } + + [[nodiscard]] auto targetRange() const -> Interval { + if (pairs.empty()) { + return {}; + } + const auto [low, high] = std::ranges::minmax_element( + pairs, {}, &BasePair::target); + return {low->target, high->target}; + } + + [[nodiscard]] auto queryRange() const -> Interval { + if (pairs.empty()) { + return {}; + } + const auto [low, high] = std::ranges::minmax_element( + pairs, {}, &BasePair::query); + return {low->query, high->query}; + } +}; + +} // namespace intarnanew diff --git a/IntaRNAnew/src/accessibility.cpp b/IntaRNAnew/src/accessibility.cpp new file mode 100644 index 0000000..3f9c7fa --- /dev/null +++ b/IntaRNAnew/src/accessibility.cpp @@ -0,0 +1,594 @@ +#include "intarnanew/accessibility.hpp" + +#include "intarnanew/compression.hpp" + +#include "noncrossing_partition.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +inline constexpr double logZero = -std::numeric_limits::infinity(); +inline constexpr std::size_t maximumAccessibilityFileBytes = 256U * 1024U * 1024U; + +[[nodiscard]] auto parseConstraint( + const Sequence& sequence, + const std::string_view encoding) -> std::vector { + std::vector result(sequence.size(), '.'); + if (encoding.empty()) return result; + if (encoding.size() == sequence.size() && encoding.find(':') == std::string_view::npos) { + for (Index index = 0; index < sequence.size(); ++index) { + const auto symbol = static_cast(std::tolower(static_cast(encoding[index]))); + if (symbol != '.' && symbol != 'x' && symbol != 'p' && symbol != 'b') { + throw std::invalid_argument("invalid accessibility constraint symbol"); + } + result[index] = symbol; + } + return result; + } + + char kind = '.'; + std::size_t cursor{}; + while (cursor < encoding.size()) { + while (cursor < encoding.size() && (encoding[cursor] == ',' || std::isspace( + static_cast(encoding[cursor])) != 0)) ++cursor; + if (cursor >= encoding.size()) break; + if (cursor + 1U < encoding.size() && encoding[cursor + 1U] == ':') { + kind = static_cast(std::tolower(static_cast(encoding[cursor]))); + if (kind != 'x' && kind != 'p' && kind != 'b') { + throw std::invalid_argument("invalid range accessibility constraint type"); + } + cursor += 2U; + } + const auto start = cursor; + while (cursor < encoding.size() && encoding[cursor] != ',' && encoding[cursor] != ':') ++cursor; + auto token = encoding.substr(start, cursor - start); + while (!token.empty() && std::isspace(static_cast(token.back())) != 0) { + token.remove_suffix(1); + } + // Start after a possible sign of the first coordinate. This also + // handles encodings such as -5--3. + const auto dash = token.find('-', 1U); + if (dash == std::string_view::npos) { + throw std::invalid_argument("constraint range must use FROM-TO encoding"); + } + long long first{}; + long long last{}; + const auto firstText = token.substr(0, dash); + const auto lastText = token.substr(dash + 1U); + const auto firstResult = std::from_chars(firstText.data(), firstText.data() + firstText.size(), first); + const auto lastResult = std::from_chars(lastText.data(), lastText.data() + lastText.size(), last); + if (firstResult.ec != std::errc{} || firstResult.ptr != firstText.data() + firstText.size() || + lastResult.ec != std::errc{} || lastResult.ptr != lastText.data() + lastText.size() || + first > last) { + throw std::invalid_argument("invalid accessibility constraint range"); + } + const auto internalFirst = sequence.internalIndex(first); + const auto internalLast = sequence.internalIndex(last); + if (!internalFirst || !internalLast) { + throw std::invalid_argument("accessibility constraint range is outside the sequence"); + } + std::fill(result.begin() + static_cast(*internalFirst), + result.begin() + static_cast(*internalLast + 1U), kind); + } + return result; +} + +[[nodiscard]] auto pairEnergy(const char left, const char right) noexcept -> double { + const auto a = static_cast(std::toupper(static_cast(left))); + const auto b = static_cast(std::toupper(static_cast(right))); + if ((a == 'G' && b == 'C') || (a == 'C' && b == 'G')) return -2.4; + if ((a == 'A' && (b == 'U' || b == 'T')) || + ((a == 'U' || a == 'T') && b == 'A')) return -1.1; + if ((a == 'G' && (b == 'U' || b == 'T')) || + ((a == 'U' || a == 'T') && b == 'G')) return -0.5; + return -0.8; // conservative value for an ambiguous IUPAC-compatible pair +} + +[[nodiscard]] auto intervalValid(const Interval interval, const Index length) noexcept -> bool { + return interval.begin <= interval.end && interval.end < length; +} + +[[nodiscard]] auto parseNumericRow(const std::string& line) -> std::vector { + std::vector values; + std::istringstream input(line); + double value{}; + while (input >> value) values.push_back(value); + return values; +} + +[[nodiscard]] auto hasGzipSuffix(const std::string_view path) noexcept -> bool { + if (path.size() < 3U) return false; + const auto offset = path.size() - 3U; + return path[offset] == '.' && + static_cast(std::tolower(static_cast(path[offset + 1U]))) == 'g' && + static_cast(std::tolower(static_cast(path[offset + 2U]))) == 'z'; +} + +[[nodiscard]] auto readBoundedAccessibilityFile(const std::string& path) -> std::string { + std::ifstream input(path, std::ios::binary); + if (!input) throw std::invalid_argument("cannot open accessibility file '" + path + "'"); + + std::string bytes; + std::array buffer{}; + while (input) { + input.read(buffer.data(), static_cast(buffer.size())); + const auto count = input.gcount(); + if (count < 0) throw std::invalid_argument("failed to read accessibility file '" + path + "'"); + const auto amount = static_cast(count); + if (amount > maximumAccessibilityFileBytes - bytes.size()) { + throw std::invalid_argument("accessibility file exceeds the 256 MiB input-byte limit: '" + + path + "'"); + } + bytes.append(buffer.data(), amount); + } + if (!input.eof()) throw std::invalid_argument("failed to read accessibility file '" + path + "'"); + + if (hasGzipMagic(bytes)) { + GzipLimits limits; + limits.maxCompressedBytes = maximumAccessibilityFileBytes; + limits.maxDecompressedBytes = maximumAccessibilityFileBytes; + auto decoded = gzipDecompress(bytes, limits); + if (!decoded) { + throw std::invalid_argument("cannot decode accessibility file '" + path + "': " + + decoded.error()); + } + return std::move(*decoded); + } + if (hasGzipSuffix(path)) { + throw std::invalid_argument("accessibility file '" + path + + "' has a .gz suffix but no gzip signature"); + } + return bytes; +} + +[[nodiscard]] auto makeConfiguredFoldingEnsemble( + const Sequence& sequence, + const EnergyKind energy, + const FoldingOptions& options) -> std::unique_ptr { + return energy == EnergyKind::basePair + ? makeBasePairFoldingEnsemble(sequence, options) + : makeTurnerFoldingEnsemble(sequence, options); +} + +// Eall/Zall describe the free monomer, not the accessibility calculation. +// In particular, they are independent of acc=N/C/P/E, accW/accL, interval +// constraints, and accessibility-only noLP/noGUend/SHAPE settings. +[[nodiscard]] auto makeMonomerSummary( + const Sequence& sequence, + const Config& globalConfig) -> std::unique_ptr { + FoldingOptions options; + options.temperatureCelsius = globalConfig.temperatureCelsius; + options.parameterSet = globalConfig.energyParameters; + options.maximumPairSpan = sequence.size(); + options.includeDangles = !globalConfig.noDangles; + return makeConfiguredFoldingEnsemble(sequence, globalConfig.energy, options); +} + +} // namespace + +TurnerAccessibility::TurnerAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig) + : length_(sequence.size()), + stride_(sequence.size() + 1U), + windowLength_(config.accessibilityWindow == 0U + ? sequence.size() : std::min(sequence.size(), config.accessibilityWindow)), + rt_(gasConstantKcal * (globalConfig.temperatureCelsius + 273.15)), + probabilityExponent_(globalConfig.energy == EnergyKind::basePair ? 1.0 / rt_ : 1.0), + blocked_(length_, false), + intervalProbabilities_(length_ * stride_, std::numeric_limits::quiet_NaN()) { + if (!std::isfinite(rt_) || rt_ <= 0.0) { + throw std::invalid_argument("Turner accessibility temperature must be above absolute zero"); + } + if (!std::isfinite(config.partitionScale) || config.partitionScale < 1.0) { + throw std::invalid_argument("partition scale must be finite and at least 1"); + } + if (config.accessibilitySpan != 0U && config.accessibilityWindow != 0U && + config.accessibilitySpan > config.accessibilityWindow) { + throw std::invalid_argument("accessibility pair span must not exceed its folding window"); + } + const auto parsed = parseConstraint(sequence, config.accessibilityConstraint); + for (Index position{}; position < length_; ++position) blocked_[position] = parsed[position] == 'b'; + + auto summary = makeMonomerSummary(sequence, globalConfig); + summary_ = std::shared_ptr(std::move(summary)); + + // The public energy=B contract is one global Nussinov ensemble. accW + // limits the largest reported/usable interval, but does not localize the + // fold; accL, accNoLP, and accNoGUend are unsupported for this model. + if (globalConfig.energy == EnergyKind::basePair) { + FoldingOptions options; + options.temperatureCelsius = globalConfig.temperatureCelsius; + options.maximumPairSpan = length_; + options.partitionScale = config.partitionScale; + options.constraint = config.accessibilityConstraint; + std::shared_ptr ensemble; + if (config.accessibilityConstraint.empty()) { + ensemble = summary_; + } else { + auto owned = makeBasePairFoldingEnsemble(sequence, options); + ensemble = std::shared_ptr(std::move(owned)); + } + windows_.push_back(Window{0U, length_, std::move(ensemble)}); + return; + } + + const auto windowCount = length_ <= windowLength_ ? 1U : length_ - windowLength_ + 1U; + windows_.reserve(windowCount); + for (Index begin{}; begin < windowCount; ++begin) { + const auto end = begin + windowLength_; + std::string constraint(parsed.begin() + static_cast(begin), + parsed.begin() + static_cast(end)); + FoldingOptions options; + options.temperatureCelsius = globalConfig.temperatureCelsius; + options.parameterSet = globalConfig.energyParameters; + options.maximumPairSpan = config.accessibilitySpan == 0U + ? windowLength_ : std::min(windowLength_, config.accessibilitySpan); + options.noLonelyPairs = globalConfig.accessibilityNoLonelyPairs; + options.noGuHelixEnds = globalConfig.accessibilityNoGuAtEnds; + options.includeDangles = !globalConfig.noDangles; + options.partitionScale = config.partitionScale; + options.constraint = std::move(constraint); + options.shapeFile = config.shapeFile; + options.shapeMethod = config.shapeMethod; + options.shapeConversion = config.shapeConversion; + const Sequence subsequence( + sequence.id(), sequence.str().substr(begin, windowLength_), sequence.externalIndex(begin)); + const auto canReuseSummary = begin == 0U && windowLength_ == length_ && + (config.accessibilitySpan == 0U || config.accessibilitySpan >= length_) && + !globalConfig.accessibilityNoLonelyPairs && !globalConfig.accessibilityNoGuAtEnds && + config.accessibilityConstraint.empty() && config.shapeFile.empty() && + config.shapeMethod.empty() && config.shapeConversion.empty(); + std::shared_ptr ensemble; + if (canReuseSummary) { + ensemble = summary_; + } else { + auto owned = makeConfiguredFoldingEnsemble(subsequence, globalConfig.energy, options); + ensemble = std::shared_ptr(std::move(owned)); + } + windows_.push_back(Window{begin, end, std::move(ensemble)}); + } +} + +auto TurnerAccessibility::openingEnergy(const Interval interval) const -> Energy { + const auto probability = unpairedProbability(interval); + return probability <= 0.0 ? infinity : -rt_ * std::log(probability); +} + +auto TurnerAccessibility::unpairedProbability(const Interval interval) const -> double { + if (!intervalValid(interval, length_)) throw std::out_of_range("accessibility interval is out of range"); + if (std::any_of(blocked_.begin() + static_cast(interval.begin), + blocked_.begin() + static_cast(interval.end + 1U), + [](const bool value) { return value; })) return 0.0; + if (interval.size() > windowLength_) return 0.0; + const auto offset = interval.begin * stride_ + interval.size(); + { + const std::scoped_lock lock(cacheMutex_); + if (!std::isnan(intervalProbabilities_[offset])) return intervalProbabilities_[offset]; + } + + double sum{}; + Index count{}; + for (const auto& window : windows_) { + if (window.begin > interval.begin || window.end <= interval.end) continue; + const Interval local{interval.begin - window.begin, interval.end - window.begin}; + sum += window.ensemble->jointUnpairedProbability(local); + ++count; + } + if (count == 0U) throw std::logic_error("no accessibility folding window contains a valid interval"); + const auto ensembleProbability = std::clamp(sum / static_cast(count), 0.0, 1.0); + // energy=B defines its monomer ensemble with RT=1, while accessibility ED + // remains an energy that is converted to Pu using the physical RT. Thus + // Pu = exp(-ED/RT_phys) = (Z_constrained/Z)^(1/RT_phys). + double probability = ensembleProbability; + if (ensembleProbability > 0.0 && probabilityExponent_ != 1.0) { + // The legacy public energy interface stores ED in integer centikcal + // units. Preserve that observable boundary before converting ED to Pu. + const auto opening = std::trunc(-std::log(ensembleProbability) * 100.0) / 100.0; + probability = std::exp(-opening * probabilityExponent_); + } + probability = std::clamp(probability, 0.0, 1.0); + { + const std::scoped_lock lock(cacheMutex_); + intervalProbabilities_[offset] = probability; + } + return probability; +} + +auto TurnerAccessibility::positionUnpairedProbability(const Index position) const -> double { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return unpairedProbability({position, position}); +} + +auto TurnerAccessibility::blocked(const Index position) const -> bool { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return blocked_[position]; +} + +auto TurnerAccessibility::ensembleLogPartition() const noexcept -> std::optional { + return summary_ ? std::optional{summary_->logPartition()} : std::nullopt; +} + +auto TurnerAccessibility::ensembleFreeEnergy() const noexcept -> std::optional { + return summary_ ? std::optional{summary_->ensembleFreeEnergy()} : std::nullopt; +} + +DisabledAccessibility::DisabledAccessibility(const Sequence& sequence, const std::string_view constraint) + : length_(sequence.size()), blocked_(length_, false) { + const auto parsed = parseConstraint(sequence, constraint); + for (Index index = 0; index < length_; ++index) blocked_[index] = parsed[index] == 'b'; +} + +DisabledAccessibility::DisabledAccessibility( + const Sequence& sequence, + const SideConfig& side, + const Config& globalConfig) + : DisabledAccessibility(sequence, side.accessibilityConstraint) { + if (!side.shapeFile.empty() || !side.shapeMethod.empty() || !side.shapeConversion.empty()) { + throw std::invalid_argument("SHAPE data requires computed accessibility"); + } + if (globalConfig.predictionRequirements.computeMonomerPartition) { + ensemble_ = makeMonomerSummary(sequence, globalConfig); + } +} + +auto DisabledAccessibility::openingEnergy(const Interval interval) const -> Energy { + if (!intervalValid(interval, length_)) throw std::out_of_range("accessibility interval is out of range"); + return std::any_of(blocked_.begin() + static_cast(interval.begin), + blocked_.begin() + static_cast(interval.end + 1U), + [](const bool value) { return value; }) ? infinity : 0.0; +} + +auto DisabledAccessibility::unpairedProbability(const Interval interval) const -> double { + return std::isfinite(openingEnergy(interval)) ? 1.0 : 0.0; +} + +auto DisabledAccessibility::positionUnpairedProbability(const Index position) const -> double { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return blocked_[position] ? 0.0 : 1.0; +} + +auto DisabledAccessibility::blocked(const Index position) const -> bool { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return blocked_[position]; +} + +auto DisabledAccessibility::unconstrained(const Interval interval) const noexcept -> bool { + return intervalValid(interval, length_) && + std::none_of(blocked_.begin() + static_cast(interval.begin), + blocked_.begin() + static_cast(interval.end + 1U), + [](const bool value) { return value; }); +} + +auto DisabledAccessibility::ensembleLogPartition() const noexcept -> std::optional { + return ensemble_ ? std::optional{ensemble_->logPartition()} : std::nullopt; +} + +auto DisabledAccessibility::ensembleFreeEnergy() const noexcept -> std::optional { + return ensemble_ ? std::optional{ensemble_->ensembleFreeEnergy()} : std::nullopt; +} + +NativeAccessibility::NativeAccessibility( + const Sequence& sequence, + const SideConfig& config, + const double temperatureCelsius) + : length_(sequence.size()), + stride_(sequence.size() + 1U), + maxPairSpan_(config.accessibilitySpan == 0U + ? sequence.size() + : std::min(sequence.size(), config.accessibilitySpan)), + rt_(gasConstantKcal * (temperatureCelsius + 273.15)), + sequence_(sequence.str()), + constraints_(parseConstraint(sequence, config.accessibilityConstraint)), + blocked_(length_, false), + intervalProbabilities_(length_ * stride_, std::numeric_limits::quiet_NaN()) { + if (!std::isfinite(rt_) || rt_ <= 0.0) { + throw std::invalid_argument("native accessibility temperature must be above absolute zero"); + } + for (Index index = 0; index < length_; ++index) { + blocked_[index] = constraints_[index] == 'b'; + } + constexpr Index minimumHairpinDistance = 4U; + auto partitions = detail::computeNoncrossingIntervalPartitions( + length_, + [this](const Index position) { return constraints_[position] != 'p'; }, + [this](const Index left, const Index right) { + if (right < left + minimumHairpinDistance || right - left > maxPairSpan_ || + constraints_[left] == 'x' || constraints_[left] == 'b' || + constraints_[right] == 'x' || constraints_[right] == 'b' || + !canPair(sequence_[left], sequence_[right])) { + return logZero; + } + return -pairEnergy(sequence_[left], sequence_[right]) / rt_; + }); + logPartition_ = partitions.logPartition; + intervalProbabilities_ = std::move(partitions.probabilities); + if (!std::isfinite(logPartition_)) { + throw std::invalid_argument("native accessibility constraints admit no secondary structure"); + } +} + + +auto NativeAccessibility::cacheOffset(const Interval interval) const -> Index { + return interval.begin * stride_ + interval.size(); +} + +auto NativeAccessibility::openingEnergy(const Interval interval) const -> Energy { + const auto probability = unpairedProbability(interval); + return probability <= 0.0 ? infinity : -rt_ * std::log(probability); +} + +auto NativeAccessibility::unpairedProbability(const Interval interval) const -> double { + if (!intervalValid(interval, length_)) throw std::out_of_range("accessibility interval is out of range"); + if (std::any_of(blocked_.begin() + static_cast(interval.begin), + blocked_.begin() + static_cast(interval.end + 1U), + [](const bool value) { return value; })) { + return 0.0; + } + for (Index position = interval.begin; position <= interval.end; ++position) { + if (constraints_[position] == 'p') return 0.0; + } + return intervalProbabilities_[cacheOffset(interval)]; +} + +auto NativeAccessibility::positionUnpairedProbability(const Index position) const -> double { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return unpairedProbability({position, position}); +} + +auto NativeAccessibility::blocked(const Index position) const -> bool { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return blocked_[position]; +} + +TableAccessibility::TableAccessibility( + const Sequence& sequence, + const SideConfig& config, + const double temperatureCelsius, + const bool tableContainsProbabilities) + : length_(sequence.size()), + stride_(sequence.size() + 1U), + rt_(gasConstantKcal * (temperatureCelsius + 273.15)), + probabilities_(length_ * stride_, std::numeric_limits::quiet_NaN()), + blocked_(length_, false) { + const auto constraints = parseConstraint(sequence, config.accessibilityConstraint); + for (Index index = 0; index < length_; ++index) blocked_[index] = constraints[index] == 'b'; + + if (config.accessibilityFile == "STDIN" || config.accessibilityFile == "-") { + throw std::invalid_argument("table accessibility from STDIN must be supplied through a dedicated stream"); + } + std::istringstream input(readBoundedAccessibilityFile(config.accessibilityFile)); + + std::string line; + Index implicitRow{}; + while (std::getline(input, line)) { + const auto first = line.find_first_not_of(" \t\r"); + if (first == std::string::npos || line[first] == '#' || line[first] == '>') continue; + auto values = parseNumericRow(line); + if (values.empty()) continue; + + Index row = implicitRow; + Index valueStart{}; + const auto possibleIndex = static_cast(std::llround(values.front())); + if (values.size() > 1U && std::abs(values.front() - static_cast(possibleIndex)) < 1e-9 && + possibleIndex >= sequence.firstPosition() && + possibleIndex < sequence.firstPosition() + static_cast(sequence.size())) { + row = static_cast(possibleIndex - sequence.firstPosition()); + valueStart = 1U; + } + if (row >= length_) continue; + for (Index column = valueStart; column < values.size(); ++column) { + const Index intervalLength = column - valueStart + 1U; + if (row + 1U < intervalLength) continue; + const Interval interval{row + 1U - intervalLength, row}; + const auto raw = values[column]; + const auto probability = tableContainsProbabilities + ? raw + : (std::isfinite(raw) ? std::exp(-raw / rt_) : 0.0); + probabilities_[offset(interval)] = std::clamp(probability, 0.0, 1.0); + } + ++implicitRow; + } + if (!input.eof()) { + throw std::invalid_argument("failed while parsing accessibility file '" + + config.accessibilityFile + "'"); + } +} + +TableAccessibility::TableAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig, + const bool tableContainsProbabilities) + : TableAccessibility( + sequence, config, globalConfig.temperatureCelsius, tableContainsProbabilities) { + if (!config.shapeFile.empty() || !config.shapeMethod.empty() || !config.shapeConversion.empty()) { + throw std::invalid_argument("SHAPE data requires computed accessibility"); + } + if (globalConfig.predictionRequirements.computeMonomerPartition) { + ensemble_ = makeMonomerSummary(sequence, globalConfig); + } +} + +auto TableAccessibility::offset(const Interval interval) const -> Index { + if (!intervalValid(interval, length_)) throw std::out_of_range("accessibility interval is out of range"); + return interval.begin * stride_ + interval.size(); +} + +auto TableAccessibility::openingEnergy(const Interval interval) const -> Energy { + const auto probability = unpairedProbability(interval); + return probability <= 0.0 ? infinity : -rt_ * std::log(probability); +} + +auto TableAccessibility::unpairedProbability(const Interval interval) const -> double { + if (!intervalValid(interval, length_)) throw std::out_of_range("accessibility interval is out of range"); + if (std::any_of(blocked_.begin() + static_cast(interval.begin), + blocked_.begin() + static_cast(interval.end + 1U), + [](const bool value) { return value; })) return 0.0; + const auto stored = probabilities_[offset(interval)]; + if (std::isfinite(stored)) return stored; + throw std::runtime_error("accessibility table does not contain the requested joint interval probability"); +} + +auto TableAccessibility::positionUnpairedProbability(const Index position) const -> double { + return unpairedProbability({position, position}); +} + +auto TableAccessibility::blocked(const Index position) const -> bool { + if (position >= length_) throw std::out_of_range("accessibility position is out of range"); + return blocked_[position]; +} + +auto TableAccessibility::ensembleLogPartition() const noexcept -> std::optional { + return ensemble_ ? std::optional{ensemble_->logPartition()} : std::nullopt; +} + +auto TableAccessibility::ensembleFreeEnergy() const noexcept -> std::optional { + return ensemble_ ? std::optional{ensemble_->ensembleFreeEnergy()} : std::nullopt; +} + +auto makeAccessibility( + const Sequence& sequence, + const SideConfig& config, + const double temperatureCelsius) -> std::expected, std::string> { + Config globalConfig; + globalConfig.temperatureCelsius = temperatureCelsius; + return makeAccessibility(sequence, config, globalConfig); +} + +auto makeAccessibility( + const Sequence& sequence, + const SideConfig& config, + const Config& globalConfig) -> std::expected, std::string> { + try { + switch (config.accessibility) { + case AccessibilityKind::disabled: + return std::make_unique(sequence, config, globalConfig); + case AccessibilityKind::compute: + return std::make_unique(sequence, config, globalConfig); + case AccessibilityKind::probabilitiesFile: + return std::make_unique( + sequence, config, globalConfig, true); + case AccessibilityKind::energiesFile: + return std::make_unique( + sequence, config, globalConfig, false); + } + } catch (const std::exception& exception) { + return std::unexpected(exception.what()); + } + return std::unexpected("unsupported accessibility mode"); +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/cli.cpp b/IntaRNAnew/src/cli.cpp new file mode 100644 index 0000000..09b045e --- /dev/null +++ b/IntaRNAnew/src/cli.cpp @@ -0,0 +1,1444 @@ +#include "intarnanew/cli.hpp" +#include "intarnanew/folding.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +enum class OptionId { + query, + qId, + qIdxPos0, + qSet, + qAcc, + qAccW, + qAccL, + qAccConstr, + qAccFile, + qIntLenMax, + qIntLoopMax, + qRegion, + qRegionLenMax, + qPfScale, + target, + tId, + tIdxPos0, + tSet, + tAcc, + tAccW, + tAccL, + tAccConstr, + tAccFile, + tIntLenMax, + tIntLoopMax, + tRegion, + tRegionLenMax, + tPfScale, + noSeed, + seedTQ, + seedBP, + seedMaxUP, + seedQMaxUP, + seedTMaxUP, + seedMaxE, + seedMaxEhybrid, + seedMinPu, + seedQRange, + seedTRange, + seedNoGU, + seedNoGUend, + qShape, + tShape, + qShapeMethod, + tShapeMethod, + qShapeConversion, + tShapeConversion, + mode, + model, + acc, + intLenMax, + intLoopMax, + accW, + accL, + energy, + energyVRNA, + energyNoDangles, + accNoLP, + accNoGUend, + energyAdd, + temperature, + windowWidth, + windowOverlap, + helixMinBP, + helixMaxBP, + helixMaxIL, + helixMinPu, + helixMaxE, + helixFullE, + out, + outSep, + outMode, + outNumber, + outOverlap, + outMaxE, + outMinPu, + outDeltaE, + outBestSeedOnly, + outNoLP, + outNoGUend, + outCsvCols, + outCsvSort, + outPerRegion, + outPairwise, + verbose, + defaultLogFile, + threads, + version, + personality, + parameterFile, + help, + fullhelp, +}; + +struct RegisteredOption { + OptionId id; + OptionSpec spec; + double minimum{}; + double maximum{}; +}; + +#define OPTION_REQUIRED(identifier, long_name, short_name, group_name, value_name, default_value, summary, is_basic) \ + RegisteredOption{OptionId::identifier, {long_name, short_name, OptionGroup::group_name, \ + OptionValueMode::required, value_name, default_value, summary, OptionSupport::implemented, false, is_basic}} +#define OPTION_NUMBER(identifier, long_name, short_name, group_name, value_name, default_value, summary, is_basic, minimum_value, maximum_value) \ + RegisteredOption{OptionId::identifier, {long_name, short_name, OptionGroup::group_name, \ + OptionValueMode::required, value_name, default_value, summary, OptionSupport::implemented, false, is_basic}, \ + minimum_value, maximum_value} +#define OPTION_BOOLEAN(identifier, long_name, group_name, default_value, summary, is_basic) \ + RegisteredOption{OptionId::identifier, {long_name, '\0', OptionGroup::group_name, \ + OptionValueMode::optionalBoolean, "BOOL", default_value, summary, OptionSupport::implemented, false, is_basic}} +#define OPTION_FLAG(identifier, long_name, short_name, group_name, summary, is_basic) \ + RegisteredOption{OptionId::identifier, {long_name, short_name, OptionGroup::group_name, \ + OptionValueMode::none, "", "", summary, OptionSupport::implemented, false, is_basic}} + +constexpr std::array registeredOptions{ + OPTION_REQUIRED(query, "query", 'q', query, "RNA|FILE|STDIN", "", "Query RNA sequence, FASTA file, or STDIN.", true), + OPTION_REQUIRED(qId, "qId", '\0', query, "ID", "query", "Query identifier used for literal input.", false), + OPTION_NUMBER(qIdxPos0, "qIdxPos0", '\0', query, "INDEX", "1", "External coordinate of the query 5' end.", false, -2'000'000'000.0, 2'000'000'000.0), + OPTION_REQUIRED(qSet, "qSet", '\0', query, "RANGES", "", "One-based query record subset.", false), + OPTION_REQUIRED(qAcc, "qAcc", '\0', query, "N|C|P|E", "C", "Query accessibility source.", false), + OPTION_NUMBER(qAccW, "qAccW", '\0', query, "N", "150", "Query accessibility window (0 means global).", false, 0.0, 99'999.0), + OPTION_NUMBER(qAccL, "qAccL", '\0', query, "N", "100", "Query accessibility base-pair span.", false, 0.0, 99'999.0), + OPTION_REQUIRED(qAccConstr, "qAccConstr", '\0', query, "CONSTRAINT", "", "Query accessibility structure constraint.", false), + OPTION_REQUIRED(qAccFile, "qAccFile", '\0', query, "FILE", "", "Query accessibility probability or ED table.", false), + OPTION_NUMBER(qIntLenMax, "qIntLenMax", '\0', query, "N", "0", "Maximum query interaction-site length.", false, 0.0, 99'999.0), + OPTION_NUMBER(qIntLoopMax, "qIntLoopMax", '\0', query, "N", "16", "Maximum query-side interior-loop unpaired bases.", false, 0.0, 30.0), + OPTION_REQUIRED(qRegion, "qRegion", '\0', query, "RANGES", "", "Explicit query prediction regions in external coordinates.", false), + OPTION_NUMBER(qRegionLenMax, "qRegionLenMax", '\0', query, "N", "0", "Automatic maximum query region length.", false, 0.0, 99'999.0), + OPTION_NUMBER(qPfScale, "qPfScale", '\0', query, "FACTOR", "1.07", "Query partition-function scaling factor.", false, 1.0, 99'999.0), + + OPTION_REQUIRED(target, "target", 't', target, "RNA|FILE|STDIN", "", "Target RNA sequence, FASTA file, or STDIN.", true), + OPTION_REQUIRED(tId, "tId", '\0', target, "ID", "target", "Target identifier used for literal input.", false), + OPTION_NUMBER(tIdxPos0, "tIdxPos0", '\0', target, "INDEX", "1", "External coordinate of the target 5' end.", false, -2'000'000'000.0, 2'000'000'000.0), + OPTION_REQUIRED(tSet, "tSet", '\0', target, "RANGES", "", "One-based target record subset.", false), + OPTION_REQUIRED(tAcc, "tAcc", '\0', target, "N|C|P|E", "C", "Target accessibility source.", false), + OPTION_NUMBER(tAccW, "tAccW", '\0', target, "N", "150", "Target accessibility window (0 means global).", false, 0.0, 99'999.0), + OPTION_NUMBER(tAccL, "tAccL", '\0', target, "N", "100", "Target accessibility base-pair span.", false, 0.0, 99'999.0), + OPTION_REQUIRED(tAccConstr, "tAccConstr", '\0', target, "CONSTRAINT", "", "Target accessibility structure constraint.", false), + OPTION_REQUIRED(tAccFile, "tAccFile", '\0', target, "FILE", "", "Target accessibility probability or ED table.", false), + OPTION_NUMBER(tIntLenMax, "tIntLenMax", '\0', target, "N", "0", "Maximum target interaction-site length.", false, 0.0, 99'999.0), + OPTION_NUMBER(tIntLoopMax, "tIntLoopMax", '\0', target, "N", "10", "Maximum target-side interior-loop unpaired bases.", false, 0.0, 30.0), + OPTION_REQUIRED(tRegion, "tRegion", '\0', target, "RANGES", "", "Explicit target prediction regions in external coordinates.", false), + OPTION_NUMBER(tRegionLenMax, "tRegionLenMax", '\0', target, "N", "0", "Automatic maximum target region length.", false, 0.0, 99'999.0), + OPTION_NUMBER(tPfScale, "tPfScale", '\0', target, "FACTOR", "1.07", "Target partition-function scaling factor.", false, 1.0, 99'999.0), + + OPTION_BOOLEAN(noSeed, "noSeed", seed, "false", "Disable the generic seed requirement.", true), + OPTION_REQUIRED(seedTQ, "seedTQ", '\0', seed, "SEEDS", "", "Explicit target-query seed encodings.", false), + OPTION_NUMBER(seedBP, "seedBP", '\0', seed, "N", "7", "Required seed base-pair count.", true, 2.0, 20.0), + OPTION_NUMBER(seedMaxUP, "seedMaxUP", '\0', seed, "N", "0", "Maximum total unpaired seed bases.", false, 0.0, 20.0), + OPTION_NUMBER(seedQMaxUP, "seedQMaxUP", '\0', seed, "N", "-1", "Maximum unpaired query seed bases (-1 inherits seedMaxUP).", false, -1.0, 20.0), + OPTION_NUMBER(seedTMaxUP, "seedTMaxUP", '\0', seed, "N", "-1", "Maximum unpaired target seed bases (-1 inherits seedMaxUP).", false, -1.0, 20.0), + OPTION_NUMBER(seedMaxE, "seedMaxE", '\0', seed, "ENERGY", "0", "Maximum total seed energy.", false, -999.0, 999.0), + OPTION_NUMBER(seedMaxEhybrid, "seedMaxEhybrid", '\0', seed, "ENERGY", "999", "Maximum seed hybridization energy.", false, -999.0, 999.0), + OPTION_NUMBER(seedMinPu, "seedMinPu", '\0', seed, "PROBABILITY", "0", "Minimum seed-region unpaired probability.", false, 0.0, 1.0), + OPTION_REQUIRED(seedQRange, "seedQRange", '\0', seed, "RANGES", "", "Query ranges searched for seeds.", false), + OPTION_REQUIRED(seedTRange, "seedTRange", '\0', seed, "RANGES", "", "Target ranges searched for seeds.", false), + OPTION_BOOLEAN(seedNoGU, "seedNoGU", seed, "false", "Disallow GU pairs inside seeds.", false), + OPTION_BOOLEAN(seedNoGUend, "seedNoGUend", seed, "false", "Disallow GU pairs at seed ends.", false), + + OPTION_REQUIRED(qShape, "qShape", '\0', shape, "FILE", "", "Query SHAPE reactivity input.", false), + OPTION_REQUIRED(tShape, "tShape", '\0', shape, "FILE", "", "Target SHAPE reactivity input.", false), + OPTION_REQUIRED(qShapeMethod, "qShapeMethod", '\0', shape, "METHOD", "", "Query SHAPE pseudo-energy method (D, Z, or W encoding).", false), + OPTION_REQUIRED(tShapeMethod, "tShapeMethod", '\0', shape, "METHOD", "", "Target SHAPE pseudo-energy method (D, Z, or W encoding).", false), + OPTION_REQUIRED(qShapeConversion, "qShapeConversion", '\0', shape, "CONVERSION", "", "Query SHAPE conversion (M, C, S, L, or O encoding).", false), + OPTION_REQUIRED(tShapeConversion, "tShapeConversion", '\0', shape, "CONVERSION", "", "Target SHAPE conversion (M, C, S, L, or O encoding).", false), + + OPTION_REQUIRED(mode, "mode", 'm', interaction, "H|M|S", "H", "Prediction mode: heuristic, exact, or seed-only.", true), + OPTION_REQUIRED(model, "model", '\0', interaction, "S|X|B|P", "X", "Interaction model: single-site, seed extension, helix blocks, or ensemble.", true), + OPTION_REQUIRED(acc, "acc", '\0', interaction, "N|C", "C", "Set query and target accessibility together.", true), + OPTION_NUMBER(intLenMax, "intLenMax", '\0', interaction, "N", "0", "Set both maximum interaction-site lengths.", false, 0.0, 99'999.0), + OPTION_NUMBER(intLoopMax, "intLoopMax", '\0', interaction, "N", "10", "Set both maximum interior-loop sizes.", false, 0.0, 30.0), + OPTION_NUMBER(accW, "accW", '\0', interaction, "N", "150", "Set both accessibility windows.", false, 0.0, 99'999.0), + OPTION_NUMBER(accL, "accL", '\0', interaction, "N", "100", "Set both accessibility spans.", false, 0.0, 99'999.0), + OPTION_REQUIRED(energy, "energy", 'e', interaction, "B|V", "V", "Base-pair or nearest-neighbor energy model.", true), + OPTION_REQUIRED(energyVRNA, "energyVRNA", '\0', interaction, "MODEL|FILE", "Turner04", "Nearest-neighbor parameter set or file.", false), + OPTION_BOOLEAN(energyNoDangles, "energyNoDangles", interaction, "false", "Disable dangling-end contributions.", false), + OPTION_BOOLEAN(accNoLP, "accNoLP", interaction, "false", "Disallow lonely pairs in accessibility folding.", false), + OPTION_BOOLEAN(accNoGUend, "accNoGUend", interaction, "false", "Disallow GU helix ends in accessibility folding.", false), + OPTION_NUMBER(energyAdd, "energyAdd", '\0', interaction, "ENERGY", "0", "Additive interaction energy correction.", false, -999.0, 999.0), + OPTION_NUMBER(temperature, "temperature", '\0', interaction, "CELSIUS", "37", "Folding temperature in Celsius.", false, 0.0, 100.0), + OPTION_NUMBER(windowWidth, "windowWidth", '\0', interaction, "N", "0", "Prediction-window width (0 disables decomposition).", false, 0.0, 99'999.0), + OPTION_NUMBER(windowOverlap, "windowOverlap", '\0', interaction, "N", "150", "Overlap between prediction windows.", false, 0.0, 99'999.0), + + OPTION_NUMBER(helixMinBP, "helixMinBP", '\0', helix, "N", "2", "Minimum helix-block base-pair count.", false, 2.0, 4.0), + OPTION_NUMBER(helixMaxBP, "helixMaxBP", '\0', helix, "N", "10", "Maximum helix-block base-pair count.", false, 2.0, 20.0), + OPTION_NUMBER(helixMaxIL, "helixMaxIL", '\0', helix, "N", "0", "Maximum internal-loop size inside helix blocks.", false, 0.0, 2.0), + OPTION_NUMBER(helixMinPu, "helixMinPu", '\0', helix, "PROBABILITY", "0", "Minimum helix-region unpaired probability.", false, 0.0, 1.0), + OPTION_NUMBER(helixMaxE, "helixMaxE", '\0', helix, "ENERGY", "0", "Maximum accepted helix-block energy.", false, -999.0, 999.0), + OPTION_BOOLEAN(helixFullE, "helixFullE", helix, "false", "Use full helix energy for helixMaxE.", false), + + RegisteredOption{OptionId::out, {"out", '\0', OptionGroup::output, OptionValueMode::required, + "DEST", "STDOUT", + "Primary output or qMinE/qSpotProb/qAcc/qPu/tMinE/tSpotProb/tAcc/tPu/" + "pMinE/spotProb prefixed auxiliary destination.", + OptionSupport::implemented, true, true}}, + OPTION_REQUIRED(outSep, "outSep", '\0', output, "CHAR", ";", "Tabular output separator.", false), + OPTION_REQUIRED(outMode, "outMode", '\0', output, "N|D|C|E", "N", "Normal, detailed, CSV, or ensemble output.", true), + OPTION_NUMBER(outNumber, "outNumber", 'n', output, "N", "1", "Maximum number of reported interactions.", true, 0.0, 1'000.0), + OPTION_REQUIRED(outOverlap, "outOverlap", '\0', output, "N|T|Q|B", "B", "Allowed target/query overlap for suboptimal output.", true), + OPTION_NUMBER(outMaxE, "outMaxE", '\0', output, "ENERGY", "0", "Report interactions below this energy.", false, -999.0, 999.0), + OPTION_NUMBER(outMinPu, "outMinPu", '\0', output, "PROBABILITY", "0", "Minimum per-position unpaired probability.", false, 0.0, 1.0), + OPTION_NUMBER(outDeltaE, "outDeltaE", '\0', output, "ENERGY", "100", "Suboptimal energy range above the optimum.", false, 0.0, 100.0), + RegisteredOption{OptionId::outBestSeedOnly, {"outBestSeedOnly", '\0', OptionGroup::output, + OptionValueMode::optionalBoolean, "BOOL", "false", + "Report only the lowest-energy seed of each interaction.", + OptionSupport::implemented, false, false}}, + OPTION_BOOLEAN(outNoLP, "outNoLP", output, "false", "Disallow lonely intermolecular pairs.", false), + OPTION_BOOLEAN(outNoGUend, "outNoGUend", output, "false", "Disallow GU pairs at interaction helix ends.", false), + OPTION_REQUIRED(outCsvCols, "outCsvCols", '\0', output, "COLUMNS", "id1,start1,end1,id2,start2,end2,subseqDP,hybridDP,E", "CSV columns; use '*' or an empty value for every documented column.", true), + OPTION_REQUIRED(outCsvSort, "outCsvSort", '\0', output, "COLUMN", "", "CSV sort-column identifier.", false), + OPTION_BOOLEAN(outPerRegion, "outPerRegion", output, "false", "Report each query-target region pair independently.", false), + OPTION_BOOLEAN(outPairwise, "outPairwise", output, "false", "Predict corresponding query-target records only.", false), + RegisteredOption{OptionId::verbose, {"verbose", 'v', OptionGroup::output, OptionValueMode::none, + "", "false", "Record a verbose-logging request; the standalone core remains quiet.", + OptionSupport::compatibilityOnly, false, false}}, + RegisteredOption{OptionId::defaultLogFile, {"default-log-file", '\0', OptionGroup::output, + OptionValueMode::required, "FILE", "", "Record the compatibility log destination; no informational log is emitted.", + OptionSupport::compatibilityOnly, false, false}}, + + OPTION_NUMBER(threads, "threads", '\0', general, "N", "1", "Worker count (0 uses available hardware).", true, 0.0, 12.0), + OPTION_FLAG(version, "version", '\0', general, "Show version information.", true), + OPTION_REQUIRED(personality, "personality", '\0', general, "NAME", "IntaRNA", "Preset: IntaRNA/3/1/2/exact/helix/duplex/sTar/seed/ens; executable basenames are aliases.", true), + RegisteredOption{OptionId::parameterFile, {"parameterFile", '\0', OptionGroup::general, + OptionValueMode::required, "FILE", "", "Read registry options from a key=value file.", + OptionSupport::implemented, true, true}}, + OPTION_FLAG(help, "help", 'h', general, "Show basic options.", true), + OPTION_FLAG(fullhelp, "fullhelp", '\0', general, "Show every registered option.", true), +}; + +#undef OPTION_REQUIRED +#undef OPTION_NUMBER +#undef OPTION_BOOLEAN +#undef OPTION_FLAG + +struct Assignment { + const RegisteredOption* option{}; + std::string value; + bool hasValue{}; + ConfigOrigin origin; +}; + +[[nodiscard]] auto equalInsensitive( + const std::string_view left, + const std::string_view right) noexcept -> bool { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + const auto leftCharacter = static_cast(left[index]); + const auto rightCharacter = static_cast(right[index]); + if (std::tolower(leftCharacter) != std::tolower(rightCharacter)) return false; + } + return true; +} + +[[nodiscard]] auto lowerAscii(std::string value) -> std::string { + std::ranges::transform(value, value.begin(), [](const unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; +} + +[[nodiscard]] auto trim(std::string value) -> std::string { + const auto first = std::find_if_not(value.begin(), value.end(), [](const unsigned char character) { + return std::isspace(character) != 0; + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](const unsigned char character) { + return std::isspace(character) != 0; + }).base(); + return first < last ? std::string(first, last) : std::string{}; +} + +[[nodiscard]] auto unquote(std::string value) -> std::string { + if (value.size() >= 2U && + ((value.front() == '"' && value.back() == '"') || + (value.front() == '\'' && value.back() == '\''))) { + return value.substr(1U, value.size() - 2U); + } + return value; +} + +[[nodiscard]] auto originText(const ConfigOrigin& origin) -> std::string { + switch (origin.source) { + case ConfigSource::baseline: + return origin.detail.empty() ? "built-in default" : origin.detail; + case ConfigSource::personality: + return origin.detail; + case ConfigSource::parameterFile: + return "parameter file '" + origin.detail + "' line " + std::to_string(origin.position); + case ConfigSource::commandLine: + return "command line argument " + std::to_string(origin.position); + } + return "unknown source"; +} + +[[nodiscard]] auto findLongOption(const std::string_view name) noexcept + -> const RegisteredOption* { + const auto found = std::ranges::find_if(registeredOptions, [name](const RegisteredOption& option) { + return equalInsensitive(option.spec.longName, name); + }); + return found == registeredOptions.end() ? nullptr : &*found; +} + +[[nodiscard]] auto findShortOption(const char name) noexcept -> const RegisteredOption* { + const auto normalized = static_cast( + std::tolower(static_cast(name))); + const auto found = std::ranges::find_if(registeredOptions, [normalized](const RegisteredOption& option) { + return option.spec.shortName != '\0' && + std::tolower(static_cast(option.spec.shortName)) == normalized; + }); + return found == registeredOptions.end() ? nullptr : &*found; +} + +[[nodiscard]] auto looksLikeOption(const std::string_view value) -> bool { + return value.starts_with("--") || + (value.size() == 2U && value.front() == '-' && + std::isalpha(static_cast(value.back())) != 0); +} + +[[nodiscard]] auto isBooleanText(const std::string_view value) -> bool { + return equalInsensitive(value, "1") || equalInsensitive(value, "0") || + equalInsensitive(value, "true") || equalInsensitive(value, "false") || + equalInsensitive(value, "yes") || equalInsensitive(value, "no") || + equalInsensitive(value, "on") || equalInsensitive(value, "off"); +} + +[[nodiscard]] auto tokenizeCommandLine(std::span arguments) + -> std::expected, std::string> { + std::vector result; + for (std::size_t index = 0U; index < arguments.size(); ++index) { + const auto argumentPosition = index + 1U; + std::string token(arguments[index]); + if (token.empty()) { + return std::unexpected("empty positional argument at command line argument " + + std::to_string(argumentPosition)); + } + + const RegisteredOption* option{}; + std::string inlineValue; + bool hasInlineValue{}; + if (token.starts_with("--")) { + token.erase(0U, 2U); + if (const auto equals = token.find('='); equals != std::string::npos) { + inlineValue = token.substr(equals + 1U); + token.erase(equals); + hasInlineValue = true; + } + option = findLongOption(token); + } else if (token.size() == 2U && token.front() == '-') { + option = findShortOption(token.back()); + } else { + return std::unexpected("unexpected positional argument '" + token + + "' at command line argument " + + std::to_string(argumentPosition)); + } + if (option == nullptr) { + return std::unexpected("unknown option '" + std::string(arguments[index]) + + "' at command line argument " + + std::to_string(argumentPosition)); + } + + Assignment assignment{option, std::move(inlineValue), hasInlineValue, + {ConfigSource::commandLine, "command line", argumentPosition}}; + if (option->spec.valueMode == OptionValueMode::none) { + if (hasInlineValue) { + return std::unexpected("--" + std::string(option->spec.longName) + + " does not take a value at " + + originText(assignment.origin)); + } + } else if (option->spec.valueMode == OptionValueMode::required && + !assignment.hasValue) { + if (index + 1U >= arguments.size() || looksLikeOption(arguments[index + 1U])) { + return std::unexpected("--" + std::string(option->spec.longName) + + " requires a value at " + + originText(assignment.origin)); + } + assignment.value = std::string(arguments[++index]); + assignment.hasValue = true; + } else if (option->spec.valueMode == OptionValueMode::optionalBoolean && + !assignment.hasValue && index + 1U < arguments.size() && + isBooleanText(arguments[index + 1U])) { + assignment.value = std::string(arguments[++index]); + assignment.hasValue = true; + } + result.push_back(std::move(assignment)); + } + return result; +} + +[[nodiscard]] auto parseParameterLine( + std::string line, + const std::filesystem::path& path, + const std::size_t lineNumber) -> std::expected { + const ConfigOrigin origin{ConfigSource::parameterFile, path.string(), lineNumber}; + if (line.starts_with("--")) line.erase(0U, 2U); + + std::string key; + std::string value; + bool hasValue{}; + if (const auto equals = line.find('='); equals != std::string::npos) { + key = trim(line.substr(0U, equals)); + value = unquote(trim(line.substr(equals + 1U))); + hasValue = true; + } else { + const auto separator = std::find_if(line.begin(), line.end(), [](const unsigned char character) { + return std::isspace(character) != 0; + }); + key = std::string(line.begin(), separator); + if (separator != line.end()) { + value = unquote(trim(std::string(separator, line.end()))); + hasValue = !value.empty(); + } + } + if (key.empty()) { + return std::unexpected("empty option at " + originText(origin)); + } + + const RegisteredOption* option{}; + if (key.size() == 2U && key.front() == '-') { + option = findShortOption(key.back()); + } else { + if (key.starts_with("-")) key.erase(0U, 1U); + option = findLongOption(key); + if (option == nullptr && key.size() == 1U) option = findShortOption(key.front()); + } + if (option == nullptr) { + return std::unexpected("unknown option '--" + key + "' at " + originText(origin)); + } + if (option->spec.valueMode == OptionValueMode::none && hasValue) { + return std::unexpected("--" + std::string(option->spec.longName) + + " does not take a value at " + originText(origin)); + } + if (option->spec.valueMode == OptionValueMode::required && !hasValue) { + return std::unexpected("--" + std::string(option->spec.longName) + + " requires a value at " + originText(origin)); + } + return Assignment{option, std::move(value), hasValue, origin}; +} + +[[nodiscard]] auto normalizedPath(const std::filesystem::path& path) + -> std::filesystem::path { + std::error_code error; + auto absolute = std::filesystem::absolute(path, error); + return (error ? path : absolute).lexically_normal(); +} + +[[nodiscard]] auto readParameterFile( + const std::filesystem::path& requestedPath, + std::vector includeStack = {}) + -> std::expected, std::string> { + const auto path = normalizedPath(requestedPath); + if (std::ranges::find(includeStack, path) != includeStack.end()) { + std::ostringstream message; + message << "parameter-file include cycle:"; + for (const auto& entry : includeStack) message << " '" << entry.string() << "' ->"; + message << " '" << path.string() << "'"; + return std::unexpected(message.str()); + } + includeStack.push_back(path); + + std::ifstream input(path); + if (!input) { + return std::unexpected("cannot open parameter file '" + path.string() + "'"); + } + + std::vector result; + std::string line; + std::size_t lineNumber{}; + while (std::getline(input, line)) { + ++lineNumber; + if (const auto comment = line.find('#'); comment != std::string::npos) line.erase(comment); + line = trim(std::move(line)); + if (line.empty()) continue; + + auto parsed = parseParameterLine(std::move(line), path, lineNumber); + if (!parsed) return std::unexpected(parsed.error()); + if (parsed->option->id == OptionId::parameterFile) { + auto nestedPath = std::filesystem::path(parsed->value); + if (nestedPath.is_relative()) nestedPath = path.parent_path() / nestedPath; + nestedPath = normalizedPath(nestedPath); + parsed->value = nestedPath.string(); + result.push_back(*parsed); + auto nested = readParameterFile(nestedPath, includeStack); + if (!nested) { + return std::unexpected(nested.error() + " (included from " + + originText(parsed->origin) + ")"); + } + result.insert(result.end(), + std::make_move_iterator(nested->begin()), + std::make_move_iterator(nested->end())); + } else { + result.push_back(std::move(*parsed)); + } + } + return result; +} + +[[nodiscard]] auto validateDuplicates(const std::vector& assignments) + -> std::expected { + using SeenKey = std::tuple; + std::map seen; + for (const auto& assignment : assignments) { + if (assignment.option->spec.repeatable) continue; + const auto key = SeenKey{ + assignment.origin.source, + assignment.origin.detail, + assignment.option->spec.longName, + }; + const auto [position, inserted] = seen.emplace(key, assignment.origin); + if (!inserted) { + return std::unexpected( + "duplicate --" + std::string(assignment.option->spec.longName) + + ": first at " + originText(position->second) + + ", again at " + originText(assignment.origin)); + } + } + return {}; +} + +[[nodiscard]] auto canonicalPersonality( + const std::string_view value, + const ConfigOrigin& origin) -> std::expected { + struct Name { + std::string_view input; + std::string_view canonical; + }; + constexpr std::array names{ + Name{"default", "IntaRNA"}, + Name{"IntaRNAnew", "IntaRNA"}, + Name{"IntaRNA", "IntaRNA"}, + Name{"IntaRNA3", "IntaRNA3"}, + Name{"IntaRNA1", "IntaRNA1"}, + Name{"IntaRNA2", "IntaRNA2"}, + Name{"IntaRNAexact", "IntaRNAexact"}, + Name{"IntaRNAhelix", "IntaRNAhelix"}, + Name{"IntaRNAduplex", "IntaRNAduplex"}, + Name{"IntaRNAsTar", "IntaRNAsTar"}, + Name{"IntaRNAseed", "IntaRNAseed"}, + Name{"IntaRNAens", "IntaRNAens"}, + }; + const auto match = std::ranges::find_if(names, [value](const Name& name) { + return equalInsensitive(name.input, value); + }); + if (match == names.end()) { + return std::unexpected("unknown personality '" + std::string(value) + + "' at " + originText(origin)); + } + return std::string(match->canonical); +} + +[[nodiscard]] auto executablePersonality(const std::string_view invocationName) + -> std::expected, std::string> { + if (invocationName.empty()) { + return std::pair{std::string{"IntaRNA"}, ConfigOrigin{ + ConfigSource::personality, "default personality", 0U}}; + } + const auto separator = invocationName.find_last_of("/\\"); + auto basename = std::string(invocationName.substr( + separator == std::string_view::npos ? 0U : separator + 1U)); + if (basename.size() > 4U && + equalInsensitive(std::string_view(basename).substr(basename.size() - 4U), ".exe")) { + basename.resize(basename.size() - 4U); + } + const ConfigOrigin aliasOrigin{ + ConfigSource::personality, "executable alias '" + basename + "'", 0U}; + auto canonical = canonicalPersonality(basename, aliasOrigin); + if (canonical) return std::pair{std::move(*canonical), aliasOrigin}; + if (!lowerAscii(basename).starts_with("intarna")) { + return std::pair{std::string{"IntaRNA"}, ConfigOrigin{ + ConfigSource::personality, "default personality", 0U}}; + } + return std::unexpected(canonical.error()); +} + +void markPersonalityDefaults( + Config& config, + const ConfigOrigin& selector, + const std::string_view personality, + const std::initializer_list options) { + const ConfigOrigin origin{ + ConfigSource::personality, + "personality '" + std::string(personality) + "' selected by " + originText(selector), + 0U, + }; + for (const auto option : options) { + config.provenance[std::string(option)] = origin; + config.assignmentHistory.push_back( + {std::string(option), "", origin}); + } +} + +void applyPersonality( + Config& config, + const std::string_view personality, + const ConfigOrigin& selector) { + config.personality = std::string(personality); + config.provenance["personality"] = selector; + config.assignmentHistory.push_back( + {"personality", std::string(personality), selector}); + + if (equalInsensitive(personality, "IntaRNA1")) { + config.model = InteractionModel::singleSite; + config.mode = PredictionMode::heuristic; + config.query.accessibilityWindow = 0U; + config.target.accessibilityWindow = 0U; + config.query.accessibilitySpan = 0U; + config.target.accessibilitySpan = 0U; + config.query.interactionLoopMax = 16U; + config.target.interactionLoopMax = 16U; + config.output.overlap = OverlapPolicy::query; + markPersonalityDefaults(config, selector, personality, + {"model", "mode", "qAccW", "tAccW", "qAccL", "tAccL", + "qIntLoopMax", "tIntLoopMax", "outOverlap"}); + } else if (equalInsensitive(personality, "IntaRNA2")) { + config.model = InteractionModel::singleSite; + config.mode = PredictionMode::heuristic; + config.query.interactionLoopMax = 16U; + config.target.interactionLoopMax = 16U; + config.output.overlap = OverlapPolicy::query; + markPersonalityDefaults(config, selector, personality, + {"model", "mode", "qIntLoopMax", "tIntLoopMax", "outOverlap"}); + } else if (equalInsensitive(personality, "IntaRNAexact")) { + config.model = InteractionModel::seedExtension; + config.mode = PredictionMode::exact; + config.query.accessibilityWindow = 0U; + config.target.accessibilityWindow = 0U; + config.query.accessibilitySpan = 0U; + config.target.accessibilitySpan = 0U; + config.query.interactionLengthMax = 60U; + config.target.interactionLengthMax = 60U; + config.output.overlap = OverlapPolicy::both; + markPersonalityDefaults(config, selector, personality, + {"model", "mode", "qAccW", "tAccW", "qAccL", "tAccL", + "qIntLenMax", "tIntLenMax", "outOverlap"}); + } else if (equalInsensitive(personality, "IntaRNAhelix")) { + config.model = InteractionModel::helixBlocks; + config.mode = PredictionMode::heuristic; + markPersonalityDefaults(config, selector, personality, {"model", "mode"}); + } else if (equalInsensitive(personality, "IntaRNAduplex")) { + config.query.accessibility = AccessibilityKind::disabled; + config.target.accessibility = AccessibilityKind::disabled; + markPersonalityDefaults(config, selector, personality, {"qAcc", "tAcc"}); + } else if (equalInsensitive(personality, "IntaRNAsTar")) { + config.query.interactionLengthMax = 60U; + config.target.interactionLengthMax = 60U; + config.query.interactionLoopMax = 8U; + config.target.interactionLoopMax = 8U; + config.seed.noGu = true; + config.seed.minUnpairedProbability = 0.001; + config.output.minUnpairedProbability = 0.001; + config.output.noLonelyPairs = true; + config.output.noGuAtEnds = true; + config.output.overlap = OverlapPolicy::query; + config.output.mode = OutputMode::csv; + config.output.csvColumns = "id1,id2,start1,end1,start2,end2,E"; + markPersonalityDefaults(config, selector, personality, + {"qIntLenMax", "tIntLenMax", "qIntLoopMax", "tIntLoopMax", + "seedNoGU", "seedMinPu", "outMinPu", "outNoLP", "outNoGUend", + "outOverlap", "outMode", "outCsvCols"}); + } else if (equalInsensitive(personality, "IntaRNAseed")) { + config.mode = PredictionMode::seedOnly; + markPersonalityDefaults(config, selector, personality, {"mode"}); + } else if (equalInsensitive(personality, "IntaRNAens")) { + config.model = InteractionModel::ensemble; + markPersonalityDefaults(config, selector, personality, {"model"}); + } +} + +template +[[nodiscard]] auto numericValue(const Assignment& assignment) + -> std::expected { + if (!assignment.hasValue) { + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " requires a value at " + originText(assignment.origin)); + } + Value value{}; + const auto* begin = assignment.value.data(); + const auto* end = begin + assignment.value.size(); + const auto [position, error] = std::from_chars(begin, end, value); + if (error != std::errc{} || position != end || + (std::is_floating_point_v && !std::isfinite(value))) { + return std::unexpected( + "invalid numeric value '" + assignment.value + "' for --" + + std::string(assignment.option->spec.longName) + " at " + + originText(assignment.origin)); + } + const auto converted = static_cast(value); + if (converted < static_cast(assignment.option->minimum) || + converted > static_cast(assignment.option->maximum)) { + std::ostringstream message; + message << "value '" << assignment.value << "' for --" + << assignment.option->spec.longName << " is outside [" + << assignment.option->minimum << ',' << assignment.option->maximum + << "] at " << originText(assignment.origin); + return std::unexpected(message.str()); + } + return value; +} + +[[nodiscard]] auto booleanValue(const Assignment& assignment) + -> std::expected { + if (!assignment.hasValue) return true; + if (equalInsensitive(assignment.value, "1") || + equalInsensitive(assignment.value, "true") || + equalInsensitive(assignment.value, "yes") || + equalInsensitive(assignment.value, "on")) { + return true; + } + if (equalInsensitive(assignment.value, "0") || + equalInsensitive(assignment.value, "false") || + equalInsensitive(assignment.value, "no") || + equalInsensitive(assignment.value, "off")) { + return false; + } + return std::unexpected( + "invalid Boolean value '" + assignment.value + "' for --" + + std::string(assignment.option->spec.longName) + " at " + + originText(assignment.origin)); +} + +[[nodiscard]] auto accessibilityValue(const Assignment& assignment) + -> std::expected { + if (!assignment.hasValue || assignment.value.size() != 1U) { + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " expects N, C, P, or E at " + + originText(assignment.origin)); + } + switch (static_cast(std::toupper( + static_cast(assignment.value.front())))) { + case 'N': return AccessibilityKind::disabled; + case 'C': return AccessibilityKind::compute; + case 'P': return AccessibilityKind::probabilitiesFile; + case 'E': return AccessibilityKind::energiesFile; + default: + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " expects N, C, P, or E at " + + originText(assignment.origin)); + } +} + +void recordAssignment(Config& config, const Assignment& assignment) { + const auto name = std::string(assignment.option->spec.longName); + config.provenance[name] = assignment.origin; + config.assignmentHistory.push_back( + {name, assignment.hasValue ? assignment.value : "true", assignment.origin}); +} + +[[nodiscard]] auto applyAssignment( + Config& config, + const Assignment& assignment, + bool& outputWasSet) -> std::expected { + const auto stringTo = [&](std::string& destination) -> std::expected { + if (!assignment.hasValue) { + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " requires a value at " + originText(assignment.origin)); + } + destination = assignment.value; + return {}; + }; + const auto booleanTo = [&](bool& destination) -> std::expected { + auto value = booleanValue(assignment); + if (!value) return std::unexpected(value.error()); + destination = *value; + return {}; + }; + const auto sizeTo = [&](std::size_t& destination) -> std::expected { + auto value = numericValue(assignment); + if (!value) return std::unexpected(value.error()); + destination = *value; + return {}; + }; + const auto intTo = [&](int& destination) -> std::expected { + auto value = numericValue(assignment); + if (!value) return std::unexpected(value.error()); + destination = *value; + return {}; + }; + const auto longTo = [&](long long& destination) -> std::expected { + auto value = numericValue(assignment); + if (!value) return std::unexpected(value.error()); + destination = *value; + return {}; + }; + const auto doubleTo = [&](double& destination) -> std::expected { + auto value = numericValue(assignment); + if (!value) return std::unexpected(value.error()); + destination = *value; + return {}; + }; + const auto choice = [&](const std::string_view accepted) + -> std::expected { + if (!assignment.hasValue || assignment.value.size() != 1U) { + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " expects one of " + std::string(accepted) + " at " + + originText(assignment.origin)); + } + const auto value = static_cast(std::toupper( + static_cast(assignment.value.front()))); + if (accepted.find(value) == std::string_view::npos) { + return std::unexpected("--" + std::string(assignment.option->spec.longName) + + " expects one of " + std::string(accepted) + " at " + + originText(assignment.origin)); + } + return value; + }; + + std::expected status{}; + switch (assignment.option->id) { + case OptionId::query: status = stringTo(config.query.input); break; + case OptionId::qId: status = stringTo(config.query.id); break; + case OptionId::qIdxPos0: status = longTo(config.query.firstPosition); break; + case OptionId::qSet: status = stringTo(config.query.subset); break; + case OptionId::qAcc: { + auto value = accessibilityValue(assignment); + if (!value) status = std::unexpected(value.error()); + else config.query.accessibility = *value; + break; + } + case OptionId::qAccW: status = sizeTo(config.query.accessibilityWindow); break; + case OptionId::qAccL: status = sizeTo(config.query.accessibilitySpan); break; + case OptionId::qAccConstr: status = stringTo(config.query.accessibilityConstraint); break; + case OptionId::qAccFile: status = stringTo(config.query.accessibilityFile); break; + case OptionId::qIntLenMax: status = sizeTo(config.query.interactionLengthMax); break; + case OptionId::qIntLoopMax: status = sizeTo(config.query.interactionLoopMax); break; + case OptionId::qRegion: status = stringTo(config.query.regions); break; + case OptionId::qRegionLenMax: status = sizeTo(config.query.regionLengthMax); break; + case OptionId::qPfScale: status = doubleTo(config.query.partitionScale); break; + + case OptionId::target: status = stringTo(config.target.input); break; + case OptionId::tId: status = stringTo(config.target.id); break; + case OptionId::tIdxPos0: status = longTo(config.target.firstPosition); break; + case OptionId::tSet: status = stringTo(config.target.subset); break; + case OptionId::tAcc: { + auto value = accessibilityValue(assignment); + if (!value) status = std::unexpected(value.error()); + else config.target.accessibility = *value; + break; + } + case OptionId::tAccW: status = sizeTo(config.target.accessibilityWindow); break; + case OptionId::tAccL: status = sizeTo(config.target.accessibilitySpan); break; + case OptionId::tAccConstr: status = stringTo(config.target.accessibilityConstraint); break; + case OptionId::tAccFile: status = stringTo(config.target.accessibilityFile); break; + case OptionId::tIntLenMax: status = sizeTo(config.target.interactionLengthMax); break; + case OptionId::tIntLoopMax: status = sizeTo(config.target.interactionLoopMax); break; + case OptionId::tRegion: status = stringTo(config.target.regions); break; + case OptionId::tRegionLenMax: status = sizeTo(config.target.regionLengthMax); break; + case OptionId::tPfScale: status = doubleTo(config.target.partitionScale); break; + + case OptionId::noSeed: { + auto value = booleanValue(assignment); + if (!value) status = std::unexpected(value.error()); + else config.seed.required = !*value; + break; + } + case OptionId::seedTQ: status = stringTo(config.seed.explicitSeeds); break; + case OptionId::seedBP: status = sizeTo(config.seed.basePairs); break; + case OptionId::seedMaxUP: status = sizeTo(config.seed.maxUnpaired); break; + case OptionId::seedQMaxUP: status = intTo(config.seed.queryMaxUnpaired); break; + case OptionId::seedTMaxUP: status = intTo(config.seed.targetMaxUnpaired); break; + case OptionId::seedMaxE: status = doubleTo(config.seed.maxEnergy); break; + case OptionId::seedMaxEhybrid: status = doubleTo(config.seed.maxHybridEnergy); break; + case OptionId::seedMinPu: status = doubleTo(config.seed.minUnpairedProbability); break; + case OptionId::seedQRange: status = stringTo(config.seed.queryRanges); break; + case OptionId::seedTRange: status = stringTo(config.seed.targetRanges); break; + case OptionId::seedNoGU: status = booleanTo(config.seed.noGu); break; + case OptionId::seedNoGUend: status = booleanTo(config.seed.noGuAtEnds); break; + + case OptionId::qShape: status = stringTo(config.query.shapeFile); break; + case OptionId::tShape: status = stringTo(config.target.shapeFile); break; + case OptionId::qShapeMethod: status = stringTo(config.query.shapeMethod); break; + case OptionId::tShapeMethod: status = stringTo(config.target.shapeMethod); break; + case OptionId::qShapeConversion: status = stringTo(config.query.shapeConversion); break; + case OptionId::tShapeConversion: status = stringTo(config.target.shapeConversion); break; + + case OptionId::mode: { + auto value = choice("HMS"); + if (!value) status = std::unexpected(value.error()); + else if (*value == 'H') config.mode = PredictionMode::heuristic; + else if (*value == 'M') config.mode = PredictionMode::exact; + else config.mode = PredictionMode::seedOnly; + break; + } + case OptionId::model: { + auto value = choice("SXBP"); + if (!value) status = std::unexpected(value.error()); + else if (*value == 'S') config.model = InteractionModel::singleSite; + else if (*value == 'X') config.model = InteractionModel::seedExtension; + else if (*value == 'B') config.model = InteractionModel::helixBlocks; + else config.model = InteractionModel::ensemble; + break; + } + case OptionId::acc: { + auto value = accessibilityValue(assignment); + if (!value) status = std::unexpected(value.error()); + else if (*value == AccessibilityKind::probabilitiesFile || + *value == AccessibilityKind::energiesFile) { + status = std::unexpected("--acc accepts only N or C at " + + originText(assignment.origin)); + } else { + config.query.accessibility = *value; + config.target.accessibility = *value; + } + break; + } + case OptionId::intLenMax: { + auto value = numericValue(assignment); + if (!value) status = std::unexpected(value.error()); + else { + config.query.interactionLengthMax = *value; + config.target.interactionLengthMax = *value; + } + break; + } + case OptionId::intLoopMax: { + auto value = numericValue(assignment); + if (!value) status = std::unexpected(value.error()); + else { + config.query.interactionLoopMax = *value; + config.target.interactionLoopMax = *value; + } + break; + } + case OptionId::accW: { + auto value = numericValue(assignment); + if (!value) status = std::unexpected(value.error()); + else { + config.query.accessibilityWindow = *value; + config.target.accessibilityWindow = *value; + } + break; + } + case OptionId::accL: { + auto value = numericValue(assignment); + if (!value) status = std::unexpected(value.error()); + else { + config.query.accessibilitySpan = *value; + config.target.accessibilitySpan = *value; + } + break; + } + case OptionId::energy: { + auto value = choice("BV"); + if (!value) status = std::unexpected(value.error()); + else config.energy = *value == 'B' ? EnergyKind::basePair : + EnergyKind::nearestNeighbor; + break; + } + case OptionId::energyVRNA: status = stringTo(config.energyParameters); break; + case OptionId::energyNoDangles: status = booleanTo(config.noDangles); break; + case OptionId::accNoLP: status = booleanTo(config.accessibilityNoLonelyPairs); break; + case OptionId::accNoGUend: status = booleanTo(config.accessibilityNoGuAtEnds); break; + case OptionId::energyAdd: status = doubleTo(config.additiveEnergy); break; + case OptionId::temperature: status = doubleTo(config.temperatureCelsius); break; + case OptionId::windowWidth: status = sizeTo(config.windowWidth); break; + case OptionId::windowOverlap: status = sizeTo(config.windowOverlap); break; + + case OptionId::helixMinBP: status = sizeTo(config.helix.minBasePairs); break; + case OptionId::helixMaxBP: status = sizeTo(config.helix.maxBasePairs); break; + case OptionId::helixMaxIL: status = sizeTo(config.helix.maxInternalLoop); break; + case OptionId::helixMinPu: status = doubleTo(config.helix.minUnpairedProbability); break; + case OptionId::helixMaxE: status = doubleTo(config.helix.maxEnergy); break; + case OptionId::helixFullE: status = booleanTo(config.helix.useFullEnergy); break; + + case OptionId::out: + if (!assignment.hasValue) { + status = std::unexpected("--out requires a value at " + + originText(assignment.origin)); + } else { + if (!outputWasSet) { + config.output.destinations.clear(); + outputWasSet = true; + } + config.output.destinations.push_back(assignment.value); + } + break; + case OptionId::outSep: + if (!assignment.hasValue || assignment.value.size() != 1U) { + status = std::unexpected("--outSep expects one character at " + + originText(assignment.origin)); + } else { + config.output.separator = assignment.value.front(); + } + break; + case OptionId::outMode: { + auto value = choice("NDCE"); + if (!value) status = std::unexpected(value.error()); + else if (*value == 'N') config.output.mode = OutputMode::normal; + else if (*value == 'D') config.output.mode = OutputMode::detailed; + else if (*value == 'C') config.output.mode = OutputMode::csv; + else config.output.mode = OutputMode::ensemble; + break; + } + case OptionId::outNumber: status = sizeTo(config.output.number); break; + case OptionId::outOverlap: { + auto value = choice("NTQB"); + if (!value) status = std::unexpected(value.error()); + else if (*value == 'N') config.output.overlap = OverlapPolicy::neither; + else if (*value == 'T') config.output.overlap = OverlapPolicy::target; + else if (*value == 'Q') config.output.overlap = OverlapPolicy::query; + else config.output.overlap = OverlapPolicy::both; + break; + } + case OptionId::outMaxE: status = doubleTo(config.output.maxEnergy); break; + case OptionId::outMinPu: status = doubleTo(config.output.minUnpairedProbability); break; + case OptionId::outDeltaE: status = doubleTo(config.output.deltaEnergy); break; + case OptionId::outBestSeedOnly: status = booleanTo(config.output.bestSeedOnly); break; + case OptionId::outNoLP: status = booleanTo(config.output.noLonelyPairs); break; + case OptionId::outNoGUend: status = booleanTo(config.output.noGuAtEnds); break; + case OptionId::outCsvCols: status = stringTo(config.output.csvColumns); break; + case OptionId::outCsvSort: status = stringTo(config.output.csvSort); break; + case OptionId::outPerRegion: status = booleanTo(config.output.perRegion); break; + case OptionId::outPairwise: status = booleanTo(config.output.pairwise); break; + case OptionId::verbose: config.verbose = true; break; + case OptionId::defaultLogFile: status = stringTo(config.logFile); break; + + case OptionId::threads: { + auto value = numericValue(assignment); + if (!value) status = std::unexpected(value.error()); + else config.threads = *value; + break; + } + case OptionId::version: config.action = RunAction::version; break; + case OptionId::personality: { + auto value = canonicalPersonality(assignment.value, assignment.origin); + if (!value) status = std::unexpected(value.error()); + else config.personality = std::move(*value); + break; + } + case OptionId::parameterFile: + if (!assignment.hasValue) { + status = std::unexpected("--parameterFile requires a value at " + + originText(assignment.origin)); + } else { + config.parameterFile = assignment.value; + config.parameterFiles.push_back(assignment.value); + } + break; + case OptionId::help: config.action = RunAction::help; break; + case OptionId::fullhelp: config.action = RunAction::fullHelp; break; + } + + if (!status) return std::unexpected(status.error()); + recordAssignment(config, assignment); + return {}; +} + +[[nodiscard]] auto effectiveOrigin( + const Config& config, + const std::string_view option) -> ConfigOrigin { + const auto found = config.provenance.find(option); + return found == config.provenance.end() ? ConfigOrigin{} : found->second; +} + +[[nodiscard]] auto citedOption( + const Config& config, + const std::string_view option) -> std::string { + return "--" + std::string(option) + " (" + + originText(effectiveOrigin(config, option)) + ")"; +} + +[[nodiscard]] auto validateShapeSide( + const Config& config, + const SideConfig& side, + const std::string_view prefix) -> std::expected { + const auto shape = std::string(prefix) + "Shape"; + const auto method = std::string(prefix) + "ShapeMethod"; + const auto conversion = std::string(prefix) + "ShapeConversion"; + if (side.shapeFile.empty() && + (!side.shapeMethod.empty() || !side.shapeConversion.empty())) { + const auto supplied = !side.shapeMethod.empty() ? method : conversion; + return std::unexpected(citedOption(config, supplied) + " requires --" + shape); + } + if (!side.shapeFile.empty() && side.accessibility != AccessibilityKind::compute) { + return std::unexpected(citedOption(config, shape) + + " requires computed accessibility (--" + + std::string(prefix) + "Acc=C)"); + } + if (!side.shapeFile.empty()) { + try { + validateShapeEncoding(side.shapeMethod, side.shapeConversion); + } catch (const std::invalid_argument& error) { + const auto supplied = !side.shapeMethod.empty() ? method : conversion; + return std::unexpected(citedOption(config, supplied) + ": " + error.what()); + } + } + return {}; +} + +[[nodiscard]] auto effectiveInteractionLength(const SideConfig& side) -> std::size_t { + const auto interaction = side.interactionLengthMax; + if (side.accessibility != AccessibilityKind::compute) return interaction; + const auto accessibility = side.accessibilityWindow; + if (interaction == 0U) return accessibility; + if (accessibility == 0U) return interaction; + return std::min(interaction, accessibility); +} + +[[nodiscard]] auto hasUnsafeWindowTracker(const Config& config) -> bool { + for (const auto& destination : config.output.destinations) { + const auto separator = destination.find(':'); + const auto prefix = lowerAscii(destination.substr(0U, separator)); + if (prefix == "qspotprob" || prefix == "tspotprob" || prefix == "spotprob") { + return true; + } + } + return false; +} + +[[nodiscard]] auto requestsEnsembleCsvColumn(const std::string_view columns) -> bool { + std::size_t begin{}; + while (begin <= columns.size()) { + const auto comma = columns.find(',', begin); + const auto end = comma == std::string_view::npos ? columns.size() : comma; + const auto column = trim(std::string(columns.substr(begin, end - begin))); + if (equalInsensitive(column, "w") || + equalInsensitive(column, "Eall") || + equalInsensitive(column, "EallTotal") || + equalInsensitive(column, "Zall") || + equalInsensitive(column, "P_E")) { + return true; + } + if (comma == std::string_view::npos) break; + begin = comma + 1U; + } + return false; +} + +[[nodiscard]] auto requestsSpotProbability(const Config& config) -> bool { + for (const auto& destination : config.output.destinations) { + const auto separator = destination.find(':'); + const auto prefix = destination.substr(0U, separator); + if (equalInsensitive(prefix, "qSpotProb") || + equalInsensitive(prefix, "tSpotProb") || + equalInsensitive(prefix, "spotProb")) { + return true; + } + } + return false; +} + +[[nodiscard]] auto validate(const Config& config) -> std::expected { + if (config.action != RunAction::predict) return {}; + if (config.query.input.empty()) return std::unexpected("missing required --query input"); + if (config.target.input.empty()) return std::unexpected("missing required --target input"); + if ((equalInsensitive(config.query.input, "STDIN") || config.query.input == "-") && + (equalInsensitive(config.target.input, "STDIN") || config.target.input == "-")) { + return std::unexpected("query and target cannot both be read from standard input"); + } + if (config.seed.required && config.seed.basePairs < 2U) { + return std::unexpected("a required seed needs at least two base pairs"); + } + if (config.mode == PredictionMode::seedOnly && + !config.seed.required && config.seed.explicitSeeds.empty()) { + return std::unexpected( + citedOption(config, "mode") + + " selects seed-only prediction but " + citedOption(config, "noSeed") + + " disables every seed and no --seedTQ was supplied"); + } + if (config.model == InteractionModel::helixBlocks && + config.mode != PredictionMode::heuristic) { + return std::unexpected( + citedOption(config, "model") + + " selects helix blocks, which require heuristic " + + citedOption(config, "mode")); + } + if (config.model == InteractionModel::seedExtension && + (config.seed.required || !config.seed.explicitSeeds.empty()) && + (config.output.mode == OutputMode::ensemble || + requestsEnsembleCsvColumn(config.output.csvColumns) || + requestsSpotProbability(config))) { + return std::unexpected( + "seeded model X has no scientifically valid partition function; " + "use model S/P or remove ensemble fields"); + } + if (config.helix.minBasePairs > config.helix.maxBasePairs) { + return std::unexpected( + citedOption(config, "helixMinBP") + " cannot exceed " + + citedOption(config, "helixMaxBP")); + } + if (!config.query.regions.empty() && config.query.regionLengthMax != 0U) { + return std::unexpected( + citedOption(config, "qRegion") + " conflicts with " + + citedOption(config, "qRegionLenMax")); + } + if (!config.target.regions.empty() && config.target.regionLengthMax != 0U) { + return std::unexpected( + citedOption(config, "tRegion") + " conflicts with " + + citedOption(config, "tRegionLenMax")); + } + if (auto status = validateShapeSide(config, config.query, "q"); !status) { + return std::unexpected(status.error()); + } + if (auto status = validateShapeSide(config, config.target, "t"); !status) { + return std::unexpected(status.error()); + } + if ((config.query.accessibility == AccessibilityKind::probabilitiesFile || + config.query.accessibility == AccessibilityKind::energiesFile) && + config.query.accessibilityFile.empty()) { + return std::unexpected(citedOption(config, "qAcc") + + " requires --qAccFile"); + } + if ((config.target.accessibility == AccessibilityKind::probabilitiesFile || + config.target.accessibility == AccessibilityKind::energiesFile) && + config.target.accessibilityFile.empty()) { + return std::unexpected(citedOption(config, "tAcc") + + " requires --tAccFile"); + } + if (config.windowWidth != 0U && config.windowWidth < 10U) { + return std::unexpected(citedOption(config, "windowWidth") + + " has to be either zero or at least 10"); + } + if (config.windowWidth != 0U && config.windowOverlap >= config.windowWidth) { + return std::unexpected(citedOption(config, "windowOverlap") + + " has to be smaller than " + + citedOption(config, "windowWidth")); + } + if (config.windowWidth != 0U) { + const auto queryLength = effectiveInteractionLength(config.query); + const auto targetLength = effectiveInteractionLength(config.target); + if (queryLength == 0U) { + return std::unexpected( + "windowed computation needs a finite query interaction length"); + } + if (targetLength == 0U) { + return std::unexpected( + "windowed computation needs a finite target interaction length"); + } + const auto requiredOverlap = std::max(queryLength, targetLength); + if (config.windowOverlap < requiredOverlap) { + return std::unexpected( + citedOption(config, "windowOverlap") + + " has to cover the maximal interaction length (at least " + + std::to_string(requiredOverlap) + ")"); + } + if (config.model == InteractionModel::ensemble || + config.output.mode == OutputMode::ensemble) { + return std::unexpected( + "ensemble/partition output is not scientifically composable across overlapping windows"); + } + if (hasUnsafeWindowTracker(config)) { + return std::unexpected( + "spot-probability trackers are not scientifically composable across overlapping windows"); + } + } + return {}; +} + +[[nodiscard]] auto groupName(const OptionGroup group) -> std::string_view { + switch (group) { + case OptionGroup::query: return "Query"; + case OptionGroup::target: return "Target"; + case OptionGroup::seed: return "Seed"; + case OptionGroup::shape: return "SHAPE"; + case OptionGroup::interaction: return "Interaction"; + case OptionGroup::helix: return "Helix"; + case OptionGroup::output: return "Output"; + case OptionGroup::general: return "General"; + } + return "Options"; +} + +[[nodiscard]] auto supportLabel(const OptionSupport support) -> std::string_view { + switch (support) { + case OptionSupport::implemented: return ""; + case OptionSupport::compatibilityOnly: return " [compatibility-only]"; + case OptionSupport::unavailable: return " [unavailable]"; + } + return ""; +} + +} // namespace + +auto Cli::parse(const std::span arguments) + -> std::expected { + return parse(arguments, {}); +} + +auto Cli::parse( + const std::span arguments, + const std::string_view invocationName) -> std::expected { + auto commandLine = tokenizeCommandLine(arguments); + if (!commandLine) return std::unexpected(commandLine.error()); + if (auto status = validateDuplicates(*commandLine); !status) { + return std::unexpected(status.error()); + } + + std::vector parameterAssignments; + for (const auto& assignment : *commandLine) { + if (assignment.option->id != OptionId::parameterFile) continue; + auto fromFile = readParameterFile(assignment.value); + if (!fromFile) { + return std::unexpected(fromFile.error() + " (requested at " + + originText(assignment.origin) + ")"); + } + parameterAssignments.insert( + parameterAssignments.end(), + std::make_move_iterator(fromFile->begin()), + std::make_move_iterator(fromFile->end())); + } + if (auto status = validateDuplicates(parameterAssignments); !status) { + return std::unexpected(status.error()); + } + + auto invocation = executablePersonality(invocationName); + if (!invocation) return std::unexpected(invocation.error()); + auto selectedPersonality = invocation->first; + auto personalityOrigin = invocation->second; + for (const auto& assignment : parameterAssignments) { + if (assignment.option->id != OptionId::personality) continue; + auto selected = canonicalPersonality(assignment.value, assignment.origin); + if (!selected) return std::unexpected(selected.error()); + selectedPersonality = std::move(*selected); + personalityOrigin = assignment.origin; + } + for (const auto& assignment : *commandLine) { + if (assignment.option->id != OptionId::personality) continue; + auto selected = canonicalPersonality(assignment.value, assignment.origin); + if (!selected) return std::unexpected(selected.error()); + selectedPersonality = std::move(*selected); + personalityOrigin = assignment.origin; + } + + Config config; + for (const auto& option : registeredOptions) { + config.provenance.emplace( + std::string(option.spec.longName), + ConfigOrigin{ConfigSource::baseline, "built-in default", 0U}); + } + applyPersonality(config, selectedPersonality, personalityOrigin); + + bool outputWasSet{}; + for (const auto& assignment : parameterAssignments) { + if (auto status = applyAssignment(config, assignment, outputWasSet); !status) { + return std::unexpected(status.error()); + } + } + for (const auto& assignment : *commandLine) { + if (auto status = applyAssignment(config, assignment, outputWasSet); !status) { + return std::unexpected(status.error()); + } + } + + if (config.threads == 0U) { + config.threads = std::max(1U, std::thread::hardware_concurrency()); + } + if (auto status = validate(config); !status) return std::unexpected(status.error()); + return config; +} + +auto Cli::optionRegistry() noexcept -> std::span { + static const std::vector publicOptions = [] { + std::vector result; + result.reserve(registeredOptions.size()); + for (const auto& option : registeredOptions) result.push_back(option.spec); + return result; + }(); + return publicOptions; +} + +auto Cli::version() -> std::string { + return "IntaRNAnew 4.0.0\nstandalone C++23 clean-room implementation\n"; +} + +auto Cli::help(const bool full) -> std::string { + std::ostringstream output; + output << "IntaRNAnew predicts RNA-RNA interactions using a standalone C++23 engine.\n" + << "Option names and documented choice values are ASCII case-insensitive.\n" + << "Precedence: built-in defaults < personality < parameter file(s) < command line.\n\n"; + + constexpr std::array groups{ + OptionGroup::query, + OptionGroup::target, + OptionGroup::seed, + OptionGroup::shape, + OptionGroup::interaction, + OptionGroup::helix, + OptionGroup::output, + OptionGroup::general, + }; + for (const auto group : groups) { + const auto hasEntries = std::ranges::any_of( + registeredOptions, [group, full](const RegisteredOption& option) { + return option.spec.group == group && (full || option.spec.basic); + }); + if (!hasEntries) continue; + output << groupName(group) << ":\n"; + for (const auto& option : registeredOptions) { + if (option.spec.group != group || (!full && !option.spec.basic)) continue; + std::ostringstream spelling; + if (option.spec.shortName != '\0') { + spelling << '-' << option.spec.shortName << ", "; + } else { + spelling << " "; + } + spelling << "--" << option.spec.longName; + if (option.spec.valueMode == OptionValueMode::required) { + spelling << " <" << option.spec.valueName << '>'; + } else if (option.spec.valueMode == OptionValueMode::optionalBoolean) { + spelling << "[=BOOL]"; + } + output << " " << std::left << std::setw(37) << spelling.str() + << option.spec.description << supportLabel(option.spec.support); + if (!option.spec.defaultValue.empty()) { + output << " (default: " << option.spec.defaultValue << ')'; + } + if (option.spec.repeatable) output << " (repeatable)"; + output << '\n'; + } + output << '\n'; + } + if (!full) output << "Use --fullhelp to list all registered compatibility options.\n"; + if (full) { + output << "Compatibility-only options are parsed, range-checked, recorded in Config, " + "and otherwise have no standalone runtime side effect.\n"; + } + return output.str(); +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/compression.cpp b/IntaRNAnew/src/compression.cpp new file mode 100644 index 0000000..4129431 --- /dev/null +++ b/IntaRNAnew/src/compression.cpp @@ -0,0 +1,622 @@ +#include "intarnanew/compression.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +constexpr std::uint8_t gzipId1{0x1fU}; +constexpr std::uint8_t gzipId2{0x8bU}; +constexpr std::uint8_t deflateMethod{8U}; + +[[nodiscard]] constexpr auto makeCrcTable() noexcept -> std::array { + std::array table{}; + for (std::uint32_t value = 0U; value < table.size(); ++value) { + auto remainder = value; + for (unsigned bit = 0U; bit < 8U; ++bit) { + remainder = (remainder >> 1U) ^ + ((remainder & 1U) != 0U ? 0xedb88320U : 0U); + } + table[value] = remainder; + } + return table; +} + +constexpr auto crcTable = makeCrcTable(); + +[[nodiscard]] auto crc32Range( + const std::string_view bytes, + std::uint32_t state = 0xffffffffU) noexcept -> std::uint32_t { + for (const unsigned char byte : bytes) { + state = crcTable[(state ^ byte) & 0xffU] ^ (state >> 8U); + } + return state; +} + +[[nodiscard]] auto byteAt(const std::string_view bytes, const std::size_t position) noexcept + -> std::uint8_t { + return static_cast(static_cast(bytes[position])); +} + +[[nodiscard]] auto little16(const std::string_view bytes, const std::size_t position) noexcept + -> std::uint16_t { + return static_cast(byteAt(bytes, position)) | + static_cast(static_cast(byteAt(bytes, position + 1U)) << 8U); +} + +[[nodiscard]] auto little32(const std::string_view bytes, const std::size_t position) noexcept + -> std::uint32_t { + return static_cast(byteAt(bytes, position)) | + (static_cast(byteAt(bytes, position + 1U)) << 8U) | + (static_cast(byteAt(bytes, position + 2U)) << 16U) | + (static_cast(byteAt(bytes, position + 3U)) << 24U); +} + +void append16(std::string& output, const std::uint16_t value) { + output.push_back(static_cast(value & 0xffU)); + output.push_back(static_cast((value >> 8U) & 0xffU)); +} + +void append32(std::string& output, const std::uint32_t value) { + output.push_back(static_cast(value & 0xffU)); + output.push_back(static_cast((value >> 8U) & 0xffU)); + output.push_back(static_cast((value >> 16U) & 0xffU)); + output.push_back(static_cast((value >> 24U) & 0xffU)); +} + +class BitReader { +public: + BitReader(const std::string_view bytes, const std::size_t start) + : bytes_(bytes), bytePosition_(start) {} + + [[nodiscard]] auto read(const unsigned count) -> std::optional { + if (count > 24U) return std::nullopt; + while (bufferedBits_ < count) { + if (bytePosition_ >= bytes_.size()) return std::nullopt; + bitBuffer_ |= static_cast(byteAt(bytes_, bytePosition_)) << bufferedBits_; + bufferedBits_ += 8U; + ++bytePosition_; + } + const auto mask = count == 0U ? 0U : (std::uint64_t{1U} << count) - 1U; + const auto result = static_cast(bitBuffer_ & mask); + bitBuffer_ >>= count; + bufferedBits_ -= count; + return result; + } + + void alignToByte() noexcept { + bitBuffer_ = 0U; + bufferedBits_ = 0U; + } + + [[nodiscard]] auto bytePosition() const noexcept -> std::size_t { + return bytePosition_; + } + + [[nodiscard]] auto bitPosition() const noexcept -> unsigned { + return bufferedBits_ == 0U ? 0U : 8U - bufferedBits_; + } + +private: + std::string_view bytes_; + std::size_t bytePosition_{}; + std::uint64_t bitBuffer_{}; + unsigned bufferedBits_{}; +}; + +class HuffmanTree { +public: + [[nodiscard]] static auto build( + const std::span lengths, + const unsigned permittedMaximum, + const bool permitEmpty, + const bool permitSingleSymbol) -> std::expected { + HuffmanTree result; + result.counts_.assign(permittedMaximum + 1U, 0U); + for (const auto length : lengths) { + if (length > permittedMaximum) { + return std::unexpected("DEFLATE Huffman code length exceeds its limit"); + } + if (length != 0U) ++result.counts_[length]; + } + + unsigned symbolCount{}; + for (unsigned length = 1U; length <= permittedMaximum; ++length) { + symbolCount += result.counts_[length]; + } + if (symbolCount == 0U) { + if (permitEmpty) return result; + return std::unexpected("DEFLATE Huffman alphabet has no symbols"); + } + + int available{1}; + for (unsigned length = 1U; length <= permittedMaximum; ++length) { + available = available * 2 - static_cast(result.counts_[length]); + if (available < 0) { + return std::unexpected("DEFLATE Huffman alphabet is oversubscribed"); + } + } + if (available != 0 && + !(permitSingleSymbol && symbolCount == 1U && result.counts_[1U] == 1U)) { + return std::unexpected("DEFLATE Huffman alphabet is incomplete"); + } + + result.firstCodes_.assign(permittedMaximum + 1U, 0U); + result.firstOffsets_.assign(permittedMaximum + 1U, 0U); + unsigned code{}; + unsigned offset{}; + for (unsigned length = 1U; length <= permittedMaximum; ++length) { + code = (code + result.counts_[length - 1U]) << 1U; + result.firstCodes_[length] = code; + result.firstOffsets_[length] = offset; + offset += result.counts_[length]; + } + result.symbols_.resize(symbolCount); + auto nextOffset = result.firstOffsets_; + for (std::size_t symbol = 0U; symbol < lengths.size(); ++symbol) { + const auto length = lengths[symbol]; + if (length == 0U) continue; + result.symbols_[nextOffset[length]++] = static_cast(symbol); + result.maximumLength_ = std::max(result.maximumLength_, static_cast(length)); + } + return result; + } + + [[nodiscard]] auto decode(BitReader& input) const -> std::expected { + if (maximumLength_ == 0U) { + return std::unexpected("DEFLATE distance alphabet is empty but a match was requested"); + } + unsigned code{}; + for (unsigned length = 1U; length <= maximumLength_; ++length) { + const auto bit = input.read(1U); + if (!bit) return std::unexpected("truncated DEFLATE Huffman code"); + code = (code << 1U) | *bit; + if (code >= firstCodes_[length]) { + const auto relative = code - firstCodes_[length]; + if (relative < counts_[length]) { + return symbols_[firstOffsets_[length] + relative]; + } + } + } + return std::unexpected("invalid DEFLATE Huffman code"); + } + +private: + std::vector counts_; + std::vector firstCodes_; + std::vector firstOffsets_; + std::vector symbols_; + unsigned maximumLength_{}; +}; + +struct DeflateTrees { + HuffmanTree literals; + HuffmanTree distances; +}; + +[[nodiscard]] auto fixedTrees() -> std::expected { + std::array literalLengths{}; + std::fill(literalLengths.begin(), literalLengths.begin() + 144, 8U); + std::fill(literalLengths.begin() + 144, literalLengths.begin() + 256, 9U); + std::fill(literalLengths.begin() + 256, literalLengths.begin() + 280, 7U); + std::fill(literalLengths.begin() + 280, literalLengths.end(), 8U); + std::array distanceLengths{}; + distanceLengths.fill(5U); + auto literals = HuffmanTree::build(literalLengths, 15U, false, false); + if (!literals) return std::unexpected(literals.error()); + auto distances = HuffmanTree::build(distanceLengths, 15U, false, false); + if (!distances) return std::unexpected(distances.error()); + return DeflateTrees{std::move(*literals), std::move(*distances)}; +} + +[[nodiscard]] auto dynamicTrees(BitReader& input) -> std::expected { + const auto rawLiteralCount = input.read(5U); + const auto rawDistanceCount = input.read(5U); + const auto rawCodeLengthCount = input.read(4U); + if (!rawLiteralCount || !rawDistanceCount || !rawCodeLengthCount) { + return std::unexpected("truncated dynamic DEFLATE header"); + } + const auto literalCount = static_cast(*rawLiteralCount) + 257U; + const auto distanceCount = static_cast(*rawDistanceCount) + 1U; + const auto codeLengthCount = static_cast(*rawCodeLengthCount) + 4U; + if (*rawLiteralCount > 29U) { + return std::unexpected("dynamic DEFLATE header declares reserved literal/length symbols"); + } + + constexpr std::array order{ + 16U, 17U, 18U, 0U, 8U, 7U, 9U, 6U, 10U, 5U, + 11U, 4U, 12U, 3U, 13U, 2U, 14U, 1U, 15U, + }; + std::array codeLengths{}; + for (std::size_t index = 0U; index < codeLengthCount; ++index) { + const auto length = input.read(3U); + if (!length) return std::unexpected("truncated dynamic DEFLATE code-length alphabet"); + codeLengths[order[index]] = static_cast(*length); + } + auto codeLengthTree = HuffmanTree::build(codeLengths, 7U, false, false); + if (!codeLengthTree) { + return std::unexpected("invalid dynamic DEFLATE code-length alphabet: " + + codeLengthTree.error()); + } + + std::vector lengths; + lengths.reserve(literalCount + distanceCount); + while (lengths.size() < literalCount + distanceCount) { + auto symbol = codeLengthTree->decode(input); + if (!symbol) return std::unexpected(symbol.error()); + if (*symbol <= 15U) { + lengths.push_back(static_cast(*symbol)); + continue; + } + + std::size_t repeat{}; + std::uint8_t value{}; + if (*symbol == 16U) { + if (lengths.empty()) { + return std::unexpected("dynamic DEFLATE repeat has no preceding code length"); + } + const auto extra = input.read(2U); + if (!extra) return std::unexpected("truncated dynamic DEFLATE repeat"); + repeat = static_cast(*extra) + 3U; + value = lengths.back(); + } else if (*symbol == 17U) { + const auto extra = input.read(3U); + if (!extra) return std::unexpected("truncated dynamic DEFLATE zero repeat"); + repeat = static_cast(*extra) + 3U; + } else if (*symbol == 18U) { + const auto extra = input.read(7U); + if (!extra) return std::unexpected("truncated dynamic DEFLATE long zero repeat"); + repeat = static_cast(*extra) + 11U; + } else { + return std::unexpected("invalid dynamic DEFLATE code-length symbol"); + } + if (repeat > literalCount + distanceCount - lengths.size()) { + return std::unexpected("dynamic DEFLATE code-length repeat exceeds its alphabets"); + } + lengths.insert(lengths.end(), repeat, value); + } + + if (lengths[256U] == 0U) { + return std::unexpected("dynamic DEFLATE literal alphabet omits end-of-block"); + } + auto literals = HuffmanTree::build( + std::span{lengths.data(), literalCount}, 15U, false, true); + if (!literals) { + return std::unexpected("invalid dynamic DEFLATE literal alphabet: " + literals.error()); + } + auto distances = HuffmanTree::build( + std::span{lengths.data() + literalCount, distanceCount}, + 15U, true, true); + if (!distances) { + return std::unexpected("invalid dynamic DEFLATE distance alphabet: " + distances.error()); + } + return DeflateTrees{std::move(*literals), std::move(*distances)}; +} + +constexpr std::array lengthBase{ + 3U, 4U, 5U, 6U, 7U, 8U, 9U, 10U, + 11U, 13U, 15U, 17U, 19U, 23U, 27U, 31U, + 35U, 43U, 51U, 59U, 67U, 83U, 99U, 115U, + 131U, 163U, 195U, 227U, 258U, +}; +constexpr std::array lengthExtra{ + 0U, 0U, 0U, 0U, 0U, 0U, 0U, 0U, + 1U, 1U, 1U, 1U, 2U, 2U, 2U, 2U, + 3U, 3U, 3U, 3U, 4U, 4U, 4U, 4U, + 5U, 5U, 5U, 5U, 0U, +}; +constexpr std::array distanceBase{ + 1U, 2U, 3U, 4U, 5U, 7U, 9U, 13U, + 17U, 25U, 33U, 49U, 65U, 97U, 129U, 193U, + 257U, 385U, 513U, 769U, 1025U, 1537U, 2049U, 3073U, + 4097U, 6145U, 8193U, 12289U, 16385U, 24577U, +}; +constexpr std::array distanceExtra{ + 0U, 0U, 0U, 0U, 1U, 1U, 2U, 2U, + 3U, 3U, 4U, 4U, 5U, 5U, 6U, 6U, + 7U, 7U, 8U, 8U, 9U, 9U, 10U, 10U, + 11U, 11U, 12U, 12U, 13U, 13U, +}; + +[[nodiscard]] auto appendDecodedByte( + std::string& output, + const char value, + const GzipLimits& limits) -> std::expected { + if (output.size() >= limits.maxDecompressedBytes) { + return std::unexpected("gzip output exceeds the configured decompressed-byte limit"); + } + output.push_back(value); + return {}; +} + +[[nodiscard]] auto decodeCompressedBlock( + BitReader& input, + const DeflateTrees& trees, + std::string& output, + const std::size_t memberStart, + const GzipLimits& limits) -> std::expected { + while (true) { + auto literal = trees.literals.decode(input); + if (!literal) return std::unexpected(literal.error()); + if (*literal < 256U) { + auto status = appendDecodedByte(output, static_cast(*literal), limits); + if (!status) return status; + continue; + } + if (*literal == 256U) return {}; + if (*literal < 257U || *literal > 285U) { + return std::unexpected("invalid DEFLATE length symbol"); + } + const auto lengthIndex = *literal - 257U; + auto length = lengthBase[lengthIndex]; + if (const auto extraCount = lengthExtra[lengthIndex]; extraCount != 0U) { + const auto extra = input.read(extraCount); + if (!extra) return std::unexpected("truncated DEFLATE match length"); + length += *extra; + } + + auto distanceSymbol = trees.distances.decode(input); + if (!distanceSymbol) return std::unexpected(distanceSymbol.error()); + if (*distanceSymbol >= distanceBase.size()) { + return std::unexpected("invalid DEFLATE distance symbol"); + } + auto distance = distanceBase[*distanceSymbol]; + if (const auto extraCount = distanceExtra[*distanceSymbol]; extraCount != 0U) { + const auto extra = input.read(extraCount); + if (!extra) return std::unexpected("truncated DEFLATE match distance"); + distance += *extra; + } + const auto producedInMember = output.size() - memberStart; + if (distance == 0U || distance > producedInMember) { + return std::unexpected("DEFLATE match distance precedes the current member"); + } + if (length > limits.maxDecompressedBytes - output.size()) { + return std::unexpected("gzip output exceeds the configured decompressed-byte limit"); + } + for (unsigned index = 0U; index < length; ++index) { + output.push_back(output[output.size() - distance]); + } + } +} + +[[nodiscard]] auto decodeDeflate( + const std::string_view bytes, + const std::size_t start, + std::string& output, + const std::size_t memberStart, + const GzipLimits& limits) -> std::expected { + BitReader input(bytes, start); + bool finalBlock{}; + std::size_t blockCount{}; + while (!finalBlock) { + if (++blockCount > limits.maxDeflateBlocks) { + return std::unexpected("gzip member exceeds the configured DEFLATE block limit"); + } + const auto final = input.read(1U); + const auto type = input.read(2U); + if (!final || !type) return std::unexpected("truncated DEFLATE block header"); + finalBlock = *final != 0U; + if (*type == 0U) { + input.alignToByte(); + const auto position = input.bytePosition(); + if (position > bytes.size() || bytes.size() - position < 4U) { + return std::unexpected("truncated stored DEFLATE block header"); + } + const auto length = little16(bytes, position); + const auto inverse = little16(bytes, position + 2U); + if (static_cast(length ^ 0xffffU) != inverse) { + return std::unexpected("stored DEFLATE block has inconsistent LEN/NLEN"); + } + const auto payload = position + 4U; + if (payload > bytes.size() || bytes.size() - payload < length) { + return std::unexpected("truncated stored DEFLATE block payload"); + } + if (static_cast(length) > limits.maxDecompressedBytes - output.size()) { + return std::unexpected("gzip output exceeds the configured decompressed-byte limit"); + } + output.append(bytes.substr(payload, length)); + input = BitReader(bytes, payload + length); + } else if (*type == 1U) { + static const auto fixed = fixedTrees(); + if (!fixed) return std::unexpected(fixed.error()); + auto status = decodeCompressedBlock( + input, *fixed, output, memberStart, limits); + if (!status) return std::unexpected(status.error()); + } else if (*type == 2U) { + auto trees = dynamicTrees(input); + if (!trees) return std::unexpected(trees.error()); + auto status = decodeCompressedBlock( + input, *trees, output, memberStart, limits); + if (!status) return std::unexpected(status.error()); + } else { + return std::unexpected("DEFLATE block uses the reserved block type"); + } + } + if (input.bitPosition() != 0U) input.alignToByte(); + return input.bytePosition(); +} + +[[nodiscard]] auto advanceZeroTerminated( + const std::string_view bytes, + std::size_t& position, + const std::size_t headerStart, + const GzipLimits& limits, + const std::string_view fieldName) -> std::expected { + while (position < bytes.size() && byteAt(bytes, position) != 0U) { + ++position; + if (position - headerStart > limits.maxHeaderBytes) { + return std::unexpected("gzip header exceeds the configured header-byte limit"); + } + } + if (position >= bytes.size()) { + return std::unexpected("truncated gzip " + std::string(fieldName)); + } + ++position; + return {}; +} + +[[nodiscard]] auto parseHeader( + const std::string_view bytes, + const std::size_t memberStart, + const GzipLimits& limits) -> std::expected { + if (memberStart > bytes.size() || bytes.size() - memberStart < 10U) { + return std::unexpected("truncated gzip member header"); + } + if (byteAt(bytes, memberStart) != gzipId1 || byteAt(bytes, memberStart + 1U) != gzipId2) { + return std::unexpected("gzip member is missing the 1f 8b signature"); + } + if (byteAt(bytes, memberStart + 2U) != deflateMethod) { + return std::unexpected("gzip member uses an unsupported compression method"); + } + const auto flags = byteAt(bytes, memberStart + 3U); + if ((flags & 0xe0U) != 0U) { + return std::unexpected("gzip member sets reserved header flags"); + } + std::size_t position = memberStart + 10U; + if ((flags & 0x04U) != 0U) { + if (bytes.size() - position < 2U) { + return std::unexpected("truncated gzip extra-field length"); + } + const auto extraLength = static_cast(little16(bytes, position)); + position += 2U; + if (position > bytes.size() || bytes.size() - position < extraLength) { + return std::unexpected("truncated gzip extra field"); + } + position += extraLength; + } + if ((flags & 0x08U) != 0U) { + auto status = advanceZeroTerminated( + bytes, position, memberStart, limits, "original filename"); + if (!status) return std::unexpected(status.error()); + } + if ((flags & 0x10U) != 0U) { + auto status = advanceZeroTerminated(bytes, position, memberStart, limits, "comment"); + if (!status) return std::unexpected(status.error()); + } + if (position - memberStart > limits.maxHeaderBytes) { + return std::unexpected("gzip header exceeds the configured header-byte limit"); + } + if ((flags & 0x02U) != 0U) { + if (position > bytes.size() || bytes.size() - position < 2U) { + return std::unexpected("truncated gzip header CRC"); + } + const auto expected = little16(bytes, position); + const auto computed = static_cast( + (crc32Range(bytes.substr(memberStart, position - memberStart)) ^ 0xffffffffU) & 0xffffU); + if (computed != expected) return std::unexpected("gzip header CRC mismatch"); + position += 2U; + } + if (position - memberStart > limits.maxHeaderBytes) { + return std::unexpected("gzip header exceeds the configured header-byte limit"); + } + return position; +} + +} // namespace + +auto hasGzipMagic(const std::string_view bytes) noexcept -> bool { + return bytes.size() >= 2U && byteAt(bytes, 0U) == gzipId1 && byteAt(bytes, 1U) == gzipId2; +} + +auto crc32(const std::string_view bytes) noexcept -> std::uint32_t { + return crc32Range(bytes) ^ 0xffffffffU; +} + +auto gzipDecompress(const std::string_view bytes, const GzipLimits& limits) + -> std::expected { + if (bytes.empty()) return std::unexpected("gzip input is empty"); + if (bytes.size() > limits.maxCompressedBytes) { + return std::unexpected("gzip input exceeds the configured compressed-byte limit"); + } + if (limits.maxMembers == 0U) { + return std::unexpected("gzip member limit is zero"); + } + + std::string output; + output.reserve(std::min(bytes.size() * (bytes.size() <= limits.maxDecompressedBytes / 2U ? 2U : 1U), + limits.maxDecompressedBytes)); + std::size_t position{}; + std::size_t memberCount{}; + while (position < bytes.size()) { + if (++memberCount > limits.maxMembers) { + return std::unexpected("gzip input exceeds the configured member limit"); + } + const auto memberStart = position; + auto deflateStart = parseHeader(bytes, memberStart, limits); + if (!deflateStart) { + return std::unexpected("gzip member " + std::to_string(memberCount) + ": " + + deflateStart.error()); + } + const auto outputStart = output.size(); + auto trailerStart = decodeDeflate(bytes, *deflateStart, output, outputStart, limits); + if (!trailerStart) { + return std::unexpected("gzip member " + std::to_string(memberCount) + ": " + + trailerStart.error()); + } + if (*trailerStart > bytes.size() || bytes.size() - *trailerStart < 8U) { + return std::unexpected("gzip member " + std::to_string(memberCount) + + ": truncated trailer"); + } + const auto expectedCrc = little32(bytes, *trailerStart); + const auto expectedSize = little32(bytes, *trailerStart + 4U); + const std::string_view memberOutput(output.data() + outputStart, output.size() - outputStart); + if (crc32(memberOutput) != expectedCrc) { + return std::unexpected("gzip member " + std::to_string(memberCount) + + ": payload CRC32 mismatch"); + } + if (static_cast(memberOutput.size() & 0xffffffffU) != expectedSize) { + return std::unexpected("gzip member " + std::to_string(memberCount) + + ": uncompressed size mismatch"); + } + position = *trailerStart + 8U; + if (position < bytes.size() && !hasGzipMagic(bytes.substr(position))) { + return std::unexpected("trailing bytes after gzip member are not another member"); + } + } + return output; +} + +auto gzipCompress(const std::string_view bytes) -> std::expected { + constexpr std::size_t blockMaximum{65535U}; + const auto blocks = std::max( + 1U, bytes.size() / blockMaximum + (bytes.size() % blockMaximum != 0U ? 1U : 0U)); + constexpr std::size_t wrapperBytes{18U}; + if (bytes.size() > std::numeric_limits::max() - wrapperBytes) { + return std::unexpected("gzip output size is not representable"); + } + if (blocks > (std::numeric_limits::max() - wrapperBytes - bytes.size()) / 5U) { + return std::unexpected("gzip output size is not representable"); + } + std::string output; + output.reserve(wrapperBytes + bytes.size() + blocks * 5U); + output.push_back(static_cast(gzipId1)); + output.push_back(static_cast(gzipId2)); + output.push_back(static_cast(deflateMethod)); + output.push_back('\0'); + append32(output, 0U); + output.push_back('\0'); + output.push_back(static_cast(0xffU)); + + std::size_t position{}; + do { + const auto length = std::min(blockMaximum, bytes.size() - position); + const auto final = position + length == bytes.size(); + output.push_back(final ? '\x01' : '\0'); + append16(output, static_cast(length)); + append16(output, static_cast(static_cast(length) ^ 0xffffU)); + output.append(bytes.substr(position, length)); + position += length; + } while (position < bytes.size()); + + append32(output, crc32(bytes)); + append32(output, static_cast(bytes.size() & 0xffffffffU)); + return output; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/energy.cpp b/IntaRNAnew/src/energy.cpp new file mode 100644 index 0000000..d8163ad --- /dev/null +++ b/IntaRNAnew/src/energy.cpp @@ -0,0 +1,292 @@ +#include "intarnanew/energy.hpp" + +#include "thermo_parameters.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +// ViennaRNA uses GASCONST = 1.98717 cal/(K mol). +constexpr Energy viennaGasConstantKcal = 0.00198717; + +enum class PairType : std::size_t { cg, gc, gu, ug, au, ua, ambiguous }; + +[[nodiscard]] auto pairType(const char target, const char query) noexcept -> PairType { + const auto left = static_cast(std::toupper(static_cast(target))); + const auto right = static_cast(std::toupper(static_cast(query))); + if (left == 'C' && right == 'G') return PairType::cg; + if (left == 'G' && right == 'C') return PairType::gc; + if (left == 'G' && right == 'U') return PairType::gu; + if (left == 'U' && right == 'G') return PairType::ug; + if (left == 'A' && right == 'U') return PairType::au; + if (left == 'U' && right == 'A') return PairType::ua; + return PairType::ambiguous; +} + +[[nodiscard]] constexpr auto pairIndex(const PairType type) noexcept -> std::size_t { + return static_cast(type); +} + +[[nodiscard]] constexpr auto reversePair(const PairType type) noexcept -> PairType { + constexpr std::array reversed{ + PairType::gc, PairType::cg, PairType::ug, PairType::gu, + PairType::ua, PairType::au, PairType::ambiguous, + }; + return reversed[pairIndex(type)]; +} + +[[nodiscard]] auto baseIndex(const char nucleotide) noexcept -> std::size_t { + switch (static_cast(std::toupper(static_cast(nucleotide)))) { + case 'A': return 1U; + case 'C': return 2U; + case 'G': return 3U; + case 'U': return 4U; + default: return 0U; + } +} + +[[nodiscard]] auto canonicalBaseIndex(const char nucleotide) noexcept -> std::optional { + switch (static_cast(std::toupper(static_cast(nucleotide)))) { + case 'A': return 0U; + case 'C': return 1U; + case 'G': return 2U; + case 'U': return 3U; + default: return std::nullopt; + } +} + +[[nodiscard]] auto stackValue(const detail::NearestNeighborParameters& parameters, + const PairType outer, const PairType inner) noexcept -> Energy { + return parameters.stack[pairIndex(outer) * 7U + pairIndex(inner)]; +} + +[[nodiscard]] auto mismatchValue(const std::vector& table, const PairType type, + const char first, const char second) noexcept -> Energy { + return table[(pairIndex(type) * 5U + baseIndex(first)) * 5U + baseIndex(second)]; +} + +[[nodiscard]] auto int11Value(const detail::NearestNeighborParameters& parameters, + const PairType outer, const PairType inner, + const char targetBase, const char queryBase) noexcept -> Energy { + const auto index = (((pairIndex(outer) * 7U + pairIndex(inner)) * 5U + + baseIndex(targetBase)) * 5U + baseIndex(queryBase)); + return parameters.int11[index]; +} + +[[nodiscard]] auto int21Value(const detail::NearestNeighborParameters& parameters, + const PairType outer, const PairType inner, + const char first, const char second, const char third) noexcept -> Energy { + const auto index = ((((pairIndex(outer) * 7U + pairIndex(inner)) * 5U + + baseIndex(first)) * 5U + baseIndex(second)) * 5U + baseIndex(third)); + return parameters.int21[index]; +} + +[[nodiscard]] auto int22Value(const detail::NearestNeighborParameters& parameters, + const PairType outer, const PairType inner, + const char first, const char second, + const char third, const char fourth) noexcept -> std::optional { + if (pairIndex(outer) >= 6U || pairIndex(inner) >= 6U) return std::nullopt; + const auto firstIndex = canonicalBaseIndex(first); + const auto secondIndex = canonicalBaseIndex(second); + const auto thirdIndex = canonicalBaseIndex(third); + const auto fourthIndex = canonicalBaseIndex(fourth); + if (!firstIndex || !secondIndex || !thirdIndex || !fourthIndex) return std::nullopt; + const auto index = (((((pairIndex(outer) * 6U + pairIndex(inner)) * 4U + *firstIndex) * 4U + + *secondIndex) * 4U + *thirdIndex) * 4U + *fourthIndex); + return parameters.int22[index]; +} + +[[nodiscard]] auto dangleValue(const std::vector& table, const PairType type, + const char nucleotide) noexcept -> Energy { + const auto value = table[pairIndex(type) * 5U + baseIndex(nucleotide)]; + return std::isfinite(value) ? std::min(0.0, value) : 0.0; +} + +[[nodiscard]] auto loopInitiation(const std::vector& table, const Index size, + const Energy logarithmicSlope) noexcept -> Energy { + if (size < table.size()) return table[size]; + return table.back() + logarithmicSlope * + std::log(static_cast(size) / static_cast(table.size() - 1U)); +} + +[[nodiscard]] auto validPath(const std::span pairs) noexcept -> bool { + if (pairs.empty()) return false; + for (Index index = 1U; index < pairs.size(); ++index) { + if (pairs[index - 1U].target >= pairs[index].target || + pairs[index - 1U].query <= pairs[index].query) return false; + } + return true; +} + +[[nodiscard]] constexpr auto weakPair(const PairType type) noexcept -> bool { + return type == PairType::gu || type == PairType::ug || + type == PairType::au || type == PairType::ua; +} + +} // namespace + +BasePairEnergyModel::BasePairEnergyModel(const double temperatureCelsius) { + static_cast(temperatureCelsius); +} + +auto BasePairEnergyModel::evaluate( + const Sequence&, + const Sequence&, + const std::span pairs) const -> EnergyBreakdown { + if (!validPath(pairs)) throw std::invalid_argument("interaction base pairs are not a monotone antiparallel path"); + EnergyBreakdown result; + result.initiation = initiationEnergy(); + result.loops = -static_cast(pairs.size() - 1U); + return result; +} + +auto BasePairEnergyModel::transitionEnergy( + const Sequence&, + const Sequence&, + const BasePair, + const BasePair) const -> Energy { + return -1.0; +} + +NearestNeighborEnergyModel::NearestNeighborEnergyModel( + const double temperatureCelsius, + const std::string_view parameterSet, + const bool includeDangles) + : rt_(viennaGasConstantKcal * (temperatureCelsius + 273.15)), + includeDangles_(includeDangles), + parameters_(detail::loadNearestNeighborParameters(temperatureCelsius, parameterSet)) { + initiation_ = parameters_->duplexInit; +} + +auto NearestNeighborEnergyModel::transitionEnergy( + const Sequence& target, + const Sequence& query, + const BasePair left, + const BasePair right) const -> Energy { + if (left.target >= right.target || left.query <= right.query) { + throw std::invalid_argument("invalid interaction transition orientation"); + } + const auto targetGap = right.target - left.target - 1U; + const auto queryGap = left.query - right.query - 1U; + const auto outer = pairType(target[left.target], query[left.query]); + const auto inner = reversePair(pairType(target[right.target], query[right.query])); + if (targetGap == 0U && queryGap == 0U) { + return stackValue(*parameters_, outer, inner); + } + + const auto total = targetGap + queryGap; + if (targetGap == 0U || queryGap == 0U) { + auto energy = loopInitiation(parameters_->bulge, total, parameters_->logarithmicLoopSlope); + if (total == 1U) { + energy += stackValue(*parameters_, outer, inner); + } else { + if (weakPair(outer)) energy += parameters_->terminalAu; + if (weakPair(inner)) energy += parameters_->terminalAu; + } + return energy; + } + + const auto targetOuter = target[left.target + 1U]; + const auto targetInner = target[right.target - 1U]; + const auto queryOuter = query[left.query - 1U]; + const auto queryInner = query[right.query + 1U]; + if (targetGap == 1U && queryGap == 1U) { + return int11Value(*parameters_, outer, inner, targetOuter, queryOuter); + } + if (targetGap == 1U && queryGap == 2U) { + return int21Value(*parameters_, outer, inner, targetOuter, queryInner, queryOuter); + } + if (targetGap == 2U && queryGap == 1U) { + return int21Value(*parameters_, inner, outer, queryInner, targetOuter, targetInner); + } + if (targetGap == 2U && queryGap == 2U) { + if (const auto exact = int22Value(*parameters_, outer, inner, + targetOuter, targetInner, queryInner, queryOuter)) { + return *exact; + } + } + + auto energy = loopInitiation(parameters_->internal, total, parameters_->logarithmicLoopSlope); + const auto asymmetry = targetGap > queryGap ? targetGap - queryGap : queryGap - targetGap; + energy += std::min(parameters_->ninioMaximum, + parameters_->ninioSlope * static_cast(asymmetry)); + const std::vector* mismatch = ¶meters_->mismatchInternal; + if (targetGap == 1U || queryGap == 1U) { + mismatch = ¶meters_->mismatchInternal1n; + } else if ((targetGap == 2U && queryGap == 3U) || + (targetGap == 3U && queryGap == 2U)) { + mismatch = ¶meters_->mismatchInternal23; + } + energy += mismatchValue(*mismatch, outer, targetOuter, queryOuter); + energy += mismatchValue(*mismatch, inner, queryInner, targetInner); + return energy; +} + +auto NearestNeighborEnergyModel::terminalPenalty(const char target, const char query) const noexcept -> Energy { + return weakPair(pairType(target, query)) ? parameters_->terminalAu : 0.0; +} + +auto NearestNeighborEnergyModel::evaluate( + const Sequence& target, + const Sequence& query, + const std::span pairs) const -> EnergyBreakdown { + if (!validPath(pairs)) throw std::invalid_argument("interaction base pairs are not a monotone antiparallel path"); + EnergyBreakdown result; + result.initiation = initiation_; + for (Index index = 1U; index < pairs.size(); ++index) { + result.loops += transitionEnergy(target, query, pairs[index - 1U], pairs[index]); + } + const auto leftType = pairType(target[pairs.front().target], query[pairs.front().query]); + const auto rightType = reversePair(pairType(target[pairs.back().target], query[pairs.back().query])); + result.endLeft = terminalPenalty(target[pairs.front().target], query[pairs.front().query]); + result.endRight = terminalPenalty(target[pairs.back().target], query[pairs.back().query]); + + if (includeDangles_) { + const auto& left = pairs.front(); + const auto& right = pairs.back(); + const bool hasLeftTarget = left.target > 0U; + const bool hasLeftQuery = left.query + 1U < query.size(); + const bool hasRightTarget = right.target + 1U < target.size(); + const bool hasRightQuery = right.query > 0U; + if (hasLeftTarget && hasLeftQuery) { + result.dangleLeft = std::min(0.0, + mismatchValue(parameters_->mismatchExterior, leftType, + target[left.target - 1U], query[left.query + 1U])); + } else if (hasLeftTarget) { + result.dangleLeft = std::min(result.dangleLeft, + dangleValue(parameters_->dangle5, leftType, target[left.target - 1U])); + } else if (hasLeftQuery) { + result.dangleLeft = std::min(result.dangleLeft, + dangleValue(parameters_->dangle3, leftType, query[left.query + 1U])); + } + if (hasRightTarget && hasRightQuery) { + result.dangleRight = std::min(0.0, + mismatchValue(parameters_->mismatchExterior, rightType, + query[right.query - 1U], target[right.target + 1U])); + } else if (hasRightTarget) { + result.dangleRight = std::min(result.dangleRight, + dangleValue(parameters_->dangle3, rightType, target[right.target + 1U])); + } else if (hasRightQuery) { + result.dangleRight = std::min(result.dangleRight, + dangleValue(parameters_->dangle5, rightType, query[right.query - 1U])); + } + } + return result; +} + +auto makeEnergyModel(const Config& config) -> std::unique_ptr { + if (config.energy == EnergyKind::basePair) { + return std::make_unique(config.temperatureCelsius); + } + return std::make_unique( + config.temperatureCelsius, config.energyParameters, !config.noDangles); +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/folding.cpp b/IntaRNAnew/src/folding.cpp new file mode 100644 index 0000000..9a655f6 --- /dev/null +++ b/IntaRNAnew/src/folding.cpp @@ -0,0 +1,945 @@ +#include "intarnanew/folding.hpp" + +#include "folding_parameters.hpp" +#include "noncrossing_partition.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +using folding_detail::FoldingParameters; +inline constexpr double logZero = -std::numeric_limits::infinity(); +inline constexpr Index minimumHairpinUnpaired = 3U; + +enum class PairType : std::size_t { cg, gc, gu, ug, au, ua, ambiguous }; + +struct ShapeTerms { + std::vector unpaired; + std::vector paired; + std::vector stack; +}; + +struct ShapeEncoding { + char method{'D'}; + double first{}; + double second{}; + char conversion{'O'}; + double conversionFirst{}; + double conversionSecond{}; +}; + +[[nodiscard]] auto logAdd(const double left, const double right) noexcept -> double { + if (left == logZero) return right; + if (right == logZero) return left; + const auto high = std::max(left, right); + return high + std::log1p(std::exp(std::min(left, right) - high)); +} + +[[nodiscard]] auto pairType(const char left, const char right) noexcept -> PairType { + const auto first = static_cast(std::toupper(static_cast(left))); + const auto second = static_cast(std::toupper(static_cast(right))); + if (first == 'C' && second == 'G') return PairType::cg; + if (first == 'G' && second == 'C') return PairType::gc; + if (first == 'G' && second == 'U') return PairType::gu; + if (first == 'U' && second == 'G') return PairType::ug; + if (first == 'A' && second == 'U') return PairType::au; + if (first == 'U' && second == 'A') return PairType::ua; + return PairType::ambiguous; +} + +[[nodiscard]] constexpr auto pairIndex(const PairType type) noexcept -> std::size_t { + return static_cast(type); +} + +[[nodiscard]] constexpr auto reversePair(const PairType type) noexcept -> PairType { + constexpr std::array reverse{ + PairType::gc, PairType::cg, PairType::ug, PairType::gu, + PairType::ua, PairType::au, PairType::ambiguous, + }; + return reverse[pairIndex(type)]; +} + +[[nodiscard]] constexpr auto weakPair(const PairType type) noexcept -> bool { + return type == PairType::gu || type == PairType::ug || + type == PairType::au || type == PairType::ua; +} + +[[nodiscard]] constexpr auto guPair(const PairType type) noexcept -> bool { + return type == PairType::gu || type == PairType::ug; +} + +[[nodiscard]] auto baseIndex(const char base) noexcept -> std::size_t { + switch (static_cast(std::toupper(static_cast(base)))) { + case 'A': return 1U; + case 'C': return 2U; + case 'G': return 3U; + case 'U': return 4U; + default: return 0U; + } +} + +[[nodiscard]] auto canonicalBaseIndex(const char base) noexcept -> std::optional { + switch (static_cast(std::toupper(static_cast(base)))) { + case 'A': return 0U; + case 'C': return 1U; + case 'G': return 2U; + case 'U': return 3U; + default: return std::nullopt; + } +} + +[[nodiscard]] auto tableStack(const FoldingParameters& parameters, + const PairType outer, const PairType inner) noexcept -> Energy { + return parameters.stack[pairIndex(outer) * 7U + pairIndex(inner)]; +} + +[[nodiscard]] auto tableMismatch(const std::vector& table, const PairType type, + const char first, const char second) noexcept -> Energy { + return table[(pairIndex(type) * 5U + baseIndex(first)) * 5U + baseIndex(second)]; +} + +[[nodiscard]] auto tableDangle(const std::vector& table, const PairType type, + const char nucleotide) noexcept -> Energy { + const auto value = table[pairIndex(type) * 5U + baseIndex(nucleotide)]; + return std::isfinite(value) ? std::min(0.0, value) : 0.0; +} + +[[nodiscard]] auto tableInt11(const FoldingParameters& parameters, const PairType outer, + const PairType inner, const char first, const char second) noexcept -> Energy { + const auto index = (((pairIndex(outer) * 7U + pairIndex(inner)) * 5U + baseIndex(first)) * 5U + + baseIndex(second)); + return parameters.int11[index]; +} + +[[nodiscard]] auto tableInt21(const FoldingParameters& parameters, const PairType outer, + const PairType inner, const char first, const char second, + const char third) noexcept -> Energy { + const auto index = ((((pairIndex(outer) * 7U + pairIndex(inner)) * 5U + baseIndex(first)) * 5U + + baseIndex(second)) * 5U + baseIndex(third)); + return parameters.int21[index]; +} + +[[nodiscard]] auto tableInt22(const FoldingParameters& parameters, const PairType outer, + const PairType inner, const char first, const char second, + const char third, const char fourth) noexcept -> std::optional { + if (pairIndex(outer) >= 6U || pairIndex(inner) >= 6U) return std::nullopt; + const auto a = canonicalBaseIndex(first); + const auto b = canonicalBaseIndex(second); + const auto c = canonicalBaseIndex(third); + const auto d = canonicalBaseIndex(fourth); + if (!a || !b || !c || !d) return std::nullopt; + const auto index = (((((pairIndex(outer) * 6U + pairIndex(inner)) * 4U + *a) * 4U + *b) * 4U + + *c) * 4U + *d); + return parameters.int22[index]; +} + +[[nodiscard]] auto loopInitiation(const std::vector& table, const Index size, + const Energy logarithmicSlope) noexcept -> Energy { + if (size < table.size()) return table[size]; + return table.back() + logarithmicSlope * + std::log(static_cast(size) / static_cast(table.size() - 1U)); +} + +[[nodiscard]] auto parseConstraint(const Sequence& sequence, const std::string_view encoding) + -> std::vector { + std::vector result(sequence.size(), '.'); + if (encoding.empty()) return result; + if (encoding.size() == sequence.size() && encoding.find(':') == std::string_view::npos) { + for (Index index{}; index < sequence.size(); ++index) { + const auto symbol = static_cast(std::tolower(static_cast(encoding[index]))); + if (symbol != '.' && symbol != 'x' && symbol != 'p' && symbol != 'b') { + throw std::invalid_argument("invalid accessibility constraint symbol"); + } + result[index] = symbol; + } + return result; + } + + char kind{}; + std::size_t cursor{}; + while (cursor < encoding.size()) { + while (cursor < encoding.size() && + (encoding[cursor] == ',' || std::isspace(static_cast(encoding[cursor])) != 0)) { + ++cursor; + } + if (cursor >= encoding.size()) break; + if (cursor + 1U >= encoding.size() || encoding[cursor + 1U] != ':') { + throw std::invalid_argument("each accessibility range requires an x:, p:, or b: prefix"); + } + kind = static_cast(std::tolower(static_cast(encoding[cursor]))); + if (kind != 'x' && kind != 'p' && kind != 'b') { + throw std::invalid_argument("invalid range accessibility constraint type"); + } + cursor += 2U; + while (true) { + const auto start = cursor; + while (cursor < encoding.size() && encoding[cursor] != ',') ++cursor; + auto token = encoding.substr(start, cursor - start); + while (!token.empty() && std::isspace(static_cast(token.back())) != 0) { + token.remove_suffix(1U); + } + const auto dash = token.find('-', 1U); + if (dash == std::string_view::npos) { + throw std::invalid_argument("constraint range must use FROM-TO encoding"); + } + long long first{}; + long long last{}; + const auto firstText = token.substr(0U, dash); + const auto lastText = token.substr(dash + 1U); + const auto firstResult = std::from_chars(firstText.data(), firstText.data() + firstText.size(), first); + const auto lastResult = std::from_chars(lastText.data(), lastText.data() + lastText.size(), last); + if (firstResult.ec != std::errc{} || firstResult.ptr != firstText.data() + firstText.size() || + lastResult.ec != std::errc{} || lastResult.ptr != lastText.data() + lastText.size() || + first > last) { + throw std::invalid_argument("invalid accessibility constraint range"); + } + const auto internalFirst = sequence.internalIndex(first); + const auto internalLast = sequence.internalIndex(last); + if (!internalFirst || !internalLast) { + throw std::invalid_argument("accessibility constraint range is outside the sequence"); + } + std::fill(result.begin() + static_cast(*internalFirst), + result.begin() + static_cast(*internalLast + 1U), kind); + if (cursor >= encoding.size()) break; + ++cursor; + std::size_t lookahead = cursor; + while (lookahead < encoding.size() && + std::isspace(static_cast(encoding[lookahead])) != 0) ++lookahead; + if (lookahead + 1U < encoding.size() && encoding[lookahead + 1U] == ':') { + cursor = lookahead; + break; + } + } + } + return result; +} + +[[nodiscard]] auto parseFloating(const std::string_view text, std::size_t& cursor, + const std::string_view context) -> double { + const std::string owned(text.substr(cursor)); + char* end{}; + errno = 0; + const auto value = std::strtod(owned.c_str(), &end); + if (end == owned.c_str() || errno == ERANGE || !std::isfinite(value)) { + throw std::invalid_argument("invalid numeric value in " + std::string(context)); + } + cursor += static_cast(end - owned.c_str()); + return value; +} + +[[nodiscard]] auto parseTagged(const std::string_view text, const char kind, + const std::string_view tags, std::array defaults, + const std::string_view context) -> std::array { + if (text.empty()) return defaults; + if (static_cast(std::toupper(static_cast(text.front()))) != kind) { + throw std::invalid_argument("invalid " + std::string(context) + " encoding"); + } + std::array seen{}; + std::size_t cursor{1U}; + while (cursor < text.size()) { + const auto tag = static_cast(std::tolower(static_cast(text[cursor++]))); + const auto position = tags.find(tag); + if (position == std::string_view::npos || position >= defaults.size() || seen[position]) { + throw std::invalid_argument("invalid or duplicate tag in " + std::string(context)); + } + seen[position] = true; + defaults[position] = parseFloating(text, cursor, context); + } + return defaults; +} + +[[nodiscard]] auto parseShapeEncoding(const std::string_view methodText, + const std::string_view conversionText) -> ShapeEncoding { + ShapeEncoding result; + const auto method = methodText.empty() + ? 'D' : static_cast(std::toupper(static_cast(methodText.front()))); + result.method = method; + if (method == 'D') { + const auto values = parseTagged(methodText.empty() ? std::string_view{"D"} : methodText, + 'D', "mb", {1.8, -0.6}, "SHAPE method"); + result.first = values[0U]; + result.second = values[1U]; + } else if (method == 'Z') { + const auto values = parseTagged(methodText, 'Z', "b_", {0.89, 0.0}, "SHAPE method"); + result.first = values[0U]; + } else if (method == 'W') { + if (methodText.size() != 1U) throw std::invalid_argument("SHAPE method W takes no parameters"); + } else { + throw std::invalid_argument("SHAPE method must be D, Z, or W"); + } + + const auto conversion = conversionText.empty() + ? 'O' : static_cast(std::toupper(static_cast(conversionText.front()))); + result.conversion = conversion; + if (conversion == 'M' || conversion == 'S') { + if (!conversionText.empty() && conversionText.size() != 1U) { + throw std::invalid_argument("SHAPE conversion M/S takes no parameters"); + } + } else if (conversion == 'C') { + std::size_t cursor{1U}; + result.conversionFirst = conversionText.size() == 1U + ? 0.25 : parseFloating(conversionText, cursor, "SHAPE conversion"); + if (cursor != conversionText.size() || result.conversionFirst < 0.0) { + throw std::invalid_argument("invalid SHAPE cutoff conversion"); + } + } else if (conversion == 'L') { + const auto values = parseTagged(conversionText, 'L', "si", {0.68, 0.2}, "SHAPE conversion"); + result.conversionFirst = values[0U]; + result.conversionSecond = values[1U]; + } else if (conversion == 'O') { + const auto values = parseTagged(conversionText.empty() ? std::string_view{"O"} : conversionText, + 'O', "si", {1.6, -2.29}, "SHAPE conversion"); + result.conversionFirst = values[0U]; + result.conversionSecond = values[1U]; + } else { + throw std::invalid_argument("SHAPE conversion must be M, C, S, L, or O"); + } + if (method != 'Z' && !conversionText.empty()) { + throw std::invalid_argument("SHAPE conversion is only applicable to method Z"); + } + return result; +} + +[[nodiscard]] auto readShapeValues(const Sequence& sequence, const std::string& path) + -> std::vector> { + if (path == "STDIN" || path == "-") { + throw std::invalid_argument("SHAPE data from STDIN requires an explicit input stream"); + } + std::ifstream input(path); + if (!input) throw std::invalid_argument("cannot open SHAPE data file '" + path + "'"); + std::vector> result(sequence.size()); + std::string line; + std::size_t lineNumber{}; + while (std::getline(input, line)) { + ++lineNumber; + const auto first = line.find_first_not_of(" \t\r"); + if (first == std::string::npos || line[first] == '#') continue; + std::istringstream fields(line); + long long position{}; + char nucleotide{}; + double value{}; + std::string extra; + if (!(fields >> position >> nucleotide >> value) || (fields >> extra) || !std::isfinite(value)) { + throw std::invalid_argument("malformed SHAPE row " + std::to_string(lineNumber)); + } + const auto internal = sequence.internalIndex(position); + // Local folding windows consume a full-sequence SHAPE file. Rows that + // belong to another window are intentionally ignored. + if (!internal) continue; + const auto observed = static_cast(std::toupper(static_cast(nucleotide))); + if (observed != sequence[*internal]) { + throw std::invalid_argument("SHAPE nucleotide does not match the RNA sequence at position " + + std::to_string(position)); + } + if (result[*internal]) throw std::invalid_argument("duplicate SHAPE position " + std::to_string(position)); + if (value > -999.0) { + result[*internal] = value; + } + } + if (!input.eof()) throw std::invalid_argument("failed while reading SHAPE data file"); + return result; +} + +[[nodiscard]] auto shapeTerms(const Sequence& sequence, const FoldingOptions& options) -> ShapeTerms { + ShapeTerms result{ + std::vector(sequence.size()), + std::vector(sequence.size()), + std::vector(sequence.size()), + }; + if (options.shapeFile.empty()) { + if (!options.shapeMethod.empty() || !options.shapeConversion.empty()) { + throw std::invalid_argument("SHAPE method/conversion requires a SHAPE data file"); + } + return result; + } + const auto encoding = parseShapeEncoding(options.shapeMethod, options.shapeConversion); + const auto values = readShapeValues(sequence, options.shapeFile); + for (Index index{}; index < sequence.size(); ++index) { + if (!values[index]) continue; + const auto value = *values[index]; + if (encoding.method == 'D') { + if (value < 0.0) throw std::invalid_argument("Deigan SHAPE reactivities must be nonnegative"); + result.stack[index] = encoding.first * std::log(value + 1.0) + encoding.second; + } else if (encoding.method == 'W') { + result.unpaired[index] = value; + } else { + if (value < 0.0) throw std::invalid_argument("Zarringhalam SHAPE reactivities must be nonnegative"); + double probability{}; + switch (encoding.conversion) { + case 'M': + // Piecewise-linear Zarringhalam mapping (public ViennaRNA + // SHAPE strategy specification). + if (value < 0.25) probability = 1.4 * value; + else if (value < 0.30) probability = 0.35 + 4.0 * (value - 0.25); + else if (value < 0.70) probability = 0.55 + 0.75 * (value - 0.30); + else probability = 0.85 + 0.5 * (value - 0.70); + break; + case 'C': probability = value >= encoding.conversionFirst ? 1.0 : 0.0; break; + case 'S': probability = value; break; + case 'L': probability = encoding.conversionFirst * value + encoding.conversionSecond; break; + case 'O': probability = (std::log(std::max(value, std::numeric_limits::min())) - + encoding.conversionSecond) / encoding.conversionFirst; break; + default: throw std::logic_error("validated SHAPE conversion is unreachable"); + } + probability = std::clamp(probability, 0.0, 1.0); + result.unpaired[index] = encoding.first * probability; + result.paired[index] = encoding.first * (1.0 - probability); + } + } + return result; +} + +class BasePairFoldingEnsemble final : public FoldingEnsemble { +public: + BasePairFoldingEnsemble(const Sequence& sequence, FoldingOptions options) + : sequence_(sequence.str()), + options_(std::move(options)), + constraints_(parseConstraint(sequence, options_.constraint)), + span_(options_.maximumPairSpan == 0U + ? sequence.size() : std::min(options_.maximumPairSpan, sequence.size())), + cache_(sequence.size() * (sequence.size() + 1U), + std::numeric_limits::quiet_NaN()) { + if (!options_.shapeFile.empty() || !options_.shapeMethod.empty() || + !options_.shapeConversion.empty()) { + throw std::invalid_argument("SHAPE pseudo-energies require the nearest-neighbour folding model"); + } + if (!std::isfinite(options_.partitionScale) || options_.partitionScale < 1.0) { + throw std::invalid_argument("partition scale must be finite and at least 1"); + } + if (!options_.noLonelyPairs && !options_.noGuHelixEnds) { + auto partitions = detail::computeNoncrossingIntervalPartitions( + sequence_.size(), + [this](const Index position) { return constraints_[position] != 'p'; }, + [this](const Index left, const Index right) { + return pairAllowed(left, right, std::nullopt) ? 1.0 : logZero; + }); + logPartition_ = partitions.logPartition; + cache_ = std::move(partitions.probabilities); + } else { + // noLP/noGUend make pair admissibility depend on an outer/inner + // stack state. Preserve the stateful recurrence for these modes. + logPartition_ = compute(std::nullopt); + } + if (!std::isfinite(logPartition_)) { + throw std::invalid_argument("folding constraints admit no secondary structure"); + } + } + + [[nodiscard]] auto logPartition() const noexcept -> double override { return logPartition_; } + [[nodiscard]] auto ensembleFreeEnergy() const noexcept -> Energy override { return -logPartition_; } + + [[nodiscard]] auto jointUnpairedProbability(const Interval interval) const -> double override { + if (interval.begin > interval.end || interval.end >= sequence_.size()) { + throw std::out_of_range("folding interval is out of range"); + } + for (Index position = interval.begin; position <= interval.end; ++position) { + if (constraints_[position] == 'p') return 0.0; + } + const auto offset = interval.begin * (sequence_.size() + 1U) + interval.size(); + { + const std::scoped_lock lock(cacheMutex_); + if (!std::isnan(cache_[offset])) return cache_[offset]; + } + const auto constrained = compute(interval); + const auto probability = constrained == logZero + ? 0.0 : std::clamp(std::exp(std::min(0.0, constrained - logPartition_)), 0.0, 1.0); + { + const std::scoped_lock lock(cacheMutex_); + cache_[offset] = probability; + } + return probability; + } + +private: + [[nodiscard]] auto matrixOffset(const Index left, const Index right) const noexcept -> Index { + return left * sequence_.size() + right; + } + + [[nodiscard]] auto intervalOffset(const Index begin, const Index end) const noexcept -> Index { + return begin * (sequence_.size() + 1U) + end; + } + + [[nodiscard]] auto pairAllowed(const Index left, const Index right, + const std::optional forcedUnpaired) const noexcept -> bool { + return right >= left + minimumHairpinUnpaired + 1U && + right - left + 1U <= span_ && + !(forcedUnpaired && (forcedUnpaired->contains(left) || forcedUnpaired->contains(right))) && + constraints_[left] != 'x' && constraints_[left] != 'b' && + constraints_[right] != 'x' && constraints_[right] != 'b' && + canPair(sequence_[left], sequence_[right]); + } + + [[nodiscard]] auto compute(const std::optional forcedUnpaired) const -> double { + if (forcedUnpaired) { + for (Index position = forcedUnpaired->begin; position <= forcedUnpaired->end; ++position) { + if (constraints_[position] == 'p') return logZero; + } + } + const auto length = sequence_.size(); + std::vector partition((length + 1U) * (length + 1U), logZero); + std::vector pairAny(length * length, logZero); + std::vector pairInnerStack(length * length, logZero); + for (Index index{}; index <= length; ++index) partition[intervalOffset(index, index)] = 0.0; + + const auto selectedPair = [&](const Index left, const Index right, + const bool outerStacked) noexcept -> double { + const auto any = pairAny[matrixOffset(left, right)]; + const auto stacked = pairInnerStack[matrixOffset(left, right)]; + if (options_.noGuHelixEnds && isGuPair(sequence_[left], sequence_[right])) { + return outerStacked ? stacked : logZero; + } + if (options_.noLonelyPairs && !outerStacked) return stacked; + return any; + }; + + for (Index width = 1U; width <= length; ++width) { + if (width >= minimumHairpinUnpaired + 2U) { + for (Index left{}; left + width <= length; ++left) { + const auto right = left + width - 1U; + if (!pairAllowed(left, right, forcedUnpaired)) continue; + const auto inside = partition[intervalOffset(left + 1U, right)]; + if (inside != logZero) pairAny[matrixOffset(left, right)] = 1.0 + inside; + if (left + 1U < right && pairAllowed(left + 1U, right - 1U, forcedUnpaired)) { + const auto child = selectedPair(left + 1U, right - 1U, true); + if (child != logZero) pairInnerStack[matrixOffset(left, right)] = 1.0 + child; + } + } + } + + for (Index begin{}; begin + width <= length; ++begin) { + const auto end = begin + width; + const auto last = end - 1U; + auto value = constraints_[last] == 'p' + ? logZero : partition[intervalOffset(begin, last)]; + if (forcedUnpaired && forcedUnpaired->contains(last)) { + partition[intervalOffset(begin, end)] = value; + continue; + } + for (Index partner = begin; partner + minimumHairpinUnpaired + 1U <= last; ++partner) { + const auto pair = selectedPair(partner, last, false); + if (pair == logZero) continue; + value = logAdd(value, partition[intervalOffset(begin, partner)] + pair); + } + partition[intervalOffset(begin, end)] = value; + } + } + return partition[intervalOffset(0U, length)]; + } + + std::string sequence_; + FoldingOptions options_; + std::vector constraints_; + Index span_{}; + double logPartition_{}; + mutable std::vector cache_; + mutable std::mutex cacheMutex_; +}; + +class TurnerFoldingEnsemble final : public FoldingEnsemble { +public: + TurnerFoldingEnsemble(const Sequence& sequence, FoldingOptions options) + : sequence_(sequence.str()), + options_(std::move(options)), + constraints_(parseConstraint(sequence, options_.constraint)), + shape_(shapeTerms(sequence, options_)), + parameters_(folding_detail::loadFoldingParameters( + options_.temperatureCelsius, options_.parameterSet)), + rt_(gasConstantKcal * (options_.temperatureCelsius + 273.15)), + span_(options_.maximumPairSpan == 0U + ? sequence.size() : std::min(options_.maximumPairSpan, sequence.size())), + cache_(sequence.size() * (sequence.size() + 1U), + std::numeric_limits::quiet_NaN()) { + if (!std::isfinite(options_.partitionScale) || options_.partitionScale < 1.0) { + throw std::invalid_argument("partition scale must be finite and at least 1"); + } + if (options_.maximumInternalLoop > 30U) { + throw std::invalid_argument("the Turner parameter model supports internal loops up to 30 bases"); + } + logPartition_ = compute(std::nullopt); + if (!std::isfinite(logPartition_)) { + throw std::invalid_argument("folding constraints admit no secondary structure"); + } + } + + [[nodiscard]] auto logPartition() const noexcept -> double override { return logPartition_; } + [[nodiscard]] auto ensembleFreeEnergy() const noexcept -> Energy override { + return -rt_ * logPartition_; + } + + [[nodiscard]] auto jointUnpairedProbability(const Interval interval) const -> double override { + if (interval.begin > interval.end || interval.end >= sequence_.size()) { + throw std::out_of_range("folding interval is out of range"); + } + for (Index position = interval.begin; position <= interval.end; ++position) { + if (constraints_[position] == 'p') return 0.0; + } + const auto offset = interval.begin * (sequence_.size() + 1U) + interval.size(); + { + const std::scoped_lock lock(cacheMutex_); + if (!std::isnan(cache_[offset])) return cache_[offset]; + } + const auto constrained = compute(interval); + const auto probability = constrained == logZero + ? 0.0 : std::clamp(std::exp(std::min(0.0, constrained - logPartition_)), 0.0, 1.0); + { + const std::scoped_lock lock(cacheMutex_); + cache_[offset] = probability; + } + return probability; + } + +private: + [[nodiscard]] auto matrixOffset(const Index left, const Index right) const noexcept -> Index { + return left * sequence_.size() + right; + } + + [[nodiscard]] auto intervalOffset(const Index begin, const Index end) const noexcept -> Index { + return begin * (sequence_.size() + 1U) + end; + } + + [[nodiscard]] auto forced(const Index position, const std::optional interval) const noexcept -> bool { + return interval && interval->contains(position); + } + + [[nodiscard]] auto unpairedAllowed(const Index position, + const std::optional) const noexcept -> bool { + return constraints_[position] != 'p'; + } + + [[nodiscard]] auto pairAllowed(const Index left, const Index right, + const std::optional interval) const noexcept -> bool { + return right > left && right - left + 1U <= span_ && + !forced(left, interval) && !forced(right, interval) && + constraints_[left] != 'x' && constraints_[left] != 'b' && + constraints_[right] != 'x' && constraints_[right] != 'b' && + canPair(sequence_[left], sequence_[right]); + } + + [[nodiscard]] auto allUnpairedAllowed(const Index begin, const Index end, + const std::optional interval) const noexcept -> bool { + for (Index position = begin; position < end; ++position) { + if (!unpairedAllowed(position, interval)) return false; + } + return true; + } + + [[nodiscard]] auto unpairedEnergy(const Index begin, const Index end, + const Energy thermodynamicPerBase = 0.0) const noexcept -> Energy { + Energy result = thermodynamicPerBase * static_cast(end - begin); + for (Index position = begin; position < end; ++position) result += shape_.unpaired[position]; + return result; + } + + [[nodiscard]] auto pairPseudoEnergy(const Index left, const Index right) const noexcept -> Energy { + return shape_.paired[left] + shape_.paired[right]; + } + + [[nodiscard]] auto stackPseudoEnergy(const Index outerLeft, const Index outerRight, + const Index innerLeft, const Index innerRight) const noexcept -> Energy { + return shape_.stack[outerLeft] + shape_.stack[outerRight] + + shape_.stack[innerLeft] + shape_.stack[innerRight]; + } + + [[nodiscard]] auto terminalPenalty(const PairType type) const noexcept -> Energy { + return weakPair(type) ? parameters_->terminalAu : 0.0; + } + + [[nodiscard]] auto hairpinEnergy(const Index left, const Index right) const noexcept -> Energy { + const auto length = right - left - 1U; + const auto motif = sequence_.substr(left, right - left + 1U); + if (const auto special = parameters_->specialHairpins.find(motif); + special != parameters_->specialHairpins.end()) { + return special->second; + } + const auto type = pairType(sequence_[left], sequence_[right]); + auto result = loopInitiation(parameters_->hairpin, length, parameters_->logarithmicLoopSlope); + if (length == 3U) { + result += terminalPenalty(type); + } else { + result += tableMismatch(parameters_->mismatchHairpin, type, + sequence_[left + 1U], sequence_[right - 1U]); + } + return result; + } + + [[nodiscard]] auto interiorEnergy(const Index outerLeft, const Index outerRight, + const Index innerLeft, const Index innerRight) const noexcept -> Energy { + const auto leftGap = innerLeft - outerLeft - 1U; + const auto rightGap = outerRight - innerRight - 1U; + const auto outer = pairType(sequence_[outerLeft], sequence_[outerRight]); + const auto inner = reversePair(pairType(sequence_[innerLeft], sequence_[innerRight])); + if (leftGap == 0U && rightGap == 0U) return tableStack(*parameters_, outer, inner); + const auto total = leftGap + rightGap; + if (leftGap == 0U || rightGap == 0U) { + auto result = loopInitiation(parameters_->bulge, total, parameters_->logarithmicLoopSlope); + if (total == 1U) { + result += tableStack(*parameters_, outer, inner); + } else { + result += terminalPenalty(outer) + terminalPenalty(inner); + } + return result; + } + const auto outerLeftBase = sequence_[outerLeft + 1U]; + const auto outerRightBase = sequence_[outerRight - 1U]; + const auto innerLeftBase = sequence_[innerLeft - 1U]; + const auto innerRightBase = sequence_[innerRight + 1U]; + if (leftGap == 1U && rightGap == 1U) { + return tableInt11(*parameters_, outer, inner, outerLeftBase, outerRightBase); + } + if (leftGap == 1U && rightGap == 2U) { + return tableInt21(*parameters_, outer, inner, + outerLeftBase, innerRightBase, outerRightBase); + } + if (leftGap == 2U && rightGap == 1U) { + return tableInt21(*parameters_, inner, outer, + innerRightBase, outerLeftBase, innerLeftBase); + } + if (leftGap == 2U && rightGap == 2U) { + if (const auto exact = tableInt22(*parameters_, outer, inner, + outerLeftBase, innerLeftBase, + innerRightBase, outerRightBase)) { + return *exact; + } + } + auto result = loopInitiation(parameters_->internal, total, parameters_->logarithmicLoopSlope); + const auto asymmetry = leftGap > rightGap ? leftGap - rightGap : rightGap - leftGap; + result += std::min(parameters_->ninioMaximum, + parameters_->ninioSlope * static_cast(asymmetry)); + const std::vector* mismatch = ¶meters_->mismatchInternal; + if (leftGap == 1U || rightGap == 1U) mismatch = ¶meters_->mismatchInternal1n; + else if ((leftGap == 2U && rightGap == 3U) || + (leftGap == 3U && rightGap == 2U)) mismatch = ¶meters_->mismatchInternal23; + result += tableMismatch(*mismatch, outer, outerLeftBase, outerRightBase); + result += tableMismatch(*mismatch, inner, innerRightBase, innerLeftBase); + return result; + } + + [[nodiscard]] auto compute(const std::optional interval) const -> double { + const auto length = sequence_.size(); + if (interval) { + for (Index position = interval->begin; position <= interval->end; ++position) { + if (constraints_[position] == 'p') return logZero; + } + } + std::vector pairAny(length * length, logZero); + std::vector pairInnerStack(length * length, logZero); + const auto intervalDimension = length + 1U; + std::vector multiZero(intervalDimension * intervalDimension, logZero); + std::vector multiOne(intervalDimension * intervalDimension, logZero); + std::vector multiTwo(intervalDimension * intervalDimension, logZero); + for (Index index{}; index <= length; ++index) { + multiZero[intervalOffset(index, index)] = 0.0; + } + + const auto selectedPair = [&](const Index left, const Index right, + const bool outerStacked) -> double { + const auto type = pairType(sequence_[left], sequence_[right]); + if (options_.noGuHelixEnds && guPair(type)) { + // A GU pair is internal to a helix only when it has both the + // already-known outer neighbour and an immediate inner stack. + return outerStacked ? pairInnerStack[matrixOffset(left, right)] : logZero; + } + if (options_.noLonelyPairs && !outerStacked) { + return pairInnerStack[matrixOffset(left, right)]; + } + return pairAny[matrixOffset(left, right)]; + }; + const auto boltzmannLog = [this](const Energy energy) noexcept -> double { + return std::isfinite(energy) ? -energy / rt_ : logZero; + }; + + for (Index width = 1U; width <= length; ++width) { + if (width >= minimumHairpinUnpaired + 2U) { + for (Index left{}; left + width <= length; ++left) { + const auto right = left + width - 1U; + if (!pairAllowed(left, right, interval)) continue; + const auto outerType = pairType(sequence_[left], sequence_[right]); + const auto pairSoft = pairPseudoEnergy(left, right); + auto any = logZero; + auto stackOnly = logZero; + + if ((!options_.noGuHelixEnds || !guPair(outerType)) && + allUnpairedAllowed(left + 1U, right, interval)) { + const auto energy = hairpinEnergy(left, right) + pairSoft + + unpairedEnergy(left + 1U, right); + any = logAdd(any, boltzmannLog(energy)); + } + + for (Index leftGap{}; leftGap <= options_.maximumInternalLoop; ++leftGap) { + const auto innerLeft = left + 1U + leftGap; + if (innerLeft >= right) break; + for (Index rightGap{}; leftGap + rightGap <= options_.maximumInternalLoop; ++rightGap) { + const auto innerRight = right - 1U - rightGap; + if (innerLeft >= innerRight) break; + if (!allUnpairedAllowed(left + 1U, innerLeft, interval) || + !allUnpairedAllowed(innerRight + 1U, right, interval)) continue; + const bool isStack = leftGap == 0U && rightGap == 0U; + if (options_.noGuHelixEnds && guPair(outerType) && !isStack) continue; + const auto child = selectedPair(innerLeft, innerRight, isStack); + if (child == logZero) continue; + auto energy = interiorEnergy(left, right, innerLeft, innerRight) + pairSoft + + unpairedEnergy(left + 1U, innerLeft) + + unpairedEnergy(innerRight + 1U, right); + if (isStack) { + energy += stackPseudoEnergy(left, right, innerLeft, innerRight); + } + const auto term = child + boltzmannLog(energy); + any = logAdd(any, term); + if (isStack) stackOnly = logAdd(stackOnly, term); + } + } + + if (!options_.noGuHelixEnds || !guPair(outerType)) { + const auto content = multiTwo[intervalOffset(left + 1U, right)]; + if (content != logZero) { + const auto closingType = reversePair(outerType); + auto energy = parameters_->multiloopClosing + parameters_->multiloopStem + + terminalPenalty(closingType) + pairSoft; + if (options_.includeDangles) { + energy += tableMismatch( + parameters_->mismatchMulti, closingType, + sequence_[right - 1U], sequence_[left + 1U]); + } + any = logAdd(any, content + boltzmannLog(energy)); + } + } + pairAny[matrixOffset(left, right)] = any; + pairInnerStack[matrixOffset(left, right)] = stackOnly; + } + } + + for (Index begin{}; begin + width <= length; ++begin) { + const auto end = begin + width; + auto zero = logZero; + auto one = logZero; + auto two = logZero; + if (unpairedAllowed(begin, interval)) { + const auto unpaired = boltzmannLog(parameters_->multiloopUnpaired + shape_.unpaired[begin]); + zero = multiZero[intervalOffset(begin + 1U, end)] + unpaired; + if (multiOne[intervalOffset(begin + 1U, end)] != logZero) { + one = multiOne[intervalOffset(begin + 1U, end)] + unpaired; + } + if (multiTwo[intervalOffset(begin + 1U, end)] != logZero) { + two = multiTwo[intervalOffset(begin + 1U, end)] + unpaired; + } + } + for (Index right = begin + minimumHairpinUnpaired + 1U; right < end; ++right) { + const auto pair = selectedPair(begin, right, false); + if (pair == logZero) continue; + const auto type = pairType(sequence_[begin], sequence_[right]); + auto branchEnergy = parameters_->multiloopStem + terminalPenalty(type); + if (options_.includeDangles) { + // Vienna's dangle-2 model decorates a multiloop stem + // from its direct sequence neighbours even when a + // neighbour is the endpoint of an adjacent stem. + if (begin > 0U && right + 1U < length) { + branchEnergy += tableMismatch( + parameters_->mismatchMulti, type, + sequence_[begin - 1U], sequence_[right + 1U]); + } else if (begin > 0U) { + branchEnergy += tableDangle( + parameters_->dangle5, type, sequence_[begin - 1U]); + } else if (right + 1U < length) { + branchEnergy += tableDangle( + parameters_->dangle3, type, sequence_[right + 1U]); + } + } + const auto branch = pair + boltzmannLog(branchEnergy); + const auto suffixZero = multiZero[intervalOffset(right + 1U, end)]; + if (suffixZero != logZero) one = logAdd(one, branch + suffixZero); + const auto suffixAtLeastOne = logAdd(multiOne[intervalOffset(right + 1U, end)], + multiTwo[intervalOffset(right + 1U, end)]); + if (suffixAtLeastOne != logZero) two = logAdd(two, branch + suffixAtLeastOne); + } + multiZero[intervalOffset(begin, end)] = zero; + multiOne[intervalOffset(begin, end)] = one; + multiTwo[intervalOffset(begin, end)] = two; + } + } + + // Exterior structures have a unique left-to-right tokenization. In + // the dangle-2 model each exterior stem is decorated from its direct + // sequence neighbours, irrespective of whether a neighbour is + // unpaired or is the endpoint of an adjacent stem. + std::vector exterior(length + 1U, logZero); + exterior[0U] = 0.0; + for (Index cursor{}; cursor < length; ++cursor) { + const auto source = exterior[cursor]; + if (source == logZero) continue; + if (unpairedAllowed(cursor, interval)) { + exterior[cursor + 1U] = logAdd( + exterior[cursor + 1U], source + boltzmannLog(shape_.unpaired[cursor])); + } + for (Index right = cursor + minimumHairpinUnpaired + 1U; right < length; ++right) { + const auto pair = selectedPair(cursor, right, false); + if (pair == logZero) continue; + const auto type = pairType(sequence_[cursor], sequence_[right]); + auto branchEnergy = terminalPenalty(type); + if (options_.includeDangles) { + const bool hasLeft = cursor > 0U; + const bool hasRight = right + 1U < length; + if (hasLeft && hasRight) { + branchEnergy += tableMismatch( + parameters_->mismatchExterior, type, + sequence_[cursor - 1U], sequence_[right + 1U]); + } else if (hasLeft) { + branchEnergy += tableDangle( + parameters_->dangle5, type, sequence_[cursor - 1U]); + } else if (hasRight) { + branchEnergy += tableDangle( + parameters_->dangle3, type, sequence_[right + 1U]); + } + } + exterior[right + 1U] = logAdd( + exterior[right + 1U], source + pair + boltzmannLog(branchEnergy)); + } + } + return exterior[length]; + } + + std::string sequence_; + FoldingOptions options_; + std::vector constraints_; + ShapeTerms shape_; + std::shared_ptr parameters_; + double rt_{}; + Index span_{}; + double logPartition_{}; + mutable std::vector cache_; + mutable std::mutex cacheMutex_; +}; + +} // namespace + +void validateShapeEncoding(const std::string_view method, const std::string_view conversion) { + static_cast(parseShapeEncoding(method, conversion)); +} + +auto makeTurnerFoldingEnsemble(const Sequence& sequence, const FoldingOptions& options) + -> std::unique_ptr { + return std::make_unique(sequence, options); +} + +auto makeBasePairFoldingEnsemble(const Sequence& sequence, const FoldingOptions& options) + -> std::unique_ptr { + return std::make_unique(sequence, options); +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/folding_parameters.cpp b/IntaRNAnew/src/folding_parameters.cpp new file mode 100644 index 0000000..ba516bc --- /dev/null +++ b/IntaRNAnew/src/folding_parameters.cpp @@ -0,0 +1,396 @@ +#include "folding_parameters.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::folding_detail { +namespace { + +namespace fs = std::filesystem; +constexpr double referenceTemperatureKelvin = 310.15; +constexpr std::uintmax_t maximumFileSize = 16U * 1024U * 1024U; +constexpr std::size_t maximumLineSize = 64U * 1024U; +constexpr std::size_t maximumSectionTokens = 20'000U; + +using NumericSections = std::map, std::less<>>; +using StringSections = std::map, std::less<>>; + +struct RawParameters { + NumericSections numeric; + StringSections textual; +}; + +constexpr std::array numericSectionNames{ + std::string_view{"stack"}, std::string_view{"stack_enthalpies"}, + std::string_view{"mismatch_hairpin"}, std::string_view{"mismatch_hairpin_enthalpies"}, + std::string_view{"mismatch_internal"}, std::string_view{"mismatch_internal_enthalpies"}, + std::string_view{"mismatch_internal_1n"}, std::string_view{"mismatch_internal_1n_enthalpies"}, + std::string_view{"mismatch_internal_23"}, std::string_view{"mismatch_internal_23_enthalpies"}, + std::string_view{"mismatch_multi"}, std::string_view{"mismatch_multi_enthalpies"}, + std::string_view{"mismatch_exterior"}, std::string_view{"mismatch_exterior_enthalpies"}, + std::string_view{"dangle5"}, std::string_view{"dangle5_enthalpies"}, + std::string_view{"dangle3"}, std::string_view{"dangle3_enthalpies"}, + std::string_view{"int11"}, std::string_view{"int11_enthalpies"}, + std::string_view{"int21"}, std::string_view{"int21_enthalpies"}, + std::string_view{"int22"}, std::string_view{"int22_enthalpies"}, + std::string_view{"hairpin"}, std::string_view{"hairpin_enthalpies"}, + std::string_view{"bulge"}, std::string_view{"bulge_enthalpies"}, + std::string_view{"internal"}, std::string_view{"internal_enthalpies"}, + std::string_view{"ML_params"}, std::string_view{"NINIO"}, std::string_view{"Misc"}, +}; + +[[nodiscard]] auto trim(const std::string_view text) noexcept -> std::string_view { + const auto first = text.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) return {}; + const auto last = text.find_last_not_of(" \t\r\n"); + return text.substr(first, last - first + 1U); +} + +[[nodiscard]] auto isNumericSection(const std::string_view name) noexcept -> bool { + return std::ranges::find(numericSectionNames, name) != numericSectionNames.end(); +} + +[[nodiscard]] auto isSpecialLoopSection(const std::string_view name) noexcept -> bool { + return name == "Triloops" || name == "Tetraloops" || name == "Hexaloops"; +} + +[[nodiscard]] auto stripBlockComments(const std::string_view line, bool& inComment) -> std::string { + std::string result; + result.reserve(line.size()); + for (std::size_t cursor{}; cursor < line.size();) { + if (inComment) { + const auto end = line.find("*/", cursor); + if (end == std::string_view::npos) return result; + inComment = false; + cursor = end + 2U; + continue; + } + const auto begin = line.find("/*", cursor); + if (begin == std::string_view::npos) { + result.append(line.substr(cursor)); + break; + } + result.append(line.substr(cursor, begin - cursor)); + inComment = true; + cursor = begin + 2U; + } + return result; +} + +[[nodiscard]] auto parseNumber(const std::string_view token, const fs::path& path, + const std::size_t lineNumber) -> double { + if (token == "INF") return std::numeric_limits::infinity(); + if (token == "NST") return 0.0; + if (token == "DEF") return -50.0; + const std::string owned(token); + char* end{}; + errno = 0; + const auto value = std::strtod(owned.c_str(), &end); + if (errno == ERANGE || end != owned.c_str() + owned.size() || !std::isfinite(value)) { + throw std::invalid_argument("invalid parameter token '" + owned + "' at " + + path.string() + ':' + std::to_string(lineNumber)); + } + return value; +} + +[[nodiscard]] auto parseFile(const fs::path& path) -> RawParameters { + std::error_code error; + const auto fileSize = fs::file_size(path, error); + if (error || fileSize > maximumFileSize) { + throw std::invalid_argument(error ? "cannot determine parameter file size: " + path.string() + : "parameter file exceeds the 16 MiB limit: " + path.string()); + } + std::ifstream input(path); + if (!input) throw std::invalid_argument("cannot open ViennaRNA parameter file: " + path.string()); + + RawParameters result; + std::string active; + std::string line; + std::size_t lineNumber{}; + bool inComment{}; + bool versionSeen{}; + while (std::getline(input, line)) { + ++lineNumber; + if (line.size() > maximumLineSize) { + throw std::invalid_argument("overlong ViennaRNA parameter line in " + path.string()); + } + const auto cleaned = stripBlockComments(line, inComment); + const auto view = trim(cleaned); + if (view.empty()) continue; + if (view.starts_with("##")) { + if (!view.starts_with("## RNAfold parameter file v2")) { + throw std::invalid_argument("unsupported ViennaRNA parameter header in " + path.string()); + } + versionSeen = true; + active.clear(); + continue; + } + if (view.front() == '#') { + auto name = trim(view.substr(1U)); + if (const auto blank = name.find_first_of(" \t"); blank != std::string_view::npos) { + name = name.substr(0U, blank); + } + active.clear(); + if (isNumericSection(name)) { + if (!result.numeric.try_emplace(std::string(name)).second) { + throw std::invalid_argument("duplicate ViennaRNA section # " + std::string(name)); + } + active = std::string(name); + } else if (isSpecialLoopSection(name)) { + if (!result.textual.try_emplace(std::string(name)).second) { + throw std::invalid_argument("duplicate ViennaRNA section # " + std::string(name)); + } + active = std::string(name); + } + continue; + } + if (active.empty()) continue; + if (isSpecialLoopSection(active)) { + result.textual.at(active).emplace_back(view); + continue; + } + auto& values = result.numeric.at(active); + std::istringstream tokens{std::string(view)}; + std::string token; + while (tokens >> token) { + if (values.size() == maximumSectionTokens) { + throw std::invalid_argument("too many values in ViennaRNA section # " + active); + } + values.push_back(parseNumber(token, path, lineNumber)); + } + } + if (!input.eof() || inComment || !versionSeen) { + throw std::invalid_argument("malformed ViennaRNA parameter file: " + path.string()); + } + return result; +} + +void appendEnvironmentPaths(std::vector& paths, const char* variable) { + const auto* value = std::getenv(variable); + if (value == nullptr || *value == '\0') return; +#ifdef _WIN32 + constexpr char separator = ';'; +#else + constexpr char separator = ':'; +#endif + const std::string_view encoded(value); + std::size_t begin{}; + while (begin <= encoded.size() && paths.size() < 64U) { + const auto end = encoded.find(separator, begin); + const auto item = trim(encoded.substr(begin, end == std::string_view::npos + ? std::string_view::npos : end - begin)); + if (!item.empty()) paths.emplace_back(item); + if (end == std::string_view::npos) break; + begin = end + 1U; + } +} + +[[nodiscard]] auto regularFile(const fs::path& path) -> std::optional { + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return std::nullopt; + auto canonical = fs::weakly_canonical(path, error); + return error ? std::optional{path} : std::optional{std::move(canonical)}; +} + +[[nodiscard]] auto resolveParameterFile(const std::string_view parameterSet) -> fs::path { + const auto requested = parameterSet.empty() ? std::string_view{"Turner04"} : parameterSet; + if (auto direct = regularFile(fs::path(requested))) return *direct; + std::string_view fileName; + if (requested == "Turner04" || requested == "rna_turner2004.par") { + fileName = "rna_turner2004.par"; + } else if (requested == "Turner99" || requested == "rna_turner1999.par") { + fileName = "rna_turner1999.par"; + } else if (requested == "Andronescu07" || requested == "rna_andronescu2007.par") { + fileName = "rna_andronescu2007.par"; + } else { + throw std::invalid_argument("unknown or missing ViennaRNA parameter set '" + + std::string(requested) + "'"); + } + + std::vector directories; + appendEnvironmentPaths(directories, "INTARNANEW_PARAMETER_DIR"); + appendEnvironmentPaths(directories, "VIENNA_RNA_DATAPATH"); + appendEnvironmentPaths(directories, "VRNA_DATAPATH"); + if (const auto* prefix = std::getenv("CONDA_PREFIX"); prefix != nullptr && *prefix != '\0') { + directories.emplace_back(fs::path(prefix) / "share" / "ViennaRNA"); + } +#ifdef _WIN32 + if (const auto* data = std::getenv("PROGRAMDATA"); data != nullptr && *data != '\0') { + directories.emplace_back(fs::path(data) / "ViennaRNA"); + } +#else + directories.emplace_back("/usr/local/share/ViennaRNA"); + directories.emplace_back("/usr/share/ViennaRNA"); +#endif + std::error_code error; + auto current = fs::current_path(error); + for (std::size_t depth{}; !error && !current.empty() && depth < 8U; ++depth) { + directories.emplace_back(current / "share" / "ViennaRNA"); + directories.emplace_back(current / ".conda-env" / "share" / "ViennaRNA"); + directories.emplace_back(current / "intaRNA_legacy" / ".conda-env" / "share" / "ViennaRNA"); + const auto parent = current.parent_path(); + if (parent == current) break; + current = parent; + } + std::set visited; + for (const auto& directory : directories) { + if (directory.empty() || !visited.insert(directory).second) continue; + if (auto file = regularFile(directory / fileName)) return *file; + } + throw std::invalid_argument("ViennaRNA parameter set '" + std::string(requested) + + "' was not found; set INTARNANEW_PARAMETER_DIR"); +} + +[[nodiscard]] auto section(const NumericSections& sections, const std::string_view name, + const std::size_t expected, const fs::path& path) -> const std::vector& { + const auto found = sections.find(name); + if (found == sections.end() || found->second.size() != expected) { + throw std::invalid_argument("ViennaRNA section # " + std::string(name) + " in " + + path.string() + " must contain " + std::to_string(expected) + " values"); + } + return found->second; +} + +[[nodiscard]] auto scale(const double freeEnergy37, const double enthalpy, + const double temperatureKelvin, + const bool quantize = true) noexcept -> Energy { + if (!std::isfinite(freeEnergy37) || !std::isfinite(enthalpy)) return infinity; + if (std::abs(temperatureKelvin - referenceTemperatureKelvin) < 1e-9) return freeEnergy37 / 100.0; + const auto value = enthalpy - (enthalpy - freeEnergy37) * + temperatureKelvin / referenceTemperatureKelvin; + return quantize ? std::trunc(value) / 100.0 : value / 100.0; +} + +[[nodiscard]] auto scaledSection(const RawParameters& raw, const std::string_view energyName, + const std::string_view enthalpyName, const std::size_t size, + const fs::path& path, const double temperatureKelvin) -> std::vector { + const auto& energies = section(raw.numeric, energyName, size, path); + const auto& enthalpies = section(raw.numeric, enthalpyName, size, path); + std::vector result; + result.reserve(size); + for (std::size_t index{}; index < size; ++index) { + result.push_back(scale(energies[index], enthalpies[index], temperatureKelvin)); + } + return result; +} + +void parseSpecialLoops(FoldingParameters& destination, const RawParameters& raw, + const double temperatureKelvin, const fs::path& path) { + for (const auto name : {std::string_view{"Triloops"}, std::string_view{"Tetraloops"}, + std::string_view{"Hexaloops"}}) { + const auto found = raw.textual.find(name); + if (found == raw.textual.end()) continue; + for (const auto& line : found->second) { + std::istringstream input(line); + std::string motif; + std::string energyToken; + std::string enthalpyToken; + std::string extra; + if (!(input >> motif >> energyToken >> enthalpyToken) || (input >> extra)) { + throw std::invalid_argument("malformed special hairpin in " + path.string()); + } + const auto energy = parseNumber(energyToken, path, 0U); + const auto enthalpy = parseNumber(enthalpyToken, path, 0U); + destination.specialHairpins.emplace(std::move(motif), + scale(energy, enthalpy, temperatureKelvin)); + } + } +} + +[[nodiscard]] auto build(const fs::path& path, const double temperatureCelsius) + -> std::shared_ptr { + const auto temperatureKelvin = temperatureCelsius + 273.15; + const auto raw = parseFile(path); + auto result = std::make_shared(); + result->stack = scaledSection(raw, "stack", "stack_enthalpies", 49U, path, temperatureKelvin); + result->hairpin = scaledSection(raw, "hairpin", "hairpin_enthalpies", 31U, path, temperatureKelvin); + result->bulge = scaledSection(raw, "bulge", "bulge_enthalpies", 31U, path, temperatureKelvin); + result->internal = scaledSection(raw, "internal", "internal_enthalpies", 31U, path, temperatureKelvin); + result->mismatchHairpin = scaledSection(raw, "mismatch_hairpin", "mismatch_hairpin_enthalpies", + 175U, path, temperatureKelvin); + result->mismatchInternal = scaledSection(raw, "mismatch_internal", "mismatch_internal_enthalpies", + 175U, path, temperatureKelvin); + result->mismatchInternal1n = scaledSection(raw, "mismatch_internal_1n", "mismatch_internal_1n_enthalpies", + 175U, path, temperatureKelvin); + result->mismatchInternal23 = scaledSection(raw, "mismatch_internal_23", "mismatch_internal_23_enthalpies", + 175U, path, temperatureKelvin); + result->mismatchMulti = scaledSection(raw, "mismatch_multi", "mismatch_multi_enthalpies", + 175U, path, temperatureKelvin); + result->mismatchExterior = scaledSection(raw, "mismatch_exterior", "mismatch_exterior_enthalpies", + 175U, path, temperatureKelvin); + result->dangle5 = scaledSection(raw, "dangle5", "dangle5_enthalpies", 35U, path, temperatureKelvin); + result->dangle3 = scaledSection(raw, "dangle3", "dangle3_enthalpies", 35U, path, temperatureKelvin); + result->int11 = scaledSection(raw, "int11", "int11_enthalpies", 1225U, path, temperatureKelvin); + result->int21 = scaledSection(raw, "int21", "int21_enthalpies", 6125U, path, temperatureKelvin); + result->int22 = scaledSection(raw, "int22", "int22_enthalpies", 9216U, path, temperatureKelvin); + + const auto& multiloop = section(raw.numeric, "ML_params", 6U, path); + result->multiloopUnpaired = scale(multiloop[0], multiloop[1], temperatureKelvin); + result->multiloopClosing = scale(multiloop[2], multiloop[3], temperatureKelvin); + result->multiloopStem = scale(multiloop[4], multiloop[5], temperatureKelvin); + const auto& ninio = section(raw.numeric, "NINIO", 3U, path); + result->ninioSlope = scale(ninio[0], ninio[1], temperatureKelvin); + result->ninioMaximum = ninio[2] / 100.0; + const auto miscIterator = raw.numeric.find("Misc"); + if (miscIterator == raw.numeric.end() || + (miscIterator->second.size() != 4U && miscIterator->second.size() != 6U)) { + throw std::invalid_argument("ViennaRNA section # Misc in " + path.string() + + " must contain 4 or 6 values"); + } + const auto& misc = miscIterator->second; + result->terminalAu = scale(misc[2], misc[3], temperatureKelvin); + result->logarithmicLoopSlope = misc.size() == 6U + ? scale(misc[4], misc[5], temperatureKelvin, false) + : scale(107.856, 0.0, temperatureKelvin, false); + parseSpecialLoops(*result, raw, temperatureKelvin, path); + return result; +} + +struct CacheKey { + fs::path path; + double temperature{}; + friend auto operator<=>(const CacheKey&, const CacheKey&) = default; +}; + +} // namespace + +auto loadFoldingParameters(const double temperatureCelsius, const std::string_view parameterSet) + -> std::shared_ptr { + if (!std::isfinite(temperatureCelsius) || temperatureCelsius <= -273.15) { + throw std::invalid_argument("folding temperature must be finite and above absolute zero"); + } + const CacheKey key{resolveParameterFile(parameterSet), temperatureCelsius}; + static std::mutex mutex; + static std::map> cache; + { + const std::scoped_lock lock(mutex); + if (const auto found = cache.find(key); found != cache.end()) { + if (auto existing = found->second.lock()) return existing; + cache.erase(found); + } + } + auto result = build(key.path, temperatureCelsius); + { + const std::scoped_lock lock(mutex); + std::erase_if(cache, [](const auto& item) { return item.second.expired(); }); + if (cache.size() < 16U) cache.emplace(key, result); + } + return result; +} + +} // namespace intarnanew::folding_detail diff --git a/IntaRNAnew/src/folding_parameters.hpp b/IntaRNAnew/src/folding_parameters.hpp new file mode 100644 index 0000000..223ef94 --- /dev/null +++ b/IntaRNAnew/src/folding_parameters.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "intarnanew/types.hpp" + +#include +#include +#include +#include +#include + +namespace intarnanew::folding_detail { + +struct FoldingParameters { + std::vector stack; + std::vector hairpin; + std::vector bulge; + std::vector internal; + std::vector mismatchHairpin; + std::vector mismatchInternal; + std::vector mismatchInternal1n; + std::vector mismatchInternal23; + std::vector mismatchMulti; + std::vector mismatchExterior; + std::vector dangle5; + std::vector dangle3; + std::vector int11; + std::vector int21; + std::vector int22; + std::unordered_map specialHairpins; + Energy multiloopUnpaired{}; + Energy multiloopClosing{}; + Energy multiloopStem{}; + Energy terminalAu{}; + Energy ninioSlope{}; + Energy ninioMaximum{}; + Energy logarithmicLoopSlope{}; +}; + +[[nodiscard]] auto loadFoldingParameters( + double temperatureCelsius, + std::string_view parameterSet) -> std::shared_ptr; + +} // namespace intarnanew::folding_detail diff --git a/IntaRNAnew/src/helix_blocks.cpp b/IntaRNAnew/src/helix_blocks.cpp new file mode 100644 index 0000000..3e125c6 --- /dev/null +++ b/IntaRNAnew/src/helix_blocks.cpp @@ -0,0 +1,129 @@ +#include "intarnanew/helix_blocks.hpp" + +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +[[nodiscard]] auto gapSize(const BasePair left, const BasePair right) -> Index { + if (left.target >= right.target || left.query <= right.query) { + throw std::invalid_argument("helix block path is not monotone antiparallel"); + } + return right.target - left.target - 1U + left.query - right.query - 1U; +} + +[[nodiscard]] auto blockConstraintEnergy( + const Sequence& target, + const Sequence& query, + const std::span block, + const HelixConfig& config, + const HybridEnergyModel& energy, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) -> std::optional { + auto breakdown = energy.evaluate(target, query, block); + const auto targetRange = Interval{block.front().target, block.back().target}; + const auto queryRange = Interval{block.back().query, block.front().query}; + if (targetAccessibility.unpairedProbability(targetRange) + 1e-15 < + config.minUnpairedProbability || + queryAccessibility.unpairedProbability(queryRange) + 1e-15 < + config.minUnpairedProbability) { + return std::nullopt; + } + Energy constraintEnergy = breakdown.loops; + if (config.useFullEnergy) { + breakdown.openingTarget = targetAccessibility.openingEnergy(targetRange); + breakdown.openingQuery = queryAccessibility.openingEnergy(queryRange); + constraintEnergy = breakdown.total(); + } + // The documented bound is exclusive: E(helix) < helixMaxE. + if (!std::isfinite(constraintEnergy) || + constraintEnergy >= config.maxEnergy - 1e-12) { + return std::nullopt; + } + return constraintEnergy; +} + +} // namespace + +auto decomposeHelixBlocks( + const Sequence& target, + const Sequence& query, + const std::span path, + const HelixConfig& config, + const HybridEnergyModel& energy, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) -> std::vector { + if (path.empty() || config.minBasePairs == 0U || + config.minBasePairs > config.maxBasePairs) { + return {}; + } + + struct Prefix { + Energy score{infinity}; + Index blocks{std::numeric_limits::max()}; + std::optional previous; + Energy lastConstraintEnergy{}; + }; + const auto decomposePrefix = [&](const Index pathEnd) { + std::vector best(pathEnd + 1U); + best.front().score = 0.0; + best.front().blocks = 0U; + for (Index end = 1U; end <= pathEnd; ++end) { + const auto maximum = std::min(config.maxBasePairs, end); + for (Index count = config.minBasePairs; count <= maximum; ++count) { + const Index begin = end - count; + bool withinBlock = true; + for (Index index = begin + 1U; index < end; ++index) { + if (gapSize(path[index - 1U], path[index]) > config.maxInternalLoop) { + withinBlock = false; + break; + } + } + if (!withinBlock) continue; + if (begin != 0U) { + if (!best[begin].previous) continue; + if (gapSize(path[begin - 1U], path[begin]) <= config.maxInternalLoop) continue; + } + const auto constraintEnergy = blockConstraintEnergy( + target, query, path.subspan(begin, count), config, energy, + targetAccessibility, queryAccessibility); + if (!constraintEnergy) continue; + + const auto score = best[begin].score + *constraintEnergy; + const auto blocks = best[begin].blocks + 1U; + const bool improves = score < best[end].score - 1e-12 || + (std::abs(score - best[end].score) <= 1e-12 && blocks < best[end].blocks); + if (improves) { + best[end] = Prefix{score, blocks, begin, *constraintEnergy}; + } + } + } + return best; + }; + + auto best = decomposePrefix(path.size()); + Index tracebackEnd = path.size(); + if (!best.back().previous && path.size() > 1U && + gapSize(path[path.size() - 2U], path.back()) > config.maxInternalLoop) { + best = decomposePrefix(path.size() - 1U); + tracebackEnd = path.size() - 1U; + } + if (!best.back().previous) return {}; + + std::vector result; + for (Index end = tracebackEnd; end != 0U;) { + const auto& prefix = best[end]; + if (!prefix.previous) return {}; + const auto begin = *prefix.previous; + result.push_back({begin, end - 1U, prefix.lastConstraintEnergy}); + end = begin; + } + std::ranges::reverse(result); + return result; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/noncrossing_partition.hpp b/IntaRNAnew/src/noncrossing_partition.hpp new file mode 100644 index 0000000..39f417d --- /dev/null +++ b/IntaRNAnew/src/noncrossing_partition.hpp @@ -0,0 +1,155 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace intarnanew::detail { + +struct NoncrossingIntervalPartitions { + double logPartition{}; + // Triangular interval data in begin * (n + 1) + length layout. Entries + // that do not encode a nonempty in-range interval remain NaN. + std::vector probabilities; +}; + +// Computes the partition function of a noncrossing matching and the joint +// unpaired probability of every nonempty interval. Q uses half-open intervals: +// +// Q[a,b] = u(b-1) Q[a,b-1] +// + sum_i Q[a,i] W(i,b-1) Q[i+1,b-1]. +// +// The reverse pass obtains the outside context O_R(i,j) of every pair token +// R(i,j)=W(i,j)Q[i+1,j]. For U=[a,b), structures in which U is unpaired either +// contain no pair spanning U, or have one unique innermost spanning pair: +// +// Z_U = Q[0,a]Q[b,n] +// + sum_{i=b} O_R(i,j)W(i,j)Q[i+1,a]Q[b,j]. +// +// Two triangular log-semiring contractions evaluate the second term for every +// U in O(n^3) total time. All arithmetic is in log space. pairLogWeight must +// return -infinity for a forbidden pair and unpairedAllowed must encode hard +// paired-position constraints. +template +[[nodiscard]] auto computeNoncrossingIntervalPartitions( + const std::size_t length, + UnpairedAllowed unpairedAllowed, + PairLogWeight pairLogWeight) -> NoncrossingIntervalPartitions { + constexpr auto logZero = -std::numeric_limits::infinity(); + const auto logAdd = [](const double left, const double right) noexcept { + if (left == logZero) return right; + if (right == logZero) return left; + const auto high = std::max(left, right); + return high + std::log1p(std::exp(std::min(left, right) - high)); + }; + const auto dimension = length + 1U; + const auto intervalOffset = [dimension](const std::size_t begin, const std::size_t end) { + return begin * dimension + end; + }; + const auto pairOffset = [length](const std::size_t left, const std::size_t right) { + return left * length + right; + }; + + std::vector inside(dimension * dimension, logZero); + std::vector pairWeights(length * length, logZero); + for (std::size_t index{}; index <= length; ++index) { + inside[intervalOffset(index, index)] = 0.0; + } + for (std::size_t left{}; left < length; ++left) { + for (std::size_t right = left + 1U; right < length; ++right) { + pairWeights[pairOffset(left, right)] = pairLogWeight(left, right); + } + } + + for (std::size_t width = 1U; width <= length; ++width) { + for (std::size_t begin{}; begin + width <= length; ++begin) { + const auto end = begin + width; + const auto last = end - 1U; + auto value = unpairedAllowed(last) + ? inside[intervalOffset(begin, last)] : logZero; + for (std::size_t partner = begin; partner < last; ++partner) { + const auto weight = pairWeights[pairOffset(partner, last)]; + if (weight == logZero) continue; + const auto left = inside[intervalOffset(begin, partner)]; + const auto inner = inside[intervalOffset(partner + 1U, last)]; + if (left == logZero || inner == logZero) continue; + value = logAdd(value, left + weight + inner); + } + inside[intervalOffset(begin, end)] = value; + } + } + + const auto logPartition = inside[intervalOffset(0U, length)]; + std::vector outside(dimension * dimension, logZero); + std::vector pairOutside(length * length, logZero); + outside[intervalOffset(0U, length)] = 0.0; + for (std::size_t width = length; width > 0U; --width) { + for (std::size_t begin{}; begin + width <= length; ++begin) { + const auto end = begin + width; + const auto last = end - 1U; + const auto context = outside[intervalOffset(begin, end)]; + if (context == logZero) continue; + if (unpairedAllowed(last)) { + auto& child = outside[intervalOffset(begin, last)]; + child = logAdd(child, context); + } + for (std::size_t partner = begin; partner < last; ++partner) { + const auto weight = pairWeights[pairOffset(partner, last)]; + if (weight == logZero) continue; + const auto left = inside[intervalOffset(begin, partner)]; + const auto inner = inside[intervalOffset(partner + 1U, last)]; + if (left == logZero || inner == logZero) continue; + + auto& tokenContext = pairOutside[pairOffset(partner, last)]; + tokenContext = logAdd(tokenContext, context + left); + auto& leftContext = outside[intervalOffset(begin, partner)]; + leftContext = logAdd(leftContext, context + weight + inner); + auto& innerContext = outside[intervalOffset(partner + 1U, last)]; + innerContext = logAdd(innerContext, context + left + weight); + } + } + } + + // rightContext[i,b] contracts every possible right endpoint j of a pair + // spanning [a,b); the remaining left contraction depends on a. + std::vector rightContext(length * dimension, logZero); + for (std::size_t left{}; left < length; ++left) { + for (std::size_t boundary{}; boundary <= length; ++boundary) { + auto value = logZero; + for (std::size_t right = boundary; right < length; ++right) { + const auto context = pairOutside[pairOffset(left, right)]; + const auto weight = pairWeights[pairOffset(left, right)]; + const auto suffix = inside[intervalOffset(boundary, right)]; + if (context == logZero || weight == logZero || suffix == logZero) continue; + value = logAdd(value, context + weight + suffix); + } + rightContext[left * dimension + boundary] = value; + } + } + + NoncrossingIntervalPartitions result; + result.logPartition = logPartition; + result.probabilities.assign( + length * dimension, std::numeric_limits::quiet_NaN()); + for (std::size_t begin{}; begin < length; ++begin) { + for (std::size_t end = begin + 1U; end <= length; ++end) { + auto constrained = inside[intervalOffset(0U, begin)] + + inside[intervalOffset(end, length)]; + for (std::size_t left{}; left < begin; ++left) { + const auto prefix = inside[intervalOffset(left + 1U, begin)]; + const auto context = rightContext[left * dimension + end]; + if (prefix == logZero || context == logZero) continue; + constrained = logAdd(constrained, prefix + context); + } + const auto probability = constrained == logZero || logPartition == logZero + ? 0.0 + : std::clamp(std::exp(std::min(0.0, constrained - logPartition)), 0.0, 1.0); + result.probabilities[begin * dimension + (end - begin)] = probability; + } + } + return result; +} + +} // namespace intarnanew::detail diff --git a/IntaRNAnew/src/output.cpp b/IntaRNAnew/src/output.cpp new file mode 100644 index 0000000..542006e --- /dev/null +++ b/IntaRNAnew/src/output.cpp @@ -0,0 +1,966 @@ +#include "intarnanew/output.hpp" + +#include "intarnanew/output_plan.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +const std::vector allColumns{ + "id1", "id2", "seq1", "seq2", "subseq1", "subseq2", "subseqDP", "subseqDB", + "start1", "end1", "start2", "end2", "hybridDP", "hybridDB", "hybridDPfull", + "hybridDBfull", "bpList", "E", "Etotal", "ED1", "ED2", "Pu1", "Pu2", "E_init", + "E_loops", "E_dangleL", "E_dangleR", "E_endL", "E_endR", "E_hybrid", "E_norm", + "E_hybridNorm", "E_add", "seedStart1", "seedEnd1", "seedStart2", "seedEnd2", "seedE", + "seedED1", "seedED2", "seedPu1", "seedPu2", "w", "Eall", "Eall1", "Eall2", "Zall", + "Zall1", "Zall2", "EallTotal", "P_E", "RT", +}; + +[[nodiscard]] auto number(const double value) -> std::string { + if (!std::isfinite(value)) return "NA"; + if (std::abs(value) < 0.0000005) return "0"; + std::array buffer{}; + const auto [end, error] = std::to_chars( + buffer.data(), buffer.data() + buffer.size(), value, std::chars_format::general, 6); + if (error == std::errc{}) return std::string(buffer.data(), end); + std::ostringstream fallback; + fallback.imbue(std::locale::classic()); + fallback << std::setprecision(6) << std::defaultfloat << value; + return fallback.str(); +} + +[[nodiscard]] auto centikcal(const Energy value) noexcept -> Energy { + if (!std::isfinite(value)) return value; + const auto scaled = value * 100.0; + const auto tolerance = 64.0 * std::numeric_limits::epsilon() * + std::max(1.0, std::abs(scaled)); + return std::trunc(scaled + std::copysign(tolerance, scaled)) / 100.0; +} + +[[nodiscard]] auto energyNumber(const Energy value) -> std::string { + return number(centikcal(value)); +} + +[[nodiscard]] auto fixedEnergyNumber(const Energy value) -> std::string { + if (!std::isfinite(value)) return "NA"; + std::ostringstream output; + output.imbue(std::locale::classic()); + const auto rounded = centikcal(value); + output << std::fixed << std::setprecision(2) << (rounded == 0.0 ? 0.0 : rounded); + return output.str(); +} + +[[nodiscard]] auto scientificNumber(const double value) -> std::string { + if (!std::isfinite(value)) return "NA"; + std::ostringstream output; + output.imbue(std::locale::classic()); + output << std::scientific << std::setprecision(6) << value; + return output.str(); +} + +[[nodiscard]] auto combinedEnergy( + const Energy interactionEnergy, + const PredictionResult& result) noexcept -> Energy { + if (!std::isfinite(interactionEnergy) || + !std::isfinite(result.targetEnsembleFreeEnergy) || + !std::isfinite(result.queryEnsembleFreeEnergy)) { + return infinity; + } + return interactionEnergy + result.targetEnsembleFreeEnergy + + result.queryEnsembleFreeEnergy; +} + +[[nodiscard]] auto totalEnsembleEnergy(const PredictionResult& result) noexcept -> Energy { + return combinedEnergy(result.ensembleFreeEnergy, result); +} + +[[nodiscard]] auto partitionWeight(const double logPartition) noexcept -> double { + return std::isfinite(logPartition) ? std::exp(logPartition) : infinity; +} + +[[nodiscard]] auto lowerAscii(std::string_view value) -> std::string { + std::string result(value); + std::ranges::transform(result, result.begin(), [](const unsigned char character) { + return static_cast(std::tolower(character)); + }); + return result; +} + +[[nodiscard]] auto split(const std::string_view value, const char delimiter) -> std::vector { + std::vector result; + std::size_t start{}; + for (std::size_t index = 0; index <= value.size(); ++index) { + if (index == value.size() || value[index] == delimiter) { + result.emplace_back(value.substr(start, index - start)); + start = index + 1U; + } + } + return result; +} + +[[nodiscard]] auto patterns( + const Interaction& interaction) -> std::pair { + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + std::string targetPattern(targetRange.size(), '.'); + std::string queryPattern(queryRange.size(), '.'); + for (const auto pair : interaction.pairs) { + targetPattern[pair.target - targetRange.begin] = '('; + queryPattern[pair.query - queryRange.begin] = ')'; + } + return {std::move(targetPattern), std::move(queryPattern)}; +} + +[[nodiscard]] auto barPatterns( + const Interaction& interaction, + const Sequence& target, + const Sequence& query, + const bool full) -> std::pair { + const auto targetRange = full ? Interval{0U, target.size() - 1U} : interaction.targetRange(); + const auto queryRange = full ? Interval{0U, query.size() - 1U} : interaction.queryRange(); + std::string targetPattern(targetRange.size(), '.'); + std::string queryPattern(queryRange.size(), '.'); + for (const auto pair : interaction.pairs) { + targetPattern[pair.target - targetRange.begin] = '|'; + queryPattern[pair.query - queryRange.begin] = '|'; + } + return {std::to_string(target.externalIndex(targetRange.begin)) + targetPattern, + std::to_string(query.externalIndex(queryRange.begin)) + queryPattern}; +} + +[[nodiscard]] auto selectedSeeds( + const Interaction& interaction, + const bool bestSeedOnly) -> std::vector { + std::vector result; + result.reserve(interaction.seeds.size()); + for (const auto& seed : interaction.seeds) result.push_back(&seed); + std::ranges::sort(result, [](const SeedMatch* left, const SeedMatch* right) { + return seedMatchLess(*left, *right); + }); + if (bestSeedOnly && result.size() > 1U) result.resize(1U); + return result; +} + +template +[[nodiscard]] auto joinedSeedValues( + const std::vector& seeds, + const std::string_view delimiter, + Formatter formatter) -> std::string { + std::string result; + for (const auto* seed : seeds) { + if (!result.empty()) result += delimiter; + result += formatter(*seed); + } + return result; +} + +[[nodiscard]] auto csvColumns(const std::string& specification) -> std::expected, std::string> { + auto result = specification.empty() || specification == "*" ? allColumns : split(specification, ','); + const std::unordered_set available(allColumns.begin(), allColumns.end()); + for (const auto& column : result) { + if (!available.contains(column)) return std::unexpected("unknown CSV column '" + column + "'"); + } + return result; +} + +[[nodiscard]] auto columnValue( + const std::string& column, + const Interaction& interaction, + const Sequence& target, + const Sequence& query, + const PredictionResult& result, + const bool bestSeedOnly) -> std::string { + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + const auto [targetPattern, queryPattern] = patterns(interaction); + if (column == "id1") return interaction.targetId; + if (column == "id2") return interaction.queryId; + if (column == "seq1") return target.str(); + if (column == "seq2") return query.str(); + if (column == "subseq1") return target.str().substr(targetRange.begin, targetRange.size()); + if (column == "subseq2") return query.str().substr(queryRange.begin, queryRange.size()); + if (column == "subseqDP") return target.str().substr(targetRange.begin, targetRange.size()) + "&" + + query.str().substr(queryRange.begin, queryRange.size()); + if (column == "subseqDB") { + return std::to_string(target.externalIndex(targetRange.begin)) + + target.str().substr(targetRange.begin, targetRange.size()) + "&" + + std::to_string(query.externalIndex(queryRange.begin)) + + query.str().substr(queryRange.begin, queryRange.size()); + } + if (column == "start1") return std::to_string(target.externalIndex(targetRange.begin)); + if (column == "end1") return std::to_string(target.externalIndex(targetRange.end)); + if (column == "start2") return std::to_string(query.externalIndex(queryRange.begin)); + if (column == "end2") return std::to_string(query.externalIndex(queryRange.end)); + if (column == "hybridDP") return targetPattern + "&" + queryPattern; + if (column == "hybridDB") { + const auto bars = barPatterns(interaction, target, query, false); + return bars.first + "&" + bars.second; + } + if (column == "hybridDPfull") { + std::string targetFull(target.size(), '.'); + std::string queryFull(query.size(), '.'); + for (const auto pair : interaction.pairs) { + targetFull[pair.target] = '('; + queryFull[pair.query] = ')'; + } + return targetFull + "&" + queryFull; + } + if (column == "hybridDBfull") { + const auto bars = barPatterns(interaction, target, query, true); + return bars.first + "&" + bars.second; + } + if (column == "bpList") { + std::string value; + for (const auto pair : interaction.pairs) { + if (!value.empty()) value.push_back(':'); + value += "(" + std::to_string(target.externalIndex(pair.target)) + "," + + std::to_string(query.externalIndex(pair.query)) + ")"; + } + return value; + } + if (column == "E") return energyNumber(interaction.energy.total()); + if (column == "Etotal") return energyNumber(combinedEnergy(interaction.energy.total(), result)); + if (column == "ED1") return energyNumber(interaction.energy.openingTarget); + if (column == "ED2") return energyNumber(interaction.energy.openingQuery); + if (column == "Pu1") return number(interaction.unpairedTarget); + if (column == "Pu2") return number(interaction.unpairedQuery); + if (column == "E_init") return energyNumber(interaction.energy.initiation); + if (column == "E_loops") return energyNumber(interaction.energy.loops); + if (column == "E_dangleL") return energyNumber(interaction.energy.dangleLeft); + if (column == "E_dangleR") return energyNumber(interaction.energy.dangleRight); + if (column == "E_endL") return energyNumber(interaction.energy.endLeft); + if (column == "E_endR") return energyNumber(interaction.energy.endRight); + if (column == "E_hybrid") return energyNumber(interaction.energy.hybrid()); + if (column == "E_norm") { + const auto denominator = std::log(static_cast(target.size())) + + std::log(static_cast(query.size())); + return number(denominator > 0.0 ? interaction.energy.total() / denominator : + std::numeric_limits::quiet_NaN()); + } + if (column == "E_hybridNorm") { + const auto denominator = std::log(static_cast(target.size())) + + std::log(static_cast(query.size())); + return number(denominator > 0.0 ? interaction.energy.hybrid() / denominator : + std::numeric_limits::quiet_NaN()); + } + if (column == "E_add") return energyNumber(interaction.energy.additive); + if (column.starts_with("seed")) { + const auto seeds = selectedSeeds(interaction, bestSeedOnly); + if (seeds.empty()) return "NAN"; + return joinedSeedValues(seeds, ":", [&](const SeedMatch& seed) { + const auto& first = interaction.pairs.at(seed.firstPair); + const auto& last = interaction.pairs.at(seed.lastPair); + if (column == "seedStart1") return std::to_string(target.externalIndex(first.target)); + if (column == "seedEnd1") return std::to_string(target.externalIndex(last.target)); + if (column == "seedStart2") return std::to_string(query.externalIndex(last.query)); + if (column == "seedEnd2") return std::to_string(query.externalIndex(first.query)); + if (column == "seedE") return energyNumber(seed.energy); + if (column == "seedED1") return energyNumber(seed.openingTarget); + if (column == "seedED2") return energyNumber(seed.openingQuery); + if (column == "seedPu1") return number(seed.unpairedTarget); + if (column == "seedPu2") return number(seed.unpairedQuery); + return std::string{}; + }); + } + if (column == "w") return number(std::exp(-centikcal(interaction.energy.total()) / result.rt)); + if (column == "Eall") return energyNumber(result.ensembleFreeEnergy); + if (column == "Eall1") return energyNumber(result.targetEnsembleFreeEnergy); + if (column == "Eall2") return energyNumber(result.queryEnsembleFreeEnergy); + if (column == "Zall") return number(partitionWeight(result.logPartition)); + if (column == "Zall1") return number(partitionWeight(result.targetLogPartition)); + if (column == "Zall2") return number(partitionWeight(result.queryLogPartition)); + if (column == "EallTotal") return energyNumber(totalEnsembleEnergy(result)); + if (column == "P_E") { + return std::isfinite(result.logPartition) + ? number(interaction.probability) : "NA"; + } + if (column == "RT") return number(result.rt); + return ""; +} + +enum class CsvSortKind { text, integer, floating }; + +[[nodiscard]] auto csvSortKind(const std::string_view column) -> CsvSortKind { + static const std::unordered_set integerColumns{ + "start1", "end1", "start2", "end2", + "seedStart1", "seedEnd1", "seedStart2", "seedEnd2", + }; + static const std::unordered_set floatingColumns{ + "E", "Etotal", "ED1", "ED2", "Pu1", "Pu2", "E_init", "E_loops", + "E_dangleL", "E_dangleR", "E_endL", "E_endR", "E_hybrid", "E_norm", + "E_hybridNorm", "E_add", "seedE", "seedED1", "seedED2", "seedPu1", + "seedPu2", "w", "Eall", "Eall1", "Eall2", "Zall", "Zall1", "Zall2", + "EallTotal", "P_E", "RT", + }; + if (integerColumns.contains(column)) return CsvSortKind::integer; + if (floatingColumns.contains(column)) return CsvSortKind::floating; + return CsvSortKind::text; +} + +[[nodiscard]] auto integerColumnValue( + const std::string_view column, + const Interaction& interaction, + const Sequence& target, + const Sequence& query) -> std::optional { + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + if (column == "start1") return target.externalIndex(targetRange.begin); + if (column == "end1") return target.externalIndex(targetRange.end); + if (column == "start2") return query.externalIndex(queryRange.begin); + if (column == "end2") return query.externalIndex(queryRange.end); + const auto* seed = interaction.bestSeed(); + if (seed == nullptr) return std::nullopt; + const auto& first = interaction.pairs[seed->firstPair]; + const auto& last = interaction.pairs[seed->lastPair]; + if (column == "seedStart1") return target.externalIndex(first.target); + if (column == "seedEnd1") return target.externalIndex(last.target); + if (column == "seedStart2") return query.externalIndex(last.query); + if (column == "seedEnd2") return query.externalIndex(first.query); + return std::nullopt; +} + +[[nodiscard]] auto finite(const double value) -> std::optional { + return std::isfinite(value) ? std::optional{value} : std::nullopt; +} + +[[nodiscard]] auto floatingColumnValue( + const std::string_view column, + const Interaction& interaction, + const Sequence& target, + const Sequence& query, + const PredictionResult& result) -> std::optional { + if (column == "E") return finite(interaction.energy.total()); + if (column == "Etotal") return finite(combinedEnergy(interaction.energy.total(), result)); + if (column == "ED1") return finite(interaction.energy.openingTarget); + if (column == "ED2") return finite(interaction.energy.openingQuery); + if (column == "Pu1") return finite(interaction.unpairedTarget); + if (column == "Pu2") return finite(interaction.unpairedQuery); + if (column == "E_init") return finite(interaction.energy.initiation); + if (column == "E_loops") return finite(interaction.energy.loops); + if (column == "E_dangleL") return finite(interaction.energy.dangleLeft); + if (column == "E_dangleR") return finite(interaction.energy.dangleRight); + if (column == "E_endL") return finite(interaction.energy.endLeft); + if (column == "E_endR") return finite(interaction.energy.endRight); + if (column == "E_hybrid") return finite(interaction.energy.hybrid()); + if (column == "E_norm" || column == "E_hybridNorm") { + const auto denominator = std::log(static_cast(target.size())) + + std::log(static_cast(query.size())); + if (!(denominator > 0.0)) return std::nullopt; + return finite((column == "E_norm" ? interaction.energy.total() : interaction.energy.hybrid()) / + denominator); + } + if (column == "E_add") return finite(interaction.energy.additive); + if (column == "seedE") { + const auto* seed = interaction.bestSeed(); + return seed != nullptr ? finite(seed->energy) : std::nullopt; + } + if (column == "seedED1") { + const auto* seed = interaction.bestSeed(); + return seed != nullptr ? finite(seed->openingTarget) : std::nullopt; + } + if (column == "seedED2") { + const auto* seed = interaction.bestSeed(); + return seed != nullptr ? finite(seed->openingQuery) : std::nullopt; + } + if (column == "seedPu1") { + const auto* seed = interaction.bestSeed(); + return seed != nullptr ? finite(seed->unpairedTarget) : std::nullopt; + } + if (column == "seedPu2") { + const auto* seed = interaction.bestSeed(); + return seed != nullptr ? finite(seed->unpairedQuery) : std::nullopt; + } + if (column == "w") { + return finite(std::exp(-centikcal(interaction.energy.total()) / result.rt)); + } + if (column == "Eall") return finite(result.ensembleFreeEnergy); + if (column == "Eall1") return finite(result.targetEnsembleFreeEnergy); + if (column == "Eall2") return finite(result.queryEnsembleFreeEnergy); + if (column == "Zall") return finite(partitionWeight(result.logPartition)); + if (column == "Zall1") return finite(partitionWeight(result.targetLogPartition)); + if (column == "Zall2") return finite(partitionWeight(result.queryLogPartition)); + if (column == "EallTotal") return finite(totalEnsembleEnergy(result)); + if (column == "P_E") { + return std::isfinite(result.logPartition) + ? finite(interaction.probability) : std::nullopt; + } + if (column == "RT") return finite(result.rt); + return std::nullopt; +} + +template +[[nodiscard]] auto optionalLess( + const std::optional& left, + const std::optional& right) -> bool { + if (!left) return false; + if (!right) return true; + return *left < *right; +} + +[[nodiscard]] auto csvField(const std::string& value, const char separator) -> std::string { + if (value.find_first_of(std::string{separator} + "\"\r\n") == std::string::npos) return value; + std::string escaped; + escaped.reserve(value.size() + 2U); + escaped.push_back('"'); + for (const char character : value) { + if (character == '"') escaped.push_back('"'); + escaped.push_back(character); + } + escaped.push_back('"'); + return escaped; +} + +[[nodiscard]] auto csv( + const Config& config, + const Sequence& target, + const Sequence& query, + const PredictionResult& result, + const bool includeHeader) -> std::expected { + auto columns = csvColumns(config.output.csvColumns); + if (!columns) return std::unexpected(columns.error()); + auto rows = result.interactions; + if (!config.output.csvSort.empty()) { + if (std::ranges::find(*columns, config.output.csvSort) == columns->end()) { + return std::unexpected("--outCsvSort column is not selected in --outCsvCols"); + } + const auto kind = csvSortKind(config.output.csvSort); + std::ranges::stable_sort(rows, [&](const Interaction& left, const Interaction& right) { + if (kind == CsvSortKind::integer) { + return optionalLess( + integerColumnValue(config.output.csvSort, left, target, query), + integerColumnValue(config.output.csvSort, right, target, query)); + } + if (kind == CsvSortKind::floating) { + return optionalLess( + floatingColumnValue(config.output.csvSort, left, target, query, result), + floatingColumnValue(config.output.csvSort, right, target, query, result)); + } + return columnValue(config.output.csvSort, left, target, query, result, + config.output.bestSeedOnly) < + columnValue(config.output.csvSort, right, target, query, result, + config.output.bestSeedOnly); + }); + } + std::ostringstream output; + output.imbue(std::locale::classic()); + if (includeHeader) { + for (Index index = 0; index < columns->size(); ++index) { + if (index != 0U) output << config.output.separator; + output << csvField((*columns)[index], config.output.separator); + } + output << '\n'; + } + for (const auto& interaction : rows) { + for (Index index = 0; index < columns->size(); ++index) { + if (index != 0U) output << config.output.separator; + output << csvField( + columnValue((*columns)[index], interaction, target, query, result, + config.output.bestSeedOnly), + config.output.separator); + } + output << '\n'; + } + return output.str(); +} + +[[nodiscard]] auto reverseString(std::string value) -> std::string { + std::ranges::reverse(value); + return value; +} + +[[nodiscard]] auto leftFlank(std::string value) -> std::string { + constexpr std::size_t flankWidth{10U}; + if (value.size() <= flankWidth) return value; + return value.substr(0U, 3U) + "..." + value.substr(value.size() - 4U); +} + +[[nodiscard]] auto rightFlank(std::string value) -> std::string { + constexpr std::size_t flankWidth{10U}; + if (value.size() <= flankWidth) return value; + return value.substr(0U, 4U) + "..." + value.substr(value.size() - 3U); +} + +[[nodiscard]] auto annotatedIndex(const Sequence& sequence, const Index index) -> std::string { + const auto external = sequence.externalIndex(index); + if (sequence.firstPosition() < 0 && external > 0) { + return "+" + std::to_string(external); + } + return std::to_string(external); +} + +[[nodiscard]] auto coordinateLine( + const std::size_t firstColumn, + const std::size_t lastColumn, + const std::string& first, + const std::string& last) -> std::string { + std::string line(firstColumn + 1U - std::min(firstColumn + 1U, first.size()), ' '); + line += first; + if (lastColumn != firstColumn) { + if (line.size() < lastColumn) line.append(lastColumn - line.size(), ' '); + line += last; + } + return line; +} + +[[nodiscard]] auto markerLine( + const std::size_t firstColumn, + const std::size_t lastColumn) -> std::string { + std::string line(firstColumn, ' '); + line.push_back('|'); + if (lastColumn != firstColumn) { + line.append(lastColumn - firstColumn - 1U, ' '); + line.push_back('|'); + } + return line; +} + +struct DisplayPair { + BasePair pair; + Index originalIndex{}; +}; + +struct InteractionChart { + std::string targetBackbone; + std::string targetPairs; + std::string pairSymbols; + std::string queryPairs; + std::string queryBackbone; + std::vector pairs; +}; + +[[nodiscard]] auto interactionChart( + const Sequence& target, + const Sequence& query, + const Interaction& interaction, + const bool bestSeedOnly) -> InteractionChart { + InteractionChart chart; + chart.pairs.reserve(interaction.pairs.size()); + for (Index index = 0U; index < interaction.pairs.size(); ++index) { + chart.pairs.push_back({interaction.pairs[index], index}); + } + std::ranges::sort(chart.pairs, {}, [](const DisplayPair& value) { + return value.pair.target; + }); + + std::vector columns(chart.pairs.size(), 0U); + for (Index index = 1U; index < chart.pairs.size(); ++index) { + const auto& previous = chart.pairs[index - 1U].pair; + const auto& current = chart.pairs[index].pair; + const auto targetGap = current.target - previous.target - 1U; + const auto queryGap = previous.query - current.query - 1U; + columns[index] = columns[index - 1U] + 1U + std::max(targetGap, queryGap); + } + + const auto width = columns.back() + 1U; + chart.targetBackbone.assign(width, ' '); + chart.targetPairs.assign(width, ' '); + chart.pairSymbols.assign(width, ' '); + chart.queryPairs.assign(width, ' '); + chart.queryBackbone.assign(width, ' '); + + const auto seeds = selectedSeeds(interaction, bestSeedOnly); + for (Index index = 0U; index < chart.pairs.size(); ++index) { + const auto& display = chart.pairs[index]; + const auto column = columns[index]; + chart.targetPairs[column] = target[display.pair.target]; + chart.queryPairs[column] = query[display.pair.query]; + const auto inSeed = std::ranges::any_of(seeds, [&](const SeedMatch* seed) { + const auto first = std::min(seed->firstPair, seed->lastPair); + const auto last = std::max(seed->firstPair, seed->lastPair); + return display.originalIndex >= first && display.originalIndex <= last; + }); + chart.pairSymbols[column] = inSeed ? '+' : + (isGuPair(target[display.pair.target], query[display.pair.query]) ? ':' : '|'); + + if (index + 1U == chart.pairs.size()) continue; + const auto& next = chart.pairs[index + 1U].pair; + const auto targetGap = next.target - display.pair.target - 1U; + const auto queryGap = display.pair.query - next.query - 1U; + const auto gapWidth = std::max(targetGap, queryGap); + const auto gapStart = column + 1U; + for (Index offset = 0U; offset < gapWidth; ++offset) { + chart.targetBackbone[gapStart + offset] = offset < targetGap + ? target[display.pair.target + 1U + offset] : '-'; + chart.queryBackbone[gapStart + offset] = offset < queryGap + ? query[display.pair.query - 1U - offset] : '-'; + } + } + return chart; +} + +[[nodiscard]] auto asciiInteraction( + const Sequence& target, + const Sequence& query, + const Interaction& interaction, + const bool detailed, + const bool bestSeedOnly) -> std::string { + constexpr std::size_t firstPairColumn{13U}; + const auto chart = interactionChart(target, query, interaction, bestSeedOnly); + const auto& first = chart.pairs.front().pair; + const auto& last = chart.pairs.back().pair; + const auto lastPairColumn = firstPairColumn + chart.targetPairs.size() - 1U; + + const auto targetLeft = leftFlank(target.str().substr(0U, first.target)); + const auto targetRight = rightFlank(target.str().substr(last.target + 1U)); + const auto queryLeft = leftFlank(reverseString(query.str().substr(first.query + 1U))); + const auto queryRight = rightFlank(reverseString(query.str().substr(0U, last.query))); + const auto outerLine = [](const std::string& left, const std::string_view orientation, + const std::string& backbone, const std::string& right, + const std::string_view terminus) { + return std::string(10U - left.size(), ' ') + std::string(orientation) + left + + backbone + right + std::string(terminus); + }; + + std::ostringstream output; + output.imbue(std::locale::classic()); + output << '\n' << target.id() << '\n' + << coordinateLine(firstPairColumn, lastPairColumn, + annotatedIndex(target, first.target), annotatedIndex(target, last.target)) << '\n' + << markerLine(firstPairColumn, lastPairColumn) << '\n' + << outerLine(targetLeft, "5'-", chart.targetBackbone, targetRight, "-3'") << '\n' + << std::string(firstPairColumn, ' ') << chart.targetPairs << '\n' + << std::string(firstPairColumn, ' ') << chart.pairSymbols << '\n' + << std::string(firstPairColumn, ' ') << chart.queryPairs << '\n' + << outerLine(queryLeft, "3'-", chart.queryBackbone, queryRight, "-5'") << '\n' + << markerLine(firstPairColumn, lastPairColumn) << '\n' + << coordinateLine(firstPairColumn, lastPairColumn, + annotatedIndex(query, first.query), annotatedIndex(query, last.query)) << '\n' + << query.id() << "\n\n"; + + if (detailed) { + output << "interaction seq1 = " << target.externalIndex(first.target) << ".." + << target.externalIndex(last.target) << '\n' + << "interaction seq2 = " << query.externalIndex(last.query) << ".." + << query.externalIndex(first.query) << "\n\n"; + } + + output << "interaction energy = " << energyNumber(interaction.energy.total()) + << " kcal/mol\n"; + if (!detailed) return output.str(); + + output << " = E(init) = " << energyNumber(interaction.energy.initiation) << '\n' + << " + E(loops) = " << energyNumber(interaction.energy.loops) << '\n' + << " + E(dangleLeft) = " << energyNumber(interaction.energy.dangleLeft) << '\n' + << " + E(dangleRight) = " << energyNumber(interaction.energy.dangleRight) << '\n' + << " + E(endLeft) = " << energyNumber(interaction.energy.endLeft) << '\n' + << " + E(endRight) = " << energyNumber(interaction.energy.endRight) << '\n' + << " : E(hybrid) = " << energyNumber(interaction.energy.hybrid()) << '\n' + << " + ED(seq1) = " << energyNumber(interaction.energy.openingTarget) << '\n' + << " : Pu(seq1) = " << number(interaction.unpairedTarget) << '\n' + << " + ED(seq2) = " << energyNumber(interaction.energy.openingQuery) << '\n' + << " : Pu(seq2) = " << number(interaction.unpairedQuery) << '\n'; + if (centikcal(interaction.energy.additive) != 0.0) { + output << " + E(add) = " << energyNumber(interaction.energy.additive) << '\n'; + } + const auto seeds = selectedSeeds(interaction, bestSeedOnly); + if (!seeds.empty()) { + const auto seedCoordinate = [&](const SeedMatch& seed, const bool targetSide) { + const auto seedFirst = std::min(seed.firstPair, seed.lastPair); + const auto seedLast = std::max(seed.firstPair, seed.lastPair); + const auto& seedLeft = interaction.pairs.at(seedFirst); + const auto& seedRight = interaction.pairs.at(seedLast); + return targetSide + ? std::to_string(target.externalIndex(seedLeft.target)) + ".." + + std::to_string(target.externalIndex(seedRight.target)) + : std::to_string(query.externalIndex(seedRight.query)) + ".." + + std::to_string(query.externalIndex(seedLeft.query)); + }; + output << '\n' + << "seed seq1 = " << joinedSeedValues(seeds, " | ", [&](const SeedMatch& seed) { + return seedCoordinate(seed, true); + }) << '\n' + << "seed seq2 = " << joinedSeedValues(seeds, " | ", [&](const SeedMatch& seed) { + return seedCoordinate(seed, false); + }) << '\n' + << "seed energy = " << joinedSeedValues(seeds, " | ", [](const SeedMatch& seed) { + return energyNumber(seed.energy); + }) << '\n' + << "seed ED1 = " << joinedSeedValues(seeds, " | ", [](const SeedMatch& seed) { + return energyNumber(seed.openingTarget); + }) << '\n' + << "seed ED2 = " << joinedSeedValues(seeds, " | ", [](const SeedMatch& seed) { + return energyNumber(seed.openingQuery); + }) << '\n' + << "seed Pu1 = " << joinedSeedValues(seeds, " | ", [&](const SeedMatch& seed) { + return number(seed.unpairedTarget); + }) << '\n' + << "seed Pu2 = " << joinedSeedValues(seeds, " | ", [&](const SeedMatch& seed) { + return number(seed.unpairedQuery); + }) << '\n'; + } + return output.str(); +} + +[[nodiscard]] auto profile( + const Sequence& sequence, + const std::vector& interactions, + const bool querySide, + const bool probability, + const char separator) -> std::string { + std::ostringstream output; + output << "idx" << separator << sequence.id() << separator + << (probability ? "spotProb" : "minE") << '\n'; + for (Index position = 0U; position < sequence.size(); ++position) { + double value = probability ? 0.0 : infinity; + for (const auto& interaction : interactions) { + const auto interval = querySide ? interaction.queryRange() : interaction.targetRange(); + if (!interval.contains(position)) continue; + if (probability) value += interaction.probability; + else value = std::min(value, interaction.energy.total()); + } + output << sequence.externalIndex(position) << separator << sequence[position] + << separator << number(value) << '\n'; + } + return output.str(); +} + +[[nodiscard]] auto effectiveAccessibilityLength( + const Sequence& sequence, + const SideConfig& config) noexcept -> Index { + auto length = sequence.size(); + if (config.interactionLengthMax != 0U) { + length = std::min(length, config.interactionLengthMax); + } + if (config.accessibility == AccessibilityKind::compute && + config.accessibilityWindow != 0U) { + length = std::min(length, config.accessibilityWindow); + } + return length; +} + +[[nodiscard]] auto accessibilityTable( + const Sequence& sequence, + const SideConfig& config, + const AccessibilityProvider& accessibility, + const bool probabilities) -> std::string { + std::ostringstream output; + output.imbue(std::locale::classic()); + output << (probabilities ? "#unpaired probabilities\n" + : "#ensemble delta energy to unpair a region ED\n"); + const auto maxLength = effectiveAccessibilityLength(sequence, config); + output << " #i$\tl=1"; + for (Index length = 2U; length <= maxLength; ++length) output << '\t' << length; + output << "\t\n"; + for (Index end = 0U; end < sequence.size(); ++end) { + output << sequence.externalIndex(end) << '\t'; + for (Index length = 1U; length <= maxLength; ++length) { + if (length > end + 1U) { + output << "NA\t"; + continue; + } + const Interval interval{end + 1U - length, end}; + const auto value = probabilities + ? accessibility.unpairedProbability(interval) + : accessibility.openingEnergy(interval); + output << scientificNumber(value) << '\t'; + } + output << '\n'; + } + return output.str(); +} + +[[nodiscard]] auto coveringProbability( + const std::vector& sites, + const Index targetPosition, + const Index queryPosition) noexcept -> double { + double probability{}; + for (const auto& interaction : sites) { + if (interaction.targetRange().contains(targetPosition) && + interaction.queryRange().contains(queryPosition)) { + probability += interaction.probability; + } + } + return std::clamp(probability, 0.0, 1.0); +} + +} // namespace + +auto OutputFormatter::primary( + const Config& config, + const Sequence& target, + const Sequence& query, + const PredictionResult& result, + const bool includeCsvHeader) -> std::expected { + if (config.output.mode == OutputMode::csv) { + return csv(config, target, query, result, includeCsvHeader); + } + if (config.output.mode == OutputMode::ensemble) { + const auto interactionEnsemble = result.ensembleSites.empty() + ? 0.0 : result.ensembleFreeEnergy; + // EallTotal describes formation of an interaction plus both monomer + // ensembles. With no favorable interaction there is no formation + // event, so the public contract reports zero rather than the sum of + // unrelated monomer folding free energies. + const auto totalEnsemble = result.ensembleSites.empty() + ? 0.0 : totalEnsembleEnergy(result); + std::ostringstream output; + output << "id1 " << target.id() << '\n' + << "id2 " << query.id() << '\n' + << "RT " << number(result.rt) << '\n' + << "Eall " << fixedEnergyNumber(interactionEnsemble) << '\n' + << "Eall1 " << fixedEnergyNumber(result.targetEnsembleFreeEnergy) << '\n' + << "Eall2 " << fixedEnergyNumber(result.queryEnsembleFreeEnergy) << '\n' + << "EallTotal " << fixedEnergyNumber(totalEnsemble) << '\n'; + return output.str(); + } + if (result.interactions.empty()) { + return std::string("\nno favorable interaction found\n"); + } + std::string output; + for (const auto& interaction : result.interactions) { + output += asciiInteraction(target, query, interaction, + config.output.mode == OutputMode::detailed, + config.output.bestSeedOnly); + } + return output; +} + +auto OutputFormatter::auxiliary( + const std::string_view descriptor, + const Config& config, + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility, + const PredictionResult& result) -> std::expected { + const auto separator = descriptor.find(':'); + const auto originalKind = descriptor.substr(0U, separator); + const auto kind = lowerAscii(originalKind); + if (kind == "qmine") return profile(query, result.ensembleSites, true, false, config.output.separator); + if (kind == "tmine") return profile(target, result.ensembleSites, false, false, config.output.separator); + if (kind == "qspotprob") return profile(query, result.ensembleSites, true, true, config.output.separator); + if (kind == "tspotprob") return profile(target, result.ensembleSites, false, true, config.output.separator); + if (kind == "qacc") return accessibilityTable(query, config.query, queryAccessibility, false); + if (kind == "qpu") return accessibilityTable(query, config.query, queryAccessibility, true); + if (kind == "tacc") return accessibilityTable(target, config.target, targetAccessibility, false); + if (kind == "tpu") return accessibilityTable(target, config.target, targetAccessibility, true); + if (kind == "pmine") { + std::ostringstream output; + output << "minE"; + for (Index queryPosition = 0U; queryPosition < query.size(); ++queryPosition) { + output << config.output.separator << query[queryPosition] << '_' + << query.externalIndex(queryPosition); + } + output << '\n'; + for (Index targetPosition = 0U; targetPosition < target.size(); ++targetPosition) { + output << target[targetPosition] << '_' << target.externalIndex(targetPosition); + for (Index queryPosition = 0U; queryPosition < query.size(); ++queryPosition) { + auto minimum = infinity; + for (const auto& interaction : result.ensembleSites) { + if (interaction.targetRange().contains(targetPosition) && + interaction.queryRange().contains(queryPosition)) { + minimum = std::min(minimum, interaction.energy.total()); + } + } + output << config.output.separator << number(minimum); + } + output << '\n'; + } + return output.str(); + } + if (kind == "spotprob") { + std::string spots; + if (separator != std::string_view::npos) { + const auto second = descriptor.find(':', separator + 1U); + if (second != std::string_view::npos) { + spots = std::string(descriptor.substr( + separator + 1U, second - separator - 1U)); + } + } + if (spots.empty()) { + std::ostringstream output; + output << "spotProb"; + for (Index queryPosition = 0U; queryPosition < query.size(); ++queryPosition) { + output << config.output.separator << query[queryPosition] << '_' + << query.externalIndex(queryPosition); + } + output << '\n'; + for (Index targetPosition = 0U; targetPosition < target.size(); ++targetPosition) { + output << target[targetPosition] << '_' << target.externalIndex(targetPosition); + for (Index queryPosition = 0U; queryPosition < query.size(); ++queryPosition) { + output << config.output.separator << number(coveringProbability( + result.ensembleSites, targetPosition, queryPosition)); + } + output << '\n'; + } + return output.str(); + } + + struct RequestedSpot { + std::string label; + Index target{}; + Index query{}; + }; + std::vector requested; + for (const auto& token : split(spots, ',')) { + if (token.empty()) continue; + const auto ampersand = token.find('&'); + if (ampersand == std::string::npos) return std::unexpected("spot must use target&query coordinates"); + long long targetExternal{}; + long long queryExternal{}; + const auto targetResult = std::from_chars(token.data(), token.data() + ampersand, targetExternal); + const auto queryResult = std::from_chars(token.data() + ampersand + 1U, + token.data() + token.size(), queryExternal); + if (targetResult.ec != std::errc{} || + targetResult.ptr != token.data() + ampersand || + queryResult.ec != std::errc{} || + queryResult.ptr != token.data() + token.size()) { + return std::unexpected("invalid spot coordinate"); + } + auto targetPosition = target.internalIndex(targetExternal); + auto queryPosition = query.internalIndex(queryExternal); + if (!targetPosition || !queryPosition) return std::unexpected("spot coordinate is out of range"); + requested.push_back({token, *targetPosition, *queryPosition}); + } + double covered{}; + for (const auto& interaction : result.ensembleSites) { + if (std::ranges::any_of(requested, [&](const RequestedSpot& spot) { + return interaction.targetRange().contains(spot.target) && + interaction.queryRange().contains(spot.query); + })) { + covered += interaction.probability; + } + } + const auto targetBefore = target.externalIndex(0U); + const auto queryBefore = query.externalIndex(0U); + if (targetBefore == std::numeric_limits::min() || + queryBefore == std::numeric_limits::min()) { + return std::unexpected("cannot encode the coordinate preceding the sequence origin"); + } + std::ostringstream output; + output << "spot" << config.output.separator << "probability\n" + << targetBefore - 1LL << '&' << queryBefore - 1LL << config.output.separator + << number(std::clamp(1.0 - covered, 0.0, 1.0)) << '\n'; + for (const auto& spot : requested) { + output << spot.label << config.output.separator + << number(coveringProbability(result.ensembleSites, spot.target, spot.query)) + << '\n'; + } + return output.str(); + } + return std::unexpected("unknown auxiliary output kind '" + std::string(originalKind) + "'"); +} + +auto writeOutput(const std::string_view destination, const std::string_view content) + -> std::expected { + const std::array artifact{OutputArtifact{std::string(destination), std::string(content)}}; + return publishOutputs(artifact); +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/output_plan.cpp b/IntaRNAnew/src/output_plan.cpp new file mode 100644 index 0000000..5c659da --- /dev/null +++ b/IntaRNAnew/src/output_plan.cpp @@ -0,0 +1,463 @@ +#include "intarnanew/output_plan.hpp" + +#include "intarnanew/compression.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +enum class AuxiliaryScope { pair, query, target }; +enum class StreamKind { none, standardOutput, standardError }; + +struct DescriptorInfo { + std::size_t index{}; + std::string kind; + std::string destination; + AuxiliaryScope scope{AuxiliaryScope::pair}; +}; + +struct DestinationIdentity { + StreamKind stream{StreamKind::none}; + std::filesystem::path path; +}; + +struct StagedFile { + std::filesystem::path destination; + std::filesystem::path temporary; + std::filesystem::path backup; + bool hadOriginal{}; + bool committed{}; +}; + +[[nodiscard]] auto lowerAscii(const std::string_view value) -> std::string { + std::string result(value); + std::ranges::transform(result, result.begin(), [](const unsigned char character) { + return static_cast(std::tolower(character)); + }); + return result; +} + +[[nodiscard]] auto descriptorKind(const std::string_view descriptor) -> std::string { + return lowerAscii(descriptor.substr(0U, descriptor.find(':'))); +} + +[[nodiscard]] auto streamKind(const std::string_view destination) noexcept -> StreamKind { + if (destination.empty() || destination == "-") return StreamKind::standardOutput; + const auto normalized = lowerAscii(destination); + if (normalized == "stdout") return StreamKind::standardOutput; + if (normalized == "stderr") return StreamKind::standardError; + return StreamKind::none; +} + +[[nodiscard]] auto normalizedStreamName(const StreamKind stream) -> std::string { + return stream == StreamKind::standardError ? "STDERR" : "STDOUT"; +} + +[[nodiscard]] auto auxiliaryScope(const std::string_view kind) -> AuxiliaryScope { + if (kind == "qacc" || kind == "qpu") return AuxiliaryScope::query; + if (kind == "tacc" || kind == "tpu") return AuxiliaryScope::target; + return AuxiliaryScope::pair; +} + +[[nodiscard]] auto withSuffix( + const std::string_view destination, + const std::string_view suffix) -> std::string { + if (suffix.empty() || streamKind(destination) != StreamKind::none) { + return std::string(destination); + } + const std::filesystem::path path(destination); + const auto extension = path.extension(); + const auto filename = path.stem().string() + std::string(suffix) + extension.string(); + return (path.parent_path() / filename).string(); +} + +[[nodiscard]] auto destinationIdentity(const std::string_view destination) + -> std::expected { + const auto stream = streamKind(destination); + if (stream != StreamKind::none) return DestinationIdentity{stream, {}}; + + std::error_code error; + auto absolute = std::filesystem::absolute(std::filesystem::path(destination), error); + if (error) { + return std::unexpected( + "cannot resolve output destination '" + std::string(destination) + "': " + + error.message()); + } + auto resolved = std::filesystem::weakly_canonical(absolute, error); + if (error) resolved = absolute.lexically_normal(); + + auto status = std::filesystem::symlink_status(resolved, error); + if (error == std::make_error_code(std::errc::no_such_file_or_directory)) { + error.clear(); + status = std::filesystem::file_status{std::filesystem::file_type::not_found}; + } else if (error) { + return std::unexpected( + "cannot inspect output destination '" + std::string(destination) + "': " + + error.message()); + } + if (status.type() != std::filesystem::file_type::not_found && + !std::filesystem::is_regular_file(resolved, error)) { + return std::unexpected( + "output destination '" + std::string(destination) + "' is not a regular file"); + } + if (error) { + return std::unexpected( + "cannot inspect output destination '" + std::string(destination) + "': " + + error.message()); + } + + auto parent = resolved.parent_path(); + if (parent.empty()) parent = std::filesystem::current_path(error); + if (error || !std::filesystem::is_directory(parent, error)) { + return std::unexpected( + "parent directory for output destination '" + std::string(destination) + + "' does not exist or is not a directory"); + } + return DestinationIdentity{StreamKind::none, std::move(resolved)}; +} + +[[nodiscard]] auto sameDestination( + const DestinationIdentity& first, + const DestinationIdentity& second) -> bool { + if (first.stream != StreamKind::none || second.stream != StreamKind::none) { + return first.stream != StreamKind::none && first.stream == second.stream; + } + if (first.path == second.path) return true; + std::error_code error; + const auto equivalent = std::filesystem::equivalent(first.path, second.path, error); + return !error && equivalent; +} + +[[nodiscard]] auto validatePublications(const std::vector& publications) + -> std::expected { + std::vector identities; + identities.reserve(publications.size()); + for (std::size_t index = 0U; index < publications.size(); ++index) { + auto identity = destinationIdentity(publications[index].destination); + if (!identity) return std::unexpected(identity.error()); + for (std::size_t previous = 0U; previous < identities.size(); ++previous) { + if (!sameDestination(*identity, identities[previous])) continue; + const auto stream = identity->stream; + if (stream != StreamKind::none) { + return std::unexpected( + "multiple output descriptors write " + normalizedStreamName(stream)); + } + return std::unexpected( + "output destinations '" + publications[previous].destination + "' and '" + + publications[index].destination + "' resolve to the same file"); + } + identities.push_back(std::move(*identity)); + } + return {}; +} + +[[nodiscard]] auto uniqueSibling( + const std::filesystem::path& destination, + const std::string_view marker) -> std::filesystem::path { + static std::atomic_size_t sequence{}; + const auto timestamp = std::chrono::steady_clock::now().time_since_epoch().count(); + for (;;) { + auto candidate = destination; + candidate += "." + std::string(marker) + "." + std::to_string(timestamp) + "." + + std::to_string(sequence.fetch_add(1U, std::memory_order_relaxed)); + std::error_code error; + if (!std::filesystem::exists(candidate, error)) return candidate; + } +} + +void removeIfPresent(const std::filesystem::path& path) noexcept { + if (path.empty()) return; + std::error_code ignored; + std::filesystem::remove(path, ignored); +} + +void cleanStaging(std::vector& files) noexcept { + for (auto& file : files) { + removeIfPresent(file.temporary); + if (!file.committed) removeIfPresent(file.backup); + } +} + +[[nodiscard]] auto rollback(std::vector& files, const std::string& reason) + -> std::expected { + std::string rollbackError; + for (auto iterator = files.rbegin(); iterator != files.rend(); ++iterator) { + auto& file = *iterator; + if (file.committed) { + removeIfPresent(file.destination); + if (file.hadOriginal) { + std::error_code error; + std::filesystem::rename(file.backup, file.destination, error); + if (error && rollbackError.empty()) { + rollbackError = " (also failed to restore '" + file.destination.string() + + "': " + error.message() + ")"; + } + } + } else if (file.hadOriginal && !file.backup.empty()) { + std::error_code error; + std::filesystem::rename(file.backup, file.destination, error); + if (error && rollbackError.empty()) { + rollbackError = " (also failed to restore '" + file.destination.string() + + "': " + error.message() + ")"; + } + } + removeIfPresent(file.temporary); + } + return std::unexpected(reason + rollbackError); +} + +} // namespace + +auto isAuxiliaryOutput(const std::string_view descriptor) noexcept -> bool { + const auto kind = descriptorKind(descriptor); + return kind == "qmine" || kind == "tmine" || kind == "qspotprob" || + kind == "tspotprob" || kind == "qacc" || kind == "tacc" || + kind == "qpu" || kind == "tpu" || kind == "pmine" || kind == "spotprob"; +} + +auto auxiliaryOutputDestination(const std::string_view descriptor) -> std::string { + const auto first = descriptor.find(':'); + if (first == std::string_view::npos) return "STDOUT"; + if (descriptorKind(descriptor) == "spotprob") { + const auto second = descriptor.find(':', first + 1U); + return second == std::string_view::npos + ? std::string(descriptor.substr(first + 1U)) + : std::string(descriptor.substr(second + 1U)); + } + return std::string(descriptor.substr(first + 1U)); +} + +auto planOutputs( + const Config& config, + const std::size_t targetCount, + const std::size_t queryCount, + const std::span groups) -> std::expected { + if (targetCount == 0U || queryCount == 0U) { + return std::unexpected("output planning requires at least one target and one query"); + } + for (const auto& group : groups) { + if (group.targetIndex >= targetCount || group.queryIndex >= queryCount) { + return std::unexpected("output group references a sequence outside the selected set"); + } + } + + std::vector primaryIndices; + std::vector auxiliary; + for (std::size_t index = 0U; index < config.output.destinations.size(); ++index) { + const auto& descriptor = config.output.destinations[index]; + if (!isAuxiliaryOutput(descriptor)) { + primaryIndices.push_back(index); + continue; + } + const auto kind = descriptorKind(descriptor); + auxiliary.push_back({ + index, kind, auxiliaryOutputDestination(descriptor), auxiliaryScope(kind), + }); + } + if (primaryIndices.size() > 1U) { + return std::unexpected("primary output destination was specified more than once"); + } + + OutputPlan plan; + OutputPublication primary; + primary.destination = primaryIndices.empty() + ? "STDOUT" : config.output.destinations[primaryIndices.front()]; + primary.parts.reserve(groups.size()); + for (std::size_t groupIndex = 0U; groupIndex < groups.size(); ++groupIndex) { + primary.parts.push_back({OutputPartKind::primary, 0U, groupIndex, 0U}); + } + plan.publications.push_back(std::move(primary)); + + std::set> distinctPairs; + std::map, std::size_t> groupsPerPair; + for (const auto& group : groups) { + const auto pair = std::pair{group.targetIndex, group.queryIndex}; + distinctPairs.insert(pair); + ++groupsPerPair[pair]; + } + const auto multiplePairs = distinctPairs.size() > 1U; + + for (const auto& descriptor : auxiliary) { + const auto stream = streamKind(descriptor.destination); + if (descriptor.scope == AuxiliaryScope::pair) { + if (stream != StreamKind::none) { + OutputPublication publication{normalizedStreamName(stream), {}}; + publication.parts.reserve(groups.size()); + for (std::size_t groupIndex = 0U; groupIndex < groups.size(); ++groupIndex) { + publication.parts.push_back({ + OutputPartKind::pairAuxiliary, descriptor.index, groupIndex, 0U, + }); + } + plan.publications.push_back(std::move(publication)); + continue; + } + for (std::size_t groupIndex = 0U; groupIndex < groups.size(); ++groupIndex) { + const auto& group = groups[groupIndex]; + std::string suffix; + if (multiplePairs) { + suffix += "-t" + std::to_string(group.targetIndex + 1U) + + "q" + std::to_string(group.queryIndex + 1U); + } + const auto pair = std::pair{group.targetIndex, group.queryIndex}; + if (groupsPerPair[pair] > 1U) { + suffix += "-rT" + std::to_string(group.targetRegionIndex + 1U) + + "Q" + std::to_string(group.queryRegionIndex + 1U); + } + plan.publications.push_back({ + withSuffix(descriptor.destination, suffix), + {{OutputPartKind::pairAuxiliary, descriptor.index, groupIndex, 0U}}, + }); + } + continue; + } + + const auto sequenceCount = descriptor.scope == AuxiliaryScope::query + ? queryCount : targetCount; + const auto partKind = descriptor.scope == AuxiliaryScope::query + ? OutputPartKind::queryAccessibility : OutputPartKind::targetAccessibility; + if (stream != StreamKind::none) { + OutputPublication publication{normalizedStreamName(stream), {}}; + publication.parts.reserve(sequenceCount); + for (std::size_t sequenceIndex = 0U; sequenceIndex < sequenceCount; ++sequenceIndex) { + publication.parts.push_back({ + partKind, descriptor.index, 0U, sequenceIndex, + }); + } + plan.publications.push_back(std::move(publication)); + continue; + } + for (std::size_t sequenceIndex = 0U; sequenceIndex < sequenceCount; ++sequenceIndex) { + const auto suffix = sequenceCount > 1U + ? "-s" + std::to_string(sequenceIndex + 1U) : std::string{}; + plan.publications.push_back({ + withSuffix(descriptor.destination, suffix), + {{partKind, descriptor.index, 0U, sequenceIndex}}, + }); + } + } + + if (auto status = validatePublications(plan.publications); !status) { + return std::unexpected(status.error()); + } + return plan; +} + +auto publishOutputs(const std::span artifacts) + -> std::expected { + std::vector publications; + publications.reserve(artifacts.size()); + for (const auto& artifact : artifacts) { + publications.push_back({artifact.destination, {}}); + } + if (auto status = validatePublications(publications); !status) { + return std::unexpected(status.error()); + } + + std::vector staged; + staged.reserve(artifacts.size()); + for (const auto& artifact : artifacts) { + if (streamKind(artifact.destination) != StreamKind::none) continue; + const std::filesystem::path destination(artifact.destination); + std::string compressed; + std::string_view payload = artifact.content; + if (lowerAscii(artifact.destination).ends_with(".gz")) { + auto encoded = gzipCompress(artifact.content); + if (!encoded) { + cleanStaging(staged); + return std::unexpected( + "cannot gzip output file '" + artifact.destination + "': " + encoded.error()); + } + compressed = std::move(*encoded); + payload = compressed; + } + if (payload.size() > static_cast( + std::numeric_limits::max())) { + cleanStaging(staged); + return std::unexpected("output file '" + artifact.destination + "' is too large to write"); + } + + StagedFile file; + file.destination = destination; + file.temporary = uniqueSibling(destination, "intarnanew-stage"); + { + std::ofstream output(file.temporary, std::ios::binary | std::ios::trunc); + if (!output) { + cleanStaging(staged); + return std::unexpected( + "cannot create temporary output for '" + artifact.destination + "'"); + } + output.write(payload.data(), static_cast(payload.size())); + output.close(); + if (!output) { + removeIfPresent(file.temporary); + cleanStaging(staged); + return std::unexpected("failed to stage output file '" + artifact.destination + "'"); + } + } + staged.push_back(std::move(file)); + } + + for (auto& file : staged) { + std::error_code error; + auto status = std::filesystem::symlink_status(file.destination, error); + if (error == std::make_error_code(std::errc::no_such_file_or_directory)) { + error.clear(); + status = std::filesystem::file_status{std::filesystem::file_type::not_found}; + } else if (error) { + return rollback(staged, + "cannot inspect output file '" + file.destination.string() + "': " + error.message()); + } + if (status.type() != std::filesystem::file_type::not_found) { + if (!std::filesystem::is_regular_file(file.destination, error) || error) { + return rollback(staged, + "cannot replace non-regular output '" + file.destination.string() + "'"); + } + file.hadOriginal = true; + file.backup = uniqueSibling(file.destination, "intarnanew-backup"); + std::filesystem::rename(file.destination, file.backup, error); + if (error) { + return rollback(staged, + "cannot preserve output file '" + file.destination.string() + "': " + + error.message()); + } + } + error.clear(); + std::filesystem::rename(file.temporary, file.destination, error); + if (error) { + return rollback(staged, + "cannot commit output file '" + file.destination.string() + "': " + error.message()); + } + file.temporary.clear(); + file.committed = true; + } + + for (auto& file : staged) removeIfPresent(file.backup); + + for (const auto& artifact : artifacts) { + const auto stream = streamKind(artifact.destination); + if (stream == StreamKind::standardOutput) { + std::cout << artifact.content; + if (!std::cout) return std::unexpected("failed to write standard output"); + } else if (stream == StreamKind::standardError) { + std::cerr << artifact.content; + if (!std::cerr) return std::unexpected("failed to write standard error"); + } + } + return {}; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/predictor.cpp b/IntaRNAnew/src/predictor.cpp new file mode 100644 index 0000000..2c0fc58 --- /dev/null +++ b/IntaRNAnew/src/predictor.cpp @@ -0,0 +1,1528 @@ +#include "intarnanew/predictor.hpp" + +#include "intarnanew/helix_blocks.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +[[nodiscard]] auto logAdd(const double left, const double right) noexcept -> double { + if (!std::isfinite(left) && left < 0.0) return right; + if (!std::isfinite(right) && right < 0.0) return left; + const auto high = std::max(left, right); + const auto low = std::min(left, right); + return high + std::log1p(std::exp(low - high)); +} + +struct ExplicitSeed { + std::vector pairs; +}; + +struct PathState { + std::vector path; + Energy hybridEnergy{}; + double logWeight{}; + bool seedMatched{}; + bool lastHasLeftStack{}; + std::vector seeds; +}; + +struct StateKey { + Index startTarget{}; + Index startQuery{}; + bool seedMatched{}; + bool lastHasLeftStack{}; + Index seedProgress{}; + std::vector suffix; + + friend auto operator==(const StateKey&, const StateKey&) -> bool = default; +}; + +struct StateKeyHash { + [[nodiscard]] auto operator()(const StateKey& key) const noexcept -> std::size_t { + auto value = std::hash{}(key.startTarget); + const auto mix = [&](const std::size_t next) { + value ^= next + 0x9e3779b97f4a7c15ULL + (value << 6U) + (value >> 2U); + }; + mix(std::hash{}(key.startQuery)); + mix(std::hash{}(key.seedMatched)); + mix(std::hash{}(key.lastHasLeftStack)); + mix(std::hash{}(key.seedProgress)); + for (const auto& pair : key.suffix) { + mix(std::hash{}(pair.target)); + mix(std::hash{}(pair.query)); + } + return value; + } +}; + +struct SiteKey { + Index targetBegin{}; + Index targetEnd{}; + Index queryBegin{}; + Index queryEnd{}; + + friend auto operator<=>(const SiteKey&, const SiteKey&) = default; +}; + +struct SiteAccumulator { + Interaction representative; + double logWeight{-infinity}; +}; + +[[nodiscard]] auto isStack(const BasePair left, const BasePair right) noexcept -> bool { + return right.target == left.target + 1U && left.query == right.query + 1U; +} + +[[nodiscard]] auto pathSignature(const std::span path) -> std::string { + std::string result; + result.reserve(path.size() * 16U); + for (const auto pair : path) { + result += std::to_string(pair.target); + result.push_back('&'); + result += std::to_string(pair.query); + result.push_back(','); + } + return result; +} + +[[nodiscard]] auto decimalLexicalCompare(Index left, Index right) noexcept -> int { + if (left == right) return 0; + Index leftDivisor{1U}; + Index rightDivisor{1U}; + while (leftDivisor <= left / 10U) leftDivisor *= 10U; + while (rightDivisor <= right / 10U) rightDivisor *= 10U; + while (leftDivisor != 0U && rightDivisor != 0U) { + const auto leftDigit = left / leftDivisor; + const auto rightDigit = right / rightDivisor; + if (leftDigit != rightDigit) return leftDigit < rightDigit ? -1 : 1; + left %= leftDivisor; + right %= rightDivisor; + leftDivisor /= 10U; + rightDivisor /= 10U; + } + return leftDivisor == 0U ? -1 : 1; +} + +[[nodiscard]] auto pathSignatureLess( + const std::span left, + const std::span right) noexcept -> bool { + const auto common = std::min(left.size(), right.size()); + for (Index index{}; index < common; ++index) { + if (const auto order = decimalLexicalCompare(left[index].target, right[index].target); + order != 0) return order < 0; + if (const auto order = decimalLexicalCompare(left[index].query, right[index].query); + order != 0) return order < 0; + } + return left.size() < right.size(); +} + +[[nodiscard]] auto effectiveLength(const SideConfig& side) noexcept -> Index { + Index result = side.interactionLengthMax; + if (side.accessibility == AccessibilityKind::compute && side.accessibilityWindow != 0U) { + result = result == 0U ? side.accessibilityWindow : std::min(result, side.accessibilityWindow); + } + return result; +} + +[[nodiscard]] auto parseSigned(const std::string_view text) -> std::expected { + long long value{}; + const auto [position, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || position != text.data() + text.size()) { + return std::unexpected("invalid sequence coordinate '" + std::string(text) + "'"); + } + return value; +} + +[[nodiscard]] auto splitTopLevel(const std::string_view text, const char delimiter) -> std::vector { + std::vector result; + std::size_t start{}; + for (std::size_t index = 0; index <= text.size(); ++index) { + if (index == text.size() || text[index] == delimiter) { + result.push_back(text.substr(start, index - start)); + start = index + 1U; + } + } + return result; +} + +[[nodiscard]] auto explicitSeeds( + const Sequence& target, + const Sequence& query, + const std::string& specification) -> std::expected, std::string> { + std::vector result; + if (specification.empty()) return result; + for (const auto encoded : splitTopLevel(specification, ',')) { + const auto ampersand = encoded.find('&'); + if (ampersand == std::string_view::npos) { + return std::unexpected("explicit seed must contain target&query encodings"); + } + const auto decodeSide = [](const std::string_view side) + -> std::expected, std::string> { + const auto patternStart = side.find_first_of("|."); + if (patternStart == std::string_view::npos || patternStart == 0U) { + return std::unexpected("explicit seed side needs a start coordinate and |/. pattern"); + } + auto coordinate = parseSigned(side.substr(0U, patternStart)); + if (!coordinate) return std::unexpected(coordinate.error()); + const auto pattern = side.substr(patternStart); + if (!std::ranges::all_of(pattern, [](const char symbol) { return symbol == '|' || symbol == '.'; })) { + return std::unexpected("explicit seed pattern contains an invalid symbol"); + } + return std::pair{*coordinate, pattern}; + }; + auto targetSide = decodeSide(encoded.substr(0U, ampersand)); + auto querySide = decodeSide(encoded.substr(ampersand + 1U)); + if (!targetSide) return std::unexpected(targetSide.error()); + if (!querySide) return std::unexpected(querySide.error()); + + std::vector targetBars; + std::vector queryBars; + for (Index offset = 0; offset < targetSide->second.size(); ++offset) { + if (targetSide->second[offset] == '|') { + auto position = target.internalIndex(targetSide->first + static_cast(offset)); + if (!position) return std::unexpected(position.error()); + targetBars.push_back(*position); + } + } + for (Index offset = 0; offset < querySide->second.size(); ++offset) { + if (querySide->second[offset] == '|') { + auto position = query.internalIndex(querySide->first + static_cast(offset)); + if (!position) return std::unexpected(position.error()); + queryBars.push_back(*position); + } + } + if (targetBars.empty() || targetBars.size() != queryBars.size()) { + return std::unexpected("explicit target/query seed patterns need equal nonzero pair counts"); + } + ExplicitSeed seed; + seed.pairs.reserve(targetBars.size()); + for (Index index = 0; index < targetBars.size(); ++index) { + seed.pairs.push_back({targetBars[index], queryBars[queryBars.size() - index - 1U]}); + } + result.push_back(std::move(seed)); + } + return result; +} + +[[nodiscard]] auto explicitSeedMatches( + const std::vector& path, + const std::vector& seeds) -> std::vector { + std::vector result; + for (const auto& seed : seeds) { + if (seed.pairs.size() > path.size()) continue; + for (Index start = 0; start + seed.pairs.size() <= path.size(); ++start) { + if (std::equal(seed.pairs.begin(), seed.pairs.end(), path.begin() + static_cast(start))) { + result.push_back({start, start + seed.pairs.size() - 1U, 0.0}); + } + } + } + return result; +} + +void mergeSeedMatches( + std::vector& retained, + std::vector candidates) { + for (auto& candidate : candidates) { + const auto duplicate = std::ranges::find_if(retained, [&](const SeedMatch& existing) { + return existing.firstPair == candidate.firstPair && + existing.lastPair == candidate.lastPair; + }); + if (duplicate == retained.end()) { + retained.push_back(std::move(candidate)); + } else if (seedMatchLess(candidate, *duplicate)) { + *duplicate = std::move(candidate); + } + } + std::ranges::sort(retained, seedMatchLess); +} + +[[nodiscard]] auto guEndViolation( + const Sequence& target, + const Sequence& query, + const std::vector& path) noexcept -> bool { + if (path.empty()) return false; + if (isGuPair(target[path.front().target], query[path.front().query]) || + isGuPair(target[path.back().target], query[path.back().query])) return true; + for (Index index = 1U; index < path.size(); ++index) { + if (!isStack(path[index - 1U], path[index]) && + (isGuPair(target[path[index - 1U].target], query[path[index - 1U].query]) || + isGuPair(target[path[index].target], query[path[index].query]))) return true; + } + return false; +} + +[[nodiscard]] auto stateKey( + const PathState& state, + const Config& config, + const std::span anchoredSeeds) -> StateKey { + StateKey key{ + state.path.front().target, + state.path.front().query, + state.seedMatched, + state.lastHasLeftStack, + 0U, + {}, + }; + if (config.model == InteractionModel::helixBlocks) { + // Helix admissibility is path dependent. Keep paths distinct until the + // documented block decomposition has been checked. + key.suffix = state.path; + } else if (!anchoredSeeds.empty() && !state.seedMatched) { + // Before an explicit anchor starts, future transitions only depend on + // the current pair, so one MFE path is sufficient. Once an anchor + // prefix has started, retain that prefix as the small automaton state. + // This avoids carrying arbitrary path suffixes while ensuring that a + // weaker path capable of completing the requested anchor is not + // merged into an incompatible one. + Index retained{}; + for (const auto& seed : anchoredSeeds) { + const auto maximum = std::min(state.path.size(), seed.pairs.size() - 1U); + for (Index length = maximum; length > retained; --length) { + if (std::equal( + state.path.end() - static_cast(length), + state.path.end(), seed.pairs.begin())) { + retained = length; + break; + } + } + } + if (retained != 0U) { + key.suffix.assign( + state.path.end() - static_cast(retained), state.path.end()); + } + } else if (config.seed.required && !state.seedMatched) { + if (config.seed.maxUnpaired == 0U && + config.seed.queryMaxUnpaired <= 0 && + config.seed.targetMaxUnpaired <= 0) { + // For the common canonical seed, progress is only the trailing + // stacked-run length. The current cell already identifies its + // final pair, so hashing/copying the full suffix is redundant. + Index progress = state.path.empty() ? 0U : 1U; + for (Index index = state.path.size(); index > 1U && + progress + 1U < config.seed.basePairs; --index) { + if (!isStack(state.path[index - 2U], state.path[index - 1U])) break; + ++progress; + } + key.seedProgress = progress; + } else { + const auto retained = std::min(state.path.size(), config.seed.basePairs > 0U + ? config.seed.basePairs - 1U + : 0U); + key.suffix.assign( + state.path.end() - static_cast(retained), state.path.end()); + } + } + return key; +} + +void mergeState( + std::unordered_map& states, + PathState candidate, + const Config& config, + const std::span anchoredSeeds) { + auto key = stateKey(candidate, config, anchoredSeeds); + const auto found = states.find(key); + if (found == states.end()) { + states.emplace(std::move(key), std::move(candidate)); + return; + } + found->second.logWeight = logAdd(found->second.logWeight, candidate.logWeight); + if (candidate.hybridEnergy < found->second.hybridEnergy - 1e-12 || + (std::abs(candidate.hybridEnergy - found->second.hybridEnergy) <= 1e-12 && + pathSignatureLess(candidate.path, found->second.path))) { + const auto combined = found->second.logWeight; + found->second = std::move(candidate); + found->second.logWeight = combined; + } +} + +[[nodiscard]] auto overlapsSelected( + const Interaction& candidate, + const std::vector& selected, + const OverlapPolicy policy) -> bool { + if (policy == OverlapPolicy::both) return false; + return std::ranges::any_of(selected, [&](const Interaction& existing) { + const bool targetOverlap = candidate.targetRange().overlaps(existing.targetRange()); + const bool queryOverlap = candidate.queryRange().overlaps(existing.queryRange()); + if (policy == OverlapPolicy::neither) return targetOverlap || queryOverlap; + if (policy == OverlapPolicy::target) return queryOverlap; + return targetOverlap; + }); +} + +[[nodiscard]] auto interactionLess(const Interaction& left, const Interaction& right) -> bool { + const auto energyDifference = left.energy.total() - right.energy.total(); + if (std::abs(energyDifference) > 1e-9) return energyDifference < 0.0; + const auto lt = left.targetRange(); + const auto rt = right.targetRange(); + const auto lq = left.queryRange(); + const auto rq = right.queryRange(); + return std::tie(lt.begin, lt.end, lq.begin, lq.end) < std::tie(rt.begin, rt.end, rq.begin, rq.end); +} + +// Legacy-compatible suboptimal traversal is asymmetric after the global +// optimum was chosen: if target positions must not overlap, equal-energy +// follow-up candidates are visited from the high query coordinate downwards. +// This mirrors rerunning the dynamic program on the remaining target domains, +// while keeping the first optimum and all other policies in canonical order. +[[nodiscard]] auto interactionFollowupLess( + const Interaction& left, + const Interaction& right) -> bool { + const auto energyDifference = left.energy.total() - right.energy.total(); + if (std::abs(energyDifference) > 1e-9) return energyDifference < 0.0; + const auto lt = left.targetRange(); + const auto rt = right.targetRange(); + if (const auto targetOrder = std::tie(lt.begin, lt.end) <=> + std::tie(rt.begin, rt.end); + targetOrder != 0) { + return targetOrder < 0; + } + const auto lq = left.queryRange(); + const auto rq = right.queryRange(); + return std::tie(rq.begin, rq.end) < std::tie(lq.begin, lq.end); +} + +[[nodiscard]] auto interactionSiteSignature(const Interaction& interaction) -> std::string { + const auto target = interaction.targetRange(); + const auto query = interaction.queryRange(); + return interaction.targetId + "\n" + interaction.queryId + "\n" + + std::to_string(target.begin) + ":" + std::to_string(target.end) + "&" + + std::to_string(query.begin) + ":" + std::to_string(query.end); +} + +[[nodiscard]] auto ensembleLess(const Interaction& left, const Interaction& right) -> bool { + if (std::abs(left.ensembleFreeEnergy - right.ensembleFreeEnergy) > 1e-9) { + return left.ensembleFreeEnergy < right.ensembleFreeEnergy; + } + return interactionLess(left, right); +} + +[[nodiscard]] auto ensembleFollowupLess( + const Interaction& left, + const Interaction& right) -> bool { + if (std::abs(left.ensembleFreeEnergy - right.ensembleFreeEnergy) > 1e-9) { + return left.ensembleFreeEnergy < right.ensembleFreeEnergy; + } + return interactionFollowupLess(left, right); +} + +void orderForOutput( + std::vector& interactions, + const Config& config) { + const auto primaryLess = [&](const Interaction* left, const Interaction* right) { + return config.model == InteractionModel::ensemble + ? ensembleLess(*left, *right) : interactionLess(*left, *right); + }; + std::ranges::sort(interactions, primaryLess); + if (interactions.size() < 2U || + (config.output.overlap != OverlapPolicy::neither && + config.output.overlap != OverlapPolicy::query)) { + return; + } + const auto followupLess = [&](const Interaction* left, const Interaction* right) { + return config.model == InteractionModel::ensemble + ? ensembleFollowupLess(*left, *right) + : interactionFollowupLess(*left, *right); + }; + std::ranges::sort(interactions.begin() + 1, interactions.end(), followupLess); +} + +void initializeMonomerEnsembles( + PredictionResult& result, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) noexcept { + if (const auto logPartition = targetAccessibility.ensembleLogPartition()) { + result.targetLogPartition = *logPartition; + } + if (const auto freeEnergy = targetAccessibility.ensembleFreeEnergy()) { + result.targetEnsembleFreeEnergy = *freeEnergy; + } + if (const auto logPartition = queryAccessibility.ensembleLogPartition()) { + result.queryLogPartition = *logPartition; + } + if (const auto freeEnergy = queryAccessibility.ensembleFreeEnergy()) { + result.queryEnsembleFreeEnergy = *freeEnergy; + } +} + +// For selected-only exact base-pair/model-S output, a site's MFE is just the +// negative maximum pair count plus one constant. A scalar max-plus recurrence +// and bounded top-k therefore preserve all observable results without the +// generic engine's paths, hash states, Boltzmann sums, or all-site materialization. +struct CompactSite { + Index targetBegin{}; + Index targetEnd{}; + Index queryBegin{}; + Index queryEnd{}; + std::uint16_t pairCount{}; +}; + +[[nodiscard]] auto compactSiteLess( + const CompactSite& left, + const CompactSite& right, + const Energy additiveEnergy) noexcept -> bool { + const auto leftEnergy = -static_cast(left.pairCount) + additiveEnergy; + const auto rightEnergy = -static_cast(right.pairCount) + additiveEnergy; + const auto energyDifference = leftEnergy - rightEnergy; + if (std::abs(energyDifference) > 1e-9) return energyDifference < 0.0; + return std::tie(left.targetBegin, left.targetEnd, left.queryBegin, left.queryEnd) < + std::tie(right.targetBegin, right.targetEnd, right.queryBegin, right.queryEnd); +} + +[[nodiscard]] auto compactSpan(const SideConfig& side, const Index domainSize) noexcept -> Index { + const auto limit = effectiveLength(side); + return limit == 0U ? domainSize : std::min(limit, domainSize); +} + +[[nodiscard]] auto compactCanPair(const char target, const char query) noexcept -> bool { + switch (target) { + case 'A': return query == 'U'; + case 'C': return query == 'G'; + case 'G': return query == 'C' || query == 'U'; + case 'U': return query == 'A' || query == 'G'; + default: return false; + } +} + +[[nodiscard]] auto supportsCompactExactBasePair( + const Config& config, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility, + const Interval targetDomain, + const Interval queryDomain) noexcept -> bool { + const auto* targetDisabled = + dynamic_cast(&targetAccessibility); + const auto* queryDisabled = + dynamic_cast(&queryAccessibility); + const auto targetSpan = compactSpan(config.target, targetDomain.size()); + const auto querySpan = compactSpan(config.query, queryDomain.size()); + return !config.predictionRequirements.retainAllSites && + !config.predictionRequirements.computeInteractionPartition && + !config.predictionRequirements.traceback && + config.mode == PredictionMode::exact && + config.model == InteractionModel::singleSite && + config.energy == EnergyKind::basePair && + !config.seed.required && + config.seed.explicitSeeds.empty() && + config.target.accessibility == AccessibilityKind::disabled && + config.query.accessibility == AccessibilityKind::disabled && + config.target.accessibilityConstraint.empty() && + config.query.accessibilityConstraint.empty() && + config.target.regions.empty() && + config.query.regions.empty() && + config.target.regionLengthMax == 0U && + config.query.regionLengthMax == 0U && + config.windowWidth == 0U && + !config.output.noLonelyPairs && + !config.output.noGuAtEnds && + config.output.minUnpairedProbability <= 0.0 && + config.output.overlap == OverlapPolicy::both && + targetDisabled != nullptr && queryDisabled != nullptr && + targetDisabled->unconstrained(targetDomain) && + queryDisabled->unconstrained(queryDomain) && + targetSpan != 0U && querySpan != 0U && + targetSpan <= std::numeric_limits::max() && + querySpan <= std::numeric_limits::max(); +} + +[[nodiscard]] auto compactExactBasePairPrediction( + const Config& config, + const Sequence& target, + const Sequence& query, + const Interval targetDomain, + const Interval queryDomain, + PredictionResult result) -> PredictionResult { + const Index resultLimit = config.output.number; + if (resultLimit == 0U) return result; + + const Index targetSpan = compactSpan(config.target, targetDomain.size()); + const Index querySpan = compactSpan(config.query, queryDomain.size()); + if (targetSpan == 0U || querySpan == 0U) return result; + const Index targetTransition = config.target.interactionLoopMax >= targetSpan - 1U + ? targetSpan : config.target.interactionLoopMax + 1U; + const Index queryTransition = config.query.interactionLoopMax >= querySpan - 1U + ? querySpan : config.query.interactionLoopMax + 1U; + const Index scoreRows = targetTransition; + if (scoreRows > std::numeric_limits::max() / querySpan) { + throw std::length_error("compact predictor matrix size overflows addressable memory"); + } + + std::vector scores(scoreRows * querySpan); + std::vector vertical(querySpan); + std::vector deque(querySpan); + std::vector retained; + retained.reserve(std::min(resultLimit, 1024U)); + + const auto totalEnergy = [&](const std::uint16_t pairCount) noexcept -> Energy { + return -static_cast(pairCount) + config.additiveEnergy; + }; + const auto siteLess = [&](const CompactSite& left, const CompactSite& right) noexcept { + return compactSiteLess(left, right, config.additiveEnergy); + }; + const auto retain = [&](const CompactSite candidate) { + if (!std::isfinite(totalEnergy(candidate.pairCount)) || + totalEnergy(candidate.pairCount) >= config.output.maxEnergy - 1e-12) { + return; + } + if (retained.size() == resultLimit && + !siteLess(candidate, retained.back())) { + return; + } + const auto position = std::ranges::lower_bound( + retained, candidate, siteLess); + retained.insert(position, candidate); + if (retained.size() > resultLimit) retained.pop_back(); + }; + + const Index queryLength = queryDomain.size(); + for (Index targetBegin = targetDomain.begin; + targetBegin <= targetDomain.end; + ++targetBegin) { + const Index maximumTargetOffset = + std::min(targetSpan - 1U, targetDomain.end - targetBegin); + for (Index queryBeginReverse = 0U; + queryBeginReverse < queryLength; + ++queryBeginReverse) { + const Index queryEnd = queryDomain.end - queryBeginReverse; + if (!compactCanPair(target.str()[targetBegin], query.str()[queryEnd])) continue; + const Index maximumQueryOffset = + std::min(querySpan - 1U, queryLength - queryBeginReverse - 1U); + + std::ranges::fill(scores, std::uint16_t{}); + scores.front() = 1U; + retain({targetBegin, targetBegin, queryEnd, queryEnd, 1U}); + + for (Index targetOffset = 1U; + targetOffset <= maximumTargetOffset; + ++targetOffset) { + const Index firstPreviousTarget = + targetOffset > targetTransition + ? targetOffset - targetTransition + : 0U; + for (Index queryOffset = 0U; + queryOffset <= maximumQueryOffset; + ++queryOffset) { + std::uint16_t best{}; + for (Index previousTarget = firstPreviousTarget; + previousTarget < targetOffset; + ++previousTarget) { + best = std::max( + best, scores[(previousTarget % scoreRows) * querySpan + queryOffset]); + } + vertical[queryOffset] = best; + } + + const Index currentRow = targetOffset % scoreRows; + std::fill_n(scores.begin() + static_cast(currentRow * querySpan), + querySpan, std::uint16_t{}); + + Index head{}; + Index tail{}; + for (Index queryOffset = 1U; + queryOffset <= maximumQueryOffset; + ++queryOffset) { + const Index added = queryOffset - 1U; + while (tail > head && + vertical[deque[tail - 1U]] <= vertical[added]) { + --tail; + } + deque[tail++] = added; + const Index firstPreviousQuery = + queryOffset > queryTransition + ? queryOffset - queryTransition + : 0U; + while (head < tail && deque[head] < firstPreviousQuery) { + ++head; + } + if (head == tail || vertical[deque[head]] == 0U) continue; + + const Index targetEnd = targetBegin + targetOffset; + const Index queryBegin = + queryDomain.end - (queryBeginReverse + queryOffset); + if (!compactCanPair(target.str()[targetEnd], query.str()[queryBegin])) continue; + const auto pairCount = static_cast( + vertical[deque[head]] + 1U); + scores[currentRow * querySpan + queryOffset] = pairCount; + retain({ + targetBegin, targetEnd, queryBegin, queryEnd, pairCount, + }); + } + } + } + } + + if (retained.empty()) return result; + const auto minimumEnergy = totalEnergy(retained.front().pairCount); + result.interactions.reserve(retained.size()); + for (const auto& site : retained) { + if (totalEnergy(site.pairCount) > + minimumEnergy + config.output.deltaEnergy + 1e-9) { + continue; + } + Interaction interaction; + interaction.targetId = target.id(); + interaction.queryId = query.id(); + interaction.pairs.push_back({site.targetBegin, site.queryEnd}); + if (site.targetBegin != site.targetEnd) { + interaction.pairs.push_back({site.targetEnd, site.queryBegin}); + } + interaction.energy.initiation = -1.0; + interaction.energy.loops = + -static_cast(site.pairCount - 1U); + interaction.energy.additive = config.additiveEnergy; + interaction.ensembleFreeEnergy = interaction.energy.total(); + result.interactions.push_back(std::move(interaction)); + } + return result; +} + +} // namespace + +auto parseIntervals(const Sequence& sequence, const std::string& specification) + -> std::expected, std::string> { + if (sequence.empty()) return std::unexpected("cannot parse regions for an empty sequence"); + if (specification.empty()) return std::vector{{0U, sequence.size() - 1U}}; + std::vector result; + std::size_t start{}; + while (start < specification.size()) { + const auto comma = specification.find(',', start); + auto token = std::string_view(specification).substr( + start, comma == std::string::npos ? std::string::npos : comma - start); + while (!token.empty() && std::isspace(static_cast(token.front())) != 0) { + token.remove_prefix(1U); + } + while (!token.empty() && std::isspace(static_cast(token.back())) != 0) { + token.remove_suffix(1U); + } + if (token.empty()) return std::unexpected("region list contains an empty entry"); + std::optional separator; + for (std::size_t position = 1U; position < token.size(); ++position) { + if (token[position] == '-') { + separator = position; + break; + } + } + if (!separator) return std::unexpected("region must use FROM-TO encoding"); + auto first = parseSigned(token.substr(0U, *separator)); + auto last = parseSigned(token.substr(*separator + 1U)); + if (!first || !last || *first > *last) return std::unexpected("invalid region coordinate range"); + auto internalFirst = sequence.internalIndex(*first); + auto internalLast = sequence.internalIndex(*last); + if (!internalFirst || !internalLast) return std::unexpected("region is outside the sequence"); + result.push_back({*internalFirst, *internalLast}); + if (comma == std::string::npos) break; + start = comma + 1U; + } + if (start == specification.size()) return std::unexpected("region list contains an empty entry"); + std::ranges::sort(result, [](const Interval left, const Interval right) { + return std::tie(left.begin, left.end) < std::tie(right.begin, right.end); + }); + result.erase(std::unique(result.begin(), result.end()), result.end()); + for (Index index = 1U; index < result.size(); ++index) { + if (result[index - 1U].overlaps(result[index])) { + return std::unexpected("overlapping regions are ambiguous; use disjoint intervals"); + } + } + return result; +} + +auto configuredRegions(const Sequence& sequence, const SideConfig& config) + -> std::expected, std::string> { + if (!config.regions.empty() && config.regionLengthMax != 0U) { + return std::unexpected("explicit and automatic regions are mutually exclusive"); + } + return parseIntervals(sequence, config.regions); +} + +auto decomposeAccessibleRegions( + const std::span input, + const std::size_t regionLengthMax, + const std::size_t seedLength, + const AccessibilityProvider& accessibility) -> std::vector { + if (regionLengthMax == 0U) return {input.begin(), input.end()}; + if (seedLength == 0U) throw std::invalid_argument("automatic region decomposition needs a positive seed length"); + + std::vector pending(input.begin(), input.end()); + std::vector result; + while (!pending.empty()) { + const auto current = pending.back(); + pending.pop_back(); + if (current.size() < seedLength) continue; + if (current.size() <= regionLengthMax) { + result.push_back(current); + continue; + } + + Index cutBegin = current.begin; + Energy highestOpening = -infinity; + for (Index candidate = current.begin; + candidate <= current.end + 1U - seedLength; + ++candidate) { + const auto opening = accessibility.openingEnergy( + {candidate, candidate + seedLength - 1U}); + if (opening > highestOpening + 1e-12) { + highestOpening = opening; + cutBegin = candidate; + } + } + const Index cutEnd = cutBegin + seedLength - 1U; + if (cutEnd < current.end) pending.push_back({cutEnd + 1U, current.end}); + if (cutBegin > current.begin) pending.push_back({current.begin, cutBegin - 1U}); + } + std::ranges::sort(result, {}, &Interval::begin); + return result; +} + +auto decomposeWindows( + const Interval parent, + const std::size_t width, + const std::size_t overlap) -> std::vector { + if (parent.size() == 0U) return {}; + if (width == 0U || parent.size() <= width) return {parent}; + if (overlap >= width) throw std::invalid_argument("window overlap has to be smaller than its width"); + + const Index step = width - overlap; + std::vector result; + for (Index begin = parent.begin;;) { + const auto end = std::min(parent.end, begin + width - 1U); + result.push_back({begin, end}); + if (end == parent.end) break; + begin += step; + } + return result; +} + +auto reducePredictions( + const std::span predictions, + const Config& config) -> PredictionResult { + PredictionResult result; + if (predictions.empty()) return result; + result.rt = predictions.front().rt; + result.targetLogPartition = predictions.front().targetLogPartition; + result.queryLogPartition = predictions.front().queryLogPartition; + result.targetEnsembleFreeEnergy = predictions.front().targetEnsembleFreeEnergy; + result.queryEnsembleFreeEnergy = predictions.front().queryEnsembleFreeEnergy; + + std::unordered_map unique; + for (const auto& prediction : predictions) { + if (std::abs(prediction.rt - result.rt) > 1e-12) { + throw std::invalid_argument("cannot reduce predictions with different RT values"); + } + for (const auto& interaction : prediction.ensembleSites) { + const auto signature = interactionSiteSignature(interaction); + const auto found = unique.find(signature); + if (found == unique.end()) { + unique.insert_or_assign(signature, interaction); + } else { + const auto siteEnergy = std::min( + found->second.ensembleFreeEnergy, interaction.ensembleFreeEnergy); + if (interactionLess(interaction, found->second)) found->second = interaction; + found->second.ensembleFreeEnergy = siteEnergy; + } + } + } + + result.ensembleSites.reserve(unique.size()); + for (auto& [signature, interaction] : unique) { + static_cast(signature); + result.ensembleSites.push_back(std::move(interaction)); + } + unique.clear(); + std::ranges::sort(result.ensembleSites, interactionLess); + + result.logPartition = -infinity; + result.ensembleFreeEnergy = infinity; + const bool seededExtensionWithoutPartition = + config.model == InteractionModel::seedExtension && + (config.seed.required || !config.seed.explicitSeeds.empty()); + if (!seededExtensionWithoutPartition) { + for (const auto& interaction : result.ensembleSites) { + if (std::isfinite(interaction.ensembleFreeEnergy)) { + result.logPartition = logAdd( + result.logPartition, -interaction.ensembleFreeEnergy / result.rt); + } + } + } + if (std::isfinite(result.logPartition)) { + result.ensembleFreeEnergy = -result.rt * result.logPartition; + for (auto& interaction : result.ensembleSites) { + interaction.probability = std::isfinite(interaction.ensembleFreeEnergy) + ? std::exp(-interaction.ensembleFreeEnergy / result.rt - result.logPartition) + : 0.0; + } + } + if (result.ensembleSites.empty() || config.output.number == 0U) return result; + + std::vector ranked; + ranked.reserve(result.ensembleSites.size()); + for (const auto& interaction : result.ensembleSites) ranked.push_back(&interaction); + orderForOutput(ranked, config); + const auto minimumEnergy = ranked.front()->energy.total(); + for (const auto* interaction : ranked) { + if (interaction->energy.total() > minimumEnergy + config.output.deltaEnergy + 1e-9) continue; + if (overlapsSelected(*interaction, result.interactions, config.output.overlap)) continue; + result.interactions.push_back(*interaction); + if (result.interactions.size() >= config.output.number) break; + } + return result; +} + +Predictor::Predictor(Config config) + : config_(std::move(config)), energy_(makeEnergyModel(config_)) {} + +auto Predictor::predict( + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility) const -> PredictionResult { + if (target.empty() || query.empty()) { + PredictionResult empty; + empty.rt = energy_->rt(); + initializeMonomerEnsembles(empty, targetAccessibility, queryAccessibility); + return empty; + } + auto targetRegionsResult = configuredRegions(target, config_.target); + auto queryRegionsResult = configuredRegions(query, config_.query); + if (!targetRegionsResult) throw std::invalid_argument(targetRegionsResult.error()); + if (!queryRegionsResult) throw std::invalid_argument(queryRegionsResult.error()); + auto targetRegions = decomposeAccessibleRegions( + *targetRegionsResult, config_.target.regionLengthMax, config_.seed.basePairs, + targetAccessibility); + auto queryRegions = decomposeAccessibleRegions( + *queryRegionsResult, config_.query.regionLengthMax, config_.seed.basePairs, + queryAccessibility); + + std::vector predictions; + predictions.reserve(targetRegions.size() * queryRegions.size()); + for (const auto targetRegion : targetRegions) { + for (const auto queryRegion : queryRegions) { + predictions.push_back(predict(target, query, targetAccessibility, queryAccessibility, + targetRegion, queryRegion)); + } + } + if (predictions.empty()) { + PredictionResult empty; + empty.rt = energy_->rt(); + initializeMonomerEnsembles(empty, targetAccessibility, queryAccessibility); + return empty; + } + if (predictions.size() == 1U) return std::move(predictions.front()); + return reducePredictions(predictions, config_); +} + +auto Predictor::predict( + const Sequence& target, + const Sequence& query, + const AccessibilityProvider& targetAccessibility, + const AccessibilityProvider& queryAccessibility, + const Interval targetDomain, + const Interval queryDomain) const -> PredictionResult { + PredictionResult result; + result.rt = energy_->rt(); + initializeMonomerEnsembles(result, targetAccessibility, queryAccessibility); + if (target.empty() || query.empty()) return result; + if (targetDomain.begin > targetDomain.end || targetDomain.end >= target.size() || + queryDomain.begin > queryDomain.end || queryDomain.end >= query.size()) { + throw std::out_of_range("prediction domain is outside the input sequence"); + } + + if (supportsCompactExactBasePair( + config_, targetAccessibility, queryAccessibility, + targetDomain, queryDomain)) { + return compactExactBasePairPrediction( + config_, target, query, targetDomain, queryDomain, std::move(result)); + } + auto explicitSeedResult = explicitSeeds(target, query, config_.seed.explicitSeeds); + if (!explicitSeedResult) throw std::invalid_argument(explicitSeedResult.error()); + const auto& anchoredSeeds = *explicitSeedResult; + + const Index targetLengthLimit = effectiveLength(config_.target); + const Index queryLengthLimit = effectiveLength(config_.query); + const Index targetDomainLength = targetDomain.size(); + const Index queryLength = queryDomain.size(); + if (targetDomainLength > std::numeric_limits::max() / queryLength) { + throw std::length_error("prediction domain matrix size overflows addressable memory"); + } + const Index cellCount = targetDomainLength * queryLength; + std::vector> states(cellCount); + const auto cell = [queryLength, targetBegin = targetDomain.begin]( + const Index targetIndex, const Index queryReverse) { + return (targetIndex - targetBegin) * queryLength + queryReverse; + }; + + const auto seedsForPath = [&](const std::vector& path) -> std::vector { + if (!anchoredSeeds.empty()) { + auto matches = explicitSeedMatches(path, anchoredSeeds); + for (auto& match : matches) { + auto breakdown = energy_->evaluate(target, query, std::span(path).subspan( + match.firstPair, match.lastPair - match.firstPair + 1U)); + const auto& first = path[match.firstPair]; + const auto& last = path[match.lastPair]; + const Interval targetInterval{first.target, last.target}; + const Interval queryInterval{last.query, first.query}; + breakdown.openingTarget = targetAccessibility.openingEnergy(targetInterval); + breakdown.openingQuery = queryAccessibility.openingEnergy(queryInterval); + match.energy = breakdown.total(); + match.openingTarget = breakdown.openingTarget; + match.openingQuery = breakdown.openingQuery; + match.unpairedTarget = targetAccessibility.unpairedProbability(targetInterval); + match.unpairedQuery = queryAccessibility.unpairedProbability(queryInterval); + } + std::vector normalized; + mergeSeedMatches(normalized, std::move(matches)); + return normalized; + } + if (!config_.seed.required || path.size() < config_.seed.basePairs) return {}; + const Index begin = path.size() - config_.seed.basePairs; + const Index end = path.size() - 1U; + const auto targetGap = path[end].target - path[begin].target + 1U - config_.seed.basePairs; + const auto queryGap = path[begin].query - path[end].query + 1U - config_.seed.basePairs; + const auto queryMax = config_.seed.queryMaxUnpaired < 0 + ? config_.seed.maxUnpaired + : static_cast(config_.seed.queryMaxUnpaired); + const auto targetMax = config_.seed.targetMaxUnpaired < 0 + ? config_.seed.maxUnpaired + : static_cast(config_.seed.targetMaxUnpaired); + if (targetGap > targetMax || queryGap > queryMax || + targetGap + queryGap > config_.seed.maxUnpaired) return {}; + for (Index index = begin; index <= end; ++index) { + if (config_.seed.noGu && isGuPair(target[path[index].target], query[path[index].query])) { + return {}; + } + } + if (config_.seed.noGuAtEnds && + (isGuPair(target[path[begin].target], query[path[begin].query]) || + isGuPair(target[path[end].target], query[path[end].query]))) return {}; + + if (!config_.seed.targetRanges.empty()) { + auto ranges = parseIntervals(target, config_.seed.targetRanges); + if (!ranges || !std::ranges::any_of(*ranges, [&](const Interval range) { + return range.contains(path[begin].target) && range.contains(path[end].target); + })) return {}; + } + if (!config_.seed.queryRanges.empty()) { + auto ranges = parseIntervals(query, config_.seed.queryRanges); + if (!ranges || !std::ranges::any_of(*ranges, [&](const Interval range) { + return range.contains(path[end].query) && range.contains(path[begin].query); + })) return {}; + } + + const auto slice = std::span(path).subspan(begin, config_.seed.basePairs); + auto breakdown = energy_->evaluate(target, query, slice); + const Interval targetInterval{path[begin].target, path[end].target}; + const Interval queryInterval{path[end].query, path[begin].query}; + breakdown.openingTarget = targetAccessibility.openingEnergy(targetInterval); + breakdown.openingQuery = queryAccessibility.openingEnergy(queryInterval); + const auto targetProbability = targetAccessibility.unpairedProbability(targetInterval); + const auto queryProbability = queryAccessibility.unpairedProbability(queryInterval); + if (breakdown.hybrid() > config_.seed.maxHybridEnergy + 1e-9 || + breakdown.total() > config_.seed.maxEnergy + 1e-9 || + targetProbability + 1e-15 < config_.seed.minUnpairedProbability || + queryProbability + 1e-15 < config_.seed.minUnpairedProbability) { + return {}; + } + return {SeedMatch{begin, end, breakdown.total(), breakdown.openingTarget, + breakdown.openingQuery, targetProbability, queryProbability}}; + }; + + const bool seedRequired = config_.seed.required || !anchoredSeeds.empty(); + const auto maxStates = config_.mode == PredictionMode::heuristic + ? 96U + : std::numeric_limits::max(); + const bool heuristicSeedExtension = + config_.mode == PredictionMode::heuristic && + config_.model == InteractionModel::seedExtension && seedRequired; + std::vector heuristicSeedStates; + std::set heuristicSeedPaths; + const auto preserveSeedState = [&](const PathState& state) { + if (anchoredSeeds.empty()) return false; + if (std::ranges::any_of(state.seeds, [&](const SeedMatch& seed) { + return seed.firstPair == 0U || + seed.lastPair + 1U == state.path.size(); + })) { + return true; + } + if (state.seedMatched) return false; + const auto& start = state.path.front(); + const auto& currentPair = state.path.back(); + return std::ranges::any_of(anchoredSeeds, [&](const ExplicitSeed& seed) { + const auto& seedFirst = seed.pairs.front(); + const auto& seedLast = seed.pairs.back(); + if (start.target > seedFirst.target || start.query < seedFirst.query || + currentPair.target > seedLast.target || currentPair.query < seedLast.query) { + return false; + } + return (targetLengthLimit == 0U || + seedLast.target - start.target + 1U <= targetLengthLimit) && + (queryLengthLimit == 0U || + start.query - seedLast.query + 1U <= queryLengthLimit); + }); + }; + + for (Index targetIndex = targetDomain.begin; targetIndex <= targetDomain.end; ++targetIndex) { + if (targetAccessibility.blocked(targetIndex)) continue; + for (Index queryReverse = 0U; queryReverse < queryLength; ++queryReverse) { + const Index queryIndex = queryDomain.end - queryReverse; + if (queryAccessibility.blocked(queryIndex) || !canPair(target[targetIndex], query[queryIndex])) continue; + + std::unordered_map current; + PathState initial{{{targetIndex, queryIndex}}, energy_->initiationEnergy(), + -energy_->initiationEnergy() / energy_->rt(), !seedRequired, false, {}}; + initial.seeds = seedsForPath(initial.path); + if (!initial.seeds.empty()) { + initial.seedMatched = true; + } + if (!config_.output.noGuAtEnds || + !isGuPair(target[targetIndex], query[queryIndex])) { + mergeState(current, std::move(initial), config_, anchoredSeeds); + } + + const Index targetDistanceMax = std::min( + targetIndex - targetDomain.begin, config_.target.interactionLoopMax + 1U); + const Index queryDistanceMax = std::min(queryReverse, config_.query.interactionLoopMax + 1U); + for (Index targetDistance = 1U; targetDistance <= targetDistanceMax; ++targetDistance) { + const Index previousTarget = targetIndex - targetDistance; + for (Index queryDistance = 1U; queryDistance <= queryDistanceMax; ++queryDistance) { + const Index previousQueryReverse = queryReverse - queryDistance; + const Index previousQuery = queryDomain.end - previousQueryReverse; + const auto transition = energy_->transitionEnergy( + target, query, {previousTarget, previousQuery}, {targetIndex, queryIndex}); + for (const auto& previous : states[cell(previousTarget, previousQueryReverse)]) { + if (targetLengthLimit != 0U && targetIndex - previous.path.front().target + 1U > targetLengthLimit) continue; + if (queryLengthLimit != 0U && previous.path.front().query - queryIndex + 1U > queryLengthLimit) continue; + const bool stacked = isStack(previous.path.back(), {targetIndex, queryIndex}); + if (config_.output.noGuAtEnds && !stacked && + (isGuPair(target[previous.path.back().target], query[previous.path.back().query]) || + isGuPair(target[targetIndex], query[queryIndex]))) continue; + if (config_.output.noLonelyPairs && !stacked && + !previous.lastHasLeftStack) continue; + + PathState next = previous; + next.path.push_back({targetIndex, queryIndex}); + next.hybridEnergy += transition; + next.logWeight -= transition / energy_->rt(); + next.lastHasLeftStack = stacked; + auto seeds = seedsForPath(next.path); + if (!seeds.empty()) { + next.seedMatched = true; + mergeSeedMatches(next.seeds, std::move(seeds)); + } + mergeState(current, std::move(next), config_, anchoredSeeds); + } + } + } + + auto& destination = states[cell(targetIndex, queryReverse)]; + destination.reserve(current.size()); + for (auto& [key, state] : current) { + static_cast(key); + destination.push_back(std::move(state)); + } + if (heuristicSeedExtension) { + for (const auto& state : destination) { + const bool oneSided = std::ranges::any_of( + state.seeds, [&](const SeedMatch& seed) { + return seed.firstPair == 0U || + seed.lastPair + 1U == state.path.size(); + }); + if (oneSided && heuristicSeedPaths.insert(pathSignature(state.path)).second) { + heuristicSeedStates.push_back(state); + } + } + } + if (destination.size() > maxStates) { + std::vector protectedStates; + std::vector candidates; + std::map, PathState> boundaryBest; + protectedStates.reserve(destination.size()); + candidates.reserve(destination.size()); + for (auto& state : destination) { + if (preserveSeedState(state)) { + protectedStates.push_back(std::move(state)); + continue; + } + if (!anchoredSeeds.empty()) { + candidates.push_back(std::move(state)); + continue; + } + const auto boundary = std::tuple{ + state.path.front().target, + state.path.front().query, + state.seedMatched, + }; + auto found = boundaryBest.find(boundary); + if (found == boundaryBest.end() || + state.hybridEnergy < found->second.hybridEnergy - 1e-12 || + (std::abs(state.hybridEnergy - found->second.hybridEnergy) <= 1e-12 && + pathSignatureLess(state.path, found->second.path))) { + if (found != boundaryBest.end()) { + candidates.push_back(std::move(found->second)); + found->second = std::move(state); + } else { + boundaryBest.emplace(boundary, std::move(state)); + } + } else { + candidates.push_back(std::move(state)); + } + } + for (auto& [boundary, state] : boundaryBest) { + static_cast(boundary); + protectedStates.push_back(std::move(state)); + } + const auto retained = std::min(candidates.size(), maxStates); + std::ranges::partial_sort(candidates, + candidates.begin() + static_cast(retained), + [](const PathState& left, const PathState& right) { + if (left.seedMatched != right.seedMatched) return left.seedMatched > right.seedMatched; + return left.hybridEnergy < right.hybridEnergy; + }); + candidates.resize(retained); + destination = std::move(protectedStates); + destination.insert(destination.end(), + std::make_move_iterator(candidates.begin()), + std::make_move_iterator(candidates.end())); + } + } + } + + if (heuristicSeedExtension) { + struct RightExtension { + Energy transitionEnergy{infinity}; + std::vector suffix; + std::vector seeds; + }; + std::map bestRight; + for (const auto& state : heuristicSeedStates) { + for (const auto& seed : state.seeds) { + if (seed.firstPair != 0U || seed.lastPair >= state.path.size()) continue; + const auto seedPath = std::span(state.path).subspan( + seed.firstPair, seed.lastPair - seed.firstPair + 1U); + const auto key = pathSignature(seedPath); + Energy extension{}; + for (Index index = seed.lastPair + 1U; index < state.path.size(); ++index) { + extension += energy_->transitionEnergy( + target, query, state.path[index - 1U], state.path[index]); + } + std::vector suffix( + state.path.begin() + static_cast(seed.lastPair), + state.path.end()); + auto [found, inserted] = bestRight.try_emplace( + key, RightExtension{extension, suffix, state.seeds}); + if (!inserted && + (extension < found->second.transitionEnergy - 1e-12 || + (std::abs(extension - found->second.transitionEnergy) <= 1e-12 && + pathSignatureLess(suffix, found->second.suffix)))) { + found->second = RightExtension{ + extension, std::move(suffix), state.seeds}; + } + } + } + + std::vector synthesized; + for (const auto& left : heuristicSeedStates) { + for (const auto& seed : left.seeds) { + if (seed.lastPair + 1U != left.path.size()) continue; + const auto seedPath = std::span(left.path).subspan( + seed.firstPair, seed.lastPair - seed.firstPair + 1U); + const auto found = bestRight.find(pathSignature(seedPath)); + if (found == bestRight.end() || found->second.suffix.size() <= 1U) continue; + + std::vector path = left.path; + path.insert(path.end(), found->second.suffix.begin() + 1, + found->second.suffix.end()); + if ((targetLengthLimit != 0U && + path.back().target - path.front().target + 1U > targetLengthLimit) || + (queryLengthLimit != 0U && + path.front().query - path.back().query + 1U > queryLengthLimit)) { + continue; + } + heuristicSeedPaths.insert(pathSignature(path)); + + PathState combined; + combined.path = std::move(path); + combined.hybridEnergy = energy_->initiationEnergy(); + for (Index index = 1U; index < combined.path.size(); ++index) { + combined.hybridEnergy += energy_->transitionEnergy( + target, query, combined.path[index - 1U], combined.path[index]); + } + combined.logWeight = -combined.hybridEnergy / energy_->rt(); + combined.seedMatched = true; + combined.lastHasLeftStack = combined.path.size() > 1U && + isStack(combined.path[combined.path.size() - 2U], combined.path.back()); + combined.seeds = left.seeds; + auto rightSeeds = found->second.seeds; + for (auto& rightSeed : rightSeeds) { + rightSeed.firstPair += seed.firstPair; + rightSeed.lastPair += seed.firstPair; + } + mergeSeedMatches(combined.seeds, std::move(rightSeeds)); + if (!combined.seeds.empty()) synthesized.push_back(std::move(combined)); + } + } + heuristicSeedStates.insert( + heuristicSeedStates.end(), + std::make_move_iterator(synthesized.begin()), + std::make_move_iterator(synthesized.end())); + + // Only the independently generated seed-extension states belong to H + // output. Release the propagation matrix before evaluating sites. + states.clear(); + states.resize(1U); + states.front() = std::move(heuristicSeedStates); + } + + std::map sites; + std::set seedOnlySeen; + for (auto& cellStates : states) { + for (auto& state : cellStates) { + if (seedRequired && !state.seedMatched) continue; + if (config_.output.noLonelyPairs && !state.lastHasLeftStack) continue; + auto path = std::move(state.path); + auto seeds = std::move(state.seeds); + double stateLogWeight = state.logWeight; + if (config_.mode == PredictionMode::seedOnly) { + if (seeds.empty()) continue; + SeedMatch seed = seeds.front(); + std::vector seedPath( + path.begin() + static_cast(seed.firstPair), + path.begin() + static_cast(seed.lastPair + 1U)); + path = std::move(seedPath); + const auto signature = pathSignature(path); + if (!seedOnlySeen.insert(signature).second) continue; + seed.firstPair = 0U; + seed.lastPair = path.size() - 1U; + seeds = {seed}; + const auto seedEnergy = energy_->evaluate(target, query, path); + stateLogWeight = -(seedEnergy.initiation + seedEnergy.loops) / energy_->rt(); + } + if (config_.model == InteractionModel::helixBlocks && + decomposeHelixBlocks( + target, query, path, config_.helix, *energy_, + targetAccessibility, queryAccessibility).empty()) continue; + if (config_.output.noGuAtEnds && guEndViolation(target, query, path)) continue; + + Interaction interaction; + interaction.targetId = target.id(); + interaction.queryId = query.id(); + interaction.pairs = std::move(path); + interaction.energy = energy_->evaluate(target, query, interaction.pairs); + // Terminal dangles require one additional exterior position on + // both interaction sites. If either configured site-length bound + // is already saturated, that exterior context is outside the DP + // domain and neither terminal dangle contributes. Raising a bound + // by one makes the same traceback eligible again. + if ((targetLengthLimit != 0U && + interaction.targetRange().size() == targetLengthLimit) || + (queryLengthLimit != 0U && + interaction.queryRange().size() == queryLengthLimit)) { + interaction.energy.dangleLeft = 0.0; + interaction.energy.dangleRight = 0.0; + } + interaction.energy.openingTarget = targetAccessibility.openingEnergy(interaction.targetRange()); + interaction.energy.openingQuery = queryAccessibility.openingEnergy(interaction.queryRange()); + interaction.energy.additive = config_.additiveEnergy; + interaction.unpairedTarget = + targetAccessibility.unpairedProbability(interaction.targetRange()); + interaction.unpairedQuery = + queryAccessibility.unpairedProbability(interaction.queryRange()); + interaction.seeds = std::move(seeds); + if (!std::isfinite(interaction.energy.total()) || + interaction.energy.total() >= config_.output.maxEnergy - 1e-12) continue; + + bool probabilityRejected = false; + for (Index position = interaction.targetRange().begin; position <= interaction.targetRange().end; ++position) { + if (targetAccessibility.positionUnpairedProbability(position) + 1e-15 < + config_.output.minUnpairedProbability) probabilityRejected = true; + } + for (Index position = interaction.queryRange().begin; position <= interaction.queryRange().end; ++position) { + if (queryAccessibility.positionUnpairedProbability(position) + 1e-15 < + config_.output.minUnpairedProbability) probabilityRejected = true; + } + if (probabilityRejected) continue; + + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + const SiteKey key{targetRange.begin, targetRange.end, queryRange.begin, queryRange.end}; + // DP state weights contain initiation and interior transitions. + // Site-specific exterior ends/dangles, accessibility and additive + // energy complete every structure's Boltzmann exponent exactly once. + const auto completeLogWeight = stateLogWeight - + (interaction.energy.dangleLeft + interaction.energy.dangleRight + + interaction.energy.endLeft + interaction.energy.endRight + + interaction.energy.openingTarget + interaction.energy.openingQuery + + interaction.energy.additive) / energy_->rt(); + auto [iterator, inserted] = sites.try_emplace(key); + if (inserted) { + iterator->second.representative = std::move(interaction); + iterator->second.logWeight = completeLogWeight; + } else { + iterator->second.logWeight = logAdd(iterator->second.logWeight, completeLogWeight); + if (interactionLess(interaction, iterator->second.representative)) { + iterator->second.representative = std::move(interaction); + } + } + } + std::vector{}.swap(cellStates); + } + std::vector>{}.swap(states); + + // The older single-site strategy keeps one interaction for each + // left boundary. In antiparallel coordinates that boundary is identified + // by (target begin, query end). Applying the same total ordering as final + // output makes tied real-interaction fixtures deterministic. + if (config_.mode == PredictionMode::heuristic && + config_.model == InteractionModel::singleSite) { + std::map, SiteKey> bestSite; + for (const auto& [siteKey, site] : sites) { + const auto boundary = std::pair{siteKey.targetBegin, siteKey.queryEnd}; + const auto found = bestSite.find(boundary); + if (found == bestSite.end() || + interactionLess(site.representative, sites.at(found->second).representative)) { + bestSite.insert_or_assign(boundary, siteKey); + } + } + std::map filtered; + for (const auto& [boundary, siteKey] : bestSite) { + static_cast(boundary); + filtered.emplace(siteKey, std::move(sites.at(siteKey))); + } + sites = std::move(filtered); + } + + if (config_.mode == PredictionMode::heuristic && + config_.model == InteractionModel::seedExtension && seedRequired) { + // Seed extension grows every seed in both directions independently. + // All one-sided extensions are candidates, while two-sided candidates + // use only the single best right extension of their seed. A shorter + // right extension is intentionally not substituted when the best one + // violates an outer interaction-length bound; this is the documented + // speed/coverage tradeoff that distinguishes H from exact mode. + struct RightExtension { + Energy transitionEnergy{infinity}; + std::vector suffix; + }; + std::map bestRight; + for (const auto& [siteKey, site] : sites) { + static_cast(siteKey); + const auto& path = site.representative.pairs; + for (const auto& seed : site.representative.seeds) { + if (seed.firstPair != 0U || seed.lastPair >= path.size()) continue; + const auto seedPath = std::span(path).subspan( + seed.firstPair, seed.lastPair - seed.firstPair + 1U); + const auto key = pathSignature(seedPath); + Energy extension{}; + for (Index index = seed.lastPair + 1U; index < path.size(); ++index) { + extension += energy_->transitionEnergy( + target, query, path[index - 1U], path[index]); + } + std::vector suffix( + path.begin() + static_cast(seed.lastPair), path.end()); + auto [found, inserted] = bestRight.try_emplace( + key, RightExtension{extension, suffix}); + if (!inserted && + (extension < found->second.transitionEnergy - 1e-12 || + (std::abs(extension - found->second.transitionEnergy) <= 1e-12 && + pathSignatureLess(suffix, found->second.suffix)))) { + found->second = RightExtension{extension, std::move(suffix)}; + } + } + } + + std::map filtered; + for (auto& [siteKey, site] : sites) { + const auto& path = site.representative.pairs; + const bool retain = std::ranges::any_of( + site.representative.seeds, [&](const SeedMatch& seed) { + if (seed.lastPair >= path.size()) return false; + if (seed.firstPair == 0U || seed.lastPair + 1U == path.size()) return true; + const auto seedPath = std::span(path).subspan( + seed.firstPair, seed.lastPair - seed.firstPair + 1U); + const auto found = bestRight.find(pathSignature(seedPath)); + if (found == bestRight.end()) return false; + const auto suffix = std::span(path).subspan(seed.lastPair); + return std::ranges::equal(suffix, found->second.suffix); + }); + if (retain) filtered.emplace(siteKey, std::move(site)); + } + sites = std::move(filtered); + } + + result.ensembleSites.reserve(sites.size()); + const bool seededExtensionWithoutPartition = + config_.model == InteractionModel::seedExtension && seedRequired; + for (auto& [key, site] : sites) { + static_cast(key); + site.representative.ensembleFreeEnergy = + config_.model == InteractionModel::singleSite || + config_.model == InteractionModel::seedExtension + ? site.representative.energy.total() + : -energy_->rt() * site.logWeight; + if (config_.model == InteractionModel::ensemble) { + const auto targetRange = site.representative.targetRange(); + const auto queryRange = site.representative.queryRange(); + site.representative.pairs = {{targetRange.begin, queryRange.end}}; + if (targetRange.begin != targetRange.end || queryRange.begin != queryRange.end) { + site.representative.pairs.push_back({targetRange.end, queryRange.begin}); + } + site.representative.seeds.clear(); + auto& energy = site.representative.energy; + const auto centikcalSiteEnergy = + std::trunc(site.representative.ensembleFreeEnergy * 100.0) / 100.0; + energy.loops = centikcalSiteEnergy - energy.openingTarget - + energy.openingQuery - energy.additive - energy.initiation - + energy.dangleLeft - energy.dangleRight - energy.endLeft - + energy.endRight; + } + const auto reportedLogWeight = + config_.model == InteractionModel::singleSite || + config_.model == InteractionModel::seedExtension + ? -site.representative.ensembleFreeEnergy / energy_->rt() + : site.logWeight; + if (!seededExtensionWithoutPartition) { + result.logPartition = logAdd(result.logPartition, reportedLogWeight); + } + result.ensembleSites.push_back(std::move(site.representative)); + } + sites.clear(); + if (std::isfinite(result.logPartition)) { + result.ensembleFreeEnergy = -energy_->rt() * result.logPartition; + for (auto& site : result.ensembleSites) { + site.probability = std::exp(-site.ensembleFreeEnergy / energy_->rt() - result.logPartition); + } + } + + std::vector ranked; + ranked.reserve(result.ensembleSites.size()); + for (const auto& interaction : result.ensembleSites) ranked.push_back(&interaction); + orderForOutput(ranked, config_); + if (ranked.empty() || config_.output.number == 0U) return result; + const auto minimumEnergy = ranked.front()->energy.total(); + for (const auto* interaction : ranked) { + if (interaction->energy.total() > minimumEnergy + config_.output.deltaEnergy + 1e-9) continue; + if (overlapsSelected(*interaction, result.interactions, config_.output.overlap)) continue; + result.interactions.push_back(*interaction); + if (result.interactions.size() >= config_.output.number) break; + } + return result; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/runner.cpp b/IntaRNAnew/src/runner.cpp new file mode 100644 index 0000000..ec2be5c --- /dev/null +++ b/IntaRNAnew/src/runner.cpp @@ -0,0 +1,81 @@ +#include "intarnanew/runner.hpp" + +#include +#include +#include + +namespace intarnanew { + +auto predictPair( + const Config& config, + const Sequence& target, + const Sequence& query) -> std::expected { + try { + auto targetAccessibility = makeAccessibility(target, config.target, config); + if (!targetAccessibility) { + return std::unexpected("target accessibility: " + targetAccessibility.error()); + } + auto queryAccessibility = makeAccessibility(query, config.query, config); + if (!queryAccessibility) { + return std::unexpected("query accessibility: " + queryAccessibility.error()); + } + + auto targetRegions = configuredRegions(target, config.target); + if (!targetRegions) return std::unexpected("target regions: " + targetRegions.error()); + auto queryRegions = configuredRegions(query, config.query); + if (!queryRegions) return std::unexpected("query regions: " + queryRegions.error()); + auto plannedTarget = decomposeAccessibleRegions( + *targetRegions, config.target.regionLengthMax, config.seed.basePairs, + **targetAccessibility); + auto plannedQuery = decomposeAccessibleRegions( + *queryRegions, config.query.regionLengthMax, config.seed.basePairs, + **queryAccessibility); + + Predictor predictor(config); + std::vector domains; + for (const auto targetRegion : plannedTarget) { + const auto targetWindows = decomposeWindows( + targetRegion, config.windowWidth, config.windowOverlap); + for (const auto queryRegion : plannedQuery) { + const auto queryWindows = decomposeWindows( + queryRegion, config.windowWidth, config.windowOverlap); + for (const auto targetWindow : targetWindows) { + for (const auto queryWindow : queryWindows) { + domains.push_back(predictor.predict( + target, query, **targetAccessibility, **queryAccessibility, + targetWindow, queryWindow)); + } + } + } + } + + PredictionResult result; + if (domains.empty()) { + result.rt = predictor.rt(); + } else if (domains.size() == 1U) { + result = std::move(domains.front()); + } else { + result = reducePredictions(domains, config); + } + if (const auto value = (*targetAccessibility)->ensembleLogPartition()) { + result.targetLogPartition = *value; + } + if (const auto value = (*queryAccessibility)->ensembleLogPartition()) { + result.queryLogPartition = *value; + } + if (const auto value = (*targetAccessibility)->ensembleFreeEnergy()) { + result.targetEnsembleFreeEnergy = *value; + } + if (const auto value = (*queryAccessibility)->ensembleFreeEnergy()) { + result.queryEnsembleFreeEnergy = *value; + } + return PairPrediction{ + std::move(result), std::move(*targetAccessibility), std::move(*queryAccessibility)}; + } catch (const std::exception& error) { + return std::unexpected(error.what()); + } catch (...) { + return std::unexpected("unknown prediction failure"); + } +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/sequence.cpp b/IntaRNAnew/src/sequence.cpp new file mode 100644 index 0000000..1cf0c93 --- /dev/null +++ b/IntaRNAnew/src/sequence.cpp @@ -0,0 +1,420 @@ +#include "intarnanew/sequence.hpp" + +#include "intarnanew/compression.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew { +namespace { + +[[nodiscard]] auto normalized(std::string sequence) -> std::expected { + std::string result; + result.reserve(sequence.size()); + for (const unsigned char raw : sequence) { + if (std::isspace(raw) != 0) { + continue; + } + auto symbol = static_cast(std::toupper(raw)); + if (symbol == 'T') { + symbol = 'U'; + } + if (nucleotideMask(symbol) == 0U) { + return std::unexpected("invalid IUPAC nucleotide '" + std::string(1, symbol) + "'"); + } + if (symbol != 'A' && symbol != 'C' && symbol != 'G' && symbol != 'U') { + symbol = 'N'; + } + result.push_back(symbol); + } + if (result.empty()) { + return std::unexpected("RNA sequence is empty"); + } + return result; +} + +[[nodiscard]] auto trimmed(std::string value) -> std::string { + const auto first = std::find_if_not(value.begin(), value.end(), [](const unsigned char c) { + return std::isspace(c) != 0; + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](const unsigned char c) { + return std::isspace(c) != 0; + }).base(); + return first < last ? std::string(first, last) : std::string{}; +} + +[[nodiscard]] auto complementMask(const std::uint8_t mask, const bool allowGu) noexcept -> std::uint8_t { + // A=1, C=2, G=4, U=8 + std::uint8_t result{}; + if ((mask & 0x1U) != 0U) { + result |= 0x8U; + } + if ((mask & 0x2U) != 0U) { + result |= 0x4U; + } + if ((mask & 0x4U) != 0U) { + result |= static_cast(0x2U | (allowGu ? 0x8U : 0U)); + } + if ((mask & 0x8U) != 0U) { + result |= static_cast(0x1U | (allowGu ? 0x4U : 0U)); + } + return result; +} + +[[nodiscard]] auto coordinateSpanFits( + const long long firstPosition, + const std::size_t length) noexcept -> bool { + if (length == 0U) return false; + if (!std::in_range(length - 1U)) return false; + const auto lastOffset = static_cast(length - 1U); + constexpr auto signedMaximum = static_cast( + std::numeric_limits::max()); + if (firstPosition >= 0) { + return lastOffset <= signedMaximum - static_cast(firstPosition); + } + + // Unsigned negation obtains |LLONG_MIN| without overflowing signed arithmetic. + const auto negativePositions = 0ULL - static_cast(firstPosition); + if (lastOffset < negativePositions) return true; + return lastOffset - negativePositions <= signedMaximum - 1U; +} + +[[nodiscard]] auto readBounded( + std::istream& input, + const std::size_t maximum, + const std::string_view description) -> std::expected { + std::string bytes; + std::array buffer{}; + while (input) { + input.read(buffer.data(), static_cast(buffer.size())); + const auto count = input.gcount(); + if (count < 0) return std::unexpected("failed to read " + std::string(description)); + const auto amount = static_cast(count); + if (amount > maximum - bytes.size()) { + return std::unexpected(std::string(description) + + " exceeds the configured input-byte limit"); + } + bytes.append(buffer.data(), amount); + } + if (!input.eof()) return std::unexpected("failed to read " + std::string(description)); + return bytes; +} + +[[nodiscard]] auto hasGzipSuffix(const std::string_view value) noexcept -> bool { + if (value.size() < 3U) return false; + const auto offset = value.size() - 3U; + return value[offset] == '.' && + static_cast(std::tolower(static_cast(value[offset + 1U]))) == 'g' && + static_cast(std::tolower(static_cast(value[offset + 2U]))) == 'z'; +} + +[[nodiscard]] auto parsePossiblyCompressed( + std::string bytes, + const bool gzipExpected, + const std::string_view description, + const std::string_view fallbackId, + const long long firstPosition) -> std::expected, std::string> { + if (hasGzipMagic(bytes)) { + auto decoded = gzipDecompress(bytes); + if (!decoded) { + return std::unexpected("cannot decode " + std::string(description) + ": " + + decoded.error()); + } + bytes = std::move(*decoded); + } else if (gzipExpected) { + return std::unexpected(std::string(description) + + " has a .gz suffix but no gzip signature"); + } + std::istringstream input(std::move(bytes)); + return SequenceReader::parseFasta(input, fallbackId, firstPosition); +} + +} // namespace + +Sequence::Sequence(std::string identifier, std::string nucleotides, const long long firstPosition) + : identifier_(std::move(identifier)), firstPosition_(firstPosition) { + auto parsed = normalized(std::move(nucleotides)); + if (!parsed) { + throw std::invalid_argument(parsed.error()); + } + nucleotides_ = std::move(*parsed); + if (!coordinateSpanFits(firstPosition_, nucleotides_.size())) { + throw std::out_of_range("sequence coordinates exceed the signed 64-bit range"); + } + if (identifier_.empty()) { + identifier_ = "sequence"; + } +} + +auto Sequence::externalIndex(const Index index) const -> long long { + if (index >= size()) throw std::out_of_range("sequence index is outside the sequence"); + if (firstPosition_ >= 0) { + return firstPosition_ + static_cast(index); + } + const auto offset = static_cast(index); + const auto negativePositions = 0ULL - static_cast(firstPosition_); + if (offset < negativePositions) { + return firstPosition_ + static_cast(offset); + } + return static_cast(offset - negativePositions + 1U); +} + +auto Sequence::internalIndex(const long long external) const -> std::expected { + if (firstPosition_ < 0 && external == 0) { + return std::unexpected("sequence index zero is not used for a negative coordinate origin"); + } + const auto adjusted = firstPosition_ < 0 && external > 0 ? external - 1LL : external; + if (adjusted < firstPosition_) { + return std::unexpected("sequence index is before the first configured position"); + } + const auto distance = static_cast(adjusted) - + static_cast(firstPosition_); + if (distance >= size()) { + return std::unexpected("sequence index is beyond the sequence end"); + } + return static_cast(distance); +} + +auto SequenceReader::read( + const std::string_view specification, + const std::string_view fallbackId, + const long long firstPosition, + std::istream& standardInput) -> std::expected, std::string> { + if (specification.empty()) { + return std::unexpected("missing RNA sequence input"); + } + if (specification == "STDIN" || specification == "-") { + GzipLimits limits; + auto bytes = readBounded(standardInput, limits.maxCompressedBytes, "standard input"); + if (!bytes) return std::unexpected(bytes.error()); + return parsePossiblyCompressed( + std::move(*bytes), false, "standard input", fallbackId, firstPosition); + } + + std::error_code error; + const std::filesystem::path path{specification}; + if (std::filesystem::is_regular_file(path, error)) { + std::ifstream input(path, std::ios::binary); + if (!input) { + return std::unexpected("cannot open sequence file '" + path.string() + "'"); + } + GzipLimits limits; + auto bytes = readBounded( + input, limits.maxCompressedBytes, "sequence file '" + path.string() + "'"); + if (!bytes) return std::unexpected(bytes.error()); + return parsePossiblyCompressed( + std::move(*bytes), hasGzipSuffix(path.string()), + "sequence file '" + path.string() + "'", fallbackId, firstPosition); + } + + auto parsed = normalized(std::string(specification)); + if (!parsed) { + return std::unexpected( + "input is neither a readable file nor an RNA sequence: " + parsed.error()); + } + try { + std::vector result; + result.emplace_back(std::string(fallbackId), std::move(*parsed), firstPosition); + return result; + } catch (const std::exception& exception) { + return std::unexpected(exception.what()); + } +} + +auto SequenceReader::parseFasta( + std::istream& input, + const std::string_view fallbackId, + const long long firstPosition) -> std::expected, std::string> { + std::vector result; + std::string identifier{fallbackId}; + std::string bases; + std::string line; + bool sawHeader = false; + bool openHeader = false; + + const auto commit = [&](const bool requireSequence) -> std::expected { + if (bases.empty()) { + if (requireSequence) { + return std::unexpected("FASTA record '" + identifier + "' has no RNA sequence"); + } + return {}; + } + auto parsed = normalized(std::move(bases)); + bases.clear(); + if (!parsed) { + return std::unexpected(parsed.error()); + } + try { + result.emplace_back(identifier, std::move(*parsed), firstPosition); + } catch (const std::exception& exception) { + return std::unexpected(exception.what()); + } + return {}; + }; + + while (std::getline(input, line)) { + line = trimmed(std::move(line)); + if (line.empty() || line.front() == ';') { + continue; + } + if (line.front() == '>') { + if (auto status = commit(openHeader); !status) { + return std::unexpected(status.error()); + } + identifier = trimmed(line.substr(1)); + if (const auto blank = identifier.find_first_of(" \t"); blank != std::string::npos) { + identifier.erase(blank); + } + if (identifier.empty()) { + identifier = std::string(fallbackId) + std::to_string(result.size() + 1U); + } + sawHeader = true; + openHeader = true; + } else { + bases += line; + } + } + if (auto status = commit(openHeader); !status) { + return std::unexpected(status.error()); + } + if (result.empty()) { + return std::unexpected("no RNA sequence found in input"); + } + if (!sawHeader && result.size() == 1U && result.front().id().empty()) { + return std::unexpected("unable to assign sequence identifier"); + } + return result; +} + +auto SequenceReader::select( + std::vector sequences, + const std::string_view specification) -> std::expected, std::string> { + if (specification.empty()) { + return sequences; + } + + std::vector selected(sequences.size(), false); + const auto parseRecordIndex = [&](const std::string_view text) + -> std::expected { + std::size_t value{}; + const auto [position, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || error != std::errc{} || position != text.data() + text.size() || + value == 0U || value > sequences.size()) { + return std::unexpected( + "sequence-set index '" + std::string(text) + "' is outside 1.." + + std::to_string(sequences.size())); + } + return value; + }; + + std::size_t cursor{}; + while (cursor <= specification.size()) { + const auto comma = specification.find(',', cursor); + const auto rawToken = specification.substr( + cursor, comma == std::string_view::npos ? std::string_view::npos : comma - cursor); + const auto tokenStorage = trimmed(std::string(rawToken)); + const std::string_view token{tokenStorage}; + if (token.empty()) { + return std::unexpected("sequence-set specification contains an empty item"); + } + + const auto dash = token.find('-'); + if (dash == std::string_view::npos) { + auto index = parseRecordIndex(token); + if (!index) return std::unexpected(index.error()); + selected[*index - 1U] = true; + } else { + if (token.find('-', dash + 1U) != std::string_view::npos) { + return std::unexpected("sequence-set range must use FROM-TO encoding"); + } + auto first = parseRecordIndex(token.substr(0U, dash)); + auto last = parseRecordIndex(token.substr(dash + 1U)); + if (!first) return std::unexpected(first.error()); + if (!last) return std::unexpected(last.error()); + if (*first > *last) { + return std::unexpected("sequence-set range start exceeds its end"); + } + std::fill(selected.begin() + static_cast(*first - 1U), + selected.begin() + static_cast(*last), true); + } + + if (comma == std::string_view::npos) break; + cursor = comma + 1U; + } + + std::vector result; + result.reserve(sequences.size()); + for (std::size_t index = 0U; index < sequences.size(); ++index) { + if (selected[index]) result.push_back(std::move(sequences[index])); + } + return result; +} + +auto nucleotideMask(const char symbol) noexcept -> std::uint8_t { + switch (static_cast(std::toupper(static_cast(symbol)))) { + case 'A': return 0x1U; + case 'C': return 0x2U; + case 'G': return 0x4U; + case 'U': case 'T': return 0x8U; + case 'R': return 0x5U; + case 'Y': return 0xAU; + case 'S': return 0x6U; + case 'W': return 0x9U; + case 'K': return 0xCU; + case 'M': return 0x3U; + case 'B': return 0xEU; + case 'D': return 0xDU; + case 'H': return 0xBU; + case 'V': return 0x7U; + case 'N': return 0xFU; + default: return 0U; + } +} + +auto canPair(const char target, const char query, const bool allowGu) noexcept -> bool { + const auto canonicalMask = [](const char symbol) noexcept -> std::uint8_t { + switch (static_cast(std::toupper(static_cast(symbol)))) { + case 'A': return 0x1U; + case 'C': return 0x2U; + case 'G': return 0x4U; + case 'U': case 'T': return 0x8U; + default: return 0U; + } + }; + const auto targetOptions = canonicalMask(target); + const auto queryOptions = canonicalMask(query); + if (targetOptions == 0U || queryOptions == 0U) return false; + return (complementMask(targetOptions, allowGu) & queryOptions) != 0U; +} + +auto isGuPair(const char target, const char query) noexcept -> bool { + const auto left = static_cast(std::toupper(static_cast(target))); + const auto right = static_cast(std::toupper(static_cast(query))); + return (left == 'G' && (right == 'U' || right == 'T')) || + ((left == 'U' || left == 'T') && right == 'G'); +} + +auto reverseComplement(const std::string_view sequence) -> std::string { + std::string result; + result.reserve(sequence.size()); + for (auto iterator = sequence.rbegin(); iterator != sequence.rend(); ++iterator) { + switch (*iterator) { + case 'A': result.push_back('U'); break; + case 'C': result.push_back('G'); break; + case 'G': result.push_back('C'); break; + case 'U': case 'T': result.push_back('A'); break; + default: result.push_back('N'); break; + } + } + return result; +} + +} // namespace intarnanew diff --git a/IntaRNAnew/src/thermo_parameters.cpp b/IntaRNAnew/src/thermo_parameters.cpp new file mode 100644 index 0000000..414dca1 --- /dev/null +++ b/IntaRNAnew/src/thermo_parameters.cpp @@ -0,0 +1,389 @@ +#include "thermo_parameters.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::detail { +namespace { + +namespace fs = std::filesystem; + +// Keep input work bounded independently of caller-provided files. +constexpr double referenceTemperatureKelvin = 310.15; +constexpr std::uintmax_t parameterFileSizeLimit = 16U * 1024U * 1024U; +constexpr std::size_t parameterLineSizeLimit = 64U * 1024U; +constexpr std::size_t sectionTokenLimit = 20'000U; + +using RawSections = std::map, std::less<>>; + +constexpr std::array requiredSections{ + std::string_view{"stack"}, + std::string_view{"stack_enthalpies"}, + std::string_view{"mismatch_internal"}, + std::string_view{"mismatch_internal_enthalpies"}, + std::string_view{"mismatch_internal_1n"}, + std::string_view{"mismatch_internal_1n_enthalpies"}, + std::string_view{"mismatch_internal_23"}, + std::string_view{"mismatch_internal_23_enthalpies"}, + std::string_view{"mismatch_exterior"}, + std::string_view{"mismatch_exterior_enthalpies"}, + std::string_view{"dangle5"}, + std::string_view{"dangle5_enthalpies"}, + std::string_view{"dangle3"}, + std::string_view{"dangle3_enthalpies"}, + std::string_view{"int11"}, + std::string_view{"int11_enthalpies"}, + std::string_view{"int21"}, + std::string_view{"int21_enthalpies"}, + std::string_view{"int22"}, + std::string_view{"int22_enthalpies"}, + std::string_view{"bulge"}, + std::string_view{"bulge_enthalpies"}, + std::string_view{"internal"}, + std::string_view{"internal_enthalpies"}, + std::string_view{"NINIO"}, + std::string_view{"Misc"}, +}; + +[[nodiscard]] auto trim(const std::string_view value) noexcept -> std::string_view { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1U); +} + +[[nodiscard]] auto isRequiredSection(const std::string_view name) noexcept -> bool { + return std::ranges::find(requiredSections, name) != requiredSections.end(); +} + +[[nodiscard]] auto stripBlockComments(const std::string_view line, bool& inComment) -> std::string { + std::string result; + result.reserve(line.size()); + for (std::size_t index = 0U; index < line.size();) { + if (inComment) { + const auto end = line.find("*/", index); + if (end == std::string_view::npos) return result; + inComment = false; + index = end + 2U; + continue; + } + const auto begin = line.find("/*", index); + if (begin == std::string_view::npos) { + result.append(line.substr(index)); + break; + } + result.append(line.substr(index, begin - index)); + inComment = true; + index = begin + 2U; + } + return result; +} + +[[nodiscard]] auto parseToken(const std::string_view token, const fs::path& path, + const std::size_t lineNumber) -> double { + if (token == "INF") return std::numeric_limits::infinity(); + if (token == "NST") return 0.0; + if (token == "DEF") return -50.0; + + const std::string owned(token); + char* end{}; + errno = 0; + const auto value = std::strtod(owned.c_str(), &end); + if (errno == ERANGE || end != owned.c_str() + owned.size() || !std::isfinite(value)) { + throw std::invalid_argument("invalid parameter token '" + owned + "' at " + + path.string() + ':' + std::to_string(lineNumber)); + } + return value; +} + +[[nodiscard]] auto parseParameterFile(const fs::path& path) -> RawSections { + std::error_code error; + const auto fileSize = fs::file_size(path, error); + if (error) { + throw std::invalid_argument("cannot determine ViennaRNA parameter file size: " + path.string()); + } + if (fileSize > parameterFileSizeLimit) { + throw std::invalid_argument("ViennaRNA parameter file exceeds the 16 MiB parser limit: " + + path.string()); + } + + std::ifstream input(path); + if (!input) throw std::invalid_argument("cannot open ViennaRNA parameter file: " + path.string()); + + RawSections sections; + std::string activeSection; + std::string line; + std::size_t lineNumber{}; + bool inComment{}; + bool versionSeen{}; + while (std::getline(input, line)) { + ++lineNumber; + if (line.size() > parameterLineSizeLimit) { + throw std::invalid_argument("overlong line in ViennaRNA parameter file: " + path.string()); + } + const auto cleaned = stripBlockComments(line, inComment); + const auto view = trim(cleaned); + if (view.empty()) continue; + if (view.starts_with("##")) { + if (!view.starts_with("## RNAfold parameter file v2")) { + throw std::invalid_argument("unsupported ViennaRNA parameter file header in " + path.string()); + } + versionSeen = true; + activeSection.clear(); + continue; + } + if (view.front() == '#') { + auto name = trim(view.substr(1U)); + if (const auto whitespace = name.find_first_of(" \t"); whitespace != std::string_view::npos) { + name = name.substr(0U, whitespace); + } + activeSection.clear(); + if (isRequiredSection(name)) { + auto [iterator, inserted] = sections.try_emplace(std::string(name)); + static_cast(iterator); + if (!inserted) { + throw std::invalid_argument("duplicate ViennaRNA parameter section # " + + std::string(name) + " in " + path.string()); + } + activeSection = std::string(name); + } + continue; + } + if (activeSection.empty()) continue; + + std::istringstream tokens{std::string(view)}; + std::string token; + auto& values = sections.at(activeSection); + while (tokens >> token) { + if (values.size() >= sectionTokenLimit) { + throw std::invalid_argument("too many values in ViennaRNA parameter section # " + + activeSection + " in " + path.string()); + } + values.push_back(parseToken(token, path, lineNumber)); + } + } + if (!input.eof()) throw std::invalid_argument("failed while reading ViennaRNA parameter file: " + path.string()); + if (inComment) throw std::invalid_argument("unterminated block comment in ViennaRNA parameter file: " + path.string()); + if (!versionSeen) throw std::invalid_argument("not a ViennaRNA v2 parameter file: " + path.string()); + return sections; +} + +void appendEnvironmentDirectories(std::vector& directories, const char* variable) { + const auto* value = std::getenv(variable); + if (value == nullptr || *value == '\0') return; +#ifdef _WIN32 + constexpr char separator = ';'; +#else + constexpr char separator = ':'; +#endif + const std::string_view list(value); + std::size_t begin{}; + while (begin <= list.size() && directories.size() < 64U) { + const auto end = list.find(separator, begin); + const auto item = trim(list.substr(begin, end == std::string_view::npos + ? std::string_view::npos : end - begin)); + if (!item.empty()) directories.emplace_back(item); + if (end == std::string_view::npos) break; + begin = end + 1U; + } +} + +[[nodiscard]] auto canonicalRegularFile(const fs::path& path) -> std::optional { + std::error_code error; + if (!fs::is_regular_file(path, error) || error) return std::nullopt; + auto canonical = fs::weakly_canonical(path, error); + return error ? std::optional{path} : std::optional{std::move(canonical)}; +} + +[[nodiscard]] auto resolveParameterFile(const std::string_view parameterSet) -> fs::path { + const auto requested = parameterSet.empty() ? std::string_view{"Turner04"} : parameterSet; + if (auto direct = canonicalRegularFile(fs::path(requested))) return *direct; + + std::string_view fileName; + if (requested == "Turner04" || requested == "rna_turner2004.par") { + fileName = "rna_turner2004.par"; + } else if (requested == "Turner99" || requested == "rna_turner1999.par") { + fileName = "rna_turner1999.par"; + } else if (requested == "Andronescu07" || requested == "rna_andronescu2007.par") { + fileName = "rna_andronescu2007.par"; + } else if (requested.find('/') != std::string_view::npos || + requested.find('\\') != std::string_view::npos || requested.ends_with(".par")) { + throw std::invalid_argument("ViennaRNA parameter file not found: " + std::string(requested)); + } else { + throw std::invalid_argument("unknown ViennaRNA parameter set '" + std::string(requested) + + "' (expected Turner04, Turner99, Andronescu07, or a .par path)"); + } + + std::vector directories; + appendEnvironmentDirectories(directories, "INTARNANEW_PARAMETER_DIR"); + appendEnvironmentDirectories(directories, "VIENNA_RNA_DATAPATH"); + appendEnvironmentDirectories(directories, "VRNA_DATAPATH"); + if (const auto* prefix = std::getenv("CONDA_PREFIX"); prefix != nullptr && *prefix != '\0') { + directories.emplace_back(fs::path(prefix) / "share" / "ViennaRNA"); + } +#ifdef _WIN32 + if (const auto* programData = std::getenv("PROGRAMDATA"); programData != nullptr && *programData != '\0') { + directories.emplace_back(fs::path(programData) / "ViennaRNA"); + } +#else + directories.emplace_back("/usr/local/share/ViennaRNA"); + directories.emplace_back("/usr/share/ViennaRNA"); +#endif + + std::error_code error; + auto current = fs::current_path(error); + for (std::size_t depth = 0U; !error && !current.empty() && depth < 8U; ++depth) { + directories.emplace_back(current / "share" / "ViennaRNA"); + directories.emplace_back(current / ".conda-env" / "share" / "ViennaRNA"); + directories.emplace_back(current / "intaRNA_legacy" / ".conda-env" / "share" / "ViennaRNA"); + const auto parent = current.parent_path(); + if (parent == current) break; + current = parent; + } + + std::set visited; + for (const auto& directory : directories) { + if (directory.empty() || !visited.insert(directory).second) continue; + if (auto resolved = canonicalRegularFile(directory / fileName)) return *resolved; + } + throw std::invalid_argument("ViennaRNA parameter set '" + std::string(requested) + + "' was not found; set INTARNANEW_PARAMETER_DIR or pass an explicit .par path"); +} + +[[nodiscard]] auto section(const RawSections& sections, const std::string_view name, + const std::size_t expected, const fs::path& path) -> const std::vector& { + const auto iterator = sections.find(name); + if (iterator == sections.end()) { + throw std::invalid_argument("missing ViennaRNA parameter section # " + std::string(name) + + " in " + path.string()); + } + if (iterator->second.size() != expected) { + throw std::invalid_argument("ViennaRNA parameter section # " + std::string(name) + " in " + + path.string() + " has " + std::to_string(iterator->second.size()) + + " values; expected " + std::to_string(expected)); + } + return iterator->second; +} + +[[nodiscard]] auto scaledValue(const double freeEnergy37, const double enthalpy, + const double temperatureKelvin, const bool quantizeToCentikcal = true) -> Energy { + if (!std::isfinite(freeEnergy37) || !std::isfinite(enthalpy)) return infinity; + if (std::abs(temperatureKelvin - referenceTemperatureKelvin) < 1e-9) { + return freeEnergy37 / 100.0; + } + const auto value = enthalpy - (enthalpy - freeEnergy37) * + temperatureKelvin / referenceTemperatureKelvin; + return quantizeToCentikcal ? std::trunc(value) / 100.0 : value / 100.0; +} + +[[nodiscard]] auto scaledSection(const RawSections& sections, const std::string_view freeEnergyName, + const std::string_view enthalpyName, const std::size_t expected, + const fs::path& path, const double temperatureKelvin) -> std::vector { + const auto& freeEnergies = section(sections, freeEnergyName, expected, path); + const auto& enthalpies = section(sections, enthalpyName, expected, path); + std::vector result; + result.reserve(expected); + for (std::size_t index = 0U; index < expected; ++index) { + result.push_back(scaledValue(freeEnergies[index], enthalpies[index], temperatureKelvin)); + } + return result; +} + +[[nodiscard]] auto buildParameters(const fs::path& path, const double temperatureCelsius) + -> std::shared_ptr { + const auto temperatureKelvin = temperatureCelsius + 273.15; + const auto raw = parseParameterFile(path); + auto result = std::make_shared(); + result->stack = scaledSection(raw, "stack", "stack_enthalpies", 7U * 7U, path, temperatureKelvin); + result->mismatchInternal = scaledSection(raw, "mismatch_internal", + "mismatch_internal_enthalpies", 7U * 5U * 5U, path, temperatureKelvin); + result->mismatchInternal1n = scaledSection(raw, "mismatch_internal_1n", + "mismatch_internal_1n_enthalpies", 7U * 5U * 5U, path, temperatureKelvin); + result->mismatchInternal23 = scaledSection(raw, "mismatch_internal_23", + "mismatch_internal_23_enthalpies", 7U * 5U * 5U, path, temperatureKelvin); + result->mismatchExterior = scaledSection(raw, "mismatch_exterior", + "mismatch_exterior_enthalpies", 7U * 5U * 5U, path, temperatureKelvin); + result->dangle5 = scaledSection(raw, "dangle5", "dangle5_enthalpies", + 7U * 5U, path, temperatureKelvin); + result->dangle3 = scaledSection(raw, "dangle3", "dangle3_enthalpies", + 7U * 5U, path, temperatureKelvin); + result->int11 = scaledSection(raw, "int11", "int11_enthalpies", + 7U * 7U * 5U * 5U, path, temperatureKelvin); + result->int21 = scaledSection(raw, "int21", "int21_enthalpies", + 7U * 7U * 5U * 5U * 5U, path, temperatureKelvin); + result->int22 = scaledSection(raw, "int22", "int22_enthalpies", + 6U * 6U * 4U * 4U * 4U * 4U, path, temperatureKelvin); + result->bulge = scaledSection(raw, "bulge", "bulge_enthalpies", 31U, path, temperatureKelvin); + result->internal = scaledSection(raw, "internal", "internal_enthalpies", 31U, path, temperatureKelvin); + + const auto& ninio = section(raw, "NINIO", 3U, path); + result->ninioSlope = scaledValue(ninio[0], ninio[1], temperatureKelvin); + result->ninioMaximum = ninio[2] / 100.0; + + const auto miscIterator = raw.find("Misc"); + if (miscIterator == raw.end() || (miscIterator->second.size() != 4U && miscIterator->second.size() != 6U)) { + throw std::invalid_argument("ViennaRNA parameter section # Misc in " + path.string() + + " must contain 4 or 6 values"); + } + const auto& misc = miscIterator->second; + result->duplexInit = scaledValue(misc[0], misc[1], temperatureKelvin); + result->terminalAu = scaledValue(misc[2], misc[3], temperatureKelvin); + // ViennaRNA v2 permits the historical LXC pair to be omitted; its specified default is 107.856/0. + result->logarithmicLoopSlope = misc.size() == 6U + ? scaledValue(misc[4], misc[5], temperatureKelvin, false) + : scaledValue(107.856, 0.0, temperatureKelvin, false); + return result; +} + +struct CacheKey { + fs::path path; + double temperatureCelsius{}; + friend auto operator<=>(const CacheKey&, const CacheKey&) = default; +}; + +} // namespace + +auto loadNearestNeighborParameters(const double temperatureCelsius, + const std::string_view parameterSet) + -> std::shared_ptr { + if (!std::isfinite(temperatureCelsius) || temperatureCelsius <= -273.15) { + throw std::invalid_argument("energy temperature must be finite and above absolute zero"); + } + const CacheKey key{resolveParameterFile(parameterSet), temperatureCelsius}; + static std::mutex cacheMutex; + static std::map> cache; + { + const std::scoped_lock lock(cacheMutex); + if (const auto found = cache.find(key); found != cache.end()) { + if (auto existing = found->second.lock()) return existing; + cache.erase(found); + } + } + + auto loaded = buildParameters(key.path, temperatureCelsius); + { + const std::scoped_lock lock(cacheMutex); + std::erase_if(cache, [](const auto& entry) { return entry.second.expired(); }); + if (cache.size() < 16U) cache[key] = loaded; + } + return loaded; +} + +} // namespace intarnanew::detail diff --git a/IntaRNAnew/src/thermo_parameters.hpp b/IntaRNAnew/src/thermo_parameters.hpp new file mode 100644 index 0000000..31ef5ef --- /dev/null +++ b/IntaRNAnew/src/thermo_parameters.hpp @@ -0,0 +1,37 @@ +#pragma once + +// Internal, temperature-scaled representation of ViennaRNA v2 parameters. + +#include "intarnanew/types.hpp" + +#include +#include +#include + +namespace intarnanew::detail { + +struct NearestNeighborParameters { + std::vector stack; + std::vector bulge; + std::vector internal; + std::vector mismatchInternal; + std::vector mismatchInternal1n; + std::vector mismatchInternal23; + std::vector mismatchExterior; + std::vector int11; + std::vector int21; + std::vector int22; + std::vector dangle5; + std::vector dangle3; + Energy terminalAu{}; + Energy duplexInit{}; + Energy ninioSlope{}; + Energy ninioMaximum{}; + Energy logarithmicLoopSlope{}; +}; + +[[nodiscard]] auto loadNearestNeighborParameters( + double temperatureCelsius, + std::string_view parameterSet) -> std::shared_ptr; + +} // namespace intarnanew::detail diff --git a/IntaRNAnew/src/tools/csv.cpp b/IntaRNAnew/src/tools/csv.cpp new file mode 100644 index 0000000..e9b225c --- /dev/null +++ b/IntaRNAnew/src/tools/csv.cpp @@ -0,0 +1,393 @@ +#include "intarnanew/tools/csv.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { +namespace { + +[[nodiscard]] auto detectSeparator(const std::string_view input) -> char { + std::size_t semicolons{}; + std::size_t tabs{}; + std::size_t commas{}; + bool quoted{}; + for (std::size_t index = 0; index < input.size(); ++index) { + const char symbol = input[index]; + if (symbol == '"') { + if (quoted && index + 1U < input.size() && input[index + 1U] == '"') { + ++index; + } else { + quoted = !quoted; + } + } else if (!quoted && (symbol == '\n' || symbol == '\r')) { + break; + } else if (!quoted) { + semicolons += symbol == ';'; + tabs += symbol == '\t'; + commas += symbol == ','; + } + } + if (tabs > semicolons && tabs >= commas) { + return '\t'; + } + if (commas > semicolons) { + return ','; + } + return ';'; +} + +[[nodiscard]] auto parseRecords( + const std::string_view input, + const char separator, + const bool allowEmptyLines) + -> std::expected>, std::string> { + std::vector> records; + std::vector record; + std::string field; + bool quoted{}; + bool afterQuote{}; + + const auto finishField = [&] { + record.push_back(std::move(field)); + field.clear(); + afterQuote = false; + }; + const auto finishRecord = [&] { + const bool syntacticallyBlank = record.empty() && field.empty() && !afterQuote; + finishField(); + if (!allowEmptyLines || !syntacticallyBlank) { + records.push_back(std::move(record)); + } + record.clear(); + }; + + for (std::size_t index = 0; index < input.size(); ++index) { + const char symbol = input[index]; + if (quoted) { + if (symbol == '"') { + if (index + 1U < input.size() && input[index + 1U] == '"') { + field.push_back('"'); + ++index; + } else { + quoted = false; + afterQuote = true; + } + } else { + field.push_back(symbol); + } + continue; + } + + if (afterQuote) { + if (symbol == separator) { + finishField(); + } else if (symbol == '\n') { + finishRecord(); + } else if (symbol == '\r') { + if (index + 1U < input.size() && input[index + 1U] == '\n') { + ++index; + } + finishRecord(); + } else if (symbol != ' ' && symbol != '\t') { + return std::unexpected( + "unexpected character after a closing CSV quote at byte " + + std::to_string(index)); + } + continue; + } + + if (symbol == '"') { + if (!field.empty()) { + return std::unexpected( + "CSV quote starts inside an unquoted field at byte " + + std::to_string(index)); + } + quoted = true; + } else if (symbol == separator) { + finishField(); + } else if (symbol == '\n') { + finishRecord(); + } else if (symbol == '\r') { + if (index + 1U < input.size() && input[index + 1U] == '\n') { + ++index; + } + finishRecord(); + } else { + field.push_back(symbol); + } + } + + if (quoted) { + return std::unexpected("unterminated quoted CSV field"); + } + if (!field.empty() || !record.empty() || afterQuote) { + finishRecord(); + } + return records; +} + +[[nodiscard]] auto escaped( + const std::string_view value, + const char separator, + const bool forceQuote = false) -> std::string { + const bool quote = forceQuote || value.find(separator) != std::string_view::npos || + value.find('"') != std::string_view::npos || + value.find('\n') != std::string_view::npos || + value.find('\r') != std::string_view::npos; + if (!quote) { + return std::string{value}; + } + std::string result; + result.reserve(value.size() + 2U); + result.push_back('"'); + for (const char symbol : value) { + if (symbol == '"') { + result.push_back('"'); + } + result.push_back(symbol); + } + result.push_back('"'); + return result; +} + +[[nodiscard]] auto rowIdentity(const std::vector& row) -> std::string { + std::string identity; + for (const auto& value : row) { + identity += std::to_string(value.size()); + identity.push_back(':'); + identity += value; + identity.push_back('|'); + } + return identity; +} + +} // namespace + +auto CsvTable::column(const std::string_view name) const noexcept -> std::optional { + const auto found = std::find(header.begin(), header.end(), name); + if (found == header.end()) { + return std::nullopt; + } + return static_cast(std::distance(header.begin(), found)); +} + +auto readCsv(std::istream& input, const CsvReadOptions options) + -> std::expected { + std::ostringstream buffer; + buffer << input.rdbuf(); + if (input.bad()) { + return std::unexpected("failed while reading CSV input"); + } + auto text = buffer.str(); + if (text.starts_with("\xEF\xBB\xBF")) { + text.erase(0U, 3U); + } + const auto separator = options.separator.value_or(detectSeparator(text)); + if (separator == '"' || separator == '\r' || separator == '\n' || separator == '\0') { + return std::unexpected("invalid CSV separator"); + } + auto parsed = parseRecords(text, separator, options.allowEmptyLines); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (parsed->empty()) { + return std::unexpected("CSV input is empty"); + } + + CsvTable table; + table.separator = separator; + if (options.requireHeader) { + table.header = std::move(parsed->front()); + parsed->erase(parsed->begin()); + } else { + const auto width = parsed->front().size(); + table.header.reserve(width); + for (std::size_t index = 0; index < width; ++index) { + table.header.push_back("column" + std::to_string(index + 1U)); + } + } + if (table.header.empty()) { + return std::unexpected("CSV header is empty"); + } + std::unordered_set headerNames; + for (const auto& name : table.header) { + if (name.empty()) { + return std::unexpected("CSV contains an empty column name"); + } + if (!headerNames.insert(name).second) { + return std::unexpected("duplicate CSV column name '" + name + "'"); + } + } + for (std::size_t index = 0; index < parsed->size(); ++index) { + if ((*parsed)[index].size() != table.header.size()) { + return std::unexpected( + "CSV record " + std::to_string(index + 2U) + " has " + + std::to_string((*parsed)[index].size()) + " fields; expected " + + std::to_string(table.header.size())); + } + } + table.rows = std::move(*parsed); + return table; +} + +auto writeCsv(const CsvTable& table, std::ostream& output) -> std::expected { + if (table.header.empty()) { + return std::unexpected("cannot write a CSV table without columns"); + } + if (table.separator == '"' || table.separator == '\r' || + table.separator == '\n' || table.separator == '\0') { + return std::unexpected("cannot write CSV with an invalid separator"); + } + std::unordered_set names; + for (const auto& name : table.header) { + if (name.empty()) { + return std::unexpected("cannot write CSV with an empty column name"); + } + if (!names.insert(name).second) { + return std::unexpected("cannot write CSV with duplicate column '" + name + "'"); + } + } + const auto writeRow = [&](const std::vector& row) { + for (std::size_t index = 0; index < row.size(); ++index) { + if (index != 0U) { + output.put(table.separator); + } + // An unquoted empty value in a one-column table is indistinguishable + // from an ignorable blank record when read back. + output << escaped( + row[index], table.separator, row.size() == 1U && row[index].empty()); + } + output.put('\n'); + }; + writeRow(table.header); + for (std::size_t index = 0; index < table.rows.size(); ++index) { + if (table.rows[index].size() != table.header.size()) { + return std::unexpected( + "cannot write malformed CSV record " + std::to_string(index + 1U)); + } + writeRow(table.rows[index]); + } + if (!output) { + return std::unexpected("failed while writing CSV output"); + } + return {}; +} + +auto csvText(const CsvTable& table) -> std::expected { + std::ostringstream output; + auto written = writeCsv(table, output); + if (!written) { + return std::unexpected(written.error()); + } + return output.str(); +} + +auto fuseCsv( + const std::span tables, + const std::span sourceLabels, + const CsvFusionOptions options) -> std::expected { + if (tables.empty()) { + return std::unexpected("at least one CSV table is required for fusion"); + } + if (!sourceLabels.empty() && sourceLabels.size() != tables.size()) { + return std::unexpected("source label count must equal table count"); + } + + CsvTable result; + result.separator = tables.front().separator; + std::unordered_map outputIndices; + for (std::size_t tableIndex = 0U; tableIndex < tables.size(); ++tableIndex) { + const auto& table = tables[tableIndex]; + if (table.header.empty()) { + return std::unexpected( + "input table " + std::to_string(tableIndex + 1U) + " has no columns"); + } + std::unordered_set names; + for (const auto& name : table.header) { + if (name.empty()) { + return std::unexpected( + "input table " + std::to_string(tableIndex + 1U) + + " contains an empty column name"); + } + if (!names.insert(name).second) { + return std::unexpected( + "input table " + std::to_string(tableIndex + 1U) + + " contains duplicate column '" + name + "'"); + } + if (!outputIndices.contains(name)) { + outputIndices.emplace(name, result.header.size()); + result.header.push_back(name); + } + } + for (const auto& row : table.rows) { + if (row.size() != table.header.size()) { + return std::unexpected( + "input table " + std::to_string(tableIndex + 1U) + " is malformed"); + } + } + } + if (options.sourceColumn) { + if (options.sourceColumn->empty()) { + return std::unexpected("source column name cannot be empty"); + } + if (outputIndices.contains(*options.sourceColumn)) { + return std::unexpected( + "source column '" + *options.sourceColumn + "' already exists"); + } + outputIndices.emplace(*options.sourceColumn, result.header.size()); + result.header.push_back(*options.sourceColumn); + } + + std::unordered_set seen; + for (std::size_t tableIndex = 0; tableIndex < tables.size(); ++tableIndex) { + const auto& table = tables[tableIndex]; + std::vector projection; + projection.reserve(table.header.size()); + for (const auto& name : table.header) { + projection.push_back(outputIndices.at(name)); + } + for (const auto& inputRow : table.rows) { + if (inputRow.size() != table.header.size()) { + return std::unexpected( + "input table " + std::to_string(tableIndex + 1U) + " is malformed"); + } + std::vector outputRow(result.header.size()); + for (std::size_t columnIndex = 0; columnIndex < inputRow.size(); ++columnIndex) { + outputRow[projection[columnIndex]] = inputRow[columnIndex]; + } + if (options.sourceColumn) { + outputRow.back() = sourceLabels.empty() + ? std::to_string(tableIndex + 1U) + : sourceLabels[tableIndex]; + } + if (!options.deduplicate || seen.insert(rowIdentity(outputRow)).second) { + result.rows.push_back(std::move(outputRow)); + } + } + } + return result; +} + +auto parseFiniteNumber(const std::string_view text) -> std::expected { + if (text.empty()) { + return std::unexpected("numeric value is empty"); + } + double value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) { + return std::unexpected("invalid numeric value '" + std::string{text} + "'"); + } + if (!std::isfinite(value)) { + return std::unexpected("numeric value must be finite"); + } + return value; +} + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/src/tools/mutations.cpp b/IntaRNAnew/src/tools/mutations.cpp new file mode 100644 index 0000000..7de957f --- /dev/null +++ b/IntaRNAnew/src/tools/mutations.cpp @@ -0,0 +1,326 @@ +#include "intarnanew/tools/mutations.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { +namespace { + +[[nodiscard]] auto normalizedBase(const char symbol) noexcept -> char { + const auto upper = static_cast(std::toupper(static_cast(symbol))); + return upper == 'T' ? 'U' : upper; +} + +[[nodiscard]] auto pairClassFiltered( + const char query, + const char target, + const std::span filters) noexcept -> bool { + const auto q = normalizedBase(query); + const auto t = normalizedBase(target); + for (const auto filter : filters) { + if (filter == CandidateFilter::removeGu && isGuPair(t, q)) { + return true; + } + if (filter == CandidateFilter::removeAu && + ((q == 'A' && t == 'U') || (q == 'U' && t == 'A'))) { + return true; + } + if (filter == CandidateFilter::removeCg && + ((q == 'C' && t == 'G') || (q == 'G' && t == 'C'))) { + return true; + } + } + return false; +} + +[[nodiscard]] auto baseName(const char value, const long long coordinate, const char mutation) + -> std::string { + return std::string(1U, value) + std::to_string(coordinate) + std::string(1U, mutation); +} + +struct ParsedHalf { + char wild{}; + long long index{}; + char mutated{}; +}; + +[[nodiscard]] auto parseHalf(const std::string_view text) -> std::expected { + if (text.size() < 3U) { + return std::unexpected("mutation half '" + std::string{text} + "' is too short"); + } + ParsedHalf result; + result.wild = normalizedBase(text.front()); + result.mutated = normalizedBase(text.back()); + constexpr std::string_view bases{"ACGU"}; + if (bases.find(result.wild) == std::string_view::npos || + bases.find(result.mutated) == std::string_view::npos) { + return std::unexpected("mutation bases must be A, C, G, or U"); + } + const auto coordinate = text.substr(1U, text.size() - 2U); + const auto [end, error] = std::from_chars( + coordinate.data(), coordinate.data() + coordinate.size(), result.index); + if (error != std::errc{} || end != coordinate.data() + coordinate.size()) { + return std::unexpected("invalid mutation coordinate '" + std::string{coordinate} + "'"); + } + return result; +} + +class SplitMix64 { +public: + explicit SplitMix64(const std::uint64_t seed) : state_(seed) {} + + [[nodiscard]] auto next() noexcept -> std::uint64_t { + std::uint64_t result = (state_ += 0x9E3779B97F4A7C15ULL); + result = (result ^ (result >> 30U)) * 0xBF58476D1CE4E5B9ULL; + result = (result ^ (result >> 27U)) * 0x94D049BB133111EBULL; + return result ^ (result >> 31U); + } + + [[nodiscard]] auto bounded(const std::uint64_t bound) noexcept -> std::uint64_t { + if (bound <= 1U) { + return 0U; + } + const auto threshold = static_cast(-bound) % bound; + for (;;) { + const auto value = next(); + if (value >= threshold) { + return value % bound; + } + } + } + +private: + std::uint64_t state_{}; +}; + +template +void shuffle(std::vector& values, SplitMix64& random) { + for (std::size_t remaining = values.size(); remaining > 1U; --remaining) { + const auto chosen = static_cast(random.bounded(remaining)); + std::swap(values[remaining - 1U], values[chosen]); + } +} + +[[nodiscard]] auto canonicalSequence(const std::string_view sequence) + -> std::expected { + if (sequence.empty()) { + return std::unexpected("sequence must not be empty"); + } + std::string result; + result.reserve(sequence.size()); + for (const char symbol : sequence) { + const char base = normalizedBase(symbol); + if (std::string_view{"ACGU"}.find(base) == std::string_view::npos) { + return std::unexpected("shuffle input must contain only A, C, G, U, or T"); + } + result.push_back(base); + } + return result; +} + +[[nodiscard]] auto baseIndex(const char base) noexcept -> std::size_t { + switch (base) { + case 'A': return 0U; + case 'C': return 1U; + case 'G': return 2U; + default: return 3U; + } +} + +} // namespace + +auto MutationCandidate::encoding(const Sequence& query, const Sequence& target) const -> std::string { + if (queryIndex >= query.size() || targetIndex >= target.size()) { + return {}; + } + return baseName(wildQuery, query.externalIndex(queryIndex), mutatedQuery) + '&' + + baseName(wildTarget, target.externalIndex(targetIndex), mutatedTarget); +} + +auto enumerateMutations( + const Sequence& query, + const Sequence& target, + const std::span interactionPairs, + const MutationGenerator generator, + const std::span filters) + -> std::expected, std::string> { + constexpr std::array bases{'A', 'C', 'G', 'U'}; + std::vector result; + std::set> seen; + for (const auto pair : interactionPairs) { + if (pair.query >= query.size() || pair.target >= target.size()) { + return std::unexpected("interaction pair is outside its sequence"); + } + const char wildQuery = query[pair.query]; + const char wildTarget = target[pair.target]; + if (!canPair(wildTarget, wildQuery)) { + return std::unexpected("interaction contains a non-pairing base combination"); + } + if (pairClassFiltered(wildQuery, wildTarget, filters)) { + continue; + } + if (generator == MutationGenerator::flip) { + const char mutatedQuery = wildTarget; + const char mutatedTarget = wildQuery; + if (mutatedQuery != wildQuery && mutatedTarget != wildTarget && + canPair(mutatedTarget, mutatedQuery)) { + const auto key = std::array{ + pair.query, pair.target, + static_cast(mutatedQuery), + static_cast(mutatedTarget)}; + if (seen.insert(key).second) { + result.push_back({ + pair.query, pair.target, wildQuery, wildTarget, + mutatedQuery, mutatedTarget}); + } + } + continue; + } + for (const char mutatedQuery : bases) { + for (const char mutatedTarget : bases) { + if (mutatedQuery == wildQuery || mutatedTarget == wildTarget || + !canPair(mutatedTarget, mutatedQuery)) { + continue; + } + const auto key = std::array{ + pair.query, pair.target, + static_cast(mutatedQuery), + static_cast(mutatedTarget)}; + if (seen.insert(key).second) { + result.push_back({ + pair.query, pair.target, wildQuery, wildTarget, + mutatedQuery, mutatedTarget}); + } + } + } + } + return result; +} + +auto parseMutationEncoding( + const std::string_view encoding, + const Sequence& query, + const Sequence& target) -> std::expected { + const auto separator = encoding.find('&'); + if (separator == std::string_view::npos || + encoding.find('&', separator + 1U) != std::string_view::npos) { + return std::unexpected("mutation encoding must contain exactly one '&'"); + } + auto queryHalf = parseHalf(encoding.substr(0U, separator)); + auto targetHalf = parseHalf(encoding.substr(separator + 1U)); + if (!queryHalf) return std::unexpected(queryHalf.error()); + if (!targetHalf) return std::unexpected(targetHalf.error()); + auto queryIndex = query.internalIndex(queryHalf->index); + auto targetIndex = target.internalIndex(targetHalf->index); + if (!queryIndex) return std::unexpected("query mutation: " + queryIndex.error()); + if (!targetIndex) return std::unexpected("target mutation: " + targetIndex.error()); + if (query[*queryIndex] != queryHalf->wild) { + return std::unexpected("encoded query wild-type base does not match the sequence"); + } + if (target[*targetIndex] != targetHalf->wild) { + return std::unexpected("encoded target wild-type base does not match the sequence"); + } + if (queryHalf->mutated == queryHalf->wild || targetHalf->mutated == targetHalf->wild) { + return std::unexpected("a compensatory candidate must mutate both sequence positions"); + } + if (!canPair(targetHalf->mutated, queryHalf->mutated)) { + return std::unexpected("encoded mutant bases do not form a canonical or GU pair"); + } + return MutationCandidate{ + *queryIndex, + *targetIndex, + queryHalf->wild, + targetHalf->wild, + queryHalf->mutated, + targetHalf->mutated}; +} + +auto applyMutation( + const Sequence& query, + const Sequence& target, + const MutationCandidate& mutation) -> std::expected { + if (mutation.queryIndex >= query.size() || mutation.targetIndex >= target.size()) { + return std::unexpected("mutation position is outside its sequence"); + } + if (query[mutation.queryIndex] != mutation.wildQuery || + target[mutation.targetIndex] != mutation.wildTarget) { + return std::unexpected("mutation wild-type bases do not match the sequences"); + } + if (mutation.mutatedQuery == mutation.wildQuery || + mutation.mutatedTarget == mutation.wildTarget) { + return std::unexpected("a compensatory candidate must mutate both sequence positions"); + } + if (!canPair(mutation.mutatedTarget, mutation.mutatedQuery)) { + return std::unexpected("mutated bases do not form a canonical or GU pair"); + } + MutationSequences result{query.str(), target.str(), query.str(), target.str()}; + result.mutatedQuery[mutation.queryIndex] = mutation.mutatedQuery; + result.mutatedTarget[mutation.targetIndex] = mutation.mutatedTarget; + return result; +} + +auto shuffleMononucleotides(const std::string_view sequence, const std::uint64_t seed) + -> std::expected { + auto parsed = canonicalSequence(sequence); + if (!parsed) { + return std::unexpected(parsed.error()); + } + SplitMix64 random(seed); + std::vector symbols(parsed->begin(), parsed->end()); + shuffle(symbols, random); + return std::string(symbols.begin(), symbols.end()); +} + +auto shuffleDinucleotides(const std::string_view sequence, const std::uint64_t seed) + -> std::expected { + auto parsed = canonicalSequence(sequence); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (parsed->size() < 2U) { + return *parsed; + } + std::array, 4U> originalEdges; + for (std::size_t index = 0U; index + 1U < parsed->size(); ++index) { + originalEdges[baseIndex((*parsed)[index])].push_back((*parsed)[index + 1U]); + } + + // Shuffling adjacency order and following a complete Hierholzer traversal + // samples from valid Euler trails. The traversal always uses every edge for + // this graph, whose degree conditions derive from an existing sequence. + SplitMix64 random(seed); + auto edges = originalEdges; + for (auto& outgoing : edges) { + shuffle(outgoing, random); + } + std::array used{}; + std::vector stack{parsed->front()}; + std::vector reversedTrail; + reversedTrail.reserve(parsed->size()); + while (!stack.empty()) { + const char current = stack.back(); + auto& offset = used[baseIndex(current)]; + const auto& outgoing = edges[baseIndex(current)]; + if (offset < outgoing.size()) { + stack.push_back(outgoing[offset++]); + } else { + reversedTrail.push_back(current); + stack.pop_back(); + } + } + if (reversedTrail.size() != parsed->size()) { + return std::unexpected("could not construct a complete dinucleotide-preserving trail"); + } + std::reverse(reversedTrail.begin(), reversedTrail.end()); + if (reversedTrail.front() != parsed->front() || reversedTrail.back() != parsed->back()) { + return std::unexpected("dinucleotide shuffle did not preserve sequence endpoints"); + } + return std::string(reversedTrail.begin(), reversedTrail.end()); +} + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/src/tools/pvalue.cpp b/IntaRNAnew/src/tools/pvalue.cpp new file mode 100644 index 0000000..56231f2 --- /dev/null +++ b/IntaRNAnew/src/tools/pvalue.cpp @@ -0,0 +1,114 @@ +#include "intarnanew/tools/pvalue.hpp" + +#include "intarnanew/tools/mutations.hpp" + +#include +#include +#include +#include +#include + +namespace intarnanew::tools { +namespace { + +[[nodiscard]] auto mixedSeed(const std::uint64_t seed, const std::uint64_t stream) noexcept + -> std::uint64_t { + auto value = seed + 0x9E3779B97F4A7C15ULL * (stream + 1U); + value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL; + value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL; + return value ^ (value >> 31U); +} + +[[nodiscard]] auto shuffled( + const std::string_view sequence, + const std::uint64_t seed, + const ShufflePreservation preservation) -> std::expected { + if (preservation == ShufflePreservation::dinucleotide) { + return shuffleDinucleotides(sequence, seed); + } + return shuffleMononucleotides(sequence, seed); +} + +} // namespace + +auto sampleRandomInteractionScores( + const std::string_view query, + const std::string_view target, + const RandomScoreOptions& options, + const InteractionScoreEvaluator& evaluator) -> std::expected, std::string> { + if (query.empty() || target.empty()) { + return std::unexpected("query and target must not be empty"); + } + if (options.cardinality == 0U) { + return std::unexpected("random-score cardinality must be positive"); + } + if (!evaluator) { + return std::unexpected("an interaction-score evaluator is required"); + } + + std::vector queries(options.cardinality, std::string{query}); + std::vector targets(options.cardinality, std::string{target}); + for (std::size_t index = 0U; index < options.cardinality; ++index) { + if (options.mode == ShuffleMode::query || options.mode == ShuffleMode::both) { + auto generated = shuffled( + query, mixedSeed(options.randomSeed, 2U * index), options.preservation); + if (!generated) { + return std::unexpected("query shuffle " + std::to_string(index) + ": " + generated.error()); + } + queries[index] = std::move(*generated); + } + if (options.mode == ShuffleMode::target || options.mode == ShuffleMode::both) { + auto generated = shuffled( + target, mixedSeed(options.randomSeed, 2U * index + 1U), options.preservation); + if (!generated) { + return std::unexpected("target shuffle " + std::to_string(index) + ": " + generated.error()); + } + targets[index] = std::move(*generated); + } + } + + std::vector> results(options.cardinality); + std::atomic next{}; + const auto requested = options.threads == 0U + ? std::max(1U, std::thread::hardware_concurrency()) + : options.threads; + const auto threadCount = std::min(requested, options.cardinality); + { + std::vector workers; + workers.reserve(threadCount); + for (std::size_t worker = 0U; worker < threadCount; ++worker) { + workers.emplace_back([&] { + for (;;) { + const auto index = next.fetch_add(1U, std::memory_order_relaxed); + if (index >= options.cardinality) { + break; + } + try { + results[index] = evaluator(queries[index], targets[index]); + } catch (const std::exception& error) { + results[index] = std::unexpected( + "score evaluator threw an exception: " + std::string{error.what()}); + } catch (...) { + results[index] = std::unexpected("score evaluator threw an unknown exception"); + } + } + }); + } + } + + std::vector scores; + scores.reserve(options.cardinality); + for (std::size_t index = 0U; index < results.size(); ++index) { + if (!results[index]) { + return std::unexpected( + "score evaluation " + std::to_string(index) + ": " + results[index].error()); + } + if (!std::isfinite(*results[index])) { + return std::unexpected("score evaluation returned a non-finite value"); + } + scores.push_back(*results[index]); + } + return scores; +} + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/src/tools/statistics.cpp b/IntaRNAnew/src/tools/statistics.cpp new file mode 100644 index 0000000..a295c62 --- /dev/null +++ b/IntaRNAnew/src/tools/statistics.cpp @@ -0,0 +1,515 @@ +#include "intarnanew/tools/statistics.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { +namespace { + +constexpr double eulerMascheroni = 0.577215664901532860606512090082402431; +constexpr double rootTwo = 1.414213562373095048801688724209698079; + +[[nodiscard]] auto finiteObservations(const std::span observations) + -> std::expected { + if (observations.size() < 2U) { + return std::unexpected("at least two observations are required"); + } + if (std::ranges::any_of(observations, [](const double value) { + return !std::isfinite(value); + })) { + return std::unexpected("all observations must be finite"); + } + const auto [minimum, maximum] = std::ranges::minmax(observations); + if (minimum == maximum) { + return std::unexpected("distribution fitting requires non-constant observations"); + } + return {}; +} + +struct Moments { + double mean{}; + double standardDeviation{}; +}; + +[[nodiscard]] auto moments(const std::span values) noexcept -> Moments { + // Welford's recurrence avoids catastrophic cancellation for shifted data. + double mean{}; + double sumSquares{}; + std::size_t count{}; + for (const double value : values) { + ++count; + const double delta = value - mean; + mean += delta / static_cast(count); + sumSquares += delta * (value - mean); + } + return {mean, std::sqrt(sumSquares / static_cast(count))}; +} + +[[nodiscard]] auto gaussianNll( + const std::span observations, + const double location, + const double scale) noexcept -> double { + if (!(scale > 0.0) || !std::isfinite(location) || !std::isfinite(scale)) { + return std::numeric_limits::infinity(); + } + long double squares{}; + for (const double value : observations) { + const long double normalized = + (static_cast(value) - location) / scale; + squares += normalized * normalized; + } + return static_cast( + static_cast(observations.size()) * + std::log(scale * std::sqrt(2.0 * std::numbers::pi)) + + 0.5L * squares); +} + +[[nodiscard]] auto gumbelNll( + const std::span observations, + const double location, + const double logScale) noexcept -> double { + const double scale = std::exp(logScale); + if (!(scale > 0.0) || !std::isfinite(location) || !std::isfinite(scale)) { + return std::numeric_limits::infinity(); + } + long double sum{}; + for (const double value : observations) { + const double normalized = (value - location) / scale; + if (!std::isfinite(normalized) || normalized < -700.0) { + return std::numeric_limits::infinity(); + } + sum += normalized + std::exp(-normalized); + } + return static_cast( + static_cast(observations.size()) * logScale + sum); +} + +[[nodiscard]] auto gevNll( + const std::span observations, + const double location, + const double logScale, + const double shape) noexcept -> double { + const double scale = std::exp(logScale); + if (!(scale > 0.0) || !std::isfinite(location) || !std::isfinite(scale) || + !std::isfinite(shape) || std::abs(shape) > 1.0) { + return std::numeric_limits::infinity(); + } + if (std::abs(shape) < 1.0e-7) { + return gumbelNll(observations, location, logScale); + } + long double sum{}; + for (const double value : observations) { + const double support = 1.0 + shape * ((value - location) / scale); + if (!(support > 0.0) || !std::isfinite(support)) { + return std::numeric_limits::infinity(); + } + const double logSupport = std::log(support); + const double exponential = std::exp(-logSupport / shape); + if (!std::isfinite(exponential)) { + return std::numeric_limits::infinity(); + } + sum += (1.0 + 1.0 / shape) * logSupport + exponential; + } + return static_cast( + static_cast(observations.size()) * logScale + sum); +} + +template +struct SearchResult { + std::array point{}; + double value{std::numeric_limits::infinity()}; + std::size_t iterations{}; + bool converged{}; +}; + +template +[[nodiscard]] auto nelderMead( + const std::array& initial, + const std::array& step, + Objective objective) -> SearchResult { + struct Vertex { + std::array point{}; + double value{}; + }; + std::array simplex{}; + simplex[0].point = initial; + simplex[0].value = objective(initial); + for (std::size_t index = 0; index < Dimensions; ++index) { + simplex[index + 1U].point = initial; + simplex[index + 1U].point[index] += step[index]; + simplex[index + 1U].value = objective(simplex[index + 1U].point); + } + + constexpr std::size_t maximumIterations = 4000U; + constexpr double relativeTolerance = 1.0e-11; + std::size_t iteration{}; + bool converged{}; + for (; iteration < maximumIterations; ++iteration) { + std::ranges::sort(simplex, {}, &Vertex::value); + double coordinateSpread{}; + for (std::size_t vertex = 1U; vertex < simplex.size(); ++vertex) { + for (std::size_t dimension = 0; dimension < Dimensions; ++dimension) { + coordinateSpread = std::max( + coordinateSpread, + std::abs(simplex[vertex].point[dimension] - simplex[0].point[dimension]) / + (1.0 + std::abs(simplex[0].point[dimension]))); + } + } + const double valueSpread = + std::abs(simplex.back().value - simplex.front().value) / + (1.0 + std::abs(simplex.front().value)); + if (coordinateSpread <= relativeTolerance && valueSpread <= relativeTolerance) { + converged = true; + break; + } + + std::array centroid{}; + for (std::size_t vertex = 0U; vertex < Dimensions; ++vertex) { + for (std::size_t dimension = 0U; dimension < Dimensions; ++dimension) { + centroid[dimension] += simplex[vertex].point[dimension] / + static_cast(Dimensions); + } + } + const auto moved = [&](const Vertex& from, const double factor) { + Vertex result; + for (std::size_t dimension = 0U; dimension < Dimensions; ++dimension) { + result.point[dimension] = centroid[dimension] + + factor * (centroid[dimension] - from.point[dimension]); + } + result.value = objective(result.point); + return result; + }; + + auto reflected = moved(simplex.back(), 1.0); + if (reflected.value < simplex.front().value) { + auto expanded = moved(simplex.back(), 2.0); + simplex.back() = expanded.value < reflected.value ? expanded : reflected; + } else if (reflected.value < simplex[Dimensions - 1U].value) { + simplex.back() = reflected; + } else { + Vertex contracted; + if (reflected.value < simplex.back().value) { + for (std::size_t dimension = 0U; dimension < Dimensions; ++dimension) { + contracted.point[dimension] = centroid[dimension] + + 0.5 * (reflected.point[dimension] - centroid[dimension]); + } + } else { + for (std::size_t dimension = 0U; dimension < Dimensions; ++dimension) { + contracted.point[dimension] = centroid[dimension] + + 0.5 * (simplex.back().point[dimension] - centroid[dimension]); + } + } + contracted.value = objective(contracted.point); + if (contracted.value < simplex.back().value) { + simplex.back() = contracted; + } else { + for (std::size_t vertex = 1U; vertex < simplex.size(); ++vertex) { + for (std::size_t dimension = 0U; dimension < Dimensions; ++dimension) { + simplex[vertex].point[dimension] = simplex.front().point[dimension] + + 0.5 * (simplex[vertex].point[dimension] - + simplex.front().point[dimension]); + } + simplex[vertex].value = objective(simplex[vertex].point); + } + } + } + } + std::ranges::sort(simplex, {}, &Vertex::value); + return {simplex.front().point, simplex.front().value, iteration, converged}; +} + +[[nodiscard]] auto validFit(const DistributionFit& fit) -> std::expected { + if (!(fit.scale > 0.0) || !std::isfinite(fit.location) || !std::isfinite(fit.scale) || + !std::isfinite(fit.shape)) { + return std::unexpected("distribution parameters are not finite and valid"); + } + if (fit.kind == DistributionKind::gev && std::abs(fit.shape) > 1.0) { + return std::unexpected("GEV shape must be in [-1,1]"); + } + return {}; +} + +[[nodiscard]] auto sortedIndices(const std::span values) + -> std::vector { + std::vector indices(values.size()); + std::iota(indices.begin(), indices.end(), 0U); + std::ranges::stable_sort(indices, [&](const auto left, const auto right) { + return values[left] < values[right]; + }); + return indices; +} + +} // namespace + +auto parseDistribution(const std::string_view name) + -> std::expected { + if (name == "gauss" || name == "gaussian" || name == "normal") { + return DistributionKind::gaussian; + } + if (name == "gumbel") { + return DistributionKind::gumbel; + } + if (name == "gev") { + return DistributionKind::gev; + } + return std::unexpected("unknown distribution '" + std::string{name} + "'"); +} + +auto distributionName(const DistributionKind kind) noexcept -> std::string_view { + switch (kind) { + case DistributionKind::gaussian: return "gaussian"; + case DistributionKind::gumbel: return "gumbel"; + case DistributionKind::gev: return "gev"; + } + return "unknown"; +} + +auto fitDistribution( + const std::span observations, + const DistributionKind kind) -> std::expected { + auto validation = finiteObservations(observations); + if (!validation) { + return std::unexpected(validation.error()); + } + if (kind == DistributionKind::gev && observations.size() < 3U) { + return std::unexpected("GEV fitting requires at least three observations"); + } + const auto sample = moments(observations); + if (kind == DistributionKind::gaussian) { + return DistributionFit{ + kind, + sample.mean, + sample.standardDeviation, + 0.0, + gaussianNll(observations, sample.mean, sample.standardDeviation), + observations.size(), + true, + 0U}; + } + + const double initialScale = sample.standardDeviation * std::sqrt(6.0) / std::numbers::pi; + const double initialLocation = sample.mean - eulerMascheroni * initialScale; + if (kind == DistributionKind::gumbel) { + const std::array initial{initialLocation, std::log(initialScale)}; + const std::array step{ + std::max(sample.standardDeviation * 0.1, 1.0e-3), 0.1}; + auto result = nelderMead<2U>(initial, step, [&](const auto& point) { + return gumbelNll(observations, point[0], point[1]); + }); + if (!std::isfinite(result.value)) { + return std::unexpected("Gumbel optimization did not find valid parameters"); + } + return DistributionFit{ + kind, + result.point[0], + std::exp(result.point[1]), + 0.0, + result.value, + observations.size(), + result.converged, + result.iterations}; + } + + // Multiple deterministic starts reduce local-minimum and support-boundary + // sensitivity without introducing a random source. + constexpr std::array starts{-0.25, -0.1, 0.0, 0.1, 0.25}; + SearchResult<3U, decltype([&](const std::array&) { return 0.0; })> best; + // SearchResult's Objective template argument is only a tag and is not stored. + best.value = std::numeric_limits::infinity(); + for (const double shape : starts) { + const std::array initial{ + initialLocation, std::log(initialScale), shape}; + const std::array step{ + std::max(sample.standardDeviation * 0.1, 1.0e-3), 0.1, 0.05}; + auto result = nelderMead<3U>(initial, step, [&](const auto& point) { + return gevNll(observations, point[0], point[1], point[2]); + }); + if (result.value < best.value) { + best.point = result.point; + best.value = result.value; + best.iterations = result.iterations; + best.converged = result.converged; + } + } + if (!std::isfinite(best.value)) { + return std::unexpected("GEV optimization did not find valid parameters"); + } + return DistributionFit{ + kind, + best.point[0], + std::exp(best.point[1]), + best.point[2], + best.value, + observations.size(), + best.converged, + best.iterations}; +} + +auto cumulativeProbability(const double value, const DistributionFit& fit) + -> std::expected { + if (!std::isfinite(value)) { + return std::unexpected("probability evaluation value must be finite"); + } + auto validation = validFit(fit); + if (!validation) { + return std::unexpected(validation.error()); + } + double cumulative{}; + if (fit.kind == DistributionKind::gaussian) { + cumulative = 0.5 * std::erfc(-(value - fit.location) / (fit.scale * rootTwo)); + } else if (fit.kind == DistributionKind::gumbel || std::abs(fit.shape) < 1.0e-7) { + cumulative = std::exp(-std::exp(-(value - fit.location) / fit.scale)); + } else { + const double support = 1.0 + fit.shape * ((value - fit.location) / fit.scale); + if (!(support > 0.0)) { + cumulative = fit.shape > 0.0 ? 0.0 : 1.0; + } else { + cumulative = std::exp(-std::pow(support, -1.0 / fit.shape)); + } + } + return std::clamp(cumulative, 0.0, 1.0); +} + +auto tailProbability( + const double value, + const DistributionFit& fit, + const ProbabilityTail tail) -> std::expected { + auto cumulative = cumulativeProbability(value, fit); + if (!cumulative) { + return std::unexpected(cumulative.error()); + } + if (tail == ProbabilityTail::lower) { + return *cumulative; + } + // Avoid cancellation in 1-CDF for values far into the upper tail. + if (fit.kind == DistributionKind::gaussian) { + return 0.5 * std::erfc((value - fit.location) / (fit.scale * rootTwo)); + } + if (fit.kind == DistributionKind::gumbel || std::abs(fit.shape) < 1.0e-7) { + const double exponent = std::exp(-(value - fit.location) / fit.scale); + return std::clamp(-std::expm1(-exponent), 0.0, 1.0); + } + const double support = 1.0 + fit.shape * ((value - fit.location) / fit.scale); + if (!(support > 0.0)) { + return fit.shape > 0.0 ? 1.0 : 0.0; + } + const double exponent = std::pow(support, -1.0 / fit.shape); + return std::clamp(-std::expm1(-exponent), 0.0, 1.0); +} + +auto interactionEnergyPValue(const double energy, const DistributionFit& fit) + -> std::expected { + return tailProbability(energy, fit, ProbabilityTail::lower); +} + +auto empiricalInteractionPValue( + const double observed, + const std::span randomScores) -> std::expected { + if (!std::isfinite(observed)) { + return std::unexpected("observed interaction energy must be finite"); + } + if (randomScores.empty()) { + return std::unexpected("at least one random score is required"); + } + std::size_t atLeastAsGood{}; + for (const double score : randomScores) { + if (!std::isfinite(score)) { + return std::unexpected("random scores must be finite"); + } + atLeastAsGood += score <= observed; + } + return static_cast(atLeastAsGood + 1U) / + static_cast(randomScores.size() + 1U); +} + +auto parseAdjustment(const std::string_view name) + -> std::expected { + if (name == "none") return AdjustmentMethod::none; + if (name == "bonferroni" || name == "bonf") return AdjustmentMethod::bonferroni; + if (name == "holm") return AdjustmentMethod::holm; + if (name == "hochberg") return AdjustmentMethod::hochberg; + if (name == "bh" || name == "fdr" || name == "benjamini-hochberg") { + return AdjustmentMethod::benjaminiHochberg; + } + if (name == "by" || name == "benjamini-yekutieli") { + return AdjustmentMethod::benjaminiYekutieli; + } + return std::unexpected("unknown p-value adjustment method '" + std::string{name} + "'"); +} + +auto adjustmentName(const AdjustmentMethod method) noexcept -> std::string_view { + switch (method) { + case AdjustmentMethod::none: return "none"; + case AdjustmentMethod::bonferroni: return "bonferroni"; + case AdjustmentMethod::holm: return "holm"; + case AdjustmentMethod::hochberg: return "hochberg"; + case AdjustmentMethod::benjaminiHochberg: return "benjamini-hochberg"; + case AdjustmentMethod::benjaminiYekutieli: return "benjamini-yekutieli"; + } + return "unknown"; +} + +auto adjustPValues( + const std::span pValues, + const AdjustmentMethod method) -> std::expected, std::string> { + if (pValues.empty()) { + return std::unexpected("at least one p-value is required"); + } + for (const double value : pValues) { + if (!std::isfinite(value) || value < 0.0 || value > 1.0) { + return std::unexpected("all p-values must be finite and in [0,1]"); + } + } + std::vector adjusted(pValues.begin(), pValues.end()); + const double count = static_cast(pValues.size()); + if (method == AdjustmentMethod::none) { + return adjusted; + } + if (method == AdjustmentMethod::bonferroni) { + for (auto& value : adjusted) { + value = std::min(1.0, value * count); + } + return adjusted; + } + + const auto order = sortedIndices(pValues); + if (method == AdjustmentMethod::holm) { + double running{}; + for (std::size_t rank = 0; rank < order.size(); ++rank) { + running = std::max( + running, + (count - static_cast(rank)) * pValues[order[rank]]); + adjusted[order[rank]] = std::min(1.0, running); + } + return adjusted; + } + + double multiplier = count; + if (method == AdjustmentMethod::benjaminiYekutieli) { + double harmonic{}; + for (std::size_t index = 1U; index <= pValues.size(); ++index) { + harmonic += 1.0 / static_cast(index); + } + multiplier *= harmonic; + } + double running = 1.0; + for (std::size_t reverse = order.size(); reverse-- > 0U;) { + const double rank = static_cast(reverse + 1U); + double candidate{}; + if (method == AdjustmentMethod::hochberg) { + candidate = (count - static_cast(reverse)) * pValues[order[reverse]]; + } else { + candidate = multiplier * pValues[order[reverse]] / rank; + } + running = std::min(running, candidate); + adjusted[order[reverse]] = std::clamp(running, 0.0, 1.0); + } + return adjusted; +} + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/src/tools/svg.cpp b/IntaRNAnew/src/tools/svg.cpp new file mode 100644 index 0000000..da5650d --- /dev/null +++ b/IntaRNAnew/src/tools/svg.cpp @@ -0,0 +1,420 @@ +#include "intarnanew/tools/svg.hpp" + +#include +#include +#include +#include +#include +#include + +namespace intarnanew::tools { +namespace { + +struct PlotBox { + double left{}; + double top{}; + double width{}; + double height{}; +}; + +[[nodiscard]] auto number(const double value) -> std::string { + std::ostringstream output; + output << std::fixed << std::setprecision(3) << value; + auto text = output.str(); + while (text.size() > 1U && text.back() == '0') { + text.pop_back(); + } + if (!text.empty() && text.back() == '.') { + text.pop_back(); + } + if (text == "-0") { + text = "0"; + } + return text; +} + +[[nodiscard]] auto validDimensions( + const std::size_t width, + const std::size_t height) -> std::expected { + if (width < 240U || height < 180U) { + return std::unexpected("SVG dimensions must be at least 240 by 180 pixels"); + } + if (width > 32768U || height > 32768U) { + return std::unexpected("SVG dimensions exceed the 32768-pixel limit"); + } + return {}; +} + +[[nodiscard]] auto documentStart( + const std::size_t width, + const std::size_t height, + const std::string_view title) -> std::string { + std::ostringstream output; + output << "\n" + << "\n" + << "" << xmlEscape(title) << "\n" + << "\n" + << "\n"; + return output.str(); +} + +[[nodiscard]] auto scale( + const double value, + const double minimum, + const double maximum, + const double outputMinimum, + const double outputMaximum) noexcept -> double { + if (minimum == maximum) { + return 0.5 * (outputMinimum + outputMaximum); + } + return outputMinimum + (value - minimum) * (outputMaximum - outputMinimum) / + (maximum - minimum); +} + +[[nodiscard]] auto tickValue(const double minimum, const double maximum, const int tick) noexcept + -> double { + return minimum + (maximum - minimum) * static_cast(tick) / 5.0; +} + +struct Rgb { int red{}; int green{}; int blue{}; }; + +[[nodiscard]] auto interpolate(const Rgb low, const Rgb high, const double fraction) noexcept -> Rgb { + const auto channel = [fraction](const int start, const int end) { + return static_cast(std::lround(start + fraction * static_cast(end - start))); + }; + return {channel(low.red, high.red), channel(low.green, high.green), channel(low.blue, high.blue)}; +} + +[[nodiscard]] auto color(const double value, const double minimum, const double maximum) -> std::string { + const double fraction = maximum == minimum + ? 0.5 + : std::clamp((value - minimum) / (maximum - minimum), 0.0, 1.0); + // ColorBrewer RdBu reversed: strongest (most negative) values are blue, + // values at the upper bound are red/near white depending on the data. + constexpr Rgb blue{33, 102, 172}; + constexpr Rgb pale{247, 247, 247}; + constexpr Rgb red{178, 24, 43}; + const Rgb result = fraction <= 0.5 + ? interpolate(blue, pale, fraction * 2.0) + : interpolate(pale, red, (fraction - 0.5) * 2.0); + std::ostringstream output; + output << '#' << std::hex << std::setfill('0') << std::setw(2) << result.red + << std::setw(2) << result.green << std::setw(2) << result.blue; + return output.str(); +} + +} // namespace + +auto xmlEscape(const std::string_view text) -> std::string { + std::string result; + result.reserve(text.size()); + for (const char symbol : text) { + switch (symbol) { + case '&': result += "&"; break; + case '<': result += "<"; break; + case '>': result += ">"; break; + case '"': result += """; break; + case '\'': result += "'"; break; + default: result.push_back(symbol); break; + } + } + return result; +} + +auto profileSvg( + const std::span points, + const ProfileSvgOptions& options) -> std::expected { + auto dimensions = validDimensions(options.width, options.height); + if (!dimensions) { + return std::unexpected(dimensions.error()); + } + if (points.empty()) { + return std::unexpected("profile requires at least one point"); + } + double xMinimum = std::numeric_limits::infinity(); + double xMaximum = -std::numeric_limits::infinity(); + double yMinimum = std::numeric_limits::infinity(); + double yMaximum = -std::numeric_limits::infinity(); + std::size_t present{}; + for (const auto& point : points) { + if (!std::isfinite(point.position)) { + return std::unexpected("profile positions must be finite"); + } + xMinimum = std::min(xMinimum, point.position); + xMaximum = std::max(xMaximum, point.position); + if (point.value) { + if (!std::isfinite(*point.value)) { + return std::unexpected("profile values must be finite or missing"); + } + yMinimum = std::min(yMinimum, *point.value); + yMaximum = std::max(yMaximum, *point.value); + ++present; + } + } + if (present == 0U) { + return std::unexpected("profile contains no finite values"); + } + if (yMinimum == yMaximum) { + const double padding = std::max(1.0, std::abs(yMinimum) * 0.1); + yMinimum -= padding; + yMaximum += padding; + } + if (options.zeroLine) { + yMinimum = std::min(yMinimum, 0.0); + yMaximum = std::max(yMaximum, 0.0); + } + + const PlotBox plot{88.0, 54.0, static_cast(options.width) - 120.0, + static_cast(options.height) - 132.0}; + std::ostringstream output; + output << documentStart(options.width, options.height, options.title) + << "" << xmlEscape(options.title) + << "\n"; + for (int tick = 0; tick <= 5; ++tick) { + const double x = plot.left + plot.width * static_cast(tick) / 5.0; + const double y = plot.top + plot.height * static_cast(tick) / 5.0; + const double xValue = tickValue(xMinimum, xMaximum, tick); + const double yValue = tickValue(yMaximum, yMinimum, tick); + output << "\n" + << "\n" + << "" + << number(xValue) << "\n" + << "" << number(yValue) + << "\n"; + } + output << "\n" + << "\n"; + if (options.zeroLine && yMinimum <= 0.0 && yMaximum >= 0.0) { + const double zero = scale(0.0, yMinimum, yMaximum, plot.top + plot.height, plot.top); + output << "\n"; + } + + bool inSegment{}; + for (const auto& point : points) { + if (!point.value) { + if (inSegment) { + output << "\"/>\n"; + inSegment = false; + } + continue; + } + const double x = scale(point.position, xMinimum, xMaximum, plot.left, plot.left + plot.width); + const double y = scale(*point.value, yMinimum, yMaximum, plot.top + plot.height, plot.top); + if (!inSegment) { + output << "\n"; + } + // A single-point segment has no visible stroke in SVG, so draw every + // present observation as a small point as well as part of the polyline. + for (const auto& point : points) { + if (!point.value) { + continue; + } + const double x = scale(point.position, xMinimum, xMaximum, plot.left, plot.left + plot.width); + const double y = scale(*point.value, yMinimum, yMaximum, plot.top + plot.height, plot.top); + output << "" + << number(point.position) << ": " << number(*point.value) + << "\n"; + } + output << "" + << xmlEscape(options.xLabel) << "\n" + << "" + << xmlEscape(options.yLabel) << "\n\n"; + return output.str(); +} + +auto heatmapSvg( + const HeatmapData& data, + const HeatmapSvgOptions& options) -> std::expected { + auto dimensions = validDimensions(options.width, options.height); + if (!dimensions) { + return std::unexpected(dimensions.error()); + } + if (data.xLabels.empty() || data.yLabels.empty()) { + return std::unexpected("heatmap requires non-empty x and y labels"); + } + if (data.values.size() != data.xLabels.size() * data.yLabels.size()) { + return std::unexpected("heatmap value count does not match its dimensions"); + } + double minimum = std::numeric_limits::infinity(); + double maximum = -std::numeric_limits::infinity(); + std::vector> values = data.values; + for (auto& value : values) { + if (!value) { + continue; + } + if (!std::isfinite(*value)) { + return std::unexpected("heatmap values must be finite or missing"); + } + if (options.clampPositiveToZero && *value > 0.0) { + *value = 0.0; + } + minimum = std::min(minimum, *value); + maximum = std::max(maximum, *value); + } + if (!std::isfinite(minimum)) { + return std::unexpected("heatmap contains no finite values"); + } + const PlotBox plot{108.0, 56.0, static_cast(options.width) - 172.0, + static_cast(options.height) - 144.0}; + const double cellWidth = plot.width / static_cast(data.xLabels.size()); + const double cellHeight = plot.height / static_cast(data.yLabels.size()); + + std::ostringstream output; + output << documentStart(options.width, options.height, options.title) + << "" << xmlEscape(options.title) + << "\n"; + for (std::size_t y = 0; y < data.yLabels.size(); ++y) { + for (std::size_t x = 0; x < data.xLabels.size(); ++x) { + const auto& value = values[y * data.xLabels.size() + x]; + output << "(x)) + << "\" y=\"" << number(plot.top + cellHeight * static_cast(y)) + << "\" width=\"" << number(cellWidth + 0.01) << "\" height=\"" + << number(cellHeight + 0.01) << "\" fill=\"" + << (value ? color(*value, minimum, maximum) : xmlEscape(options.missingColor)) + << "\">" << xmlEscape(data.xLabels[x]) << " × " + << xmlEscape(data.yLabels[y]) << ": " + << (value ? number(*value) : std::string{"NA"}) << "\n"; + } + } + output << "\n"; + + const auto labelStride = [](const std::size_t count) { + return std::max(1U, (count + 11U) / 12U); + }; + for (std::size_t x = 0; x < data.xLabels.size(); x += labelStride(data.xLabels.size())) { + output << "(x) + 0.5) * cellWidth) + << "\" y=\"" << number(plot.top + plot.height + 17.0) + << "\" text-anchor=\"middle\">" << xmlEscape(data.xLabels[x]) << "\n"; + } + for (std::size_t y = 0; y < data.yLabels.size(); y += labelStride(data.yLabels.size())) { + output << "(y) + 0.5) * cellHeight + 4.0) + << "\" text-anchor=\"end\">" << xmlEscape(data.yLabels[y]) << "\n"; + } + output << "" + << xmlEscape(options.xLabel) << "\n" + << "" + << xmlEscape(options.yLabel) << "\n" + << "min " << number(minimum) << "\n" + << "max " << number(maximum) << "\n" + << "\n"; + return output.str(); +} + +auto regionsSvg( + const std::span regions, + const RegionSvgOptions& options) -> std::expected { + auto dimensions = validDimensions(options.width, options.height); + if (!dimensions) { + return std::unexpected(dimensions.error()); + } + if (regions.empty()) { + return std::unexpected("region plot requires at least one span"); + } + long long minimum = std::numeric_limits::max(); + long long maximum = std::numeric_limits::min(); + for (const auto& region : regions) { + if (region.id.empty()) { + return std::unexpected("region identifiers must not be empty"); + } + if (region.start > region.end) { + return std::unexpected( + "region '" + region.id + "' starts after it ends"); + } + minimum = std::min(minimum, region.start); + maximum = std::max(maximum, region.end); + } + const PlotBox plot{150.0, 55.0, static_cast(options.width) - 185.0, + static_cast(options.height) - 128.0}; + const double laneHeight = plot.height / static_cast(regions.size()); + const double barHeight = std::clamp(laneHeight * 0.58, 2.0, 18.0); + const auto x = [&](const long long position) { + return scale( + static_cast(position), static_cast(minimum), + static_cast(maximum), plot.left, plot.left + plot.width); + }; + + std::ostringstream output; + output << documentStart(options.width, options.height, options.title) + << "" << xmlEscape(options.title) + << "\n"; + for (int tick = 0; tick <= 5; ++tick) { + const double coordinate = plot.left + plot.width * static_cast(tick) / 5.0; + const double value = tickValue( + static_cast(minimum), static_cast(maximum), tick); + output << "\n" + << "" + << number(value) << "\n"; + } + for (std::size_t index = 0U; index < regions.size(); ++index) { + const auto& region = regions[index]; + const double center = plot.top + (static_cast(index) + 0.5) * laneHeight; + const double left = x(region.start); + const double right = x(region.end); + output << "\n" + << "" + << xmlEscape(region.id) << "\n" + << "" << xmlEscape(region.id) << ": " << region.start << "–" + << region.end << "\n"; + } + output << "\n" + << "" + << xmlEscape(options.xLabel) << "\n\n"; + return output.str(); +} + +} // namespace intarnanew::tools diff --git a/IntaRNAnew/tests/accessibility_oracle.cpp b/IntaRNAnew/tests/accessibility_oracle.cpp new file mode 100644 index 0000000..7f2e501 --- /dev/null +++ b/IntaRNAnew/tests/accessibility_oracle.cpp @@ -0,0 +1,364 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/compression.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using intarnanew::Index; +using intarnanew::Interval; +using intarnanew::NativeAccessibility; +using intarnanew::Sequence; +using intarnanew::SideConfig; +using intarnanew::gasConstantKcal; + +struct Edge { + Index left{}; + Index right{}; + double energy{}; +}; + +auto failures = 0; + +void check(const bool condition, const std::string_view message) { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + ++failures; + } +} + +[[nodiscard]] auto close(const double left, const double right, const double tolerance = 1e-11) -> bool { + const auto scale = std::max({1.0, std::abs(left), std::abs(right)}); + return std::abs(left - right) <= tolerance * scale; +} + +[[nodiscard]] auto pairEnergy(const char left, const char right) -> double { + const auto a = static_cast(std::toupper(static_cast(left))); + const auto b = static_cast(std::toupper(static_cast(right))); + if ((a == 'G' && b == 'C') || (a == 'C' && b == 'G')) return -2.4; + if ((a == 'A' && b == 'U') || (a == 'U' && b == 'A')) return -1.1; + if ((a == 'G' && b == 'U') || (a == 'U' && b == 'G')) return -0.5; + return -0.8; +} + +[[nodiscard]] auto crosses(const Edge& left, const Edge& right) -> bool { + return (left.left < right.left && right.left < left.right && left.right < right.right) || + (right.left < left.left && left.left < right.right && right.right < left.right); +} + +[[nodiscard]] auto brutePartition( + const Sequence& sequence, + const std::string_view constraints, + const Index maxPairSpan, + const std::optional forcedUnpaired = std::nullopt) -> double { + std::vector edges; + for (Index left = 0; left < sequence.size(); ++left) { + for (Index right = left + 4U; right < sequence.size(); ++right) { + if (right - left > maxPairSpan || + constraints[left] == 'x' || constraints[left] == 'b' || + constraints[right] == 'x' || constraints[right] == 'b' || + (forcedUnpaired && (forcedUnpaired->contains(left) || forcedUnpaired->contains(right))) || + !intarnanew::canPair(sequence[left], sequence[right])) { + continue; + } + edges.push_back({left, right, pairEnergy(sequence[left], sequence[right])}); + } + } + + const auto rt = gasConstantKcal * (37.0 + 273.15); + std::vector paired(sequence.size(), false); + std::vector selected; + double partition{}; + + const std::function enumerate = [&](const Index edgeIndex, const double energy) { + if (edgeIndex == edges.size()) { + for (Index position = 0; position < sequence.size(); ++position) { + if (constraints[position] == 'p' && !paired[position]) return; + } + partition += std::exp(-energy / rt); + return; + } + + enumerate(edgeIndex + 1U, energy); + + const auto edge = edges[edgeIndex]; + if (paired[edge.left] || paired[edge.right] || + std::ranges::any_of(selected, [&](const Edge chosen) { return crosses(edge, chosen); })) { + return; + } + paired[edge.left] = true; + paired[edge.right] = true; + selected.push_back(edge); + enumerate(edgeIndex + 1U, energy + edge.energy); + selected.pop_back(); + paired[edge.left] = false; + paired[edge.right] = false; + }; + enumerate(0U, 0.0); + return partition; +} + +void compareEveryInterval( + const std::string_view bases, + const std::string_view constraints, + const Index maxPairSpan) { + const Sequence sequence("oracle", std::string(bases)); + SideConfig config; + config.accessibilitySpan = maxPairSpan; + config.accessibilityConstraint = std::string(constraints); + const NativeAccessibility provider(sequence, config, 37.0); + + const auto denominator = brutePartition(sequence, constraints, maxPairSpan); + check(denominator > 0.0, "enumerated constrained ensemble is nonempty"); + for (Index begin = 0; begin < sequence.size(); ++begin) { + for (Index end = begin; end < sequence.size(); ++end) { + const Interval interval{begin, end}; + const bool containsBlocked = std::string_view(constraints).substr( + begin, end - begin + 1U).find('b') != std::string_view::npos; + if (containsBlocked) { + check(provider.unpairedProbability(interval) == 0.0, + "blocked interval is excluded from interaction"); + continue; + } + const auto expected = brutePartition( + sequence, constraints, maxPairSpan, interval) / denominator; + const auto observed = provider.unpairedProbability(interval); + check(close(observed, expected), "joint interval probability matches exhaustive enumeration"); + check(close(provider.positionUnpairedProbability(begin), + brutePartition(sequence, constraints, maxPairSpan, Interval{begin, begin}) / + denominator), + "single-position marginal matches exhaustive enumeration"); + const auto opening = provider.openingEnergy(interval); + if (expected == 0.0) { + check(std::isinf(opening), "zero joint probability has infinite opening energy"); + } else { + const auto rt = gasConstantKcal * (37.0 + 273.15); + check(close(opening, -rt * std::log(expected)), + "opening energy is -RT log of the joint probability"); + } + } + } +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + check(NativeAccessibility::modelName == "native-noncrossing-pair-v1", + "native model is explicitly identified"); + + compareEveryInterval("GCAAAUGC", "........", 8U); + compareEveryInterval("GCAAAUGC", "p.......", 8U); + compareEveryInterval("GCAAAUGC", "x.......", 8U); + compareEveryInterval("GCAAAUGC", ".......b", 8U); + compareEveryInterval("GCAAAUGC", "........", 4U); + + { + const Sequence sequence("correlation", "GAAAC"); + SideConfig config; + config.accessibilitySpan = sequence.size(); + NativeAccessibility provider(sequence, config, 37.0); + const auto singleLeft = provider.unpairedProbability({0U, 0U}); + const auto singleRight = provider.unpairedProbability({4U, 4U}); + const auto joint = provider.unpairedProbability({0U, 4U}); + const auto expected = brutePartition(sequence, ".....", sequence.size(), Interval{0U, 4U}) / + brutePartition(sequence, ".....", sequence.size()); + check(close(joint, expected), "correlated endpoint joint probability is exact"); + check(!close(joint, singleLeft * singleRight, 1e-6), + "joint probability is not fabricated from marginal products"); + } + + { + const Sequence sequence("forced-pair", "GAAAC"); + SideConfig config; + config.accessibilitySpan = sequence.size(); + config.accessibilityConstraint = "p...p"; + NativeAccessibility provider(sequence, config, 37.0); + check(provider.positionUnpairedProbability(0U) == 0.0, + "p forces its position to be paired"); + check(provider.positionUnpairedProbability(4U) == 0.0, + "both forced-pair endpoints remain paired"); + check(provider.positionUnpairedProbability(2U) == 1.0, + "unpairable hairpin interior remains unpaired"); + } + + { + const Sequence sequence("ranges", "GAAAC", 10); + SideConfig config; + config.accessibilitySpan = sequence.size(); + config.accessibilityConstraint = "x:10-10,b:14-14"; + NativeAccessibility provider(sequence, config, 37.0); + check(provider.positionUnpairedProbability(0U) == 1.0, + "x range forces a position unpaired"); + check(provider.blocked(4U), "b range marks a position blocked"); + check(provider.positionUnpairedProbability(4U) == 0.0, + "blocked position is excluded from interaction"); + check(std::isinf(provider.openingEnergy({4U, 4U})), + "blocked position has infinite interaction opening energy"); + } + + { + const Sequence sequence("negative-ranges", "GAAAC", -5); + SideConfig config; + config.accessibilitySpan = sequence.size(); + config.accessibilityConstraint = "x:-5--3,b:-1--1"; + NativeAccessibility provider(sequence, config, 37.0); + check(provider.positionUnpairedProbability(0U) == 1.0, + "signed coordinate ranges are parsed without confusing the separator"); + check(provider.blocked(4U), "negative blocked range maps to the final position"); + } + + { + const Sequence sequence("temperature", "GAAAC"); + SideConfig config; + bool rejected{}; + try { + const NativeAccessibility provider(sequence, config, -273.15); + static_cast(provider); + } catch (const std::invalid_argument&) { + rejected = true; + } + check(rejected, "nonphysical absolute temperature is rejected"); + } + + { + const Sequence sequence("impossible", "AAAAA"); + SideConfig config; + config.accessibilityConstraint = "p...."; + bool rejected{}; + try { + const NativeAccessibility provider(sequence, config, 37.0); + static_cast(provider); + } catch (const std::invalid_argument&) { + rejected = true; + } + check(rejected, "infeasible forced-pair constraints are rejected"); + } + + { + const auto tablePath = std::filesystem::temp_directory_path() / + "intarnanew-accessibility-oracle-table.txt"; + const auto gzipPath = std::filesystem::temp_directory_path() / + "intarnanew-accessibility-oracle-table.txt.gz"; + const auto magicPath = std::filesystem::temp_directory_path() / + "intarnanew-accessibility-oracle-table.bin"; + const auto falseGzipPath = std::filesystem::temp_directory_path() / + "intarnanew-accessibility-oracle-false.txt.gz"; + const auto corruptGzipPath = std::filesystem::temp_directory_path() / + "intarnanew-accessibility-oracle-corrupt.txt.gz"; + const std::string tableText{"1 0.5\n2 0.5\n3 0.5\n4 0.5\n5 0.5\n"}; + { + std::ofstream table(tablePath); + table << tableText; + } + const Sequence sequence("table", "GAAAC"); + SideConfig config; + config.accessibilityFile = tablePath.string(); + TableAccessibility provider(sequence, config, 37.0, true); + check(close(provider.unpairedProbability({0U, 0U}), 0.5), + "table-backed single-position probability is read"); + bool rejected{}; + try { + static_cast(provider.unpairedProbability({0U, 1U})); + } catch (const std::runtime_error&) { + rejected = true; + } + check(rejected, + "missing table joint probability is not replaced by a marginal product"); + + const auto compressed = gzipCompress(tableText); + check(compressed.has_value(), "accessibility table gzip fixture is encoded"); + if (compressed) { + { + std::ofstream gzipFile(gzipPath, std::ios::binary); + gzipFile.write(compressed->data(), static_cast(compressed->size())); + } + config.accessibilityFile = gzipPath.string(); + TableAccessibility gzipProvider(sequence, config, 37.0, true); + check(close(gzipProvider.positionUnpairedProbability(2U), 0.5), + "gzip accessibility table is decoded through the native compression API"); + + { + std::ofstream magicFile(magicPath, std::ios::binary); + magicFile.write(compressed->data(), static_cast(compressed->size())); + } + config.accessibilityFile = magicPath.string(); + TableAccessibility magicProvider(sequence, config, 37.0, true); + check(close(magicProvider.positionUnpairedProbability(4U), 0.5), + "gzip accessibility table is detected by signature without a .gz suffix"); + + auto corrupt = *compressed; + corrupt[corrupt.size() - 8U] = static_cast( + static_cast(corrupt[corrupt.size() - 8U]) ^ 1U); + { + std::ofstream corruptFile(corruptGzipPath, std::ios::binary); + corruptFile.write(corrupt.data(), static_cast(corrupt.size())); + } + config.accessibilityFile = corruptGzipPath.string(); + rejected = false; + try { + const TableAccessibility corruptProvider(sequence, config, 37.0, true); + static_cast(corruptProvider); + } catch (const std::invalid_argument& error) { + rejected = std::string_view(error.what()).find("CRC32") != std::string_view::npos; + } + check(rejected, "corrupt gzip accessibility table reports its checksum failure"); + } + + { + std::ofstream falseGzip(falseGzipPath, std::ios::binary); + falseGzip << tableText; + } + config.accessibilityFile = falseGzipPath.string(); + rejected = false; + try { + const TableAccessibility falseGzipProvider(sequence, config, 37.0, true); + static_cast(falseGzipProvider); + } catch (const std::invalid_argument& error) { + rejected = std::string_view(error.what()).find("no gzip signature") != std::string_view::npos; + } + check(rejected, ".gz accessibility input without gzip magic has a clear diagnostic"); + + std::error_code removalError; + for (const auto& path : {tablePath, gzipPath, magicPath, falseGzipPath, corruptGzipPath}) { + std::filesystem::remove(path, removalError); + check(!removalError, "temporary accessibility table fixture is removed"); + removalError.clear(); + } + } + + { + std::string bases; + bases.reserve(120U); + for (Index index = 0; index < 120U; ++index) { + bases.push_back(index % 2U == 0U ? 'G' : 'C'); + } + const Sequence sequence("stability", bases); + SideConfig config; + config.accessibilitySpan = 80U; + NativeAccessibility provider(sequence, config, 37.0); + const auto probability = provider.unpairedProbability({45U, 74U}); + check(std::isfinite(probability) && probability >= 0.0 && probability <= 1.0, + "log-space recurrence remains finite on a high-weight ensemble"); + const auto repeat = provider.unpairedProbability({45U, 74U}); + check(repeat == probability, "cached probability is deterministic"); + } + + if (failures == 0) { + std::cout << "All native accessibility oracle tests passed.\n"; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/IntaRNAnew/tests/cli_io_output.cpp b/IntaRNAnew/tests/cli_io_output.cpp new file mode 100644 index 0000000..902cd65 --- /dev/null +++ b/IntaRNAnew/tests/cli_io_output.cpp @@ -0,0 +1,550 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/cli.hpp" +#include "intarnanew/output.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failureCount{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failureCount; + } +} + +[[nodiscard]] auto occurrences( + const std::string_view text, + const std::string_view needle) -> std::size_t { + std::size_t count{}; + std::size_t position{}; + while ((position = text.find(needle, position)) != std::string_view::npos) { + ++count; + position += needle.size(); + } + return count; +} + +[[nodiscard]] auto outputInteraction( + const std::size_t targetIndex, + const std::string& targetId = "target") -> intarnanew::Interaction { + intarnanew::Interaction interaction; + interaction.targetId = targetId; + interaction.queryId = "query"; + interaction.pairs.push_back({targetIndex, 0U}); + interaction.energy.initiation = -1.0; + return interaction; +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + { + const std::vector arguments{ + "--TaRgEt=C", "--QuErY=G", "--EnErGy=B", "--AcC=N", "--NoSeEd", + "--OuTmOdE=C", "--QiDxPoS0", "-2", + }; + auto parsed = Cli::parse(arguments); + check(parsed.has_value(), "long option names are case-insensitive"); + if (parsed) { + check(parsed->query.firstPosition == -2, "space-separated negative numeric value is consumed"); + check(parsed->output.mode == OutputMode::csv, "mixed-case output option was applied"); + check(!parsed->seed.required, "mixed-case Boolean option was applied"); + } + } + + { + const auto rejected = [](const std::string_view option) { + const std::vector arguments{ + "--target=C", "--query=G", "--energy=B", "--acc=N", "--noSeed", option, + }; + return Cli::parse(arguments); + }; + check(!rejected("--windowWidth=4"), "prediction windows below the documented minimum are rejected"); + check(!rejected("--qShape=shape.txt"), "incomplete SHAPE configuration is rejected"); + check(rejected("--tRegionLenMax=20").has_value(), + "implemented automatic-region configuration is accepted"); + check(rejected("--outPerRegion").has_value(), + "implemented per-region output configuration is accepted"); + check(rejected("--qPfScale=1.2").has_value(), + "implemented partition scaling is accepted"); + check(rejected("--accNoLP").has_value(), + "implemented accessibility noLP control is accepted"); + check(rejected("--outBestSeedOnly").has_value(), + "implemented best-seed filtering is accepted"); + check(rejected("--verbose").has_value(), + "compatibility logging control is accepted and recorded"); + } + + { + const Sequence sequence("coordinates", "AAAA", -2); + check(sequence.externalIndex(0U) == -2, "negative origin first coordinate"); + check(sequence.externalIndex(1U) == -1, "coordinate before skipped zero"); + check(sequence.externalIndex(2U) == 1, "coordinate zero is skipped"); + check(sequence.externalIndex(3U) == 2, "coordinate after skipped zero"); + const auto internal = sequence.internalIndex(1); + check(internal && *internal == 2U, "positive external coordinate maps across skipped zero"); + check(!sequence.internalIndex(0), "external coordinate zero is rejected for negative origins"); + } + + { + const Sequence sequence("iupac", "ACGURYSWKMBDHVN"); + check(sequence.str() == "ACGUNNNNNNNNNNN", "non-canonical IUPAC input normalizes to N"); + check(!canPair('N', 'A'), "N is non-pairing"); + check(!canPair('R', 'Y'), "ambiguous public symbols are non-pairing"); + check(canPair('C', 'G'), "canonical complementary bases still pair"); + } + + { + std::vector sequences; + sequences.emplace_back("one", "A"); + sequences.emplace_back("two", "C"); + sequences.emplace_back("three", "G"); + auto selected = SequenceReader::select(std::move(sequences), "3, 1"); + check(selected && selected->size() == 2U, "sequence subset selects requested records"); + if (selected && selected->size() == 2U) { + check((*selected)[0].id() == "one" && (*selected)[1].id() == "three", + "sequence subset preserves FASTA input order"); + } + std::vector invalidSequences; + invalidSequences.emplace_back("one", "A"); + check(!SequenceReader::select(std::move(invalidSequences), "0"), + "sequence subset rejects zero index"); + } + + const Sequence target("target", "CCCCCCCCCCCC"); + const Sequence query("query", "G"); + PredictionResult result; + result.rt = 1.0; + result.interactions.push_back(outputInteraction(9U)); + result.interactions.push_back(outputInteraction(1U)); + result.interactions.push_back(outputInteraction(2U)); + + { + Config config; + config.output.mode = OutputMode::csv; + config.output.csvColumns = "start1,E"; + config.output.csvSort = "start1"; + auto formatted = OutputFormatter::primary(config, target, query, result); + check(formatted && *formatted == "start1;E\n2;-1\n3;-1\n10;-1\n", + "CSV coordinate sorting is numeric rather than lexical"); + + auto withoutHeader = OutputFormatter::primary(config, target, query, result, false); + check(withoutHeader && !withoutHeader->starts_with("start1;E"), + "subsequent CSV documents can suppress their header"); + if (formatted && withoutHeader) { + check(occurrences(*formatted + *withoutHeader, "start1;E\n") == 1U, + "combined multi-pair CSV has exactly one header"); + } + } + + { + Config config; + config.output.mode = OutputMode::csv; + config.output.csvColumns = "start1,P_E"; + config.output.csvSort = "P_E"; + PredictionResult probabilityResult; + probabilityResult.rt = 1.0; + probabilityResult.logPartition = 0.0; + auto lowerProbability = outputInteraction(0U); + lowerProbability.energy.initiation = -10.0; + lowerProbability.probability = 0.2; + auto higherProbability = outputInteraction(1U); + higherProbability.probability = 0.8; + probabilityResult.interactions.push_back(std::move(lowerProbability)); + probabilityResult.interactions.push_back(std::move(higherProbability)); + auto formatted = OutputFormatter::primary( + config, target, query, probabilityResult); + check(formatted && *formatted == "start1;P_E\n1;0.2\n2;0.8\n", + "P_E formatting and sorting use the normalized site probability"); + } + + { + Config config; + config.output.mode = OutputMode::csv; + config.output.csvColumns = "id1,E"; + PredictionResult escapedResult; + escapedResult.rt = 1.0; + escapedResult.interactions.push_back(outputInteraction(0U, "target;quoted")); + auto formatted = OutputFormatter::primary(config, target, query, escapedResult); + check(formatted && formatted->find("\"target;quoted\";-1") != std::string::npos, + "CSV values containing the separator are quoted"); + } + + { + Config config; + config.output.mode = OutputMode::ensemble; + PredictionResult empty; + empty.rt = 0.6; + empty.targetLogPartition = 0.0; + empty.queryLogPartition = 0.0; + empty.targetEnsembleFreeEnergy = -1.31; + empty.queryEnsembleFreeEnergy = -0.25; + auto formatted = OutputFormatter::primary(config, target, query, empty); + check(formatted && *formatted == + "id1 target\nid2 query\nRT 0.6\nEall 0.00\nEall1 -1.31\n" + "Eall2 -0.25\nEallTotal 0.00\n", + "empty ensemble keeps monomer summaries but reports no formation total"); + check(formatted && formatted->find("inf") == std::string::npos, + "non-finite implementation spelling is not emitted"); + + PredictionResult populated; + populated.rt = 1.0; + populated.ensembleSites.push_back(outputInteraction(0U)); + populated.ensembleFreeEnergy = -5.889; + populated.targetEnsembleFreeEnergy = -1.239; + populated.queryEnsembleFreeEnergy = -2.349; + auto populatedText = OutputFormatter::primary(config, target, query, populated); + check(populatedText && *populatedText == + "id1 target\nid2 query\nRT 1\nEall -5.88\nEall1 -1.23\n" + "Eall2 -2.34\nEallTotal -9.47\n", + "ensemble energies use centikcal truncation and include monomer totals"); + PredictionResult signedZero; + signedZero.rt = 1.0; + signedZero.ensembleSites.push_back(outputInteraction(0U)); + signedZero.ensembleFreeEnergy = -1.0; + signedZero.targetEnsembleFreeEnergy = -0.0; + signedZero.queryEnsembleFreeEnergy = -0.0; + const auto signedZeroText = + OutputFormatter::primary(config, target, query, signedZero); + check(signedZeroText && + signedZeroText->find("Eall1 0.00\nEall2 0.00\n") != std::string::npos, + "fixed ensemble output canonicalizes negative zero"); + } + + { + Config config; + DisabledAccessibility accessibility(target); + auto formatted = OutputFormatter::auxiliary( + "QACC:ignored", config, target, query, accessibility, accessibility, result); + check(formatted.has_value(), "auxiliary output descriptor names are case-insensitive"); + } + + { + const Sequence encodedTarget("target", "ACG", 5); + const Sequence encodedQuery("query", "UGC", -2); + Interaction interaction; + interaction.targetId = "target"; + interaction.queryId = "query"; + interaction.pairs = {{0U, 2U}, {2U, 0U}}; + interaction.energy.initiation = -2.0; + interaction.energy.additive = 0.5; + interaction.seeds = {SeedMatch{0U, 1U, -4.0, 0.5, 0.25, 0.6, 0.7}}; + PredictionResult encodedResult; + encodedResult.rt = 1.0; + encodedResult.interactions.push_back(interaction); + encodedResult.logPartition = std::log(10.0); + encodedResult.ensembleFreeEnergy = -3.0; + encodedResult.targetLogPartition = std::log(2.0); + encodedResult.queryLogPartition = std::log(3.0); + encodedResult.targetEnsembleFreeEnergy = -1.0; + encodedResult.queryEnsembleFreeEnergy = -2.0; + + Config config; + config.output.mode = OutputMode::csv; + config.output.csvColumns = + "subseqDB,hybridDB,bpList,E,Etotal,E_hybrid,E_add," + "seedE,seedED1,seedED2,seedPu1,seedPu2," + "Eall1,Eall2,Zall1,Zall2,EallTotal"; + auto formatted = OutputFormatter::primary( + config, encodedTarget, encodedQuery, encodedResult); + check(formatted && *formatted == + "subseqDB;hybridDB;bpList;E;Etotal;E_hybrid;E_add;" + "seedE;seedED1;seedED2;seedPu1;seedPu2;" + "Eall1;Eall2;Zall1;Zall2;EallTotal\n" + "5ACG&-2UGC;5|.|&-2|.|;(5,1):(7,-2);-1.5;-4.5;-1.5;0.5;" + "-4;0.5;0.25;0.6;0.7;" + "-1;-2;2;3;-6\n", + "CSV sequence, bar, pair-list, and monomer-ensemble fields follow the public contract"); + + encodedResult.interactions.front().seeds.clear(); + config.output.csvColumns = + "seedStart1,seedEnd1,seedStart2,seedEnd2,seedE," + "seedED1,seedED2,seedPu1,seedPu2"; + auto seedless = OutputFormatter::primary( + config, encodedTarget, encodedQuery, encodedResult); + check(seedless && *seedless == + "seedStart1;seedEnd1;seedStart2;seedEnd2;seedE;" + "seedED1;seedED2;seedPu1;seedPu2\n" + "NAN;NAN;NAN;NAN;NAN;NAN;NAN;NAN;NAN\n", + "seedless CSV fields use the public NAN convention"); + } + + { + const Sequence chartTarget("target", "UUUUUUCCCUAAAUCCUUCUUU"); + const Sequence chartQuery("query", "GGGGGGGGGGGGGGGGGGGGG"); + Interaction interaction; + interaction.targetId = "target"; + interaction.queryId = "query"; + interaction.pairs = {{7U, 6U}, {8U, 5U}, {14U, 4U}, {15U, 3U}}; + interaction.energy.initiation = -1.0; + interaction.energy.loops = -3.0; + PredictionResult chartResult; + chartResult.rt = 1.0; + chartResult.interactions.push_back(interaction); + Config config; + config.output.mode = OutputMode::normal; + auto formatted = OutputFormatter::primary( + config, chartTarget, chartQuery, chartResult); + check(formatted && *formatted == + "\ntarget\n" + " 8 16\n" + " | |\n" + " 5'-UUUUUUC UAAAU UUCUUU-3'\n" + " CC CC\n" + " || ||\n" + " GG GG\n" + "3'-GGG...GGGG ----- GGG-5'\n" + " | |\n" + " 7 4\n" + "query\n\n" + "interaction energy = -4 kcal/mol\n", + "normal ASCII output is byte-compatible with the public no-GU-end fixture"); + } + + { + const Sequence chartTarget("target", "UCCC", -2); + const Sequence chartQuery("query", "GGGG", -2); + Interaction interaction; + interaction.targetId = "target"; + interaction.queryId = "query"; + interaction.pairs = {{0U, 3U}, {1U, 2U}, {2U, 1U}, {3U, 0U}}; + interaction.energy.initiation = -1.0; + interaction.energy.loops = -3.0; + interaction.energy.additive = 2.5; + interaction.unpairedTarget = 0.35; + interaction.unpairedQuery = 0.45; + interaction.seeds = {SeedMatch{1U, 2U, -2.34, 0.5, 0.25, 0.6, 0.7}}; + PredictionResult chartResult; + chartResult.rt = 1.0; + chartResult.interactions.push_back(interaction); + Config config; + config.output.mode = OutputMode::detailed; + auto formatted = OutputFormatter::primary( + config, chartTarget, chartQuery, chartResult); + check(formatted && *formatted == + "\ntarget\n" + " -2 +2\n" + " | |\n" + " 5'- -3'\n" + " UCCC\n" + " :++|\n" + " GGGG\n" + " 3'- -5'\n" + " | |\n" + " +2 -2\n" + "query\n\n" + "interaction seq1 = -2..2\n" + "interaction seq2 = -2..2\n\n" + "interaction energy = -1.5 kcal/mol\n" + " = E(init) = -1\n" + " + E(loops) = -3\n" + " + E(dangleLeft) = 0\n" + " + E(dangleRight) = 0\n" + " + E(endLeft) = 0\n" + " + E(endRight) = 0\n" + " : E(hybrid) = -1.5\n" + " + ED(seq1) = 0\n" + " : Pu(seq1) = 0.35\n" + " + ED(seq2) = 0\n" + " : Pu(seq2) = 0.45\n" + " + E(add) = 2.5\n\n" + "seed seq1 = -1..1\n" + "seed seq2 = -1..1\n" + "seed energy = -2.34\n" + "seed ED1 = 0.5\n" + "seed ED2 = 0.25\n" + "seed Pu1 = 0.6\n" + "seed Pu2 = 0.7\n", + "detailed ASCII output renders stored native Pu values without RT reconstruction"); + } + + { + const Sequence seedTarget("target", "CCCC"); + const Sequence seedQuery("query", "GGGG"); + Interaction interaction; + interaction.targetId = "target"; + interaction.queryId = "query"; + interaction.pairs = {{0U, 3U}, {1U, 2U}, {2U, 1U}, {3U, 0U}}; + interaction.energy.loops = -4.0; + interaction.seeds = { + SeedMatch{2U, 3U, -2.0}, + SeedMatch{0U, 1U, -2.0}, + SeedMatch{1U, 2U, -2.0}, + }; + PredictionResult seedResult; + seedResult.rt = 1.0; + seedResult.interactions.push_back(interaction); + + Config csvConfig; + csvConfig.output.mode = OutputMode::csv; + csvConfig.output.csvColumns = + "seedStart1,seedEnd1,seedStart2,seedEnd2,seedE"; + auto csvAll = OutputFormatter::primary( + csvConfig, seedTarget, seedQuery, seedResult); + check(csvAll && *csvAll == + "seedStart1;seedEnd1;seedStart2;seedEnd2;seedE\n" + "1:2:3;2:3:4;3:2:1;4:3:2;-2:-2:-2\n", + "CSV emits all seeds colon-delimited in energy/coordinate order"); + + csvConfig.output.bestSeedOnly = true; + auto csvBest = OutputFormatter::primary( + csvConfig, seedTarget, seedQuery, seedResult); + check(csvBest && *csvBest == + "seedStart1;seedEnd1;seedStart2;seedEnd2;seedE\n" + "1;2;3;4;-2\n", + "outBestSeedOnly restricts CSV seed fields to the deterministic best seed"); + + Config detailedConfig; + detailedConfig.output.mode = OutputMode::detailed; + auto detailed = OutputFormatter::primary( + detailedConfig, seedTarget, seedQuery, seedResult); + check(detailed && + detailed->find(" ++++\n") != std::string::npos && + detailed->find("seed seq1 = 1..2 | 2..3 | 3..4\n") != std::string::npos && + detailed->find("seed seq2 = 3..4 | 2..3 | 1..2\n") != std::string::npos && + detailed->find("seed energy = -2 | -2 | -2\n") != std::string::npos, + "detailed output joins all seeds with pipes and marks their chart union"); + + detailedConfig.output.bestSeedOnly = true; + auto detailedBest = OutputFormatter::primary( + detailedConfig, seedTarget, seedQuery, seedResult); + check(detailedBest && + detailedBest->find(" ++||\n") != std::string::npos && + detailedBest->find("seed seq1 = 1..2\n") != std::string::npos && + detailedBest->find("seed seq1 = 1..2 |") == std::string::npos, + "outBestSeedOnly restricts detailed fields and chart markers to one seed"); + + interaction.seeds.clear(); + seedResult.interactions = {interaction}; + detailedConfig.output.bestSeedOnly = false; + auto noSeed = OutputFormatter::primary( + detailedConfig, seedTarget, seedQuery, seedResult); + check(noSeed && noSeed->find("seed seq1") == std::string::npos && + noSeed->find("++++") == std::string::npos, + "interactions without seeds emit neither seed details nor seed chart markers"); + } + + { + const Sequence trackedTarget("target", "CU", 5); + const Sequence trackedQuery("query", "GA", -2); + DisabledAccessibility targetAccessibility(trackedTarget); + DisabledAccessibility queryAccessibility(trackedQuery); + Interaction broad; + broad.targetId = "target"; + broad.queryId = "query"; + broad.pairs = {{0U, 1U}, {1U, 0U}}; + broad.energy.initiation = -1.0; + broad.probability = 0.6; + Interaction focused; + focused.targetId = "target"; + focused.queryId = "query"; + focused.pairs = {{0U, 0U}}; + focused.energy.initiation = -2.0; + focused.probability = 0.2; + PredictionResult tracked; + tracked.rt = 1.0; + tracked.ensembleSites = {broad, focused}; + Config config; + + auto qMin = OutputFormatter::auxiliary( + "qMinE:ignored", config, trackedTarget, trackedQuery, + targetAccessibility, queryAccessibility, tracked); + check(qMin && *qMin == "idx;query;minE\n-2;G;-2\n-1;A;-1\n", + "minimum-energy profiles include external coordinate, base, and legacy header"); + + auto pairMin = OutputFormatter::auxiliary( + "pMinE:ignored", config, trackedTarget, trackedQuery, + targetAccessibility, queryAccessibility, tracked); + check(pairMin && *pairMin == + "minE;G_-2;A_-1\nC_5;-2;-1\nU_6;-1;-1\n", + "pair minimum energies use the documented target-row/query-column matrix"); + + auto selectedSpots = OutputFormatter::auxiliary( + "spotProb:5&-2,6&-1:ignored", config, trackedTarget, trackedQuery, + targetAccessibility, queryAccessibility, tracked); + check(selectedSpots && *selectedSpots == + "spot;probability\n4&-3;0.2\n5&-2;0.8\n6&-1;0.6\n", + "specific spot output computes an overlap-safe union and coordinate-aware none label"); + + auto allSpots = OutputFormatter::auxiliary( + "spotProb::ignored", config, trackedTarget, trackedQuery, + targetAccessibility, queryAccessibility, tracked); + check(allSpots && *allSpots == + "spotProb;G_-2;A_-1\nC_5;0.8;0.6\nU_6;0.6;0.6\n", + "empty spot list emits the documented complete probability matrix"); + + auto malformedSpot = OutputFormatter::auxiliary( + "spotProb:5x&-2:ignored", config, trackedTarget, trackedQuery, + targetAccessibility, queryAccessibility, tracked); + check(!malformedSpot, "spot coordinates reject trailing non-numeric characters"); + } + + { + const Sequence tableTarget("target", "CCC", 5); + const Sequence tableQuery("query", "GGG", -2); + DisabledAccessibility targetAccessibility(tableTarget); + DisabledAccessibility queryAccessibility(tableQuery); + PredictionResult empty; + Config config; + config.query.accessibility = AccessibilityKind::disabled; + config.query.interactionLengthMax = 2U; + auto table = OutputFormatter::auxiliary( + "qPu:ignored", config, tableTarget, tableQuery, + targetAccessibility, queryAccessibility, empty); + check(table && *table == + "#unpaired probabilities\n #i$\tl=1\t2\t\n" + "-2\t1.000000e+00\tNA\t\n" + "-1\t1.000000e+00\t1.000000e+00\t\n" + "1\t1.000000e+00\t1.000000e+00\t\n", + "accessibility output uses the public Pu matrix layout and effective length cap"); + } + + { + const auto unique = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto directory = std::filesystem::temp_directory_path() / + ("intarnanew-output-test-" + unique); + std::error_code error; + std::filesystem::create_directory(directory, error); + check(!error, "transaction test directory was created"); + const auto destination = directory / "result.csv"; + { + std::ofstream initial(destination); + initial << "old"; + } + auto status = writeOutput(destination.string(), "new\n"); + check(status.has_value(), "transactional file output commits successfully"); + std::ifstream input(destination); + const std::string content( + std::istreambuf_iterator{input}, std::istreambuf_iterator{}); + check(content == "new\n", "transactional output replaces the destination content"); + + const auto directoryDestination = directory / "not-a-file"; + std::filesystem::create_directory(directoryDestination, error); + auto rejected = writeOutput(directoryDestination.string(), "content"); + check(!rejected, "output refuses to replace a directory"); + check(std::filesystem::is_directory(directoryDestination), + "failed output commit leaves the directory destination intact"); + + std::filesystem::remove_all(directory, error); + } + + if (failureCount == 0) { + std::cout << "All IntaRNAnew CLI/IO/output regression tests passed.\n"; + } + return failureCount == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/compression_io.cpp b/IntaRNAnew/tests/compression_io.cpp new file mode 100644 index 0000000..063ab45 --- /dev/null +++ b/IntaRNAnew/tests/compression_io.cpp @@ -0,0 +1,480 @@ +#include "intarnanew/compression.hpp" +#include "intarnanew/output.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failureCount{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failureCount; + } +} + +[[nodiscard]] auto independentCrc32(const std::string_view bytes) noexcept -> std::uint32_t { + std::uint32_t result{0xffffffffU}; + for (const unsigned char byte : bytes) { + result ^= byte; + for (unsigned bit = 0U; bit < 8U; ++bit) { + result = (result >> 1U) ^ ((result & 1U) != 0U ? 0xedb88320U : 0U); + } + } + return result ^ 0xffffffffU; +} + +void append16(std::string& output, const std::uint16_t value) { + output.push_back(static_cast(value & 0xffU)); + output.push_back(static_cast((value >> 8U) & 0xffU)); +} + +void append32(std::string& output, const std::uint32_t value) { + output.push_back(static_cast(value & 0xffU)); + output.push_back(static_cast((value >> 8U) & 0xffU)); + output.push_back(static_cast((value >> 16U) & 0xffU)); + output.push_back(static_cast((value >> 24U) & 0xffU)); +} + +class IndependentBitWriter { +public: + void bit(const unsigned value) { + if (bitPosition_ == 0U) bytes_.push_back('\0'); + bytes_.back() = static_cast( + static_cast(bytes_.back()) | + static_cast((value & 1U) << bitPosition_)); + bitPosition_ = (bitPosition_ + 1U) % 8U; + } + + void leastSignificantFirst(const unsigned value, const unsigned count) { + for (unsigned index = 0U; index < count; ++index) bit(value >> index); + } + + void huffman(const unsigned symbol, const std::vector& lengths) { + constexpr unsigned maximumLength{15U}; + std::array counts{}; + for (const auto length : lengths) { + if (length > maximumLength) throw std::logic_error("test code length is too large"); + if (length != 0U) ++counts[length]; + } + std::array next{}; + unsigned code{}; + for (unsigned length = 1U; length <= maximumLength; ++length) { + code = (code + counts[length - 1U]) << 1U; + next[length] = code; + } + for (unsigned candidate = 0U; candidate < symbol; ++candidate) { + if (lengths[candidate] != 0U) ++next[lengths[candidate]]; + } + const auto length = static_cast(lengths.at(symbol)); + if (length == 0U) throw std::logic_error("test symbol has no Huffman code"); + const auto assigned = next[length]; + for (unsigned remaining = length; remaining != 0U; --remaining) { + bit(assigned >> (remaining - 1U)); + } + } + + [[nodiscard]] auto take() && -> std::string { return std::move(bytes_); } + +private: + std::string bytes_; + unsigned bitPosition_{}; +}; + +[[nodiscard]] auto fixedLiteralLengths() -> std::vector { + std::vector lengths(288U, 0U); + std::fill(lengths.begin(), lengths.begin() + 144, 8U); + std::fill(lengths.begin() + 144, lengths.begin() + 256, 9U); + std::fill(lengths.begin() + 256, lengths.begin() + 280, 7U); + std::fill(lengths.begin() + 280, lengths.end(), 8U); + return lengths; +} + +[[nodiscard]] auto fixedDeflate() -> std::string { + IndependentBitWriter output; + output.leastSignificantFirst(1U, 1U); // final block + output.leastSignificantFirst(1U, 2U); // fixed Huffman block + const auto literals = fixedLiteralLengths(); + const std::vector distances(32U, 5U); + output.huffman(static_cast('A'), literals); + output.huffman(static_cast('B'), literals); + output.huffman(static_cast('C'), literals); + output.huffman(260U, literals); // six bytes + output.huffman(2U, distances); // distance three + output.huffman(256U, literals); + return std::move(output).take(); +} + +[[nodiscard]] auto dynamicDeflate() -> std::string { + IndependentBitWriter output; + output.leastSignificantFirst(1U, 1U); // final block + output.leastSignificantFirst(2U, 2U); // dynamic Huffman block + output.leastSignificantFirst(3U, 5U); // 260 literal/length symbols + output.leastSignificantFirst(0U, 5U); // one distance symbol + output.leastSignificantFirst(14U, 4U); // 18 code-length symbols + + constexpr std::array order{ + 16U, 17U, 18U, 0U, 8U, 7U, 9U, 6U, 10U, 5U, + 11U, 4U, 12U, 3U, 13U, 2U, 14U, 1U, 15U, + }; + std::vector codeLengths(19U, 0U); + codeLengths[0U] = 2U; + codeLengths[1U] = 2U; + codeLengths[2U] = 2U; + codeLengths[18U] = 2U; + for (std::size_t index = 0U; index < 18U; ++index) { + output.leastSignificantFirst(codeLengths[order[index]], 3U); + } + + output.huffman(18U, codeLengths); // 65 zero lengths + output.leastSignificantFirst(54U, 7U); + output.huffman(1U, codeLengths); // literal A: length one + output.huffman(18U, codeLengths); // 138 zero lengths + output.leastSignificantFirst(127U, 7U); + output.huffman(18U, codeLengths); // 52 zero lengths + output.leastSignificantFirst(41U, 7U); + output.huffman(2U, codeLengths); // end-of-block: length two + output.huffman(0U, codeLengths); // symbols 257 and 258 absent + output.huffman(0U, codeLengths); + output.huffman(2U, codeLengths); // length-five symbol: length two + output.huffman(1U, codeLengths); // distance-one symbol: length one + + std::vector literals(260U, 0U); + literals[65U] = 1U; + literals[256U] = 2U; + literals[259U] = 2U; + const std::vector distances{1U}; + output.huffman(65U, literals); + output.huffman(259U, literals); // five-byte match + output.huffman(0U, distances); // distance one + output.huffman(256U, literals); + return std::move(output).take(); +} + +struct IndependentMember { + std::string bytes; + std::size_t headerCrcOffset{std::string::npos}; +}; + +[[nodiscard]] auto member( + const std::string_view deflate, + const std::string_view uncompressed, + const bool optionalHeader = false) -> IndependentMember { + IndependentMember result; + result.bytes.push_back(static_cast(0x1fU)); + result.bytes.push_back(static_cast(0x8bU)); + result.bytes.push_back(static_cast(8U)); + result.bytes.push_back(static_cast(optionalHeader ? 0x1eU : 0U)); + append32(result.bytes, 0x12345678U); + result.bytes.push_back('\0'); + result.bytes.push_back(static_cast(3U)); + if (optionalHeader) { + append16(result.bytes, 3U); + result.bytes.append("xyz", 3U); + result.bytes.append("fixture", 7U); + result.bytes.push_back('\0'); + result.bytes.append("generated", 9U); + result.bytes.push_back('\0'); + result.headerCrcOffset = result.bytes.size(); + append16(result.bytes, static_cast(independentCrc32(result.bytes))); + } + result.bytes.append(deflate); + append32(result.bytes, independentCrc32(uncompressed)); + append32(result.bytes, static_cast(uncompressed.size() & 0xffffffffU)); + return result; +} + +[[nodiscard]] auto readAll(const std::filesystem::path& path) -> std::string { + std::ifstream input(path, std::ios::binary); + return std::string( + std::istreambuf_iterator{input}, std::istreambuf_iterator{}); +} + +void writeAll(const std::filesystem::path& path, const std::string_view bytes) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output.write(bytes.data(), static_cast(bytes.size())); +} + +[[nodiscard]] auto sameSequences( + const std::vector& left, + const std::vector& right) -> bool { + if (left.size() != right.size()) return false; + for (std::size_t index = 0U; index < left.size(); ++index) { + if (left[index].id() != right[index].id() || + left[index].str() != right[index].str() || + left[index].firstPosition() != right[index].firstPosition()) { + return false; + } + } + return true; +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + check(crc32("123456789") == 0xcbf43926U, + "CRC32 matches the standard check vector"); + + const std::string knownHello{ + "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03" + "\xcb\x48\xcd\xc9\xc9\xe7\x02\x00" + "\x20\x30\x3a\x36\x06\x00\x00\x00", + 26U}; + auto knownDecoded = gzipDecompress(knownHello); + check(knownDecoded && *knownDecoded == "hello\n", + "decoder accepts an externally specified canonical gzip check member"); + + const auto fixed = member(fixedDeflate(), "ABCABCABC").bytes; + auto fixedDecoded = gzipDecompress(fixed); + check(fixedDecoded && *fixedDecoded == "ABCABCABC", + "fixed-Huffman DEFLATE literals and back-reference decode"); + + const auto dynamicMember = member(dynamicDeflate(), "AAAAAA", true); + auto dynamicDecoded = gzipDecompress(dynamicMember.bytes); + check(dynamicDecoded && *dynamicDecoded == "AAAAAA", + "dynamic-Huffman DEFLATE and all optional gzip header fields decode"); + bool everyOptionalHeaderTruncationRejected = true; + for (std::size_t size = 0U; size < dynamicMember.bytes.size(); ++size) { + everyOptionalHeaderTruncationRejected = everyOptionalHeaderTruncationRejected && + !gzipDecompress(std::string_view(dynamicMember.bytes).substr(0U, size)); + } + check(everyOptionalHeaderTruncationRejected, + "every truncation of a member with optional header fields is rejected"); + + auto concatenated = gzipDecompress(fixed + dynamicMember.bytes); + check(concatenated && *concatenated == "ABCABCABCAAAAAA", + "concatenated gzip members decode in order"); + + std::string binary; + binary.reserve(150000U); + for (std::size_t index = 0U; index < 150000U; ++index) { + binary.push_back(static_cast((index * 73U + 19U) & 0xffU)); + } + auto encoded = gzipCompress(binary); + check(encoded.has_value(), "stored-block gzip encoder accepts a multi-block binary payload"); + auto decoded = encoded ? gzipDecompress(*encoded) + : std::expected{std::unexpected("encode failed")}; + check(decoded && *decoded == binary, "stored-block gzip round-trip preserves every byte"); + auto encodedAgain = gzipCompress(binary); + check(encoded && encodedAgain && *encoded == *encodedAgain, + "gzip encoder is deterministic"); + + auto emptyEncoded = gzipCompress(""); + auto emptyDecoded = emptyEncoded ? gzipDecompress(*emptyEncoded) + : std::expected{ + std::unexpected("encode failed")}; + check(emptyDecoded && emptyDecoded->empty(), "empty gzip member round-trips"); + + auto truncationMember = gzipCompress("truncation fixture"); + bool everyTruncationRejected = truncationMember.has_value(); + if (truncationMember) { + for (std::size_t size = 0U; size < truncationMember->size(); ++size) { + everyTruncationRejected = everyTruncationRejected && + !gzipDecompress(std::string_view(*truncationMember).substr(0U, size)); + } + } + check(everyTruncationRejected, "every truncation of a valid gzip member is rejected"); + + if (encoded) { + auto badCrc = *encoded; + badCrc[badCrc.size() - 8U] = static_cast( + static_cast(badCrc[badCrc.size() - 8U]) ^ 0x80U); + auto badCrcResult = gzipDecompress(badCrc); + check(!badCrcResult && badCrcResult.error().find("CRC32") != std::string::npos, + "corrupt payload CRC32 is diagnosed"); + + auto badSize = *encoded; + badSize.back() = static_cast(static_cast(badSize.back()) ^ 1U); + auto badSizeResult = gzipDecompress(badSize); + check(!badSizeResult && badSizeResult.error().find("size") != std::string::npos, + "corrupt gzip ISIZE is diagnosed"); + + auto badStoredLength = *encoded; + badStoredLength[13U] = static_cast( + static_cast(badStoredLength[13U]) ^ 1U); + auto badLengthResult = gzipDecompress(badStoredLength); + check(!badLengthResult && badLengthResult.error().find("LEN/NLEN") != std::string::npos, + "corrupt stored-block length complement is diagnosed"); + + GzipLimits compressedLimit; + compressedLimit.maxCompressedBytes = encoded->size() - 1U; + check(!gzipDecompress(*encoded, compressedLimit), + "compressed-byte limit is enforced"); + + GzipLimits decompressedLimit; + decompressedLimit.maxDecompressedBytes = binary.size() - 1U; + check(!gzipDecompress(*encoded, decompressedLimit), + "decompressed-byte limit is enforced"); + + GzipLimits blockLimit; + blockLimit.maxDeflateBlocks = 1U; + check(!gzipDecompress(*encoded, blockLimit), "DEFLATE block limit is enforced"); + } + + GzipLimits memberLimit; + memberLimit.maxMembers = 1U; + check(!gzipDecompress(fixed + dynamicMember.bytes, memberLimit), + "concatenated-member limit is enforced"); + + GzipLimits headerLimit; + headerLimit.maxHeaderBytes = 16U; + check(!gzipDecompress(dynamicMember.bytes, headerLimit), + "optional gzip header-byte limit is enforced"); + headerLimit.maxHeaderBytes = dynamicMember.headerCrcOffset; + check(!gzipDecompress(dynamicMember.bytes, headerLimit), + "gzip header-byte limit includes the optional header CRC itself"); + + auto badHeaderCrc = dynamicMember.bytes; + badHeaderCrc[dynamicMember.headerCrcOffset] = static_cast( + static_cast(badHeaderCrc[dynamicMember.headerCrcOffset]) ^ 1U); + auto headerCrcResult = gzipDecompress(badHeaderCrc); + check(!headerCrcResult && headerCrcResult.error().find("header CRC") != std::string::npos, + "corrupt optional gzip header CRC is diagnosed"); + + auto reservedFlags = fixed; + reservedFlags[3U] = static_cast(0x20U); + check(!gzipDecompress(reservedFlags), "reserved gzip header flags are rejected"); + + auto trailing = fixed + "junk"; + auto trailingResult = gzipDecompress(trailing); + check(!trailingResult && trailingResult.error().find("trailing bytes") != std::string::npos, + "non-member trailing bytes are rejected"); + + auto reservedBlock = member(std::string(1U, '\x07'), "").bytes; + auto reservedBlockResult = gzipDecompress(reservedBlock); + check(!reservedBlockResult && reservedBlockResult.error().find("reserved block") != std::string::npos, + "reserved DEFLATE block type is rejected"); + + { + std::istringstream consecutive(">empty\n>valid\nACGU\n"); + auto parsed = SequenceReader::parseFasta(consecutive, "fallback", 1); + check(!parsed && parsed.error().find("empty") != std::string::npos, + "empty FASTA record before another header is rejected"); + + std::istringstream trailingHeader(">valid\nACGU\n>empty\n"); + check(!SequenceReader::parseFasta(trailingHeader, "fallback", 1), + "empty trailing FASTA record is rejected"); + } + + { + bool spanRejected{}; + try { + const Sequence invalid( + "overflow", "AA", std::numeric_limits::max()); + (void)invalid; + } catch (const std::out_of_range&) { + spanRejected = true; + } + check(spanRejected, "coordinate span beyond LLONG_MAX is rejected at construction"); + + const Sequence maximum("maximum", "A", std::numeric_limits::max()); + check(maximum.externalIndex(0U) == std::numeric_limits::max(), + "maximum signed coordinate remains representable"); + bool indexRejected{}; + try { + (void)maximum.externalIndex(1U); + } catch (const std::out_of_range&) { + indexRejected = true; + } + check(indexRejected, "external coordinate conversion rejects an out-of-range index"); + + const Sequence minimum("minimum", "AA", std::numeric_limits::min()); + check(minimum.externalIndex(0U) == std::numeric_limits::min() && + minimum.externalIndex(1U) == std::numeric_limits::min() + 1LL, + "minimum signed origin advances without signed overflow"); + } + + { + const auto unique = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto directory = std::filesystem::temp_directory_path() / + ("intarnanew-gzip-test-" + unique); + std::error_code error; + std::filesystem::create_directory(directory, error); + check(!error, "gzip I/O test directory was created"); + + const std::string fasta{">one description\nACGT\n>two\nNNNN\n"}; + const auto plainPath = directory / "input.fa"; + const auto gzipPath = directory / "input.fa.gz"; + const auto magicPath = directory / "input.bin"; + writeAll(plainPath, fasta); + writeAll(gzipPath, "old destination contents"); + auto writeStatus = writeOutput(gzipPath.string(), fasta); + check(writeStatus.has_value(), "transactional .gz output replaces a regular file"); + const auto compressedBytes = readAll(gzipPath); + check(hasGzipMagic(compressedBytes), ".gz output has the gzip signature"); + auto writtenPayload = gzipDecompress(compressedBytes); + check(writtenPayload && *writtenPayload == fasta, + "transactional .gz output decompresses to the requested content"); + + std::istringstream unused; + auto plainSequences = SequenceReader::read(plainPath.string(), "fallback", -2, unused); + auto gzipSequences = SequenceReader::read(gzipPath.string(), "fallback", -2, unused); + check(plainSequences && gzipSequences && sameSequences(*plainSequences, *gzipSequences), + "plain and gzip FASTA files parse to byte-equivalent sequences"); + if (plainSequences && plainSequences->size() == 2U) { + check((*plainSequences)[0].str() == "ACGU" && (*plainSequences)[1].str() == "NNNN", + "gzip integration preserves normal FASTA normalization"); + } + + writeAll(magicPath, compressedBytes); + auto magicSequences = SequenceReader::read(magicPath.string(), "fallback", -2, unused); + check(gzipSequences && magicSequences && sameSequences(*gzipSequences, *magicSequences), + "gzip input is detected by signature without relying on its suffix"); + + std::istringstream gzipInput(compressedBytes); + auto stdinSequences = SequenceReader::read("STDIN", "fallback", -2, gzipInput); + check(gzipSequences && stdinSequences && sameSequences(*gzipSequences, *stdinSequences), + "gzip-compressed standard input parses identically to a gzip file"); + + const auto falseGzipPath = directory / "plain.fa.gz"; + writeAll(falseGzipPath, fasta); + auto falseGzip = SequenceReader::read(falseGzipPath.string(), "fallback", 1, unused); + check(!falseGzip && falseGzip.error().find("no gzip signature") != std::string::npos, + ".gz input without a gzip signature has a clear diagnostic"); + + const auto corruptPath = directory / "corrupt.fa.gz"; + auto corruptBytes = compressedBytes; + corruptBytes[corruptBytes.size() - 8U] = static_cast( + static_cast(corruptBytes[corruptBytes.size() - 8U]) ^ 1U); + writeAll(corruptPath, corruptBytes); + auto corruptInput = SequenceReader::read(corruptPath.string(), "fallback", 1, unused); + check(!corruptInput && corruptInput.error().find("CRC32") != std::string::npos, + "corrupt gzip FASTA reports the member validation failure"); + + const auto secondPath = directory / "second.fa.gz"; + auto secondWrite = writeOutput(secondPath.string(), fasta); + check(secondWrite && readAll(secondPath) == compressedBytes, + "file-level gzip output is deterministic across destinations"); + + const auto directoryDestination = directory / "not-a-file.gz"; + std::filesystem::create_directory(directoryDestination, error); + auto rejectedOutput = writeOutput(directoryDestination.string(), fasta); + check(!rejectedOutput && std::filesystem::is_directory(directoryDestination), + "failed transactional .gz commit leaves a directory destination intact"); + + std::filesystem::remove_all(directory, error); + } + + if (failureCount == 0) { + std::cout << "All IntaRNAnew compression and gzip I/O tests passed.\n"; + } + return failureCount == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/config_registry.cpp b/IntaRNAnew/tests/config_registry.cpp new file mode 100644 index 0000000..cb67d50 --- /dev/null +++ b/IntaRNAnew/tests/config_registry.cpp @@ -0,0 +1,330 @@ +#include "intarnanew/cli.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failures; + } +} + +[[nodiscard]] auto lower(std::string_view value) -> std::string { + std::string result(value); + std::ranges::transform(result, result.begin(), [](const unsigned char character) { + return static_cast(std::tolower(character)); + }); + return result; +} + +[[nodiscard]] auto fixtureRoot() -> std::filesystem::path { + if (const auto* configured = std::getenv("INTARNANEW_SOURCE_DIR")) { + return std::filesystem::path(configured) / "tests" / "fixtures"; + } + return std::filesystem::path("tests") / "fixtures"; +} + +[[nodiscard]] auto parse( + const std::vector& arguments, + const std::string_view invocation = {}) { + return intarnanew::Cli::parse(arguments, invocation); +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + const auto registry = Cli::optionRegistry(); + check(registry.size() == 92U, "registry exposes the complete 92-option public surface"); + std::set longNames; + std::set shortNames; + for (const auto& option : registry) { + check(!option.longName.empty(), "every option has a canonical long name"); + check(longNames.insert(lower(option.longName)).second, + "long option names are ASCII-case-insensitively unique"); + if (option.shortName != '\0') { + const auto normalized = static_cast( + std::tolower(static_cast(option.shortName))); + check(shortNames.insert(normalized).second, + "short aliases are ASCII-case-insensitively unique"); + } + check(!option.description.empty(), "every registry entry documents its behavior"); + if (option.valueMode == OptionValueMode::required) { + check(!option.valueName.empty(), "required values have a help metavariable"); + } + } + check(shortNames == std::set{'e', 'h', 'm', 'n', 'q', 't', 'v'}, + "documented short alias set is complete"); + + const auto fullHelp = Cli::help(true); + for (const auto& option : registry) { + check(fullHelp.find("--" + std::string(option.longName)) != std::string::npos, + "full help contains every registry option"); + } + check(fullHelp.find("[compatibility-only]") != std::string::npos, + "full help identifies accepted compatibility-only controls"); + check(fullHelp.find("qMinE/qSpotProb/qAcc/qPu") != std::string::npos, + "full help enumerates supported auxiliary output prefixes"); + check(fullHelp.find("IntaRNA/3/1/2/exact/helix/duplex/sTar/seed/ens") != + std::string::npos, + "full help enumerates supported personalities"); + const auto basicHelp = Cli::help(false); + for (const auto& option : registry) { + if (option.basic) { + check(basicHelp.find("--" + std::string(option.longName)) != std::string::npos, + "basic help is generated from basic registry entries"); + } + } + + { + const auto parameter = (fixtureRoot() / "config" / "base.parameter").string(); + for (const auto& option : registry) { + std::vector owned; + if (option.longName != "help" && option.longName != "fullhelp" && + option.longName != "version") { + owned.emplace_back("--help"); + } + std::string spelling = "--" + std::string(option.longName); + if (option.valueMode == OptionValueMode::required) { + std::string value(option.defaultValue); + if (option.longName == "query") value = "G"; + else if (option.longName == "target") value = "C"; + else if (option.longName == "parameterFile") value = parameter; + else if (value.empty()) value = "x"; + spelling += "=" + value; + } + owned.push_back(std::move(spelling)); + std::vector arguments; + arguments.reserve(owned.size()); + for (const auto& argument : owned) arguments.push_back(argument); + check(parse(arguments).has_value(), + "every registry entry is routed by the command-line parser"); + } + } + + { + const std::vector arguments{ + "--TaRgEt=C", "--QuErY=G", "--EnErGy=b", "--AcC=n", "--NoSeEd", + "-M", "h", "-N", "2", "--OuTmOdE=c", + }; + const auto config = parse(arguments); + check(config.has_value(), "long/short names and documented choices are case-insensitive"); + if (config) { + check(config->energy == EnergyKind::basePair, "lower-case energy choice is applied"); + check(config->output.mode == OutputMode::csv, "lower-case output choice is applied"); + check(config->output.number == 2U, "upper-case short alias is applied"); + } + } + + { + const auto parameter = (fixtureRoot() / "config" / "base.parameter").string(); + const std::vector arguments{ + "--personality=IntaRNAexact", + "--parameterFile", parameter, + "--mode=S", + "--seedBP=2", + "--out=STDERR", + }; + const auto config = parse(arguments, "/tmp/IntaRNAduplex"); + check(config.has_value(), "personality, nested files, and CLI form one valid layered config"); + if (config) { + check(config->personality == "IntaRNAexact", + "CLI personality overrides the executable personality alias"); + check(config->query.accessibility == AccessibilityKind::compute && + config->target.accessibility == AccessibilityKind::compute, + "chosen personality replaces lower-precedence executable preset"); + check(config->query.accessibilityWindow == 0U && + config->target.accessibilityWindow == 0U, + "personality defaults apply before parameter and CLI assignments"); + check(config->mode == PredictionMode::seedOnly, + "CLI scalar overrides nested and outer parameter-file values"); + check(config->query.regions == "2-3", + "nested parameter-file values are loaded relative to the including file"); + check(config->parameterFiles.size() == 2U, + "top-level and nested parameter files are retained"); + check(config->output.destinations.size() == 3U && + config->output.destinations[0] == "file-from-base" && + config->output.destinations[1] == "qMinE:tracker.csv" && + config->output.destinations[2] == "STDERR", + "repeatable --out values retain deterministic layer/file order"); + const auto modeOrigin = config->provenance.find("mode"); + check(modeOrigin != config->provenance.end() && + modeOrigin->second.source == ConfigSource::commandLine, + "effective scalar provenance identifies the winning CLI source"); + check(config->assignmentHistory.size() >= 12U, + "assignment history retains overridden sources"); + } + } + + { + const std::vector> personalities{ + {"IntaRNA", InteractionModel::seedExtension}, + {"IntaRNA3", InteractionModel::seedExtension}, + {"IntaRNA1", InteractionModel::singleSite}, + {"IntaRNA2", InteractionModel::singleSite}, + {"IntaRNAexact", InteractionModel::seedExtension}, + {"IntaRNAhelix", InteractionModel::helixBlocks}, + {"IntaRNAduplex", InteractionModel::seedExtension}, + {"IntaRNAsTar", InteractionModel::seedExtension}, + {"IntaRNAseed", InteractionModel::seedExtension}, + {"IntaRNAens", InteractionModel::ensemble}, + }; + for (const auto& [personality, expectedModel] : personalities) { + const std::vector arguments{ + "--query=GGGGGGG", "--target=CCCCCCC", "--energy=B", + "--personality", personality, + }; + const auto config = parse(arguments); + check(config.has_value(), "documented personality is accepted"); + if (config) check(config->model == expectedModel, "personality model preset is correct"); + } + const std::vector unknown{"--personality=IntaRNAup", "--help"}; + const auto rejected = parse(unknown); + check(!rejected && rejected.error().find("unknown personality") != std::string::npos, + "stale/unknown personality names are rejected precisely"); + } + + { + const std::vector exactOverride{ + "--query=GGGG", "--target=CCCC", "--personality=IntaRNAexact", + "--accW=42", "--mode=H", + }; + const auto config = parse(exactOverride); + check(config && config->query.accessibilityWindow == 42U && + config->target.accessibilityWindow == 42U && + config->mode == PredictionMode::heuristic, + "CLI explicitly overrides personality defaults regardless of argument order"); + } + + { + const std::vector aliases{ + "--query=GGGG", "--target=CCCC", "--energy=B", + }; + const auto duplex = parse(aliases, "/opt/bin/IntaRNAduplex"); + check(duplex && duplex->personality == "IntaRNAduplex" && + duplex->query.accessibility == AccessibilityKind::disabled, + "executable basename selects a personality"); + const auto generic = parse(aliases, "/opt/bin/custom-runner"); + check(generic && generic->personality == "IntaRNA", + "an embedded non-IntaRNA executable name uses the standard personality"); + check(generic && generic->query.accessibility == AccessibilityKind::compute, + "generic invocation retains standard computed accessibility"); + } + + { + const auto conflictFile = (fixtureRoot() / "config" / "conflict.parameter").string(); + const std::vector arguments{ + "--parameterFile", conflictFile, "--qRegionLenMax=10", + }; + const auto rejected = parse(arguments); + check(!rejected && + rejected.error().find("parameter file") != std::string::npos && + rejected.error().find("command line argument") != std::string::npos, + "cross-layer conflicts cite both effective origins"); + } + + { + const auto duplicateFile = (fixtureRoot() / "config" / "duplicate.parameter").string(); + const std::vector arguments{"--parameterFile", duplicateFile}; + const auto rejected = parse(arguments); + check(!rejected && + rejected.error().find("duplicate --query") != std::string::npos && + rejected.error().find("line 1") != std::string::npos && + rejected.error().find("line 2") != std::string::npos, + "same-source scalar duplicates cite both parameter lines"); + } + + { + const std::vector supported{ + "--query=GGGG", "--target=CCCC", "--energy=B", "--acc=C", + "--qPfScale=1.2", "--tPfScale=2", "--accNoLP", "--accNoGUend", + "--outBestSeedOnly", "--verbose", "--default-log-file=diagnostics.log", + }; + const auto config = parse(supported); + check(config.has_value(), "new accessibility and compatibility controls are accepted"); + if (config) { + check(config->query.partitionScale == 1.2 && + config->target.partitionScale == 2.0, + "per-side partition scaling is stored"); + check(config->accessibilityNoLonelyPairs && + config->accessibilityNoGuAtEnds, + "accessibility folding flags are stored"); + check(config->output.bestSeedOnly && config->verbose && + config->logFile == "diagnostics.log", + "compatibility-only controls are recorded deterministically"); + } + } + + { + const std::vector invalidSeedOnly{ + "--query=G", "--target=C", "--energy=B", "--acc=N", + "--mode=S", "--noSeed", + }; + const auto rejected = parse(invalidSeedOnly); + check(!rejected && rejected.error().find("seed-only") != std::string::npos, + "seed-only mode rejects a disabled seed without an explicit seed"); + + const std::vector invalidHelix{ + "--query=GG", "--target=CC", "--energy=B", "--acc=N", + "--model=B", "--mode=M", + }; + const auto helixRejected = parse(invalidHelix); + check(!helixRejected && helixRejected.error().find("require heuristic") != std::string::npos, + "helix-block model rejects a non-heuristic mode"); + + const std::vector> invalidEnsembleRequests{ + {"--query=GGGGGGG", "--target=CCCCCCC", "--energy=B", "--model=X", "--outMode=E"}, + {"--query=GGGGGGG", "--target=CCCCCCC", "--energy=B", "--model=X", + "--outMode=C", "--outCsvCols=id1,P_E"}, + {"--query=GGGGGGG", "--target=CCCCCCC", "--energy=B", "--model=X", + "--out=spotProb:STDOUT"}, + }; + for (const auto& request : invalidEnsembleRequests) { + const auto ensembleRejected = parse(request); + check(!ensembleRejected && + ensembleRejected.error().find("no scientifically valid partition") != + std::string::npos, + "seeded model X rejects ensemble-dependent output"); + } + const std::vector unseededEnsemble{ + "--query=GGGG", "--target=CCCC", "--energy=B", "--model=X", + "--noSeed", "--outMode=E", + }; + check(parse(unseededEnsemble).has_value(), + "unseeded model X retains scientifically composable ensemble output"); + } + + { + const auto parameterDirectory = fixtureRoot() / "parameters"; + std::size_t count{}; + for (const auto& entry : std::filesystem::directory_iterator(parameterDirectory)) { + if (!entry.is_regular_file() || entry.path().extension() != ".parameter") continue; + const auto path = entry.path().string(); + const std::vector arguments{"--parameterFile", path}; + const auto config = parse(arguments); + check(config.has_value(), "normalized public parameter fixture parses"); + ++count; + } + check(count == 17U, "all 17 normalized public parameter fixtures are present"); + } + + if (failures != 0) { + std::cerr << failures << " registry test(s) failed\n"; + return 1; + } + std::cout << "all CLI/config registry tests passed\n"; + return 0; +} diff --git a/IntaRNAnew/tests/consumer/CMakeLists.txt b/IntaRNAnew/tests/consumer/CMakeLists.txt new file mode 100644 index 0000000..d6f7320 --- /dev/null +++ b/IntaRNAnew/tests/consumer/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.25) + +project(IntaRNAnewConsumerSmoke LANGUAGES CXX) + +find_package(IntaRNAnew 1 CONFIG REQUIRED) + +add_executable(intarnanew-consumer main.cpp) +target_link_libraries(intarnanew-consumer PRIVATE IntaRNAnew::tools) +target_compile_features(intarnanew-consumer PRIVATE cxx_std_23) +set_target_properties(intarnanew-consumer PROPERTIES CXX_EXTENSIONS OFF) diff --git a/IntaRNAnew/tests/consumer/main.cpp b/IntaRNAnew/tests/consumer/main.cpp new file mode 100644 index 0000000..43d65d0 --- /dev/null +++ b/IntaRNAnew/tests/consumer/main.cpp @@ -0,0 +1,58 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/cli.hpp" +#include "intarnanew/compression.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/energy.hpp" +#include "intarnanew/folding.hpp" +#include "intarnanew/helix_blocks.hpp" +#include "intarnanew/output.hpp" +#include "intarnanew/output_plan.hpp" +#include "intarnanew/parallel.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/runner.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/tools/csv.hpp" +#include "intarnanew/tools/mutations.hpp" +#include "intarnanew/tools/pvalue.hpp" +#include "intarnanew/tools/statistics.hpp" +#include "intarnanew/tools/svg.hpp" +#include "intarnanew/types.hpp" + +#include +#include +#include + +auto main() -> int { + const intarnanew::Sequence sequence{"consumer", "ACGU"}; + if (sequence.size() != 4U || intarnanew::reverseComplement(sequence.str()) != "ACGU") { + return 1; + } + + const std::array pValues{0.01, 0.04, 0.03}; + const auto adjusted = intarnanew::tools::adjustPValues( + std::span{pValues}, + intarnanew::tools::AdjustmentMethod::benjaminiHochberg); + if (!adjusted || adjusted->size() != pValues.size() || + std::abs((*adjusted)[0] - 0.03) > 1e-12) { + return 2; + } + + const auto compressed = intarnanew::gzipCompress("installed consumer"); + if (!compressed) return 3; + const auto decompressed = intarnanew::gzipDecompress(*compressed); + if (!decompressed || *decompressed != "installed consumer") return 4; + + intarnanew::Config config; + config.energy = intarnanew::EnergyKind::basePair; + config.mode = intarnanew::PredictionMode::exact; + config.model = intarnanew::InteractionModel::singleSite; + config.seed.required = false; + config.target.accessibility = intarnanew::AccessibilityKind::disabled; + config.query.accessibility = intarnanew::AccessibilityKind::disabled; + config.output.number = 1U; + const intarnanew::Sequence target{"target", "CCAACACC"}; + const intarnanew::Sequence query{"query", "GG"}; + const auto prediction = intarnanew::predictPair(config, target, query); + if (!prediction || prediction->prediction.interactions.empty()) return 5; + return 0; +} diff --git a/IntaRNAnew/tests/execution_regions.cpp b/IntaRNAnew/tests/execution_regions.cpp new file mode 100644 index 0000000..d8d3c98 --- /dev/null +++ b/IntaRNAnew/tests/execution_regions.cpp @@ -0,0 +1,266 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/cli.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failureCount{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failureCount; + } +} + +[[nodiscard]] auto config(std::initializer_list arguments) + -> intarnanew::Config { + const std::vector values(arguments); + auto parsed = intarnanew::Cli::parse(values); + if (!parsed) { + std::cerr << "configuration failed: " << parsed.error() << '\n'; + std::exit(2); + } + return std::move(*parsed); +} + +[[nodiscard]] auto signatures(const intarnanew::PredictionResult& prediction) + -> std::vector> { + std::vector> result; + result.reserve(prediction.interactions.size()); + for (const auto& interaction : prediction.interactions) { + result.emplace_back( + interaction.targetRange(), interaction.queryRange(), interaction.energy.total()); + } + return result; +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + { + const Sequence sequence("signed", "AAAAAAAA", -5); + auto parsed = parseIntervals(sequence, "-5--3,-2-2"); + check(parsed && *parsed == std::vector{{0U, 2U}, {3U, 6U}}, + "signed external regions map across the skipped zero coordinate"); + check(!parseIntervals(sequence, "-5--2,-3-1"), + "overlapping manual regions are rejected independent of insertion order"); + check(!parseIntervals(sequence, "-5--3,"), + "a trailing empty region is rejected"); + check(!parseIntervals(sequence, "0-1"), + "external coordinate zero is rejected for a negative origin"); + } + + { + const auto windows = decomposeWindows({0U, 13U}, 10U, 2U); + check(windows == std::vector{{0U, 9U}, {8U, 13U}}, + "window decomposition uses a fixed step and clipped final window"); + bool allCovered = true; + for (Index begin = 0U; begin <= 13U; ++begin) { + for (Index length = 1U; length <= 2U && begin + length <= 14U; ++length) { + const Interval candidate{begin, begin + length - 1U}; + if (!std::ranges::any_of(windows, [&](const Interval window) { + return window.contains(candidate.begin) && window.contains(candidate.end); + })) { + allCovered = false; + } + } + } + check(allCovered, "every overlap-bounded interaction is contained in a window"); + } + + { + const Sequence sequence("automatic", "CCCCCCCCCCCC"); + DisabledAccessibility accessibility(sequence); + const std::vector whole{{0U, sequence.size() - 1U}}; + const auto regions = decomposeAccessibleRegions(whole, 4U, 2U, accessibility); + check(regions == std::vector{{8U, 11U}}, + "automatic-region accessibility ties use the deterministic left-most cut"); + } + + { + const std::vector enabled{ + "--target=CCCCCCCCCCCCCC", "--query=GGGGGGGGGGGG", "--energy=B", + "--acc=N", "--noSeed", "--intLenMax=2", "--windowWidth=10", + "--windowOverlap=2", "--tRegionLenMax=4", "--outPerRegion", + }; + auto parsed = Cli::parse(enabled); + check(parsed && parsed->windowWidth == 10U && parsed->windowOverlap == 2U && + parsed->target.regionLengthMax == 4U && parsed->output.perRegion, + "region, window, and per-region CLI options are enabled"); + + const std::vector mixedRegions{ + "--target=CCCC", "--query=GG", "--energy=B", "--acc=N", "--noSeed", + "--tRegion=1-4", "--tRegionLenMax=3", + }; + check(!Cli::parse(mixedRegions), "manual and automatic regions are mutually exclusive"); + + const std::vector unboundedWindow{ + "--target=CCCCCCCCCCCC", "--query=GGGGGGGGGG", "--energy=B", "--acc=N", + "--noSeed", "--windowWidth=10", "--windowOverlap=2", + }; + check(!Cli::parse(unboundedWindow), + "windowing rejects an unbounded interaction length"); + + const std::vector ensembleWindow{ + "--target=CCCCCCCCCCCC", "--query=GGGGGGGGGG", "--energy=B", "--acc=N", + "--noSeed", "--model=P", "--intLenMax=2", "--windowWidth=10", + "--windowOverlap=2", + }; + check(!Cli::parse(ensembleWindow), + "partition-site mode is rejected across overlapping windows"); + } + + { + auto run = config({ + "--target=CCCCCCCCCCCCCC", "--query=GGGGGGGGGGGG", "--energy=B", + "--acc=N", "--noSeed", "--mode=M", "--model=S", "--intLenMax=2", + "--outNumber=1000", "--outOverlap=B", "--outDeltaE=100", + }); + const Sequence target("target", "CCCCCCCCCCCCCC"); + const Sequence query("query", "GGGGGGGGGGGG"); + DisabledAccessibility targetAccessibility(target); + DisabledAccessibility queryAccessibility(query); + const Predictor predictor(run); + const auto full = predictor.predict( + target, query, targetAccessibility, queryAccessibility); + + std::vector domains; + const auto targetWindows = decomposeWindows({0U, target.size() - 1U}, 10U, 2U); + const auto queryWindows = decomposeWindows({0U, query.size() - 1U}, 10U, 2U); + for (const auto targetWindow : targetWindows) { + for (const auto queryWindow : queryWindows) { + domains.push_back(predictor.predict( + target, query, targetAccessibility, queryAccessibility, + targetWindow, queryWindow)); + } + } + const auto reduced = reducePredictions(domains, run); + check(signatures(full) == signatures(reduced), + "windowed real-interaction sites equal the non-windowed prediction"); + check(std::abs(full.logPartition - reduced.logPartition) < 1e-12, + "overlapping windows are deduplicated before partition reduction"); + check(std::ranges::any_of(reduced.interactions, [](const Interaction& interaction) { + return interaction.targetRange() == Interval{9U, 10U}; + }), "an interaction crossing the first window end is retained"); + + std::ranges::reverse(domains); + const auto reverseReduced = reducePredictions(domains, run); + check(signatures(reduced) == signatures(reverseReduced) && + std::abs(reduced.logPartition - reverseReduced.logPartition) < 1e-12, + "domain reduction is byte-order independent for parallel completion"); + } + + { + auto run = config({ + "--target=CCCCCCCC", "--query=GGGG", "--energy=B", "--acc=N", + "--noSeed", "--mode=M", "--model=S", "--outNumber=1", + "--outOverlap=B", "--outDeltaE=100", "--outPerRegion", + }); + const Sequence target("target", "CCCCCCCC"); + const Sequence query("query", "GGGG"); + DisabledAccessibility targetAccessibility(target); + DisabledAccessibility queryAccessibility(query); + const Predictor predictor(run); + const std::vector targetRegions{{0U, 1U}, {6U, 7U}}; + const std::vector queryRegions{{0U, 1U}, {2U, 3U}}; + std::vector regionResults; + for (const auto targetRegion : targetRegions) { + for (const auto queryRegion : queryRegions) { + auto prediction = predictor.predict(target, query, + targetAccessibility, queryAccessibility, targetRegion, queryRegion); + check(prediction.interactions.size() == 1U && + std::abs(prediction.interactions.front().energy.total() + 2.0) < 1e-12, + "each real region combination has an independent optimum"); + regionResults.push_back(std::move(prediction)); + } + } + const auto globallyReduced = reducePredictions(regionResults, run); + check(globallyReduced.interactions.size() == 1U, + "without per-region grouping the global output limit is applied once"); + } + + { + auto run = config({ + "--target=CCCCCCCCCCCC", "--query=GG", "--energy=B", "--acc=N", + "--noSeed", "--seedBP=2", "--mode=M", "--model=S", + "--tRegionLenMax=4", "--qRegion=1-2", "--outNumber=1", + "--outOverlap=B", "--outDeltaE=100", + }); + const Sequence target("target", "CCCCCCCCCCCC"); + const Sequence query("query", "GG"); + DisabledAccessibility targetAccessibility(target); + DisabledAccessibility queryAccessibility(query); + const auto prediction = Predictor(run).predict( + target, query, targetAccessibility, queryAccessibility); + check(prediction.interactions.size() == 1U && + prediction.interactions.front().targetRange() == Interval{8U, 9U}, + "automatic-region real interaction matches the legacy tie fixture"); + } + + { + auto run = config({ + "--target=CCAACCCACC", "--query=GGGG", "--energy=B", "--acc=N", + "--mode=H", "--model=X", "--seedBP=2", "--intLenMax=2", + "--outNumber=10", "--outOverlap=Q", "--outDeltaE=0", + }); + const Sequence target("target", "CCAACCCACC"); + const Sequence query("query", "GGGG"); + DisabledAccessibility targetAccessibility(target); + DisabledAccessibility queryAccessibility(query); + const auto prediction = Predictor(run).predict( + target, query, targetAccessibility, queryAccessibility); + std::vector> sites; + for (const auto& interaction : prediction.interactions) { + sites.emplace_back(interaction.targetRange(), interaction.queryRange()); + } + check(sites == std::vector>{ + {{0U, 1U}, {0U, 1U}}, + {{4U, 5U}, {2U, 3U}}, + {{8U, 9U}, {2U, 3U}}, + }, + "target-nonoverlap tie traversal matches the legacy RNA interaction corpus"); + } + + { + auto run = config({ + "--target=GGGAAACCC", "--query=GGGAAACCC", "--energy=B", "--acc=N", + "--noSeed", "--mode=M", "--model=P", "--outNumber=1", + }); + const Sequence target("target", "GGGAAACCC"); + const Sequence query("query", "GGGAAACCC"); + DisabledAccessibility targetAccessibility(target); + DisabledAccessibility queryAccessibility(query); + const auto prediction = Predictor(run).predict( + target, query, targetAccessibility, queryAccessibility); + check(prediction.interactions.size() == 1U, "model-P fixture has a best site"); + if (!prediction.interactions.empty()) { + const auto& interaction = prediction.interactions.front(); + check(interaction.pairs == std::vector{{0U, 8U}, {8U, 0U}}, + "model P reports only its site boundary pairs"); + check(std::abs(interaction.energy.total() - + std::trunc(interaction.ensembleFreeEnergy * 100.0) / 100.0) < 1e-12, + "model-P reported energy is the centikcal-truncated site free energy"); + check(std::abs(interaction.energy.total() + 7.91) < 0.02, + "model-P base-pair fixture matches the legacy site energy"); + } + } + + if (failureCount == 0) { + std::cout << "All IntaRNAnew region/window execution tests passed.\n"; + } + return failureCount == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/fixtures/config/base.parameter b/IntaRNAnew/tests/fixtures/config/base.parameter new file mode 100644 index 0000000..d0bac39 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/config/base.parameter @@ -0,0 +1,5 @@ +query=GGGG +target=CCCC +mode=M +out=file-from-base +parameterFile=nested.parameter diff --git a/IntaRNAnew/tests/fixtures/config/conflict.parameter b/IntaRNAnew/tests/fixtures/config/conflict.parameter new file mode 100644 index 0000000..7b462b6 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/config/conflict.parameter @@ -0,0 +1,3 @@ +query=GGGG +target=CCCC +qRegion=2-3 diff --git a/IntaRNAnew/tests/fixtures/config/duplicate.parameter b/IntaRNAnew/tests/fixtures/config/duplicate.parameter new file mode 100644 index 0000000..2da372e --- /dev/null +++ b/IntaRNAnew/tests/fixtures/config/duplicate.parameter @@ -0,0 +1,3 @@ +query=GGGG +query=AAAA +target=CCCC diff --git a/IntaRNAnew/tests/fixtures/config/nested.parameter b/IntaRNAnew/tests/fixtures/config/nested.parameter new file mode 100644 index 0000000..7a78c9b --- /dev/null +++ b/IntaRNAnew/tests/fixtures/config/nested.parameter @@ -0,0 +1,3 @@ +mode=H +out=qMinE:tracker.csv +qRegion=2-3 diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelS.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelS.parameter new file mode 100644 index 0000000..e38a9b7 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelS.parameter @@ -0,0 +1,14 @@ +model=S +mode=M +noSeed=false +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelX.parameter new file mode 100644 index 0000000..5b0e9f5 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact-seed-modelX.parameter @@ -0,0 +1,14 @@ +model=X +mode=M +noSeed=false +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact.parameter new file mode 100644 index 0000000..9cd2d08 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-exact.parameter @@ -0,0 +1,13 @@ +mode=M +noSeed=true +model=S +energy=B +tAcc=N +qAcc=N +target=CCAACACC +query=GG +outMode=C +outNumber=20 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelS.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelS.parameter new file mode 100644 index 0000000..34f8b05 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelS.parameter @@ -0,0 +1,13 @@ +model=S +mode=H +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..02564c5 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic-seed-modelX.parameter @@ -0,0 +1,13 @@ +model=X +mode=H +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic.parameter new file mode 100644 index 0000000..85447c5 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-heuristic.parameter @@ -0,0 +1,13 @@ +mode=H +noSeed=true +model=S +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelS.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelS.parameter new file mode 100644 index 0000000..1b138df --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelS.parameter @@ -0,0 +1,14 @@ +model=S +mode=M +noSeed=false +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelX.parameter new file mode 100644 index 0000000..390376e --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact-seed-modelX.parameter @@ -0,0 +1,14 @@ +model=X +mode=M +noSeed=false +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact.parameter new file mode 100644 index 0000000..1f7734a --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-exact.parameter @@ -0,0 +1,13 @@ +mode=M +noSeed=true +model=S +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelS.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelS.parameter new file mode 100644 index 0000000..6c882b5 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelS.parameter @@ -0,0 +1,13 @@ +model=S +mode=H +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..639e8f8 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic-seed-modelX.parameter @@ -0,0 +1,13 @@ +model=X +mode=H +seedBP=3 +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic.parameter new file mode 100644 index 0000000..aae8b0f --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-noLP-heuristic.parameter @@ -0,0 +1,13 @@ +mode=H +noSeed=true +model=S +energy=B +tAcc=N +qAcc=N +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=true diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-outNoGUend.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-outNoGUend.parameter new file mode 100644 index 0000000..83dad13 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-outNoGUend.parameter @@ -0,0 +1,8 @@ +query=ggggggggggggggggggggg +target=uuuuuucccuaaauccuucuuu +energy=B +noSeed=true +tAcc=N +outNoGUend=true +tRegion=8-17 +qRegion=4-14 diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapB-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapB-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..0254079 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapB-heuristic-seed-modelX.parameter @@ -0,0 +1,15 @@ +model=X +mode=H +seedBP=2 +energy=B +tAcc=N +qAcc=N +tIntLenMax=2 +qIntLenMax=2 +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=B +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapN-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapN-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..73f2d09 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapN-heuristic-seed-modelX.parameter @@ -0,0 +1,15 @@ +model=X +mode=H +seedBP=2 +energy=B +tAcc=N +qAcc=N +tIntLenMax=2 +qIntLenMax=2 +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=N +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapQ-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapQ-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..2bbb4b1 --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapQ-heuristic-seed-modelX.parameter @@ -0,0 +1,15 @@ +model=X +mode=H +seedBP=2 +energy=B +tAcc=N +qAcc=N +tIntLenMax=2 +qIntLenMax=2 +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=Q +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapT-heuristic-seed-modelX.parameter b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapT-heuristic-seed-modelX.parameter new file mode 100644 index 0000000..ecd94bc --- /dev/null +++ b/IntaRNAnew/tests/fixtures/parameters/energyB-accN-overlapT-heuristic-seed-modelX.parameter @@ -0,0 +1,15 @@ +model=X +mode=H +seedBP=2 +energy=B +tAcc=N +qAcc=N +tIntLenMax=2 +qIntLenMax=2 +target=CCAACCCACC +query=GGGG +outMode=C +outNumber=10 +outOverlap=T +outDeltaE=0 +outNoLP=false diff --git a/IntaRNAnew/tests/folding_oracle.cpp b/IntaRNAnew/tests/folding_oracle.cpp new file mode 100644 index 0000000..ca53052 --- /dev/null +++ b/IntaRNAnew/tests/folding_oracle.cpp @@ -0,0 +1,318 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/folding.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/types.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +auto failures = 0; + +void check(const bool condition, const std::string_view message) { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + ++failures; + } +} + +[[nodiscard]] auto close(const double observed, const double expected, + const double tolerance = 1e-10) noexcept -> bool { + return std::abs(observed - expected) <= tolerance * + std::max({1.0, std::abs(observed), std::abs(expected)}); +} + +[[nodiscard]] auto options() -> intarnanew::FoldingOptions { + intarnanew::FoldingOptions result; + result.parameterSet = "Turner04"; + result.maximumPairSpan = 100U; + result.includeDangles = false; + return result; +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + const auto rt = gasConstantKcal * 310.15; + + { + const Sequence sequence("unpairable", "AAAAAA"); + const auto ensemble = makeTurnerFoldingEnsemble(sequence, options()); + check(close(ensemble->logPartition(), 0.0), "unpairable RNA has Z=1"); + check(close(ensemble->ensembleFreeEnergy(), 0.0), "unpairable RNA has ensemble energy zero"); + check(close(ensemble->jointUnpairedProbability({0U, 5U}), 1.0), + "all positions of an unpairable RNA are jointly unpaired"); + } + + { + const Sequence sequence("base-pair", "GAAAC"); + auto baseOptions = options(); + baseOptions.maximumPairSpan = sequence.size(); + const auto ensemble = makeBasePairFoldingEnsemble(sequence, baseOptions); + const auto expectedLogZ = std::log1p(std::exp(1.0)); + check(close(ensemble->logPartition(), expectedLogZ), + "base-pair model sums the empty and one-pair structures exactly"); + check(close(ensemble->ensembleFreeEnergy(), -expectedLogZ), + "base-pair model uses its documented RT=1"); + check(close(ensemble->jointUnpairedProbability({0U, 4U}), + 1.0 / (1.0 + std::exp(1.0))), + "base-pair model computes exact joint interval probability"); + baseOptions.noLonelyPairs = true; + const auto noLp = makeBasePairFoldingEnsemble(sequence, baseOptions); + check(close(noLp->logPartition(), 0.0), + "base-pair noLP removes the isolated pair"); + } + + { + // Exactly two structures exist: all-unpaired and one GC hairpin with + // three unpaired nucleotides. Turner04 assigns 5.4 kcal/mol. + const Sequence sequence("single-hairpin", "GAAAC"); + const auto ensemble = makeTurnerFoldingEnsemble(sequence, options()); + const auto weight = std::exp(-5.4 / rt); + const auto expectedLogZ = std::log1p(weight); + check(close(ensemble->logPartition(), expectedLogZ), + "single-hairpin partition equals its exhaustive two-structure sum"); + check(close(ensemble->jointUnpairedProbability({0U, 4U}), 1.0 / (1.0 + weight)), + "whole-interval Pu is the all-unpaired structure probability"); + + auto constrained = options(); + constrained.constraint = "p...p"; + const auto forcedPair = makeTurnerFoldingEnsemble(sequence, constrained); + check(close(forcedPair->logPartition(), -5.4 / rt), + "p constraints condition the ensemble on the required pair"); + check(forcedPair->jointUnpairedProbability({0U, 0U}) == 0.0, + "a p-constrained position cannot be queried as unpaired"); + } + + { + // Official ViennaRNA 2.7.2 public-executable differential fixture + // (Turner04, 37 C, dangles=0): Eall=-0.17 kcal/mol and the global + // all-unpaired structure probability is 0.755597. Legacy IntaRNA + // 3.4.1 reports 0.7589416 because its accessibility adapter applies a + // distinct exterior-loop convention; the provider model is explicit. + const Sequence sequence("legacy-differential", "GCAAAUGC"); + const auto ensemble = makeTurnerFoldingEnsemble(sequence, options()); + const auto observedProbability = ensemble->jointUnpairedProbability({0U, 7U}); + check(close(ensemble->ensembleFreeEnergy(), -0.17, 0.012), + "Turner ensemble free energy matches the black-box fixture"); + check(close(observedProbability, 0.755597, 2e-5), + "Turner joint Pu matches the black-box fixture"); + + auto dangleOptions = options(); + dangleOptions.includeDangles = true; + const Sequence dangleSequence("dangle-two", "AGGGAAACCC"); + const auto dangles = makeTurnerFoldingEnsemble(dangleSequence, dangleOptions); + check(close(dangles->ensembleFreeEnergy(), -1.80, 0.012), + "Turner dangle-2 ensemble matches official RNAfold"); + } + + { + // Public RNAeval 2.7.2 Turner04/dangles=0 forced-structure oracles. + // These distinguish the three nucleotide axes of the asymmetric int21 + // table; symmetric A-only fixtures cannot catch a permutation. + auto first = options(); + first.constraint = "pxxpxxxpxp"; + const auto int21TwoByOne = makeTurnerFoldingEnsemble( + Sequence("int21-2x1", "AACGCAGCGU"), first); + check(close(int21TwoByOne->ensembleFreeEnergy(), 8.90, 1e-10), + "Turner int21 2x1 nucleotide order matches RNAeval"); + + auto second = options(); + second.constraint = "pxpxxxpxxp"; + const auto int21OneByTwo = makeTurnerFoldingEnsemble( + Sequence("int21-1x2", "AAUCGUAAGU"), second); + check(close(int21OneByTwo->ensembleFreeEnergy(), 10.10, 1e-10), + "Turner int21 1x2 nucleotide order matches RNAeval"); + } + + { + // Turner99 and Andronescu07 use the valid six-value ViennaRNA Misc + // section (DuplexInit, TerminalAU, LXC pairs). + for (const auto& [name, hairpin] : + std::initializer_list>{ + {"Turner99", 5.70}, {"Andronescu07", 4.75}}) { + auto named = options(); + named.parameterSet = name; + const auto ensemble = makeTurnerFoldingEnsemble( + Sequence("named-parameters", "GAAAC"), named); + const auto weight = std::exp(-hairpin / rt); + check(close(ensemble->logPartition(), std::log1p(weight), 1e-10), + "six-value Misc parameter set loads into the folding ensemble"); + check(close(ensemble->ensembleFreeEnergy(), -rt * std::log1p(weight), 1e-10), + "named folding parameter set preserves its own hairpin partition"); + } + } + + { + const Sequence sequence("lonely", "GAAAC"); + auto noLonelyPairs = options(); + noLonelyPairs.noLonelyPairs = true; + const auto ensemble = makeTurnerFoldingEnsemble(sequence, noLonelyPairs); + check(close(ensemble->logPartition(), 0.0), "noLP removes an isolated hairpin pair"); + + const Sequence guSequence("gu-end", "GAAAU"); + auto noGuEnds = options(); + noGuEnds.noGuHelixEnds = true; + const auto guEnsemble = makeTurnerFoldingEnsemble(guSequence, noGuEnds); + check(close(guEnsemble->logPartition(), 0.0), "noGUend removes an isolated GU helix end"); + + auto internalGu = options(); + internalGu.noGuHelixEnds = true; + internalGu.constraint = "ppp....ppp"; + const Sequence internalGuSequence("internal-gu", "GGGAAAACUC"); + const auto accepted = makeTurnerFoldingEnsemble(internalGuSequence, internalGu); + check(std::isfinite(accepted->logPartition()), + "noGUend permits a GU pair stacked on both helix sides"); + + auto terminalGu = options(); + terminalGu.noGuHelixEnds = true; + terminalGu.constraint = "ppxxxxxxpp"; + bool rejectedTerminalGu{}; + try { + static_cast(makeTurnerFoldingEnsemble( + Sequence("terminal-gu", "GGAAAAAACU"), terminalGu)); + } catch (const std::invalid_argument&) { + rejectedTerminalGu = true; + } + check(rejectedTerminalGu, "noGUend rejects a GU pair at the outer helix end"); + } + + { + // Public RNAfold/RNAeval 2.7.2 oracle for the single structure + // ((...)(...)): dangles=0 gives 17.40 kcal/mol and dangles=2 gives + // 13.20 kcal/mol. This pins dangle decoration between directly + // adjacent multiloop stems. + const Sequence sequence("forced-multiloop", "GGAAACGAAACC"); + auto noDangles = options(); + noDangles.constraint = "ppxxxppxxxpp"; + const auto dangleZero = makeTurnerFoldingEnsemble(sequence, noDangles); + check(close(dangleZero->ensembleFreeEnergy(), 17.40, 0.012), + "forced multiloop matches the public dangle-0 oracle"); + + auto dangleTwo = noDangles; + dangleTwo.includeDangles = true; + const auto decorated = makeTurnerFoldingEnsemble(sequence, dangleTwo); + check(close(decorated->ensembleFreeEnergy(), 13.20, 0.012), + "forced multiloop matches the public dangle-2 oracle"); + } + + { + // Eall/Zall are full-monomer quantities even when Pu/ED comes from a + // local fold, a table, or is disabled. This is also a black-box legacy + // contract: GAAAC under energy=B always has Z=1+e and Eall=-ln(Z). + const Sequence sequence("summary", "GAAAC"); + Config config; + config.energy = EnergyKind::basePair; + config.query.accessibilityWindow = 4U; + config.query.accessibilitySpan = 4U; + const auto expectedLogZ = std::log1p(std::exp(1.0)); + + config.query.accessibility = AccessibilityKind::compute; + auto computedResult = makeAccessibility(sequence, config.query, config); + check(computedResult.has_value(), "local computed accessibility constructs"); + if (computedResult) { + check(computedResult.value()->ensembleLogPartition().has_value() && + close(*computedResult.value()->ensembleLogPartition(), expectedLogZ), + "local computed accessibility exposes the global monomer partition"); + const auto physicalRt = gasConstantKcal * 310.15; + const auto openingEnergy = std::trunc(expectedLogZ * 100.0) / 100.0; + check(close(computedResult.value()->unpairedProbability({0U, 0U}), + std::exp(-openingEnergy / physicalRt)), + "base-pair Pu converts its RT=1 ensemble ED using physical RT"); + check(close(computedResult.value()->openingEnergy({0U, 0U}), openingEnergy), + "base-pair opening energy preserves public centikcal semantics"); + } + + config.query.accessibility = AccessibilityKind::disabled; + auto disabledResult = makeAccessibility(sequence, config.query, config); + check(disabledResult.has_value(), "disabled accessibility constructs with monomer summary"); + if (disabledResult) { + check(disabledResult.value()->ensembleFreeEnergy().has_value() && + close(*disabledResult.value()->ensembleFreeEnergy(), -expectedLogZ), + "disabled accessibility retains the global monomer free energy"); + check(close(disabledResult.value()->openingEnergy({0U, 4U}), 0.0), + "disabled interval opening energy remains zero"); + } + + const auto tablePath = std::filesystem::temp_directory_path() / + "intarnanew-folding-summary-table.txt"; + { + std::ofstream table(tablePath); + table << "1 1\n2 1\n3 1\n4 1\n5 1\n"; + } + config.query.accessibility = AccessibilityKind::probabilitiesFile; + config.query.accessibilityFile = tablePath.string(); + auto tableResult = makeAccessibility(sequence, config.query, config); + check(tableResult.has_value(), "table accessibility constructs with monomer summary"); + if (tableResult) { + check(tableResult.value()->ensembleLogPartition().has_value() && + close(*tableResult.value()->ensembleLogPartition(), expectedLogZ), + "table accessibility retains the global monomer partition"); + check(close(tableResult.value()->positionUnpairedProbability(2U), 1.0), + "table values still govern interval accessibility"); + } + std::error_code error; + std::filesystem::remove(tablePath, error); + check(!error, "temporary summary table fixture is removed"); + } + + { + const Sequence sequence("constraints", "GAAAC", -2); + auto constrained = options(); + constrained.constraint = "x:-2--2,b:2-2"; + const auto ensemble = makeTurnerFoldingEnsemble(sequence, constrained); + check(close(ensemble->logPartition(), 0.0), "x/b endpoints are unpaired in the folding ensemble"); + check(close(ensemble->jointUnpairedProbability({0U, 4U}), 1.0), + "x/b constraints preserve their conditional unpaired probability"); + } + + { + const auto shapePath = std::filesystem::temp_directory_path() / + "intarnanew-folding-shape-oracle.txt"; + { + std::ofstream shape(shapePath); + shape << "1 G 0\n5 C 0\n"; + } + const Sequence sequence("shape", "GAAAC"); + auto shaped = options(); + shaped.shapeFile = shapePath.string(); + shaped.shapeMethod = "Zb0.89"; + shaped.shapeConversion = "S"; + const auto ensemble = makeTurnerFoldingEnsemble(sequence, shaped); + const auto weight = std::exp(-(5.4 + 2.0 * 0.89) / rt); + check(close(ensemble->logPartition(), std::log1p(weight)), + "Zarringhalam S conversion adds the documented paired-state penalties"); + std::error_code error; + std::filesystem::remove(shapePath, error); + check(!error, "temporary SHAPE fixture is removed"); + } + + { + bool rejected{}; + try { + validateShapeEncoding("Dm1.8m2", ""); + } catch (const std::invalid_argument&) { + rejected = true; + } + check(rejected, "duplicate SHAPE method tags are rejected"); + rejected = false; + try { + validateShapeEncoding("D", "S"); + } catch (const std::invalid_argument&) { + rejected = true; + } + check(rejected, "conversion without Zarringhalam method is rejected"); + } + + if (failures == 0) std::cout << "All Turner folding oracle tests passed.\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/IntaRNAnew/tests/integration.cpp b/IntaRNAnew/tests/integration.cpp new file mode 100644 index 0000000..b6dfd9c --- /dev/null +++ b/IntaRNAnew/tests/integration.cpp @@ -0,0 +1,224 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/cli.hpp" +#include "intarnanew/energy.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +auto failureCount = 0; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failureCount; + } +} + +[[nodiscard]] auto isInterval( + const intarnanew::Interval interval, + const intarnanew::Index begin, + const intarnanew::Index end) -> bool { + return interval.begin == begin && interval.end == end; +} + +[[nodiscard]] auto baseConfig(const std::vector& arguments) -> intarnanew::Config { + auto parsed = intarnanew::Cli::parse(arguments); + if (!parsed) { + std::cerr << "parse failed: " << parsed.error() << '\n'; + std::exit(2); + } + return std::move(*parsed); +} + +[[nodiscard]] auto predict( + const intarnanew::Config& config, + const intarnanew::Sequence& target, + const intarnanew::Sequence& query) -> intarnanew::PredictionResult { + auto targetAccessibility = intarnanew::makeAccessibility(target, config.target, config.temperatureCelsius); + auto queryAccessibility = intarnanew::makeAccessibility(query, config.query, config.temperatureCelsius); + if (!targetAccessibility || !queryAccessibility) { + std::cerr << "accessibility setup failed\n"; + std::exit(2); + } + return intarnanew::Predictor(config).predict(target, query, **targetAccessibility, **queryAccessibility); +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + { + const auto config = baseConfig({"--target=CCAACACC", "--query=GG", "--energy=B", "--acc=N", + "--noSeed", "--mode=M", "--model=S", "--outMode=C", "--outNumber=20", + "--outOverlap=B", "--outDeltaE=0"}); + const Sequence target("target", "CCAACACC"); + const Sequence query("query", "GG"); + const auto result = predict(config, target, query); + check(result.interactions.size() == 10U, "legacy real-interaction base-pair fixture has ten optima"); + check(std::ranges::all_of(result.interactions, [](const auto& interaction) { + return std::abs(interaction.energy.total() + 2.0) < 1e-9; + }), "all base-pair fixture optima have -2 kcal/mol"); + check(isInterval(result.interactions.front().targetRange(), 0U, 1U), + "first base-pair fixture interaction uses target positions 1..2"); + } + + { + const auto config = baseConfig({"--target=CCAACCCACC", "--query=GGGG", "--energy=B", "--acc=N", + "--seedBP=3", "--mode=M", "--model=S", "--outMode=C", "--outNumber=10", + "--outOverlap=B", "--outDeltaE=0", "--outNoLP=true"}); + const Sequence target("target", "CCAACCCACC"); + const Sequence query("query", "GGGG"); + const auto result = predict(config, target, query); + check(result.interactions.size() == 2U, "legacy no-lonely-pair seed fixture has two optima"); + check(result.interactions.front().pairs.size() == 3U, "no-lonely-pair seed uses three pairs"); + check(isInterval(result.interactions.front().targetRange(), 4U, 6U), + "no-lonely-pair seed targets C stretch"); + } + + { + const auto config = baseConfig({"--target=CCAACCCACC", "--query=GGGG", "--energy=B", "--acc=N", + "--seedBP=2", "--mode=H", "--model=X", "--tIntLenMax=2", "--qIntLenMax=2", + "--outMode=C", "--outNumber=10", "--outOverlap=N", "--outDeltaE=0"}); + const Sequence target("target", "CCAACCCACC"); + const Sequence query("query", "GGGG"); + const auto result = predict(config, target, query); + check(result.interactions.size() == 2U, "non-overlap policy reproduces two disjoint fixtures"); + check(!result.interactions[0].targetRange().overlaps(result.interactions[1].targetRange()), + "selected target intervals do not overlap"); + check(!result.interactions[0].queryRange().overlaps(result.interactions[1].queryRange()), + "selected query intervals do not overlap"); + } + + { + const auto config = baseConfig({"--target=UUUUUUCCCUAAAUCCUUCUUU", + "--query=GGGGGGGGGGGGGGGGGGGGG", "--energy=B", "--acc=N", "--noSeed", + "--tRegion=8-17", "--qRegion=4-14", "--outNoGUend=true", "--outNumber=1", + "--outDeltaE=0"}); + const Sequence target("target", "UUUUUUCCCUAAAUCCUUCUUU"); + const Sequence query("query", "GGGGGGGGGGGGGGGGGGGGG"); + const auto result = predict(config, target, query); + check(result.interactions.size() == 1U, "no-GU-end fixture has one best interaction"); + if (!result.interactions.empty()) { + check(result.interactions.front().pairs.size() == 4U, + "no-GU-end fixture has four base pairs"); + check(result.interactions.front().targetRange() == Interval{7U, 15U}, + "no-GU-end fixture spans external target positions 8..16"); + check(std::abs(result.interactions.front().energy.total() + 4.0) < 1e-9, + "no-GU-end fixture has -4 kcal/mol"); + } + } + + { + const auto config = baseConfig({"--target=CGC", "--query=GUG", "--energy=B", + "--acc=N", "--noSeed", "--mode=M", "--model=S", "--outNoGUend=true", + "--outNumber=1", "--outDeltaE=0"}); + const Sequence target("target", "CGC"); + const Sequence query("query", "GUG"); + const auto result = predict(config, target, query); + check(result.interactions.size() == 1U, + "no-GU-end permits a GU pair stacked inside a helix"); + if (!result.interactions.empty()) { + check(result.interactions.front().pairs == + std::vector{{0U, 2U}, {1U, 1U}, {2U, 0U}}, + "internal-GU fixture retains all three stacked pairs"); + check(std::abs(result.interactions.front().energy.total() + 3.0) < 1e-9, + "internal-GU base-pair fixture has -3 kcal/mol"); + } + } + + { + const NearestNeighborEnergyModel model(37.0, "Turner04", false); + const auto evaluate = [&](const std::string_view targetText, + const std::string_view queryText, + const std::initializer_list pairs) { + const Sequence target("target", std::string(targetText)); + const Sequence query("query", std::string(queryText)); + const std::vector path(pairs); + return model.evaluate(target, query, path); + }; + check(std::abs(model.rt() - 0.616321) < 5e-7, + "Turner04 RT matches the ViennaRNA constant"); + check(std::abs(evaluate("CG", "CG", {{0U, 1U}, {1U, 0U}}).loops + 2.4) < 1e-9, + "Turner04 GC/GC stack oracle"); + check(std::abs(evaluate("GCG", "CGC", {{0U, 2U}, {1U, 1U}, {2U, 0U}}).loops + 5.8) < 1e-9, + "Turner04 three-pair stack oracle"); + check(std::abs(evaluate("AG", "CU", {{0U, 1U}, {1U, 0U}}).loops + 2.1) < 1e-9, + "Turner04 AU/GC stack orientation oracle"); + check(std::abs(evaluate("GA", "UC", {{0U, 1U}, {1U, 0U}}).loops + 2.4) < 1e-9, + "Turner04 GC/AU stack orientation oracle"); + check(std::abs(evaluate("GG", "CU", {{0U, 1U}, {1U, 0U}}).loops + 2.1) < 1e-9, + "Turner04 GU/GC stack orientation oracle"); + check(std::abs(evaluate("GG", "UC", {{0U, 1U}, {1U, 0U}}).loops + 1.5) < 1e-9, + "Turner04 GC/GU stack orientation oracle"); + check(std::abs(evaluate("GAC", "GC", {{0U, 1U}, {2U, 0U}}).loops - 0.4) < 1e-9, + "Turner04 one-nucleotide bulge oracle"); + check(std::abs(evaluate("GAC", "GAC", {{0U, 2U}, {2U, 0U}}).loops - 0.8) < 1e-9, + "Turner04 int11 oracle"); + check(std::abs(evaluate("GAAC", "GAC", {{0U, 2U}, {3U, 0U}}).loops - 2.3) < 1e-9, + "Turner04 int21 orientation oracle"); + check(std::abs(evaluate("GAC", "GAAC", {{0U, 3U}, {2U, 0U}}).loops - 2.3) < 1e-9, + "Turner04 reversed int21 orientation oracle"); + check(std::abs(evaluate("UCGU", "AAA", {{0U, 2U}, {3U, 0U}}).loops - 3.7) < 1e-9, + "Turner04 asymmetric int21 1x2 nucleotide-order oracle"); + check(std::abs(evaluate("AAU", "AAGU", {{0U, 3U}, {2U, 0U}}).loops - 3.7) < 1e-9, + "Turner04 asymmetric int21 2x1 nucleotide-order oracle"); + check(std::abs(evaluate("GAAC", "GAAC", {{0U, 3U}, {3U, 0U}}).loops - 1.5) < 1e-9, + "Turner04 int22 oracle"); + + const NearestNeighborEnergyModel dangleModel(37.0, "Turner04", true); + const Sequence target("target", "AAGC"); + const Sequence query("query", "ACUA"); + const std::vector path{{1U, 2U}, {2U, 1U}}; + const auto dangles = dangleModel.evaluate(target, query, path); + check(std::abs(dangles.loops + 2.1) < 1e-9 && + std::abs(dangles.dangleLeft + 1.0) < 1e-9 && + std::abs(dangles.dangleRight + 1.1) < 1e-9 && + std::abs(dangles.hybrid() - 0.4) < 1e-9, + "Turner04 exterior mismatch/dangle oracle"); + + const NearestNeighborEnergyModel model20(20.0, "Turner04", false); + const Sequence auTarget("target", "A"); + const Sequence auQuery("query", "U"); + const std::vector auPath{{0U, 0U}}; + const auto at20 = model20.evaluate(auTarget, auQuery, auPath); + check(std::abs(at20.initiation - 4.07) < 1e-9 && + std::abs(at20.endLeft - 0.67) < 1e-9 && + std::abs(at20.endRight - 0.67) < 1e-9, + "ViennaRNA centikcal temperature interpolation oracle"); + + const NearestNeighborEnergyModel turner99(37.0, "Turner99", false); + const NearestNeighborEnergyModel andronescu(37.0, "Andronescu07", false); + check(std::abs(turner99.initiationEnergy() - 4.1) < 1e-9 && + std::abs(andronescu.initiationEnergy() - 4.1) < 1e-9, + "named ViennaRNA parameter sets load natively"); + check(std::abs(BasePairEnergyModel(0.0).rt() - 1.0) < 1e-12, + "base-pair-counting energy model uses RT=1"); + } + + { + const Sequence sequence("iupac", "acgturyswkmbdhvn", -5); + check(sequence.str() == "ACGUUNNNNNNNNNNN", + "non-canonical IUPAC symbols normalize to non-pairing N"); + check(sequence.externalIndex(0U) == -5, "external coordinate origin"); + check(!canPair('R', 'Y'), "ambiguous IUPAC symbols do not pair"); + check(!canPair('N', 'A'), "N does not pair"); + check(!canPair('A', 'C'), "incompatible pair rejected"); + } + + if (failureCount == 0) { + std::cout << "All IntaRNAnew real-interaction integration tests passed.\n"; + } + return failureCount == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/model_semantics.cpp b/IntaRNAnew/tests/model_semantics.cpp new file mode 100644 index 0000000..614a400 --- /dev/null +++ b/IntaRNAnew/tests/model_semantics.cpp @@ -0,0 +1,374 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/energy.hpp" +#include "intarnanew/helix_blocks.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using intarnanew::AccessibilityKind; +using intarnanew::AccessibilityProvider; +using intarnanew::BasePair; +using intarnanew::BasePairEnergyModel; +using intarnanew::Config; +using intarnanew::DisabledAccessibility; +using intarnanew::EnergyKind; +using intarnanew::InteractionModel; +using intarnanew::OverlapPolicy; +using intarnanew::PredictionMode; +using intarnanew::Predictor; +using intarnanew::Sequence; + +class SeedOpeningAccessibility final : public AccessibilityProvider { +public: + [[nodiscard]] auto openingEnergy(const intarnanew::Interval interval) const + -> intarnanew::Energy override { + return interval.begin == 0U ? 5.0 : 0.0; + } + [[nodiscard]] auto unpairedProbability(const intarnanew::Interval) const + -> double override { return 1.0; } + [[nodiscard]] auto positionUnpairedProbability(const intarnanew::Index) const + -> double override { return 1.0; } + [[nodiscard]] auto blocked(const intarnanew::Index) const -> bool override { return false; } +}; + +class NativeProbabilityAccessibility final : public AccessibilityProvider { +public: + explicit NativeProbabilityAccessibility(const double probability) + : probability_(probability) {} + + [[nodiscard]] auto openingEnergy(const intarnanew::Interval) const + -> intarnanew::Energy override { return 0.0; } + [[nodiscard]] auto unpairedProbability(const intarnanew::Interval) const + -> double override { return probability_; } + [[nodiscard]] auto positionUnpairedProbability(const intarnanew::Index) const + -> double override { return 1.0; } + [[nodiscard]] auto blocked(const intarnanew::Index) const -> bool override { return false; } + +private: + double probability_{}; +}; + +int failures{}; + +void check(const bool condition, const std::string& message) { + if (!condition) { + std::cerr << "FAILED: " << message << '\n'; + ++failures; + } +} + +[[nodiscard]] auto baseConfig() -> Config { + Config config; + config.energy = EnergyKind::basePair; + config.mode = PredictionMode::exact; + config.model = InteractionModel::seedExtension; + config.seed.required = false; + config.target.accessibility = AccessibilityKind::disabled; + config.query.accessibility = AccessibilityKind::disabled; + config.output.number = 1'000U; + config.output.overlap = OverlapPolicy::both; + config.output.deltaEnergy = 100.0; + config.output.maxEnergy = 0.0; + return config; +} + +void checkHelixContracts() { + const Sequence target("target", "CCCCC"); + const Sequence query("query", "GGGGG"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + const BasePairEnergyModel energy(37.0); + intarnanew::HelixConfig helix; + helix.minBasePairs = 2U; + helix.maxBasePairs = 2U; + + const std::vector twoPair{{0U, 1U}, {1U, 0U}}; + auto blocks = intarnanew::decomposeHelixBlocks( + target, query, twoPair, helix, energy, + targetAccessibility, queryAccessibility); + check(blocks.size() == 1U && blocks.front().basePairCount() == 2U, + "a two-pair canonical helix satisfies the default block contract"); + + const std::vector overlong{{0U, 3U}, {1U, 2U}, {2U, 1U}, {3U, 0U}}; + check(intarnanew::decomposeHelixBlocks( + target, query, overlong, helix, energy, + targetAccessibility, queryAccessibility).empty(), + "a canonical run cannot be split without a separating interior loop"); + + const std::vector blockAndAnchor{{0U, 3U}, {1U, 2U}, {3U, 1U}}; + blocks = intarnanew::decomposeHelixBlocks( + target, query, blockAndAnchor, helix, energy, + targetAccessibility, queryAccessibility); + check(blocks.size() == 1U && blocks.front().lastPair == 1U, + "a separated right-most initiation pair can terminate a block composition"); + + const std::vector twoBlocks{{0U, 4U}, {1U, 3U}, {3U, 2U}, {4U, 1U}}; + blocks = intarnanew::decomposeHelixBlocks( + target, query, twoBlocks, helix, energy, + targetAccessibility, queryAccessibility); + check(blocks.size() == 2U, + "two bounded helices separated by an interior loop compose an interaction"); + + const std::vector oneBaseBulge{{0U, 2U}, {2U, 1U}}; + check(intarnanew::decomposeHelixBlocks( + target, query, oneBaseBulge, helix, energy, + targetAccessibility, queryAccessibility).empty(), + "helixMaxIL zero rejects a one-base bulge inside a helix"); + helix.maxInternalLoop = 1U; + check(intarnanew::decomposeHelixBlocks( + target, query, oneBaseBulge, helix, energy, + targetAccessibility, queryAccessibility).size() == 1U, + "helixMaxIL bounds the total unpaired bases inside a helix transition"); + + helix.maxInternalLoop = 0U; + helix.maxEnergy = -1.0; + check(intarnanew::decomposeHelixBlocks( + target, query, twoPair, helix, energy, + targetAccessibility, queryAccessibility).empty(), + "helixMaxE is an exclusive loop-energy threshold"); + helix.useFullEnergy = true; + check(intarnanew::decomposeHelixBlocks( + target, query, twoPair, helix, energy, + targetAccessibility, queryAccessibility).size() == 1U, + "helixFullE switches the threshold from loop-only to full helix energy"); +} + +void checkModelPartitions() { + const Sequence target("target", "CCCC"); + const Sequence query("query", "GGGG"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + + auto singleSiteConfig = baseConfig(); + singleSiteConfig.model = InteractionModel::singleSite; + const auto singleSite = Predictor(singleSiteConfig).predict( + target, query, targetAccessibility, queryAccessibility); + double expectedPartition{}; + for (const auto& interaction : singleSite.ensembleSites) { + check(std::abs(interaction.ensembleFreeEnergy - interaction.energy.total()) < 1e-12, + "model S assigns one MFE Boltzmann weight to each site"); + expectedPartition += std::exp(-interaction.energy.total()); + } + check(std::abs(std::exp(singleSite.logPartition) - expectedPartition) < 1e-10, + "model-S global partition is the sum of site-MFE weights"); + + auto extensionConfig = baseConfig(); + const auto extension = Predictor(extensionConfig).predict( + target, query, targetAccessibility, queryAccessibility); + double extensionPartition{}; + for (const auto& interaction : extension.ensembleSites) { + check(std::abs(interaction.ensembleFreeEnergy - interaction.energy.total()) < 1e-12, + "unseeded model X reports one MFE weight per interaction site"); + extensionPartition += std::exp(-interaction.energy.total()); + } + check(std::abs(std::exp(extension.logPartition) - extensionPartition) < 1e-10, + "unseeded model X has the same valid site-MFE partition reduction as model S"); + + auto seededExtensionConfig = baseConfig(); + seededExtensionConfig.seed.required = true; + seededExtensionConfig.seed.basePairs = 2U; + const auto seededExtension = Predictor(seededExtensionConfig).predict( + target, query, targetAccessibility, queryAccessibility); + check(!std::isfinite(seededExtension.logPartition) && + !std::isfinite(seededExtension.ensembleFreeEnergy), + "seeded model X leaves its algorithmically incomplete global partition unavailable"); + for (const auto& interaction : seededExtension.ensembleSites) { + check(std::abs(interaction.ensembleFreeEnergy - interaction.energy.total()) < 1e-12, + "seeded model X still exposes scientifically valid site-MFE weights"); + } +} + +void checkEnsembleCentikcalContract() { + const Sequence target("target", "GGGAAACCC"); + const Sequence query("query", "GGGAAACCC"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + auto config = baseConfig(); + config.model = InteractionModel::ensemble; + const auto prediction = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility); + check(!prediction.interactions.empty(), "model P produces a best site"); + if (prediction.interactions.empty()) return; + const auto& best = prediction.interactions.front(); + const auto expectedReported = std::trunc(best.ensembleFreeEnergy * 100.0) / 100.0; + check(std::abs(best.energy.total() - expectedReported) < 1e-12, + "model P retains raw site free energy and reports centikcal truncation"); + check(best.ensembleFreeEnergy < best.energy.total(), + "negative model-P raw energy is more precise than its reported value"); +} + +void checkEnsembleTerminalWeights() { + auto config = baseConfig(); + config.energy = EnergyKind::nearestNeighbor; + config.energyParameters = "Turner04"; + config.model = InteractionModel::ensemble; + config.output.maxEnergy = 100.0; + + { + const Sequence target("target", "A"); + const Sequence query("query", "U"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + const auto prediction = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility); + check(prediction.ensembleSites.size() == 1U, + "single AU model-P ensemble contains one interaction site"); + if (!prediction.ensembleSites.empty()) { + const auto& site = prediction.ensembleSites.front(); + check(std::abs(site.ensembleFreeEnergy - 5.10) < 1e-9, + "model-P site partition includes both terminal-AU penalties"); + check(std::abs(prediction.logPartition + 5.10 / prediction.rt) < 1e-9, + "model-P global partition includes terminal-AU penalties"); + check(std::abs(site.probability - 1.0) < 1e-12, + "single model-P site remains normalized"); + } + } + + { + const Sequence target("target", "AAG"); + const Sequence query("query", "AUA"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + const std::vector path{{1U, 1U}}; + const intarnanew::NearestNeighborEnergyModel energy( + 37.0, "Turner04", true); + const auto expected = energy.evaluate(target, query, path).total(); + const auto prediction = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility, + {1U, 1U}, {1U, 1U}); + check(prediction.ensembleSites.size() == 1U, + "single-cell model-P domain contains one interaction"); + if (!prediction.ensembleSites.empty()) { + check(std::abs(prediction.ensembleSites.front().ensembleFreeEnergy - expected) < 1e-9, + "model-P site partition includes exterior dangle energy"); + check(std::abs(prediction.logPartition + expected / prediction.rt) < 1e-9, + "model-P global partition includes exterior dangle energy"); + } + } +} + +void checkSeedOnlyAndHelixPrediction() { + const Sequence target("target", "CCCC"); + const Sequence query("query", "GGGG"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + + auto seedConfig = baseConfig(); + seedConfig.mode = PredictionMode::seedOnly; + seedConfig.seed.required = true; + seedConfig.seed.basePairs = 2U; + seedConfig.output.bestSeedOnly = true; + const auto seeds = Predictor(seedConfig).predict( + target, query, targetAccessibility, queryAccessibility); + check(seeds.ensembleSites.size() == 9U, + "seed-only mode enumerates every exact two-pair seed site"); + for (const auto& interaction : seeds.ensembleSites) { + check(interaction.pairs.size() == 2U && interaction.seeds.size() == 1U, + "seed-only interactions contain exactly their seed pairs"); + } + + auto bestSeedConfig = baseConfig(); + bestSeedConfig.seed.required = true; + bestSeedConfig.seed.basePairs = 2U; + bestSeedConfig.output.number = 1U; + bestSeedConfig.output.bestSeedOnly = true; + const auto bestSeed = Predictor(bestSeedConfig).predict( + target, query, targetAccessibility, queryAccessibility); + check(!bestSeed.interactions.empty() && + bestSeed.interactions.front().seeds.size() == 3U && + bestSeed.interactions.front().seeds[0U].firstPair == 0U && + bestSeed.interactions.front().seeds[1U].firstPair == 1U && + bestSeed.interactions.front().seeds[2U].firstPair == 2U, + "discovered seed matches are retained once in deterministic energy/coordinate order"); + check(!bestSeed.interactions.empty() && bestSeed.interactions.front().bestSeed() != nullptr && + bestSeed.interactions.front().bestSeed()->firstPair == 0U && + bestSeed.interactions.front().bestSeed()->lastPair == 1U, + "best-seed helper resolves equal-energy seeds by their interaction coordinates"); + + auto explicitConfig = baseConfig(); + explicitConfig.seed.explicitSeeds = "1||&3||,3||&1||"; + explicitConfig.output.maxEnergy = 999.0; + const SeedOpeningAccessibility seedOpeningAccessibility; + const auto explicitPrediction = Predictor(explicitConfig).predict( + target, query, seedOpeningAccessibility, queryAccessibility); + bool explicitFullSiteChecked = false; + for (const auto& interaction : explicitPrediction.ensembleSites) { + if (interaction.targetRange() != intarnanew::Interval{0U, 3U} || + interaction.queryRange() != intarnanew::Interval{0U, 3U}) continue; + explicitFullSiteChecked = true; + check(interaction.seeds.size() == 2U && interaction.bestSeed() != nullptr && + interaction.bestSeed()->firstPair == 2U && + interaction.bestSeed()->lastPair == 3U, + "best-seed selection evaluates every matching explicit seed, not input order"); + } + check(explicitFullSiteChecked, "explicit-seed regression contains its full interaction site"); + + auto helixConfig = baseConfig(); + helixConfig.mode = PredictionMode::heuristic; + helixConfig.model = InteractionModel::helixBlocks; + helixConfig.helix.maxBasePairs = 2U; + const auto helixPrediction = Predictor(helixConfig).predict( + target, query, targetAccessibility, queryAccessibility); + check(!helixPrediction.interactions.empty() && + helixPrediction.interactions.front().pairs.size() == 3U && + std::abs(helixPrediction.interactions.front().energy.total() + 3.0) < 1e-12, + "model B composes a bounded two-pair block with a separated anchor"); +} + +void checkNativeInteractionProbabilities() { + const Sequence target("target", "CCCC"); + const Sequence query("query", "GGGG"); + const NativeProbabilityAccessibility targetAccessibility(0.37); + const NativeProbabilityAccessibility queryAccessibility(0.61); + + auto config = baseConfig(); + for (const auto mode : {PredictionMode::exact, PredictionMode::seedOnly}) { + config.mode = mode; + config.seed.required = mode == PredictionMode::seedOnly; + config.seed.basePairs = 2U; + const auto prediction = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility); + check(!prediction.ensembleSites.empty(), + "native-Pu regression produces interaction sites"); + for (const auto& interaction : prediction.ensembleSites) { + check(std::abs(interaction.unpairedTarget - 0.37) < 1e-12 && + std::abs(interaction.unpairedQuery - 0.61) < 1e-12, + "interaction preserves provider-native Pu independently of energy RT"); + } + } + + config.mode = PredictionMode::exact; + config.model = InteractionModel::ensemble; + config.seed.required = false; + const auto ensemble = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility); + check(!ensemble.ensembleSites.empty(), "model-P native-Pu regression produces sites"); + for (const auto& interaction : ensemble.ensembleSites) { + check(std::abs(interaction.unpairedTarget - 0.37) < 1e-12 && + std::abs(interaction.unpairedQuery - 0.61) < 1e-12, + "model P preserves provider-native Pu during representative reduction"); + } +} + +} // namespace + +auto main() -> int { + checkHelixContracts(); + checkModelPartitions(); + checkEnsembleCentikcalContract(); + checkEnsembleTerminalWeights(); + checkSeedOnlyAndHelixPrediction(); + checkNativeInteractionProbabilities(); + if (failures == 0) { + std::cout << "All interaction-model semantic tests passed.\n"; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/IntaRNAnew/tests/output_execution.cpp b/IntaRNAnew/tests/output_execution.cpp new file mode 100644 index 0000000..6db93e9 --- /dev/null +++ b/IntaRNAnew/tests/output_execution.cpp @@ -0,0 +1,197 @@ +#include "intarnanew/compression.hpp" +#include "intarnanew/output_plan.hpp" +#include "intarnanew/parallel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failures; + } +} + +[[nodiscard]] auto readAll(const std::filesystem::path& path) -> std::string { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; +} + +[[nodiscard]] auto publication( + const intarnanew::OutputPlan& plan, + const std::string_view destination) -> const intarnanew::OutputPublication* { + for (const auto& item : plan.publications) { + if (item.destination == destination) return &item; + } + return nullptr; +} + +} // namespace + +auto main() -> int { + using namespace intarnanew; + + { + Config config; + config.output.destinations = { + "primary.csv", + "qMinE:pair.csv.gz", + "qAcc:qacc.csv.gz", + "tPu:tpu.txt", + }; + const std::vector groups{ + {0U, 0U, 0U, 0U}, {0U, 1U, 0U, 0U}, + {1U, 0U, 0U, 0U}, {1U, 1U, 0U, 0U}, + }; + const auto plan = planOutputs(config, 2U, 2U, groups); + check(plan.has_value(), "multi-sequence output plan is valid"); + if (plan) { + const auto* primary = publication(*plan, "primary.csv"); + check(primary != nullptr && primary->parts.size() == 4U, + "primary output combines every pair in deterministic order"); + check(publication(*plan, "pair.csv-t1q1.gz") != nullptr && + publication(*plan, "pair.csv-t1q2.gz") != nullptr && + publication(*plan, "pair.csv-t2q1.gz") != nullptr && + publication(*plan, "pair.csv-t2q2.gz") != nullptr, + "pair suffixes precede the gzip extension and identify target/query"); + const auto* query1 = publication(*plan, "qacc.csv-s1.gz"); + const auto* query2 = publication(*plan, "qacc.csv-s2.gz"); + check(query1 != nullptr && query2 != nullptr && + query1->parts.size() == 1U && query2->parts.size() == 1U && + query1->parts.front().sequenceIndex == 0U && + query2->parts.front().sequenceIndex == 1U, + "query accessibility is emitted once per query with legacy -s suffixes"); + check(publication(*plan, "tpu-s1.txt") != nullptr && + publication(*plan, "tpu-s2.txt") != nullptr, + "target accessibility is emitted once per target"); + } + } + + { + Config config; + config.output.destinations = {"primary.csv", "qMinE:pair.csv"}; + const std::vector groups{ + {0U, 0U, 0U, 0U}, {0U, 0U, 1U, 0U}, + }; + const auto plan = planOutputs(config, 1U, 1U, groups); + check(plan && publication(*plan, "pair-rT1Q1.csv") != nullptr && + publication(*plan, "pair-rT2Q1.csv") != nullptr, + "per-region pair artifacts receive deterministic collision-free suffixes"); + } + + { + Config streams; + streams.output.destinations = {"STDOUT", "qMinE:STDOUT"}; + const std::vector groups{{0U, 0U, 0U, 0U}}; + const auto plan = planOutputs(streams, 1U, 1U, groups); + check(!plan && plan.error().find("multiple output descriptors") != std::string::npos, + "conflicting standard-stream writers are rejected before prediction"); + + Config files; + files.output.destinations = {"same.csv", "qMinE:same.csv"}; + const auto collision = planOutputs(files, 1U, 1U, groups); + check(!collision && collision.error().find("same file") != std::string::npos, + "colliding real-file destinations are rejected before prediction"); + + Config oneQuery; + oneQuery.output.destinations = {"main.csv", "qAcc:qacc.csv"}; + const std::vector twoPairs{ + {0U, 0U, 0U, 0U}, {1U, 0U, 0U, 0U}, + }; + const auto oneQueryPlan = planOutputs(oneQuery, 2U, 1U, twoPairs); + const auto* query = oneQueryPlan ? publication(*oneQueryPlan, "qacc.csv") : nullptr; + check(query != nullptr && query->parts.size() == 1U, + "one query accessibility artifact is not duplicated across target pairs"); + } + + { + const auto unique = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto directory = std::filesystem::temp_directory_path() / + ("intarnanew-publication-test-" + unique); + std::error_code error; + std::filesystem::create_directory(directory, error); + check(!error, "batch-publication test directory is created"); + + const auto preserved = directory / "preserved.txt"; + { + std::ofstream output(preserved); + output << "old\n"; + } + const std::vector invalidBatch{ + {preserved.string(), "new\n"}, + {(directory / "missing" / "later.txt").string(), "later\n"}, + }; + const auto rejected = publishOutputs(invalidBatch); + check(!rejected && readAll(preserved) == "old\n", + "a later invalid destination cannot partially publish an earlier file"); + + const auto plain = directory / "plain.txt"; + const auto compressed = directory / "compressed.txt.gz"; + const std::vector validBatch{ + {plain.string(), "plain payload\n"}, + {compressed.string(), "compressed payload\n"}, + }; + const auto published = publishOutputs(validBatch); + const auto decoded = published ? gzipDecompress(readAll(compressed)) + : std::expected{ + std::unexpected("publication failed")}; + check(published && readAll(plain) == "plain payload\n" && decoded && + *decoded == "compressed payload\n", + "batch publication commits plain and gzip artifacts together"); + + const std::vector duplicate{ + {plain.string(), "first"}, {plain.string(), "second"}, + }; + check(!publishOutputs(duplicate) && readAll(plain) == "plain payload\n", + "duplicate batch destinations fail without changing existing content"); + + std::filesystem::remove_all(directory, error); + check(!error, "batch-publication test directory is removed"); + } + + { + std::vector values(64U); + const auto success = runParallelIndexed( + values.size(), 8U, + [&](const std::size_t, const std::size_t task, const std::stop_token token) { + if (token.stop_requested()) throw std::runtime_error("unexpected cancellation"); + values[task] = task + 1U; + }); + bool complete = success.has_value(); + for (std::size_t index = 0U; index < values.size(); ++index) { + complete = complete && values[index] == index + 1U; + } + check(complete, "parallel indexed execution completes every task exactly once"); + + std::atomic_size_t claimed{}; + const auto failed = runParallelIndexed( + 100'000U, 8U, + [&](const std::size_t, const std::size_t task, const std::stop_token) { + claimed.fetch_add(1U, std::memory_order_relaxed); + if (task == 3U) throw std::runtime_error("intentional worker failure"); + }); + check(!failed && failed.error().taskIndex == 3U && + failed.error().message == "intentional worker failure", + "parallel failures retain a deterministic task index and diagnostic"); + check(claimed.load(std::memory_order_relaxed) < 100'000U, + "the first worker failure stops new task claims cooperatively"); + } + + if (failures == 0) { + std::cout << "All output planning and parallel execution tests passed.\n"; + } + return failures == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/predictor_oracle.cpp b/IntaRNAnew/tests/predictor_oracle.cpp new file mode 100644 index 0000000..e1daaee --- /dev/null +++ b/IntaRNAnew/tests/predictor_oracle.cpp @@ -0,0 +1,453 @@ +#include "intarnanew/accessibility.hpp" +#include "intarnanew/config.hpp" +#include "intarnanew/predictor.hpp" +#include "intarnanew/sequence.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using intarnanew::BasePair; +using intarnanew::Config; +using intarnanew::DisabledAccessibility; +using intarnanew::Energy; +using intarnanew::Index; +using intarnanew::Interaction; +using intarnanew::InteractionModel; +using intarnanew::PredictionMode; +using intarnanew::PredictionResult; +using intarnanew::Predictor; +using intarnanew::Sequence; +using intarnanew::canPair; +using intarnanew::infinity; + +struct SiteKey { + Index targetBegin{}; + Index targetEnd{}; + Index queryBegin{}; + Index queryEnd{}; + + friend auto operator<=>(const SiteKey&, const SiteKey&) = default; +}; + +struct OracleSite { + Energy minimumEnergy{infinity}; + double partition{}; + std::size_t structureCount{}; +}; + +using OracleSites = std::map; + +int failures{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAILED: " << description << '\n'; + ++failures; + } +} + +[[nodiscard]] auto close( + const double left, + const double right, + const double relativeTolerance = 1e-11) -> bool { + const auto scale = std::max({1.0, std::abs(left), std::abs(right)}); + return std::abs(left - right) <= relativeTolerance * scale; +} + +[[nodiscard]] auto isStack(const BasePair left, const BasePair right) noexcept -> bool { + return right.target == left.target + 1U && left.query == right.query + 1U; +} + +[[nodiscard]] auto noLonelyPairs(const std::span path) noexcept -> bool { + if (path.size() < 2U) return false; + for (Index index = 0U; index < path.size(); ++index) { + const bool leftStack = index > 0U && isStack(path[index - 1U], path[index]); + const bool rightStack = index + 1U < path.size() && isStack(path[index], path[index + 1U]); + if (!leftStack && !rightStack) return false; + } + return true; +} + +[[nodiscard]] auto siteKey(const std::span path) -> SiteKey { + return { + path.front().target, + path.back().target, + path.back().query, + path.front().query, + }; +} + +// This enumerator intentionally does not reuse any production recurrence or +// energy implementation. For the documented base-pair backend each legal +// intermolecular pair contributes exactly -1 and RT is exactly 1. +[[nodiscard]] auto enumerate( + const Sequence& target, + const Sequence& query, + const Config& config) -> OracleSites { + OracleSites sites; + std::vector path; + + const auto registerPath = [&] { + if (config.output.noLonelyPairs && !noLonelyPairs(path)) return; + const auto energy = -static_cast(path.size()); + auto& site = sites[siteKey(path)]; + site.minimumEnergy = std::min(site.minimumEnergy, energy); + site.partition += std::exp(-energy); // RT=1 for --energy=B + ++site.structureCount; + }; + + std::function extend; + extend = [&](const BasePair previous) { + registerPath(); + const auto targetLast = std::min( + target.size(), previous.target + config.target.interactionLoopMax + 2U); + const auto queryDistanceMax = std::min( + previous.query, config.query.interactionLoopMax + 1U); + for (Index nextTarget = previous.target + 1U; nextTarget < targetLast; ++nextTarget) { + for (Index queryDistance = 1U; queryDistance <= queryDistanceMax; ++queryDistance) { + const Index nextQuery = previous.query - queryDistance; + if (!canPair(target[nextTarget], query[nextQuery])) continue; + if (config.target.interactionLengthMax != 0U && + nextTarget - path.front().target + 1U > config.target.interactionLengthMax) { + continue; + } + if (config.query.interactionLengthMax != 0U && + path.front().query - nextQuery + 1U > config.query.interactionLengthMax) { + continue; + } + path.push_back({nextTarget, nextQuery}); + extend(path.back()); + path.pop_back(); + } + } + }; + + for (Index targetIndex = 0U; targetIndex < target.size(); ++targetIndex) { + for (Index queryIndex = 0U; queryIndex < query.size(); ++queryIndex) { + if (!canPair(target[targetIndex], query[queryIndex])) continue; + path.push_back({targetIndex, queryIndex}); + extend(path.back()); + path.pop_back(); + } + } + return sites; +} + +[[nodiscard]] auto predictionSites(const PredictionResult& result) + -> std::map { + std::map sites; + for (const auto& interaction : result.ensembleSites) { + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + const auto [iterator, inserted] = sites.emplace( + SiteKey{targetRange.begin, targetRange.end, queryRange.begin, queryRange.end}, + &interaction); + static_cast(iterator); + check(inserted, "predictor emits each interaction site once"); + } + return sites; +} + +void compareCase( + const std::string_view targetBases, + const std::string_view queryBases, + const Index targetLoopMax, + const Index queryLoopMax, + const bool rejectLonelyPairs, + const Index targetLengthMax = 0U, + const Index queryLengthMax = 0U, + const InteractionModel model = InteractionModel::singleSite) { + Config config; + config.mode = PredictionMode::exact; + config.model = model; + config.energy = intarnanew::EnergyKind::basePair; + config.seed.required = false; + config.target.accessibility = intarnanew::AccessibilityKind::disabled; + config.query.accessibility = intarnanew::AccessibilityKind::disabled; + config.target.interactionLoopMax = targetLoopMax; + config.query.interactionLoopMax = queryLoopMax; + config.target.interactionLengthMax = targetLengthMax; + config.query.interactionLengthMax = queryLengthMax; + config.output.noLonelyPairs = rejectLonelyPairs; + config.output.number = 1'000U; + config.output.deltaEnergy = 100.0; + config.output.overlap = intarnanew::OverlapPolicy::both; + config.output.maxEnergy = 0.0; + + const Sequence target("target", std::string(targetBases)); + const Sequence query("query", std::string(queryBases)); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + + const auto oracle = enumerate(target, query, config); + const auto prediction = Predictor(config).predict( + target, query, targetAccessibility, queryAccessibility); + const auto observed = predictionSites(prediction); + + check(observed.size() == oracle.size(), "exact predictor finds every enumerated site"); + double expectedPartition{}; + for (const auto& [key, expected] : oracle) { + const auto expectedSitePartition = model == InteractionModel::ensemble + ? expected.partition + : std::exp(-expected.minimumEnergy); + expectedPartition += expectedSitePartition; + const auto found = observed.find(key); + check(found != observed.end(), "enumerated site is present in predictor result"); + if (found == observed.end()) continue; + const auto& interaction = *found->second; + if (model == InteractionModel::ensemble) { + const auto expectedFreeEnergy = -std::log(expected.partition); + const auto expectedReportedEnergy = std::trunc(expectedFreeEnergy * 100.0) / 100.0; + check(close(interaction.energy.total(), expectedReportedEnergy), + "ensemble-model reported energy is centikcal-truncated site free energy"); + } else { + check(close(interaction.energy.total(), expected.minimumEnergy), + "single-site model energy matches independent MFE enumeration"); + } + const auto observedSitePartition = std::exp(-interaction.ensembleFreeEnergy); + check(close(observedSitePartition, expectedSitePartition), + model == InteractionModel::ensemble + ? "ensemble-model site partition matches independent path enumeration" + : "single-site model contributes exactly one MFE weight per site"); + } + + if (expectedPartition == 0.0) { + check(!std::isfinite(prediction.logPartition), "empty oracle has empty predictor partition"); + return; + } + check(close(std::exp(prediction.logPartition), expectedPartition), + "global partition matches independent path enumeration"); + check(close(prediction.ensembleFreeEnergy, -std::log(expectedPartition)), + "global ensemble energy is -RT log(Z) with RT=1"); + for (const auto& [key, expected] : oracle) { + const auto found = observed.find(key); + if (found == observed.end()) continue; + const auto expectedSitePartition = model == InteractionModel::ensemble + ? expected.partition + : std::exp(-expected.minimumEnergy); + check(close(found->second->probability, expectedSitePartition / expectedPartition), + "site probability is normalized by the complete partition"); + } +} +void compareCompactKernel() { + std::uint32_t randomState{0x6d2b79f5U}; + const auto nextRandom = [&]() -> std::uint32_t { + randomState ^= randomState << 13U; + randomState ^= randomState >> 17U; + randomState ^= randomState << 5U; + return randomState; + }; + const auto randomSequence = [&](const Index length) -> std::string { + static constexpr std::string_view alphabet{"ACGU"}; + std::string result(length, 'A'); + for (auto& nucleotide : result) { + nucleotide = alphabet[nextRandom() % alphabet.size()]; + } + return result; + }; + const auto selectedSites = [](const PredictionResult& result) { + std::vector> sites; + sites.reserve(result.interactions.size()); + for (const auto& interaction : result.interactions) { + const auto targetRange = interaction.targetRange(); + const auto queryRange = interaction.queryRange(); + sites.emplace_back( + SiteKey{ + targetRange.begin, targetRange.end, + queryRange.begin, queryRange.end, + }, + interaction.energy.total()); + } + return sites; + }; + + for (Index caseIndex{}; caseIndex < 300U; ++caseIndex) { + const Index targetLength = 3U + nextRandom() % 7U; + const Index queryLength = 3U + nextRandom() % 7U; + const Sequence target("target", randomSequence(targetLength)); + const Sequence query("query", randomSequence(queryLength)); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + + Config complete; + complete.mode = PredictionMode::exact; + complete.model = InteractionModel::singleSite; + complete.energy = intarnanew::EnergyKind::basePair; + complete.seed.required = false; + complete.target.accessibility = intarnanew::AccessibilityKind::disabled; + complete.query.accessibility = intarnanew::AccessibilityKind::disabled; + complete.target.interactionLoopMax = nextRandom() % 5U; + complete.query.interactionLoopMax = nextRandom() % 5U; + if (caseIndex % 17U == 0U) { + complete.target.interactionLengthMax = 0U; + complete.query.interactionLengthMax = 0U; + } else if (caseIndex % 19U == 0U) { + complete.target.interactionLengthMax = 70'000U; + complete.query.interactionLengthMax = 70'000U; + } else { + complete.target.interactionLengthMax = + 1U + nextRandom() % std::min(6U, targetLength); + complete.query.interactionLengthMax = + 1U + nextRandom() % std::min(6U, queryLength); + } + complete.output.number = 1U + nextRandom() % 6U; + complete.output.overlap = intarnanew::OverlapPolicy::both; + complete.output.deltaEnergy = static_cast(nextRandom() % 5U); + complete.output.maxEnergy = -static_cast(nextRandom() % 3U); + complete.additiveEnergy = + static_cast(static_cast(nextRandom() % 9U) - 4) * 0.25; + + auto compact = complete; + compact.predictionRequirements.retainAllSites = false; + compact.predictionRequirements.computeInteractionPartition = false; + compact.predictionRequirements.traceback = false; + + const auto expected = Predictor(complete).predict( + target, query, targetAccessibility, queryAccessibility); + const auto observed = Predictor(compact).predict( + target, query, targetAccessibility, queryAccessibility); + const auto expectedSites = selectedSites(expected); + const auto observedSites = selectedSites(observed); + check(observedSites.size() == expectedSites.size(), + "compact predictor returns the same selected-site count"); + const auto common = std::min(observedSites.size(), expectedSites.size()); + for (Index index{}; index < common; ++index) { + check(std::get<0>(observedSites[index]) == std::get<0>(expectedSites[index]), + "compact predictor preserves selected-site ordering and boundaries"); + check(close(std::get<1>(observedSites[index]), std::get<1>(expectedSites[index])), + "compact predictor preserves selected-site energy"); + } + check(observed.ensembleSites.empty(), + "selected-only compact prediction does not materialize the site ensemble"); + check(!std::isfinite(observed.logPartition), + "selected-only compact prediction skips the unrequested partition"); + } + const auto completeConfig = [] { + Config config; + config.mode = PredictionMode::exact; + config.model = InteractionModel::singleSite; + config.energy = intarnanew::EnergyKind::basePair; + config.seed.required = false; + config.target.accessibility = intarnanew::AccessibilityKind::disabled; + config.query.accessibility = intarnanew::AccessibilityKind::disabled; + config.target.interactionLengthMax = 0U; + config.query.interactionLengthMax = 0U; + config.output.number = 20U; + config.output.overlap = intarnanew::OverlapPolicy::both; + config.output.deltaEnergy = 100.0; + return config; + }; + const auto selectedOnly = [](Config config) { + config.predictionRequirements.retainAllSites = false; + config.predictionRequirements.computeInteractionPartition = false; + config.predictionRequirements.traceback = false; + return config; + }; + const auto sameSelection = [&](const PredictionResult& expected, + const PredictionResult& observed) { + const auto expectedSites = selectedSites(expected); + const auto observedSites = selectedSites(observed); + if (expectedSites.size() != observedSites.size()) return false; + for (Index index{}; index < expectedSites.size(); ++index) { + if (std::get<0>(expectedSites[index]) != std::get<0>(observedSites[index]) || + !close(std::get<1>(expectedSites[index]), std::get<1>(observedSites[index]))) { + return false; + } + } + return true; + }; + + { + const Sequence target("target", "CCCC"); + const Sequence query("query", "GGGG"); + const DisabledAccessibility targetAccessibility(target, "..b."); + const DisabledAccessibility queryAccessibility(query); + const auto complete = completeConfig(); + const auto compact = selectedOnly(complete); + const auto domain = intarnanew::Interval{0U, 3U}; + const auto expected = Predictor(complete).predict( + target, query, targetAccessibility, queryAccessibility, domain, domain); + const auto observed = Predictor(compact).predict( + target, query, targetAccessibility, queryAccessibility, domain, domain); + check(sameSelection(expected, observed), + "compact eligibility honors the actual accessibility providers"); + check(!observed.ensembleSites.empty(), + "blocked accessibility safely falls back to the full predictor"); + } + + { + const Sequence target("target", "CCCCCCC"); + const Sequence query("query", "GGG"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + auto complete = completeConfig(); + complete.target.regions = "1-3,5-7"; + const auto compact = selectedOnly(complete); + const auto expected = Predictor(complete).predict( + target, query, targetAccessibility, queryAccessibility); + const auto observed = Predictor(compact).predict( + target, query, targetAccessibility, queryAccessibility); + check(sameSelection(expected, observed), + "selected-only requirements preserve multi-region reduction"); + check(!observed.ensembleSites.empty(), + "multi-region prediction safely retains reducible site results"); + } + + { + const Sequence target("target", "CCCC"); + const Sequence query("query", "GGGG"); + const DisabledAccessibility targetAccessibility(target); + const DisabledAccessibility queryAccessibility(query); + auto complete = completeConfig(); + complete.target.interactionLengthMax = 70'000U; + complete.query.interactionLengthMax = 70'000U; + complete.additiveEnergy = 1e300; + complete.output.maxEnergy = infinity; + complete.output.deltaEnergy = 0.0; + complete.output.number = std::numeric_limits::max(); + const auto compact = selectedOnly(complete); + const auto expected = Predictor(complete).predict( + target, query, targetAccessibility, queryAccessibility); + const auto observed = Predictor(compact).predict( + target, query, targetAccessibility, queryAccessibility); + check(sameSelection(expected, observed), + "compact ranking matches generic floating-energy tie semantics"); + check(observed.ensembleSites.empty(), + "actual finite domains enable compact prediction for oversized limits"); + } +} + +} // namespace + +auto main() -> int { + compareCase("CCAC", "GGUG", 0U, 0U, false); + compareCase("CCAUC", "GGAUG", 2U, 2U, false); + compareCase("CCACCC", "GGUGG", 4U, 3U, false); + compareCase("CCAUC", "GGAUG", 3U, 3U, true); + compareCase("CCACCC", "GGUGG", 4U, 4U, true); + compareCase("CCACCC", "GGUGG", 4U, 4U, false, 3U, 3U); + + compareCase("CCAC", "GGUG", 0U, 0U, false, 0U, 0U, InteractionModel::ensemble); + compareCase("CCAUC", "GGAUG", 2U, 2U, false, 0U, 0U, InteractionModel::ensemble); + compareCase("CCACCC", "GGUGG", 4U, 4U, true, 0U, 0U, InteractionModel::ensemble); + + compareCompactKernel(); + if (failures == 0) { + std::cout << "All independent predictor-oracle tests passed.\n"; + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/IntaRNAnew/tests/pvalue_executable.cpp b/IntaRNAnew/tests/pvalue_executable.cpp new file mode 100644 index 0000000..88f62fe --- /dev/null +++ b/IntaRNAnew/tests/pvalue_executable.cpp @@ -0,0 +1,244 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +struct Result { + int exitCode{}; + std::string output; + std::string diagnostics; +}; + +[[nodiscard]] auto temporaryFile(const std::string_view label) + -> std::expected, std::string> { + std::error_code error; + const auto directory = std::filesystem::temp_directory_path(error); + if (error) return std::unexpected("cannot locate temporary directory: " + error.message()); + auto pattern = (directory / ("intarnanew-pvalue-" + std::string(label) + "-XXXXXX")).string(); + std::vector bytes(pattern.begin(), pattern.end()); + bytes.push_back('\0'); + const int descriptor = ::mkstemp(bytes.data()); + if (descriptor < 0) { + return std::unexpected("cannot create temporary capture: " + + std::error_code(errno, std::generic_category()).message()); + } + return std::pair{descriptor, std::filesystem::path{bytes.data()}}; +} + +[[nodiscard]] auto readFile(const std::filesystem::path& path) + -> std::expected { + std::ifstream input(path, std::ios::binary); + if (!input) return std::unexpected("cannot open captured output"); + std::string bytes(std::istreambuf_iterator{input}, std::istreambuf_iterator{}); + if (input.bad()) return std::unexpected("cannot read captured output"); + return bytes; +} + +[[nodiscard]] auto temporaryInput( + const std::string_view label, + const std::string_view contents) -> std::expected { + auto file = temporaryFile(label); + if (!file) return std::unexpected(file.error()); + ::close(file->first); + std::ofstream output(file->second, std::ios::binary | std::ios::trunc); + output.write(contents.data(), static_cast(contents.size())); + output.close(); + if (!output) { + std::error_code ignored; + std::filesystem::remove(file->second, ignored); + return std::unexpected("cannot write temporary p-value input"); + } + return std::move(file->second); +} + +[[nodiscard]] auto run( + const std::filesystem::path& executable, + std::vector arguments) -> std::expected { + auto output = temporaryFile("stdout"); + if (!output) return std::unexpected(output.error()); + auto diagnostics = temporaryFile("stderr"); + if (!diagnostics) { + ::close(output->first); + std::error_code ignored; + std::filesystem::remove(output->second, ignored); + return std::unexpected(diagnostics.error()); + } + arguments.insert(arguments.begin(), executable.string()); + std::vector argv; + argv.reserve(arguments.size() + 1U); + for (auto& argument : arguments) argv.push_back(argument.data()); + argv.push_back(nullptr); + + const auto child = ::fork(); + if (child < 0) { + ::close(output->first); + ::close(diagnostics->first); + std::error_code ignored; + std::filesystem::remove(output->second, ignored); + std::filesystem::remove(diagnostics->second, ignored); + return std::unexpected("cannot fork p-value executable test"); + } + if (child == 0) { + if (::dup2(output->first, STDOUT_FILENO) < 0 || + ::dup2(diagnostics->first, STDERR_FILENO) < 0) { + ::_exit(126); + } + ::close(output->first); + ::close(diagnostics->first); + ::execv(arguments.front().c_str(), argv.data()); + ::_exit(127); + } + ::close(output->first); + ::close(diagnostics->first); + int status{}; + pid_t waited{}; + bool timedOut{}; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60); + do { + waited = ::waitpid(child, &status, WNOHANG); + if (waited == 0 && std::chrono::steady_clock::now() >= deadline) { + timedOut = true; + static_cast(::kill(child, SIGKILL)); + do { + waited = ::waitpid(child, &status, 0); + } while (waited < 0 && errno == EINTR); + break; + } + if (waited == 0) ::usleep(1'000U); + } while (waited == 0 || (waited < 0 && errno == EINTR)); + auto capturedOutput = readFile(output->second); + auto capturedDiagnostics = readFile(diagnostics->second); + std::error_code ignored; + std::filesystem::remove(output->second, ignored); + std::filesystem::remove(diagnostics->second, ignored); + if (waited < 0) return std::unexpected("cannot wait for p-value executable test"); + if (timedOut) return std::unexpected("p-value executable timed out after 60 seconds"); + if (!capturedOutput) return std::unexpected(capturedOutput.error()); + if (!capturedDiagnostics) return std::unexpected(capturedDiagnostics.error()); + const int exitCode = WIFEXITED(status) ? WEXITSTATUS(status) : 128; + return Result{exitCode, std::move(*capturedOutput), std::move(*capturedDiagnostics)}; +} + +[[nodiscard]] auto lines(const std::string_view text) -> std::size_t { + return static_cast(std::ranges::count(text, '\n')); +} + +[[nodiscard]] auto finiteProbability(const std::string_view text) -> bool { + double value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + const auto trailing = std::string_view{end, static_cast(text.data() + text.size() - end)}; + return error == std::errc{} && std::isfinite(value) && value >= 0.0 && value <= 1.0 && + (trailing.empty() || trailing == "\n"); +} + +} // namespace + +auto main(const int argc, char** argv) -> int { + if (argc != 2) { + std::cerr << "usage: IntaRNAnewPvalueExecutableTests PATH\n"; + return 2; + } + const std::filesystem::path executable{argv[1]}; + int failures{}; + const auto require = [&](const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAIL: " << description << '\n'; + ++failures; + } + }; + + auto help = run(executable, {"--help"}); + require(help && help->exitCode == 0 && help->output.starts_with("intarnanew-pvalue") && + help->output.find("--cardinality") != std::string::npos, + "help succeeds and identifies the executable"); + + auto parameterFile = temporaryInput("parameters", + "energy=B\nacc=N\nnoSeed=true\nmode=M\nmodel=S\n" + "intLenMax=12\noutNumber=1\n"); + auto queryFasta = temporaryInput("query-fasta", + ">query-first\nAACCGGUUAUCG\n>query-ignored\nAAAA\n"); + auto targetFasta = temporaryInput("target-fasta", + ">target-first\nGCUAACCGGAUU\n>target-ignored\nUUUU\n"); + const auto removeInputs = [&] { + std::error_code ignored; + if (parameterFile) std::filesystem::remove(*parameterFile, ignored); + if (queryFasta) std::filesystem::remove(*queryFasta, ignored); + if (targetFasta) std::filesystem::remove(*targetFasta, ignored); + }; + if (!parameterFile || !queryFasta || !targetFasta) { + std::cerr << "cannot create p-value contract input: " + << (!parameterFile ? parameterFile.error() + : (!queryFasta ? queryFasta.error() : targetFasta.error())) << '\n'; + removeInputs(); + return 2; + } + const auto parameterOption = "--parameterFile=" + parameterFile->string(); + + const std::vector common{ + "--query=AACCGGUUAUCG", "--target=GCUAACCGGAUU", "--samples=32", + "--shuffle-mode=b", "--randSeed=42", "--output=scores", parameterOption}; + auto oneArguments = common; + oneArguments.push_back("--threads=1"); + auto fourArguments = common; + fourArguments.push_back("--threads=4"); + auto oneThread = run(executable, std::move(oneArguments)); + auto fourThreads = run(executable, std::move(fourArguments)); + require(oneThread && fourThreads && oneThread->exitCode == 0 && fourThreads->exitCode == 0, + "score-mode executions succeed"); + require(oneThread && fourThreads && oneThread->output == fourThreads->output, + "score output is byte-identical across one and four threads"); + require(oneThread && lines(oneThread->output) == 32U, + "score output contains the requested sample count"); + + auto probability = run(executable, { + "--query=AACCGGUUAUCG", "--target=GCUAACCGGAUU", "--samples=80", + "--shuffle-mode=b", "--randSeed=42", "--threads=4", + "--distribution=gauss", "--output=pvalue", parameterOption}); + require(probability && probability->exitCode == 0 && finiteProbability(probability->output), + "p-value mode emits one finite probability in [0,1]"); + + auto fasta = run(executable, { + "--query=" + queryFasta->string(), "--target=" + targetFasta->string(), + "--cardinality=8", "--shuffle-mode=b", "--randSeed=42", "--threads=2", + "--output=scores", parameterOption}); + auto literal = run(executable, { + "--query=AACCGGUUAUCG", "--target=GCUAACCGGAUU", "--samples=8", + "--shuffle-mode=b", "--randSeed=42", "--threads=2", + "--output=scores", parameterOption}); + require(fasta && literal && fasta->exitCode == 0 && literal->exitCode == 0 && + fasta->output == literal->output && lines(fasta->output) == 8U, + "FASTA first-record input and --cardinality match literal --samples output"); + + auto malformed = run(executable, { + "--query=A", "--target=U", "--samples=0"}); + require(malformed && malformed->exitCode == 2 && + malformed->diagnostics.find("samples must be positive") != std::string::npos, + "malformed sample count fails with a diagnostic"); + + removeInputs(); + + if (failures == 0) std::cout << "All p-value executable contract tests passed.\n"; + return failures == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/runner_contracts.cpp b/IntaRNAnew/tests/runner_contracts.cpp new file mode 100644 index 0000000..8edba41 --- /dev/null +++ b/IntaRNAnew/tests/runner_contracts.cpp @@ -0,0 +1,74 @@ +#include "intarnanew/runner.hpp" + +#include +#include +#include + +namespace { + +int failures{}; + +void check(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAIL: " << description << '\n'; + ++failures; + } +} + +[[nodiscard]] auto basePairConfig() -> intarnanew::Config { + intarnanew::Config config; + config.energy = intarnanew::EnergyKind::basePair; + config.mode = intarnanew::PredictionMode::exact; + config.model = intarnanew::InteractionModel::singleSite; + config.seed.required = false; + config.target.accessibility = intarnanew::AccessibilityKind::disabled; + config.query.accessibility = intarnanew::AccessibilityKind::disabled; + config.output.number = 20U; + config.output.overlap = intarnanew::OverlapPolicy::both; + config.output.deltaEnergy = 100.0; + return config; +} + +} // namespace + +auto main() -> int { + const intarnanew::Sequence target{"target", "CCAACACC"}; + const intarnanew::Sequence query{"query", "GG"}; + auto config = basePairConfig(); + auto evaluated = intarnanew::predictPair(config, target, query); + check(evaluated.has_value(), "whole-pair evaluation succeeds"); + if (evaluated) { + check(!evaluated->prediction.interactions.empty(), + "whole-pair evaluation returns favorable interactions"); + check(evaluated->prediction.rt == 1.0, + "base-pair runner preserves the model RT contract"); + check(evaluated->targetAccessibility->openingEnergy({0U, 1U}) == 0.0 && + evaluated->queryAccessibility->unpairedProbability({0U, 1U}) == 1.0, + "runner retains its native accessibility providers"); + check(std::isfinite(evaluated->prediction.targetEnsembleFreeEnergy) && + std::isfinite(evaluated->prediction.queryEnsembleFreeEnergy), + "runner attaches whole-sequence monomer summaries"); + } + + config.target.regions = "2-7"; + auto restricted = intarnanew::predictPair(config, target, query); + check(restricted.has_value(), "explicit-region pair evaluation succeeds"); + if (restricted) { + bool withinRegion = true; + for (const auto& interaction : restricted->prediction.interactions) { + const auto range = interaction.targetRange(); + withinRegion = withinRegion && range.begin >= 1U && range.end <= 6U; + } + check(withinRegion, "runner applies configured external-coordinate regions"); + } + + config.target.regions = "100-101"; + auto invalid = intarnanew::predictPair(config, target, query); + check(!invalid && invalid.error().find("target regions") != std::string::npos, + "runner returns contextual region failures instead of throwing"); + + if (failures == 0) { + std::cout << "All in-process pair-runner contract tests passed.\n"; + } + return failures == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tests/tools_contracts.cpp b/IntaRNAnew/tests/tools_contracts.cpp new file mode 100644 index 0000000..1a4ab0f --- /dev/null +++ b/IntaRNAnew/tests/tools_contracts.cpp @@ -0,0 +1,376 @@ +#include "intarnanew/sequence.hpp" +#include "intarnanew/tools/csv.hpp" +#include "intarnanew/tools/mutations.hpp" +#include "intarnanew/tools/pvalue.hpp" +#include "intarnanew/tools/statistics.hpp" +#include "intarnanew/tools/svg.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int failures{}; + +void require(const bool condition, const std::string_view description) { + if (!condition) { + std::cerr << "FAIL: " << description << '\n'; + ++failures; + } +} + +void close(const double observed, const double expected, const double tolerance, + const std::string_view description) { + require(std::abs(observed - expected) <= tolerance, description); +} + +[[nodiscard]] auto dinucleotides(const std::string_view sequence) + -> std::map { + std::map result; + for (std::size_t index = 0U; index + 1U < sequence.size(); ++index) { + ++result[std::string{sequence.substr(index, 2U)}]; + } + return result; +} + +[[nodiscard]] auto sorted(std::string text) -> std::string { + std::ranges::sort(text); + return text; +} + +void csvContracts() { + std::istringstream input{ + "id;note;E\r\n" + "a;\"semi;colon\";-2.5\r\n" + "b;\"two\nlines and \"\"quote\"\"\";-1\r\n"}; + auto parsed = intarnanew::tools::readCsv(input); + require(parsed.has_value(), "CSV parses RFC-style quoted fields and CRLF"); + if (!parsed) return; + require(parsed->separator == ';', "CSV separator auto-detection"); + require(parsed->rows.size() == 2U, "CSV record count"); + require(parsed->rows[0][1] == "semi;colon", "CSV embedded separator"); + require(parsed->rows[1][1] == "two\nlines and \"quote\"", "CSV multiline/quote unescaping"); + auto serialized = intarnanew::tools::csvText(*parsed); + require(serialized.has_value(), "CSV serialization succeeds"); + std::istringstream roundtrip{serialized.value_or("")}; + auto reparsed = intarnanew::tools::readCsv(roundtrip); + require(reparsed.has_value() && reparsed->rows == parsed->rows, + "CSV parse/write round trip preserves fields"); + + intarnanew::tools::CsvTable second{ + .header = {"id", "probability"}, + .rows = {{"c", "0.1"}}, + .separator = ';'}; + std::array tables{*parsed, second}; + const std::array labels{std::string{"first"}, std::string{"second"}}; + auto fused = intarnanew::tools::fuseCsv( + tables, labels, {.sourceColumn = "source", .deduplicate = false}); + require(fused.has_value(), "CSV schema-union fusion succeeds"); + if (fused) { + require(fused->header == std::vector{"id", "note", "E", "probability", "source"}, + "CSV schema union has stable first-occurrence order"); + require(fused->rows[2] == std::vector{"c", "", "", "0.1", "second"}, + "CSV fusion fills absent fields and source label"); + } + + intarnanew::tools::CsvTable pathological{ + .header = {"value"}, + .rows = {{""}, {"comma,value"}, {"tab\tvalue"}, {"line\r\nvalue"}, + {"quote\"value"}, {"UTF-8 π"}}, + .separator = ';'}; + auto pathologicalText = intarnanew::tools::csvText(pathological); + std::istringstream pathologicalInput{pathologicalText.value_or("")}; + auto pathologicalRoundtrip = intarnanew::tools::readCsv(pathologicalInput); + require(pathologicalRoundtrip.has_value() && + pathologicalRoundtrip->rows == pathological.rows, + "CSV round trip preserves empty, control, quoted, and UTF-8 fields"); + std::istringstream duplicateHeader{"id;id\na;b\n"}; + require(!intarnanew::tools::readCsv(duplicateHeader), + "CSV parser rejects duplicate column names"); + std::istringstream unterminated{"id;note\na;\"unfinished\n"}; + require(!intarnanew::tools::readCsv(unterminated), + "CSV parser rejects unterminated quoted fields"); + intarnanew::tools::CsvTable malformed{ + .header = {"a", "a"}, .rows = {{"1", "2"}}, .separator = ';'}; + const std::array malformedTables{malformed}; + require(!intarnanew::tools::fuseCsv(malformedTables), + "CSV fusion rejects malformed programmatic tables"); + std::ostringstream malformedOutput; + require(!intarnanew::tools::writeCsv(malformed, malformedOutput), + "CSV writer rejects malformed programmatic tables"); + require(!intarnanew::tools::parseFiniteNumber("1.2x") && + !intarnanew::tools::parseFiniteNumber("nan"), + "numeric CSV conversion rejects trailing text and non-finite values"); +} + +void statisticContracts() { + const std::array gaussian{-2.0, -1.0, 0.0, 1.0, 2.0}; + auto fit = intarnanew::tools::fitDistribution( + gaussian, intarnanew::tools::DistributionKind::gaussian); + require(fit.has_value(), "Gaussian fit succeeds"); + if (fit) { + close(fit->location, 0.0, 1.0e-14, "Gaussian MLE location"); + close(fit->scale, std::sqrt(2.0), 1.0e-14, "Gaussian population MLE scale"); + auto probability = intarnanew::tools::interactionEnergyPValue(0.0, *fit); + require(probability.has_value(), "Gaussian CDF evaluation succeeds"); + if (probability) close(*probability, 0.5, 1.0e-14, "Gaussian CDF at location"); + } + const intarnanew::tools::DistributionFit gumbel{ + .kind = intarnanew::tools::DistributionKind::gumbel, + .location = 3.0, + .scale = 2.0}; + auto gumbelAtLocation = intarnanew::tools::cumulativeProbability(3.0, gumbel); + require(gumbelAtLocation.has_value(), "Gumbel CDF evaluation succeeds"); + if (gumbelAtLocation) close(*gumbelAtLocation, std::exp(-1.0), 1.0e-14, + "Gumbel CDF at location"); + auto stableUpper = intarnanew::tools::tailProbability( + 203.0, gumbel, intarnanew::tools::ProbabilityTail::upper); + require(stableUpper.has_value() && *stableUpper > 0.0, + "Gumbel upper tail remains nonzero when 1-CDF would cancel"); + const intarnanew::tools::DistributionFit gev{ + .kind = intarnanew::tools::DistributionKind::gev, + .location = -4.0, + .scale = 1.5, + .shape = 0.2}; + auto gevAtLocation = intarnanew::tools::cumulativeProbability(-4.0, gev); + require(gevAtLocation.has_value(), "GEV CDF evaluation succeeds"); + if (gevAtLocation) close(*gevAtLocation, std::exp(-1.0), 1.0e-14, + "GEV CDF at location"); + + std::vector gumbelQuantiles; + for (std::size_t index = 0U; index < 200U; ++index) { + const double probability = (static_cast(index) + 0.5) / 200.0; + gumbelQuantiles.push_back(-3.0 - 2.0 * std::log(-std::log(probability))); + } + auto fittedGumbel = intarnanew::tools::fitDistribution( + gumbelQuantiles, intarnanew::tools::DistributionKind::gumbel); + require(fittedGumbel.has_value() && fittedGumbel->converged, + "Gumbel MLE converges on deterministic population quantiles"); + if (fittedGumbel) { + close(fittedGumbel->location, -3.0, 0.05, "Gumbel MLE recovers location"); + close(fittedGumbel->scale, 2.0, 0.05, "Gumbel MLE recovers scale"); + } + std::vector gevQuantiles; + constexpr double gevLocation = -2.0; + constexpr double gevScale = 1.2; + constexpr double gevShape = 0.15; + for (std::size_t index = 0U; index < 300U; ++index) { + const double probability = (static_cast(index) + 0.5) / 300.0; + const double transformed = std::pow(-std::log(probability), -gevShape); + gevQuantiles.push_back(gevLocation + gevScale * (transformed - 1.0) / gevShape); + } + auto fittedGev = intarnanew::tools::fitDistribution( + gevQuantiles, intarnanew::tools::DistributionKind::gev); + require(fittedGev.has_value() && fittedGev->converged, + "GEV MLE converges on deterministic population quantiles"); + if (fittedGev) { + close(fittedGev->location, gevLocation, 0.06, "GEV MLE recovers location"); + close(fittedGev->scale, gevScale, 0.06, "GEV MLE recovers scale"); + close(fittedGev->shape, gevShape, 0.04, "GEV MLE recovers shape"); + } + + const std::array background{-7.0, -6.0, -5.0, -4.0}; + auto empirical = intarnanew::tools::empiricalInteractionPValue(-5.5, background); + require(empirical.has_value(), "empirical p-value succeeds"); + if (empirical) close(*empirical, 3.0 / 5.0, 1.0e-14, "plus-one empirical lower tail"); + + const std::array probabilities{0.01, 0.04, 0.03, 0.002}; + auto bonferroni = intarnanew::tools::adjustPValues( + probabilities, intarnanew::tools::AdjustmentMethod::bonferroni); + require(bonferroni.has_value(), "Bonferroni adjustment succeeds"); + if (bonferroni) require(*bonferroni == std::vector{0.04, 0.16, 0.12, 0.008}, + "Bonferroni exact values"); + auto holm = intarnanew::tools::adjustPValues( + probabilities, intarnanew::tools::AdjustmentMethod::holm); + require(holm.has_value(), "Holm adjustment succeeds"); + if (holm) require(*holm == std::vector{0.03, 0.06, 0.06, 0.008}, + "Holm exact values"); + auto bh = intarnanew::tools::adjustPValues( + probabilities, intarnanew::tools::AdjustmentMethod::benjaminiHochberg); + require(bh.has_value(), "Benjamini-Hochberg adjustment succeeds"); + if (bh) require(*bh == std::vector{0.02, 0.04, 0.04, 0.008}, + "Benjamini-Hochberg exact values"); + auto hochberg = intarnanew::tools::adjustPValues( + probabilities, intarnanew::tools::AdjustmentMethod::hochberg); + require(hochberg.has_value() && + *hochberg == std::vector{0.03, 0.04, 0.04, 0.008}, + "Hochberg exact values"); + auto by = intarnanew::tools::adjustPValues( + probabilities, intarnanew::tools::AdjustmentMethod::benjaminiYekutieli); + require(by.has_value(), "Benjamini-Yekutieli adjustment succeeds"); + if (by) { + close((*by)[0], 1.0 / 24.0, 1.0e-14, "BY first adjusted value"); + close((*by)[1], 1.0 / 12.0, 1.0e-14, "BY second adjusted value"); + close((*by)[2], 1.0 / 12.0, 1.0e-14, "BY third adjusted value"); + close((*by)[3], 1.0 / 60.0, 1.0e-14, "BY fourth adjusted value"); + } + const std::array invalid{0.1, -0.1}; + require(!intarnanew::tools::adjustPValues( + invalid, intarnanew::tools::AdjustmentMethod::holm), + "invalid p-values are rejected"); +} + +void mutationContracts() { + const intarnanew::Sequence query{"q", "GCAU", 1}; + const intarnanew::Sequence target{"t", "CGUA", 10}; + const std::array pairs{intarnanew::BasePair{.target = 0U, .query = 0U}}; + auto flipped = intarnanew::tools::enumerateMutations( + query, target, pairs, intarnanew::tools::MutationGenerator::flip); + require(flipped.has_value() && flipped->size() == 1U, "flip mutation enumeration"); + if (flipped && !flipped->empty()) { + require(flipped->front().encoding(query, target) == "G1C&C10G", + "mutation external-coordinate encoding"); + auto sequences = intarnanew::tools::applyMutation(query, target, flipped->front()); + require(sequences.has_value() && sequences->mutatedQuery == "CCAU" && + sequences->mutatedTarget == "GGUA", + "mutation application yields four-combination inputs"); + } + auto parsed = intarnanew::tools::parseMutationEncoding("G1C&C10G", query, target); + require(parsed.has_value(), "explicit mutation parsing succeeds"); + if (parsed && flipped && !flipped->empty()) { + require(*parsed == flipped->front(), "explicit mutation parsing round trip"); + } + auto any = intarnanew::tools::enumerateMutations( + query, target, pairs, intarnanew::tools::MutationGenerator::any); + require(any.has_value() && any->size() == 4U, + "any generator emits every pairing combination mutating both bases"); + const std::array filter{intarnanew::tools::CandidateFilter::removeCg}; + auto filtered = intarnanew::tools::enumerateMutations( + query, target, pairs, intarnanew::tools::MutationGenerator::any, filter); + require(filtered.has_value() && filtered->empty(), "pair-class candidate filter"); + if (flipped && !flipped->empty()) { + auto invalidMutation = flipped->front(); + invalidMutation.mutatedTarget = invalidMutation.wildTarget; + require(!intarnanew::tools::applyMutation(query, target, invalidMutation), + "manually constructed non-compensatory mutation is rejected"); + } + + const std::string input{"AACCGGUUAACCGG"}; + auto mono = intarnanew::tools::shuffleMononucleotides(input, 42U); + require(mono.has_value() && sorted(*mono) == sorted(input), + "mononucleotide shuffle preserves exact composition"); + require(mono == intarnanew::tools::shuffleMononucleotides(input, 42U), + "mononucleotide shuffle is seed-deterministic"); + auto di = intarnanew::tools::shuffleDinucleotides(input, 42U); + require(di.has_value() && dinucleotides(*di) == dinucleotides(input), + "dinucleotide shuffle preserves directed dinucleotide counts"); + require(di.has_value() && di->front() == input.front() && di->back() == input.back(), + "dinucleotide shuffle preserves trail endpoints"); + + constexpr std::array bases{'A', 'C', 'G', 'U'}; + bool exhaustiveInvariant = true; + std::size_t sequenceCount = 1U; + for (std::size_t length = 1U; length <= 6U && exhaustiveInvariant; ++length) { + sequenceCount *= bases.size(); + for (std::size_t encoded = 0U; encoded < sequenceCount && exhaustiveInvariant; ++encoded) { + auto remaining = encoded; + std::string sequence(length, 'A'); + for (std::size_t position = 0U; position < length; ++position) { + sequence[position] = bases[remaining % bases.size()]; + remaining /= bases.size(); + } + for (std::uint64_t seed = 0U; seed < 3U; ++seed) { + auto shuffled = intarnanew::tools::shuffleDinucleotides(sequence, seed); + if (!shuffled || shuffled->size() != sequence.size() || + shuffled->front() != sequence.front() || shuffled->back() != sequence.back() || + dinucleotides(*shuffled) != dinucleotides(sequence)) { + exhaustiveInvariant = false; + break; + } + } + } + } + require(exhaustiveInvariant, + "dinucleotide shuffle invariants hold exhaustively through length six"); +} + +void pvalueOrchestrationContracts() { + const intarnanew::tools::RandomScoreOptions single{ + .cardinality = 16U, + .mode = intarnanew::tools::ShuffleMode::both, + .preservation = intarnanew::tools::ShufflePreservation::mononucleotide, + .randomSeed = 901U, + .threads = 1U}; + auto evaluator = [](const std::string_view query, const std::string_view target) + -> std::expected { + double score{}; + for (std::size_t index = 0; index < std::min(query.size(), target.size()); ++index) { + score -= query[index] == target[index] ? 1.0 : 0.0; + } + return score; + }; + auto oneThread = intarnanew::tools::sampleRandomInteractionScores( + "AACCGGUU", "GGUUCCAA", single, evaluator); + auto parallel = single; + parallel.threads = 4U; + auto fourThreads = intarnanew::tools::sampleRandomInteractionScores( + "AACCGGUU", "GGUUCCAA", parallel, evaluator); + require(oneThread.has_value() && fourThreads.has_value() && *oneThread == *fourThreads, + "random-score samples are invariant to thread count"); +} + +void svgContracts() { + const std::array profile{ + intarnanew::tools::ProfilePoint{1.0, -1.0}, + intarnanew::tools::ProfilePoint{2.0, std::nullopt}, + intarnanew::tools::ProfilePoint{3.0, -2.0}}; + intarnanew::tools::ProfileSvgOptions profileOptions; + profileOptions.title = "A&B "; + auto first = intarnanew::tools::profileSvg(profile, profileOptions); + auto second = intarnanew::tools::profileSvg(profile, profileOptions); + require(first.has_value() && first == second, "profile SVG is deterministic"); + if (first) { + require(first->find("A&B <profile>") != std::string::npos, + "profile SVG escapes XML text"); + const auto firstPolyline = first->find("find("find("find("q1 × t1: -3") != std::string::npos && + svg->find("q2 × t1: 0") != std::string::npos && + svg->find("q1 × t2: NA") != std::string::npos, + "heatmap values, positive clamp, and missing cells are explicit"); + auto invalidOptions = profileOptions; + invalidOptions.width = 100U; + require(!intarnanew::tools::profileSvg(profile, invalidOptions), + "SVG rejects unusably small dimensions"); + const std::array regions{ + intarnanew::tools::RegionSpan{"target&A", -5, 3}, + intarnanew::tools::RegionSpan{"targetB", 10, 14}}; + auto regionSvg = intarnanew::tools::regionsSvg(regions); + require(regionSvg.has_value() && + regionSvg->find("target&A: -5–3") != std::string::npos, + "region SVG preserves coordinates and escapes identifiers"); + const std::array invalidRegions{intarnanew::tools::RegionSpan{"bad", 4, 2}}; + require(!intarnanew::tools::regionsSvg(invalidRegions), + "region SVG rejects reversed spans"); +} + +} // namespace + +int main() { + csvContracts(); + statisticContracts(); + mutationContracts(); + pvalueOrchestrationContracts(); + svgContracts(); + if (failures == 0) { + std::cout << "All native companion-tool contract tests passed.\n"; + } + return failures == 0 ? 0 : 1; +} diff --git a/IntaRNAnew/tools/README.md b/IntaRNAnew/tools/README.md new file mode 100644 index 0000000..be2140d --- /dev/null +++ b/IntaRNAnew/tools/README.md @@ -0,0 +1,126 @@ +# IntaRNAnew native companion tools + +These utilities are C++23 implementations with no Python, R, Perl, shell, or +external-process runtime. Their reusable APIs live below +`include/intarnanew/tools/`. They fail on malformed data instead of silently +dropping rows or coercing values. + +## `intarnanew-stats` + +`fit` estimates Gaussian, Gumbel, or generalized-extreme-value parameters from +a numeric CSV column. Gaussian fitting is the closed-form population maximum +likelihood estimate. Gumbel and GEV fitting minimize negative log likelihood +with a deterministic multi-start Nelder-Mead search; GEV shape is constrained +to `[-1,1]`. The fit reports whether the numerical convergence tolerance was +reached. + +`pvalue` appends the lower-tail probability `P(X <= E)`. This is the relevant +tail for IntaRNA energy because a more-negative energy is a better score. +`--empirical` instead uses the explicitly specified plus-one estimator + +``` +(number of background scores <= E + 1) / (background size + 1). +``` + +`adjust` implements Bonferroni, Holm, Hochberg, Benjamini-Hochberg, and +Benjamini-Yekutieli adjustment. Results remain in input order, and original row +order breaks equal-p-value ties deterministically. + +Examples: + +``` +intarnanew-stats fit -i interactions.csv -c E -d gev +intarnanew-stats pvalue -i interactions.csv -o with-p.csv -c E -d gev +intarnanew-stats adjust -i with-p.csv -c p-value -m bh -o adjusted.csv +``` + +The API in `pvalue.hpp` adds deterministic mono- or exact directed- +dinucleotide-preserving shuffling and parallel score sampling. It takes a C++ +score callback, so a prediction-library runner can be connected without +forking an `IntaRNA` executable. Sampling order and generated sequences do not +depend on thread count. + +## `intarnanew-pvalue` + +`intarnanew-pvalue` connects this API to the native predictor in-process. It +accepts literal RNAs or the first record of FASTA/gzip inputs, a positive sample +count (`--samples`, `--cardinality`, or `--scores`), shuffle side, optional +prediction parameter file, deterministic seed, distribution, and thread count. +`--output scores` prints the sampled energies in stable sample order; +`--output pvalue` prints the fitted lower-tail probability. + +``` +intarnanew-pvalue -q AACCGGUU -t GGUUCCAA -s 100 -m b \ + --randSeed 42 --threads 4 --output pvalue +``` + +## `intarnanew-csv` + +This is a robust interaction-table parser and fusion utility. It supports CSV, +TSV, quoted separators, quoted newlines, and escaped quotes. Fusion produces a +stable union of the schemas in first-occurrence order, preserves row order, and +leaves fields empty when an input did not provide a column. + +``` +intarnanew-csv --source-column source -o all.csv first.csv second.csv +intarnanew-csv --deduplicate --separator tab a.tsv b.tsv +``` + +This is table fusion. It is not claimed to reproduce the historical +`IntaRNA-fuse.pl` biological construct workflow: its public help exposes RNA +folding and prediction parameters, but does not specify the construct sequence, +constraints, coordinate transformation, or energy recomputation sufficiently +for an independent scientific implementation. + +## `intarnanew-svg` + +This emits a self-contained SVG directly. `profile` plots any numeric CSV +columns and splits lines at `NA`; `heatmap` accepts IntaRNA `pMinE`-shaped data, +retains missing cells, and by default applies the documented convention of +clamping positive energies to zero. `regions` replaces the documented +`IntaRNA_plotRegions.R` workflow using `id1/start1/end1` or `id2/start2/end2` +columns. All labels and interactive cell titles are XML escaped. + +``` +intarnanew-svg profile target-minE.csv target.svg --x-column position --y-column minE +intarnanew-svg heatmap pair-minE.csv pair.svg +intarnanew-svg regions predictions.csv regions.svg --sequence 1 +``` + +Output is deterministic for identical input and options. It intentionally +targets SVG only; rasterization is outside the standard C++ library. + +## `intarnanew-mutate` + +This is the prediction-independent CopomuS layer. Given base pairs, it +enumerates compensatory mutations using the documented `flip` or `any` +generators, supports GU/AU/CG wild-pair filters, validates explicit mutation +encodings, respects configured external coordinate origins, and materializes +the wild/mutant sequence combinations. + +``` +intarnanew-mutate -q GCAU -t CGUA --t-index-origin 10 --pairs '1&10' -g any +intarnanew-mutate -q GCAU -t CGUA --t-index-origin 10 \ + --mutation-encoding 'G1C&C10G' +``` + +CopomuS candidate selection (`mfe`, `mfeSO`) and ranking measures (`mfeCover`, +energy deltas, accessibility and energy profiles) depend on prediction results. +They are not assigned invented formulas here. The mutation API is designed to +feed a future in-process prediction runner for the `ww`, `wm`, `mw`, and `mm` +evaluations. + +## Deliberately unspecified compatibility + +Public documentation does not uniquely define: + +- the optimizer, initialization, convergence policy, or failure policy used by + historical SciPy distribution fitting; +- the exact random algorithm and seed stream used by the historical Python + shuffle implementation; +- the biological construct and coordinate mapping used by + `IntaRNA-fuse.pl`; +- ranking tie-breakers and every class boundary in historical CopomuS measures. + +IntaRNAnew therefore publishes precise behavior above, tests it, and avoids +claiming byte-for-byte identity where the public contract cannot establish it. diff --git a/IntaRNAnew/tools/intarnanew-csv.cpp b/IntaRNAnew/tools/intarnanew-csv.cpp new file mode 100644 index 0000000..ad417b2 --- /dev/null +++ b/IntaRNAnew/tools/intarnanew-csv.cpp @@ -0,0 +1,130 @@ +#include "intarnanew/tools/csv.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +[[nodiscard]] auto help() -> std::string { + return R"(intarnanew-csv - lossless deterministic interaction CSV fusion + +Usage: intarnanew-csv [options] INPUT [INPUT ...] + +Options: + -o, --output FILE output file, or '-' for standard output (default: -) + --separator CHAR force input and output separator; 'tab' selects TSV + --source-column N append source file name in column N + --deduplicate remove exact duplicate rows, preserving the first + -h, --help show this help + +Quoted fields, embedded separators/newlines, and escaped quotes are supported. +The output schema is the stable union of input schemas; absent fields are empty. +)"; +} + +struct Arguments { + std::vector inputs; + std::string output{"-"}; + std::optional separator; + std::optional sourceColumn; + bool deduplicate{}; +}; + +[[nodiscard]] auto parse(const int argc, const char* const* argv) + -> std::expected, std::string> { + Arguments result; + for (int index = 1; index < argc; ++index) { + const std::string_view option{argv[index]}; + const auto value = [&]() -> std::expected { + if (++index >= argc) return std::unexpected("missing value for " + std::string{option}); + return argv[index]; + }; + if (option == "-h" || option == "--help") return std::optional{}; + if (option == "-o" || option == "--output") { + auto item = value(); if (!item) return std::unexpected(item.error()); + result.output = *item; + } else if (option == "--separator") { + auto item = value(); if (!item) return std::unexpected(item.error()); + if (*item == "tab") result.separator = '\t'; + else if (item->size() == 1U) result.separator = item->front(); + else return std::unexpected("separator must be one character or 'tab'"); + } else if (option == "--source-column") { + auto item = value(); if (!item) return std::unexpected(item.error()); + result.sourceColumn = *item; + } else if (option == "--deduplicate") { + result.deduplicate = true; + } else if (option.starts_with('-') && option != "-") { + return std::unexpected("unknown option '" + std::string{option} + "'"); + } else { + result.inputs.emplace_back(option); + } + } + if (result.inputs.empty()) return std::unexpected("at least one input is required"); + const auto stdinCount = std::ranges::count(result.inputs, "-"); + if (stdinCount > 1) return std::unexpected("standard input can appear only once"); + return std::optional{std::move(result)}; +} + +[[nodiscard]] auto writeOutput(const std::string& destination, const std::string& content) + -> std::expected { + if (destination == "-") { + std::cout << content; + if (!std::cout) return std::unexpected("failed to write standard output"); + return {}; + } + std::ofstream output(destination, std::ios::binary | std::ios::trunc); + if (!output) return std::unexpected("cannot open output file '" + destination + "'"); + output << content; + if (!output) return std::unexpected("failed while writing output file '" + destination + "'"); + return {}; +} + +[[nodiscard]] auto readInput( + const std::string& inputName, + const std::optional separator) -> std::expected { + if (inputName == "-") { + return intarnanew::tools::readCsv(std::cin, {.separator = separator}); + } + std::ifstream input(inputName, std::ios::binary); + if (!input) { + return std::unexpected("cannot open '" + inputName + "'"); + } + return intarnanew::tools::readCsv(input, {.separator = separator}); +} + +} // namespace + +int main(const int argc, const char* const* argv) { + auto arguments = parse(argc, argv); + if (!arguments) { std::cerr << "intarnanew-csv: " << arguments.error() << '\n'; return 2; } + if (!*arguments) { std::cout << help(); return 0; } + std::vector tables; + std::vector labels; + for (const auto& inputName : (**arguments).inputs) { + auto table = readInput(inputName, (**arguments).separator); + if (inputName == "-") { + labels.emplace_back("STDIN"); + } else { + labels.push_back(std::filesystem::path(inputName).filename().string()); + } + if (!table) { std::cerr << "intarnanew-csv: " << inputName << ": " << table.error() << '\n'; return 1; } + tables.push_back(std::move(*table)); + } + auto fused = intarnanew::tools::fuseCsv( + tables, + labels, + {.sourceColumn = (**arguments).sourceColumn, .deduplicate = (**arguments).deduplicate}); + if (!fused) { std::cerr << "intarnanew-csv: " << fused.error() << '\n'; return 1; } + if ((**arguments).separator) fused->separator = *(**arguments).separator; + auto output = intarnanew::tools::csvText(*fused); + if (!output) { std::cerr << "intarnanew-csv: " << output.error() << '\n'; return 1; } + auto written = writeOutput((**arguments).output, *output); + if (!written) { std::cerr << "intarnanew-csv: " << written.error() << '\n'; return 1; } + return 0; +} diff --git a/IntaRNAnew/tools/intarnanew-mutate.cpp b/IntaRNAnew/tools/intarnanew-mutate.cpp new file mode 100644 index 0000000..0d4713a --- /dev/null +++ b/IntaRNAnew/tools/intarnanew-mutate.cpp @@ -0,0 +1,139 @@ +#include "intarnanew/sequence.hpp" +#include "intarnanew/tools/mutations.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +[[nodiscard]] auto help() -> std::string { + return R"(intarnanew-mutate - reusable compensatory-mutation enumeration + +Usage: + intarnanew-mutate -q SEQUENCE -t SEQUENCE --pairs qIndex&tIndex[,..] [options] + intarnanew-mutate -q SEQUENCE -t SEQUENCE --mutation-encoding G1C&U7G + +Options: + -q, --query SEQUENCE raw query RNA + -t, --target SEQUENCE raw target RNA + --q-index-origin N first query coordinate (default: 1) + --t-index-origin N first target coordinate (default: 1) + --pairs LIST external query&target base-pair coordinates + -g, --generator MODE flip or any (default: flip) + -f, --filter CLASS GU, AU, or CG; repeatable + --mutation-encoding M select one explicit mutation + -h, --help show this help + +Output is deterministic TSV with mutation encoding and the four ww/wm/mw/mm +sequence combinations. Ranking measures require a prediction-library runner and +are intentionally outside this enumeration layer. +)"; +} + +[[nodiscard]] auto integer(const std::string_view text) -> std::expected { + long long result{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), result); + if (error != std::errc{} || end != text.data() + text.size()) return std::unexpected("invalid integer '" + std::string{text} + "'"); + return result; +} + +[[nodiscard]] auto pairs( + const std::string_view text, + const intarnanew::Sequence& query, + const intarnanew::Sequence& target) -> std::expected, std::string> { + std::vector result; + std::size_t start{}; + while (start < text.size()) { + const auto comma = text.find(',', start); + const auto token = text.substr(start, comma == std::string_view::npos ? text.size() - start : comma - start); + const auto amp = token.find('&'); + if (amp == std::string_view::npos) return std::unexpected("each pair must be queryIndex&targetIndex"); + auto qExternal = integer(token.substr(0U, amp)); + auto tExternal = integer(token.substr(amp + 1U)); + if (!qExternal) return std::unexpected(qExternal.error()); + if (!tExternal) return std::unexpected(tExternal.error()); + auto q = query.internalIndex(*qExternal); + auto t = target.internalIndex(*tExternal); + if (!q) return std::unexpected(q.error()); + if (!t) return std::unexpected(t.error()); + result.push_back({.target = *t, .query = *q}); + if (comma == std::string_view::npos) break; + start = comma + 1U; + } + if (result.empty()) return std::unexpected("pair list is empty"); + return result; +} + +} // namespace + +int main(const int argc, const char* const* argv) { + std::string queryText, targetText, pairText, encoding; + long long queryOrigin = 1, targetOrigin = 1; + auto generator = intarnanew::tools::MutationGenerator::flip; + std::vector filters; + for (int index = 1; index < argc; ++index) { + const std::string_view option{argv[index]}; + const auto value = [&]() -> std::string_view { + if (++index >= argc) { std::cerr << "intarnanew-mutate: missing value for " << option << '\n'; std::exit(2); } + return argv[index]; + }; + if (option == "-h" || option == "--help") { std::cout << help(); return 0; } + if (option == "-q" || option == "--query") queryText = value(); + else if (option == "-t" || option == "--target") targetText = value(); + else if (option == "--pairs") pairText = value(); + else if (option == "--mutation-encoding") encoding = value(); + else if (option == "--q-index-origin" || option == "--t-index-origin") { + auto parsed = integer(value()); if (!parsed) { std::cerr << parsed.error() << '\n'; return 2; } + if (option == "--q-index-origin") queryOrigin = *parsed; else targetOrigin = *parsed; + } else if (option == "-g" || option == "--generator") { + auto mode = value(); + if (mode == "flip") generator = intarnanew::tools::MutationGenerator::flip; + else if (mode == "any") generator = intarnanew::tools::MutationGenerator::any; + else { std::cerr << "intarnanew-mutate: generator must be flip or any\n"; return 2; } + } else if (option == "-f" || option == "--filter") { + auto filter = value(); + if (filter == "GU") filters.push_back(intarnanew::tools::CandidateFilter::removeGu); + else if (filter == "AU") filters.push_back(intarnanew::tools::CandidateFilter::removeAu); + else if (filter == "CG" || filter == "GC") filters.push_back(intarnanew::tools::CandidateFilter::removeCg); + else { std::cerr << "intarnanew-mutate: this layer supports GU, AU, and CG filters\n"; return 2; } + } else { std::cerr << "intarnanew-mutate: unknown option " << option << '\n'; return 2; } + } + if (queryText.empty() || targetText.empty()) { std::cerr << "intarnanew-mutate: query and target are required\n"; return 2; } + try { + intarnanew::Sequence query("query", queryText, queryOrigin), target("target", targetText, targetOrigin); + std::vector mutations; + if (!encoding.empty()) { + auto mutation = intarnanew::tools::parseMutationEncoding(encoding, query, target); + if (!mutation) { std::cerr << "intarnanew-mutate: " << mutation.error() << '\n'; return 1; } + mutations.push_back(*mutation); + } else { + auto interactionPairs = pairs(pairText, query, target); + if (!interactionPairs) { std::cerr << "intarnanew-mutate: " << interactionPairs.error() << '\n'; return 1; } + auto generated = intarnanew::tools::enumerateMutations(query, target, *interactionPairs, generator, filters); + if (!generated) { std::cerr << "intarnanew-mutate: " << generated.error() << '\n'; return 1; } + mutations = std::move(*generated); + } + std::cout << "mutation\tqueryIndex\ttargetIndex\tbpWildtype\tbpMutated" + "\twwQuery\twwTarget\twmQuery\twmTarget" + "\tmwQuery\tmwTarget\tmmQuery\tmmTarget\n"; + for (const auto& mutation : mutations) { + auto sequences = intarnanew::tools::applyMutation(query, target, mutation); + if (!sequences) { std::cerr << "intarnanew-mutate: " << sequences.error() << '\n'; return 1; } + std::cout << mutation.encoding(query, target) << '\t' << query.externalIndex(mutation.queryIndex) + << '\t' << target.externalIndex(mutation.targetIndex) << '\t' + << mutation.wildQuery << mutation.wildTarget << '\t' + << mutation.mutatedQuery << mutation.mutatedTarget << '\t' + << sequences->wildQuery << '\t' << sequences->wildTarget << '\t' + << sequences->wildQuery << '\t' << sequences->mutatedTarget << '\t' + << sequences->mutatedQuery << '\t' << sequences->wildTarget << '\t' + << sequences->mutatedQuery << '\t' << sequences->mutatedTarget << '\n'; + } + } catch (const std::exception& error) { + std::cerr << "intarnanew-mutate: " << error.what() << '\n'; return 1; + } + return 0; +} diff --git a/IntaRNAnew/tools/intarnanew-pvalue.cpp b/IntaRNAnew/tools/intarnanew-pvalue.cpp new file mode 100644 index 0000000..d1e7b8b --- /dev/null +++ b/IntaRNAnew/tools/intarnanew-pvalue.cpp @@ -0,0 +1,269 @@ +#include "intarnanew/cli.hpp" +#include "intarnanew/runner.hpp" +#include "intarnanew/sequence.hpp" +#include "intarnanew/tools/pvalue.hpp" +#include "intarnanew/tools/statistics.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using intarnanew::Config; +using intarnanew::Sequence; +using intarnanew::tools::DistributionKind; +using intarnanew::tools::RandomScoreOptions; +using intarnanew::tools::ShuffleMode; + +struct Arguments { + std::string query; + std::string target; + std::string parameterFile; + std::size_t samples{}; + ShuffleMode shuffle{ShuffleMode::both}; + DistributionKind distribution{DistributionKind::gev}; + bool scores{}; + std::size_t threads{}; + std::uint64_t seed{}; +}; + +[[nodiscard]] auto help() -> std::string_view { + return R"(intarnanew-pvalue - native randomization-based interaction p-values + +Usage: + intarnanew-pvalue -q RNA_OR_FASTA -t RNA_OR_FASTA -s N -m q|t|b [options] + +Options: + -q, --query INPUT query RNA or FASTA/gzip file (first record) + -t, --target INPUT target RNA or FASTA/gzip file (first record) + -s, -c, --samples N positive random-sample count + --cardinality, --scores N + aliases for the random-sample count + -m, --shuffle-mode q|t|b shuffle query, target, or both + -p, --parameterFile FILE IntaRNAnew parameter file for scoring + -d, --distribution KIND gev, gumbel, or gauss (default: gev) + -o, --output KIND pvalue or scores (default: pvalue) + --threads N worker count; zero uses available hardware + --randSeed N deterministic unsigned random seed (default: 0) + -h, --help show this help + +All predictions run in-process through the C++ library; no executable or +script is launched. Randomized sequences and result order are thread invariant. +)"; +} + +template +[[nodiscard]] auto integer( + const std::string_view text, + const std::string_view option) -> std::expected { + Integer value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (text.empty() || error != std::errc{} || end != text.data() + text.size()) { + return std::unexpected(std::string(option) + " requires an unsigned integer"); + } + return value; +} + +[[nodiscard]] auto parse(std::span values) + -> std::expected, std::string> { + if (values.empty()) return std::unexpected("missing arguments; use --help"); + Arguments result; + for (std::size_t index = 0U; index < values.size(); ++index) { + auto option = values[index]; + std::optional inlineValue; + if (option.starts_with("--")) { + if (const auto separator = option.find('='); separator != std::string_view::npos) { + inlineValue = option.substr(separator + 1U); + option = option.substr(0U, separator); + } + } + if (option == "-h" || option == "--help") return std::optional{}; + const auto next = [&]() -> std::expected { + if (inlineValue) { + const auto value = *inlineValue; + inlineValue.reset(); + return value; + } + if (++index >= values.size()) { + return std::unexpected("missing value for " + std::string(option)); + } + return values[index]; + }; + if (option == "-q" || option == "--query") { + auto value = next(); if (!value) return std::unexpected(value.error()); + result.query = *value; + } else if (option == "-t" || option == "--target") { + auto value = next(); if (!value) return std::unexpected(value.error()); + result.target = *value; + } else if (option == "-s" || option == "-c" || option == "--samples" || + option == "--cardinality" || option == "--scores") { + auto value = next(); if (!value) return std::unexpected(value.error()); + auto parsed = integer(*value, option); + if (!parsed || *parsed == 0U) { + return std::unexpected(parsed ? "--samples must be positive" : parsed.error()); + } + result.samples = *parsed; + } else if (option == "-m" || option == "--shuffle-mode") { + auto value = next(); if (!value) return std::unexpected(value.error()); + if (*value == "q") result.shuffle = ShuffleMode::query; + else if (*value == "t") result.shuffle = ShuffleMode::target; + else if (*value == "b") result.shuffle = ShuffleMode::both; + else return std::unexpected("shuffle mode must be q, t, or b"); + } else if (option == "-p" || option == "--parameterFile") { + auto value = next(); if (!value) return std::unexpected(value.error()); + result.parameterFile = *value; + } else if (option == "-d" || option == "--distribution") { + auto value = next(); if (!value) return std::unexpected(value.error()); + auto parsed = intarnanew::tools::parseDistribution(*value); + if (!parsed) return std::unexpected(parsed.error()); + result.distribution = *parsed; + } else if (option == "-o" || option == "--output") { + auto value = next(); if (!value) return std::unexpected(value.error()); + if (*value == "scores") result.scores = true; + else if (*value == "pvalue") result.scores = false; + else return std::unexpected("output must be pvalue or scores"); + } else if (option == "--threads") { + auto value = next(); if (!value) return std::unexpected(value.error()); + auto parsed = integer(*value, option); + if (!parsed) return std::unexpected(parsed.error()); + result.threads = *parsed; + } else if (option == "--randSeed") { + auto value = next(); if (!value) return std::unexpected(value.error()); + auto parsed = integer(*value, option); + if (!parsed) return std::unexpected(parsed.error()); + result.seed = *parsed; + } else { + return std::unexpected("unknown option '" + std::string(option) + "'"); + } + } + if (result.query.empty()) return std::unexpected("--query is required"); + if (result.target.empty()) return std::unexpected("--target is required"); + if (result.samples == 0U) return std::unexpected("--samples is required"); + return std::optional{std::move(result)}; +} + +[[nodiscard]] auto scoringConfig(const Arguments& arguments) + -> std::expected { + std::vector storage; + storage.reserve(5U); + if (!arguments.parameterFile.empty()) { + storage.push_back("--parameterFile=" + arguments.parameterFile); + } + storage.emplace_back("--target=" + arguments.target); + storage.emplace_back("--query=" + arguments.query); + storage.emplace_back("--outNumber=1"); + storage.emplace_back("--threads=1"); + std::vector views; + views.reserve(storage.size()); + for (const auto& value : storage) views.push_back(value); + return intarnanew::Cli::parse(views, "IntaRNAnew"); +} + +[[nodiscard]] auto score( + const Config& base, + const std::string_view queryText, + const std::string_view targetText) -> std::expected { + try { + const Sequence target(base.target.id, std::string(targetText), base.target.firstPosition); + const Sequence query(base.query.id, std::string(queryText), base.query.firstPosition); + auto evaluation = intarnanew::predictPair(base, target, query); + if (!evaluation) return std::unexpected(evaluation.error()); + return evaluation->prediction.interactions.empty() + ? base.output.maxEnergy + : evaluation->prediction.interactions.front().energy.total(); + } catch (const std::exception& error) { + return std::unexpected(error.what()); + } +} + +} // namespace + +auto main(const int argc, const char* const* argv) -> int { + std::vector values; + values.reserve(static_cast(argc > 0 ? argc - 1 : 0)); + for (int index = 1; index < argc; ++index) values.emplace_back(argv[index]); + auto arguments = parse(values); + if (!arguments) { + std::cerr << "intarnanew-pvalue: " << arguments.error() << '\n'; + return 2; + } + if (!*arguments) { + std::cout << help(); + return 0; + } + auto config = scoringConfig(**arguments); + if (!config) { + std::cerr << "intarnanew-pvalue: " << config.error() << '\n'; + return 2; + } + auto targetInput = intarnanew::SequenceReader::read( + (*arguments)->target, config->target.id, config->target.firstPosition, std::cin); + if (!targetInput) { + std::cerr << "intarnanew-pvalue: target input: " << targetInput.error() << '\n'; + return 2; + } + auto queryInput = intarnanew::SequenceReader::read( + (*arguments)->query, config->query.id, config->query.firstPosition, std::cin); + if (!queryInput) { + std::cerr << "intarnanew-pvalue: query input: " << queryInput.error() << '\n'; + return 2; + } + if (targetInput->empty() || queryInput->empty()) { + std::cerr << "intarnanew-pvalue: query and target inputs must contain a sequence\n"; + return 2; + } + const auto& target = targetInput->front(); + const auto& query = queryInput->front(); + config->target.id = target.id(); + config->query.id = query.id(); + + auto observed = score(*config, query.str(), target.str()); + if (!observed) { + std::cerr << "intarnanew-pvalue: observed score: " << observed.error() << '\n'; + return 1; + } + RandomScoreOptions options; + options.cardinality = (*arguments)->samples; + options.mode = (*arguments)->shuffle; + options.randomSeed = (*arguments)->seed; + options.threads = (*arguments)->threads; + auto sampled = intarnanew::tools::sampleRandomInteractionScores( + query.str(), target.str(), options, + [&](const std::string_view shuffledQuery, const std::string_view shuffledTarget) { + return score(*config, shuffledQuery, shuffledTarget); + }); + if (!sampled) { + std::cerr << "intarnanew-pvalue: " << sampled.error() << '\n'; + return 1; + } + + std::cout << std::setprecision(17); + if ((*arguments)->scores) { + for (const auto value : *sampled) std::cout << value << '\n'; + return std::cout ? 0 : 1; + } + auto fitted = intarnanew::tools::fitDistribution( + *sampled, (*arguments)->distribution); + if (!fitted) { + std::cerr << "intarnanew-pvalue: " << fitted.error() << '\n'; + return 1; + } + auto probability = intarnanew::tools::interactionEnergyPValue(*observed, *fitted); + if (!probability) { + std::cerr << "intarnanew-pvalue: " << probability.error() << '\n'; + return 1; + } + std::cout << *probability << '\n'; + return std::cout ? 0 : 1; +} diff --git a/IntaRNAnew/tools/intarnanew-stats.cpp b/IntaRNAnew/tools/intarnanew-stats.cpp new file mode 100644 index 0000000..babc1f4 --- /dev/null +++ b/IntaRNAnew/tools/intarnanew-stats.cpp @@ -0,0 +1,298 @@ +#include "intarnanew/tools/csv.hpp" +#include "intarnanew/tools/statistics.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using intarnanew::tools::AdjustmentMethod; +using intarnanew::tools::CsvTable; +using intarnanew::tools::DistributionFit; +using intarnanew::tools::DistributionKind; + +struct Arguments { + std::string command; + std::string input{"-"}; + std::string output{"-"}; + std::string column{"E"}; + std::string outputColumn; + std::optional separator; + DistributionKind distribution{DistributionKind::gev}; + AdjustmentMethod adjustment{AdjustmentMethod::benjaminiHochberg}; + bool empirical{}; + bool columnSpecified{}; + bool distributionSpecified{}; + bool adjustmentSpecified{}; + bool outputColumnSpecified{}; +}; + +[[nodiscard]] auto help() -> std::string { + return R"(intarnanew-stats - deterministic interaction-score statistics + +Usage: + intarnanew-stats fit [options] + intarnanew-stats pvalue [options] + intarnanew-stats adjust [options] + +Commands: + fit fit a distribution and print name, location, scale, shape, and NLL + pvalue append lower-tail p-values for interaction energies + adjust append multiple-testing adjusted p-values + +Options: + -i, --input FILE CSV/TSV input, or '-' for standard input + -o, --output FILE output file, or '-' for standard output + -c, --column NAME numeric input column (E; p-value for adjust) + --output-column NAME appended column (default: p-value or p-adjusted) + -d, --distribution KIND gev, gumbel, or gauss (default: gev) + --empirical pvalue: exact plus-one empirical lower tail + -m, --method METHOD adjust: none, bonferroni, holm, hochberg, bh, by + --separator CHAR force input/output separator; use 'tab' for TSV + -h, --help show this help + +The fitted p-value is P(X <= E), because more-negative interaction energy is +more extreme. The empirical estimate is (#{sample <= E}+1)/(n+1). Missing, +infinite, and malformed numeric cells are rejected rather than imputed. +)"; +} + +[[nodiscard]] auto optionValue( + const std::span values, + std::size_t& index, + const std::string_view option) -> std::expected { + if (index + 1U >= values.size()) { + return std::unexpected("missing value for " + std::string{option}); + } + return values[++index]; +} + +[[nodiscard]] auto parse(std::span values) + -> std::expected, std::string> { + if (values.empty()) { + return std::unexpected("missing command; use --help"); + } + if (values.front() == "-h" || values.front() == "--help") { + return std::optional{}; + } + Arguments result; + result.command = values.front(); + if (result.command != "fit" && result.command != "pvalue" && result.command != "adjust") { + return std::unexpected("unknown command '" + result.command + "'"); + } + for (std::size_t index = 1U; index < values.size(); ++index) { + const auto option = values[index]; + if (option == "-h" || option == "--help") { + return std::optional{}; + } + auto get = [&] { return optionValue(values, index, option); }; + if (option == "-i" || option == "--input") { + auto value = get(); if (!value) return std::unexpected(value.error()); + result.input = *value; + } else if (option == "-o" || option == "--output") { + auto value = get(); if (!value) return std::unexpected(value.error()); + result.output = *value; + } else if (option == "-c" || option == "--column") { + auto value = get(); if (!value) return std::unexpected(value.error()); + result.column = *value; + result.columnSpecified = true; + } else if (option == "--output-column") { + auto value = get(); if (!value) return std::unexpected(value.error()); + result.outputColumn = *value; + result.outputColumnSpecified = true; + } else if (option == "-d" || option == "--distribution") { + auto value = get(); if (!value) return std::unexpected(value.error()); + auto kind = intarnanew::tools::parseDistribution(*value); + if (!kind) return std::unexpected(kind.error()); + result.distribution = *kind; + result.distributionSpecified = true; + } else if (option == "-m" || option == "--method") { + auto value = get(); if (!value) return std::unexpected(value.error()); + auto method = intarnanew::tools::parseAdjustment(*value); + if (!method) return std::unexpected(method.error()); + result.adjustment = *method; + result.adjustmentSpecified = true; + } else if (option == "--separator") { + auto value = get(); if (!value) return std::unexpected(value.error()); + if (*value == "tab") result.separator = '\t'; + else if (value->size() == 1U) result.separator = value->front(); + else return std::unexpected("separator must be one character or 'tab'"); + } else if (option == "--empirical") { + result.empirical = true; + } else { + return std::unexpected("unknown option '" + std::string{option} + "'"); + } + } + if (result.command == "adjust" && !result.columnSpecified) { + result.column = "p-value"; + } + if (result.command != "adjust" && result.adjustmentSpecified) { + return std::unexpected("--method is only valid for the adjust command"); + } + if (result.command == "adjust" && result.distributionSpecified) { + return std::unexpected("--distribution is not valid for the adjust command"); + } + if (result.command != "pvalue" && result.empirical) { + return std::unexpected("--empirical is only valid for the pvalue command"); + } + if (result.empirical && result.distributionSpecified) { + return std::unexpected("--empirical and --distribution are mutually exclusive"); + } + if (result.command == "fit" && result.outputColumnSpecified) { + return std::unexpected("--output-column is not valid for the fit command"); + } + if (result.command != "fit" && result.outputColumn.empty()) { + result.outputColumn = result.command == "adjust" ? "p-adjusted" : "p-value"; + } + return std::optional{std::move(result)}; +} + +[[nodiscard]] auto readTable(const Arguments& arguments) -> std::expected { + if (arguments.input == "-") { + return intarnanew::tools::readCsv( + std::cin, {.separator = arguments.separator}); + } + std::ifstream input(arguments.input, std::ios::binary); + if (!input) { + return std::unexpected("cannot open input file '" + arguments.input + "'"); + } + return intarnanew::tools::readCsv(input, {.separator = arguments.separator}); +} + +[[nodiscard]] auto extract( + const CsvTable& table, + const std::string_view column) -> std::expected, std::string> { + const auto index = table.column(column); + if (!index) { + return std::unexpected("CSV has no column '" + std::string{column} + "'"); + } + std::vector values; + values.reserve(table.rows.size()); + for (std::size_t row = 0U; row < table.rows.size(); ++row) { + auto value = intarnanew::tools::parseFiniteNumber(table.rows[row][*index]); + if (!value) { + return std::unexpected( + "record " + std::to_string(row + 2U) + ", column '" + + std::string{column} + "': " + value.error()); + } + values.push_back(*value); + } + return values; +} + +[[nodiscard]] auto render(const double value) -> std::string { + std::array buffer{}; + const auto [end, error] = std::to_chars( + buffer.data(), buffer.data() + buffer.size(), value); + if (error != std::errc{}) { + return {}; + } + return std::string(buffer.data(), end); +} + +[[nodiscard]] auto writeText( + const std::string_view destination, + const std::string_view text) -> std::expected { + if (destination == "-") { + std::cout << text; + if (!std::cout) return std::unexpected("failed to write standard output"); + return {}; + } + const std::filesystem::path finalPath{destination}; + const auto temporary = finalPath.string() + ".tmp"; + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + if (!output) return std::unexpected("cannot open temporary output file '" + temporary + "'"); + output << text; + if (!output) return std::unexpected("failed while writing temporary output file"); + } + std::error_code error; + std::filesystem::rename(temporary, finalPath, error); + if (error) { + std::filesystem::remove(temporary); + return std::unexpected("cannot install output file '" + finalPath.string() + "': " + error.message()); + } + return {}; +} + +[[nodiscard]] auto execute(const Arguments& arguments) -> std::expected { + auto table = readTable(arguments); + if (!table) return std::unexpected(table.error()); + auto values = extract(*table, arguments.column); + if (!values) return std::unexpected(values.error()); + if (arguments.command == "fit") { + auto fit = intarnanew::tools::fitDistribution(*values, arguments.distribution); + if (!fit) return std::unexpected(fit.error()); + std::ostringstream output; + output << "distribution\tlocation\tscale\tshape\tnll\tobservations\tconverged\titerations\n" + << intarnanew::tools::distributionName(fit->kind) << '\t' + << std::setprecision(17) << fit->location << '\t' << fit->scale << '\t' + << fit->shape << '\t' << fit->negativeLogLikelihood << '\t' + << fit->observations << '\t' << (fit->converged ? "true" : "false") + << '\t' << fit->iterations << '\n'; + return writeText(arguments.output, output.str()); + } + if (table->column(arguments.outputColumn)) { + return std::unexpected("output column '" + arguments.outputColumn + "' already exists"); + } + std::vector probabilities; + probabilities.reserve(values->size()); + if (arguments.command == "adjust") { + auto adjusted = intarnanew::tools::adjustPValues(*values, arguments.adjustment); + if (!adjusted) return std::unexpected(adjusted.error()); + probabilities = std::move(*adjusted); + } else if (arguments.empirical) { + for (const double value : *values) { + auto probability = intarnanew::tools::empiricalInteractionPValue(value, *values); + if (!probability) return std::unexpected(probability.error()); + probabilities.push_back(*probability); + } + } else { + auto fit = intarnanew::tools::fitDistribution(*values, arguments.distribution); + if (!fit) return std::unexpected(fit.error()); + for (const double value : *values) { + auto probability = intarnanew::tools::interactionEnergyPValue(value, *fit); + if (!probability) return std::unexpected(probability.error()); + probabilities.push_back(*probability); + } + } + table->header.push_back(arguments.outputColumn); + for (std::size_t row = 0U; row < table->rows.size(); ++row) { + table->rows[row].push_back(render(probabilities[row])); + } + auto output = intarnanew::tools::csvText(*table); + if (!output) return std::unexpected(output.error()); + return writeText(arguments.output, *output); +} + +} // namespace + +int main(const int argc, const char* const* argv) { + std::vector values; + for (int index = 1; index < argc; ++index) values.emplace_back(argv[index]); + auto arguments = parse(values); + if (!arguments) { + std::cerr << "intarnanew-stats: " << arguments.error() << '\n'; + return 2; + } + if (!*arguments) { + std::cout << help(); + return 0; + } + auto result = execute(**arguments); + if (!result) { + std::cerr << "intarnanew-stats: " << result.error() << '\n'; + return 1; + } + return 0; +} diff --git a/IntaRNAnew/tools/intarnanew-svg.cpp b/IntaRNAnew/tools/intarnanew-svg.cpp new file mode 100644 index 0000000..bb6281b --- /dev/null +++ b/IntaRNAnew/tools/intarnanew-svg.cpp @@ -0,0 +1,232 @@ +#include "intarnanew/tools/csv.hpp" +#include "intarnanew/tools/svg.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +[[nodiscard]] auto help() -> std::string { + return R"(intarnanew-svg - direct deterministic SVG profiles and heatmaps + +Usage: + intarnanew-svg profile INPUT OUTPUT [options] + intarnanew-svg heatmap INPUT OUTPUT [options] + intarnanew-svg regions INPUT OUTPUT [options] + +Options: + --x-column NAME profile x column (default: first column) + --y-column NAME profile value column (default: last column) + --title TEXT plot title + --x-label TEXT horizontal axis label + --y-label TEXT vertical axis label + --width PIXELS SVG width (default: 960) + --height PIXELS SVG height (profile 480, heatmap 720) + --separator CHAR force CSV separator; 'tab' selects TSV + --keep-positive heatmap: do not clamp positive energies to zero + --sequence SIDE regions: use id/start/end columns suffixed 1 or 2 + -h, --help show this help + +Profile input is a headered table; NA and empty values split line segments. +Heatmap input follows IntaRNA pMinE layout: the first column supplies row labels +and all remaining column names are x labels. NA and empty cells remain missing. +Regions input follows IntaRNA CSV output and defaults to id1/start1/end1. +)"; +} + +struct Arguments { + std::string mode; + std::string input; + std::string output; + std::optional xColumn; + std::optional yColumn; + std::optional separator; + std::string title; + std::string xLabel; + std::string yLabel; + std::size_t width{960U}; + std::size_t height{}; + bool keepPositive{}; + unsigned sequenceSide{1U}; +}; + +[[nodiscard]] auto parseSize(const std::string_view text) -> std::expected { + std::size_t value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) { + return std::unexpected("invalid pixel size '" + std::string{text} + "'"); + } + return value; +} + +[[nodiscard]] auto parseCoordinate(const std::string_view text) + -> std::expected { + long long value{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + if (error != std::errc{} || end != text.data() + text.size()) { + return std::unexpected("invalid integral coordinate '" + std::string{text} + "'"); + } + return value; +} + +[[nodiscard]] auto parse(const int argc, const char* const* argv) + -> std::expected, std::string> { + if (argc == 2 && + (std::string_view{argv[1]} == "--help" || std::string_view{argv[1]} == "-h")) { + return std::optional{}; + } + if (argc < 4) return std::unexpected("expected MODE INPUT OUTPUT; use --help"); + Arguments result; + result.mode = argv[1]; + result.input = argv[2]; + result.output = argv[3]; + if (result.mode != "profile" && result.mode != "heatmap" && result.mode != "regions") { + return std::unexpected("mode must be 'profile', 'heatmap', or 'regions'"); + } + result.height = result.mode == "profile" ? 480U : 720U; + for (int index = 4; index < argc; ++index) { + const std::string_view option{argv[index]}; + const auto value = [&]() -> std::expected { + if (++index >= argc) return std::unexpected("missing value for " + std::string{option}); + return argv[index]; + }; + if (option == "-h" || option == "--help") return std::optional{}; + if (option == "--x-column") { + auto item = value(); if (!item) return std::unexpected(item.error()); result.xColumn = *item; + } else if (option == "--y-column") { + auto item = value(); if (!item) return std::unexpected(item.error()); result.yColumn = *item; + } else if (option == "--title") { + auto item = value(); if (!item) return std::unexpected(item.error()); result.title = *item; + } else if (option == "--x-label") { + auto item = value(); if (!item) return std::unexpected(item.error()); result.xLabel = *item; + } else if (option == "--y-label") { + auto item = value(); if (!item) return std::unexpected(item.error()); result.yLabel = *item; + } else if (option == "--width" || option == "--height") { + auto item = value(); if (!item) return std::unexpected(item.error()); + auto size = parseSize(*item); if (!size) return std::unexpected(size.error()); + if (option == "--width") result.width = *size; else result.height = *size; + } else if (option == "--separator") { + auto item = value(); if (!item) return std::unexpected(item.error()); + if (*item == "tab") result.separator = '\t'; + else if (item->size() == 1U) result.separator = item->front(); + else return std::unexpected("separator must be one character or 'tab'"); + } else if (option == "--sequence") { + auto item = value(); if (!item) return std::unexpected(item.error()); + if (*item == "1") result.sequenceSide = 1U; + else if (*item == "2") result.sequenceSide = 2U; + else return std::unexpected("--sequence must be 1 or 2"); + } else if (option == "--keep-positive") result.keepPositive = true; + else return std::unexpected("unknown option '" + std::string{option} + "'"); + } + return std::optional{std::move(result)}; +} + +[[nodiscard]] auto missing(const std::string_view text) noexcept -> bool { + return text.empty() || text == "NA" || text == "NaN" || text == "nan"; +} + +[[nodiscard]] auto makeSvg( + const intarnanew::tools::CsvTable& table, + const Arguments& arguments) -> std::expected { + if (arguments.mode == "profile") { + const auto x = arguments.xColumn + ? table.column(*arguments.xColumn) + : std::optional{0U}; + const auto y = arguments.yColumn ? table.column(*arguments.yColumn) + : std::optional{table.header.size() - 1U}; + if (!x) return std::unexpected("profile x column not found"); + if (!y) return std::unexpected("profile y column not found"); + std::vector points; + for (std::size_t row = 0U; row < table.rows.size(); ++row) { + auto position = intarnanew::tools::parseFiniteNumber(table.rows[row][*x]); + if (!position) return std::unexpected("row " + std::to_string(row + 2U) + ": " + position.error()); + std::optional value; + if (!missing(table.rows[row][*y])) { + auto parsed = intarnanew::tools::parseFiniteNumber(table.rows[row][*y]); + if (!parsed) return std::unexpected("row " + std::to_string(row + 2U) + ": " + parsed.error()); + value = *parsed; + } + points.push_back(intarnanew::tools::ProfilePoint{*position, value}); + } + intarnanew::tools::ProfileSvgOptions options; + options.width = arguments.width; options.height = arguments.height; + if (!arguments.title.empty()) options.title = arguments.title; + options.xLabel = arguments.xLabel.empty() ? table.header[*x] : arguments.xLabel; + options.yLabel = arguments.yLabel.empty() ? table.header[*y] : arguments.yLabel; + return intarnanew::tools::profileSvg(points, options); + } + if (arguments.mode == "regions") { + const auto suffix = std::to_string(arguments.sequenceSide); + const auto idColumn = table.column("id" + suffix); + const auto startColumn = table.column("start" + suffix); + const auto endColumn = table.column("end" + suffix); + if (!idColumn || !startColumn || !endColumn) { + return std::unexpected( + "regions input requires id" + suffix + ", start" + suffix + + ", and end" + suffix + " columns"); + } + std::vector regions; + regions.reserve(table.rows.size()); + for (std::size_t row = 0U; row < table.rows.size(); ++row) { + auto start = parseCoordinate(table.rows[row][*startColumn]); + auto end = parseCoordinate(table.rows[row][*endColumn]); + if (!start || !end) { + return std::unexpected( + "row " + std::to_string(row + 2U) + + " has a non-integral or out-of-range region coordinate"); + } + regions.push_back({ + table.rows[row][*idColumn], *start, *end}); + } + intarnanew::tools::RegionSvgOptions options; + options.width = arguments.width; options.height = arguments.height; + if (!arguments.title.empty()) options.title = arguments.title; + if (!arguments.xLabel.empty()) options.xLabel = arguments.xLabel; + return intarnanew::tools::regionsSvg(regions, options); + } + if (table.header.size() < 2U) return std::unexpected("heatmap needs a row-label column and at least one value column"); + intarnanew::tools::HeatmapData data; + data.xLabels.assign(table.header.begin() + 1, table.header.end()); + for (std::size_t row = 0U; row < table.rows.size(); ++row) { + data.yLabels.push_back(table.rows[row][0]); + for (std::size_t column = 1U; column < table.header.size(); ++column) { + if (missing(table.rows[row][column])) data.values.emplace_back(std::nullopt); + else { + auto parsed = intarnanew::tools::parseFiniteNumber(table.rows[row][column]); + if (!parsed) return std::unexpected("row " + std::to_string(row + 2U) + ": " + parsed.error()); + data.values.emplace_back(*parsed); + } + } + } + intarnanew::tools::HeatmapSvgOptions options; + options.width = arguments.width; options.height = arguments.height; + options.clampPositiveToZero = !arguments.keepPositive; + if (!arguments.title.empty()) options.title = arguments.title; + if (!arguments.xLabel.empty()) options.xLabel = arguments.xLabel; + if (!arguments.yLabel.empty()) options.yLabel = arguments.yLabel; + return intarnanew::tools::heatmapSvg(data, options); +} + +} // namespace + +int main(const int argc, const char* const* argv) { + auto arguments = parse(argc, argv); + if (!arguments) { std::cerr << "intarnanew-svg: " << arguments.error() << '\n'; return 2; } + if (!*arguments) { std::cout << help(); return 0; } + std::ifstream input((**arguments).input, std::ios::binary); + if (!input) { std::cerr << "intarnanew-svg: cannot open input file\n"; return 1; } + auto table = intarnanew::tools::readCsv(input, {.separator = (**arguments).separator}); + if (!table) { std::cerr << "intarnanew-svg: " << table.error() << '\n'; return 1; } + auto svg = makeSvg(*table, **arguments); + if (!svg) { std::cerr << "intarnanew-svg: " << svg.error() << '\n'; return 1; } + std::ofstream output((**arguments).output, std::ios::binary | std::ios::trunc); + if (!output) { std::cerr << "intarnanew-svg: cannot open output file\n"; return 1; } + output << *svg; + if (!output) { std::cerr << "intarnanew-svg: failed while writing output\n"; return 1; } + return 0; +}