From 0110eb3259b52213ce2251830ac10e36d77bc75b Mon Sep 17 00:00:00 2001 From: Michael Selby <149940738+michaelselby-signaloid@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:20:29 +0000 Subject: [PATCH 1/2] Updated version to 1.12.0 --- .flake8 | 1 + .../00-public-bug-issue-template-v1.yml | 81 - .github/workflows/signaloid-python.yaml | 6 +- .gitmodules | 4 + README.md | 30 + ...naloid-python-diagram-flat-dark-withCR.png | Bin 0 -> 420381 bytes ...aloid-python-diagram-flat-light-withCR.png | Bin 0 -> 422435 bytes pyproject.toml | 32 +- src/signaloid/benchmarking/__init__.py | 59 + src/signaloid/benchmarking/assets | 1 + .../benchmarking/automation/README.md | 556 +++++++ .../benchmarking/automation/__init__.py | 21 + .../benchmarking/automation/__main__.py | 24 + .../benchmarking/automation/analysis.py | 708 +++++++++ .../analytic_ground_truth_database_test.py | 229 +++ .../automation/application_version_test.py | 86 + .../benchmarking/automation/arguments.py | 447 ++++++ .../benchmarking/automation/benchmark.py | 503 ++++++ .../automation/benchmark_application.py | 476 ++++++ .../benchmarking/automation/benchmark_test.py | 85 + .../automation/benchmarking_utils.py | 629 ++++++++ .../benchmarking/automation/build.py | 434 +++++ .../automation/compare_tracing_ux_strings.py | 279 ++++ .../compare_tracing_ux_strings_test.py | 177 +++ .../automation/config_layer_test.py | 259 +++ .../automation/database_generator.py | 537 +++++++ .../automation/database_generator_test.py | 139 ++ .../automation/generate_uxhw_tracing_test.py | 168 ++ .../automation/measurement_loader.py | 530 +++++++ .../automation/measurement_loader_test.py | 624 ++++++++ .../automation/merge_tracing_dbs.py | 310 ++++ .../automation/merge_tracing_dbs_test.py | 177 +++ .../automation/native_mc_flat_test.py | 746 +++++++++ .../automation/num_parallel_workers_test.py | 191 +++ .../automation/output_directories_test.py | 76 + .../benchmarking/automation/parse_cpu_time.py | 168 ++ .../automation/parse_cpu_time_test.py | 178 +++ .../automation/path_to_pin_test.py | 109 ++ .../automation/read_db_metrics.py | 108 ++ .../automation/read_db_metrics_test.py | 171 ++ .../benchmarking/automation/report_writer.py | 870 ++++++++++ .../automation/report_writer_test.py | 637 ++++++++ .../automation/sample_generator.py | 545 +++++++ .../symlink_application_inputs_test.py | 115 ++ .../timing_intermediate_parser_test.py | 555 +++++++ .../automation/uxhw_blow_up_guard_test.py | 166 ++ .../automation/validate_args_test.py | 76 + .../benchmark_timing/get-timing-template.sh | 42 + .../benchmark_timing/get-timings.sh | 1011 ++++++++++++ .../get_timing_template_test.py | 122 ++ src/signaloid/benchmarking/config.py | 356 +++++ .../benchmarking/configs/athens.yaml | 30 + .../benchmarking/configs/europa.yaml | 30 + .../benchmarking/configs/full-sweep.yaml | 35 + .../benchmarking/configs/jupiter.yaml | 36 + src/signaloid/benchmarking/configs/quick.yaml | 29 + .../distribution_helpers/__init__.py | 28 + .../distribution_helpers/collapse.py | 72 + .../distribution_helpers/collapse_test.py | 73 + .../distribution_helpers/density.py | 80 + .../distribution_helpers/density_test.py | 51 + .../distribution_helpers/quantization.py | 116 ++ .../distribution_helpers/quantization_test.py | 102 ++ .../representation_health.py | 96 ++ .../benchmarking/equivalent_mc/README.md | 105 ++ .../benchmarking/equivalent_mc/__init__.py | 39 + .../equivalent_mc/adversary_distance.py | 170 ++ .../benchmarking/equivalent_mc/conftest.py | 1400 +++++++++++++++++ ...value_from_row_representation_size_test.py | 177 +++ .../distance_wasserstein_test.py | 241 +++ .../equivalent_mc/equiv_mc_test.py | 415 +++++ .../equivalent_mc/equivalent_mc_main.py | 329 ++++ .../equivalent_mc/equivalent_mc_utils.py | 920 +++++++++++ .../equivalent_mc/equivalent_monte_carlo.py | 590 +++++++ .../equivalent_monte_carlo_test.py | 169 ++ .../equivalent_mc/inputs/signaloid.yaml | 9 + .../equivalent_mc/inputs/uxhw_distances.csv | 5 + .../benchmarking/equivalent_mc/load.py | 744 +++++++++ .../equivalent_mc/load_mc_test.py | 110 ++ .../equivalent_mc/print_uxhw_table_test.py | 120 ++ .../benchmarking/equivalent_mc/py.typed | 0 .../equivalent_mc/shared_xlim_test.py | 122 ++ src/signaloid/benchmarking/py.typed | 0 src/signaloid/benchmarking/types.py | 338 ++++ .../types_asymptotic_distribution_test.py | 122 ++ .../types_benchmarking_variable_test.py | 109 ++ .../types_distribution_samples_test.py | 78 + .../benchmarking/types_emcc_results_test.py | 60 + .../types_timing_measurements_test.py | 92 ++ .../benchmarking/types_uxhw_distances_test.py | 102 ++ .../circuitpython/extended_ulab_numpy.py | 6 +- .../distributional/distributional.py | 18 +- .../distributional/distributional_test.py | 14 +- .../distributional_distance/README.md | 3 +- .../distributional_distance/_validators.py | 2 +- .../_validators_test.py | 6 +- .../binned_wasserstein.py | 18 +- .../binned_wasserstein_test.py | 22 +- .../distributional_distance/ks_distance.py | 6 +- .../ks_distance_test.py | 4 +- .../distributional_distance/scalar.py | 14 +- .../distributional_distance/scalar_test.py | 12 +- .../distributional_distance/wasserstein.py | 18 +- .../wasserstein_test.py | 4 +- .../plot_histogram_dirac_deltas_test.py | 6 +- .../plot_wrapper.py | 20 +- .../sample_generator.py | 4 +- src/signaloid/out.dat | 100 ++ src/signaloid/plot.png | Bin 0 -> 77908 bytes src/signaloid/statistical_tests/README.md | 13 +- .../statistical_tests/ks_hypothesis.py | 4 +- 111 files changed, 21315 insertions(+), 177 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml create mode 100644 .gitmodules create mode 100644 images/signaloid-external-illustration-signaloid-python-diagram-flat-dark-withCR.png create mode 100644 images/signaloid-external-illustration-signaloid-python-diagram-flat-light-withCR.png create mode 100644 src/signaloid/benchmarking/__init__.py create mode 160000 src/signaloid/benchmarking/assets create mode 100644 src/signaloid/benchmarking/automation/README.md create mode 100644 src/signaloid/benchmarking/automation/__init__.py create mode 100644 src/signaloid/benchmarking/automation/__main__.py create mode 100644 src/signaloid/benchmarking/automation/analysis.py create mode 100644 src/signaloid/benchmarking/automation/analytic_ground_truth_database_test.py create mode 100644 src/signaloid/benchmarking/automation/application_version_test.py create mode 100644 src/signaloid/benchmarking/automation/arguments.py create mode 100644 src/signaloid/benchmarking/automation/benchmark.py create mode 100644 src/signaloid/benchmarking/automation/benchmark_application.py create mode 100644 src/signaloid/benchmarking/automation/benchmark_test.py create mode 100644 src/signaloid/benchmarking/automation/benchmarking_utils.py create mode 100644 src/signaloid/benchmarking/automation/build.py create mode 100644 src/signaloid/benchmarking/automation/compare_tracing_ux_strings.py create mode 100644 src/signaloid/benchmarking/automation/compare_tracing_ux_strings_test.py create mode 100644 src/signaloid/benchmarking/automation/config_layer_test.py create mode 100644 src/signaloid/benchmarking/automation/database_generator.py create mode 100644 src/signaloid/benchmarking/automation/database_generator_test.py create mode 100644 src/signaloid/benchmarking/automation/generate_uxhw_tracing_test.py create mode 100644 src/signaloid/benchmarking/automation/measurement_loader.py create mode 100644 src/signaloid/benchmarking/automation/measurement_loader_test.py create mode 100644 src/signaloid/benchmarking/automation/merge_tracing_dbs.py create mode 100644 src/signaloid/benchmarking/automation/merge_tracing_dbs_test.py create mode 100644 src/signaloid/benchmarking/automation/native_mc_flat_test.py create mode 100644 src/signaloid/benchmarking/automation/num_parallel_workers_test.py create mode 100644 src/signaloid/benchmarking/automation/output_directories_test.py create mode 100644 src/signaloid/benchmarking/automation/parse_cpu_time.py create mode 100644 src/signaloid/benchmarking/automation/parse_cpu_time_test.py create mode 100644 src/signaloid/benchmarking/automation/path_to_pin_test.py create mode 100644 src/signaloid/benchmarking/automation/read_db_metrics.py create mode 100644 src/signaloid/benchmarking/automation/read_db_metrics_test.py create mode 100644 src/signaloid/benchmarking/automation/report_writer.py create mode 100644 src/signaloid/benchmarking/automation/report_writer_test.py create mode 100644 src/signaloid/benchmarking/automation/sample_generator.py create mode 100644 src/signaloid/benchmarking/automation/symlink_application_inputs_test.py create mode 100644 src/signaloid/benchmarking/automation/timing_intermediate_parser_test.py create mode 100644 src/signaloid/benchmarking/automation/uxhw_blow_up_guard_test.py create mode 100644 src/signaloid/benchmarking/automation/validate_args_test.py create mode 100644 src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh create mode 100644 src/signaloid/benchmarking/benchmark_timing/get-timings.sh create mode 100644 src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py create mode 100644 src/signaloid/benchmarking/config.py create mode 100644 src/signaloid/benchmarking/configs/athens.yaml create mode 100644 src/signaloid/benchmarking/configs/europa.yaml create mode 100644 src/signaloid/benchmarking/configs/full-sweep.yaml create mode 100644 src/signaloid/benchmarking/configs/jupiter.yaml create mode 100644 src/signaloid/benchmarking/configs/quick.yaml create mode 100644 src/signaloid/benchmarking/distribution_helpers/__init__.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/collapse.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/collapse_test.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/density.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/density_test.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/quantization.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/quantization_test.py create mode 100644 src/signaloid/benchmarking/distribution_helpers/representation_health.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/README.md create mode 100644 src/signaloid/benchmarking/equivalent_mc/__init__.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/adversary_distance.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/conftest.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/dist_value_from_row_representation_size_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/distance_wasserstein_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/equiv_mc_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/equivalent_mc_main.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/equivalent_mc_utils.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/inputs/signaloid.yaml create mode 100644 src/signaloid/benchmarking/equivalent_mc/inputs/uxhw_distances.csv create mode 100644 src/signaloid/benchmarking/equivalent_mc/load.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/load_mc_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/print_uxhw_table_test.py create mode 100644 src/signaloid/benchmarking/equivalent_mc/py.typed create mode 100644 src/signaloid/benchmarking/equivalent_mc/shared_xlim_test.py create mode 100644 src/signaloid/benchmarking/py.typed create mode 100644 src/signaloid/benchmarking/types.py create mode 100644 src/signaloid/benchmarking/types_asymptotic_distribution_test.py create mode 100644 src/signaloid/benchmarking/types_benchmarking_variable_test.py create mode 100644 src/signaloid/benchmarking/types_distribution_samples_test.py create mode 100644 src/signaloid/benchmarking/types_emcc_results_test.py create mode 100644 src/signaloid/benchmarking/types_timing_measurements_test.py create mode 100644 src/signaloid/benchmarking/types_uxhw_distances_test.py create mode 100644 src/signaloid/out.dat create mode 100644 src/signaloid/plot.png diff --git a/.flake8 b/.flake8 index 4ff24cf..ce0f0a5 100644 --- a/.flake8 +++ b/.flake8 @@ -12,3 +12,4 @@ per-file-ignores = # We should aim is to address all of these in the future src/signaloid/distributional_information_plotting/*: C901 src/signaloid/distributional/*: C901 + src/signaloid/benchmarking/*: C901 diff --git a/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml b/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml deleted file mode 100644 index b9138e3..0000000 --- a/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: "Bug Report" -description: "File a structured report to help us reproduce and fix an issue." -title: "[Bug]: " -type: "bug" -body: - - type: markdown - attributes: - value: | - ### Thank you for reporting a bug! - To help us resolve this as quickly as possible, please provide as much detail as you can. - Before submitting, please ensure you are using the latest version of our tools. - - - type: textarea - id: what-happened - attributes: - label: "What happened?" - description: "A clear and concise description of the bug." - placeholder: "e.g., I expected the Signaloid Cloud Developer Platform (SCDP) to return a specific distribution, but instead it..." - validations: - required: true - - - type: textarea - id: reproduction-steps - attributes: - label: "Steps to Reproduce" - description: "How can we make this happen again? You can provide a list of steps, specific inputs, or a relevant code snippet." - placeholder: | - 1. Run 'signaloid-cli load...' - 2. Set parameters to... - 3. See error... - validations: - required: true - - - type: dropdown - id: environment - attributes: - label: "Environment" - description: "Where did you encounter the issue?" - options: - - Signaloid Cloud Developer Platform (Browser) - - Signaloid CLI / Local Execution - - Signaloid Compute Modules (e.g, C0-microSD) - - Documentation / Website - - Other - validations: - required: true - - - type: input - id: version - attributes: - label: "Version / Commit Hash" - description: "Which version of the Signaloid toolchain or API are you using?" - placeholder: "e.g., v2.1.0 or commit a1b2c3d" - validations: - required: true - - - type: dropdown - id: os - attributes: - label: "Operating System" - options: - - Linux - - macOS - - Windows - - Other (Cloud Platform) - - - type: textarea - id: logs - attributes: - label: "Relevant log output or Trace" - description: "Please paste any compiler errors, CLI output, or console logs here." - render: shell - placeholder: "Paste logs here..." - - - type: textarea - id: visual-evidence - attributes: - label: "Screenshots or Diagrams" - description: "Drag and drop images here." - validations: - required: false diff --git a/.github/workflows/signaloid-python.yaml b/.github/workflows/signaloid-python.yaml index dd8962f..61c881a 100644 --- a/.github/workflows/signaloid-python.yaml +++ b/.github/workflows/signaloid-python.yaml @@ -34,6 +34,10 @@ jobs: ref: ${{ github.head_ref }} fetch-depth: 0 fetch-tags: true + # The benchmarking build-template assets are a submodule, and + # `pyproject.toml` includes them in the built distributions. + submodules: recursive + token: ${{ secrets.CI_HELPER_PAT || github.token }} - name: Debug run: | @@ -41,7 +45,7 @@ jobs: echo "github.event.pull_request.head.sha:" ${{ github.event.pull_request.head.sha }} echo "github.sha:" ${{ github.sha }} echo "github.event.release.tag_name:" ${{ github.event.release.tag_name }} - echo "contains(github.event.pull_request.labels, 'ci:upload-binaries'):" ${{ contains(github.event.pull_request.labels, 'ci:"upload-binaries') }} + echo "contains(github.event.pull_request.labels.*.name, 'ci:upload-binaries'):" ${{ contains(github.event.pull_request.labels.*.name, 'ci:upload-binaries') }} - name: Set up Python uses: actions/setup-python@v6 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..92cc7b2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "src/signaloid/benchmarking/assets"] + path = src/signaloid/benchmarking/assets + url = ../../signaloid/Signaloid-Demo-TemplateBuildAssets + branch = main diff --git a/README.md b/README.md index 7977f3d..4b1f629 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ The Signaloid Python Library and SDK provides tools for interacting with applications that utilize Signaloid's UxHw® technology for distributional arithmetic. Use the library to analyze Ux Data values from the application. +Also, run benchmarking of applications to compare the performance with +equivalent Monte Carlo methods. + +![signaloid-python diagram](images/signaloid-external-illustration-signaloid-python-diagram-flat-light-withCR.png#gh-light-mode-only) +![signaloid-python diagram](images/signaloid-external-illustration-signaloid-python-diagram-flat-dark-withCR.png#gh-dark-mode-only) ## Requirements @@ -29,6 +34,31 @@ python -m pip install . ## Usage +### Benchmarking UxHw applications + +Use the `signaloid-benchmarking` command-line tool to benchmark an application +running with UxHw against a Monte Carlo baseline. The following example +benchmarks for UxHw Core microarchitectures Athens and Jupiter for precisions 8, +16, and 32, for both types of correlation tracking. + +```bash +python -m signaloid.benchmarking.automation \ + --path-to-application ./my-uxhw-app \ + --path-to-uxhw-sdk ~/project-uxhw-sdk \ + --path-to-pin ~/pin-external-4.2 \ + -u Athens Jupiter \ + -s 8 16 32 \ + -c Disabled Autocorrelation \ + -r Mean +``` + +The tool needs access to the Signaloid UxHw SDK to build the applications for +UxHw, and access to the Intel Pin tool for accurate benchmarking. Arguments +`-u/--representation-types`, `-s/--representation-sizes`, +`-c/--uncertainty-correlation_types`, `-r/--reporting-methods` can also be +supplied using a YAML file with `--config `. + +For details, see the package [README.md](src/signaloid/benchmarking/automation/README.md). ### Parsing Ux Data Construct `DistributionalValue` Python objects by parsing diff --git a/images/signaloid-external-illustration-signaloid-python-diagram-flat-dark-withCR.png b/images/signaloid-external-illustration-signaloid-python-diagram-flat-dark-withCR.png new file mode 100644 index 0000000000000000000000000000000000000000..12ef2df2906e9d244cf617ddc1924aef222afba7 GIT binary patch literal 420381 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

?wNF>gEan%UQ#(-TBaDMLDV&VuIE!GqHGK#F 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%0D%R3_&e3dpf-UaJZt&?H*wvIxn-y8{IO5?5 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 K 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 z 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�oN$%amX&DzZ^(E-BGg5fi~sB2%YmE)(^dM^Z>&ptCyg|Hnk zGCbh9a-oD&eLEGFo;&ep_Gh5WcwyB+?_>JQ?i|m0O`|9XRC<*Q;Va^X27&~s>ug?O z6COT(ri9{#8Wtm-k?~t%7TV652+pOXSfg44dE`|hx8WOMi?|Wex7%MP;3aonh^IAzhREan{F4P(@W%ejq|I+9by3Wg0 zt`Q9jx#GxW0X=(c3Uy4aySx*6wQdg8$mmN7nx#K&EPCWN94&bFBiugXIx3_H`}Ixf z<$_*38AmFTv#R@(n9utskhP1%u;{sVo^y1sVLmXHR}U4j#mE=1E*;(Aw}Pm^=B8|k zM`A_TkV|ER3rSApv8r+dx;Q-80ld2v9&h}?-{Q#Q(M%S#PI9x?-(b~jwRh)2b`TW> zWIJ)k|kw^&Pw> zr-Ns)^t6=rAXLDQCDq-qQhq&eI&?F~I&HmpE58!`#r|ft<^?0-i#7H!z32@lbk@A~ zZ>_e)PM+6OZZFQ<6u&EKiGS}VwzBk;#E#PI?E^#?phjnX-FN8MMIKUJ_E7y)OmdPsXs>IY z?g}#eW6)({5e)BT9;0t8xKHr?lYVNvM8Q6+WGnTLz)>_e% zhmZe6Q&T|fYGOAiFA~EuEsx^jbgsO1Fv3tC8}uef-DyaOtU^jFjeu*55p5|T`;o5D zK=L$jydyy>D#(^bmZddqu?{?0ltTfDf>m%efm45ZM)ZDu_i8|OwHyY=(-6n1rE3mh z-od^mk*}Zy=tIKgCyzWX65wgf--Im*>sVS&_FG0pSO%j}>!p?MSWFB|cqUjEXH-P` z2!O*MdiAwPnBLN!)MjVg%5GyL;qj0I{b~9l-fBw698$T|{W_h2LHG3U?0NwOMQSiK=X|m7*9jXpVbKNbLE7RrWvv zmAOVkUsvxPMZ#1D5xayIcp*4`))<)7#mphOMOBE|xO7NWlTiiPJ#PO;mpJdy`UaV) z*Qm#E2 z?Yuui+1Um0e516e#zMv7qjfx^0&3-gOsZMbM#cS@%Nz}HGjm#_$b7fZ!Ei&`*tCj8~ObKUF7e4zX6GZ9yjJDqSk}o2*#DLFfNII zjF)V+Bz@HfoJTD;-qd?ENoHo1wy2r9z;Ip&E_*5cz7h3|J|rY&g8imbsCcX6=niOZ zzdgCCbQ?Us+5PVgF&%=By7%O%M3^EBT!9t0G0ZCH)lVp&o~~{wuU`#26KdzugfvmO z3XYGzv3PH7wdQXgV)SGD;2;b&Tr^7WXZ!fbZEzw+)#+g^$uIojTKd~ppsKO;;X(~A zn_wu@R4|TE^gC^q$d@qRO^pqQ=PNOSHaN8mS(C8diJCtc<%{o~F_=Lvh<%-mMHF!j zk1S7iB`Y&aXdJ=RUEgp)>hw8a>hUMUL_~ESL;Mumi(|&`j#u>7^kS?dGt-}~rNap% zt5PfWokdzQ?Jn}ha-M~k-JghTbMVQKlS*&iz^yAn4)P853AQ* z0xM+AjAs-F$BLvrx<2lSELV+8qRtJkx~nv?Q&852sTh@4uQOYs`HWqr6S%1du?mjo zo@7m)(<8vwV16uzj7FC)1}bR+mI;U66+D~24r6}h_xr}K7mhCwa1@6@3UW1^KQ5aQ zPO7KBQyG9r{J!|fGiaLmmPqRu8|uztqMnE|Pi_ROpL0nkan}s;6*F?Mh}PF{$Yat?Gd6E_WRVJTE^tcYV73ClNaw)rZbwII%~AU5hTTCdnoPj)T8jM zGP{q$Sn$VInGJsfLEifq$j@K;<}BbKYKT!3iVXi80Sm@xF7Wzd%a+L@tX({St9G6` znl>F@I3DOSP_C9cx^fm~M5u4*K$9waEoGW!JHh#UTu9yGjIjR8x*^_CNo{zd zLd|9d-z`69m%#)MbxCI1)M|q=dV&4uRiDOzC7q%?ZCyGUpI^|nM) z|I9P6*YBPeQUNFb0%a@g+YSN+g%_V*UpKuu-}76&j5M}y+0Ev4PxJr!*$oSAWa*ePiJ0hUfrV=G+elxgCbL0Pmnz=M)4N4=<2{LE?NVFqP-{xtaBxm{VcTr7FOkwv`Vn1b#C6BsM% z0aymC?BCP13iy_W2oOFFB0eOELx)m^&2W>?s@2Xid4TnXtc_eX0Oek27vzHsPr~tP z0vgFF7kwk$KcM0EBk&WnTxabUkR_=#8m>|w9+Xf$Ai>ZD=Wx}$>K+TPpo~rS)ax9Q z_qR*Fk5RM$R*t;zO$(sZ;~T5XiWkmL^O+@YbPP?Mc~4Qtix5|ax-8`u4$1TlU9~@) zUKB`?E&JW*F^$!G9yG=LZSI4@uz1n@;bIxKmF4T$i-b+V{B&+b!RPi@_gFcE{=FmQ+oE?KD;vT5Vw>oZ zB7^+IK~Yc3<~PyUbC{a zLm^j$EZ5WXy5sNv@!=Rkp-;q~O1`*T#Ml2)y-w>GNZawur}rVjTR8F()Qib~A-2{B zZDYr4hk(q^om`NC{ij5+=kJi`tqwiCVtDf6{)M6ivN7$3!xbbnI@$lgbEYC*E5B^} z)NY^dQaZNXow<3$_?fYfTYZR9ZmV58=GgrY+wG(p88tZRFJ|!mNSAo{o47}G1?OcX zQj!7J%k0Alk0c+a>4Aj;Pu&Rx6Z~>4zv>T0wAimO>dcyCWN@p{WsS)^IX*S{oQ&O7BN5mjIqe^LdqLW5#sVeR3rCVePcKa&Vv@9gjxcp z7wZ;r<=#~=YdJ*GOJ|D@X|`zi>NY1SYyGI*tDIw*oB3eB)ylW-)(SCoWp^46tLt37 zxM^|6hET@hOaZmJs7ZjChlYKWLHxwlAGDJoLBtTt(NwSe5Pf!NFnPFm7#Gym^FmMv zS}!}!d?R?VB-(o)DJT>B5wT$Q{+qZYluzBK&%lY5_Th!Zxsk_cdXq4pPFX%sh69Rb zb`|{#?mQXn-Oft`t?lrLR#&`OB!*9!rpJejmIkWuCx9}N%cDR=pHFG&RzGBGC3lI5 zgamu<;A$p=Mi<2uFb2&f!o?zW)EP6yf3dl35O3)cz$N5XvC#gAy^E=-XUur0Rn%#{ z-4%uw-8(8)O+?Gk1fz+G_|M@#y8l}Ke9PeC8id=Yv2pdm@4kZxh!v`0*&C56Ug=&- z28A76+25c@bFdX@piY!D=I8<#>Y@&C;M-ussxG*PI8eDHc-h1?OnK9Kb6j3Hu1yNSo>`}#KM3@&t=JBNK_z^wFmv>lOI@=L(k=v8G0BF zk~$k&${puXZ*$X^sa!uF+{_U>dE#rGeLL(J-*&NtUe3nOG+?KgsaR%)qG-F|2l?Qu zAgvPyPzL>6qgjK17CRv!k-zupWmR5Ze89cW{q#)ngymDxB33_i55-84JuszFrj1Ea{LRPGW5G7 z2u5YSlfYp-jdVTOvjWeFq^04`kF4;iV^^IL-)w$Smat$RkVMd(p1gb(O#O8C8Sf{* zMhNwJ(_2slCAMFC3Gut+FytWK48wF~hmnTy42O^Co(;FA;+0c^U6u9llrSHHVCx$I zVGigxf5K~Bi-5j+e#PnoITkdW@u}ZS2oY5r_#VQY3A*W(_%|iH0BIY{zZ>Fcmi*2d z>j?V+p5sAGd;IoT{Z?T7-}ys> zALq{whMOzGpH1jn+0yOlPh(ie0|?W(EZ0IgT}dfViKem!d)}pF6S6OLN+qiKEep@1 z?Qp<&x^>>HN37!iu^nS1M4`(d@NwITD=4O#IpFs-yB0Tspy)9&jCFj5O=gVI27%P;xp`cRENM91V8 z$E&0@cQ_5q!j_;8_%8joMYW|TXWcn+4a(0T2xwC+R$@{pR%->z7UIP_{cr!6Zrlkp zppM?I_MZ_8XXCa1cEwDm3?!H!&aGPK;~wp(G&0&PF!V>WCE?h0tOsdXtJ`8wNotL9 z)A#+dC~enO#l}wp>qv#9Pv-nr@H;Tn#H`d}QUCe+Do-#Bat4Z4v73IABFG!xuVMLw zkhHDAKIIBd*u3Hw+XhQBoAO1^++N9SLeBM281!B5!u5`$f}^(rj9P=FK>MgHN-TW5A8N9ZV>X6-@70AB9SxWBshbn>3%NK2jQr1!VDWbU$l#++%GHkQ9T!aOEdwqrZFm`)uxZ!9|4Z!yG|l%nUQRD z(qPtfc?}U-FCqq(SEjm&xnTclN8Lk^$?<{u>Tmf|;7S00^0x=@gi!T86 zpts`w3?soJ7CZQj=9 z$GW9UhRioai$^W*5LueoeH^YmuT2>PbdnQ;1gmPxh3a*^t=QY20*N|D%0*NMH|AeH z?z|bAjI~*)*((H-<1+48Lelf8By(kpOAcP}mJ54;S@?!3m#(}YilqE<@|dgIG!llm zf!|!y>hY#4WIhqL`?$Oo1e(~2{@l<#K*p8n|95SoXLY>!pv|(1dEno_6E#0y3r{Q0 zi+u^oSrtM7W4@M*E{*3CFtnB_i4@?hh)NM=_QcPy?lniuG%_MF{an`yS4l)wcS`EZ zk_60qOB)s9a~dk$noPqWmd&~qnAgp#Bh9s|RyT2dCP~~{SoTH-BYpcfOIY-@g@lFV z^b+^`AqeF$5=C; z8T5yse1nNCQQ>qQYJRMg^gc)(ZQ)8e>UXgjx6P9l;s)d0*53cLth;RlD;emK@)aiP zT4NV3$s6gu3bYx46B3sP=tCp&U0p_MZoC(zuOTD+IDcTl(77CLAcVBSI8ve$pvKX} z-S{VPxAYSlE8K`yNNKc_D2_?d6CV0~E|+OV0t75xHcEJnI7ShV7M+8iB z-frmgoS=$6VRwIePc%@#`G$H~d{yDe!4=J4wfoKMs*KMOZio`w2SNM#(d|g!cONk& zHwYj-;5e-B@_IA|Ut(vt>9LHsn7E~gkr%~b4g_K9KVz|59cF=3C-lfVZH3Ovxqe{O zLcDXJPkzZG203TFm6agHsZP0Y;2XfvnCZ42_mUM(ffPz0S^(BMDv7s|PLC`yPfR*oC$mW`Du#ifAEuZq8RX z+ThpAuJ8H8Esll1JVWKTDAP=C^|(5eFO@P-Co6t>fcEicXo3NH_l1d$83nOa(iic* zAq}v`%fUxRv$rAP0GpbHJHb%~4*tl{axTB!5l7ZEQMG60qz(2%e)sLxc7OlHpUube6iQ?9 z&EJSXdR2`-brU5$#^sQ0)F^AWC1w5Pxl+*kMm6!pkJ4_EAQ$?i^L5^o8r0I!Sh`Ew zTl4jrBZ*RiMa<(Nl&6AdwnD@oTU<0dqz=FQo%tJ4z9@HMFiVhv&)KT#`In?EzoYjo z*K!Q|dslNVVM(b+yn)r0ft~Cxv<-m?(R{KwbcH&9yW1{8H3Er>Y2M~s zI;E6fw5DsO{J0c*{9wW6A(&@iewH!w0E%w6=V+#=C2zx)R?G-mSM|Tyspt61b@X{# z_xy0Yl6CtKr7e|b3Kr$obIHDa{RfVvuAhnvZ*`8jFC2y-_R^N}c?le@i47fLCw@tY zt3bcH>h*ip?CYxx$M_>@+k!D5Zh^42e{u4^3KoEJmXdADeE8+HwP8G*$}QXQ^SPH& z5S^rFKG|lKFKxkd#LlZHSH}BKp1S*;?C?_$Y$ix{+f1scG#!-mV>-Z9 zkiw}CM|XI7+HBzCqS5MbrLzC-%Z4{(fmZPHFOr>$Ik z)P|u!Bjp+h@ZzC#V-4t`S90yO0yP$buCIZfIB5UH18;dzX|}u0&xSa+rG7K?=Os>U zCrvWec?Zc=OElxOfEH_=A})<@feCLW=%k?;O3a!68BvPf8Uc^ORod1|$zwaNRNq^o z$CK@v^+6G!B5H{0#GJ-BD$B4+%#EtZ z#u)BKJ_KD%Qi(ZwC=m>^28)J5U7Y9L2jD;vPIV@z`cb47Zq+&g06u_}vQ{}`R>0r6 z_ScB_!8!N-J_n@WMW^C2KcnCbvjOjyP<7n&dH|Mi^}?Y~M*+oCgv{3z;VW#47DdBx z%KL4>S3?i^UdcCrhgR4J>=oVzxA%Xs^IiQbu^>%!+7Uqc**KJX&;euw4@ z2BKgYSAY=6a9+J`tBX35fNhK-E^bRTiP~$vSQM9%xg-3Z7ZE^=HvL{*b}?gX8^wWi zW$6gr;+*`>L>l*eAkr0dq3tIy{}ObPVo_N+FsvsW)s}giuDR$=$0Y58E?#ElvM`9$&vHkDj$o%h##bjlj+jpt9_YvL|pgoPM!; z@DBy1RxML0_$|Hl2Y7S|40l3bZv4mbxjbh3vi-^uP2w2tp_<0Mb$~ zo^f|e6@5J$sJir)-HhJ4*54us;(094PzK2PTbD6FnGJYk zYLto%n6g-}SJn@#ux)VR0Q(>v6bN$&BZ2{`{Z!w)J~&d4T8~dt{u(~B{mybdD_P!FYp=VK0-pj#y;~ANI@CHpqkJ3ONXH)t z%ESb+5b-NBNu@9BJ#yGynNt-d216I1G zw%Js>SH`GUzUXTwvK2tWgtmN0Ngbn%;Vn<;eB^)V3wHT#Z9Q#geiVN6yTaXOm$#$b zp<=~hHKxVf)b?gbNac%GY>^NV)P?**j=`JC3|c`K1b8sj9DS(=yhp&cX>Ud`h(n{x z?S1=h>2w3IgCNIf*8PScU^NZYN5c@rRw*8_!OB(HKkkLk#uq)#VVUIYm3^+~YzF}A zy&mhtJR%lY5}jMar;#;HyZ+kEL9$&b>9OON3XeI88Dw!IsGaUq zy8f`yb0Uf7FfnKCBxJG}Xk_LyA@%&j!Fi+&?Mn)-mOpQysH9gwE7zBgxBGmHs(-6V zS0K|4jJ_8%8Z3BguoEA~6m`oDC9OVEG}R;3r>iinCwBIA;~ntua7Z_x{0cGi$H+Tb zN)bG#5X`Ws`{!x*U5nY&Je@NfJzQz}c@{aw+pcIs&3-o_b;n||Wwm_|3yifEB~>ic z6YfL_%C^CdE1I|Dm!|Ud6_GI`7jdy3vRxJ$l8nZbistqH2YA83?cA7_TolEZ45!8i zBmU#}*rJGe?7xfmR2=eo+OAPCrM>^EHyI@SO%G3&Q(FSwhg#7ktx{vK%4#RLCWrbE zZg$Flq9bIs%_|8k{;tu85}iT08za{VXCmayGBZuV$VEp1W0ba&JetLakCgd@keFVMb_wRJ zw9=yaho<_g&&;QOGwlmbqjnd+=yMEg5Rsds>&x2`pVI5i4|}8E4D}5_>k>S6<~X0e zf#XC+Cnt`+x*1qA4+2&rvoY`Zba)dp=TYLFEr-|67Yn1M?b z*la)~OQ3vXOAr2PcJIq8(hF;qn|J;=_rRNtp}upA)j?1Dz@pP4&+yPfhbdZ1eZ77?0`RZ4GV~ny_ze|oYzw1 z6WyUR2lnN8@C{(t@X}x(k6w6HhrC~~cBfb=P~9OA!LH23Vw8+k=An2s@Put-v?cGu zY&SUmWq9r16lI{s^4!iPp3{03#` z6XD^YC5-z$bMf==gSFLnk<)k1g9tqP`W0qYo(<#;azoLAZ0=l-k5F=Xsv4K)-+;F8 zZ=SEBncDNE{vWsrQ7R_x{^Rc^35Q&054_GrpL&Hu4%%*(CZvAP&o^uSt?raYna{`F zhiMgd+MC+gIi=T~?SqL3Nufk9a0{f;PCw2MdFA+@ixwZAbdD0Yoa)Zs&@V5|w6`Ac zew#6pdh~YA{iLo$0bc>RCaP#rEL(6(nT{J=wwbLS)>nF9pY8JAdimArhQy(CMppl8>4s@~r>4CBU>-pw<6x0P-Gm;{X8V}<&)A~J zt_ITYLTDbVKb*(hTI?Ru8Z-yJXcKKai= zudW=rkm6gDu$h*2&pU(PwNFju%qp8MQhj)K2|YO1n150o?R)1jqzMq&m*F%c&KN{g z7tv`T*{qjCaAe>mW4ls1*?f&-a1KcG;WTg;s1bX632=Q6hmrOkzLskq`^z$tyFDqR zSFB^E#Ab{0Jil2zM{>-jgl+zB^i0I);%u-{CBJ!OAi(k`h0SqreBt2|W}HkB2ZG@0 zaurLT2tcF*g^YzZoDc@@XjT|8_iFL6o8?TM@ddSEtD;*p9J* z3Lf=S(L!fL3{O~M*LA|bOLO!+Zg>^U75Qw|EMy=VWV~&OK4s^5q2KZ+#dcHDX>NVW z$zXhI0zgeps88r&ic0|Vu<}Ai-dUg{s?!UzwO4cjKVx%4P>g{&bMR;|D79VGMCh)F zKT1x-sz*Hw7qRf)J1*&J(;#rVmP+&iq0BKlzw?MT)kW0|(Ho&^N!)&PO-T<)lx`}Sb|H|0)Aqzh!vsi(0as_F?bP?z8m z)H<0Z`bl$S)jBx3@qKVbo6lbBs>ab>5(8ZJ;}Bkp%Nb22p=0ZTv_;RLo_qB>ci4@; z@AVK*MSmafXyx?K$HZFb+zRJowAdfDXC>p8p7^-f4_MB|1bh#n28pf*j zvtscRT7^6*crlDYp`1|Epx@9VaA$vJU?&m< z3$KQ0taNb{l?Pq85XmyZ&~T`gZ8o?26JmB*JiUbHfF8L1x;s@Y`6yS!$~R*@1xav+ z0iFYD9+*FB#r$PN*r{jQ&G9`JA7pc|41T=_{b_0z`yo(w;d~V~0&kax6{OD%20+h0 zg0@7SuIe~2KZT#rtE(RJ9gbtQ>T=eTp8a3P zj9%IV%bUeVY204P*v{3AgeNu8Z_e92od*4gJ6Mj3hJF!bT<3T`6%uC!h=ya=J& z@O5hGq2cHJr(e6=)G@sJ7*J`;8FpUiM|;b%gVkp= zsfU7%rYm_Ld>nm5IirW7Skre1B^yfv4fZB%ylJbh+Sg^(DRou*f5{l$`CYbyzOo6p zX8ofYxM8jK7e`uz2ery}gvIB1%M$a`{??vEYd}{ayeO=O{rdzU8uF?+K@kKC-hiQa zHAR~9w0iyZ%5;EHPC7wN+-x;^dM*`OQe zg~SO;Vpm(!aVjR*IoqP};G8(jcsilJXwx5Y*N>4MrJK$~Ht#L?F5!k0gZeDQ>l>DN(f*X#&JH*z;XG5Cl*M<;-Zq=pE+t0y`Habp@E2fZ*( zcb|Ainp&?h_P)^OB{k5-E*p&liQ9yz(y+vlBMdx@QBNpPDNK42e4r9Abxz`FYUa~Z z5NWITdhP1?p@OCA6Jr-uY}Kzz#)qKxhm0iIfT87L2C z8;S#Qw(BO4ZCQ66@ONKlVipp&UwuOR-4UYgf+L(4SA^_+$gbc?QQXu_unUI(Izl<9 z6r}LTEZ7&VM7Ab2nQAxF0R&t=owk11(beeJ$ut+;%}r$LTMCt&e4lU zQ;9>SPzb^|8iodkiu&tc?KKYU;zYHGO_M z$gk#I#r9sfIf?0TErC)*Elo!j;D@E#qgngdbYCVx1F;a4@~7mdvn@C0oE6lpf`3rw|&fHp_FKk4Zzw7zv{gqb~bh4Pmc#Os@TK|FlorrPjrFVP1ZZ2Jm0oqz=Uo)B5HWXWmUP*Lk!96H&Dwrfk_t{4mgw_j`{A z(Q@WPER(%TE@4ZO+@b-Zk`E0pXto@7Dh9j}i&aPaxPLXs8K3r}l0?rv`XI(+qqnGqPfTfLvJ(Y3{3(q^vo~gVR1s z#Yj!ysv6GhQ)ABZx}Iwlkael*@0EQX78nU8poGZH*J&a$4^ax1^#A^GqlQKW^qj{b zMDK|R!B^_N@G+SLT2uc4ACtq!EVqua zeFEPYbbb9RI<%nhE8;2zQ|688i1lj`N*>U$c<-6X^EIH?{ch$HVg8q`eBp>#c8E~9 z7SDNu33`HSf42Gp2TA7j5RLbrM7;fCe|L9(Helr?IB5+ob)+-?rQAVGS3bRJlf}_N zVuml&?y2>xRpt6;SNySAgHZ3COeyJ--&8a3%4;lz;P6>Wm2|x3=@pKvlmCsqC`$%- z9aBnomJ1@35f^Q>3jB0QD*4nNRiFE5tb04l&9yVm?PuNOtgBB!_^^Fo-s+gv<5O?- zMQ{1W?0VLS3X=g0z7)XJ%vxV0dC2fj1JSgco%ac)iS2dBRQ-){My%CpWsxf>$+>#- z6a876nD3?9d=_Bp5y%N9-c&_nIpzk8L+)}u!Ri0K~QPWw@bR%jU`#BY`cIUbGv zck>C^AO-vYt78zpQ!0-uwg$bK@IYy|s6)SN^^@>H5+TzJoX>87H-&oQHY`zU@JG5| zTq!N;lk%Nd<{l3;6_*Zv=lMc?c$iDzC16oIFQ?^1qa=ZRLaF*s-+3`=e^;NyXuZ${ z{dvT^PSK^ID|DjVpdrnK)?fZhp6?$*3;V*Jm(4aoqh*$pbLSTBL89TJxlqSXI=NLa zhrG_}l8Clo{AZ6MeMFEf(P!hV6A1pV*-2oeq7Lfx%GR5R^GMNhYjS$T`Xy>_jZitl zJ6!-d0F2yeHp~^zJNO-xAM-n$kvkBU_kCXp4y*eh^{S?MAZDuOLi1y)nyoomz@K9_ zNPW~)HwI>L>tq6l~6;ghL9VWzr|DX_QMILi?HxQVwL*qNhzw%a@N@Hnn;i*+x zIB9Y&?lxO>lm+QA+e$_5A?2IxXqHp;;hGv%Jj=u4i5!F9ya(|MLkv~V!wO~H`T6I! zvL)RL`+wPe>=QpuuKIIup{*>9-K<}UYGPSCK<4d-^(;RzO1Bk?SpS|i`NLZqgS z88@H!p6QnqA@jDc@ihb2<-e8#?{mQe6$+j3$e(^7Q+OWs_!G~8v>W3k;%~y=IHJky zQ+vOjL0ysZC#&FhdpODRrKPOUjyEcGzw{zcU+ENwKusj3zaYw@xD%Ab*MrGElIqQJ z?6fwmyRoIv$?Gh8?*6592t}*h|IVSK{vc0`-066~ad;6#=er&!p!r2xQRqV>pde$Y z8I2tCMw?7POf{ZB%7er*+Sh_`zdr|RuY9Z5ttma;%KW+Gre6S#nJ)u?dD`NG+5S%RxqIRk5s^4WGeAJw%oLGrtg!8 z+X`ulSA#CC21o~oL=OHdoksubf>drJ|16y}vV>4x8eV!LN-iG}vw8U&r)Wf}U54nW zUcKHiIR*A2o|`x6FL6znk_r>UHejv=DTv-+ur*XhoD&W+g+Tu?to z3JB^s(s}|}mi#mekua6~%CTBy8{G34B_6g&HSE3yi~d`HF6*&&nASU<2=_m2U0go! z^S>$VJ6O8jzUbQ%Vxe>%c=s>Qufcn<7ZxZUhEL%Zms3=@hoRB4+Hb|b@eMikLY1O? zd+!JRZHZ(io&7huINxUULFs>z=oV#KF*k&s1ObiS#Ar(fANU`S+;Q72p}fjMI@8_s zuFYkKnm?2pu4rrU7e6~DOu|_S)e%h)|M}_s+OeD{e7g2GL_UOwxLJbiiy`Jl`Q2YW z7x(p~&uec2<{|Il=4Qfwyz6{DICs7QvEVniGsN%|n6!IhJ8xyNZI6sgoM%ZQh&b}? z9L1}-e1EHymWE>z#U!z-klyzHq8i8f%+{in0q)_Yy_PCKA9EW`$S7}rkTTIzA88{S zD|id>-IEqG1RZ`!8@Q1vRtVy7kTwtGeJA;>y%)JvO1+C)&6ZYHwhA{*=|=7k$Q>go1qx%eUBxHAKwMDp^9|V-|%m8_?xrum@jSN;SII?gyJJ z5V*)v`V4QxXH{+g`vbJNXd6KRHek1$rlXh?n=^3Acn#SH4$?zPW3uqD-O zeSwuyFk;T=Ay6(WeGD6b7pdq=p2?(us>?KAVwDWQPv9We6zOUs`Ps*9FveF9qR#GG z6EtSIk6q6k1U*6&8sODH%OJ-6zCO(D*kr$~l_0CTJF zr}t8K=Y4oa*6qG+6UiRwPuN=rR1 zdNncDB}e_oDgTq)z>(d>9v;=*nPRqFNrEy4gsW661o(p#238fUVNnrDB|^LkkK5J9 zICT6S>#bXXh1&ea$1^jF&VUeIDN6N7WiYw7c8Zu`PSR;x@QO9TtUsMVAsLJ;WyDV% zZqsmJAA~PoW(YrA&QyJz5q1r_zV zpK{qqj9 zGdSEB8S}RpSnBQVo?0)4*5uR5XE+~EQV~<$Pg+SFNYROQqVk-7t-?kXq!w-;2M0`r z<2S`_SdnftZ*BxjB`!n%V@O80c{Du`F>~3*w(Y-*dChn@!b5rx*V`VW@Wt4vI_1ok z8+6CvjN9|dp+maSmyw3fX|_mpy+7Juv)7K1ib|pIOH*p-4G_3a!QpvJ2&}(ofYaGB zX|KZn^9A$S`4;q3uc}aeN~eHz*xG6o)q1>gi+j`RN% zez^>!Vt|ZV$XN~4|Fme7VMMAf{hse+dN519G} zelS~E+3A-Z!`1yvsDXD z$;uDk2poR@^Hals(6dvtzorQ9h$LXAsJFY5W_7jPrNpE(}}eg*hT*W46KbfsqE7^8Nt;KfZo<*a!FJQ1P4&+ikZ!orsCn$0+x z+GI{f##yy4;cS)G_tRM!9TNs}Z~X7CRsaL_XSZb@aRVBQ`6k_EE8&ejmcNi}o+J1K z!vk#(y1PaEW}xtn-(#XHS9(V_Cvm{AcQ3BUWq!NF>V6 z&ll|=pO{FhV2aM(A}5}lOjUNE!ay_ab6vt&c@f~_UwD^r=e^yk`!lK)$R-#{1uIzA zavqKg;ud8WE(669b+ocWxUj+fn#IdVkB$Ho%%bAeW5&HjW%;4|NH%f zakM7?%~%1@LHNA&qD(@61!$MhHN9`TD6@1XF||Gk?V=Gq$7{CWS`R$fV{W=)%}fKPQupUBsG?+72CoMj{HzPNR!?0J)N zOq>7nxQuBvIHli?{C|1LQ3pIwWFMG-_Q~-9hkcQs1?|-WFbPR5RLOam4>xP%lQ@Yc z*+c@c5PHzP+a)pV_M9r{RrUwujD^TZp{*)_n!#p1Q9dj^MqxM+dKO zPxi&M%>2s#+N!8Dm!EL0G`P_dWJ(g4gd_4Dv4|`UFUK;0(JvMJt`g|urx0b*qIX$k zug#qSEYI{lQ(4buQbF||LvP%!l^^#IxD;j+>pPvn^ZJ&2&d!A851y?hf$Ejh{g2lj z`(vF4%a$L%&I>G|`&npltmcW9VslVuI{HTTIlJ=Rt9MVhTyZra@0UmJ;Xj$)9iekw zj@H9j=PifxRFheR6p#a-&wN7z^K;MR=D=-Cj_8;;QHE@BufL}|b#bC7iG`W|%c_*) z5Ledu(ij1kDuMFSv{ZGHT%x9Exc3|dtLN!#q*di$b`gcXnAjl~z5N`vBWYfC8J^4L{W>|ogGx94d`ia#|qt2ZKL z8Zd6!k^QNAFvB;gH6oBrtN-|(&zYHLM?4Q$B`Kr1A1C{-4qGX);U}tJS{9l}AhbHB zR%LXGmP6SSVm$KSBa#ym8*|Vf4y~5g0z6Jz1FjE+8M4G&|4tSIeP@0*=UJ4hnZ3Qp zmXt!%rP{rL-RXp?g8GZ9y1F^Ul-(S4B9rh|Lf^!Q?{1uw-{<)e1s@Lz7Q4{& z?>|?U{yn{^sjmo_Kfkwc-K(y67|Sx)6?U+3%&~tnT-Qy~>eHHT9<0Pqt^8YT^L`uI zdO+uMur^^Sc{v=ecHm{vTRMpDkfA@Xi@SoJ8sxOQG&ZRG3aaZ+@gb@UN-J@%3i3(M*?FV&vJF zCDNj>)`GeuaElsPdAKLbu}^ehn@qJjGF_Yhz3+nzI(I|HTQ?Br_#B_7v8*YD{pq(tV^IRWeC3 zm%9SQzVcUhl_VK{==T6OYuBR`;Y{dHiISQXoP5j3HRuHJt2HcU2<18;R@bgX(;T(I z1p~1iSfaC6sQ`}m*T?Rm<(sjKzC$K1vHI$N@G;yBt5K!@kELfBf3nB~s=TjHll*?! zwGW<)Q(B;(lRMPMy6L&wf4dbu<{SM@_a03q)d6&tZqBPMY=kv0C%~<1)?pMqNcPn5ev_&kCnbKdUZ& zJC$Zx{fUYntJyqJ3tHOM)`!7NoFnRI)7|T6JnJ2qmHvqpAD@ED@2HPEH7qPrQrwHg zQrV#Dnxg}0Ih1a{O3=q<(Hrzcq@#lkLDQJXrlKpvoROC9rLR->ieCxfR-9fGWRSjG z_q2Vc^tISfBfV0ho*}FBR5@hQ2$>k}^irSO@Np*^^8++UJ`3b37e#>bH#11kR&|~U zop@enZS!5AU#wP8kLA#=^e0|e>|kEe2pcJq#Y9_AX*3rElrmD!qnmW%`m1mtVQAA} z`+6}%%(uJ@);Sz<)u&@p>U*Y$v;|EbEuxMgDh;Ng%+tY)T?Njq_%8&HMs)`8M0X2T zm1RP(wreZ~?NM+Qfn=HV4cfv1Xy%$wb?#eng&KRN7%9VN&Nu41(OX&NA>v=TF8}pE z&$7dch`}x14EyYg<+B)~v*A5<-!sSo8?t8kCI`)ADXwFjnoW353er$X_AO%JA=jgy zut4!i?=pwtD>q_?EQjaD#HmlZ{L7yU-SkYcvq6okobrbnZ=k)8$jQiZfei#2T`yxX z#qm-v#u9%yW**LeH2C^d59RD}J|$nCp3w5M#&sO*>6pGq>^{O0UqlG@QYz`Eu4rZZ z6QF=6AHyCN5n=YcVopzrmUt?OFh(Zl3mz+T)Z2eB-8wqHLTUw3v#Zy;d9PPqWPRa# z{zwQLfYH7`zdTF9i{(-BnyN5xe6`nf9rjUo(PW@i;r|?BamlJ2}hKQw=gp<+n&b3SM%zZto?B_PZWJ?GCH?u%m0V3w~mWC z{MrT)DWw#H1`&oFx;w;xp?l~UO1g7s5Re>TXiyOl$r)N&6c|!O8p)x%8}{pOpLh4& zeV$$a^P|8pbH4X|opZ%WyRxXbE-3Wb9;8K*aAd~ISC7wlZVt@_8c{3)%7GWJ+Gl;o zr7z8nOuGt$qgt~AWW;7I<1>-oharr35o(_zF1~K|_O3hI1Kp$Fa^ZcE2JUwZ1P1KWi|$3e z%(9RPX7hIJ0#Dj=@-1^S$V+q{&Mx45jc){umf@u5SPZ!#tE!cdWmxW8ru@f`CkI-l zy|r?T!OnBTyioU3ukmmXzpk=L977;yn6E6`N=%&jx*IGk0I=Y_2qorhUv2zNknIns zK&^~z*{cqOHZBXBAZ0@)G2KNBRNy(7Kfo8Mf2gEq;BVBuy`g!OeYo81pH6HX*kqC z2#60x@SQV?s8g#+Sc;=5N?yHZ<;*iLHY)QBZM4azApvKSIIAjRjeFKo9S0v}LIrfY z1o5+===-IwfVP;#n1y6P2MZC|p*je`Lt)FJ@2I^F;B+r#;egbEdlCAiU=uIjn104Y zfKm1d=|UK(7RZyjqVqvMKr=z4q0*Dix99)(T48cL{=+j|8jJ3O`|OOdHgF-7WsYM| zBjHFd&Eo7c3?B80IYsP|sF#YyWB`;qtLDI;7s#;68AQD7GRp-OWUaT7S!2kmumtg- zzN=^AMk~6Sm4K~o@GDq+%jQcKi125n9`XEpie7{^kn=g@guDWTGr>12)7VFlSEaMJ ziyi-R8=5y6&<8+T13);Ejjb9M^{8H}%N)NKo$KmAUH|hF(Y&=E|LUUQvJW`o?!Ilg zZj|i;XEGbTiUtz9SeQ=UH@jS`S?+8O z_Gtk(?wQG;N88thK8kd|Xqi3Q(i;E!Q}Tv0?hvmV_b2%LN(W#dO}*HD99v?x=?_0M zi+t$^+LI*Q*NH8nWN?(#2OuyoWvTbgR15ri*e3#37}|;zIsg@W_>KV~(ND~PQijS0 zmXI}H-LJw@(^o_N|K#LyYP2FaaUiVz$F`N^K_~0TUFm05+!Y4fz*QQRHJ-0!faYFH z;D)+WWM%QAU>guMyfXwZ(LPAf0lfhk^l!yJPIr;v596I=?)}Q!NPLN4Mp=-!@!uIm z9=F($t+<+@%gP=3+wiGJ*8C5xBAi&CM^= zF&uj!$_A2Fn6qk~v}}%z@>4K%qvm-wI|r2EX_hq}uCFxy=W48uI5J1az+J%#qtRt8 zHfgPc2*u*X@hQQS$KV+19mt6#F|(Nf{auW!e${Wki>dqg7(i*A#2kedItDeV00s_8 zpiUp2A>fLWFyXO>Xf0DJx-=u9)Ly*Tg~*_N%{>rgQ~W39K4kC>G7gS;6b{Gqo{3Mj zVm|=>ho&`{3CTld6!uxI1P=d8zwm7GUnyqYw={M*(&2IZH%xg^ zxAD*7w(po6VK5a4l47+64sCUfDu;fA?FdQtrZ5TkZ|83zaQ)kVv>I6Kq_cq{+6}%G zY_bh#|3k}j$p*={>jlQIzy6H%I^hIJ@9}edn?WArjL4zZ@0BE-*41*r6A}dc%YSf^ zKMGr%@27@S&ZkC))pY{cuFY#%-|J&hwOkpGe|?Pt5ox4O!h;vtkHcFM7y@?Jr30Y7 z2Bu{IXA2BC5L4f|Sh_*A$={zcFMg(&h}5B~>6g+`WLHgZw)-2(dhuu* ze$5zkmaSbZ<9E6i(SMsB^rbL>W zA{WHQ1=cNl5(4Mjxsc`%Fq>W8OZ0$<*nnbs-H>y(xRHPfSK7MiKvO+@j83ZlO$Ckp z)k$l&O@CInSZr~HuG!@60faHKBerjt1~zJDZV@G+~|(b`})!{7Z0 z5~Ppcb(d;oYY?_TyLe9AKkzm*-PW_;iu%T#BHpEWa|e)pguPGC^IK`0-A;VuRo)zW z%+T9ic439X=HSfa)T*2Lc+Aq6IgBY*BAX@@ z=B=Y;j--Q{a&IF9Nv;~N<86f;M^a1{oYRU^8wo{euEvEWjX+@zhaH^ZofA@nugx(8 zhdH{`kijokWNxg2qXrIej$(kc?jZt_{larsssX(uM5kse+}-*L|7P` zW+mm(!Ic=fWqg2CjCc207w=i9j%kY#jt!yji8b;eb5g;R@7hKJpk@V5XR zc+g;p0YX3C0@)u1ZN7(2x5}T;a`g!`q{tkH%(1AO(g3x$0KFu-Y`8far>T2HQj}gZ zWI9zFYd7K}0zvrCPXqH6XVyMjqa5cvIm(?1fquh#*=RV~~6SYxFA6 zhzU&Pke?%#hFRbI->3>}AP+F%=OuvZx@AKxdh;jZPAUTs$noTor@_BOr6%q+IgJL6 zr(`_URA4r3?dtl#wKn+CvC6LBpcyUQH=rxN&h@$UVKSPA-`dN%&YMSf8t4%P1GC6u zed1doEeH*t*qPrC5h`7JNR$ctjE9ND2!OV@Yo+-4=?y+)WaDg63iORbOgg8`=i zJa8igXp?JL@`o0wRg73oJh#>Y`WgCtLZo}XkCg}8G~pLXF+adT2=eHT;Ul+JT2E@H zAuojX_j~hb%UL?r>{o(TY+6-bpwDsBW2d5XZIUa*xS$99P>)<#m-QW+ZCDX}h0gA! z&(JZ&7aS?O&5X+()KXbwr^-t^G4ajHWZEYKzN}iAbx@Ca<8J}22S<6fke?QQ-dlJO zT9F_l1l8|(tKF93Q<>5hpUV`mukey|jsdJasaNh{e<;nbEJB3z#FCTA1tksSBpQ5Q zfR8*E2-FhIPkgE~uN`GjpM%hcDnxI%l; z2e85WpFUbtdB;7l-{VC+e420)FmyVFN9@}kL`60GWzUTF_9DrF=FZi-v*EN;3g1h8 z!KAWkn7%bedE%~zrHwDN+sz{mMEMvQm|=G>;3wOAO_b<^n9!-!t`lc6uvXyNgv2~yJvX5mg7`Pj2WjG#We-4!f z!ng36P7Dsd%jT1>*vmrlwu#r`lN%Xj0b-acOZg~X_FQj(_dhPZ-NNBVek7~B4Rt#M zRJ#A$B!U%aexkg0rzS2tz1{^Mm>-$U%I>pFMt<3ePt>jbUtJL;gaGk`VqW46fckJg zgjx7(mv4Rx7;|j7B@}2xGqQD}$eKNq96_&*+xDHG&o^177lH`tl%!!PW${;s*)eyn z(Ni+rP0beVqj(#6Vmqv!ewLjO8&ZUiq5;vzXNDkh)LU!)N5c9f%%^| z2I#T;&)4XYs~w=lQ1Wws`neXX?LTszOZOe2E~qXze0_Rr-r3c(+jKi5BX(|NUi>;S zR}P>TZjwB$P)Fys;nVfnn}=b%yr!4OE`ev$DRNr7ZM#g<3t(s$96w1V?UP6=}q9?h;80%bP7g0G zZ+36z3&RN$>1D}klO<1nrr&I|hvv_?_vQ1wQuE-mLe8{Hmx%S(+TTbl+hQJ<^8+bD z;rC5|h+=ngLb34h!1`KzOzuNCMl-N~=`~=l?{2cW6*a<*?$b077+lJ*o@e9YmAL=d z(UT>BEB?`18o-hy8H+<+og*Hq4H8}=u4h0IAm(s%wkm)pQbF2?UJiFOe>b4%8u?=` zm~3f(2SIyC!lM2GEUxH<$b15(tn~X5B6lq_+XnT*cWR01P^2M z6SRN>tG)S9P)d#B5(3DET}_)GHPr$}h(I%jgMGy%2#toi7ye%!KwxwTJg_Ls+?Ic0 zv(lZ21|Bdq1yeewN-{C-f&a?UI#P*p;g~z6D13Y}(Q&~5M#=rEMrBM=Ol+dKF5(XX z?2lP?jpF6`=GY};gsP#_6BLZYzVmqZ_bh)R^Y&drc=ige+Rpo5^~}Ih5_ZGj-WQkK zEAr)$9xMU&Sjh-bXNl%fUvcLbN$*=oC5~Ea?2@;P*<6#AZHW%um7{CNcaW4Jt1zyX z>p_9^kY?A_`mPaAb1DjEGi!ZgqHmaz$$Q>@11xPk{8GmNDPm~Cv%*labfo9VugB{m zRpbTitG^?J%?@tv@>0J4sa>pIJCHRXkcd0`VIb&q;$8zaYgNLhltv(u3m+MHaa4kg z8F5Tb%(RPaK*QmQhuV)qo0|0ZRR!0U`=I@qP9S1%NO|1n`>imEInGtB7pzTbo}VoX zhtiE-vfDHARYnH=11DFTy-DaF1+-o&5TU*-1V_gqL9o<{@hDsm7IHs%lC( zTecWKv+TQ2frw^+6T3iYPo(E~jEEC?CAn7dLea1-ND)}v!j>Wf6dn4;SAPaF zZ=1qEki54IVU9$L78Upq?7Kolay6_T_{left(fO9%r4`Eo#NYb; z08he_bR&H9eTLtkyM!xeI;-xvdOe$LtMu23GHNkQDpR(=XbDW$f9_H0K=h53EMY%v z>XLD0e>YA9Uc|yFDBwkkehezP2=df5Uh!A%M0#Ka&}RLo&N(eH)%HP)q(F?qZ`{|s z8!ZCtt~Fh5p?9oSD`xN_h26Kvy-AOO&?`m(I;$#GImu*Rj{5uT(vQ+-c4 zxvjeJ7%@Z`tnR%&eZxZPK^hUW cu5%rNhx-9T??Orr~c@ju zX?0-gUgh$SW0~eU-W%`9+s(2q;**Uzjs;EVcZnALWC8ZnH#DbxDuQ)=hTk-&uHc0G z;(UtSd))LjS+pD`ciPkPW8v(R+%kAfh{Wud`xGDbtvG)6fyn3BbnG*Ho8_=0t^nlG zUDij`fi#t<(K@Fc;0KRT>b2_b4Z3uPXyAys}g+XL#` zN$cg1r13db>;6+eOlzY-08d!<;va#^-Z6o9mY2FT#P@s$A{8`+lgf^P7@kRWF}76} z#a1j{6%2?YmM*uv>Tr7oleWm4?}5w2f+uB@vc>|;BEOsh8NzqtDGJ5v_n&B@O8`g& zK&O{&(d~$i=JyZED8>B8lY`9W*9L&Zo{8ApCaQge@VsHy$8uo(1^TA5jUlNPL(?u^ zj>1lYeb93(s&%+YfVn$i;xN6B55j8n(MA@6ywh@xw2n9J`<6-c3B8so*Qn_~LPK z)HeL5ttAnKQ|%eMjhuFV=MSX%iHcEw!kYx8rQfvO<-Lw@l=j|Y6k}QWA~w|7DKhL@ zsV8wdxZlQm5R_yM+Zl>8$HZetlx_bODIhTR{XS;zpzdAC=Lks_$O=8;jZK{+yvl&? z#&d534^3IUt{|hU%;WFo>w9~1_g-;K9`kw&^e~V;&Eve6djv} zb>|b3skY)dIr{Zin~uduu8w`i68KE1?r42fBtU}a_l1f|dM6aZ(Q1WSN{1|wTWZ@=5}h<3|O zYFD5%v>TIy8Q8`FbBs%bIA z=SGdw4PM{^CHv_(75bnmhG_3oHH=yF)dbZOhgaan#+lCVW-B;_PZxP=5v7sMbS(C+sjFe^s&mc)o3iX9V`WidVqVZ4a4oHn?sb_om`!2(X zoAWaVJMHHlZv4kY=A?uqA=0~B`T+6o35f^Yl}>XjU)Eb$`&%$7Cv$q^p7?BF#e7^W zN~A>3-5A;lz^9Ye@R6-KS0Z%9gM7v-& z0Cn%7hJxipAM+Wa;#bcuHedHFgu$e~p$My1bt_Vt280jYdWw{xXe&z$T#|upSv~)p zWx*x{B&rdf-i^qgJ;deMJ72v8qJosV5ZMnPLA$%u0o+WpIn~n0nLl#!fRD;s0wV8z zh}VnACIM52>;Ni=GMPaH^hZyBX=R%!n5@m?C_|B~t{0KW0Iw92y6TXHCsi?{V82aL zLHQ5ClLH~&^zS33JSsYu-p8J+y5Q0nxXYtqcVXWH*;7G~PB$D#9!)4nozx1m`kVoI zP%L_4RH2KEqmKo~M%1~WzElhY)k&w7Di!HLS`H2H)o-S(9zoQAOd0N>jPUDTg{}CM z9&jU_?+W>QA?o7C5oKuD4wcQb#`ed+>vW=HqXZ103oy5Pu;u1uj()_s>AfP2iX72< z!VJ0em5&-rovdmf8la#22-3QFBs8VeC!ek(60%EBe!2k26GboO-Qx}1Us++KIU!FK zp|6_@h7bed8Rzbb$SuP7&~Ywp0=DA7@p=;P_^_1lzHIWx9}kpiV|M!wmP-lhljnJC zFgI0qw0v7kK#-ICqf%AAZv>FT$sTWoEHi2W@-amIji8f> ztFb%X>GtNjVY7F;PcBXRLWQzMBydP; zJOKni|EFuw`omOTZke?)a?G}ocxBOU#3rsC5D0VWHT(;b|DRxY<3=Fn!FP$CM2C;5 z-+Zl23X{Q}iKWUwQOJm#21%gj;0(9RenZjQ&fuV;qQ9l}N&Lyvh%h~*ZHVjhn ztiRT5xWFip$BcuEe1|54-_R@%#9R3ga`Ll51Q3Y*u?`xK1fmM7D?h-O4ctFkBKzgg zo86doBCxm#J!B9-De@=AKM=Y({b$OC0cE`xh-wLl-u=?1FKdzScGakT?h#^I_lB2MXTU3;J-h6_=P4ZMG+6&ni`Af?yLmEg;2ffQAs z_>fZB_)RC-pOq}O6+=KSe%pC3Tl6iFBsxaF9Rel(jZ&M5WnLevh^sLGRj_Jh++8rx z_))Cu_-q*9J1l|98=EZsUlUXy!Osl{q9Y0T38p6F7ZZC^7i9;~97MZAC=1L}RhjE& zZQi`}#;(T{=(I2DA=p-&O%LEHHm+)*ecu94RJt5|UpBqYQ#|Nvn>HDVpxk_DJumZ^_za_R(djXgr}7;@If=y5u{rv_;j)m z*j`!gS>Z5B^PxkKs@7}5^Ycq>=An-<$&jB<+7a51#Wj$|G&hcW+G8~%3cdK!@QZ#_ z!ETI{A47`iWc4d0#om>s07k32t>9aqCUF;G#0w!-g#AI|r`2y1;TzkZbP=Fxm44NG z1Yi9=X(uNM6A7nTVBV#xZe;bkq8Xns4-PMXOn57YmX&vhMt*{lFK;jvocAhq9mg&5 zeJbcQSBd;dleo*dsc99~tG=O#Al$}NT_CoELzC!2LDk}xA#$7#memf98rhZ{pcjj4Bh&{o;#nbbVx2L7gOtoX03K+-DehU44(0N zJ;)LqPg?&iukB*A@@who(pGgXWV7jHs`wl5r|Fx2sqFRj^(Q&B)+o{?IWm4dV*-~% zJ%GrKHWWskW*8Y(%+P(vrO;gde1bdk>!S|+R9+~Z`fpEzk>pa7gAHN~q|x{8e6c)1 z@$oiM%u~Y~deQi%M@`(n3#c26k{!j$h=R84`t7v#nM~AHAt#;olG^CCCzcr9=UrZ6 zP;_LSs_u~KaAv!#r{~3l-?I%nsys6obcGH3u}7H z;lMTTx;_}7e@8pEvVJtA27iu|W9hrpsDy;@82{-tjT>(uml+oU+>aAlr(a9Y=CCnkHq0C(Y3mRZoKXoMWN+sdYAk0!daSN@xwdn0@8;w)h^BFOSeZGT$%8JMUW-q^4)=0$70Mh z6sxxx2h!gdh$UggTx#ck+x=w=%@5wYy@LC_{U+2=_uD|fw6atfa6GC%u=|OqCaH}D zsh9NuV}+B?TISUT$Qks%jX8+byWmW8!xK-I-S&Iosw?KvHk#tK6+c6~8egQZUv$yG zIkbBA!f%1I1%{DKQb$Q5jftyaycBy<*@M=Yk(W^SG(@dkPM;;lIcRzP6Sxtkq)iwL znjU>@(_}1>#fSSpu|({5yM&+d7#$3#pk_ zF}`iup4Nu^^^Iig=t3NGo_2&+FfQ278d$_Q^m)4qtIV1W3K)KtVe5`%D&lF2d&^0a z+{LE@A1T{lq5sm!tudGwv*AQd7b>u3(P(q>Cw2PxJw3HnCV7yuGKF?3uT-n>+sz#g zGLvk6Fq9K$^qN#v&;36Izi3v_L`q@OIuX|%I)jQRpiBIT0SXlo1FFnjsvFaZ2EC^m z?Ahvjp~a{eT7ZA_5}dg{n-)Ok3L}OJSa#90B04Gr$x$_6#)w>tNQoapL`v{S>_WK@ zx2Q8e>sC3?EoIfVe~W)p)sEqTj70z{LRv2)`?TXb4=>K{Mlyb9K?V}A4~X0WA!j_| zpJph5#X0PK(+mza9Off@HNepM(UD?xmke=9jqRdqMoE{$rsMp|=8Ia<@N-0dz>c5Z z!tr<;F|mFMDCJ&AKprnNB5qLiT~6sJ4C6Q~I0TP5&Y^dz)83+!KHAgtbnGl9sm2mi zE!cq1FQY-X8@X*KUD+;G+`Ug(AbHQA?ifpq>Jp?vHMjm8w*w)2n4SLwtc-$X(0FXj z4D5bePbYtqq=Le_CxK+o_L%<_s7u>Tt`_hvv3&%Fe<2fqhfMI@(RCE4l2=uzdt+&H z;~2$`l)7htKK@f%44&+ck&JQv>w%QgjsUrAMS~)cx{TK~| z^_;)lEf!SziVj)!Vv=$XboY&M1Zdn58Ha1RS_Nx&ut!Q+S7SNI!vFTv`on z5BM+T4s?{d`+i#4zcFwZPI0k+4bIHh$O6?5;vkKe#TtzZcX`K>c9mi5dzUtF^YJv1~U>Y?2)V^7)DQ*@BUFX5mx=`=tS&%#^sZ zgX+Gg?d{(-^l>ZqX~oPoK|<@&dU$bbZP<62YF>$2K9+&kX8w2*K3<=!HK~)WL?>$7 zYr=%AQjSFRlh`u2TW*HUB2o>5Ee5`^t~)SXh4l2s9e}D89&XXpdCZ>;5c*O%{dNsJ zJ15j=?830a1%%YukSd%gtl$fvF6saGI^lG%T;R4fyB-UDu^3tfBc;D9cho$vD+9lH zJ$HFSUeU7Suld~W)5q`xy!4lP<3Uxhe9cq0c4SVnLGPP; zoWYxWtbyRh&jx74htVLee%IlEI!w)vfC?OPX*S5{y<}~_rJ{PJTT3KwV~LY!+J#Q- za9M)rWHAx@b#G}UO7*sLow3DwWExLYSISDuC&VesX)~>|O^+tOHv)Q)nxLAC!iq9M z23-#hW>4GpZIEC;f13^q%p`N0ho~75<7%2loI}f-Uj=cXO{(z`>IG+&RGOv2i?fQB z-Jvo6CDNA-UOn9<6rY#8cYf35fPV$u1txMKpv^LjR-b>~x&#y*Z`H&(r^NOYWM#~y zAoaSdA)ynq52ki^uKk9GiQ=CI`>6%(tVM&yrNK~H$tW72V>xZI*3wlN>-SkcuZvjVp;lpC(X2x0xuK56rT&S(xMB$ z$dhK6(C+wU&uzb`zJuJ_q@9^@31nrQsi1*8jZBW1_UU~FsavddpWkm`j1I)S`*T-$ zAY{&WIc*&MYb*_;C0reoN^9(xwtKx75PaC_F?2cUE^PH?^i`2^fo_Fr>er>rw~r?8 z{_yu0YISUzJ;SKy`5u&%z zlD=d7gh*Nv{^9Cq{WiJQ75jx$U*N)hR+0xM+0!+85pQdM;tKqYud4Yu;~zbmfnGfA z&6~Nv5bn!t#w(|iVIJESr!iImxz-PD2EtXp#p%Ia~G< z@p*l$<2nj*n)l6dTS=N`1{;Fy%jq~TAq(8XUT&-0n78ueG613kkUowa#o>M9%>z+4U zl_dnY3bc1!#S(zf#=~|EIy&Ad?4p0hNU?hIRS(~c{h4gPcP&Ct=4c{DmN0K@bM1)|hxfVkIpT618!%f%U)LFC&pQ zm;h1;*~gjDJD978lhz`qaR1s9~Gs~mqJLfES1F|@TSb1X@@LEYf34Cr=lPLxnG+i;*0z(OK-f^VBwMPt2$drqsGf5*w*IXCf z6F<_-Hoi;$+Nln@5q5TQ!(+VkF41&t1|1uPfrw8yn!opKZAQUg?Uypyk3D``*N(hz z#241fH&N>>Uu8SaGH_8O-Qiy{!~)+in&|CEqFcoF`c;AL6$j?yQa)&=j^x(OwDYic}SdnN+8PKi2e9!w8}=3jjN+yUGQc@8!0DwY@}rOwhH2z@B?AcEI_ z$lC&Ao!_>f`9cDD9F!l}(^zuC#c1?h@c}VXQ zgz%z@EngH^F-GQ0f@m`%EGlLmfX@dmcVvYK3=VdHwEmeRwjrs*E%8;8Bowww$NH1s zK4{wPdTM<33X^@ibbCE4zS`WRV?RnWQX8W(y02Fv!mnq~m-clj2V~2#{_!2-H70Sg z?e=&)_*%4Vazn9HWU@l7X6|OtNBXKqv%|CPwzF-LPqGp!K&PlB%AAcN{k)!-yj{^NvZ_yB8^YN zuy2r39Gy(QW#%F{d3eXyIAZ-V#GzP~6aj}O%4IG3wFuI8;%@2&XbUd_cFTbJ%`7iD zcPh4S6c!!rtN8-##{pooEIGox0P!ozUGJ9hw71oIPNRaznF@a#i|FyJ{roXZ$0yJcGx_Lb2k2S0CUF_`XX3i~c+x9k zp3SLX^=8f&S}%TY2`VvjRcG^3BW&^{&z*^)B1@_K%8ogEL!GFTEm-Xb5c136RRX2r zd)oP_LI(eDsLko{1L_{Lgk^+%j9Hd_IDDbHmw3bo7#HnL7SiOX=HB5rdnFjA}*@Qnn?UH`-Q{O0`>mAUglAJgj#2idc> zWsJo2ZSE}E+P>_M7T6DHz;wev=?(+DQoh)3*&8$2^Zi8pj_c{R%jw{=?puseFV5cP z-1VT}Dlf8&txqMsAE%t@+ZWdnZbG~}2Phb8Yobdu(Zua~?RW+rv+eB!Szh(I3G3j; ze9-ta$PI%H#w8QJxjs_-Q^dDzcBXpuZ976RN2LmOEak&)a8Q&>T-PjPQ+a0!#p;2a z!B_LPvXuNq8HUZIG6!&Ziuh+q3k(5Ecq+yaxKR9uB`h%9B#2J1U$ZP2+E6GAsQ?(G zK5NXfvw=G)+CmmmAr8)D)rz)L``&QPAdyyz(JHS8i`x{i+qkf}436SJ-HFjyAiOYM z5P)e!$T)+_bWpdc@C9ockdh;~Lk zY}#Eyj54_KoibRw zBtRb7d>PuOxC0Y)J)&1<#0xEO86i5^JA8U?Y|sSe_H+kp#Y?{rq}GBnM>eyCW+LGa z7%Z`dCz+q2jGkZK_8=-R7MG(zFErxY>-f}}k>J)fxT3T?*l$>=Cda^CON#?2aJ&D< zSseos1X?W<+_g`^KvyR$a8mnA^-`9A=_q^V<0hmy(#%VRc0-o z){W*Hf3)lmg#T4xQ5)EJ(d*v&wrhHM*8%?9;q^-yk1s5rY!kWm-`$+vb}k&vQVL`R5$mE zdF5uD%-qEpSep1zqxACOSRw$sJUhXqi-%J~TAk-~8EKZDpovC}t>GX3bl}-f1ppJ4 z=j~oeApOSY2PKshfH&b1KmbYrA}%K-Uj5QwX8U#UCeuf)MufDP7OpE6zW!v~IN_Nv1tK>|`=8WYhGPrl;`@}|v7!UB9o+jrX zZ){y2X9}K)1b7Ir9EUE6GLNH&SO&cDykAsdH}fwdjl?M5firb~HKq*j(BXHKE^zuY zYO4RB>_Jd&EB1Xt%it@F&J2NIH1QC__NcZAGr68)_Lld z|D@4^;P!^BlH$XlG^Ik@s#B2uN0}-a%Bk)%5&d(qADgX1Fp>#M56OVc5ZVFE<3ANY z1i@)9YfVsjNp)Rs%e}6&&uloqzwKn~&JUPvlJVUpW&9gFZ)SR0Ip$(GufAH$6@P4; z$u;i(<}HKjMzfD9Y5d^dtDWT*Msrnuj8#RBL|TkZlslVPa-@3ZW{Ne&E9+ZenMiGg z2u7%DijgvJ{^_NFUiMYvsj>e+?d?X_b=!B5njjl_DLJrc7Fw-9Trb~PKObAFZXcK} zee_sPR6C$drlq1a4~|67x2rNaqb!Ast>K$Wmf4K&>hOs>4$b5qm;gc}jm?7xkfnK6Uj1eI1V43YEeA=e8z9y~2WdDOdv^8$u5CGrPzL+wb7aUuETT z_&V&eUW72EVqjNtH%`(}vp*&-e zcCfFEhB%<#O=ZDP6zQc^m&;`+4}M+@r3$BSeBr#T_~yN>1SD60Xt#1>CQ4W~C*wYfCO=1;b!V%-Mx+Lq4-0F3{2 z22&OJk|COQDtUoZf?i;8=lOEAlcLNUy{FO^%aFc+zbRtFS=46+8~GE~z6&qY$^JdT zSx&gXb+t;v&6fgPD8KgrxTLFRiRt_j5-%2$x;JmSwK}uPkT{d5R)iQgHch%S_Y?}r+ z(~3Lc-Oop(;7reeGE`6*Dio;jc7&?ELtoyN8W)ol5(kKb9tk6l-ZVu@J*s-hJJd-{ z@Ix^kG5ROLPbJw2DHWc9GOe-dDEj^dZHb{143Gmv$kSI1(bK-j9~i+OS`$UOVs|%p zYiLl=14l5Vq9kRS6jEV@+=_-_oMJ)mZtoma_`Xg`}zI+V|$=+&}h@cEy* zE&Uvy!H)*+X}|}{T=4@jKImrT5NnG8Tzc|9fHM^Qou-mOEU20mevw5eixXy;P1pIH zQy7M6H6QaagnlghWn<8`05%x&qF)(y0`USZ02QlFE{=r__sY~rrQD7k8^W;6-R~|?nMXv=$fnV&r z-x`yB0mMALs@S21u#9^1 zNyKKB^vUAGABao?Rba~wpZS2^!t-J9^KR!VhhZ7pp;Pb_tGym|U0J%qT=ndy=*D=1 zgHN%qNudYNPetbzSwf#0DT1jLd?;lL@^Elf*$ln8ss}*Gz*n6A0~5f>g~D3sSYje; zELI$ohtC^V^GZGGDV%g8;c5?QczJzT{^_R`??$zr_EhW29e-GNN@#LR}5`)Y&M{&#z|W&Q)nH8 z1T@JXfrbWuSQ!(ON#h&1)1tA60R-eJ1j%3Vv!mqC7q&*PhX)djEa^Yoo&u=HHO^r$ zJ8jDyKf|K&Cqk8C*%#R?NXd4&s2>1$np4RFog|V_IdOt+IsH)dqal$j(A^-75RkUL zz@e4#qsG`$Qt~%NhajsQV_~qk_G>Wo2^n8kZGZ39ZPWjt6|k{jp6L_dfC5F8-&K1hQNTuX!b}) zOJic>OqoITYd0j_{RGt%h{+D=yzvL}m%)@jNq%xP*`&~PR^2`M|^LTiLIz;^5))m({T6RdozfQ2-+}4%|1+`3Y_VoyJ^d4P;NWLqPgh;7RkK z>U$fH5AO?nsb#)hkMfF5sJ~juF*u;n%xC|Z8DE+4_7QCc!EAghXCOsl@Or}=5)x{* z#E0+)np#e(bpx&SAI--tOYSSdf31|&$H->bBY|A7JhM{)20*UG1FOM_;Q>a4ui@&a}sY649nJ6u-0$mpFRjd}C*75y(b2XYQ!n}pshs}akIyLAatTZAa!NaZ zO8@CQ+xO(Si`lA9L)~ZE>(BL4*%#bboQOcDCdDz{xrDmvMKiK*wv;-A&6@ge{cb7t zb~H3idM=0ljv31*4%%}BqM(!5cRgR~vQ9muGJ5_%M_xP)-~Wyb?Se{Wzb zBlq3{%#2|fx}>mTuJo{ot_P1=y$jLDU8MX{<^$tM&MVqufDS35csFh&1o6%{_CWT} zkqy7?6nz=@SPh9>RB$g5EWFQwTKAvk>1QPrc{u@YblP7sMuIEH_}gi!^H6NEFRog=7(m+&40S`RnOoFJVsWt`+pl|Y z9mQCNTcN*Y)2H%T0e+@VsKJuQGG<fPjN+js%;67S0oo7~H$;Dj(%t&SJ@FMN2ea}NvAlVvSkl+)zC`?}%X^LGaB z_YPNZH~1lA9?!G^dvl)sV>%b5>t9ZZvz!!nnX7jfRvBHD_)5DtnUT#s;U=4FBmVZU zEYcRJ^kjOSI@nbONW9C9ew~=X5Kn;tGh}Rw+Id)5X|u5Hh*35II9_`V;tT|)@x&B= zp|3)+zxrtw>W;NgX7DjK z-KyowT>t8PT_EqSE#07>3FmHmfAUD(cA<=G0Ck{Kf{-5UV;2Bm!9FYbqg?oqiVHO7 z?TL7rbnDQ3(gRBj&Fz~(!;(IO5op7D6iFs~i{}%LmoWO1oKUP1M~kE|L!c4CKwdxC zZb_BHT9Icw`y1J&;AhG)59v;X5y-ib2N1#KDcBH3Y%4U0(( zC1PsBhTpa;2{uiw|LOf3Q%tjV@j>IsH9jQ-n+gY$7xuZpMinkM6vg%spYu+X+LQ~X zVDTEB+tr`irTBuB#3xm#SD%f15+gv^&$cWi1fdnC&pk3=#M8SsZ_&b$T4ZdP`Di;x z62EpYVk(C)lQ%NiP)@Azs`tu3yatA24l_{rxtT;T1gqXS=eqf=y4qTL0Ji&>clv&eEj12jr1ToDd;pQqBa??5~9vLAI!FNSt6&fZ|;WlR5`DMs4 zis!wPoY>!eohiFm)B|3ld&^W;4^_RAImD}OF|OU-|{ zwAI`}P(LrD10+cfEL{WR%=lE}_3ROVOVZ3RWsk0sV$8B1gJ404)Oc%XoBt4wlwYNV zH)z%1m^;vjP?)W=ydQ@AMH$4Fq*;SwY{QD)BsLcF5CKgd&m>r&OE_i|H*kM21Ge#y z{TUA$-Y|~9+ze88slkyBqs^T>7~uZ$-RM#Pf0%1g%fON6=erOJ{H-@z&l!k8cp%+C zg~*LVL8?K};v8uh9U+)(`?O3NvvW9M0&gG(fs7z4Bk?*lB9{>EFPR5(;>zDYI{nFW zPb@O_nXSMunp)lUleGT{@`!P)fL5sWIy_Uc>IS0V(ZXE&3~>f4#;p2c805Ofi-A%g z;{9e$WZ}m@RX7hDNWnnK*u&O?@N>Iwd8O>CZ&fOTx8Vk2owE!K68S2Zt5`(QNA+oI z9!jDmt8#3@h~Gb3*%s!&z;l{F0UZq^OSvO<(?N!c%vtZp5=bz=iy5A2?H5Nj zm9bE*Y%PrGwsJmuz}c#+bAMH26-$!ET0~x7~YFrfcNdt5UB{M!jZBWh@}P= zwQ{cBS}RD;%2x~{Z?&O^2h)mm84~sqw(7`9vU_?jNK_;J-jY*)9JjGp zvRS5a4*>-HA@@#3WbAwsxKzNxITW;P#c@bJSUaM7?$cncRzEfNo*J>v(LnnWZVOJr z9ENpSC7cAoP_vue3HTMb-w0#GnAIRX1gTbK$t~p^AZjoFPWmTW3EPqCd?xY5Uj?;= zN_E%~LQnRw#1*(2RLeEbmQ6+)HcZ)5>>9YsF;3qBbmRIB#MNc0X87#2%p^UwKJ~@@JX?yn(I{z3i=4g9=L-q(qFIcko3DB*w(sm|g0+#yJIkxz_s;Q)v3T z(UXS%dK`?Hloqj9d)9$QMwr&`vi+=}SO8bCFV!0Z?T?1hnO-(lB#*(>{)!#7eXMcJ z>ym!YAV6(sCc(H($d!USDy}PqkW{0r(LQ>V>`M!G;r_ZW#w@%v$EygxqzC;(MBP89 z+Eb1u1C_ya?$ghRE~>ATHcHVZOPXhFb6=ee9-OG^7%wMpu-o$>w%?>&UkLu4Vr1sI z+fHn((=F6RCfw+?w6Iw^?o9g-Ec@xlVVPi$LPr__HU#_k&bFn_Y`j%C6fPiO@WSM- zz4@=-WJ-mS+GZs;t5ESNEX=nE`TC5D9aT8E&t?XBq1gkZ88gqZ zQhFMmy`hA`1*@51Lz?gG%4fMy&B%3sMB_2i-j2I^;=Spq3Fe9RO+v8o)%AbaR-3mT zxPUhhX=F&+F=L#s@0)0qdMt{%8AMci%m$A3BrAINPyFS_c-59A%=j|%ir7S$C+Y8r9O4uRJMt2qcD>BKH z?ZhL3cJLLZgs;-cfVWwB*k=%2FUiTT!a6TRVeDVBU(f}9aX2*xG97mDT8nYe3Z+Ne|pi=hrX6S5o`oJ zI(Ev!#G9mP1^qnAZFjp$sr8TsDeKx#J)%IgQ^w!rOM|eG>vJjR%Y}VC{COL?0ne)>PU!N_#jffhPk8G>-wXg3zu zP=UmDba|S+gyjtri5?@3Qk+306aLaTi1742+ zxBqc~^Vgt4`_6jNE{+1cKz3Sv6^<&8J~L2HH;=6W-o%65xa1o)kYwElFm!YIj*R$6 z`0N`>n#wQH>F@g&LUA03kz&nihd(`gK?y)cb&#Hw<@1S=b;r$4aAdY-@?kLHWnTku85QNI~qfd;7 zqXw)#cy1xlZ31Nva(>K#3eez>k?x-M`Un|{9yHQ88m`{p*Y_;2256I73l+z@!6=b2 zW;?EUJ&0g_;hW~m5q5YFs+?|QE>4`%$ zUv2J;f|zP|OxfbTC<-1d^;0AL8)wPRd1>3{@mLx36=4n z@A+!_^ve+e$q8cd{nacxCi#x76kz)!d%#&dJHE|+p{icoaT_iqU^YHYqB4Rjf6JF>dBB%hn*fwjDayd`#&VKFg=mG?h36kkx0NpID?QVX?-iNuHwQ1h zt0W(!uUN*0UiCYG(a}WGvc;?II1qO_nTbe`?FUaNB z#w6edOzfd}b&TeS*p?qPs<^0T33;%Dwv0 z={FEQS^z%t7jpi`A{b*P)QKj`K)1kWWrHX7DYmC>N<9Nd*JNkAZKxa#eAa9sL3p+g zUAh3mu<#7Q#nUwc^*p5uj?Bz>r~TIL0Z6j)$9WoiK={xvP*>SPbEGw>++x%u_{vE) z{jSHsHQ^0~Tg3Spj&5Imj|dWet!>@f$@Aa%Zc6`Gc5poG{BZ@d2#%9zcdpZ7jl#M` zG~aX3TDPNC-24z~-v3v>3b!Bazg4c;@|W?dwqSLo_CS&ZWCDpBiP1H*XIKfx?E4IY z($(*0y4aUss-2#D!DUwUoN-Y*$Q#(J2Gc5l@ldlnzj|3BqgLxU9x z?j|SHtCz5<8^3r|faArK4N9aKW-`3efAN^M_0QaO{Fu@)Ml z02__Vl$oTw#-8pQ#%b2>2qO1Cs(Uk-kqnlqCpO(H4R0R4kS_E9gE%~cpfInVS5>&D za3>kHlPK86Vr=Xh;?!0=IGgOXUbVV;#9oV{NDk;*^P_qVulb0W`bp1Wj@PfufTnj4 zT^D+6PHCdqg`G5M%eb#>B;MUcCylq)TL>W;Or~9OoAbl`kJrKt-_Fno{kUxo#G-2r z4gi+4S4{gp`gEDareBt5G4S{TN}25vln)y5;j4nh>;aoNm3rw9C+Tb#J@15Bb(3?- z+sAnEMFj45RGf?x_PHtk{>?cNUPr`TL=LX%^j88Oz>ywbH`qhr{?tEnsb{9l=&>k6 zkEKjtrnA6xR9Jt1!m!e*D%)PYkkWMjo`W=MFh zbAu6S?*A8|0VL4(?}ct@XN)N1aW+YGN^X{6SQzNJ z@LZ}&J(jaFkUZ%~P~jypd5nD2YLFt_AcDvRwdq~5Wzw2QWY2wKgpcb`Z+uk(PE~L$ z!|~0xAMg$S6PEf`=zAKuz0YdGY=yCh)w@(U#_R=r7LU#8z_hD9t^#YsCjW`!O0<}2 zRv#>92ZC@=;ip*Z@x}zd3JAixa)r*t&dru`zFt$2Yo+Tdc<8SMrE@{4)lP zxJDF3E==8=XW+=ia&p9o4}KM{g%jg^e@K^FCh@qivVYc|*Z~rdL{!Ma8}>9Ma2NIO zKBLO#UTafqy!-x3Y%R17?e@HTu+?gEa+yBhYV(P!8LcJB&%UeSYiG(&`_rpgY}>1Cgo@^-)81W#D2*!xe~Y*+4}k}wg+9gdX^;q67@I}oAYd0NBW)UF+imy z4g0>Nd1nsTf@Zr{zdhR&{)fRLt%qtO&aOW*OUfXXFEdZ7Vtb5UTcpFg<7g2U-Y{U$ z&2$BR3@zT|StC`C-mrJql`ip6P%PI>EiHav@_BZH+%L&1igZc^|5{f%Fs<=Ei`3_g zX#0*_Ao`fm6TS8chOo)PtTYQ8oI(RGdA!wq~S`h;N$KAXkCdb@}_h>9N=*v1O(rc6$T9;!mCiN2lQ z|CM>>j_XfyAYq;MaNT*^ZP!bW=#5d|RW@tYg$N`s?4UVtrNKAG-ZcYn>OOmS73!fn zAhhDy^{qdTqA3PW^fv0K%brw;niv@+SaDXBD(;LiA#R7?e0U?w2BO5CM21Fw^nE2% zrYTdz$bd;2D&R@Gd6qA$F(VoyT`}=&j5Da~n9a`lq(k+Vj(^W_3!X0K$Gh9;9`taw zPOvp(&gWRC+q=nH1Ee4`kZSq#0SZoSyr^h7K*VANo$XQyKdUhoJ5zXwQ$wUR+qW=a z51G_zaM)qAdn5i_h}ogWsB?h!=VOs9TW%yaj7&Mk_=t&fvyJCps4dHc;&R)18Qq(M-Zw^ANqf;z>DOvM6&k ziei--5o!O}3$J$Dgh4rnydDwm(w$p**{?>dh(n{z{_{TMYNupM9)Z#!>W*6z|IAi{ zqNQ!ps7CEmZaK8#S_6$ezTbO?1V)5+ts;oMYT*U}jHZ7A1;TK~*i}6grN8NAm2l%q z?;V6iWY&q5tuW#=wa@+4q&+ljU=0n+V{R5OM8ixGFCzShYf!-yKD}rKF{_!d;j|!Y zOk51MR2e>7W0?W#x`~g{BvDee01C@I&}gzm1Hh0%2bOf0+w_lG13T#C4?w8GLyV_> z-V&a%s6uTM=(brq(vQK1(B=L*5x4^J+JM{EmTyj0v~M6PZrDP4UBLW|kI`&MqD3gC zOkT&#f2n#pvhEAKT4yCSA?vSb%eDSO+eG3S+HPha!3kf#=r2cm-ub<_FvMJ?@13wh zxUom1YTLhf66r~ux83csGZM;gV&{e6?fiErYLvlqs*^=Qe?gM^zaR-Qr*5Q`LDbj- z5)54@D;VZlB5YA{F-5fpWEzr{mqiOCN-8TArc_Mt%z?P3IaxHv+bz`%6(xPwIB%Ei zhMX|4npyiBN3g^RXFc`*q@VE>2cEouWp@XfP{WavL)AoT3KTo=cJJT!R`V1c(oR0n zW9B$!pC%+6!}@Aavc0vm4FPOw^;e~5CuSwwR#A_9`bZAQ@oI)M1pQ~9zOLdx2;KEn z$Lu7!YXKX~LN7FxfiLEMe{5|E6mkne{EZy=ELjgfSBL4p(-AFq+SQPkyWb-KxM@?yqeN z;IQsSvvduieH}4yWua|wLoLX~q7s)HD63FkImXD*-(lNfq_Vk(N{-aNqT+&?489#V+Y$I{;c>S*TuC1##?f-ldqD!G;8szk5V zacW!Cz%$Jw87#cMwKt6!#eezVAN&hxe3%%n_$vvCY8XyCHTy?`P*h@QpfBDDo5W$= z)<~o}2A&I%PH@Jh&A+1ULvPsL73=qpqv0GysFPiy?-MrdYhGR$P}2QB;;znb>+7dgI)i?Z9l^0`*P*O_%mINHr?c=Ygw@L z4;+6nh-R<{^ADW_jTOH(#oW9yB=YCp>6d@Xk)^fI`H#xsL9PExT`+6&`8UjP>txR6 zbVD7woC4`P%ebXwuH03}xHn&m&2*98cA-w+u|(1jCyO@V95@SpCA0@YRdzF!r7;{rrnrLismErksD*G~$z!p&a#*U1#dVFc zE1z&$sy$~c@Hq{0iB4g^jOhE@GJTkLw$rjQIDWK5y*x|R%m0sm#}0M9&N z{|xtM;M9mt$LC)RrcXC_s2>+Q#0DVlUOcz&qER6Sst1_>#04j;O#~i0!UaO z9H!%MESB+(-(*?((u)g^XT<-Owh!g&A1#;!i+*)@gYREG>1KWHZFU5G*^K~WqSfa0 zj_bg66b%rJ!;)L6RG)IsrbtPBhB1paRn@hd-A+1peX*22!_4>K$e+2Yk+vjtNV^+o z6Znfr+ZNSsyEkc>g5hwcy0|4Fxf@g~FI9P4J_zxM)3ZOF?+Q?z*u~?tpPbPRPp-E6 zVIl7k+m%5*Ls<{!N)e{~f(5Po%r{_hCj7f)IL-Ueq0TvQa|Jl?z*2}b0ee4qJxxJ{ ztmfZ};}^0F#_TNNI;v5RZXvF=;u_2yhX0Xio;`vQBab|8B6`p5_7|#v#nmYk-T-=- zVNxPf!=62}Lo;ozL}zH0`*v`KXHBECDy=BGc}BAye@Kw{ZGHIXs)&yWM;^GX|#*DQ;_hiss9#qY-qA?q7AFoQN`Ml$LZ zj(_8)wa%}aC&Ba>%qbDKMOjeLR5bqp`iacg0(UT!1hf5UJSBcrIN7>lSo9k>Dr_Kc zVMM~G54})N5VTN*!XZXnQ6gu)(Xe+7QlsfRc2K?}rzI#8C2Vg8Ll?HtSSaXiv6bkg z+PswzKQP}e{T~E^H1|G$n(onC_e)>D*`}eQrfy91=EMIm5FDwl+W&gPDXkd4FcURR z?Z&oprraUbC{On|4U*ate_t57WM3L`(0$H{h%{zkdcoQKLU8;0cva4jF*%`oS1z0& z@6J4zeC|QfRzL1(lL)KWwYoPJK^H_Hy`xiz+5Vixwb7`#)bYIsJ-&)D0`V9a4~zmJ zv=+e>IaEBDoI5-GSJA<(`SRnMSclKVay4R`rmr_%*qWs`l0VgIVp|r9pa?6gw{MHa zn-2H9bI!CHV4ac(3%q44c3FWuLf#Qy4?$<+>1PWsV#NO9=XY)>nb$+jpIP=!I2r#ubO3Qn+$ohRqc_cr+ z;Pt#lL|g!W@-gEhgJ8-BG*P@konr`Z^b9o@e}qHjJBxytR^iwCqBnb7JM5tst`Bav zNwsHEUM!By-^u4V)>gmy0DaAh3#EjmyJDf8WuCbq1z9#a}u6P+mQNg*A=@6A?Rb(OGWw4dsqQn?Yt9s zuh5m}O`Qfv71JBed@2%MzOpmkSk%rK(f*?KZ zR@~c~ykjz%eawwUt-$qO@GOe{8mPR5(_v&)CGKTcKY&8<{WT4WYcTRJW#*lorsNt2 zQ|EO#e!JJ|iSP||vv6>+Qv_(o8m2NA(vNW2ww+zEfs$h9n4+(93DkV2&-;l7g3tfB z04_foQIP&%P1dxA-s#>3s4o1I17ppd<9-jEWpLz>2ahXO?dH1D@LbP<@x+5h%Kx1V z03iPHgV?tOtV(tjdir<6+40c$S9voh3bndtn&+o~Qg^|b-jA2b2|ipqi{YP1GH06P zIb~LOSO5BT9t`pAD8El=mZ1lIA^G(cSth2%<5T5e+UPi!Q7TN1A=+j4{ebU??br)@ zVGcbcA;+veG?)b&oT*9s=PKGMzs?%+&V~(Msqo7=M~d+ZpB;Qb5Z!NWc@TBfIwAK9fV7Zjhcl_@yW6C4sX5MHaO-H+fk3vYS!30|Irk~G~ZK+9V}J1YwxJx99G<(Uq6dhz|V zZSn%$XGxqm$0idOqmms28Hbd|>A}!7Ws12U!mvtdZv&c}ABL*!kV%7Cj}#b_7oA^l z%>J>w4Kk2aWAv|CjUMGwJ)7KQX*6f=9j!OUUd-LKgh3;3uJ7jq#avGLN841;cFfDH-{W8OqknWR60x-KEDKu;!N5CP| zjTRMGbV@1GtU=vR@O~A9Ar~Oef{h}^yJ~+-(4_c@m>1VMu1V_Ejte1H=#F5Yhdxs7 zTI!CzYnc}I_V%Sj3u5G1jV#4&fmngkRsY-hxJ^k?ed9{`cRY)+2fVB z(OKKf;8yww7&77^3eA7%@o`@}z`9Gi-FTRSzp3B{O#U1Qi`3+t#MfD!^l>zVPFUv`MjwB9P$v>p6;7a!l2``D-nuPhq)-4696S z5&1V(0|cM^yDQ0(kf$3QhZGGv~u^e(A>vY zemdRk#~FIn#*BFUI)fFII)YU-0-81@;=vq9%D@}SfNhUTbfE=TtxiK~^vr*u5hN@u zNjn&ZLwVH}zVX{c$L=Q2KT4X9;H<3j97$MAfuU<6O?VbR>dpHdO372PtChkH@BiE^ z5=Q!pRdMIG6iB~RSiI}+D-}-AirOZMZV%D>U(ZiF} z;Ab?p3#~~}smz(DutXJ187*;!vYpmdxQxj1Y7?PiKc}UKLT|cr4bI{mjVS$wpwzK^ zF)&uS2437kU=i$V`x$uoQ|IzS^La4g*CxepIrWz9J_j!3O@LkriRum4hxPr_yj}F|*7V$m0N8v6Z9HpkBVm3oC z%6QTLmVM=|AXe$`zgxu`bdacYecI#ikw+029yIy5lEGjPH|YTFh@sB=g`hA?vEcE| zA<0(@V*lGY_~{=t4zJIQeG1In{sv}PRFb{)c3hhN%(bM)zJ6NQ_M$sk&WcOJ!zU_a zbWDg4SL_8tT^^R_H(smj>E?-jFt+1dC``OXol>^7@=ufRR z+CICic8IJl%sS|yvw~hXmnexAE9b<78<5ZtL-gwVS<{qbuV;zGSe>aK&?vPf>q)s6 z1g(Xqhw3oj(i1lBUie2oxPl-0;r$rX%yVOvc>_Vom_X*AOjd>4A=AM$!;fLdn^QM$ zA&7a@TtD4+9cJ6p;ImTrF>>|gLr_d-iZbJoVz)k{sy}pgjrVqapTt>1vJF_=XF(nb z>`1M3wE{`oVAd4ExeO_MzuyIG_IAa+fSi2#r^7*@DFlD<9{G0DJymTs{L>=ToKT^6 zbKVq-$`Nkp0k}|(gu1H1WCx*VMo(kjSddh364?Ln4PX(iGxgIj}UOM z5i_$kdQP)1@;!dbqBQ#=HVY^%TPEJf>?}Fufk1GOfo%mys#xKflfO1w##l~-q2N~v z)Ah%vx6@(UR@42rD15K4KoV>Nfa z1N-F?ih)H%pdmJe_3}dp6hPexl_>$){T)!F}VMfH57XE1?QJOHZMeW z-RY%#{q|17!auWlCF`#F<`x|k#mXwFPeN)I9C#Ay`{v_n?)UMs|Ne<-ta#jy9*l+^ z*8nSPfcyrHSZVj*LBJ@wg=>{j;BDD?JE!Os=>Avv_0<4B8<2}>Rml=bDe7c}nX}GE&<+0>(5^FtS<5=NzDQZ)+@jF-K{A7ZA?H_4 zTXOm1(u3X+SaL`RZNyV3HkJ)|6tz?8PY!X&UZwZdTdi{gT{UNbb`^-^G5sLD z75H`p7- zlLp?=0ge7@X3!=Oh=l1ybY!NOW6aXw4Iqd7hI(%Y`KL7yV<2PR9h)#$nF0fB=2C|R z##uB@bb3hh{lxI6%Qb7Yp0? zGvbBx0O3CJQ}l5EQD`?5R^Mm?No2u%L4^4tlfsXwBK^ZH>|f6`nDo_)i~Lp(_xfZn z1#b|q8g7)GT%725=}auwulj{z!TQ2%xZ)+9ZAr^+a}TI_OcF28zfOW{SD*ib!<08s z23@+;!Dk!KPC8ZRYy^ypT~1plm@Iz$nL3K1G#4O&g{OA*#Tf%JCwQ9nGV%%al@m>XrL9(17?UklIS$3=gsE?LW6C3#< zflS|0B5&jf%0At`C%q{2Z2z>!*YXO2E6NxAUu%B3qzNzI8-W*IJ-ptf07V# zwwUBMU%Wi!Zx+6oneB`c{gOV!GM#vGk2M=M+EaylEu86di_l5z8$uEB2)980T(T9V zzjp#73~;0`$f@TTX)wmI{Mjg;o}uyRovmT6gANKkL4>DRn0 z@;=;GAB>LZ^C*~Yj(z)2HO>T#1_QRx%#IV-55UBnz@+#ru`hOGoVS-gy#hXF_X_SH z=!=Wy8T#b)__Qv?IeM_=2dwG7%woE{U@)}GUn91QaP?jSSRSei{lwA()|Wo79F=YZ zxm-u=eM&duA3|pn*_cJSxj^ajI3O_B+S9J z@psKRMiI-hCpRzv+Ee3Ic6Ib~T5`TeE?EjqOtIbpPkbE06sXPU4Mxy++a41Y)5Cw2 zh8!9&GO@5yMW;y2wEn;??3j^+mNR?<4KE%Q)X0Y)3>Bxp-b-kYP;y_6P!gS^LWI7y z2JZKLt&CzfRq%N3JA_^pj<+!HB+PV~nV;!+`D(ap$8da>Q|o`*Bg^GQUQ;sOUC=A+ z8`!4NRcbcuc0GMvDii;iJWjU6P6+|jnaIPujnw9tlxvvgvWz_;&O}v4Zihnbi>Pwt z*r$WF>${Z1XJry<(>%u5vUhlz_4XZpoEUPDH>;wG6^%Z;ALIXYM_gT%u`s7_^o>U} zX_dd|gI40r+^m;$X=@yXY_)T1r;{#@XO)|8jT#FTdrR?JrwBMOs8RZ!JJRBolAp%A zu2Qni8+>;RZwsPR%|Ms$)cCNj#CiEzdLliBf1(xOOb?gsg3U)&oILhWP$X z46QvB&?k;D%cJ3+9%GHlq0ORuCERc;7)q;_Y{TP3(AzdWp$U5@xvbB($g+vZ{aH}k zrbvF%3TiE4xnZjfmwEl7-t`$+>HBSKv^#@6rs;;-is#ELuH+LsaG7e>*zvr^_94oGMlsrIZ1>3_9B8*h397$k_9&wB%4<@pJbH9B|D0*@ag?b41 z?z``AVL@BuRyVNCt*`Lbietq7z!_o6+Y>*;6cf z{I>U_sTF1l2p_|D??T7snuQFiQ6nt$8&asX6@k4mLPCC-9HJ;kO3GA4idrPY*b^`Z(OlyD>`x8u-e~rB)>)0UMXx@*OL6v(z&Pgl!^XT zxa=$JTdOMKHJ>{Aa-WXOlE=YVBEb*|kvCu%?1I8hL!pGt&kp5q*s<)YNUwKF4Q_og zZKwF9H8lJFPN`5(7us5$Fz{u@=maam$95;;$x}r%EL{ZQs>^{~MWs_9{E=c6d@2g2 zCo*kb+{<&+M|_dyoA2eg?s`}$3-qG3-VCD+-jIz=3UQZhV^FC^_iV05-8K|WJ4se! zd3!yOjRFi}EtN*J0+;Mb!FcM4BS9Aa1hvriSuIEdB|-@CVS*gx+QvS`v|Br^F%Qu$ zD~$cAntN|z7r5_1-q!$^sh6+DJzlu@cAoh?kHO9S?8wN1^1oLfi`&BWlm$}E?@6Ug zC9gWGVF~M8+cY_*8eEs9?ECET=Z|p1-o^!Ynw7!+$kX=ojI0Xl!_x%+wW*hQPr2CU z-dEh|y{PwK9FZ;vKDx10V+HMBt~pU5>}AhZsE6&m$*MqF5QEkpO0KAO*iIt01=z>@ z1C#fA7gvhSC137h@Lf6`-D2p|cX!n2x5uB5!V! zSbOVvl=(-3yd;y`f_zb64*e2#8MuGV@iAo}cIc*1ckpYxs_w3u$l0V#h8mP!NJ?x< zFh>I-$GP@yII0GSQM=(VBhqSIV?>lh@~~i;7BcZq`M?2o7p{R4FYy2af2&&E2DVUJ zn_eZ~uF=$Obz7ToTLxpBS#>E33+fCTFaiPEDpWmzHT_Bvi<~^+j8P8~g1Izl@WWyC z9EhuW@jYl=zaVmSLz$6j(fvVjM=TAk5ji4K??aa3`ahthBvQ*YD^if*t>%M6rMc9v zac5;%+7vzM+L3j6?-P0N&rCE83c4@0uPKD{34%)ODcCj9ux*c_$iBHpcVzx%=+Kn_ zsoq;!8*eQ2SjGV|0=fQ`uI*^nY`LPVeW-j+W3eU~dw4?$u%)1mC(!a41`F)5u#_W; zs|pyKXW!45M`a#&kJac?Kf9o6kWztF<3d3a-jm1oU0pH)celS9`)%7>UR)5nUJR?` zolg|*`<~2pS^8}z_w}n3QzXoMBR+P@>dnv2FY-q_U6#MEw-J*e?S2+k{7P5) zUAdy8dvs)E>A$=!jP+R1&>d;55w5x-**S2Te6d<&FnuXL>F}}bv0B2j-TuQ$wvhD~ z!ZD<0(jpgs*p*I>@>D(P9!fz_uod*o@(AA9B}BMK0qokAV$tsLpl98#B_PH_ z4B{ZBxt|M26+50~QHVU*5)0GMcs><&h9Z`wa-DBm&=&G^SQL;YylXO_S^u#VZzA{@ zcAoT<1Q%)`4{y+1M8Y@Ty7f}F_T6pkemzt$6|;2WAL{gGM`sgZ0VFXIVQWilpAUHu z#0p}H<&J_Oe#$CO6^Q%n&cI0q`dttdU%QG(c&5~Q0&BWgHymh=eb0+XAnLNP^eFYT z^mqrUQoFK~fu>FD)QZFOrI&$>1}o_CHhxmssL{Mzr~Ngrk8Q1}5Rev2{C%ym zIjCncQ)jzNYMz(s$jS1ux5u=l@4jDj3i|ISQa89IKVd2H<$t;`1g#{JEx+sXPpK8<(exK~bSmdl%bn%GKI>Yz z->v_-kWYOric*vNzW;QMYbMndauiX64OHwlxAv$uhhB zqp`U3*z7TXN<)QrgM*;-wPM>u6 zS$#PwTOIs+&g26pFC|Q;2BntL2!2~6m@@LvEE2m>?MdHBb_;gnIcR0Fv~}T+c(h!c zmV+6NsoTn6ss-o}p$Aq>H2F!8m}&huJl!5DI|skR(E<|Pf{L2b)8TY-}SX4Dq)Rc^V}`#Ud5aTUatjsrNL31!P(d9 z_lLC_by9bQy2CUAMOV_u|D^i*HP2`8JEE@&>$q#xKg>RMN^*WGR-9oqz4T>T_>(#& z2xbMX8_;;lY(y#~?PL}^8hbER;Y6SPxFMaXcB873ap^T>NkW|8SB78T#QZ0J8hZ#< z0(a@X@|++;Fr9p{al#~z=ohHj`M~F!roq2&mkx2Q7x7Vv3P(_4(05DAXR!0U8E^K> z?mu05O~?Cx&i5&sZ8a|R05tSJ1qy=ZdYlxe@%ZnUb(K=yPnnBcTgJ(A@w;&=H}8F- zOpp}W)_;NtVj4yViidfbY75EBxOX(w=R*cjU%uaeXKgqRW3fEX!ePaVx8tphXbiy=mkk#xieq&c;216_bkQeZkvarLRwMd zRi?zQwoBh9-3v7|@Zw1OT=>mGc_TUxzcD+1`u)ug((YZ8JQau_GxVH?w}U_&OiaTD zyusj6E9w~+Fih2SPXCC9)i=Xsdeh6m`LSghrBIDa>JNf8EUS8qLIPHi8_gHt@Bj6& z*8l%J(a=B3`#LuRaVZ3xO*XN{FT#AqirIl2`FBvjUmV~QpV*@F*T?S0&!kwM-$`!2 zoqp(r_uONPw1{b(-i|1(A`fO8YFa%8A0!)YsOsa6hn@~~7P=chLg!nyFN;BU9 z(;e9Pl2@5Cc1D8?zXqF2K@w3GD^9_ydaR*qDfY1)u~l=WO>PR;8ioRiixC&9X`CWL z0N6>Fu-!g1oKJ_RO0hI~ViOIf^ZAe<*{K^shB|#iRJdDu^w|Gxo`J#i7CG}gme_|^ z5UzZd9^#Cvjk93*!>V<>J;Y`y_TAU#*tWL|eH;X#VN&k^OO(l;745!ImrsaHaW65o z0AsFc-u4%?B6~8>wm}e}h&qH*)Ge`RFFx;3BU~{c$+j$!svosxIOY`#J2an>PmbaRCZHHf&q>=^Q9kj-Z;;7OFSg)gE0gc237EF$ z{WTC{)*m*Y@J7-9%L(z^X$5?;pzzn{1N;2(fm8)r;&0}pbqC`xWICLw3 zUQj-^z!XY-Q6c%}7i zBmIS{q)wiCV(T9Hb! zVF9sm5e1_WF5?f+opHU?Zjl~yKR+;MJ&vsBQxlpCs;&L>lmKz~QEX>cZV|6t@5P=l z#v{yniPi?X(mp7u+sA^fFf473B})=t!pch1`H66ryM4Iv0Wq&{BDN&o1qh zyr+3aSfm*dTX#c#d<~JhdW$UhoaOrU<}M9|iH-fHRP$zrR?mh1_wfrgZxi@jw*C}f zl-JSFD20W7#O}Miwz=}5lDYpuEP|~*8hp8{q7@MHN$>lzV)KIJX4~f zz&}F1$JXF^ifMwr44VkGk$XkScM;Q90`=swc=8T^oUYu&v}(y6ZT?~@Oz^QZ7Hhwj zdnDBVF!_N9*^~20;2NYH`^2}o=S9n4J_T>mPfu`J2=q9YQ>+7XWkve9J zOP2c6vt9-4tF^K`CzL_Kk)b%B(`&|J@0-CgkVZz}UeZ+~mmr_~3_QO38MDfXIia1p z4u+7mhYk6Xs`&cvvn422cgEY(xmgKDYb})!0Mlw-6R9jK*O&=I%*9VgB0xhphLGX7geH$F*AC ztyNW3yG5#26t!1dRW$YE`{omD<)wyY9O6S%7lj-)4L<`Y*&U z4$7Q1QM;8^{|B9RdSN+6nH&M4ADQo)1}Gt7L{C%)LoLUE8A-aH(DxdgoY?>}$FM0>YR2bO-e3@5=Yf09PI$nAPjHaU*S!H> z3dcV#L1xxS`=(HJbZ1DLN1C$bLo1Nfm>hzHIlA4cV8d9{Bq+=;mUL3UU8pnN?88SW)uCVExYBR6 zZG&9$gr}NvPe$mjG@FyWuMU&SZjk*ZE%PNge=25`{ajS^caP#&VMVU^`IH|S(jd-L zX3tEvo}>`cAo@Yl)!84{drLk=n}vWvT!rT$Gr9h_0iai_SN+17+0H9T>gW#B1|*WK zJ37XNtqOpY|7M^(Jo&T#HmZbWv61WFPS5>OIj_f>v!_&Xn(Qa54QyT^F*OXe%0Zk7 zw)>c+_Zs&za{wVcmpc1V4&>RG7!;eYv-TiUKj`U>UfR4J>URPCgwtz2&l4f+>iARY zNZ-#_m7-+y%W_|zXE=upWXJtjqWIwVh!D-J-_m3Yf7zybY`e4k}ewHxc# z9mhD4R4@*nt(GaE{~#n$PIRCyUo$}yBBZ->HEc{hj?_h~fN)}Ji+c-t&fTYLx_q)- z5JMQq|FC3cN_NL=eRUWaT^!hHkYFcog7DA~_rO@WZH_ocBwxE4yeQY%ZF%2Z_(f|4 z{}ln%2nWqL<-9_H%3Fz^iIU6_5>N!mMe>kvbamAG`bFg6w~prHR}g5b>aZ7tAOS z+zFVS(3tN&ir!+KR2m(L_jhpYh`ce#MDsXcZxmGdQ8nn)`|`R;OSI9VZ^%)!e<)Ou z#yN*N_?Q<$GfWMBsh_sRV{b9%8$GGvFVuE?*X$SRY|wKXLdI&@MAI%?wV}acUuLxt zrRPglAV=bCM?zS*1sjfYucy3X;sEYLq`yZ<`UD9oCvmmV(f)ybjt7H@6zCXRruc=)4PuH=}L@594#+} zyP`5`4S*{puHfE@aBt5=>Yn)WMYqfLUK_Ww)L6#^^&fm2-&hE~`qh-Sqs;-e(&3pU zOB_r0t}It<@}d(<0!lhFtz(n<{Au*Gp$BV|%!s?d6!V749kAnEB3fFI^ z@x`5o6|^C#v|b8GD-2MQ!Q}KE#wk5~NN$maTH^>`|It&RIBZY&CKOgbCAD~DD``%eXSagX;3FCl3vZ%*=5ez|M15lW{) z<7e*ornc>9Z(C!wuQyX7UC4fG{7|kk$p<)rR&75gefz5fMPWfyH~KXY}{`#GVWdxgze74$dKy2mtBQg5gvF_utxM z36cNPM^24HE&#+5|JZqNXgJH|CO2EcYxs=Nq$ngUXPc+qLrM(H9f8<2M@>Cw@d6t`tDOB&~Zvf=mk%>1P#JY=Vj zC-c`1vhOC5`(g7gE$LlCn0qmO&E8%k~%Z z1Vn&?$l1V#1+M5Sza$yg%2kmqSdkXkMiAm;!2m4d_3##SA~%XyzHS;uIDVDsvHDun z{7!%kN|#tFx~*e^yJIYYC$VlSGJ!rT0q992Lp(Xt8Z^oX{02VL?a1c8lN?g!#DkgG zE@&Kd3h04yWJCFO&|^wRW> z$s+$rD0f=$>xaQZ|HM&3$Q0!`uB%mL>x)HvA^8`-I?ofzfB_r~Px ztb9AI*~H#jE>n3p$WPOTJZxanVvddW6 z=*g2!h}q|TP}q*~M7{$6^Nqqv1o1O3Ty@|GEa7+OTV({zCuMVD;j-Vk#=FcXkl@XT z`|ZH?jzX_rW`}%njrM?8nCH764Fhp`%cNoj~r^aQ`L$_=64+YL^ll zhs~dzJ@;uMvmeWV{Zzpdh0{{XZWPNKTnDO< zZ#8{8)!&D|b%Ed##DOJfzrlUH>sK8JIEmKg%8M$$1;F}u-i%c|992-Zdb1jNA5|Ju z@$}gOKo?Ibwo4^mY~?12+?{J1h5}{xgiS%#o4iurL#udC z$227iA)B;lfU|d&>uD_|Wc2&*9u8lc{96ze@c=oe?z4^D~r`cz8JHXfVIL=$CMCXM_FLl=A#qsMxAi@v84B7Aw}W(DC1KW;BlJZXymYMPfxDdBCc4AMKoZ)FjQu0M z6MqX6LXo9PZT0G?L_6dJ>Dc$vQZQ1wNR2aA7~h9XYBhv^JOEW47j-BfPG}dZ;(&wo zEx1$Ge~x9z+<23$VqP(3ErAKox5Xhj@yGwj9kCWJ_~V%33jO24TRcl22}O`UCpECY zNOrloJbDg@FyokP{7dx9@%KB6>3#m`t15RmJq_bGfH?z0XUoF2t81Y@`=i$y$3DO4 zbCcdg!w{>*qBokI@`v&*ANk>!9w?zhB*?%V4)Nl&jGL-qnKDT`$8GI>h{$*NwX*XCEx2Xr(=Pt&q<8*?0cI;Vr8GwtT~W zGMX++q(4&zf710QcT%6tMU>mJJhM}~XBI0zPS;>S9;81Zv|*0$v?hOYxzO3xh_cnc z*&dx*4lHH{-)v2{IJ-QZ;I^ZgI2UJov)(Nc#$hD9U_GvnUbMM}=((ZegQ!UJI&^27_dKBlHAG}~kVuTM^dPw_bLU7H{Rb`TgZoo1mGJP$K>RvK1 zWq{#yR3-T_>FqB?VptcMfFpxIGC7~i*Eqiw4*EkqDw`Y3Pm|{xSEwKDEW#C6N5=Kq z1eOIgb*nZchB#O60n_8qe=J) z3A28Y5@%tFA(6TnDK1GOL3TUyUhk7f0v)ctl*qAU_(jR;sv>kCDCZw&+M8yQOKr~boVxGGeLR>GeB7IFe@mSCd4pTh`4WLc_sYnX z@|qA3U$QEi;M0bj)QcQ?b;ZPmq+IkLt6iF18S0OdR+Crl=Ph`FE!n`P(;v;>OlorK zhrtD1>+KgBP3$bS>I*P}U+j`3aA}$WBKrE~&MmGLtE{%z3kiIPF>uo334|J0M3b!8GgkZ(hkro>Y%cPL zYiFG1iNC@axbRfW;JIOuYGUZ<&oA$US>%Bc!)#R3l#vw z?YWg#=>)oTC^<|mne0-OI#HBxGIBrMPu%$EL#J0W1oKu~0bq0RJ3~?`f|pxJ;8Vy+ z`rH#AYb956VkFxck^-vKH89)!!f{vko_qMF%eI3~9{jg*a1M2~d+ZA1lHzjVgx}74 zQG9>^8Lbs5W;CYoraiCbCpT$|Q=}rA zU|a#>vV@-b9|hmc+>?Z2>M7FoJr`Q+uh@H&r(0BgWW5u&Au9J`;A>bP%3*6W81ufx_a0E` z{aw!iZV_&fZWCH5svdq#{x(bpu(E&zzqa81n{Y_aru!b{+Ehxmm2_~B#L_ej zMz_RBg0vxFPAzMG(@X}sxcGzjZ7^3s^7idZE+U#DK4-1ZRJ%HRmT8OYOuntDH3nJN z;_7IPQW9FN^?F|Ez7IwPypHyazO}14n6Y%_@a6!Ga_v91ULV*1u2V|Ef4EX#a`psP z%sV+bwRiUliTt5w3s~gyzw^c3X&d<6Htm?aYvc)sH$si2^ebJ`WF$C?bZw&U6=}GD z4LLyw%<=x6mc0fUdCsV34xT!^{=-A3YQ+phREzHVfZb-P5Z`hYtq(Ilg~_{ZgW`vT z!kJrPQ0w0|ZihN32lN3q^{-{lhLhg|D;*b0O221+`(IYOtlC^b3KVl8 z=aCx02rTh;RN$$bPmLCc;HU2W(t`eniJola!MEc2D)}#*X#sm(=8A!gyQAg}Tc1b1 zRj3p0(J6jINA*5PD-3p5&$u}iUhE-Zf3R?5XslbWx7)K&~S=3+Zm{e>zGIh0srkVeLk^Lf2U z&oI>S~2? z=~z0?S^qJ;S`S^!Gs(~?(lA4U!0g*<&o8l{=sJ&!GLwY-h6l0YH{s1?y!OX+Wv0Sz zRneyKrKqe@Nb!^6PMwz?@J?>6x8`NWiKtu?lR*n6X6T5AUH zkmd%nqYB~3@=S4;8|)z>=h|0Llw{qg%Tq@HC2;m}rX)G0tcH=SYtMrCYIG%z`#|KU zF@53FA&{9iQ=;5)VewqFsc@!mQVmRcq>^r%B^)Bw+}s(e_A#~Opg2NZ{o7{;N%=bI zYR|zom_8O!u$T3D#$BNr}I5#G03+X=~ z{RCAP3?Xye%LKaUQI9h@Z2mYsd>DA)+z?yoatb~fGH9R)xe@RuB#q_O`RAwf@BPs} zvBa$%zvI913EgQOaWBUbCEa_|u|Bd(x-$i1Rd$6xa!$>l5zX=wD(N&q@)!8Nu8O}# zl|sbp<|7YhN$m6U|NE362o3(GQkBS5_4+K1oY$FUCeS*`82X;#y*FSe@IDev;$ulubu` zf?z_z`glqx1_Oy}GkfJNuFFyok+V0Ew879|W zQ4mtZ{7vLjhgwc3Wahf_Gh(%x>|#+%Ma-3c?@0UFuLztG6rHq~NQWyJ9H@MNtPO*R z1xzdDtoI=yZ@x(@JF=>~rKBzzr=ixt-mLC8jUOx3W*R@$Uq;%y zTyvT|A1pmrtGC>&r|R)u;fu;CH*MT8uF4YQcLOYCy%`rYu#ftGUfh+xFYd(68y(gd5Eep|-hY8@HO+kzeh|+S z>T##+a3bhh?+23nwQ7R)xTZK%EL`=~dMXqtH2zk1AzZ;}e81vGutZ=Cs%}0;P_IPa z|4Xf!-o3Z)9MKUX`r9F~Qx5%ZdJpa_8giW68UJTzXq0L?a^iuDdW!o$(tn@)f%jgn86n__FIX~( z$4Dr_7F9Q=YWSoV&K46{2=S~-*+g<%^x1G#={RAxe2c+LfuCY`JNpiB2WLDqz z776_!gI`>5B)~_4@!yX8G4S1;94bsg^|lG*TxU19K2Lt|o<3W#U5MN)YV8>MnR&7v zqsc?*G{>*&f{QGDz=1F2mcnZ>arH+1GrYAVXT#uvD?~3*x`IPZ-;BJ{*u1OniDPa8 zR3(zj_r4Msk;>GZoYFVVSHTu3wgR2Sxhfr|C zU0yt(I#SsGGBY`yy_D;wRpf#bX#xpKppqB52b&H3Q4TCH1ypw&Lqf4>PoW2V{85yN z3+{-c_b6Jg4n8W|6^Z{Lv%*I=P@u+zN3VR`>X?3lQbpKc2>5yzoCRMp`X=zKz$duv zztrb|_7AQd#67%owW0kc2N+NLSX*kgMfq7pyARk1<-UN#ytSO=$i)!EWuiB02(_FT z0%Y+^r#I>WG78rZg=-Cdfv95y+*gp6zPVuUu?$G$pR6_5+i6g3`plBvTrFXRn}CEQ zj1@sfMIP-Rk{Xq4)DW!faX&%F$qUFu(XO?!qSI8GBX>9r7~j2-j3Ky+qExG9v5;ad zb}-}Le_LUZvO%{I^zjNQit;I<5k%Vm^^P~4N!9i7qU)Z++cTL|Ljo9{$)R9KheHC$`+Cu!Tco|O zEr0uAWSsqp_#DgFOfjoC|5CM6I|%VdX`B#THjq+@{fQp$9A7U|v)G%V7n4-0)7 z{9b41sU2~0$N!UfD`h(2)L(SOvl%tWal=P_=Vr2${sX*(i6mO^sGhGb^X^ejUd}kc zFfI_ixn^N3DP@jE%!K7nMXe+8=A`{*Y9%}NixW#fNEuNM2xYSqd~PR*0s95G`z}U3 zSyC-l8Q;ww2gc`NRT-DJcS}1ax@yuPZ1}J`o(PZzRJzpgE-q9mVISS=hvRaRkWzws zNx^FUqUlm4@N4V8ehKrQwvw}3Nu7``AQ`ZbQLVMh$h)!_m<{d!=x}e+zvDI~@O>Hx z;7|2@`o(M%W|}|fSY*)9LDAVEjwn@L^IDPk8ZdssxlEZV_y^c1(%`Nlu|ntaC1Ypu zn`nqi_k9csCYG?o)j%1WmipG)12!k*JTFN%hnDtgOST z;FvEzA2FIM4iUZj9aAfkhH&a?=#JusvX53>ouqEK!dPeRG}CW}X^Fo3jQ%jxW-!wV zwX^A)T0+PKsHC8p;30}~m|R$_fjLb-eP~zmaKkEJ8QjIlIQ0b*xelBCRRi<`F)a6z zA*n3QpptpymA|^b?iy?kurxHidk-X+(sRfwd9R#_%ak3y5(M2%w`bQV1(D7&;!{-+ zYn_*2)bW5DlvsWR#)kofhvhrS>4?wixF#>FhhWJj5b$JwfG4xS5T;#C(FWzAkfTXY z4@3+BiI_a7hRuRd;wdwNm#%bj=Icxwzib!~NQXGvS8B)M?eH#xLum&s@gf+YoL46@7huYL?GK z9tTfX$#uHG2?@PZe|Q>AwT4VmE>hII-a00vFX?@C8s64+oGLkp`qXLj^cB0hbr;?t zyA%rlfUaOIeU|imvQ1Lkh{CcD6jqiVQ{C6Kl{1GVCFf8!_usVUDWdc28*o|Cv0$N2 z6&s0DH*j0xy?gRp0SdporQlenTaJNXHB~uW`-*A^-cz7jd&PD$DlZ`@?;j?x5kmS{ z!)b?;>aW2lto&c4qd&cqB(o;Rit6r+FPbyub=+MAq2X2zmJ~#X7 zfHlM>v*Meb_c|-eA;;ZulH*J}p~(D+LLQ5DEBy<2evG0tBs*UaDMEbQnK7ZZ)+na> z8YI59nc`nG?G2$qs$*$q#uO#S?UuH98n^F@U-PAP#SL!d)wvMEqBZqB1#Rks@|YuC z>`l*3(#DvK0~{?Jn~Z;UJpd(t!bV_hdO=}T!tk5V!5B60bPF+BYGx2fq@?cJbVE;? ztu^##V^$sO;{wRsT^l`c`$tgXF9hzPD!!VaxQ0}TTg(CDD^j0hN@9uvsoIJmX44Q< zchy&orlOwg`kO%2Pz)vVt|Hk(plI@Y$#q}M}u2U#@%ppK&k@?1Mia_7Z9d37g3JkL$CbhA$Wb{80`D_VD|OGT8gZ{{-Ne{|dnL zr@sL;aT)`a7o&>es-575E+!oK->;1$mJ6{S8`T?jbTPMmR>gw3kb1I>-6$wnSTz5S zW&u=kA_}0`TSQt)8HEB~v|&Deu@n;Urb<$vT~xH)qKCy3_ln!iqw}|=(ka|0-po7$ zUOe^q_=VE!egticOUW;ZrBMPn0t2%b*g`J(M(-e=C(xjDTy}JfL`km8!cvZ8hA% zTEU5D?y8Fu@%xsdgVjOAlq2!D&dVW zuE~Xw`hxz>nI)DQ(E;g3$4x9(D;;xJ((OR#XTs~mji0CCzN78M0d9*9Mqex={l9Z2x_U`d-SaqY`@fLRx!M=S>cV-3eitRfi%stLkcVw^~ZTmrJ*sCZ2tqjcHF^w-T@ z`CCxFXW}cymdX}-d2MCmZGLB81v66K0|1Pg#iJ93*;DyT(;YbVyDI}PSdFSxlJvY2 zhdfwIV-64E@R25|cXu}I=U5Hy3Ae0?Zk@htocc!!Z#!OOgS>4jd?R|w+@9r%<>a@E z3QwGI)B2OrRLIQkFA3f4RT$?x(`biUf;iF9=R?9IdLtYkd|lV|i*xb4mZ^M*97x;w zp{KE>we1?x!+H%q&^FNM+eRgVc;L&)S`BNWl9s=Qrxk+sfXe3^9-thGmV%hZJaD0h zAe2MouVNOU93uas)cVy*pKHt&r??+4l-6hCAZh8@Epcq;EkSQGXG;8Y9(Y-+BzT$o zE6)}s<6%nlx$Ta75Pi;+`&BYWQB6?t$o`2Lev)AY>0yz-umG0aV*kWc*4lpK1@@~4 zToKjX9jF>%{mWsf5~6<@@5Hr=L}f!#DMvtKBe;K+ij7A9i;?UHp6&d}3|7B97}=rW z!=CSEni&WDhtt&Y5qBe@3=2N$=+A*rODtBJ!q1YEAy_*+sOsex2=-^!861~A6a=B3 zqIxbW?TItEG<{STogr7B)?b^jxPLGm?t#;``?SE;T0@wt&p<+QtgA>-$y^v6Uc_84;0ng0_yu8 z-`+1dd)bSS_dh%X5Yy^lh+N?rfDsW&rJ%%t|Ak)E*QUF#Jmz2B-(x!J_@JUQDMjjQGGO`ar$9^`Xa#)eRRkdI$v?Nkyj* z#Lx5iP<6Exm7AUwD@ep;Zm8b4wkCYi73YfTjx4JId*_%EyQ>K*fW+1a*P^?Zsy$7G zajsiCkT^GpEf#ht+mH(U2!)tP^s?ZT;5bjot@f^$(D2AO0Cii&r<0|05i$9&>3R(3=p>72m4V_ z!+vc0>{$~xxu1z&Ug2mEMDF4LdO)7~BuNr7wL!~+n=Uh;o8C?V*{%xsg&UUA1Gurh z<520Fm0v>NxC?y9WGRNGIMl|~FRpW;pz3j|56nDs*Hi^2dkuy078VXDhLfS ztsJ87-QN<#`Agi>*D6zfD9eomhst*2l^*W9xYBARtmy79%P(7Mu%i6zZw3_(*Ogcm zJvIqZ{L#lCH<-tcg;7~g?VCm}A{}*{UJ$3%nj+&SgRyn3k~Zn}S{AeYUzo3^+`Ie{ z&o*<8=CXfdzy+nxbTMUaHj_@%~Kt@uq|SN}*%f()_5;M?0-12~_8K zB#TvMyWz8w6h|Y^uXTFO^K`X;xlzOid7#!t)g`~>*G9?!Ll+M7LP6JmYUgFu5K;t?CS$mT(QByg-UWv{v<6tdpfbT{kZAs! zTq{g?7nH*H#DN-0pu{ouahnI5$gTit41wGhjAtxR zgTgCj#a){94qA{9GaoRXt0`3z#qaVjG4c9uM+KbId9ED?{#H5lD^T*prD0Dm-V^)G z&xr=wY_CP_`2|jvt1gXtospHqlB2psAP%Ap<9KnsME7agHs zd_lr0aHIo#Qf!0TbS50UzWpaoD=CldA8*RXxbF-D%kPra5vPAJ$^j~ZiFah4o zRsJe(HO#KkZzqWZnN&kiaR%e@68H{TSDeq|d|l2i!s+nQQ#}w4|FHOFDQjXqsgW0mU9b7_V?g6^_c@*`D>+0Nv8ZePYcmenlM% z(ftSQoDv%)pZJ1&C3pw9PkUxQvZ{A}D&BAbyjt%UXp@4(|5oNuIoYv)n*4y#T8@N! z`na339iEj6HY`K9?#OeoPfVL$*L92#&R-y&z26s+VKT*JT_u_+{9X(0(a9XK7`wsq zcIJ{PCrKS0cEN&}Avl!0IPBHZNM4riPKUi!d_FASviuG!0J!NO1X#QTI#SyCj*G+k z9bX}VCTa~sJ|-hynVNv2UK|Z1M8`V2snku&F5oBch&pdQ$6|-x33v?pMQ)f((C6%` zmvbJ=&VYu%j%&mlt1zMVid2xSa5qw(csq~)Q*V)f_bs712VEC2ze>GMROmW-D zz|-=ya#5NKm*WFj+ak1vF2*fBq@ME$qx+{Fq~7a#CJ#_2BYQE$sR?;C!SX85Z~BW{ zr3}`7A${GSCHoY?hT4F8^3EF1|8n5pIlK(+$Iz?KMeoksaSHvd;@wxWg?E{yW23O!Hh5WNI6lX4q%98m?+pW5;FLh@Y49Oq=Y}(TE$$B)aB0p$BzOK z*8O&x3CFmE#u5x7_bJ#(t*u*fRA@rC_yuvT=zFlQ$`Q$}Wf!E@> zBAt_6@uMeC;eCY0sspUe@!;qI+vH^O=V@=72Kx0jtZ#GoKs~D!=8-&+KSf4M$`K1T zV{I^b&9eJs(V}+(+(%*F6;GQZ<}|;MZX`6D^UL9+f> z;$xA-a;q>W0m-8)Qz9BK$Vf7m@yo8^&qs}9qI1AT8qLxfBEqcRA86zJV!!y!J$v>( z+ms#)WK0_d(swTFSCVM#^99(&Zm0MfipGmM=(d9X02X!#a&;H5*@?vI`^VmEq`pTq3~`1(C7kj z0!@&)gW`M4$++Lv^tE4|2ng-DR5HX^txc(NF|kU z>v?z`WR4uW)dPo`Z?WK3p!`oBVd|2~wEOE>T}SuhhHdA`7M8F%@{alIS&pgth3CUv zMi;|;E5g1eqx<>bVK+#v=(LX8Msh{Yn7ZUq)_hT*f{ta955b|ej1XWva%L5)+mtAX zNw(GF6L)r640^mGghEIm8iQ}7Kcf6>VV|@F)Dbpk7?pazU+FAkU1j1?@RPtRMLR3i z%Qs1vitg7Eh@!eiyaxuMcTb0t1&sii`r+IF&oTj${|-KJ?b-)*Ez#L%IR>$?71-{B zqPl=}pFxMwmEYqGB;R+3pl3IVQ<4WWr&2FZX9G+wjru_Bm*)>_P$GT@T;VHih>9;i0Jqzz6o4h4 zfZQIPRIbfGK|up=Tg`y?!6%(lo&;u;7p*4!*O&ncMP z{88ZeX3x_ldAIgg-_#{>Oz}*k6tf2`kNXT?-#+&2Dl58Yx@1ic4rMmGxuor1;735M z_>A)tTgsn%_8pV)yr>Q5GLzzvMBv!1z$Z&eG@E8bsPLBKB&ct!7-&X;PxNwP_RDn! zKolQmSuI6V_O$Oc=k&jL-M2X_-mR?C0pg2Rg`#s~`gVbw>}k68vbO&QSG6^c6coW* zJdccRK2rD^c(9muZBUcFa!!fF0FKL?YOV+1fo5agg36n}az+K#k*x0#kFu;;---Mu zCY^gwyg+hKs;=UTjL?oUm&wBs>V2j6LEu8dvm5LfUEgWG*K_+It?ehW^R@`j=K+d!zLP{0T7ESL)G zRcfeY1m%7>_2>NxMpm$4J*P_RIC&*7@SY)-Hc*J~$R8Mv0@z{Z;|{F7!`JuC4igvbj@ATa) zkTolI=>26EHJ8)hXBw?K2AKo}w$u=8+ghLWA=IQtJ#qF+NbU)c2H=hH z9|cv}{T?828tWK>N+YNbvyp*Sr;c?4Dhoo_9!Goh5P-zIyZ36ASALzV8Z60wF+(FK z`Hk=LYmduR2dF@%zTvWJ_Q%f=Q0L;Sfw-b-*h+Q4X^A|78e<0)PV#1cSN@+6Z|=eN zJjuN$#qM{~(nA#CVc?UtUVs0-1*EO9sX13}JBYc;Cgqj*`p<@HSedadw?P;Li)0ZQ zVywKrlsbu3A{r(gCI2CsVdowTo(k&v>O5i7UMY{R}tE?VmoclJ(B}L z4)i{SffhN03X6;1FFz}%o(8Sw?I(|`Ly3?Dy%kX#(RWvn0@QPE82hd(CB+w$0nz!qV%= zl;I}e8rlg4NHx0PkoK#st(}oM)51I=~1nPjl#yPczySS zkp`oWC1VtT7qM2H;&C^e!Cf7cK%Kz!LaD_JRBIv2fX<4sWkqztAj&HrBn90&!@N;e zlB`s3?q++{FfQGHe(Yp#hKO4&T1k}^+H7T<4RV9(p!hq8tO)lM46N@YVa#}00zh|G zGVU9^dPDhkm}-NBprZIo|x? zgDAU#9;VkBF}59_g&^w{3K6f}I}o#VL&&GyJh;7xvfalM)+Hn;6T-0P{h_J=XaUs{XnS0X?h5@;V=>}d8R9Y_Ea|ic1%Lp8j95q~ z+(uBRS4hJa!;Pd!Q3VP)nmhZTzMDqUz+j6^`3l5o2()r&Uky9rruJ}$0}LOazQWIh zg$tH_5Gje5Zlb-F0!>Mz-^C@3{9cTfc%N&1;BkF3ub}*Sp0>{4Xnlsy(xWiC*zDe5 z8RxGUKtlANOjTle#P?&!cWu!ydl|u&mW@GkPo}AKNO7UJ*6{05(`zzc;=H z<|z6qg}*xChlO_qKE|K`TGe0N{Zi{Jr}HhZIae(hPu=vB)7}vSSCf6y&3HEdiy3Il z{gUAP`H03|y+D;C-8OwIb)VkRk;SDu+`zii-dBJ0%Bwp?RmUTRmGlPrGbNexUQ+G->OEes0p>ZL1c52@fm7Z;S z@Qnybqc=pTe0AS0%Szr|j6b6@Hntwixeg>lVWNu79@o;z{-C96sZ}0lP{f6fE^2O^# zzpWpnJx(f@YL!J5_g1s#|Cut z-I{kO+0U+$JhFOQk~TN7SNJ6lqHiy`SDRhNwsiGOb6&H@7H47}FliJ0hPBSMULtV7sD0FS?)HgOx=1u>37;ek27X=UeoHg#MhE% z1ioy(SZ-5UsLz#3VDdkE5wkz(en*8=lpnbz#?{Kc!q`w^x*0`gRue;WRb9r-(+ER| zb;VhY@7X6qJYS%T-V11)V?wUSsRz7m{%ix`4r}TWgEszTZc4tR-1)G-QW0I2)cS`) zS8U_?x>2Sp4y$ZN1WP{S9^38F$Nc21OcslG#hF3TJ4hPh5vwOE-)1Z-Dq~Kx zu>Vofa2dh;tR#|mJW2y-SSW~!dklK0K7E&CW}q$(L=>;PB6x$&B@Nbouk%`>u;^|Re88p_sbQR{&UFEd#^3&RT&X^na+Hoe~DMkao#p31q#m(*~QCahHi-Lc3|oRf^f zd#D!ZwZmj%M@i*A<* ztfKmwCYs12L)L~K7m;F)hO#M#fiapCW)h~}yd@Nw4wAPQqdbP;UY!Iri1H-mW+G~8 z{gwl(y8ms#0iAU+ocSnc%PSP}-x3Wh~I|RZn&p`*}@k+mZYeqBuTAK&E?=p-OQpxd_ zdS+34q}r!x*@@Udi%%6-CYK>Por=5LH5KcG5+0iq#VoL}BLpQDFZ*ld^@GYa*t{kE zHyKln96mv?TS^})Sws-Ej-+JDVN9iv4E0mmc=LP;pk5$II3$Xf8|o9jf&WC9>f z`F_bKykg3q7c9}qh7n$GRFokB6PusEDd}_fyyiCWd;wPn>e+BeDjf<6)S+5TIw$}2}uZmaUT4gU=GFW zO<_;6(=Ipq_UlF){s1YTk-tT)kH4>%Tb_B1UWF9Z3%SBwY+6ziR<4u{ak zrpXQKj{MrvofyBNCii#r-*kJsF}*2(b>s&;>J=$FH6EWY^`tZX({$p^S1&)y(Jk)t$`ra(YiP^zIS(j*k=Edh}LB0cn~sPrbGgpP$GEl4MH>6@zb zUK4te(52n2=bZbV?|tvM8N)IB!H;C^z1Et~eCC{wo2c%L!q*mIE!1A8c6YfIs!WkP zFgd_O(b3H|+_YFZKexWiGD&jUzz9!4mni?U9V2?(p{KHl7`wLT_z9siq~i=Qt7de5 zshb%uRvWV435H$Uz98If@T1*UUYR0ae%IDi@%}RZ%J-d=q0x+m*zb3s$zmr-H-)^>Mw2ceEP{VW+r6y z4jNxuYsAcU{P}RB3C1_c{w+=Ui=trWKE&a^Y4UOAswI4TFxi}XSCc(kX}7qpR|kc) z>B8E;ESw^g)=d;Sj5gbA;hC~-dOSuZv|Ds|>FUnR%Y8XE;X3LZ^y5T#X0{!c>=C7l z%`@uOUj*lJMn3)7>~vUL_RMvKy%IIaFh(SPya`PxOz)1x@n&%kla)3XuLQ8-N8{s+ zDCc;%s!*9?uH||heN_`Ef;ghFI84Lb1&)|3p#dc9w2h+2<`kSon5U=cd&B- zTJmW0F>E2svfT|=a=!Kl=;FwX_Q6V~CY=GAAOdKtBq-Q$A=qV9pm=}6mN5&3rqTK= zZU8T+VQ~bCOKUabsOWxGQD2=7=dmY0K&Q*?ZpHaSnIn$GD8^Spgnuce{;rRjY8`sM zQHXy7FlNvzx#>tql2T3`c~xBI!PM)drHaLbWqSNW9mYOu$GLu2mA= ztKo#}&}mzctRar56wJb>nuTP4&T@9Q(dlXa#q5AdZ_Ncbr%STIyW_{>OUdmzoEGzp zao$rx{_FL%J9Y*Un)wn+*XQA7EhuB5)v&Fo#+dlkJv-^6!P|9|?QV|5Zfv!7t*uRi zn9!=^z&*uxu@>#C0tG^l_I^e*9iNcNKq!!!bjCLr1vjoQAr}C(*{8$NF5k33K6(#VbnqGDdoKS{K zYjT|yqlo-PHAR!sW|>CcU3@+I*Us6bF;nr!y1n6d=GOdxJAImCVk>s^Kme0csHe|| zzPan6daWr>j|7uQ-=O!hHPC-dCB()>GmO1{MBjq5xjC(DJ!`nn9c{ak zypGuu(+-cJw1*i<20T4*PgB$;Zjtm03$s^tfwAU6y!YnNH+j>i?9sgcO zKrbYzP>g#eI?;fhAJnJ(e#vA0{gVF@mpjQi0?eFSd1n|ynfz#b)I7k^h2{P>_0Ikk zC^1R_m{xk`99+~-Uy2txvRSGa1fMT=B`%_Hwz})qy0&p|%H**&YI6NcFCLA514Di9 zV-Y5Aojw2{{)S+^l^ua1Gc~uKOYW}?XyfQc5c8=|2NUsK6$EPmDE`h~wV+AbYFmF; z6U17hy%j>QF)E8Fa^o#l4|h4txs3r`2A|AqEDy|f76!PRAu@-2)b^j6o9_$((aoAw zsIlqp*dpZ!Fl#>|I>;Qb6M-fel+f@7g5HEKl1Z>WLU1Ka!T|`BADG2pyFjOh?stRe zShz3f6^bQbK=pqG;Q@lgOZ%&6I=qgxS%52oY!mqLocY(q@!OB_=a4ASGbl0L;+6I+ zKrOkw%&01E8JfWJa(DY6XF#WTh%j*3z|O24Wvmuz7te2bPSf{!Qz{1c`9(xRpWE5D zQi4B6SQt4YU>pt1QPo!W@7cN}c4L#TuEhJJSJ*U1VkyiQd8JNWo!O@%uCHs@jBoY5 zMf2qE99k!Zdt^x8AJaZ)7k?_coM^2-{kaD1RQIu*Z*SG6U!{JaHP&y^G7k-=!lxLu zMc&CQ-P{cqE^L zM1x&vhER|1SXr1yhFK>oF0;XB2e#o-*IlJfG9=X<$@(Cdd7ygCZVksZ6gH7)9KR(lr|mgQ7A! zoRzdpSu?cNu?6*pvwPSotH#Y$g^nwQuhjF=GWf<3#ht8%QZ*!`>QhQ)be#dUH~EEM z@hC5Mo9c)cFKn%0b!O`^brg^|xU1G30sDuOM_uzL3T02;{IKP4%vaufuyDr?vBJ%K zYmKt3Jfry2yYlme z_WC{8!xH{w@0H}$R7;H4U`>=Jj&apq^7z3sMWl{e@yjpk|r~^sPHjT!GrOpbC^cJFim?6h??2@Z*Znsxk{!n6*jP zV!e7=Bc#m!}5k;#Ov8j$df-3=5A9$M|o3D8uuXiCx7g+1uWj^Jl8BK;Q zd1uED*W4?jDL?}+#Me}=sDlKW5cD8;0W(wvcfDCR2#&d*gC@wR@-Aa+sXI^Q>ZQf) z9D2h$A=pkOyJd{x-)C^xf1knBnm#J}99dvFUe12`#=)5ytKECTDy?h@!7^84a>=U6$Vp!W0wu3^D)3-?EX zEX+l@l|$xsCUb>7jUE1SqxcFxwXA+LXCfCe++u;Rvu|cU%ypWqn7W`v5rvYHoa$K4>}gR96rT zG@=MgE3(Lvi>9nrxFwvTs40f-MG6f_IR2-fKZEr?sKySNO zm+jyR5*=HCI^DaR{!RCbuLpwY`qJz+LMD9B)JW$bV97P5AR4x5(=EGMdvq^^`t@qq z=aqM$-NP*vt}F8*TO@A0r>eduRa2S?mP}<{$Et!LA;8-UYCx>u{1BH6AGCmnq^k@7 zD(^A|0Xz%4$CpyI!PmM*U;;xN`l8~a=rI=nfu4){^YiF=opN1u$pVGG0j6uIZ2{ihFk39*V_Sf=lnyjE^kl#Uq6iY4kF<{^tNiPRQSzCG#FDNa_3`1~x`*&pl# zep!!|Db8lT!QXPP?-S>8KY!jOvly+j#oCIK5nuYWv8&+;Xu@iR32i?$pgHV$MXcN* z94V%Fk;)}`vxS>4rO?ah^FmmDgLPci&9X0?c}cz6sqpUpe%UJFHB2i7cwzHZ`7d;A z(&VM>4Tm1`O*lN~hcdWiZ|)NYR%?4j8O1LH455xK%3MOHuV{+bM2`gKWutXHavbSY zMQrfeBj6M8O2tB9W1ud-czQoLZY4Dt>QZ#m>?8anORv=xSig1UlGzMBRT*PkCN2Gh z&|lYoMBBegsNe2wn4W)9iNBVNXgLKo`MTFojl3(((`O!%(1gntAIX-iY+E5i-++cn z2^r&&(pb+Z8@Ig4I}}OD?TKtK!Ba=j>b?<-LOwTX6Vof5!{mk%ddHXnw&L#Lx(s)P zZ<=ng_RJrJpd$SF^%8SISD@LPm~wS{+6K|bjLDaOK4^%5xw6OFuO?#$^l*2h#T!c@ zv8767tt{-Aq;CLMic80A3&+rPBBmo-pKAmKB!H(Mw))#0F3T#y=4%ZDZH^9c*ssA# zwQGSd906k84T~toGu6wD5CL{*Lbk~OL?ET53EcDjjJ+;6B^I3sI8MR@^sB9yPG{W# z>1|daO}x`>zI<j$|^8XV==xu5NPkO{j?R*;K#Yk}z9Yv*rpn;fW1UvsK>KrNThOe1{x7Ki_vlxpuaqB zhA1*h8?#va^DLGLtW7S=VvwbtIB^l-X4hke=SC{qx)c1MA)96 z?7-9PzV;V3Qt2LYCx(JTWu*{bl0TlesU*w zrfA8I^{%wWq~NX?lmz|&xIZI5JGhVB$ zrW4!gD|@L<@3}NhhJ3R(U>>ougcb*oBos_zCQJTHUVepOl($Bw5h5;Hpx4*fWd2Z8 z1Z5Ds=^t0qceN);AfZAy*Y|$m7cLA#ZaE2I2Epc;Oe_wd>FvId+Acq}-AhYcpNH!% zyoNtoYl4jNXiI&)|K3$ERa(S=)IU_?l>aUi7M#?BT}_+)3>Teyf(A09UZmQ)7+&(d zfPOJ@MXhdy56hGwID)Sje~FGf?GgC}#Mc@A2MoSj=r3iXm#7bBaY~z>qE#QO*-b*1 zeKYZ=LEla_)feluz^Sw=7k;hR_o=X0`Ffa9 zvv8x!K;~DJNt_**#&n4#$35VDL#0%soL=ke>PL!Qa?zI11M)eHj!()}DQX}@b`P4D zA08q$3K75{Na?}w2-l#^CSQw<(R4dM*ibE;?gUs{Pka7rO_MME_nHO_FV7D5^ks0Z z64TP7IA6KU#f-&@seFhkNUyGxz1l?0RXO9+-0t^N1y5sRo92Ao?)lDjY)onQ<1lmi z2;q8e`bQiolTV1x#4!<^JX#SJA;#bY{!oSQ#U$Ic& zX#DGZyZ_%?I`L^ofSHVnx<`ZEO)IWPlN|O>?i*Wv&u#(pgA+S!R@Dq{2Eiw{n6Izn zIMORnmI5xIWBriXr5&qfi3l;uK63w)GMNoy<_H8sVa^9{u6~vtD5($sVf?;#dl(}W zfk%xn2!+~6*%V|HPjMZXiQa)-$97gN@D@C7?FSl?XKgC&h2FfP$9hLy2U$Sh&8IA> zs6APGp`+NOi6B0LyAaY~+W$2sTc0?GVHOyxhXgiy94C+IJ}J_{xB0HN=5)gx)wCCb zEX>MoaxNV2K8}EkLKA}F#wnl-qfM&*38Ki$&-q6hcha4tNH}!t)=}8p@76CF*?Wrr zMi{D}Gw*nQ7}SnwxHn3Rnb(*{pEKHsj&-nYfR0=r>ptMOR_Lh>6cZ`Zn;EUA6W_uV z`TI-XUVtoP4!Cg#Bx?%A#pQ;qKfI_Pb=F~*CH&Q%6q(f0Up{#7B05MyI8ty7KDfd1 zjJ%%V{C8Qe&h3{|pZ%P3P4jsqG@ufG>&8vWlhBy*t5xi|T_}y-Gs-olPF9&x7OPh5 zSf&to83mBG%G7D~h?uBA<>mjFs0R05eZTzi<_3nV7Vg^cT0s2OI}Ob3z!U+2vEA|0@yB8M1<*ockj!J#NXDp_Mwu{wJzSu zjf(tk%)_aUYrg~{qeaZ48qd}Wlo6MS(`Y>Z_Hrl=A|o=n6sN8XCS-6MC-HAqt$P^; zdYOEMz+dByu-?=R(ZmdlG}^r6Cb=3gJCL6F)LUyQD0?4Anw|K&^1{-3;o%cahQgMCMYpa%knm`j<9t!Y0j zs-@YuA=qV#h|0TB4TqXob>-W~JW83XLe-6pctoAUdV?zL`Tj#t9tdNfXKF@*x5x3> zA#~zOj8){P+ikr(OT^dkxGa@2BnecH=(Rw&5VyFy^?6u7!s9LyYIoru@db=+^RE~u zISSb3D{#)YmTFLc`iiD+T)`jnTV7O)r@*&(19FBYBf**Q=#;x0oqq3bSetMd70Zcr zI{L^XNN+|Kgi<6Az2`AmczCKO496d^hGACgg`}xPtbpX>yKyJc)}7W%FkI;54UJEr z4>SOVJD!Bj!=(+dlClxjzi)#Z|9ueDv*X@j+5tWIQ zUIlI+nK7DaYev~U{zn&pZ#_{>B$5cLnXHHGo8q1-xTk&fSmM-RMW9xRoVezUQ=io< zB1R}Bxr#92obB*yahl0nb~qPjmi){g)<#|M0inkuCR0VI$7AD~pDN~R_FL<)+)Uwo z=9-+`?>c!2AFySAs=gb~*7WXThHH>!-$cT(dCThaD~N4eCb+&oD~G;_^6658vir1` zKM5n2O%O{qk9;I4#0ju|4O+AxLldqvTt%XvE=K5MC8>_1y$A|_fJ;1m@;`5!a_G;m zj*Zu%5X&gskM^O?I3-X9PK@8${bFjm#%zDb`<-L7UgFhr#RAtFWBIYkJoJpagpBYd zmF#G8a82{=1OnZ(3Ru73U6_QbK4t}eVl#-4Q9}b`-oj-bGh5njKzIEYWsW(}pkv)n z#Uvk2YFfMCslE*SIQ+46QTSHPUQVh%DyItd$tt>o{|#@f-t&Bx+>?+c2}tf)1bT3d z8+!2Y>$$9G3hJZ!7CN8>A`=fy5a*i4DEf1*_Ja+eWpg>M3p_pTLoxgmUfE!L?|O!W zh=>q);|j7FH~kX9G86ezvinG4H33(2ZLIc4mvV30;F;eU!Vis5&8 z_wjPGufNH>^(4lY^|&Ib|6q){`K0twa}Du&2lsXhp~FnGDb)HS~wA;|7?aQTe`UFILF%N2;OTIv4<42SqF;aL62NCgX>u&&!gj;>!{kh$#a!VlVH$; z^@h)c^Wb`=bEM|_%EKzuT0k}zT}$k)t=>cIcoQ33`~XuO&~G(BdNn(7$$v(54KsB? z^oHGoXKyxM@35yhs2Vd?61J)6tzyxUnjZC#Lq<1nDwT&0HhH1qWIOkQ@`t1dwq9jjB08X*?s2jEyEJUd_19w28)o3 z9BFUTY3p(ne^H#J7J=?NPysX^b;dYR@|J8;!dl{$pgRHYqw|9_A76Bw$-==cY7$>+ zO45$T!E0UJ4drQN2EGtAk5{E8B=Q>#*K*7nm&-9vsq`vdt5|rM4!c{PCgNl%3Yw}E zseNu#_bfiPC6B=SfWF!TUHj(VwcJ0xpBKUz_WZd@7Z*x;XkK|N{$z5Hdp&IM{0B+C zf^%~?&()v1Z|e5PyH+w7S=0rTx67aPgoC*BnLoFdzlv!CcH|%)n2!;PMpJ!FH>)v1%LXRw{l<(vn0Kjvj zY6~LcG!IYZ1XaLIkQM0?i`YzZE0^m~0~hTtl!vDzsvcvhlkqji~0PWQ`x??Y|d+tz?kQWU1211^>T$7*gDp-&E&%cBq=*JXotk zVh<}|5>v5BUODxST3sO+Ba3)3AmOC$-;kHQ4thLO)o?;&&+8x+1ZDat-hc_O*Iv5? zQYxlJaJ!K)m={R&;e54}y?e%MUm*5-*~$rqu0TJ76Wa*yhCVqJ*%-v@AyCf1Do(TDZV+;8jt9V@Z_+J!H#>@`e*1R9u(FmwSXX@xnkOboZXk+1bl>+a5ntr>Zq z@|3&1s3K^LL!4%s&tUVy1uY}`kZ1~Vk9a6d6Az|%EeqM{gQo)MZX7p*nPVmKznB6S zY<0@esa@{Jpg$9T^XHySwepfDJti>kPZd79&6A8y|FWk1cGrvc%PxhFf&^JM6rayD z^r2nNM5ohn!fW07JETvGwZk1`=*#mi&BF0JBND}@GALVKb6n~w0d|||ERb&I> zmZ~&Kdwm-Nn%qUQ&mc(V{g90?IE%X8O;PB`35JOpz^Kia6iS(ZNUVrk5{XULPICfy z#~{%vL*1S2#!qtzVFwNGEiwb6#n8>f3S&^}j1B1rEh!hU-XCE&#acL&C{>>Ac0ybO zpQa4B2!3k6;(;FM7{5GJ2qrjDD6@Qpym|O5_$+JNZl3H09lvL&E3(L%27y)SG&dE% znVs!d;#V(ro1(9&EPrI?7GW=)X-L zYCy3ZVgl9FE&5EyRhL^J8)b2rDda*iy8vY|h5r!*2E`DJk&!-px9uR;On>00$CWd zZ-o>WKan8Mw#Fvk%~|?lu;^}AKw6z?JS8q{NP6w)OP%vwdHHfh0CesB+AFW_25wwg zrnfqX>^*5@IJralAGf#Ah59egk=#23jCDu+=bY^GQ$01rCc7y}+F4oagiY_&WI`TG zT(aY%C?=`=FnG6Twk?+FeF%Jhex`eaXH?qrYgeA#l_dFsh!iW>W(Cif){L<;Q@)p> z9OY)Lvjw$Fk#?&9$_<4zi6Z4PGxLbNEOwM_jVXd>Z zhWh~m%qg1%nz{=#?2P&BJi~GC6{FTM@IphUEJ(zrIZvG;k~CgH;J!TpQTK8Hlm~yx zDBp)*tMA-oZF4)uo-P&i7eD>|o1J%0iL6vpY!+0kyQGvXlmGa?es40}8VxLTL^-N5 ze;w0k__@pX=}&w)5DRARjzh{>u!vM$WYHgaC9Dm|TGQuW$Qk28=N2>0{&l?{A6JpkTw};Db~nDZwy_C(XB= zvONciJP!`PCATB1PPLA%R%JK?pN!i1wRn%Op*c{vQ6`Y$3XGR@z2A zn+k{7jq?pzW89X%p5FnbgcMd%8iM6p%}uf2myK8%zVfxT+i{{EQcS62tuK$QwwuK$ zHnR*}as+TnAOPJ0cP=Qik$xKio>mQ?%bXC3$lo1xGNlXu_YHiH34R_Ro|8=*IK`{i!hPUA}Ao~^#0tft>I_i@-1zre#( zx8Jv2v>(+gZ~fTIa;$J3LF#G=8%siu4_38QH3DyZGUSWSUF)=+uvL!oUOJK`+IhJw zh}L(etg^>uMZ3?eR;T==r{JEtNX*uI{sJa;dwZf(zE;Vh#ps#(m{c)vzFhNQ^A@?_ zicSY47l_P;r~VMfQ>shu1XU2wLu=kOa)_?LJ z|2_trS~SGSu!!woE^d33nN%&eH%}KlTY2@Mllo2CQ1uX=Xx+zrVwUX^cXZ-8f*B16 zD{uEwf)^NbpEv_zCl#f>ybgcdg^p<9{nbQYmSwMa+w#S_UG$nc+kU*#z&mvo+A>f1 zrG2g5TI=~l90-09q!hiYZPQmO2;ZGyf}k6~88}GOk{`ZoBpGkmw&J?KGufeAVpxNA0tS)@8QNJ3z!A26%6*z-(ncDeDK=&$F3eu{APT!)yfhd}skVumQEkfsrFi1b*pXppjE5;UK{ddFo~df$V90`#cGszN)0J}-K= z=r|#)Z~O2?Nxp_>LKFIcX!#crD}t%Ry+9MC z@h^Jjz0rTpnsU$)OK$|xEq`b$Rojz>i4Nu|&rq6Nv|J>XWUo@YM!wzMxa5aEuDs+p zM?Y^=fzNsVK5_{o<{!gv45J>Yb1dmrifwPOsx(#C9bmoe?3)=ck6>rOt|I4&uU3yG z#oeed6Qd2+qJ59gmcrtPXQA45hV&^<-UL2wzX87)==^r7kk#q@oHMwRMIeu4hDb-h ziWtAU(bAg&CRuDmsu{Pe@SVjb=Ngoc?|n6#jk_;u*jR8ld4ZKz`7zibdM09oDv0*) zYuj;uhyVv1{RxahdK9RNRffzuAnIZ8LADWl2UV;}vu*AhZ=OX|cJ+j7H|77)Z7Vzvw&^Mz)C`|MrmFbpJus-TUK8d(%^%n!5r-6L3sqMFaOz z5Z^(EOB9$+>wn34VzWM>?IfKYfktAh7bvv6YRy)Xmoc<%U*+q!qViqThEh~SrJrNR z4fVuafjPe_!aG<1+_mIhRIM6}JyHs5^95PdGsJC`axETsv-}(%i8P(bxr9_i+;pt z2Ge|i84%iI9CD(myazj*KqDHYPkHtW3tWNpJvSC9*mryOq7^Nm%gOsvdRBBtrS!&2 z@3A8r%W?DTG2X)rSV;{62og~lc+rUEhicfV zMlC1Zz>Xdz4n`SeP+wf^`K)7q0H}q zw&@d4KKRe+`77)LT%DRqf&|Ym;h)x%9s~;jVGX^CC`!Mw;_Tke@R(2C0Z_SOcO*O@ z<2E#SXjE2>k*@{&KEW3+q@o}F0NX_xUk9fyW?`7+iBtlzbLr0ZzUQZfZ66FA0a zS_s?8Flz`+0K>DUJ`*rpOHdE(3IgbpS82%hz$WXss~Rl`W5)wDEaV0MYX3ht{m)hW zOL;0hO4pAr$(qHCRG_XGcq=#p2=eZFNEW2j!qyrjl1+r?=6|P*}-1W-MQ1_L^BLF7jfxY@t1W9rhlSTE@8Q_=a`l~BNmVD3x{n2(~a6gvzwv{rX->Cjh_>^CYK zadI&(;$?9=ZtX;?eEujEst?4e3(j&G%$IDPT8+ZKG2fEeDbcGbZlUC0ClR&yj+`GD zYSgC)z~qfRMINykOd2ANQzK<@XU4W9h9DORxlbJ=t^qR^Z(YzCTkAonKD`qMwiGMS zjN@g#V=+HpAw9eI1MCObP6yzkDK`%=pugerP@JWzGa%QhfFyZ=fp7k3L{BNaOfRAL z0K<9~2?jrt(j}L7(TH;Fk~k;k+^={u@FRZiKadiPuyVL<43bE3pFh3}->WGulq#H< z{&)s5cKoT%<0SUDW6z}e)FmNphjJ%>N>V!hE>OM0=9 zpi`@6d>rmQu{!i3i?YwJhE0U;$#BeDt-0Om9qE8-0pF)eb8GDO)sF+CU44_n&bZP~ ze)HUTQy_C-Qn%afrEZtzTUc?l$FSb>M?^#BA(AO8Dk)@VEbAmVw9ED7O{RRkW@e(4 z_kOR$AT>v%Wk)dQmS*U{Vm$(nVl&AuAX)8FO1>wyG%-Z_qqBUQ*tGPq2yVu*cquQd z==C>N6vsZX{9L?({*@<^Yj;qbblP8Wt;NZB&~)=^=`}s8_tESdd!`%Q_g3x7>L+(b z74b6-klgo#3shX60()d?ymm*T$(P-^APp_0Hr;n#%9u)+SzU1^V0axhYIlczW6@W| zWdB)N@KSjXuxd`x>{At(xdv{2Eo1!C9AuqWG5MlKg*gd9o+~3$Cw(2Kcl4n>mFN2P zK3nq^gu88h^U6RZQiXC zdaIzH5t+={OXl(lJ7j-ySa*8CrK@&l)gAkiPEd`MrD(2s?9&&KepfNdv(0~ zJ0n}&4?vK@Vs?suyF`$zx4O^MVRPw)3`w=dK` z05xg$!m+I0L-fE+-jn!iz)cHMTVD64EEDZ(z{{P%sqoB>WQsKdr}k_}_)E&^!_f?G zERFpD$rVvluA6fHZR9a3UTiAJ;knbfp>V&xn~-BdW?b$dXR6)$qByKBs!)!3>%(|D z7*%(=TyVPnV`AT7({3*^My)#x?Py&&qFqN%slik>@M{_NqI3GRxoW94GEBADMe?c4 zcf!k${_w5{%1qppEN@GN({b^GU0t0U#F=e#oSao{(0WbA{;1F|E6F|DLhdVFPOIHf ztMw{zt9FXn2B6f%i`82Tzpx~^_ti-V6uyk66!!&2A2hCtoJMzJ!5V9(YfI0qN2bc_ zq^c9rcQin~QQyj0FFwVWB7pz4VBP2E+@D~~{}QxAw^5m&}SRnc|-V|F;;@ zPx1?nh}kH^G7J#z&0#h^D^0dR4+12;1-7>&1X$|a4|ZOj?#dA8JYtKJRRlPTlN*zI zAywk|kBeRG^_^ZM4j);TMD^t1L<8FAG3o=fp?kzDiQK$7iNQ7B%hhmZ!hPK$*GdfD zr40na17POOb&+{Qnm8^g{i%^_n?=NMOd2zGhPiq@YE zvTNrx*{RAdJFId)sLVB>Cb+V%)ezHs0 zuKB~fcUrb{)`uN))TA@V*JSSg1tVGhDZ+oxI-pisHI!i+0VT-;!EoPA(#i#eK2Ql7 z>R{A791d6WOIrLMv5_1j0*M?Ohvz^!CiRr8XhqORx5cnnEca;3Y69LzQk|m^+N@*)tY+AKfkaTIRgIhRG;-7 zdVym5HUgNZ{0Xe0-tKWb0}up z`ab$oTEGrL(gOh{4HgsyHpQDQ5?ptRk;A$OG{>EhNVsoTBwUoSfEL?%llUA#dZC&A z{b~zDAmlyaydwbbX8mOh2S3xs|A6YhRwJv1*JZEi)wJ4KC0{zAU6y{Y{ z%a?X#epyeoD7RCWE^n1b*fG`cCm;H;u~z$7W-Qmv2d)!yu8Xl_Ws&)s*|vaqI<0xH zcYf1Pr!qk?X@BaQC+}1@01|ND=sue`tj7_l?Y>ExYk+L{V8=7naPIc$Uu$)z?L$?S z?Ooy&CDF$UUM*+`N79@?oY(cQs>aCUWj%%2d15BFa zDY8YMnO@9*^i~cHife`jy#b02hN@56dpRlJYfj^*@BIzXE&l=Nzr8tUml9k=2xNkG z8k~`(N|VNhx7+;XYxxjG1(1^5VR{TkI9U%h-8+h{HO~RTEQO<)r!fzys~#0Tiu03 zn?hFbsP%5TnhTR0F+wi5HtTjZYl%@_`;OA7@`d|aUYaRgyaiW{)RGDdFTQjSw)A6! zi>050A782jYU%~98p(Zu)TlHOrI%}a|ZcNty;$sN>T2~%y$AbdiP zM%~ttC7^M<=Zpir`)^eRRehZV)YwlHWKRg$`lgW^7z!*VL*kxXe2^`IJlSG(_>{VzRQ7 z$HuVM@a=_N%&DWocku*J=i46M`@5F_S3Z$$3>latI@a%(8INdGpUEE!g-0;U??FAX z5v0)MO>dF1@@PCtWbO6cOTzpy$X{={01B(Z6$i4{3@J>RkFd${^k;wn;(pL@^jE_8 zZ|9M_qg8(P?2lEd)4fxCO`Y<{v7Rib>nihSKKMN#H za`c;)39OX>J&vOMHTKw;RBg+x_T0Hx>`qk(e6Wf_tYymn>5YrmC#ZpRe_gYqNFj9& z`xN;5F!;f(0-;q^oom(+5t# zi)Xsc5B1Oi<0}K+Z=Tzahr2X;FXiwZE+ccsBJPyo~;5jhKv4JSfI==tzg7 zDqDPU9DU@H84TxxO~y4$FvP>A7R4NqX99*$v|h1}o*PXP@WAw7J6j=#N&Hvqk_GU0 zf~yG6CNeBz^a@Y!Ma})bke+`2Kc~x|$Muz!a5V~vS{hKyl&qfN{rFEeODSSGlB7Mw z1h%bdu04+}XyrEeq;-sIi?64PaJdAI;0UpHFut!$ z-Mr*YP^XK^SyA4}EfHe9qT8@sqVqNbb+jV8=L9Nl7y|dqwmyZgBL&6YY&?F#UQ%f+ zTt;}68%ND)Tu>R{-n~3?*77K^#YngdSjj`s;}|@sVxS(j;R*mGiIj2sRQIk$Eo|Qz@{gT~4EYaB_WQ~* z+Xpa=(%Y7E81k>}>1t4r4N^&MvnTEORH0fo6lh2lT&UZ&Hw|KXlvBn-v~?{x48P`@ zB^Rn{q*gGS&LN+L&XoG?R_l0lHKd+1QsYR@t`}g;i8%NXqIvGn>ZHjP2)ryXS&l&8 z#_S0TA1EYwIr=3civG3}&L@aE_4A9d$>YD5U^8=^U2Q|I!6;z4}^rW4$|qQ3Y0 zpa;#C>ZzyephM~AL+ynmG@^+0id6X<487fRbb`8sDwo?I^S{-FWQ#Qa=rw-9!o8&?IYY&?LGN(3or;urQ333jcv8Y1B=U~a+|J2-?9hBE>;;}1um zr!o|tDi#J`|1;>X>h{-J|DU;qupMNPlIYfE>}3JmEpnWQ(ebj>rT}Yu-vP$D5_KAf znf{@#GhbDy(6iELcVFM5Ot65XKc3>}r{-tnND<9N%(e9Y}ke&*aM37$jONw=-BD)vQhx=z}IuIm9=AWq}|4>hh9z^PWwzr0tedYI<&9Hn)ZnvMl zNTlYsSX_YQdO!7YS={euH&KO4+Ig~!WCmRQIy#4lCH%ps7))@+uC|wLA0|~is|}Uf zeCe;Z+n&u#Ga>ZlFxe+fv(6}83rO`msOd3Q(1+Xtldh_#cvf@+^jL785F7xSFQrR_=f+z9 zMD4YKVja3DL?uBTj;&75N?LClA)(@;h)+J4CAk3Uu@~7Dnk_>3{$MtaS;XYx^d}&S<$XDD7P{7-w}b+QJ>%6_ zVg$}551VAFabt`IfQb4*hdS*qYv88mQceR;s2VVtMFl~k1hX9|SH?t*eP(yxiXWam zXtnF(A99l|yIWUq*)NCtOkS28#9tsav*hP@<5z_aF<;x9FwlK~uqNGlC1~ylV zTYH$Xl3jA7nE^E5v@6bGnCG49i+>QQZw^_e}|9{x}%cv;ZuzMWF zL`A>?r4bmKp}P!vkPsvX5E#0-? z8@-YWRZG$esqra_n{*rAQi5*<-$hN{+x^iy#pt8JJ2v*26BUpL?}fk&V>sj1%E9Vp z;_ylB3-BE%l+In}MBxROzs}!R1zV}*gTc~OMW>e*(aK|M*XcVUlAzV>1ppb+h!}U( zK!;8AI@?qkncl6Yrkky6t_Ci+SuOpQfM>FFdM<2XgmMgG7_u#^Z1XIIo~_NJLy_?5 z$LWtQnHW$6aiy08yA9W31v!D7djf{w0@@)&Wt2N$3ID6Ozb5~mw&XGBaxXq)F8lfe7}>IT(c#O&dbCLe*bhLNkvs%rqMaPMjTbog)lk#ZM4&+QuYAfeK65TYJU zgG!^R3FBhL^_vIorC$M_C~0oErN!5hk}|{ac6kd5sQq(Yf8IpAufg(k%*RCVTlt~l zKqYEaHPLBq|V6vKOzSsUWq>B_a1!99hnjtm%onTR^55kjOt`Gw<#R)khV zQ4QgZCH#(%Y+|jlPKw=aX!2MI=p(buF@*P{1sh1nV9_&92BM`ZugVugYhQK?DiuG@ zWGJb{cLT-a{J4?`Sbc70=QzX)WxI|{P%=hAlc&GJP?RSh1zz(pGTya|mF)IV@qT?uJa_p&5>Apw0SSM=tR016uFN-4?sB21+WPOBU%% z_QG_2&;ze%#c&JL*Z`EOLeCpF-25*$BY~yjj?Azrx5*jrzwmn6-TwnRDJ1dwjQ9YG z*v)kNOMrMwIDX~#Aujj;397R%rP~cU-}tG=b=(!L?7zUb$Ye>oByYv}@BlTJS4V$1^~ndynHS;pZ z&q5T+eV%{M*CCA22Dt~9>!=jMQ#_1$O_&PJ~j~T4%QK$qmF@n zkg8f06!{n{?Tn&D6W`-tA6rKTJ>74{HQ@(oO8ydwh`xqggbVOBfwcV+cMiY^tyUOA1zp{#m=pr!X61)y- zsw7%4{&{Y1omtZd35TqRcD$yclsVaX{LLFz(ej>f5+KBs+}rl}E2~PN#i{r++l{;t z$?k#(lzsEMBl9h|%=_k5Ee?F%w|4WZ_?H7AvH6jq1~erpBJ%60JS#imiXQ2TBT=bv zSKE8M;*~Xo`D(Z;-P5tJBC};0lu=$`BJ1x3W<=`hztFwoP?>qU=XL8&7@`kSRwF1~ zWRDnom>PwO=z>ScI^ZG1Q5eK44cgsdo|kkpoZAWPgryluHXl*$Ry$ITle8Tu#-sk> zasHdf@_0z-!X}b5^@=3N`AC$M8PrG4I9j6>MrLO7xRRNp);ieGyG<`dy0VBZyvyk% za^&v?C3>C>{IHTt!ISw)_@f48&K%;VFOQC0eu`BcOp%Ny#S)m+-;4?@beUe7-f z=Ps9^2(gU0Fc$`E<$dsGO;bpep~n<;d>8Bs-;cHh3pm57BYD%OqfherjEK`sj%duy z|J;y2U(a2^g|WN`#0B`o>3!#4O_fLwroFlFlJwBSNgs_euWMPJwW!rE&_g{ zK}!Nz zt=@MDQ{Nh;pEgur2-z}Fap7u2Mv(7$sQwGP80n4fH!z+3e~=E*M__UH^*Siih*(ty z=*fsI(?7LAP=mM?5h%7>^5X4j9wm|cQ4wMXI|)NKfPUx0ZWr8;K3e_g?d|)V*sE!u2ZOMLGy_^r~8A z0=uSlktctzXMgi(T_2M0S?$sN6>oiGnHy*UX;RcACEWGBD(o;Zk^ECnIRW9-yE7K#G2b^*9N=emun*nO=w);&+3WknC74Y#@ysA`YdR z5}NOzXt|=kARxN9!!sWocC<kjnwrZr~_U@HI2DRestlnJvGugu|eOf*Qnq=-3dA;T+L88UCIG z78AjfGWJ;H?mEgcmJWC)k%E;ePf=Qq7Ad2xH`Sf_8%R6dS_`8s3Fj_I$?*?lste9f z8jk*zPy$m+f~hUL;a*&7b;I@e%_aF_v}bo&kq#8}XRpq|UB8|5{C@j%q+8Sf`Ja&d z=ZlZye{&c66F)JcB#%NMv8t&(f-~nr)O!$+(H(dQ&@OIOd#q@cb)5e_=u-;*JZkd! zx?P1C9*gjrDF_AXf~Klv#c`b*&&P=7aBm6MaN_e@%k}CX+a?q6)HQ^`2}tJ%(^Trh zd_wYR4<=qoq~lSQrExygN3$kNCxh3tD4JU%S3Yr20O_9q>mdTgsMKxmQ=o%|H@z3x z=qz(|z4`^k?*vPU2GLD1<># zH%Ll|%7m8S-u)fX3O-`y%Nv*nZ2u-I8KcIit0kByZgPc(_=C=>{QtL>m@zj{ID zS)TtkXN8eh#ghT$+2TkS+O##f_4P&4XbeH9Lx%aHh-nvOg9nccfGWyRCi_V)pCxLq z>HZ*zoDW#_(BH2m2s_l(s_(UahzjJ^vF9yGaxPcKS?qoC(>by3-FuFchw9hcApJ`F zBkm?v-P+02W0!aoc)?&;Ql~%hkWS@u&ICmFqvs`5p?b}T_|+_)^HiVmM z&jVkK7rq=G`Khfz=U%)b|JRuiea!*Kri<@Sjguv`HcRw;c+(lz8M3vzjL4MFd7@_| zP$LH15IiO#KhWI9tCFKu&A3tUltUxe5APk|w~p{toncGI)Qc12LH!@Ek=2+;YLD1( zFC1{Oj|W(3X?i>Y8VSt(U3G6;Y4KPSa)vMo8X z-@fMDs9HuW0n!a2Z)Tt-zr|xGZPBZ1)UCpGbHvGSR@rD#!VM==P&5TO(g=r9mf)&W zh8Wc5;&+}aL6b4YWlD)=;VrdynjyqpkkDpGRSZn#*M)D8I87D;3$*Z?dN(A$zN(S` zfA5Lr|9elmfaTiroULGqvDOebT=ON{^yqpQ+`te{2PMV599c1&%|e4}&iK8yrEP@v z0-qEMHF9;A2F3-#IgcN8>hW%v&oJAE8i{7-a@Lws%|N!98xXFz3#X5pn0(|sT%V!r zhj8@k13<26W#1jwvM>~{>l1`Um_vpxJqxlEul$X$5e>+PDRiqkwwC5S}K)Yj0|ZVkl8*m-ABVryv8mu_{$9((K`<>rqA#Lz2#t7nOQ9!ZB zMt~Ei6f8&*>1yF62_e;=hD_uYm`ZJZX0R|34kTJZ*g3FiO6e4fmMUqD7zrpIW5IHj zfE){r2YOwrA~pOM+kp9NGeo~vdnBu&Dd5_L*yDVB=B9Im!^7FCb z&&n2fVPOx||3vs+&YU5c%WW-|(@K-&{6TH|4drK;OixN0r*H0U(DyetK^i!)1V&_) z*=GmFFoXhfLA0cOEo5^lZu)V{1beFB0jj@QOMgV-zZwOg#Q*0hs#&1yQySofacy3> zIsY=4nyoXAr!29ohqrImI(#YZr4pszaWX8?Jm8jWoBp`;O0Yxtb?FY8PSxnI0@9V< zi5!DsHrqU&=Ys3gm>tnBpWRw1(1YLZaMwQfhNO{?G|{hsN*0jVPolkTyW&r>>z7UEUT?2ZZG}_IIGAE$k6xk=-U;l z&X#X}hCWX-spSo($an@)cTZC>owH)u-I8Q^=omD_&JAZ{I8Hl|$cjo^QGgJtzCvhj zY(&e{Wq<(iOZ#a6&d<=bO4esBY|~@fJL!n^e2qCzgQa1|n&aH@ewiH?y4WF)CD^*xoXEjhOE zNG%>&`zMV|B;7-6V66!*P`BXS%NyB{SHu_sOW=J*wO>A9ci1Jm@m4)J^=^|^^TtcV z^@wmZOouw~MSdJ$SIS`uVZ)StB`=T<2|m1tGb?x8Z%$Lb>}kreCP->m!BcM3*t<^& z7xIh*MtP9C0YG*S%Vo zq57RJ4j#CQDXtbl!?VbHf6u}HpGx7~>?CCIsp8T_a8(Cz3Kh@C-&M!MsZs@va{{3} z*1Muu!iQxf-gZlM$%nA=;bta}Td4WAZp%}>Y5}{kyA1pGA{CJXlrjO=P`4(EgBEyw zi{`|Is}xQzgr!m3BfjtD`$8{C?YoidS0l9Vn5NOz>nH6L?<7o2{OZgkRg4vT+aO%Q zR`Cvv`I#b>bWbIV@6g$k{tONBrxCFoT%{%!)_`PLazR83XZVXQi#Ajiz^LFQ2T^$E z()yTP$rCQOR+VKk`M4DusV(I6t0|o{N8|Ar*1>1tCWzsRGMz2@$PH>VpvfHcWw$X5 z8KJ+Y?cC;>!f)+gje+$T06*C>zvQgAme2i(z3XN?Z#>J~NM{&j`>6W;oMYj$WY)DC z1@#TxG`36`lVMZw|Ds1R>`%h%I*w#umY%BFr+2IsAo{Xt{kI&(FCtNwZmT{aKO0!5 zC@&f{LKj8}p}IQRRr?|N++8iBvq=W8b@Tj5%N?e;l(Rq0U|Xk3s|k!V)tQsRA#G1? zTU}BKR=m4U?I$w$w#sLM30RJXl<{KT00oeVtkr%>d$nSXLE&{fQErjsTn~5aUd@>{XFwfQZmvSGbth?tL35?(_1N}E#9*NVb zWCg0~v>ML|#eS(RfmLp3Ulo9$mF*z%&w?u#=HNxT3srQq7j)_%$c zpt==bj|Z|x-i;~v@df0;e6AYmy2Mz})0&dMJdjqRS&(sJkHft;Y(Ar$XNuof&oS1eO=r8 z8p5j|$T1#O-xqEGA<-;O!;dE>$k~?vPfd|`0c69irOW_ov{_8pIN6Ud23$gdB(|g0+D}Y4?jKFD#}~8}gr)z@XKIJkL`0 zyyNmNw;0-vSK80&9DB*>79I9EcV%jR&Tu@cE0l{6aKo93UB8eM*QCVv47k=J*efL8 z@3^Wno~FT85VN+=F{y(u4Ovn>9C8R6 zyK*;_FbZEdcy__GmhfH77(E0bAEa!N7pM}5hl98!!tMf+#;bbeO)K0L;f(t< z1c6muSjw*kvH^xD)TK*ls5!I_rcep$HzAN%wHoe!c}2P%C=U5De;k95b3^t012|F5 zNHxk3MjQF3$vUeC1QGr8|INn0Ncpm5ssqE!Lx~!%wdome3!>*ieSf>swgE`0Z!alS zpV!=y7R6ndql2Oah#_hOH$G17f&1nS#Tr4N`n{6XKsuqWZ6=|iIvyrcg?j`R9xWDb z8Vt#!i;Toes}TW!x3dP-SAV(Dnkd3hTFdfe1en&`$u3HYCf{?zEu>QerpfB>-H_eq zpG7rbb~{~7{2{PmVl@$?r(Yo>Kaq6%%$bdQL%wxJLS#cA@KF7P`ek{k4N&*{j@~cmr@Im958mzsp`%gX5lU;c;aYR zym6ON+)=So6e#;Num_#2DY8+b$QxJ++hZ?@?>puq)!YKg9_qCja5N9ONbIK$g#^60 zaR-DvWG60ytQhpj8H|||)+a8wu9ckEu*!MNa4T%u&J&0I=!P-f&b7P_a*5uq14lSK z5-f{4DWhspJXxzsGBbL{^*-a-2@FdCl^y!d7;1 zcM)u#yVTu>qn%O7?R}2ZkTzp5t-~UgpsdvhDa8&Kxv%A45dm_lW|8voWUp`?$&-tH=7ggCtOfX#&2vc-MKj8d}Lf;x3GU{ zBJ%n0D3`y=_9b;E36(6_;#`;Uj`B;%cUzkxt^|f8+8MnN?FvlIGjXwqmEyN&8Vk#$ z@ocCok1`U-X4I*Xn1+fwi6pw^i||yzUTAsZN~a)gLQU6Pa0{W74*~4oGfOOJHOrq0 z^%>Nx@9*)(H;Daxpf4jYf|$F2ov-v`>=q^(>}jK~J1AasbTVi^_cfw1=*(RxcGVf@ zs~q%#&)OU-Ij#M?1bsOOCevl)%6*Yay%Cc7C%6SnK~g^$hglA;AagOjjhe|cRV}%4TgLe?k-NjiO~dldsjr`6Y#>rm zuwg+5c2PQXkne43b(SLxK{B&O+VAIlg8&oq>StIK9{>gYcAVEbb#CESnjn7D`L-T7 ze7eTwGEW#G*%{Y!q}%7F%>sQ2Si0c^xlHo^sp?<LXFI%-6GLfB}+mXbn-vc38?^s5X9IXN}70?73*YI~R;yL;}#~-dSi1Z{0cg?C1-1xQ#WA|@k7 zTO?5vjxJX!f+lxWC8>!Rqj3J9dMQEF$Oqs+WJZ#!qEDd@L5Ms;{w8I{W((hb!Y?C59mIlw7d;NGuD z9~B#cp<|x zGM%P+SgWU6&=+3|sq#|j&A zKklGZ_^w_n^q^aM4Kt&5I0kki`^bx$ws0_L^2Ec#3HQkb&e<}~7qXm~-o(07?Q14F zJKMjxKE1ETYH^odKG+$ujvc5?_r@UBCb7C)Si(yTf#$|JFPv-)TwoZWdfa58_Aji% z+GB9PEoAn7wT5r9l~?YKX9jTJI%~4-X~|+~|6eTO&j5txB7I8jb`SC2Oh5$w4=cgt}X3QIx9D^!gqBkV(%9SYXQiz$Y{}AzvCAuiC zLdHZyYXg&pfjKI7zB9pIuvG2sK4!rd(A~2^Tr~tvq2fpq(#`6E z93G`=g;+SI@hQc&WP3HNI4ACaqpX@BN`aDk>xN28fxD*6a1VyN88KmBdqWE!gc%CL zl4Q%gYY1Uyyw3o=ZNdMbhVU^BM#&(Rvq;$ofkoT)=$^nO#?JVBr4ju{*`_xlahVqX z!u!ev083tec>M`|1SL%gIT(WdL6)zxE+Tg|EJIZ-?^*HH5D}key42 z3Q2+DHV7!R$xxG;LC@cuYN#{7KZlzv^-?sT^}GBXY89WU5%bg_6g*H7rwAr&7OaxW zq6$_?7~Z@3WX}^f$Fzp{Sf~E|J*+kG7E1LFN)oDH3-pPOjIX}BN{T_aRA0qlwex67 z^pq_OOO!nA?F^tkPr-;q2#9N^*S>AWyg2hw?Y*D2^&0v>R6 zfsBpFkN6H-E6DPC`6321E^R4FQS%tW8(py>5$!J~2}WogH2XK<4Q)`5W#GqGk@`3d z+DJVHVewcuI2&_W{j*SsVWTaLxbAJ%6?-QIVOvxVvxEO@?2gg)sy)MH#qkfhE6|8jD<4Ja818KcrL+ z$=j#CQt*N9DnboT7W14zo^We@0p3TBmUCvQ3l-o%65@TiB1(;FXssh*0s?= z|Mi(4!D89h)>omdR`y4~9U@1Fi7cuAOvEo|Kz$5f(G)Ku%LK46%aD6spB9JozEmR& zrhg(Q-nnKoEB2SLN7c(Z(L%+0o2|d&$j(yU86~&wcFtE6_sV4@+>>&74iD^SW3R*I zy)?I($?g8c-?18lt~VHzOv^D1-AM>PLQa_YdSYbP5fL|RBharZb5&`I5}CTKYHl+! z#67(5R=L~IHI9+4{zIB{4V}gHJ{+mCx_bvFcae#hO7S78sS`qCfXcWzukGuRUbQ7= ziT%<7f&DmqzfGTi{}c!+Jp&0guHYILy;tm9?_yAw*kP$#^Fr;Kxmclb>G{|~6t{3g zGbL(ktFv{>;+D}{=-A>5EB7Y9ilw@cgJLZcJG(n0*x>tE0>kS4k;TW@O>gPW~}0i+Yh zlRt%ZxHWMnSJX!K38roX)V;`Stwaj9+-PdeJe2iUFwWV z%ei@a9aI!8kZqLi#Q`0~$wij5%NVl2FYy6c&6e!cy6Q-Imj}C?x zDwye$)ayRIY~bAE_%eU<4FNghpHxj~2HL|bTIcp1!hSK&;}%JZFE2Q?ktFie=NhD^ zEy>7`ZcK%Ra+ zk4m$niSy>WW>odjS%D4aQzgPw4ruZcSMGMHJ>1pwZqd5$fDXb1 z7t0<*CxK;zj@^S4HW~@^WkqkR7r3)tg( zpen`z6sqYa9~-f7F+JpPa>gCL_Lq_VAqAzn`Q<%9r|a5}?3;|SQV|$f)D8RJt@gZ! zK5$oH*i}a67!E7`_*lZ0xE%gqoRZYVt%7n!gGXy1Aqbf}?Jvxl3S6kL2(&#QmYY-0 zAFE5tjVkLuUe%M+T{Ua*mq*hW`UJIk3=aGZ==_P8kW^l7Cz*IN@opw+G}$%$RuxPR zy^IX}<$Qo4(8?0S{e|x_ub`uM%UgXZTZ2>tNkPLa#7RPbB)gcn&9bF93qAFb`#I`eNlN0bXHHG83E z)e*@J|M#1RL|-EScZ#U>fs&|E9|XVajlE`0DE&~w00Nz`e)EU^MwQBWnwHz2VTpe7 zTv&u5=+0|&ZsHuY;Ak=TOs``3vn`*gn4OkKsJDcd15cWuWY^z?ZLPX(Dtlc^U?{J- zurqeE?TM;ztW?)s<)1g-m$vKsm}Wa@QiS6f7lPzCC55jK@`os*=_$>_m%g-(Uu}eK z3J8hsDY!rVWolGn`G|%h- z{h&Tq;TvumD{2kb+^wYir?Ym!4*i->HSatIOxPQ9-YSk)+BZfB$cjxQ8(qA8U#Uo7 zEry$SA_itT1Lc^n6Ct>zeo4@6e2r?^e|MeL`{8edMe~+C6P9}zanD{caXx^5<4AkP z=7y7le_kT~(DbmF+!fc(bo;fQx3at^uJMxpqw72ai6pN@WuRkTE~(#lRi3~v%`AuM zT8iIStOybTxgj+;* z;*R^FW7#7KyN65A!(oSNz~bEr_d2U5}~N48Wx%C9McJ+-I)nqRyW z^}QOLBegs66=gPnSuEm-wS<-G)59-|%AG`Dy>EDiYLdSakZQx0lPNK{$kl{gFx@y0 z^sXJpG;j2VZu;*jdk6%xd~5xpI!JA;uY!zA-=wso(5AO~wq_Y>A+ywLm8ofU=N(xH z*gN?lhsZ}aPH9dit0ND@3XF9am2Td*HP`gIv#W6Pnqp6-@XRB_G`4`|`wwG=pGw`1 zawU^|Q>*vAuggUd{o}Kmh}gnlzhU=;vwp;r5sZkbp;Y&$7!xCB9GeiTmf(tu8~9T{ z!=vI?2--k`?qs6y`Q5|=#!HnSu63B?I?Q)p9+?lidy%Ecutr`)#lrmAE}iDOSQae9 z-_>Jt*pmB!kpR6b{URZsNxaKQ0Lw}Pca=}4EwyJD=I`sSk~ghB4DeJ%!6i>!`g4cJ zR@JWlC}X7=Tb(;*4{1=R{M88h{`VNd8BkeV{W@;s%A%2Fit_!rWwUJD{kR_&d>>Gw z{!J~GFM#~T@8dke3}bHD@l|Q0;ht!X{qK$QWMOXocAoSh9=h_BY|Wp zB@(bP73RJJDmIVzQ2nbatb3Z2by&pEE=`>~`*}UJDHC?2@+Er|vv}jPFSS{a3Y@u7 z_F)$1-rUw)h>B4r8;5(F*!h)n+a%}ABumqMODa1|(gZ|Zagc@sz9SBxFoFB34jqSM zvFm26a!yvKx$5z@oAm>Qs?T|~Y}22t#YYfAa6=hVCej^R4NB2Zi#L%=67s0kd%Oq4 z_O*oLq_?7U2k)-}(O(nf$5mv3D0$}2g6n#W@9D;WLp%wP#lG-|`K5Zwx?XV?AkzNB zwTpO&!w~uxFxSi&8B@w?Sw<2Qgjykrj}%!}ocAMP#kUG0iXZWad?}rIW(O375#B9A zy-%j7e!&M!QJ=rRts!W*;F=%KO;1OyBi-a<_tRLt=bIsILD(yOPw_}%1JFfB^y@T5 zCskc(9yJ3nt^CH@s#Zvu+L}}~sui+t4Ht0atj%CvKq}r8e>rAF48bTMwu3PQjcAwx zWm*lwEF8GWelXN$Z)z?BT$CGD583>h!7vBiA5ym&0n?eDR%!vOkzTluId@R1k<JJ0mpb?s-@&Gdk~ zc(fjHhIj}zFd7v(&KMqH7fd5z!;u2b|55!0!8Li_)K3GIas9#0QgivGEr^vb`2lRY zz?Zj%)c2{-l{QHbQ4-8}4)tM|Fb`8&))TEplq!~}nh0WVJc|;9t{}rHm4AM3w=Og@ z?J}2gyXUDW#^iZJu8t^}WdHmeiv)LLk^`B?Z%_9(n_{<3SuD$o?!OMnd>4@2?)m05 z+v!}m$w6QAsqMknnjf`+zUOZ$veZ6cI8=uve7X&`K0p_!~q5o}5 z89;E?xZ0arKtYGq*v&$OhVY`el|WCJtGlJ4X^ibC1r{ALY? zFPwbmk1brlSW2R6pA>(QGQo7J4nVe#cvG2AE&?ndZWEa;bS>0HtV`zc;bU_=1SY#3 zCKJk~i~KNOz!^_^3A*Q_Ma%2s`dd0JzU__`0C}#?TMraeSHZ{#7(_rV0?=ww;AsEq zgR-=}W9F*5Frlsf#ZEA|DSh(aZVl1Dj3@9;ZQ5t?LZt3hBFjHDJ>O*=6r9|fOJp;< zu~tbR82GgaXZ$#Pw?c^d&^3}?YSmWS@4@9hpxy(akw>GoWenWDQ^{Fd6NDZ~_`Uo2%R7zuxS0zb$Of_l8_RP8>c$Cia`8W5eR$l3>1CI@Y6Y%KC;y@jK1o zm2-+aZPqLcJw$y^T$I-Rlls0Ld~6cRrkk~%Ld3t4ZgU<;R4*(lE-EfQ*~t$K>X1{4 zYBi#zoRqH?GR%?xYG-|^$ulV-IodBZ?sQ@G_9c9lp4F;m%w>9cS7U2;gVIL(;G`=L zb6f{;m9N`b(H&{N2iF%?$X^`BGETq28MHIX5nH{?>$8oO@8NVl{_T`0b0TY*CUtP6 zpTKVB_O9=AUDBCQ;q>B2QTA|uvFBtLyB&CvU3J=o%RiOB=R_9QZCZPEV(va*j|={) zyuLXscfVKgYSHV8YE_wr!nltsQQYnoB+%s0s7uP!5Sz(|nfE)XoRhEwo@!X0ggz|! zc|(NuUfX9_rIOqfWMz58`zoF2SkOU=e(lllC0N~x*5}&X9un6*lE(+vh(8SaPxz)0 z`%s_i*~S?1!&zW!vR=3(J{5BUQgO_Ry_Un`tPXV`&gQ9?%t+2cactJ5fO2LRs&Xn| zUEXQA4WsgEk=g?C9MvvEo+$rEZa7zFQSQ+8-Fi@-PDTP-%D!|<)tEZ8uA0o={4 z+eyK~$#}S=RRSBO17!dHmP^mg{*+5%;P6!Vojorf^)4cMe)5Oi{n?C~cdS5thLY6s z34aQ?*ykL1NIMrx`hpjIt%(u#j%lmk8H$WSI`);sf-2M4EOJ||j=wr$tm32gTr>Yv z89*Q2En(E*uAsM25J~SoUdw;sb!lv(f=f5hr?clsDU?GeNsndVH{Sj119SV9vEfw1 zhLWV}1^SuECr#6gjfmUcLi6#6raUN>xMe(R$_>v&QHQbm+OzhpdS;&Z1D`uy3+A*v zF!cG8{xEpm*lgE4a3k{K0oK1K05 z^<+e_P{NotTWGU&RF-39!V{`7Ii2KZ_7#FX=|{2?eRAH~4P@G_J`)?Vn|3CF-6 zTe)YVk}_0_WsPGJrG&iMRlVmMqdvXZvW>!`LZ)A~2MwL5vraAbGZ-S?+|fs?JPZ>G zhTlJlFyuQGCsL78K=q$yHtN0!ku(Gp~^;3@9 z)ztgE)yu@pc5F7^{)X||BBN};;N5mOQB^wj$^lgEdiC*YP~nE-SZ)vP0IAZV+F@28 z8J<&vphZa`pj5e&~2<-gUmw}d$! zCz6%yiM-ky`*Ei6uQjq~`2(b8Vyc$D8AUs%AA=rs!l}M&)8O3KKN%im^kfX8%{MJv zI~>-t9@MEAeEw8p| zIKf@()dpe`S)k%zpCNp+ei$U}&h~gbaxGP4_;cg4am;<02IrYh`dr_2?%|_g7G$oV z%!1EOIX3ZrY0);D7O7PbHxN5E4+m>L_cOn6)N37l8(+|hNv5&>>4M`>uVU;*9cNKn zeg-yM^KF$m%CVzb>l^D^6-P$V#1WrfN6Sk0t?~!8L8?B&)N^v4T*!`FdS1S%CLQSpCzmDK0{AZ}=5PHU=kK;U5E;^HDBjJrOa;Z5ElB&mc; zDNBZ!tmEiI*OO>w$4$K!gp&DFs}|Ly9_`A{SZBVwdxGfnj>^}y9$t4m6xVv*j+x$3 zIgbJs!o&JOx#f|z>uIIrx}jl{$SL*QR(n?f5iCrSBWn|Mo(_$Ypk@rfH%)lWD7?`m29_#R+{A9$Sfe|4#CJwUMP@!f-)P zpG3Xg^(Q^@u3{|2S`kJhR6uBL2j}OtX2Ibjv;7-SPWOU&wg&tN!rcN1=_xGcU}>1mNHyJY$Nd<|hW?(|axsuqaE%DLmN_)GV$IX!p_ z$sxg{*Cu^~6oiNJR|fS^*yijVjL?}5P_)l>v(9!iGVmtXlfU5uaC9YfS_gr$r{zt? zB5d@rj$rW94Naa3>6jVZX?$J17g+ax91r=QeTr((V8a#d$9&_GHmD;6h{XO*wqj}! z0UG_m95sl`Hz!6RnPL$hTi=*7>o)pKzRipW^4c-`Cb_G;iDslq?Z!(c+S|q27YIl{ z(Th_N_Dpg&OPj9fVy4nfnwSo9bsIMOmd8W6>;nClyGX^>Jh`&YwcV$`cvv%Ywp}qa zv_I{)(IzY7wU!OSSUrv0wwS3f{h63@rthJ?S+0rqCVBgfcI!6D?X{jf#)n5}e5D`M zq&AJ>V3zzWfQ!0fPE)erJ{g|%yAEfSXkO$Z#0P$!vLOA20-`Zry;j9eJL2_?J8BK` zHm;wte(s1)dGtP>(}F5z`~JM=8$fvVxTcE7DYN=(s>5i)z+u9YfMKz}xC#&wXY$W# z>%h|mf3~mCa2(?~$kz_(vVw}&wC@OmU}|?t6ae{XqCdQcOOVoddvEKw@@lT?+ty1D z;$3iBOocYSS;E)zD|o_`LUcoibdr~#8-EcM!Q(M(4cr4P z9MuzO#~;@7kfUfvR*=En2elSrd5ATt%A2jdlf8xy-GXPsyJeSt?m2X%YjY_bxNpz;$? z_w^2g)mM5tY-C4wLz*GmDeuYVyGa<%CT}-v2D4>ORh^X@dQPr6<9Lr}bz@+smt1i? zGVc5yIO%1iOQJpDZXXdY2)k-{=c`0BlaEZZ+;swS>`U7iRfGhKg3n-7Ke23uWI=z4 z>?RGlR5`Jv%_?&9O2_>dJVZ4FwO05jY23MvVpYz`Z0Nyu@A2u`&UnMgukq!;lhA@D znyOwG)a1k2PRWf&4{>${1r6H=`DgnK4FPLS;8dNmJfNhiK@1Z0ccOSLAW)O~m?H{z zT9Hdr-9K-8`DglX9f%sAo#ydmTDLSqdYMs^GjARM&WC)866K>fDoxZ}eq64&Ou$%f zE?Pz-cMj=eyK^y&Fy%*1Cvo_O30cPAeYp>p>2tj4&^b}X9;JoR`R)Dv>S4P_YnDY@ z!20>9-K3JC;1%vOiCFhG{tOwH2P+-hsI~c(4NPyDiS#7f;jOOzIeG$UI$LB&L^Uq9gP; zKRF6jU^dpDs5-k64XbxvG7~u(EU*G<4Cpumn2Q% ze6dM>x|_hoP`DK+R-Qc{J9q9i1fDG` za@m&Dgwr^ilbS4?U_2cj0FX>*8K>JyUWpt5@1X+@tWKQR*TW9JdxU3f9BAE_V~B z&i7`~a2dIjxz8|Ov?BEN8=wo!|N2!j7qXKx84})rK3$^ysO-?riVSey8dh<^U6?~= zUkM`s{3gfg9ZYBH%QH6JFxWaXDOH%xD8DrDn+q4Z5l=fYhqN)iy=6s@ir9>TRn{PO zTpt_Q!O;uIV2Su>m@%;g)!ctDp)DA5IiH|U79hiHPRl!@qQ=TZ*4O@Y z$YfAA#%095@10=#cP9c)+NutminPeIs?bYhS2ICRYZjmE;n+!sT2qTccp3pj#Gr1H zeIiSI``}YVaohkuH(E>q{q`sFlNyNVy{w=0%4y1FKaZx$B;ag$xnq2+ zD)5oH0yFha+1Zbqg#jgNE#k!!%>fEchE6s69YY0jC~y>HWsiC!Wsg!b%U*@(%6c7( ze&J}&Ch=0hK3aIsQ^La__PQUAufup>mEX*eI1!dvw-jOH+8q~dfUMuC_?$i~dbTrT z$<-y&eR)Z>>|o|(dLI{344mpfCAz$}EsK5gI;gJ!Wg^WS{F@JZ>j!^MwqwdoPc6s# zO#g+0!iqHC`4xSKGHJRgOaPR&$8@6xzjtJv9M@aXcsgO#TT)z@A-NB|mZ5f~>F()| z==La?a=R+NWkd({t>njt^8-@vxa<;*R2UUK;uPf9N`i`44C^4FPYvlfH>B^i)q>9C zM9c;EvrM*uF^pi&3M$c}qm3k_3H*%vt~#%8{HJ=4Oy_B2g3$DpJYN>OybKKDh{?KM zz}WTW0GxT$yQ<@3YsSyw(nh8^bR@~RK+Y50=hBVpLS8sRBfS`0AcO1<>i=Wwy`!2A zx^7_=K?S9V(xfO5P)cah5iIl|2qHBBktS7YAXF7;(j*9>gQ6h4L+H{CRXU*yq4&_F z-%+3Ez3=_j_lGO6mMi2pnK`r1Is5FL1FAB>_W2R3lQyO!In(`2Nu^Jv)t!3*98gJW zd*t-fIHrR!ZMg7%Axk`fDDd?^kR^F1z8Fxy0nhVvDo<(6dqdZ4V&}{mxSR}ew*LUXvcCPVj{Wx|)eU1lQB#Mm2*6?*{r}^w{|NDgVh9%D$ejV}3k|M1jZ9e&I!g*RP{DxiY!FvSz}`ahDyoSI^9UTPaUF zp;teF_Ey-~NUviB*1CKbVW(1u%V`mL-WKbhe0$eANsn(b1@t(azDbiHxSOL+O`U0+ zaGcxomU4$0`uo7#%DKHu<1=-6*Ube=)7*;sV9`EzB*(l-X0yKq3P_4ju z#Btqb@&H*@v*+WuA0xe1i<-YDK8fr;idp3!eB`m&YrcfWt0#HP+Tatj+2B^i3rlpH zefZ#IN`d2Z(Z0k*%MnKbj-U$}#`hjwTc@wvo~=H_MkPPo>PfEMm}Z-OSScQQ5NjlM z?_$-*mHRXoj`~Hq6~})ylqH;OviZjGdoK0IRR|_El5p2~uZ`#YsBm6K|6XyP_eEcq za63Va^(yLJ%EG*qSLbB zb4$T(oeIWOR3!}jL$C0BY`yQ1*+A!KU)nentMVP(Fn0wzjXc8 zNO5&UM{>M~eSkGPDq)?yeRR$JWH*APGPugj!tUw(vg&Mgk$4~egZc4i(Q@Oq1q6ey zId%>5Fm70Q)isIF^U|}N>aTj}wpNpe^Nv$_Jasz77pR&c^MUkoAHeuANJ`75(R&OC zVIIEflp&quN#f9jfxfItv(=KeOC^gW&qjA0F8;R)L-waX6*S@C2yUvik>W|N=s(Sl zRECf`&AKdqt!PQE&byU_#&d_^%rj6%UL!H;SAlv`oe=Jr+J^#imV6xwAO@nS(np3e zUEij?U}agbO=r;MsUqB6x_q%8BBz2Nvbas8Y>TyyKbAYhPkR0SrF6RIdoo&oR91nM zyO;^4oqY4zI9PfkLyE^v$I{4>XB}2% zNM-)TwV9BsO2yRdKsMzAEPO$I+~bftcyQcz!?7O1v08b$L34H9YkAeJEXt@Qd~}_Q zecxZeTmSMsCCdtF_f^A5uKKowGF$G|TT54F>Muq;&VX}YP)U)P%1B0utzC(lSQrqQ z{1H<_JQ+xE8@orJ-hLQ?PF*szufn(prAVLJ>r5RKc1rxhBcTytbOz6|GR;xz@5@|V;-#X zLe$>;7zB->F&^un-_)1n;d`EVr=wL`fyRctlYS%aRhsrM#{!*U7xzsX3u${v)!!O@ z`o+m@>`!Q~E(K&gFuW$vnBSq+M@}wAkDD9X;RKVtPXX=lGt7s!iePp#>m@rnP8|xb zgEKz3)Lv$IpCPmY<*P;k&g|LFUiC1TSPg2MZ&CY(Br`yms!shfyLNah#mGF1U|f&t2^xhSL<>zU5&p z`pk;T+-Zc44v6u?yu0fA7n{C}RKV!iJGyH8T1V*0?7 zV&uzwRMraG5l;MMs&Nf|H)kl2d&M0(9lKcJ)4j^nju{uI_+B`(-(lLIYjz^k;3yS| z8A{)3I5(#{(#}@=asK)b-n67aEw^`!X|{)awmcdyxz*(wRo&|qXVfw))TcVjc3%73CvtSiMiY!# ztser5Sx(i}qQNc0xG~H#|7x>m3jS~m;M)Ndpc+uJIwNggv6|fCOiA+nqm_)Q;F%6O zt#7mmwWmGCLW)D<0xt|dL4J3XebhQD#YR8Zj_!I43^X7q{p@Tq2lzv(RRj10Qm@T1 zsWsF9QAE?{P`t!Y61-uxi}Ff^M|wPFjYes?@i0xqb`T`<4x1pp$rXgrHLUp3XY8+> zVYk|?@n{>~^}5p&e(5!?E`};I&Oi*6%wR*~7#YJ?)#+rkVrMZytGGKuIGNTRTt;%3R3lcpO;$s8M}}@G zTOjFVgokwl)!J3H|4DW)9Ly^;${t3!{qT5VqoQac93fXcGf5Ezb;XUJ<$A@6FUoy4 zi&cO5cDX<;e7V5E=W}`E(f6z?Me-DUr=jtL8Avue`RVs(##&wF2)1DtTfCKD`POKq zVF3R9;_NXmYTU$j7`bXGOT&FzDV+Z2(TVzn`W#rJSzThGd?ondZgd=GzlV`25x`Dd#onPW#(0=gOi#P)?3P*se<;kDdm)39OE)w?)M{wSJC0 z9yaET5PWoHMonr`JRJWiDwked_jOS{L$=K$k%znQ!cZ$a@p|3mHYfkd& z*&~8;ce$q(?_%k+J@1l5ia?T&x$EkPKaAeMQ6T$_{~{rTJ`yH-+$R3Br%|&SPcvMC zP0CsAdDQxuvbWMi_-HKnH2mdAXUM1uOHnrrBVhXJu&X^kkw$Wd2lQHpCV#V1*mJSc z@6UY0w8VZ3q3max_+=aMkWQ1OX>oLZLcjU6D7t#HW$)b5k3bU{nbR)grx-rY!IK{2 znr4Jke;l_1CR7$>v~15)hNJ7nT@=<6w($g(opqDc-9YsOTU@d(JiB%ugCyL(FL(Cx~dDk~eB|apxEOZs|P^ zY%0feNQ;)HS*FyjO)W-VvHH3$e<`n6qk}}!ZNuu9VYl|c+?7iycsfZp+3&LZy8eCk ztciD}x$oo2`zw`ZnDq+Jv^#d5M_JqeVq+MBP<_=N4UjNxhS)$7 z%6^>11DfUEh<`@(pZt(uA2yEUHNFw*k;^VAQ1@}>w$Al6baSd%UKE?p%Fl2Tx1d>^ zuJt4&5{XFAj-r+aUbn2-w%z}XPWc35?B#$7zt{@B;zN)KwJpIB^ z)JXo*_k~Gt69&VK3>CCoChIvq!zicg7j?VfH3#jbN0V8CHM@kZIwd#}J{A{3Es!J| z1}8RCtX`}joaqbyrVau(D3mJ(UC>p0Q0czcU9%Nsd$L!%H!tLbTlikPgYk{CTh1+? zXef$G);DD-@y&kawdZLk{8qG+uMq^=Ojn;r9xP^bixO9#d}jlu(d0GQ0p*IwEf90RWaj)s}viw91$#kBott@ zminsL2PL|aqsIxD8H#Tr356?e6MI&82*u*EDCTIfF@i7F$U&ca;`Q0z3HP^`R{d6$ z4?5_n*Uyy+_l`HY?`gJe#F_7oxV4I(&oh3(i53o^YjNM0m-1OR~3+@N@=kdIu=JaEc@WHm`jWT%A+e^ghKV zxQYSO;9?cw8AyNCBEYHI?>;39Q}kNn#=kvOh8L}%MJ9f)?FA*@^E??Js@-1&0T{Kv zJ@j*)*YYwDb-U0CNijm0LnWu7alqKQ4M^^ScJrG)c>eK>fZTA=PWm^I>PSVNd|Ah7 z@wk^t-Z{FO5<5!~69=$sH;%WRW9NJ3MS{35X~-P$lgOLGFb4>L)7qnD;y zHjLa*{78Q^)=Y)7Dj|`@M1QsUVh>~zyPEz;DVe%>WklYorFCz73|pRXi;lZ4_*_g# zMzbn(q>V%!UMFuI{?zyjX=xl}jtb%5`eJ(b?nZ2~0jafUy~2gT)T1l(z6mh8dwFhf zr=?Jsdh-qj%He`U=pc06%?K+ja_0)LeNQ#}R#?E# z2xNjHFKJ!Kir-+sU5Ge3IF2~!vujTgBzqLXl2rXlbD&b;Ucje2V%k@6!I#;l$_hdR zbv)#jE4MEl!aO%FRKV7@Hy4`y1Y~xYFHP6D&X4DGW6rPa?#UetGcmEFX1BOg5V9K* zmv+W|7jCcpl$Y8E_npFL7gw;7N4JFP&7PRO&?_eL+SsJ{&Q(-&rH(IhphG8M0(C`| z9;-DW^aeCpBug~uj(N&!M%7+-M%yy`EZ^BbEtz{v!B*$7qgqzscY&=t=3p#%vg1AC zc=p*{awp-J%^ySP1@dx@JXcHQ6ioVF(2KBPHJOr$R5(OayYnpUQ=j^y*K3X z{w(_s0Vcouc{~N`&xb7IyEub>>ypsaL)gRt56BuW?5&%LhP41HM!WhLq}2%sNHP59 z2L7J&-{@KrM)I2RPwAP#ED~duR85#iKQ@1NMgKWzjEecoAxURktJ^(b1mZ0YTaHvH z9qz%?d6KzC=4@Y|6VVLii)?D*6OI*2-?DSpH_O~BcDWX*UW?Xw2WoO5W(2h6bNd7T zj1=yot(YXY@@W%ZL&3!hNk)oQeTQ8)Ucn7(cFXAnjz(xtF9$;MStPtJo13<@?fah0 zl@AT-l*MUh*5-+j%!94Kx&7>X{`F@odd*Fb(CgX~$JM2dFE>@ViKN9|d zUEI|u)5{%^sIFp3x198Gvm=cV44aAF*9TT2Y#%n(bR>Sf41DP|dz-OYf4r!ecGuA4 z6>9Pq1wC|a()axGfF_v1T)Vv%_M~BT;z_s;TNYw>t;^ALuRQZt;e@Y{i+jfpF>go_ zjM5pyEZhg!WP38i+XFZyCyZC@T!~WOf2z~%FU5X6i@o%txn(o6qcUrwqfpPuLv=%* zWuZIwd{58xkWPT3We{sBg-z`1dnjT>^kA;PgM2A!8KNPboie-rk0 z)Add4<%Rq9o7-W9Ii1W>sv;@{Ro0Q|{77750iq2eJ!A|f_+CXmt|TzAQv3_9AhJ(*a56j`YV2%=UKnAM6M15@r{Wbid zK1nM>J)~Vq|Gk1v+t4qxjiWmqX3VjQU|-djWIVh}rF(Lc79DXC;XEG%ll?th2r9fi z-}CFm4zer*ELSot_ZdLV%`l1vTp}1h!t)UH9yWGy*?`y6G8|IHd{h_CY;B}OtM2OM5=G81gqO?&h$pX ziQp#J)Rj1E*#hw35bk2v;jWx4{kGw+n?ax+#m2p*DPtXYyx$31uW>Wk8EI>#z&K;Z zZZvzJcf;{7mK4QUq<_0GHj57IN;S*8qv~key!yd5sk37jT(wSg7bP0_SUGFOozr9eItz=n@62&vV7{MK{&m1#_4g> zo>P!}8ejP7(|0x9)IydX1z~k-_S%pZI1$u64x10-Utm<)X0PbrWIID{ zv~bJFDSX4K!C>#%7gyhttBg`Z zzzMBb1%-cC6mMTLRFbY$Bv}s_7rBJ7lrYTa@7iy%n z#%%`gsAlV!vj5h$KsHj=+nEkB0av?rgslA6ixe9QNp&Wc)W06Q zRe85CpW~z!8xA^Txp%?^4RMpwsxCHIX3x7w=8qPKd8M-*Hy zU;Yy7{Xk+ZKV45kwOR`bayj4qLomg5t8(s~Gye2-PA5lI@>F#j(-{9@E_6993+=Rh zVJ!h|9B7pOaFNvS!NP4Zou71@72ffF>-uNNt(5GU0Q^Xn0hB8ysB z&GJal4*bO_-}u^Zi1DY10DdD+#VEN6k~Vo*w2|GaHhD+v4rS}N(3zmec1m7ioVEOB z*>)TmK7|Aq+KbS*E6y;*w1{bgR&cctuk$R2zSC&6zLy?Nbd4Ix7{C0kyDiqneAM{( z_WqM4<)YBs&ASfgm=v714q|RN%i<{>+(gRR3$E z*q*^^wu}$jEIXAjuED3kAFOA+8!+8B(2yPsMhIf!4HyeyRN)}n9F z(Q<)N(a=PVgv(<#eK?hg9V;yl{~w5!OG#7vpZz$)3gW|_kUkj!sG_jF3Cid%kS09- zVC;rGqIX|<#BGX!r?{xh$<7Wn8L1OXhWBx$5jd*X{SaiNkAUPDwPT%T*+5Y#&-M;8 zF7#wodiys#BZ9F*V9aH)ck-9JMM*=nO}6tbFi)~VXvg&E8hbBxz(0UdF7l?DeKSF} zJx$gX@56`!yIk+3Pu(SHG;J5hAK;7AWDh!c2H(%GpKf!|Lr>UwUG2;vBypfjEr%u6 z}M(nZ8Oag}-%A?%%*=_@pdYt5f&}+zvv;Zby1{Stk#it`_?y4J@rx zxEV^YiIx$q65fp>0|5CnaAk+-9-4P3&;0aL76Rm@-7Kg4{Yq+v%J2KU_ZUfD6p@$~ zJ9ac>==pbP)2ZOO7dJIV$wjNoh$77X_zI4*ZPgABx5AWIvVfZ%ND-bURQjl7L3DK){j0;;7DBs_A%B#T8udsC>XAT2*DUCN*|{s|WeY`U?Zr$o z-*T12I5dNf&YON2wwL5PE(NfXmy!5B1*EsnwMT8|JARMo7-hrKf%u|ldfR$v*x~d4 z=JS8%YeDb_fds;Q#`f}g=VCsp!JxXNFR+$oy(32}j8|v{;RgXt8p-^=B45AUG^kvhl zC5qd3{p(ZX(5BU%n`Q{|=4Sen&P+^QWbQ_`fVUm=e)uiv-j>=cMz)iIb^+hx7XHz#;(RQ@jM@zhHm?#hF{gyD;9tFlFB#RW@{zrI z?DaJfK#K5V_Wgxhq4u7k;7-wn3zM?pz;0LtMI_@1PzJ2?UT>JJ-J3{|^I8T0FuF4& z<@?z|jt89WBpB<*%GO^|hSLoav)P}0-y6?b<8LN_sr0Aykcc1{-AKj~nhFL*@z%U} zsZZ`Qr2KMZBbS0%$<#XVWsgneJJ@dKzU%NAqMlaUO-|bFA@0-ntE`yXH}6`RsM||I z3sMA~aps9az~o-pN2-a2oXk$2a}-hJ+S%uZ>owE~JDdVuzK?qU*N7R|3$a!s-_L$; z`M!pZR7ZxGRR&~9<~a(*@K#mBWY?Tkqhp6>yvT zUPQMWa<6l#Eb zo+t7g8ka+xFQ~dHWkvV+s+_9!l;8v!Gfc2N9>r-&xN zj)~}Dz;p!WcOi&0=$Xo4$PEE_SXIr=>zS8otJM7ALb>Y~6ZJ_XE>-iPtzHTro{evL za(f$V&|)ezfwGV6)A14gKyP)h&&Ck(X#fJSZo;7AntdU-tAKr9OAz z;&20{0fSY)BJ02^POFr3_CiOcJX)j8)17Q=Waa|f9^fWytwlgO4HH77^s|AO!Bt# z?j;FDq1Npw)5Gj{(esUJb8|*iOwi{yy$(uB-fU!w->NBSgiwv5D=@X6*O5Vrx4716 zpHy{scYWgE{^5>uvOb2)e%79qntPL5-F;Lv8qa(k=~cuRQOXfDN_Nw_uE_==UG|%Ghn};GDI>R{GMu}|P+!r^~ z-M9hC>=r<$fma2>g`p7-^s$cY_JjsQM=APSBSPZ&`sN3#lv$V3HQ>To&)(wfW`ml< z)6UpmyB+$~6?y+hz?0)hWy&a7f$oDrEB4JZR4HoD>pMA?(0&tFR@dM^n?XMRAS~pT zB+G{Z>wM}5~?mhl@nvD>kj=PAyr;w%~ z#8f*yQu_Yvz`M9S1H{hdyP;drS4c}n?$pfU%r9?6@z7O#v>mi0n?CyD04Xbe^6T0% zRC31^7d(YFPl8WC=2@A{h3cXB+wh&8^7{WwA)Pt*pYmte4T&?u49Z;J7tp+~2jQKN zElF3b*`3YxVW{LC2i((ZxI4=p7G(WX=nGje$^&P+9!M0SaTqNOG8GqLC(PK_gP00K zP{lVKhWKUxngfJ<|%+Z7PVK24!dGLv!OV@0gwcp+8%Os!=&teIeo*-Mf+ZmuJ zYbC41MXN$qc+uvl6r!jHEmeRee3CWBzm%1up)j1I&m0p%IBn_zh4r%H%TN>xkQM|x z6g2W>{}W)C#6f_$~~ua$C> z+$d%qx~0(O7}l$L!B0my$9cjvnW}_&*Wj|Y@5knNGpn`vsTKU$_6MDN*Q(62Y)N7$ z<7csJMCKt!H|JeEF#giEZRI={`z5)hu>YQ!2}$BqrN5G~{rq?5N+69TQf8F%#_s(9izP12it|Ml&hR2# zX_ZwdzUSSN6OL}4lj?h7xe5;A*#ha754Ul^(E)if#Wyf6Dg2kPd!3&TGY}?`Jgyt` zxSVQ}SfqC?v;fuuxgq1m&yJ_V*dDgsM&s*syBZiW)ZB1|KRP;>{U~&*o~5pT{Jg zZs4OI(7s_03&FYiufA4Wy|02U*y{ z4o4jvXH9C6LgE5JuOKbw!a{_cNzcJhAvj+1R+yoep?SKiml{+bD=i4dh$6>qFDyQN zppRi@ggaR~;T{FTD6d7@dTKl0tIMVEh3_KB|L*BCsnq|)u67^^hNAI^DJc}ns@^;j zR$-2uDHMm7CA5voRL;#rdwxpd%^50y;jnU18&znlYI>Of$2dp<(EnH#F30e z(9$DHL++lhCNkuoBNxzyW{^T#MNNyvTmH*tp*f7ilZBY0>wDAIP zD@rnT`1?t67CfD}l5{=wx+2ggd$IGtMqQ9tuZ?he_sJtxAuOa;$7|rG8;+xpN~)WX zxqB3T4#_#+aP2|Yx#b}`UOFzTj}IC?@^>ma0~uhdK0a~KfV`iMO#K2YAAkrj&I{>=L}%@8qbymFK1dA{-*mBF;vBIOGgTspDWH2`b2HNE_r+yq~?e}cw7lyEN&RCd8- z67A`bIBq_miJ~l&4D$iSpg@11RJi(T7hc3W&HQiO8UA$xLC)}Z-GK}fkYIlJ1}4U2 zK9hJAsvk)^=ZF!-c)fIjN^T-?a}SvN*k8>rlJc|b3!UA6K)qdzb>{>kV@RAyyb|6? zs6)YN4Sl5x{C@Z4FafQ9S~P`jG`oiUP}PZWl1N?5QDM8AXBXQc4KMl@1%|3xkT~p% zx3Ct^toLTj&+U*n6-i7^`Gs3K)$y?OK6T8>Anxe;JD|kYhH%mY=4JknDQB~EWK+|X zuo?8(a1g{Lo~K8Fh|B3sDpdUoUZgo06GByn*`rcLYFmtJfq2+F8V3i{!2fN^aQzu3 z_=}8N&VFC{Jdd>iVn5}XpKw`cjL?dr6NVnu1k&m({~F-%{c*;C`H%mF!6Hh#a_h2p zc=Lgz0OROe^o5naxz#qC26mbsANf!EZhl()bP@OMyEWV?YcHYvMepbb81Or4YcmdrS{2w4YP<(f2jwX|&-jJdNQ%b&s-tv3pgj>=t15OBmNNM&7 zmayfq@U1e=LDgTbS0!c69QQe8^+^{VC#T8$CRA^sCVW6^EMVS)8IjJZY0%px1}9WB#p>C-X+o?$eX;0kbPRqTJ~_Gn#LDGG39}Kh>3>6ufeE{F$G7qiUSlW1_AoCrMG0hcZ0!tSf< zDTJ8>ehY=7nTANm3IZT@wog+{ewgxzA1|2a zq!bxfZd2X*dbjNO{N}Bw#qT&^>k=JbvOlUBGx}{B{Rjx%4MR?+ev!OdvuahuM|bn40U)hE?w`ATpN_UV zmkU!JCh%Ya`vewdXn#%A8GE zMIW1;j+?*h7Z!Yo(@%b-(~zYY{kdFU{O-L5G9kW{^vFRKe#`fC? z@weqrJ{7yYNr#%?#qxthOw2|3AXsyENqX5?-chnjFyv+Dj zW${DxKht6O?E43v=10H!4k=ervRkyTB|{MJJK-*cDT9$IcKl!8eAM~CN-U(7W%an&kzp(>L1%wFaRmf!V*5G zn@c?bf2NyWeeDZ^$SI!|@!Ix_uzCBLG7cSNr3FxZ=XW~J2g0xUUx)l)W#9U)tfMag zjUl^ZV89bzE%vW*2!I~)Kf3|EeE&U_x5R}g^5i&GX^tKr>)A}JI}gW&-yyFXh*lD? zz$%oPy%R!_(J>04vgB>usWhMC8IuSxRU3+!4C;Ymm@zK6gJGSC9Wiw9{N}z!EEB--F3qnt|jpK85^=#No|2JKqdPv4d5B0>Q>_$ko6!NoV zo@u)kQS;ElWE_G4qG41l=cN9FWV!yI2lQD2VVd<+FaOf2o;ZJV*@CE%FpG>95miYa z`i#*kI$jlUzJ@e*d{-a7n)gQ?cM+dzj|{kFy=}hM?Jq51*zp_jsanItQr=y%db^+; zM%PX?oU)ybl+6g$j_G<^zpUIfJT|U@C=xUhJ}70Ca(Y`yIEsHHAP}~!c;@8?{sm{N z5J4r-z+zHrlwfYTonZeSA^i48nFL2Os3gzuS>w^0Pv(_0UOWYPJn;8#HYB@l5gl=@ zhS=Asu&0zavKs)V+(bW0fcc1+MmWtppMj)Z3T%t!b80mU;eOL2MPF&wN-m9>O|aMg zw?S{5{|9=`f)_zqY)a)%$dHUtkyxjVRxgB2q>|9z4oNs?cn>ZtVuxOS_AC*Tx8;bty9~b%*zu?>L4(-HW=#siyBUTx7u$Ylgc3JhZ^%gL!djO>nqO%BzUe z899gpZldTzpS$_%9Wr|c1*oJvRFXW9um&V4d&wG=bu}F65@6!}K_JSa0-*)Q6|yCZmtRZ~y&)eWuibvQLxbTlhGXL&CKtfgMAATlXLCH&x7%tU zUo=Ux<%k1L+OYPb&S$_QVXRn>;WiMya(NgDd^SYNLV5#$LeJFqR)`W{xb{PAWM73^ z0{uJM*7*s$gmuBDaH47)&`Bq*l>9;zVXdNIP>j@nHAcUm|NneWZ@91tmlHfgn;U=< zcOgv$;ioUHC7i6+6H8T_99{B{m*(|5EP2<2_dS-1fB3leDL4U1^@E}-r0k;xZ#^yB z^-N&6Aa6wyd4=PA=A}1BrXb(66R!R<&d87eqy$aOZWJTWbx&v-$a>#>B2DtESeW<$ zHBdp9dR6mMFPUFrQu`q21`>}{=>GY*36fj{HFm|v(5!t_QyoVa4wWN_o7 zTdsFR2_Zloc^pt;V(6)y8==aXi*IfhBYStbuBsabkw zLhWJF=bKpaSJ4y}g!>5vftDjyl2?)UP}_>-u`zCxIJJGQ=QhIWI#0KL(n@S)4b}`b zd*NQaC&dAeHr!G6Wdgx?nZTE_x(MC(8x@eFex*zpImkZhQ?RpM4GGb-6^qa=Q+3Z@ zi4-w-G^wBX^YKg4A1uR~{m(?hC$|3PjI5Xb0wX~X!hS|hHEws>_NdRt3?Q6#njLUj z)<}@HTWy8he|p*G&8Myo(6S$fUy;Io`@RW}g+i|1+={w5BUTRj&84?eGzZBAMJZbD zzEc~b=q}gxvY}KRo8totXt1tK7}q#odE1J7$RZ{T7zB2Z?1ikMhipAmHwWLD>|Nac zQ8?q-ZFsBE_8TnW$n|mYl{s`2Ke4~!p=|fZxeDrroVthVqM_E;jEdD$giGi5^APM$ zKRW+}%qPO^529Dmx#fE~%Mb6UV!(or$jcFeG^bKI>H`-O@Sl^q_^WE5D2*)XPKqwOxN>u-{?~F^ z98eOP%Pe0uks8ucxgwPXo0BKrhsI6D=qno!Fhb)to0{kfpm8S#i-8Z|PDhvc-A8`) zN>>s@Tc5nRx~;stY%!wK#TRdU4=Oo%4r80&XMRtG6>HW9$v3Dl+hK~SEBu@w-3bwI zggX}pHem^VK(Z4rCkYHYn?0sw8X!?M6Xh<#|5!QShyT}y&SgvAtJ0K?G!$GxA2Q5H zK}%dF^zL(z^(Ph#E}}7^%3?bcH@Q-M-8XZt3vWA&jpc*6gzKM2XVH>LV^zv)z@13Z zY8kzSaK#Nhj}*AQ3XQiDYifn$k3%Kdh9Ff#_~FMu>c?*Wi-pvnms;QXgwb{MR3a1&GhT8lIt*=?%{2}vx z2XX7Z8^Lt>UEpGk?Y7?Jlb3yMg3nG%;Y9690+I0rV6*Ukzqemu>8;zP*TQZ*k+5ub zZjkwK+>9rA{s-@}=SGjSczt477Ei)%$555JaE9q#%lKVrBpk`vWBIwXkb|2e7xgR2 zxqeMnEmF<4kP-d$)VhkWI1G)O*Y-(P^b)7DJ!^QTJ3sjxRjQr4quPQKUznoS#uG67%+Z3JWn*V`|n*py*I=50zL%QXV$D4)g zGOhn`l^<3y8~|(V&mXOWwHc#@iKW9O`{ySM7k|N>J}QSdl>N}=)(scD*Do6VC1Y18 z-sCxkFHuNjXXnO8QrCEBne301zPh+)8f0?Ky+$lOYD~0bFcQG_B zg}=xcW19k5p;e&L$xpg$&y6-6q&1$ZzdK*>9TJrrwbP0vz$?wlJ7CT@6nu^ov!eg& zLpUQ8AGkbHIi`-0v71F_#K6kPlFHyk7QT|=Id?Z|*U)J$MWr0KFh~7va-O@(ufk3Q znf~k#`j66e2Q8bc+%2_R%xC}RxtFJxBr4yNOg-daGn8htqpG&rx>Rf0M_&)Al6Pt> z18u+~z>IC~sxqtHl4uNn9GOGgmj?X?3zz#^l95Cf0JHB@hDyE_ZBO-!iF&A5MX;5} zbXcf6;SleazWQRujPWfQQLy9+cu~#VSwxX-CLOdQ7}mv)S)o)F8;%Ht&Dq|aN3R;~ zfse`?evp6i%D9#|U}L)X_q%~}zE23W^f`h!3OF7gW%jfeoBZEc%CbKk@0-U`Xxkpf zx<&O9u{?Gll(&pE3)Jj_mJ`D>Et`=eHwTzqTyg#K>KxPPj9n>#5?FEq$&*8PQEt?H zH7xm^0u%Pag%PFElry9b1(zogCHR1(1B5VmuFgQ)~lyt z3O`JL6cel|700s_1I2qqhTrWo9b%)W!HL)IZ4a~jy}Ie6Z@jvP7*oF{etoO4v4^5q z<^MiuC92kQ9}mKliEadt6Dess^$^t%6TZKtuQ}@7CLqZjZJ*n%U^? zXS&H9(M8WfhW9HJeh21ma^}8~*)4tKpEHoouvb`G=r8^Hx3Edj$@-@Tb%qoH^ z!wsr7T)yEf`d-L_+#R=T5?<*x?}J5LjN!Y)MubhV@up+7;K$TN z@jEtQ{2pbaT4BpWe7i0q@Xk!O*}QcN52lgT51KLuWXWr2<9tMsURD^4?gkM{pkl(< zPJHh#KR;q$)QBvqdP99lt*S{74Q$2)z zw>q7gB&72;+V<+dtq!1(3+HyMv`M{D*@BVDM_4l08z?Gv$uzk;m>i$?V^89ge8b}i z9*meAxnjzZ$#8Z_JS4=jbpg0Yj`=rw#fSo0i5(8PralhJF~r&G16W zqACmD9^)g05FC1mil40&h~FvbTNVZca=4_8<7gYCJ1#m9POLBSgUuO-3YsIeG&YXg z++UoUT&2VDc0p_~Gk;=N^JZ-&syegrFWiXW(ycW(VbL5j2sJ`K23B9-UH}2@b@)40 z=M4T~?~t`pe~@erK(=NMTH*T>?l4p1XonU;3iHIdQ)o2g4!H*um!C2O z3^8+{XP8{Tv3^&)$BxE@AHtnRL@{o-tt%B~`C%}L8uu>c6-lK#_s|vU8S`LxfS7R} z17CHr!6e?TAd<V}Dk&*=TUI~TU z6_!5MKd~)UbgS`^P{+;!1kd?5S^A4amC-Jjpre1Dc`*v&=7#ICdNIy5jBE*GMJ}7^ zaEfox>3=x*cslJc<*mb7Ot#fijpVA?=^aa(sTyM#+8XexZJTR=v{&dpo>e5xkg_}Qdzp0p3lNCU5A9QS zv6v~Ljmo=ng~gi{CbQT)&DT=Wsl)EZvvWRgE#4M$l-^^M4zm|A7G6d(Hg_QzKvop@ zRKWU;ft`nYHEefQj@s?Pfhg0zR*+wT-yU#K2FvtsRjM6>^~(}n6R#+hgRXl$axnRk5w#t!v)}B(R$RPk#lCQ_jp=N z^i*L=Z(XF-pz?UR?D-zDMqC(~C`+ORm5hR-vL1#dO_Uq`l>NYwPr9O+g3Up41rJku zj%+4ImSkHVWabQ@1X@l!1h(gNg!khj*MB)Fu7y{9I0m|Pv|%tKkfd4=F84ml2|0X= zx{)vEB9t@cAX*uaKfkeKY7i5C<;(cn-AfS=aCow~bmMq*(6-w_84jk^W|I;cP{0>q6etdNMn@G<~~@ zAfb};srsR|#m;NP$*1KHpYTbHg(MubuA}KIVabNj3fXTJ8=b{u$9yKYE2kFy!<{-T zWo4gzsU*0*S6&0->T@%(ei?)OWj_Y-6J9Cb9YqTt=gw#32GHWd)XU(+EJs{O3&gWO zpXv7}|H~FbaUTA?dG?=vtJ^(FZ-EWdv>H%hu*$`KlQy(B*Tc=VhQtBYI zmP`VHd&MLneY5oA{;>gSRQHkp=qT%-AoDyBUw``m{u<{C2Y^<|1Nf@4*Zj|9i$`k_M9ly<*{#@47lHmI%>hk!HZiL_NT5 zf;ZAkWcOoCVlF1jzAQvO_-rb6yC2lXU1ZgyoXey;r9z!-?j^?HuVYr6aNgj-;~a4? zF{r+gF_!nR0QiihXX@&(jQ`1+E3uzz`KSs`Uj`TqK?5RUdlUwVwev$<$U1Cbt?$}WSTkUfd52hLjiOx>%8P%{Y zqr2l}q;OEY<5ffFy7w;xw?I$vtr8=>GH2n?$Lynk*=Whm#Xywa1O;$a2xPK|cOVA+(q zC`DYX7e{BPKCSJZVp1Al2C z7e0w4nM&zehU$|`c0!iVbt_3H#Fk(^myfoZf-FBG7R--T+7HU2X}usN)>V^acJt`w zG>6vq^PA((&!lVZQ+v4KmKN*MqZa*$ZIHWA{mafQV5u&41ixT$HX=_v(>)k2W0MsZ zb`@C=YQW5+|L%@hol+K$R&K#8I-jzeERWtUpHIqiWp8Iw|AshobDV6_`PE;RJM)|P z;2%#8W^5s;ZJl2O_cfPSMeGtUr7gZFIBVli%)Mz>@FG%^(8nLD*xB9vwC17C6S(T6!afuqV}0e%=QRsQR+{i8HKVK&+<7G5eDK*N07;rO5jGJh zN-9I-L~0tn=!N6F8;2!4W5ED0S4+5!QG?!Phf0Q2G3}0tW3$3taB8J&K^-GDjAqXN zf7@%%0c@|4xbe=|Gu|OPsYQzWo?)=A3o)QPU9pd&WUc)D&JVb3$7Qdw8WyLitFMY| za>`0^H-?RL4I^=O0`(qnCko6;IE+%Y9y(9EY^wwMQ5)MG>MMYN)&43PU)r7vtjkzu zv1WPQ5&}VIlbBN`eS+T*sB)VYHY#CVuD}LH$`TH)V}6qO7Y#ocM;U|Ln;{xuup6S7 z#?!AKyoe?F%}1nCdgWI;D~hD>n@h393IuCJnNz)3499K#N;6;)_^&)6_MLJ<_iu$_iV@L#*5OlR#Ly~E`q;vaR?pCk z?9-}ity#RJ5p|2e@;LNVCenM(vwra!W+ij!`BNh}W(CmjHz$`1^(sypNqP4iBnNYX zV|#m-(b;J`6WOoqJ0Q+i3zM1mzLC?*YP}x{QZFMaVSlA6Bj?0no-LQX5Cg%Y#!i9p zi+S%yZD#E4nDmNeyBJl}K9^E=NU{^iRTcyIT0U)S|6@A5Tx<2La3Y|oZc>y zI&#F>A&aomYxmtPJyzfj5o__lYNXh2(Ne%RV(l`ek6sJ1)P%C%*OSn@YMGy->uW!=r|0R7sCw z7e2x5?I_a8zax^&?s@!d_$_xoa|XA}dHwa(NMB;Z5WpN_;|4sWxm+XGj)!?QU{FMl z9=eO%@=7dn*!$bw4jVAmqTrTsgK7t!C^yC(zF1Rtn3^ zra*wn8=)f3+4yIL#a|{S^}|p=YF`ml4Q{k2OuA6y(8>2v3YzhBWa&n_g~#}PY~E3Z6MjT0cwAP$mZeeH|ME zHpR9(s0U;=Lyjt$wfFW=l1dH1yL70HsMVS;pN)8ccgAgvj;jQvs*lF2V??DyFA$*~H3`r9$AjN6`k}0>H%j^H~ayyEFL5$byEhPJ}HVEhYyMKCeLmbEs z_%oVWyHQbmdl#G-T9Os`F8@RMQ3KE~omCZ7Zpv;>ot_Zu&9I>~T=T>!oD3I<*))BH zSj{21kEeTnnJ^o05qIEM{^=6znA+M1%6x_q%(Lpm6!njdX8pbOi|{C+4$*z0zHqr>Y4; z)~|v-%6pjj)d4BvNtPNs89-1?j6EO^r`1~zv3j>#O~7=5iqDDRZ(`>d^KHe4K;m;q zLlksV|Cs$b$XgIaWSSqNMopMUtObjKy&7&k0b{hGh#8f_RuF%;oHs%Q*aey9B#-_d z-zyml+fWA?9)c0zQ_*C7zM7!(?B(O+LzZXRH>lI$SI5}PXW?wyfuS<4@5MRH6tppJ-(~Dm^Fj_dz+gkXHh8f5y|#+D zM%@rET+xfji~A;x zrd!$2uq&GjZug(qcJ1NgU8YU-ifJyRd<||Oun{I%UhJ;wZ26sbPrzR4@}*p^T?;ra zB)P9=*I2N`_UJ1*bB0JprirHk7^XYvp0t-ShsFn|WVaFo^rj)$oSV{7~H|f{*h= z{C8y=MB0k^!I<>zu8$8esju{W>hr-~N>S+W&gFIHN#TM=3CS;&5cfZ%{rVV~ra5Vj zaKxxKUfOu&?W8VFAZmsaBCFvec&zRj2(b?U>c@w)dKcQ4t~HDdU29ke=L~=vm7#py zb%Qog(jc%(uYjBkzEC!cuse;5|* zx9Iho(f^r3Br@_v&Va>=$jG6WpS>49G4GFaag4+4z-S3$eXtjRq|fF2C{v;18O>0- z-YT?(D~?qD1*Y|wi8|4#(dOr7mDp-KhpOFqo7h{${wul$E~%x~O2F8P*e8WMbaQ6c zp)1eNbd{KF)?2lJisK*Q6Jc2R6y$CU#@`w;-&wSb9K`Mxqj`ULNa2h5^}*yP7BCXD8h)bkEO_F1 zpy*0MLAtTXlymZS4265?u@Y^WLj zt-{_0Dy)K(Nh2^rjYwl3357!RwAuAJC^|tJLIfmidi;)@hsazUnTIu9vHfK*?LkvI znjoD1CAiS~t2sJh%yq`rP~U1_AQD*Z-tGBjPKNoYJ5W5>Uq`Nd7#q8mTF3knN#te$7sz3)Z9QGmn>jO}t3HXyQ6cTsnx0j45r%zZKtTg4MwdkjGM&k#xb3 zBCx}*g8@3gDQy$`tjun!s|R(|#N7kp;o8w-^-4h@7DCGwOsrtF{4`1FqYITIcF~$? z`Avu)S84zGbxQlUuhTy;gIcFt|6a(Ko&L}kXDm|G>q$<|rgHVRa@=180l0dpfzFS$9lhQH3DVb6wmM1Jd9g`Wz3&-viPmc_d3P3?vuyjwtaZwv-OJoa7; zfJWjTJgdHf)?#XOdj82E6*YYRvSzxHmE}+TE=}7V)0`EKoAW^I`E74=i>Pin%Q&Vv zJP@7mQ23PTcrf)TpjjDsL8B~2Gd6CPJVseV5O705H69wL$ygueYk{wXLo>*&qzmIe ztYV~t&!6|ByDZVQS_)f zotK5c7IY<)2l8XVzz`nUNedxkFE4^oPW8pM+l5_%z>b@|yoUcjUc%4KIFxTChK!t? zd+%5t51}ndWHZ69AunDQ!@mM{*rJ1h*9N>wl?+-#J*;*HSs2P=Zd@;4bubslM0)#{kJz_ zH`=olhbD(Bm<gq@${|JetGs90}Spraf4g+;#OXD zXBV=R5`Y4evN5^ri?wm&aV&+0-47mmKpdW5f=K08@JhxC>w0<@lHoXP5zX!sj_vNU z`fZTUfijF=0|XL}($#rLL{=uUjxq#S@` zyA_sqoT5=eJ-mwd=32$?H8(M!RFiOhEqx=7ehmc7G-%`#wjY$=X-@tu>&GF4i|5J$ zWCBo9fd{tgsu&<$Z-D2;S}a0(4giREBLq?jLuCDw+&}Ywo|e7ZI7g+A^Q@b+?`O_B z1UAXWxPbl8D|GLIXloexX8}LQonJ@9VEX1O*Z5?!#f-9sOzZ zOFrJgtF-${DfCGpm-T`(c7y_fRZNo6vqp-fGTv_QlCQ4G6qLNZy<+rPjHUlbGshHX zgLnVz=*PEj4{9*uMbA4!e-(IqqP%sK_VeTo>joabqlSBYmB$>=zPWobTIN3%PrPiA z)5Dnnaim&{hFLuC+?QER-^*f1IRAV={@SNbcE?+Mw;I!m;yAer$EcAa!mDhaI=7Uk z^__#$4tthd#4P%0?P~@AYC};_1Zs$kW|;R@-;ZmkvW8s+N@B`USqI4aEULh*bV}er z-lLeAPALd5N;|9>q}Ub_4Ek(Q4)=d%cqZqi01V-JozPEqb;`7Gq(c#fd)NEt zJ>S|5e_7mkX96unPsLT@V~p6+m%-$32rPwUHQf3!P>Peclc}xip06a?g2;CSYi$fr z1<_^PxGAGI@iTGWqe2^^`tJUyl*AtUFF_F6J9(5gV7#blX=Pa@BrS?YITo!6As zT-ErC(kP5i5qD#`$;fF>Jerw0+O)t^PWJ9 zn{h4?K$YnMDiX7YxP|zh=KxzfXsM?f4s_iK!owh(t*dNO3H*m zNfWb3d^JJi;v7;G1udSat40A!PoT2{A*p{4w`X3bnp13Y{Q!QH12D^=>0 zm+x@cu@uHVth2Ug1~G_{^NtDvYVs{>SKYDttq1R2unkwI(L)q+)4fL4-MQw&H*%`s zp4wVbA`up0P||}OE~~e^sR1MTvAgL|E}f`>1k{jP?SX)KfZ|8aH14u^{^466c@qTH z%9{hwhF|R7+4ltaK5}V*JNa81VDiti)%-3@wHJ4V!`|l6fxVa)ko6W&yWW)#%@$DW zNF`vMo>C#)k!OktLaz8R{d2k3@~`FIWb;|)zTdf-9uSUrh<}C$)>0&#kxA14PE9c& z;mgzT_%gGw6@1&5XWKeb-9&vTXYZPe4f`sN);j!Bl668vX(ds^D}~uge_5HT=q>HH z6Xrmn$UM;0Nfg8_x6AQk|LyhKHyEWpX=NrWnKixCVfLX10~eD~=ku&Mh4KpPw0>;c zgA%dSosFz8($K225DYQ?auAM5bPUumBG1zVK4NWzeabqVBqW7ly*J}RcZQln--`RV zqKnhU9>&;bn-;=K6V&J8HKJMqbQfKqrL4Rk$&yHsR#5t#I4@UzG2BD>)W8TP*Z?js7p#j)$06DIg= zsW;wqP0rZxkB|g?t^tg#tTs_upA(@T4>{5BM@>M%74ZxY44-f)7*>7XEpEB9>E%Li znFCAfcxYR66(ffzYV_2t^{5TlnBg}CzOrYo{eApquZRcp8Ye3(e`r9}H?$f2AF}LT z2#^Jn-_{V0JM~xJdvku44cO^wi3c_Gy*ugA}_Q9$;?m; z!T$ahhJc`6L286R8irMU4?}9)UIb0uL?Atp)uH;S57+-{3>-I1P z9?h}GF!$FxqW$z&h(D<|;1xNvJ$XRJ>+y*FcMuOC3qjfIngQd#itt1TJS$edhBh2( zNB|zvK}MDvjACBnP*T8H0VItIpv(ZzgFRi~KhV<3e}Tv?w_V&AD897<=vNN)X0&YZ zjKLufuuj@rUE4RQJ^h8%2L650_tAOecnJJ%Mi-YXyIcHsFF8qm@nDZ{G{%~BOi%1UOR;*vEKc_m+p{=g2|g#S+yt3rL1%^pb5A_mOlB-}DsN zk}aUyV+h2X_Ui1z&(SjuL;d#DPI2q^EG$DXGxLy2A>b+`^Ai>C=!T$-KW@@~XI8$^pZ>PA9)c*VU zB8_?a_=9q%OD#h+Mkv>=4-Is+M2b0>TX*H4qeMetcc9@EBa1w`&h2EQ$a%h|SnFJC z{)R&#YE!n-XbblooAO?oA(z`>4yNbb?ViuI1jUOz;MKIEk0IN zQgZ{w)O_gePntJeEt)VD-zq|n-ZH34Ufd{8=D}0+Vc7zqIA=Be!3vmEo_SpNk$VJ! ze7s7Jx~X zbcHrwZesd!H*iwrSetuvS%b#U*1#oU^cTx@IT}uPTT6?%_+&D&JB8b>xROMny=hOa zy{nwE<>vBK5ApPg>8&TGJc+JZy4#@5rC0!}v_&1&bb>~fytMAu$UD}c7m-T=&IZqY zMFScUSSrU6?gw%ROY0#n6{CT;?Zg6hkb)gz8^kfqmf0OgY%uIE4gxe|`0Lp%6}2bd zCx4y&4F$Yb{P6d5`tkeY_tHl&3q)y4RJZ&~PliqU zxV3o7XFU@5B{pbyBrWe&pE$hyDN#1OXj9Zn+=L1H4*%U8jLzpikJltyud3Ph@j5v%f%(n%bjeWEAsD61{eX-waD2iMH zHij6_7%&=o?iETR3n2B@5bB$^_dCIAFuvPZe!K2f|BOLwPq;oIi`z3G`tv(T+6vg~ z4=w4;>&SnV6rf31{{D+&j`6U5(2;STtU-7vzH&<{EV8 zt0l$BM6Dt%%wqtKl*sy%XW02?J#T_)2nMp#A~Sa)^*yQycvgT%amfRFl5afHFgOX) znEjIl@s;{-Xz8~c4n}|a#{Vz<`;j}=J&h*v9F3Zi2X&{ZNMWvxtCoU%#VUcJclXsy z3cvO@4iMehoDeN=h)UIno}ZVljDLMj#jUCl&?8-@0u%A2e7?yZo1Av1A3YlTeHanr z^QGsVC+`Ic|Hz4%x@hGv?KNq_?Xbc3fP8hyR7y-dxA!qg1WYafoV%=nJ$5<c^ zTSanvp&90Sh1=ZO`av4NI}%dT3Q$BPW{~o4+*4KCsvZ0N`X|E~zt`L?^KR12>C8p# z@)lWt=jzmu_dz{oaIAUB-{!sv_Uedmf!)5x52TUBgVFSsI5-yQ1b#oh|AvGAxr9PH zgZ{v7b{$vuGj(7-$dCx|03x<+3sl)5bN8x`O8F# z=_IPn?_G(Iyy`){ZTEf`kQ3@6Zf>X!cs0g?zE+hw64r#O&0#2&s-{@D#{D3xBgh)&`0YJ4ju!>e2P&009#W#@-dKw@cviE%ke z!LKPFU-*$Txo~TTNk^gA@OIW(;qxKE?Pqa6vjUq*uBZ1hpq$hz?m}o$u5A`uuD%)J zymqkbvo5hboX*m^@YM&4kl{;09D>Q1YhhNWoSX#bxc3j_T0NH z=nHv(QZ@;FezWt_vqjN6f3-0FZ06qyXYl#y(erS84*fQ*HgMkaCz|K?r*-M??*5Os z;TF3uA{LQx04Oi@~#7h`>?^^JSoZmJ2R;Ba(LXrMqbJF#_ z7GAZ2tDZUQ#SA+Ns!hM5xc5<+rji+omQ3M9hq<#el?G|o00)PF+Ru?@LWN5=z<5@q z;Huj=C>^Ssv%o%Ski3E{2Eq&A|9-#e2qjcfU5@SzD30tb7fShRLK_Q!^*xS`GILx(TK{8D zI(qN#-ScnaCD>kl)}Ap3B6ZbJGP)DKD_fTku1aiMro}Hr7>B{rZ?Cn2xZ-cGAlC~S zEJOI)*S-5Z5F-c0W1h&OT6jIgp4j!xp+w@2HzPL`;inMkip}Ai1U5(41>Qp{=aGso z*fG!jm8)P;B62A@7_v8{PzqU`{8|`km~?aLC5YZWjou z?w_`D;0yop%SQcB99o2bF#hhcA1`O9?CF1O<8SYeqNHT^kj*Rp*kvHzK$R6M86~Qs z>(MqFtrk@`{eFG@*!{ezTIn&r!Md{7#8s1_0T`J4s(OIBs+FV)aAWHk7CgRs*N(m8 zuVAgvXa2k=0Vaop5Lk`*8Qw>CIH`W~<(#b?573zDS3}wF>#n6FEFufTjh+h8w9t=e zi?s>5U}^j#?6^flKE^f&kE*x<^rKJwlUGm@r0J{RfjbCng}{(|smbL%`jJmBiu4g( ztCv6#LjemKT%_D+ztksf?sxB_RISbpw^f^JPUEXYh~2{Z$gwR zp)ZQYrelMQ7*aciu`U1zXP3-!*8l&f3jD6;Ou5Wj#h|G6wFb-gW)4nON)qhv;8p(6 zg_T+}yY~IB2}(ads!LZnJ~y^5P(Z)1?`!?S^9WMz)*t6yVmM%!b7*%$&D#I5Ov+$~ zcFc8j(}TzMdO!wp^ulPHJA}NbHQa6q=GT*Wpsx)KEoBnV`Z&$iUS~0j z<)In!igwiDa)@=@ocUmFb=5Y2n__}}Qhnla&pv&k)WB(0uTipI;x?6IdiY;G@^^|w zJKrjqbeH)G>07yFm7A_th2RDZp6j)hMn=-U_ zIs7rgf#_J#X;U)h?OT?Wj^C_IFY>EA82kaseX-E?g8=CPEeYfxutxgm;-n2Whx#zH zG|POvTn#~a1-WMUE&fGeA~)jQUdMMp#!zp=ZdJQ{TsOTTcm2YZzUJPo%YUjUxC~Y5hQYt1+eUjvpG}5%e73q1EvOBa&T^0HMMu*XIey!#=ijP4JD~3n z-8qv#`y^#wZ~%Q|ee^7^c>Ult5U^V|vLT{bSiahQS-+PP_CdsgYqBe#pug~3czWAr zQIyhLIOIsVwjJ`R#E4{Slo;Cv(vJg+@G11c**FJ>^`5Ph=yA;{Md~;Tj!`$jt!Lv#!p=A0Sy=!+r;^S zF?TWbT^VwS1!UQ=uiguVZ^92MZIZJ0x`ywI%Udk0pTA$q!(DJ*t-;rJ{wS`*fo6-Fj=1x2qT~|Dh8Srpjlcday=kh~0|QsQ9Brtu-=YjA z7sb5X2-nT;=cPz8Q~bGCUnYnGFyq?RuUub8W2FI zU*;`<^6luG?_i~Q|H2qXQszJrllF$3v2{v&qk4yxzU?r?R6mHp2;ho)0Su|N_@5UU zfF;trkSjp!^1x=Po3voyTHFd$`r?8P^T@)UZ*D=aP|$e~tkLZM@x-LMp$cg2v#zM) z-!FC#Y@0z#?bb3PU6?E{j(NSiI)}t-^;K@yz=x-+VF(!(pPjz9pJ$QDCh((G_vSI` zk@vuQ2>hM-@rgw93;S|#MAqA6n^4O}-DhpQWrViuSg{hC=$%rBn#Pg`m}%Z34c40$ zSMZe_a!W*yM>|Eahr4X%5q7}mU8if((gO04BdK&k{nCx5Af%9LxD}9&15v*#Wn^SB ztu%7Nn{R#gIAt{rW&zCsa^t>OgKz z#=h|4^9o@MG=3i1{KvJ(%rO^X^}Uvui%)3Kp}ODLTNBPJ<&lTr7Dc$i+@ql~x(OP)mY)^9bS4O~MZnJXxfQF&WJ zZ*KuQie^d~Y^Ybfi0q8Yv>O=%;q>rRwkNqkfpeYYLmpb#fh7rE)+1!f84-_Ltcc=1 zsRgKCI^tK{9rgfWn3KZX!Q7_nqJBGZzMOsic36hJ!WR|9Q;JLfB!D~rN1>_Fmdu%| zhU{I)N(E#^ohk1~$w9Mi=1i|Gj(uKtFXyv0%3p2EZr@X=UGZ79XxeZ&d#G%C`a*wH zvhmoiKPtjF9Xiq22`aPSGZ+|%?on4Uhm~?8CB-mU=Uttd0j&OHb&KnB^=%3(Tkrg! z&h)7jtz|>wUJws3Iq8DY*ZwMKwOkcLKRu@1MCNqe=w8)>x>^-FE&`I|Bk>1qpz#ca zHQLH3;axH;=SdHk=`YC?ASTx)oZmcZca!2i>aiOiPZ?`_`^G9V-`fI;u&N=H?K{>V zHHGs8ICF?gu+@Jl{efQyv;pj1eB6vHA=%w`ow2?jYPiJG=bW)z?$Bx|>DE6K1I=as zL*4zO7*O9$@dnmjqo*MOOE3haXJT5sMO*Wg_6PR4F3GY%XhOt@90NXE_9%bJ@3|iVZu>+oq znxTwWIfhb0Fj8vcX|^*KRb&dPe+fKX5RA((B; zHZI|irL-|9*(Mu5QK6{35~TZB{C(BZw>#sY*wP`udJ>TNlZ&S!w;T3e41wz_=%aq% z_IPnNKoqE363KpY3F)Mdd!0}NF%h(Y@&gud)*cZqg^_M*f{6*!{a6I(xQ5Vu!W1Lc zR-s^)XqVtI+d0cyqWqi`$222msby`cs)p*)eMdZ>IUXWs`$B&Ihe|2dw!!oHb6@h} z>29DiR zlkD342c_{$bLs#75R+W67Pl7@$7?iySUB1Ec$GRi{n(XM)_)6=VHhUGW_7qKkWw%} zRJ^g$si!Gw)I~v*`F)#+0o>Hmzk)8bAU~vQYO1sG{Osl`;#ud_O9SXR%!KnR6F6PW z0Neib8~kQWp%9d6@F2~;+w6_@A)IU3{Wo2>D&B(|hN$EO{v=r@y_=ikt4iUOBc(+1 zrhcukmdEFHb^V3C%2C1z#JGwnyY8g4FKQeVjP8{Zuj_%yQZ4{>w8nerThzqutu3Q3 zjzj!w>ORjuSms-wknpH{GBW+3jDeHnQ)ysX7MyFfpB31BMNe?7$Y$5HFqSe$h|^(+ z4H0jl7dJ6cAhr|1TaP`!(srYS!sPwm$cQ6-+UkRSKhLWa^NSQ#l6yc5~0}m`~Q!OE-Sgie5 zTU4$GYy*9g*P5xdWW-~cw>&i1p^^F}#*pe2Y(;AHLr2|X6qwvF+MO%Q;R7SR0d`=Y z2bgcc@vFI;Q#Zh5G>f!$IN?|qSWYYsAm=(&Z)-gqEwIwl8i(E_?+#<@sm_r+t^3S*D`G3AWz(MfiI=BLhKmeu*;A`FXyyYL7Hcw2hm>&{8 zEQLvp;EKNaw} zV~k3;-opMH=&-V<+Qf>|Uy{)cJN&ALwU$86o4c1>T;%J{4JD(#Bu|`nRh5uT)qE5QTvd)@+Ru5W9dpdM)tpg;i z{m2JWdsz`#ZcnHjEA^=AO9+8yV9=Y2gn^eG%nUo!_p&vvdwl-s@W5ukezvxtR|lbyp`{Y1*LFRLr3z(Kr~*oQ6JMMy3hMu;fAi#ExN|5S z5iRYhCNg5UHLznQCHnYbgDSSX{piWa^+!FCmg|wy9z#e5v9{fE{FkiQcPX;lSy$O zl1_E9BPLzFSA3dN9;O&?2~3xqXv49)EyD>TWlGS^tY`h!cI|VrLuHMmLn40;Bd-@U z+(4Qsw#C9Brb0zR$e(Z6xdwc>e6yxU;j%zN%`jl~#p>xz0o68i1!EY&2FnCr_KmQh zH(!lAkWYHq=U|6nfI?Ow#VUmPCc1Q*=BnQYTtDpsX9ixW8?nDxzh||AG!4)mjMzY; z1U zMK#n{{fEiG529_yi)Q!|_r%xOh@j!%+30vnl&kt~8_FD5Q2jbPh1`u7gtjWoIMR&t*y>i@uc6r#qsE_Zx2 zJ7J@$Z@uR6c}93pV|%M}SkcdIGi>vN3)8E4FxjAZ{J!ldn*vokAOx3DIA=G-%a0e) zfR4}QefGVB^p>$d-DF6IoDB0c0G8&ax4*lORTJtwusz@K>(-)m>QOG(BobR*|8=4D z?mIXFhkVw15+hs4lJlF+zl?f7jfNP)?TSzSfE*mkr(N>wl(-nLBf#qG!b4-4aSE`Xbq}Y>ot+Z9Hr+Ez~PoiPtG>qE4o!POkT! zKl~zDO<+FIK=7AHS4hzpc9($h1F-pN`{fPfd&vh!@zX`#u~ct$P>dM$9?+T#)-K@# zhkqOl@kXm~z-uMCOoh!lIu648lExWpOh8)eZl+?t%+;Q57H(7Uqy;!H0S3E5U@sg3 zdx*;aVKV^Qbm#zSr~$&$RLOF8pu8kk6RarrUi>%8b@hA98RB1UKl>u{MPluh=nsYD zV5KI%9&YkonS|V&(2k;Stzb7*pvBt9RycL&uyh6MME$uPvQ;tp0b?r)o4dYCm5FS~ zNE4fN|(zmJ0h#3JWra!mo}#{+xtAKr3-EeIQI8=0&oRvB-?hU{FNoI zcv{9)R1@wN9{EMNID{`@L?zsNK{PZ@J4FldraDN;f1z`nr}8-as$A3-Ubw;8^Kk9% z$oYdZaLc8qEEtxZ+WjJ$cIs)1gmsh9yW_*X~KUuR`UV1clgPR~r{u&Od=sKLiiAbcRcHCY;M! z&|<%|5uO=Y3C`5eFj_13`^shQe4LwQ+o-Kw8mu6z=Z*ZO`R0WfCMLmQy_-|ZG9VKF zF~1Ok@->aORe^ui`d*}_x>%s!^G=w1MB8IN=s1Xrr`~hz=y)7BBJb7O^hfT9coRP9 zd+L(uL>@a@h~ya~PKYC^s;l?K0{fT6B~_ z-nT9lxZzMa+wJUtH`4D71^wHrfkzuap<#uRq?OIEAHJb z1C(w;COD3`F_5(vH_{qV&qS>tGlV!NO)OM%&k7>=!UmbRJG@lPm9dL5{Z*-u9vc3t zz-YlGp^jRO+Nqte+t08e*bP2&jC~C8Q#fRC!ox7J0BFU|&mnm< z%Gys;_nGVVm%#t{tPF;G#1`KF60P<$q!pW`<7~fz@1>yUjM6d3d9lv+z2-J z%Om?9YBW&Ub~XDp5R1DT)_66(qUnS1ME5zhJMkM@b=*@5NC;-?Hv`a$>T^s|g9thbQ3Q%PhVBq*&+-+HCevg zO%DaQ!Q5}J?qOrbZ!!lP6%-<{g8Gd${tH2LrsNs*rlo*iUN$DKxv~0X-a^ZkpPL)4*FU1XFGm~_U@Y6QWxj90u`DKjKT@2fd0hVmRuL4`XeB1t473RMepkG<* zj$_kL7N`9W!%a^nmq9Dxs2)@9y)XCpPF;B>$UmC{{0@sxymyf2zl#4oRHr3G1Bt@O z)i)IZuh%}oN`)CQnv;UmwPnU~f;%3y@Db)nld5R?$>d;|@_u3U3yr^4)=K0^z|u^E zF~a1lGwS7#H8CcCoj-m_ORLK1*zrxqwG4e!erV^R$pY?e+}s1V^;f3hS?WKnl5t&a zv`#EB!PW27J*+)0l-Vkj-K?k4-uEu}+Ib^($FDi9h<@keY?76b`5e-81Xc=_XajYs z^ukIT`s__?2gBwLo2(mswx@z>_oYN615SIi0}d2p@d&Nf2|97U@#5<8Jbdv~RwDOR zD`1IgY%kdLonSSlt51jHGdL7^;B1@q`&5mm$D8xrYkS*4EGNtRLybRP1w4T|(%-Nq{y69$%KyYycRtsr0Vq6|@Dd%$XT=`$C^&fW1t7rMqa; zPYwdSHURhwce>TndHU**>U1X>P(SN{MQh7%>VPq~umdXyp>D2lU$a&C3i9HlJfQ1S zpxo{OG}>s@fVm_7q5Ik!`s+yR_uKfKaGaqB02jW#I%_IE3~3wV2n_AMs2Lx&GFtI z-nUhOTN2&Z`({vb!W3@FD6UjE`WUIkV6|0v?xZ4cd%zziyHXe*Il<{3#Uug z)@HEM;%0+SSiLve8sf=f&j7u7ti7&A-D(8H1O({!Wn$M;)E2{MklT(fSYNack(B6_znE|7cpDzE+@_W7yVJKBr`d2r><*aR)@}X!8)_mJ;Qqg^u}Qk&`L96*(E^5$ zw7mANr)UE5IK0;s#-PPF)0Ln+8T?I+r%CT*-LCVQ1*`W$$p$D%j&*@96Ze9v1 zy&jOG=r(B9I=gk5_)-Uu$m`N;VFldW1psl(3oAaD)5(dr34(<`r>S8$WmK5I;UYsj zaplrfS;r-uP$h&`8*AGPVDkEB^|j^o>O>tGBFQB zjpdiu6jBBya%=}}@0%9vt?ltkpUhc|6yI9Vp@m)xC>(D5-CO;N!X>|f-_Ed$2aikDe`>IXbmr>NdoMg~DZ)r5)#r14ws zz4L3^M2X~KQ5z;R3rPpr$hl%KjZ3wU<{O=^1-v-tUdoFqXl<%ZDcP;}@@f^QJr|JZ zJ}?CQ_rii?)7N?spoQtF6UmWS}wyi8@*Hd+p_eQ?9GB)WiCnP>W21|J4Q=w zjFj$W%;RAE6*YBDbHIy@?z@YMwerydbOe1#C4f)bkY=C<<2;UW)Ll*_Od#0rW7V_I;3+_fw+72sMjwLS&^53r3 zNUUIA(z;dgZILcTPc=bM-rVDP4S`C%^F4;j5#{^*l8HuMOie!aQ-x#MpR=Fw#9rh{ zZRB)Y9G51q^+6cQc$~38i221bDwdP?4~KA5m@|E=XRdYsJ=oxTEnLnp8NN z0R{qG|5%WE6qeZ4%=Nc@JXRq5lTBv_7dRNU9I-M8o{UDy}R)-+dBbJ6&#Rx1=JUCx1 z@-+S$-ECiqm??lvah(`b+r{J{FEpiuUj$@oJmR1~3EWr{oEXwRW2|;%_pl zL`yBf^oZnyF-`Z|969~zb3E;IvR+k?`MCdGYy)B^vqVSpOthx|(qwwRfkFIq+IJxC z-1KNVz?u*+e`0(xd5W)<7N*to+1fKb9(?%P?%awwbxHEhv?9xK)Gl7KbXEr6qg2u$0y@;@oY@S-uWp!>!#j@2;J({JT=wcPXFw`9;&`)+&<0 z^uhM?nBHe>RUT)e-$Izx`6C+a##L(%?2r9!5x-x^vn(egMFS=lmaU+rJ_hzCY-O4w z?%aa$6V1L@|4G1`q!6{58y%yZT@eL%Y{fyQ3iKC!P#I@DCoR72`{mh;1a!Rc&&t)Z zfEv&KScf9k4uAe!%qHbPEXBEEO`n_ zZz(ucA-Gx*9JXssBe=&`W91MdiE(#`!D~H7**0)?o__q~`}MFPlW#$n=x)gIM_xkp z40SYdwJJVTz`w?2+i+Iu9r*X>^>J{%`8}0`Z~?UR2DY{IqP#lpf#u~acQD^A<4+!C zzpzuN2SGmzgtgUEyx%C&5=MNbiMx1uG~1YW1Pd%Y`>N$%(@MtLl`~+@gCK-$rV{h` zoohTu7H*WpS1bAeimKR8`F_cF>Pg%Z6gEG2f8&$Jj_*=7kkP>2>6I>HajcIgZi56+ zXOcXU)Kp)*O6~ea`;EEX2ht-H3^BQhN&(Ud>?b%5yTACEGVx^Uo@5^G4IY8OsEG@W ztN9C;3KA?>h0(bXlgK4xeR#PWR+M55`Dn2e(0g>N#O=V;fxr3%MOdt@jE;=mTW|GF z2;`gas=TRcO)-F5!uKq&KA?cx-3guJ9znAd1(dZv%t6wLw>erGYj+dpe#)@|qTvWHghRxjOW=&;T znff}b&tlx1L7^}9H8*{8T3l@VNgS(iq<>3))5X2}NY)Xw&Lz)Tyx)jj@rn-}4&+lq zGa||8muo6tf`Soi&m!_T|E*a8`$a}~R@E&JSGe*1KyNYx%#t~*hWVWk$oT*LwEckFJ+ zFo+9;grFokx!$TvJx)wuBxtbsZMB<99Pd`$MIFtkJG`)4{kCxT_SvJRGZHhyr{9zs z3tz1~Q=3PguJn+lFURFy_Nn*5+fYj8w*So|n^ zE$2`ER;e+c91?@Ee%`P2)A7bJh?)rMD5&vQOpf{y7@fLhBoI~-lK-t-sYyBG-)A!1 za?QEd;T>o|D9@|4e(|ZWMlTSwwALoXi4@MJ@*z**RUE04bIFCEk{T_lu*@JXBaZzW zsIJBGTOXTm2~~^$FHOZ+C`ky=MDT{v0qYPxXk0Zxg-zH6D|5b<(ZTkEOSpW$o)l$4 z%Cxr@@a_wNMLBpII(R8$W8a_DW&_n>38M=wE#ZFe6BPm3t0g!?NzfJ$7YY#VECD7- z)da-D6-4Ip(ef}2?&ud}`Y3rROVgtoem{B0GfC<#%<974I%*mKl+I6rq-TIoV93e* zKTg?F6?W>soqQV2r!^sh6ek@>88*HWV+96)Zlp6#87Mq8M!Y1dlIIEt@9)* z@}K{j{QuZGuYjhyc3ayUA}Ugp_K5_P5=5HRd=@|mMS4pB>Ai#!YETi7E}?{uN|P?V zgd&|#g#e+~P($w}khA>$eg1#%v(E)L+-1$R=A7?%$NP-`2nT&(m(MZKEyKfCRFvh) z?ww24d?6^E|5iO8j<{J_fcg|XlEND;g#)6(7Ww3K`c%ER07 z^@)PgA?-~yTE@xN{QdiD;wu6EhQ=9 z-hl)6h&LEwX6c$=+l%rcwd$*={A*8(hMslTpAN}(Guw7i=W(jKU6;|4%>f=TSw>_G zi2^F%%JH8ThOncSIR>Pz@%`xZJQVF!ZW=6^RRYxMT78+1HB_0j$^P`^z4suqcuO6Mk;_ zMij-3c8o<^tkN%M>$02zfk}SbyvIgCPOWROV#6DrvERK2yp3fi(>=o~HFR!b3un`< zmFD+W;ZKbDuYfVOF6cY3V$X;f>n7Y_37Xw0AqE)VNuG}i9j6if9zMx|1i0L#b?Qgi zZQszl{?Xwfnp~mhbEz$GnYkM$FnC%pT9r_brpd(^w<% zI(D3q@x3_3UPS16Krf|baWOE^c;uhYBXHUqxVATQn;$ULlgHTo!s5(Eo`z?Gb%`6D zppZAS-IYY+5z+N}nX5N9me3C4-dfT1huV1~Q#upy;zjreBVOTi*w%lSAo4Mn&b<$A zEoLqfufl&x_X#shD87ra3zpxoPL9_z%k|NCVJ1(dJ@Q!`K_|^A6`FOq?HyE~yH>YK zxt(T}IotM8!jm}2GpXezw`5R~oK!w_LcGxFwDVMWm`T@4T8XRwWU_I=1$m_^y7bl0 zLw`2^LO*Rbk(xdnV9>J0)Z{R0+R-zm5`e+z_{8(Eq?>wOKUlP_125D33jKZU&7(2t zMf9Fzx3b4l3CMsoSfDfqw1B9*Jd0r45*uf**EA&*J*=s!`yD;F_hjV} zZ(=<^CeLj%EdnK1krD0Yopx@s{oiI5_f~quIT)-XYo_MSt`yaO&+^j!4H?TRe=Nn$XO#qn?6JIBkh9)j=sIYEQxODKu@E=4@?xx%Xu#Yw==b zs-|QCfT+Wj0WD}0$n`Mot~3REK2!534(Px2Y4E2@vDCWL0WgI~-ZFB+4KWgcV&Md~ z{WoBN@T><{6;_&p(12s44W#k|Y5=%Fj=w!7D-5_%qxb62~@P zwL<~ITqE9D?7vV5H_a$tEE2(XhF1O?+naXhi)59MX2DY&0Cq zev|`yMb={9;c|J3sxz(sMx!w9I_o8~<@tP)oyosQ<*bhOBSiyGxRXDNCLN&{2ZeAq zgbj-~Elx~Lryt}L!$kYR@0cBN#;5AD!<7OBN)vI9(az>^Hbc~+M-%ZlmDX_gH0_hq z68{0~a^MnQzi`zB$C0A5%X}wba!F?yJr#WvX1>-7!dq_pNxqMbjUgGPv$dGj+T!a-no8B$S?m# zw)jFs1In;enTXfU2n$=_W<%TWKgUNTs3-1ZQC z{_DSY>i|-r_d*Edc>IURt@j~e6kRtq@KM6tL)4Dr)J-4ti@eyBL#!6qjr)szl|YtY zRDCi1OxMw_uW32(E2vqxP!>Z?LBt7AM%4%-K1WMTil64HAZJ<6#`BEluE`HQ*Io^J z#X45KW7s8|ua&tCa1C0Ev&bO4-y^V=3qNkPRui$F>eW3NMcRR%4=#44vRA*jF?8#S z^7e;LOBy*L7T$ChoZ%sxgF*d4^xa^*hje1$rHrys!=bmBq&`(Q_(6X*IGzKHaAO^r zZm#xZF6pF>qFY(M)z+0U=B7^dGzQyB-M9MV3%zeXug9~Xay_|)$7LHIS^7uj(U=_E zdhLGZ*8-{T`Ul;fMx?<@AvHSLn1_d$b1TXz8=8urxMEN1V>{S;Gct@kpQNr4t`#z) zsvZi*jt0wswd+*+2uUn8+h-w#(d0(YEm`HlJP9$ZtPB z%-SYI5Wuq0R2bjy+>lNyDN`7kB0#J-*Zklds z1&x(Aznff5W>RP=`erX|C~6bB?z!u<=Bg-ad8<@dJ-6K4CcHyqaZz&R)$*~5>I>W@ zCsc@7Zu5Y_RW+$CtuPlmw~LYP1j~fXa{P5!8ix^i16K}DYU<>R>fuYYRe!~MeGi68 z`#NIZ-+(lMHk`m!w?<0g+aIq8U~ig5Nte+wbbv^G1vPKVFQu%Z`HHx-8SfW%DGl4^ zD)E6w66{79q9v1{;hk)AXa+aiYH7n_kxQio2s2pm@v=mrD0syBFB9sk0m{mEac1W= zBn2W1%?>!7O&Fxe>U1rbVoFGAv~r5}oQa{HGOHkbJM&W{8JLG>QzPPmlF0xuWb_v+gpZ~py1hXCac+eVN< zf|X;vCZ2&Zj?#IHEfMuk3;N5UnEGd^mN!$}{>w#;;Vd)Q7gLsWOb0&Xj&YP)f zYVzi0iafxCNvjg(Z%+a3s+QSZUP8LBBYBg2^6Kjd>=h5SXdJ}BV&<^jXsU+4 zmNLFGFzjHva~f6i!Q4kB>b-0Q(Y7@#8| zrH;7XT1Wln>9doz)IVi%j?y&&TgKl`@NGc$&{UPNyXVhecTX%N=e9G%7g(>!lRe0f zXBBQhKGGY4PnBsI{~+gCn$E+FVV6Go{cB_ARyqIOK>G(Nf5>Lh#y@PFrpjaG{r(jl z)mSq*VHUk1{(2jp<&}2w5^3rMFOH&Cf+UU7M6gVq+N^_Wj|OBA_lG|({jeeVNpiLyj9O!{I@%S;{uw<9j38}(UTxO5Mu?)Ty| zYYA#EJV|m;3bcsUg)$WTNO!O5eSGb-dRr1bpo-A{^pl#`{j2$%^A zm4_82S2k6I!D>IxLO++ruZQ!pY+^mRQ2!T0AC)F4q({^3AUNpM5^G4z4pwUlM~Jx>>%*$4q?y>p-J~4ng+-M&ef9#ndw(P#|u_Qs= z%I?+jLc4f0k?mzNIdN&My8y=-STkvqV#zgqXR z)kQ7r1)OXht;A~_de81&(B4vkkASsbEFcK$yeleUZ_%tcyP2~Vjpy4BpI}j$$Wi)@ z``H2;NcAQ=6(x$SoLI@15(=a_nUl(WqybPnOqA2Q2j_5@%!&etS2Y zwoCG1q<4B@Td2>t{O|+ma#XPme=(FXU-q)*PsRq9qbWHc`SoqC6^VE&*65P8tI!is zTNX2eN0t_DSK>z1zPRF$6@uUq+Gt64SaAuLd){XJkiVsRYa58ofdee|7kUCbGA892 zU=JLx7-S28&Y{X0WCzEy(CC_t0qKS=&%#9ZTR#;6dP=UmKV_@Na-UrQWNa@)-oT~#s`%$${I3VI`x02`q3|Ru) zdbY%Sf9S`5-(U|IOF7j2Hrto|;fj&y|KKW=wuy+lR{J)T`@O;MorFd1cq#Ix_1+(T z`7sg{S@tdBP$8@(vs9g3ijKFeY9v8GzfCntAm3=0Okj9znSRGemS0>4Fx67q9rPc z+yX*|#kv{rdx&ul{%!hIg$P zT{o`Sq#V*!MT7}9DC;LhsmS$wmE50?b{g*T#sQ5f<5nOT+an>!uk^4kDf*Q@RcY(! zKcuWN4D@Xtyr0h8D-$cZu8p z{2RIVL=**71N>zF)-n+mBte1*CTpI0>N0oER2ZMgZvIpYB%x+lwmjrCp|U0W{X$aL zASmQvtlmzUlkAQo%a|c_zsTHdpLyb&ZIPUlDKh6ks8WwmCzlzRDA92^y8d+C4T**|Bx`)y>Sc4$G`W^ODp2&+d*LQS`VZW4!M|D*T7#-!#X zCLaB3`#)zSp_(!*SaZ}n2}B`4jt=Mgpj5w>{$0%dnSC5JlFYNnVsn$~7PZJzbiJ}K zenPmS%03badHM9yo@Vr+00T4P@kB|~1L+B?2%+AFp@5;}ew)0jcjoPtBIaf)J>AqA zL~Qt#1mC|Y-!IMCF&{oxekFA1I_vWSkzo6_vTspX=5~0v%VO~hhB)qPiI2b{_QQ8^ z4bo9Ux5}lBuyv1GRTU4!vpMxye(oPld7U?=UtJrR;iC(b{A>&?#0zUMRMZnlQ;XUT zKWJc?ck#f~Ov8&UqRpl5M)x7Y-VC`GtJ7D%3cL6?vTKFdabG=|msSS#isLH07~rPp za2+h8xtc+O2_y8lMs^!^F$8HHl16X)wpgPjM2%Fpn+(Aoj3!>zE|ik_Sihsjfv+Tf zg&H_6XDW-pp9yxk=0W#+l9_M5SYJikq$$p!W0X_!6(S|O4**yF#Xk-{`9IE!KlUmL zU<~@xvz9t&Tmx>csx?lO`~wuc7efyB%CtD=7Wi*Z=O6R{bcKt+ZKgDT-eG7Wq;y1_9_=Sdn|W=PaDD&IxiA79dq6JoB2m=rg zB?0nrmq;^9YvqFHOI09TaU96M$g1BI-qva;cEg=O#l{3ASe{Kut>9u_&A}x)l30Oo zFRGi-EWj0!8x5Z`$M2b50s#4Ua8b$sFnQ%A0mbNB6r92e+*9{e07D?+!cXe|@k)yu zCx+%)WcGg6(;OG{Y*k6pRp{D-uM}B3PmUx=dH%vBFws|*FCg%tse;@)&#PPY+>?hc zQ7yYX^l_-Q`YSDCb^9&JV<@u~Cc(IJMUO8C&73t5@ajf+_G4)ajGGgd*q%1b{WCN@ zy|p4+-f1;2qJ4~e5L5P`M|+;w@pXH1e&B|4YgXdrr@qy0c@bj{>(lXtX|2pX+K(Lh z3jn?Y=V^N1%1|!1RFVaASx2e~A69YloaOk>k-!m2x9?b{*H!OyIm$nUn*>YE;FXz{ zu4gZLyPlR|o1NCs4m@(t6Bqv=v#$G_SDHHIjt}$01p>_97JPZ8CK?X(%KE?~`yJBz zIE}Vg2lM`HsFwZ|O%Fzqm{G?-%YDtGY*j%e{a(}jYSm!BFF}(VK9Y;mFok3>zKt+f*$*=ABN~z9O!_#Pe!hxHGQirgn2E1QxEGGn&(8DX^HmDNoUOaC|uxfyDFQ|;!!#uh&CDlMet!LCXzPVtS zM>NCNM+<~t=**{5SfVh{V^FG&V2PZm7Nb<_-dxwN9s6yq+TS-EdvXY8v$|}#ftETg z;m35b~Qy)?nSz>ss_kDK$2Yv?8gHHO}OatD>*chOc*ML^8& z^O%|`!0I8oh;}Z7n(H?|mL+}+ciizPA+-1&n92utbnQbw{q|X&Oue2=ZdNz^!N3bg zNMkin3Ya`kjiQ0`BjX?5L;^54^LzF@*MEZ#La?MM>M~fNgMUchi`lHAEdQP48hnuP;ZHKuBEe z%46%7%1bLWd8f>RWkUcdc9fA>w}V9AoxP6XJzyX`ABK=pU;P4D$rLib{fYIodus+{ zd%#n;CmeAqzB4wBpwzluy;`GRAbfW`INsVQ6)M78{LtwHffEoH1G<|)=E>eIpo{yD zC<;80{|3yTY1J1E;hD`hl$@Hs{^*=U$do|b!Y`F^oit?-{g6o*HT&3_oZuJDcqXX@ zx@cW7MY?3Pio@wasAklbR70uUgaTS^=fiesZf^O+-#h`Ulh>a~XmQAqh~Rvq0KILn z*xI#z!t}L8VR*6XQw-EVKjQ|YS^0jb-4XB)#`txc=ttYx4w3P(m5I<>Y}Yg?E@$_2 zZ)S$%#OQ3CUh@3REc9}(qFT5&uAtvB^TN0kcw|s&wWq80>sPF4wUwMdK#f-6!L0tq zv0`AGd&qjV@pb8)IsQI@f|7nvVR4mMtE<^#vl}Ed1N4E#YU_hJ&ll#Uu3=8etOj{O z#B_mzgA*h!mU^NR+SATuM`dR|uMyXu0M_jvaNCw3g7STAjqandIun zWHYb4je>1kTa)w)4}js?4$Zrm;P7rh`u1oC5xO`$Rq%Qi{~L4BMBdz7B}{RHgALCN z>jxB=&i!;2QV8;Wk7``GJ;C$)I`4IhE5Oyt#MdOvXzlX6Y+t4h9N=d8>W575=9P5r$ady3cjB=Av@zf4aK@=0^1HlJ-mrFmqox(+d- znr6i<`3*$`|H#qGde(Nm*;ZFj#d*7in}*2fGiEAXo0@-KgI1{T_ zK6(i<>CQm_8Ay4z!lj4lT8UQWV@XS;)B-+#ksH6b4m>a9+~;f6}~ZFyt+=!)_t2KuHYR z7uRPPe9{wRS%1{BxL)f<0TNlfPbQIU`YhUJKTc#vgfLno8QUfIeknb4?*0}#==F3y zl$Q&wJ{zD3iC}1LRXNLV4qjZi#_r#Hu&Y4NE~cNia5BG`C;9bI`_ey7bl`*f54vX) z2JuyZF{A?H5u#6rh0i@aqwG&+>c*xi{vJmtr$jjw*qa;ZJ-vHPLE}2z=ZKfwxBq(F zaE-(BOSNaHv8wQxK;o$4X`ne=q&8QxSCQXbeODzZ>b_wiw!SdX>yw4M4ra9K24 z?=>1qa5QAgb5|i7)snsbQnt!ehKnkj`C22YZ`|7nVTe|r@uB|x_y_HWH|qpaJ@?K* z!V8V|uit_mipJPQI8>Ty-*!uBQhi>asW5FKM?T!hbM?>A=0+|$F83Py{FN=~x_74M zii@Qx{rGyPw}51-M0THh$#QK@qktV9Q8QEivKqR6xBhr5vDcI#jm75sLuE@9$(k*v zVIc6V)^(P2>I%~CWO~tXtO}!1beK*@#t5Vxl8QMRK^4b%$k)_*r;3JOk!jCCH%7WQ zs)&Ur7NzNksH0Dkm22p$a!%9esT-3BsmEwqprwZL_2_)@B-Gg;ivjRfQ}=yzcs{Qo z9U|Y9OYQo1*~P^<`{|pNrbnrUC#(7O$5Ur3iC^!eo~yO=%pjZPYKMvWFpp7ldkP`@meBb1wFyn>;qa|5PM=O zZ}OQ<*rg#a#dDXU=rNHgW`6o?d34H`oY)IFIc1YRJ!L~}?@*33Len@4;oEZnNw!Qo zq85s>0z4tQ!om`PP>~41Mv#7n>m~0KZp#_;#<&9Nc@Qxn4O-x4oVr=3YVX+nC*;d~ z_KL6epYhXQ#I<@#uDG~ZB>Ri5siVbuWk^~}1Z6iZCOW-cdS>s0%(=ZqIW1WR9W5|S z!Q|yr;tkLi!O=tlWzFJLfYK)DizE{u1b)3;wD-=&l=3wy^^4`lud_Im-l41*GLL#y zC}iYzVRuNsSN83QVmlVu{D#qwQU~O@zBBlj3Gw>F9!P!S5<0okbSrBynvJsTkYt?B zAZvyD%&8l`6d&j8Z*fUhGa73cx(uClApScrfjF4Z6CdrLqPk(%J4)Wl$g+HU|ne{ z@KG8?N=&85_`tIoi3hRj*`rn>F1YP?R1=`i=sDt3^Lv!!yzi835|`2!m)9c-=~$l~ zZOU4fN#;E+3V+>JYTc6*lS5CmvON`ko)VK}?#xvoLIL~_;3}Wczo1;cyN15AnkQDz zAZK1gUm;40y?hph%(j-QJYt<<;WrAZU;a$|0o_k|bGS$F-OggO@|oU1ktSiYk}4(Z zI!knWR)1IF>!OC~v*XQa$7UJok9~d5_NKgdOqVmU#B)a>o+6k$#`#N_@jv0zpbcZN z7?~vEh|B!M8Va4H13D%2b|C{;Vqx8p+ZsC6W8*5Dn<}9n1bGhjz{~@S38mK~^rWI< z-(hNK;0SHp9ZXHbACk#0+wtj*8-UDkuGxJpztEdvSa(q3d+OBDI4(iiUEdX~+glzR zfi9lU(w@ry&H*{YpWva}#}ReXwH)>23Yw7Jz?f)a-RXo#Bs=J6zW%fnV!nosaXx)X z&fVP3FKjk&!UU+JA#$+dK=oajU~grZ3J zdP^<%yqUDZX!6V2#?lsd<)bso(WwCYQLkN36wEt?x^#{$X-B%eWk*{k5eP{Cg7;e8 zc~N5%o%LpwYwB>V!dN?5OLb3(#OlE|i;Po`a+Qc?75&AC7d2J&-I}9pr;T1>K6ynD z4bK|edtuaeMI6CDEV&<4Na-e|MC>O5N<;j3bL9M!(Sq+oI#aoZsMFj=P()smx_o11 zFNv$y`*>pxR;&)qvOxwr8CLYn^^lnC@j@o!@#c^Guj5ZYP(lckp0)96qjOVO_)Fik zoF(nv)On$kM3Yq9MroGSP`pagk)Zz^lN#_M(0o)U!%qrtx8^I;qbgE&>bI6FMAp4z z13h-B$bP;@AKYs1vmaHg36WSJs2w{_gFf*M@*MVw&tBD?siQVzd7(j>x?FA9UhYD9 ze>HJV7kDvTS_7byqu>z_{ALhQs^FAyUg41=E?khcqRln^mot`l=~$|OkA?-Rt&$(h zok5CM zu=}mp2%4@~%>|= zMDZ`IZWUgxfJ+R?jLJ~amh8E{TUU1R7lY5u6n(3oe3KoXzrWcv6Cbu+jH8uf!#^8v z-4o0y+v8z_m>~zGvESbh{z@$4L4Wi>9=P9pU_;Ukkn-<#`7L+6|+>y^9 zV@pJLLq_hHivdXM;lTvXwkoSBmB& z=^a-%d;(QF(su7>DT%I`@+@w?PZlj^9W7*D%U(BW+{y9DoOw}6q&@j!^m^ks$lfS3 zOy!BRf!$JC9ZJjzpU@zQ*E&mPt65@^8!sQ-w@c7T(XM>As6h9QIsZ~wytpdgt+Fjw zy_4AaN|PQCcfL;>uiZSJX99E(UL`m$>q+W6)l(B9Gh!s;HwUyLLl@TBo?IK7sHK*2 z4poA9ZmVjZr}dL8OmQ>6J7I1{>)4b49UdEDpzxKkmIdggVl&#Qppq5o>-DYbKXy^zbg!E@Xnoul5j54T+#pJJaq}yU6i_ z;GQL~hzs}JaC==tH!pb%C%;wrxQ}~()gr|zc%@c#FxlM(Gwx~Ql;v!nHVEXkGxKU$ zn(Ia~hkb~R>@KM!_T1~BDje9Afgy;K(aY$V=cC71ej5PATk3VnP=7XIVMu;eJF+J+ zbrdOirpf_Xt59k4WeXQA!|;vLG*+c=-(U7>TuyHLC_cYsI>e@rA+IALhZ7r=!*%3j zp@CPe*No6V1doM<|GH5CYpNIvoy-701lVoqH{sCz&Jr)j*9Zp8cmV6IvCG(}-~1nJ zppoiu#31)o&B&agEQicfdGcyrrK#Kt53tzd&>8eWK6LUbxa!F-*#|>GVBO{m+~5(L z?0Xny{leb+FB(A(ubDS9N2?qZUEqk^DE*+N77$IfY5tTrz=nser$T?c?!6;Wnf#KL zzuA4(?51%xq~8nN2PplW{xX@# z8<-l`;DOJrFEBNlbLiS_7ix{(iZ$umeerybI4|T00Hx5G$h6P)d@Y*D9O{Idt@@;6 z*c}3$Jb1V~Lr$wADj{jmK)4`Sx0*O{0bDiZtaB^Yxr*ox+tz~>OU3g40_7$*JL9l( zJv02b2G{o@d`~713`EjnrO6XQ-~jw)dCNH2omo{S)8A!>Hc+ zt$ieECgKm>`+Zea!xVsZ0ru7R-wy>Izq{h_f*fbL?zF~x;Xz;6`pb8)ZgKxSA@8lZ zC3+;S-kpm%eAi?#>P_s+M{Aem2n<^M5tpQ0Z6B?VNzoxMnpql}G7fjtYPh&6AFR~G zELm1WI*EPnV~cR{g4uvaRI?gPu6kayvd&pqc^&)O;oyqJ)#WIE@nxb?MOzsx+5!1< z)keBpt?;wm6s?!d?-@Lk{*yaW{ziK`O4mGGhYv+I1TM@gh|bc*(Ewo|q>K~>jT>{) z2h#&VH%EtDiuD<7?gcKlenEQrKTjKE-!6*DWXO(MEN4=9GMj}Nnh+FErnNiy1;xom z=OkaL?GvAUcgYsp!n3Fh4hoIR5iOLnTQW2t(*Zys$YSWhs?y7YPt z8g%`5MQcNY>0QU*^|$f<;}d^fDStH;9Qf%N$yY%$;sVyi3buNpRxCAP+vdMOqKQTf zw7Bb!yb1-HQQ7ks-8wY;}K?|Zm3*DHN;>Xb!FyMNlfXy{Rb z3UaS)+k4l{O2BZTldXOeaC?e}(=DKSLM1UF0&+y2pP^o?_%VHj@F!Hzh|_)(s1g2T zS$@5}S8Sz}8?w&BHEO;ykT(q#S^HR5ov^@nD)G6#ybW?`ARftg;x@CrJJS+*!f^KP zR6USewYrFlX6*sb!Gl~>n8TyObs57^CFI|O?9VN{?|+Tqc61NBx&9?cNoJ7lJA)Da zdvXt74O~!JVn3*WU#xs!raj8`vyXPqbli*c;};yp8wVzg=3?5G$Lx`VHnL^>tqQeM zpHIG~4P9REF%zmC<=V5)Ce}wCVRM%Ph}qE3Ss-y_F~*MD+NAJa+UW8cdx1Uekm=K3 zr}4zn6tLFVL4MTIt;dPI51#iOZ&7V1UzmkW-s7Uhz6@T!HxPmU_#$<=ddPXpV0B%2 z+{+!Lo%tv&wN&U%t7gQP@Pc4B&Z{E{2<~eOiWOBSkxRC-4-_xVq>oBZ_qznSr4Sr= zSj&PutQVpJ3AYjn;Ux-&qi!i4OXf@_T2qhxy;Evws3#|ycU~#1t?AD#?ccUh-to*6 z!Cea(fvn@BXO@3vlgBF3lEcfK-XL)0%5EDA1x)ta&%?`Erj^PAR|fqai1XU7)382E z8K#hO=>-$#&|$lHc|&138@p(}LcP~7r><)8N15IquRSyZ!XzoLZ7XUfuP2X7Kk0XS ze{iqo-d$fN`5 z!wF~CD)OdZgj<&9PGy7epKOD;ASN>W!#_0t$Dc}inDRV4?iTE4@(bOkJ!q>Al8kS` z2b2ABq6}9>q{^OJQqS5XD7#hJ3=0nw$RsdLt*?5OQtj3v>_#Mte8k%`OJ1jleW8q) zx_b~1l$G7rVyA^jM?$L>M+Ok^lK z6p&2C7k)CKb)BcLglJ&%bH$UUzFGO7~HdlLYHnbWi5_;hZcb20usd6Dk7SP!juJ@D+N=FqJ!_KAY3Pram=8v*3Jl ztBCFAvgXoc>RRdpB-5K0Rq!?r46~{w{5^u0(Lz%O9eTa(XC29;pO@(cJ{tT8_IMD_ z4oCpj-`7)`oON5;DM7>bR$vP{i;WNJDGq1zUTl9NOKIW%NM~6je~FB7I_5f0}$ytM&hRKVAbVw_ESRp(ue75W(%6 ztI-@BNL1K(i5U-0Z9V2)LNj?{ybosIbb%lMaF5Orm%QPWRJ4xPe?*E)dCMu5z~PAV zfdP@>>%9hq6&qCy@5y@?-0Wc#toV*(PBrmF12eAGZUprCj13}hR1>*28f&irvn|eL z2P#%V{?Yi)43)7&v_4IKHL*O3(Wj6{o^%Z$hCfCJ!VnU*8eSG!eR_-b!=N!f=}};g za?dK|(_7efNEICMbPV~(J{>w4fyD6}1N1l-Tu1)Xb@T)kkR$Y#>DGw=cx0>13aonq zx?N!@53Z^Prk>*Xa@^U3fk^Iev@uS&wN}OtP}D+M6BERp@+dp%k7fcnBZeFRqZHcp z`+Q9xn_AKZc(Fvd3&xutC0jREMdVj51s|0hgF5Pu$V^km_JkXvmH)(43Of1K^Zbh& z5iu4Qz;_-T0K{XHVeo3aPa-3ppIQjk8=v7Ry9IULi zgTJtEt8L-|(DCpqbdPOOeUZakqyF)I@}uWYxR~+jYG>R_2k4gJT2cuX^*Be&|1OiQ zf=bdMc20qC_qJFimx-s}(z1nF?#_(*?yTH~;D^vV)r9=0FyCK)U&?vjChAgL5$HIT zicJ2?O4=UHJUtH-1^37*_irMWLF$Z^a9xFF7wj>1nOU^Oa(L+SHQ-9DZN+xrWNX>z zc98N(t9+|BX+LewCoSW%#VtvT@r5l`{l4rCC%xkkKZWuQ-0T62xvceF(2e z)UOGW8uw>+#N|NBfjOxKw9R*LRp%qVRf7k*G8|V$50az`0!z#)P2ULs0mrQc1+*(X z86|H42spA;zX{}EX6{t#)H7TyK7+1(_6b~7klRFZ@Hy<`3RgWNN2CICXj8yn1PWN? z!%gjt)6J}nx`T#_i<2x1KA7Dk_*tF{38oO>koxwS??(JV#Pj=;lx4Y8ase$)jf+b9 zaC2@VcP^IjzZX2?`R+!O^YbUcw=J?qgPv4Ld}DsAYaZh(Wffjx)7bWy zUl>np%h_Sk9&(KYKd1B-*rj)j6J_&-X_zi>B`g|%y^+FE?&N%gDSy3XX5Pz{1}XcP zl;XY1L)Bq~@kSpv1^)iW-(5ZRjV{Ybl`>OyoG4wVCWeA22$Gbw(QC~%nILTP;Op+> zcW--}VnlbWI!9U5*lD^7vd!HP8kX-A@#vPp-g*7)_DEJ^7pB@IWuDjD4r6_zkX^^k z+=2}bFM-&#i~B;dZ9(vLy22QU;RUIV1Qin33qSEFM@V=zWG#=sn(jO2vG0o8Wrl-0 zly;w{y$71NtVJghek6~5!Lv0FBjs}GM#rdkq*YVJ`-$HT#0+?LX{=gCRa1#!aicd$ z*}+6qyE~I>rQ3d;?_;Hs&XSqWrn(&QaqH+4GW$d_zIxY6+=I@tdyR&DWaN|ChQTi< zZ@s0N0Re;BvmqMk!=Z{H$6df|@;0{1X|8>|P0;xoZ)Dtyd*o$$wcIknLqGcsbi zf)2ioeIE2`y%oT!|loCI! zsa3P6Ih@ZzWzS;)zx?Dhhc9ZQl<6l16E7dEV<}^P( zWb5BtEMJHj-Bhu@r#7HN$U~mCmQQ#y^~85DTMqWa8D=IF7ba6Vlun`AT&dy+J)$b< zIzoKK=Ifc3pv5shK?FbPTE=H1o$cEpU-qgn$+MB%FBn#}!DfGrRqc+yxA^mU7p@!m zz0JotfvDS*qlo?X;O!}gp4=!lQ`^$@(6%bm10K7p*Y2vXh*k;k#JZ;^CxNs;)|~SvYtkp}{F|mq>3nFzV3P7Y*$?OK;$wykv7Q5bZYd97awt$9^Teu2YhUXptAD;r^ z-zQ?mZwyri>rVYM=AO9ux3A=%XogHXH>PH5+y|J{@=a`mzL4B!jx^P+Y>a{n9%e*mwRh!k;b%%V2c zZ!S%uxIkA|MyaDND$RabMV}gE1)H{jt{Te6RY*nV30p3rO{CtkjJhi6(S%q|=(yrg zOhJGW>P#)7TPnty4DsKazV#i&e|3I^Y*(;D^(b+W7PoM2x$HDqpHZ8`=8_Z#H+n3^ z1o?B?2RC69iPl+lTFV``XIm{fHt~%h83Vq`x$4|6XKz>jE|;@6UT`T6H&346i5r7l z#EfgpoFpu&t2_@f?FR9`7^#qx3$m3fjJ7!F@!G)N=?Z^yc36@6XPL14lhgm&@|yo6 zn0|+YLi3npO&ptAcEgg_Wot_>|DKJ%LJ+`H32MCmDq=3Ps%N{_kSk92&SjT;F=Qa@ zEQr{5!^-TMXzl97`(;|cXCtt}o(K1?=YKJxrL8Y;9W7|RQXM!KI#_a(VZmKTpZS=j zHA6LDPoscGJ+}$2w1J**G96ls7PuR_&UqSx9$_5yyoMcTfp7FaxyXU-O)^NFsFyrR zuRhSDWbcG5*{#LDx7<)lsYr`gv48{Jxda9r7kq`A2vZ?*OzNy=Pj$@WeznsF6Q9wfGRt#&(k&G3_GdJW0@UK?yv z9H%FceeW}xRMb|W%Gumh{Z1{Lheg%@uE=DtuPyl=WXg~8Vpf-%?3c@Rd3^if+nlf= z6&}T6XwLw2&XUEC<=c-+)82EJ<6pI{!bahP%@@QU#wq2Q){m@mK2Fz#1VCdOy&k1` z>0c+beJTq!7Ti1@^fUMwpxx_Ut2}6;oDv6hv(5t;LTitd!Z?*fU)xT}bQ- zN93b~(pq;uhFL3n)P7Di`L>Pqx3!yU#?NIQRk_VG6V< ztDX|LakkmihM~-WEBl#X|2jb`|1FSx%maSQEZ9Tu%kC(wn6_$ZL97D0Kb0(52rbaB ziDIXPZSxF*+D`nCUtRE1E zsc)9GQ_2NQ(nGNYliH*lhSh2zlS9I6b?RDL*(FR>eCYhMsOMIl(Uq3V5zIBuJxu8` zs!Rn|WUx!@RS@@$(6HUJxF_JMGnFk^$dGT%NZ06R2@%&z^G>YNj0oHs8P*$i2Bpp^ z__8m7&UNoC5|2wQ*tO$2u3D7td(6bGty=R^GS_~1Y;{yZ?;U15bIi|vsoaaYlHoC+ zI3G}q?%GSJQPk{w{cPs_J0khUqb6l}JH03M)LtV_xhxV%fy&>hFyt*R!BZzU-5~e16orp^Wi;&L^kSFPszC^clQp@6}S)O`sK_v zwfR1l^EuB7@rZkyprTZo&E8;lsMy!g>$;Gl>WMo2HY=KlVD9*+uh{T_WGlqX&A@vi)@cs%RD z6l%i{&>nh$iqX)kHhwB)pN8K}7-(N6Ir`bu0r!Kaw4#UDe?`Q-jTNw&J@P2lUett} zF5c&j+sS{+WozuAaXS4w?|G-@vPSB+)E2S2ijPxrnVh&cn#`X2GP#e1CzPVGU~F#2 zOly006+pg z?WqZf%fo|MQ@SkF`fD?ndOLbn>=x9&1N(A((5Q#>W2K`ElIv z%@t=H`K|EMI>HoC#)d5j3WCTABHbXVbi>kul$0#s(jd~^-Mz@t-QC^IcOgFa^S)LWvD2jrS`9G0#xsvDH8K{+bZY2-phzE+=B)13>Og#^ z#@qcm4zbV_#8CFZq&S+s_PnEM@kL`*D0hiTKeu9Yv0a{bHpq{nKB5DkPs2X&a=U!i zV0^Tqi&@&RuNDm9T*rO7^)$F%s}booOovygiI0fc-c@xAHvafvtRXdHyg6UJV7<{r z+CC27WyK1Aj`Kt!B$E&ZU0D zv0d{vO3qib?;&T9NUKi;lFFh6QJzyj$P5>!Z&!R`AYXgCF&YLp5DX|^;D8*)?v z4s#U^IeJ+jJk*E#SyG_`Cm6ff2=dI6K(}}1NvTC)LV=tso0o|f`-w_UdP3-?Zqs=} z?_aGww4WFWPn(V1W1)+-%_#uGz7HpWUvJ}lVJGtK9!}+QEHD?W^(4yk>@y3xNRY5Z z8Za%D&7a#$Ja&>jM6#6`Ii?QohHs1hc0g1eJPdD@8|Q+%WOcZ!-oc;J+RkfUFdnQQ z*!>z$b27&u*-m*$%G+yNxHI0R#mSGCR=KfhcDDQ2+_mQ4wpt@0V!i5wRexgSo{6Lk zeu(g-%!k)G-1<)};=@t{PMCCUHXUceggw4Jdi*?Ae$0w@V2z{QDtiWQt3+svtTvTY zChgQ1DnUfBcF-~4J`kuqJ@*-)Hd|Ak3iqaAj*d_Br(A(A%nzb_F)7WjQ;qkU9~vuo zSYyI|$!>6LSV~i_OJztQ_^TOclQZ!He?a5JXem)zWDh>C%u7#}mGs8hVRd}zym)a` zxjNGNj>ObZqUueiAGOAZJWHvwq?M159x1o)@-KrV>^KkroZV8s0grOf$!x@o$hz9n zu}gMekkBFV4;YoWYyMQR>sclcMumN_*G7m`jaSDL*zu*av4IQ--xm>XtvVe+X z(D%IcRfzaaV6c=PZTpmzIWgTtEwWO&+Wv~v(xg5f+(Ea6p@`hW=eysO0VLUr1Ci#b zU5(x9MR<6b-fy~iDxj4eTZM?7*N$)5d}6HW@&{1uU7{YQOTr`6pjPzIj*&g5l1{Pf zQ5xHV9J8dHv}?rT>kt6v&x!U)(*};>$ChR~ZZA$k6Pn{a(XW0`Om%1tRt3iyzsxSiXcOagJNVBXY>aP)z8Y= zUi**Gohly&U_1?Y^MLw*{3>+70;;q)@_5}x(>X&~Hg}^S(Wt_%YU?Txt6|#m%Q6kS zs}hioYtBi8aK7{r%5uq0wP-OU=?|i!V7<31UjU`;3N6o7ZF9S`3O7{!HATFNw(;z+ z#XxJ|^Yf$r@wstpe31}x05*`fnV+U6F^9 zgMZ%!;zUIWJc`GDq`ou3{9^7NQ6-z-7FIEMd?femcqIHi-YaF<*Y0;~nzSt5f6!VT zHU3`fD?cgL2=sz#k|-Lbjx!z7NO4^iqyT3L6y|2!7wC@nygxi9geu1s$E?9C`Mjd0 z;InFwo(s7IAS;+yGw}WgI}Sf(V_Xv1TCa&9u|c=2ltFhzr+ih}^UK;(B6sgo_+t0P(d_U8spBVBKv^%qwJ$yxWw%IXPeSB*|w?xSZH;s0SNji)t ztUz2#+y431I<;fRO2r6-!?bhU6kU2Xh+W^_3?Zgbph9)JY*%X*7{pbTdm*PqwrLp_ z_1Gj<8GChs_uG7>IKvpqMsPi0J`Zi)&X2y9%L2XP3#3<0z2H$*hD6ZXBt97CSZ5=r zdREog7p4$-I@x1%Ag)^f)&`muCm-K2U~j_Y#lQD}hxOupx--J$9R2^eYRUxe#d6ak22)L@v7`feUY=ZTJW zWIHVO$i|z9r#pUY!W)<0(gIxF#lY+p99nPvZN5hFcX^{c9`|!FFORgh4bldws9rD> zHUhR7{k|_9MUHTG9ui>lnyadOV<-$r9;8B!)_6 zPaUCg@v=nz$Q%qNK!5T3c68JrK}CV|!zk?wOQaWexlUHuJZI4q=-=C}-|d{CzNKcj zVLo)J7TsB0$xI>HiUf~z>3U<7vyT-;AR(W<2*hry5aaD5i`!=X!<&>HbH=+2>Oz&A z8d0Qnpvn)Y9Z0Ei9g3sq-dT$%C~>@goMigU9nl!zMt)9dQWtBL+`#}pLNJC5~pv7;dDG1!4?D=fE)e z;1^V2XZJ!>J2Fu~Kb<<@wd zEjY|g4Ok`qbrQm^J33G zsX3AVP0bl8+Xe8SBrMg{trSngTj(&7fF4;ddE2&_FN6G`HA<>|MnT6$uIa^>(LvA} zycQ+4_VLhmv+n1O$}sOU(Wny4q%xX;R8#nSdGmieIl<8O^(1jGYYF-W8reBhpoh^*dI2GnA_wg>HH;u^udcJ zHnp)bE9)Uq3*lTMtH_5123hOTEBaAZ+Yuj|u3U;5$x>dGcog2fcr?-tv}nbL&3%6G zf{Xk%8KsP091nct(iq}M7~eyumPKzN(|ee&@jy*0N#_mE-2T|7_UdAJvB%a3Ugd!A zHY~M})k51X?Mqp(3S!~yrply>Bl5|9=bR7tHzg%BDPk(!YdxOuhC&u1UqT=&5LS0 zwS=qX8MC$9Mp8<*?M9O*Pr>8DZV6AW7*oq=!P~tCt@WjjSQ9ZE*!$L0Lqqd za+mpv)s8wCMz8n;F>VvrE6ub}QdVf~y)sxY*Lx(cLtEV#SJ`0zVQ1QE;kuJ_3gqX8 zXwZXU&jdu``$#IR6#HE{lEx*i0MvR;7Ym}KW&6p@;x<#X6{U}wG?v$`ZP)j^n^d@zy2oAUOp*|!AP3JJ*gnL7gXDRNH!uM46L66vdPwSWyz6b;E z1?n=*lSQxAD23e89x#jsD-@oJpFFU=Ql#3x0y3bBh~hehb$t4!)#)O$6t~xw$M_jF zzi+2D{PloWes6W4*_hpBlXM1-aped!rWFzU!23|FAWB85G-!)5oSncH}OI0u4${7!XyytE&TPmDMEh-6J<{k-6Cf4^Y< z#A@^>}axM028YMZf{1R0VU z8i^uZQ&KX9ntuKqUH^<6h^xC-pv%c=$A6gZUQVfFK{E0ILYe!_HYv;nm)?UUrb6iV zTT^e!_a9DI;deD39nl~2Y~p8z8Ir^_PAa?s>ZOk2G%0Co6uc$O8IoPN$$0Fs^h4*n z7wxFaW1yWxUG-H-?;t+Y-+xO=8hd z4A^|;+oC|af9FwSee}~Sf3{A$`jG&vk}pM|Q9$QJ2sCwaS)#$t4qOD^PhHx)rQ zW>*Rcb)@btExfG1Z~gs&Gl9xgU>4(9RDC%yBYrU!zhU8`J(`NG!J1*t7Sjt&{QKPp zp-?I%S$Nr1FbtQh=#n2Uy9CcHrGBz z5t?3XGZ~-*SZG`4Dwas^y}b&vJZ8<$M4En?WR!?1CEMLPb zy4-;NGQCt|7Oi#2_AvWm3C|V@>DeH4_X-HIJty@(8HU0R-4W@V`ec1^Xm|{K^;riF5UUN;6RVkOuHP zWW2YgV8TbrxC@ibf>ZV}{w(<6Iol{W|Xlr}z6t5j*Mm|#LJ zL8?PJ0vH5|WLK=vum^W#cr{do`nT<|tDf_f6-|ePiJu%R*)OZ_x;RfiYt%D;O}7~? zH1Z3%Wv)YTOo+1JBC>?VED)`8cO-DcZB^|sAlFm4dk_4iVOmxWtUq4DdO!=Zo4kTs zw)VtR-YpQ*`IV-qZAu_n42R`MoiY$O}(- zr!BWAB%3u2JS!7p)tt%BAW`nDFZPk*4&^r4*OCo|0g&<7G5BE}wTyuxD$e+p|_g^eWo>?$| z3e<tVw+S+3hLi;$BK3*<9Jbj68e-*f^t&*sKD#@!SDZ~|=eXZmH!zkhkB!T@~G{H~u zX)kyd=t6G8$oHB9QG=+IkX}S|cFbU}wW?!nP1O{C>^5g%X;7B9&Os*fL+)Uz+RsRm zFT};6dDR&MgY1PP7y;6WwHDj~?e~8KdwM=IZuJ~>dMF;a5ZB}1!u5K0^XnALK*MmO zMXr$2#{-!0Q(vLTyN@#NY#l>`eLbp0g0gxFTBDOM=H6`%G+GUr?o9ZVcS3%^0!OQY ze>&`!PN?Lz)tEMG6!ar?$1SK3>fSwGepgkNZnm_=+TVzzG0g;_BpMh7_kZ&(_HEv~ z$@l+ww!k{|w>}J1=Rw;)}-2-DPLy{%Riv9UL1)WgXoDA~#N-xce(V`#)Eu$kr}w zrF(*A$S;M2C>9xeq zPxy*|sE6=xRuG$qyp1u}u{W!1qKkh;%V&S}soGA4sSa#|Eyb^F^kAfftKtTta2V*+ge=n zDJP@AHY2Y^7>dFzmH@$V2IVL&Y33?edY#x(EldFOTWXE7hw)4U#>x#dczTml99Qisf{< zZw!@=L*u8fbXI24BH4cZfFSsGwq*BvKM5}_4Gai(iY%#C9fE^+_4N40Z3L>yW(63W zc5MmnB&@{Q3aDmgi30p~{_ zO(k;G4Fx~`o_?hkV-OO5C@`=yl~Y7^1>D6~Yz*?9Hz33Lb=%aE53%#n1mExEbt%lBR&?IULJ7pD=EdB z)fBldSG^sA_h$M?8*#rT0)j;!#&$nEuPq-E;Q>!T>Z?Zo=ah6uRR=8a=n;xyBrg`+KqK{v|nMm&QgLVyT05&Tpji; z#S{CJEdC`Hv5qo&h0Pk9tI?4ywqvDUz-G|$biZ%ph-W0;1Tu0*S0kZH!COtq@Aky2MF{2Ilup$gw5 z9W0m6C{YnG7(DC@QDTH|HJ5ciN9Ag6t$eSUfiJdT@>uVx)HtAfFIHE1Iz)d^LXV}g z3#KtsYxJ_I_*#Wh;SiNP)!FB87g#!mIA>spGE}y@oo!uZxK_>_T`f4})?OVXvl0V5 z2ln1T)SjQu&9u5X)?Tvgw7i*8u97fB|6}@DxOzm)0impi;1Sh?A)qazx)p2SsHekP zv?qM`I6i2jrHYl6btf7n@WnY0emWa<7JpYpBvQ=9^Oq+H_XU#L@d5-rh)sC-8m#*H~s> z2EP0n6i+~LZQ9IBYrQIdV=#noi5yd8DzWN`$06~B3LaY`)fm9#(MF2~%CzPG^cu*3_Bs`W+a%{w4Fb3rF3bKZ^J7TOvod zgQd?NTOuLmR~K3XJ%5^3(i|VST`gx%T{idDMiB!TM*ywZ$QBz;r*Avrrx3bs-&>Sj z#7TM1_7UIohlO2g(S}mu@0v|7CHCsGQng8qilLRj@HmbF5rHznO+CJ|)$_+j$aaF1 zCE|_>;yp)hT_KY6tEW8`-$X@Bv#k5;#5jyC_ORYQdA*r%7-UgZ+5FD*=(Pm%`)$}) zw4kz&2W_jtch*`PRV2zZ#Odua{g%phvcHGW3_1Erq0UFuv`#K-owp3HPUo6QegUzB zX^&%|@OMAf?fmfYx0dGVQ7`+AGFhZ5G1Tt3H5Fc`=2uBTSW!1DyI2;kxc0u?%0kbD z_ha?2wJqkKg@{+YQ~9kKqA?=Qxkhzwl&n_Jti(XXdw$@Akz`XpC~J`+Whv)C4I!7% zsU&W(u|~>loXjrh*_mBFk2AW$dEPh8ziuYPcKY+hntyVyQeBu@Qjf(7cMVzHvF*ks zkMoTM2KtVDqu(jeCxy6wNoWqzr4Emmy)NpFJy?@?-ERS~+-k3)>qKU!9nzomEG2%Z zdb^cifnP_FyKR+g6e7)Rswmacm2;VH@|hL;X!OM|MgS_Nj;@vM&}Rw{)wnsh4a2^>J@k|C(tVbi#c0=6c)Nw4*O>>&!}ORin>886m6i5a(5;t- z)d89(Cm0wF@(}hsU1ymuSqG`^&`vajNpH~d_IuC0!bokT6o zyoI{GOpbTpS@;aIo!u`tTDf#Q?Ro(n=mXElYdDcGg_ZMo-?|@_Yqln(Az!S*(&O+m zDQjJ6QnFepa}ujY!k&Wl`xF+*sn}xsd;1C*wWQxIL}T@$kBm0*H}TkLI`t+QNOOg~ z3-&%b2!+G>I<(5Q@{`MAc>DB9)`{>LTwV}Z5xmf1G?&t>GP$1Z5P)$u?K0-+JMHhSk+IU9et|iaO$i2?HGQ}rw90Id1@Ww! z{J=cjnQr}&>^y1tR`Qe3D2`CT3vZaU>YzKj1X2{eC8TYT?+t-gXqB-fIz>>y@C|YG zf@BA7YRk}7_))$6#LM@RtS*5h>+mCzL@)?UWNtD!2Un8zh6)z5f=7CgAxfS=ML$TG z;-*^AwpVqgm7s<~vx9DmBoKNyh)7;G?TrV@1t6{CW^NM(B{4gTZ)NVfMV0lBxtgs# z&ucUM7VrV#;&WI=tlx*o%hfK_ul>SbzDb4(+=3qgrIW?kqo>)9iPaz6nI^Oq+i^<0 z7bu3vk`F=J#4#t`5-g0sxan$fFA*~svB_sQ{JS^AVN#| zf<3ID?L`=o_?v|G{bpjL;=EA_14y0f*mhq}UFTUa$rhCg3r~7^>raTx1FoV8zA`mA zvwrPg*9;j8~d4E!*>@WF?guwC|9!%M)#1-`PxlE z-dj7m!Y(uiyL5MeS(oA7l!s)B`OZhNH3HEpNd`C?Dyy=QDwuEmNj4gN%x~1zj}q2A zx4_kk?Gujxb0TB0xG1fpQ5wj5|GH*vSlRf#@f)(9Rq^_ZC*y7u%i2j~N6Rx#j!y%s z-_)>Ue^(|_iHy$B733$IT;m0bc;1+^YRF%+i}8Qh#qJyQ&~_KNvY&d@b0SySU_3I# zQK4t+!h0`w+-4?P?dtt$aiJzZcmKKUKpcQh#L6N3uxx&(V-dbViRmdsK~MPfAVXJ; z`O`GK%da%un+OqbQ)z~ABR^j29s)V`IZ*IL%KW=|+1Ti~M^>C=;R}-j4cZ1Ysh5r- zDx)qDuC&w<12(?<#D*9jXeQENk)1l#qTeh_1%0&qHuRrJ;RXF*&<0I+G#!=M5%e9F zP5Qgd9qoiw)>dURm2O$&;^$+${a#L5+-jTS6j#z}EBSJnXmT*ZL95`>2@hB=h~DFd zLC7*B%=}d5IUX-eLmC->ws36Q=;&1_tN(~3I&!X9J)8o&tVqkIJUM8&e@R%AsQh_T zrQ%BlZrR=HO5t)FA;!Ki$GM7^yl-iL$63TRyFl)smgDD)=3?&uBaPi7bwtcPMFDM63gE~HB;DUF}t_> zEWw13DQj{v?gsQ-HOcqixaEUDUgYVcSKV%aAV77^Q{&lsm0sM{HKieEgWnT%a? z9XlV0T#atAB9-8r1s3DA95Mw(U;$K|Ho8gf?+`Dcc}x``r*1{73=?@^t*@b7`aPpk zz&Lq8>sz)5|DZhv!9skA?9h`9jlxc~@)b3)bz7~TUPHP|U-!{0qf=ROxDe%?nO6-q zOnWQ|ng>+3);_6d1(f#BhNFSX^-%dOE?jYODH?KHD_pYUhw)4_6UY2agMQ;n@mXEd zbAlc7>)E%jc}(o@gjyQFu+%4gFLmp&OP#}OOh{GkQiKR?@R+5CIdqpf@a@6P_{aIz zdcOES-Q*)N4>}0l^ki_g+;#_9r~t2cO)D^-O+L^v3V z+Z#^w_OOhip6bMU*sZ~)xR%8=@b{pRGL?#m&R3)_Ia@PKIsTS5W5J@b)9>X^j)U|A zJE+X;r4}%d)GpyT^lWFaocBiIS^v4=0x47V>4snvi>qZ3t|~3|Pq|fz$OmSxY#cU> zxt$@HGKq>Ex*~%EAjTRetz*#|SDM^lv=33XW z$3b`6WpofjXI>cH~uGZ{=6oys!1dsg&xF?85%KSOFAp=kI}<;zL71rSDSSBX(DQP)_<(4mvV0 z|FlP?B3sU^lTOWe5KFgzaWKu9X5uJaTBw6*qaynKhqhHOHH{-@plIo24kauzJzHEY zg8NIzzHhf%IWIuINj}|cKgu@myNV}gH5(&@m+s=*2ZQKn8hdcDnKkB@A?cFwt_o;L z1iLRW<-PRe`|7(ZK^#`rPp@YtbHiuCcU)|H)wrrziHLz@7skiGyUXTJGF+@fz_4Iw z$r!gl<2^(SyNQ39Ub4zBuXoI2zUQ9eEcuvbd7|h83L2l!EuAT%`L_;M#6MYyt6Onu z@>NK*M)2cwwnI)6wg-&3c_7 z3z#=KI5{cvFBMdopq|N)ANvw8mcW6xWZoY26FCl15%~DBUJ10x-$fcm1`LUv-7P0c& zkm-^7eKShFnAsKM8`h<}v9ZUW8rmTlVO85tAZ|MKvnSc?tdq|`c|3;JNJb1pO?cun z%S0e4k=aV zqej%N%s!fhFo!TdH|?g|LNnwqJA&NF*4@-MO^(mh#*Cja%G20AsUQbfsgC8waB_bo zOVW3tM9Rm?HF=aPkfY~_e|WJ&SL`4|5G{mRW&A>sN+r0HPG4VWR~6O#yH+|&)v7nJ zYoavkOTrjmxvaUG01z>i!XpVh5mYq>2iDYbaX%}kTY%BC8tS~{gltVItX<)l7n_=- zja!#>3kcLjqHrWrIuH zfT8x%Iha6k*#-}0-E$Vu5vm<#-ItrpcpqgTWR{%F>P8TMPF80%kl)eAMsH;QKtE{; zblS#F@$`YhS7`tv0~B%V5B))ejX_XiXz5!^q|?sUa`MN4$78#_l|NKw;r8I>z1$F~ zO~YBZoDq=Bru;aXcMcxvA3}ks>Uv*m%carh5LEsW3LtTO=hRoAn}aW9yq$$Z2lUQZ zssDHxiZ^>JLu|OAsS#>A%e-0^|J)&k4?HMXzTEs1|IW}D>|lqQK%Y z9{dRj?A>fSh==SaS69JU^31~22*rl4Ha6vn8OINc9&166nh4Ozi9w!n|vnWkU^h#@-WA{q~u5OygL#0}YxNv7v>J z1XmWS-#peHz**PWGB=;YX=%p3w#O{)-#GE0P9#eW`{QXVzp{_ayLY@5GS%XN2;q0P z3<<@~=)*Vz(6ays%5UTWze%o-!2i6z?qtV~*wmid){cAv&lTPMx84lh<@Uiwi=h^^ zH!L8pzs;*PacwiuBe}ZQc0?7j5O}DgQI45yd!lly^+U$jF2&1^viYVK)aROk8Wi_} zX&)nrLyAYX?X>V?=`EkbbeO^k};B{KseV<+*_!OdJsW@DZsjA7i(mdz2uoE?ffuBx@i{S6XujJlieBQ9vy4veZ!VWjNEB|gG@92(ovn!V8u8|NI?HvsrDp{;_uE-%J)>$1ObOQoo-bhXubr?60X{|0$4n0&kd2`0Gz*Ntf=n8h9 z`I*;&Qi9KhkS$@$4qL4(fd06v7E}&|`ms9cx z=PjW{IJF~9`w4&Viy=|_ySNz>j#>qBnZGo@18p;vsJjw$bM(G2RLnQM`XX~j;%}^s z;g78Zyh53SX1s8hL04=1j{nM|rjHAsBi6eiK~>)~wG$q$XV~=>ibS#?Vjj+tn@y~5 zOlDQis~Ic0d+}6@(K%rM(P&{eUKcP{X0ovQF0DWRF_tn-tsIC!K#XC@`>q!G3Ygaf z@sngnx~Of&vfoU5sW$hH0k?7$CJ6k1;kayOKS~e%8h^1a;I9CIrcZg*)OYELze}nY zRMj=L6wJbPMM~YJ6>BBb+3T=YW&xiK^ZE(Mjrr)VKcoL0RNBH$b78Ut9kj8UUubPP zgEO5Bg~6+R!s+pE%3K3QpEwm?;AQbdEY6y_rsLxAQ6A`qSl-G!)&?oH1pRvuYWOn&=nQ} zxNfNsE~dKVyjQf>gy}&4b2x&q;5$KGoVRH9_?|D1YSCj^Wa|Zt|J3-Udot&Qp9(Kp zv80{A(Z0WDZ#%0mjjazBq$97l8D|Mipzgt%L+$uG||{tz2-1RIp!wC*Q(qC z-*L-${OI29i?D?o)nj|J1nsxJQ1mhZ10vnSoSt7uNcYcYV@CzLzp+0!|wCn)K!K##7@CpITLa z9r9njq!_!^TBGxXVKD10ZP`52CJu=1uY2*ARv^&e+aF>aQj;Sr1*xH->|?jG{7W0M zP#I7gXd~jYB9jO2kiZfJZ9189vCV|W?Wa#n2|wjpVF2@Xm;LYCeVbjtwD31gbX(?i zq_|qz%1(4+oI|(NB7gho&BAZdf06ie2HwAsWDrOlW6yV9eQx-;`7LIRCyTpC!%xem~~!~c0JmIN{K=cgjU)V5|x z=jNZqKf5Rs*>}@~Z_iYu=QMkzfy*l`SeN{yq*&lAZ}}VUBO0CyD^|?H!@mKk=t+Ow zpns;6MKF;VNma|Z^UIL#0kTAWJtlK69Lh~S3VnBhZEg)t_aS+;f?n{W3dq<&Px~;1 zaQ>v72TTam0lHy3AT|k_pvEE7X9C0Eg#52(;b#x+ULl+4%m)BJnV^<l4`hk>)Ff7NL>3`)dwAvd`iw_e0;ym6 z6^&Kv9dvi7Es=`cz*^q{V2?_IUf1q!%wP9Euu+u^q>lD;@SqO^kn54IDw3-fy4hC{ z-<6lV9KI(F-@grA$kBXB+=7}Bg!bnSgoWB#&HH^lH`C@mg4m;h0w>B?0Zs#k{JRK? zxx7Ji5XpPy5{DwFG{k@8;fHUcQ0hs*{CZhVfXw0OH0>6PNF}of_9oK>I`sfgDpTD! zv}8WTT%a-sI1miDbhw4!f68xxoTJ3uR!wa;>E3Vru?BS4a3|2$U~jyZg9HGI7x>{$ z=HmrCg`SnrwF6c;@cLN6fN!vH6SV8oSfQ z>K7A9b$3alwR62<{%oo665J)W%I1GYdj39yz5~}_b%`Qmu5Gn#UjQD|TfIBVDj%1x zV)%z3n16ZbMybmX=bI_8s6S%i$(0tuS^VO!9lHH^T73A9?|vvx4CedyhbxI}3|&3R z3>uxc?Z0dM-p&48oE56<_f0{q-nwOv>nO?0qF>v11=vI1B{UfYc!Udb-9F zfRXyYQjgd=SXbyl6;2=KgT0Hf?JdNXYCPA~f0*}uH8@z$8pWEODqx8lzW=`FZiZtp?sU})TAflCGFew>mfzS9#x&R*TT*7}B) z&wwkMP9QKTtYhx2-i=K`Xc$R~7?)^DGFGt)ULz{k%T(Dat%y}9iP*ekISUgusu;xZ z_(R;L&t)@zc+}uO1&YA8%WeTaO!oPAwML8dM~hu9kvXTlVII|_HoD8!3}y4ygKX+-6%x|*{@{a>PQ|^EJ`Bys(++wc3w?ejPeql*;!g3w) z0?pm5gOC>uca{7JL|dHtnjgH|;FZH%o}D*9U3U|M`=<*`+8*sd zhd8Kon14C|9XLXqTgs%Xy*S?1)O{QT4Srow8A*@}Fd^HGTUu9jBfOIMIhs!bYCAOo z>TTBcc-M?ZT2wO>yXN5xJc~fo<-U~305Ss|f4%j7@(cO=+ELvSR-^AiJFCRGGv(1+ zSrfvYftq`!Mf@S>esVUQRZ&)mTguq&S zmB==To}g2=8vaa@d%nk+2~m}c;>lkjshwQYo&WUQ*ETVsx$%5Qkl<}CsiekqTRGT1 zj@)R|Z*LW4hgNn^aS~6?o8tNn0T?&5_*TC#S4gDG<`UwOOlVacmN8&ro1X@)VU&%s zQt-U@um_|69rXx>B#rpUz)_}{nSTh(>giQSs8PEHx;7&)-pSi*5n^M#;aAzOnM(e{ zDPaf8&5+^d>`kI91>>J`N)1uPt9yWGRUa#D#50$ta4!I~Ns{RR+j`!;{q0jRJUpx|qkaBM_$*C@+1!D53|>%LF(GB}2y)9Tnat?( zLBGG2VBLu&hWvov%*D!HO;6*XQ;7a$oJ|Oh@kbgVC5#=I(0=5PyUHx@ny*Q)_g6`I zDOY6RzJZbF;WKNfDnIz_?*!3T(HnZkjgjB0BIc#m@w;)7OF?MO)_t1@ z0GXcH9qBe>HqDPAvygajYl}zx|-Wm41riiw>k~B~{552OH z5X8lat=C88T(Z7_yGA9RL*-oOmf4M6@}FgXlLavRJfSsq@0!)p!hJLf#(zN=T}~v` zgrYIyKcZ;M^bE0$)prkoyMZ$C|NT;A%R~<;!g(UAj&9BC?OPv-;^0dhV}q)v9UZmD z;U3MOUI14{$}es<4E9&Ne(u~SeYCLFXoc>&rly&}Ggu#JQ(Wgiw92c-lC0v-lQeD< z@ypK#L!yZ~81Pc)vvCzI|3=Q zl-p;{+~V2G{yxS8EKC@7vnL#u1#N0f&mtGHe<$XZsZ#n6Z4VS_o82PLjr32k*hA@t zd`vfE`wpY<$FT^Xmo~J3ba_W!8GD-DCu&jIi{w2pKO*XyM9_KMk zQIB%$orI9NRL~`VJ`UQ${I;d@+W|Squ>AqqRgo54^0PweVs~gw9g??HWr<=!Qi>jO z1mxqe%!}Ehl}SoYWOvHR^X9W^+5&F_4c(aL2~Sl-EOXdtlh*v#pZYEPyesFSA(;Un zcgatHDkX@@vbF0i@u8%+&Bge96{xLW*nSV{Wn%fz7ChTPa+RyaPR^qdAE4S%4Dj`|D0Ncq0GLa(Rni{Q6${6gE zQ&WhgA$yND;}xu(&}3CT$uB{^0^Uy&zAIZ!dKgno!i zdQ4uvp=^vE^~eRAvozXlL90rCFDkspl*HTO^67#_)T>0~YqMZ+~|pWWnNCeYSD z%v%UwVnPF#;|v;I5?;tW^F?7AF>AI(8nYj9lpvocY3h=oMnuy3q7~ZwF6i3|B}~<& ztXUhvRzB@`++uDOE1O^3d5Y{kxHiEp=>A%fi5jb(V1o@qXjU=4$aSYGB`)u|XP3jwwSz$a)LX<*oU zQ$UDxYW)A?+LwR|zlBaW#GD*(AHewNgg|b|@mAscOW-mx_^*;C|9d*-SLUjEEM!aN z33Ek5JDs%H@yQrpPo-s4J>=VPLH?i+>^pVDXAlHTs!tuQQ(Dy;>apDny*AT@DkE;nqB!MVS)S8R-=B z1K?Ru2|HwGdN6f4TqqRRg3$nh^}!Oqh46EFgYgDi|La*1)VeefgSf!MbphaY(qhYxmRhv#}q?4?ej5wpWm0`)5iUG zk(vCSe!TLVmOzPdu-G-bJ-dISDjyUJxsGCnIDdbIJlfbL{`pYp^=Trn`U6I(atR>L z4+8a;`y%;|_GTfxa+j~3fL~kfp*c-mG0}umsuAaK0wcMv-9+VNeCH$w&Gu-;tV;71 zn7@CfhA}9giU3~#fr#mk6~+b9M{3`soQ zZ;i;z_u8!lxsz`ASYDwyZS}SSA^&S0`Sw?dHucbLslG#)I0H>f{(Q6gq;XiOZTI~j zJPJ_9Up!}=)K7Drbd=Inz3RKu*fZKhCMnK2EisnwsG9wcz(IhP| zE|Sq|{-@Qv2dPt(|Kw6o`IANwVd zQ$A%EBl;Yu9X`w0-*Dc#FJx3P&d||Ktn4H{{9*z~c4I^YMewnDU)&;0h z1qciu6|0&#`c@ZZG~Y#*oow9uJTG3eN?Vo6+Sojge(4!tysHq{n#K9qNAB}U>wN6U z#Y25bpU@`k2<295gQK+`Z)$gKSzHm?BRgA!qT~8AubI}J`R@#Qvelkw?0T-iyM6*} zhlxsJh6HJ>5-~-jKm~`tE(Pih?y^=>JjorfM5zwawMIEeK_Z)?up%KAIw+&x%^^}M ziS*gG&sLhTQ;6704`q!C9H>&ArQ_4A_uke1rk%9x#nz?x_yG|!sxZiT_d}a3AsJ7L zJqEh;!n8r&BqFO~zUu#*5R2*tm+mawzd~(it^6sdr zG7;|q*EkXS!!OtwZ?WD|G13C^w<7|!Z7o+HUv(TWzT+dHb#LZO`~0Q%c#8UdUD``0=E(~VQV>w>$%N4 zVwJZ&Sz=Guu%dKc2l|AELTlt!A6yUB@>i04ZIA%`lp*iW73DW-10JJySq>IgtINyB z|11^a1}xRi^Z#0^PVgVH{q>AHzLt6&BoKau6pkKziilQ#EM#B-?5O_{-jxkrBY{A! zBj_>@Zw!CuJUEcH{r3+7>yIA#&p&~m&Nm$V-%qA*=K9|cK$L$5`X3)0-;n#ipVZyB fL;w8%BzPsn0P3>*W`^_O`qQ^!(ywzwv_1YG>G{C; literal 0 HcmV?d00001 diff --git a/images/signaloid-external-illustration-signaloid-python-diagram-flat-light-withCR.png b/images/signaloid-external-illustration-signaloid-python-diagram-flat-light-withCR.png new file mode 100644 index 0000000000000000000000000000000000000000..e0bdd2a43868866accdef26c050e04bd600d016b GIT binary patch literal 422435 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

POoN&npmw(Ov+d0x}gDTFCnvIt$+tm2{ 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 of your choice. +3. Set parameter `--path-to-pin ` (or `export PIN_ROOT=`). +3. Build the `inscount0` counter once (the kit does not ship it prebuilt): + + +### Google Sheets (Optional) + +The benchmarking tool can optionally upload the benchmarking results to a Google +Sheet. It only runs when `--write-sheets` is passed and is is disabled by +default. + +To enable it you need: +1. The `sheets` extra installed: + ``` + pip install ".[sheets]" + ``` + A plain `pip install .` does **not** pull in the Sheets stack (`gspread`, + `google-api-python-client`, `oauth2client`). +2. A Google Cloud service-account credentials JSON file, supplied via + `--google-credentials ` or the `GOOGLE_APPLICATION_CREDENTIALS` + environment variable (whichever resolves to an existing file). There is no + built-in default — passing `--write-sheets` without a resolvable credentials + file raises `RuntimeError` at startup (before the pipeline runs). +3. The target Google Drive folder and Sheets template, supplied via the + `UXHW_SHEETS_DRIVE_FOLDER_ID` and `UXHW_SHEETS_TEMPLATE_ID` environment + variables. `--write-sheets` without both set also raises `RuntimeError` at + startup. + +The Sheets template is public: +. +The tab-name and metadata-row constants in `config.py` (`ReportSheetTabs`, +`MetadataRowLabels`) must match this template exactly. When it changes, read the +full tab names from its **ODS** export, never the XLSX export (XLSX truncates +tab names to 31 characters). + + +## Source setup + +1. Clone this repository and install: + ``` + git clone + cd signaloid-python + git submodule update --init --recursive + python -m venv .venv + source .venv/bin/activate + pip install ".[sheets]" + ``` + (`pip install .` gives a fully working benchmarking tool. The `[sheets]` + extra additionally pulls in the optional Sheets stack for the Google Sheets + upload.) + +2. Verify the UxHw SDK is available at its expected path (or note the path for + use with `--path-to-uxhw-sdk`). + +3. Verify that the target application compiles natively: + ``` + cd /path/to/application + make local-build # if using a Makefile + ``` + The compiled executable (`demo-native-mc`) should be located at the + application root. + +## Usage + +Run the tool as a module: +``` +python -m signaloid.benchmarking.automation [OPTIONS] +``` +or via the installed entry point: +``` +signaloid-benchmarking [OPTIONS] +``` + + +### Required Arguments + +| Flag | Description | +|---|---| +| `-u`, `--representation-types` | Uncertain representation types. One or more of: `Athens`, `Atlas`, `Jupiter`, `Europa`. | +| `-s`, `--representation-sizes` | Uncertain representation sizes (integers). E.g., `16 32 64 128 256 512`. | +| `-c`, `--uncertainty-correlation_types` | Correlation tracking types. One or more of: `Disabled`, `Autocorrelation`. | +| `-r`, `--reporting-methods` | Reporting methods. One or more of: `Mean`, `Quantile-95`, `Quantile-99`. | +| `--path-to-application` | Path to the application repository to benchmark. | + +### Optional Arguments + +| Flag | Default | Description | +|---|---|---| +| `--path-to-uxhw-sdk` | `~/project-uxhw-sdk` | Path to the UxHw SDK. | +| `--path-to-pin` | `None` (uses `PIN_ROOT` env) | Path to the Intel PIN kit, exported as `PIN_ROOT` for the timing script's dynamic instruction count. Omit to keep any existing `PIN_ROOT`. A timing run fails clearly if neither `--path-to-pin` nor `PIN_ROOT` is set. | +| `--demo-cli-args` | `""` | Extra command-line arguments passed to both native-MC and UxHw executions. Use this when the demo application requires additional flags (e.g., `--demo-cli-args "--asc-file inputs/blink.asc"`). | +| `--ground-truth-size` | `1` | Number of Monte Carlo samples for ground truth generation. | +| `--ground-truth-type` | `MonteCarlo` | Type of ground truth: `MonteCarlo` or `WeightedSamples`. | +| `--path-to-ground-truth-file` | — | Path to the Python script that generates analytic ground truth. | +| `--distance-type` | `Wasserstein-1` | Distance metric: `Wasserstein-1` or `Wasserstein-2`. | +| `--use-binned-uxhw` / `--no-use-binned-uxhw` | `True` | Use binned Wasserstein-1 for UxHw distances (only valid with `Wasserstein-1`). Pass `--no-use-binned-uxhw` (or `use_binned_uxhw: false` in a config) to disable. | +| `--use-clt` | `False` | Predict the equivalent-MC count from the asymptotic (CLT / Brownian-bridge) distance distribution instead of measuring it via explicit adversary MC. | +| `--adversary-mc-size` | `1` | Number of Monte Carlo samples for the adversary database. | +| `--adversary-max-size-scalar` | `100000` | Maximum adversarial MC size for scalar output variables. | +| `--num-adversaries` | `100` | Number of adversary repetitions. | +| `--max-num-weighted-samples` | `1` | Maximum number of weighted samples for ground truth conversion. | +| `-j`, `--jobs` | `1` | Number of parallel workers for native MC generation. | +| `--config` | `None` | Path to a YAML config file whose keys populate defaults (see [Config Files](#config-files)). | +| `--print-config` | `False` | Resolve defaults + config + CLI, print the effective config and the swept benchmark matrix, then exit without running. | +| `--write-sheets` | `False` | Opt in to the Google Sheets upload step (pipeline step 15). Requires the `sheets` extra, a resolvable credentials file (`--google-credentials` or `GOOGLE_APPLICATION_CREDENTIALS`), and the `UXHW_SHEETS_DRIVE_FOLDER_ID` + `UXHW_SHEETS_TEMPLATE_ID` environment variables. Missing any of these raises `RuntimeError` at startup (before the pipeline runs). | + +For an extensive list of command line arguments for his program please run with the `--help` command. + +### Example + +``` +python -m signaloid.benchmarking.automation \ + --path-to-application ~/Signaloid-Demo-Example \ + --path-to-uxhw-sdk ~/project-uxhw-sdk \ + -u Athens Atlas \ + -s 16 32 64 128 256 512 \ + -c Disabled Autocorrelation \ + -r Mean Quantile-95 Quantile-99 \ + --ground-truth-size 1000000 \ + --adversary-mc-size 1000000 \ + --distance-type Wasserstein-1 \ + -j 4 +``` + +## Config Files + +Due to the large number of command line arguments this tool contains, one can +create a configuration file with the `--config ` flag that stores the +information for data quality metrics like ground truth size etc. With the +presets in `configs/`, a standard run drops to: + +``` +signaloid-benchmarking --config configs/full-sweep.yaml \ + --path-to-application ~/Signaloid-Demo-Example +``` + +**Precedence** is `built-in defaults < config file < explicit CLI flags`. Config +keys are the argparse `dest` names (e.g. `representation_types`, `correlations`, +`use_binned_uxhw`), not the CLI flag spellings. Unknown keys (typos) are +rejected with a clear error rather than silently ignored. + +Because the four sweep args (`representation_types`, `representation_sizes`, +`correlations`, `reporting_methods`) can be supplied via the file, they are no +longer `required` on the CLI — but at least one source must provide each, or the +run fails fast with a `ValueError`. + +You can find two presets for the configuration YAML in `configs/`: + +- `configs/quick.yaml` — a small, fast matrix for smoke-tests. +- `configs/full-sweep.yaml` — the full experiment matrix reused across demos. + +Sample schema (every key is optional and omitted keys fall back to defaults): + +```yaml +# Benchmark matrix +representation_types: [Athens, Jupiter] +representation_sizes: [64, 128, 256, 512] +correlations: [Disabled, Autocorrelation] +reporting_methods: [Mean, Quantile-95] +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: false +n_adversaries: 100 # dest name. The CLI flag is --num-adversaries +# plotting controls (all default off) +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false +``` + +Use `--print-config` to resolve defaults + file + CLI, print the effective +configuration and the benchmark matrix that would be swept, then exit without +running: + +``` +python -m signaloid.benchmarking.automation --config configs/quick.yaml \ + --path-to-application ~/Signaloid-Demo-Example --print-config +``` + +### Plotting controls + +Plotting is off by default, so a plain run no longer pays the plotting cost. +Three independent toggles enable the three plot calls in the equivalent-MC stage +and each is a `BooleanOptionalAction`, so it accepts both the positive and +`--no-...` forms on the CLI and the matching boolean key in a config file: + +- `--plot-distance-vs-asymptotic` — empirical equivalent-MC distance + distribution vs its asymptotic prediction (Brownian-bridge / half-normal). +- `--plot-adversary-distances` — adversary-distance plots (empty under + `--use-clt`). +- `--plot-representative-mc` — a representative equivalent-MC run matching the + UxHw–ground-truth distance (distribution outputs only). + +## Pipeline Overview + +The tool executes the following steps in order (shown as `[Step N/15]` in the +output): + +1. **Machine Info** — Detects CPU model and core count via `lscpu`. +2. **Application Info** — Reads `signaloid.yaml`, resolves benchmarking + variables, compiles the native MC executable, and sets up output paths. +3. **Ground Truth Database** — Generates the reference distribution database + using (in priority order): analytic formula, then native MC execution. A + native build (a `Makefile` `local-build` target or `src/config.mk` with + `SOURCES`) or `--has-analytic-ground-truth` is required. +4. **UxHw Tracing Database** — Runs the application through the UxHw tracing + pipeline to produce UxHw distributional outputs for each representation + type/size/correlation combination. If a tracing database already exists, the + script will prompt before overwriting. The tracing build uses `-O0` (so the + `addDistValueTrace` `file:line` directives resolve against unoptimised debug + info); to guard against optimisation changing the traced values, each config + is also built at `-O2` and its Ux strings are checked (byte-for-byte) against + the `-O0` ones. Any difference is reported as a warning and does not stop the + run. If a config's `-O2` build or run fails, that config is skipped and + reported as failing in an `-O2 ux-string verification FAILED` summary (the + run still continues). Set `TRACING_VERIFY_OPTFLAGS` to compare against a + different level. +5. **Asymptotic Distance Distributions** — Computes asymptotic distance + distributions from the tracing data. +6. **UxHw Distances** — Computes Wasserstein distances between each UxHw + configuration and the ground truth. +7. **EMCC Predictions** — Predicts equivalent Monte Carlo sample counts from + asymptotic distances. +8. **Adversary Database** — Generates adversarial Monte Carlo databases for EMCC + validation. +9. **Equivalent Monte Carlo** — Computes the true EMCC by comparing adversary MC + distances to UxHw distances. +10. **UxHw Timings** — Measures execution time and dynamic instruction counts + for each UxHw configuration. +11. **Native MC Timings** — Measures execution time for native MC at each EMCC + sample size. +12. **Load Measurements** — Loads all measurement data from the timing file. +13. **Load Timing Data** — Loads equivalent MC timing data from native + executions. +14. **Compute Results** — Merges all timing and EMCC data into final dataframes. +15. **Write Outputs** — Writes results to Markdown unconditionally, and to a + Google Sheets spreadsheet when `--write-sheets` is passed. The Sheets upload + is opt-in (default behaviour prints a skip message). When opted in: per + variable, a "best-of triptych" of three plots — the adversary MC at the + selected EMCC count, the UxHw distribution for the best UxHw configuration, + and the ground truth — is uploaded to Google Drive and linked from the + sheet. The "best" configuration is the one with the maximum speedup against + the native MC baseline, and the selection is printed to the terminal during + upload. All other generated plots remain in `results/plots/` and are not + uploaded. Credentials come from `--google-credentials` or + `GOOGLE_APPLICATION_CREDENTIALS`, and the target Drive folder + Sheets + template from `UXHW_SHEETS_DRIVE_FOLDER_ID` + `UXHW_SHEETS_TEMPLATE_ID`. + Passing `--write-sheets` without all of these resolving raises + `RuntimeError` at startup, before step 1 runs. + +## Application Configuration + +The target application must contain a `signaloid.yaml` file at its root. The +file defines which variables to trace and benchmark. + +### Required Fields + +```yaml +TraceVariables: + - Expression: "variableName" + File: "main.c" + LineNumber: "42" + +BenchmarkingAllOutputs: + - CommandLineArguments: "-S 2" + +BenchmarkingVariables: + - VariableName: "variableName" + VariableDescription: "Description of the output" + OutputObject: "Distribution" # or "Scalar" + CommandLineArguments: "-S 0" +``` + +- **`TraceVariables`**: Lists the C expressions to trace, with source file and + line number. +- **`BenchmarkingAllOutputs`**: The command-line arguments that produce all + outputs simultaneously (used for tracing runs). +- **`BenchmarkingVariables`**: Maps each traced variable to a human-readable + description, its output type (`Distribution` or `Scalar`), and the + command-line arguments to isolate that variable. + +Array expressions such as `outputVariables[0:5]` in `TraceVariables` are +automatically expanded into individual entries (`outputVariables[0]`, +`outputVariables[1]`, ..., `outputVariables[5]`). + +### Source convention +The tool benchmarks one output at a time, so by convention `main.c`: +- writes each output into an array (e.g. `double outputVariables[N];`), and +- accepts a `-S ` command-line argument selecting which output to + compute/emit. + +`signaloid.yaml` ties these together: `TraceVariables` points +`File`/`LineNumber`/`Expression` at the array (`outputVariables[0:N]`), and each +`BenchmarkingVariables` entry pairs a `VariableName` (`outputVariables[i]`) with +the `CommandLineArguments` (`-S i`) that isolate it. The program should also +print a `CPU time used: seconds` line to stdout — the timing layer +parses it for the reference timing. + +## Bash Timing Scripts + +The `src/signaloid/benchmarking/benchmark_timing/` directory contains the +underlying bash scripts used by the pipeline: + +- **`get-timings.sh`**: The main timing and compilation driver. It is sourced + (not executed) by the Python tool with pre-set environment variables. It + handles UxHw compilation, native MC benchmarking, UxHw tracing, and timing + collection (the UxHw cores are compiled and timed via UxHw. The dynamic + instruction count comes from Intel PIN). Compilation warnings are redirected + to log files (`uxhw-build.log` and `native-mc-build.log` in the `logs/` + directory). Source files are discovered recursively (excluding `build/` + directories), and C++ files (`.cc`, `.cpp`) are automatically included when + present. +- **`get-timing-template.sh`**: A standalone template showing the required + environment variables and how to source `get-timings.sh` directly from the + command line. + +The UxHw `.m` config files that drive the reference / Monte Carlo / tracing +passes are generated inline by the `write_emulator_config` shell function +(consumed by the UxHw `opt` transform via `--m-config-file`). There is no +separate template file. + +## Output Files + +Output files are organized into `results/` and `logs/` directories in the +current working directory: + +``` +results/ # Final outputs +├── timings.json +├── *.db (ground truth, adversary, tracing) +├── *.csv (output_data, uxhw_distances, asymptotic_distances) +├── *.md (markdown reports) +└── plots/ +``` + +| File | Description | +|---|---| +| `output_data.csv` | Full EMCC results with distances, timing data, and speedups for every variable and configuration. | +| `uxhw_distances.csv` | UxHw distance data (Wasserstein distances between each configuration and ground truth). | +| `asymptotic_distances.csv` | Asymptotic distance distribution parameters. | +| `.md` | Per-variable Markdown summary tables. | +| `--timings.json` | Canonical timing output as a single JSON document (in `results/`). Session-invariant fields (application identity, SDK versions, target UxHw repetition count) sit at the top level. A `runs` array holds per-run records, each with its own `timestamp`, `commandLineArguments`, `commandLineArgumentsHash`, and `measurements` array. The schema is defined by `TimingFormat` in `signaloid/benchmarking/config.py`. | +| `--timings.intermediate` | Transient flat file the bash timing script writes line-by-line. Python parses it into the JSON above and deletes it on success. It is kept on failure for debugging. | +| `*.db` | SQLite databases for ground truth, adversary, and tracing data (in `/src/`). | + +Example `*-timings.json` document: + +```json +{ + "applicationName": "call-option", + "applicationVersion": "a1b2c3d", + "uxhwSdkVersion": "4.1.2", + "uxhwTargetRepetitions": "20", + "runs": [ + { + "timestamp": "2026-04-15T14:22:10Z", + "commandLineArguments": "-T 100 --strike 110", + "commandLineArgumentsHash": "abc123", + "measurements": [ + { + "config": "Athens-16-Autocorrelation", + "time": 1.23, + "dbTime": 4.56, + "e2eTime": 7.89, + "dbDynInstCount": 0.0, + "pinDynInstCount": 2000.0 + } + ] + } + ] +} +``` + +Notes on the schema: + +- Missing numeric fields (e.g. `dbTime` on native runs) are serialised as + `null`. +- `dbDynInstCount` for UxHw rows is `0`. +- `uxhwTargetRepetitions` is the UxHw-loop target at session start. The actual + rep count used per measurement can differ — native-MC rows use + `NATIVE_MC_REPETITION` (dynamically computed per precision), and the UxHw + `REPETITION` is rescaled per-testcase from a warmup run inside + `run_uxhw_benchmarks`. Treat this field as the configured session target + rather than a per-run ground truth. + +### Error Logs + +On failure, the pipeline writes a detailed traceback to a timestamped log file: +``` +logs/benchmarking_automation_error_.log +``` +This file contains the full Python traceback and the command-line arguments +used. Additionally: +- Bash timing script stderr is captured to `logs/timing_script_stderr.log`. +- UxHw compilation warnings are logged to `logs/uxhw-build.log`. +- Native MC compilation warnings are logged to `logs/native-mc-build.log`. +- Per-execution errors can be found in `logs/exec.stderr` and `logs/opt.err`. + +## Common Issues + +### Native Compilation Fails +- **Missing GSL**: Ensure `libgsl-dev` (or equivalent) is installed and that + `/opt/local/lib` and `/opt/local/include` are valid paths on your system, or + that the application's `Makefile` handles library paths. +- **No compilation method found**: The application needs one of: a root-level + `Makefile` with a `local-build` target, or a `src/config.mk` with `SOURCES` + defined. +- **Executable not found**: The native executable (`demo-native-mc`) should be + placed at the application root by `make local-build` or built from + `config.mk`. +- **Fallback**: If the timing script's ad-hoc compilation fails, it will look + for a pre-built `demo-native-mc` in both `src/` and the application root + (where `make local-build` places it). + +### Timing Script Fails +- The error message will include captured stderr and point to `exec.stderr` and + `opt.err` in the `logs/` directory. Check these files for UxHw compilation or + runtime errors. +- If the tracing database already exists, the script will prompt `Do you want to + continue execution? (y/n)`. Answer `y` to overwrite or `n` to abort. + +### Timing Data Mismatch +- The intermediate timing file is automatically truncated at the start of each + pipeline run, so stale data from previous runs should not cause issues. If + problems persist, manually delete the `--timings.intermediate` + and `--timings.json` files in the `results/` directory and + re-run. + +### EMCC Data Not Found +- If the pipeline is interrupted after tracing but before EMCC computation, + re-running will attempt to load `results/output_data.csv`. If this file does + not exist or is incomplete, delete it and re-run from the beginning. + +### Analytic Ground Truth +- When using `--has-analytic-ground-truth`, you must also pass + `--ground-truth-type WeightedSamples` and provide + `--path-to-ground-truth-file`. The ground truth script must accept ` + ` as positional arguments. + +### Parallelism +- The `-j` flag controls parallelism for native MC sample generation only. If + `-j` exceeds the number of detected CPU cores, a warning is printed. UxHw + executions are always sequential. + +### Binned Distance Computation +- `--use-binned-uxhw` is only compatible with `--distance-type Wasserstein-1`. + The tool will error if you try to combine it with `Wasserstein-2`. + +### Input Files Not Found +- Input files from the application's `inputs/` directory are symlinked into the + working directory. Files that already exist in the target directory (e.g., + `README.md`) are skipped with a warning to avoid overwriting repository files. + If the demo application expects files at a relative path like + `inputs/filename`, ensure the demo's default paths do not include the + `inputs/` prefix, since the files are symlinked flat into the working + directory. diff --git a/src/signaloid/benchmarking/automation/__init__.py b/src/signaloid/benchmarking/automation/__init__.py new file mode 100644 index 0000000..ea9d135 --- /dev/null +++ b/src/signaloid/benchmarking/automation/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +__all__: list[str] = [] diff --git a/src/signaloid/benchmarking/automation/__main__.py b/src/signaloid/benchmarking/automation/__main__.py new file mode 100644 index 0000000..02ef76f --- /dev/null +++ b/src/signaloid/benchmarking/automation/__main__.py @@ -0,0 +1,24 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from signaloid.benchmarking.automation.benchmark_application import main + +if __name__ == "__main__": + main() diff --git a/src/signaloid/benchmarking/automation/analysis.py b/src/signaloid/benchmarking/automation/analysis.py new file mode 100644 index 0000000..1b70ffa --- /dev/null +++ b/src/signaloid/benchmarking/automation/analysis.py @@ -0,0 +1,708 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import csv +import math +import warnings +from typing import Callable + +import numpy as np +import pandas as pd +from scipy.stats import halfnorm # type: ignore + +from signaloid.benchmarking.automation.benchmarking_utils import ( + compute_emcc_prediction, + write_uxhw_distance_file, +) +from signaloid.benchmarking.automation.measurement_loader import ( + load_asymptotic_dist, + load_uxhw_distances, +) +from signaloid.benchmarking.automation.sample_generator import ( + generate_scalar_samples, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, + UxhwDistanceRecord, +) +from signaloid.benchmarking.config import ( + AsymptoticDistanceDistribution, + BenchmarkingVariables, + DistanceMetrics, + EquivMC, + Measurements, + ReportingMethods, + ReportingNumbers, + RepresentationTypes, + VariableTypes, +) +from signaloid.benchmarking.distribution_helpers.representation_health import ( + _representation_blow_up_reason, +) +from signaloid.benchmarking.equivalent_mc.equivalent_mc_main import ( + load_data_and_compute_equivalent_mc, +) +from signaloid.benchmarking.equivalent_mc.equivalent_mc_utils import ( + _compute_asymptotic_distribution_brownian_bridge, + _compute_asymptotic_distribution_scalar_empirical, +) +from signaloid.benchmarking.equivalent_mc.load import ( + _load_ground_truth, + _load_uxhw_distributions, +) +from signaloid.distributional_distance.wasserstein import ( + wasserstein_1_uxhw_wrapper, + wasserstein_2_uxhw_wrapper, +) +from signaloid.distributional_distance.binned_wasserstein import ( + binned_wasserstein_1_uxhw_wrapper, +) +from signaloid.distributional_distance.scalar import relative_error_uxhw_wrapper + + +def _get_equiv_mc_list( + *, + benchmarking_variables: list[BenchmarkingVariable], +) -> None: + """ + Populate ``equiv_mc_list`` from each variable's ``emcc_data``. + + Uses the measured ``EMCC`` values, falling back to the predicted + ``EMCC_PREDICTED`` values when no measured ones are present. + + Args: + benchmarking_variables: Variables whose ``equiv_mc_list`` is populated. + """ + for variable in benchmarking_variables: + variable.emcc_results.equiv_mc_list = sorted( + set( + [ + d[EquivMC.EMCC] + for d in variable.emcc_results.emcc_data + if EquivMC.EMCC in d + ] + ) + ) + if len(variable.emcc_results.equiv_mc_list) == 0: + variable.emcc_results.equiv_mc_list = sorted( + set( + [ + d[EquivMC.EMCC_PREDICTED] + for d in variable.emcc_results.emcc_data + if EquivMC.EMCC_PREDICTED in d + ] + ) + ) + + +def load_emcc_data( + *, + benchmarking_variables: list[BenchmarkingVariable], + output_data_file: str, +) -> None: + """ + Load EMCC data into each variable's `emcc_results.emcc_data` list + if the list has not already been populated. + + Args: + benchmarking_variables: Variables to populate. + output_data_file: CSV file written by + :func:`compute_emcc_predictions` that backs the EMCC data + when the in-memory list is empty. + """ + if not benchmarking_variables: + return + + if not benchmarking_variables[0].emcc_results.emcc_data: + print( + f"In-memory EMCC data not found. " + f"Loading EMCC data from {output_data_file}" + ) + + try: + # Load and group data by variable name + emcc_df = pd.read_csv(output_data_file) + emcc_dict = emcc_df.to_dict("records") + + # Create a lookup dict for faster access + data_by_variable: dict = {} + for record in emcc_dict: + var_name = record[BenchmarkingVariables.VARIABLE_DESCRIPTION] + data_by_variable.setdefault(var_name, []).append(record) + + # Populate each variable's emcc_results.emcc_data + for variable in benchmarking_variables: + variable.emcc_results.emcc_data.extend( + data_by_variable.get(variable.description, []) + ) + + except FileNotFoundError: + print(f"Error: File not found at {output_data_file}") + print("Cannot continue without EMCC data. Terminating.") + raise + + _get_equiv_mc_list(benchmarking_variables=benchmarking_variables) + + +def compute_speedups( + *, + benchmarking_variables: list[BenchmarkingVariable], +) -> None: + """ + Compute the ``SPEEDUP`` field for each EMCC record. + + Sets ``SPEEDUP`` to native-time / database-time where both are available and + the database time is non-zero, else ``None``. + + Args: + benchmarking_variables: Variables whose ``EMCC`` records are updated. + """ + # Calculate speedup for each record in each variable + for variable in benchmarking_variables: + for dic in variable.emcc_results.emcc_data: + native_time = dic.get(Measurements.NATIVE_IN_APP_TIME) + db_time = dic.get(Measurements.DB_TIME) + + # Calculate speedup if both values exist and db_time is non-zero + if native_time is not None and db_time is not None and db_time != 0: + dic[Measurements.SPEEDUP] = native_time / db_time + else: + dic[Measurements.SPEEDUP] = None + + +def compute_uxhw_distances( + *, + benchmarking_variables: list[BenchmarkingVariable], + distance_type: str, + ground_truth_db_path: str, + ground_truth_type: str, + tracing_db_path: str, + representation_types: list[str], + representation_sizes: list[int], + uxhw_distance_file: str, +) -> None: + """ + Compute UxHw distances for every variable / representation pair. + + Loads each variable's ground-truth and UxHw distributions + from the timing/tracing databases and records distance metrics on + the corresponding :class:`UxhwDistanceRecord` entries. + + Args: + benchmarking_variables: Variables to process. + distance_type: Which distance metric to use (e.g. + ``DistanceMetrics.WASSERSTEIN_1``). + ground_truth_db_path: SQLite database containing ground-truth + samples. + ground_truth_type: Representation type string (e.g. + ``RepresentationTypes.MONTE_CARLO``). + tracing_db_path: SQLite database containing the traced UxHw + distributions. + representation_types: UxHw UR-type strings to evaluate. + representation_sizes: UxHw UR-size integers to evaluate. + uxhw_distance_file: CSV file to write the resulting distance + records to. + """ + # binned_uxhw_distance_fn stays `None` for the W2 / Binned-W1 metrics so + # the per-variable `is not None` check below falls through correctly. + uxhw_distance_fn: Callable | None = None + binned_uxhw_distance_fn: Callable | None = None + if distance_type == DistanceMetrics.WASSERSTEIN_1: + uxhw_distance_fn = wasserstein_1_uxhw_wrapper + binned_uxhw_distance_fn = binned_wasserstein_1_uxhw_wrapper + elif distance_type == DistanceMetrics.WASSERSTEIN_2: + uxhw_distance_fn = wasserstein_2_uxhw_wrapper + elif distance_type == DistanceMetrics.BINNED_WASSERSTEIN_1: + uxhw_distance_fn = binned_wasserstein_1_uxhw_wrapper + else: + raise RuntimeError( + f"Invalid distance type configuration " + f"{distance_type}. Check py for valid options" + ) + + for variable in benchmarking_variables: + ground_truth = _load_ground_truth( + db_path=ground_truth_db_path, + table=ground_truth_type, + target_expression=variable.name, + expression_type=variable.type, + monte_carlo=ground_truth_type == RepresentationTypes.MONTE_CARLO, + ) + + # Get all distributions from databases + variable.emcc_results.emcc_data = [] + uxhw_data: list[TaggedDistributionalValue] = _load_uxhw_distributions( + db_path=tracing_db_path, + tables=[EquivMC.TRACING_TABLE], + target_expr=variable.name, + ur_types=representation_types, + ur_sizes=representation_sizes, + ) + + for uxhw_conf in uxhw_data: + + # Marks a degraded representation. If not None, the distances are + # set to `inf` and the row is reported as "blow-up / excluded". + # It's not dropped (see BenchmarkingVariables.BLOW_UP_REASON). + blow_up_reason: str | None = None + if variable.type == VariableTypes.DISTRIBUTION: + assert uxhw_distance_fn is not None + # Traced representations may carry benign special-value + # (NaN / +inf / -inf) Dirac deltas at exactly zero mass. Dropping + # them lets the finite-only distance validator accept the + # otherwise-valid distribution. + uxhw_conf.dv.drop_zero_mass_positions() + + blow_up_reason = _representation_blow_up_reason(uxhw_conf.dv) + if blow_up_reason is not None: + # Warn and set both distances to +inf + # (reported as the worst config) rather than masking it with + # a silent 0 or crashing the pipeline. + warnings.warn( + f"Representation blow-up for variable " + f"{variable.description!r} config {uxhw_conf!r}: " + f"{blow_up_reason}. Setting UxHw distances to inf." + ) + uxhw_distance = float("inf") + binned_uxhw_distance = float("inf") + else: + uxhw_distance = uxhw_distance_fn( + uxhw_conf.dv, + ground_truth.dv, + ) + if binned_uxhw_distance_fn is not None: + try: + binned_uxhw_distance = binned_uxhw_distance_fn( + uxhw_conf.dv, + ground_truth.dv, + ) + except Exception as e: + # Treat failed binned-distance computation + # as inf, matching the blow-up path above. + warnings.warn( + f"Binned UxHw distance failed for config " + f"{uxhw_conf!r}: {e}. Setting it to inf." + ) + binned_uxhw_distance = float("inf") + else: + binned_uxhw_distance = uxhw_distance + else: + # Scalar branch: a large finite scalar is legitimate, so only + # the non-finite check applies (check_magnitude=False). Guard it + # because a non-finite scalar would make + # relative_error_uxhw_wrapper's validator raise. + blow_up_reason = _representation_blow_up_reason( + uxhw_conf.dv, check_magnitude=False + ) + if blow_up_reason is not None: + warnings.warn( + f"Representation blow-up for scalar variable " + f"{variable.description!r} config {uxhw_conf!r}: " + f"{blow_up_reason}. Setting UxHw distances to inf." + ) + uxhw_distance = float("inf") + binned_uxhw_distance = float("inf") + else: + uxhw_distance = ( + relative_error_uxhw_wrapper(uxhw_conf.dv, ground_truth.dv) + * EquivMC.BASIS_POINT_CONVERSION_FACTOR + ) + binned_uxhw_distance = uxhw_distance + + variable.uxhw_distances.records.append( + UxhwDistanceRecord( + uxhw_conf=uxhw_conf, + uxhw_distance=uxhw_distance, + uxhw_binned_distance=binned_uxhw_distance, + blow_up_reason=blow_up_reason, + ) + ) + + write_uxhw_distance_file(uxhw_distance_file, benchmarking_variables) + + +def compute_emcc_predictions( + *, + benchmarking_variables: list[BenchmarkingVariable], + use_binned_uxhw: bool, + reporting_methods: list[str], + output_data_file: str, + uxhw_distance_file: str, + asymptotic_dist_file: str, + distance_type: str, +) -> None: + """ + Compute EMCC (equivalent Monte Carlo count) predictions per + reporting method and persist them to ``output_data_file``. + + Args: + benchmarking_variables: Variables to process. + use_binned_uxhw: When ``True``, prefer the binned + Wasserstein-1 distance and fall back to the plain + Wasserstein distance when the binned variant is missing + or invalid (zero / infinite). + reporting_methods: Reporting-method identifiers + (e.g. mean, quantile-95). + output_data_file: CSV file to write the EMCC predictions to. + uxhw_distance_file: CSV file produced by + :func:`compute_uxhw_distances`. + asymptotic_dist_file: CSV file produced by + :func:`generate_asymptotic_distance_distributions`. + distance_type: Distance metric used (written to the output + CSV for traceability). + """ + load_uxhw_distances( + benchmarking_variables=benchmarking_variables, + uxhw_distance_file=uxhw_distance_file, + asymptotic_dist_file=asymptotic_dist_file, + ) + for variable in benchmarking_variables: + new_emcc_data: list[dict[str, object]] = [] + for record in variable.uxhw_distances.records: + # Use binned when asked and fall back to the plain Wasserstein + # distance for missing/invalid values. + if ( + use_binned_uxhw + and record.uxhw_binned_distance is not None + and record.uxhw_binned_distance != 0 + and not math.isinf(record.uxhw_binned_distance) + ): + distance = record.uxhw_binned_distance + else: + distance = record.uxhw_distance + + # Create a new dictionary for each reporting method + for method in reporting_methods: + method_dic: dict[str, object] = { + BenchmarkingVariables.UXHW_CONF: record.uxhw_conf, + BenchmarkingVariables.UXHW_DISTANCE: record.uxhw_distance, + BenchmarkingVariables.UXHW_BINNED_DISTANCE: ( + record.uxhw_binned_distance + ), + # Annotate the blow-up marker into the in-memory emcc_data + # so the report can exclude degraded rows. This is in-memory + # only and not written to output_data.csv (see + # BenchmarkingVariables.BLOW_UP_REASON). UXHW_CONF is kept + # so the row survives the timing join and only its EMCC. + # contribution is excluded. + BenchmarkingVariables.BLOW_UP_REASON: record.blow_up_reason, + } + method_dic[EquivMC.REPORTING_METHOD] = method + method_dic[EquivMC.EMCC_PREDICTED] = compute_emcc_prediction( + variable.asymptotic_distribution.value_for(method), + distance, + ) + new_emcc_data.append(method_dic) + + # Replace the original list with the expanded one + variable.emcc_results.emcc_data = new_emcc_data + + # Write predictions to a file + with open(output_data_file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + BenchmarkingVariables.VARIABLE_DESCRIPTION, + BenchmarkingVariables.UXHW_CONF, + BenchmarkingVariables.VARIABLE_TYPE, + EquivMC.DISTANCE_TYPE, + EquivMC.REPORTING_METHOD, + BenchmarkingVariables.UXHW_DISTANCE, + BenchmarkingVariables.UXHW_BINNED_DISTANCE, + EquivMC.EMCC_PREDICTED, + ] + ) + for variable in benchmarking_variables: + for emcc_dic in variable.emcc_results.emcc_data: + writer.writerow( + [ + variable.description, + repr(emcc_dic[BenchmarkingVariables.UXHW_CONF]), + variable.type, + distance_type, + emcc_dic[EquivMC.REPORTING_METHOD], + emcc_dic[BenchmarkingVariables.UXHW_DISTANCE], + emcc_dic[BenchmarkingVariables.UXHW_BINNED_DISTANCE], + emcc_dic[EquivMC.EMCC_PREDICTED], + ] + ) + + +def generate_asymptotic_distance_distributions( + *, + benchmarking_variables: list[BenchmarkingVariable], + ground_truth_db_path: str, + ground_truth_type: str, + distance_type: str, + asymptotic_dist_file: str, + n_processors: int, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, +) -> None: + """ + Compute the asymptotic distance distribution per variable. + + If a Central Limit Theorem exists for the variable with respect to + the distance metric, computes the limiting distribution of + ``sqrt(N) * distance(MC(N), GT)``, where ``GT`` is the ground + truth and ``MC(N)`` is an MC simulation with ``N`` samples. + + For distributions and the Wasserstein-1 metric, uses the + Brownian-bridge identity + ``sqrt(N) * W1(MC(N), GT) ~ ∫ |B(t)| dQ(t)``. + + For scalars, the distance from the ground truth for quantities + such as the mean and quantile is normally distributed, so + ``sqrt(N) * | MC(N) - GT | ~ HalfNormal(σ)``. + + Args: + benchmarking_variables: Variables to process. + ground_truth_db_path: SQLite database containing ground-truth + samples. + ground_truth_type: Representation type string used to select + the appropriate ground-truth loader. + distance_type: Distance metric (passed to the Brownian-bridge + asymptotic computation). + asymptotic_dist_file: CSV file to write the per-variable + asymptotic statistics to. + n_processors: Number of parallel worker processes used by + :func:`sample_generator.generate_scalar_samples` to draw + scalar Monte Carlo samples. + path_to_application: Root path of the application source tree + (forwarded to ``generate_scalar_samples``). + native_executable_name: Filename of the compiled native + binary (forwarded to ``generate_scalar_samples``). + native_executable_dir: Directory containing the native binary + (forwarded to ``generate_scalar_samples``). + demo_cli_args: Per-application command-line argument prefix + (forwarded to ``generate_scalar_samples``). + """ + csv_rows = [] + + for variable in benchmarking_variables: + # Load ground truth + monte_carlo = ground_truth_type == RepresentationTypes.MONTE_CARLO + ground_truth = _load_ground_truth( + db_path=ground_truth_db_path, + table=ground_truth_type, + target_expression=variable.name, + expression_type=variable.type, + monte_carlo=monte_carlo, + ) + is_normal = False + std = None + + if variable.type == VariableTypes.DISTRIBUTION: + # Compute the asymptotic distance using the Brownian bridge + asymptotic_dist = _compute_asymptotic_distribution_brownian_bridge( + ground_truth, + num_points=1000, + num_samples=1000, + distance_type=distance_type, + ) + # Use inverse_cdf so reported quantiles interpolate. + # DistributionalValue.quantile is a non-interpolating step lookup + # and would shift the reported quantiles. + treat_as_samples = ( + getattr(asymptotic_dist, "representation_type", None) + == RepresentationTypes.SAMPLES + ) + asymptotic_mean = asymptotic_dist.mean + if asymptotic_mean is not None: + mean_quantile = asymptotic_dist.cdf( + asymptotic_mean, treat_as_samples=treat_as_samples + ) + else: + mean_quantile = None + quantile_95 = asymptotic_dist.inverse_cdf( + ReportingNumbers.QUANTILE_95, treat_as_samples=treat_as_samples + ) + quantile_99 = asymptotic_dist.inverse_cdf( + ReportingNumbers.QUANTILE_99, treat_as_samples=treat_as_samples + ) + + variable.asymptotic_distribution.samples = asymptotic_dist.positions + + # Save asymptotic distribution data for each distribution + np.save( + f"{variable.formatted_description}-asymptotic.npy", + asymptotic_dist.positions, + ) + + elif variable.type == VariableTypes.SCALAR: + # Scalars use an empirical approach: draw samples and, if they are + # normally distributed, estimate the standard deviation. + test_size = 1000 + samples = generate_scalar_samples( + variable=variable, + sizes=[test_size], + repetitions=10_000, + n_processors=n_processors, + path_to_application=path_to_application, + native_executable_name=native_executable_name, + native_executable_dir=native_executable_dir, + demo_cli_args=demo_cli_args, + )[test_size] + mean, std, is_normal = _compute_asymptotic_distribution_scalar_empirical( + samples, test_size, ground_truth.dv.positions[0] + ) + + # Convert distances to units of basis points + std *= EquivMC.BASIS_POINT_CONVERSION_FACTOR / np.abs( + ground_truth.dv.positions[0] + ) + + half_norm_dist = halfnorm(scale=std) + asymptotic_mean = std * np.sqrt(2 / np.pi) + mean_quantile = half_norm_dist.cdf(asymptotic_mean) + quantile_95 = half_norm_dist.ppf(ReportingNumbers.QUANTILE_95) + quantile_99 = half_norm_dist.ppf(ReportingNumbers.QUANTILE_99) + + # Populate the dataclass, casting numpy scalars to plain `float` to + # match the `float | None` fields. For distribution variables, + # `is_normal` and `scale` keep their loop-head defaults (`False` / + # `None`). + asymptotic = variable.asymptotic_distribution + asymptotic.mean = None if asymptotic_mean is None else float(asymptotic_mean) + asymptotic.quantile_95 = None if quantile_95 is None else float(quantile_95) + asymptotic.quantile_99 = None if quantile_99 is None else float(quantile_99) + asymptotic.mean_quantile = ( + None if mean_quantile is None else float(mean_quantile) + ) + asymptotic.is_normal = is_normal + asymptotic.scale = None if std is None else float(std) + + csv_rows.append( + [ + variable.description, + asymptotic_mean, + quantile_95, + quantile_99, + mean_quantile, + is_normal, + std, + ] + ) + + # Write asymptotic distance distribution data to CSV + with open(asymptotic_dist_file, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + BenchmarkingVariables.VARIABLE_DESCRIPTION, + ReportingMethods.MEAN, + ReportingMethods.QUANTILE_95, + ReportingMethods.QUANTILE_99, + EquivMC.MEAN_QUANTILE, + AsymptoticDistanceDistribution.IS_NORMAL, + AsymptoticDistanceDistribution.SCALE, + ] + ) + writer.writerows(csv_rows) + + +def compute_equivalent_mc( + *, + benchmarking_variables: list[BenchmarkingVariable], + output_data_file: str, + asymptotic_dist_file: str, + ground_truth_db_path: str, + has_analytic_ground_truth: bool, + ground_truth_type: str, + adversary_db_path: str, + tracing_db_path: str, + representation_types: list[str], + representation_sizes: list[int], + correlations: list[str], + num_parallel_workers: int, + n_adversaries: int, + distance_type: str, + use_binned_uxhw: bool, + use_clt: bool, + reporting_methods: list[str], + plots_dir: str, + plot_comparison_distributions: bool = False, + plot_adversary_distances: bool = False, + plot_distributions: bool = False, +) -> None: + """ + Compute equivalent Monte Carlo counts across variables. + + Loads the EMCC data and the asymptotic-distance distributions + written by the earlier pipeline stages and delegates to + :func:`load_data_and_compute_equivalent_mc`. + + Only the plotting flags are documented below. The remaining arguments are + forwarded verbatim to :func:``load_data_and_compute_equivalent_mc``. + + Args: + plot_comparison_distributions: Generate comparison-distribution + plots (UxHw vs MC vs ground truth). + plot_adversary_distances: Generate adversary-distance plots. + plot_distributions: Generate representative-MC distribution + plots (distribution-typed outputs only). Maps to the + ``plot_distributions`` gate in ``equivalent_mc_utils``. + """ + load_emcc_data( + benchmarking_variables=benchmarking_variables, + output_data_file=output_data_file, + ) + load_asymptotic_dist( + benchmarking_variables=benchmarking_variables, + asymptotic_dist_file=asymptotic_dist_file, + ) + load_data_and_compute_equivalent_mc( + { + "ground_truth_database_path": ground_truth_db_path, + "ground_truth_table_name": ( + "WeightedSamples" + if has_analytic_ground_truth or ground_truth_type == "WeightedSamples" + else "MonteCarlo" + ), + "benchmarking_variables": benchmarking_variables, + "adversary_database_path": adversary_db_path, + "uxhw_database_path": tracing_db_path, + "uxhw_ur_types": representation_types, + "uxhw_ur_sizes": representation_sizes, + "correlations": correlations, + "n_processes": num_parallel_workers, + "n_adversaries": n_adversaries, + "plot_comparison_distributions": plot_comparison_distributions, + "ground_truth_type": ground_truth_type, + "plot_adversary_distances": plot_adversary_distances, + "use_adaptive_steps": True, + "distance_type": distance_type, + "use_binned_uxhw": use_binned_uxhw, + "use_clt": use_clt, + "reporting_methods": reporting_methods, + "auto_prefix": True, + "adversary_size_step": n_adversaries, + "adversary_size_min": 1, + "adversary_size_max": 50000, + "adversary_table_name": "MonteCarlo", + "uxhw_table_names": ["TracingTable"], + "output_file": output_data_file, + "plot_distributions": plot_distributions, + "plots_dir": plots_dir, + } + ) diff --git a/src/signaloid/benchmarking/automation/analytic_ground_truth_database_test.py b/src/signaloid/benchmarking/automation/analytic_ground_truth_database_test.py new file mode 100644 index 0000000..3d63e51 --- /dev/null +++ b/src/signaloid/benchmarking/automation/analytic_ground_truth_database_test.py @@ -0,0 +1,229 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from signaloid.benchmarking.automation.database_generator import ( + generate_analytic_ground_truth_database, +) +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import VariableTypes + + +def _make_variable(name: str) -> BenchmarkingVariable: + """ + Create a BenchmarkingVariable fixture for use in ground-truth tests. + + Args: + name: The variable name, matched against CSV rows. + + Returns: + A BenchmarkingVariable of type DISTRIBUTION with default metadata. + """ + return BenchmarkingVariable( + name=name, + description=name, + value_id=f"id_{name}", + program="main", + path="", + line_number="1", + type=VariableTypes.DISTRIBUTION, + ) + + +def _write_ground_truth_script( + script_path: Path, + rows: list[tuple[str, str, str]], +) -> None: + """ + Write a Python helper script that writes a fixed CSV when invoked. + + The script accepts two positional arguments ( ) to + match the invocation pattern used by generate_analytic_ground_truth_database, + but ignores and always writes the fixed rows. + + Args: + script_path: Destination path for the generated script. + rows: List of (name, position, weight) tuples to write as CSV rows. + """ + row_literals = repr(rows) + script_path.write_text( + "import csv, sys\n" + "rows = " + row_literals + "\n" + "csv_path = sys.argv[2]\n" + "with open(csv_path, 'w', newline='') as f:\n" + " writer = csv.writer(f)\n" + " writer.writerows(rows)\n", + encoding="utf-8", + ) + + +class TestAnalyticGroundTruthDatabase(unittest.TestCase): + """Tests for generate_analytic_ground_truth_database.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_happy_path_two_variables(self) -> None: + """ + Happy path: two variables, three CSV rows each, correct DB rows produced. + + The resulting WeightedSamples table must contain exactly the expected + (ValueId, Position, Weight) tuples for each variable. + """ + app_dir = self.tmp_path / "app" + src_dir = app_dir / "src" + src_dir.mkdir(parents=True) + + script_path = self.tmp_path / "ground_truth.py" + rows = [ + ("alpha", "1.0", "0.5"), + ("alpha", "2.0", "0.3"), + ("alpha", "3.0", "0.2"), + ("beta", "10.0", "0.6"), + ("beta", "20.0", "0.4"), + ("beta", "30.0", "0.0"), + ] + _write_ground_truth_script(script_path, rows) + + var_alpha = _make_variable("alpha") + var_beta = _make_variable("beta") + db_path = str(self.tmp_path / "ground_truth.db") + + generate_analytic_ground_truth_database( + benchmarking_variables=[var_alpha, var_beta], + path_to_application=str(app_dir), + path_to_ground_truth_file=str(script_path), + ground_truth_size=3, + ground_truth_db_path=db_path, + ) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute( + "SELECT ValueId, Position, Weight FROM WeightedSamples" + " WHERE ValueId = ? ORDER BY Position", + ("id_alpha",), + ) + alpha_rows = cursor.fetchall() + self.assertEqual(len(alpha_rows), 3) + self.assertEqual(alpha_rows[0], ("id_alpha", 1.0, 0.5)) + self.assertEqual(alpha_rows[1], ("id_alpha", 2.0, 0.3)) + self.assertEqual(alpha_rows[2], ("id_alpha", 3.0, 0.2)) + + cursor.execute( + "SELECT ValueId, Position, Weight FROM WeightedSamples" + " WHERE ValueId = ? ORDER BY Position", + ("id_beta",), + ) + beta_rows = cursor.fetchall() + self.assertEqual(len(beta_rows), 3) + self.assertEqual(beta_rows[0], ("id_beta", 10.0, 0.6)) + self.assertEqual(beta_rows[1], ("id_beta", 20.0, 0.4)) + self.assertEqual(beta_rows[2], ("id_beta", 30.0, 0.0)) + + conn.close() + + def test_pre_existing_values_are_cleared(self) -> None: + """ + Pre-existing values and weights on each variable are cleared before + being repopulated from the CSV. + + This verifies that variable.empty_values() is called first so that + stale data from a previous pipeline stage does not accumulate. + """ + app_dir = self.tmp_path / "app" + src_dir = app_dir / "src" + src_dir.mkdir(parents=True) + + script_path = self.tmp_path / "ground_truth.py" + rows = [("gamma", "5.0", "1.0")] + _write_ground_truth_script(script_path, rows) + + var_gamma = _make_variable("gamma") + # Seed with dummy pre-existing data that must be erased. + var_gamma.values = [999.0, 888.0] # type: ignore[attr-defined] + var_gamma.weights = [0.9, 0.1] # type: ignore[attr-defined] + + db_path = str(self.tmp_path / "ground_truth.db") + + generate_analytic_ground_truth_database( + benchmarking_variables=[var_gamma], + path_to_application=str(app_dir), + path_to_ground_truth_file=str(script_path), + ground_truth_size=1, + ground_truth_db_path=db_path, + ) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + "SELECT Position FROM WeightedSamples WHERE ValueId = ?", + ("id_gamma",), + ) + rows_in_db = cursor.fetchall() + conn.close() + + # Only the CSV row should appear. Pre-existing dummy values must be gone. + self.assertEqual(len(rows_in_db), 1) + self.assertEqual(rows_in_db[0], (5.0,)) + + def test_subprocess_error_raises_runtime_error(self) -> None: + """ + A ground-truth script that exits with non-zero must raise RuntimeError + with a message that includes the exit code. + """ + app_dir = self.tmp_path / "app" + src_dir = app_dir / "src" + src_dir.mkdir(parents=True) + + failing_script = self.tmp_path / "failing_gt.py" + failing_script.write_text( + "import sys\n" + "print('something went wrong', file=sys.stderr)\n" + "sys.exit(42)\n", + encoding="utf-8", + ) + + var_delta = _make_variable("delta") + db_path = str(self.tmp_path / "ground_truth.db") + + with self.assertRaises(RuntimeError) as cm: + generate_analytic_ground_truth_database( + benchmarking_variables=[var_delta], + path_to_application=str(app_dir), + path_to_ground_truth_file=str(failing_script), + ground_truth_size=1, + ground_truth_db_path=db_path, + ) + + error_message = str(cm.exception) + self.assertIn("42", error_message) + self.assertIn("Ground truth generation failed", error_message) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/application_version_test.py b/src/signaloid/benchmarking/automation/application_version_test.py new file mode 100644 index 0000000..3c8276d --- /dev/null +++ b/src/signaloid/benchmarking/automation/application_version_test.py @@ -0,0 +1,86 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + + +import re +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from signaloid.benchmarking.automation.benchmark import Benchmark + +_DATE_VERSION_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$") + + +class TestResolveApplicationVersion(unittest.TestCase): + """Tests for Benchmark._resolve_application_version fallback and git-hash paths.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_resolve_application_version_falls_back_to_date_when_not_git_repo( + self, + ) -> None: + version = Benchmark._resolve_application_version(str(self.tmp_path)) + self.assertTrue(version) + self.assertRegex( + version, + _DATE_VERSION_RE, + msg=f"Expected YYYY-MM-DD-HH-MM-SS date stamp, got {version!r}", + ) + + def test_resolve_application_version_falls_back_to_date_on_git_failure( + self, + ) -> None: + (self.tmp_path / ".git").mkdir() + with patch.object( + subprocess, + "check_output", + side_effect=subprocess.CalledProcessError(128, "git"), + ): + version = Benchmark._resolve_application_version(str(self.tmp_path)) + self.assertRegex( + version, + _DATE_VERSION_RE, + msg=f"Expected date fallback after git failure, got {version!r}", + ) + + def test_resolve_application_version_returns_short_git_hash_for_repo( + self, + ) -> None: + (self.tmp_path / ".git").mkdir() + with patch.object( + subprocess, "check_output", return_value="abcdef0\n" + ) as mock_check: + version = Benchmark._resolve_application_version(str(self.tmp_path)) + self.assertEqual(version, "abcdef0") + args, _kwargs = mock_check.call_args + cmd = args[0] + self.assertEqual(cmd[:2], ["git", "-C"]) + self.assertEqual(cmd[2], str(self.tmp_path)) + self.assertEqual(cmd[3:], ["rev-parse", "--short=7", "HEAD"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/arguments.py b/src/signaloid/benchmarking/automation/arguments.py new file mode 100644 index 0000000..ab24ce9 --- /dev/null +++ b/src/signaloid/benchmarking/automation/arguments.py @@ -0,0 +1,447 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from argparse import ArgumentParser, BooleanOptionalAction, Namespace +from signaloid.benchmarking.config import ( + RepresentationTypes, + DistanceMetrics, + ReportingMethods, + Correlations, + DEFAULT_UXHW_SDK_PATH, + DEFAULT_GROUND_TRUTH_SIZE, + DEFAULT_ADVERSARY_MC_SIZE, + DEFAULT_MAX_NUM_WEIGHTED_SAMPLES, + DEFAULT_NUM_ADVERSARIES, + DEFAULT_MAX_JUPITER_SIZE, + DEFAULT_ADVERSARY_MAX_SIZE_SCALAR, +) + +# Valid choices for the sweep args, as module constants so +# `create_argument_parser` (CLI `choices=`) and `validate_args` (file-supplied +# values, which argparse does not check) share one source. +REPRESENTATION_TYPE_CHOICES = [ + RepresentationTypes.ATHENS, + RepresentationTypes.ATLAS, + RepresentationTypes.JUPITER, + RepresentationTypes.EUROPA, +] +CORRELATION_CHOICES = [ + Correlations.DISABLED, + Correlations.AUTOCORRELATION, +] +REPORTING_METHOD_CHOICES = [ + ReportingMethods.MEAN, + ReportingMethods.QUANTILE_95, + ReportingMethods.QUANTILE_99, +] + + +def create_argument_parser() -> ArgumentParser: + """ + Build the argument parser for the benchmarking CLI. + + Returns: + The configured ``ArgumentParser``. + """ + parser = ArgumentParser( + prog="signaloid-benchmarking", + description="Benchmark UxHw applications", + ) + + # Optional YAML config file whose values populate argparse defaults (see + # benchmark_application.load_config), so explicit CLI flags still win. + # Defined first so --help lists it near the top. + parser.add_argument( + "--config", + dest="config", + type=str, + default=None, + help=( + "YAML configuration file. Keys populate arguments " + "with matching names. Precedence: built-in " + "defaults < configuration file < explicit CLI flags." + ), + ) + + parser.add_argument( + "--print-config", + dest="print_config", + action="store_true", + default=False, + help=( + "Resolve parameters and print the effective " + "configuration and the benchmark matrix that the " + "benchmark will use, then exit without running." + ), + ) + + # UxHw configurations. + # + # The four sweep args below are not required= (a --config file supplies + # them via set_defaults, which cannot satisfy argparse's required=). + # Presence and membership are validated in validate_args() after the merge. + # + # They use "store" (not "extend") with nargs="+" so an explicit CLI value + # fully replaces a file-supplied default. + parser.add_argument( + "-u", + "--representation-types", + dest="representation_types", + choices=REPRESENTATION_TYPE_CHOICES, + type=str, + nargs="+", + help="Uncertain representation types to evaluate.", + ) + + parser.add_argument( + "-s", + "--representation-sizes", + dest="representation_sizes", + type=int, + nargs="+", + help="Uncertain representation sizes to evaluate.", + ) + + parser.add_argument( + "-c", + "--uncertainty-correlation_types", + dest="correlations", + type=str, + choices=CORRELATION_CHOICES, + nargs="+", + help="Uncertain representation correlations to evaluate", + ) + + parser.add_argument( + "-r", + "--reporting-methods", + dest="reporting_methods", + type=str, + choices=REPORTING_METHOD_CHOICES, + nargs="+", + help="Reporting methods.", + ) + + parser.add_argument( + "--ground-truth-size", + dest="ground_truth_size", + type=int, + default=DEFAULT_GROUND_TRUTH_SIZE, + help="Size of ground truth.", + ) + + parser.add_argument( + "--num-adversaries", + dest="n_adversaries", + type=int, + default=DEFAULT_NUM_ADVERSARIES, + help="Number of adversaries to evaluate.", + ) + + parser.add_argument( + "--max-jupiter-size", + dest="max_jupiter_size", + type=int, + default=DEFAULT_MAX_JUPITER_SIZE, + help="Maximum representation size to benchmark for Jupiter.", + ) + + parser.add_argument( + "--max-num-weighted-samples", + dest="max_num_weighted_samples", + type=int, + default=DEFAULT_MAX_NUM_WEIGHTED_SAMPLES, + help="Maximum number of weighted samples to use for ground truth.", + ) + + parser.add_argument( + "--adversary-mc-size", + dest="adversary_mc_size", + type=int, + default=DEFAULT_ADVERSARY_MC_SIZE, + help="Size of adversary Monte Carlo array.", + ) + + parser.add_argument( + "--adversary-max-size-scalar", + dest="adversary_max_size_scalar", + type=int, + default=DEFAULT_ADVERSARY_MAX_SIZE_SCALAR, + help="Maximum size of adversarial MC for scalar outputs.", + ) + + # Multiprocessing. Default is None (unset) which resolves to the detected + # core count in Benchmark.get_machine_info. When set, -j sets the max for + # every worker pool (compile, the three MC sample-generation stages, and the + # EMCC adversary-distance stage). + parser.add_argument( + "-j", + "--jobs", + "--num-parallel-workers", + dest="num_parallel_workers", + type=int, + default=None, + help=( + "Number of max parallel workers. " "Defaults to the detected core count." + ), + ) + + # Ground Truth Type + parser.add_argument( + "--ground-truth-type", + dest="ground_truth_type", + type=str, + default=RepresentationTypes.MONTE_CARLO, + choices=[RepresentationTypes.MONTE_CARLO, RepresentationTypes.WEIGHTED_SAMPLES], + help=f"Type of ground truth ({RepresentationTypes.MONTE_CARLO} or {RepresentationTypes.WEIGHTED_SAMPLES}). Default is {RepresentationTypes.MONTE_CARLO}.", + ) + + parser.add_argument( + "--distance-type", + dest="distance_type", + type=str, + default=DistanceMetrics.WASSERSTEIN_1, + choices=[ + DistanceMetrics.WASSERSTEIN_1, + DistanceMetrics.WASSERSTEIN_2, + ], + help="Distance function to use for as the accuracy metric for comparing distributions.", + ) + + parser.add_argument( + "--has-analytic-ground-truth", + dest="has_analytic_ground_truth", + action="store_true", + default=False, + help="Application has analytic ground truth.", + ) + + parser.add_argument( + "--use-binned-uxhw", + dest="use_binned_uxhw", + action=BooleanOptionalAction, + default=True, + help=( + "Use the binned Wasserstein-1 computation for UxHw " + "distances. Pass --no-use-binned-uxhw (or set " + "use_binned_uxhw: false in a config) to disable." + ), + ) + + parser.add_argument( + "--use-clt", + dest="use_clt", + action="store_true", + default=False, + help=( + "Estimate the equivalent Monte Carlo count from the asymptotic " + "(CLT / Brownian-bridge) distance distribution instead of " + "measuring it via explicit adversary MC simulation." + ), + ) + + # Paths + parser.add_argument( + "--path-to-application", + dest="path_to_application", + type=str, + help="Application to benchmark.", + ) + + parser.add_argument( + "--path-to-uxhw-sdk", + dest="path_to_uxhw_sdk", + default=DEFAULT_UXHW_SDK_PATH, + help="Path to the Signaloid UxHw SDK.", + ) + + parser.add_argument( + "--path-to-pin", + dest="path_to_pin", + type=str, + default=None, + help=( + "Path to the Intel PIN kit (sets PIN_ROOT for the timing " + "script, which uses it to count dynamic instructions). When " + "omitted, an inherited PIN_ROOT is used. If neither is set " + "the timing run errors with 'Intel Pin not found'." + ), + ) + + parser.add_argument( + "--path-to-ground-truth-file", + dest="path_to_ground_truth_file", + type=str, + help="Path to ground truth file", + ) + + parser.add_argument( + "--demo-cli-args", + dest="demo_cli_args", + type=str, + default="", + help="Extra command-line arguments passed to both native-MC and UxHw executions.", + ) + + # Plotting controls. Each gates one plot call in the equivalent-MC stage + # (the gates in equivalent_mc_utils). Disabled by default. + # BooleanOptionalAction adds the --no-... variant, so each is toggleable + # both ways and settable from a config via set_defaults. + parser.add_argument( + "--plot-distance-vs-asymptotic", + dest="plot_distance_vs_asymptotic", + action=BooleanOptionalAction, + default=False, + help=( + "Plot the empirical equivalent-MC distance distribution " + "against its asymptotic prediction (Brownian-bridge for " + "distribution outputs, half-normal for scalars)." + ), + ) + + parser.add_argument( + "--plot-adversary-distances", + dest="plot_adversary_distances", + action=BooleanOptionalAction, + default=False, + help=( + "Generate adversary-distance plots during the " + "equivalent-MC stage (empty under --use-clt)." + ), + ) + + parser.add_argument( + "--plot-representative-mc", + dest="plot_representative_mc", + action=BooleanOptionalAction, + default=False, + help=( + "Plot a representative equivalent Monte Carlo run (the MC sample set " + "whose distance to ground truth matches the UxHw result) " + "per distribution-typed output." + ), + ) + + parser.add_argument( + "--google-credentials", + dest="google_credentials", + type=str, + default=None, + help=( + "Path to a Google Cloud service-account JSON file for the Sheets " + "upload. Falls back to the GOOGLE_APPLICATION_CREDENTIALS " + "environment variable. Required when --write-sheets is set." + ), + ) + + parser.add_argument( + "--write-sheets", + dest="write_sheets", + action="store_true", + default=False, + help=( + "Optional Google Sheets upload at pipeline step 15 (see README.md). " + "Requires a credentials file (via --google-credentials or " + "GOOGLE_APPLICATION_CREDENTIALS) and the `sheets` package " + "to be installed." + ), + ) + + return parser + + +def _validate_sweep_args(args: Namespace) -> None: + """Validate the four sweep args after the config layer has merged. + + + Raises: + ValueError: If any sweep arg is missing after the merge, is not a + non-empty list, (for representation_sizes) contains a + non-integer, or contains a value outside its allowed enum + membership. + """ + required_sweep_args = { + "representation_types": REPRESENTATION_TYPE_CHOICES, + "representation_sizes": None, + "correlations": CORRELATION_CHOICES, + "reporting_methods": REPORTING_METHOD_CHOICES, + } + for dest, allowed in required_sweep_args.items(): + values = getattr(args, dest, None) + if values is None: + raise ValueError( + f"Error! '{dest}' must be provided via the CLI or a " + "--config file (it has no default)." + ) + if not isinstance(values, list) or len(values) == 0: + raise ValueError( + f"Error! '{dest}' must be a non-empty list (a YAML list " + f"or CLI nargs), got {type(values).__name__}." + ) + if dest == "representation_sizes" and any( + not isinstance(value, int) for value in values + ): + raise ValueError( + f"Error! '{dest}' must contain only integers, " f"got {values}." + ) + if allowed is None: + continue + invalid = [value for value in values if value not in allowed] + if invalid: + raise ValueError( + f"Error! '{dest}' contains invalid values {invalid}. " + f"Allowed values are {allowed}." + ) + + +def validate_args(args: Namespace) -> None: + """ + Validate the merged arguments before the pipeline runs. + + Checks the worker count, the sweep args, the analytic-ground-truth / + weighted-samples pairing, and the binned-distance / metric pairing. + + Args: + args: The parsed and config-merged arguments. + + Raises: + ValueError: If any of those constraints is violated. + """ + # -j is None when unset (get_machine_info resolves it to the core count). + # When given explicitly it must be positive, else the worker pools get a + # non-positive max_workers and crash deep in the run. + if args.num_parallel_workers is not None and args.num_parallel_workers < 1: + raise ValueError( + "Error! -j/--jobs/--num-parallel-workers must be >= 1, got " + f"{args.num_parallel_workers}." + ) + + _validate_sweep_args(args) + + # Analytic ground truth means utilizing weighted samples + if args.has_analytic_ground_truth: + if args.ground_truth_type != RepresentationTypes.WEIGHTED_SAMPLES: + raise ValueError("Error! Analytic ground truth must have weighted samples.") + + if args.use_binned_uxhw and (args.distance_type != DistanceMetrics.WASSERSTEIN_1): + raise ValueError( + "Error! Binned distance computations only supported" + f" for {DistanceMetrics.WASSERSTEIN_1}." + ) diff --git a/src/signaloid/benchmarking/automation/benchmark.py b/src/signaloid/benchmarking/automation/benchmark.py new file mode 100644 index 0000000..e2abacb --- /dev/null +++ b/src/signaloid/benchmarking/automation/benchmark.py @@ -0,0 +1,503 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import subprocess +import yaml +import os +import re +import datetime +import functools +import hashlib +from signaloid.benchmarking.types import ( + BenchmarkingVariable, +) +from signaloid.benchmarking.automation.benchmarking_utils import ( + get_git_remote, + expand_array_expressions, +) +from signaloid.benchmarking.automation.build import ( + compile_native, + export_timing_env, + run_timing_script, +) +from signaloid.benchmarking.automation.analysis import ( + load_emcc_data, +) +from signaloid.benchmarking.config import ( + RepresentationTypes, + Correlations, + DistanceMetrics, + ReportingMethods, + SignaloidYaml, + VariableTypes, + TimingFormat, + DEFAULT_UXHW_SDK_PATH, + DEFAULT_GROUND_TRUTH_SIZE, + DEFAULT_ADVERSARY_MC_SIZE, + DEFAULT_MAX_NUM_WEIGHTED_SAMPLES, + DEFAULT_NUM_ADVERSARIES, + DEFAULT_MAX_JUPITER_SIZE, + DEFAULT_ADVERSARY_MAX_SIZE_SCALAR, +) + + +class Benchmark: + """ + Pipeline coordinator for the benchmarking automation tool. + + Holds the resolved configuration for a single application benchmark (paths + to the application, the UxHw SDK and PIN, the representation types/sizes and + correlation modes under test, ground-truth and adversary sample sizes, the + distance metric, and reporting options) and threads it through the + multi-stage pipeline. The per-stage work (compilation, database generation, + sample generation, measurement loading, EMCC and UxHw-distance analysis, and + report writing) lives in the focused ``build``, ``database_generator``, + ``sample_generator``, ``measurement_loader``, ``analysis`` and + ``report_writer`` modules. This class owns no per-stage logic itself and + simply orchestrates those steps. + """ + + def __init__( + self, + path_to_application: str, + path_to_uxhw_sdk: str = DEFAULT_UXHW_SDK_PATH, + path_to_pin: str | None = None, + has_analytic_ground_truth: bool = False, + path_to_ground_truth_file: str = "", + ground_truth_size: int = DEFAULT_GROUND_TRUTH_SIZE, + adversary_mc_size: int = DEFAULT_ADVERSARY_MC_SIZE, + adversary_max_size_scalar: int = DEFAULT_ADVERSARY_MAX_SIZE_SCALAR, + representation_types: list[str] = [ + RepresentationTypes.ATHENS, + ], + representation_sizes: list[int] = [16, 32, 64, 128, 256, 512], + max_jupiter_size: int = DEFAULT_MAX_JUPITER_SIZE, + correlations: list[str] = [ + Correlations.DISABLED, + Correlations.AUTOCORRELATION, + ], + ground_truth_type: str = RepresentationTypes.MONTE_CARLO, + max_num_weighted_samples: int = DEFAULT_MAX_NUM_WEIGHTED_SAMPLES, + num_parallel_workers: int | None = None, + distance_type: str = DistanceMetrics.WASSERSTEIN_1, + use_clt: bool = False, + reporting_methods: list[str] = [ + ReportingMethods.MEAN, + ReportingMethods.QUANTILE_95, + ReportingMethods.QUANTILE_99, + ], + n_adversaries: int = DEFAULT_NUM_ADVERSARIES, + use_binned_uxhw: bool = True, + demo_cli_args: str = "", + google_credentials: str | None = None, + ) -> None: + """ + Store the resolved benchmark configuration. + + See the class docstring for the configuration groups. Each argument + sets the correspondingly-named attribute. + """ + self.path_to_application = os.path.expanduser(path_to_application) + self.path_to_uxhw_sdk = os.path.expanduser(path_to_uxhw_sdk) + self.path_to_pin = os.path.expanduser(path_to_pin) if path_to_pin else None + self.has_analytic_ground_truth = has_analytic_ground_truth + if self.has_analytic_ground_truth: + self.path_to_ground_truth_file = os.path.expanduser( + path_to_ground_truth_file + ) + self.ground_truth_size = ground_truth_size + self.adversary_mc_size = adversary_mc_size + self.adversary_max_size_scalar = adversary_max_size_scalar + self.all_outputs_cla: str = "" + self.representation_types = representation_types + self.representation_sizes = representation_sizes + self.correlations = correlations + self.cwd = os.path.abspath(os.getcwd()) + self.results_dir = os.path.join(self.cwd, "results") + self.logs_dir = os.path.join(self.cwd, "logs") + self.plots_dir = os.path.join(self.results_dir, "plots") + self.ground_truth_type = ground_truth_type + self.max_num_weighted_samples = max_num_weighted_samples + # None until get_machine_info resolves it to the detected core count + # All readers run after that, by which point it is an int. + self.num_parallel_workers: int | None = num_parallel_workers + self.output_data_file = os.path.join(self.results_dir, "output_data.csv") + self.asymptotic_dist_file = os.path.join( + self.results_dir, "asymptotic_distances.csv" + ) + self.uxhw_distance_file = os.path.join(self.results_dir, "uxhw_distances.csv") + self.distance_type = distance_type + self.use_clt = use_clt + self.reporting_methods = reporting_methods + self.variable_types: list[str] = [] + self.n_adversaries = n_adversaries + self.use_binned_uxhw = use_binned_uxhw + self.demo_cli_args = demo_cli_args + self.google_credentials = google_credentials + self.benchmarking_variables: list[BenchmarkingVariable] = [] + self.max_jupiter_size = max_jupiter_size + # False until compile_native() sets it True on a successful native-MC + # build (via Makefile or config.mk). + self.has_native_mc = False + # Populated by `load_measurement_dicts` (in `measurement_loader`) + # via the orchestrator. Consumed by the Google-Sheets writer. + self.uxhw_version: str = "" + + def get_machine_info(self) -> None: + """ + Detect the machine model and CPU count, and resolve the parallel-worker + count. + """ + + result = subprocess.check_output(["lscpu"], text=True) + + # Get the name of the computer model + match = re.search(r"Model name:\s+(.+)", result) + if match: + self.machine_name = match.group(1).strip() + else: + self.machine_name = "Unknown" + + # Get the number of CPUs + match = re.search(r"CPU\(s\)\s*:\s*(.+)", result) + if match: + self.n_processors = int(match.group(1).strip()) + else: + self.n_processors = 1 + + # Resolve an unset -j to the detected core count and clamp any request + # above it. + + num_parallel_workers = ( + self.n_processors + if self.num_parallel_workers is None + else self.num_parallel_workers + ) + + if num_parallel_workers > self.n_processors: + print( + f"Warning! Requested {num_parallel_workers} parallel workers but only {self.n_processors} detected!" + ) + num_parallel_workers = self.n_processors + + self.num_parallel_workers = num_parallel_workers + + def load_yaml(self) -> None: + """ + Parse the application's signaloid.yaml into benchmarking variables. + """ + + self.benchmarking_variables = [] + program = "main" + + signaloid_yaml_path = self.path_to_application + "/signaloid.yaml" + with open(signaloid_yaml_path, "r") as yaml_file: + yaml_list = yaml.safe_load(yaml_file) + + # Get trace variable info for file path and line number + trace_variables = yaml_list[SignaloidYaml.TRACE_VARIABLES] + trace_variables = expand_array_expressions(trace_variables) + + if SignaloidYaml.BENCHMARKING_VARIABLES in yaml_list: + if SignaloidYaml.ALL_OUTPUTS not in yaml_list: + raise ValueError( + f"signaloid.yaml at '{signaloid_yaml_path}' defines " + f"'{SignaloidYaml.BENCHMARKING_VARIABLES}' but is missing " + f"'{SignaloidYaml.ALL_OUTPUTS}'. An all-outputs invocation " + "is required to run combined-output measurements." + ) + all_outputs = yaml_list[SignaloidYaml.ALL_OUTPUTS] + self.all_outputs_cla = all_outputs[0][ + SignaloidYaml.COMMAND_LINE_ARGUMENTS + ] + full_cla = f"{self.demo_cli_args} {self.all_outputs_cla}".strip() + self.value_id = hashlib.md5(full_cla.encode()).hexdigest() + + # Get the list of BenchmarkingVariable entries + benchmarking_variables = yaml_list[SignaloidYaml.BENCHMARKING_VARIABLES] + + for var_config in benchmarking_variables: + variable_name = var_config[SignaloidYaml.VARIABLE_NAME] + trace_variable = next( + ( + elem + for elem in trace_variables + if elem.get(SignaloidYaml.EXPRESSION) == variable_name + ), + None, + ) + if trace_variable is None: + raise ValueError( + f"Benchmarking variable '{variable_name}' has no matching traced variable!" + ) + file_name = trace_variable[SignaloidYaml.FILE] + line_number = trace_variable[SignaloidYaml.LINE_NUMBER] + path = f"{self.path_to_application}/src/{file_name}" + + variable_description = var_config[ + SignaloidYaml.VARIABLE_DESCRIPTION + ] + variable_type = var_config[SignaloidYaml.OUTPUT_OBJECT] + command_line_args = var_config[SignaloidYaml.COMMAND_LINE_ARGUMENTS] + + # Create BenchmarkingVariable object + variable = BenchmarkingVariable( + value_id=self.value_id, + name=variable_name, + description=variable_description, + program=program, + path=path, + line_number=line_number, + file_name=file_name, + type=variable_type, + cla=command_line_args, + ) + self.benchmarking_variables.append(variable) + else: + print( + "Old Yaml format detected. Falling back to using trace variables as benchmarking variables." + ) + self.all_outputs_cla = f"-S {len(trace_variables)}" + full_cla = f"{self.demo_cli_args} {self.all_outputs_cla}".strip() + self.value_id = hashlib.md5(full_cla.encode()).hexdigest() + for i, elem in enumerate(trace_variables): + file_name = elem[SignaloidYaml.FILE] + line_number = elem[SignaloidYaml.LINE_NUMBER] + path = f"{self.path_to_application}/src/{file_name}" + variable_name = elem[SignaloidYaml.EXPRESSION] + # Create BenchmarkingVariable object + variable = BenchmarkingVariable( + value_id=self.value_id, + name=variable_name, + description=f"Variable {i}", + program=program, + path=path, + line_number=line_number, + file_name=file_name, + type=VariableTypes.DISTRIBUTION, + cla=f"-S {i}", + ) + self.benchmarking_variables.append(variable) + + @staticmethod + def _resolve_application_version(path_to_application: str) -> str: + """ + Resolve a non-empty version string for an application path. + + Uses the git short hash when the path is a git repository, and + falls back to a date stamp otherwise (or when ``git rev-parse`` + fails). The fallback guarantees a non-empty value, which + ``get-timings.sh`` requires via ``require_var APPLICATION_VERSION``. + + Args: + path_to_application: Path to the application repository. + + Returns: + The git short hash, or a date stamp when unavailable. + """ + if os.path.isdir(os.path.join(path_to_application, ".git")): + try: + return subprocess.check_output( + [ + "git", + "-C", + path_to_application, + "rev-parse", + "--short=7", + "HEAD", + ], + text=True, + ).strip() + except subprocess.CalledProcessError: + print( + "Error retrieving git hash. " + "Falling back to date-based versioning." + ) + else: + print( + "Warning! Application path is not a git repository. " + "Falling back to date-based versioning." + ) + return datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + + def get_application_info(self) -> None: + """ + Resolve application identity and prepare the benchmark's inputs. + + Resolves the application name/version, loads signaloid.yaml, compiles + the native-MC build, creates the output directories, and derives the + database paths and shared timing environment. + """ + self.application_version = self._resolve_application_version( + self.path_to_application + ) + application_name = [ + part for part in self.path_to_application.split("/") if part + ][-1] + self.application_name = application_name.replace("Signaloid-Demo-", "") + + # Load the signaloid.yaml file + self.load_yaml() + + # Attempt native compilation + if self.num_parallel_workers is None: + raise RuntimeError( + "num_parallel_workers is unset; call get_machine_info() " + "before get_application_info()." + ) + num_parallel_workers = self.num_parallel_workers + ( + self.native_executable_name, + self.native_compilation_command, + self.native_executable_dir, + self.has_native_mc, + ) = compile_native( + path_to_application=self.path_to_application, + num_parallel_workers=num_parallel_workers, + ) + + # Obtain git remote + self.git_repo_remote = get_git_remote(self.path_to_application) + + ground_truth_prefix = adversary_prefix = self.application_name + + # Create output directories + os.makedirs(self.results_dir, exist_ok=True) + os.makedirs(self.logs_dir, exist_ok=True) + os.makedirs(self.plots_dir, exist_ok=True) + + # Define database paths + self.tracing_db_path = os.path.join( + self.results_dir, + f"uxhwExecutionStatistics-{self.application_version}-{self.value_id}-tracing.db", + ) + if self.has_native_mc: + ground_truth_prefix += "-nativeMC" + adversary_prefix += "-nativeMC" + else: + ground_truth_prefix += "-uxhwExecutionStatistics" + adversary_prefix += "-uxhwExecutionStatistics" + + if self.has_analytic_ground_truth: + ground_truth_prefix += "-analytic" + + self.ground_truth_db_path = os.path.join( + self.results_dir, + f"{ground_truth_prefix}-{self.application_version}-{self.value_id}-ground-{self.ground_truth_size}.db", + ) + self.adversary_db_path = os.path.join( + self.results_dir, + f"{adversary_prefix}-{self.application_version}-{self.value_id}-adv-{self.adversary_mc_size}.db", + ) + + # Export common timing environment variables + self.tracing_db_path = export_timing_env( + path_to_uxhw_sdk=self.path_to_uxhw_sdk, + path_to_pin=self.path_to_pin, + path_to_application=self.path_to_application, + application_name=self.application_name, + application_version=self.application_version, + max_jupiter_size=self.max_jupiter_size, + results_dir=self.results_dir, + logs_dir=self.logs_dir, + tracing_db_path=self.tracing_db_path, + ) + + def intermediate_timings_path(self) -> str: + """ + Path of the transient intermediate timings file. + + Returns: + The absolute path of the intermediate file that bash writes + line-by-line and Python parses into the canonical JSON artifact. + """ + filename = ( + f"{self.application_name}-{self.application_version}" + f"{TimingFormat.INTERMEDIATE_SUFFIX}" + ) + return os.path.join(self.results_dir, filename) + + def json_timings_path(self) -> str: + """ + Path of the canonical JSON timings artifact. + + Returns: + The absolute path of the JSON artifact produced by the Python + reader. + """ + filename = ( + f"{self.application_name}-{self.application_version}" + f"{TimingFormat.JSON_SUFFIX}" + ) + return os.path.join(self.results_dir, filename) + + def bind_timing_script(self) -> "functools.partial[None]": + """ + Bind the run_timing_script kwargs shared by every timing pass. + + Every caller (UxHw timing, native-MC timing, and the tracing / + adversary / ground-truth database generators) passes the same eight + kwargs sourced from this Benchmark. + + Returns: + A partial that still needs the per-call arguments: the + ``variable_index`` and exactly one mode flag (``timing`` / + ``native_mc_timing`` / ``tracing`` / ``adversary_mc`` / + ``ground_truth``). + """ + return functools.partial( + run_timing_script, + all_outputs_cla=self.all_outputs_cla, + benchmarking_variables=self.benchmarking_variables, + demo_cli_args=self.demo_cli_args, + representation_types=self.representation_types, + representation_sizes=self.representation_sizes, + correlations=self.correlations, + logs_dir=self.logs_dir, + intermediate_timings_path=self.intermediate_timings_path(), + ) + + def generate_uxhw_timings(self) -> None: + """ + Generate the UxHw timing data for every benchmarking variable. + """ + print("Generating timing database.") + bound_run_timing_script = self.bind_timing_script() + for i in range(len(self.benchmarking_variables)): + bound_run_timing_script(variable_index=i, timing=True) + + def generate_native_mc_timings(self) -> None: + """ + Generate the native Monte Carlo timing data. + + If EMCC data is not found on the Benchmark object, attempts to load it + from the equivalent-MC output file and populate the benchmarking + variables. + """ + + load_emcc_data( + benchmarking_variables=self.benchmarking_variables, + output_data_file=self.output_data_file, + ) + + print("Generating native Monte Carlo timing data") + bound_run_timing_script = self.bind_timing_script() + for i, var in enumerate(self.benchmarking_variables): + bound_run_timing_script(variable_index=i, native_mc_timing=True) diff --git a/src/signaloid/benchmarking/automation/benchmark_application.py b/src/signaloid/benchmarking/automation/benchmark_application.py new file mode 100644 index 0000000..9aa01c5 --- /dev/null +++ b/src/signaloid/benchmarking/automation/benchmark_application.py @@ -0,0 +1,476 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import datetime +import os +import sys +import traceback + +import yaml + +from argparse import ArgumentParser, Namespace + +from signaloid.benchmarking.automation.benchmark import ( + Benchmark, +) +from signaloid.benchmarking.automation.analysis import ( + compute_emcc_predictions, + compute_equivalent_mc, + compute_speedups, + compute_uxhw_distances, + generate_asymptotic_distance_distributions, + load_emcc_data, +) +from signaloid.benchmarking.automation.arguments import ( + create_argument_parser, + validate_args, +) +from signaloid.benchmarking.automation.database_generator import ( + generate_adversary_database, + generate_ground_truth_database, + generate_uxhw_tracing_database, +) +from signaloid.benchmarking.automation.measurement_loader import ( + load_measurement_dicts, + load_timing_data_to_dfs, +) +from signaloid.benchmarking.automation.report_writer import ( + TARGET_FOLDER_ID, + TEMPLATE_ID, + resolve_google_credentials_path, + write_results_to_markdown, + write_results_to_spreadsheet, +) + +TOTAL_STEPS = 15 +LOG_FILE_PREFIX = "benchmarking_automation_error" + + +def _use_color() -> bool: + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() + + +def _log_step(step: int, description: str) -> None: + tag = f"[Step {step:2d}/{TOTAL_STEPS}]" + if _use_color(): + tag = f"\033[1;32m{tag}\033[0m" + print(f"{tag} {description}") + + +def _resolve_sheets_credentials(args: Namespace) -> str | None: + """ + Resolve Google credentials up-front when ``--write-sheets`` is set. + + Raising here (rather than after 14 pipeline steps) surfaces a + misconfiguration the user should fix first. + + Args: + args: The parsed command-line arguments. + + Returns: + The resolved credentials path when ``--write-sheets`` is set, or + ``None`` when the user opted out. + + Raises: + RuntimeError: If ``--write-sheets`` is set but no credentials file + resolves on disk, or the Drive folder / Sheets template IDs are + unset. + """ + if not args.write_sheets: + return None + credentials_path = resolve_google_credentials_path( + google_credentials=args.google_credentials, + ) + if credentials_path is None: + raise RuntimeError( + "--write-sheets was set but no Google credentials file " + "could be resolved. Provide a path via " + "--google-credentials or set " + "GOOGLE_APPLICATION_CREDENTIALS to an existing file." + ) + if not TARGET_FOLDER_ID or not TEMPLATE_ID: + raise RuntimeError( + "--write-sheets requires the target Google Drive folder and " + "Sheets template IDs. Set UXHW_SHEETS_DRIVE_FOLDER_ID and " + "UXHW_SHEETS_TEMPLATE_ID." + ) + return credentials_path + + +def load_config(parser: ArgumentParser) -> Namespace: + """Resolve arguments with config-file layering. + + Performs a two-pass parse so the precedence is + ``defaults < config file < explicit CLI flags``: a first + ``parse_known_args`` pass reads ``--config``. If present, the YAML + file's keys populate argparse defaults via ``set_defaults`` (so any + CLI flag still overrides them). A second ``parse_args`` pass applies + the CLI on top. + + Args: + parser: The argument parser from ``create_argument_parser``. + + Returns: + The fully resolved arguments namespace. + + Raises: + ValueError: If ``--config`` points to a missing file, the file + is not a YAML mapping, or it contains keys that do not map to + a known argument ``dest`` (catches typos like + ``representaiton_sizes``). + """ + pre, _ = parser.parse_known_args() + if pre.config: + config_path = os.path.expanduser(pre.config) + try: + with open(config_path, encoding="utf-8") as config_file: + file_config = yaml.safe_load(config_file) or {} + except FileNotFoundError as error: + raise ValueError(f"Config file not found: {config_path}") from error + if not isinstance(file_config, dict): + raise ValueError( + "Config file must contain a YAML mapping (key/value " + f"pairs) at the top level, got {type(file_config).__name__}." + ) + known_dests = {action.dest for action in parser._actions} - {"help"} + unknown_keys = set(file_config) - known_dests + if unknown_keys: + raise ValueError( + f"Unknown config keys: {sorted(unknown_keys)}. " + f"Known keys are {sorted(known_dests)}." + ) + parser.set_defaults(**file_config) + return parser.parse_args() + + +def _print_effective_config(args: Namespace) -> None: + """Print the resolved config and the swept benchmark matrix. + + Used by ``--print-config`` to let the user confirm the merged + configuration (defaults + config file + CLI) before committing to a + full pipeline run. + + Args: + args: The fully resolved arguments namespace. + """ + print("Effective configuration:") + for dest in sorted(vars(args)): + print(f" {dest}: {getattr(args, dest)}") + + matrix = [ + (representation_type, representation_size, correlation, reporting_method) + for representation_type in args.representation_types + for representation_size in args.representation_sizes + for correlation in args.correlations + for reporting_method in args.reporting_methods + ] + print( + "\nBenchmark matrix " + "(representation_type x representation_size x correlation x " + f"reporting_method): {len(matrix)} combinations:" + ) + for ( + representation_type, + representation_size, + correlation, + reporting_method, + ) in matrix: + print( + f" {representation_type} | size={representation_size} | " + f"{correlation} | {reporting_method}" + ) + + +def main() -> None: + """ + CLI entry point: parse arguments and run the benchmarking pipeline. + + Handles ``--print-config`` (print the resolved config and exit) and + ``--write-sheets`` pre-flight, then runs the pipeline. On failure, writes + a timestamped traceback to ``logs/`` and exits non-zero. + """ + parser = create_argument_parser() + args = load_config(parser) + validate_args(args) + + if args.print_config: + _print_effective_config(args) + sys.exit(0) + + # Fail early on misconfigured --write-sheets: credential resolution depends + # only on args + env + filesystem, so raise here, not after 14 steps. + credentials_path = _resolve_sheets_credentials(args) + + try: + _run_pipeline(args, credentials_path=credentials_path) + except Exception: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + logs_dir = os.path.join(os.getcwd(), "logs") + os.makedirs(logs_dir, exist_ok=True) + log_file = os.path.join(logs_dir, f"{LOG_FILE_PREFIX}_{timestamp}.log") + with open(log_file, "w") as f: + f.write(f"Benchmarking automation failed at " f"{timestamp}\n") + f.write(f"Arguments: {vars(args)}\n\n") + traceback.print_exc(file=f) + + print(f"\nError: pipeline failed. " f"Full traceback written to {log_file}") + traceback.print_exc() + sys.exit(1) + + +def _run_pipeline(args: Namespace, *, credentials_path: str | None) -> None: + """Run the full benchmarking pipeline. + + Args: + args: Parsed command-line arguments. + credentials_path: Resolved Google credentials path, or + ``None`` when the user did not opt in to ``--write-sheets``. + """ + benchmark = Benchmark( + path_to_application=args.path_to_application, + path_to_uxhw_sdk=args.path_to_uxhw_sdk, + path_to_pin=args.path_to_pin, + has_analytic_ground_truth=(args.has_analytic_ground_truth), + path_to_ground_truth_file=(args.path_to_ground_truth_file), + ground_truth_type=args.ground_truth_type, + distance_type=args.distance_type, + use_binned_uxhw=args.use_binned_uxhw, + ground_truth_size=args.ground_truth_size, + adversary_mc_size=args.adversary_mc_size, + adversary_max_size_scalar=(args.adversary_max_size_scalar), + use_clt=args.use_clt, + representation_types=(args.representation_types), + representation_sizes=(args.representation_sizes), + reporting_methods=args.reporting_methods, + correlations=args.correlations, + max_num_weighted_samples=(args.max_num_weighted_samples), + num_parallel_workers=(args.num_parallel_workers), + n_adversaries=args.n_adversaries, + max_jupiter_size=args.max_jupiter_size, + demo_cli_args=args.demo_cli_args, + google_credentials=args.google_credentials, + ) + + # Get the machine information + _log_step(1, "Collecting machine info...") + benchmark.get_machine_info() + + # Worker count bounding the three MC sample-generation pools (ground-truth, + # asymptotic-distance, adversary-DB). get_machine_info has resolved an unset + # -j to the core count, so this caps the MC stages at -j without exceeding + # detected cores. + if benchmark.num_parallel_workers is None: + raise RuntimeError("num_parallel_workers is unset after get_machine_info().") + mc_worker_count = min(benchmark.num_parallel_workers, benchmark.n_processors) + + # Get the benchmarking information + _log_step(2, "Loading application info...") + benchmark.get_application_info() + + # Bind the common run_timing_script kwargs upfront. Each database + # generator passes only the mode-specific flag (tracing=True) plus any + # per-variable index. + _bound_run_timing_script = benchmark.bind_timing_script() + + # Generate the Ground Truth database + _log_step(3, "Generating ground truth database...") + generate_ground_truth_database( + has_analytic_ground_truth=benchmark.has_analytic_ground_truth, + has_native_mc=benchmark.has_native_mc, + benchmarking_variables=benchmark.benchmarking_variables, + path_to_application=benchmark.path_to_application, + path_to_ground_truth_file=getattr(benchmark, "path_to_ground_truth_file", ""), + ground_truth_size=benchmark.ground_truth_size, + ground_truth_db_path=benchmark.ground_truth_db_path, + ground_truth_type=benchmark.ground_truth_type, + max_num_weighted_samples=benchmark.max_num_weighted_samples, + n_processors=mc_worker_count, + n_adversaries=benchmark.n_adversaries, + adversary_max_size_scalar=benchmark.adversary_max_size_scalar, + use_clt=benchmark.use_clt, + native_executable_name=benchmark.native_executable_name, + native_executable_dir=benchmark.native_executable_dir, + demo_cli_args=benchmark.demo_cli_args, + run_timing_script=_bound_run_timing_script, + ) + + # Generate the UxHw tracing database + _log_step(4, "Generating UxHw tracing database...") + generate_uxhw_tracing_database( + benchmarking_variables=benchmark.benchmarking_variables, + tracing_db_path=benchmark.tracing_db_path, + run_timing_script=_bound_run_timing_script, + ) + + # Compute asymptotic distance distributions + _log_step( + 5, + "Computing asymptotic distance distributions...", + ) + generate_asymptotic_distance_distributions( + benchmarking_variables=benchmark.benchmarking_variables, + ground_truth_db_path=benchmark.ground_truth_db_path, + ground_truth_type=benchmark.ground_truth_type, + distance_type=benchmark.distance_type, + asymptotic_dist_file=benchmark.asymptotic_dist_file, + n_processors=mc_worker_count, + path_to_application=benchmark.path_to_application, + native_executable_name=benchmark.native_executable_name, + native_executable_dir=benchmark.native_executable_dir, + demo_cli_args=benchmark.demo_cli_args, + ) + + # Compute UxHw distances + _log_step(6, "Computing UxHw distances...") + compute_uxhw_distances( + benchmarking_variables=benchmark.benchmarking_variables, + distance_type=benchmark.distance_type, + ground_truth_db_path=benchmark.ground_truth_db_path, + ground_truth_type=benchmark.ground_truth_type, + tracing_db_path=benchmark.tracing_db_path, + representation_types=benchmark.representation_types, + representation_sizes=benchmark.representation_sizes, + uxhw_distance_file=benchmark.uxhw_distance_file, + ) + + # Compute EMCC predictions + _log_step(7, "Computing EMCC predictions...") + compute_emcc_predictions( + benchmarking_variables=benchmark.benchmarking_variables, + use_binned_uxhw=benchmark.use_binned_uxhw, + reporting_methods=benchmark.reporting_methods, + output_data_file=benchmark.output_data_file, + uxhw_distance_file=benchmark.uxhw_distance_file, + asymptotic_dist_file=benchmark.asymptotic_dist_file, + distance_type=benchmark.distance_type, + ) + + # Generate the adversary database + _log_step(8, "Generating adversary database...") + generate_adversary_database( + has_native_mc=benchmark.has_native_mc, + adversary_mc_size=benchmark.adversary_mc_size, + adversary_db_path=benchmark.adversary_db_path, + benchmarking_variables=benchmark.benchmarking_variables, + n_processors=mc_worker_count, + n_adversaries=benchmark.n_adversaries, + ground_truth_size=benchmark.ground_truth_size, + adversary_max_size_scalar=benchmark.adversary_max_size_scalar, + use_clt=benchmark.use_clt, + path_to_application=benchmark.path_to_application, + native_executable_name=benchmark.native_executable_name, + native_executable_dir=benchmark.native_executable_dir, + demo_cli_args=benchmark.demo_cli_args, + run_timing_script=_bound_run_timing_script, + ) + + # Compute Equivalent Monte Carlo + _log_step(9, "Computing equivalent Monte Carlo...") + compute_equivalent_mc( + benchmarking_variables=benchmark.benchmarking_variables, + output_data_file=benchmark.output_data_file, + asymptotic_dist_file=benchmark.asymptotic_dist_file, + ground_truth_db_path=benchmark.ground_truth_db_path, + has_analytic_ground_truth=benchmark.has_analytic_ground_truth, + ground_truth_type=benchmark.ground_truth_type, + adversary_db_path=benchmark.adversary_db_path, + tracing_db_path=benchmark.tracing_db_path, + representation_types=benchmark.representation_types, + representation_sizes=benchmark.representation_sizes, + correlations=benchmark.correlations, + num_parallel_workers=benchmark.num_parallel_workers, + n_adversaries=benchmark.n_adversaries, + distance_type=benchmark.distance_type, + use_binned_uxhw=benchmark.use_binned_uxhw, + use_clt=benchmark.use_clt, + reporting_methods=benchmark.reporting_methods, + plots_dir=benchmark.plots_dir, + # User-facing flag names map to the internal EMCC keys, which keep + # their original spellings. + plot_comparison_distributions=args.plot_distance_vs_asymptotic, + plot_adversary_distances=args.plot_adversary_distances, + plot_distributions=args.plot_representative_mc, + ) + + # Generate the UxHw timing data + _log_step(10, "Generating UxHw timing data...") + benchmark.generate_uxhw_timings() + + # Generate the Monte Carlo timing data + _log_step(11, "Generating native MC timing data...") + benchmark.generate_native_mc_timings() + + # Load all measurements + _log_step(12, "Loading measurement data...") + load_emcc_data( + benchmarking_variables=benchmark.benchmarking_variables, + output_data_file=benchmark.output_data_file, + ) + benchmark.uxhw_version = load_measurement_dicts( + benchmarking_variables=benchmark.benchmarking_variables, + intermediate_path=benchmark.intermediate_timings_path(), + json_path=benchmark.json_timings_path(), + logs_dir=benchmark.logs_dir, + demo_cli_args=benchmark.demo_cli_args, + representation_sizes=benchmark.representation_sizes, + representation_types=benchmark.representation_types, + correlations=benchmark.correlations, + ) + + # Load equivalent MC timing data from native executions. emcc_data is + # already populated by step 12 above. Note that load_emcc_data is + # idempotent. + _log_step(13, "Loading timing data...") + load_timing_data_to_dfs( + benchmarking_variables=benchmark.benchmarking_variables, + ) + + # Compute per-record speedups + _log_step(14, "Computing results...") + compute_speedups( + benchmarking_variables=benchmark.benchmarking_variables, + ) + + # Write results to markdown and spreadsheet + _log_step(15, "Writing output files...") + write_results_to_markdown( + benchmarking_variables=benchmark.benchmarking_variables, + results_dir=benchmark.results_dir, + ) + if credentials_path is None: + print("Skipping Google Sheets upload; pass --write-sheets to opt in.") + else: + write_results_to_spreadsheet( + benchmarking_variables=benchmark.benchmarking_variables, + credentials_path=credentials_path, + reporting_methods=benchmark.reporting_methods, + application_name=benchmark.application_name, + application_version=benchmark.application_version, + uxhw_version=benchmark.uxhw_version, + machine_name=benchmark.machine_name, + git_repo_remote=benchmark.git_repo_remote, + plots_dir=benchmark.plots_dir, + ) + + +if __name__ == "__main__": + main() diff --git a/src/signaloid/benchmarking/automation/benchmark_test.py b/src/signaloid/benchmarking/automation/benchmark_test.py new file mode 100644 index 0000000..973323d --- /dev/null +++ b/src/signaloid/benchmarking/automation/benchmark_test.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +"""Unit tests for ``Benchmark.bind_timing_script``. + +``bind_timing_script`` binds the eight ``run_timing_script`` kwargs shared by +every timing pass into a ``functools.partial``. Callers then supply only the +per-call ``variable_index`` and one mode flag. It replaced four hand-maintained +copies of the same kwarg bundle, so these tests pin exactly which kwargs are +bound (and that the per-call args are deliberately left unbound). +""" + +import functools +import unittest + +from signaloid.benchmarking.automation.benchmark import Benchmark +from signaloid.benchmarking.automation.build import run_timing_script + + +class TestBindTimingScript(unittest.TestCase): + def _make_benchmark(self) -> Benchmark: + benchmark = Benchmark( + path_to_application="/tmp/app", + representation_types=["Athens"], + representation_sizes=[16, 32], + demo_cli_args="--demo", + ) + # Normally populated by get_application_info / get_machine_info. + benchmark.all_outputs_cla = "-S 1" + benchmark.benchmarking_variables = [] + benchmark.application_name = "app" + benchmark.application_version = "v0" + return benchmark + + def test_binds_shared_kwargs(self) -> None: + benchmark = self._make_benchmark() + + bound = benchmark.bind_timing_script() + + self.assertIsInstance(bound, functools.partial) + self.assertIs(bound.func, run_timing_script) + self.assertEqual( + bound.keywords, + { + "all_outputs_cla": "-S 1", + "benchmarking_variables": [], + "demo_cli_args": "--demo", + "representation_types": benchmark.representation_types, + "representation_sizes": benchmark.representation_sizes, + "correlations": benchmark.correlations, + "logs_dir": benchmark.logs_dir, + "intermediate_timings_path": benchmark.intermediate_timings_path(), + }, + ) + + def test_per_call_args_not_prebound(self) -> None: + bound = self._make_benchmark().bind_timing_script() + for per_call_key in ( + "variable_index", + "timing", + "native_mc_timing", + "tracing", + ): + self.assertNotIn(per_call_key, bound.keywords) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/benchmarking_utils.py b/src/signaloid/benchmarking/automation/benchmarking_utils.py new file mode 100644 index 0000000..cf3741d --- /dev/null +++ b/src/signaloid/benchmarking/automation/benchmarking_utils.py @@ -0,0 +1,629 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from contextlib import contextmanager +from typing import Any, Iterable, Iterator +import subprocess +import os +import shlex +import pandas as pd +from signaloid.benchmarking.config import ( + BenchmarkingVariables, + EquivMC, + TimingFormat, +) +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, +) +import re +import numpy as np +import tempfile +import csv + +_SAMPLES_KEY = "_samples" + + +def _symlink_application_inputs(inputs_dir: str, dest_dir: str) -> None: + """ + Symlink an application's ``inputs/`` files into a run directory. + + The native binary writes its samples to a *relative* ``data.out`` in + its working directory. A stale ``data.out`` left in ``inputs/`` (the + bash timing layer runs the binary there) must never be symlinked in: + the binary's ``fopen("data.out", "w")`` would follow the symlink and + write *through* it to the single shared file, so concurrent workers + (``-j > 1``) would clobber one another and different variables would + end up with identical samples. + + Args: + inputs_dir: The application's ``inputs/`` directory. + dest_dir: The per-run working directory to populate with symlinks. + """ + # Resolve to absolute paths so the symlink targets are valid even when + # a relative application path was provided (os.symlink stores the + # target verbatim, so a relative target would resolve against dest_dir). + inputs_dir = os.path.abspath(inputs_dir) + dest_dir = os.path.abspath(dest_dir) + if not os.path.isdir(inputs_dir): + return + for entry in os.listdir(inputs_dir): + if entry == EquivMC.MC_OUTPUT_FILENAME: + # Never expose the binary's output file as an input. + continue + os.symlink( + os.path.join(inputs_dir, entry), + os.path.join(dest_dir, entry), + ) + + +def _read_pin_inst_count(path: str) -> float: + """ + Read an inscount.out file produced by Intel PIN and return the count. + + Args: + path: Path to the inscount.out file written by PIN's inscount0.so + tool. Both absolute and relative paths are accepted. Relative + paths are resolved against the current working directory. The + file contains a single line of the form ``Count ``. + + Returns: + The instruction count as a float. + + Raises: + ValueError: If the file content does not match the expected format. + OSError: If the file cannot be opened. + """ + with open(path) as f: + content = f.read().strip() + if not content.startswith("Count "): + raise ValueError(f"Unexpected inscount.out format in {path!r}: {content!r}") + return float(content.removeprefix("Count ")) + + +def _resolve_pin_inst_from_samples( + samples: dict[str, list[str]], +) -> float | None: + """ + Average per-iteration PIN instruction counts from collected sample files. + + Each ``samples`` value is a list of file paths whose counts are summed to + give that iteration's total. The per-iteration totals are then averaged. + + Args: + samples: Mapping from iteration index to a list of inscount.out + paths whose counts are summed for that iteration. + + Returns: + The averaged instruction count, or ``None`` if ``samples`` is empty. + """ + if not samples: + return None + per_iteration_totals: list[float] = [] + for paths in samples.values(): + iteration_total = sum(_read_pin_inst_count(p) for p in paths) + per_iteration_totals.append(iteration_total) + return sum(per_iteration_totals) / len(per_iteration_totals) + + +def _resolve_time_from_samples( + samples: dict[str, list[str]], +) -> float | None: + """ + Average per-iteration float values from collected SAMPLE values. + + Like :func:`_resolve_pin_inst_from_samples` but for value-typed SAMPLE + entries, where each value is a literal float string rather than a file path. + Values are summed within an iteration, then averaged across iterations. + Being field-agnostic, it serves the elapsedTime, databaseTime, and + databaseDynInstCount fields uniformly. + + Args: + samples: Mapping from iteration index to a list of literal + float-string values emitted by the bash side as SAMPLE values for a + value-typed field. + + Returns: + The averaged value, or ``None`` if ``samples`` is empty or every value + is non-numeric. + """ + if not samples: + return None + per_iteration_totals: list[float] = [] + for tokens in samples.values(): + valid: list[float] = [] + for value_str in tokens: + try: + valid.append(float(value_str)) + except ValueError: + continue + if not valid: + continue + per_iteration_totals.append(sum(valid)) + if not per_iteration_totals: + return None + return sum(per_iteration_totals) / len(per_iteration_totals) + + +def parse_timing_intermediate_stream(lines: Iterable[str]) -> list[dict]: + """ + Parse the tagged line stream written by get-timings.sh into a list + of per-run dicts. + + A new run begins whenever a `META timestamp ...` line is seen. All + subsequent META lines attach to that run, SAMPLE lines accumulate + per-iteration tokens keyed by (config, field), and MEASUREMENT + lines append to the run's `measurements` list. When a MEASUREMENT + line arrives, any field whose value is the ``?`` sentinel is + resolved from the accumulated SAMPLE data for the matching + (config, field): path-typed samples (e.g. ``pinDynInstCount``) are + resolved by reading and summing the referenced inscount.out files + per iteration before averaging. Value-typed samples (e.g. + ``elapsedTime``, ``databaseTime``, ``databaseDynInstCount``) are + float-parsed in place and averaged the same way. Session-level + META keys (see + TimingFormat.SESSION_META_KEYS) are preserved on every run dict + at this stage and are promoted to the top level by the caller + that writes the canonical JSON document. + + Args: + lines: Iterable of raw lines (with or without trailing + newline). Accepts a file object, a list of strings, or any + other iterable so the source can be swapped from a file to + a live pipe without touching this logic. + + Returns: + A list of run dicts, each carrying the META keys at the top + level and a `measurements` list of per-config dicts. + """ + runs: list[dict] = [] + current: dict | None = None + for raw in lines: + line = raw.rstrip("\n") + if not line: + continue + tag, _, rest = line.partition(" ") + if tag == TimingFormat.META_TAG: + key, _, value = rest.partition(" ") + if key == TimingFormat.META_KEY_TIMESTAMP: + if current is not None: + _finalise_run(current) + runs.append(current) + current = { + TimingFormat.JSON_KEY_MEASUREMENTS: [], + _SAMPLES_KEY: {}, + } + if current is None: + # META line before any timestamp: be permissive and + # open a fresh run so keys are not dropped. + current = { + TimingFormat.JSON_KEY_MEASUREMENTS: [], + _SAMPLES_KEY: {}, + } + current[key] = value + elif tag == TimingFormat.SAMPLE_TAG: + if current is None: + continue + tokens = rest.split(maxsplit=3) + if len(tokens) != 4: + continue + s_config, s_field, s_iteration, s_token = tokens + sample_key = (s_config, s_field) + iteration_map: dict[str, list[str]] = current[_SAMPLES_KEY].setdefault( + sample_key, {} + ) + iteration_map.setdefault(s_iteration, []).append(s_token) + elif tag == TimingFormat.MEASUREMENT_TAG: + if current is None: + continue + tokens = rest.split() + if len(tokens) != 6: + continue + config, t_val, db_t, e2e_t, db_i, pin_i = tokens + + def _num(token: str) -> float | None: + if token == TimingFormat.MISSING_VALUE: + return None + return float(token) + + time_value = _num(t_val) + if time_value is None: + sample_key = (config, TimingFormat.SAMPLE_FIELD_TIME) + iteration_map = current[_SAMPLES_KEY].pop(sample_key, {}) + time_value = _resolve_time_from_samples(iteration_map) + + db_time_value = _num(db_t) + if db_time_value is None: + sample_key = (config, TimingFormat.SAMPLE_FIELD_DB_TIME) + iteration_map = current[_SAMPLES_KEY].pop(sample_key, {}) + db_time_value = _resolve_time_from_samples(iteration_map) + + db_inst_value = _num(db_i) + if db_inst_value is None: + sample_key = ( + config, + TimingFormat.SAMPLE_FIELD_DB_DYN_INST_COUNT, + ) + iteration_map = current[_SAMPLES_KEY].pop(sample_key, {}) + db_inst_value = _resolve_time_from_samples(iteration_map) + + pin_inst_value = _num(pin_i) + if pin_inst_value is None: + sample_key = ( + config, + TimingFormat.SAMPLE_FIELD_PIN_INST, + ) + iteration_map = current[_SAMPLES_KEY].pop(sample_key, {}) + pin_inst_value = _resolve_pin_inst_from_samples(iteration_map) + + current[TimingFormat.JSON_KEY_MEASUREMENTS].append( + { + TimingFormat.JSON_KEY_MEASUREMENT_CONFIG: config, + TimingFormat.JSON_KEY_MEASUREMENT_TIME: time_value, + TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME: db_time_value, + TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME: _num(e2e_t), + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT: ( + db_inst_value + ), + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT: ( + pin_inst_value + ), + } + ) + if current is not None: + _finalise_run(current) + runs.append(current) + return runs + + +def _finalise_run(run: dict) -> None: + """Remove internal bookkeeping keys before a run dict is returned. + + Args: + run: A run dict that may contain the private ``_SAMPLES_KEY`` + entry added during parsing. + """ + run.pop(_SAMPLES_KEY, None) + + +def get_git_remote(directory: str) -> str | None: + """ + Return the normalised ``origin`` remote URL of a Git repository. + + Strips the protocol, any embedded token, and the ``.git`` suffix. + + Args: + directory: Path to the repository to inspect. + + Returns: + The normalised remote URL, or ``None`` if ``directory`` is not a Git + repository or has no ``origin`` remote. + """ + + if not os.path.isdir(os.path.join(directory, ".git")): + print("Not a Git repository") + return None + + try: + # Run the 'git remote get-url origin' command + remote_url = subprocess.check_output( + ["git", "remote", "get-url", "origin"], + cwd=directory, + stderr=subprocess.DEVNULL, + text=True, + ).strip() + + # Handle SSH format: git@github.com:user/repo.git + if "git@" in remote_url: + remote_url = remote_url.split("git@")[1] + remote_url = remote_url.replace(":", "/") + # Handle HTTPS format: https://github.com/user/repo.git + elif remote_url.startswith("https://"): + # Remove the protocol + remote_url = remote_url.replace("https://", "") + # Remove token if present (TOKEN@github.com -> github.com) + if "@" in remote_url: + remote_url = remote_url.split("@", 1)[1] + + # Remove .git suffix if present + if remote_url.endswith(".git"): + remote_url = remote_url[:-4] + + print(f"Remote URL: {remote_url}") + return remote_url + + except subprocess.CalledProcessError: + print("No remote repository found") + return None + + +@contextmanager +def _native_mc_run( + prefix: str, + path: str, + executable: str, + executable_dir: str, + cla: str, + sample_size: int, +) -> Iterator[tuple["subprocess.CompletedProcess[str]", str]]: + """ + Run the native MC binary in a throwaway working directory. + + Sets up an isolated temp dir, symlinks the application's ``inputs/``. Not + the binary's own ``data.out`` (see _symlink_application_inputs). Then, runs + `` -M `` there. The temp dir (and the yielded + file) live only for the ``with`` body. + + Args: + prefix: Prefix for the temporary directory name. + path: Application root (its ``inputs/`` is symlinked in). + executable: Native binary filename. + executable_dir: Directory containing the native binary. + cla: Command-line arguments passed to the binary. + sample_size: Monte Carlo sample count (passed as ``-M``). + + Yields: + A tuple of (completed process, path to the binary's ``data.out``). + """ + with tempfile.TemporaryDirectory(prefix=prefix) as work_dir: + _symlink_application_inputs(os.path.join(path, "inputs"), work_dir) + + argv = [ + os.path.join(executable_dir, executable), + *shlex.split(cla), + "-M", + str(sample_size), + ] + result = subprocess.run(argv, cwd=work_dir, capture_output=True, text=True) + yield result, os.path.join(work_dir, EquivMC.MC_OUTPUT_FILENAME) + + +def _run_scalar_native( + path: str, + executable: str, + executable_dir: str, + cla: str, + sample_size: int, + run_id: int, +) -> tuple[int, float | None]: + """ + Run a native MC execution whose output is a scalar. + + Args: + path: Application root (its ``inputs/`` is symlinked in). + executable: Native binary filename. + executable_dir: Directory containing the native binary. + cla: Command-line arguments passed to the binary. + sample_size: Monte Carlo sample count. + run_id: Identifier for this run, used in warnings and the temp-dir name. + + Returns: + A tuple of (sample_size, scalar value), where the value is ``None`` if + the run failed or its output could not be parsed. + """ + value: float | None = None + with _native_mc_run( + f"scalar_run_{run_id}_", path, executable, executable_dir, cla, sample_size + ) as (result, data_file): + if result.returncode != 0: + print( + f"Warning: Scalar run {run_id} (size={sample_size}) returned non-zero " + f"exit code: {result.stderr}" + ) + + try: + with open(data_file, "r") as f: + for line_no, line in enumerate(f): + if line_no == 1: + value = float(line.strip()) + break + except Exception as e: + print( + f"Error reading {EquivMC.MC_OUTPUT_FILENAME} in " + f"{os.path.dirname(data_file)}: {e}" + ) + + return sample_size, value + + +def clean_data_for_sheets(rows: list[list[Any]]) -> list[list[Any]]: + """ + Clean row data for Google Sheets API compatibility. + + Renders distribution objects via ``repr``, blanks out NaN cells, and casts + numpy scalars to plain floats. + + Args: + rows: Rows of cell values to clean. + + Returns: + The cleaned rows. + """ + cleaned_rows: list[list[Any]] = [] + for row in rows: + cleaned_row: list[Any] = [] + for cell in row: + if isinstance(cell, (DistributionalValue, TaggedDistributionalValue)): + cleaned_row.append(repr(cell)) + elif pd.isna(cell): + cleaned_row.append("") + elif isinstance(cell, (np.integer, np.floating)): + cleaned_row.append(float(cell)) + else: + cleaned_row.append(cell) + cleaned_rows.append(cleaned_row) + return cleaned_rows + + +def expand_array_expressions(data: list[dict]) -> list[dict]: + """ + Expand array expressions like 'outputVariables[0:5]' into individual entries. + + Args: + data: list of dictionaries with 'Expression' key containing array expressions + + Returns: + Expanded list with individual entries for each array index + """ + expanded = [] + + for item in data: + expression = item["Expression"] + + # Check if expression contains array slice notation [start:end] + array_match = re.match(r"(\w+)\[(\d+):(\d+)\]", expression) + + if array_match: + array_name = array_match.group(1) + start_idx = int(array_match.group(2)) + end_idx = int(array_match.group(3)) + + # Create individual entries for each index + for i in range(start_idx, end_idx + 1): + new_item = item.copy() + new_item["Expression"] = f"{array_name}[{i}]" + expanded.append(new_item) + else: + # Not an array expression, keep as-is + expanded.append(item) + + return expanded + + +def _run_mc_simulation( + work_item: tuple[str, int, str], + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, +) -> list[float]: + """ + Run a single MC simulation in a separate process. + + Args: + work_item: A ``(index, sample_size, cla)`` tuple describing the run. + path_to_application: Application root (its ``inputs/`` is symlinked in). + native_executable_name: Native binary filename. + native_executable_dir: Directory containing the native binary. + + Returns: + The scalar values parsed from the run's ``data.out``. + + Raises: + RuntimeError: If the simulation exits with a non-zero status. + """ + index, sub_size, cla = work_item + + values: list[float] = [] + with _native_mc_run( + f"mc_sim_{index}_", + path_to_application, + native_executable_name, + native_executable_dir, + cla, + sub_size, + ) as (result, data_file): + if result.returncode != 0: + raise RuntimeError( + f"Simulation {index} returned non-zero exit code: {result.stderr}" + ) + + # Read results + if os.path.exists(data_file): + with open(data_file, "r") as file: + for line_no, line in enumerate(file): + if line_no > 0: # Skip header + try: + values.append(float(line.strip())) + except ValueError: + continue + + return values + + +def compute_emcc_prediction( + asymptotic_dist_quantity: float | None, + distance: float, +) -> int: + """ + Predict the equivalent Monte Carlo count from an asymptotic quantity. + + Computes ``(asymptotic_dist_quantity / distance) ** 2``, clamped to a + minimum of 1. Returns 1 if the inputs are missing or invalid (e.g. a + ``None`` quantity or a zero distance). + + Args: + asymptotic_dist_quantity: The asymptotic-distance statistic, or + ``None`` when unavailable. + distance: The UxHw-to-ground-truth distance. + + Returns: + The predicted EMCC (at least 1). + """ + try: + emcc_predicted = int((asymptotic_dist_quantity / distance) ** 2) # type: ignore[operator] # noqa: E501 + # Check results make sense + if emcc_predicted < 1: + print("Warning! Predicted EMCC values are zero!") + emcc_predicted = 1 + except (TypeError, ZeroDivisionError, ValueError) as e: + print(f"Warning. EMCC prediction failed with: {e}.") + emcc_predicted = 1 + + return emcc_predicted + + +def write_uxhw_distance_file( + filename: str, benchmarking_variables: list[BenchmarkingVariable] +) -> None: + """ + Write UxHw distance data to a CSV file. + + Args: + filename: Path to the CSV file to write. + benchmarking_variables: Benchmarking variables to write, each with a + ``description`` and ``uxhw_distances``. + """ + header = [ + BenchmarkingVariables.VARIABLE_DESCRIPTION, + BenchmarkingVariables.UXHW_CONF, + BenchmarkingVariables.UXHW_DISTANCE, + BenchmarkingVariables.UXHW_BINNED_DISTANCE, + ] + + with open(filename, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(header) + + rows = [ + [ + variable.description, + repr(record.uxhw_conf), + record.uxhw_distance, + ( + "" + if record.uxhw_binned_distance is None + else record.uxhw_binned_distance + ), + ] + for variable in benchmarking_variables + for record in variable.uxhw_distances.records + ] + writer.writerows(rows) diff --git a/src/signaloid/benchmarking/automation/build.py b/src/signaloid/benchmarking/automation/build.py new file mode 100644 index 0000000..11e560f --- /dev/null +++ b/src/signaloid/benchmarking/automation/build.py @@ -0,0 +1,434 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import shlex +import subprocess +import sys + +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import ( + EquivMC, + TimingFormat, + get_repo_root, + get_resources_dir, +) + + +def export_timing_env( + *, + path_to_uxhw_sdk: str, + path_to_pin: str | None, + path_to_application: str, + application_name: str, + application_version: str, + max_jupiter_size: int, + results_dir: str, + logs_dir: str, + tracing_db_path: str, +) -> str: + """ + Export the environment variables needed by the timing bash scripts. + + Called after ``get_application_info()`` so all paths and config are + available. ``TRACING_DB_ABS`` is preserved when already set. Otherwise + ``tracing_db_path`` is used and exported. + + Args: + path_to_uxhw_sdk: Path to the UxHw SDK. + path_to_pin: Path to the Intel PIN kit, exported as ``PIN_ROOT``. + ``None`` leaves any inherited ``PIN_ROOT`` in place. PIN is + mandatory, so the run errors if neither is set. + path_to_application: Path to the application source tree. + application_name: Application name (e.g. ``Finance-...``). + application_version: Application version string. + max_jupiter_size: Maximum Jupiter limit. + results_dir: Directory where results artifacts are written. + logs_dir: Directory where logs are written. + tracing_db_path: Default tracing-database path. Overridden when + ``TRACING_DB_ABS`` is already set. + + Returns: + The resolved tracing-database path (also written to + ``TRACING_DB_ABS``). Callers should store it back to + ``self.tracing_db_path``. + """ + os.environ["SIGNALOID_PYTHON_DIR"] = os.fspath(get_repo_root()) + os.environ["BENCHMARKING_RESOURCES_DIR"] = str(get_resources_dir()) + os.environ["PATH_TO_UXHW_SDK"] = path_to_uxhw_sdk + # PIN is mandatory: when no path is given we leave any shell-set + # PIN_ROOT in place. There is no built-in default. get-timings.sh + # will error clearly if neither is set. + if path_to_pin: + os.environ["PIN_ROOT"] = path_to_pin + # The timing bash layer shells out to `python3 -m + # signaloid.benchmarking...`. We pass our own interpreter so those + # subprocesses use this venv (with the benchmarking dependencies) even when + # the venv is not on PATH / not activated. + os.environ["BENCHMARKING_PYTHON"] = sys.executable + os.environ["APPLICATION_PATH"] = path_to_application + os.environ["APPLICATION_NAME"] = application_name + os.environ["APPLICATION_VERSION"] = application_version + os.environ["PROGRAM"] = "main" + os.environ["CLA_FOR_MULTIPLE_EXECUTIONS"] = "-M" + os.environ["MAX_JUPITER_LIMIT"] = str(max_jupiter_size) + os.environ["APPEND_TO_OUTPUT_FILE"] = "0" + os.environ["NATIVE_MC_REPETITION"] = "1" + os.environ["RESULTS_DIR"] = results_dir + os.environ["LOGS_DIR"] = logs_dir + resolved_tracing_db_path = os.environ.get("TRACING_DB_ABS", tracing_db_path) + os.environ["TRACING_DB_ABS"] = resolved_tracing_db_path + return resolved_tracing_db_path + + +def run_timing_script( + *, + all_outputs_cla: str, + benchmarking_variables: list[BenchmarkingVariable], + demo_cli_args: str, + representation_types: list[str], + representation_sizes: list[int], + correlations: list[str], + logs_dir: str, + intermediate_timings_path: str, + timing: bool = False, + tracing: bool = False, + native_mc_timing: bool = False, + variable_index: int = 1, +) -> None: + """ + Run the timing bash script with the appropriate environment + variables and mode-specific bash array variables. + + Args: + all_outputs_cla: The all-outputs invocation CLA string. + benchmarking_variables: Variables to run timing against. + demo_cli_args: Demo-level command-line arguments string. + representation_types: Configured representation types. + representation_sizes: Configured representation sizes. + correlations: Configured correlation modes. + logs_dir: Directory where the bash stderr log is written. + intermediate_timings_path: Absolute path of the transient + intermediate timings file the bash script writes. + timing: When ``True``, run the per-variable UxHw-timing pass. + tracing: When ``True``, run the per-variable UxHw-tracing + pass. + native_mc_timing: When ``True``, run the per-variable native-MC + timing pass. + variable_index: Index into ``benchmarking_variables`` for the + per-variable passes (tracing, timing, native_mc_timing). + + Raises: + RuntimeError: If the bash timing script exits with a non-zero + status code. The captured stderr (from + ``timing_script_stderr.log``) is included in the message. + """ + # Determine skip flags + skip_uxhw = 1 + skip_native_mc = 1 + skip_tracing = 1 + + command_line_arguments = all_outputs_cla + variables = benchmarking_variables + native_mc_sizes: str = "50 100" + + if tracing: + skip_tracing = 0 + command_line_arguments = benchmarking_variables[variable_index].cla + variables = [benchmarking_variables[variable_index]] + if timing: + skip_uxhw = 0 + command_line_arguments = "-T " + benchmarking_variables[variable_index].cla + variables = [benchmarking_variables[variable_index]] + if native_mc_timing: + skip_native_mc = 0 + command_line_arguments = benchmarking_variables[variable_index].cla + native_mc_sizes = " ".join( + map( + str, + benchmarking_variables[variable_index].emcc_results.equiv_mc_list, + ) + ) + variables = [benchmarking_variables[variable_index]] + + # Set mode-specific scalar env vars + if demo_cli_args: + command_line_arguments = f"{demo_cli_args} {command_line_arguments}".strip() + os.environ["CLA"] = command_line_arguments + os.environ["SKIP_UXHW"] = str(skip_uxhw) + os.environ["SKIP_UXHW_TRACING"] = str(skip_tracing) + os.environ["SKIP_NATIVE_MC"] = str(skip_native_mc) + + # Wire the timing-intermediate tokens and file path through to + # the bash script so its emitted tags match what the Python + # reader expects (see TimingFormat in config.py). + os.environ[TimingFormat.META_TAG_ENV_VAR] = TimingFormat.META_TAG + os.environ[TimingFormat.MEASUREMENT_TAG_ENV_VAR] = TimingFormat.MEASUREMENT_TAG + os.environ[TimingFormat.SAMPLE_TAG_ENV_VAR] = TimingFormat.SAMPLE_TAG + os.environ[TimingFormat.INTERMEDIATE_FILE_ENV_VAR] = intermediate_timings_path + + # Build bash arrays (cannot be env vars) and source the script + representations = " ".join(map(str, representation_types)) + rep_sizes = " ".join(map(str, representation_sizes)) + correlations_str = " ".join(map(str, correlations)) + + traces_lines = "" + for variable in variables: + native_mc_sizes = " ".join(map(str, variable.emcc_results.equiv_mc_list)) + traces_lines += ( + " 'addDistValueTrace " + + f'{variable.name} "{variable.file_name}:{variable.line_number}"' + + "'\n" + ) + if len(native_mc_sizes) == 0: + native_mc_sizes = "50" + + bash_cmd = f""" +TRACES=( +{traces_lines}) +REFERENCE_PRECISIONS=({native_mc_sizes}) +REPRESENTATION_TYPES=({representations}) +REPRESENTATION_SIZES=({rep_sizes}) +CORRELATION_TRACKING_TYPES=({correlations_str}) +. $SIGNALOID_PYTHON_DIR/src/signaloid/benchmarking/benchmark_timing/get-timings.sh +""" + stderr_log = os.path.join(logs_dir, "timing_script_stderr.log") + # Use tee so stderr streams to terminal + # (preserving interactive prompts) and is + # also captured to a log file for debugging. + quoted_stderr_log = shlex.quote(stderr_log) + wrapped_cmd = f"{{ {bash_cmd.strip()} ; }} " f"2> >(tee {quoted_stderr_log} >&2)" + result = subprocess.call(["bash", "-c", wrapped_cmd]) + # After the first timing script call, switch to append mode + os.environ["APPEND_TO_OUTPUT_FILE"] = "1" + if result != 0: + stderr_content = "" + if os.path.isfile(stderr_log): + with open(stderr_log, "r") as f: + stderr_content = f.read().strip() + error_message = "Timing script failed " f"(exit code {result})." + if stderr_content: + error_message += "\nstderr:\n" + stderr_content + error_message += ( + "\nSee also:\n" f" - {logs_dir}/exec.stderr\n" f" - {logs_dir}/opt.err" + ) + raise RuntimeError(error_message) + + +def compile_native( + *, + path_to_application: str, + num_parallel_workers: int, +) -> tuple[str, str, str, bool]: + """ + Compile the application for native execution MC. + + Tries the following in order: + + 1. If a Makefile exists at the application root, + run ``make local-build``. + 2. Fall back to building a gcc command from + config.mk. + + Args: + path_to_application: Path to the application source tree. + num_parallel_workers: Number of parallel ``make -j`` workers. + + Returns: + Tuple ``(native_executable_name, native_compilation_command, + native_executable_dir, has_native_mc)``. The caller should + store these back to the corresponding ``Benchmark`` attributes. + When the gcc-fallback path returns early (no ``SOURCES`` in + ``config.mk``), the tuple is ``("", "", "", False)``. + """ + makefile_path = os.path.join(path_to_application, "Makefile") + + use_makefile = os.path.isfile(makefile_path) + if use_makefile: + print( + "Makefile detected. Attempting to compile native executable using 'make local-build'." + ) + native_executable_name = "demo-native-mc" + native_compilation_command = f"make -j{num_parallel_workers} local-build" + else: + print( + "No Makefile detected. Attempting to compile native executable using gcc and config.mk." + ) + config_path = f"{path_to_application}" "/src/config.mk" + + # Use make to expand variables (handles both plain lists + # and Make functions like $(wildcard ...), $(filter-out ...), etc.) + def _make_expand(var: str) -> str: + result = subprocess.run( + [ + "make", + "-f", + config_path, + "-f", + "-", + f"print-{var}", + ], + capture_output=True, + text=True, + cwd=f"{path_to_application}/src", + input=f"print-{var}:\n\t@echo $({var})\n", + ) + # Empty stdout means "unset" only from a *successful* make (e.g. no + # SOURCES, hence no native MC). A non-zero return (e.g. missing + # config.mk) also yields empty stdout and must not be read as unset. + if result.returncode != 0: + raise RuntimeError(f"make failed: {result.stderr}") + return result.stdout.strip() + + sources_str = _make_expand("SOURCES") + if not sources_str: + return ("", "", "", False) + sources = sources_str.split() + sources.append("uxhw.c") + + cflags_str = _make_expand("CFLAGS") + cflags = cflags_str.split() if cflags_str else [] + + # config.mk's -I/-L paths are written relative to src/ (like SOURCES), + # but we compile from the application root, so rebase relative include + # and library search paths onto src/ to match the src/-prefixed sources + # below. Absolute paths (e.g. -I/opt/local/include) are left untouched. + def _rebase_search_path(flag: str) -> str: + for opt in ("-I", "-L"): + if flag.startswith(opt): + path = flag[len(opt) :] + if path and not os.path.isabs(path): + return f"{opt}src/{path}" + return flag + + cflags = [_rebase_search_path(f) for f in cflags] + + native_executable_name = "demo-native-mc" + sources = [f"src/{s}" for s in sources] + parts = ( + [ + "gcc", + "-O3", + "-o", + native_executable_name, + "-Isrc", + "-I/opt/local/include", + ] + + cflags + + sources + + [ + "-L/opt/local/lib", + "-lgsl", + "-lgslcblas", + "-lm", + ] + ) + native_compilation_command = " ".join(parts) + + # Symlink input files into the application root, skipping any that already + # exist as non-symlinks (don't overwrite e.g. README.md). Also skip the + # binary's own data.out: symlinking a stale one would let a run there write + # back through it to the shared inputs/data.out (see + # benchmarking_utils._symlink_application_inputs). + subprocess.call( + # ``inputs/*`` is relative to cwd (this call's ``cwd=path_to_application``). + # Prefixing path_to_application would double it. + f"for f in inputs/*; do " + f'b="$(basename "$f")"; ' + f'if [ "$b" = "{EquivMC.MC_OUTPUT_FILENAME}" ]; then continue; fi; ' + f'if [ -e "$b" ] && [ ! -L "$b" ]; then ' + f'echo "WARNING: skipping symlink for $b (file already exists in application root)"; ' + f"continue; fi; " + f'ln -sf "$f" .; done', + shell=True, + cwd=path_to_application, + stdout=None, + stderr=subprocess.DEVNULL, + ) + + # Always compile from the application root + native_executable_dir = path_to_application + + # Clear any inherited jobserver flags to avoid + # "warning: jobserver unavailable: using -j1" in sub-makes. + clean_env = {**os.environ} + clean_env.pop("MAKEFLAGS", None) + + print( + f"Compiling native executable {native_executable_name} at {native_executable_dir}" + ) + if use_makefile: + clean_result = subprocess.run( + f"make -j{num_parallel_workers} clean", + shell=True, + cwd=native_executable_dir, + capture_output=True, + text=True, + env=clean_env, + ) + if clean_result.returncode != 0: + print( + f"Warning: 'make clean' failed (exit code {clean_result.returncode}). " + f"Continuing with build." + ) + result = subprocess.run( + native_compilation_command, + shell=True, + cwd=native_executable_dir, + capture_output=True, + text=True, + env=clean_env, + ) + if result.returncode != 0: + print("Native compilation failed " f"(exit code {result.returncode}).") + print(result.stdout) + print(result.stderr) + has_native_mc = False + else: + # Verify the executable was produced and resolve its location + root_path = os.path.join(path_to_application, native_executable_name) + src_path = os.path.join(path_to_application, "src", native_executable_name) + if os.path.isfile(root_path): + native_executable_dir = path_to_application + elif os.path.isfile(src_path): + native_executable_dir = os.path.join(path_to_application, "src") + else: + print( + f"Warning: Compilation succeeded but '{native_executable_name}' " + f"not found in {path_to_application} or {path_to_application}/src." + ) + has_native_mc = False + return ( + native_executable_name, + native_compilation_command, + native_executable_dir, + has_native_mc, + ) + has_native_mc = True + print( + f"Native compilation succeeded. " + f"Executable: {native_executable_dir}/{native_executable_name}" + ) + return ( + native_executable_name, + native_compilation_command, + native_executable_dir, + has_native_mc, + ) diff --git a/src/signaloid/benchmarking/automation/compare_tracing_ux_strings.py b/src/signaloid/benchmarking/automation/compare_tracing_ux_strings.py new file mode 100644 index 0000000..d3900a2 --- /dev/null +++ b/src/signaloid/benchmarking/automation/compare_tracing_ux_strings.py @@ -0,0 +1,279 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Compare the Ux strings produced by two tracing runs of the same application. + +The benchmarking tracing pass compiles the application at ``-O0`` (the +``OPTFLAGS`` default in ``Makefile.pro``) so the ``addDistValueTrace`` +``file:line`` directives resolve against unoptimised debug info. Real +deployments compile at ``-O2``. Optimisation must not change the values the +uncertainty machinery computes, so the Ux strings from the ``-O0`` and ``-O2`` +builds are expected to be byte-for-byte identical. This module loads the final +(last-written) Ux string for each traced expression from two tracing DBs and +reports any expression whose Ux string differs or is present in only one build. + +Invoked from the bash tracing layer (``get-timings.sh``) as:: + + python3 -m signaloid.benchmarking.automation.compare_tracing_ux_strings \ + [--table TABLE] [--config SUFFIX] \ + [--baseline-label O0] [--candidate-label O2] + +Exit status is ``0`` when the two builds produce identical Ux strings and +non-zero when they differ or cannot be compared. The caller treats a non-zero +status as a warning and continues. +""" + +import argparse +import sqlite3 +import sys +from contextlib import closing + +from signaloid.benchmarking.config import EquivMC + +# Table holding the per-execution emulator metadata, joined to the tracing +# table on Execution_ID = Execution_Info_Table_ID (see merge_tracing_dbs). +_EXECUTION_INFO_TABLE = "Emulator_Execution_Info" + +# Columns that together identify a single traced expression under one emulator +# configuration. Two rows sharing these values are writes to the same logical +# distributional value. The last such write is the final Ux string. This +# mirrors the grouping keys _load_uxhw uses when picking the last write. +_IDENTITY_COLUMNS = ( + "Expression_DeclarationFileName", + "Expression_Subprogram", + "Expression_Name", + "Expression_DeclarationLineNumber", + "UR_Type", + "UR_Order", + "UR_Order_CoreLibrary", + "CorrelationTracking_Status", +) + + +def _load_final_ux_strings(db_path: str, table: str) -> dict[tuple[object, ...], str]: + """ + Load the final Ux string for each traced expression from a tracing DB. + + Joins *table* to ``Emulator_Execution_Info`` on the execution foreign key, + orders by ``rowid`` (insertion order), and keeps the last write per + identity group. A program can write a value, jump back, and overwrite it, + so the last write (not the highest PC / assignment index) is the final + value — matching ``_load_uxhw``'s ``.last()`` semantics. + + Args: + db_path: Path to the tracing SQLite database. + table: Name of the traced-values table (e.g. ``TracingTable``). + + Returns: + Mapping from the identity tuple (``_IDENTITY_COLUMNS`` order) to the + last-written ``Dist_Value`` for that expression. + """ + identity_select = ", ".join(f't."{c}"' for c in _IDENTITY_COLUMNS[:4]) + exec_select = ", ".join(f'e."{c}"' for c in _IDENTITY_COLUMNS[4:]) + query = ( + f"SELECT {identity_select}, {exec_select}, t.Dist_Value " + f'FROM "{table}" t ' + f'JOIN "{_EXECUTION_INFO_TABLE}" e ' + f"ON e.Execution_ID = t.Execution_Info_Table_ID " + f"ORDER BY t.rowid" + ) + final: dict[tuple[object, ...], str] = {} + with closing(sqlite3.connect(db_path)) as connection: + for row in connection.execute(query): + key = tuple(row[: len(_IDENTITY_COLUMNS)]) + # Insertion order is preserved by ORDER BY rowid, so overwriting + # here leaves the last write as the final value. + final[key] = row[len(_IDENTITY_COLUMNS)] + return final + + +class ComparisonResult: + """Outcome of comparing two tracing DBs' Ux strings.""" + + def __init__( + self, + *, + matched: int, + mismatches: list[tuple[tuple[object, ...], str, str]], + only_in_baseline: list[tuple[object, ...]], + only_in_candidate: list[tuple[object, ...]], + ) -> None: + self.matched = matched + self.mismatches = mismatches + self.only_in_baseline = only_in_baseline + self.only_in_candidate = only_in_candidate + + @property + def is_identical(self) -> bool: + """True when every expression matched and none was missing on a side.""" + return ( + not self.mismatches + and not self.only_in_baseline + and not self.only_in_candidate + ) + + +def compare_ux_strings( + baseline_db: str, candidate_db: str, table: str +) -> ComparisonResult: + """ + Compare the final Ux strings of two tracing DBs for the same application. + + Args: + baseline_db: Path to the baseline (``-O0``) tracing DB. + candidate_db: Path to the candidate (``-O2``) tracing DB. + table: Name of the traced-values table in both DBs. + + Returns: + A :class:`ComparisonResult` describing matches, byte-level mismatches, + and expressions present in only one of the two builds. + """ + baseline = _load_final_ux_strings(baseline_db, table) + candidate = _load_final_ux_strings(candidate_db, table) + + matched = 0 + mismatches: list[tuple[tuple[object, ...], str, str]] = [] + for key, baseline_value in baseline.items(): + if key not in candidate: + continue + if candidate[key] == baseline_value: + matched += 1 + else: + mismatches.append((key, baseline_value, candidate[key])) + + only_in_baseline = [key for key in baseline if key not in candidate] + only_in_candidate = [key for key in candidate if key not in baseline] + + return ComparisonResult( + matched=matched, + mismatches=mismatches, + only_in_baseline=only_in_baseline, + only_in_candidate=only_in_candidate, + ) + + +def _format_key(key: tuple[object, ...]) -> str: + """Render an identity tuple as ``expr @ file:line [UR_Type/UR_Order/...]``.""" + fields = dict(zip(_IDENTITY_COLUMNS, key)) + return ( + f'{fields["Expression_Name"]} @ ' + f'{fields["Expression_DeclarationFileName"]}:' + f'{fields["Expression_DeclarationLineNumber"]} ' + f'[{fields["UR_Type"]}/{fields["UR_Order"]}/' + f'{fields["CorrelationTracking_Status"]}]' + ) + + +def _report( + result: ComparisonResult, + *, + baseline_label: str, + candidate_label: str, + config: str | None, +) -> None: + """Print a human-readable summary of *result* to stdout.""" + scope = f" for config '{config}'" if config else "" + if result.is_identical: + print( + f"ux-string check{scope}: OK — {result.matched} traced " + f"expression(s) identical between {baseline_label} and " + f"{candidate_label} builds." + ) + return + + print( + f"WARNING: ux-string check{scope}: {baseline_label} and " + f"{candidate_label} builds produced different Ux strings " + f"({result.matched} identical, {len(result.mismatches)} differing, " + f"{len(result.only_in_baseline)} only in {baseline_label}, " + f"{len(result.only_in_candidate)} only in {candidate_label})." + ) + for key, baseline_value, candidate_value in result.mismatches: + print(f" DIFF {_format_key(key)}") + print(f" {baseline_label}: {baseline_value}") + print(f" {candidate_label}: {candidate_value}") + for key in result.only_in_baseline: + print(f" ONLY IN {baseline_label}: {_format_key(key)}") + for key in result.only_in_candidate: + print(f" ONLY IN {candidate_label}: {_format_key(key)}") + + +def main() -> int: + """ + Parse arguments, compare the two tracing DBs, and report the result. + + Returns: + ``0`` when the two builds produced identical Ux strings, ``1`` when + they differed, and ``2`` when the comparison could not be performed + (e.g. a DB was missing or had an incompatible schema). The bash caller + treats any non-zero status as a warning and continues. + """ + parser = argparse.ArgumentParser( + description=( + "Compare the final Ux strings of two tracing DBs built at " + "different optimisation levels, warning on any byte-level difference." + ), + ) + parser.add_argument("baseline_db", help="Baseline (e.g. -O0) tracing DB.") + parser.add_argument("candidate_db", help="Candidate (e.g. -O2) tracing DB.") + parser.add_argument( + "--table", + default=EquivMC.TRACING_TABLE, + help=f"Traced-values table name (default: {EquivMC.TRACING_TABLE}).", + ) + parser.add_argument( + "--baseline-label", + default="O0", + help="Label for the baseline build in messages (default: O0).", + ) + parser.add_argument( + "--candidate-label", + default="O2", + help="Label for the candidate build in messages (default: O2).", + ) + parser.add_argument( + "--config", + default=None, + help="Optional config suffix, included in messages for context.", + ) + args = parser.parse_args() + + try: + result = compare_ux_strings(args.baseline_db, args.candidate_db, args.table) + except (OSError, sqlite3.Error) as exc: + print( + f"WARNING: ux-string check could not compare " + f"{args.baseline_db} and {args.candidate_db}: {exc}", + file=sys.stderr, + ) + return 2 + + _report( + result, + baseline_label=args.baseline_label, + candidate_label=args.candidate_label, + config=args.config, + ) + return 0 if result.is_identical else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/signaloid/benchmarking/automation/compare_tracing_ux_strings_test.py b/src/signaloid/benchmarking/automation/compare_tracing_ux_strings_test.py new file mode 100644 index 0000000..3263f82 --- /dev/null +++ b/src/signaloid/benchmarking/automation/compare_tracing_ux_strings_test.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import sqlite3 +import tempfile +import unittest +from collections.abc import Mapping, Sequence +from pathlib import Path + +from signaloid.benchmarking.automation.compare_tracing_ux_strings import ( + compare_ux_strings, + main, +) + +_TABLE = "TracingTable" + + +def _make_tracing_db(path: str, rows: Sequence[Mapping[str, object]]) -> None: + """ + Write a minimal tracing DB with the columns the comparator reads. + + Each entry in *rows* becomes one ``TracingTable`` write plus its + ``Emulator_Execution_Info`` row, sharing ``Execution_ID = 1`` so all + writes belong to one emulator configuration. Rows are inserted in list + order, so a later row overwrites an earlier one with the same identity + (exercising the last-write-wins grouping). + """ + with sqlite3.connect(path) as conn: + conn.execute( + "CREATE TABLE Emulator_Execution_Info (" + "Execution_ID INTEGER PRIMARY KEY AUTOINCREMENT, " + "UR_Type TEXT, UR_Order INTEGER, UR_Order_CoreLibrary INTEGER, " + "CorrelationTracking_Status TEXT)" + ) + conn.execute( + "INSERT INTO Emulator_Execution_Info " + "(Execution_ID, UR_Type, UR_Order, UR_Order_CoreLibrary, " + "CorrelationTracking_Status) VALUES (1, 'Athens', 64, 64, 'OFF')" + ) + conn.execute( + f'CREATE TABLE "{_TABLE}" (' + "Expression_DeclarationFileName TEXT, " + "Expression_Subprogram TEXT, " + "Expression_Name TEXT, " + "Expression_DeclarationLineNumber INTEGER, " + "Execution_Info_Table_ID INTEGER, " + "Dist_Value TEXT)" + ) + for row in rows: + conn.execute( + f'INSERT INTO "{_TABLE}" ' + "(Expression_DeclarationFileName, Expression_Subprogram, " + "Expression_Name, Expression_DeclarationLineNumber, " + "Execution_Info_Table_ID, Dist_Value) " + "VALUES (?, ?, ?, ?, 1, ?)", + ( + row.get("file", "main.c"), + row.get("subprogram", "main"), + row["name"], + row.get("line", 10), + row["dist_value"], + ), + ) + conn.commit() + + +class TestCompareUxStrings(unittest.TestCase): + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp = Path(tmp_dir.name) + + def _db(self, name: str, rows: Sequence[Mapping[str, object]]) -> str: + path = str(self.tmp / name) + _make_tracing_db(path, rows) + return path + + def test_identical_ux_strings_match(self) -> None: + rows = [ + {"name": "a", "dist_value": "Ux04ffff"}, + {"name": "b", "dist_value": "Ux04aaaa"}, + ] + result = compare_ux_strings( + self._db("o0.db", rows), self._db("o2.db", rows), _TABLE + ) + self.assertTrue(result.is_identical) + self.assertEqual(result.matched, 2) + self.assertEqual(result.mismatches, []) + + def test_differing_ux_string_is_reported(self) -> None: + o0 = self._db("o0.db", [{"name": "a", "dist_value": "Ux04ffff"}]) + o2 = self._db("o2.db", [{"name": "a", "dist_value": "Ux04fffe"}]) + result = compare_ux_strings(o0, o2, _TABLE) + self.assertFalse(result.is_identical) + self.assertEqual(result.matched, 0) + self.assertEqual(len(result.mismatches), 1) + _key, baseline_value, candidate_value = result.mismatches[0] + self.assertEqual(baseline_value, "Ux04ffff") + self.assertEqual(candidate_value, "Ux04fffe") + + def test_last_write_wins_per_expression(self) -> None: + # Both DBs' final write for `a` is the same, even though an earlier + # write differs. The comparison must use the last write only. + o0 = self._db( + "o0.db", + [ + {"name": "a", "dist_value": "Ux04early"}, + {"name": "a", "dist_value": "Ux04final"}, + ], + ) + o2 = self._db( + "o2.db", + [{"name": "a", "dist_value": "Ux04final"}], + ) + result = compare_ux_strings(o0, o2, _TABLE) + self.assertTrue(result.is_identical) + self.assertEqual(result.matched, 1) + + def test_expression_only_in_one_build(self) -> None: + o0 = self._db( + "o0.db", + [ + {"name": "a", "dist_value": "Ux04aaaa"}, + {"name": "b", "dist_value": "Ux04bbbb"}, + ], + ) + o2 = self._db("o2.db", [{"name": "a", "dist_value": "Ux04aaaa"}]) + result = compare_ux_strings(o0, o2, _TABLE) + self.assertFalse(result.is_identical) + self.assertEqual(result.matched, 1) + self.assertEqual(len(result.only_in_baseline), 1) + self.assertEqual(result.only_in_baseline[0][2], "b") # Expression_Name + self.assertEqual(result.only_in_candidate, []) + + def test_main_returns_zero_when_identical(self) -> None: + rows = [{"name": "a", "dist_value": "Ux04aaaa"}] + argv = [self._db("o0.db", rows), self._db("o2.db", rows), "--table", _TABLE] + self.assertEqual(_run_main(argv), 0) + + def test_main_returns_one_when_differing(self) -> None: + o0 = self._db("o0.db", [{"name": "a", "dist_value": "Ux04aaaa"}]) + o2 = self._db("o2.db", [{"name": "a", "dist_value": "Ux04bbbb"}]) + self.assertEqual(_run_main([o0, o2, "--table", _TABLE]), 1) + + def test_main_returns_two_on_missing_db(self) -> None: + o0 = self._db("o0.db", [{"name": "a", "dist_value": "Ux04aaaa"}]) + missing = str(self.tmp / "does-not-exist.db") + self.assertEqual(_run_main([o0, missing, "--table", _TABLE]), 2) + + +def _run_main(argv: list[str]) -> int: + """Invoke ``main`` with a patched ``sys.argv`` and return its exit code.""" + from unittest.mock import patch + + with patch("sys.argv", ["compare_tracing_ux_strings", *argv]): + return main() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/config_layer_test.py b/src/signaloid/benchmarking/automation/config_layer_test.py new file mode 100644 index 0000000..cac17c6 --- /dev/null +++ b/src/signaloid/benchmarking/automation/config_layer_test.py @@ -0,0 +1,259 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any, Dict +from unittest.mock import patch + +import yaml + +from signaloid.benchmarking.automation.arguments import ( + create_argument_parser, + validate_args, +) +from signaloid.benchmarking.automation.benchmark_application import ( + load_config, +) + + +def _write_config(tmp_path: Path, config: Dict[str, Any]) -> str: + """Write ``config`` to a YAML file under ``tmp_path`` and return the + path as a string.""" + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.safe_dump(config)) + return str(config_path) + + +def _base_config() -> Dict[str, Any]: + """A minimal valid sweep config (all four required sweep args).""" + return { + "representation_types": ["Athens"], + "representation_sizes": [16, 64], + "correlations": ["Disabled"], + "reporting_methods": ["Mean"], + } + + +class TestConfigLayer(unittest.TestCase): + """Config-file merge, validation, and plot/flag defaults for the CLI.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def _set_argv(self, argv: list[str]) -> None: + """Patch ``sys.argv`` for the duration of the test.""" + p = patch.object(sys, "argv", argv) + p.start() + self.addCleanup(p.stop) + + def test_config_file_populates_defaults(self) -> None: + config_path = _write_config(self.tmp_path, _base_config()) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + + self.assertEqual(args.representation_types, ["Athens"]) + self.assertEqual(args.representation_sizes, [16, 64]) + self.assertEqual(args.correlations, ["Disabled"]) + self.assertEqual(args.reporting_methods, ["Mean"]) + # validate_args must accept a file-populated sweep. + validate_args(args) + + def test_cli_overrides_config_value(self) -> None: + config_path = _write_config(self.tmp_path, _base_config()) + self._set_argv( + ["prog", "--config", config_path, "-r", "Quantile-95"], + ) + + args = load_config(create_argument_parser()) + + # CLI flag wins over the file value for reporting_methods, while the + # other file-supplied sweep args are retained. + self.assertEqual(args.reporting_methods, ["Quantile-95"]) + self.assertEqual(args.representation_types, ["Athens"]) + + def test_config_sets_non_sweep_scalar(self) -> None: + # Config keys are dest names: --num-adversaries has dest + # n_adversaries, so the config key is n_adversaries (not the flag + # spelling). + config = _base_config() + config["n_adversaries"] = 7 + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + + self.assertEqual(args.n_adversaries, 7) + + def test_missing_required_after_merge_raises(self) -> None: + # No config file and no sweep args on the CLI: validate_args must + # raise a clear ValueError naming the missing dest. + self._set_argv(["prog"]) + args = load_config(create_argument_parser()) + with self.assertRaisesRegex(ValueError, "representation_types"): + validate_args(args) + + def test_partial_config_missing_one_sweep_arg_raises(self) -> None: + config = _base_config() + del config["correlations"] + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + with self.assertRaisesRegex(ValueError, "correlations"): + validate_args(args) + + def test_unknown_config_key_rejected(self) -> None: + config = _base_config() + # Typo of representation_sizes. + config["representaiton_sizes"] = [32] + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + with self.assertRaisesRegex(ValueError, "representaiton_sizes"): + load_config(create_argument_parser()) + + def test_non_dict_config_rejected(self) -> None: + # A YAML file that is valid but not a top-level mapping (e.g. a + # list) must raise a clear ValueError, not a confusing TypeError. + config_path = self.tmp_path / "config.yaml" + config_path.write_text("- not\n- a\n- mapping\n") + self._set_argv(["prog", "--config", str(config_path)]) + + with self.assertRaisesRegex(ValueError, "mapping"): + load_config(create_argument_parser()) + + def test_missing_config_file_raises(self) -> None: + # A missing --config file must raise a clear ValueError, not a raw + # FileNotFoundError. + self._set_argv(["prog", "--config", "/nonexistent/config.yaml"]) + with self.assertRaisesRegex(ValueError, "not found"): + load_config(create_argument_parser()) + + def test_config_path_expands_user(self) -> None: + # --config works with ~ expansion. + config_path = self.tmp_path / "cfg.yaml" + config_path.write_text(yaml.safe_dump(_base_config())) + with patch.dict(os.environ, {"HOME": str(self.tmp_path)}): + self._set_argv(["prog", "--config", "~/cfg.yaml"]) + + args = load_config(create_argument_parser()) + self.assertEqual( + args.representation_types, + _base_config()["representation_types"], + ) + + def test_scalar_sweep_arg_rejected(self) -> None: + # A config that supplies a scalar where a list is expected (e.g. + # representation_types: Athens) must raise, not iterate char-by-char. + config = _base_config() + config["representation_types"] = "Athens" + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + with self.assertRaisesRegex(ValueError, "non-empty list"): + validate_args(args) + + def test_invalid_sweep_membership_rejected(self) -> None: + # File-supplied values bypass argparse's choices= check, so + # validate_args must catch an out-of-enum representation type. + config = _base_config() + config["representation_types"] = ["NOT_A_REAL_TYPE"] + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + with self.assertRaisesRegex(ValueError, "NOT_A_REAL_TYPE"): + validate_args(args) + + def test_plot_flag_defaults_off(self) -> None: + for dest in [ + "plot_distance_vs_asymptotic", + "plot_adversary_distances", + "plot_representative_mc", + ]: + with self.subTest(dest=dest): + parser = create_argument_parser() + args = parser.parse_args([]) + self.assertFalse(getattr(args, dest)) + + def test_plot_flag_positive_form(self) -> None: + for dest, flag in [ + ("plot_distance_vs_asymptotic", "--plot-distance-vs-asymptotic"), + ("plot_adversary_distances", "--plot-adversary-distances"), + ("plot_representative_mc", "--plot-representative-mc"), + ]: + with self.subTest(dest=dest, flag=flag): + parser = create_argument_parser() + args = parser.parse_args([flag]) + self.assertTrue(getattr(args, dest)) + + def test_plot_flag_negated_form(self) -> None: + for dest, flag in [ + ( + "plot_distance_vs_asymptotic", + "--no-plot-distance-vs-asymptotic", + ), + ("plot_adversary_distances", "--no-plot-adversary-distances"), + ("plot_representative_mc", "--no-plot-representative-mc"), + ]: + with self.subTest(dest=dest, flag=flag): + parser = create_argument_parser() + args = parser.parse_args([flag]) + self.assertFalse(getattr(args, dest)) + + def test_plot_flag_settable_from_config(self) -> None: + config = _base_config() + config["plot_representative_mc"] = True + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + self.assertTrue(args.plot_representative_mc) + + def test_use_binned_uxhw_defaults_true(self) -> None: + parser = create_argument_parser() + args = parser.parse_args([]) + self.assertTrue(args.use_binned_uxhw) + + def test_use_binned_uxhw_disableable_from_cli(self) -> None: + parser = create_argument_parser() + args = parser.parse_args(["--no-use-binned-uxhw"]) + self.assertFalse(args.use_binned_uxhw) + + def test_use_binned_uxhw_disableable_from_config(self) -> None: + config = _base_config() + config["use_binned_uxhw"] = False + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + args = load_config(create_argument_parser()) + self.assertFalse(args.use_binned_uxhw) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/database_generator.py b/src/signaloid/benchmarking/automation/database_generator.py new file mode 100644 index 0000000..72f1cdc --- /dev/null +++ b/src/signaloid/benchmarking/automation/database_generator.py @@ -0,0 +1,537 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import csv +import os +import sqlite3 +import subprocess +import sys +from contextlib import closing +from typing import Any, Callable +from signaloid.benchmarking.automation.sample_generator import ( + generate_database_native_mc, +) +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import RepresentationTypes, VariableTypes + +# The five expression-metadata columns that prefix every row in both the +# WeightedSamples and MonteCarlo data tables. +_SAMPLE_METADATA_COLUMNS = ( + "ValueId", + "Expression_Name", + "Expression_Subprogram", + "Expression_DeclarationFileName", + "Expression_DeclarationLineNumber", +) + + +def _variable_metadata(variable: BenchmarkingVariable) -> tuple[Any, ...]: + return ( + variable.value_id, + variable.name, + variable.program, + variable.path, + variable.line_number, + ) + + +def _weighted_sample_rows(variable: BenchmarkingVariable) -> list[tuple[Any, ...]]: + """ + Build ``(Position, Weight, MonteCarlo_Count)`` rows for one variable. + + Args: + variable: The variable whose distribution samples are converted. + + Returns: + One row per distribution sample or per scalar output value. + """ + samples = variable.distribution_samples + if variable.type == VariableTypes.DISTRIBUTION: + return [ + (value, weight, 1) for value, weight in zip(samples.values, samples.weights) + ] + if variable.type == VariableTypes.SCALAR: + return [ + (value, 1.0, mc_count) + for mc_count, value_list in samples.scalar_output_dict.items() + for value in value_list + ] + print(f"Warning! variable type {variable.type} not supported") + return [] + + +def _mc_sample_rows(variable: BenchmarkingVariable) -> list[tuple[Any, ...]]: + """ + Build ``(Assignment_Index, Particle_Value, MonteCarlo_Count)`` rows for one + variable. + + Args: + variable: The variable whose distribution samples are converted. + + Returns: + One row per distribution sample or per scalar output value. + """ + samples = variable.distribution_samples + if variable.type == VariableTypes.DISTRIBUTION: + return [(1, value, 1) for value in samples.values] + if variable.type == VariableTypes.SCALAR: + return [ + (adversary_index, value, mc_count) + for mc_count, value_list in samples.scalar_output_dict.items() + for adversary_index, value in enumerate(value_list) + ] + print(f"Warning! variable type {variable.type} not supported") + return [] + + +def _write_samples_database( + database_path: str, + variables: list[BenchmarkingVariable], + *, + table_name: str, + data_table_columns: str, + value_columns: tuple[str, ...], + row_builder: Callable[[BenchmarkingVariable], list[tuple[Any, ...]]], +) -> None: + """ + Write a samples database with the shared two-table layout. + + Both ground-truth-style tables share the same structure: a data table + (five expression-metadata columns + an autoincrement PK + three value + columns) and a ``Printed_ValueIds`` table. + + Args: + database_path: Path to the SQLite database to write. + variables: Variables whose samples populate the data table. + table_name: Name of the data table to create. + data_table_columns: SQL for the PK + value columns. + value_columns: Names of the value columns, for the INSERT. + row_builder: Yields the value-column tuples for a variable's rows. + """ + # Connect to SQLite database (creates a new file if it doesn't exist) + with closing(sqlite3.connect(database_path)) as conn: + cursor = conn.cursor() + + # Create two tables + cursor.execute(f""" + CREATE TABLE IF NOT EXISTS {table_name} ( + ValueId TEXT NOT NULL, + Expression_Name TEXT NOT NULL, + Expression_Subprogram TEXT NOT NULL, + Expression_DeclarationFileName TEXT NOT NULL, + Expression_DeclarationLineNumber TEXT NOT NULL, + {data_table_columns} + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS Printed_ValueIds ( + ValueId TEXT NOT NULL, + SampleType TEXT NOT NULL + ) + """) + + table1_data = [] + for variable in variables: + metadata = _variable_metadata(variable) + for value_row in row_builder(variable): + table1_data.append(metadata + value_row) + + columns = ", ".join(_SAMPLE_METADATA_COLUMNS + value_columns) + placeholders = ", ".join( + ["?"] * (len(_SAMPLE_METADATA_COLUMNS) + len(value_columns)) + ) + cursor.executemany( + f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})", + table1_data, + ) + + # Populate table2 with data + table2_data = [ + (variables[0].value_id, table_name), + ] + + cursor.executemany( + "INSERT INTO Printed_ValueIds (ValueId, SampleType) VALUES (?, ?)", + table2_data, + ) + + # Commit the changes. The connection is closed on with-block exit. + conn.commit() + + +def generate_database_from_weighted_samples( + database_path: str, variables: list[BenchmarkingVariable] +) -> None: + """ + Write a ``WeightedSamples`` database for the given variables. + + Args: + database_path: Path to the SQLite database to write. + variables: Variables whose weighted samples are written. + """ + _write_samples_database( + database_path, + variables, + table_name="WeightedSamples", + data_table_columns=( + "Id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " Position FLOAT,\n" + " Weight FLOAT,\n" + " MonteCarlo_Count INTEGER" + ), + value_columns=("Position", "Weight", "MonteCarlo_Count"), + row_builder=_weighted_sample_rows, + ) + + +def generate_database_from_mc_samples( + database_path: str, variables: list[BenchmarkingVariable] +) -> None: + """ + Write a ``MonteCarlo`` database for the given variables. + + Args: + database_path: Path to the SQLite database to write. + variables: Variables whose Monte Carlo samples are written. + """ + _write_samples_database( + database_path, + variables, + table_name="MonteCarlo", + data_table_columns=( + "MC_Id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " Assignment_Index INTEGER,\n" + " Particle_Value FLOAT,\n" + " MonteCarlo_Count INTEGER" + ), + value_columns=("Assignment_Index", "Particle_Value", "MonteCarlo_Count"), + row_builder=_mc_sample_rows, + ) + + +def generate_analytic_ground_truth_database( + *, + benchmarking_variables: list[BenchmarkingVariable], + path_to_application: str, + path_to_ground_truth_file: str, + ground_truth_size: int, + ground_truth_db_path: str, +) -> None: + """Generate the Ground Truth database using the analytic formula. + + Shells out to a ground-truth script, reads the resulting CSV, populates + each variable's sample buffers, and writes the SQLite DB via + generate_database_from_weighted_samples. + + Args: + benchmarking_variables: List of variables to populate with ground + truth samples. + path_to_application: Root path of the application being benchmarked. + The CSV is written to {path_to_application}/src/ground-truth.csv. + path_to_ground_truth_file: Path to the Python script that generates + the ground truth CSV. + ground_truth_size: Number of ground truth samples to generate. + ground_truth_db_path: Path at which to write the output SQLite DB. + + Raises: + RuntimeError: If the ground truth script exits with a non-zero return + code. + """ + print("Generating Ground Truth using analytic formula.") + path_to_csv = f"{path_to_application}/src/ground-truth.csv" + + # Generate the CSV of values. Use argv-list invocation (shell=False) so + # paths with spaces or shell metacharacters cannot break the call or inject. + result = subprocess.run( + [ + sys.executable, + path_to_ground_truth_file, + str(ground_truth_size), + path_to_csv, + ], + stderr=subprocess.PIPE, + text=True, + check=False, + ) + if result.returncode != 0: + error_message = ( + "Ground truth generation failed " "(exit code " f"{result.returncode})." + ) + if result.stderr and result.stderr.strip(): + error_message += "\nstderr:\n" + result.stderr + raise RuntimeError(error_message) + + # Empty values + for variable in benchmarking_variables: + variable.distribution_samples.empty_values() + + # Lookup by name. Rows for unknown names are silently skipped. + variables_by_name = {variable.name: variable for variable in benchmarking_variables} + + # Load csv data into BenchmarkingVariable objects. + with open(path_to_csv, mode="r", newline="", encoding="utf-8") as file: + reader = csv.reader(file) + + for line_number, row in enumerate(reader, start=1): + if not row: + continue # skip blank lines (e.g. a trailing newline) + row_variable = variables_by_name.get(row[0]) + if row_variable is None: + continue # rows for unknown names are skipped (see above) + if len(row) < 3: + raise ValueError( + f"Malformed ground-truth CSV {path_to_csv!r} at line " + f"{line_number}: expected 3 columns (name, value, weight), " + f"got {len(row)}." + ) + try: + value = float(row[1]) + weight = float(row[2]) + except ValueError as e: + raise ValueError( + f"Malformed ground-truth CSV {path_to_csv!r} at line " + f"{line_number}: value and weight (columns 2-3) must be " + f"numeric, got {row[1]!r} and {row[2]!r}." + ) from e + row_variable.distribution_samples.values.append(value) + row_variable.distribution_samples.weights.append(weight) + + # Convert to Database + for variable in benchmarking_variables: + generate_database_from_weighted_samples(ground_truth_db_path, [variable]) + + +def generate_uxhw_tracing_database( + *, + benchmarking_variables: list[BenchmarkingVariable], + tracing_db_path: str, + run_timing_script: Callable[..., None], +) -> None: + """Generate the UxHw tracing database. + + Runs tracing for each benchmarking variable individually using its + own command-line arguments, then collects the UX strings from each + run into a single tracing database. + + Args: + benchmarking_variables: Variables to trace. One + ``run_timing_script`` invocation is issued per variable. + tracing_db_path: Filesystem path of the tracing database. + Removed before the first tracing run so that stale data + does not bleed into the new results. + run_timing_script: Callable that executes the bash timing + script for a single variable. Must accept at minimum the + keyword arguments ``variable_index: int`` and + ``tracing: bool``. All other required arguments are bound + by the caller via ``functools.partial`` (or equivalent). + """ + print("Generating UxHw tracing database.") + if os.path.isfile(tracing_db_path): + os.remove(tracing_db_path) + for i in range(len(benchmarking_variables)): + run_timing_script(variable_index=i, tracing=True) + + +def generate_adversary_database( + *, + has_native_mc: bool, + adversary_mc_size: int, + adversary_db_path: str, + benchmarking_variables: list[BenchmarkingVariable], + n_processors: int, + n_adversaries: int, + ground_truth_size: int, + adversary_max_size_scalar: int, + use_clt: bool, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, + run_timing_script: Callable[..., None], +) -> None: + """Generate the Adversarial MC database required for equivalent_mc. + + Generated via native execution. A native build is required. + + Args: + has_native_mc: When ``True``, generate the database via native + execution (otherwise raise). + adversary_mc_size: Number of adversary Monte Carlo samples to + generate. + adversary_db_path: Filesystem path at which to write the + adversary SQLite database. + benchmarking_variables: Variables to populate with samples + (native-MC path only). + n_processors: Number of parallel worker processes + (native-MC path only). + n_adversaries: Number of independent adversary runs per + scalar sample size (native-MC path only). + ground_truth_size: Sample size used for the ground-truth + scalar pass (native-MC path only; unused here but + forwarded for signature parity). + adversary_max_size_scalar: Upper bound used when generating + the geometric series of scalar sample sizes + (native-MC path only). + use_clt: When ``True``, scalar sample sizes are + taken from pre-computed EMCC predictions (native-MC path + only). + path_to_application: Root path of the application source tree + (native-MC path only). + native_executable_name: Filename of the compiled native + binary (native-MC path only). + native_executable_dir: Directory containing the native binary + (native-MC path only). + demo_cli_args: Per-application command-line argument prefix + (native-MC path only). + run_timing_script: Callable that executes the bash timing + script for the adversary-MC pass. Must accept at minimum + the keyword argument ``adversary_mc: bool``. All other + required arguments are bound by the caller. + """ + if has_native_mc: + print("Generating Adversarial MC using native execution.") + generate_database_native_mc( + size=adversary_mc_size, + database_path=adversary_db_path, + benchmarking_variables=benchmarking_variables, + n_processors=n_processors, + n_adversaries=n_adversaries, + ground_truth_size=ground_truth_size, + adversary_max_size_scalar=adversary_max_size_scalar, + use_clt=use_clt, + path_to_application=path_to_application, + native_executable_name=native_executable_name, + native_executable_dir=native_executable_dir, + demo_cli_args=demo_cli_args, + ) + else: + raise RuntimeError( + "Adversary Monte Carlo generation requires a native build " + "(a Makefile 'local-build' target or src/config.mk with SOURCES)." + ) + + +def generate_ground_truth_database( + *, + has_analytic_ground_truth: bool, + has_native_mc: bool, + benchmarking_variables: list[BenchmarkingVariable], + path_to_application: str, + path_to_ground_truth_file: str, + ground_truth_size: int, + ground_truth_db_path: str, + ground_truth_type: str, + max_num_weighted_samples: int, + n_processors: int, + n_adversaries: int, + adversary_max_size_scalar: int, + use_clt: bool, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, + run_timing_script: Callable[..., None], +) -> None: + """Generate the Ground Truth database required for equivalent_mc. + + Requires an analytic ground-truth script or a native build. Selects: + analytic formula first, then native execution. + + Args: + has_analytic_ground_truth: When ``True``, generate via the + analytic ground-truth script. + has_native_mc: When ``True`` and no analytic ground truth is + available, generate via native execution. + benchmarking_variables: Variables to populate with ground + truth samples (analytic and native-MC paths). + path_to_application: Root path of the application (analytic + path: CSV written to + ``{path_to_application}/src/ground-truth.csv``. + native-MC path: working directory for the native binary). + path_to_ground_truth_file: Path to the Python script that + generates the ground truth CSV (analytic path only). + ground_truth_size: Number of ground truth samples to generate. + ground_truth_db_path: Filesystem path at which to write the + ground truth SQLite database. + ground_truth_type: Representation type string used to decide + whether weighted samples are required (native-MC path). + max_num_weighted_samples: Maximum number of weighted samples + (native-MC path only). + n_processors: Number of parallel worker processes + (native-MC path only). + n_adversaries: Number of independent adversary runs per + scalar sample size (native-MC path only; unused here but + forwarded for signature parity). + adversary_max_size_scalar: Upper bound used when generating + the geometric series of scalar sample sizes + (native-MC path only; unused here but forwarded for + signature parity). + use_clt: When ``True``, scalar sample sizes are + taken from pre-computed EMCC predictions (native-MC path + only; unused for ground-truth scalars but forwarded for + signature parity). + native_executable_name: Filename of the compiled native + binary (native-MC path only). + native_executable_dir: Directory containing the native binary + (native-MC path only). + demo_cli_args: Per-application command-line argument prefix + (native-MC path only). + run_timing_script: Callable that executes the bash timing + script for the ground-truth pass. Must accept at minimum + the keyword argument ``ground_truth: bool``. All other + required arguments are bound by the caller. + """ + if has_analytic_ground_truth: + generate_analytic_ground_truth_database( + benchmarking_variables=benchmarking_variables, + path_to_application=path_to_application, + path_to_ground_truth_file=path_to_ground_truth_file, + ground_truth_size=ground_truth_size, + ground_truth_db_path=ground_truth_db_path, + ) + else: + if has_native_mc: + print("Generating Ground Truth using native execution.") + generate_database_native_mc( + size=ground_truth_size, + database_path=ground_truth_db_path, + benchmarking_variables=benchmarking_variables, + n_processors=n_processors, + n_adversaries=n_adversaries, + ground_truth_size=ground_truth_size, + adversary_max_size_scalar=adversary_max_size_scalar, + use_clt=use_clt, + path_to_application=path_to_application, + native_executable_name=native_executable_name, + native_executable_dir=native_executable_dir, + demo_cli_args=demo_cli_args, + weighted_samples=( + ground_truth_type == RepresentationTypes.WEIGHTED_SAMPLES + ), + num_weighted_samples=max_num_weighted_samples, + ground_truth=True, + ) + else: + raise RuntimeError( + "Ground-truth generation requires an analytic ground-truth " + "script (--has-analytic-ground-truth) or a native build " + "(a Makefile 'local-build' target or src/config.mk with " + "SOURCES)." + ) diff --git a/src/signaloid/benchmarking/automation/database_generator_test.py b/src/signaloid/benchmarking/automation/database_generator_test.py new file mode 100644 index 0000000..c85fe5e --- /dev/null +++ b/src/signaloid/benchmarking/automation/database_generator_test.py @@ -0,0 +1,139 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +"""Unit tests for the SQLite sample-database writers.""" + +import os +import sqlite3 +import tempfile +import unittest + +from signaloid.benchmarking.automation.database_generator import ( + _mc_sample_rows, + _weighted_sample_rows, + generate_database_from_mc_samples, + generate_database_from_weighted_samples, +) +from signaloid.benchmarking.config import VariableTypes +from signaloid.benchmarking.types import BenchmarkingVariable + + +def _make_var(var_type: str) -> BenchmarkingVariable: + return BenchmarkingVariable( + name="outputDistributions[0]", + description="Variable 0", + type=var_type, + cla="-S 0", + value_id="vid-1", + program="main", + path="main.c", + line_number="42", + ) + + +# Metadata columns prefixing every data row, in their stored order. +_META = ("vid-1", "outputDistributions[0]", "main", "main.c", "42") + + +class TestSampleRowBuilders(unittest.TestCase): + """The row builders emit the exact value-column tuples per variable.""" + + def test_weighted_distribution_rows(self) -> None: + var = _make_var(VariableTypes.DISTRIBUTION) + var.distribution_samples.set_weighted_values([1.0, 2.0], [0.25, 0.75]) + # (Position, Weight, MonteCarlo_Count=1) + self.assertEqual(_weighted_sample_rows(var), [(1.0, 0.25, 1), (2.0, 0.75, 1)]) + + def test_weighted_scalar_rows(self) -> None: + var = _make_var(VariableTypes.SCALAR) + var.distribution_samples.scalar_output_dict = {10: [5.0, 6.0]} + # (value, Weight=1.0, MonteCarlo_Count=mc_count) + self.assertEqual(_weighted_sample_rows(var), [(5.0, 1.0, 10), (6.0, 1.0, 10)]) + + def test_mc_distribution_rows(self) -> None: + var = _make_var(VariableTypes.DISTRIBUTION) + var.distribution_samples.set_values([1.0, 2.0]) + # (Assignment_Index=1, Particle_Value, MonteCarlo_Count=1) + self.assertEqual(_mc_sample_rows(var), [(1, 1.0, 1), (1, 2.0, 1)]) + + def test_mc_scalar_rows(self) -> None: + var = _make_var(VariableTypes.SCALAR) + var.distribution_samples.scalar_output_dict = {10: [5.0, 6.0]} + # (Assignment_Index=enumerate, Particle_Value, MonteCarlo_Count=mc_count) + self.assertEqual(_mc_sample_rows(var), [(0, 5.0, 10), (1, 6.0, 10)]) + + def test_unsupported_type_yields_no_rows(self) -> None: + var = _make_var("NotARealVariableType") + self.assertEqual(_weighted_sample_rows(var), []) + self.assertEqual(_mc_sample_rows(var), []) + + +class TestGenerateWeightedSamplesDatabase(unittest.TestCase): + def test_roundtrip_columns_and_printed_value_ids(self) -> None: + var = _make_var(VariableTypes.DISTRIBUTION) + var.distribution_samples.set_weighted_values([1.0, 2.0], [0.25, 0.75]) + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "weighted.db") + generate_database_from_weighted_samples(db_path, [var]) + with sqlite3.connect(db_path) as con: + rows = con.execute( + "SELECT ValueId, Expression_Name, Expression_Subprogram, " + "Expression_DeclarationFileName, " + "Expression_DeclarationLineNumber, " + "Position, Weight, MonteCarlo_Count " + "FROM WeightedSamples ORDER BY Id" + ).fetchall() + printed = con.execute( + "SELECT ValueId, SampleType FROM Printed_ValueIds" + ).fetchall() + self.assertEqual( + rows, + [_META + (1.0, 0.25, 1), _META + (2.0, 0.75, 1)], + ) + self.assertEqual(printed, [("vid-1", "WeightedSamples")]) + + +class TestGenerateMonteCarloDatabase(unittest.TestCase): + def test_roundtrip_columns_and_printed_value_ids(self) -> None: + var = _make_var(VariableTypes.SCALAR) + var.distribution_samples.scalar_output_dict = {10: [5.0, 6.0]} + with tempfile.TemporaryDirectory() as tmp: + db_path = os.path.join(tmp, "mc.db") + generate_database_from_mc_samples(db_path, [var]) + with sqlite3.connect(db_path) as con: + rows = con.execute( + "SELECT ValueId, Expression_Name, Expression_Subprogram, " + "Expression_DeclarationFileName, " + "Expression_DeclarationLineNumber, " + "Assignment_Index, Particle_Value, MonteCarlo_Count " + "FROM MonteCarlo ORDER BY MC_Id" + ).fetchall() + printed = con.execute( + "SELECT ValueId, SampleType FROM Printed_ValueIds" + ).fetchall() + self.assertEqual( + rows, + [_META + (0, 5.0, 10), _META + (1, 6.0, 10)], + ) + self.assertEqual(printed, [("vid-1", "MonteCarlo")]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/generate_uxhw_tracing_test.py b/src/signaloid/benchmarking/automation/generate_uxhw_tracing_test.py new file mode 100644 index 0000000..ac35a60 --- /dev/null +++ b/src/signaloid/benchmarking/automation/generate_uxhw_tracing_test.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest + +from pathlib import Path +from unittest.mock import MagicMock + +from signaloid.benchmarking.automation.database_generator import ( + generate_uxhw_tracing_database, +) +from signaloid.benchmarking.types import BenchmarkingVariable + + +def _make_variables(n: int) -> list[BenchmarkingVariable]: + """Create n dummy BenchmarkingVariable instances.""" + return [ + BenchmarkingVariable( + name=f"outputVariables[{i}]", + description=f"Variable {i}", + cla=f"-S {i}", + ) + for i in range(n) + ] + + +class TestGenerateUxHwTracingDatabase(unittest.TestCase): + """``generate_uxhw_tracing_database`` deletes any stale DB then + invokes the timing script once per variable, in index order.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_deletes_existing_db_before_tracing(self) -> None: + """Existing tracing DB should be removed before any tracing runs.""" + db_path = str(self.tmp_path / "tracing.db") + with open(db_path, "w") as f: + f.write("stale data") + + variables = _make_variables(2) + removal_observed: list[bool] = [] + + def assert_db_removed_before_run(**kwargs: object) -> None: + removal_observed.append(not os.path.exists(db_path)) + + mock_run = MagicMock(side_effect=assert_db_removed_before_run) + generate_uxhw_tracing_database( + benchmarking_variables=variables, + tracing_db_path=db_path, + run_timing_script=mock_run, + ) + + # The DB was deleted before the first invocation. + self.assertTrue(all(removal_observed)) + self.assertEqual(mock_run.call_count, 2) + self.assertFalse(os.path.exists(db_path)) + + def test_calls_run_timing_script_per_variable(self) -> None: + """run_timing_script should be called once per benchmarking variable + with tracing=True and the correct variable_index.""" + variables = _make_variables(3) + mock_run = MagicMock() + + generate_uxhw_tracing_database( + benchmarking_variables=variables, + tracing_db_path="/nonexistent/tracing.db", + run_timing_script=mock_run, + ) + + self.assertEqual(mock_run.call_count, 3) + for i, call_args in enumerate(mock_run.call_args_list): + self.assertEqual(call_args.kwargs["variable_index"], i) + self.assertIs(call_args.kwargs["tracing"], True) + + def test_single_variable(self) -> None: + """With a single variable, run_timing_script is called exactly once.""" + variables = _make_variables(1) + mock_run = MagicMock() + + generate_uxhw_tracing_database( + benchmarking_variables=variables, + tracing_db_path="/nonexistent/tracing.db", + run_timing_script=mock_run, + ) + + self.assertEqual(mock_run.call_count, 1) + self.assertEqual(mock_run.call_args.kwargs["variable_index"], 0) + self.assertIs(mock_run.call_args.kwargs["tracing"], True) + + def test_no_variables_does_not_call_run_timing_script(self) -> None: + """With no benchmarking variables, run_timing_script not called.""" + mock_run = MagicMock() + + generate_uxhw_tracing_database( + benchmarking_variables=[], + tracing_db_path="/nonexistent/tracing.db", + run_timing_script=mock_run, + ) + + mock_run.assert_not_called() + + def test_no_error_when_db_does_not_exist(self) -> None: + """Should not raise when the tracing DB does not exist yet.""" + db_path = str(self.tmp_path / "tracing.db") + self.assertFalse(os.path.exists(db_path)) + + mock_run = MagicMock() + generate_uxhw_tracing_database( + benchmarking_variables=_make_variables(1), + tracing_db_path=db_path, + run_timing_script=mock_run, + ) + + self.assertEqual(mock_run.call_count, 1) + + def test_invocation_order_is_sequential(self) -> None: + """Variables must be traced in index order so that per-variable + DBs merge into the final DB in a deterministic sequence.""" + variables = _make_variables(5) + invocation_order: list[object] = [] + + def record_call(**kwargs: object) -> None: + invocation_order.append(kwargs["variable_index"]) + + generate_uxhw_tracing_database( + benchmarking_variables=variables, + tracing_db_path="/nonexistent/tracing.db", + run_timing_script=record_call, + ) + + self.assertEqual(invocation_order, [0, 1, 2, 3, 4]) + + def test_run_timing_script_is_importable_from_database_generator( + self, + ) -> None: + """Sanity: generate_uxhw_tracing_database must be importable from + database_generator so orchestrators and tests bind to the right + symbol.""" + from signaloid.benchmarking.automation import ( + database_generator as dg_module, + ) + + self.assertTrue(hasattr(dg_module, "generate_uxhw_tracing_database")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/measurement_loader.py b/src/signaloid/benchmarking/automation/measurement_loader.py new file mode 100644 index 0000000..78d9eba --- /dev/null +++ b/src/signaloid/benchmarking/automation/measurement_loader.py @@ -0,0 +1,530 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import json +import os +from collections import defaultdict + +import numpy as np +import pandas as pd + +from signaloid.benchmarking.automation.benchmarking_utils import ( + parse_timing_intermediate_stream, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + UxhwDistanceRecord, +) +from signaloid.benchmarking.config import ( + AsymptoticDistanceDistribution, + BenchmarkingVariables, + EquivMC, + ReportingMethods, + TimingFormat, + VariableTypes, +) + + +def load_measurement_dicts( + *, + benchmarking_variables: list[BenchmarkingVariable], + intermediate_path: str, + json_path: str, + logs_dir: str, + demo_cli_args: str, + representation_sizes: list[int], + representation_types: list[str], + correlations: list[str], +) -> str: + """ + Load measurement information from the transient intermediate file, + emit the canonical JSON artifact, and populate BenchmarkingVariable + objects. + + The canonical artifact is a single JSON document: a top-level object + containing session-invariant fields (application identity, SDK + versions, target rep count) alongside a ``runs`` array of per-run + records. Session-level META keys are validated for consistency + across runs and lifted out of each run dict into the top level. + + Args: + benchmarking_variables: Variables to populate with measurement + data. + intermediate_path: Absolute path of the transient intermediate + timings file written by ``get-timings.sh``. + json_path: Absolute path of the canonical JSON timings artifact + to write. + logs_dir: Directory containing timing script logs (used in error + messages). + demo_cli_args: Demo-level command-line arguments string. + representation_sizes: Configured representation sizes. + representation_types: Configured representation types. + correlations: Configured correlation modes. + + Returns: + The ``uxhw_version`` lifted from the canonical document so the + caller can store it as session metadata. + + Raises: + RuntimeError: If the timing intermediate file is missing, if + the parsed run count is smaller than + ``len(benchmarking_variables) * 2``, or propagated from + ``load_measurement_data`` (mismatched native-MC count or + missing EMCC pre-load). + """ + print(f"Loading measurements from {intermediate_path}") + + if not os.path.isfile(intermediate_path): + stderr_log = os.path.join(logs_dir, "timing_script_stderr.log") + raise RuntimeError( + f"Timing intermediate not found at {intermediate_path}. " + f"The bash timing script likely failed before writing it. " + f"See {stderr_log} for details." + ) + + with open(intermediate_path, "r") as file: + runs = parse_timing_intermediate_stream(file) + + # Keep the last two runs per variable: one UxHw-timing and one + # native-MC-timing invocation. + num = len(benchmarking_variables) * 2 + if len(runs) < num: + raise RuntimeError( + f"Error! {len(runs)} runs of UxHw timing data found, " f"expected {num}." + ) + runs = runs[-num:] + + # Promote session-level META keys to the top-level document. Warn (don't + # fail) on disagreement across runs, and pick the first non-empty value in + # run order so placeholders never override a real value (deterministically). + session: dict = {} + for key in TimingFormat.SESSION_META_KEYS: + values = [run.get(key, "") for run in runs] + distinct = set(values) + if len(distinct) > 1: + print(f"Warning! {key} differs across runs: {sorted(distinct)}") + session[key] = next((v for v in values if v != ""), "") + for run in runs: + for key in TimingFormat.SESSION_META_KEYS: + run.pop(key, None) + + uxhw_version: str = session[TimingFormat.META_KEY_UXHW_SDK_VERSION] + + # Write the canonical JSON document before consuming the runs. + document = {**session, TimingFormat.JSON_KEY_RUNS: runs} + with open(json_path, "w") as json_file: + json.dump(document, json_file, indent=2) + json_file.write("\n") + + # Populate BenchmarkingVariable objects with measurement data. + load_measurement_data( + benchmarking_variables=benchmarking_variables, + runs=runs, + demo_cli_args=demo_cli_args, + representation_sizes=representation_sizes, + representation_types=representation_types, + correlations=correlations, + ) + + # The JSON is now the canonical artifact, so remove the intermediate. Only + # reached after a successful parse + write, so a failure keeps it around. + try: + os.remove(intermediate_path) + except OSError: + pass + + return uxhw_version + + +def load_measurement_data( + *, + benchmarking_variables: list[BenchmarkingVariable], + runs: list[dict], + demo_cli_args: str, + representation_sizes: list[int], + representation_types: list[str], + correlations: list[str], +) -> None: + """ + Populate BenchmarkingVariable objects with measurement data from + parsed runs. + + Precondition: each variable's ``emcc_results.equiv_mc_list`` must + already be populated (typically via ``analysis.load_emcc_data``) + so the final native-MC count check has a meaningful expected value. + The orchestrator (``benchmark_application.py`` or + ``Benchmark.compute_*`` callers) is responsible for pre-calling + ``analysis.load_emcc_data``. + + Args: + benchmarking_variables: Variables to populate. + runs: list of run dicts produced by + ``parse_timing_intermediate_stream``. + demo_cli_args: Demo-level command-line arguments string. + representation_sizes: Configured representation sizes (used + only in the final invariant check). + representation_types: Configured representation types (used + only in the final invariant check). + correlations: Configured correlation modes (used only in the + final invariant check). + + Raises: + RuntimeError: If the parsed native-MC count disagrees with the + EMCC-derived expectation, or if any non-zero native-MC + measurements were parsed while ``equiv_mc_list`` is empty + for every variable (indicates missing EMCC pre-load). + """ + native_mc_counter = 0 + uxhw_counter = 0 + for run in runs: + # A UxHw run's recorded CLA includes a standalone `-T` tracing flag. + # Drop that token so it matches the variable CLA, which has none. Match + # on the whole token (not a substring) so application arguments that + # merely contain "-T" (e.g. `-Threads`, `-T5`, a path with `-T`) are + # preserved rather than corrupted. + raw_cla = run.get(TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS, "") + cla = " ".join(tok for tok in raw_cla.split() if tok != "-T") + + for measurement in run.get(TimingFormat.JSON_KEY_MEASUREMENTS, []): + config = measurement[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG] + time = measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME] + db_time = measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME] + e2e_time = measurement[TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME] + db_dyn_inst_count = measurement[ + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT + ] + pin_dyn_inst_count = measurement[ + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT + ] + + if config.startswith("Native-MC"): + # Native-MC rows have no database time or database + # dynamic instruction count. Ignore those fields. + if None in (time, e2e_time, pin_dyn_inst_count): + continue + for variable in benchmarking_variables: + # Collapse internal whitespace, matching the normalization + # of `cla` above, so YAML CLAs with accidental double + # spaces still match. + full_cla = " ".join(f"{demo_cli_args} {variable.cla}".split()) + if full_cla == cla: + variable.timing_measurements.append( + config=config, + time=time, + e2e_time=e2e_time, + pin_dyn_inst_count=pin_dyn_inst_count, + ) + native_mc_counter += 1 + else: + # UxHw path: only ingest rows with all five numeric fields. + # Reference and Native rows carry `?` in some columns and are + # intentionally skipped. + if None in ( + time, + db_time, + e2e_time, + db_dyn_inst_count, + pin_dyn_inst_count, + ): + continue + for variable in benchmarking_variables: + full_cla = " ".join(f"{demo_cli_args} {variable.cla}".split()) + if full_cla == cla: + variable.timing_measurements.append( + config=config, + time=time, + db_time=db_time, + e2e_time=e2e_time, + db_dyn_inst_count=db_dyn_inst_count, + pin_dyn_inst_count=pin_dyn_inst_count, + ) + uxhw_counter += 1 + num_uxhw_configs = ( + len(benchmarking_variables) + * len(representation_sizes) + * len(representation_types) + * len(correlations) + ) + if uxhw_counter != num_uxhw_configs: + raise RuntimeError( + f"Error! Measurement data was obtained for {uxhw_counter} " + f"UxHw configurations, expected {num_uxhw_configs}" + ) + num_native_mc_configs: int = int( + np.sum( + [ + len(variable.emcc_results.equiv_mc_list) + for variable in benchmarking_variables + ] + ) + ) + if native_mc_counter > 0 and num_native_mc_configs == 0: + raise RuntimeError( + f"Parsed {native_mc_counter} native-MC measurement rows but " + "BenchmarkingVariable.emcc_results.equiv_mc_list is empty for " + "every variable. Call analysis.load_emcc_data() before " + "load_measurement_data()." + ) + if native_mc_counter != num_native_mc_configs: + raise RuntimeError( + f"Error! Measurement data was obtained {native_mc_counter} " + f"native MC configurations, expected {num_native_mc_configs}" + ) + + +def load_timing_data_to_dfs( + *, + benchmarking_variables: list[BenchmarkingVariable], +) -> None: + """ + Cross-join per-variable timing measurements into ``emcc_data``. + + For each variable, walk its ``timing_measurements.measurement_dict`` + and merge each row into the matching ``emcc_data`` record. Native-MC + configurations are matched on the trailing integer (the equivalent + Monte Carlo count) and prefix their column names with ``"Native "``. + UxHw configurations are matched on the UxHw configuration string. + + Precondition: each variable's ``emcc_results.emcc_data`` must be + populated (typically via ``analysis.load_emcc_data``). Otherwise + there is nothing to join into and every row would be silently + skipped. The orchestrator is responsible for pre-calling + ``analysis.load_emcc_data``. + + Args: + benchmarking_variables: Variables whose ``timing_measurements`` + and ``emcc_data`` are joined in place. + + Raises: + RuntimeError: If every variable has an empty + ``emcc_results.emcc_data`` (indicates missing EMCC pre-load). + """ + if benchmarking_variables and not any( + variable.emcc_results.emcc_data for variable in benchmarking_variables + ): + raise RuntimeError( + "Cannot join timing measurements: BenchmarkingVariable." + "emcc_results.emcc_data is empty for every variable. Call " + "analysis.load_emcc_data() before load_timing_data_to_dfs()." + ) + + for variable in benchmarking_variables: + for dist, values in variable.timing_measurements.measurement_dict.items(): + dist_str = repr(dist).strip("'") + is_native = dist_str.startswith("Native-MC-") + + match_val: str | int + # Determine match key and value + if is_native: + native_count_str = dist_str.split("-")[-1] + try: + match_val = int(native_count_str) + except ValueError: + print( + f"Warning: could not parse an MC count from native-MC " + f"config {dist_str!r}; skipping this measurement." + ) + continue + if ( + variable.emcc_results.emcc_data + and EquivMC.EMCC in variable.emcc_results.emcc_data[0] + ): + match_key = EquivMC.EMCC + elif ( + variable.emcc_results.emcc_data + and EquivMC.EMCC_PREDICTED in variable.emcc_results.emcc_data[0] + ): + match_key = EquivMC.EMCC_PREDICTED + else: + print( + f"Warning: Neither {EquivMC.EMCC} nor {EquivMC.EMCC_PREDICTED} found" + ) + continue + else: + match_key = BenchmarkingVariables.UXHW_CONF + match_val = dist + + # Update matching records. repr() + .strip("'") normalises both + # plain strings and non-string objects in emcc_data to the same + # bare configuration text. + found_match = False + for record in variable.emcc_results.emcc_data: + record_val = record.get(match_key) + if record_val == match_val or repr(record_val).strip("'") == dist_str: + found_match = True + for col, val in values.items(): + col_name = f"Native {col}" if is_native else col + record[col_name] = val + + if not found_match: + print(f"Warning: No timing data found for {dist_str}") + + +def load_asymptotic_dist( + *, + benchmarking_variables: list[BenchmarkingVariable], + asymptotic_dist_file: str, +) -> None: + """ + Load asymptotic distribution data into each variable's + ``asymptotic_distribution`` dataclass if not already populated. + + For distribution-typed variables, also loads the asymptotic-sample + NumPy file produced by the asymptotic-distance pipeline. + + A variable counts as already populated when its + ``asymptotic_distribution.quantile_95`` is not ``None``. The generator + always sets ``quantile_95`` (from a ``quantile`` / ``ppf`` call that never + returns ``None``), making it a more reliable "producer has run" sentinel + than ``mean``, which can legitimately be ``None``. + + Args: + benchmarking_variables: Variables to populate. + asymptotic_dist_file: Path to the asymptotic-distance CSV file. + + Raises: + FileNotFoundError: If ``asymptotic_dist_file`` is missing and + at least one variable still needs loading. (No exception + is raised when every variable already has + ``asymptotic_distribution.quantile_95`` populated — the + function returns early before any file access.) + """ + # Skip the whole CSV read if every variable already has data. + if all( + variable.asymptotic_distribution.quantile_95 is not None + for variable in benchmarking_variables + ): + return + + print( + "Warning: Asymptotic distance distribution data not found in Benchmark object." + ) + print(f"Loading asymptotic distance data from {asymptotic_dist_file}") + + try: + asymptotic_df = pd.read_csv(asymptotic_dist_file) + except FileNotFoundError: + print(f"Error: File not found at {asymptotic_dist_file}") + print("Cannot continue without asymptotic distance data. Terminating.") + raise + + records_by_variable: defaultdict = defaultdict(list) + for record in asymptotic_df.to_dict("records"): + var_name = record[BenchmarkingVariables.VARIABLE_DESCRIPTION] + records_by_variable[var_name].append(record) + + for variable in benchmarking_variables: + if variable.asymptotic_distribution.quantile_95 is not None: + continue + + if variable.type == VariableTypes.DISTRIBUTION: + samples = np.load(f"{variable.formatted_description}-asymptotic.npy") + variable.asymptotic_distribution.samples = samples + + variable_records: list[dict] = records_by_variable[variable.description] + if not variable_records: + print(f"Warning: No asymptotic data found for '{variable.description}'") + continue + + # Take the first matching record (matches legacy behaviour). + record = variable_records[0] + asymptotic = variable.asymptotic_distribution + asymptotic.mean = record.get(ReportingMethods.MEAN) + asymptotic.quantile_95 = record.get(ReportingMethods.QUANTILE_95) + asymptotic.quantile_99 = record.get(ReportingMethods.QUANTILE_99) + asymptotic.mean_quantile = record.get(EquivMC.MEAN_QUANTILE) + asymptotic.is_normal = record.get(AsymptoticDistanceDistribution.IS_NORMAL) + asymptotic.scale = record.get(AsymptoticDistanceDistribution.SCALE) + + +def load_uxhw_distances( + *, + benchmarking_variables: list[BenchmarkingVariable], + uxhw_distance_file: str, + asymptotic_dist_file: str, +) -> None: + """ + Load UxHw distance distribution data into each variable's + ``uxhw_distances.records`` if not already populated. + + Also pre-loads asymptotic distribution data (intra-module call to + :func:`load_asymptotic_dist`), preserving the legacy invocation + ordering at the orchestrator's call sites. + + Args: + benchmarking_variables: Variables to populate. + uxhw_distance_file: Path to the UxHw distances CSV file. + asymptotic_dist_file: Path to the asymptotic-distance CSV file + (forwarded to :func:`load_asymptotic_dist`). + + Raises: + FileNotFoundError: If ``uxhw_distance_file`` is missing and at + least one variable still needs UxHw-distance hydration, or + propagated from :func:`load_asymptotic_dist` if + ``asymptotic_dist_file`` is missing and any variable still + needs asymptotic hydration. (Returns early without raising + when every variable's relevant stage is already populated.) + """ + load_asymptotic_dist( + benchmarking_variables=benchmarking_variables, + asymptotic_dist_file=asymptotic_dist_file, + ) + + # Skip CSV read if no variable needs hydration. + if not any( + not variable.uxhw_distances.records for variable in benchmarking_variables + ): + return + + print("Warning: UxHw distance distribution data not found in Benchmark object.") + print(f"Loading UxHw distance data from {uxhw_distance_file}") + + try: + distance_df = pd.read_csv(uxhw_distance_file) + except FileNotFoundError: + print(f"Error: File not found at {uxhw_distance_file}") + print("Cannot continue without UxHw distances data. Terminating.") + raise + + records_by_variable: defaultdict = defaultdict(list) + for record in distance_df.to_dict("records"): + var_name = record[BenchmarkingVariables.VARIABLE_DESCRIPTION] + records_by_variable[var_name].append(record) + + for variable in benchmarking_variables: + if variable.uxhw_distances.records: + continue + + variable_records = records_by_variable[variable.description] + if not variable_records: + print(f"Warning: No UxHw distance data found for '{variable.description}'") + continue + + for record in variable_records: + binned = record.get(BenchmarkingVariables.UXHW_BINNED_DISTANCE) + if pd.isna(binned): + binned = None + variable.uxhw_distances.records.append( + UxhwDistanceRecord( + uxhw_conf=record.get(BenchmarkingVariables.UXHW_CONF), + uxhw_distance=record.get(BenchmarkingVariables.UXHW_DISTANCE), + uxhw_binned_distance=binned, + ) + ) diff --git a/src/signaloid/benchmarking/automation/measurement_loader_test.py b/src/signaloid/benchmarking/automation/measurement_loader_test.py new file mode 100644 index 0000000..9e6b4d2 --- /dev/null +++ b/src/signaloid/benchmarking/automation/measurement_loader_test.py @@ -0,0 +1,624 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import json +import os +import tempfile +import unittest +from pathlib import Path + +import numpy as np +import pandas as pd + +from signaloid.benchmarking.automation.measurement_loader import ( + load_asymptotic_dist, + load_measurement_data, + load_measurement_dicts, + load_timing_data_to_dfs, + load_uxhw_distances, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + UxhwDistanceRecord, +) +from signaloid.benchmarking.config import ( + AsymptoticDistanceDistribution, + BenchmarkingVariables, + EquivMC, + RepresentationTypes, + ReportingMethods, + TimingFormat, + VariableTypes, +) + + +def _make_variable( + name: str, + description: str, + *, + cla: str = "", + var_type: str = VariableTypes.DISTRIBUTION, +) -> BenchmarkingVariable: + """Create a minimal BenchmarkingVariable for tests.""" + return BenchmarkingVariable( + name=name, + description=description, + cla=cla, + type=var_type, + ) + + +class _ConfigLikeObject: + """Non-string config-like object: __repr__ returns the bare config + text (matching Distribution.__repr__ in the real pipeline). Used to + exercise the repr()-based normalization path in load_timing_data_to_dfs.""" + + def __init__(self, config: str) -> None: + self._config = config + + def __repr__(self) -> str: + return self._config + + +class TestLoadMeasurementDicts(unittest.TestCase): + """Exercise load_measurement_dicts (intermediate parse + JSON write).""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_load_measurement_dicts_writes_json_and_returns_versions( + self, + ) -> None: + """End-to-end happy path: parse intermediate, write JSON, return + versions.""" + variable = _make_variable( + name="x", + description="x var", + cla="-S 0", + ) + intermediate_path = self.tmp_path / "app-v1-timings.intermediate" + json_path = self.tmp_path / "app-v1-timings.json" + logs_dir = self.tmp_path / "logs" + logs_dir.mkdir() + + intermediate_path.write_text( + "META timestamp 2026-04-15T14:22:10Z\n" + "META applicationName app\n" + "META applicationVersion v1\n" + "META uxhwSdkVersion uxhw-Y\n" + "META commandLineArguments -T -S 0\n" + "META commandLineArgumentsHash abc\n" + "META uxhwTargetRepetitions 1\n" + "MEASUREMENT Athens-16 1.0 2.0 3.0 100 200\n" + "META timestamp 2026-04-15T14:25:00Z\n" + "META applicationName app\n" + "META applicationVersion v1\n" + "META uxhwSdkVersion uxhw-Y\n" + "META commandLineArguments -S 0\n" + "META commandLineArgumentsHash abc\n" + "META uxhwTargetRepetitions 1\n" + ) + + uxhw_version = load_measurement_dicts( + benchmarking_variables=[variable], + intermediate_path=str(intermediate_path), + json_path=str(json_path), + logs_dir=str(logs_dir), + demo_cli_args="", + representation_sizes=[16], + representation_types=[RepresentationTypes.ATHENS], + correlations=["Disabled"], + ) + + self.assertEqual(uxhw_version, "uxhw-Y") + self.assertTrue(json_path.exists()) + # Intermediate is removed on successful parse+write. + self.assertFalse(intermediate_path.exists()) + + document = json.loads(json_path.read_text()) + # Session-level META lifted to top level, runs array preserved. + self.assertEqual(document[TimingFormat.META_KEY_UXHW_SDK_VERSION], "uxhw-Y") + self.assertIn(TimingFormat.JSON_KEY_RUNS, document) + self.assertEqual(len(document[TimingFormat.JSON_KEY_RUNS]), 2) + + # Variable was populated via internal call to load_measurement_data. + measurement_dict = variable.timing_measurements.measurement_dict + self.assertIn("Athens-16", measurement_dict) + + def test_load_measurement_dicts_missing_intermediate_raises(self) -> None: + """Missing intermediate file raises RuntimeError pointing at the + log.""" + variable = _make_variable(name="x", description="x var") + intermediate_path = self.tmp_path / "missing.intermediate" + json_path = self.tmp_path / "out.json" + logs_dir = self.tmp_path / "logs" + logs_dir.mkdir() + + with self.assertRaises(RuntimeError) as exc_info: + load_measurement_dicts( + benchmarking_variables=[variable], + intermediate_path=str(intermediate_path), + json_path=str(json_path), + logs_dir=str(logs_dir), + demo_cli_args="", + representation_sizes=[16], + representation_types=[RepresentationTypes.ATHENS], + correlations=["Disabled"], + ) + + message = str(exc_info.exception) + self.assertIn("missing.intermediate", message) + self.assertIn("timing_script_stderr.log", message) + + def test_load_measurement_dicts_prefers_first_non_empty_meta(self) -> None: + """When META values disagree across runs, the first non-empty value + in run order wins (deterministic; not arbitrary set iteration).""" + variable = _make_variable(name="x", description="x var", cla="") + intermediate_path = self.tmp_path / "app-v1-timings.intermediate" + json_path = self.tmp_path / "app-v1-timings.json" + logs_dir = self.tmp_path / "logs" + logs_dir.mkdir() + + # Two runs disagree on uxhwSdkVersion: first is "uxhw-A", + # second is "uxhw-B". The first non-empty value in run order + # should win. + intermediate_path.write_text( + "META timestamp 2026-04-15T14:22:10Z\n" + "META applicationName app\n" + "META applicationVersion v1\n" + "META uxhwSdkVersion uxhw-A\n" + "META commandLineArguments -T\n" + "META commandLineArgumentsHash abc\n" + "META uxhwTargetRepetitions 1\n" + "MEASUREMENT Athens-16 1.0 2.0 3.0 100 200\n" + "META timestamp 2026-04-15T14:25:00Z\n" + "META applicationName app\n" + "META applicationVersion v1\n" + "META uxhwSdkVersion uxhw-B\n" + "META commandLineArguments \n" + "META commandLineArgumentsHash abc\n" + "META uxhwTargetRepetitions 1\n" + ) + + uxhw_version = load_measurement_dicts( + benchmarking_variables=[variable], + intermediate_path=str(intermediate_path), + json_path=str(json_path), + logs_dir=str(logs_dir), + demo_cli_args="", + representation_sizes=[16], + representation_types=[RepresentationTypes.ATHENS], + correlations=["Disabled"], + ) + + # First non-empty value in run order wins. Not the second run's + # value, and not any arbitrary set-iteration pick. + self.assertEqual(uxhw_version, "uxhw-A") + + +class TestLoadMeasurementData(unittest.TestCase): + """Exercise load_measurement_data run-to-variable routing.""" + + def test_load_measurement_data_populates_timing_measurements(self) -> None: + """UxHw and Native-MC measurements are routed to the right + variable.""" + variable = _make_variable(name="x", description="x var", cla="-S 0") + variable.emcc_results.equiv_mc_list = [50] + + runs = [ + { + TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS: "-T -S 0", + TimingFormat.JSON_KEY_MEASUREMENTS: [ + { + TimingFormat.JSON_KEY_MEASUREMENT_CONFIG: ("Athens-16"), + TimingFormat.JSON_KEY_MEASUREMENT_TIME: 1.0, + TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME: 2.0, + TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME: 3.0, + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT: (100.0), + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT: (200.0), + }, + ], + }, + { + TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS: "-S 0", + TimingFormat.JSON_KEY_MEASUREMENTS: [ + { + TimingFormat.JSON_KEY_MEASUREMENT_CONFIG: "Native-MC-50", + TimingFormat.JSON_KEY_MEASUREMENT_TIME: 0.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME: None, + TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME: 1.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT: (None), + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT: (300.0), + }, + ], + }, + ] + + load_measurement_data( + benchmarking_variables=[variable], + runs=runs, + demo_cli_args="", + representation_sizes=[16], + representation_types=[RepresentationTypes.ATHENS], + correlations=["Disabled"], + ) + + measurement_dict = variable.timing_measurements.measurement_dict + uxhw = measurement_dict["Athens-16"] + self.assertEqual(uxhw["In Application Time"], 1.0) + self.assertEqual(uxhw["Database Time"], 2.0) + self.assertEqual(uxhw["End-to-End Time"], 3.0) + self.assertEqual(uxhw["Database Dyn. Inst. Count"], 100.0) + self.assertEqual(uxhw["PIN Dyn. Inst. Count"], 200.0) + + native = measurement_dict["Native-MC-50"] + self.assertEqual(native["In Application Time"], 0.5) + self.assertEqual(native["End-to-End Time"], 1.5) + self.assertEqual(native["PIN Dyn. Inst. Count"], 300.0) + + +class TestLoadTimingDataToDfs(unittest.TestCase): + """Exercise load_timing_data_to_dfs joining timings into emcc_data.""" + + def test_load_timing_data_to_dfs_joins_measurements_into_emcc_data( + self, + ) -> None: + """Native and UxHw timings are merged into emcc_data records.""" + variable = _make_variable(name="x", description="x var") + variable.emcc_results.emcc_data = [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + EquivMC.EMCC: 50, + }, + ] + variable.timing_measurements.append( + config="Athens-16", + time=1.0, + e2e_time=3.0, + pin_dyn_inst_count=200.0, + db_time=2.0, + db_dyn_inst_count=100.0, + ) + variable.timing_measurements.append( + config="Native-MC-50", + time=0.5, + e2e_time=1.5, + pin_dyn_inst_count=300.0, + ) + + load_timing_data_to_dfs(benchmarking_variables=[variable]) + + record = variable.emcc_results.emcc_data[0] + # UxHw columns merged without prefix. + self.assertEqual(record["In Application Time"], 1.0) + self.assertEqual(record["Database Time"], 2.0) + # Native columns merged with "Native " prefix. + self.assertEqual(record["Native In Application Time"], 0.5) + self.assertEqual(record["Native End-to-End Time"], 1.5) + + def test_load_timing_data_to_dfs_raises_when_emcc_data_empty(self) -> None: + """If every variable has empty emcc_data, the function must raise + rather than silently producing unjoined records (signals missing + analysis.load_emcc_data pre-call).""" + variable = _make_variable(name="x", description="x var") + # Populate timing_measurements but leave emcc_data empty. + variable.timing_measurements.append( + config="Athens-16", + time=1.0, + e2e_time=3.0, + pin_dyn_inst_count=200.0, + db_time=2.0, + db_dyn_inst_count=100.0, + ) + + with self.assertRaises(RuntimeError) as exc_info: + load_timing_data_to_dfs(benchmarking_variables=[variable]) + + self.assertIn("load_emcc_data", str(exc_info.exception)) + + def test_load_timing_data_to_dfs_matches_distribution_repr(self) -> None: + """UxHw records whose UXHW_CONF is a non-string object must still + match via repr()-based normalization.""" + variable = _make_variable(name="y", description="y var") + variable.emcc_results.emcc_data = [ + { + BenchmarkingVariables.UXHW_CONF: _ConfigLikeObject("Athens-16"), + EquivMC.EMCC: 50, + }, + ] + variable.timing_measurements.append( + config="Athens-16", + time=4.0, + e2e_time=5.0, + pin_dyn_inst_count=600.0, + db_time=2.0, + db_dyn_inst_count=100.0, + ) + + load_timing_data_to_dfs(benchmarking_variables=[variable]) + + record = variable.emcc_results.emcc_data[0] + self.assertEqual(record["In Application Time"], 4.0) + self.assertEqual(record["End-to-End Time"], 5.0) + self.assertEqual(record["PIN Dyn. Inst. Count"], 600.0) + + +class TestLoadAsymptoticDist(unittest.TestCase): + """Exercise load_asymptotic_dist CSV/.npy population.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def _chdir_to_tmp(self) -> None: + """chdir into the test's temp dir, restoring cwd on cleanup.""" + original_cwd = os.getcwd() + self.addCleanup(os.chdir, original_cwd) + os.chdir(self.tmp_path) + + def test_load_asymptotic_dist_populates_variable(self) -> None: + """CSV records are loaded into the asymptotic_distribution + dataclass per variable.""" + # The function loads ``-asymptotic.npy`` from + # the current working directory for DISTRIBUTION variables and isolate + # to tmp. + self._chdir_to_tmp() + + variable = _make_variable( + name="x", + description="x var", + var_type=VariableTypes.DISTRIBUTION, + ) + samples = np.array([0.1, 0.2, 0.3]) + np.save(f"{variable.formatted_description}-asymptotic.npy", samples) + + asymptotic_csv = self.tmp_path / "asymptotic_distances.csv" + pd.DataFrame( + [ + { + BenchmarkingVariables.VARIABLE_DESCRIPTION: "x var", + ReportingMethods.MEAN: 0.1, + ReportingMethods.QUANTILE_95: 0.5, + ReportingMethods.QUANTILE_99: 0.9, + EquivMC.MEAN_QUANTILE: 0.42, + AsymptoticDistanceDistribution.IS_NORMAL: True, + AsymptoticDistanceDistribution.SCALE: 0.05, + } + ] + ).to_csv(asymptotic_csv, index=False) + + load_asymptotic_dist( + benchmarking_variables=[variable], + asymptotic_dist_file=str(asymptotic_csv), + ) + + asymptotic = variable.asymptotic_distribution + assert asymptotic.samples is not None # narrow for type checker + np.testing.assert_array_equal(asymptotic.samples, samples) + self.assertEqual(asymptotic.mean, 0.1) + self.assertEqual(asymptotic.quantile_95, 0.5) + self.assertEqual(asymptotic.quantile_99, 0.9) + self.assertEqual(asymptotic.mean_quantile, 0.42) + self.assertTrue(asymptotic.is_normal) + self.assertEqual(asymptotic.scale, 0.05) + + def test_load_asymptotic_dist_skips_csv_when_all_populated(self) -> None: + """If every variable already has + ``asymptotic_distribution.quantile_95`` populated, the function + must return without reading the CSV — so a non-existent path must + not raise.""" + variable = _make_variable(name="x", description="x var") + variable.asymptotic_distribution.quantile_95 = 0.5 + + load_asymptotic_dist( + benchmarking_variables=[variable], + asymptotic_dist_file=str(self.tmp_path / "does-not-exist.csv"), + ) + + # Pre-existing data untouched. No FileNotFoundError despite the + # CSV not existing. + self.assertEqual(variable.asymptotic_distribution.quantile_95, 0.5) + + +class TestLoadUxhwDistances(unittest.TestCase): + """Exercise load_uxhw_distances CSV population and intra-module wiring.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def _chdir_to_tmp(self) -> None: + """chdir into the test's temp dir, restoring cwd on cleanup.""" + original_cwd = os.getcwd() + self.addCleanup(os.chdir, original_cwd) + os.chdir(self.tmp_path) + + def test_load_uxhw_distances_populates_variable(self) -> None: + """CSV records are loaded into uxhw_distances.records per variable. + + Also covers the intra-module call to ``load_asymptotic_dist`` — + pre-populating ``asymptotic_distribution.quantile_95`` + short-circuits that side branch so we can focus on the UxHw path + here. + """ + self._chdir_to_tmp() + + variable = _make_variable( + name="x", + description="x var", + var_type=VariableTypes.DISTRIBUTION, + ) + # Pre-populate asymptotic so the intra-module call short-circuits. + variable.asymptotic_distribution.quantile_95 = 0.5 + + uxhw_csv = self.tmp_path / "uxhw_distances.csv" + pd.DataFrame( + [ + { + BenchmarkingVariables.VARIABLE_DESCRIPTION: "x var", + BenchmarkingVariables.UXHW_CONF: "Athens-16", + BenchmarkingVariables.UXHW_DISTANCE: 0.123, + BenchmarkingVariables.UXHW_BINNED_DISTANCE: 0.234, + } + ] + ).to_csv(uxhw_csv, index=False) + + asymptotic_csv = self.tmp_path / "asymptotic_distances.csv" + # Empty CSV with required column so the asymptotic loader is a no-op. + pd.DataFrame([{BenchmarkingVariables.VARIABLE_DESCRIPTION: "ignored"}]).to_csv( + asymptotic_csv, index=False + ) + + load_uxhw_distances( + benchmarking_variables=[variable], + uxhw_distance_file=str(uxhw_csv), + asymptotic_dist_file=str(asymptotic_csv), + ) + + self.assertEqual(len(variable.uxhw_distances.records), 1) + record = variable.uxhw_distances.records[0] + self.assertEqual(record.uxhw_conf, "Athens-16") + self.assertEqual(record.uxhw_distance, 0.123) + self.assertEqual(record.uxhw_binned_distance, 0.234) + + def test_load_uxhw_distances_normalises_missing_binned_to_none( + self, + ) -> None: + """Empty binned-distance cells round-trip as ``None``, not NaN. + + pandas reads empty CSV cells as NaN. The loader normalises so + callers can rely on the typed ``float | None`` contract. + """ + self._chdir_to_tmp() + + variable = _make_variable( + name="x", + description="x var", + var_type=VariableTypes.DISTRIBUTION, + ) + variable.asymptotic_distribution.quantile_95 = 0.5 + + uxhw_csv = self.tmp_path / "uxhw_distances.csv" + pd.DataFrame( + [ + { + BenchmarkingVariables.VARIABLE_DESCRIPTION: "x var", + BenchmarkingVariables.UXHW_CONF: "Athens-16", + BenchmarkingVariables.UXHW_DISTANCE: 0.123, + BenchmarkingVariables.UXHW_BINNED_DISTANCE: None, + } + ] + ).to_csv(uxhw_csv, index=False) + + asymptotic_csv = self.tmp_path / "asymptotic_distances.csv" + pd.DataFrame([{BenchmarkingVariables.VARIABLE_DESCRIPTION: "ignored"}]).to_csv( + asymptotic_csv, index=False + ) + + load_uxhw_distances( + benchmarking_variables=[variable], + uxhw_distance_file=str(uxhw_csv), + asymptotic_dist_file=str(asymptotic_csv), + ) + + record = variable.uxhw_distances.records[0] + self.assertIsNone(record.uxhw_binned_distance) + + def test_load_uxhw_distances_invokes_load_asymptotic_dist(self) -> None: + """Intra-module call wires asymptotic data into the same + variables.""" + self._chdir_to_tmp() + + variable = _make_variable( + name="x", + description="x var", + var_type=VariableTypes.SCALAR, + ) + # SCALAR variable: load_asymptotic_dist skips the .npy load and just + # reads the CSV. + + uxhw_csv = self.tmp_path / "uxhw_distances.csv" + pd.DataFrame( + [ + { + BenchmarkingVariables.VARIABLE_DESCRIPTION: "x var", + BenchmarkingVariables.UXHW_CONF: "Athens-16", + BenchmarkingVariables.UXHW_DISTANCE: 0.1, + BenchmarkingVariables.UXHW_BINNED_DISTANCE: 0.2, + } + ] + ).to_csv(uxhw_csv, index=False) + + asymptotic_csv = self.tmp_path / "asymptotic_distances.csv" + pd.DataFrame( + [ + { + BenchmarkingVariables.VARIABLE_DESCRIPTION: "x var", + ReportingMethods.MEAN: 0.9, + ReportingMethods.QUANTILE_95: 0.95, + ReportingMethods.QUANTILE_99: 0.99, + EquivMC.MEAN_QUANTILE: 0.5, + AsymptoticDistanceDistribution.IS_NORMAL: False, + AsymptoticDistanceDistribution.SCALE: 0.7, + } + ] + ).to_csv(asymptotic_csv, index=False) + + load_uxhw_distances( + benchmarking_variables=[variable], + uxhw_distance_file=str(uxhw_csv), + asymptotic_dist_file=str(asymptotic_csv), + ) + + # Both data structures were populated. + self.assertTrue(variable.uxhw_distances.records) + self.assertEqual(variable.asymptotic_distribution.mean, 0.9) + + def test_load_uxhw_distances_skips_csv_when_all_populated(self) -> None: + """If every variable already has uxhw_distances.records populated + (and ``asymptotic_distribution.quantile_95``, since + load_uxhw_distances calls load_asymptotic_dist intra-module + first), neither CSV is read.""" + variable = _make_variable(name="x", description="x var") + variable.asymptotic_distribution.quantile_95 = 0.5 + placeholder = UxhwDistanceRecord( + uxhw_conf="placeholder", + uxhw_distance=0.0, + ) + variable.uxhw_distances.records = [placeholder] + + load_uxhw_distances( + benchmarking_variables=[variable], + uxhw_distance_file=str(self.tmp_path / "missing-uxhw.csv"), + asymptotic_dist_file=str(self.tmp_path / "missing-asymptotic.csv"), + ) + + # Both stages left untouched. No FileNotFoundError despite both + # CSV paths being bogus. + self.assertEqual(variable.uxhw_distances.records, [placeholder]) + self.assertEqual(variable.asymptotic_distribution.quantile_95, 0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/merge_tracing_dbs.py b/src/signaloid/benchmarking/automation/merge_tracing_dbs.py new file mode 100644 index 0000000..0f04659 --- /dev/null +++ b/src/signaloid/benchmarking/automation/merge_tracing_dbs.py @@ -0,0 +1,310 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import argparse +import os +import shutil +import sqlite3 +import sys +from contextlib import closing + +# Name of the table whose primary key is auto-incremented per source DB (and +# which the foreign keys in the other tracing tables refer to). Pulled out of +# the inline SQL so a schema rename only touches these constants. +_EXECUTION_INFO_TABLE = "Emulator_Execution_Info" +_EXECUTION_ID_COLUMN = "Execution_ID" +_FOREIGN_KEY_COLUMN = "Execution_Info_Table_ID" + + +def _get_table_columns(connection: sqlite3.Connection, table: str) -> list[str]: + """ + Return the column names of *table* in the order ``PRAGMA`` lists. + + ``PRAGMA table_info`` yields rows of the form + ``(cid, name, type, notnull, dflt_value, pk)``. The second column is the + name. + + Args: + connection: Open connection to the database holding *table*. + table: Table whose column names to return. + + Returns: + The column names, in ``PRAGMA table_info`` order. + """ + cursor = connection.execute(f'PRAGMA table_info("{table}")') + return [row[1] for row in cursor.fetchall()] + + +def _list_tables(connection: sqlite3.Connection) -> list[str]: + """ + Return all user-table names in *connection* (excluding indices). + + Lists ordinary tables only (skipping views and internal tables) + by filtering ``sqlite_master`` on ``type='table'`` and excluding the + ``sqlite_*`` prefix. + + Args: + connection: Open connection to inspect. + + Returns: + The user-table names, sorted. + """ + cursor = connection.execute( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' AND name NOT LIKE 'sqlite_%' " + "ORDER BY name" + ) + return [row[0] for row in cursor.fetchall()] + + +def _insert_execution_info(connection: sqlite3.Connection, src_db: str) -> None: + """ + Insert ``Emulator_Execution_Info`` rows from *src_db* into the target. + + The ``Execution_ID`` auto-increment column is excluded from both the column + list and the SELECT so the target's ``INTEGER PRIMARY KEY`` assigns a fresh + value to each inserted row. + + Args: + connection: Open connection to the target database. + src_db: Path to the source database to copy rows from. + """ + columns = _get_table_columns(connection, _EXECUTION_INFO_TABLE) + insert_columns = [c for c in columns if c != _EXECUTION_ID_COLUMN] + column_list = ", ".join(f'"{c}"' for c in insert_columns) + + # ATTACH/DETACH around the INSERT scopes the cross-DB access to this + # statement group and avoids leaving an attached connection between + # source iterations. + connection.execute("ATTACH DATABASE ? AS src", (src_db,)) + try: + connection.execute( + f'INSERT INTO "{_EXECUTION_INFO_TABLE}" ({column_list}) ' + f"SELECT {column_list} " + f'FROM src."{_EXECUTION_INFO_TABLE}"' + ) + # Python's sqlite3 opens an implicit transaction on DML, and DETACH + # fails while one is open on the attached source, so commit first. + connection.commit() + finally: + connection.execute("DETACH DATABASE src") + + +def _insert_remapped_table( + connection: sqlite3.Connection, + src_db: str, + table: str, + id_offset: int, +) -> None: + """ + Insert *src_db*'s rows for *table*, offsetting the FK column. + + The column list comes from the source's ``PRAGMA table_info``, and the + SELECT replaces ``Execution_Info_Table_ID`` with + ``Execution_Info_Table_ID + id_offset``. The offset is the pre-INSERT + ``MAX(Execution_ID)`` of the target. Since each source starts at + ``Execution_ID = 1``, adding it places the row at ``old_max + 1``, the new + ``Execution_ID`` in the target. + + Args: + connection: Open connection to the target database. + src_db: Path to the source database to copy rows from. + table: Table to copy (must contain the FK column). + id_offset: Value added to each row's ``Execution_Info_Table_ID``. + """ + # Read the schema from the source DB via a separate connection (rather than + # the attached alias) so introspection is independent of ATTACH state. + with closing(sqlite3.connect(src_db)) as src_connection: + columns = _get_table_columns(src_connection, table) + column_list = ", ".join(f'"{c}"' for c in columns) + connection.execute("ATTACH DATABASE ? AS src", (src_db,)) + try: + # id_offset comes from a SELECT result, not user input, so it is always + # an integer. It is embedded inline rather than parameter-bound because + # it appears inside a SELECT projection. Cast to int for safety. + offset = int(id_offset) + select_pieces = [] + for column in columns: + if column == _FOREIGN_KEY_COLUMN: + select_pieces.append(f'"{_FOREIGN_KEY_COLUMN}" + {offset}') + else: + select_pieces.append(f'"{column}"') + select_list = ", ".join(select_pieces) + connection.execute( + f'INSERT INTO "{table}" ({column_list}) ' + f"SELECT {select_list} " + f'FROM src."{table}"' + ) + # sqlite3 opens an implicit transaction on DML, and DETACH + # fails while one is open on the attached source, so commit first. + connection.commit() + finally: + connection.execute("DETACH DATABASE src") + + +def _insert_or_ignore_table( + connection: sqlite3.Connection, src_db: str, table: str +) -> None: + """ + Idempotent merge for tables without the FK column. + + Uses ``INSERT OR IGNORE`` so re-running the merge against an already-merged + target is a no-op for these value-only lookup-style tables. + + Args: + connection: Open connection to the target database. + src_db: Path to the source database to copy rows from. + table: Table to copy (has no FK column). + """ + connection.execute("ATTACH DATABASE ? AS src", (src_db,)) + try: + connection.execute( + f'INSERT OR IGNORE INTO "{table}" ' f'SELECT * FROM src."{table}"' + ) + # sqlite3 opens an implicit transaction on DML, and DETACH + # fails while one is open on the attached source, so commit first. + connection.commit() + finally: + connection.execute("DETACH DATABASE src") + + +def _merge_one_source(target_db: str, src_db: str) -> None: + """ + Merge a single source DB into an existing *target_db*. + + Assumes target_db already exists (this is not the first source). Computes + the FK offset, copies the execution-info row, then walks the remaining + tables, picking the FK-remapped or ``INSERT OR IGNORE`` branch by + inspecting each table's columns. + + Args: + target_db: Path to the existing target database. + src_db: Path to the source database to merge in. + """ + with sqlite3.connect(target_db) as connection: + cursor = connection.execute( + f'SELECT MAX("{_EXECUTION_ID_COLUMN}") ' f'FROM "{_EXECUTION_INFO_TABLE}"' + ) + row = cursor.fetchone() + # MAX() over an empty table is NULL. Treat as 0 so the first offset + # places the source row at Execution_ID = 1. + old_max = row[0] if row is not None and row[0] is not None else 0 + + _insert_execution_info(connection, src_db) + + # The tables to remap come from the source DB so tables present in the + # source but absent in the target are not silently dropped. ``closing`` + # is required so the schema-reading connection releases its lock on + # src_db before the per-table merges ATTACH it on the target connection. + with closing(sqlite3.connect(src_db)) as src_connection: + tables = _list_tables(src_connection) + fk_flags = { + table: _FOREIGN_KEY_COLUMN in _get_table_columns(src_connection, table) + for table in tables + if table != _EXECUTION_INFO_TABLE + } + + for table, has_fk in fk_flags.items(): + if has_fk: + _insert_remapped_table(connection, src_db, table, old_max) + else: + _insert_or_ignore_table(connection, src_db, table) + + connection.commit() + + +def merge_tracing_dbs(*, target_db: str, source_dbs: list[str]) -> None: + """Merge per-config tracing SQLite DBs into *target_db*. + + For the first source DB that exists, *target_db* is created via + :func:`shutil.copy`. Subsequent source DBs are merged with + ``Execution_Info_Table_ID`` remapped so that foreign keys point at + the new ``Execution_ID`` in the target. Missing source DBs print + a warning to stderr and are skipped. Each source DB is removed + from disk after it is processed. + + Args: + target_db: Destination SQLite DB path. May not exist. The + first present source DB is copied to this path. + source_dbs: Ordered list of per-config tracing DB paths. + Each is consumed (deleted) after merging or after being + reported as missing. + + Raises: + sqlite3.OperationalError: If a source DB has an incompatible + schema (e.g. the ``Emulator_Execution_Info`` table is + absent). + OSError: If file operations on *target_db* or any source + fail. + """ + for src_db in source_dbs: + if not os.path.isfile(src_db): + print( + f"Warning: expected tracing DB {src_db} not found, " f"skipping", + file=sys.stderr, + ) + continue + if not os.path.isfile(target_db): + shutil.copy(src_db, target_db) + else: + _merge_one_source(target_db, src_db) + # Remove every processed source (including the one used to seed the + # target). Missing sources are handled by the `continue` above, so + # os.remove here always has a file to remove. + os.remove(src_db) + + +def main() -> None: + """ + Parse command-line arguments and merge tracing DBs. + + Intended for the bash layer via ``python3 -m + signaloid.benchmarking.automation.merge_tracing_dbs``. The first positional + argument is the destination DB. The rest are the per-config source DBs. + """ + parser = argparse.ArgumentParser( + description=( + "Merge per-config tracing SQLite DBs into a single " + "target DB, remapping Execution_Info_Table_ID foreign " + "keys to point at the new Execution_ID in the target." + ), + ) + parser.add_argument( + "target_db", + help="Destination SQLite DB path (created if absent).", + ) + parser.add_argument( + "source_dbs", + nargs="+", + help=( + "One or more per-config source DB paths. Each is removed " + "from disk after being merged." + ), + ) + args = parser.parse_args() + merge_tracing_dbs(target_db=args.target_db, source_dbs=args.source_dbs) + + +if __name__ == "__main__": + try: + main() + except (OSError, sqlite3.OperationalError, ValueError) as exc: + print(f"merge_tracing_dbs: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/src/signaloid/benchmarking/automation/merge_tracing_dbs_test.py b/src/signaloid/benchmarking/automation/merge_tracing_dbs_test.py new file mode 100644 index 0000000..041cd8a --- /dev/null +++ b/src/signaloid/benchmarking/automation/merge_tracing_dbs_test.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import filecmp +import io +import os +import shutil +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from signaloid.benchmarking.automation.merge_tracing_dbs import ( + main, + merge_tracing_dbs, +) + + +def _make_source_db(path: str, execution_data: str, fk_value: int) -> None: + """Create a per-config tracing DB with the expected schema. + + Mirrors the production tracing schema as used by `merge_tracing_dbs`: + one auto-incrementing `Emulator_Execution_Info` table with a single + row at `Execution_ID = 1`, one FK-bearing table referencing it via + `Execution_Info_Table_ID`, and one lookup table without the FK + (exercises the `INSERT OR IGNORE` branch). + """ + with sqlite3.connect(path) as conn: + conn.execute( + "CREATE TABLE Emulator_Execution_Info (" + "Execution_ID INTEGER PRIMARY KEY, " + "some_data TEXT)" + ) + conn.execute( + "INSERT INTO Emulator_Execution_Info (some_data) VALUES (?)", + (execution_data,), + ) + conn.execute( + "CREATE TABLE Printed_ValueIds (" + "Execution_Info_Table_ID INTEGER, " + "value REAL)" + ) + conn.execute( + "INSERT INTO Printed_ValueIds VALUES (?, ?)", + (fk_value, 1.5), + ) + conn.execute("CREATE TABLE Lookup (name TEXT PRIMARY KEY, value INTEGER)") + conn.execute("INSERT INTO Lookup VALUES ('alpha', 1)") + conn.commit() + + +def _read_all_rows(path: str, table: str) -> list[tuple]: + """Return every row of *table* in declaration order.""" + with sqlite3.connect(path) as conn: + cursor = conn.execute(f'SELECT * FROM "{table}" ORDER BY rowid') + return cursor.fetchall() + + +class TestMergeTracingDbs(unittest.TestCase): + """Coverage for merge_tracing_dbs helper and CLI entry point.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_single_source_is_copied_to_target(self) -> None: + """First source DB is byte-copied into a non-existent target. + + After processing, the source is removed (mirrors bash `rm -f`). + """ + src = str(self.tmp_path / "src.db") + target = str(self.tmp_path / "target.db") + _make_source_db(src, "run1", fk_value=1) + src_copy = str(self.tmp_path / "src_snapshot.db") + shutil.copy(src, src_copy) + + merge_tracing_dbs(target_db=target, source_dbs=[src]) + + self.assertTrue(filecmp.cmp(target, src_copy, shallow=False)) + self.assertFalse( + os.path.exists(src), "source DB should be removed after merging" + ) + + def test_two_sources_remap_foreign_keys(self) -> None: + """Second source DB's FK column gets offset by the target's old_max.""" + src_a = str(self.tmp_path / "a.db") + src_b = str(self.tmp_path / "b.db") + target = str(self.tmp_path / "target.db") + _make_source_db(src_a, "run_a", fk_value=1) + _make_source_db(src_b, "run_b", fk_value=1) + + merge_tracing_dbs(target_db=target, source_dbs=[src_a, src_b]) + + exec_rows = _read_all_rows(target, "Emulator_Execution_Info") + self.assertEqual(exec_rows, [(1, "run_a"), (2, "run_b")]) + + fk_rows = _read_all_rows(target, "Printed_ValueIds") + # Source A's FK=1 was copied (no remap on first source). + # Source B's FK=1 was remapped to 1 + old_max (1) = 2. + self.assertEqual(fk_rows, [(1, 1.5), (2, 1.5)]) + + def test_missing_source_emits_warning_and_continues(self) -> None: + """A non-existent source DB prints a stderr warning and is skipped. + Other sources still merge.""" + missing = str(self.tmp_path / "missing.db") + src = str(self.tmp_path / "src.db") + target = str(self.tmp_path / "target.db") + _make_source_db(src, "run_present", fk_value=1) + + stderr_buf: io.StringIO = io.StringIO() + with patch.object(sys, "stderr", stderr_buf): + merge_tracing_dbs(target_db=target, source_dbs=[missing, src]) + + self.assertIn("missing.db not found", stderr_buf.getvalue()) + exec_rows = _read_all_rows(target, "Emulator_Execution_Info") + self.assertEqual(exec_rows, [(1, "run_present")]) + + def test_insert_or_ignore_table_is_idempotent(self) -> None: + """A table without `Execution_Info_Table_ID` uses INSERT OR IGNORE, + so re-merging the same lookup row is a no-op.""" + src_a = str(self.tmp_path / "a.db") + src_b = str(self.tmp_path / "b.db") + target = str(self.tmp_path / "target.db") + _make_source_db(src_a, "run_a", fk_value=1) + _make_source_db(src_b, "run_b", fk_value=1) + + merge_tracing_dbs(target_db=target, source_dbs=[src_a, src_b]) + + # Both sources had `Lookup` row ('alpha', 1). INSERT OR IGNORE + # collapses to one row in the target. + lookup_rows = _read_all_rows(target, "Lookup") + self.assertEqual(lookup_rows, [("alpha", 1)]) + + def test_main_invokes_merge_via_argv(self) -> None: + """`main()` parses argv into target + source list and merges.""" + src = str(self.tmp_path / "src.db") + target = str(self.tmp_path / "target.db") + _make_source_db(src, "run_main", fk_value=1) + + with patch.object(sys, "argv", ["merge_tracing_dbs", target, src]): + main() + + exec_rows = _read_all_rows(target, "Emulator_Execution_Info") + self.assertEqual(exec_rows, [(1, "run_main")]) + + def test_main_rejects_missing_source_list(self) -> None: + """`main()` requires at least one source DB. Argparse raises + SystemExit when only the target positional is given.""" + target = str(self.tmp_path / "target.db") + + with patch.object(sys, "argv", ["merge_tracing_dbs", target]): + with self.assertRaises(SystemExit): + main() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/native_mc_flat_test.py b/src/signaloid/benchmarking/automation/native_mc_flat_test.py new file mode 100644 index 0000000..e88518b --- /dev/null +++ b/src/signaloid/benchmarking/automation/native_mc_flat_test.py @@ -0,0 +1,746 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest +import warnings +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import numpy as np +from unittest.mock import patch + +from signaloid.distributional.distributional import DistributionalValue + +from signaloid.benchmarking.automation import sample_generator +from signaloid.benchmarking.automation.sample_generator import ( + _run_distribution_mc_flat, + _run_scalar_mc_flat, +) +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import EquivMC, VariableTypes +from signaloid.benchmarking.distribution_helpers.collapse import ( + _collapse_asymptotically_optimal_w1, +) + +SAMPLE_GEN_MODULE = "signaloid.benchmarking.automation.sample_generator" + + +def _make_dist_var(name: str, cla: str) -> BenchmarkingVariable: + return BenchmarkingVariable( + name=name, + description=name, + type=VariableTypes.DISTRIBUTION, + cla=cla, + ) + + +def _make_scalar_var(name: str, cla: str) -> BenchmarkingVariable: + return BenchmarkingVariable( + name=name, + description=name, + type=VariableTypes.SCALAR, + cla=cla, + ) + + +class TestRunDistributionMcFlat(unittest.TestCase): + """Per-variable aggregation, ordering, and error semantics of + ``_run_distribution_mc_flat``.""" + + def setUp(self) -> None: + # Common kwargs the flat helpers consume. + self.sample_gen_kwargs: dict[str, Any] = dict( + n_processors=2, + path_to_application="/tmp/fake-app", + native_executable_name="fake-exec", + native_executable_dir="/tmp/fake-app", + demo_cli_args="--demo", + ) + + def test_aggregates_per_variable_unweighted(self) -> None: + """Each variable should receive the union of its own chunks only.""" + var_a = _make_dist_var("a", "-S 1") + var_b = _make_dist_var("b", "-S 2") + benchmarking_variables = [var_a, var_b] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + _index, sub_size, cla = work_item + tag = 1.0 if "-S 1" in cla else 2.0 + return [tag] * sub_size + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_distribution_mc_flat( + dist_vars=[(0, var_a), (1, var_b)], + size=10, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + self.assertEqual(var_a.distribution_samples.values, [1.0] * 10) + self.assertEqual(var_b.distribution_samples.values, [2.0] * 10) + self.assertEqual(var_a.distribution_samples.weights, []) + self.assertEqual(var_b.distribution_samples.weights, []) + + def test_chunks_large_size_and_orders_by_chunk_index(self) -> None: + """Per-variable values must be concatenated in chunk-index order + even though chunks complete out of order.""" + var = _make_dist_var("a", "-S 1") + benchmarking_variables = [var] + + # Patch the chunk size down so the test exercises multi-chunk + # ordering without allocating multi-million-element lists. + chunk_size = 4 + total_size = 10 # 4 + 4 + 2 -> three chunks, tail smaller than full. + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + _index, sub_size, _cla = work_item + # Encode chunk position via the sub_size: full/full/tail. + if sub_size == chunk_size: + return [1.0] * sub_size + return [2.0] * sub_size + + with patch(f"{SAMPLE_GEN_MODULE}._MC_CHUNK_SIZE", chunk_size), patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_distribution_mc_flat( + dist_vars=[(0, var)], + size=total_size, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + self.assertEqual(len(var.distribution_samples.values), total_size) + # First two chunks (chunk_idx 0 and 1) are full-sized, then the tail. + head_len = 2 * chunk_size + tail_len = total_size - head_len + self.assertEqual(var.distribution_samples.values[:head_len], [1.0] * head_len) + self.assertEqual(var.distribution_samples.values[head_len:], [2.0] * tail_len) + + def test_passes_globally_unique_index_to_simulation(self) -> None: + """Temp-dir/error indices must disambiguate across variables.""" + var_a = _make_dist_var("a", "") + scalar_filler = _make_scalar_var("filler", "") + var_b = _make_dist_var("b", "") + # Sparse var indices: dist vars sit at positions 0 and 2 in the + # parent variable list, so the test catches code that confuses + # ``var_idx`` with a dense enumeration. + benchmarking_variables = [var_a, scalar_filler, var_b] + + seen_indices: list[Any] = [] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + index, sub_size, _cla = work_item + seen_indices.append(index) + return [0.0] * sub_size + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_distribution_mc_flat( + dist_vars=[(0, var_a), (2, var_b)], + size=5, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + # All indices distinct and var_idx is encoded so failures point at + # the right variable even when chunk_idx repeats. + self.assertEqual(sorted(seen_indices), ["v0_c0", "v2_c0"]) + + def test_uses_demo_cli_args_prefix(self) -> None: + """``demo_cli_args`` should prefix every variable's CLA.""" + var = _make_dist_var("a", "-S 1") + benchmarking_variables = [var] + seen_cla: list[Any] = [] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + _index, sub_size, cla = work_item + seen_cla.append(cla) + return [0.0] * sub_size + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_distribution_mc_flat( + dist_vars=[(0, var)], + size=3, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + self.assertEqual(seen_cla, ["--demo -S 1"]) + + def test_zero_size_skips_with_warning(self) -> None: + var = _make_dist_var("a", "") + benchmarking_variables = [var] + + with patch(f"{SAMPLE_GEN_MODULE}._run_mc_simulation") as mock_sim, patch( + f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor + ), self.assertWarnsRegex(UserWarning, "must be > 0"): + _run_distribution_mc_flat( + dist_vars=[(0, var)], + size=0, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + mock_sim.assert_not_called() + self.assertEqual(var.distribution_samples.values, []) + + def test_raises_when_no_samples_returned(self) -> None: + """If every chunk for a variable returns empty, finalize must raise.""" + var = _make_dist_var("a", "") + benchmarking_variables = [var] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + return [] + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + with self.assertRaisesRegex(RuntimeError, "No MC samples collected"): + _run_distribution_mc_flat( + dist_vars=[(0, var)], + size=5, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + def test_raises_when_simulation_chunk_raises(self) -> None: + """Partial-failure must propagate — silent under-sampling would + corrupt the downstream database with strictly fewer samples than + ``size`` for the affected variable.""" + var_a = _make_dist_var("a", "-S 1") + var_b = _make_dist_var("b", "-S 2") + benchmarking_variables = [var_a, var_b] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + _index, sub_size, cla = work_item + if "-S 2" in cla: + raise RuntimeError("synthetic native-MC failure") + return [1.0] * sub_size + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + with self.assertRaisesRegex(RuntimeError, "MC simulation failed"): + _run_distribution_mc_flat( + dist_vars=[(0, var_a), (1, var_b)], + size=5, + weighted_samples=False, + num_weighted_samples=0, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + def test_weighted_samples_collapse_per_variable(self) -> None: + """With weighted_samples=True, each variable should end up with + positions/weights set via ``set_weighted_values``, independently.""" + var_a = _make_dist_var("a", "-S 1") + var_b = _make_dist_var("b", "-S 2") + benchmarking_variables = [var_a, var_b] + + def fake_sim(work_item: Any, *_args: Any, **_kwargs: Any) -> list[float]: + _index, sub_size, cla = work_item + base = 10.0 if "-S 1" in cla else 20.0 + return [base + i * 0.001 for i in range(sub_size)] + + with patch( + f"{SAMPLE_GEN_MODULE}._run_mc_simulation", side_effect=fake_sim + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_distribution_mc_flat( + dist_vars=[(0, var_a), (1, var_b)], + size=200, + weighted_samples=True, + num_weighted_samples=8, + benchmarking_variables=benchmarking_variables, + **self.sample_gen_kwargs, + ) + + # Both variables converted independently. Sample magnitudes are disjoint + # between var_a (~10) and var_b (~20), so verify each variable's + # collapsed support comes from its own samples only. + self.assertTrue( + len(var_a.distribution_samples.values) + == len(var_a.distribution_samples.weights) + > 0 + ) + self.assertTrue( + len(var_b.distribution_samples.values) + == len(var_b.distribution_samples.weights) + > 0 + ) + self.assertTrue( + all(9.5 <= v <= 11.0 for v in var_a.distribution_samples.values) + ) + self.assertTrue( + all(19.5 <= v <= 21.0 for v in var_b.distribution_samples.values) + ) + + +class TestRunScalarMcFlat(unittest.TestCase): + """Per-variable scalar aggregation, repetition, and error semantics of + ``_run_scalar_mc_flat``.""" + + def setUp(self) -> None: + # Common kwargs the flat helpers consume. + sample_gen_kwargs: dict[str, Any] = dict( + n_processors=2, + path_to_application="/tmp/fake-app", + native_executable_name="fake-exec", + native_executable_dir="/tmp/fake-app", + demo_cli_args="--demo", + ) + # Common kwargs for ``_run_scalar_mc_flat``. + self.scalar_gen_kwargs: dict[str, Any] = dict( + sample_gen_kwargs, + n_adversaries=1, + ground_truth_size=50, + adversary_max_size_scalar=100, + use_clt=False, + ) + + def test_aggregates_per_variable(self) -> None: + """Each variable should accumulate only its own scalar outputs.""" + var_a = _make_scalar_var("a", "-S 1") + var_b = _make_scalar_var("b", "-S 2") + benchmarking_variables = [var_a, var_b] + + def fake_scalar( + _path: Any, + _exec: Any, + _exec_dir: Any, + cla: str, + sample_size: int, + _run_id: Any, + ) -> tuple[int, float]: + return sample_size, 1.0 if "-S 1" in cla else 2.0 + + with patch( + f"{SAMPLE_GEN_MODULE}._run_scalar_native", side_effect=fake_scalar + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_scalar_mc_flat( + scalar_vars=[(0, var_a), (1, var_b)], + n_steps_scalar=3, + ground_truth=False, + benchmarking_variables=benchmarking_variables, + **self.scalar_gen_kwargs, + ) + + # var_a should only see 1.0 outputs, var_b only 2.0 + for size, vals in var_a.distribution_samples.scalar_output_dict.items(): + self.assertTrue(all(v == 1.0 for v in vals), f"var_a size {size}: {vals}") + for size, vals in var_b.distribution_samples.scalar_output_dict.items(): + self.assertTrue(all(v == 2.0 for v in vals), f"var_b size {size}: {vals}") + + def test_ground_truth_uses_ground_truth_size_once(self) -> None: + var = _make_scalar_var("a", "") + benchmarking_variables = [var] + + seen_sizes: list[int] = [] + + def fake_scalar( + _path: Any, + _exec: Any, + _exec_dir: Any, + _cla: Any, + sample_size: int, + _run_id: Any, + ) -> tuple[int, float]: + seen_sizes.append(sample_size) + return sample_size, 7.0 + + with patch( + f"{SAMPLE_GEN_MODULE}._run_scalar_native", side_effect=fake_scalar + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_scalar_mc_flat( + scalar_vars=[(0, var)], + n_steps_scalar=10, + ground_truth=True, + benchmarking_variables=benchmarking_variables, + **self.scalar_gen_kwargs, + ) + + self.assertEqual(seen_sizes, [self.scalar_gen_kwargs["ground_truth_size"]]) + self.assertEqual( + var.distribution_samples.scalar_output_dict, + {self.scalar_gen_kwargs["ground_truth_size"]: [7.0]}, + ) + + def test_repetitions_match_n_adversaries(self) -> None: + """For non-ground-truth runs, every size is repeated n_adversaries times.""" + var = _make_scalar_var("a", "") + benchmarking_variables = [var] + # Override the fixture default for this test. + scalar_gen_kwargs = dict(self.scalar_gen_kwargs, n_adversaries=4) + + def fake_scalar( + _path: Any, + _exec: Any, + _exec_dir: Any, + _cla: Any, + sample_size: int, + run_id: int, + ) -> tuple[int, float]: + return sample_size, float(run_id) + + with patch( + f"{SAMPLE_GEN_MODULE}._run_scalar_native", side_effect=fake_scalar + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_scalar_mc_flat( + scalar_vars=[(0, var)], + n_steps_scalar=3, + ground_truth=False, + benchmarking_variables=benchmarking_variables, + **scalar_gen_kwargs, + ) + + for size, vals in var.distribution_samples.scalar_output_dict.items(): + self.assertEqual( + len(vals), 4, f"size {size}: expected 4 reps, got {len(vals)}" + ) + + def test_skips_none_values(self) -> None: + """``_run_scalar_native`` returning ``None`` for the value + should not be appended.""" + var = _make_scalar_var("a", "") + benchmarking_variables = [var] + scalar_gen_kwargs = dict(self.scalar_gen_kwargs, n_adversaries=2) + + call_count = {"n": 0} + + def fake_scalar( + _path: Any, + _exec: Any, + _exec_dir: Any, + _cla: Any, + sample_size: int, + _run_id: Any, + ) -> tuple[int, float | None]: + call_count["n"] += 1 + # Drop every other value. + value = None if call_count["n"] % 2 == 0 else 5.0 + return sample_size, value + + with patch( + f"{SAMPLE_GEN_MODULE}._run_scalar_native", side_effect=fake_scalar + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + _run_scalar_mc_flat( + scalar_vars=[(0, var)], + n_steps_scalar=2, + ground_truth=False, + benchmarking_variables=benchmarking_variables, + **scalar_gen_kwargs, + ) + + for vals in var.distribution_samples.scalar_output_dict.values(): + self.assertTrue(all(v == 5.0 for v in vals)) + self.assertTrue(len(vals) <= 2) + + def test_no_work_items_returns_early(self) -> None: + """Empty scalar_vars should not invoke the simulator.""" + with patch(f"{SAMPLE_GEN_MODULE}._run_scalar_native") as mock_scalar, patch( + f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor + ): + _run_scalar_mc_flat( + scalar_vars=[], + n_steps_scalar=3, + ground_truth=False, + benchmarking_variables=[], + **self.scalar_gen_kwargs, + ) + + mock_scalar.assert_not_called() + + def test_raises_when_scalar_run_raises(self) -> None: + """A raising scalar future must propagate. Silent failure + previously caused the variable's ``scalar_output_dict`` to be missing + entries without operator visibility.""" + var = _make_scalar_var("a", "") + benchmarking_variables = [var] + scalar_gen_kwargs = dict(self.scalar_gen_kwargs, n_adversaries=2) + + call_count = {"n": 0} + + def fake_scalar( + _path: Any, + _exec: Any, + _exec_dir: Any, + _cla: Any, + sample_size: int, + _run_id: Any, + ) -> tuple[int, float]: + call_count["n"] += 1 + if call_count["n"] == 2: + raise RuntimeError("synthetic scalar failure") + return sample_size, 5.0 + + with patch( + f"{SAMPLE_GEN_MODULE}._run_scalar_native", side_effect=fake_scalar + ), patch(f"{SAMPLE_GEN_MODULE}.ProcessPoolExecutor", ThreadPoolExecutor): + with self.assertRaisesRegex(RuntimeError, "Scalar MC run failed"): + _run_scalar_mc_flat( + scalar_vars=[(0, var)], + n_steps_scalar=2, + ground_truth=False, + benchmarking_variables=benchmarking_variables, + **scalar_gen_kwargs, + ) + + +class TestSampleGeneratorModuleExports(unittest.TestCase): + """The public free-function surface of ``sample_generator`` is importable.""" + + def test_sample_generator_module_exports(self) -> None: + """Smoke: verify the public free-function surface is importable.""" + self.assertTrue(callable(sample_generator.generate_database_native_mc)) + self.assertTrue(callable(sample_generator.generate_scalar_samples)) + self.assertTrue(callable(sample_generator._run_distribution_mc_flat)) + self.assertTrue(callable(sample_generator._run_scalar_mc_flat)) + self.assertTrue(callable(sample_generator._determine_scalar_sample_sizes)) + self.assertIsInstance(sample_generator._MC_CHUNK_SIZE, int) + + +def _scalar_var_with_predictions( + predicted_sizes: list[int], +) -> BenchmarkingVariable: + """Scalar variable whose EMCC predictions drive the pre-compute path.""" + variable = _make_scalar_var("scalar", "-S 0") + variable.emcc_results.emcc_data = [ + {EquivMC.EMCC_PREDICTED: size} for size in predicted_sizes + ] + return variable + + +class TestDetermineScalarSampleSizes(unittest.TestCase): + """Clamping, warning, and geometric-schedule behaviour of + ``_determine_scalar_sample_sizes``.""" + + def test_determine_scalar_sample_sizes_clamps_to_ground_truth(self) -> None: + """Pre-compute-EMCC sizes above ground_truth_size clamp to it.""" + cases: list[tuple[list[int], int, list[int]]] = [ + # A single over-INT_MAX prediction clamps down to the cap. + ([3_750_665_567], 10_000_000, [10_000_000]), + # Mixed: only the over-cap predictions are clamped. The small one + # survives. Two distinct over-cap values both clamp to the cap and + # collapse via the sorted-set dedup. + ([500, 3_750_665_567, 2_152_521_315], 10_000_000, [500, 10_000_000]), + # Everything at or below the cap is left untouched. + ([100, 5_000, 9_999_999], 10_000_000, [100, 5_000, 9_999_999]), + # Boundary: a size exactly equal to the cap is NOT clamped + # (the comparison is strictly-greater). + ([10_000_000], 10_000_000, [10_000_000]), + ] + for predicted, ground_truth_size, expected in cases: + with self.subTest( + predicted=predicted, + ground_truth_size=ground_truth_size, + expected=expected, + ): + variable = _scalar_var_with_predictions(predicted) + sizes = sample_generator._determine_scalar_sample_sizes( + variable=variable, + n_steps_scalar=10, + use_clt=True, + adversary_max_size_scalar=100, + ground_truth_size=ground_truth_size, + ) + self.assertEqual(sizes, expected) + + def test_determine_scalar_sample_sizes_warns_with_details(self) -> None: + """A clamp warns, naming the variable, the prediction, and the cap.""" + variable = _scalar_var_with_predictions([3_750_665_567]) + variable.description = "gg_cross_section" + with self.assertWarns(UserWarning) as cm: + sample_generator._determine_scalar_sample_sizes( + variable=variable, + n_steps_scalar=10, + use_clt=True, + adversary_max_size_scalar=100, + ground_truth_size=10_000_000, + ) + message = str(cm.warning) + self.assertIn("3750665567", message) # original (pre-clamp) prediction + self.assertIn("gg_cross_section", message) # the variable named + self.assertIn("10000000", message) # the cap it was clamped to + + def test_determine_scalar_sample_sizes_no_warn_within_bound(self) -> None: + """No clamp warning when every prediction is within the bound.""" + variable = _scalar_var_with_predictions([100, 5_000]) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + sizes = sample_generator._determine_scalar_sample_sizes( + variable=variable, + n_steps_scalar=10, + use_clt=True, + adversary_max_size_scalar=100, + ground_truth_size=10_000_000, + ) + self.assertEqual(sizes, [100, 5_000]) + self.assertFalse(any("clamping" in str(w.message) for w in recorded)) + + def test_determine_scalar_sample_sizes_geometric_branch_ignores_cap( + self, + ) -> None: + """The non-pre-compute geometric schedule is unaffected by the cap.""" + variable = _make_scalar_var("scalar", "-S 0") + sizes = sample_generator._determine_scalar_sample_sizes( + variable=variable, + n_steps_scalar=5, + use_clt=False, + adversary_max_size_scalar=100, + ground_truth_size=10, + ) + # Geometric schedule spans 1..100; a ground_truth_size of 10 must + # NOT clamp it (clamping only applies to the pre-compute branch). + self.assertEqual(sizes[0], 1) + self.assertEqual(sizes[-1], 100) + self.assertTrue(max(sizes) > 10) + + +# Golden captured from the legacy local Distribution.collapse() before the +# migration. That Distribution class is not part of this package. +# +# Frozen golden, captured from the legacy analyses-side oracle before the +# migration by running, on `np.random.default_rng(12345).normal(loc=3.0, +# scale=1.5, size=5_000)` with n_dirac_deltas=16: +# +# reference = Distribution.from_samples(samples) +# reference.representation_type = RepresentationTypes.ASYMPTOTICALLY_OPTIMAL_W1 +# reference.representation_size = 16 +# reference = reference.collapse() +# # reference.positions, reference.masses +# +# The legacy free-function helper was verified to reproduce these arrays exactly +# (`np.array_equal` True), and the relocated +# `signaloid.benchmarking.distribution_helpers.collapse._collapse_asymptotically_optimal_w1` +# was then verified to reproduce the same golden. So this test pins the +# relocated helper against the pre-migration `Distribution.collapse()` behaviour +# without importing the (project-uxhw-only) `Distribution` class. +_COLLAPSE_GOLDEN_N_DIRAC_DELTAS = 16 +_COLLAPSE_GOLDEN_POSITIONS = np.array( + [ + 0.15123892294603908, + 1.0001846373663956, + 1.4828055862879468, + 1.8585344179779364, + 2.146328987738469, + 2.4234668203514094, + 2.64603234008446, + 2.866208282199121, + 3.0956121716323417, + 3.339269867384286, + 3.588948140745383, + 3.869471210253865, + 4.166571099311518, + 4.534450957209472, + 4.987797195677714, + 5.836204379853931, + ] +) +_COLLAPSE_GOLDEN_MASSES = np.array( + [ + 0.0581533088149018, + 0.06431726150274462, + 0.06288896876050541, + 0.0665813095373724, + 0.059637233508816945, + 0.06557286657028133, + 0.05696562926616555, + 0.07041828174894837, + 0.05839853287387953, + 0.06422738707839659, + 0.06245163653588759, + 0.060347899205485445, + 0.06329965714293706, + 0.06540079792140441, + 0.06357280075481142, + 0.05776642877746152, + ] +) + + +class TestCollapseAsymptoticallyOptimalW1(unittest.TestCase): + """The relocated ``_collapse_asymptotically_optimal_w1`` free function must + reproduce the legacy ``Distribution.collapse()`` golden and reject too-few + deltas.""" + + def test_collapse_asymptotically_optimal_w1_matches_distribution_collapse( + self, + ) -> None: + """The free-function helper must reproduce the legacy ``Distribution.collapse()`` + result for the ASYMPTOTICALLY_OPTIMAL_W1 representation, producing the same + positions/masses on a representative MC-style sample input. + + The legacy ``Distribution`` oracle is NOT imported here. It is not + part of the benchmarking package. + Instead the expected positions/masses are pinned as a frozen golden + (``_COLLAPSE_GOLDEN_*``) captured from that legacy path before the migration. + """ + rng = np.random.default_rng(12345) + samples = rng.normal(loc=3.0, scale=1.5, size=5_000) + n_dirac_deltas = _COLLAPSE_GOLDEN_N_DIRAC_DELTAS + + # Under test: the new free-function helper on a plain DistributionalValue. + dv = DistributionalValue.from_samples(samples) + collapsed = _collapse_asymptotically_optimal_w1( + dv, n_dirac_deltas=n_dirac_deltas + ) + + # Tight tolerance, not exact equality: the golden was captured on one + # platform, and the pure-numpy collapse (argsort / cumsum / np.interp) can + # differ by ~1 ULP across platforms/BLAS (~1e-16). 1e-12 is far above that + # float noise yet far below any real algorithmic change. + np.testing.assert_allclose( + collapsed.positions, _COLLAPSE_GOLDEN_POSITIONS, rtol=1e-12, atol=1e-12 + ) + np.testing.assert_allclose( + collapsed.masses, _COLLAPSE_GOLDEN_MASSES, rtol=1e-12, atol=1e-12 + ) + # Sanity: the collapse actually produced the requested support size. + self.assertEqual(len(collapsed.positions), n_dirac_deltas) + + def test_collapse_asymptotically_optimal_w1_rejects_too_few_deltas( + self, + ) -> None: + """n_dirac_deltas < 2 raises a clear ValueError (not an opaque IndexError).""" + dv = DistributionalValue.from_samples(np.random.default_rng(0).normal(size=100)) + for n in (0, 1): + with self.subTest(n=n): + with self.assertRaisesRegex(ValueError, "n_dirac_deltas"): + _collapse_asymptotically_optimal_w1(dv, n_dirac_deltas=n) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/num_parallel_workers_test.py b/src/signaloid/benchmarking/automation/num_parallel_workers_test.py new file mode 100644 index 0000000..81f5ce9 --- /dev/null +++ b/src/signaloid/benchmarking/automation/num_parallel_workers_test.py @@ -0,0 +1,191 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Coverage for the ``-j`` / ``--num-parallel-workers`` worker-count dial. + +""" + +import io +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +from signaloid.benchmarking.automation import benchmark as benchmark_module +from signaloid.benchmarking.automation.arguments import ( + create_argument_parser, + validate_args, +) +from signaloid.benchmarking.automation.benchmark import Benchmark + + +def _lscpu_output(n_cpus: int) -> str: + """A minimal ``lscpu`` stdout exposing a model name and CPU count.""" + return f"Model name: Fake CPU\nCPU(s): {n_cpus}\n" + + +def _make_benchmark(num_parallel_workers: int | None) -> Benchmark: + return Benchmark( + path_to_application="/tmp/fake-app", + num_parallel_workers=num_parallel_workers, + ) + + +def _detect_with(benchmark: Benchmark, n_cpus: int) -> None: + """Run ``get_machine_info`` with a stubbed ``lscpu`` reporting + ``n_cpus`` cores.""" + with patch.object( + benchmark_module.subprocess, # type: ignore[attr-defined] + "check_output", + return_value=_lscpu_output(n_cpus), + ): + benchmark.get_machine_info() + + +class TestNumParallelWorkers(unittest.TestCase): + """Coverage for the ``-j`` / ``--num-parallel-workers`` worker-count parameter.""" + + def test_j_flag_default_is_none(self) -> None: + """An unset ``-j`` parses to ``None`` so the constructor / machine + info can distinguish it from an explicit value and resolve it to the + detected core count.""" + parser = create_argument_parser() + args = parser.parse_args(["--path-to-application", "/tmp/fake-app"]) + self.assertIsNone(args.num_parallel_workers) + + def test_j_flag_explicit_value_is_parsed(self) -> None: + for requested in [1, 4, 64]: + with self.subTest(requested=requested): + parser = create_argument_parser() + args = parser.parse_args( + [ + "--path-to-application", + "/tmp/fake-app", + "-j", + str(requested), + ] + ) + self.assertEqual(args.num_parallel_workers, requested) + + def test_num_parallel_workers_alias_is_parsed(self) -> None: + """The ``--num-parallel-workers`` long alias referenced by the help + text and validation message must resolve to the same destination as + ``-j`` / ``--jobs``.""" + parser = create_argument_parser() + args = parser.parse_args( + ["--path-to-application", "/tmp/fake-app", "--num-parallel-workers", "8"] + ) + self.assertEqual(args.num_parallel_workers, 8) + + def test_j_flag_rejects_non_positive(self) -> None: + """An explicit ``-j`` below 1 is rejected at validation time, rather + than crashing deep in a worker pool with ``max_workers < 1``.""" + for bad in [0, -1]: + with self.subTest(bad=bad): + parser = create_argument_parser() + args = parser.parse_args( + ["--path-to-application", "/tmp/fake-app", "-j", str(bad)] + ) + with self.assertRaisesRegex(ValueError, "must be >= 1"): + validate_args(args) + + def test_unset_j_resolves_to_detected_cores(self) -> None: + """Leaving ``-j`` unset must preserve current behaviour: the MC + stages default to the detected core count, not 1.""" + benchmark = _make_benchmark(None) + self.assertIsNone(benchmark.num_parallel_workers) + + _detect_with(benchmark, n_cpus=32) + + self.assertEqual(benchmark.n_processors, 32) + self.assertEqual(benchmark.num_parallel_workers, 32) + + def test_explicit_j_is_left_untouched_by_machine_info(self) -> None: + """An explicit ``-j`` value is never overridden by the detected core + count.""" + for requested in [1, 8]: + with self.subTest(requested=requested): + benchmark = _make_benchmark(requested) + _detect_with(benchmark, n_cpus=32) + self.assertEqual(benchmark.num_parallel_workers, requested) + + def test_warning_fires_only_when_request_exceeds_cores(self) -> None: + """The 'requested N but only M detected' warning must describe a + constraint that actually holds, i.e. only fire when the explicit + ``-j`` exceeds the detected core count.""" + buf: io.StringIO = io.StringIO() + benchmark = _make_benchmark(64) + with redirect_stdout(buf): + _detect_with(benchmark, n_cpus=32) + self.assertIn( + "Requested 64 parallel workers but only 32 detected", buf.getvalue() + ) + + buf2: io.StringIO = io.StringIO() + benchmark = _make_benchmark(8) + with redirect_stdout(buf2): + _detect_with(benchmark, n_cpus=32) + self.assertNotIn("parallel workers but only", buf2.getvalue()) + + # Unset (resolved to detected cores) must never warn. + buf3: io.StringIO = io.StringIO() + benchmark = _make_benchmark(None) + with redirect_stdout(buf3): + _detect_with(benchmark, n_cpus=32) + self.assertNotIn("parallel workers but only", buf3.getvalue()) + + def test_num_parallel_workers_resolved_and_clamped(self) -> None: + """After ``get_machine_info``, ``num_parallel_workers`` is the single + resolved value that bounds every worker pool: an unset ``-j`` resolves to + the detected core count, and a request above the core count is clamped + down to it. + + This is the value all five pools read — the native compile and the EMCC + adversary-distance stage read ``num_parallel_workers`` directly, and the + three MC sample-generation stages derive ``mc_worker_count`` from it in + ``benchmark_application._run_pipeline``. The ``(64, 32, 32)`` case in + particular pins that EMCC no longer oversubscribes for ``-j > cores``. + """ + cases = [ + # (num_parallel_workers, n_processors, expected) + # Unset -> resolved to detected cores. + (None, 32, 32), + # Explicit -j below detected cores -> left as-is. + (8, 32, 8), + # Explicit -j above detected cores -> clamped down to cores. + (64, 32, 32), + # Explicit -j == detected cores. + (32, 32, 32), + # Serial run. + (1, 32, 1), + ] + for num_parallel_workers, n_processors, expected in cases: + with self.subTest( + num_parallel_workers=num_parallel_workers, + n_processors=n_processors, + expected=expected, + ): + benchmark = _make_benchmark(num_parallel_workers) + _detect_with(benchmark, n_cpus=n_processors) + self.assertEqual(benchmark.num_parallel_workers, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/output_directories_test.py b/src/signaloid/benchmarking/automation/output_directories_test.py new file mode 100644 index 0000000..7f0353d --- /dev/null +++ b/src/signaloid/benchmarking/automation/output_directories_test.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest +from unittest.mock import patch + +from signaloid.benchmarking.automation.benchmark import Benchmark + + +class TestOutputDirectoryPaths(unittest.TestCase): + """Output directory paths derive from the process CWD at construction.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = tmp_dir.name + with patch.object(os, "getcwd", return_value=self.tmp_path): + self.benchmark = Benchmark( + path_to_application="/tmp/fake-app", + path_to_uxhw_sdk="/tmp/fake-uxhw-sdk", + ) + + def test_results_dir_is_under_cwd(self) -> None: + self.assertEqual( + self.benchmark.results_dir, os.path.join(self.tmp_path, "results") + ) + + def test_logs_dir_is_under_cwd(self) -> None: + self.assertEqual(self.benchmark.logs_dir, os.path.join(self.tmp_path, "logs")) + + def test_plots_dir_is_under_results(self) -> None: + self.assertEqual( + self.benchmark.plots_dir, + os.path.join(self.tmp_path, "results", "plots"), + ) + + def test_output_data_file_is_under_results(self) -> None: + self.assertEqual( + self.benchmark.output_data_file, + os.path.join(self.benchmark.results_dir, "output_data.csv"), + ) + + def test_asymptotic_dist_file_is_under_results(self) -> None: + self.assertEqual( + self.benchmark.asymptotic_dist_file, + os.path.join(self.benchmark.results_dir, "asymptotic_distances.csv"), + ) + + def test_uxhw_distance_file_is_under_results(self) -> None: + self.assertEqual( + self.benchmark.uxhw_distance_file, + os.path.join(self.benchmark.results_dir, "uxhw_distances.csv"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/parse_cpu_time.py b/src/signaloid/benchmarking/automation/parse_cpu_time.py new file mode 100644 index 0000000..72c8a90 --- /dev/null +++ b/src/signaloid/benchmarking/automation/parse_cpu_time.py @@ -0,0 +1,168 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import argparse +import re +import sys + +# Capture the leading decimal float after ``CPU time used:`` and stop. +# Anything after the float is intentionally not anchored, because: +# * The UxHw binary emits a Ux Data value right up against the float +# (no separator), e.g. ``CPU time used: 0.000163Ux0400...000 seconds``. +# * The native binary emits ``CPU time used: 0.0001 seconds``. +# * Some outputs omit ``seconds`` entirely. +# Anchoring on the float alone matches all three. +_CPU_TIME_PATTERN = re.compile(r"CPU time used:\s*([0-9]+(?:\.[0-9]+)?)") + + +def _extract_cpu_time(content: str) -> float: + """ + Extract the first ``CPU time used: N`` value from *content*. + + Single-binary stdouts (the per-iteration files from + ``run_uxhw_benchmarks``) contain exactly one such line. For multi-section + stdouts where every matching line must contribute to a total, use + :func:`_sum_cpu_times` instead. + + Args: + content: Raw text from a benchmark binary's stdout. Both + ``CPU time used: 0.000198`` and the ``... seconds`` form are + accepted. + + Returns: + The CPU time as a float in seconds. + + Raises: + ValueError: If no ``CPU time used: `` line is present. + """ + match = _CPU_TIME_PATTERN.search(content) + if match is None: + raise ValueError("no 'CPU time used: ' line found in stdout") + return float(match.group(1)) + + +def _sum_cpu_times(content: str) -> float: + """ + Sum every ``CPU time used: N`` value in *content*. + + Every matching line contributes, not just the first. As with + :data:`_CPU_TIME_PATTERN`, the match anchors only on the leading numeric + value, so UxHw (``CPU time used: 0.000163Ux seconds``), bare + (``CPU time used: 0.0001``), and classic (``... seconds``) forms all count. + + Args: + content: Raw text from one or more benchmark stdout sections. + + Returns: + The summed CPU time in seconds, or 0.0 if no matching line is present. + """ + return sum(float(m) for m in _CPU_TIME_PATTERN.findall(content)) + + +def _read_stdout_text(path: str) -> str: + """ + Read a stdout file as text, replacing any non-UTF-8 bytes. + + Treats the file as text even when it contains stray binary bytes. The + ASCII ``CPU time used:`` line survives the replacement substitution. + + Args: + path: Path to the stdout file to read. + + Returns: + The file contents, with non-UTF-8 bytes replaced by U+FFFD. + """ + # Pin the encoding so a non-UTF-8 system locale (e.g. C/POSIX on + # minimal containers, or latin-1) doesn't silently change the + # decoded input stream. `errors="replace"` then turns every + # non-UTF-8 byte into U+FFFD regardless of locale. + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + + +def read_cpu_time(path: str) -> float: + """ + Read a benchmark stdout file and return its first CPU-time value. + + Args: + path: Path to the stdout file written by the benchmark binary. + + Returns: + The CPU time as a float in seconds. + + Raises: + OSError: If the file cannot be opened. + ValueError: If the file content does not contain a + ``CPU time used: `` line. + """ + return _extract_cpu_time(_read_stdout_text(path)) + + +def main() -> None: + """ + Parse command-line arguments and emit the extracted CPU time. + + Intended for the bash layer via ``python3 -m + signaloid.benchmarking.automation.parse_cpu_time``. With a single + positional argument, prints the seconds value from that file. With + ``--sum`` and one or more paths, prints the sum across all files. + """ + parser = argparse.ArgumentParser( + description=( + "Extract 'CPU time used:' seconds from one or more benchmark " + "stdout files." + ), + ) + parser.add_argument( + "paths", + nargs="+", + help="Path(s) to stdout file(s) produced by the benchmark binary.", + ) + parser.add_argument( + "--sum", + action="store_true", + help=( + "Sum the CPU times across every file passed as positional " + "arguments. Without this flag exactly one path is expected." + ), + ) + args = parser.parse_args() + + if args.sum: + total = 0.0 + for path in args.paths: + total += _sum_cpu_times(_read_stdout_text(path)) + print(total) + return + + if len(args.paths) != 1: + parser.error( + "exactly one path is required when --sum is not set " + f"(got {len(args.paths)})" + ) + print(read_cpu_time(args.paths[0])) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError) as exc: + print(f"parse_cpu_time: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/src/signaloid/benchmarking/automation/parse_cpu_time_test.py b/src/signaloid/benchmarking/automation/parse_cpu_time_test.py new file mode 100644 index 0000000..205ccf2 --- /dev/null +++ b/src/signaloid/benchmarking/automation/parse_cpu_time_test.py @@ -0,0 +1,178 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import io +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from signaloid.benchmarking.automation.parse_cpu_time import ( + _extract_cpu_time, + main, + _sum_cpu_times, + read_cpu_time, +) + +_VALID_STDOUT = ( + "Some preamble.\n" "Doing things...\n" "CPU time used: 0.001853 seconds\n" "Done.\n" +) + +_MALFORMED_STDOUT = "Some preamble.\nNo CPU time line here.\n" + + +class TestParseCpuTime(unittest.TestCase): + """Coverage for parse_cpu_time helper functions and CLI entry point.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_extract_cpu_time_parses_seconds_value(self) -> None: + """The first ``CPU time used:`` float is returned.""" + self.assertEqual(_extract_cpu_time(_VALID_STDOUT), 0.001853) + + def test_extract_cpu_time_handles_integer_seconds(self) -> None: + """A bare integer (no decimal) is accepted.""" + self.assertEqual(_extract_cpu_time("CPU time used: 5 seconds\n"), 5.0) + + def test_extract_cpu_time_raises_on_missing_line(self) -> None: + """A file with no ``CPU time used:`` line raises ``ValueError``.""" + with self.assertRaisesRegex(ValueError, "no 'CPU time used:"): + _extract_cpu_time(_MALFORMED_STDOUT) + + def test_extract_cpu_time_accepts_no_seconds_suffix(self) -> None: + """``CPU time used: N`` with no trailing token still parses.""" + uxhw_format = "Some preamble.\nCPU time used: 0.000198\nDone.\n" + self.assertEqual(_extract_cpu_time(uxhw_format), 0.000198) + + def test_extract_cpu_time_handles_ux_distributional_value(self) -> None: + """The UxHw binary emits a Ux-encoded distributional value + directly against the decimal time, e.g. + ``CPU time used: 0.000163Ux seconds``. The regex must capture + the leading decimal float and stop at the ``U`` rather than + requiring whitespace-separated ``seconds``. Regression for the + Step 10 timing-loop crash.""" + uxhw_with_ux = ( + "CPU time used: 0.000163Ux0400000000000000003F255D5F56A7AC82" + "000000013F255D5F56A7AC828000000000000000 seconds\n" + ) + self.assertEqual(_extract_cpu_time(uxhw_with_ux), 0.000163) + + def test_sum_cpu_times_adds_every_match_in_content(self) -> None: + """``_sum_cpu_times`` finds every ``CPU time used:`` line, not just the + first — preserves the original bash ``grep | sum`` semantics for + multi-section stdouts.""" + multi = ( + "Run 1\nCPU time used: 0.1 seconds\n" + "Run 2\nCPU time used: 0.2 seconds\n" + "Run 3\nCPU time used: 0.3 seconds\n" + ) + self.assertAlmostEqual(_sum_cpu_times(multi), 0.6) + + def test_sum_cpu_times_returns_zero_for_no_matches(self) -> None: + """No ``CPU time used:`` lines → 0, not an exception.""" + self.assertEqual(_sum_cpu_times("Nothing relevant here.\n"), 0.0) + + def test_read_cpu_time_reads_from_file(self) -> None: + """``read_cpu_time`` wraps ``_extract_cpu_time`` over a file.""" + stdout_path = self.tmp_path / "stdout.txt" + stdout_path.write_text(_VALID_STDOUT) + + self.assertEqual(read_cpu_time(str(stdout_path)), 0.001853) + + def test_read_cpu_time_raises_on_missing_file(self) -> None: + """Opening a non-existent file raises ``OSError`` (FileNotFoundError).""" + with self.assertRaises(OSError): + read_cpu_time(str(self.tmp_path / "missing.txt")) + + def test_read_cpu_time_survives_non_utf8_bytes(self) -> None: + """Non-UTF-8 bytes in the stdout don't crash the read. + + Mirrors the ``grep -a`` flag the bash sites used to force-text + interpretation even when the binary's stdout contains stray + non-text bytes. + """ + path = self.tmp_path / "binary.txt" + payload = b"junk\xff\xfe preamble\nCPU time used: 0.42 seconds\n" + path.write_bytes(payload) + + self.assertEqual(read_cpu_time(str(path)), 0.42) + + def test_main_single_path_prints_value(self) -> None: + """``main`` with a single positional path prints the seconds value.""" + stdout_path = self.tmp_path / "stdout.txt" + stdout_path.write_text(_VALID_STDOUT) + + buf: io.StringIO = io.StringIO() + with patch.object(sys, "argv", ["parse_cpu_time", str(stdout_path)]): + with redirect_stdout(buf): + main() + + self.assertEqual(float(buf.getvalue().strip()), 0.001853) + + def test_main_sum_adds_across_paths(self) -> None: + """``--sum`` adds the values across every positional path.""" + a = self.tmp_path / "a.txt" + b = self.tmp_path / "b.txt" + a.write_text("CPU time used: 0.1 seconds\n") + b.write_text("CPU time used: 0.25 seconds\n") + + buf: io.StringIO = io.StringIO() + with patch.object(sys, "argv", ["parse_cpu_time", "--sum", str(a), str(b)]): + with redirect_stdout(buf): + main() + + self.assertAlmostEqual(float(buf.getvalue().strip()), 0.35) + + def test_main_sum_adds_multiple_matches_within_a_file(self) -> None: + """``--sum`` over a single multi-section stdout sums every matching + line within that file (mirrors the original L506 ``grep ... | sum``).""" + path = self.tmp_path / "multi.txt" + path.write_text( + "CPU time used: 0.1 seconds\n" + "CPU time used: 0.2 seconds\n" + "CPU time used: 0.3 seconds\n" + ) + + buf: io.StringIO = io.StringIO() + with patch.object(sys, "argv", ["parse_cpu_time", "--sum", str(path)]): + with redirect_stdout(buf): + main() + + self.assertAlmostEqual(float(buf.getvalue().strip()), 0.6) + + def test_main_rejects_multiple_paths_without_sum(self) -> None: + """Two positional paths without ``--sum`` is a usage error.""" + a = self.tmp_path / "a.txt" + b = self.tmp_path / "b.txt" + a.write_text(_VALID_STDOUT) + b.write_text(_VALID_STDOUT) + + with patch.object(sys, "argv", ["parse_cpu_time", str(a), str(b)]): + with self.assertRaises(SystemExit): + main() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/path_to_pin_test.py b/src/signaloid/benchmarking/automation/path_to_pin_test.py new file mode 100644 index 0000000..9c3eb2c --- /dev/null +++ b/src/signaloid/benchmarking/automation/path_to_pin_test.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest + +from pathlib import Path +from typing import Optional + +from signaloid.benchmarking.automation.arguments import ( + create_argument_parser, +) +from signaloid.benchmarking.automation.build import export_timing_env +from signaloid.benchmarking.config import ( + Correlations, + RepresentationTypes, + ReportingMethods, +) + + +def _base_argv() -> list[str]: + """The four required sweep args, with no PIN path.""" + return [ + "-u", + RepresentationTypes.ATHENS, + "-s", + "16", + "-c", + Correlations.DISABLED, + "-r", + ReportingMethods.MEAN, + ] + + +def _export_kwargs(tmp_path: Path, path_to_pin: Optional[str]) -> dict: + """Minimal valid kwargs for ``export_timing_env``.""" + return dict( + path_to_uxhw_sdk="~/project-uxhw-sdk", + path_to_pin=path_to_pin, + path_to_application=str(tmp_path), + application_name="demo", + application_version="abc1234", + max_jupiter_size=32, + results_dir=str(tmp_path / "results"), + logs_dir=str(tmp_path / "logs"), + tracing_db_path=str(tmp_path / "tracing.db"), + ) + + +class TestPathToPin(unittest.TestCase): + """``--path-to-pin`` parsing and its ``PIN_ROOT`` export side effect.""" + + def setUp(self) -> None: + # Snapshot and restore ``os.environ`` so ``export_timing_env`` side + # effects do not leak into other tests. + snapshot = dict(os.environ) + + def _restore_environ() -> None: + os.environ.clear() + os.environ.update(snapshot) + + self.addCleanup(_restore_environ) + + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_parser_accepts_path_to_pin(self) -> None: + parser = create_argument_parser() + args = parser.parse_args(_base_argv() + ["--path-to-pin", "/opt/pin-test"]) + self.assertEqual(args.path_to_pin, "/opt/pin-test") + + def test_parser_path_to_pin_defaults_to_none(self) -> None: + parser = create_argument_parser() + args = parser.parse_args(_base_argv()) + self.assertIsNone(args.path_to_pin) + + def test_export_timing_env_exports_pin_root(self) -> None: + export_timing_env(**_export_kwargs(self.tmp_path, "/opt/pin-test")) + self.assertEqual(os.environ["PIN_ROOT"], "/opt/pin-test") + + def test_export_timing_env_omits_pin_root_when_unset(self) -> None: + # PIN is mandatory and overridable: with no --path-to-pin we must not + # touch PIN_ROOT, so get-timings.sh applies its built-in fallback. + os.environ.pop("PIN_ROOT", None) + export_timing_env(**_export_kwargs(self.tmp_path, None)) + self.assertNotIn("PIN_ROOT", os.environ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/read_db_metrics.py b/src/signaloid/benchmarking/automation/read_db_metrics.py new file mode 100644 index 0000000..3e7986a --- /dev/null +++ b/src/signaloid/benchmarking/automation/read_db_metrics.py @@ -0,0 +1,108 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import argparse +import sqlite3 +import sys +import urllib.parse + +# Metric name to SQL query. Column and table names must match the schema +# exactly, preserving case, since SQLite is case-sensitive for identifiers in +# some configurations. +_METRIC_QUERIES: dict[str, str] = { + "host-wallclock": ("SELECT Host_UserTimeElapsedWallClock FROM runtimeStats"), + "host-wallclock-total": ( + "SELECT TOTAL(Host_UserTimeElapsedWallClock) FROM runtimeStats" + ), + "emulated-dyn-inst": ("SELECT EmulatedCPU_DynCnt FROM Emulator_Execution_Info"), + "emulated-dyn-inst-total": ("SELECT TOTAL(EmulatedCPU_DynCnt) FROM runtimeStats"), +} + + +def read_db_metric(db_path: str, metric: str) -> float: + """ + Query a single scalar metric from a UxHw/emulator SQLite database. + + Args: + db_path: Path to the SQLite database file. + metric: Metric name (one of the keys in :data:`_METRIC_QUERIES`). + + Returns: + The queried value as a float. + + Raises: + KeyError: If *metric* is not a recognised metric name. + OSError: If the database file cannot be opened. + sqlite3.OperationalError: If the query fails (e.g. missing table + or column). + ValueError: If the query returns no rows or a NULL value. + """ + if metric not in _METRIC_QUERIES: + valid = ", ".join(sorted(_METRIC_QUERIES)) + raise KeyError(f"unknown metric {metric!r}; valid choices: {valid}") + sql = _METRIC_QUERIES[metric] + # `uri=True` + `mode=ro` opens read-only and raises OperationalError if the + # file is missing (instead of silently creating an empty DB). The path is + # percent-encoded so paths containing `?` or `#` are not split into URI + # query / fragment components. + quoted_path = urllib.parse.quote(db_path) + with sqlite3.connect(f"file:{quoted_path}?mode=ro", uri=True) as conn: + cursor = conn.execute(sql) + row = cursor.fetchone() + if row is None or row[0] is None: + raise ValueError( + f"query returned no value for metric {metric!r} " f"from {db_path!r}" + ) + return float(row[0]) + + +def main() -> None: + """ + Parse command-line arguments and print the queried metric value. + + Intended for the bash layer via ``python3 -m + signaloid.benchmarking.automation.read_db_metrics``. Prints a single float + to stdout. + """ + parser = argparse.ArgumentParser( + description=( + "Read a single scalar metric from a UxHw/emulator " "SQLite database." + ), + ) + parser.add_argument( + "db_path", + help="Path to the SQLite database file.", + ) + parser.add_argument( + "--metric", + required=True, + choices=list(_METRIC_QUERIES), + help="Metric to read from the database.", + ) + args = parser.parse_args() + print(read_db_metric(args.db_path, args.metric)) + + +if __name__ == "__main__": + try: + main() + except (OSError, sqlite3.OperationalError, ValueError, KeyError) as exc: + print(f"read_db_metrics: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/src/signaloid/benchmarking/automation/read_db_metrics_test.py b/src/signaloid/benchmarking/automation/read_db_metrics_test.py new file mode 100644 index 0000000..e2736ec --- /dev/null +++ b/src/signaloid/benchmarking/automation/read_db_metrics_test.py @@ -0,0 +1,171 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import io +import sqlite3 +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from signaloid.benchmarking.automation.read_db_metrics import ( + main, + read_db_metric, +) + + +def _make_db(path: str) -> None: + """Populate a test SQLite database with known values. + + Creates the two tables queried by :mod:`read_db_metrics`. Inserts + two rows into ``runtimeStats`` (so the ``-total`` metrics sum more + than one value) and one row into ``Emulator_Execution_Info``. + """ + with sqlite3.connect(path) as conn: + conn.execute( + "CREATE TABLE runtimeStats " "(Host_UserTimeElapsedWallClock REAL)" + ) + conn.execute("INSERT INTO runtimeStats VALUES (1.5)") + conn.execute("INSERT INTO runtimeStats VALUES (2.5)") + conn.execute( + "CREATE TABLE Emulator_Execution_Info " "(EmulatedCPU_DynCnt REAL)" + ) + conn.execute("INSERT INTO Emulator_Execution_Info VALUES (42.0)") + conn.commit() + + +class TestReadDbMetrics(unittest.TestCase): + """Coverage for read_db_metric helper and CLI entry point.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_path_with_uri_reserved_chars_is_handled(self) -> None: + """Paths containing ``?`` or ``#`` must be percent-encoded before + being interpolated into the SQLite URI (regression: a raw + ``f"file:{db_path}?mode=ro"`` would treat ``?`` as the query + delimiter and fail to open the database).""" + db = str(self.tmp_path / "weird?name#frag.db") + _make_db(db) + self.assertEqual(read_db_metric(db, "host-wallclock"), 1.5) + + def test_host_wallclock_returns_first_row(self) -> None: + """``host-wallclock`` returns the first row of + ``Host_UserTimeElapsedWallClock``.""" + db = str(self.tmp_path / "test.db") + _make_db(db) + result = read_db_metric(db, "host-wallclock") + self.assertEqual(result, 1.5) + + def test_host_wallclock_total_sums_all_rows(self) -> None: + """``host-wallclock-total`` sums every row via TOTAL().""" + db = str(self.tmp_path / "test.db") + _make_db(db) + result = read_db_metric(db, "host-wallclock-total") + self.assertAlmostEqual(result, 4.0) + + def test_emulated_dyn_inst_returns_value(self) -> None: + """``emulated-dyn-inst`` returns ``EmulatedCPU_DynCnt`` from + ``Emulator_Execution_Info``.""" + db = str(self.tmp_path / "test.db") + _make_db(db) + result = read_db_metric(db, "emulated-dyn-inst") + self.assertEqual(result, 42.0) + + def test_emulated_dyn_inst_total_sums_runtime_stats(self) -> None: + """``emulated-dyn-inst-total`` uses TOTAL() over ``runtimeStats``. + + The query reads ``EmulatedCPU_DynCnt`` from ``runtimeStats`` (not + ``Emulator_Execution_Info``). Create a DB that has that column so + we can verify the correct table is targeted. + """ + db = str(self.tmp_path / "total_dyn.db") + with sqlite3.connect(db) as conn: + conn.execute("CREATE TABLE runtimeStats (EmulatedCPU_DynCnt REAL)") + conn.execute("INSERT INTO runtimeStats VALUES (100.0)") + conn.execute("INSERT INTO runtimeStats VALUES (200.0)") + conn.execute( + "CREATE TABLE Emulator_Execution_Info " "(EmulatedCPU_DynCnt REAL)" + ) + conn.execute("INSERT INTO Emulator_Execution_Info VALUES (9999.0)") + conn.commit() + result = read_db_metric(db, "emulated-dyn-inst-total") + self.assertAlmostEqual(result, 300.0) + + def test_missing_db_file_raises_operational_error(self) -> None: + """A path that does not exist raises ``sqlite3.OperationalError``.""" + missing = str(self.tmp_path / "nonexistent.db") + with self.assertRaises(sqlite3.OperationalError): + read_db_metric(missing, "host-wallclock") + + def test_schema_mismatch_raises_operational_error(self) -> None: + """A DB that lacks the expected table raises + ``sqlite3.OperationalError``.""" + db = str(self.tmp_path / "empty.db") + with sqlite3.connect(db) as conn: + conn.execute("CREATE TABLE unrelated (x INTEGER)") + conn.commit() + with self.assertRaises(sqlite3.OperationalError): + read_db_metric(db, "host-wallclock") + + def test_unknown_metric_raises_key_error(self) -> None: + """Requesting an unrecognised metric raises ``KeyError``.""" + db = str(self.tmp_path / "test.db") + _make_db(db) + with self.assertRaisesRegex(KeyError, "unknown metric"): + read_db_metric(db, "not-a-real-metric") + + def test_main_host_wallclock_prints_value(self) -> None: + """``main`` with ``--metric host-wallclock`` prints the correct float.""" + db = str(self.tmp_path / "test.db") + _make_db(db) + + buf: io.StringIO = io.StringIO() + with patch.object( + sys, "argv", ["read_db_metrics", db, "--metric", "host-wallclock"] + ): + with redirect_stdout(buf): + main() + + self.assertEqual(float(buf.getvalue().strip()), 1.5) + + def test_main_host_wallclock_total_prints_sum(self) -> None: + """``main`` with ``--metric host-wallclock-total`` prints the sum.""" + db = str(self.tmp_path / "test.db") + _make_db(db) + + buf: io.StringIO = io.StringIO() + with patch.object( + sys, + "argv", + ["read_db_metrics", db, "--metric", "host-wallclock-total"], + ): + with redirect_stdout(buf): + main() + + self.assertAlmostEqual(float(buf.getvalue().strip()), 4.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/report_writer.py b/src/signaloid/benchmarking/automation/report_writer.py new file mode 100644 index 0000000..e0b79da --- /dev/null +++ b/src/signaloid/benchmarking/automation/report_writer.py @@ -0,0 +1,870 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import glob +import os +import time +from typing import Any + +import numpy as np +import pandas as pd +from tabulate import tabulate + +from signaloid.benchmarking.automation.benchmarking_utils import ( + clean_data_for_sheets, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, +) +from signaloid.benchmarking.config import ( + BenchmarkingVariables, + EquivMC, + Measurements, + MetadataRowLabels, + ReportSheetTabs, + VariableTypes, +) +from signaloid.distributional.distributional import DistributionalValue + +# The Google Drive folder and Sheets template the upload writes to. +# Deployment-specific, so supply them via environment variables (the upload is +# opt-in via --write-sheets and is validated up front in benchmark_application). +TARGET_FOLDER_ID = os.environ.get("UXHW_SHEETS_DRIVE_FOLDER_ID", "") +TEMPLATE_ID = os.environ.get("UXHW_SHEETS_TEMPLATE_ID", "") + +# Token rendered in place of EMCC / distance values for a blown-up (degraded) +# representation row, in both the markdown report and the Sheets export. +BLOW_UP_DISPLAY_TOKEN = "blow-up / excluded" + + +def _is_blow_up(value: Any) -> bool: + """ + Whether an emcc_data BLOW_UP_REASON cell marks a degraded row. + + The marker is a non-empty reason string for a blown row. It is ``None`` + (in-memory) or NaN / empty (round-tripped through pandas / CSV) for a + healthy row, all of which read as "not a blow-up". + + Args: + value: A ``BLOW_UP_REASON`` cell value. + + Returns: + ``True`` if the cell marks a blown-up row, else ``False``. + """ + if value is None: + return False + if isinstance(value, float) and np.isnan(value): + return False + return str(value).strip() != "" + + +def write_results_to_markdown( + *, + benchmarking_variables: list[BenchmarkingVariable], + results_dir: str, +) -> None: + """ + Write the results to markdown format. + + Args: + benchmarking_variables: List of benchmarking variables to write. + results_dir: Directory in which to write the markdown files. + """ + print("Writing results to Markdown files...") + for variable in benchmarking_variables: + df = pd.DataFrame(variable.emcc_results.emcc_data) + if BenchmarkingVariables.UXHW_CONF in df.columns: + df[BenchmarkingVariables.UXHW_CONF] = df[ + BenchmarkingVariables.UXHW_CONF + ].apply(repr) + # Show "blow-up / excluded" in the EMCC / distance columns of blown-up + # rows, so a degraded representation reads as excluded rather than as a + # meaningless small distance. + if BenchmarkingVariables.BLOW_UP_REASON in df.columns: + blown_mask = df[BenchmarkingVariables.BLOW_UP_REASON].apply(_is_blow_up) + blow_up_columns = [ + col + for col in ( + BenchmarkingVariables.UXHW_DISTANCE, + BenchmarkingVariables.UXHW_BINNED_DISTANCE, + EquivMC.EMCC, + EquivMC.EMCC_PREDICTED, + ) + if col in df.columns + ] + if blown_mask.any(): + for col in blow_up_columns: + # Cast to object first: assigning a string token into a + # numeric column otherwise raises a pandas + # incompatible-dtype FutureWarning. + df[col] = df[col].astype(object) + df.loc[blown_mask, col] = BLOW_UP_DISPLAY_TOKEN + md_path = os.path.join(results_dir, f"{variable.formatted_description}.md") + with open(md_path, "w") as f: + f.write( + tabulate( + df, # type: ignore[arg-type] + headers="keys", + tablefmt="github", + ) + ) + + +def resolve_google_credentials_path( + *, + google_credentials: str | None, +) -> str | None: + """ + Resolve the Google service-account credentials JSON path. + + The resolution order is: + + 1. The ``google_credentials`` argument (typically from + ``--google-credentials``). + 2. The ``GOOGLE_APPLICATION_CREDENTIALS`` environment variable. + + The resolved path is returned only when a file actually exists at + that location. If no candidate path resolves to an existing file, + a warning is printed and ``None`` is returned (this function never + raises). + + Args: + google_credentials: Explicit credentials path, or ``None`` to + fall back to the environment variable. + + Returns: + The resolved absolute path to an existing credentials file, or + ``None`` if no candidate resolves to an existing file. + """ + if google_credentials: + json_path = os.path.abspath(os.path.expanduser(google_credentials)) + elif os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"): + json_path = os.path.abspath( + os.path.expanduser(os.environ["GOOGLE_APPLICATION_CREDENTIALS"]) + ) + else: + print( + "Warning: no --google-credentials and no " + "GOOGLE_APPLICATION_CREDENTIALS set; cannot upload to Google Sheets." + ) + return None + + if not os.path.isfile(json_path): + print(f"Warning: Google credentials file not found at " f"'{json_path}'.") + return None + + return json_path + + +def _get_credentials(*, credentials_path: str) -> Any: + """ + Build a Google API service-account client for the given path. + + Args: + credentials_path: Path to the service-account JSON key file. + + Returns: + An authorised service-account credentials object. + """ + from oauth2client.service_account import ( # type: ignore + ServiceAccountCredentials, + ) + + scope = [ + "https://spreadsheets.google.com/feeds", + "https://www.googleapis.com/auth/drive", + ] + return ServiceAccountCredentials.from_json_keyfile_name(credentials_path, scope) + + +def _escape_drive_query_value(value: str) -> str: + """ + Escape a value for safe interpolation in a Drive query string. + + Per the Google Drive API, backslashes and single quotes inside + string literals must be backslash-escaped. Escape backslashes + first so the subsequent quote escape does not double-escape them. + + Args: + value: The raw value to embed in a Drive query. + + Returns: + The value with backslashes and single quotes escaped. + """ + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def _get_or_create_folder( + *, + drive_service: Any, + folder_name: str, + parent_id: str, +) -> str: + """ + Get the existing Drive folder by name under ``parent_id``, or create it. + + Args: + drive_service: Authorised Drive API service. + folder_name: Name of the folder to find or create. + parent_id: ID of the parent folder to search within / create under. + + Returns: + The folder's Drive ID. + """ + safe_name = _escape_drive_query_value(folder_name) + safe_parent = _escape_drive_query_value(parent_id) + query = ( + f"name = '{safe_name}' " + f"and mimeType = 'application/vnd.google-apps.folder' " + f"and '{safe_parent}' in parents " + f"and trashed = false" + ) + # supportsAllDrives + includeItemsFromAllDrives let the lookup see folders + # on a shared drive (the create/copy calls already opt in). Without them, + # repeated uploads would mint a new folder every run instead of reusing it. + response = ( + drive_service.files() + .list( + q=query, + fields="files(id, name)", + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ) + .execute() + ) + folders = response.get("files", []) + + if folders: + return str(folders[0]["id"]) + + metadata = { + "name": folder_name, + "mimeType": "application/vnd.google-apps.folder", + "parents": [parent_id], + } + folder = ( + drive_service.files() + .create(body=metadata, fields="id", supportsAllDrives=True) + .execute() + ) + return str(folder["id"]) + + +def _get_or_create_folder_structure( + *, + drive_service: Any, + uxhw_version: str, + application_name: str, +) -> str: + """ + Get or create the ``uxhw-/`` results folder. + + Args: + drive_service: Authorised Drive API service. + uxhw_version: UxHw version, naming the top-level folder. + application_name: Application name, naming the nested folder. + + Returns: + The Drive ID of the application folder. + """ + parent_directory = f"uxhw-{uxhw_version}" + parent_folder_id = _get_or_create_folder( + drive_service=drive_service, + folder_name=parent_directory, + parent_id=TARGET_FOLDER_ID, + ) + return _get_or_create_folder( + drive_service=drive_service, + folder_name=application_name, + parent_id=parent_folder_id, + ) + + +def _find_label_row(*, column_a: list[str], label: str) -> int: + """ + Return the 1-based row of the first column-A cell containing ``label``. + + Matching is case-insensitive and by substring, because the template + prefixes the labels with a (non-stable) list number and may carry trailing + descriptive text (see :class:`MetadataRowLabels`). + + Args: + column_a: The sheet's column-A values, top to bottom. + label: The distinguishing label substring to find. + + Returns: + The 1-based row index of the first matching cell. + + Raises: + ValueError: If no column-A cell contains ``label`` (the template no + longer has the expected row), so a structural mismatch fails loudly + at the metadata step rather than silently dropping the value. + """ + needle = label.casefold() + for index, cell in enumerate(column_a): + if needle in (cell or "").casefold(): + return index + 1 + raise ValueError( + f"Metadata sheet '{ReportSheetTabs.ASSUMPTIONS_CONFIG}' has no column-A " + f"row containing {label!r}; the report template layout changed. " + f"Update MetadataRowLabels to match it." + ) + + +def _update_metadata_sheet( + *, + sheet: Any, + application_version: str, + uxhw_version: str, + machine_name: str, + git_repo_remote: str | None, +) -> None: + """ + Write session metadata into the assumptions / configuration sheet. + + Each value is written to column B of the row whose column-A label matches + (by-name via :func:`_find_label_row`), so the write follows the template's + labels if its rows are reordered instead of being pinned to fixed cells. + + Args: + sheet: The assumptions / configuration worksheet. + application_version: Git hash written to the Git Hash row. + uxhw_version: UxHw SDK version written to the SDK-version row. + machine_name: Machine type written to the Machine Type row. + git_repo_remote: Repository URL written to the GitHub Repository row + (empty string when unknown). + """ + column_a = sheet.col_values(1) + label_values = [ + (MetadataRowLabels.GITHUB_REPOSITORY, git_repo_remote or ""), + (MetadataRowLabels.GIT_HASH, application_version), + (MetadataRowLabels.UXHW_SDK_VERSION, uxhw_version), + (MetadataRowLabels.MACHINE_TYPE, machine_name), + ] + updates = [ + { + "range": f"B{_find_label_row(column_a=column_a, label=label)}", + "values": [[value]], + } + for label, value in label_values + ] + sheet.batch_update(updates) + + +def _get_best_speedup_info(*, df: pd.DataFrame) -> tuple[str, int]: + """ + Get the configuration and EMCC for the best speedup. + + Blown-up rows are excluded first: a degraded representation collapses to + ``EMCC=1`` and would otherwise win "best speedup" spuriously. + + Args: + df: Per-configuration results for one variable. + + Returns: + A tuple of (best config string, its EMCC count). + """ + if BenchmarkingVariables.BLOW_UP_REASON in df.columns: + df = df[~df[BenchmarkingVariables.BLOW_UP_REASON].apply(_is_blow_up)] + idx = df[Measurements.SPEEDUP].idxmax() + + best_config = df[BenchmarkingVariables.UXHW_CONF][idx] + best_config = ( + repr(best_config) + if isinstance(best_config, (DistributionalValue, TaggedDistributionalValue)) + else str(best_config) + ) + + try: + raw_value = df.at[idx, EquivMC.EMCC] + except KeyError: + raw_value = df.at[idx, EquivMC.EMCC_PREDICTED] + + if not isinstance(raw_value, (float, int, np.integer, np.floating)): + error_value = ( + raw_value.decode() if isinstance(raw_value, bytes) else str(raw_value) + ) + raise TypeError( + f"Expected float or int. {error_value} is of type " f"{type(raw_value)}" + ) + + return best_config, int(raw_value) + + +def _write_measurement_data( + *, + sheet: Any, + df: pd.DataFrame, +) -> None: + """ + Write measurement data to the sheet. + + Blown-up rows have their EMCC / distance cells replaced with the + "blow-up / excluded" token so a degraded representation does not read as a + tiny distance or ``EMCC=1``. + + Args: + sheet: The timing-performance worksheet. + df: Per-configuration results for one variable. + """ + sheet_columns = [ + BenchmarkingVariables.UXHW_CONF, + Measurements.IN_APP_TIME, + Measurements.DB_TIME, + Measurements.E2E_TIME, + BenchmarkingVariables.UXHW_DISTANCE, + BenchmarkingVariables.UXHW_BINNED_DISTANCE, + EquivMC.EMCC, + EquivMC.EMCC_PREDICTED, + EquivMC.PERCENTAGE_MC_BEATS_UXHW, + Measurements.NATIVE_IN_APP_TIME, + Measurements.NATIVE_E2E_TIME, + Measurements.SPEEDUP, + Measurements.PIN_DYN_COUNT, + Measurements.DB_DYN_COUNT, + Measurements.NATIVE_PIN_COUNT, + ] + + # Capture which rows are blown-up before the reindex below drops the + # BLOW_UP_REASON column. + if BenchmarkingVariables.BLOW_UP_REASON in df.columns: + blown_flags = ( + df[BenchmarkingVariables.BLOW_UP_REASON].apply(_is_blow_up).tolist() + ) + else: + blown_flags = [False] * len(df) + + rows = df.reindex(columns=sheet_columns).values.tolist() + rows = clean_data_for_sheets(rows) + + # Columns in sheet_columns whose value is meaningless for a blown row: + # UxHw Distance, Binned UxHw Distance, EMCC, EMCC Predicted. + blow_up_indices = [ + sheet_columns.index(col) + for col in ( + BenchmarkingVariables.UXHW_DISTANCE, + BenchmarkingVariables.UXHW_BINNED_DISTANCE, + EquivMC.EMCC, + EquivMC.EMCC_PREDICTED, + ) + ] + for row, is_blown in zip(rows, blown_flags): + if is_blown: + for col_index in blow_up_indices: + row[col_index] = BLOW_UP_DISPLAY_TOKEN + + start_row = 4 + end_row = start_row + len(rows) - 1 + + sheet.batch_update( + [ + { + "range": f"A{start_row}:C{end_row}", + "values": [row[:3] for row in rows], + }, + { + "range": f"E{start_row}:E{end_row}", + "values": [row[3:4] for row in rows], + }, + { + "range": f"G{start_row}:Q{end_row}", + "values": [row[4:] for row in rows], + }, + ] + ) + time.sleep(1) + + +# The results table and the plot generator now use the same canonical +# correlation tokens (``Disabled`` / ``Autocorrelation``), written verbatim by +# both, so no remapping is needed and this map stays empty. (It previously +# bridged the old verbose/terse spellings, e.g. "CORRELATION_OFF" -> "OFF".) +_RESULTS_TO_PLOT_CORRELATION_TOKEN: dict[str, str] = {} + + +def _plot_per_config_token(best_config: str) -> str: + """ + Rewrite a results "UxHw Conf" string to the generator's form. + + Only the trailing correlation token is remapped. The type and size are + left untouched. Results and generator now share one canonical correlation + token (``Disabled`` / ``Autocorrelation``), so the map is empty and this is + effectively a pass-through (kept as the single seam should the two forms + ever diverge again). + + Args: + best_config: The results-table "UxHw Conf" string. + + Returns: + The same string with its correlation token mapped to the generator's + form. + """ + head, separator, correlation = best_config.rpartition("-") + if not separator: + return best_config + mapped = _RESULTS_TO_PLOT_CORRELATION_TOKEN.get(correlation, correlation) + return f"{head}-{mapped}" + + +def _scalar_adversary_plot( + *, + variable: BenchmarkingVariable, + plots_dir: str, +) -> str: + """ + Locate a scalar variable's adversary-distances scatter plot. + + Scalars have no per-EMCC adversary distribution plot, only the + distances-vs-size scatter written by ``_plot_adversary_distances``. Its + suffix (ground-truth UR order and adversary count) is not reconstructable + here, so glob for it. ``variable.name`` may contain glob metacharacters + (e.g. ``outputVariables[0]``) and is escaped before matching. + + Args: + variable: The scalar benchmarking variable. + plots_dir: Directory the plot generator wrote into. + + Returns: + The path of the most recently modified matching scatter plot, or a + representative non-glob path when none exists (so the caller reports it + missing rather than raising). + """ + pattern = os.path.join( + plots_dir, + f"{glob.escape(variable.name)}-adversary_distances-*.png", + ) + matches = glob.glob(pattern) + if matches: + # Matches accumulate across reruns. Pick the most recently written to + # align with the latest generator output (lexicographic order tracks + # neither recency nor the numeric suffixes in the name). + return max(matches, key=os.path.getmtime) + return os.path.join( + plots_dir, + f"{variable.name}-adversary_distances.png", + ) + + +def _get_triptych_image_names( + *, + variable: BenchmarkingVariable, + best_config: str, + best_emcc: int, + plots_dir: str, +) -> list[str]: + """ + Reconstruct the filenames the plot generators wrote. + + The adversary slot differs by variable type: distribution variables get a + per-EMCC adversary distribution plot (``...-adversary-.png``). Scalar + variables only get the adversary-distances scatter (located by glob). + + Args: + variable: The benchmarking variable. + best_config: The best-speedup config string (names the UxHw plot). + best_emcc: The EMCC count of the best config (names the adversary plot). + plots_dir: Directory the plot generators wrote into. + + Returns: + The reconstructed triptych image paths. + """ + if variable.type == VariableTypes.SCALAR: + adversary_path = _scalar_adversary_plot(variable=variable, plots_dir=plots_dir) + else: + adversary_path = os.path.join( + plots_dir, + f"{variable.formatted_description}-adversary-{best_emcc}.png", + ) + + return [ + adversary_path, + os.path.join( + plots_dir, + f"{variable.formatted_description}-" + f"{_plot_per_config_token(best_config)}.png", + ), + os.path.join( + plots_dir, + f"{variable.formatted_description}-ground-truth.png", + ), + ] + + +def upload_and_link_images_to_sheet( + *, + image_files: list[str], + sheets_service: Any, + drive_service: Any, + folder_id: str, + sheet_id: str, +) -> None: + """ + Upload images to a shared Google Drive folder and link them. + + Each image listed in ``image_files`` is uploaded to the folder + identified by ``folder_id`` and a HYPERLINK formula referencing the + uploaded file is written into the ``Plot Triptych`` sheet of the + spreadsheet identified by ``sheet_id``. Files that do not exist on + disk are skipped with a warning. + + Args: + image_files: Local paths of PNG files to upload. + sheets_service: A Google Sheets v4 service client. + drive_service: A Google Drive v3 service client. + folder_id: Drive folder ID to upload images into. + sheet_id: Spreadsheet ID to link the uploaded images from. + """ + from googleapiclient.http import MediaFileUpload # type: ignore + + link_rows = [] + for file_name in image_files: + try: + if not os.path.exists(file_name): + print(f"Warning: Image file {file_name} not found, " f"skipping") + continue + + base_name = os.path.basename(file_name) + file_metadata = { + "name": base_name, + "parents": [folder_id], + } + media = MediaFileUpload(file_name, mimetype="image/png") + uploaded_file = ( + drive_service.files() + .create( + body=file_metadata, + media_body=media, + fields="id", + supportsAllDrives=True, + ) + .execute() + ) + + file_id = uploaded_file["id"] + image_url = f"https://drive.google.com/file/d/{file_id}/view" + # Sheets escapes " inside a string literal by doubling it. + escaped_url = image_url.replace('"', '""') + escaped_name = base_name.replace('"', '""') + link_formula = f'=HYPERLINK("{escaped_url}", "{escaped_name}")' + link_rows.append([link_formula]) + + except Exception as e: + print(f"Error processing image {file_name}: {e}") + + if link_rows: + sheets_service.spreadsheets().values().update( + spreadsheetId=sheet_id, + range=f"{ReportSheetTabs.PLOT_TRIPTYCH}!A1", + valueInputOption="USER_ENTERED", + body={"values": link_rows}, + ).execute() + + +def _create_measurement_sheet( + *, + sh: Any, + template_sheet: Any, + results_df: pd.DataFrame, + reporting_method: str, + variable: BenchmarkingVariable, + index: int, + sheets_service: Any, + drive_service: Any, + folder_id: str, + sheet_id: str, + plots_dir: str, +) -> None: + """ + Create and populate one measurement-data sheet for a reporting method. + + Duplicates the template tab, writes the measurement data, and uploads the + best-config plot triptych, linking it from the sheet. + """ + # Insert each duplicate just after the template sheet (relative to its live + # index) rather than at a hard-coded position, so the layout does not assume + # the template tab sits at a fixed index. + emcc_sheet = template_sheet.duplicate( + insert_sheet_index=template_sheet.index + 1 + index + ) + time.sleep(1) + emcc_sheet.update_title(f"{EquivMC.EMCC} {reporting_method}") + time.sleep(1) + + df = results_df[results_df[EquivMC.REPORTING_METHOD] == reporting_method] + + best_config, best_emcc = _get_best_speedup_info(df=df) + print( + f"Best UxHw configuration for {variable.description} " + f"({reporting_method}): {best_config} (EMCC={best_emcc}). " + f"Uploading triptych for this configuration." + ) + + _write_measurement_data(sheet=emcc_sheet, df=df) + + image_files = _get_triptych_image_names( + variable=variable, + best_config=best_config, + best_emcc=best_emcc, + plots_dir=plots_dir, + ) + upload_and_link_images_to_sheet( + image_files=image_files, + sheets_service=sheets_service, + drive_service=drive_service, + folder_id=folder_id, + sheet_id=sheet_id, + ) + + +def _create_spreadsheet_for_variable( + *, + variable: BenchmarkingVariable, + client: Any, + drive_service: Any, + sheets_service: Any, + folder_id: str, + reporting_methods: list[str], + application_version: str, + uxhw_version: str, + machine_name: str, + git_repo_remote: str | None, + plots_dir: str, +) -> None: + """ + Create and populate the Google Sheets report for a single variable. + + Copies the template, writes the metadata sheet, and adds one + measurement-data sheet per reporting method. + """ + spreadsheet_title = f"{variable.description}" + copied_file = { + "name": spreadsheet_title, + "parents": [folder_id], + } + new_file = ( + drive_service.files() + .copy( + fileId=TEMPLATE_ID, + body=copied_file, + supportsAllDrives=True, + ) + .execute() + ) + + new_sheet_id = new_file["id"] + sh = client.open_by_key(new_sheet_id) + + _update_metadata_sheet( + sheet=sh.worksheet(ReportSheetTabs.ASSUMPTIONS_CONFIG), + application_version=application_version, + uxhw_version=uxhw_version, + machine_name=machine_name, + git_repo_remote=git_repo_remote, + ) + + results_df = pd.DataFrame(variable.emcc_results.emcc_data) + template_sheet = sh.worksheet(ReportSheetTabs.TIMING_PERFORMANCE) + + for i, reporting_method in enumerate(reporting_methods): + _create_measurement_sheet( + sh=sh, + template_sheet=template_sheet, + results_df=results_df, + reporting_method=reporting_method, + variable=variable, + index=i, + sheets_service=sheets_service, + drive_service=drive_service, + folder_id=folder_id, + sheet_id=new_sheet_id, + plots_dir=plots_dir, + ) + + sh.del_worksheet(template_sheet) + + +def write_results_to_spreadsheet( + *, + benchmarking_variables: list[BenchmarkingVariable], + credentials_path: str, + reporting_methods: list[str], + application_name: str, + application_version: str, + uxhw_version: str, + machine_name: str, + git_repo_remote: str | None, + plots_dir: str, +) -> None: + """ + Write the results to Google Sheets and upload them. + + Authenticates against the Google APIs using the supplied + credentials path, creates (or reuses) a Drive folder hierarchy + keyed on ``uxhw_version`` and ``application_name``, and writes + one spreadsheet per benchmarking variable into that folder. + + Args: + benchmarking_variables: Variables whose results to upload. + credentials_path: Path to a Google service-account JSON file. + Must exist on disk (see + :func:`resolve_google_credentials_path`). + reporting_methods: Reporting methods to emit a sheet for. + application_name: Application identifier (used as a Drive + folder name). + application_version: Application version string (written to + the metadata sheet). + uxhw_version: UxHw version string (used as a Drive folder + name and written to the metadata sheet). + machine_name: Machine identifier (written to the metadata + sheet). + git_repo_remote: Git remote URL of the application repo, or + ``None`` if unavailable. + plots_dir: Directory containing the triptych plot PNGs. + """ + import gspread + from googleapiclient.discovery import build # type: ignore + + print("Uploading results to Google Sheets...") + creds = _get_credentials(credentials_path=credentials_path) + client = gspread.authorize(creds) + drive_service = build("drive", "v3", credentials=creds) + sheets_service = build("sheets", "v4", credentials=creds) + + folder_id = _get_or_create_folder_structure( + drive_service=drive_service, + uxhw_version=uxhw_version, + application_name=application_name, + ) + + for variable in benchmarking_variables: + _create_spreadsheet_for_variable( + variable=variable, + client=client, + drive_service=drive_service, + sheets_service=sheets_service, + folder_id=folder_id, + reporting_methods=reporting_methods, + application_version=application_version, + uxhw_version=uxhw_version, + machine_name=machine_name, + git_repo_remote=git_repo_remote, + plots_dir=plots_dir, + ) diff --git a/src/signaloid/benchmarking/automation/report_writer_test.py b/src/signaloid/benchmarking/automation/report_writer_test.py new file mode 100644 index 0000000..c225b41 --- /dev/null +++ b/src/signaloid/benchmarking/automation/report_writer_test.py @@ -0,0 +1,637 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import Mock, patch + +import numpy as np +import pandas as pd + +from signaloid.benchmarking.automation.report_writer import ( + BLOW_UP_DISPLAY_TOKEN, + _escape_drive_query_value, + _get_best_speedup_info, + _get_triptych_image_names, + _is_blow_up, + _plot_per_config_token, + _update_metadata_sheet, + resolve_google_credentials_path, + write_results_to_markdown, +) +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import ( + BenchmarkingVariables, + EquivMC, + Measurements, + VariableTypes, +) + + +def _make_variable( + description: str, + emcc_data: list, +) -> BenchmarkingVariable: + """ + Create a BenchmarkingVariable fixture with given emcc_data. + + Args: + description: Human-readable description of the variable. + emcc_data: List of dicts to populate emcc_data. + + Returns: + A BenchmarkingVariable instance ready for use in tests. + """ + variable = BenchmarkingVariable( + name=description, + description=description, + ) + variable.emcc_results.emcc_data = emcc_data + return variable + + +def _make_speedup_df(rows: list[dict]) -> pd.DataFrame: + """Build a DataFrame with the columns _get_best_speedup_info reads.""" + return pd.DataFrame(rows) + + +# Column A of the assumptions / configuration sheet in the live template +# (https://docs.google.com/spreadsheets/d/1MKPkwY_B_m5WH-coqIy19Otpg3IE5yRSKaKGw6wxzL8): +# the labels carry a list number (whose sequence skips) and trailing text, so +# the metadata write must locate rows by substring rather than fixed position. +_LIVE_METADATA_COLUMN_A = [ + "1. GitHub Repository for Application:", + "2. Git Hash for Code:", + "3. Number of distributional inputs:", + "4. Number of distributional outputs:", + "5. Example SCCE TaskID for SCCE runs:", + "7. UxHw SDK version", + "8. Machine Type", +] + + +def _metadata_cell(sheet_mock: Mock, cell_range: str) -> Any: + """Extract a cell value (or None) from a mocked ``sheet.batch_update``.""" + updates = sheet_mock.batch_update.call_args.args[0] + match = next((u for u in updates if u["range"] == cell_range), None) + return None if match is None else match["values"][0][0] + + +def _run_metadata(sheet_mock: Mock, column_a: list[str] | None = None) -> None: + sheet_mock.col_values.return_value = ( + _LIVE_METADATA_COLUMN_A if column_a is None else column_a + ) + _update_metadata_sheet( + sheet=sheet_mock, + application_version="v1", + uxhw_version="4.2.1", + machine_name="test-machine", + git_repo_remote=None, + ) + + +class TestWriteResultsToMarkdown(unittest.TestCase): + """write_results_to_markdown emits one .md file per variable.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_file_created_per_variable(self) -> None: + """ + Each variable produces one .md file named after its formatted_description. + """ + var_a = _make_variable("alpha result", [{"col": 1}]) + var_b = _make_variable("beta result", [{"col": 2}]) + write_results_to_markdown( + benchmarking_variables=[var_a, var_b], + results_dir=str(self.tmp_path), + ) + self.assertTrue((self.tmp_path / "alpha-result.md").exists()) + self.assertTrue((self.tmp_path / "beta-result.md").exists()) + + def test_markdown_content_contains_headers_and_values(self) -> None: + """ + The markdown file contains the column header and row values. + """ + emcc_data = [ + {"Representation": "Athens-16", "EquivMC": 128}, + {"Representation": "Athens-32", "EquivMC": 256}, + ] + var = _make_variable("my variable", emcc_data) + write_results_to_markdown( + benchmarking_variables=[var], + results_dir=str(self.tmp_path), + ) + content = (self.tmp_path / "my-variable.md").read_text() + self.assertIn("Representation", content) + self.assertIn("EquivMC", content) + self.assertIn("Athens-16", content) + self.assertIn("128", content) + self.assertIn("Athens-32", content) + self.assertIn("256", content) + + def test_uxhw_conf_column_is_repr_formatted(self) -> None: + """ + Values in the UxHw Conf column are formatted with repr(). + """ + uxhw_col = BenchmarkingVariables.UXHW_CONF + emcc_data = [{uxhw_col: 0.95, "EquivMC": 64}] + var = _make_variable("conf variable", emcc_data) + write_results_to_markdown( + benchmarking_variables=[var], + results_dir=str(self.tmp_path), + ) + content = (self.tmp_path / "conf-variable.md").read_text() + self.assertIn(repr(0.95), content) + + def test_zero_variables_writes_no_files(self) -> None: + """ + Passing an empty list writes no files and raises no exception. + """ + write_results_to_markdown( + benchmarking_variables=[], + results_dir=str(self.tmp_path), + ) + self.assertEqual(list(self.tmp_path.iterdir()), []) + + +class TestGetBestSpeedupInfo(unittest.TestCase): + """_get_best_speedup_info selects the best config / EMCC from a table.""" + + def test_get_best_speedup_info_picks_row_with_max_speedup(self) -> None: + """ + _get_best_speedup_info returns the row whose Speedup is largest. + """ + df = _make_speedup_df( + [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + Measurements.SPEEDUP: 1.5, + EquivMC.EMCC: 100, + }, + { + BenchmarkingVariables.UXHW_CONF: "Athens-32", + Measurements.SPEEDUP: 4.2, + EquivMC.EMCC: 256, + }, + { + BenchmarkingVariables.UXHW_CONF: "Atlas-64", + Measurements.SPEEDUP: 2.1, + EquivMC.EMCC: 128, + }, + ] + ) + best_config, best_emcc = _get_best_speedup_info(df=df) + self.assertEqual(best_config, "Athens-32") + self.assertEqual(best_emcc, 256) + + def test_get_best_speedup_info_preserves_correlation_prefix(self) -> None: + """ + ``best_config`` is returned verbatim from the results table, full + ``CORRELATION_`` token included. The per-config plot on disk uses the + short token (``...-Athens-16-OFF.png``). Mapping the results token to + that short form is the job of ``_plot_per_config_token`` / + ``_get_triptych_image_names``, not of ``_get_best_speedup_info``, + which must report the configuration exactly as the results show it. + """ + df = _make_speedup_df( + [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + Measurements.SPEEDUP: 9.0, + EquivMC.EMCC: 42, + }, + ] + ) + best_config, best_emcc = _get_best_speedup_info(df=df) + self.assertEqual(best_config, "Athens-16") + self.assertEqual(best_emcc, 42) + + def test_get_best_speedup_info_falls_back_to_emcc_predicted(self) -> None: + """ + When EMCC is missing, _get_best_speedup_info reads EMCC_PREDICTED. + """ + df = _make_speedup_df( + [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + Measurements.SPEEDUP: 3.0, + EquivMC.EMCC_PREDICTED: 99, + }, + ] + ) + best_config, best_emcc = _get_best_speedup_info(df=df) + self.assertEqual(best_config, "Athens-16") + self.assertEqual(best_emcc, 99) + + def test_get_best_speedup_info_rejects_non_numeric_emcc(self) -> None: + """ + A non-numeric EMCC value raises TypeError. + """ + df = _make_speedup_df( + [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + Measurements.SPEEDUP: 1.0, + EquivMC.EMCC: "not-a-number", + }, + ] + ) + with self.assertRaises(TypeError): + _get_best_speedup_info(df=df) + + +class TestBlowUpReporting(unittest.TestCase): + """Blown-up rows are rendered as "blow-up / excluded" and never win + "best speedup".""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_is_blow_up_recognises_markers(self) -> None: + """A reason string is a blow-up. None / NaN / empty are not.""" + self.assertTrue(_is_blow_up("0.5 of mass sits at ...")) + self.assertFalse(_is_blow_up(None)) + self.assertFalse(_is_blow_up(np.nan)) + self.assertFalse(_is_blow_up("")) + + def test_markdown_renders_blow_up_token(self) -> None: + """A blow-up-marked row shows the excluded token in the EMCC / + distance columns rather than a meaningless small distance.""" + emcc_data = [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + BenchmarkingVariables.UXHW_DISTANCE: 0.01, + EquivMC.EMCC_PREDICTED: 128, + BenchmarkingVariables.BLOW_UP_REASON: None, + }, + { + BenchmarkingVariables.UXHW_CONF: "Jupiter-32", + BenchmarkingVariables.UXHW_DISTANCE: float("inf"), + EquivMC.EMCC_PREDICTED: 1, + BenchmarkingVariables.BLOW_UP_REASON: "0.5 of mass sits at ...", + }, + ] + var = _make_variable("blow up variable", emcc_data) + write_results_to_markdown( + benchmarking_variables=[var], + results_dir=str(self.tmp_path), + ) + content = (self.tmp_path / "blow-up-variable.md").read_text() + self.assertIn(BLOW_UP_DISPLAY_TOKEN, content) + # The healthy row's real EMCC is preserved. + self.assertIn("128", content) + + def test_best_speedup_excludes_blown_row(self) -> None: + """A blown row collapses to EMCC=1 and might post a huge speedup. + ``_get_best_speedup_info`` must exclude it and pick a healthy row.""" + df = _make_speedup_df( + [ + { + BenchmarkingVariables.UXHW_CONF: "Athens-16", + Measurements.SPEEDUP: 4.2, + EquivMC.EMCC: 256, + BenchmarkingVariables.BLOW_UP_REASON: None, + }, + { + BenchmarkingVariables.UXHW_CONF: "Jupiter-32", + Measurements.SPEEDUP: 999.0, + EquivMC.EMCC: 1, + BenchmarkingVariables.BLOW_UP_REASON: "blown", + }, + ] + ) + best_config, best_emcc = _get_best_speedup_info(df=df) + # The blown 999x row is excluded. The healthy Athens-16 row wins. + self.assertEqual(best_config, "Athens-16") + self.assertEqual(best_emcc, 256) + + +class TestTriptychImageNames(unittest.TestCase): + """_get_triptych_image_names / _plot_per_config_token build plot paths.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_triptych_image_names_compose_three_paths(self) -> None: + """ + _get_triptych_image_names returns adversary, config, ground-truth + PNG paths under the provided plots_dir. + """ + variable = BenchmarkingVariable( + name="my variable", + description="my variable", + ) + plots_dir = str(self.tmp_path) + names = _get_triptych_image_names( + variable=variable, + best_config="Athens-32", + best_emcc=256, + plots_dir=plots_dir, + ) + self.assertEqual( + names, + [ + os.path.join(plots_dir, "my-variable-adversary-256.png"), + os.path.join(plots_dir, "my-variable-Athens-32.png"), + os.path.join(plots_dir, "my-variable-ground-truth.png"), + ], + ) + + def test_plot_per_config_token_passes_correlation_through(self) -> None: + """The results and generator share one canonical correlation token, so + the per-config token passes through unchanged.""" + cases = [ + # Disabled correlation is omitted from the config string, and + # Autocorrelation is written verbatim by both the results table and + # the generator: both pass through unchanged. + ("Jupiter-16", "Jupiter-16"), + ("Jupiter-16-Autocorrelation", "Jupiter-16-Autocorrelation"), + # A config whose trailing segment is not a correlation token is + # left untouched (representation type/size are never rewritten). + ("Athens-32", "Athens-32"), + # A string with no dash passes through unchanged. + ("Athens", "Athens"), + ] + for best_config, expected in cases: + with self.subTest(best_config=best_config, expected=expected): + self.assertEqual(_plot_per_config_token(best_config), expected) + + def test_triptych_per_config_uses_canonical_correlation_token(self) -> None: + """A Disabled best_config (suffix omitted) yields the on-disk + ...-Jupiter-16.png name.""" + variable = BenchmarkingVariable( + name="gg -> gg cross-section (pb)", + description="gg -> gg cross-section (pb)", + type=VariableTypes.DISTRIBUTION, + ) + plots_dir = str(self.tmp_path) + names = _get_triptych_image_names( + variable=variable, + best_config="Jupiter-16", + best_emcc=10, + plots_dir=plots_dir, + ) + formatted = variable.formatted_description + self.assertEqual( + names, + [ + os.path.join(plots_dir, f"{formatted}-adversary-10.png"), + os.path.join(plots_dir, f"{formatted}-Jupiter-16.png"), + os.path.join(plots_dir, f"{formatted}-ground-truth.png"), + ], + ) + + def test_triptych_scalar_adversary_globs_distances_scatter(self) -> None: + """A scalar's adversary slot resolves to the distances scatter on disk. + + The generator writes the scatter with the raw variable name (which + contains glob metacharacters) and a suffix encoding ground-truth and + adversary detail the uploader cannot rebuild, so the slot is located + by glob rather than reconstructed. + """ + variable = BenchmarkingVariable( + name="outputVariables[0]", + description="gg -> gg cross-section (pb)", + type=VariableTypes.SCALAR, + ) + scatter_name = ( + "outputVariables[0]-adversary_distances-" + "100_weighted_ground_truth_samples-100_adversaries.png" + ) + # Write the scatter literally (pathlib does not treat [] as a glob). + (self.tmp_path / scatter_name).write_text("") + plots_dir = str(self.tmp_path) + + names = _get_triptych_image_names( + variable=variable, + best_config="Jupiter-16", + best_emcc=12345, + plots_dir=plots_dir, + ) + formatted = variable.formatted_description + # Adversary slot is the scatter (NOT a reconstructed -adversary-N.png), + # found despite the [] metacharacters in the variable name. + self.assertEqual(names[0], os.path.join(plots_dir, scatter_name)) + # Per-config uses formatted_description + short token. Scatter used + # the raw name -- the two prefixes legitimately differ. + self.assertEqual( + names[1], + os.path.join(plots_dir, f"{formatted}-Jupiter-16.png"), + ) + self.assertEqual( + names[2], os.path.join(plots_dir, f"{formatted}-ground-truth.png") + ) + + def test_triptych_scalar_adversary_picks_most_recent_match(self) -> None: + """With several scatter matches, the most recently written one wins. + + The lexicographically-last file is deliberately made the OLDER one, + so this fails if selection falls back to sorting by name. + """ + variable = BenchmarkingVariable( + name="outputVariables[0]", + description="scalar out", + type=VariableTypes.SCALAR, + ) + # "2_adversaries" sorts lexicographically AFTER "100_adversaries" + # ('2' > '1'), so a name-sort would wrongly pick the 2-adversary file. + newer = self.tmp_path / ( + "outputVariables[0]-adversary_distances-" + "50_weighted_ground_truth_samples-100_adversaries.png" + ) + older = self.tmp_path / ( + "outputVariables[0]-adversary_distances-" + "50_weighted_ground_truth_samples-2_adversaries.png" + ) + newer.write_text("") + older.write_text("") + os.utime(newer, (2000, 2000)) + os.utime(older, (1000, 1000)) + + names = _get_triptych_image_names( + variable=variable, + best_config="Athens-16", + best_emcc=7, + plots_dir=str(self.tmp_path), + ) + self.assertEqual(names[0], str(newer)) + + def test_triptych_scalar_adversary_fallback_when_missing(self) -> None: + """With no scatter on disk the scalar adversary slot is a clean path. + + It must not raise and must not leak a glob wildcard, so the uploader + reports it missing (the prior skip-with-warning behavior). + """ + variable = BenchmarkingVariable( + name="outputVariables[0]", + description="scalar out", + type=VariableTypes.SCALAR, + ) + names = _get_triptych_image_names( + variable=variable, + best_config="Athens-16", + best_emcc=7, + plots_dir=str(self.tmp_path), + ) + self.assertEqual( + names[0], + os.path.join( + str(self.tmp_path), "outputVariables[0]-adversary_distances.png" + ), + ) + self.assertNotIn("*", names[0]) + self.assertFalse(os.path.exists(names[0])) + + +class TestResolveGoogleCredentials(unittest.TestCase): + """resolve_google_credentials_path resolves explicit / env / default paths.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_resolve_google_credentials_returns_explicit_path(self) -> None: + """ + A credentials file passed via google_credentials is returned. + """ + creds_file = self.tmp_path / "creds.json" + creds_file.write_text("{}") + resolved = resolve_google_credentials_path( + google_credentials=str(creds_file), + ) + self.assertEqual(resolved, str(creds_file)) + + def test_resolve_google_credentials_uses_env_var(self) -> None: + """ + Falls back to GOOGLE_APPLICATION_CREDENTIALS if the explicit arg + is None. + """ + creds_file = self.tmp_path / "env-creds.json" + creds_file.write_text("{}") + with patch.dict( + os.environ, + {"GOOGLE_APPLICATION_CREDENTIALS": str(creds_file)}, + ): + resolved = resolve_google_credentials_path(google_credentials=None) + self.assertEqual(resolved, str(creds_file)) + + def test_resolve_google_credentials_returns_none_when_missing(self) -> None: + """ + Returns None (no raise) when no candidate file exists on disk. + """ + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None) + resolved = resolve_google_credentials_path( + google_credentials="/nonexistent/path/to/creds.json", + ) + self.assertIsNone(resolved) + + def test_resolve_google_credentials_no_inputs_returns_none(self) -> None: + """ + With no explicit arg and no env var, returns None (no org fallback). + """ + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None) + resolved = resolve_google_credentials_path(google_credentials=None) + self.assertIsNone(resolved) + + +class TestEscapeDriveQueryValue(unittest.TestCase): + """_escape_drive_query_value escapes Drive files().list query values.""" + + def test_escape_drive_query_value_passes_safe_strings_through(self) -> None: + """Strings without special characters are returned unchanged.""" + self.assertEqual(_escape_drive_query_value("uxhw-1.2.3"), "uxhw-1.2.3") + self.assertEqual(_escape_drive_query_value("MyApp"), "MyApp") + + def test_escape_drive_query_value_escapes_single_quote(self) -> None: + """Single quotes inside the value are backslash-escaped so the + Drive ``files().list`` query string is not terminated early.""" + self.assertEqual(_escape_drive_query_value("foo's bar"), "foo\\'s bar") + + def test_escape_drive_query_value_escapes_backslash_before_quote(self) -> None: + """Backslashes are escaped before single quotes to avoid the + quote-escape's backslash being doubled into a literal backslash + plus an unescaped quote.""" + self.assertEqual(_escape_drive_query_value("a\\b"), "a\\\\b") + self.assertEqual(_escape_drive_query_value("a\\'b"), "a\\\\\\'b") + + +class TestUpdateMetadataSheet(unittest.TestCase): + """_update_metadata_sheet writes the session metadata cells by label.""" + + def test_metadata_sheet_cell_mapping(self) -> None: + # Each value lands in column B of the row whose column-A label matches, + # resolved against the LIVE template's column A. With that layout the + # rows resolve to B1 repo, B2 git hash, B6 "UxHw SDK version", + # B7 "Machine Type". Nothing is written at B8 and no shading is applied. + sheet = Mock() + _run_metadata(sheet) + self.assertEqual(_metadata_cell(sheet, "B1"), "") + self.assertEqual(_metadata_cell(sheet, "B2"), "v1") + self.assertEqual(_metadata_cell(sheet, "B6"), "4.2.1") + self.assertEqual(_metadata_cell(sheet, "B7"), "test-machine") + self.assertIsNone(_metadata_cell(sheet, "B8")) + sheet.format.assert_not_called() + + def test_metadata_follows_reordered_rows(self) -> None: + """Reordering the template rows moves the writes with them: the values + track column-A labels, not fixed cell positions.""" + reordered = [ + "8. Machine Type", # row 1 + "2. Git Hash for Code:", # row 2 + "1. GitHub Repository for Application:", # row 3 + "7. UxHw SDK version", # row 4 + ] + sheet = Mock() + _run_metadata(sheet, column_a=reordered) + self.assertEqual(_metadata_cell(sheet, "B1"), "test-machine") + self.assertEqual(_metadata_cell(sheet, "B2"), "v1") + self.assertEqual(_metadata_cell(sheet, "B3"), "") + self.assertEqual(_metadata_cell(sheet, "B4"), "4.2.1") + + def test_metadata_missing_label_raises(self) -> None: + """A template that no longer carries an expected label fails loudly + (with the missing label named) rather than silently dropping it.""" + without_machine = [ + "1. GitHub Repository for Application:", + "2. Git Hash for Code:", + "7. UxHw SDK version", + ] + sheet = Mock() + with self.assertRaises(ValueError) as ctx: + _run_metadata(sheet, column_a=without_machine) + self.assertIn("Machine Type", str(ctx.exception)) + sheet.batch_update.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/sample_generator.py b/src/signaloid/benchmarking/automation/sample_generator.py new file mode 100644 index 0000000..e82d9f0 --- /dev/null +++ b/src/signaloid/benchmarking/automation/sample_generator.py @@ -0,0 +1,545 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import warnings +from collections import defaultdict +from concurrent.futures import ( + ProcessPoolExecutor, + ThreadPoolExecutor, + as_completed, +) + +import numpy as np +from signaloid.distributional.distributional import DistributionalValue + +from signaloid.benchmarking.automation.benchmarking_utils import ( + _run_mc_simulation, + _run_scalar_native, +) +from signaloid.benchmarking.types import BenchmarkingVariable +from signaloid.benchmarking.config import ( + EquivMC, + VariableTypes, +) +from signaloid.benchmarking.distribution_helpers.collapse import ( + _collapse_asymptotically_optimal_w1, +) + +# Per-process MC sub-job size used by ``_run_distribution_mc_flat`` to +# split large draws across the worker pool. Module-level so tests can +# patch it. +_MC_CHUNK_SIZE = 1_000_000 + + +def generate_database_native_mc( + *, + size: int, + database_path: str, + benchmarking_variables: list[BenchmarkingVariable], + n_processors: int, + n_adversaries: int, + ground_truth_size: int, + adversary_max_size_scalar: int, + use_clt: bool, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, + n_steps_scalar: int = 100, + weighted_samples: bool = False, + num_weighted_samples: int = 100_000, + ground_truth: bool = False, +) -> None: + """ + Generate a Monte Carlo database by executing the native binary. + + Splits ``benchmarking_variables`` into distribution-typed and + scalar-typed groups, runs each group through its respective + parallel-MC helper, and writes the collected samples to a SQLite + database in either weighted-sample or raw-MC format. + + Args: + size: Number of Monte Carlo samples to draw per distribution + variable. + database_path: Filesystem path at which to write the database. + benchmarking_variables: Variables to populate with samples. + n_processors: Number of parallel worker processes. + n_adversaries: Number of independent adversary runs per scalar + sample size. + ground_truth_size: Sample size used for the ground-truth scalar + pass. + adversary_max_size_scalar: Upper bound used when generating the + geometric series of scalar sample sizes. + use_clt: When ``True``, scalar sample sizes are taken + from the pre-computed EMCC predictions rather than the + geometric schedule. + path_to_application: Root path of the application source tree. + native_executable_name: Filename of the compiled native binary. + native_executable_dir: Directory containing the native binary. + demo_cli_args: Per-application command-line argument prefix + prepended to each variable's own CLA when invoking the + native executable. + n_steps_scalar: Number of different scalar sample sizes to + evaluate. + weighted_samples: When ``True``, convert raw MC samples to + weighted samples before persisting. + num_weighted_samples: Target weighted-sample support size. + ground_truth: When ``True``, generate the ground-truth + database. Otherwise the adversarial MC database. + """ + # Split variables by type, preserving original index for stable + # ordering. + dist_vars: list[tuple[int, BenchmarkingVariable]] = [] + scalar_vars: list[tuple[int, BenchmarkingVariable]] = [] + for var_idx, variable in enumerate(benchmarking_variables): + if variable.type == VariableTypes.DISTRIBUTION: + dist_vars.append((var_idx, variable)) + elif variable.type == VariableTypes.SCALAR: + scalar_vars.append((var_idx, variable)) + else: + print( + f"Warning! BenchmarkingVariable {variable.name} " f"type not supported!" + ) + + if dist_vars: + _run_distribution_mc_flat( + dist_vars=dist_vars, + size=size, + weighted_samples=weighted_samples, + num_weighted_samples=num_weighted_samples, + benchmarking_variables=benchmarking_variables, + n_processors=n_processors, + path_to_application=path_to_application, + native_executable_name=native_executable_name, + native_executable_dir=native_executable_dir, + demo_cli_args=demo_cli_args, + ) + + if scalar_vars: + _run_scalar_mc_flat( + scalar_vars=scalar_vars, + n_steps_scalar=n_steps_scalar, + ground_truth=ground_truth, + benchmarking_variables=benchmarking_variables, + n_processors=n_processors, + n_adversaries=n_adversaries, + ground_truth_size=ground_truth_size, + adversary_max_size_scalar=adversary_max_size_scalar, + use_clt=use_clt, + path_to_application=path_to_application, + native_executable_name=native_executable_name, + native_executable_dir=native_executable_dir, + demo_cli_args=demo_cli_args, + ) + + # Write to database. Imported here to avoid a top-level circular + # import: database_generator.py imports generate_database_native_mc + # from this module. + from signaloid.benchmarking.automation.database_generator import ( + generate_database_from_mc_samples, + generate_database_from_weighted_samples, + ) + + if weighted_samples: + generate_database_from_weighted_samples(database_path, benchmarking_variables) + else: + generate_database_from_mc_samples(database_path, benchmarking_variables) + + # Clean up + for variable in benchmarking_variables: + variable.distribution_samples.empty_values() + + +def _determine_scalar_sample_sizes( + *, + variable: BenchmarkingVariable, + n_steps_scalar: int, + use_clt: bool, + adversary_max_size_scalar: int, + ground_truth_size: int, +) -> list[int]: + """Determine which sample sizes to use for a scalar variable. + + In the ``use_clt`` branch the sizes come from the (uncapped) EMCC + predictions, which can run into the billions for a high-fidelity + representation. Such a count cannot be executed natively (``-M`` is a + signed 32-bit ``int``, and running more iterations than the ground truth is + infeasible anyway), so each predicted size is clamped to + ``ground_truth_size`` for *execution*. The reported ``EMCC_PREDICTED`` value + is computed elsewhere and left uncapped. + + Args: + variable: The scalar variable whose sample sizes are resolved. + n_steps_scalar: Number of points in the geometric schedule used + when ``use_clt`` is ``False``. + use_clt: When ``True``, take sizes from the EMCC predictions. + Otherwise build a geometric schedule. + adversary_max_size_scalar: Upper bound of the geometric + schedule. + ground_truth_size: Feasible upper bound for any executed size. + Predicted sizes above it are clamped down to it. + + Returns: + Sorted, de-duplicated list of sample sizes to execute. + """ + sizes: list[int] + if use_clt: + sizes = [] + for dic in variable.emcc_results.emcc_data: + predicted = dic[EquivMC.EMCC_PREDICTED] + if predicted > ground_truth_size: + warnings.warn( + f"EMCC-predicted sample size {predicted} for " + f"'{variable.description}' exceeds the ground-truth " + f"size {ground_truth_size}; clamping the executed " + f"adversary size to {ground_truth_size} (the reported " + f"EMCC prediction is unchanged).", + stacklevel=2, + ) + predicted = ground_truth_size + sizes.append(predicted) + else: + sizes = np.geomspace( + 1, + adversary_max_size_scalar, + num=n_steps_scalar, + dtype=int, + ).tolist() + + sizes = sorted(set(sizes)) + + return sizes + + +def _run_distribution_mc_flat( + *, + dist_vars: list[tuple[int, BenchmarkingVariable]], + size: int, + weighted_samples: bool, + num_weighted_samples: int, + benchmarking_variables: list[BenchmarkingVariable], + n_processors: int, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, +) -> None: + """ + Run MC sampling for all distribution variables in a shared pool. + + Chunks each variable's ``size`` samples into sub-jobs of up to 1M, + then submits the union of all sub-jobs across variables to one + ``ProcessPoolExecutor`` so that ``-j N`` is saturated even when + each variable alone would fit in a single chunk. + """ + if size <= 0: + warnings.warn( + f"Skipping distribution MC for {len(dist_vars)} variables: " + f"requested sample size is {size} (must be > 0).", + stacklevel=2, + ) + return + + max_size = _MC_CHUNK_SIZE + quotient, remainder = divmod(size, max_size) + sub_sizes_per_var = [max_size] * quotient + ([remainder] if remainder else []) + + # Flat list of (var_idx, chunk_idx, sub_size, cla) work items across all + # variables. + work_items: list[tuple[int, int, int, str]] = [] + for var_idx, variable in dist_vars: + cla = f"{demo_cli_args} {variable.cla}".strip() + for chunk_idx, sub_size in enumerate(sub_sizes_per_var): + work_items.append((var_idx, chunk_idx, sub_size, cla)) + + print( + f"Running {len(work_items)} MC simulations across " + f"{len(dist_vars)} distributional variables using {n_processors} " + f"parallel processes" + ) + if weighted_samples: + # Log ``num_weighted_samples`` directly, since that is the collapse + # size (``n_dirac_deltas``), including when it exceeds ``size`` (an + # explicit oversampling request the collapse accepts). + print( + f"Converting {size} samples into {num_weighted_samples} " + f"weighted samples for {len(dist_vars)} variables." + ) + + def finalize(var_idx: int, chunk_map: dict[int, list[float]]) -> None: + variable = benchmarking_variables[var_idx] + values: list[float] = [] + for chunk_idx in sorted(chunk_map.keys()): + values.extend(chunk_map.pop(chunk_idx)) + + if not values: + raise RuntimeError( + f"No MC samples collected for variable " + f"'{variable.description}' (var_idx={var_idx}); " + f"all simulation chunks returned empty results." + ) + + if weighted_samples: + dv = DistributionalValue.from_samples(np.array(values)) + collapsed = _collapse_asymptotically_optimal_w1( + dv, n_dirac_deltas=num_weighted_samples + ) + variable.distribution_samples.set_weighted_values( + collapsed.positions.tolist(), collapsed.masses.tolist() + ) + else: + variable.distribution_samples.set_values(values) + + # var_idx -> {chunk_idx -> samples}. Popped once a variable's last chunk + # arrives, so raw samples are not retained for all variables at once. + results: dict[int, dict[int, list[float]]] = defaultdict(dict) + remaining_chunks = {var_idx: len(sub_sizes_per_var) for var_idx, _ in dist_vars} + completed_vars = 0 + # The weighted-sample collapse is numpy-heavy and independent across + # variables, so overlap finalize work with the remaining MC chunks. + finalize_pool = ( + ThreadPoolExecutor(max_workers=n_processors) if weighted_samples else None + ) + finalize_futures: list = [] + # ``try/finally`` guards ``finalize_pool.shutdown()`` so a re-raised + # exception from the inner ``with ProcessPoolExecutor`` block does not leak + # the thread pool, which lives in this outer scope. + try: + with ProcessPoolExecutor(max_workers=n_processors) as executor: + future_to_key = {} + for var_idx, chunk_idx, sub_size, cla in work_items: + # Globally-unique index so _run_mc_simulation's temp-dir + # prefix and error messages disambiguate across variables. + global_index = f"v{var_idx}_c{chunk_idx}" + fut = executor.submit( + _run_mc_simulation, + (global_index, sub_size, cla), + path_to_application, + native_executable_name, + native_executable_dir, + ) + future_to_key[fut] = (var_idx, chunk_idx) + + for future in as_completed(future_to_key): + var_idx, chunk_idx = future_to_key[future] + try: + results[var_idx][chunk_idx] = future.result() + except Exception as exc: + # Fail fast: absorbing a partial failure (substituting + # ``[]``) would corrupt the database with under-sampled + # variables. + raise RuntimeError( + f"MC simulation failed " + f"(var_idx={var_idx}, chunk={chunk_idx}): " + f"{exc}" + ) from exc + remaining_chunks[var_idx] -= 1 + if remaining_chunks[var_idx] == 0: + completed_vars += 1 + variable = benchmarking_variables[var_idx] + print( + f" [{completed_vars}/{len(dist_vars)}] MC " + f"complete: {variable.description}" + ) + chunk_map = results.pop(var_idx) + if finalize_pool is not None: + finalize_futures.append( + finalize_pool.submit(finalize, var_idx, chunk_map) + ) + else: + finalize(var_idx, chunk_map) + + if finalize_pool is not None: + for fut in finalize_futures: + fut.result() + finally: + if finalize_pool is not None: + finalize_pool.shutdown() + + +def _run_scalar_mc_flat( + *, + scalar_vars: list[tuple[int, BenchmarkingVariable]], + n_steps_scalar: int, + ground_truth: bool, + benchmarking_variables: list[BenchmarkingVariable], + n_processors: int, + n_adversaries: int, + ground_truth_size: int, + adversary_max_size_scalar: int, + use_clt: bool, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, +) -> None: + """ + Run scalar MC sampling for all scalar variables in a shared pool. + + Flattens ``(size, repetition)`` combinations across every scalar + variable into one work list so the pool stays saturated instead of + idling between variables. + """ + # Initialize per-variable output dicts and collect work items + work_items: list[tuple[int, int, str, int]] = [] + remaining_runs: dict[int, int] = {} + run_id_counter = 0 + for var_idx, variable in scalar_vars: + if ground_truth: + sizes = [ground_truth_size] + repetitions = 1 + else: + repetitions = n_adversaries + sizes = _determine_scalar_sample_sizes( + variable=variable, + n_steps_scalar=n_steps_scalar, + use_clt=use_clt, + adversary_max_size_scalar=adversary_max_size_scalar, + ground_truth_size=ground_truth_size, + ) + + variable.distribution_samples.scalar_output_dict = {s: [] for s in sizes} + cla = f"{demo_cli_args} {variable.cla}".strip() + var_runs = 0 + for size in sizes * repetitions: + work_items.append((var_idx, size, cla, run_id_counter)) + run_id_counter += 1 + var_runs += 1 + remaining_runs[var_idx] = var_runs + + if not work_items: + return + + print( + f"Running {len(work_items)} scalar simulations across " + f"{len(scalar_vars)} variables using {n_processors} " + f"parallel processes" + ) + + completed_vars = 0 + with ProcessPoolExecutor(max_workers=n_processors) as executor: + future_to_var = {} + for var_idx, size, cla, run_id in work_items: + fut = executor.submit( + _run_scalar_native, + path_to_application, + native_executable_name, + native_executable_dir, + cla, + size, + run_id, + ) + future_to_var[fut] = var_idx + + for future in as_completed(future_to_var): + var_idx = future_to_var[future] + variable = benchmarking_variables[var_idx] + try: + sample_size, value = future.result() + if value is not None: + variable.distribution_samples.scalar_output_dict[ + sample_size + ].append(value) + except Exception as exc: + # Fail fast: absorbing a partial failure would leave the + # variable under-sampled relative to + # ``n_adversaries`` * len(sizes). + raise RuntimeError( + f"Scalar MC run failed for " f"{variable.description}: {exc}" + ) from exc + finally: + remaining_runs[var_idx] -= 1 + if remaining_runs[var_idx] == 0: + completed_vars += 1 + print( + f" [{completed_vars}/{len(scalar_vars)}] " + f"scalar MC complete: {variable.description}" + ) + + +def generate_scalar_samples( + *, + variable: BenchmarkingVariable, + sizes: list[int], + repetitions: int, + n_processors: int, + path_to_application: str, + native_executable_name: str, + native_executable_dir: str, + demo_cli_args: str, +) -> dict[int, list[float]]: + """ + Generate Monte Carlo samples for a scalar variable using the + native executable with parallel processing. + + Runs the native executable with ``-M `` for each combination + of ``sizes`` and ``repetitions``, collecting a single scalar + output per run. Each size is executed ``repetitions`` times in + parallel via ``ProcessPoolExecutor``, producing multiple + independent scalar values per sample size (e.g. one per + adversary). + + Args: + variable: The benchmarking variable whose ``cla`` field + supplies the command-line arguments passed to the native + executable. + sizes: List of Monte Carlo sample sizes to evaluate. Each size + is passed as the ``-M`` argument to the native executable. + repetitions: Number of independent runs per sample size. For + ground truth generation this is typically 1. For adversary + generation it equals ``n_adversaries``. + n_processors: Number of parallel worker processes. + path_to_application: Root path of the application source tree. + native_executable_name: Filename of the compiled native + binary. + native_executable_dir: Directory containing the native binary. + demo_cli_args: Per-application command-line argument prefix + prepended to ``variable.cla`` when invoking the native + executable. + + Returns: + A dict mapping each sample size to a list of scalar output + values collected across all repetitions for that size. + """ + + scalar_output_dict: dict[int, list[float]] = {size: [] for size in sizes} + + # Use multiprocessing to speedup scalar adversary array creation + with ProcessPoolExecutor(max_workers=n_processors) as executor: + futures = [] + for run_id, size in enumerate(sizes * repetitions): + futures.append( + executor.submit( + _run_scalar_native, + path_to_application, + native_executable_name, + native_executable_dir, + f"{demo_cli_args} {variable.cla}".strip(), + size, + run_id, + ) + ) + + for future in as_completed(futures): + sample_size, value = future.result() + if value is not None: + scalar_output_dict[sample_size].append(value) + + return scalar_output_dict diff --git a/src/signaloid/benchmarking/automation/symlink_application_inputs_test.py b/src/signaloid/benchmarking/automation/symlink_application_inputs_test.py new file mode 100644 index 0000000..161ed65 --- /dev/null +++ b/src/signaloid/benchmarking/automation/symlink_application_inputs_test.py @@ -0,0 +1,115 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest + +from pathlib import Path + +from signaloid.benchmarking.automation.benchmarking_utils import ( + _symlink_application_inputs, +) +from signaloid.benchmarking.config import EquivMC + +MC_OUTPUT_FILENAME = EquivMC.MC_OUTPUT_FILENAME + + +class TestSymlinkApplicationInputs(unittest.TestCase): + """``_symlink_application_inputs`` exposes inputs without leaking + the binary's ``data.out`` into the shared inputs directory.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_real_inputs_are_symlinked(self) -> None: + """Genuine input files are symlinked into the run directory.""" + inputs_dir = self.tmp_path / "inputs" + inputs_dir.mkdir() + (inputs_dir / "config.json").write_text("{}") + (inputs_dir / "prices.csv").write_text("1,2,3") + dest = self.tmp_path / "run" + dest.mkdir() + + _symlink_application_inputs(str(inputs_dir), str(dest)) + + for name in ("config.json", "prices.csv"): + link = dest / name + self.assertTrue(link.is_symlink()) + self.assertEqual(os.readlink(link), str(inputs_dir / name)) + + def test_data_out_is_not_symlinked(self) -> None: + """The binary's output file is never exposed as an input.""" + inputs_dir = self.tmp_path / "inputs" + inputs_dir.mkdir() + (inputs_dir / MC_OUTPUT_FILENAME).write_text("stale output\n") + (inputs_dir / "config.json").write_text("{}") + dest = self.tmp_path / "run" + dest.mkdir() + + _symlink_application_inputs(str(inputs_dir), str(dest)) + + self.assertFalse((dest / MC_OUTPUT_FILENAME).exists()) + self.assertTrue((dest / "config.json").is_symlink()) + + def test_worker_write_does_not_leak_into_shared_inputs(self) -> None: + """Regression: a stale inputs/data.out must not be written *through*. + + Reproduces the parallel-MC corruption precondition. Before the fix the + helper symlinked inputs/data.out into the run dir, so a worker writing a + relative ``data.out`` (as the native binary does) wrote through the + symlink to the single shared file -> concurrent ``-j > 1`` workers + clobbered one another and variables ended up with identical samples. + """ + inputs_dir = self.tmp_path / "inputs" + inputs_dir.mkdir() + sentinel = "SENTINEL-MUST-NOT-BE-OVERWRITTEN\n" + shared = inputs_dir / MC_OUTPUT_FILENAME + shared.write_text(sentinel) + dest = self.tmp_path / "run" + dest.mkdir() + + _symlink_application_inputs(str(inputs_dir), str(dest)) + + # Emulate the native binary writing its samples to a relative data.out. + with open(dest / MC_OUTPUT_FILENAME, "w") as f: + f.write("42.0\n") + + # The shared inputs file must be untouched and isolated by inode. + self.assertEqual(shared.read_text(), sentinel) + self.assertFalse((dest / MC_OUTPUT_FILENAME).is_symlink()) + self.assertNotEqual( + (dest / MC_OUTPUT_FILENAME).stat().st_ino, shared.stat().st_ino + ) + + def test_missing_inputs_dir_is_noop(self) -> None: + """A missing inputs/ directory is tolerated without error.""" + dest = self.tmp_path / "run" + dest.mkdir() + + _symlink_application_inputs(str(self.tmp_path / "does-not-exist"), str(dest)) + + self.assertEqual(list(dest.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/timing_intermediate_parser_test.py b/src/signaloid/benchmarking/automation/timing_intermediate_parser_test.py new file mode 100644 index 0000000..e160402 --- /dev/null +++ b/src/signaloid/benchmarking/automation/timing_intermediate_parser_test.py @@ -0,0 +1,555 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import io +import json +import tempfile +import unittest +from pathlib import Path + +from signaloid.benchmarking.automation.benchmarking_utils import ( + parse_timing_intermediate_stream, +) +from signaloid.benchmarking.config import TimingFormat + +UXHW_RUN = """\ +META timestamp 2026-04-15T14:22:10Z +META applicationName call-option +META applicationVersion a1b2c3d +META uxhwSdkVersion v4.5.6 +META commandLineArguments -T 100 --strike 110 +META commandLineArgumentsHash abc123 +META uxhwTargetRepetitions 20 +MEASUREMENT Athens-16-Autocorrelation 1.23 4.56 7.89 1000 2000 +MEASUREMENT Athens-16 1.01 4.02 7.03 950 ? +MEASUREMENT Reference-50-1 0.5 1.0 1.5 100 ? +MEASUREMENT Native-50-1 0.6 ? 1.7 ? ? +""" + +NATIVE_MC_RUN = """\ +META timestamp 2026-04-15T14:24:55Z +META applicationName call-option +META applicationVersion a1b2c3d +META uxhwSdkVersion v4.5.6 +META commandLineArguments 100 --strike 110 +META commandLineArgumentsHash abc123 +META uxhwTargetRepetitions 20 +MEASUREMENT Native-MC-50 0.9 ? 2.3 ? ? +MEASUREMENT Native-MC-100 1.1 ? 2.7 ? ? +""" + + +class TestParseTimingIntermediateStream(unittest.TestCase): + """Tests for parse_timing_intermediate_stream: run splitting and meta/measurement parsing.""" + + def test_splits_runs_on_timestamp_meta(self) -> None: + runs = parse_timing_intermediate_stream((UXHW_RUN + NATIVE_MC_RUN).splitlines()) + self.assertEqual(len(runs), 2) + self.assertEqual( + runs[0][TimingFormat.META_KEY_TIMESTAMP], "2026-04-15T14:22:10Z" + ) + self.assertEqual( + runs[1][TimingFormat.META_KEY_TIMESTAMP], "2026-04-15T14:24:55Z" + ) + + def test_meta_keys_attached_to_current_run(self) -> None: + runs = parse_timing_intermediate_stream(UXHW_RUN.splitlines()) + run = runs[0] + self.assertEqual(run[TimingFormat.META_KEY_APPLICATION_NAME], "call-option") + self.assertEqual(run[TimingFormat.META_KEY_APPLICATION_VERSION], "a1b2c3d") + self.assertEqual(run[TimingFormat.META_KEY_UXHW_SDK_VERSION], "v4.5.6") + self.assertEqual( + run[TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS], "-T 100 --strike 110" + ) + self.assertEqual( + run[TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS_HASH], "abc123" + ) + self.assertEqual(run[TimingFormat.META_KEY_UXHW_TARGET_REPETITIONS], "20") + + def test_measurement_numerics_parsed_as_float(self) -> None: + runs = parse_timing_intermediate_stream(UXHW_RUN.splitlines()) + first = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertEqual( + first[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG], "Athens-16-Autocorrelation" + ) + self.assertEqual(first[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 1.23) + self.assertEqual(first[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME], 4.56) + self.assertEqual(first[TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME], 7.89) + self.assertEqual( + first[TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT], 1000.0 + ) + self.assertEqual( + first[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT], 2000.0 + ) + + def test_missing_value_becomes_none(self) -> None: + runs = parse_timing_intermediate_stream(UXHW_RUN.splitlines()) + measurements = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS] + + reference = next( + m + for m in measurements + if m[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG] == "Reference-50-1" + ) + self.assertIsNone( + reference[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT] + ) + + native = next( + m + for m in measurements + if m[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG] == "Native-50-1" + ) + self.assertIsNone(native[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME]) + self.assertIsNone(native[TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT]) + + def test_malformed_measurement_lines_are_skipped(self) -> None: + fixture = """\ +META timestamp 2026-04-15T14:22:10Z +MEASUREMENT too few tokens here +MEASUREMENT config 1 2 3 4 5 +MEASUREMENT way too many tokens 1 2 3 4 5 6 7 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurements = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS] + self.assertEqual(len(measurements), 1) + self.assertEqual( + measurements[0][TimingFormat.JSON_KEY_MEASUREMENT_CONFIG], "config" + ) + + def test_blank_lines_are_ignored(self) -> None: + fixture = UXHW_RUN.replace( + "META applicationVersion a1b2c3d", + "\nMETA applicationVersion a1b2c3d\n", + ) + runs = parse_timing_intermediate_stream(fixture.splitlines()) + self.assertEqual(len(runs), 1) + self.assertEqual(runs[0][TimingFormat.META_KEY_APPLICATION_VERSION], "a1b2c3d") + + def test_accepts_file_like_iterable(self) -> None: + stream = io.StringIO(UXHW_RUN) + runs = parse_timing_intermediate_stream(stream) + self.assertEqual(len(runs), 1) + + def test_parsed_run_is_json_serialisable(self) -> None: + runs = parse_timing_intermediate_stream((UXHW_RUN + NATIVE_MC_RUN).splitlines()) + for run in runs: + line = json.dumps(run) + self.assertEqual(json.loads(line), run) + + +# --------------------------------------------------------------------------- +# SAMPLE tag tests — value-typed elapsedTime variant +# --------------------------------------------------------------------------- + +SAMPLE_TIME_RUN = """\ +META timestamp 2026-04-15T14:30:00Z +META applicationName call-option +META applicationVersion a1b2c3d +META uxhwSdkVersion v4.5.6 +META commandLineArguments 50 --strike 110 +META commandLineArgumentsHash abc123 +META uxhwTargetRepetitions 3 +SAMPLE Native-MC-50 elapsedTime 1 1.0 +SAMPLE Native-MC-50 elapsedTime 2 2.0 +SAMPLE Native-MC-50 elapsedTime 3 3.0 +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + + +class TestSampleElapsedTime(unittest.TestCase): + """Tests for value-typed elapsedTime SAMPLE resolution.""" + + def test_sample_time_resolves_average_from_question_mark(self) -> None: + """ + A MEASUREMENT with `?` in the time slot is resolved to the mean of + the preceding elapsedTime SAMPLE values for the same config. + """ + runs = parse_timing_intermediate_stream(SAMPLE_TIME_RUN.splitlines()) + self.assertEqual(len(runs), 1) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG], "Native-MC-50" + ) + # mean of 1.0, 2.0, 3.0 is 2.0 + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 2.0) + + def test_sample_time_non_missing_time_slot_is_not_overridden(self) -> None: + """ + When the time slot in a MEASUREMENT is an explicit float (not `?`), + SAMPLE lines for the same config must not override it. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 1 1.0 +SAMPLE Native-MC-50 elapsedTime 2 9.0 +MEASUREMENT Native-MC-50 4.0 ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 4.0) + + def test_sample_time_missing_samples_yields_none(self) -> None: + """ + A MEASUREMENT with `?` in the time slot and no matching SAMPLE lines + must leave the time field as None. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertIsNone(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME]) + + def test_sample_time_malformed_value_tokens_are_skipped(self) -> None: + """ + Non-numeric value tokens in elapsedTime SAMPLE lines are silently + discarded. Only valid float tokens contribute to the average. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 1 not_a_number +SAMPLE Native-MC-50 elapsedTime 2 6.0 +SAMPLE Native-MC-50 elapsedTime 3 bad +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # Only the valid token (6.0) contributes — mean is 6.0. + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 6.0) + + def test_sample_time_all_malformed_values_yield_none(self) -> None: + """ + When every elapsedTime SAMPLE token is non-numeric the resolved + time must be None rather than raising an exception. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 1 bad +SAMPLE Native-MC-50 elapsedTime 2 also_bad +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertIsNone(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME]) + + def test_sample_time_malformed_sample_lines_too_few_tokens_skipped(self) -> None: + """ + SAMPLE lines with fewer than four tokens are silently skipped and + do not affect the resolved average. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 4.0 +SAMPLE Native-MC-50 elapsedTime 1 8.0 +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # Only the well-formed line (8.0) contributes. + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 8.0) + + def test_sample_time_samples_scoped_to_run(self) -> None: + """ + SAMPLE lines from a previous run must not bleed into the next run's + MEASUREMENT resolution. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 1 99.0 +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +META timestamp 2026-04-15T14:31:00Z +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + self.assertEqual(len(runs), 2) + first = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + second = runs[1][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertAlmostEqual(first[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 99.0) + self.assertIsNone(second[TimingFormat.JSON_KEY_MEASUREMENT_TIME]) + + def test_sample_time_config_isolation(self) -> None: + """ + SAMPLE lines for one config must not affect the resolved time of a + different config in the same run. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-MC-50 elapsedTime 1 4.0 +MEASUREMENT Native-MC-50 ? ? 5.0 ? 100 +MEASUREMENT Native-MC-100 ? ? 6.0 ? 200 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurements = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS] + mc_50 = next( + m + for m in measurements + if m[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG] == "Native-MC-50" + ) + mc_100 = next( + m + for m in measurements + if m[TimingFormat.JSON_KEY_MEASUREMENT_CONFIG] == "Native-MC-100" + ) + self.assertAlmostEqual(mc_50[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 4.0) + self.assertIsNone(mc_100[TimingFormat.JSON_KEY_MEASUREMENT_TIME]) + + def test_sample_value_typed_summed_within_iteration(self) -> None: + """ + Multiple value-typed SAMPLE lines for the same iteration index are + summed before averaging across iterations. + + `run_native_benchmarks` relies on this: it emits two ``elapsedTime`` + SAMPLEs per iteration (program runtime + framework overhead) and + expects the parser to sum them within the iteration before + averaging across iterations — mirroring the pre-refactor + `prog + overhead` semantics that fed `compute_average`. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Native-50-1 elapsedTime 0 1.5 +SAMPLE Native-50-1 elapsedTime 0 0.3 +SAMPLE Native-50-1 elapsedTime 1 2.0 +SAMPLE Native-50-1 elapsedTime 1 0.4 +MEASUREMENT Native-50-1 ? ? 5.0 ? 100 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # iter 0: 1.5 + 0.3 = 1.8; iter 1: 2.0 + 0.4 = 2.4; mean = 2.1 + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 2.1) + + +# --------------------------------------------------------------------------- +# SAMPLE tag tests — path-typed pinDynInstCount variant +# --------------------------------------------------------------------------- + + +class TestSamplePinDynInstCount(unittest.TestCase): + """Tests for path-typed pinDynInstCount SAMPLE resolution.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + def test_sample_lines_resolved_to_averaged_pin_inst(self) -> None: + """SAMPLE lines are read and averaged into pinDynInstCount on MEASUREMENT.""" + sample_0 = self.tmp_path / "inscount-cfg-0.out" + sample_1 = self.tmp_path / "inscount-cfg-1.out" + sample_0.write_text("Count 1000\n") + sample_1.write_text("Count 3000\n") + + fixture = f"""\ +META timestamp 2026-04-15T14:22:10Z +SAMPLE cfg pinDynInstCount 0 {sample_0} +SAMPLE cfg pinDynInstCount 1 {sample_1} +MEASUREMENT cfg 1.0 ? 2.0 ? ? +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT], 2000.0 + ) + + def test_sample_lines_summed_within_iteration(self) -> None: + """Multiple SAMPLE lines for the same iteration index are summed before averaging.""" + prog_0 = self.tmp_path / "inscount-prog-0.out" + overhead_0 = self.tmp_path / "inscount-overhead-0.out" + prog_1 = self.tmp_path / "inscount-prog-1.out" + overhead_1 = self.tmp_path / "inscount-overhead-1.out" + prog_0.write_text("Count 1000\n") + overhead_0.write_text("Count 500\n") + prog_1.write_text("Count 2000\n") + overhead_1.write_text("Count 1000\n") + + fixture = f"""\ +META timestamp 2026-04-15T14:22:10Z +SAMPLE Native-50-1 pinDynInstCount 0 {prog_0} +SAMPLE Native-50-1 pinDynInstCount 0 {overhead_0} +SAMPLE Native-50-1 pinDynInstCount 1 {prog_1} +SAMPLE Native-50-1 pinDynInstCount 1 {overhead_1} +MEASUREMENT Native-50-1 0.6 ? 1.7 ? ? +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # iteration 0: 1000+500=1500, iteration 1: 2000+1000=3000 → avg=2250 + self.assertEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT], 2250.0 + ) + + def test_sample_lines_scoped_to_run(self) -> None: + """SAMPLE lines from one run do not bleed into the next run.""" + sample = self.tmp_path / "inscount.out" + sample.write_text("Count 5000\n") + + fixture = f"""\ +META timestamp 2026-04-15T14:22:10Z +SAMPLE cfg pinDynInstCount 0 {sample} +MEASUREMENT cfg 1.0 ? 2.0 ? ? +META timestamp 2026-04-15T14:25:00Z +MEASUREMENT cfg 1.0 ? 2.0 ? ? +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + first = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + second = runs[1][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertEqual( + first[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT], 5000.0 + ) + self.assertIsNone(second[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT]) + + def test_malformed_sample_lines_are_skipped(self) -> None: + """SAMPLE lines with wrong token count are silently ignored.""" + sample = self.tmp_path / "inscount.out" + sample.write_text("Count 1000\n") + + fixture = f"""\ +META timestamp 2026-04-15T14:22:10Z +SAMPLE too few tokens +SAMPLE cfg pinDynInstCount 0 {sample} +MEASUREMENT cfg 1.0 ? 2.0 ? ? +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT], 1000.0 + ) + + +# --------------------------------------------------------------------------- +# SAMPLE tag tests — value-typed databaseTime variant (B1) +# --------------------------------------------------------------------------- + + +class TestSampleDatabaseTime(unittest.TestCase): + """Tests for value-typed databaseTime SAMPLE resolution.""" + + def test_sample_db_time_resolves_average_from_question_mark(self) -> None: + """ + A MEASUREMENT with `?` in the db_t slot is resolved to the mean of + the preceding databaseTime SAMPLE values for the same config. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Athens-16-Autocorrelation databaseTime 0 1.0 +SAMPLE Athens-16-Autocorrelation databaseTime 1 2.0 +SAMPLE Athens-16-Autocorrelation databaseTime 2 3.0 +MEASUREMENT Athens-16-Autocorrelation 5.0 ? 7.0 100 200 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # mean of 1.0, 2.0, 3.0 is 2.0 + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME], 2.0 + ) + + def test_sample_db_time_explicit_value_not_overridden(self) -> None: + """ + When the db_t slot in a MEASUREMENT is an explicit float (not `?`), + SAMPLE lines for the same config must not override it. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE cfg databaseTime 0 1.0 +SAMPLE cfg databaseTime 1 9.0 +MEASUREMENT cfg 5.0 4.0 7.0 100 200 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME], 4.0 + ) + + +# --------------------------------------------------------------------------- +# SAMPLE tag tests — value-typed databaseDynInstCount variant (B1) +# --------------------------------------------------------------------------- + + +class TestSampleDatabaseDynInstCount(unittest.TestCase): + """Tests for value-typed databaseDynInstCount SAMPLE resolution.""" + + def test_sample_db_dyn_inst_count_resolves_average_from_question_mark( + self, + ) -> None: + """ + A MEASUREMENT with `?` in the db_i slot is resolved to the mean of + the preceding databaseDynInstCount SAMPLE values for the same config. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE Athens-16-Autocorrelation databaseDynInstCount 0 100 +SAMPLE Athens-16-Autocorrelation databaseDynInstCount 1 200 +SAMPLE Athens-16-Autocorrelation databaseDynInstCount 2 300 +MEASUREMENT Athens-16-Autocorrelation 5.0 4.0 7.0 ? 500 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + # mean of 100, 200, 300 is 200 + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT], 200.0 + ) + + def test_sample_db_dyn_inst_count_explicit_value_not_overridden(self) -> None: + """ + When the db_i slot in a MEASUREMENT is an explicit float, SAMPLE + lines for the same config must not override it. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE cfg databaseDynInstCount 0 100 +SAMPLE cfg databaseDynInstCount 1 900 +MEASUREMENT cfg 5.0 4.0 7.0 400 500 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT], 400.0 + ) + + def test_sample_mixed_three_slots_resolved_independently(self) -> None: + """ + A MEASUREMENT with `?` in the time, db_time, and db_dyn_inst_count + slots must resolve all three from their respective SAMPLE streams + without cross-contamination. + """ + fixture = """\ +META timestamp 2026-04-15T14:30:00Z +SAMPLE cfg elapsedTime 0 1.0 +SAMPLE cfg elapsedTime 1 3.0 +SAMPLE cfg databaseTime 0 10.0 +SAMPLE cfg databaseTime 1 20.0 +SAMPLE cfg databaseDynInstCount 0 100 +SAMPLE cfg databaseDynInstCount 1 300 +MEASUREMENT cfg ? ? 7.0 ? 500 +""" + runs = parse_timing_intermediate_stream(fixture.splitlines()) + measurement = runs[0][TimingFormat.JSON_KEY_MEASUREMENTS][0] + self.assertAlmostEqual(measurement[TimingFormat.JSON_KEY_MEASUREMENT_TIME], 2.0) + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME], 15.0 + ) + self.assertAlmostEqual( + measurement[TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT], 200.0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/uxhw_blow_up_guard_test.py b/src/signaloid/benchmarking/automation/uxhw_blow_up_guard_test.py new file mode 100644 index 0000000..d8c97bb --- /dev/null +++ b/src/signaloid/benchmarking/automation/uxhw_blow_up_guard_test.py @@ -0,0 +1,166 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.distribution_helpers.representation_health import ( + _representation_blow_up_reason, +) +from signaloid.distributional.dirac_delta import DiracDelta +from signaloid.distributional.distributional import DistributionalValue + + +class TestRepresentationBlowUpGuard(unittest.TestCase): + """``_representation_blow_up_reason`` distinguishes benign special-value + remnants from genuine representation blow-ups.""" + + def test_benign_zero_mass_special_values_returns_none(self) -> None: + """Finite deltas plus zero-mass NaN / +inf slots are benign: after + ``drop_zero_mass_positions`` the helper returns ``None`` and the + distribution reads as finite.""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=0.4), + DiracDelta(position=2.0, mass=0.6), + DiracDelta(position=float("nan"), mass=0.0), + DiracDelta(position=float("inf"), mass=0.0), + ] + ) + + # Mirror the production call site: shed zero-mass slots first. + dist.drop_zero_mass_positions() + + self.assertIsNone(_representation_blow_up_reason(dist)) + self.assertIs(dist.is_finite, True) + + def test_blow_up_non_finite_with_mass_returns_reason(self) -> None: + """A non-finite position carrying non-zero mass is a genuine blow-up + (mode 1): the helper returns a reason string.""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=0.5), + DiracDelta(position=float("inf"), mass=0.5), + ] + ) + + dist.drop_zero_mass_positions() + + reason = _representation_blow_up_reason(dist) + self.assertIsNotNone(reason) + assert reason is not None # narrow for type checker + self.assertIn("non-finite", reason) + + def test_blow_up_nan_position_with_mass_returns_reason(self) -> None: + """A NaN position carrying mass is also flagged by mode 1.""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=3.0, mass=0.7), + DiracDelta(position=float("nan"), mass=0.3), + ] + ) + + dist.drop_zero_mass_positions() + + reason = _representation_blow_up_reason(dist) + self.assertIsNotNone(reason) + + def test_blow_up_absurd_magnitude_mass_returns_reason(self) -> None: + """A finite distribution with a meaningful mass fraction parked at an + overflow-scale ``|position| > BLOW_UP_POSITION_MAGNITUDE`` (~1.34e154) + is a genuine blow-up (mode 2).""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=0.9), + DiracDelta(position=1e300, mass=0.1), + ] + ) + + dist.drop_zero_mass_positions() + + # The distribution is finite (1e300 is a finite float), so mode 1 does + # not fire. Mode 2 must catch the overflow-scale mass. + self.assertIs(dist.is_finite, True) + reason = _representation_blow_up_reason(dist) + self.assertIsNotNone(reason) + assert reason is not None # narrow for type checker + self.assertIn("position", reason) + + def test_clean_finite_distribution_returns_none(self) -> None: + """An ordinary finite distribution is not a blow-up.""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=-1.0, mass=0.2), + DiracDelta(position=0.0, mass=0.5), + DiracDelta(position=1.0, mass=0.3), + ] + ) + + self.assertIsNone(_representation_blow_up_reason(dist)) + + def test_negligible_mass_at_absurd_magnitude_returns_none(self) -> None: + """A truly negligible mass fraction at an overflow-scale magnitude is a + benign remnant, not a blow-up (mode 2 mass fraction stays below + threshold).""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=1.0 - 1e-9), + DiracDelta(position=1e300, mass=1e-9), + ] + ) + + self.assertIsNone(_representation_blow_up_reason(dist)) + + def test_check_magnitude_false_ignores_large_finite_value(self) -> None: + """With ``check_magnitude=False`` (the scalar path), even an + overflow-scale finite position is not a blow-up: mode 2 is skipped and + mode 1 does not fire on a finite position. The same value WOULD be + flagged with ``check_magnitude=True``, which is why the gate matters.""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1e300, mass=1.0), + ] + ) + + self.assertIs(dist.is_finite, True) + self.assertIsNone(_representation_blow_up_reason(dist, check_magnitude=False)) + self.assertIsNotNone(_representation_blow_up_reason(dist, check_magnitude=True)) + + def test_check_magnitude_false_still_flags_non_finite(self) -> None: + """With ``check_magnitude=False`` the non-finite mode 1 check still + runs: a non-finite position carrying mass is flagged. (A non-finite + scalar would crash ``relative_error_uxhw_wrapper``'s validator, so the + scalar branch must still detect it.)""" + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(position=2.0, mass=0.5), + DiracDelta(position=float("inf"), mass=0.5), + ] + ) + + dist.drop_zero_mass_positions() + + reason = _representation_blow_up_reason(dist, check_magnitude=False) + self.assertIsNotNone(reason) + assert reason is not None # narrow for type checker + self.assertIn("non-finite", reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/validate_args_test.py b/src/signaloid/benchmarking/automation/validate_args_test.py new file mode 100644 index 0000000..d894ea7 --- /dev/null +++ b/src/signaloid/benchmarking/automation/validate_args_test.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from argparse import Namespace + +from signaloid.benchmarking.automation.arguments import validate_args +from signaloid.benchmarking.config import ( + Correlations, + DistanceMetrics, + ReportingMethods, + RepresentationTypes, +) + + +class TestValidateArgs(unittest.TestCase): + """The python-O-safe validation guards.""" + + def test_validate_args_analytic_ground_truth_requires_weighted_samples( + self, + ) -> None: + # has_analytic_ground_truth=True with MonteCarlo ground_truth_type + # must raise ValueError (not AssertionError) so the guard survives + # python -O. + args = Namespace( + representation_types=[RepresentationTypes.ATHENS], + representation_sizes=[16], + correlations=[Correlations.DISABLED], + reporting_methods=[ReportingMethods.MEAN], + has_analytic_ground_truth=True, + ground_truth_type=RepresentationTypes.MONTE_CARLO, + use_binned_uxhw=False, + distance_type=DistanceMetrics.WASSERSTEIN_1, + num_parallel_workers=None, + ) + with self.assertRaisesRegex(ValueError, "weighted samples"): + validate_args(args) + + def test_validate_args_binned_uxhw_requires_wasserstein1(self) -> None: + # use_binned_uxhw=True with a non-W1 distance must raise ValueError + # (not AssertionError) so the guard survives python -O. + args = Namespace( + representation_types=[RepresentationTypes.ATHENS], + representation_sizes=[16], + correlations=[Correlations.DISABLED], + reporting_methods=[ReportingMethods.MEAN], + has_analytic_ground_truth=False, + ground_truth_type=RepresentationTypes.MONTE_CARLO, + use_binned_uxhw=True, + distance_type=DistanceMetrics.WASSERSTEIN_2, + num_parallel_workers=None, + ) + with self.assertRaisesRegex(ValueError, "Binned distance"): + validate_args(args) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh b/src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh new file mode 100644 index 0000000..f08b5f2 --- /dev/null +++ b/src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh @@ -0,0 +1,42 @@ +PROGRAM="main" +CLA="-T" +CLA_FOR_MULTIPLE_EXECUTIONS="" + +# APPLICATION_NAME and APPLICATION_VERSION are required by get-timings.sh. +# When invoked via the Python tool these are exported from +# Benchmark.export_timing_env(). For standalone use, set them here (or +# replace with explicit values, e.g. APPLICATION_NAME="my-demo", +# APPLICATION_VERSION="0.1.0"). +APPLICATION_NAME=$(basename "$APPLICATION_PATH" | sed 's/Signaloid-Demo-//') +APPLICATION_VERSION=$(git -C "$APPLICATION_PATH" rev-parse --short=7 HEAD 2>/dev/null \ + || date '+%Y-%m-%d-%H-%M-%S') + +# Intel PIN kit location (PIN_ROOT). When invoked via the Python tool +# this is exported from --path-to-pin. For standalone use, set it here. +# Uncomment and point at your Pin kit directory: +# export PIN_ROOT="/path/to/pin-external--gcc-linux" + +TRACES=( + 'addDistValueTrace variableName "main.c:124"' +) + +# Reference precisions (Monte Carlo sample counts) to benchmark. Counts up to +# and including EXTRAPOLATION_THRESHOLD (default 1000000) are directly measured. +# Larger counts are linearly extrapolated. Export EXTRAPOLATION_THRESHOLD to +# set: +# export EXTRAPOLATION_THRESHOLD=200000 +REFERENCE_PRECISIONS=(50 500) + +SKIP_UXHW=0 +SKIP_UXHW_TRACING=1 +SKIP_NATIVE_MC=1 + +APPEND_TO_OUTPUT_FILE=1 + +REPRESENTATION_TYPES=(Athens Jupiter) +REPRESENTATION_SIZES=(64 128 256 512) +CORRELATION_TRACKING_TYPES=(Disabled Autocorrelation) +MAX_JUPITER_LIMIT=32 + +# You need to source it to get the env variables to correctly work, also set the correct path to the shell script +. $SIGNALOID_PYTHON_DIR/src/signaloid/benchmarking/benchmark_timing/get-timings.sh diff --git a/src/signaloid/benchmarking/benchmark_timing/get-timings.sh b/src/signaloid/benchmarking/benchmark_timing/get-timings.sh new file mode 100644 index 0000000..6d79151 --- /dev/null +++ b/src/signaloid/benchmarking/benchmark_timing/get-timings.sh @@ -0,0 +1,1011 @@ +#!/usr/bin/env bash + +# * ========================================================================== +# * This script measures the runtime of a given application repository on +# * different core configurations +# * ========================================================================== + +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) + +# =========================================================================== +# Utility functions +# =========================================================================== + +die() { + echo "$*" >&2 + exit 1 +} + +warn() { + echo "$*" >&2 +} + +log_section() { + echo "===============================================================" + echo "=== $* ===" + echo "===============================================================" +} + +# Require a variable to be set and non-empty. Exits with an error message if not. +# Usage: require_var VAR_NAME +require_var() { + local var_name="$1" + if [[ -z "${!var_name:-}" ]]; then + die "Please set \$$var_name, via env or $SCRIPT_DIR/get_timings_local.sh" + fi +} + +# Warn if a variable is not set, but don't exit. +# Usage: warn_var VAR_NAME +warn_var() { + local var_name="$1" + if [[ -z "${!var_name:-}" ]]; then + warn "No \$$var_name set. Set via env or $SCRIPT_DIR/get_timings_local.sh" + fi +} + +# Set an array variable to a default value if it is not already set. +# Usage: default_array ARRAY_NAME default_val1 default_val2 ... +default_array() { + local var_name="$1" + shift + if eval "[ -z \${${var_name}+x} ]"; then + eval "$var_name=(\"\$@\")" + fi + local count + count=$(eval "echo \${#${var_name}[@]}") + echo "Testing $count $var_name" +} + +# Compute the average of a Python-style list string, e.g. "[1,2,3,]". +# Usage: compute_average "[1.0,2.0,3.0,]" +# +# Surviving callers (B1 / B5 boundary): only the two extrapolation-source +# sites in run_native_mc_benchmarks (last_measured_time and +# last_measured_instructions). Per-iteration values consumed by +# emit_measurement now flow through the SAMPLE tag and are averaged in +# Python (see signaloid.benchmarking.automation.benchmarking_utils +# `parse_timing_intermediate_stream`). +compute_average() { + "${BENCHMARKING_PYTHON:-python3}" -c "values = $1; print(sum(values) / len(values))" +} + +# Scale repetitions so the total measurement time approaches $target_total, +# clamped to [$min_reps, $max_reps]. Falls back to $max_reps if +# $single_time is not a positive number. +# Usage: reps=$(compute_time_scaled_repetitions ) +compute_time_scaled_repetitions() { + awk -v s="$1" -v t="$2" -v min_r="$3" -v max_r="$4" \ + 'BEGIN { + if (s+0 <= 0) { print max_r; exit } + r = t / s; + r = (r == int(r)) ? r : int(r) + 1; + if (r < min_r) r = min_r; + if (r > max_r) r = max_r; + print r; + }' +} + +unset MAKEFLAGS +MAKE_JOBS="-j$(nproc)" + +# Run a UxHw make build command, redirecting compiler warnings to UXHW_BUILD_LOG. +# Usage: uxhw_make [make args...] +uxhw_make() { + make -s $MAKE_JOBS -f "$UXHW_MAKEFILE" "$@" >/dev/null 2>>"$UXHW_BUILD_LOG" + # Move compiler-generated opt.err to logs directory if present + if [[ -f opt.err ]]; then + mv opt.err "$LOGS_DIR/opt.err" + fi +} + +# cd to the application input directory if it exists. +cd_to_input_dir() { + if [[ -e "$APPLICATION_PATH/$INPUT_DIR" ]]; then + cd "$APPLICATION_PATH/$INPUT_DIR" + fi +} + +# Check UxHw compilation output for errors. +check_uxhw_compilation() { + if grep -q 'Could not find' "$LOGS_DIR/opt.err" 2>/dev/null; then + die "Compilation failed, see $LOGS_DIR/opt.err" + fi + echo "ok" +} + +# Insert trace lines into an m config file. +# Usage: add_traces_to_config +add_traces_to_config() { + local config_file="$1" + # Announce the traced expressions once per distinct trace set (compact: + # "expr @ file:line"), not on every per-config .m rebuild. + if [[ "${TRACES[*]}" != "${_ANNOUNCED_TRACES:-}" ]]; then + local summary="" t expr loc + for t in "${TRACES[@]}"; do + loc=$(sed -E 's/.*"([^"]*)".*/\1/' <<<"$t") + expr=$(sed -E 's/^addDistValueTrace[[:space:]]+//; s/[[:space:]]*"[^"]*".*//' <<<"$t") + summary+="${summary:+, }$expr @ $loc" + done + echo "Tracing: $summary" + _ANNOUNCED_TRACES="${TRACES[*]}" + fi + for TRACE in "${TRACES[@]}"; do + sed -i "/--\s*INSERT TRACES AFTER THIS/a $TRACE" "$config_file" + done +} + +# Write a UxHw emulator m-config file inline (tracing/timing), then append $TRACES. +# +# Replaces the old run-montecarlo.m template + cp/sed chain. The template's +# srecl / loadDwarfBin / loadMapFile directives were UxHw-SDK ("sf") +# RISC-V-image loaders that the LLVM `opt` transformation (which consumes this +# config via --m-config-file) ignores — the tracing/timing passes always +# carried the literal "program-name" placeholders for them and still produced +# correct output — so they are dropped. setMcExecModeIterations stays commented +# out: the tracing/timing passes never enable MC mode. +# Config-file whitespace is not significant, so single-space formatting is used. +# +# Usage: write_emulator_config +write_emulator_config() { + local out_file="$1" db_file="$2" db_table="$3" + cat > "$out_file" </dev/null 2>>"${LOGS_DIR:-/tmp}/hyperfine.log" + jq '.results[0].mean' hyperfine.json +} + +# Extract the seconds value from a `CPU time used:` line in one or +# more benchmark stdout files, via the parse_cpu_time Python helper. +# Prefixes PYTHONPATH so the module resolves when the script is +# sourced standalone (without the poetry venv). With one positional +# arg the first match in that file is printed; with `--sum` followed +# by paths, every match across every file is summed. +# Usage: +# t=$(parse_cpu_time "$LOGS_DIR/$EXEC_STDOUT") +# total=$(parse_cpu_time --sum "${stdout_files[@]}") +parse_cpu_time() { + PYTHONPATH="$SIGNALOID_PYTHON_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \ + "${BENCHMARKING_PYTHON:-python3}" -m signaloid.benchmarking.automation.parse_cpu_time "$@" +} + +# Read a single-value DB metric via the read_db_metrics Python helper. +# Same PYTHONPATH-prefix shape as parse_cpu_time. +# Usage: t=$(read_db_metric host-wallclock "$UXHW_DB_BASE.db") +read_db_metric() { + local metric=$1 db=$2 + PYTHONPATH="$SIGNALOID_PYTHON_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \ + "${BENCHMARKING_PYTHON:-python3}" -m signaloid.benchmarking.automation.read_db_metrics \ + "$db" --metric "$metric" +} + +# =========================================================================== +# Argument parsing +# =========================================================================== + +usage() { + cat <<'HELP' +Usage: get-timings.sh [-h] [-a APPLICATION_PATH] [-c APPLICATION_VERSION] [-n APPLICATION_NAME] + +Options: + -h Show this help message + -a Path to the application/demo repository + -c Application version string + -n Application name +HELP +} + +parse_args() { + while getopts ":ha:c:n:" option; do + case $option in + h) + usage + exit 0 + ;; + a) APPLICATION_PATH=$OPTARG ;; + c) APPLICATION_VERSION=$OPTARG ;; + n) APPLICATION_NAME=$OPTARG ;; + \?) + echo "Error: Invalid option -$OPTARG" >&2 + usage >&2 + exit 1 + ;; + :) + echo "Error: Option -$OPTARG requires an argument" >&2 + usage >&2 + exit 1 + ;; + esac + done +} + +# =========================================================================== +# Validation and setup +# =========================================================================== + +validate_required_vars() { + require_var SIGNALOID_PYTHON_DIR + require_var PATH_TO_UXHW_SDK + require_var APPLICATION_PATH + require_var APPLICATION_NAME + require_var APPLICATION_VERSION + warn_var CLA + require_var CLA_FOR_MULTIPLE_EXECUTIONS + require_var PROGRAM + require_var REFERENCE_PRECISIONS + require_var TRACES +} + +copy_build_resources() { + cp "$RESOURCES_DIR/C0/$INIT_S" ./ + cp "$RESOURCES_DIR/common/$STARTUP_CPP" ./ + cp "$RESOURCES_DIR/C0Pro/$UXHW_MAKEFILE" ./ + cp "$RESOURCES_DIR/C0/$MAKEFILE" ./ +} + +# Fallback tag strings for the case where this script is run directly +# (e.g. via get-timing-template.sh) rather than from Python. The Python +# driver overrides these via environment variables so the tokens match +# the TimingFormat class in src/signaloid/benchmarking/config.py. +: "${TIMING_META_TAG:=META}" +: "${TIMING_MEASUREMENT_TAG:=MEASUREMENT}" +: "${TIMING_SAMPLE_TAG:=SAMPLE}" + +# Emit a single `META ` line to the intermediate timing file. +# The tag strings and target path are supplied by Python via env vars (see +# the TimingFormat class in src/signaloid/benchmarking/config.py). +# Usage: emit_meta key value... +emit_meta() { + local key="$1" + shift + echo "$TIMING_META_TAG $key $*" >>"$TIMING_INTERMEDIATE_FILE" +} + +# Emit a single `MEASUREMENT config time db_time e2e_time db_inst pin_inst` +# line to the intermediate timing file. Missing numeric fields must be +# passed as the literal `?` character (TimingFormat.MISSING_VALUE). +# Usage: emit_measurement config time db_time e2e_time db_inst pin_inst +emit_measurement() { + echo "$TIMING_MEASUREMENT_TAG $*" >>"$TIMING_INTERMEDIATE_FILE" +} + +# Emit a single `SAMPLE config field iteration ` line to +# the intermediate timing file. The 4th token is a literal float value +# (value-typed variant, e.g. elapsedTime) or a file path (path-typed +# variant, e.g. pinDynInstCount). The Python reader collects these and, +# at MEASUREMENT time, resolves any `?` sentinel in the matching field +# by reading/averaging the accumulated samples. +# Usage: emit_sample config field iteration value-or-path +emit_sample() { + echo "$TIMING_SAMPLE_TAG $*" >>"$TIMING_INTERMEDIATE_FILE" +} + +write_timings_header() { + if [[ $SKIP_UXHW -eq 0 ]] || [[ $SKIP_NATIVE_MC -eq 0 ]] || [[ $SKIP_UXHW_TRACING -eq 0 ]]; then + emit_meta timestamp "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + emit_meta applicationName "$APPLICATION_NAME" + emit_meta applicationVersion "$APPLICATION_VERSION" + emit_meta uxhwSdkVersion "$(tr -d '"' < "$PATH_TO_UXHW_SDK/.sdk_installed_release")" + emit_meta commandLineArguments "$CLA" + emit_meta commandLineArgumentsHash "$CLA_HASH" + # $REPETITION is the UxHw-loop target at session start. The + # actual rep count used per measurement can be rescaled later + # (see the per-testcase `uxhw_repetition` warmup scaling in + # run_uxhw_benchmarks) and native-MC rows use + # $NATIVE_MC_REPETITION, which is computed dynamically + # per-precision. This field therefore documents the target, + # not the per-run ground truth. + emit_meta uxhwTargetRepetitions "$REPETITION" + fi +} + +# =========================================================================== +# Benchmark: UxHw cores +# =========================================================================== + +# Build the canonical UxHw config string "-[-]". +# Disabled correlation tracking is the implicit default and is omitted, so it +# yields e.g. "Athens-16"; Autocorrelation yields "Athens-16-Autocorrelation". +# Must stay in lockstep with TaggedDistributionalValue.__repr__ (Python side), +# since this string is the join key between timing and EMCC data. +uxhw_config_string() { + local repr_type="$1" repr_size="$2" corr_type="$3" + if [[ "$corr_type" == "Autocorrelation" ]]; then + printf '%s-%s-%s' "$repr_type" "$repr_size" "$corr_type" + else + printf '%s-%s' "$repr_type" "$repr_size" + fi +} + +build_uxhw_testcase_binary() { + local repr_type="$1" + local repr_size="$2" + local corr_type="$3" + + uxhw_make clean + + local args=( + REPRESENTATION_TYPE="$repr_type" + REPRESENTATION_SIZE="$repr_size" + CORRELATION_TRACKING="$corr_type" + M_CONFIG_FILE="$M_CONFIG_FILE" + TARGET_ARCH="$TARGET_ARCH" + ENABLE_UNCERTAIN_TYPE_MODIFIER="$ENABLE_UNCERTAIN_TYPE_MODIFIER" + ) + uxhw_make "${args[@]}" + + mv "$PROGRAM" "$PROGRAM-$(uxhw_config_string "$repr_type" "$repr_size" "$corr_type")" + + uxhw_make clean +} + +run_uxhw_benchmarks() { + log_section "Get timing from UxHw cores." + + UXHW_TESTCASES=() + + for CORRELATION_TRACKING_TYPE in "${CORRELATION_TRACKING_TYPES[@]}"; do + for REPRESENTATION_TYPE in "${REPRESENTATION_TYPES[@]}"; do + for REPRESENTATION_SIZE in "${REPRESENTATION_SIZES[@]}"; do + if should_skip_representation "$REPRESENTATION_TYPE" "$CORRELATION_TRACKING_TYPE" "$REPRESENTATION_SIZE"; then + continue + fi + + build_uxhw_testcase_binary "$REPRESENTATION_TYPE" "$REPRESENTATION_SIZE" "$CORRELATION_TRACKING_TYPE" + + UXHW_TESTCASES+=("$(uxhw_config_string "$REPRESENTATION_TYPE" "$REPRESENTATION_SIZE" "$CORRELATION_TRACKING_TYPE")") + done + done + done + + for UXHW_TESTCASE in "${UXHW_TESTCASES[@]}"; do + echo "---> Timing $UXHW_TESTCASE" + + # Run with UxHw cores to generate timing measurements and dynamic + # instruction count for x86_64 + cd_to_input_dir + + # Warmup run to scale repetitions for this specific UxHw testcase by + # measured single-run time. The resulting count is testcase-local and + # may differ across UxHw configurations within the same session. + rm -f "$UXHW_DB_BASE".db + "$APPLICATION_PATH/src/$PROGRAM-$UXHW_TESTCASE" $CLA 1>"$LOGS_DIR/$EXEC_STDOUT" 2>"$LOGS_DIR/$EXEC_STDERR" + local warmup_time + local uxhw_repetition + warmup_time=$(grep -a 'CPU time used:' "$LOGS_DIR/$EXEC_STDOUT" \ + | sed -E 's/.*CPU time used: *([0-9]+\.[0-9]+).*/\1/') + uxhw_repetition=$(compute_time_scaled_repetitions "$warmup_time" "$TIMING_TARGET_TOTAL_TIME" "$TIMING_MIN_REPETITIONS" "$TIMING_MAX_REPETITIONS") + echo "Scaled repetitions=$uxhw_repetition (warmup ${warmup_time}s, target ${TIMING_TARGET_TOTAL_TIME}s)" + + for ((i = 0; i < uxhw_repetition; i++)); do + [ -t 1 ] && echo -ne "\rRepetitions ($((i + 1))/$uxhw_repetition)" + rm -f "$UXHW_DB_BASE".db + + "$APPLICATION_PATH/src/$PROGRAM-$UXHW_TESTCASE" $CLA 1>"$LOGS_DIR/$EXEC_STDOUT" 2>"$LOGS_DIR/$EXEC_STDERR" + + local timing_value + timing_value=$(parse_cpu_time "$LOGS_DIR/$EXEC_STDOUT") + emit_sample "$UXHW_TESTCASE" "elapsedTime" "$i" "$timing_value" + + local db_timing_value + db_timing_value=$(read_db_metric host-wallclock "$UXHW_DB_BASE.db") + emit_sample "$UXHW_TESTCASE" "databaseTime" "$i" "$db_timing_value" + + $INSTRUCTION_COUNT_COMMAND "$APPLICATION_PATH/src/$PROGRAM-$UXHW_TESTCASE" $CLA &>/dev/null + if [[ ! -f inscount.out ]]; then + die "inscount.out not found after PIN execution" + fi + local uxhw_sample_path="$LOGS_DIR/inscount-$UXHW_TESTCASE-$i-$$.out" + mv inscount.out "$uxhw_sample_path" + emit_sample "$UXHW_TESTCASE" "pinDynInstCount" "$i" "$uxhw_sample_path" + done + echo + + cd "$APPLICATION_PATH/src" + + cd_to_input_dir + PROCESS_E2E_TIME=$(run_hyperfine_mean "$APPLICATION_PATH/src/$PROGRAM-$UXHW_TESTCASE $CLA" --shell=none) + + # The emulated RISC-V dynamic instruction count (dbDynInstCount, the 5th + # field) was produced by the now-removed UxHw `sf` emulator pass; emit + # the literal 0 it carried in the SDK-absent case rather than a `?` + # sentinel that has no samples to resolve. + emit_measurement "$UXHW_TESTCASE" "?" "?" "$PROCESS_E2E_TIME" 0 "?" + + rm -f "$UXHW_DB_BASE.db" + rm -f sunflower-*.out + done + + rm -f ./*.m + cd "$APPLICATION_PATH/src" +} + +# =========================================================================== +# Benchmark: UxHw tracing (accuracy data) +# =========================================================================== + +run_uxhw_tracing() { + cd "$APPLICATION_PATH/src" + + log_section "Get accuracy data from UxHw Cores." + + write_emulator_config "$M_CONFIG_FILE_TRACING" "$TRACING_DB_ABS" "TracingTable" + + local tracing_binaries=() + local tracing_dbs=() + local tracing_configs=() + + # -O2 verification: the tracing build uses -O0 (the OPTFLAGS default in + # Makefile.pro) so the addDistValueTrace file:line directives resolve + # against unoptimised debug info, but real deployments compile at -O2. + # Optimisation must not change the values the uncertainty machinery + # computes, so we build a parallel -O2 binary per config and later check + # (warn-only) that its Ux strings byte-match the -O0 ones. TRACING_VERIFY_OPTFLAGS + # overrides the level compared against; -gdwarf-4 is kept so tracing still resolves. + local verify_optflags="${TRACING_VERIFY_OPTFLAGS:-"-O2 -gdwarf-4"}" + local verify_binaries=() + local verify_dbs=() + local verify_baseline_dbs=() + local verify_configs=() + # Configs whose -O2 build or run failed: skipped for comparison and + # reported (as failing) in the verification summary below. + local verify_failed_configs=() + + # Phase 1: Serial compilation — build each config and rename the binary + for CORRELATION_TRACKING_TYPE in "${CORRELATION_TRACKING_TYPES[@]}"; do + for REPRESENTATION_TYPE in "${REPRESENTATION_TYPES[@]}"; do + for REPRESENTATION_SIZE in "${REPRESENTATION_SIZES[@]}"; do + if should_skip_representation "$REPRESENTATION_TYPE" "$CORRELATION_TRACKING_TYPE" "$REPRESENTATION_SIZE"; then + continue + fi + + local suffix + suffix="$(uxhw_config_string "$REPRESENTATION_TYPE" "$REPRESENTATION_SIZE" "$CORRELATION_TRACKING_TYPE")" + local binary_name="$PROGRAM-tracing-$suffix" + local per_config_db="${TRACING_DB_ABS%.db}-$suffix.db" + local per_config_m="run-tracing-$suffix.m" + + # Create per-config .m file pointing to its own DB + write_emulator_config "$per_config_m" "$per_config_db" "TracingTable" + + uxhw_make clean + + local args=( + REPRESENTATION_TYPE="$REPRESENTATION_TYPE" + REPRESENTATION_SIZE="$REPRESENTATION_SIZE" + CORRELATION_TRACKING="$CORRELATION_TRACKING_TYPE" + M_CONFIG_FILE="$per_config_m" + TARGET_ARCH="$TARGET_ARCH" + ENABLE_TRACING=ON + ENABLE_UNCERTAIN_TYPE_MODIFIER="$ENABLE_UNCERTAIN_TYPE_MODIFIER" + STATS_DB_FILENAME="$per_config_db" + STATS_DB_TABLENAME="Emulator_Execution_Info" + ) + echo "Compiling $binary_name" + uxhw_make "${args[@]}" + + mv "$PROGRAM" "$binary_name" + + uxhw_make clean + + tracing_binaries+=("$binary_name") + tracing_dbs+=("$per_config_db") + tracing_configs+=("$suffix") + + # Build the -O2 verification binary for this same config, into + # its own DB, so its Ux strings can be compared against the + # -O0 baseline above. + local verify_binary_name="$binary_name-O2" + local verify_db="${TRACING_DB_ABS%.db}-$suffix-O2.db" + local verify_m="run-tracing-$suffix-O2.m" + + write_emulator_config "$verify_m" "$verify_db" "TracingTable" + + local verify_args=( + REPRESENTATION_TYPE="$REPRESENTATION_TYPE" + REPRESENTATION_SIZE="$REPRESENTATION_SIZE" + CORRELATION_TRACKING="$CORRELATION_TRACKING_TYPE" + M_CONFIG_FILE="$verify_m" + TARGET_ARCH="$TARGET_ARCH" + ENABLE_TRACING=ON + ENABLE_UNCERTAIN_TYPE_MODIFIER="$ENABLE_UNCERTAIN_TYPE_MODIFIER" + STATS_DB_FILENAME="$verify_db" + STATS_DB_TABLENAME="Emulator_Execution_Info" + OPTFLAGS="$verify_optflags" + ) + echo "Compiling $verify_binary_name (verification, OPTFLAGS='$verify_optflags')" + if uxhw_make "${verify_args[@]}" && [[ -f "$PROGRAM" ]]; then + mv "$PROGRAM" "$verify_binary_name" + verify_binaries+=("$verify_binary_name") + verify_dbs+=("$verify_db") + verify_baseline_dbs+=("$per_config_db") + verify_configs+=("$suffix") + else + warn "WARNING: verification build failed (OPTFLAGS='$verify_optflags') for config '$suffix'; skipping its Ux-string check." + verify_failed_configs+=("$suffix (build failed)") + rm -f "$verify_m" "$verify_db" "$PROGRAM" + fi + + uxhw_make clean + done + done + done + + # Phase 2: Parallel execution + local pids=() + for i in "${!tracing_binaries[@]}"; do + local binary="${tracing_binaries[$i]}" + local suffix="${tracing_configs[$i]}" + ( + if [[ -e "$APPLICATION_PATH/$INPUT_DIR" ]]; then + cd "$APPLICATION_PATH/$INPUT_DIR" + fi + echo "Tracing $binary execution" + "$APPLICATION_PATH/src/$binary" $CLA \ + 1>"$APPLICATION_PATH/src/$binary.stdout" \ + 2>"$APPLICATION_PATH/src/$binary.stderr" + ) & + pids+=($!) + done + + # Verification (-O2) executions run in their own batch so a failure here is + # a warning, not fatal, and does not trip the die() below. + local verify_pids=() + for i in "${!verify_binaries[@]}"; do + local binary="${verify_binaries[$i]}" + ( + if [[ -e "$APPLICATION_PATH/$INPUT_DIR" ]]; then + cd "$APPLICATION_PATH/$INPUT_DIR" + fi + echo "Tracing $binary execution (verification)" + "$APPLICATION_PATH/src/$binary" $CLA \ + 1>"$APPLICATION_PATH/src/$binary.stdout" \ + 2>"$APPLICATION_PATH/src/$binary.stderr" + ) & + verify_pids+=($!) + done + + # Wait for all and check for failures + local failed=0 + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + echo "Error: tracing process $pid failed" + failed=1 + fi + done + if [ "$failed" -ne 0 ]; then + die "One or more tracing executions failed" + fi + + # A failed -O2 execution is warn-only; its config is dropped from the + # verification set so the comparison below does not read a partial DB. + for i in "${!verify_pids[@]}"; do + if ! wait "${verify_pids[$i]}"; then + warn "WARNING: -O2 verification run failed for config '${verify_configs[$i]}'; skipping its Ux-string check." + verify_failed_configs+=("${verify_configs[$i]} (run failed)") + verify_dbs[$i]="" + fi + done + + cd "$APPLICATION_PATH/src" + + # Phase 2.5: Verify the -O2 Ux strings byte-match the -O0 baseline. This is + # warn-only (guarded with `|| true`) and must run before the merge below, + # which consumes (deletes) the per-config baseline DBs. + for i in "${!verify_dbs[@]}"; do + [[ -z "${verify_dbs[$i]}" ]] && continue + PYTHONPATH="$SIGNALOID_PYTHON_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \ + "${BENCHMARKING_PYTHON:-python3}" -m signaloid.benchmarking.automation.compare_tracing_ux_strings \ + "${verify_baseline_dbs[$i]}" "${verify_dbs[$i]}" \ + --baseline-label O0 --candidate-label O2 --config "${verify_configs[$i]}" || true + done + + # Report configs whose -O2 verification could not run (build or run + # failure). These are skipped for comparison but surfaced here as failing + # so an -O2 build/run regression is not silently swallowed. + if [[ ${#verify_failed_configs[@]} -gt 0 ]]; then + warn "WARNING: -O2 ux-string verification FAILED (build/run) for ${#verify_failed_configs[@]} config(s): ${verify_failed_configs[*]}" + fi + + # Phase 3: Merge per-config DBs into final tracing DB + PYTHONPATH="$SIGNALOID_PYTHON_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \ + "${BENCHMARKING_PYTHON:-python3}" -m signaloid.benchmarking.automation.merge_tracing_dbs \ + "$TRACING_DB_ABS" "${tracing_dbs[@]}" + + # Cleanup temporary binaries and config files + for i in "${!tracing_binaries[@]}"; do + rm -f "${tracing_binaries[$i]}" "${tracing_binaries[$i]}.stdout" "${tracing_binaries[$i]}.stderr" + rm -f "run-tracing-${tracing_configs[$i]}.m" + done + + # Cleanup -O2 verification artifacts. Their DBs are not consumed by the + # merge above (only the -O0 DBs are), so remove them explicitly here. + for i in "${!verify_binaries[@]}"; do + rm -f "${verify_binaries[$i]}" "${verify_binaries[$i]}.stdout" "${verify_binaries[$i]}.stderr" + rm -f "${TRACING_DB_ABS%.db}-${verify_configs[$i]}-O2.db" + rm -f "run-tracing-${verify_configs[$i]}-O2.m" + done +} + +# =========================================================================== +# Benchmark: Native MC +# =========================================================================== + +run_native_mc_benchmarks() { + cd "$APPLICATION_PATH/src" + + # Collect all C and C++ sources recursively, excluding build artifacts. + # Compile C and C++ files separately to avoid C99/C++ incompatibilities, + # then link all object files together. + local c_sources=() + mapfile -t c_sources < <(find . -name '*.c' -not -path '*/build/*') + local cxx_sources=() + local has_cxx=false + local cxx_files + mapfile -t cxx_files < <(find . \( -name '*.cc' -o -name '*.cpp' \) -not -path '*/build/*') + for f in "${cxx_files[@]}"; do + if [[ -f "$f" ]]; then + cxx_sources+=("$f") + has_cxx=true + fi + done + + local common_flags=( + "${NATIVE_CFLAGS[@]}" + -Wall -Wextra -Wpedantic + -g -O3 + -I. -I/opt/local/include + ) + local link_flags=( + -L/opt/local/lib + -lgsl -lgslcblas + -lm + -frecord-gcc-switches + ) + + local compiled=false + if $has_cxx; then + # Compile C and C++ to object files separately, then link with c++ + local obj_files=() + local compile_ok=true + for f in "${c_sources[@]}"; do + if ! cc "${common_flags[@]}" -c "$f" -o "${f%.c}.o" 2>>"$NATIVE_MC_BUILD_LOG"; then + compile_ok=false; break + fi + obj_files+=("${f%.c}.o") + done + if $compile_ok; then + for f in "${cxx_sources[@]}"; do + if ! c++ "${common_flags[@]}" -c "$f" -o "${f%.*}.o" 2>>"$NATIVE_MC_BUILD_LOG"; then + compile_ok=false; break + fi + obj_files+=("${f%.*}.o") + done + fi + if $compile_ok; then + if c++ "${obj_files[@]}" "${link_flags[@]}" -o "$PROGRAM-native" 2>>"$NATIVE_MC_BUILD_LOG"; then + compiled=true + fi + fi + # Clean up object files + rm -f "${obj_files[@]}" + else + # Pure C project — compile and link in one step + if cc "${c_sources[@]}" "${common_flags[@]}" "${link_flags[@]}" -o "$PROGRAM-native" 2>>"$NATIVE_MC_BUILD_LOG"; then + compiled=true + fi + fi + + if $compiled; then + echo "Compilation succeeded" + else + echo "Compilation failed, checking for demo-native-mc executable... (see $NATIVE_MC_BUILD_LOG)" + # Check both src/ and application root (where make local-build places it) + if [ -f "./demo-native-mc" ]; then + echo "Found demo-native-mc in src/, using it as $PROGRAM-native" + cp ./demo-native-mc "./$PROGRAM-native" + elif [ -f "$APPLICATION_PATH/demo-native-mc" ]; then + echo "Found demo-native-mc in application root, using it as $PROGRAM-native" + cp "$APPLICATION_PATH/demo-native-mc" "./$PROGRAM-native" + else + die "demo-native-mc not found either" + fi + fi + + cd_to_input_dir + + # Run the native program for Monte Carlo Mode. The extrapolation threshold + # is the largest reference precision that is directly measured and + # precisions above it are linearly extrapolated. It defaults to 1000000 but + # can be overridden via the EXTRAPOLATION_THRESHOLD environment variable to + # trade accuracy for a shorter run. + local extrapolation_threshold=${EXTRAPOLATION_THRESHOLD:-1000000} + if ! [[ "$extrapolation_threshold" =~ ^[0-9]+$ ]]; then + die "EXTRAPOLATION_THRESHOLD must be a non-negative integer, got '$extrapolation_threshold'" + fi + echo "---> Using extrapolation threshold = $extrapolation_threshold" + local last_measured_precision="" + local last_measured_time="" + local last_measured_e2e_time="" + local last_measured_instructions="" + + for REFERENCE_PRECISION in "${REFERENCE_PRECISIONS[@]}"; do + # Extrapolate for large precisions using the last measured values + if [ "$REFERENCE_PRECISION" -gt "$extrapolation_threshold" ] && [ -n "$last_measured_precision" ]; then + echo "---> Extrapolating for precision = $REFERENCE_PRECISION from $last_measured_precision" + + local scaling_factor + scaling_factor=$(awk -v ref="$REFERENCE_PRECISION" -v prev="$last_measured_precision" \ + 'BEGIN { printf "%.10f", ref / prev }') + local result + result=$(awk -v time_val="$last_measured_time" -v scale="$scaling_factor" \ + 'BEGIN { printf "%.10f", time_val * scale }') + local process_e2e_time + process_e2e_time=$(awk -v e2e_time="$last_measured_e2e_time" -v scale="$scaling_factor" \ + 'BEGIN { printf "%.10f", e2e_time * scale }') + local pin_dynamic_instructions + pin_dynamic_instructions=$(awk -v instructions="$last_measured_instructions" -v scale="$scaling_factor" \ + 'BEGIN { printf "%.10f", instructions * scale }') + + emit_measurement "Native-MC-$REFERENCE_PRECISION" "$result" "?" "$process_e2e_time" "?" "$pin_dynamic_instructions" + continue + fi + + echo "---> Timing Native-MC-$REFERENCE_PRECISION" + + local test_time + test_time=$("$APPLICATION_PATH/src/$PROGRAM-native" $CLA $CLA_FOR_MULTIPLE_EXECUTIONS "$REFERENCE_PRECISION" | grep -oP 'CPU time used: \K[0-9.]+ seconds' | awk '{print $1}') + + # The native binary must print a "CPU time used: seconds" line for + # timing to work. An empty/zero test_time would divide by zero below. + # This fails with a clear message (e.g. a non-conforming or failed + # native build that fell back to a stale prebuilt binary). + if [[ -z "$test_time" ]] || ! awk -v t="$test_time" 'BEGIN { exit !(t > 0) }'; then + die "Native-MC timing: '$PROGRAM-native' produced no parseable 'CPU time used: seconds' line (got: '${test_time:-}'). Check $LOGS_DIR/native-mc-build.log and confirm the native binary prints its CPU time." + fi + # Scale repetitions based on inverse of single sample run time + local const_time=10 + NATIVE_MC_REPETITION=$(echo "$const_time $test_time" | awk '{result = $1/$2; print int(result) + (result > int(result))}') + NATIVE_MC_REPETITION=$((NATIVE_MC_REPETITION < 400 ? NATIVE_MC_REPETITION : 400)) + + echo "Running timing measurements" + local time_array="[" + for ((i = 1; i <= NATIVE_MC_REPETITION; i++)); do + [ -t 1 ] && echo -ne "\rRepetitions ($i/$NATIVE_MC_REPETITION)" + local time_val + time_val=$("$APPLICATION_PATH/src/$PROGRAM-native" $CLA $CLA_FOR_MULTIPLE_EXECUTIONS "$REFERENCE_PRECISION" | grep -oP 'CPU time used: \K[0-9.]+ seconds' | awk '{print $1}') + emit_sample "Native-MC-$REFERENCE_PRECISION" "elapsedTime" "$i" "$time_val" + time_array+="$time_val," + done + time_array+="]" + echo + + rm -f inscount.out + + # Use at most 20 repetitions for dynamic instruction count. + local native_mc_repetition_pin=$((NATIVE_MC_REPETITION < 20 ? NATIVE_MC_REPETITION : 20)) + local native_mc_config="Native-MC-$REFERENCE_PRECISION" + # Tracked locally so larger precisions in the same loop can + # extrapolate from this row (Python is the source of truth for + # the JSON field via SAMPLE lines; this average is bash-only). + local pin_dyn_inst_array="[" + echo "Running dynamic instruction measurements" + for ((i = 1; i <= native_mc_repetition_pin; i++)); do + [ -t 1 ] && echo -ne "\rRepetitions ($i/$native_mc_repetition_pin)" + $INSTRUCTION_COUNT_COMMAND "$APPLICATION_PATH/src/$PROGRAM-native" $CLA $CLA_FOR_MULTIPLE_EXECUTIONS "$REFERENCE_PRECISION" &>/dev/null + if [[ ! -f inscount.out ]]; then + die "inscount.out not found after PIN execution" + fi + pin_dyn_inst_array+=$(sed 's/Count //' inscount.out) + pin_dyn_inst_array+="," + local native_mc_sample_path="$LOGS_DIR/inscount-native-mc-$REFERENCE_PRECISION-$i-$$.out" + mv inscount.out "$native_mc_sample_path" + emit_sample "$native_mc_config" "pinDynInstCount" "$i" "$native_mc_sample_path" + done + echo + pin_dyn_inst_array+="]" + + local process_e2e_time + process_e2e_time=$(run_hyperfine_mean "$APPLICATION_PATH/src/$PROGRAM-native $CLA $CLA_FOR_MULTIPLE_EXECUTIONS $REFERENCE_PRECISION") + + # Time and PIN instruction count are both resolved by Python + # from the SAMPLE lines emitted above. The `?` sentinels trigger + # resolution via the accumulated elapsedTime and pinDynInstCount + # samples for this config. + emit_measurement "$native_mc_config" "?" "?" "$process_e2e_time" "?" "?" + + # Store values for potential extrapolation. The bash-side average + # is computed here solely to populate last_measured_time, which + # feeds the awk-based extrapolation arithmetic above. Python + # independently computes the authoritative average from SAMPLE lines. + last_measured_precision=$REFERENCE_PRECISION + # B1 / B5 boundary: this `compute_average` callsite stays in bash + # because the awk-based extrapolation block above (parallel to B5's + # awk-stays-in-bash decision) consumes the average inline as control + # flow input. Do not migrate to SAMPLE: the JSON field is already + # authoritative via the SAMPLE lines emitted above. + last_measured_time=$(compute_average "$time_array") + last_measured_e2e_time=$process_e2e_time + # B1 / B5 boundary: same reasoning as last_measured_time above. + last_measured_instructions=$(compute_average "$pin_dyn_inst_array") + done +} + +# =========================================================================== +# Main +# =========================================================================== + +main() { + echo ============================================================================== + echo Initialising... + + parse_args "$@" + + # OUTPUT DIRECTORIES + RESULTS_DIR="${RESULTS_DIR:-$APPLICATION_PATH/src}" + LOGS_DIR="${LOGS_DIR:-$APPLICATION_PATH/src}" + mkdir -p "$RESULTS_DIR" "$LOGS_DIR" + + # FILES + INIT_S=init.S + STARTUP_CPP=startup.cpp + UXHW_MAKEFILE=Makefile.pro + MAKEFILE=Makefile + UXHW_DB_BASE=signaloidUxHwExecutionStatistics + EXEC_STDOUT=exec.stdout + EXEC_STDERR=exec.stderr + UXHW_BUILD_LOG="$LOGS_DIR/uxhw-build.log" + NATIVE_MC_BUILD_LOG="$LOGS_DIR/native-mc-build.log" + + TARGET_ARCH=x86_64-unknown-linux-gnu + M_CONFIG_FILE=file.m + + REPETITION=20 + NATIVE_MC_REPETITION=20 + + TIMING_TARGET_TOTAL_TIME=${TIMING_TARGET_TOTAL_TIME:-30} + TIMING_MIN_REPETITIONS=${TIMING_MIN_REPETITIONS:-2} + TIMING_MAX_REPETITIONS=${TIMING_MAX_REPETITIONS:-20} + + # Set defaults if not defined + REQUIRED_UXHW_SDK_VERSION=${REQUIRED_UXHW_SDK_VERSION:-'"1.1.10-icelake-server"'} + + SKIP_UXHW=${SKIP_UXHW:-0} + SKIP_NATIVE_MC=${SKIP_NATIVE_MC:-1} + SKIP_UXHW_TRACING=${SKIP_UXHW_TRACING:-1} + APPEND_TO_OUTPUT_FILE=${APPEND_TO_OUTPUT_FILE:-0} + MAX_JUPITER_LIMIT=${MAX_JUPITER_LIMIT:-32} + ENABLE_UNCERTAIN_TYPE_MODIFIER=${ENABLE_UNCERTAIN_TYPE_MODIFIER:-OFF} + + default_array REPRESENTATION_TYPES Athens + default_array REPRESENTATION_SIZES 16 32 64 128 256 512 + default_array CORRELATION_TRACKING_TYPES Disabled Autocorrelation + + [[ -f "$SCRIPT_DIR/get_timings_local.sh" ]] && source "$SCRIPT_DIR/get_timings_local.sh" + + validate_required_vars + + ORIG_PWD=$(pwd) + RESOURCES_DIR="${BENCHMARKING_RESOURCES_DIR:-$SCRIPT_DIR/../../assets/template/coreClass}" + + CLA_HASH=$(echo -n "$CLA" | md5sum | cut -d ' ' -f1) + + # The tracing database name contains the application version hash and a + # hash generated by the CLA used to generate the database. + TRACING_DB="$UXHW_DB_BASE-$APPLICATION_VERSION-$CLA_HASH-tracing" + TRACING_DB_ABS="${TRACING_DB_ABS:-$RESULTS_DIR/$TRACING_DB.db}" + M_CONFIG_FILE_TRACING=run-tracing.m + + # Python owns the canonical output path and passes the intermediate + # file path here via $TIMING_INTERMEDIATE_FILE. Fall back to + # $RESULTS_DIR if the env var is unset so the script remains + # runnable via get-timing-template.sh. + if [[ -z "${TIMING_INTERMEDIATE_FILE:-}" ]]; then + TIMING_INTERMEDIATE_FILE="$RESULTS_DIR/$APPLICATION_NAME-$APPLICATION_VERSION-timings.intermediate" + fi + + # Get timings for the UxHw (UxHw) cores. + cd "$APPLICATION_PATH/src" + + # Stage build resources (Makefile, startup, etc.) into the application's + # src/ before any step that consumes them. On a fresh checkout the + # application does not ship these files; they live in $RESOURCES_DIR. + copy_build_resources + + write_emulator_config "$M_CONFIG_FILE" "timing-dummy.db" "TimingTable" + + if [[ $APPEND_TO_OUTPUT_FILE -eq 0 ]]; then + rm -f "$TIMING_INTERMEDIATE_FILE" + fi + + write_timings_header + + # Set PATH_TO_UXHW_SDK in Makefile.uxhw + PATH_TO_UXHW_SDK_ALT=$(echo "$PATH_TO_UXHW_SDK" | sed 's#/#\\/#g') + sed -i 's/^PATH_TO_UXHW_SDK\s*.*/PATH_TO_UXHW_SDK='"$PATH_TO_UXHW_SDK_ALT"'/g' "$UXHW_MAKEFILE" + + INPUT_DIR="inputs" + echo "\$INPUT_DIR is $INPUT_DIR" + + # PIN_ROOT must be provided: the Python tool exports it from + # --path-to-pin, or set it in the environment for a standalone run. + # There is no built-in default (the check below fails clearly if unset). + PIN_ROOT="${PIN_ROOT:-}" + PIN_TOOL="$PIN_ROOT/source/tools/ManualExamples/obj-intel64/inscount0.so" + INSTRUCTION_COUNT_COMMAND="$PIN_ROOT/pin -t $PIN_TOOL --" + + # Check Pin tool availability for benchmarks that need it + if [[ $SKIP_UXHW -eq 0 ]] || [[ $SKIP_NATIVE_MC -eq 0 ]]; then + if [[ ! -x "$PIN_ROOT/pin" ]]; then + die "Intel Pin not found at '$PIN_ROOT/pin'. Set PIN_ROOT (or pass --path-to-pin) to a Pin kit directory containing the 'pin' binary." + fi + if [[ ! -f "$PIN_TOOL" ]]; then + die "Pin inscount0 tool not found at $PIN_TOOL. Build it with: make -C $PIN_ROOT/source/tools/ManualExamples obj-intel64/inscount0.so" + fi + fi + + # Run benchmark sections + if [[ $SKIP_UXHW -eq 0 ]]; then + run_uxhw_benchmarks + fi + + if [[ $SKIP_UXHW_TRACING -eq 0 ]]; then + run_uxhw_tracing + fi + + if [[ $SKIP_NATIVE_MC -eq 0 ]]; then + run_native_mc_benchmarks + fi + + cd "$ORIG_PWD" +} + +main "$@" diff --git a/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py b/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py new file mode 100644 index 0000000..e69ffcc --- /dev/null +++ b/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Regression coverage for the standalone-bash usage path. + +bash.s ``validate_required_vars`` requires non-empty +APPLICATION_NAME and APPLICATION_VERSION. The template must +therefore populate both before sourcing ``get-timings.sh``, even +when APPLICATION_PATH is not a git repository. +""" + +import os +import subprocess +import tempfile +import unittest + +from pathlib import Path + +# The template now lives adjacent to this test in the relocated package. +_TEMPLATE = Path(__file__).resolve().parent / "get-timing-template.sh" + + +def _source_template(application_path: Path, signaloid_python_dir: Path) -> str: + """Source the template and echo APPLICATION_NAME / APPLICATION_VERSION. + + Returns combined stdout. Tests parse it with simple substring checks. + """ + cmd = ( + f"source {_TEMPLATE} && " + 'echo "NAME=$APPLICATION_NAME" && ' + 'echo "VERSION=$APPLICATION_VERSION"' + ) + result = subprocess.run( + ["bash", "-c", cmd], + env={ + "SIGNALOID_PYTHON_DIR": str(signaloid_python_dir), + "APPLICATION_PATH": str(application_path), + "PATH": os.environ["PATH"], + "HOME": os.environ.get("HOME", ""), + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f"template sourcing failed: stderr={result.stderr!r}" + return result.stdout + + +class TestGetTimingTemplate(unittest.TestCase): + """The template populates APPLICATION_NAME / APPLICATION_VERSION even + when APPLICATION_PATH is not a git repository.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + self.stub_signaloid_python_dir = self._make_stub_signaloid_python_dir() + + def _make_stub_signaloid_python_dir(self) -> Path: + """Provide a stub SIGNALOID_PYTHON_DIR with a no-op get-timings.sh. + + The template ends by sourcing + ``$SIGNALOID_PYTHON_DIR/src/signaloid/benchmarking/benchmark_timing/get-timings.sh``. + For these tests we only care about the variable assignments at + the top of the template, so we point SIGNALOID_PYTHON_DIR at a + fake tree whose get-timings.sh is empty. + """ + fake_get_timings = ( + self.tmp_path + / "src" + / "signaloid" + / "benchmarking" + / "benchmark_timing" + / "get-timings.sh" + ) + fake_get_timings.parent.mkdir(parents=True) + fake_get_timings.write_text("# no-op stub for tests\n") + return self.tmp_path + + def test_template_application_name_is_derived_from_path(self) -> None: + app_path = self.tmp_path / "Signaloid-Demo-MyDemo" + app_path.mkdir() + out = _source_template(app_path, self.stub_signaloid_python_dir) + self.assertIn("NAME=MyDemo", out) + + def test_template_application_version_is_non_empty_for_non_git_path( + self, + ) -> None: + app_path = self.tmp_path / "demo" + app_path.mkdir() + out = _source_template(app_path, self.stub_signaloid_python_dir) + version_line = next( + line for line in out.splitlines() if line.startswith("VERSION=") + ) + version = version_line.removeprefix("VERSION=") + self.assertTrue(version, "expected non-empty APPLICATION_VERSION") + self.assertRegex( + version, + r"^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/config.py b/src/signaloid/benchmarking/config.py new file mode 100644 index 0000000..02722cf --- /dev/null +++ b/src/signaloid/benchmarking/config.py @@ -0,0 +1,356 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +from enum import Enum +from pathlib import Path + +# Default location of the UxHw SDK checkout. Both the ``--path-to-uxhw-sdk`` +# CLI flag and the ``Benchmark`` constructor fall back to this, so the default +# is defined in exactly one place. +DEFAULT_UXHW_SDK_PATH = "~/project-uxhw-sdk" + +# Shared numeric defaults for the benchmarking + ground-truth tools, used by +# both the ``Benchmark`` constructor and the argparse defaults. +DEFAULT_GROUND_TRUTH_SIZE = 1_000_000 +DEFAULT_ADVERSARY_MC_SIZE = 1_000_000 +DEFAULT_MAX_NUM_WEIGHTED_SAMPLES = 1_000_000 +DEFAULT_NUM_ADVERSARIES = 100 +DEFAULT_MAX_JUPITER_SIZE = 32 +DEFAULT_ADVERSARY_MAX_SIZE_SCALAR = 100_000 + + +class VariableTypes: + DISTRIBUTION = "Distribution" + SCALAR = "Scalar" + + +class CoreLibraryRepresentationTypes(Enum): + """ + Subset of the core library's ``UxHwCoreRepresentationType`` enum: the + benchmarked representation types plus ``NoUncertaintyTracking``. + + The integer values mirror the core library and must not be renumbered. + """ + + ATHENS = 4 + JUPITER = 6 + ATLAS = 7 + EUROPA = 8 + NO_UNCERTAINTY_TRACKING = 9 + + +class RepresentationTypes: + """ + String names for the representation types used across benchmarking: the + core-library types (Athens, Jupiter, Atlas, Europa) and the Monte-Carlo / + ground-truth types (WeightedSamples, Samples, MonteCarlo). + """ + + # Representation types supported by the core library and benchmarked here. + ATHENS = "Athens" + JUPITER = "Jupiter" + ATLAS = "Atlas" + EUROPA = "Europa" + + # The UxHw SDK writes the canonical representation strings + # ("Athens"/"Atlas"/"Jupiter"/"Europa") directly to the DB UR_Type column, + # so no name translation is needed and these maps stay empty. + TO_UXHW_DB: dict[str, str] = {} + FROM_UXHW_DB = {v: k for k, v in TO_UXHW_DB.items()} + + @classmethod + def to_uxhw_db(cls, rep_type: str) -> str: + """Convert a representation type to its UxHw Database name.""" + return cls.TO_UXHW_DB.get(rep_type, rep_type) + + @classmethod + def from_uxhw_db(cls, uxhw_name: str) -> str: + """Convert a UxHw Database name back to the standard representation type.""" + return cls.FROM_UXHW_DB.get(uxhw_name, uxhw_name) + + # Ground-truth / Monte-Carlo representation types. + WEIGHTED_SAMPLES = "WeightedSamples" + SAMPLES = "Samples" + MONTE_CARLO = "MonteCarlo" + + +CORE_TO_STRING_REPRESENTATION = { + CoreLibraryRepresentationTypes.ATHENS.value: RepresentationTypes.ATHENS, + CoreLibraryRepresentationTypes.JUPITER.value: RepresentationTypes.JUPITER, + CoreLibraryRepresentationTypes.ATLAS.value: RepresentationTypes.ATLAS, +} + +STRING_TO_CORE_REPRESENTATION = {v: k for k, v in CORE_TO_STRING_REPRESENTATION.items()} + + +class ReportingMethods: + MEAN = "Mean" + QUANTILE_95 = "Quantile-95" + QUANTILE_99 = "Quantile-99" + + +class ReportingNumbers: + QUANTILE_95 = 0.95 + QUANTILE_99 = 0.99 + + +class Correlations: + DISABLED = "Disabled" + AUTOCORRELATION = "Autocorrelation" + + +class DistanceMetrics: + WASSERSTEIN_1 = "Wasserstein-1" + WASSERSTEIN_2 = "Wasserstein-2" + BINNED_WASSERSTEIN_1 = "Binned_Wasserstein-1" + + +class BenchmarkingVariables: + VARIABLE = "Variable" + VARIABLE_TYPE = "Variable Type" + VARIABLE_DESCRIPTION = "Variable Description" + UXHW_CONF = "UxHw Conf" + UXHW_DISTANCE = "UxHw Distance" + UXHW_BINNED_DISTANCE = "Binned UxHw Distance" + # Human-readable reason a UxHw configuration's representation blew up. + # ``None`` / NaN means healthy. A non-empty string marks the row as + # degraded so it is flagged in the report rather than dropped (dropping it + # would break the full-matrix join in measurement_loader). In-memory only: + # not written to the CSVs, so it does not survive a re-load. + BLOW_UP_REASON = "Blow-Up Reason" + + +class EquivMC: + EMCC = "EMCC" + EMCC_PREDICTED = "EMCC Predicted" + REPORTING_METHOD = "Reporting Method" + DISTANCE_TYPE = "Distance Type" + PERCENTAGE_MC_BEATS_UXHW = "% MC beats UxHw" + MEAN_QUANTILE = "Mean Quantile" + BASIS_POINT_CONVERSION_FACTOR = 10_000 + TRACING_TABLE = "TracingTable" + # Filename the native binary writes its Monte Carlo samples to, + # relative to its working directory (see the demo's ``common.c``). + MC_OUTPUT_FILENAME = "data.out" + + +class SignaloidYaml: + TRACE_VARIABLES = "TraceVariables" + BENCHMARKING_VARIABLES = "BenchmarkingVariables" + FILE = "File" + EXPRESSION = "Expression" + LINE_NUMBER = "LineNumber" + ALL_OUTPUTS = "BenchmarkingAllOutputs" + VARIABLE_NAME = "VariableName" + VARIABLE_DESCRIPTION = "VariableDescription" + OUTPUT_OBJECT = "OutputObject" + COMMAND_LINE_ARGUMENTS = "CommandLineArguments" + + +class AsymptoticDistanceDistribution: + IS_NORMAL = "Is normal" + SCALE = "Scale" + + +class Measurements: + NATIVE_IN_APP_TIME = "Native In Application Time" + DB_TIME = "Database Time" + SPEEDUP = "Speedup" + IN_APP_TIME = "In Application Time" + E2E_TIME = "End-to-End Time" + NATIVE_E2E_TIME = "Native End-to-End Time" + PIN_DYN_COUNT = "PIN Dyn. Inst. Count" + DB_DYN_COUNT = "Database Dyn. Inst. Count" + NATIVE_PIN_COUNT = "Native PIN Dyn. Inst. Count" + + +class ReportSheetTabs: + """Worksheet tab names in the benchmarking-report Google Sheets template. + + ``report_writer`` looks worksheets up by these names (and the Plot Triptych + write targets one by name) instead of by positional index, so the tool + survives the template being reordered or having tabs added / removed. + + The values MUST match the tab names in the Drive template + (``UXHW_SHEETS_TEMPLATE_ID``) exactly. See the automation README's Google + Sheets section for the template link and how to read the exact tab names. + + Only ``ASSUMPTIONS_CONFIG``, ``TIMING_PERFORMANCE`` (duplicated per + reporting method), and ``PLOT_TRIPTYCH`` are written by the tool. The rest + are listed for completeness and are populated manually / statically. + """ + + ASSUMPTIONS_CONFIG = "Assumptions, Configuration, and Terminology" + SUMMARY_GITHUB = "Summary for Copying into GitHub" + TIMING_PERFORMANCE = "Timing Performance" + PLOT_TRIPTYCH = "Plot Triptych" + C_CODE_SIZE = "C Code Size Estimate" + NRE = "Non-Recurring Engineering (NRE) Monetary Cost" + + +class MetadataRowLabels: + """Column-A labels on the ``ReportSheetTabs.ASSUMPTIONS_CONFIG`` sheet whose + column B the uploader fills in. + + Each target row is matched by a case-insensitive substring search for these + strings. Each constant holds only the stable part of the label, since the + template adds a list-number prefix and trailing descriptive text. + """ + + GITHUB_REPOSITORY = "GitHub Repository" + GIT_HASH = "Git Hash" + UXHW_SDK_VERSION = "UxHw SDK version" + MACHINE_TYPE = "Machine Type" + + +class TimingFormat: + """ + Wire format shared between get-timings.sh (which emits tagged lines into a + transient intermediate file) and the Python reader (which parses them and + writes the canonical JSON artifact). + + The canonical output is one JSON document per benchmark session: a top-level + object with the session-invariant fields (application identity, SDK version, + target repetition count) plus a ``runs`` array of per-run records, each with + its own timestamp, command-line arguments, and ``measurements`` array. + + These constants reach bash via environment variables set in + ``signaloid.benchmarking.automation.build.run_timing_script``, so this class + is the single source of truth for both sides. + """ + + # Tags prefixed onto each line of the intermediate file. + META_TAG = "META" + MEASUREMENT_TAG = "MEASUREMENT" + # SAMPLE lines carry per-iteration raw values that Python averages + # during parse. Format: + # SAMPLE + # where the 4th token is a literal float (value-typed variant, used + # for elapsed time) or a file path (path-typed variant, used for + # PIN instruction counts). + SAMPLE_TAG = "SAMPLE" + + # Names of the environment variables used to pass the tag strings + # and the intermediate file path to the bash script. + META_TAG_ENV_VAR = "TIMING_META_TAG" + MEASUREMENT_TAG_ENV_VAR = "TIMING_MEASUREMENT_TAG" + SAMPLE_TAG_ENV_VAR = "TIMING_SAMPLE_TAG" + INTERMEDIATE_FILE_ENV_VAR = "TIMING_INTERMEDIATE_FILE" + + # Field names used in SAMPLE lines to identify the measured + # quantity. These are the values emitted as the second token of a + # SAMPLE line by the bash script. Value-typed (literal float) + # fields: elapsedTime, databaseTime, databaseDynInstCount. + # Path-typed (file path) field: pinDynInstCount (declared below + # near the JSON keys for grouping reasons). + SAMPLE_FIELD_TIME = "elapsedTime" + SAMPLE_FIELD_DB_TIME = "databaseTime" + SAMPLE_FIELD_DB_DYN_INST_COUNT = "databaseDynInstCount" + + # Filename suffixes appended to "-". + INTERMEDIATE_SUFFIX = "-timings.intermediate" + JSON_SUFFIX = "-timings.json" + + # Keys emitted as `META ` lines in the intermediate. + META_KEY_TIMESTAMP = "timestamp" + META_KEY_APPLICATION_NAME = "applicationName" + META_KEY_APPLICATION_VERSION = "applicationVersion" + META_KEY_UXHW_SDK_VERSION = "uxhwSdkVersion" + META_KEY_COMMAND_LINE_ARGUMENTS = "commandLineArguments" + META_KEY_COMMAND_LINE_ARGUMENTS_HASH = "commandLineArgumentsHash" + # Configured UxHw repetition target at session start. The per-measurement + # count can differ (get-timings.sh rescales ``REPETITION``), so treat this + # as the session target, not a per-run ground truth. + META_KEY_UXHW_TARGET_REPETITIONS = "uxhwTargetRepetitions" + + # META keys whose values are invariant across every run in a + # benchmarking session. The Python reader lifts these out of the + # per-run dicts and places them at the top level of the JSON + # document, validating consistency across runs in the process. + SESSION_META_KEYS = ( + META_KEY_APPLICATION_NAME, + META_KEY_APPLICATION_VERSION, + META_KEY_UXHW_SDK_VERSION, + META_KEY_UXHW_TARGET_REPETITIONS, + ) + + # Field names in the canonical JSON document. Non-session META + # keys carry over verbatim into each run record. + JSON_KEY_RUNS = "runs" + JSON_KEY_MEASUREMENTS = "measurements" + JSON_KEY_MEASUREMENT_CONFIG = "config" + JSON_KEY_MEASUREMENT_TIME = "time" + JSON_KEY_MEASUREMENT_DB_TIME = "dbTime" + JSON_KEY_MEASUREMENT_E2E_TIME = "e2eTime" + JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT = "dbDynInstCount" + JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT = "pinDynInstCount" + + # Field name for PIN dynamic instruction count samples emitted via + # SAMPLE lines (used as the token in the wire format). + SAMPLE_FIELD_PIN_INST = "pinDynInstCount" + + # Sentinel used in the intermediate for missing numeric fields + # (e.g. database time is not measured for native runs). + MISSING_VALUE = "?" + + +def get_repo_root() -> Path: + """ + Find the repository root. + + Checks the SIGNALOID_PYTHON_DIR env var first, then walks upward from + this file looking for pyproject.toml. + + Returns: + The repository root directory. + + Raises: + RuntimeError: If the repository root cannot be located. + """ + env_override = os.environ.get("SIGNALOID_PYTHON_DIR") + if env_override: + p = Path(env_override).resolve() + if p.is_dir(): + return p + + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "pyproject.toml").exists(): + return current + current = current.parent + + raise RuntimeError( + "Could not find repository root. Set SIGNALOID_PYTHON_DIR or run from a repo checkout." + ) + + +def get_resources_dir() -> Path: + """ + Path to the bundled coreClass build-template assets. + + ``assets/`` is a verbatim vendored copy of the build-template assets. + Resolved relative to this package, so it works from both a source checkout + and an installed wheel. + + Returns: + The ``assets/template/coreClass`` directory inside this package. + """ + return Path(__file__).parent / "assets" / "template" / "coreClass" diff --git a/src/signaloid/benchmarking/configs/athens.yaml b/src/signaloid/benchmarking/configs/athens.yaml new file mode 100644 index 0000000..e69a067 --- /dev/null +++ b/src/signaloid/benchmarking/configs/athens.yaml @@ -0,0 +1,30 @@ +# configs/athens.yaml — benchmarking_automation sweep, Athens only. +# Pass with: +# benchmarking_automation --config configs/athens.yaml \ +# --path-to-application + +# Experiment matrix (Athens across the full order range). +representation_types: [Athens] +representation_sizes: [16, 32, 64, 128, 256, 512] +correlations: [Disabled, Autocorrelation] +reporting_methods: [Mean, Quantile-95, Quantile-99] + +# Distance / EMCC settings. +# NOTE: keys are argparse dest names, not CLI flag spellings — +# --num-adversaries has dest n_adversaries, so the key is n_adversaries. +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: true +n_adversaries: 100 + +# Ground-truth and adversary sizing. +ground_truth_size: 1000000 +ground_truth_type: MonteCarlo +adversary_mc_size: 1000000 +adversary_max_size_scalar: 100000 +max_num_weighted_samples: 1 + +# Plotting controls (all default off — enable only what you need). +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false diff --git a/src/signaloid/benchmarking/configs/europa.yaml b/src/signaloid/benchmarking/configs/europa.yaml new file mode 100644 index 0000000..0246d52 --- /dev/null +++ b/src/signaloid/benchmarking/configs/europa.yaml @@ -0,0 +1,30 @@ +# configs/europa.yaml — benchmarking_automation sweep, Europa only. +# Pass with: +# benchmarking_automation --config configs/europa.yaml \ +# --path-to-application + +# Experiment matrix (Europa across the full order range). +representation_types: [Europa] +representation_sizes: [16, 32, 64, 128, 256, 512] +correlations: [Disabled, Autocorrelation] +reporting_methods: [Mean, Quantile-95, Quantile-99] + +# Distance / EMCC settings. +# NOTE: keys are argparse dest names, not CLI flag spellings — +# --num-adversaries has dest n_adversaries, so the key is n_adversaries. +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: true +n_adversaries: 100 + +# Ground-truth and adversary sizing. +ground_truth_size: 1000000 +ground_truth_type: MonteCarlo +adversary_mc_size: 1000000 +adversary_max_size_scalar: 100000 +max_num_weighted_samples: 1 + +# Plotting controls (all default off — enable only what you need). +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false diff --git a/src/signaloid/benchmarking/configs/full-sweep.yaml b/src/signaloid/benchmarking/configs/full-sweep.yaml new file mode 100644 index 0000000..94ff4c9 --- /dev/null +++ b/src/signaloid/benchmarking/configs/full-sweep.yaml @@ -0,0 +1,35 @@ +# configs/full-sweep.yaml — benchmarking_automation experiment matrix. +# Pass with: +# benchmarking_automation --config configs/full-sweep.yaml \ +# --path-to-application +# +# Per-demo / per-box values (paths, --path-to-application, +# --google-credentials, -j) are intentionally left out — keep those on +# the CLI or in a machine-local config. + +# Experiment matrix (the four sweep dimensions). +representation_types: [Athens, Atlas, Jupiter, Europa] +representation_sizes: [16, 32, 64, 128, 256, 512] +correlations: [Disabled, Autocorrelation] +reporting_methods: [Mean, Quantile-95, Quantile-99] + +# Distance / EMCC settings. +# NOTE: keys are argparse dest names, not CLI flag spellings — +# --num-adversaries has dest n_adversaries, so the key is n_adversaries. +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: true +n_adversaries: 100 + +# Ground-truth and adversary sizing. +ground_truth_size: 1000000 +ground_truth_type: MonteCarlo +adversary_mc_size: 1000000 +adversary_max_size_scalar: 100000 +max_num_weighted_samples: 1 +max_jupiter_size: 32 + +# Plotting controls (all default off — enable only what you need). +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false diff --git a/src/signaloid/benchmarking/configs/jupiter.yaml b/src/signaloid/benchmarking/configs/jupiter.yaml new file mode 100644 index 0000000..1555b3d --- /dev/null +++ b/src/signaloid/benchmarking/configs/jupiter.yaml @@ -0,0 +1,36 @@ +# configs/jupiter.yaml — benchmarking_automation sweep, Jupiter only. +# Pass with: +# benchmarking_automation --config configs/jupiter.yaml \ +# --path-to-application + +# Experiment matrix. Jupiter order is kept <= 32: the per-dimension +# order is bounded by max_jupiter_size below, and the swept sizes are +# capped to match, since Jupiter representations blow up at higher orders. +representation_types: [Jupiter] +representation_sizes: [8, 16, 32] +correlations: [Disabled, Autocorrelation] +reporting_methods: [Mean, Quantile-95, Quantile-99] + +# Per-dimension Jupiter order cap (sets MAX_JUPITER_LIMIT at +# build time). Keep in lockstep with the largest representation_sizes value. +max_jupiter_size: 32 + +# Distance / EMCC settings. +# NOTE: keys are argparse dest names, not CLI flag spellings — +# --num-adversaries has dest n_adversaries, so the key is n_adversaries. +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: true +n_adversaries: 100 + +# Ground-truth and adversary sizing. +ground_truth_size: 1000000 +ground_truth_type: MonteCarlo +adversary_mc_size: 1000000 +adversary_max_size_scalar: 100000 +max_num_weighted_samples: 1 + +# Plotting controls (all default off — enable only what you need). +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false diff --git a/src/signaloid/benchmarking/configs/quick.yaml b/src/signaloid/benchmarking/configs/quick.yaml new file mode 100644 index 0000000..24565a5 --- /dev/null +++ b/src/signaloid/benchmarking/configs/quick.yaml @@ -0,0 +1,29 @@ +# configs/quick.yaml — small, fast benchmarking_automation matrix. +# A minimal sweep for smoke-tests and local iteration. +# Pass with: +# benchmarking_automation --config configs/quick.yaml \ +# --path-to-application + +# Experiment matrix (small). +representation_types: [Athens] +representation_sizes: [16, 64] +correlations: [Disabled] +reporting_methods: [Mean] + +# Distance / EMCC settings. +# NOTE: keys are argparse dest names, not CLI flag spellings — +# --num-adversaries has dest n_adversaries, so the key is n_adversaries. +distance_type: Wasserstein-1 +use_binned_uxhw: true +use_clt: false +n_adversaries: 10 + +# Ground-truth and adversary sizing (small for speed). +ground_truth_size: 10000 +ground_truth_type: MonteCarlo +adversary_mc_size: 10000 + +# Plotting controls (all default off). +plot_distance_vs_asymptotic: false +plot_adversary_distances: false +plot_representative_mc: false diff --git a/src/signaloid/benchmarking/distribution_helpers/__init__.py b/src/signaloid/benchmarking/distribution_helpers/__init__.py new file mode 100644 index 0000000..d19d6c3 --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Benchmarking-internal distribution helpers. + +This package is internal to ``signaloid.benchmarking`` and is not part of the +public API. Modules and functions may change or be removed without notice. +""" + +__all__: list[str] = [] diff --git a/src/signaloid/benchmarking/distribution_helpers/collapse.py b/src/signaloid/benchmarking/distribution_helpers/collapse.py new file mode 100644 index 0000000..558345a --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/collapse.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Collapse a distribution to a small asymptotically-optimal Dirac-delta set. + +Benchmarking-internal helper (not part of the public API). Builds a reduced +Wasserstein-1-optimal representation via the local ``quantization`` routines. +""" + +from signaloid.distributional.dirac_delta import DiracDelta +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.distribution_helpers.quantization import ( + _asymptotically_optimal_wasserstein_p_representation, +) + + +def _collapse_asymptotically_optimal_w1( + dist: DistributionalValue, *, n_dirac_deltas: int +) -> DistributionalValue: + """ + Collapse a distribution to ``n_dirac_deltas`` Dirac deltas, asymptotically + optimally with respect to the Wasserstein-1 distance. + + It sorts the distribution and delegates to the + ``_asymptotically_optimal_wasserstein_p_representation`` algorithm (with + ``p=1``). + + Args: + dist: The distribution to collapse. Mutated in place by ``sort()``. + n_dirac_deltas: The number of Dirac deltas in the collapsed + representation. + + Returns: + A new ``DistributionalValue`` with ``n_dirac_deltas`` Dirac deltas + approximating ``dist``. + + Raises: + ValueError: If ``n_dirac_deltas`` is less than 2. The underlying + algorithm indexes the second-and-later collapsed positions, so + fewer than two points is undefined (it would otherwise raise an + opaque ``IndexError``). + """ + if n_dirac_deltas < 2: + raise ValueError( + "n_dirac_deltas must be >= 2 for asymptotically-optimal-W1 collapse" + ) + dist.sort() + positions, masses = _asymptotically_optimal_wasserstein_p_representation( + dist.positions, dist.masses, n_dirac_deltas=n_dirac_deltas, p=1 + ) + dirac_deltas = [ + DiracDelta(position=pos, mass=mass) for pos, mass in zip(positions, masses) + ] + return DistributionalValue(dirac_deltas=dirac_deltas) diff --git a/src/signaloid/benchmarking/distribution_helpers/collapse_test.py b/src/signaloid/benchmarking/distribution_helpers/collapse_test.py new file mode 100644 index 0000000..ed24f7d --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/collapse_test.py @@ -0,0 +1,73 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +import numpy as np + +from signaloid.benchmarking.distribution_helpers.collapse import ( + _collapse_asymptotically_optimal_w1, +) +from signaloid.distributional.distributional import DistributionalValue + + +def _uniform_distribution(n: int) -> DistributionalValue: + """A deterministic equally-weighted distribution on ``[0, 1]``.""" + positions = list(np.linspace(0.0, 1.0, n)) + masses = [1.0] * n + return DistributionalValue.from_weighted_samples(positions, masses) + + +class TestCollapseAsymptoticallyOptimalW1(unittest.TestCase): + def test_preserves_total_mass(self) -> None: + """Collapsing to N deltas keeps total mass at 1.0 with exactly N deltas.""" + dist = _uniform_distribution(64) + collapsed = _collapse_asymptotically_optimal_w1(dist, n_dirac_deltas=8) + self.assertEqual(len(collapsed.dirac_deltas), 8) + self.assertAlmostEqual(float(np.sum(collapsed.masses)), 1.0) + + def test_deterministic(self) -> None: + """Same input collapses to identical positions and masses (no RNG).""" + first = _collapse_asymptotically_optimal_w1( + _uniform_distribution(64), n_dirac_deltas=8 + ) + second = _collapse_asymptotically_optimal_w1( + _uniform_distribution(64), n_dirac_deltas=8 + ) + np.testing.assert_array_equal(first.positions, second.positions) + np.testing.assert_array_equal(first.masses, second.masses) + + def test_collapsed_positions_within_support(self) -> None: + """Collapsed positions stay within the original support ``[0, 1]``.""" + collapsed = _collapse_asymptotically_optimal_w1( + _uniform_distribution(64), n_dirac_deltas=8 + ) + self.assertGreaterEqual(float(np.min(collapsed.positions)), 0.0) + self.assertLessEqual(float(np.max(collapsed.positions)), 1.0) + + def test_fewer_than_two_deltas_raises(self) -> None: + with self.assertRaises(ValueError): + _collapse_asymptotically_optimal_w1( + _uniform_distribution(16), n_dirac_deltas=1 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/distribution_helpers/density.py b/src/signaloid/benchmarking/distribution_helpers/density.py new file mode 100644 index 0000000..1bbf768 --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/density.py @@ -0,0 +1,80 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Histogram-based density (pdf) estimate for a DistributionalValue. + +Benchmarking-internal helper (not part of the public API). This is an +opinionated density *estimate* (Freedman-Diaconis binning for weighted +samples, ``numpy`` auto-binning for the samples branch, both via +``scipy.stats.rv_histogram``), deliberately kept out of the core +``DistributionalValue`` class, which exposes only exact distribution +operations (``cdf`` / ``inverse_cdf``). +""" + +import numpy as np +from scipy.stats import rv_histogram # type: ignore + +from signaloid.distributional.distributional import DistributionalValue + + +def _histogram_pdf( + dist: DistributionalValue, + x: float | np.floating | np.ndarray, + *, + treat_as_samples: bool = False, +) -> np.ndarray: + """ + Estimate the pdf of ``dist`` at ``x`` via a histogram density. + + Args: + dist: The distributional value whose pdf is estimated. + x: Evaluation point(s). + treat_as_samples: When True, ignore masses and bin the positions + as equally-weighted samples (``numpy`` auto bins). When False + (default), build a Freedman-Diaconis-binned weighted histogram. + + Returns: + The estimated pdf value(s) at ``x``. + """ + sort_idx = np.argsort(dist.positions) + sorted_positions = dist.positions[sort_idx] + sorted_masses = dist.masses[sort_idx] + + if treat_as_samples: + hist_dist = rv_histogram(np.histogram(sorted_positions, bins="auto")) + else: + # Freedman-Diaconis rule for the bin count, with fallbacks when the + # IQR or the support width is degenerate. + n = len(sorted_positions) + q75 = float(np.percentile(sorted_positions, 75)) + q25 = float(np.percentile(sorted_positions, 25)) + iqr = q75 - q25 + support_width = float(sorted_positions.max() - sorted_positions.min()) + bin_width = 2 * iqr / (n ** (1 / 3)) if iqr > 0 else support_width / 50 + bins = int(support_width / bin_width) if bin_width > 0 else 50 + bins = max(10, min(bins, 1000)) + hist_dist = rv_histogram( + np.histogram( + sorted_positions, bins=bins, weights=sorted_masses, density=True + ) + ) + + return np.asarray(hist_dist.pdf(x)) diff --git a/src/signaloid/benchmarking/distribution_helpers/density_test.py b/src/signaloid/benchmarking/distribution_helpers/density_test.py new file mode 100644 index 0000000..82bcbab --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/density_test.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +import numpy as np + +from signaloid.benchmarking.distribution_helpers.density import _histogram_pdf +from signaloid.distributional.distributional import DistributionalValue + + +def _equal_mass_distribution() -> DistributionalValue: + """Four equally-weighted points at 0, 1, 2, 3 (deterministic).""" + return DistributionalValue.from_weighted_samples( + [0.0, 1.0, 2.0, 3.0], [1.0, 1.0, 1.0, 1.0] + ) + + +class TestHistogramPdf(unittest.TestCase): + def test_pdf_evaluates_and_returns_array(self) -> None: + """Weighted branch returns a non-negative pdf estimate array.""" + out = _histogram_pdf(_equal_mass_distribution(), 1.5) + self.assertIsInstance(out, np.ndarray) + self.assertTrue(np.all(out >= 0.0)) + + def test_pdf_samples_branch_evaluates(self) -> None: + """Samples branch (auto bins) returns a non-negative pdf estimate.""" + out = _histogram_pdf(_equal_mass_distribution(), 1.5, treat_as_samples=True) + self.assertIsInstance(out, np.ndarray) + self.assertTrue(np.all(out >= 0.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/distribution_helpers/quantization.py b/src/signaloid/benchmarking/distribution_helpers/quantization.py new file mode 100644 index 0000000..2b2bdf0 --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/quantization.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Quantization helpers used by the collapse routines. + +These pure-numpy functions compute weighted quantiles and an asymptotically +optimal discrete (Wasserstein-p) representation of a distribution. +""" + +import numpy as np + + +def _weighted_quantile( + values: np.ndarray | list[float], + quantiles: np.ndarray | list[float], + weights: np.ndarray | list[float] | None = None, +) -> np.ndarray: + """ + Compute quantiles for weighted samples by linearly interpolating the + probability mass function built from ``values`` and ``weights``. + + Args: + values: The sample values. + quantiles: The quantiles to compute (e.g. [0.25, 0.5, 0.75]). + weights: The sample weights. Equal weights when omitted. + + Returns: + The computed quantile values. + """ + values = np.array(values) + quantiles = np.array(quantiles) + weights = np.ones_like(values) if weights is None else np.array(weights) + + # Sort values and associated weights + sorted_indices = np.argsort(values) + sorted_values = values[sorted_indices] + sorted_weights = weights[sorted_indices] + + # Compute cumulative sum of weights and normalize to [0, 1] + cumulative_weights = np.cumsum(sorted_weights) / np.sum(sorted_weights) + + # Use linear interpolation to find the quantile values + return np.asarray(np.interp(quantiles, cumulative_weights, sorted_values)) + + +def _asymptotically_optimal_wasserstein_p_representation( + positions: np.ndarray, masses: np.ndarray, n_dirac_deltas: int, p: int | float +) -> tuple[np.ndarray, np.ndarray]: + """ + Compute an asymptotically optimal discrete representation of a distribution + under the Wasserstein-p distance (method from Section 7.3 of 'Foundations + of Quantization'). + + Args: + positions: Array of sorted sample positions. + masses: Corresponding mass values (weights). + n_dirac_deltas: Number of Dirac deltas in the approximation. + p: Order of the Wasserstein distance to optimize for. + + Returns: + The discrete positions and their corresponding masses. + """ + if len(positions) != len(masses): + raise ValueError("positions and masses must have the same length") + + # Compute the cumulative distribution function (CDF) + cumulative_masses = np.cumsum(masses) + cumulative_masses /= cumulative_masses[-1] + + def cdf(x: np.ndarray) -> np.ndarray: + return np.asarray( + np.interp(x, positions, cumulative_masses, left=0.0, right=1.0) + ) + + probabilities = (2 * np.arange(1, n_dirac_deltas + 1) - 1) / (2 * n_dirac_deltas) + + # Calculate transformed masses + transformed_masses = masses ** (1 / (1 + p)) + transformed_masses /= np.sum(transformed_masses) + + # Compute positions + new_positions = _weighted_quantile( + positions, quantiles=probabilities, weights=transformed_masses + ) + midpoints = (new_positions[1:] + new_positions[:-1]) / 2 + + # Compute CDF values + cdf_midpoints = cdf(midpoints) + + # Compute new masses + new_masses = np.zeros(n_dirac_deltas) + new_masses[0] = cdf_midpoints[0] + new_masses[-1] = 1 - cdf_midpoints[-1] + new_masses[1:-1] = np.diff(cdf_midpoints) + + new_masses /= np.sum(new_masses) + + return new_positions, new_masses diff --git a/src/signaloid/benchmarking/distribution_helpers/quantization_test.py b/src/signaloid/benchmarking/distribution_helpers/quantization_test.py new file mode 100644 index 0000000..09ced8d --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/quantization_test.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +import numpy as np + +from signaloid.benchmarking.distribution_helpers.quantization import ( + _asymptotically_optimal_wasserstein_p_representation, + _weighted_quantile, +) + + +class TestWeightedQuantile(unittest.TestCase): + def test_uniform_weights_match_linear_interpolation(self) -> None: + """Equal weights reduce to an interp over the empirical CDF grid. + + For n equally-weighted points, ``_weighted_quantile`` interpolates over + the cumulative-weight grid ``[1/n, 2/n, ..., 1]`` (right-edge of each + step), so the expected output is ``np.interp`` against that grid -- a + deterministic analytic reference, not ``np.quantile`` (which uses the + midpoint grid by default). + """ + values = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + quantiles = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) + n = len(values) + cumulative_grid = np.arange(1, n + 1) / n + expected = np.interp(quantiles, cumulative_grid, np.sort(values)) + np.testing.assert_allclose(_weighted_quantile(values, quantiles), expected) + + def test_median_matches_interp_on_cumulative_grid(self) -> None: + """The 0.5 quantile interpolates the right-edge cumulative-weight grid. + + For 5 equally-weighted points the cumulative grid is + [.2, .4, .6, .8, 1.0]; interp(0.5, grid, sorted_values) falls halfway + between the .4 -> -1.0 and .6 -> 0.0 knots, i.e. -0.5. + """ + values = np.array([-2.0, -1.0, 0.0, 1.0, 2.0]) + result = _weighted_quantile(values, [0.5]) + np.testing.assert_allclose(result, np.array([-0.5])) + + def test_unsorted_input_is_sorted_internally(self) -> None: + """Output is invariant to input ordering (values are argsorted).""" + ordered = _weighted_quantile([0.0, 1.0, 2.0], [0.5]) + shuffled = _weighted_quantile([2.0, 0.0, 1.0], [0.5]) + np.testing.assert_allclose(ordered, shuffled) + + +class TestAsymptoticRepresentation(unittest.TestCase): + def test_preserves_total_mass(self) -> None: + """The collapsed masses always sum to 1.0.""" + positions = np.linspace(0.0, 10.0, 50) + masses = np.ones_like(positions) + _, new_masses = _asymptotically_optimal_wasserstein_p_representation( + positions, masses, n_dirac_deltas=8, p=1 + ) + self.assertAlmostEqual(float(np.sum(new_masses)), 1.0) + self.assertEqual(len(new_masses), 8) + + def test_deterministic(self) -> None: + """Same inputs give bit-identical outputs (pure-numpy, no RNG).""" + positions = np.linspace(-1.0, 1.0, 40) + masses = np.linspace(1.0, 2.0, 40) + first = _asymptotically_optimal_wasserstein_p_representation( + positions, masses, n_dirac_deltas=6, p=1 + ) + second = _asymptotically_optimal_wasserstein_p_representation( + positions, masses, n_dirac_deltas=6, p=1 + ) + np.testing.assert_array_equal(first[0], second[0]) + np.testing.assert_array_equal(first[1], second[1]) + + def test_collapsed_positions_within_support(self) -> None: + """Collapsed positions stay within the original support range.""" + positions = np.linspace(3.0, 7.0, 30) + masses = np.ones_like(positions) + new_positions, _ = _asymptotically_optimal_wasserstein_p_representation( + positions, masses, n_dirac_deltas=5, p=1 + ) + self.assertGreaterEqual(float(np.min(new_positions)), 3.0) + self.assertLessEqual(float(np.max(new_positions)), 7.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/distribution_helpers/representation_health.py b/src/signaloid/benchmarking/distribution_helpers/representation_health.py new file mode 100644 index 0000000..210faed --- /dev/null +++ b/src/signaloid/benchmarking/distribution_helpers/representation_health.py @@ -0,0 +1,96 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +""" +Shared representation-health guard for traced UxHw distributions. + +Traced representations can blow up into non-finite or absurd-magnitude +positions, which the ``signaloid.distributional_distance`` wrappers reject. +Callers use :func:`_representation_blow_up_reason` to detect a genuine blow-up +and surface it as the worst-case distance instead of crashing. +""" + +import numpy as np + +from signaloid.distributional.distributional import DistributionalValue + +# Positions above `sqrt(float max)` (~1.34e154) overflow float64 when the +# variance squares them, so that is the blow-up cutoff: corruption (~1e300) is +# caught while legitimate large values (finance ~1e15) pass. A float-derived +# bound avoids the false flags an arbitrary cutoff (e.g. 1e10) would cause. +BLOW_UP_POSITION_MAGNITUDE = float(np.sqrt(np.finfo(np.float64).max)) + +# Max fraction of total mass allowed at an overflow-scale position before the +# representation counts as a blow-up. Below it the mass is a benign remnant. At +# or above it the distribution is genuinely corrupt. +BLOW_UP_MASS_FRACTION = 1e-6 + + +def _representation_blow_up_reason( + dv: DistributionalValue, + check_magnitude: bool = True, +) -> str | None: + """ + Decide whether a distribution (after ``drop_zero_mass_positions``) is a + genuine representation blow-up rather than a benign one that merely carried + zero-mass special-value slots. + + Two modes: (1) a non-finite position still carries mass (always checked), + and (2) a non-trivial mass fraction sits at an overflow-scale magnitude + (``|position| > BLOW_UP_POSITION_MAGNITUDE``). Mode 2 is gated on + ``check_magnitude`` because a large-but-finite scalar (e.g. ~1e15) is not + corruption. + + Args: + dv: The distribution to inspect. Callers should drop zero-mass deltas + first so benign special-value slots do not trip mode 1. + check_magnitude: Apply the overflow-magnitude check (mode 2). ``True`` + for distribution outputs. ``False`` for scalar outputs, where a + large finite value is legitimate. + + Returns: + A human-readable reason string when ``dv`` is a genuine blow-up, else + ``None``. + """ + # Mode 1: a non-finite position still carrying mass. `is_finite` is + # tri-state. Only the explicit `False` is a blow-up. `None` (no deltas / + # indeterminate) deliberately falls through. Always runs. + if dv.is_finite is False: + return ( + "non-finite position carries non-zero mass after dropping zero-mass deltas" + ) + + # Mode 2: a meaningful mass fraction parked at an absurd magnitude. Gated on + # `check_magnitude` so a large-but-finite scalar output is not mis-flagged. + if check_magnitude: + positions = np.asarray(dv.positions, dtype=np.float64) + masses = np.asarray(dv.masses, dtype=np.float64) + + total_mass = float(masses.sum()) + if total_mass > 0: + absurd = np.abs(positions) > BLOW_UP_POSITION_MAGNITUDE + absurd_fraction = float(masses[absurd].sum()) / total_mass + if absurd_fraction > BLOW_UP_MASS_FRACTION: + return ( + f"{absurd_fraction:.3g} of mass sits at |position| > " + f"{BLOW_UP_POSITION_MAGNITUDE:.0e}" + ) + + return None diff --git a/src/signaloid/benchmarking/equivalent_mc/README.md b/src/signaloid/benchmarking/equivalent_mc/README.md new file mode 100644 index 0000000..0f656a0 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/README.md @@ -0,0 +1,105 @@ +# Equivalent Monte Carlo + +This is a Python library for computing the Equivalent Monte Carlo count numbers +for each UxHw configuration using a Ground Truth Monte Carlo simulation and a +series of Adversary Monte Carlo simulations. + + +### Definition: Equivalent Monte Carlo Count + +An Equivalent Monte Carlo Count (EMCC) is always assigned per variable per UxHw +instance. The Equivalent Monte Carlo Count is the number of Monte Carlo +re-executions required such that the result will of equal or better accuracy +compared to the corresponding UxHw instance for the chosen distance metric and +reporting method. For a given UxHw instance, if we wish to compute the +equivalent Monte Carlo count with the Wasserstein-1 distance metric and the mean +reporting method, we calculate the maximum number of Monte Carlo samples such +that across ALL adversary MC simulations, the Wasserstein distance between the +UxHw instance and the ground truth is greater than the mean Wasserstein distance +of Monte Carlo. I.e., we have 100% empirical confidence that the average Monte +Carlo of that size beats UxHw. + +### Other terms + +- **Ground Truth:** Samples which act as the authoritative golden reference of + what the correct result of the application (under uncertainty) is. These can + be provided via random samples such as a large Monte Carlo simulation or + through weighted samples obtained through an analytic formula or empirical + method. +- **Adversary:** Samples from a Monte Carlo simulation that we use to create + samples of simulated Monte Carlo adversaries for UxHw. + +## Usage + +Import `load_data_and_compute_equivalent_mc` in your code. + + +### `load_data_and_compute_equivalent_mc` + +`load_data_and_compute_equivalent_mc(args)` loads the ground-truth, UxHw, and +adversary distributions from their databases, computes the equivalent Monte +Carlo count for each benchmarking variable, prints the results, and (optionally) +writes plots and a CSV. It takes a single `LoadDataComputeEquivalentMCArgs` +`TypedDict`. A representative subset of its keys: + +```python +from signaloid.benchmarking import ( + LoadDataComputeEquivalentMCArgs, + load_data_and_compute_equivalent_mc, +) + +args: LoadDataComputeEquivalentMCArgs = { + "benchmarking_variables": benchmarking_variables, # list[BenchmarkingVariable] + "ground_truth_database_path": "GroundTruth.db", + "ground_truth_table_name": "MonteCarlo", + "ground_truth_type": "MonteCarlo", + "adversary_database_path": "Adversary.db", + "adversary_table_name": "MonteCarlo", + "adversary_size_step": 1, + "adversary_size_min": 1, + "adversary_size_max": None, + "n_adversaries": 100, + "uxhw_database_path": "UxHw.db", + "uxhw_table_names": ["Evaluation"], + "uxhw_ur_types": ["Athens"], + "uxhw_ur_sizes": [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048], + "correlations": ["Disabled"], + "distance_type": "Wasserstein-1", + "reporting_methods": ["Mean"], + "n_processes": 1, + "use_clt": False, + "use_adaptive_steps": False, + "use_binned_uxhw": False, + "auto_prefix": False, + "output_file": None, + "plot_distributions": False, + "plot_comparison_distributions": False, + "plot_adversary_distances": False, + "plots_dir": "", +} + +load_data_and_compute_equivalent_mc(args) +``` + +See the `LoadDataComputeEquivalentMCArgs` definition in +[`equivalent_mc_main.py`](./equivalent_mc_main.py) for the full, authoritative +set of keys and their types. + +## Outputs +Information printed includes: +- Statistics on ground truth and adversarial samples +- Statistics on UxHw distributions +- Table of equivalent MC information including MC counts, distances and analytic predictions. + +Files generated include: +- For each traced variable: + - `*_adversary_distances.npy`: the NumPy array of distances between each adversary and the ground truth. + - `*_uxhw_distances.csv`: a csv file containing the distances between UxHw and the ground truth. +- `*-equivalent_mc.csv`: a csv file containing the Equivalent MC counts for each UxHw configuration +- When comparison-distribution plotting is enabled, for each UxHw configuration's equivalent MC count there will be a plot produced called `*_ground_truth-*_mc_count-*-adversaries.png`. + +## Auditing the quality of Monte Carlo data +A question when comparing against Monte Carlo is how many samples are enough for both the ground truth and adversary arrays. There is no universal answer to this. We use use the following guiding principle: +1. Enable adversary-distance plotting. If the data is of a good enough quality then the measured adversary distances should not deviate significantly from the predicted line. Significant deviations are usually due to a poor quality ground truth or an adversary array that is too small. +2. Use analytic formulas. If an analytic formula for the target distribution is known, then a ground truth formed from 1 million weighted samples from the exact PDF will almost certainly be of better quality than 10-100 million random samples (this is due to the relatively slow inverse square root convergence of MC). +3. Focus on smaller representation sizes. Despite a 1 million MC ground truth sample count being unsuitable for Athens256 and Athens512 in many cases, it is more than good enough for representation sizes below this and you can rely on the analytic estimates to extrapolate up with. diff --git a/src/signaloid/benchmarking/equivalent_mc/__init__.py b/src/signaloid/benchmarking/equivalent_mc/__init__.py new file mode 100644 index 0000000..b7fedfa --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/__init__.py @@ -0,0 +1,39 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +"""Equivalent-Monte-Carlo analysis. + +Computes the number of Monte-Carlo samples whose accuracy is equivalent to a +Signaloid (UxHw) computation, by comparing distributional distances. +""" + +from signaloid.benchmarking.equivalent_mc.equivalent_mc_main import ( + LoadDataComputeEquivalentMCArgs, + load_data_and_compute_equivalent_mc, +) +from signaloid.benchmarking.equivalent_mc.equivalent_mc_utils import ( + anderson_darling_test, +) + +__all__ = [ + "load_data_and_compute_equivalent_mc", + "LoadDataComputeEquivalentMCArgs", + "anderson_darling_test", +] diff --git a/src/signaloid/benchmarking/equivalent_mc/adversary_distance.py b/src/signaloid/benchmarking/equivalent_mc/adversary_distance.py new file mode 100644 index 0000000..490da99 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/adversary_distance.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import multiprocessing +from typing import Callable + +import numpy as np +import ot # type: ignore +from scipy.stats import wasserstein_distance # type: ignore + + +def _wasserstein_1_distance( + adversary_samples: np.ndarray, + ground_truth_positions: np.ndarray, + ground_truth_masses: np.ndarray | None, +) -> float: + if ground_truth_masses is None: + return float(wasserstein_distance(adversary_samples, ground_truth_positions)) + return float( + wasserstein_distance( + u_values=adversary_samples, + v_values=ground_truth_positions, + v_weights=ground_truth_masses, + ) + ) + + +def _wasserstein_2_distance( + adversary_samples: np.ndarray, + ground_truth_positions: np.ndarray, + ground_truth_masses: np.ndarray | None, +) -> float: + if ground_truth_masses is None: + return float( + np.sqrt( + ot.wasserstein_1d( + u_values=adversary_samples, v_values=ground_truth_positions, p=2 + ) + ) + ) + return float( + np.sqrt( + ot.wasserstein_1d( + u_values=adversary_samples, + v_values=ground_truth_positions, + v_weights=ground_truth_masses, + p=2, + ) + ) + ) + + +def _adversary_distance_loop( + adversary_size_array: list[int] | np.ndarray, + adversary_array: np.ndarray, + ground_truth_positions: np.ndarray, + progress_queue: "multiprocessing.Queue[int]", + ground_truth_masses: np.ndarray | None, + distance_fn: Callable[[np.ndarray, np.ndarray, np.ndarray | None], float], + seed: int | None = None, +) -> list[tuple[int, list[float]]]: + """ + Subsample the adversary array at each requested size and score it. + + Shared by the W1/W2 wrappers. Only ``distance_fn`` (the metric) differs. + + Args: + adversary_size_array: Sample sizes to draw and score, in order. + adversary_array: Pool of adversary samples to subsample from. + ground_truth_positions: Ground-truth distribution positions. + progress_queue: Queue notified as each size is scored, for the + progress bar. + ground_truth_masses: Ground-truth masses, or ``None`` for unweighted + samples. + distance_fn: Metric applied to (samples, positions, masses). + seed: RNG seed. A fresh independent seed is drawn when ``None``. + + Returns: + A list of ``(size, [distance])`` tuples, one per requested size. + """ + output_list = [] + steps_per_progress_bar_update = 1 + + # Generate a new seed for each calculation to achieve independence + if seed is None: + seed = np.random.SeedSequence().generate_state(1)[0] + rng = np.random.default_rng(seed=seed) + + for j, adversary_sample_size in enumerate(adversary_size_array): + # Subsample with replacement (requested size may exceed the array) + sample_indices = rng.choice( + adversary_array.size, size=adversary_sample_size, replace=True + ) + adversary_samples = adversary_array[sample_indices] + + distance = distance_fn( + adversary_samples, ground_truth_positions, ground_truth_masses + ) + output_list.append((adversary_sample_size, [distance])) + + # Update progress bar + if j % steps_per_progress_bar_update == 0 and progress_queue is not None: + progress_queue.put(steps_per_progress_bar_update) + + return output_list + + +def _wasserstein_1_adversary_wrapper( + adversary_size_array: list[int] | np.ndarray, + adversary_array: np.ndarray, + ground_truth_positions: np.ndarray, + progress_queue: "multiprocessing.Queue[int]", + ground_truth_masses: np.ndarray | None = None, + seed: int | None = None, +) -> list[tuple[int, list[float]]]: + """ + Score adversary subsamples against the ground truth under Wasserstein-1. + + Thin wrapper over :func:`_adversary_distance_loop` with the W1 metric. See + it for argument and return details. + """ + return _adversary_distance_loop( + adversary_size_array, + adversary_array, + ground_truth_positions, + progress_queue, + ground_truth_masses, + _wasserstein_1_distance, + seed=seed, + ) + + +def _wasserstein_2_adversary_wrapper( + adversary_size_array: list[int] | np.ndarray, + adversary_array: np.ndarray, + ground_truth_positions: np.ndarray, + progress_queue: "multiprocessing.Queue[int]", + ground_truth_masses: np.ndarray | None = None, +) -> list[tuple[int, list[float]]]: + """ + Score adversary subsamples against the ground truth under Wasserstein-2. + + Thin wrapper over :func:`_adversary_distance_loop` with the W2 metric. See + it for argument and return details. + """ + return _adversary_distance_loop( + adversary_size_array, + adversary_array, + ground_truth_positions, + progress_queue, + ground_truth_masses, + _wasserstein_2_distance, + ) diff --git a/src/signaloid/benchmarking/equivalent_mc/conftest.py b/src/signaloid/benchmarking/equivalent_mc/conftest.py new file mode 100644 index 0000000..a32bc0c --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/conftest.py @@ -0,0 +1,1400 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import sqlite3 +from contextlib import closing +from dataclasses import dataclass + +import numpy as np + +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.config import STRING_TO_CORE_REPRESENTATION + +gaussianMotherTTR4Positions = [ + -1.3657612687884203, + -0.3782569681479246, + 0.3782569681479247, + 1.3657612687884202, +] + +gaussianMotherTTR4Probabilities = [ + 0.2124687418455399, + 0.2875312581544601, + 0.2875312581544601, + 0.2124687418455399, +] + + +gaussianMotherTTR8Positions = [ + -1.8252859837884490, + -1.0532374527155278, + -0.5795005914090781, + -0.1868843945219808, + 0.1868843945219810, + 0.5795005914090782, + 1.0532374527155276, + 1.8252859837884487, +] + +gaussianMotherTTR8Probabilities = [ + 0.0860069533523957, + 0.1264617884931441, + 0.1401511127335078, + 0.1473801454209523, + 0.1473801454209524, + 0.1401511127335078, + 0.1264617884931441, + 0.0860069533523958, +] + + +gaussianMotherTTR16Positions = [ + -2.2194097941743495, + -1.5678879052503879, + -1.1997100902403894, + -0.9205473015952268, + -0.6859608829321615, + -0.4772650338396895, + -0.2817093824970845, + -0.0931705533432515, + 0.0931705533432517, + 0.2817093824970847, + 0.4772650338396895, + 0.6859608829321619, + 0.9205473015952266, + 1.1997100902403890, + 1.5678879052503878, + 2.2194097941743488, +] + +gaussianMotherTTR16Probabilities = [ + 0.0339789420895466, + 0.0520280112628491, + 0.0601091352738556, + 0.0663526532192885, + 0.0686569819983892, + 0.0714941307351186, + 0.0732557829220845, + 0.0741243624988679, + 0.0741243624988679, + 0.0732557829220844, + 0.0714941307351186, + 0.0686569819983892, + 0.0663526532192885, + 0.0601091352738556, + 0.0520280112628491, + 0.0339789420895466, +] + + +gaussianMotherTTR32Positions = [ + -2.5689134128985755, + -1.9965740450523287, + -1.6872699601541340, + -1.4618447092115275, + -1.2797932027422807, + -1.1244621534384755, + -0.9854456485374128, + -0.8581393463409277, + -0.7411486341846846, + -0.6321334047377548, + -0.5279227690600045, + -0.4274116948588812, + -0.3297269328558301, + -0.2341213804228881, + -0.1399250241340056, + -0.0465515868288349, + 0.0465515868288351, + 0.1399250241340060, + 0.2341213804228881, + 0.3297269328558306, + 0.4274116948588814, + 0.5279227690600041, + 0.6321334047377552, + 0.7411486341846854, + 0.8581393463409275, + 0.9854456485374126, + 1.1244621534384745, + 1.2797932027422808, + 1.4618447092115275, + 1.6872699601541339, + 1.9965740450523275, + 2.5689134128985748, +] + +gaussianMotherTTR32Probabilities = [ + 0.0132294289721687, + 0.0207495131173779, + 0.0244747053614816, + 0.0275533059013675, + 0.0291190230966525, + 0.0309901121772031, + 0.0325273245838392, + 0.0338253286354494, + 0.0339001460636393, + 0.0347568359347500, + 0.0354609794294314, + 0.0360331513056871, + 0.0364633252037134, + 0.0367924577183711, + 0.0370083963166454, + 0.0371159661822225, + 0.0371159661822226, + 0.0370083963166453, + 0.0367924577183711, + 0.0364633252037133, + 0.0360331513056871, + 0.0354609794294315, + 0.0347568359347501, + 0.0339001460636391, + 0.0338253286354493, + 0.0325273245838392, + 0.0309901121772030, + 0.0291190230966526, + 0.0275533059013675, + 0.0244747053614816, + 0.0207495131173779, + 0.0132294289721688, +] + + +gaussianMotherTTR64Positions = [ + -2.8856270715622317, + -2.3701661019894658, + -2.0993152793462120, + -1.9062707202769609, + -1.7534946025063321, + -1.6256480330060107, + -1.5134478726747126, + -1.4127159687339791, + -1.3219629454681338, + -1.2390893214196459, + -1.1615379597661530, + -1.0883896195617139, + -1.0189512566926558, + -0.9526620578044378, + -0.8890547285322769, + -0.8277614765402220, + -0.7693102055744242, + -0.7133736761876785, + -0.6588880357379042, + -0.6056771598339144, + -0.5535889408162109, + -0.5024864318330494, + -0.4522446877633873, + -0.4027532282974526, + -0.3539224805239350, + -0.3056594215789341, + -0.2578667119352991, + -0.2104637486108614, + -0.1633746834619164, + -0.1165265593609948, + -0.0698484184184955, + -0.0232715903999292, + 0.0232715903999293, + 0.0698484184184957, + 0.1165265593609954, + 0.1633746834619168, + 0.2104637486108613, + 0.2578667119352991, + 0.3056594215789350, + 0.3539224805239354, + 0.4027532282974526, + 0.4522446877633880, + 0.5024864318330490, + 0.5535889408162101, + 0.6056771598339157, + 0.6588880357379038, + 0.7133736761876793, + 0.7693102055744255, + 0.8277614765402207, + 0.8890547285322777, + 0.9526620578044379, + 1.0189512566926551, + 1.0883896195617131, + 1.1615379597661511, + 1.2390893214196474, + 1.3219629454681329, + 1.4127159687339800, + 1.5134478726747119, + 1.6256480330060095, + 1.7534946025063333, + 1.9062707202769600, + 2.0993152793462098, + 2.3701661019894655, + 2.8856270715622303, +] + +gaussianMotherTTR64Probabilities = [ + 0.0051008972323567, + 0.0081285317398121, + 0.0097063083829115, + 0.0110432047344664, + 0.0117967851358985, + 0.0126779202255831, + 0.0134382371619147, + 0.0141150687394528, + 0.0143019841833360, + 0.0148170389133166, + 0.0152825322930425, + 0.0157075798841606, + 0.0160865195941409, + 0.0164408049896983, + 0.0167643483721007, + 0.0170609802633487, + 0.0168329201566992, + 0.0170672259069400, + 0.0172809664932315, + 0.0174758694415185, + 0.0176507464919648, + 0.0178102329374667, + 0.0179530421240812, + 0.0180801091816059, + 0.0181832960802084, + 0.0182800291235050, + 0.0183621941987760, + 0.0184302635195951, + 0.0184839771952281, + 0.0185244191214173, + 0.0185512753215633, + 0.0185646908606592, + 0.0185646908606593, + 0.0185512753215633, + 0.0185244191214174, + 0.0184839771952280, + 0.0184302635195950, + 0.0183621941987761, + 0.0182800291235051, + 0.0181832960802082, + 0.0180801091816060, + 0.0179530421240811, + 0.0178102329374665, + 0.0176507464919650, + 0.0174758694415186, + 0.0172809664932315, + 0.0170672259069401, + 0.0168329201566990, + 0.0170609802633486, + 0.0167643483721007, + 0.0164408049896983, + 0.0160865195941409, + 0.0157075798841604, + 0.0152825322930426, + 0.0148170389133166, + 0.0143019841833359, + 0.0141150687394528, + 0.0134382371619147, + 0.0126779202255831, + 0.0117967851358985, + 0.0110432047344663, + 0.0097063083829115, + 0.0081285317398121, + 0.0051008972323567, +] + + +gaussianMotherTTR128Positions = [ + -3.1770064428358795, + -2.7048251373502417, + -2.4614537826058657, + -2.2904536401014733, + -2.1567713660595797, + -2.0461451545434971, + -1.9500973245671418, + -1.8647592325950299, + -1.7886220928498387, + -1.7197537519608484, + -1.6559349864764683, + -1.5963241489586080, + -1.5402874624665105, + -1.4873162331985276, + -1.4369912972667470, + -1.3889834126502030, + -1.3436473064561419, + -1.3006853172746012, + -1.2592673917957917, + -1.2192421406592014, + -1.1804806783902054, + -1.1428691343760602, + -1.1063059188691768, + -1.0707032784876825, + -1.0359928629890060, + -1.0021047000504020, + -0.9689670657220035, + -0.9365241858050105, + -0.9047262380740626, + -0.8735274613301282, + -0.8428855901904144, + -0.8127625585239206, + -0.7835440682032633, + -0.7551795088214694, + -0.7272144029962550, + -0.6996234665112413, + -0.6723833900417276, + -0.6454722160697664, + -0.6188691839319126, + -0.5925550390665076, + -0.5665130681030094, + -0.5407261703260295, + -0.5151768226904943, + -0.4898497651765216, + -0.4647306159562542, + -0.4398055891328949, + -0.4150614298068486, + -0.3904855712090856, + -0.3660716591803816, + -0.3418080309813450, + -0.3176778423384618, + -0.2936703640916838, + -0.2697752668623345, + -0.2459824879427961, + -0.2222821969205304, + -0.1986648667378803, + -0.1751214728701657, + -0.1516429047400431, + -0.1282199416778381, + -0.1048437901705328, + -0.0815057913799940, + -0.0581973703698228, + -0.0349100119237261, + -0.0116352700791039, + 0.0116352700791040, + 0.0349100119237263, + 0.0581973703698230, + 0.0815057913799941, + 0.1048437901705337, + 0.1282199416778388, + 0.1516429047400436, + 0.1751214728701661, + 0.1986648667378804, + 0.2222821969205299, + 0.2459824879427967, + 0.2697752668623338, + 0.2936703640916843, + 0.3176778423384636, + 0.3418080309813453, + 0.3660716591803821, + 0.3904855712090858, + 0.4150614298068481, + 0.4398055891328964, + 0.4647306159562546, + 0.4898497651765213, + 0.5151768226904937, + 0.5407261703260279, + 0.5665130681030087, + 0.5925550390665100, + 0.6188691839319137, + 0.6454722160697656, + 0.6723833900417266, + 0.6996234665112439, + 0.7272144029962544, + 0.7551795088214691, + 0.7835440682032672, + 0.8127625585239183, + 0.8428855901904127, + 0.8735274613301311, + 0.9047262380740622, + 0.9365241858050100, + 0.9689670657220047, + 1.0021047000503996, + 1.0359928629890068, + 1.0707032784876797, + 1.1063059188691779, + 1.1428691343760586, + 1.1804806783902019, + 1.2192421406592028, + 1.2592673917957949, + 1.3006853172746006, + 1.3436473064561398, + 1.3889834126502052, + 1.4369912972667474, + 1.4873162331985259, + 1.5402874624665099, + 1.5963241489586071, + 1.6559349864764654, + 1.7197537519608520, + 1.7886220928498389, + 1.8647592325950271, + 1.9500973245671424, + 2.0461451545434951, + 2.1567713660595759, + 2.2904536401014742, + 2.4614537826058646, + 2.7048251373502400, + 3.1770064428358772, +] + +gaussianMotherTTR128Probabilities = [ + 0.0019531736540864, + 0.0031477235782703, + 0.0037891505061083, + 0.0043393812337038, + 0.0046651297284823, + 0.0050411786544292, + 0.0053718081422966, + 0.0056713965921698, + 0.0057796305101807, + 0.0060171546257178, + 0.0062365482207130, + 0.0064413720048702, + 0.0066293188503156, + 0.0068089183115991, + 0.0069777425762680, + 0.0071373261631848, + 0.0070832917115849, + 0.0072186924717511, + 0.0073472730662843, + 0.0074697658470323, + 0.0075856212334693, + 0.0076969110595732, + 0.0078030621409546, + 0.0079045177432060, + 0.0079969653123541, + 0.0080895542817868, + 0.0081780534641232, + 0.0082627515255751, + 0.0083434206020312, + 0.0084209277700695, + 0.0084950361929321, + 0.0085659440704166, + 0.0083858482340357, + 0.0084470719226635, + 0.0085056168696200, + 0.0085616090373201, + 0.0086149467282950, + 0.0086660197649365, + 0.0087147224620066, + 0.0087611469795119, + 0.0088043743404139, + 0.0088463721515509, + 0.0088862267786818, + 0.0089240061587849, + 0.0089596558222975, + 0.0089933863017837, + 0.0090251406142273, + 0.0090549685673787, + 0.0090786349458819, + 0.0091046611343265, + 0.0091288355143974, + 0.0091511936091076, + 0.0091717083401675, + 0.0091904858586085, + 0.0092074972265648, + 0.0092227662930303, + 0.0092360798645277, + 0.0092478973307004, + 0.0092580043774642, + 0.0092664147439531, + 0.0092731206477424, + 0.0092781546738209, + 0.0092815074364248, + 0.0092831834242344, + 0.0092831834242345, + 0.0092815074364248, + 0.0092781546738209, + 0.0092731206477424, + 0.0092664147439532, + 0.0092580043774641, + 0.0092478973307004, + 0.0092360798645276, + 0.0092227662930302, + 0.0092074972265648, + 0.0091904858586085, + 0.0091717083401676, + 0.0091511936091078, + 0.0091288355143973, + 0.0091046611343264, + 0.0090786349458818, + 0.0090549685673786, + 0.0090251406142274, + 0.0089933863017839, + 0.0089596558222972, + 0.0089240061587847, + 0.0088862267786818, + 0.0088463721515507, + 0.0088043743404142, + 0.0087611469795123, + 0.0087147224620063, + 0.0086660197649362, + 0.0086149467282953, + 0.0085616090373202, + 0.0085056168696199, + 0.0084470719226637, + 0.0083858482340353, + 0.0085659440704162, + 0.0084950361929324, + 0.0084209277700698, + 0.0083434206020309, + 0.0082627515255752, + 0.0081780534641231, + 0.0080895542817867, + 0.0079969653123542, + 0.0079045177432059, + 0.0078030621409545, + 0.0076969110595730, + 0.0075856212334696, + 0.0074697658470326, + 0.0073472730662840, + 0.0072186924717509, + 0.0070832917115850, + 0.0071373261631850, + 0.0069777425762678, + 0.0068089183115990, + 0.0066293188503157, + 0.0064413720048700, + 0.0062365482207131, + 0.0060171546257179, + 0.0057796305101806, + 0.0056713965921698, + 0.0053718081422966, + 0.0050411786544292, + 0.0046651297284823, + 0.0043393812337038, + 0.0037891505061083, + 0.0031477235782703, + 0.0019531736540864, +] + + +gaussianSamples = [ + -0.729344, + -0.222177, + 0.216394, + 1.065880, + -0.440118, + -1.203964, + -0.098486, + -1.988127, + -0.186173, + 1.016394, + -1.511403, + -0.890236, + -0.769161, + -0.813816, + 0.093971, + 1.507131, + 0.928033, + -0.348220, + 1.099132, + -1.067616, + -0.706466, + 0.325596, + 1.081297, + 0.789550, + -2.167423, + 0.866377, + 1.129343, + 1.249692, + -1.315281, + -0.301959, + -0.124286, + 1.160450, + -2.554896, + -1.583605, + 1.907926, + 0.434597, + -0.479934, + 0.377450, + -0.481492, + -0.235370, + -0.644224, + -0.451129, + -0.069855, + -0.346765, + 0.685287, + -0.306165, + -1.263862, + -3.183638, + 0.378153, + -0.333200, + -0.274922, + 0.782968, + 1.741746, + -0.618590, + -0.094352, + -2.660029, + -0.614906, + -0.742858, + -0.423725, + -0.975117, + -0.034194, + -1.160920, + 0.568975, + 0.073665, + -0.663017, + 0.886669, + 1.935467, + 0.032250, + -0.094419, + 1.232738, + 0.511148, + 0.083358, + -0.265403, + 0.419076, + -1.473727, + -0.734423, + 3.381895, + 0.273336, + 1.381835, + 0.847986, + -0.800732, + -0.396674, + -1.612471, + 0.412056, + 0.077921, + -1.292814, + 2.226115, + -0.134355, + -1.348737, + 1.078885, + 0.926135, + 0.743076, + -1.657959, + 0.866005, + -0.735491, + 1.050081, + -1.164285, + 0.175893, + -0.918976, + -1.488902, + 2.303644, + -0.156700, + -0.673872, + -0.545343, + 2.134761, + 1.122922, + 0.829171, + -0.839001, + -0.041235, + 0.460823, + -1.378390, + -0.492725, + -2.046101, + 0.509944, + -0.188256, + 0.832254, + 1.111176, + -0.084715, + 0.614958, + -0.529950, + 1.491025, + -0.412145, + -0.082555, + 0.174954, + -1.055107, + 0.471154, + 0.152529, + 0.132789, + 0.618456, + -0.115071, + 0.647831, + -1.337197, + 0.195160, + -0.402539, + -0.830955, + -0.333935, + -0.999410, + -1.015441, + -1.806832, + -0.077623, + -1.335967, + 0.435414, + -0.416111, + 0.886985, + 0.340691, + -1.266117, + 0.888801, + -0.528152, + 2.334889, + -0.765015, + -0.066102, + -0.885615, + 0.681548, + -2.130962, + -0.479825, + -0.790529, + 0.092399, + 2.081046, + -0.960091, + 0.005540, + -0.166358, + -0.694265, + -2.821820, + -0.155295, + 0.793166, + -0.102724, + 0.791445, + -1.138097, + -0.363459, + 0.055029, + -0.275790, + 0.388222, + -0.537903, + 0.258844, + -0.577769, + -3.109720, + 1.334530, + 0.048057, + 2.004694, + -2.481697, + 1.667158, + -1.366776, + -0.758703, + 1.295664, + -1.149816, + -0.166979, + -1.307721, + -0.415222, + 0.208869, + -0.506786, + -1.032919, + 0.695883, + -1.051567, + -0.909032, + -0.491234, + -0.344361, + -0.688405, + 0.157208, + 1.097415, + 0.288698, + -0.569206, + 0.886658, + -0.018751, + 0.035363, + -0.155119, + -0.178571, + 1.291137, + 0.558884, + -0.093981, + 1.084390, + 0.746700, + 0.120144, + 0.798418, + -0.755072, + 1.932235, + 0.542523, + -0.158491, + -1.624038, + -0.696076, + -0.346283, + 1.118692, + -0.209305, + -0.354278, + -0.414270, + -0.840728, + 0.224436, + -1.692767, + 0.065089, + 0.668713, + 0.139609, + 0.318275, + 1.524790, + -0.037197, + 1.409862, + -0.335758, + 1.197956, + -0.671748, + -0.192902, + -0.868134, + 1.113932, + 0.467568, + -0.067489, + 0.191140, + -1.378933, + 1.260321, + 1.455567, + -0.586131, + -0.051687, + 0.391163, + -1.250503, + -0.384290, + 1.528402, + 0.011036, + 0.426842, + 2.310901, + 1.267299, + -0.197692, + 1.077924, + -0.990444, + -0.951339, + 0.047970, + 0.992359, + 0.251366, + 0.836678, + -1.571752, + -0.195567, + -0.589568, + -2.830122, + 1.075790, + 1.551387, + -0.228258, + 0.278524, + 0.797749, + -1.358774, + 0.011843, + 1.470213, + -0.014955, + 1.507906, + 0.506730, + 1.245874, + -1.165680, + -0.005232, + -1.726068, + -0.130144, + -0.102900, + 0.097525, + 0.378248, + 1.327142, + -1.364084, + 0.663681, + 0.149708, + 0.490067, + -0.718498, + 0.733912, + 2.116380, + -0.080719, + -0.869604, + 0.099323, + 0.110047, + -0.681231, + -0.444645, + -0.626263, + -0.807478, + -0.730622, + 0.968956, + 0.397563, + 0.889608, + -0.364571, + 0.275757, + 0.110494, + 0.029858, + -1.078198, + 0.222740, + 0.668217, + -0.470601, + -0.774314, + 1.253467, + -1.123168, + -0.557402, + -1.707377, + 0.674557, + 0.064351, + 0.399073, + 1.411447, + -0.140660, + -1.466434, + 0.745685, + 1.053214, + -0.901873, + 0.920732, + -1.062447, + -0.982635, + 1.488336, + -0.044924, + 0.711650, + 0.843113, + -1.058007, + -0.341042, + 1.685125, + -0.514406, + -0.091830, + 0.253044, + 0.775527, + 1.838840, + -0.624548, + -2.006077, + 0.248227, + -1.249411, + -0.386954, + -1.126488, + -0.171708, + -0.163443, + -0.109345, + -1.093308, + 0.497279, + 1.624830, + -0.600596, + 0.888753, + -0.043975, + -1.623052, + -0.035699, + -0.753761, + 0.760186, + -0.610676, + -0.661975, + 0.273143, + -1.161783, + -0.399750, + -1.046375, + -0.551558, + 0.794808, + -0.527122, + 2.162672, + -0.390830, + 0.849147, + 0.524226, + -0.149446, + -0.944381, + 1.091571, + -0.218589, + -0.853680, + 1.320900, + 0.273300, + 1.027159, + 1.613941, + 1.697883, + -0.732691, + 1.672841, + -0.480449, + -0.440991, + -1.558252, + 0.207388, + 0.180742, + 0.275857, + 1.093019, + 0.532335, + -1.139344, + 0.181906, + -0.392841, + 1.499431, + 0.027457, + 1.431444, + -1.009044, + 0.659600, + 1.397930, + -0.824992, + -0.580880, + 1.808624, + -0.063663, + -0.641062, + -0.473558, + -0.825852, + 1.484163, + -0.032398, + 0.100559, + -0.394402, + 0.100206, + 0.239752, + 0.372747, + -0.911925, + 0.338502, + 0.134113, + -0.308972, + -1.025172, + -1.751214, + 0.379795, + 0.097775, + 0.582318, + 0.317118, + -0.211210, + -1.823023, + -0.928048, + -0.232308, + -0.517780, + 1.719735, + 0.666742, + -0.009584, + -1.623571, + -2.028906, + 1.696269, + 0.963299, + -0.320299, + 0.473735, + 0.444899, + -0.343413, + 0.826695, + 0.714555, + -0.204020, + -1.892207, + -2.310861, + -2.008431, + 2.841490, + -0.352433, + -0.714956, + -0.005623, + 0.748038, + -0.832992, + -0.235889, + -0.322531, + -0.560292, + 1.344659, + 0.186646, + -1.644539, + -0.221037, + 0.989838, + 0.936169, + 0.109064, + 0.655830, + 0.740896, + -0.136700, + -0.088867, + 0.406884, + -1.142394, + -1.217116, + -0.178145, + 0.795650, + 1.916304, + -0.482433, + 0.349885, + 1.379611, + -0.617577, + 0.253774, + 0.102643, + 0.840949, + -0.022814, + -0.143834, + -0.261948, + -0.308949, + 2.056430, + -0.948402, + -0.671354, + 1.198562, + -1.472090, + -0.025892, + 1.189661, + -0.735652, + -1.658143, + -1.078264, + 1.400555, + -0.304805, + -0.589860, + -1.508522, + 0.255952, + 0.317324, + 0.269957, + 0.839505, + -0.171971, + -0.466313, + 0.127331, + -1.910157, + 0.702784, + 1.044666, + 0.727186, + -0.526868, + -1.118533, + -0.170877, + -1.289905, + 0.694972, + -0.488655, + -1.698419, + 0.939248, + -0.926584, + 0.229300, + -0.871427, + -0.590214, + 0.663715, + 0.369825, + -0.836952, + -0.116561, + -0.746493, + -0.019931, + 0.719781, + 0.333742, + -1.681203, + -1.031602, + -0.766897, + 1.077952, + 1.098745, + -0.624862, + -0.458914, + 0.824060, + 0.977751, + -1.166830, + -0.126788, + 1.370757, + 0.620778, + -0.036122, + -0.126291, + 0.366687, + 2.189035, + -1.225670, + 0.813193, + 1.399468, + 0.093479, + 0.626346, + -2.170446, + -0.356681, + 0.116124, + 0.283903, + 0.753089, + 1.183400, + 0.347239, + 1.081193, + 0.882044, + -0.165559, + 0.487946, + 1.413469, + 0.261326, + -0.824750, + 0.311666, + -0.979512, + 1.600498, + 0.586124, + 1.432643, + -0.132841, + 0.533776, + 1.901085, + -0.835617, + 1.858434, + -0.547644, + 1.080555, + -1.420959, + 0.638523, + -0.811084, + -0.096047, + -0.807046, + 0.233498, + 0.149054, + 2.116558, + -0.181507, + 0.865554, + -1.092717, + -1.870886, + 1.128502, + -1.652775, + 1.991950, + 1.240171, + 1.641125, + -0.227882, + -2.222983, + 1.502003, + 0.593558, + 0.219004, + -0.224892, + 1.359892, + 0.838904, + -0.565909, + 0.264523, + -0.232417, + 1.011459, + -1.478063, + 1.545169, + -0.546955, + -1.012858, + 0.035468, + 0.916762, + 0.058337, + 1.850075, + -0.927682, + 1.008455, +] + +gaussianMotherTTRTestcases = [ + { + "positions": gaussianMotherTTR4Positions, + "masses": gaussianMotherTTR4Probabilities, + }, + { + "positions": gaussianMotherTTR8Positions, + "masses": gaussianMotherTTR8Probabilities, + }, + { + "positions": gaussianMotherTTR16Positions, + "masses": gaussianMotherTTR16Probabilities, + }, + { + "positions": gaussianMotherTTR32Positions, + "masses": gaussianMotherTTR32Probabilities, + }, + { + "positions": gaussianMotherTTR64Positions, + "masses": gaussianMotherTTR64Probabilities, + }, + { + "positions": gaussianMotherTTR128Positions, + "masses": gaussianMotherTTR128Probabilities, + }, +] + + +# --------------------------------------------------------------------------- +# Synthetic UxHw-trace database builder: shared test support. +# +# Replaces the committed binary fixtures (uxhw-reference.db, uxhw-under-test.db, +# tracing.db), which also scrubs the internal absolute source path they embedded +# in Expression_DeclarationFileName. The data is synthetic: each (expression, +# configuration) carries a Gaussian quantised to the configuration's atom count, +# exported to the Ux string format UxHw emits and parsed back by the production +# loaders (``equivalent_mc/load.py``). Tests assert pipeline structure, not +# specific numbers, so fidelity to the original captured traces is unnecessary. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SyntheticUxhwExpression: + """One traced output expression in a synthetic UxHw database.""" + + name: str + value_id: str + centre: float + scale: float + subprogram: str = "main" + file_name: str = "main.c" + line_number: int = 95 + + +@dataclass(frozen=True) +class SyntheticUxhwConfig: + """One emulator configuration — a row of ``Emulator_Execution_Info``.""" + + ur_type: str + ur_order: int + correlation: str # "Disabled" or "Autocorrelation" + + @property + def ur_order_core_library(self) -> int: + # UxHw also counts the 3 internal sentinel Diracs it keeps for + # Athens-style representations, so the core-library order is N + 3. + return self.ur_order + 3 + + +def gaussian_ux_string(centre: float, scale: float, n_atoms: int, ur_type: str) -> str: + """ + Build a UxHw-format Ux string for a Gaussian quantised to ``n_atoms``. + + Positions span +/- 3.5 standard deviations around ``centre`` with Gaussian + masses. The result is exported through the production + :meth:`DistributionalValue.export` path so it round-trips through + :meth:`DistributionalValue.parse`. + + Args: + centre: Mean of the Gaussian. + scale: Standard deviation of the Gaussian. + n_atoms: Number of quantisation atoms (positions). + ur_type: Representation type name to stamp on the Ux string. + + Returns: + The Ux-format string for the quantised Gaussian. + """ + positions = np.linspace(centre - 3.5 * scale, centre + 3.5 * scale, n_atoms) + masses = np.exp(-0.5 * ((positions - centre) / scale) ** 2) + masses = masses / masses.sum() + distribution = DistributionalValue.from_weighted_samples(positions, masses) + distribution.UR_type = STRING_TO_CORE_REPRESENTATION[ur_type] + return str(distribution) + + +def build_uxhw_database( + db_path: str, + table_name: str, + expressions: list[SyntheticUxhwExpression], + configurations: list[SyntheticUxhwConfig], +) -> None: + """ + Write a synthetic traced-UxHw SQLite database to ``db_path``. + + Creates ``Emulator_Execution_Info`` (one row per configuration) and + ``table_name`` (one row per expression x configuration, carrying a Ux + string), matching the schema the production ``_load_uxhw`` loader reads. + + Args: + db_path: Path to write the SQLite database to. + table_name: Name of the traced-values table to create. + expressions: Traced output expressions to populate. + configurations: Emulator configurations to populate. + """ + with closing(sqlite3.connect(db_path)) as connection: + cursor = connection.cursor() + cursor.execute(""" + CREATE TABLE Emulator_Execution_Info ( + Execution_ID INTEGER PRIMARY KEY AUTOINCREMENT, + Host_Date TEXT, + Host_Microseconds INTEGER, + UR_Type TEXT, + UR_Order INTEGER, + UR_Order_CoreLibrary INTEGER, + CorrelationTracking_Status TEXT, + Host_UserTimeElapsedWallClock REAL, + EmulatedCPU_DynCnt INTEGER + ) + """) + cursor.execute(f""" + CREATE TABLE "{table_name}" ( + ValueId TEXT, + Expression_Name TEXT, + Expression_Subprogram TEXT, + Expression_DeclarationFileName TEXT, + Expression_DeclarationLineNumber INTEGER, + Host_Date TEXT, + Host_Microseconds INTEGER, + Execution_Info_Table_ID INTEGER, + UR_Type TEXT, + UR_Order INTEGER, + UR_Order_CoreLibrary INTEGER, + Assignment_Line INTEGER, + Assignment_Index INTEGER, + Particle_Value REAL, + Dist_Value TEXT + ) + """) + for execution_id, config in enumerate(configurations, start=1): + cursor.execute( + "INSERT INTO Emulator_Execution_Info (" + "Execution_ID, Host_Date, Host_Microseconds, UR_Type, UR_Order, " + "UR_Order_CoreLibrary, CorrelationTracking_Status, " + "Host_UserTimeElapsedWallClock, EmulatedCPU_DynCnt) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + execution_id, + "1970-01-01", + 0, + config.ur_type, + config.ur_order, + config.ur_order_core_library, + config.correlation, + 0.0, + 0, + ), + ) + for expression in expressions: + ux_string = gaussian_ux_string( + expression.centre, + expression.scale, + config.ur_order, + config.ur_type, + ) + cursor.execute( + f'INSERT INTO "{table_name}" (' + "ValueId, Expression_Name, Expression_Subprogram, " + "Expression_DeclarationFileName, " + "Expression_DeclarationLineNumber, Host_Date, " + "Host_Microseconds, Execution_Info_Table_ID, UR_Type, " + "UR_Order, UR_Order_CoreLibrary, Assignment_Line, " + "Assignment_Index, Particle_Value, Dist_Value) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + expression.value_id, + expression.name, + expression.subprogram, + expression.file_name, + expression.line_number, + "1970-01-01", + 0, + execution_id, + config.ur_type, + config.ur_order, + config.ur_order_core_library, + expression.line_number, + 1, + expression.centre, + ux_string, + ), + ) + connection.commit() diff --git a/src/signaloid/benchmarking/equivalent_mc/dist_value_from_row_representation_size_test.py b/src/signaloid/benchmarking/equivalent_mc/dist_value_from_row_representation_size_test.py new file mode 100644 index 0000000..91888e8 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/dist_value_from_row_representation_size_test.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import struct +import unittest + +import pandas as pd + +from signaloid.benchmarking.config import CoreLibraryRepresentationTypes +from signaloid.benchmarking.equivalent_mc.load import _dist_value_from_row + +# UR_type byte for Athens +_ATHENS_UR_TYPE = CoreLibraryRepresentationTypes.ATHENS.value + + +def _build_ux_string( + ur_type: int, + positions: list[float], + raw_masses: list[int], + particle_value: float = 0.0, + mean: float = 0.0, +) -> str: + """Build a minimal double-precision Ux string for testing. + + The format mirrors DistributionalValue.export(to_str=True): + Ux<(pos,mass)...> + All multi-byte fields are big-endian (STRUCT_FORMATS["str"]). + """ + n = len(positions) + buf = b"" + buf += struct.pack(">B", ur_type) # UR_type (1 byte) + buf += struct.pack(">Q", n) # sample_count (8 bytes, unused) + buf += struct.pack(">d", mean) # mean (8 bytes) + buf += struct.pack(">I", n) # UR_order (4 bytes) + for pos, raw_mass in zip(positions, raw_masses, strict=True): + buf += struct.pack(">d", pos) # position (8 bytes, double) + buf += struct.pack(">Q", raw_mass) # mass (8 bytes, fixed-point) + return f"{particle_value}Ux{buf.hex().upper()}" + + +def _make_row( + ux_string: str, + ur_order: int, + ur_order_core_library: int, + ur_type_str: str = "Athens", + correlation_tracking: str = "Independent", + value_id: str = "v0", + particle_value: float = 0.0, +) -> pd.Series: + """Build a pd.Series matching the columns consumed by _dist_value_from_row.""" + return pd.Series( + { + "Dist_Value": ux_string, + "Expression_Name": "test_expr", + "Expression_Subprogram": "main", + "UR_Type": ur_type_str, + "UR_Order": ur_order, + "UR_Order_CoreLibrary": ur_order_core_library, + "CorrelationTracking_Status": correlation_tracking, + "ValueId": value_id, + "Particle_Value": particle_value, + } + ) + + +# Equal mass for N user atoms (sum = 1.0) +_FIXED_POINT_ONE = 0x8000000000000000 + + +def _equal_mass(n: int) -> int: + """Return the fixed-point raw_mass for equal-weight N-atom distribution.""" + return int(_FIXED_POINT_ONE / n) + + +def _build_uxhw_ttr_row(n: int) -> pd.Series: + """Build a row for a real UxHw-style Athens-N distribution. + + Real UxHw reports UR_Order == N (the user-facing build size) and + UR_Order_CoreLibrary == N+3 (it additionally counts 3 internal + sentinel Diracs). It serialises only the N user atoms into the Ux + string. The "+3" appears only in the column, never on the wire. + """ + positions = [float(i) for i in range(n)] + raw_masses = [_equal_mass(n)] * n + ux = _build_ux_string( + ur_type=_ATHENS_UR_TYPE, + positions=positions, + raw_masses=raw_masses, + ) + return _make_row(ux, ur_order=n, ur_order_core_library=n + 3) + + +def _build_external_ttr_row(n: int) -> pd.Series: + """Build a row for an external-producer Athens-N distribution. + + External producers carry exactly N atoms with no appended sentinels. + The CSV-to-tracing-DB path sets both UR_Order and UR_Order_CoreLibrary + to N. + """ + positions = [float(i) for i in range(n)] + raw_masses = [_equal_mass(n)] * n + ux = _build_ux_string( + ur_type=_ATHENS_UR_TYPE, + positions=positions, + raw_masses=raw_masses, + ) + return _make_row(ux, ur_order=n, ur_order_core_library=n) + + +def _build_collapsed_scalar_ttr_row(n: int) -> pd.Series: + """Build a row for a Athens-N scalar-like output that collapses to a + single serialised atom (e.g. Value at Risk). + + UR_Order is still the build size N and UR_Order_CoreLibrary is N+3, + but the Ux string carries only ONE atom. representation_size must be N + (the build size), not 1 (parsed-atom count) and not N+3. + """ + ux = _build_ux_string( + ur_type=_ATHENS_UR_TYPE, + positions=[42.0], + raw_masses=[_FIXED_POINT_ONE], + ) + return _make_row(ux, ur_order=n, ur_order_core_library=n + 3) + + +class TestDistValueFromRowRepresentationSize(unittest.TestCase): + """representation_size is determined by UR_Order (build size), not parsed atoms.""" + + def test_uxhw_ttr_representation_size_uses_build_size(self) -> None: + """Real UxHw: UR_Order == N, UR_Order_CoreLibrary == N+3. + representation_size must be the build size N, not N+3.""" + for n in [4, 8, 16, 32, 64]: + with self.subTest(n=n): + row = _build_uxhw_ttr_row(n) + tagged = _dist_value_from_row(row) + self.assertEqual(tagged.representation_size, n) + + def test_external_ttr_representation_size_uses_build_size(self) -> None: + """External producer: UR_Order == UR_Order_CoreLibrary == N. + representation_size must be N (no over-subtraction).""" + for n in [4, 8, 16, 32, 64]: + with self.subTest(n=n): + row = _build_external_ttr_row(n) + tagged = _dist_value_from_row(row) + self.assertEqual(tagged.representation_size, n) + + def test_collapsed_scalar_ttr_representation_size_is_build_size(self) -> None: + """A Athens-N output that collapses to a single serialised atom (e.g. + Value at Risk) must still be labelled with the build size N and not the + parsed-atom count (1) and not N+3. Guards both the parsed-atom-count and + the sentinel-subtraction approaches.""" + for n in [2, 4, 8, 16]: + with self.subTest(n=n): + row = _build_collapsed_scalar_ttr_row(n) + tagged = _dist_value_from_row(row) + self.assertEqual(tagged.representation_size, n) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/distance_wasserstein_test.py b/src/signaloid/benchmarking/equivalent_mc/distance_wasserstein_test.py new file mode 100644 index 0000000..f7b91aa --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/distance_wasserstein_test.py @@ -0,0 +1,241 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import math +import unittest +from typing import TypedDict + +import numpy as np +import ot # type: ignore +from scipy.stats import wasserstein_distance # type: ignore + +from .conftest import gaussianMotherTTRTestcases, gaussianSamples + +from signaloid.distributional_distance.wasserstein import ( + wasserstein_1_uxhw_wrapper, + wasserstein_p_distance, +) +from signaloid.distributional.distributional import DistributionalValue + + +class TestWasserstein1(unittest.TestCase): + """Wasserstein-1 distance from an Athens/weighted distribution to its mother.""" + + def test_wasserstein_1_bootleg_zero(self) -> None: + """W1 is ~0 between a weighted distribution and a ground truth + resampled from its own (position, mass) pairs.""" + + class TestCase(TypedDict): + positions: list[float] + masses: list[float] + + testcases: list[TestCase] = [ + {"positions": [1, 2, 3, 4, 5], "masses": [0.1, 0.2, 0.1, 0.4, 0.2]}, + {"positions": [10, 20, 30, 40, 50], "masses": [0.1, 0.2, 0.1, 0.4, 0.2]}, + {"positions": [10, 20, 30, 40, 50], "masses": [0.3, 0.1, 0.1, 0.3, 0.2]}, + { + "positions": [-100, 20, 500, 1000, 0], + "masses": [0.3, 0.1, 0.1, 0.3, 0.2], + }, + ] + + for tc in testcases: + with self.subTest(positions=tc["positions"], masses=tc["masses"]): + sample_arrays = [] + for pos, mass in zip(tc["positions"], tc["masses"]): + # Blow up the sample array + sample_arrays.append( + np.full(shape=math.floor(mass * 100), fill_value=pos) + ) + + test_dist = DistributionalValue.from_weighted_samples( + tc["positions"], tc["masses"] + ) + samples = np.concatenate((sample_arrays), axis=None) + ground_truth = DistributionalValue.from_samples(samples) + result = wasserstein_1_uxhw_wrapper(test_dist, ground_truth) + + self.assertAlmostEqual(result, 0) + + def test_wasserstein_1_gaussian_mother_zero(self) -> None: + """W1 from a Gaussian Athens representation to its mother samples + is near zero (within a loose tolerance).""" + testcases = gaussianMotherTTRTestcases + samples = gaussianSamples + + ground_truth = DistributionalValue.from_samples(samples) + + for tc in testcases: + with self.subTest(size=len(tc["positions"])): + test_dist = DistributionalValue.from_weighted_samples( + tc["positions"], tc["masses"] + ) + result = wasserstein_1_uxhw_wrapper(test_dist, ground_truth) + + self.assertAlmostEqual( + result, + 0, + delta=0.3, + msg=f"Assertion failed for Athens-{len(tc['positions'])}", + ) + + # For uniform(0, 1): dd_pos(i) = (i + 0.5) / N for i in [0, N). + # + # The DD positions of an Athens of size N for a uniform(a, a + L) are: + # positions = [a + (i + 0.5) * L / N for i in range(N)] + # + # The Wasserstein distance between a uniform distribution with range L + # and its Athens of size N is W(L, N) = L / (4N). + def test_wasserstein_1_uniform_mother_zero(self) -> None: + """W1 between a uniform(a, a + L) and its size-N Athens matches the + closed form L / (4N).""" + rng = np.random.default_rng(20240624) + for a in np.arange(-5, 5, 1): # Uniform start point + for L in [0.5, 1, 2]: # Uniform length + samples = rng.uniform(a, a + L, size=100_000) + + for N in [4, 8, 16, 32, 64, 128]: # Representation size + with self.subTest(a=a, L=L, N=N): + positions = [a + (i + 0.5) * L / N for i in range(N)] + masses = [1 / N] * N + + # Check against formula + expected_distance = L / (4 * N) + + ground_truth = DistributionalValue.from_samples(samples) + test_dist = DistributionalValue.from_weighted_samples( + np.asarray(positions), masses + ) + result = wasserstein_1_uxhw_wrapper(test_dist, ground_truth) + + self.assertAlmostEqual( + result, + expected_distance, + delta=0.05, + msg=f"Expectation failed for a={a},L={L}, N={N}", + ) + + +class TestWassersteinPDistanceParity(unittest.TestCase): + """`wasserstein_p_distance` matches the scipy / POT calls it replaces. + + Pins numerical parity on the exact input shapes the benchmarking call + sites use: `equivalent_mc_utils._distance_func` (raw weighted arrays, + ``u_weights`` may be `None`) and the `adversary_distance` W1/W2 loops + (unweighted samples vs an optionally weighted ground truth). W1 is + checked against ``scipy.stats.wasserstein_distance``. W2 against + ``sqrt(ot.wasserstein_1d(..., p=2))``. + """ + + def setUp(self) -> None: + rng = np.random.default_rng(seed=20260701) + # Unweighted samples (the adversary-loop `u` and the `_distance_func` + # convergence samples, whose `u_weights` is None). + self.u_samples = rng.standard_normal(2000) + # Weighted ground truth (positions + non-uniform masses). + self.v_positions = rng.standard_normal(300) + 0.4 + v_masses = rng.random(300) + 0.05 + self.v_masses = v_masses / v_masses.sum() + # A fully weighted `u` for the general _distance_func signature. + u_masses = rng.random(2000) + 0.05 + self.u_masses = u_masses / u_masses.sum() + + def test_w1_unweighted_both_matches_scipy(self) -> None: + """p=1, u_masses=None and v_masses=None (adversary unweighted branch).""" + expected = float(wasserstein_distance(self.u_samples, self.v_positions)) + actual = wasserstein_p_distance( + self.u_samples, None, self.v_positions, None, p=1 + ) + self.assertAlmostEqual(actual, expected, places=9) + + def test_w1_weighted_v_matches_scipy(self) -> None: + """p=1, unweighted u vs weighted v (adversary weighted branch).""" + expected = float( + wasserstein_distance( + u_values=self.u_samples, + v_values=self.v_positions, + v_weights=self.v_masses, + ) + ) + actual = wasserstein_p_distance( + self.u_samples, None, self.v_positions, self.v_masses, p=1 + ) + self.assertAlmostEqual(actual, expected, places=9) + + def test_w1_both_weighted_matches_scipy(self) -> None: + """p=1, both sides weighted (general _distance_func signature).""" + expected = float( + wasserstein_distance( + self.u_samples, self.v_positions, self.u_masses, self.v_masses + ) + ) + actual = wasserstein_p_distance( + self.u_samples, self.u_masses, self.v_positions, self.v_masses, p=1 + ) + self.assertAlmostEqual(actual, expected, places=9) + + def test_w2_unweighted_both_matches_ot(self) -> None: + """p=2, u_masses=None and v_masses=None (adversary unweighted branch).""" + expected = float( + np.sqrt(ot.wasserstein_1d(self.u_samples, self.v_positions, p=2)) + ) + actual = wasserstein_p_distance( + self.u_samples, None, self.v_positions, None, p=2 + ) + self.assertAlmostEqual(actual, expected, places=9) + + def test_w2_weighted_v_matches_ot(self) -> None: + """p=2, unweighted u vs weighted v (adversary weighted branch).""" + expected = float( + np.sqrt( + ot.wasserstein_1d( + u_values=self.u_samples, + v_values=self.v_positions, + v_weights=self.v_masses, + p=2, + ) + ) + ) + actual = wasserstein_p_distance( + self.u_samples, None, self.v_positions, self.v_masses, p=2 + ) + self.assertAlmostEqual(actual, expected, places=9) + + def test_w2_both_weighted_matches_ot(self) -> None: + """p=2, both sides weighted (general _distance_func signature).""" + expected = float( + np.sqrt( + ot.wasserstein_1d( + self.u_samples, + self.v_positions, + self.u_masses, + self.v_masses, + p=2, + ) + ) + ) + actual = wasserstein_p_distance( + self.u_samples, self.u_masses, self.v_positions, self.v_masses, p=2 + ) + self.assertAlmostEqual(actual, expected, places=9) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/equiv_mc_test.py b/src/signaloid/benchmarking/equivalent_mc/equiv_mc_test.py new file mode 100644 index 0000000..e5de231 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/equiv_mc_test.py @@ -0,0 +1,415 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +import tempfile +import unittest +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +from signaloid.benchmarking.automation.analysis import compute_emcc_predictions +from signaloid.benchmarking.automation.database_generator import ( + generate_database_from_mc_samples, + generate_database_from_weighted_samples, +) +from signaloid.benchmarking.config import ( + DistanceMetrics, + VariableTypes, + ReportingMethods, + RepresentationTypes, + BenchmarkingVariables, + EquivMC, +) +from signaloid.benchmarking.equivalent_mc.equivalent_mc_main import ( + load_data_and_compute_equivalent_mc, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + UxhwDistanceRecord, +) + +from .conftest import ( + SyntheticUxhwConfig, + SyntheticUxhwExpression, + build_uxhw_database, +) + +# Expressions used to synthesise the ground-truth + adversary databases. These +# mirror the (name, description) pairs the original committed ``inputs/*.db`` +# fixtures carried. +_SYNTHETIC_EQUIV_MC_EXPRESSIONS = [ + ("outputDistributions[0]", "Stock Price at Maturity"), + ("outputDistributions[1]", "Call Option"), + ("outputDistributions[2]", "Put Option"), +] + +test_1_dict = { + "distance_type": DistanceMetrics.WASSERSTEIN_1, + "gt_path": "inputs/ground-truth-weighted-samples.db", + "pre_compute": True, + "plots": True, + "adaptive": True, + "use_binned": True, +} +test_2_dict = { + "distance_type": DistanceMetrics.WASSERSTEIN_2, + "gt_path": "inputs/ground-truth-weighted-samples.db", + "pre_compute": False, + "plots": False, + "adaptive": True, + "use_binned": False, +} +test_3_dict = { + "distance_type": DistanceMetrics.WASSERSTEIN_1, + "gt_path": "inputs/ground-truth-weighted-samples.db", + "pre_compute": False, + "plots": False, + "adaptive": False, + "use_binned": True, +} +config_options: list[dict[str, Any]] = [test_1_dict, test_2_dict, test_3_dict] + + +def _build_synthetic_equiv_mc_dbs(directory: Path) -> dict[str, str]: + """Generate tiny ground-truth + adversary SQLite DBs for the equiv-MC smoke + test, replacing the large committed ``inputs/*.db`` fixtures. + + Uses the package's own ``database_generator`` writers so the schema always + matches the loaders. The data is synthetic, seeded and small: the equiv-MC + smoke test asserts only that the pipeline runs end to end and produces + structurally valid EMCC output, not specific numbers, so fidelity to the + original captured distributions is unnecessary. + """ + rng = np.random.default_rng(20260623) + gt_variables: list[BenchmarkingVariable] = [] + adversary_variables: list[BenchmarkingVariable] = [] + for index, (name, description) in enumerate(_SYNTHETIC_EQUIV_MC_EXPRESSIONS): + centre = 100.0 + 10.0 * index + # Ground truth: a small weighted (positions, masses) distribution. + positions = np.linspace(centre - 45.0, centre + 45.0, 64) + masses = np.exp(-0.5 * ((positions - centre) / 15.0) ** 2) + masses = masses / masses.sum() + gt = BenchmarkingVariable( + name=name, description=description, value_id=f"synthetic-{index}" + ) + gt.distribution_samples.set_weighted_values( + [float(p) for p in positions], [float(m) for m in masses] + ) + gt_variables.append(gt) + # Adversary: a small Monte-Carlo sample pool for the same expression. + samples = rng.normal(loc=centre, scale=15.0, size=5_000) + adversary = BenchmarkingVariable( + name=name, description=description, value_id=f"synthetic-{index}" + ) + adversary.distribution_samples.set_values([float(s) for s in samples]) + adversary_variables.append(adversary) + gt_path = directory / "ground-truth-weighted-samples.db" + adversary_path = directory / "adversary.db" + generate_database_from_weighted_samples(str(gt_path), gt_variables) + generate_database_from_mc_samples(str(adversary_path), adversary_variables) + return {"gt_path": str(gt_path), "adv_path": str(adversary_path)} + + +def _build_synthetic_tracing_db(directory: Path) -> str: + """Generate a tiny Athens-16 UxHw tracing database for the equiv-MC smoke + test, replacing the large committed ``inputs/tracing.db`` fixture. + + Carries the same three expressions (at the same Gaussian centres as the + synthetic ground truth) under both correlation-tracking statuses, matching + the shape the original fixture provided. + """ + expressions = [ + SyntheticUxhwExpression( + name=name, + value_id=f"synthetic-{index}", + centre=100.0 + 10.0 * index, + scale=15.0, + ) + for index, (name, _description) in enumerate(_SYNTHETIC_EQUIV_MC_EXPRESSIONS) + ] + configs = [ + SyntheticUxhwConfig(RepresentationTypes.ATHENS, 16, "Disabled"), + SyntheticUxhwConfig(RepresentationTypes.ATHENS, 16, "Autocorrelation"), + ] + tracing_path = directory / "tracing.db" + build_uxhw_database(str(tracing_path), EquivMC.TRACING_TABLE, expressions, configs) + return str(tracing_path) + + +def _build_benchmarking_variables( + uxhw_distance_file: str, +) -> list[BenchmarkingVariable]: + """Build the three benchmarking variables and preload their UxHw distances + from ``inputs/uxhw_distances.csv`` (the equiv-MC pipeline needs them).""" + benchmarking_variables: list[BenchmarkingVariable] = [ + BenchmarkingVariable( + name="outputDistributions[0]", + type=VariableTypes.DISTRIBUTION, + description="Stock Price at Maturity", + ), + BenchmarkingVariable( + name="outputDistributions[1]", + type=VariableTypes.DISTRIBUTION, + description="Call Option", + ), + BenchmarkingVariable( + name="outputDistributions[2]", + type=VariableTypes.DISTRIBUTION, + description="Put Option", + ), + ] + + for variable in benchmarking_variables: + if not variable.uxhw_distances.records: + print( + "Warning: UxHw distance distribution data not found in Benchmark object." + ) + print(f"Loading UxHw distance data from {uxhw_distance_file}") + + try: + # Load and group data by variable name + distance_df = pd.read_csv(uxhw_distance_file) + distance_dict = distance_df.to_dict("records") + + # Create a lookup dict for faster access + data_by_variable: dict = {} + for record in distance_dict: + var_name = record[BenchmarkingVariables.VARIABLE_DESCRIPTION] + data_by_variable.setdefault(var_name, []).append(record) + + # Now USE the lookup dict to populate the variable + variable_records = data_by_variable.get(variable.description, []) + + # Append all matching records to the list + if variable_records: + for record in variable_records: + variable.uxhw_distances.records.append( + UxhwDistanceRecord( + uxhw_conf=record.get(BenchmarkingVariables.UXHW_CONF), + uxhw_distance=record.get( + BenchmarkingVariables.UXHW_DISTANCE + ), + uxhw_binned_distance=record.get( + BenchmarkingVariables.UXHW_BINNED_DISTANCE + ), + ) + ) + else: + print( + f"Warning: No UxHw distance data found for '{variable.description}'" + ) + + except FileNotFoundError: + print(f"Error: File not found at {uxhw_distance_file}") + print("Cannot continue without UxHw distances data. Terminating.") + raise + return benchmarking_variables + + +_ASYMPTOTIC_MEAN = 200.0 +_ASYMPTOTIC_QUANTILE_95 = 200.0 + +# Deterministic fallback UxHw distance for any variable the CSV does not cover. +# Note `inputs/uxhw_distances.csv` lists "Stock Price At Maturity" (capital +# "At") which does not match the variable's "Stock Price at Maturity" (lowercase +# "at"), so that variable gets this record. +_FALLBACK_UXHW_CONF = "Athens-16" +_FALLBACK_UXHW_DISTANCE = 10.0 + + +def _populate_emcc_prediction_inputs( + benchmarking_variables: list[BenchmarkingVariable], +) -> None: + """Populate the real inputs that ``compute_emcc_predictions`` consumes, so + its loaders (``load_uxhw_distances`` → ``load_asymptotic_dist``) early-return + without reading any file: + + - ``asymptotic_distribution`` — ``load_asymptotic_dist`` early-returns only + when EVERY variable has ``quantile_95 is not None``, so set deterministic + positive ``mean`` and ``quantile_95`` (the fields ``value_for`` reads for + ``ReportingMethods.MEAN`` / ``ReportingMethods.QUANTILE_95``) on each. + - ``uxhw_distances.records`` — already preloaded from the CSV by + ``_build_benchmarking_variables``. For any variable the CSV does not cover + (e.g. "Stock Price at Maturity"), append a single deterministic record so + every variable has >= 1 record and ``load_uxhw_distances`` early-returns. + + With these populated, ``compute_emcc_predictions`` runs entirely on + in-memory inputs and genuinely computes ``EMCC Predicted`` per + variable x reporting method. + """ + for variable in benchmarking_variables: + variable.asymptotic_distribution.mean = _ASYMPTOTIC_MEAN + variable.asymptotic_distribution.quantile_95 = _ASYMPTOTIC_QUANTILE_95 + if not variable.uxhw_distances.records: + variable.uxhw_distances.records.append( + UxhwDistanceRecord( + uxhw_conf=_FALLBACK_UXHW_CONF, + uxhw_distance=_FALLBACK_UXHW_DISTANCE, + uxhw_binned_distance=_FALLBACK_UXHW_DISTANCE, + ) + ) + + +class TestEquivMc(unittest.TestCase): + """End-to-end smoke test for the equivalent-Monte-Carlo pipeline against + synthetic ground-truth + adversary databases.""" + + def test_equiv_mc(self) -> None: + current_dir = os.path.dirname(os.path.abspath(__file__)) + uxhw_distance_file = os.path.join(current_dir, "inputs/uxhw_distances.csv") + + for options in config_options: + with self.subTest( + distance_type=options["distance_type"], + pre_compute=options["pre_compute"], + plots=options["plots"], + adaptive=options["adaptive"], + use_binned=options["use_binned"], + ): + # Per-config isolation: a fresh temp dir + fresh synthetic GT / + # adversary DBs + fresh benchmarking variables, with cwd moved + # into the temp dir so PNG / CSV output from one config cannot + # bleed into another. The input DB / CSV paths above are + # absolute, so they are unaffected by the chdir. + with tempfile.TemporaryDirectory() as tmp_dir: + original_cwd = os.getcwd() + os.chdir(tmp_dir) + try: + dbs = _build_synthetic_equiv_mc_dbs(Path(tmp_dir)) + gt_path = dbs["gt_path"] + adv_path = dbs["adv_path"] + tracing_path = _build_synthetic_tracing_db(Path(tmp_dir)) + benchmarking_variables = _build_benchmarking_variables( + uxhw_distance_file + ) + reporting_methods = [ + ReportingMethods.MEAN, + ReportingMethods.QUANTILE_95, + ] + # Populate real inputs, then run the REAL + # compute_emcc_predictions so emcc_data (including "EMCC + # Predicted") is genuinely produced by production code. + # Its loaders early-return on the pre-populated + # asymptotic / UxHw data, so asymptotic_dist_file is + # never read (see _populate_emcc_prediction_inputs). + _populate_emcc_prediction_inputs(benchmarking_variables) + emcc_predictions_file = os.path.join( + tmp_dir, "emcc_predictions.csv" + ) + asymptotic_dist_file = os.path.join( + tmp_dir, "asymptotic_distances.csv" + ) + compute_emcc_predictions( + benchmarking_variables=benchmarking_variables, + use_binned_uxhw=options["use_binned"], + reporting_methods=reporting_methods, + output_data_file=emcc_predictions_file, + uxhw_distance_file=uxhw_distance_file, + asymptotic_dist_file=asymptotic_dist_file, + distance_type=options["distance_type"], + ) + load_data_and_compute_equivalent_mc( + { + "ground_truth_database_path": gt_path, + "ground_truth_table_name": "WeightedSamples", + "benchmarking_variables": benchmarking_variables, + "adversary_database_path": adv_path, + "adversary_table_name": "MonteCarlo", + "adversary_size_step": 100, + "adversary_size_min": 1, + "adversary_size_max": 1000, + "uxhw_database_path": tracing_path, + "uxhw_table_names": [EquivMC.TRACING_TABLE], + "uxhw_ur_types": [RepresentationTypes.ATHENS], + "uxhw_ur_sizes": [16], + "correlations": [], + "distance_type": options["distance_type"], + "n_processes": 1, + "n_adversaries": 4, + "output_file": "equivalent_mc.csv", + "plot_comparison_distributions": options["plots"], + "use_clt": options["pre_compute"], + "auto_prefix": True, + "reporting_methods": reporting_methods, + "ground_truth_type": "WeightedSamples", + "plot_adversary_distances": options["plots"], + "use_adaptive_steps": options["adaptive"], + "plot_distributions": options["plots"], + "use_binned_uxhw": options["use_binned"], + "plots_dir": "", + } + ) + + self._assert_emcc_structure(benchmarking_variables) + if options["plots"]: + self._assert_plots_written(Path(tmp_dir)) + finally: + os.chdir(original_cwd) + + def _assert_emcc_structure( + self, benchmarking_variables: list[BenchmarkingVariable] + ) -> None: + """Assert that every variable produced structurally valid EMCC output: + non-empty ``emcc_data`` with exactly one record per reporting method, + each carrying a positive EMCC-predicted count and (when present) a + well-formed ``% MC beats UxHw`` proportion.""" + # The pipeline was passed exactly these two reporting methods. + reporting_methods = [ReportingMethods.MEAN, ReportingMethods.QUANTILE_95] + for variable in benchmarking_variables: + emcc_data = variable.emcc_results.emcc_data + self.assertTrue( + emcc_data, + f"emcc_data is empty for variable '{variable.description}'", + ) + # Exactly one record per reporting method. + self.assertEqual(len(emcc_data), len(reporting_methods)) + observed_methods = sorted( + str(record[EquivMC.REPORTING_METHOD]) for record in emcc_data + ) + self.assertEqual(observed_methods, sorted(reporting_methods)) + + for record in emcc_data: + self.assertIn(EquivMC.EMCC_PREDICTED, record) + emcc_predicted = record[EquivMC.EMCC_PREDICTED] + self.assertGreaterEqual(float(emcc_predicted), 1.0) + + # The pipeline-under-test fills in the equivalent MC count. + self.assertIn(EquivMC.EMCC, record) + self.assertGreaterEqual(float(record[EquivMC.EMCC]), 1.0) + + if EquivMC.PERCENTAGE_MC_BEATS_UXHW in record: + proportion = float(record[EquivMC.PERCENTAGE_MC_BEATS_UXHW]) + self.assertGreaterEqual(proportion, 0.0) + self.assertLessEqual(proportion, 1.0) + + def _assert_plots_written(self, output_dir: Path) -> None: + """Assert at least one ``.png`` was written under the temp dir when + plotting is enabled.""" + pngs = list(output_dir.rglob("*.png")) + self.assertTrue( + pngs, + f"expected at least one .png under {output_dir}, found none", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_main.py b/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_main.py new file mode 100644 index 0000000..c8f2b59 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_main.py @@ -0,0 +1,329 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +from os import path +from typing import TypedDict +from tabulate import tabulate +import signaloid.distributional_information_plotting.plot_wrapper as plot_wrapper +from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import ( + PlotData, +) +from signaloid.benchmarking.equivalent_mc.equivalent_monte_carlo import ( + EquivalentMonteCarlo, +) +from signaloid.benchmarking.equivalent_mc.load import ( + _load_uxhw_distributions, + _load_ground_truth, +) +from signaloid.benchmarking.equivalent_mc.equivalent_mc_utils import ( + _generate_adversary_list, + _generate_equivalent_mc_plots, + _compute_shared_xlim, +) +from signaloid.benchmarking.config import ( + RepresentationTypes, +) + +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, +) +from signaloid.benchmarking.distribution_helpers.representation_health import ( + _representation_blow_up_reason, +) + +# Printed in the Mean / Variance columns for a blown-up (degraded) +# representation, in place of the overflowing statistics. +BLOW_UP_TABLE_TOKEN = "blow-up / excluded" + + +class LoadDataComputeEquivalentMCArgs(TypedDict): + """ + Bundle of inputs for :func:`load_data_and_compute_equivalent_mc`: database + locations/tables, the UxHw sweep parameters, distance/reporting settings, + and plotting toggles. + """ + + benchmarking_variables: list[BenchmarkingVariable] + ground_truth_database_path: str + ground_truth_table_name: str + adversary_database_path: str + adversary_table_name: str + adversary_size_step: int + adversary_size_min: int + adversary_size_max: int | None + uxhw_database_path: str + uxhw_table_names: list[str] + uxhw_ur_types: list[str] + uxhw_ur_sizes: list[int] + correlations: list[str] + distance_type: str + n_processes: int + n_adversaries: int + output_file: str | None + auto_prefix: bool + plot_comparison_distributions: bool + use_clt: bool + reporting_methods: list[str] + ground_truth_type: str + plot_adversary_distances: bool + use_adaptive_steps: bool + plot_distributions: bool + use_binned_uxhw: bool + plots_dir: str + + +def _print_uxhw_table(uxhw: list[TaggedDistributionalValue]) -> None: + """ + Print a table of the loaded UxHw configurations and their mean/variance. + + Blown-up representations are shown as ``"blow-up / excluded"`` instead of + their (overflowing) statistics. + + Args: + uxhw: The loaded UxHw configurations to tabulate. + """ + table_data = [["UR_type", "UR_order", "Correlation Tracking", "Mean", "Variance"]] + alignments = ["left", "right", "left", "left", "left"] + # representation_type / correlation_tracking come from the carrier metadata + # (never off a Distribution). Numeric stats come from the carried dv. + for uxhw_conf in uxhw: + # The dv is freshly re-loaded here (it does not carry the Step-6 + # inf-distance flag), so re-detect a blow-up. A blown representation + # parks mass at ~1e303, where reading .mean / .variance overflows and + # crashes the sweep. Emit a "blow-up / excluded" row instead. + if _representation_blow_up_reason(uxhw_conf.dv) is not None: + mean_cell: str = BLOW_UP_TABLE_TOKEN + variance_cell: str = BLOW_UP_TABLE_TOKEN + else: + mean_cell = str(uxhw_conf.dv.mean) + variance_cell = str(uxhw_conf.dv.variance) + table_data.append( + [ + str(uxhw_conf.representation_type), + str(uxhw_conf.dv.UR_order), + str(uxhw_conf.correlation_tracking), + mean_cell, + variance_cell, + ] + ) + print( + tabulate(table_data, headers="firstrow", tablefmt="simple", colalign=alignments) + ) + print() + + +def _find_common_prefix(args: LoadDataComputeEquivalentMCArgs) -> str: + """ + Derive a shared filename prefix from the database paths. + + Args: + args: The equivalent-MC arguments (uses the database paths and the + ``auto_prefix`` flag). + + Returns: + The common basename prefix of the databases, or ``""`` when + ``auto_prefix`` is disabled. + """ + database_common_prefix = "" + if args["auto_prefix"]: + args_list = [args["ground_truth_database_path"], args["uxhw_database_path"]] + args_list.append(args["adversary_database_path"]) + database_common_prefix = path.basename(path.commonprefix(args_list)) + print(f"Detected common prefix from databases: '{database_common_prefix}'") + return database_common_prefix + + +def _resolve_plot_path(filename: str, plots_dir: str) -> str: + """ + Prepend ``plots_dir`` to a plot filename when set. + + Args: + filename: The plot filename. + plots_dir: Directory to place the plot in. Unchanged when empty. + + Returns: + The joined path, or ``filename`` unchanged when ``plots_dir`` is empty. + """ + if plots_dir: + return os.path.join(plots_dir, filename) + return filename + + +def _generate_distribution_plots( + variable: BenchmarkingVariable, + ground_truth: TaggedDistributionalValue, + uxhw_from_table: list[TaggedDistributionalValue], + plots_dir: str, + xlim: tuple[float, float] | None = None, +) -> None: + """ + Generate the ground-truth plot and a plot per UxHw configuration. + + Args: + variable: The benchmarking variable being plotted. + ground_truth: The ground-truth distribution. + uxhw_from_table: The UxHw configurations loaded from the database. + plots_dir: Directory to write the plots into. Defaults to the current working directory when empty. + xlim: Limits applied to the x-axis for every plot. ``None`` falls back to per-plot auto-scaling. + """ + try: + print("Generating Ground Truth Plot.") + gt_plot_path = _resolve_plot_path( + f"{variable.formatted_description}-ground-truth.png", plots_dir + ) + plot_wrapper.plot( + plot_data=PlotData(ground_truth.dv), + path=gt_plot_path, + save=True, + xlim=xlim, + ) + except Exception as e: + print( + f"Failed to generate ground truth plot for " + f"{variable.description} with error: {e}" + ) + + # The per-config filename embeds the carrier's config string (its repr, + # e.g. "Athens-16" or "Athens-16-Autocorrelation") so it matches exactly + # what report_writer reconstructs from the results table. + for dist in uxhw_from_table: + assert dist.correlation_tracking is not None + plot_name = _resolve_plot_path( + f"{variable.formatted_description}-{dist}.png", + plots_dir, + ) + try: + plot_wrapper.plot( + plot_data=PlotData(dist.dv), + path=plot_name, + save=True, + xlim=xlim, + ) + except Exception as e: + print(f"Failed to generate plot {plot_name} with error: {e}") + + +def load_data_and_compute_equivalent_mc(args: LoadDataComputeEquivalentMCArgs) -> None: + """ + Run the full equivalent-MC analysis for each benchmarking variable. + + For every variable: loads the ground truth, adversaries, and UxHw + distributions from the databases, prints the UxHw table, computes and + reports the EMCC, and generates the configured plots. + + Args: + args: The equivalent-MC arguments (database locations, sweep + parameters, distance/reporting settings, and plotting toggles). + """ + optional_args = {} + database_common_prefix = None + n_processes = args["n_processes"] + n_adversaries = args["n_adversaries"] + reporting_methods = args["reporting_methods"] + database_common_prefix = _find_common_prefix(args=args) + plots_dir = args["plots_dir"] + ground_truth_is_mc = ( + True if args["ground_truth_type"] == RepresentationTypes.MONTE_CARLO else False + ) + distance_type = args["distance_type"] + + # Loop through variables + for variable in args["benchmarking_variables"]: + print("=================================================================") + print("Traced expression:", variable.name) + print("Expression description:", variable.description) + print("Expression type:", variable.type) + print() + + # Load ground truth + ground_truth = _load_ground_truth( + db_path=args["ground_truth_database_path"], + table=args["ground_truth_table_name"], + target_expression=variable.name, + expression_type=variable.type, + monte_carlo=ground_truth_is_mc, + ) + + # Load adversaries + adversary_list: list[TaggedDistributionalValue] = _generate_adversary_list( + args=args, target_expr=variable.name, expr_type=variable.type + ) + optional_args["adversary_mc"] = adversary_list + + # Get all distributions from databases + uxhw = _load_uxhw_distributions( + db_path=args["uxhw_database_path"], + tables=args["uxhw_table_names"], + target_expr=variable.name, + ur_types=args["uxhw_ur_types"], + ur_sizes=args["uxhw_ur_sizes"], + ) + + # A single x-axis domain shared by the ground-truth, UxHw, and + # representative MC plots. The adversary distributions contribute an + # outlier-robust range so Monte Carlo tails do not blow up the domain. + shared_xlim = None + if args["plot_distributions"]: + shared_xlim = _compute_shared_xlim( + ground_truth=ground_truth, + uxhw=uxhw, + adversaries=adversary_list, + ) + _generate_distribution_plots( + variable, ground_truth, uxhw, plots_dir, xlim=shared_xlim + ) + + _print_uxhw_table(uxhw=uxhw) + + # Compute + emcc_prefix = database_common_prefix + if plots_dir and emcc_prefix: + emcc_prefix = os.path.join(plots_dir, emcc_prefix) + emcc = EquivalentMonteCarlo( + ground_truth=ground_truth, + uxhw_data=uxhw, + variable=variable, + distance_type=distance_type, + use_binned_uxhw=args["use_binned_uxhw"], + n_processes=n_processes, + n_adversaries=n_adversaries, + prefix=emcc_prefix, + reporting_methods=reporting_methods, + use_clt=args["use_clt"], + adversary_size_step=args["adversary_size_step"], + adversary_size_min=args["adversary_size_min"], + adversary_size_max=args["adversary_size_max"], + **optional_args, + ) + + emcc.compute_distance_data(use_adaptive_steps=args["use_adaptive_steps"]) + emcc.compute_and_report_emmc() + + # Generate plots + _generate_equivalent_mc_plots( + emcc=emcc, + args=args, + target_expr=variable.name, + expr_description=variable.description, + distance_type=distance_type, + xlim=shared_xlim, + ) diff --git a/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_utils.py b/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_utils.py new file mode 100644 index 0000000..da7b8a0 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/equivalent_mc_utils.py @@ -0,0 +1,920 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os +from typing import TYPE_CHECKING +import numpy as np +from scipy.stats import halfnorm, anderson # type: ignore +from scipy.integrate import trapezoid # type: ignore +from signaloid.benchmarking.types import TaggedDistributionalValue +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.distribution_helpers.density import _histogram_pdf +from signaloid.benchmarking.config import ( + DistanceMetrics, + VariableTypes, + EquivMC, + BenchmarkingVariables, + RepresentationTypes, +) +import signaloid.distributional_information_plotting.plot_wrapper as plot_wrapper +from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import ( + PlotData, +) +from signaloid.distributional_distance.wasserstein import wasserstein_p_distance +from numpy.typing import ArrayLike +import pandas as pd +import re +import math +from heapq import merge +from signaloid.benchmarking.equivalent_mc.load import ( + _load_mc_scalar, + _load_mc, +) +import numpy.typing as npt + +if TYPE_CHECKING: + from signaloid.benchmarking.equivalent_mc.equivalent_monte_carlo import ( + EquivalentMonteCarlo, + ) + from signaloid.benchmarking.equivalent_mc.equivalent_mc_main import ( + LoadDataComputeEquivalentMCArgs, + ) +import matplotlib.pyplot as plt + +# Quantile bounds used to derive an outlier-robust plotting range for +# sample-based distributions (e.g. Monte Carlo adversaries), so that a few +# extreme samples in the tails do not stretch the shared x-axis domain. +_ROBUST_RANGE_LOWER_QUANTILE = 0.005 +_ROBUST_RANGE_UPPER_QUANTILE = 0.995 + + +def _distribution_range( + dv: DistributionalValue, robust: bool +) -> tuple[float, float] | None: + """ + Compute the x-axis range spanned by a distribution. + + Args: + dv: The distribution whose support range to measure. + robust: When True, clip the range to the + [``_ROBUST_RANGE_LOWER_QUANTILE``, ``_ROBUST_RANGE_UPPER_QUANTILE``] + quantiles so extreme outliers (common in Monte Carlo samples) do not + stretch the domain. When False, use the full support. + + Returns: + The ``(low, high)`` range, or ``None`` if it cannot be determined + (e.g. empty distribution or non-finite bounds). + """ + treat_as_samples = ( + getattr(dv, "representation_type", None) == RepresentationTypes.SAMPLES + ) + lower, upper = ( + (_ROBUST_RANGE_LOWER_QUANTILE, _ROBUST_RANGE_UPPER_QUANTILE) + if robust + else (0.0, 1.0) + ) + try: + low, high = dv.inverse_cdf( + np.array([lower, upper]), treat_as_samples=treat_as_samples + ) + except Exception: + return None + if not (math.isfinite(low) and math.isfinite(high)) or high < low: + return None + return float(low), float(high) + + +def _compute_shared_xlim( + ground_truth: TaggedDistributionalValue, + uxhw: list[TaggedDistributionalValue], + adversaries: list[TaggedDistributionalValue], + padding_fraction: float = 0.05, +) -> tuple[float, float] | None: + """ + Compute a single x-axis domain shared by the ground-truth, UxHw, and + adversary/Monte-Carlo distribution plots so they are directly comparable. + + The ground-truth and UxHw distributions contribute their full support (they + are trustworthy binned representations), while the adversary distributions + contribute an outlier-robust range so that Monte Carlo tails do not blow up + the domain. The union of these ranges, padded by ``padding_fraction`` on + each side (matching the padding ``plot_wrapper.plot`` applies when it + auto-scales), is returned. + + Args: + ground_truth: The ground-truth distribution. + uxhw: The UxHw configurations to be plotted. + adversaries: The adversary distributions the Monte Carlo plots are + drawn from. + padding_fraction: Fraction of the total range to pad on each side. + + Returns: + The shared ``(min, max)`` x-limits, or ``None`` if no range could be + determined (callers then fall back to per-plot auto-scaling). + """ + ranges: list[tuple[float, float]] = [] + + gt_range = _distribution_range(ground_truth.dv, robust=False) + if gt_range is not None: + ranges.append(gt_range) + + for dist in uxhw: + dist_range = _distribution_range(dist.dv, robust=False) + if dist_range is not None: + ranges.append(dist_range) + + for adversary in adversaries: + adversary_range = _distribution_range(adversary.dv, robust=True) + if adversary_range is not None: + ranges.append(adversary_range) + + if not ranges: + return None + + min_x = min(low for low, _ in ranges) + max_x = max(high for _, high in ranges) + range_spacing = padding_fraction * (max_x - min_x) + return (min_x - range_spacing, max_x + range_spacing) + + +def _compute_asymptotic_distribution_brownian_bridge( + ground_truth: TaggedDistributionalValue, + num_points: int, + num_samples: int, + distance_type: str, +) -> DistributionalValue: + """ + Compute the asymptotic distance distribution for the given metric. + + Dispatches to the Wasserstein-1, Wasserstein-2, or absolute-mean-deviation + variant based on ``distance_type``. + + Args: + ground_truth: The ground-truth distribution. + num_points: Number of discretization points for the integral. + num_samples: Number of Monte Carlo samples. + distance_type: Which asymptotic distribution to compute. + + Returns: + The asymptotic distance distribution. + + Raises: + ValueError: If ``distance_type`` is not supported. + """ + + if distance_type == DistanceMetrics.WASSERSTEIN_1: + dist = _compute_wasserstein_1_asymptotic_distribution_brownian_bridge( + ground_truth, num_points=num_points, num_samples=num_samples + ) + elif distance_type == DistanceMetrics.WASSERSTEIN_2: + dist = _compute_wasserstein_2_asymptotic_distribution_brownian_bridge( + ground_truth, num_points=num_points, num_samples=num_samples + ) + elif distance_type == "AbsoluteMeanDeviation": + dist = _compute_absolute_mean_deviation_asymptotic_distribution( + ground_truth, num_points + ) + else: + raise ValueError(f"Unsupported distance_type: {distance_type!r}") + + return dist + + +def _compute_wasserstein_1_asymptotic_distribution_brownian_bridge( + ground_truth: TaggedDistributionalValue, num_points: int, num_samples: int +) -> DistributionalValue: + """ + Compute the distribution of the integral of |B(t)| dQ(t) over t in [0, 1], + where B(t) is a Brownian bridge and Q(t) is the ground truth's quantile + function. + + Args: + ground_truth: The ground-truth distribution. + num_points: Number of discretization points for the integral. + num_samples: Number of Monte Carlo samples. + + Returns: + The asymptotic Wasserstein-1 distance distribution. + """ + small_number = 1e-5 # Prevents quantile function blowing up + + # The carried DistributionalValue has no `representation_type` attribute, so + # `treat_as_samples` is False and we take the mass-weighted branch. This is + # identical to the prior behaviour for MonteCarlo / WeightedSamples ground + # truths (neither is the "Samples" type that selects the other branch). + treat_as_samples = ( + getattr(ground_truth.dv, "representation_type", None) + == RepresentationTypes.SAMPLES + ) + t = np.linspace(small_number, 1 - small_number, num_points) + # inverse_cdf is the vectorised inverse-CDF, so it accepts an array + # (unlike the scalar-only ``quantile``). + quantile_vals = ground_truth.dv.inverse_cdf(t, treat_as_samples=treat_as_samples) + + pdf_vals = np.asarray( + _histogram_pdf( + ground_truth.dv, quantile_vals, treat_as_samples=treat_as_samples + ) + ) + pdf_vals = pdf_vals[pdf_vals > 0] + + values = 1 / pdf_vals + + # Call specific function for performing integral + brownian_bridge_integrals = _compute_integral_of_brownian_bridge( + len(values), num_samples, values + ) + dist = DistributionalValue.from_samples(np.array(brownian_bridge_integrals)) + # Tag as SAMPLES so a downstream consumer deriving `treat_as_samples` from + # `representation_type` takes the samples branch (`np.quantile` inverse- CDF + # / auto-binned pdf). preserving the prior behaviour when this returned a + # `Distribution` built via `from_samples`. + setattr(dist, "representation_type", RepresentationTypes.SAMPLES) + + return dist + + +def _compute_wasserstein_2_asymptotic_distribution_brownian_bridge( + ground_truth: TaggedDistributionalValue, num_points: int, num_samples: int +) -> DistributionalValue: + """ + Compute the distribution of sqrt(integral of |B(t)|^2 dQ(t)) over t in + [0, 1], where B(t) is a Brownian bridge and Q(t) is the ground truth's + quantile function. + + Args: + ground_truth: The ground-truth distribution. + num_points: Number of discretization points for the integral. + num_samples: Number of Monte Carlo samples. + + Returns: + The asymptotic Wasserstein-2 distance distribution. + """ + small_number = 1e-5 # Prevents quantile function blowing up + + # See the W1 variant for why the carried DistributionalValue (no + # ``representation_type``) takes the mass-weighted branch. + treat_as_samples = ( + getattr(ground_truth.dv, "representation_type", None) + == RepresentationTypes.SAMPLES + ) + t = np.linspace(small_number, 1 - small_number, num_points) + # inverse_cdf is the vectorised inverse-CDF, so it accepts an array + # (unlike the scalar-only ``quantile``). + quantile_vals = ground_truth.dv.inverse_cdf(t, treat_as_samples=treat_as_samples) + values = 1 / _histogram_pdf( + ground_truth.dv, quantile_vals, treat_as_samples=treat_as_samples + ) + + # Call specific function for performing integral + brownian_bridge_integrals = _compute_integral_of_brownian_bridge_squared( + num_points, num_samples, np.asarray(values) + ) + brownian_bridge_integrals = np.sqrt(np.array(brownian_bridge_integrals)) + + dist = DistributionalValue.from_samples(brownian_bridge_integrals) + # Tag as SAMPLES so a downstream consumer deriving `treat_as_samples` from + # `representation_type` takes the samples branch (`np.quantile` inverse- CDF + # / auto-binned pdf). This preserves the prior behaviour when this returned a + # `Distribution` built via `from_samples`. + setattr(dist, "representation_type", RepresentationTypes.SAMPLES) + + return dist + + +def _compute_absolute_mean_deviation_asymptotic_distribution( + ground_truth: TaggedDistributionalValue, num_points: int +) -> DistributionalValue: + """ + Compute the distribution of the absolute mean deviation |E(X) - E(Xₙ)|, + where Xₙ is the empirical process simulating X with n samples. This quantity + is distributed as HalfNormal(sigma * √2 / √π). + + Args: + ground_truth: The ground-truth distribution whose mean and standard + deviation parameterize the asymptotic half-normal distribution. + num_points: Number of discretization points for the integral. + + Returns: + The asymptotic half-normal distance distribution. + """ + positions = ground_truth.dv.positions + masses = ground_truth.dv.masses + mean = np.dot(positions, masses) + std = np.sqrt(np.dot(positions**2, masses) - mean**2) + + positions = np.linspace(0, 12 * std, num_points) + weights = halfnorm.pdf(x=positions, scale=std) + + dist = DistributionalValue.from_weighted_samples(positions, weights) + + return dist + + +def _simulate_brownian_bridge(N: int) -> tuple[np.ndarray, np.ndarray]: + """ + Simulate a standard Brownian bridge path over t in [0, 1]. + + Args: + N: Number of discretization points. + + Returns: + The time points and the simulated Brownian bridge values. + """ + # Generate discrete time values betwen 0 and 1 + t = np.linspace(0, 1, N + 1) + + # Simulate regular Wiener process + W = np.random.normal(0, np.sqrt(np.diff(np.insert(t, 0, 0))), N + 1) + + # Compute Browian Bridge (pinned Wiener process) + B = np.cumsum(W) + B = B - t * B[-1] + + return t, B + + +def _compute_integral_of_brownian_bridge( + num_points: int, num_samples: int, quantile_derivative_values: np.ndarray +) -> list[float]: + """ + Compute the distribution of the integral of |B(t)| dQ(t) over t in [0, 1], + where B(t) is a Brownian bridge and Q'(t) (the quantile function's + derivative) is supplied via ``quantile_derivative_values``. + + Args: + num_points: Number of discretization points. + num_samples: Number of Monte Carlo paths to simulate. + quantile_derivative_values: Quantile-derivative values Q'(t). + + Returns: + One integral value per simulated path. + """ + integrals = [] + + for _ in range(num_samples): + # Simulate Brownian bridge B(u) over u in [0, 1] + u, B_u = _simulate_brownian_bridge(num_points) + + # Compute the integral numerically + integral = trapezoid( + y=np.abs(B_u[1:]) * quantile_derivative_values, x=u[1:], dx=1 / num_points + ) + integrals.append(integral) + + return integrals + + +def _compute_integral_of_brownian_bridge_squared( + num_points: int, num_samples: int, quantile_derivative_values: np.ndarray +) -> list[float]: + """ + Compute the distribution of the integral of |B(t)|^2 dQ(t) over t in + [0, 1], where B(t) is a Brownian bridge and Q'(t) (the quantile function's + derivative) is supplied via ``quantile_derivative_values``. + + Args: + num_points: Number of discretization points. + num_samples: Number of Monte Carlo paths to simulate. + quantile_derivative_values: Quantile-derivative values Q'(t). + + Returns: + One integral value per simulated path. + """ + integrals = [] + + for _ in range(num_samples): + # Simulate Brownian bridge B(u) over u in [0, 1] + u, B_u = _simulate_brownian_bridge(num_points) + + # Compute the integral numerically + integral = trapezoid( + y=B_u[1:] ** 2 * quantile_derivative_values**2, x=u[1:], dx=1 / num_points + ) + integrals.append(integral) + + return integrals + + +def _compute_asymptotic_wasserstein_distribution_mean( + ground_truth: TaggedDistributionalValue, +) -> float: + """ + Compute the integral of sqrt(2 / pi) * sqrt(F * (1 - F)), where F is the + ground truth's CDF. Divided by sqrt(N), this equals the mean Wasserstein-1 + distance between the ground truth and an N-sample empirical MC simulation + of it. + + Args: + ground_truth: The ground-truth distribution. + + Returns: + The value of the integral. + """ + + def integrand(F: np.ndarray) -> np.ndarray: + return np.asarray(np.sqrt(F * (1 - F))) + + # Calculate the support, CDF and integrand values. The carried + # DistributionalValue carries no `representation_type`, so use its + # cumulative-mass CDF directly (the prior + # `Distribution.calculate_distribution_values` weighted-samples branch). + support = ground_truth.dv.positions + cdf_values = np.cumsum(ground_truth.dv.masses) + y = integrand(cdf_values) + + # Remove numerical errors, all values should be in (0, 0.5) + y = np.where((np.isreal(y)) & (y >= 0) & (y < 0.5), y, 0) + + # Compute the integral and multiply by prefactor + mean = trapezoid(y, support) + mean *= np.sqrt(2 / np.pi) + + return float(mean) + + +def _generate_representative_mc_plots( + emcc: "EquivalentMonteCarlo", + variable_name: str, + distance_type: str, + plots_dir: str = "", + xlim: tuple[float, float] | None = None, +) -> None: + """ + Generate MC plots from representative samples that approximate the UxHw + distribution's distance to the ground truth. + + Builds up Monte Carlo samples progressively in chunks until the distance + between the MC samples and the ground truth approximates the UxHw-to-ground + truth distance: + + 1. For each row in the results DataFrame, extract the target EMCC count. + 2. Compute the target distance (UxHw vs ground truth). + 3. Build MC samples progressively in chunks to match that target distance. + 4. Fall back to the best attempt if convergence fails within the attempt + limit. + + Args: + emcc: EquivalentMonteCarlo object holding the results DataFrame, + adversary array, and ground truth. + variable_name: Name of the variable being analysed. + distance_type: Distance metric to use. + plots_dir: Directory to write the plots into. Defaults to the current working directory when empty. + xlim: Limits applied to the x-axis so the MC plots use the same domain + as the ground-truth and UxHw plots. ``None`` falls back to per-plot + auto-scaling. + """ + # Convert variable name to filesystem-safe string + expr_string = re.sub(r"\s+", "-", variable_name.lower()) + + # Maximum MC size we generate + max_mc_size = 1_000_000 + + count_list = [] + # Main loop over UxHw configurations + df = pd.DataFrame(emcc.variable.emcc_results.emcc_data) + for _, row in df.iterrows(): + try: + # Skip if the EMCC column does not exist. + if EquivMC.EMCC not in df.columns: + continue + + # Get equivalent Monte Carlo count if it exists + equiv_mc_count = row[EquivMC.EMCC] + if pd.isna(equiv_mc_count) or not isinstance(equiv_mc_count, (int, float)): + continue + equiv_mc_count = int(equiv_mc_count) + + # Some UxHw results will have same equivMC counts so we can skip repeats + if equiv_mc_count in count_list: + continue + # Set upper limit on MC size to avoid memory related issues + if equiv_mc_count > max_mc_size: + equiv_mc_count = max_mc_size + count_list.append(equiv_mc_count) + + # Get target distance based on whether we're using binned UxHw or not + target_distance = ( + row[BenchmarkingVariables.UXHW_BINNED_DISTANCE] + if emcc.use_binned_uxhw + else row[BenchmarkingVariables.UXHW_DISTANCE] + ) + selected_samples = _generate_progressive_samples( + emcc, equiv_mc_count, target_distance, distance_type + ) + adversary_dist = DistributionalValue.from_samples(selected_samples) + adv_plot_path = f"{expr_string}-adversary-{equiv_mc_count}.png" + if plots_dir: + adv_plot_path = os.path.join(plots_dir, adv_plot_path) + plot_wrapper.plot( + plot_data=PlotData(adversary_dist), + path=adv_plot_path, + save=True, + xlim=xlim, + ) + except Exception as e: + print( + f"Failed to generate representative MC plot for {expr_string} with an equivalent MC count of {equiv_mc_count}: {e}" + ) + continue + + +def _distance_func( + distance_type: str, + u_values: ArrayLike, + v_values: ArrayLike, + u_weights: ArrayLike | None = None, + v_weights: ArrayLike | None = None, +) -> float: + """ + Compute the distance between two weighted distributions. + + Only used for computing the equivalent Monte Carlo counts. + + Args: + distance_type: Distance metric (Wasserstein-1 or Wasserstein-2). + u_values: Support points for the first distribution. + v_values: Support points for the second distribution. + u_weights: Weights for the first distribution. + v_weights: Weights for the second distribution. + + Returns: + The distance between the two distributions. + + Raises: + ValueError: If ``distance_type`` is not a supported metric. + """ + + if distance_type == DistanceMetrics.WASSERSTEIN_1: + return wasserstein_p_distance(u_values, u_weights, v_values, v_weights, p=1) + elif distance_type == DistanceMetrics.WASSERSTEIN_2: + return wasserstein_p_distance(u_values, u_weights, v_values, v_weights, p=2) + else: + raise ValueError(f"Unknown distance type: {distance_type}") + + +def _generate_adversary_list( + args: "LoadDataComputeEquivalentMCArgs", target_expr: str, expr_type: str +) -> list[TaggedDistributionalValue]: + """ + Load the adversary distributions for Monte Carlo comparison. + + Distribution variables load a single distribution (wrapped in a list). + Scalar variables load several directly. + + Args: + args: Config holding the adversary database path and table name. + target_expr: Variable name to load from the database. + expr_type: Expression type (distribution or scalar). + + Returns: + The adversary distributions loaded from the database. + """ + adversary_list: list[TaggedDistributionalValue] = [] + if expr_type == VariableTypes.DISTRIBUTION: + adversary_list.append( + _load_mc( + db_path=args["adversary_database_path"], + table=args["adversary_table_name"], + target_expression=target_expr, + ) + ) + else: + adversary_list = _load_mc_scalar( + db_path=args["adversary_database_path"], + table=args["adversary_table_name"], + target_expression=target_expr, + ) + return adversary_list + + +def _generate_comparison_plots(emcc: "EquivalentMonteCarlo", prefix: str) -> None: + """ + Plot the distance distribution (scaled by sqrt(MC_count)) against the + asymptotic distribution (e.g. from a Brownian-bridge simulation). + + Args: + emcc: The Equivalent Monte Carlo object. + prefix: Prefix for the figure name. + """ + + for mc_count in emcc.variable.emcc_results.equiv_mc_list: + if mc_count > 0 and mc_count < emcc.adversary_size_max: + if emcc.variable.type == VariableTypes.DISTRIBUTION: + samples = emcc.variable.asymptotic_distribution.samples + assert samples is not None, ( + "asymptotic_distribution.samples must be populated " + "for distribution-typed variables before plotting" + ) + emcc._plot_equiv_mc_vs_brownian_bridge( + mc_count, + samples, + prefix=prefix, + ) + else: + emcc._plot_equiv_mc_vs_half_norm(mc_count, prefix=prefix) + + +def _plot_adversary_distances(emcc: "EquivalentMonteCarlo", prefix: str) -> None: + """ + Plot the distance distribution for each adversary. + + Args: + emcc: The Equivalent Monte Carlo object. + prefix: Prefix for the figure name. + """ + for i, (k, lst) in enumerate(emcc._adversary_distances.items()): + plt.scatter( + np.log(k), + np.log(np.mean(lst)), + marker="x", + color="r", + label="Measured Adversary Distances" if i == 0 else None, + ) + + plt.xlabel("Log of Adversary Size") + plt.ylabel("Log of Mean Wasserstein Distance") + plt.legend() + # Tag the ground truth in the filename by its representation type (from the + # carrier metadata, never off a Distribution). Only WeightedSamples counts + # as "weighted". MonteCarlo / Samples are "unweighted". + string = ( + "unweighted" + if emcc.ground_truth.representation_type in ("Samples", "MonteCarlo") + else "weighted" + ) + plt.savefig( + f"{prefix}-adversary_distances-{emcc.ground_truth.dv.UR_order}_{string}_ground_truth_samples-{emcc.n_adversaries}_adversaries.png", + dpi=500, + ) + plt.close() + + +def _generate_equivalent_mc_plots( + emcc: "EquivalentMonteCarlo", + args: "LoadDataComputeEquivalentMCArgs", + target_expr: str, + expr_description: str, + distance_type: str, + xlim: tuple[float, float] | None = None, +) -> None: + """ + Generate the configured equivalent Monte Carlo analysis plots. + + Args: + emcc: The Equivalent Monte Carlo object. + args: Config specifying which plots to generate. + target_expr: Variable name used for plot file naming. + expr_description: Human-readable description for plot titles. + distance_type: Distance metric used (e.g. Wasserstein-1). + xlim: Limits applied to the x-axis for the representative MC plots, so they use + the same domain as the ground-truth and UxHw plots. ``None`` falls + back to per-plot auto-scaling. + """ + plots_dir = args["plots_dir"] + prefixed_expr = os.path.join(plots_dir, target_expr) if plots_dir else target_expr + + if args["plot_adversary_distances"]: + _plot_adversary_distances(emcc=emcc, prefix=prefixed_expr) + + if args["plot_comparison_distributions"]: + _generate_comparison_plots(emcc=emcc, prefix=prefixed_expr) + # Plot equivalent MC run with "similar" distance to UxHw + if args["plot_distributions"] and emcc.variable.type == VariableTypes.DISTRIBUTION: + print("Generating MC plots...") + _generate_representative_mc_plots( + emcc, expr_description, distance_type, plots_dir=plots_dir, xlim=xlim + ) + + +def _attempt_convergence( + emcc: "EquivalentMonteCarlo", + selected_samples: np.ndarray, + sample_size_step: int, + sample_size: int, + target_constant: float, + distance_type: str, + threshold: float = 0.05, +) -> tuple[bool, np.ndarray, float]: + """ + Draw one more chunk of samples and check convergence to the target distance. + + Args: + emcc: The Equivalent Monte Carlo object. + selected_samples: Samples accumulated so far (sorted). + sample_size_step: Number of new samples to draw this attempt. + sample_size: Total sample size the distance is scaled against. + target_constant: Target value of distance * sqrt(sample_size). + distance_type: Distance metric to use. + threshold: Max proportional difference from the target to count as + converged. + + Returns: + A tuple of (converged, merged samples, proportional difference from + the target). + """ + new_samples = np.random.choice(emcc._adversary_array, size=sample_size_step) + new_samples.sort() + + # Merge sort with previously selected samples in O(n) time + samples = np.array(list(merge(selected_samples, new_samples))) + + mc_distance = _distance_func( + distance_type, + u_values=samples, + v_values=emcc.ground_truth.dv.positions, + v_weights=emcc.ground_truth.dv.masses, + ) + + # Check convergence using the target constant + attempt_constant = mc_distance * np.sqrt(sample_size) + proportion_diff = np.abs(attempt_constant - target_constant) / target_constant + + converged = proportion_diff < threshold + return converged, samples, proportion_diff + + +def _generate_mc_samples_for_plotting( + emcc: "EquivalentMonteCarlo", + selected_samples: np.ndarray, + sample_size: int, + sample_size_step: int, + target_constant: float, + distance_type: str, + equiv_mc_count: int, +) -> np.ndarray: + """ + Generate samples for one target size, retrying until convergence. + + Repeatedly calls :func:`_attempt_convergence`, keeping the best attempt as + a fallback if none converge within the attempt limit. + + Args: + emcc: The Equivalent Monte Carlo object. + selected_samples: Samples accumulated so far (sorted). + sample_size: Total sample size the distance is scaled against. + sample_size_step: Number of new samples to draw per attempt. + target_constant: Target value of distance * sqrt(sample_size). + distance_type: Distance metric to use. + equiv_mc_count: Equivalent MC count, used to bound the attempt count. + + Returns: + The converged samples, or the best-effort fallback samples. + """ + max_attempts = max(5, min(1000, math.ceil(10_000_000 / equiv_mc_count))) + num_attempts = 0 + smallest_diff = np.inf + fallback_samples = np.array([]) + + while num_attempts < max_attempts: + num_attempts += 1 + + converged, samples, proportion_diff = _attempt_convergence( + emcc, + selected_samples, + sample_size_step, + sample_size, + target_constant, + distance_type, + ) + + # Update fallback option with best result so far + if proportion_diff < smallest_diff: + smallest_diff = proportion_diff + fallback_samples = samples.copy() + + if converged: + return samples + + # Return fallback if no convergence + return fallback_samples + + +def _generate_progressive_samples( + emcc: "EquivalentMonteCarlo", + equiv_mc_count: int, + target_distance: float, + distance_type: str, +) -> np.ndarray: + """ + Build samples up to ``equiv_mc_count`` in chunks, converging each chunk. + + Args: + emcc: The Equivalent Monte Carlo object. + equiv_mc_count: Target total number of samples. + target_distance: UxHw-to-ground-truth distance to match. + distance_type: Distance metric to use. + + Returns: + The accumulated samples. + """ + target_constant = target_distance * np.sqrt(equiv_mc_count) + sample_sizes, sample_size_steps = _calculate_sample_sizes(equiv_mc_count) + + selected_samples = np.array([]) + + # Progressive sampling: build up samples in chunks + for sample_size, sample_size_step in zip(sample_sizes, sample_size_steps): + selected_samples = _generate_mc_samples_for_plotting( + emcc, + selected_samples, + sample_size, + sample_size_step, + target_constant, + distance_type, + equiv_mc_count, + ) + + return selected_samples + + +def _calculate_sample_sizes( + equiv_mc_count: int, sample_size_step: int = 100_000 +) -> tuple[list[int], list[int]]: + """ + Calculate cumulative sample sizes and step sizes for progressive sampling. + + Args: + equiv_mc_count: Target total number of samples. + sample_size_step: Chunk size for each progressive step. + + Returns: + A tuple of (cumulative sample sizes, per-step increments). + """ + if equiv_mc_count <= sample_size_step: + sample_sizes = [equiv_mc_count] + else: + sample_sizes = list( + range(sample_size_step, equiv_mc_count, sample_size_step) + ) + [equiv_mc_count] + + sample_size_steps = [sample_sizes[0]] + [ + sample_sizes[i] - sample_sizes[i - 1] for i in range(1, len(sample_sizes)) + ] + + return sample_sizes, sample_size_steps + + +def _compute_asymptotic_distribution_scalar_empirical( + samples: npt.ArrayLike, sample_size: int, ground_truth_value: float +) -> tuple[float, float, bool]: + """ + Compute the asymptotic distribution of N samples, fitting a Gaussian to + ``samples * sqrt(N)`` centred on the ground truth. + + Args: + samples: Input samples. + sample_size: Number of samples. + ground_truth_value: Ground-truth value to centre samples around. + + Returns: + A tuple of (mean, standard deviation, is_normal) for the centred and + scaled samples, where ``is_normal`` is the Anderson-Darling verdict. + """ + samples_array = (np.asarray(samples, dtype=float) - ground_truth_value) * np.sqrt( + sample_size + ) + + mean = np.mean(samples_array) + std = np.std(samples_array, ddof=1) + + is_normal = anderson_darling_test(samples_array) + + return mean.item(), std.item(), is_normal + + +def anderson_darling_test(samples: npt.ArrayLike) -> bool: + """ + Anderson-Darling test for normality. + + Args: + samples: Samples to test. + + Returns: + ``True`` if the samples appear normal at the 15% significance level. + """ + result = anderson(samples, dist="norm") + + if result.statistic < result.critical_values[0]: + print("✓ Data appears normally distributed at 15% level") + return True + else: + print("✗ Data does NOT appear normally distributed at 15% level") + return False diff --git a/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo.py b/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo.py new file mode 100644 index 0000000..aed0ad9 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo.py @@ -0,0 +1,590 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from numpy.typing import ArrayLike +from collections.abc import Callable +from typing import Any +from concurrent.futures import ProcessPoolExecutor +from multiprocessing import Manager +from threading import Thread +from tabulate import tabulate +import matplotlib.pyplot as plt +import math +import pandas as pd +import numpy as np +from tqdm import tqdm +from signaloid.benchmarking.equivalent_mc.adversary_distance import ( + _wasserstein_1_adversary_wrapper, + _wasserstein_2_adversary_wrapper, +) +from signaloid.benchmarking.config import ( + ReportingMethods, + ReportingNumbers, + VariableTypes, + DistanceMetrics, + EquivMC, + BenchmarkingVariables, +) +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, +) +from scipy.stats import halfnorm # type: ignore + + +# Configure the computation using this class +class EquivalentMonteCarlo: + """ + Compute the equivalent Monte Carlo count (EMCC) for a benchmarking variable. + + Measures how many Monte Carlo samples an adversary needs before its distance + to the ground truth beats each UxHw configuration. Adversary distances are + computed in parallel over a range of sample sizes (optionally adaptively + stepped). The resulting EMCC values and the proportion of adversaries that + beat each configuration are written back onto the variable's EMCC results + and reported in a table. + """ + + def __init__( + self, + ground_truth: TaggedDistributionalValue, + uxhw_data: list[TaggedDistributionalValue], + variable: BenchmarkingVariable, + adversary_mc: list[TaggedDistributionalValue], + distance_type: str = DistanceMetrics.WASSERSTEIN_1, + reporting_methods: list[str] = [ + ReportingMethods.MEAN, + ReportingMethods.QUANTILE_95, + ReportingMethods.QUANTILE_99, + ], + n_processes: int = 1, + n_adversaries: int = 1, + use_clt: bool = False, + prefix: str | None = "", + adversary_size_step: int = 100, + adversary_size_min: int = 1, + adversary_size_max: int | None = None, + use_binned_uxhw: bool = True, + ) -> None: + """ + Initialise the equivalent Monte Carlo computation. + + Args: + ground_truth: The ground-truth distribution to measure against. + uxhw_data: The UxHw representations under test. Must be non-empty. + variable: The benchmarking variable whose EMCC results are populated. + adversary_mc: Adversary Monte Carlo samples. The pool is subsampled at + each requested size. + distance_type: Distance metric (Wasserstein-1, Binned Wasserstein-1, + or Wasserstein-2). + reporting_methods: Reporting statistics to compute (mean / quantiles). + n_processes: Number of worker processes for the adversary-distance + computation. + n_adversaries: Number of adversary repetitions per sample size. + use_clt: Use the predicted (CLT) EMCC instead of measuring it + explicitly. + prefix: Prefix for saved plot filenames. + adversary_size_step: Fixed step between adversary sizes in the + non-adaptive mode. + adversary_size_min: Smallest adversary sample size. + adversary_size_max: Largest adversary sample size. Defaults to + ``len(adversary array) - 1``. + use_binned_uxhw: Use the binned Wasserstein-1 UxHw distance. + + Raises: + ValueError: If ``uxhw_data`` is empty. + RuntimeError: If ``distance_type`` is not a supported metric. + """ + self.ground_truth: TaggedDistributionalValue = ground_truth + self.adversary_mc: list[TaggedDistributionalValue] = adversary_mc + if len(uxhw_data) == 0: + raise ValueError("uxhw_data must contain at least one entry.") + self.uxhw_data: list[TaggedDistributionalValue] = uxhw_data + self.variable = variable + self.distance_type = distance_type + self.use_binned_uxhw = use_binned_uxhw + + self.adversary_distance_fn: Callable[..., list[tuple[int, list[float]]]] + if distance_type in [ + DistanceMetrics.WASSERSTEIN_1, + DistanceMetrics.BINNED_WASSERSTEIN_1, + ]: + self.adversary_distance_fn = _wasserstein_1_adversary_wrapper + elif distance_type == DistanceMetrics.WASSERSTEIN_2: + self.adversary_distance_fn = _wasserstein_2_adversary_wrapper + else: + raise RuntimeError( + f"Invalid distance type configuration " + f"{distance_type}. Check py for valid options" + ) + + self.n_processes = n_processes + self.n_adversaries = n_adversaries + self.prefix = prefix + self.use_clt = use_clt + self.reporting_methods = reporting_methods + + # Maps an adversary sample count to the distances that different + # subsamplings of that size achieved against the ground truth. For + # example, `self._adversary_distances[420] == [1, 0.6, 1.2]` means three + # subsamplings of 420 samples scored distances 1, 0.6, and 1.2. + self._adversary_distances: dict[int, list[float]] = dict() + self._uxhw_distances: list[tuple[TaggedDistributionalValue, float, float]] = [] + + # Create single large adversary array for distributional variables + self._adversary_array: np.ndarray = np.array(adversary_mc[0].dv.positions) + + self.adversary_size_step = adversary_size_step + self.adversary_size_min = adversary_size_min + self.adversary_size_max = ( + adversary_size_max + if adversary_size_max is not None + else len(self._adversary_array) - 1 + ) + + def compute_distance_data(self, use_adaptive_steps: bool = True) -> None: + """ + Compute adversary distances across the generated sample-size array. + + Args: + use_adaptive_steps: Grow the adversary-size step for larger sizes. + """ + assert self.adversary_distance_fn is not None + + # Compute the adversary size array an step size array + self.adversary_size_array = self._generate_adversary_size_array( + use_adaptive_steps + ) + # Compute the adversary distances + self._compute_adversary_distances() + + def _generate_adversary_size_array( + self, use_adaptive_steps: bool = True + ) -> np.ndarray: + """ + Generate the array of Monte Carlo sample sizes for distance measurements. + + There are two ways to compute the equivalent MC count: + 1. Estimate the values first, then use EquivalentMonteCarlo to verify. + 2. Compute distances across an array of candidate sizes and read the + count off those measurements. + + Args: + use_adaptive_steps: Grow the adversary-size step for larger sizes. + + Returns: + The array of adversarial MC sizes. + """ + # Only the distribution branch of `_compute_adversary_distances` uses + # this array. The scalar branch iterates `self.adversary_mc` directly. + # For scalars `adversary_size_max` can be 0, which would make the + # adaptive-step `np.log(adversary_size_max)` below hit `log(0)`, so + # short-circuit here. + if self.variable.type != VariableTypes.DISTRIBUTION: + return np.array([]) + + predicted_size_array = np.array([]) + if len(self.variable.emcc_results.equiv_mc_list) > 0: + predicted_size_array = np.array(self.variable.emcc_results.equiv_mc_list) + predicted_max_value: int = int(np.max(predicted_size_array)) + + # If using predictions then we can reduce the maximum adversary size + # to the largest prediction (if smaller) + if self.use_clt: + self.adversary_size_max = min( + self.adversary_size_max, predicted_max_value + ) + + if self.variable.type == VariableTypes.DISTRIBUTION: + if len(self._adversary_array) < predicted_max_value: + print( + f"Warning! Predicted maximum adversary size {predicted_max_value} greater than size of adversary array {len(self._adversary_array)}!" + ) + + # Only consider values below the max + predicted_size_array = predicted_size_array[ + predicted_size_array < self.adversary_size_max + ] + + # Return array of sizes + if self.use_clt: + return predicted_size_array + + else: + # Compute EMCC explicitly + if use_adaptive_steps: + # Space the adversary sizes so each step is roughly a constant + # fraction (alpha) of the MC count, keeping precision consistent + # while minimizing computational effort. + alpha = 0.01 + num_steps = math.ceil( + -np.log(self.adversary_size_max) / np.log(1 - alpha) + ) + array = np.geomspace( + self.adversary_size_min, self.adversary_size_max, num=num_steps + ) + size_array = np.unique(np.ceil(array).astype(int)) + else: + size_array = np.arange( + self.adversary_size_min, + self.adversary_size_max, + self.adversary_size_step, + ) + + return np.asarray(size_array) + + def _compute_adversary_distances(self) -> None: + assert self.adversary_distance_fn is not None + + print( + "Computing adversary choices distances with " + + f"n_processes={self.n_processes} and " + + f"n_adversaries={self.n_adversaries}" + ) + + if self.variable.type == VariableTypes.DISTRIBUTION: + total_iterations = len(self.adversary_size_array) * self.n_adversaries + results = [] + # Extract only needed attributes once + gt_positions = self.ground_truth.dv.positions + gt_masses = self.ground_truth.dv.masses + + with Manager() as manager: + progress_queue = manager.Queue() + + with tqdm( + total=total_iterations, desc="Processing", unit="step" + ) as progress_bar: + progress_updater = Thread( + target=_update_progress, args=(progress_queue, progress_bar) + ) + progress_updater.start() + + with ProcessPoolExecutor(max_workers=self.n_processes) as executor: + futures = [ + executor.submit( + self.adversary_distance_fn, + self.adversary_size_array, + self._adversary_array, + gt_positions, + progress_queue, + gt_masses, + ) + for _ in range(self.n_adversaries) + ] + for future in futures: + result = future.result() + results.append(result) + progress_queue.put(None) + progress_updater.join() + else: + results = [] + # adversary.mc_count is the per-adversary scalar MC sample count, + # read off the carrier metadata (never off a Distribution). + for adversary in self.adversary_mc: + if adversary.mc_count is not None: + # Scalar distance in basis points: relative error from the + # ground truth scaled by 10,000 (BASIS_POINT_CONVERSION_FACTOR, + # since 1 bp = 0.01%). + distance_array = ( + np.abs( + adversary.dv.positions - self.ground_truth.dv.positions[0] + ) + * EquivMC.BASIS_POINT_CONVERSION_FACTOR + / np.abs(self.ground_truth.dv.positions[0]) + ) + distance_list = distance_array.tolist() + results.append([(adversary.mc_count, distance_list)]) + + for result in results: + for adversary_size, distance_lst in result: + self._adversary_distances.setdefault(adversary_size, []).extend( + distance_lst + ) + + def _resolve_adversary_distances(self, mc_count: int) -> list[float] | None: + """ + Return the adversary distance list for ``mc_count``. + + The adversary-distance computation can use adaptive stepping, so + arbitrary integers (for example predicted-EMCC values that were not + sampled) are not guaranteed to be keys in ``self._adversary_distances``. + When ``mc_count`` is absent, fall back to the closest sampled key. + + Args: + mc_count: Monte Carlo count to look up. + + Returns: + The list of distances at ``mc_count`` (exact match) or at + the closest sampled MC count. ``None`` only when no + adversary distances have been computed yet. + """ + if not self._adversary_distances: + return None + if mc_count in self._adversary_distances: + return self._adversary_distances[mc_count] + closest = min( + self._adversary_distances.keys(), + key=lambda k: abs(k - mc_count), + ) + return self._adversary_distances[closest] + + def compute_and_report_emmc(self) -> None: + """ + Determine the EMCC for each UxHw configuration and print the report. + """ + self.determine_equiv_mc_counts() + self._report_emmc() + + def determine_equiv_mc_counts(self) -> None: + """ + Compute the EMCC for each UxHw configuration and store it on the + variable's EMCC data. + + For each configuration, finds the smallest adversary MC count whose + distance to the ground truth beats the UxHw distance (or uses the + predicted count under ``use_clt``), and records the proportion of + adversaries that beat the configuration. + """ + # Loop through UxHw configurations + for emcc_dic in self.variable.emcc_results.emcc_data: + distance = ( + emcc_dic[BenchmarkingVariables.UXHW_BINNED_DISTANCE] + if self.use_binned_uxhw + else emcc_dic[BenchmarkingVariables.UXHW_DISTANCE] + ) + + if self.use_clt: + mc_count = emcc_dic[EquivMC.EMCC_PREDICTED] + + else: + # Find all MC adversaries that beat UxHw configuraion + mc_beats_uxhw = [ + (k, lst) + for k, lst in self._adversary_distances.items() + if ( + _comparison_statistic(lst, emcc_dic[EquivMC.REPORTING_METHOD]) + < distance + ) + ] + + # We want to find the smallest possible Monte Carlo count such that MC beats UxHw + mc_beats_uxhw.sort(key=lambda x: x[0]) + + if not mc_beats_uxhw: + # This means there was no Adversary whose average distance was less than UxHw. + mc_count = 1 + print( + f"Warning: Adversary Monte Carlo data not enough for {emcc_dic[BenchmarkingVariables.UXHW_CONF]}" + ) + else: + mc_count = max(mc_beats_uxhw, key=lambda x: x[0])[0] + + # If mc_count is non-positive then set to 1 + mc_count = max(1, mc_count) + + emcc_dic[EquivMC.EMCC] = mc_count + + distances = self._resolve_adversary_distances(mc_count) + if distances is not None: + distance_samples = np.array(distances) + proportion_mc_beats_uxhw = float( + np.sum( + distance_samples + < _comparison_statistic( + distance_samples, + emcc_dic[EquivMC.REPORTING_METHOD], + ) + ) + ) / float(len(distance_samples)) + emcc_dic[EquivMC.PERCENTAGE_MC_BEATS_UXHW] = proportion_mc_beats_uxhw + + def _report_emmc(self) -> None: + """ + Print the variable's EMCC results as a table. + """ + print("Report: Equivalent Monte Carlo Count") + print(f"expression={self.variable.name}") + df = pd.DataFrame(self.variable.emcc_results.emcc_data) + + # Use the column name directly, not the index + if BenchmarkingVariables.UXHW_CONF in df.columns: + df[BenchmarkingVariables.UXHW_CONF] = df[ + BenchmarkingVariables.UXHW_CONF + ].apply(repr) + + print(tabulate(df, headers="keys", tablefmt="psql", showindex=False)) # type: ignore + + def _plot_equiv_mc_vs_brownian_bridge( + self, mc_count: int, brownian_bridge_integrals: ArrayLike, prefix: str + ) -> None: + """ + Plot the scaled adversary distances at ``mc_count`` against the + Brownian-bridge asymptotic prediction, and save the figure. + + Args: + mc_count: Adversary MC count whose distances are plotted. + brownian_bridge_integrals: Simulated Brownian-bridge integrals for + the asymptotic reference histogram. + prefix: Prefix for the saved figure name. + """ + distances = self._resolve_adversary_distances(mc_count) + if distances is None: + return + data = [dist * np.sqrt(mc_count) for dist in distances] + mean = self.variable.asymptotic_distribution.mean + assert ( + mean is not None + ), "asymptotic_distribution.mean must be populated before plotting" + plt.axvline( + x=mean, + color="red", + linestyle="--", + linewidth=2, + label="Predicted Asymptotic Mean", + ) + plt.hist( + data, + bins=max(1, int(1 + math.log2(self.n_adversaries))), + density=True, + color="b", + alpha=0.5, + label="Adversarial MC", + ) + plt.hist( + brownian_bridge_integrals, + bins=max(1, int(1 + math.log2(len(np.asarray(brownian_bridge_integrals))))), + density=True, + alpha=0.5, + color="r", + label="Brownian Bridge Simulation", + ) + plt.title( + f"{self.ground_truth.dv.UR_order} Ground Truth, {mc_count} MC Count for {self.n_adversaries} Adversaries" + ) + plt.xlabel(r"Wasserstein Distance $\times\ \sqrt{\text{MC Count}}$") + plt.ylabel("Probability Density") + plt.legend() + plt.savefig( + f"{prefix}-{self.ground_truth.dv.UR_order}_ground_truth-{mc_count}_mc_count-{self.n_adversaries}-adversaries.png", + dpi=500, + ) + plt.close() + + def _plot_equiv_mc_vs_half_norm(self, mc_count: int, prefix: str) -> None: + """ + Plot the scaled adversary distances at ``mc_count`` against the + half-normal asymptotic prediction (for scalar variables), and save the + figure. + + Args: + mc_count: Adversary MC count whose distances are plotted. + prefix: Prefix for the saved figure name. + """ + distances = self._resolve_adversary_distances(mc_count) + if distances is None: + return + data = [dist * np.sqrt(mc_count) for dist in distances] + asymptotic = self.variable.asymptotic_distribution + assert ( + asymptotic.mean is not None and asymptotic.scale is not None + ), "asymptotic_distribution.mean and .scale must be populated before plotting" + plt.axvline( + x=asymptotic.mean, + color="red", + linestyle="--", + linewidth=2, + label="Predicted Asymptotic Mean", + ) + plt.hist( + data, + bins=max(1, int(1 + math.log2(self.n_adversaries))), + density=True, + color="b", + alpha=0.5, + label="Adversarial MC", + ) + x = np.linspace(0, max(data), 100) + half_norm_values = halfnorm.pdf( + x, + loc=0, + scale=asymptotic.scale, + ) + plt.plot(x, half_norm_values, color="red", label="Asymptotic Distribution") + plt.title( + f"{self.ground_truth.dv.UR_order} Ground Truth, {mc_count} MC Count for {self.n_adversaries} Adversaries" + ) + plt.xlabel(r"Wasserstein Distance $\times\ \sqrt{\text{MC Count}}$") + plt.ylabel("Probability Density") + plt.legend() + plt.savefig( + f"{prefix}-{self.ground_truth.dv.UR_order}_ground_truth-{mc_count}_mc_count-{self.n_adversaries}-adversaries.png", + dpi=500, + ) + plt.close() + + +def _comparison_statistic(lst: list[float] | np.ndarray, method: str) -> float: + """ + Reduce a list of distances to a single value by the reporting method. + + Args: + lst: Distances to reduce. + method: One of ``ReportingMethods.MEAN``, ``QUANTILE_95``, or + ``QUANTILE_99``. + + Returns: + The mean or requested quantile of ``lst``. + + Raises: + ValueError: If ``method`` is not a supported reporting method. + """ + methods = { + ReportingMethods.MEAN: np.mean, + ReportingMethods.QUANTILE_95: lambda x: np.quantile( + x, ReportingNumbers.QUANTILE_95 + ), + ReportingMethods.QUANTILE_99: lambda x: np.quantile( + x, ReportingNumbers.QUANTILE_99 + ), + } + + arr = np.asarray(lst) + + if method not in methods: + raise ValueError(f"Reporting statistic '{method}' is not supported.") + + return methods[method](arr) # type: ignore + + +def _update_progress(progress_queue: Any, progress_bar: Any) -> None: + """ + Advance ``progress_bar`` by counts read from ``progress_queue`` until a + ``None`` sentinel is received. + + Args: + progress_queue: Queue yielding step counts, then ``None`` to stop. + progress_bar: The tqdm progress bar to advance. + """ + while True: + progress = progress_queue.get() + if progress is None: + break + progress_bar.update(progress) diff --git a/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo_test.py b/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo_test.py new file mode 100644 index 0000000..378bb47 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/equivalent_monte_carlo_test.py @@ -0,0 +1,169 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.distributional.distributional import DistributionalValue + +from signaloid.benchmarking.config import VariableTypes +from signaloid.benchmarking.types import ( + BenchmarkingVariable, + TaggedDistributionalValue, +) +from signaloid.benchmarking.equivalent_mc.equivalent_monte_carlo import ( + EquivalentMonteCarlo, +) + + +def _make_emcc_with_distances( + distances_by_count: dict[int, list[float]], +) -> EquivalentMonteCarlo: + """Build an EquivalentMonteCarlo with only ``_adversary_distances`` + populated, bypassing __init__ for focused unit-testing of the + closest-key fallback. The plot methods read ``self.n_adversaries`` + via ``math.log2``, so seed it too.""" + emcc = object.__new__(EquivalentMonteCarlo) + emcc._adversary_distances = distances_by_count + emcc.n_adversaries = 5 + return emcc + + +def _scalar_tagged(samples: list[float], mc_count: int) -> TaggedDistributionalValue: + """A scalar adversary/ground-truth carrier, as produced by the scalar + loaders in ``load.py`` (DistributionalValue.from_samples + mc_count).""" + return TaggedDistributionalValue( + dv=DistributionalValue.from_samples(samples), + mc_count=mc_count, + ) + + +class TestResolveAdversaryDistances(unittest.TestCase): + """``_resolve_adversary_distances`` falls back to the closest sampled key.""" + + def test_resolve_adversary_distances_exact_match(self) -> None: + """An exact key returns its list unchanged.""" + emcc = _make_emcc_with_distances({100: [1.0, 2.0], 500: [3.0]}) + + self.assertEqual(emcc._resolve_adversary_distances(100), [1.0, 2.0]) + self.assertEqual(emcc._resolve_adversary_distances(500), [3.0]) + + def test_resolve_adversary_distances_falls_back_to_closest_key(self) -> None: + """A missing key resolves to the closest sampled adversary size. + + Regression for the bug surfaced on the Rendering Importance + Sampling demo: a predicted EMCC of 1363 raised ``KeyError`` because + the adaptive adversary stepping never sampled exactly 1363. The + helper must pick the nearest sampled key instead. + """ + emcc = _make_emcc_with_distances({1000: [1.0], 1500: [2.0], 2000: [3.0]}) + + # 1363 is closer to 1500 than to 1000 or 2000. + self.assertEqual(emcc._resolve_adversary_distances(1363), [2.0]) + + def test_resolve_adversary_distances_ties_to_lower_key(self) -> None: + """``min`` with ``abs`` keys ties to the first key encountered.""" + emcc = _make_emcc_with_distances({100: [1.0], 200: [2.0]}) + + # 150 is equidistant from 100 and 200. + # min() takes the first seen. + result = emcc._resolve_adversary_distances(150) + self.assertIn(result, ([1.0], [2.0])) + + def test_resolve_adversary_distances_empty_returns_none(self) -> None: + """No adversaries have been computed yet → None, no exception.""" + emcc = _make_emcc_with_distances({}) + + self.assertIsNone(emcc._resolve_adversary_distances(1)) + self.assertIsNone(emcc._resolve_adversary_distances(50000)) + + +class TestPlotSkipsWhenNoDistances(unittest.TestCase): + """Plot helpers bail before matplotlib work when no distances exist.""" + + def test_plot_equiv_mc_vs_brownian_bridge_skips_when_no_distances(self) -> None: + """``_plot_equiv_mc_vs_brownian_bridge`` must not raise when + ``_adversary_distances`` is empty — it should bail before any + matplotlib work.""" + emcc = _make_emcc_with_distances({}) + + emcc._plot_equiv_mc_vs_brownian_bridge( + mc_count=1363, + brownian_bridge_integrals=[0.0, 1.0, 2.0], + prefix="unused", + ) + + def test_plot_equiv_mc_vs_half_norm_skips_when_no_distances(self) -> None: + """``_plot_equiv_mc_vs_half_norm`` must not raise when + ``_adversary_distances`` is empty.""" + emcc = _make_emcc_with_distances({}) + + emcc._plot_equiv_mc_vs_half_norm(mc_count=1363, prefix="unused") + + +class TestComputeDistanceDataScalar(unittest.TestCase): + """Scalar variables with a single-sample adversary must not crash.""" + + def test_compute_distance_data_scalar_single_sample_adversary_no_raise( + self, + ) -> None: + """Regression: a scalar variable whose ``adversary_mc[0]`` carries a + single sample must not crash ``compute_distance_data``. + + A single-sample adversary makes ``adversary_size_max`` collapse to + ``len(positions) - 1 == 0``. The scalar branch of + ``_compute_adversary_distances`` never reads the adversary-size array, + but the array was still generated unconditionally — driving the + adaptive-step path into ``np.log(0)`` (``math.ceil(-inf)`` raises + ``OverflowError``). ``_generate_adversary_size_array`` now short-circuits + to an empty array for non-distribution variables. + """ + variable = BenchmarkingVariable( + name="scalarOutput", + description="Scalar Output", + type=VariableTypes.SCALAR, + ) + emcc = EquivalentMonteCarlo( + ground_truth=_scalar_tagged([1.0], mc_count=1), + uxhw_data=[_scalar_tagged([1.1], mc_count=1)], + variable=variable, + # adversary_mc[0] carries a single sample => adversary_size_max == 0 + adversary_mc=[ + _scalar_tagged([1.2], mc_count=1), + _scalar_tagged([0.9, 1.1], mc_count=2), + ], + n_processes=1, + n_adversaries=1, + ) + + # adversary_size_max is the degenerate value that previously broke the + # adaptive-step log so the guard must tolerate it. + self.assertEqual(emcc.adversary_size_max, 0) + + # Adaptive steps is the path that previously hit log(0). + emcc.compute_distance_data(use_adaptive_steps=True) + + # The scalar branch ignores the size array (empty for scalars). + self.assertEqual(len(emcc.adversary_size_array), 0) + # The scalar distance computation still ran against each adversary. + self.assertTrue(emcc._adversary_distances) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/inputs/signaloid.yaml b/src/signaloid/benchmarking/equivalent_mc/inputs/signaloid.yaml new file mode 100644 index 0000000..c8d4d28 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/inputs/signaloid.yaml @@ -0,0 +1,9 @@ +MinimumCore: + Microarchitecture: Athens + MemorySize: 256000 + Precision: 32 + +TraceVariables: + - File: "main.c" + LineNumber: 95 + Expression: "outputDistributions[0:1]" diff --git a/src/signaloid/benchmarking/equivalent_mc/inputs/uxhw_distances.csv b/src/signaloid/benchmarking/equivalent_mc/inputs/uxhw_distances.csv new file mode 100644 index 0000000..68c64ba --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/inputs/uxhw_distances.csv @@ -0,0 +1,5 @@ +Variable Description,UxHw Conf,UxHw Distance,Binned UxHw Distance +Stock Price At Maturity,Athens-16,7.39176800566347,6.934449175119165 +Call Option,Athens-16,87.08559181008,87.08559181008003 +Put Option,Athens-16,1.6481838925174965,1.6329142433632824 +Value at Risk,Athens-16,8.612125769230232,8.612125769230232 diff --git a/src/signaloid/benchmarking/equivalent_mc/load.py b/src/signaloid/benchmarking/equivalent_mc/load.py new file mode 100644 index 0000000..4d42f5a --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/load.py @@ -0,0 +1,744 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from contextlib import closing +from sqlite3 import Connection +import sqlite3 +from typing import Callable, TypeVar, cast +import pandas as pd +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.types import TaggedDistributionalValue +from signaloid.benchmarking.config import ( + VariableTypes, + RepresentationTypes, + STRING_TO_CORE_REPRESENTATION, +) +import os +import struct + + +def _connect_to_database(db_path: str) -> sqlite3.Connection: + """ + Connect to an existing SQLite database. + + Args: + db_path: Path to the database file. + + Returns: + An open connection to the database. + + Raises: + FileNotFoundError: If the file is missing or not a valid SQLite database. + """ + try: + if not os.path.exists(db_path): + raise FileNotFoundError(f"{db_path} does not exist.") + + con = sqlite3.connect(db_path) + try: + con.execute("SELECT 1").fetchone() # verify it's a valid SQLite database + except Exception: + con.close() + raise + + return con + + except FileNotFoundError: + raise + except sqlite3.DatabaseError as e: + raise FileNotFoundError(f"{db_path} is not a valid SQLite database.") from e + except Exception as e: + raise FileNotFoundError(f"Could not connect to {db_path}: {e}") from e + + +_LoadResult = TypeVar("_LoadResult") + + +def _load_with_connection( + db_path: str, + table: str, + target_expression: str, + loader: Callable[[sqlite3.Connection, str, str], _LoadResult], +) -> _LoadResult: + """ + Open ``db_path``, run ``loader`` against the connection, then close it. + """ + with closing(_connect_to_database(db_path)) as con: + return loader(con, table, target_expression) + + +def _load_mc( + db_path: str, table: str, target_expression: str +) -> TaggedDistributionalValue: + """ + Load Monte Carlo samples data from SQLite database into a + TaggedDistributionalValue. + """ + return _load_with_connection(db_path, table, target_expression, _load_mc_with_con) + + +def _load_mc_scalar( + db_path: str, table: str, target_expression: str +) -> list[TaggedDistributionalValue]: + """ + Load Monte Carlo samples data from SQLite database into a list of + TaggedDistributionalValue. + """ + return _load_with_connection( + db_path, table, target_expression, _load_scalar_mc_with_con + ) + + +def _load_weighted_samples( + db_path: str, table: str, target_expression: str +) -> TaggedDistributionalValue: + """ + Load weighted samples data from SQLite database into a + TaggedDistributionalValue. + """ + return _load_with_connection( + db_path, table, target_expression, _load_weighted_samples_with_con + ) + + +def _load_weighted_samples_scalar( + db_path: str, table: str, target_expression: str +) -> list[TaggedDistributionalValue]: + """ + Load weighted samples data from SQLite database into a list of + TaggedDistributionalValue. + """ + return _load_with_connection( + db_path, table, target_expression, _load_scalar_weighted_samples_with_con + ) + + +def _load_ground_truth( + db_path: str, + table: str, + target_expression: str, + monte_carlo: bool, + expression_type: str, +) -> TaggedDistributionalValue: + """ + Load ground truth samples, via either mc or weighted samples. + """ + if monte_carlo: + if expression_type == VariableTypes.DISTRIBUTION: + tagged = _load_mc(db_path, table, target_expression) + else: + # Ground truth is just a scalar + tagged_list = _load_mc_scalar(db_path, table, target_expression) + tagged = _max_mc_count(tagged_list) + else: + if expression_type == VariableTypes.DISTRIBUTION: + tagged = _load_weighted_samples(db_path, table, target_expression) + elif expression_type == VariableTypes.SCALAR: + tagged_list = _load_weighted_samples_scalar( + db_path, table, target_expression + ) + tagged = _max_mc_count(tagged_list) + else: + raise ValueError(f"Error! Expression type {expression_type} not supported!") + + return tagged + + +def _load_uxhw_distributions( + db_path: str, + tables: list[str], + target_expr: str, + ur_types: list[str], + ur_sizes: list[int], +) -> list[TaggedDistributionalValue]: + """ + Load and concatenate UxHw distributions across DB tables. + """ + result: list[TaggedDistributionalValue] = [] + for table in tables: + result.extend( + _load_uxhw( + db_path=db_path, + table=table, + target_expression=target_expr, + ur_types=ur_types, + ur_sizes=ur_sizes, + value_id=None, + ) + ) + return result + + +def _max_mc_count( + tagged_list: list[TaggedDistributionalValue], +) -> TaggedDistributionalValue: + """ + Pick the largest-``mc_count`` scalar carrier as ground truth. + + The scalar loaders set ``mc_count`` from the DB row. We read it back off + the carrier (never off a ``Distribution``) to select the highest-MC scalar. + """ + valid_objects = [obj for obj in tagged_list if obj.mc_count is not None] + if not valid_objects: + raise ValueError("No valid objects with non-None mc_count found") + + def get_mc_count(obj: TaggedDistributionalValue) -> int: + assert obj.mc_count is not None # We filtered these out above + return obj.mc_count + + return max(valid_objects, key=get_mc_count) + + +def _load_mc_with_con( + con: Connection, table: str, target_expression: str +) -> TaggedDistributionalValue: + """ + Load Monte Carlo samples data from SQLite database connection into a + TaggedDistributionalValue. + """ + + mc_data_select = [ + "Expression_Name", + "Expression_Subprogram", + "Expression_DeclarationFileName", + "Expression_DeclarationLineNumber", + "MC_Id", + "Particle_Value", + "Assignment_Index", + "ValueId", + ] + + mc_data_df = pd.read_sql_query( + f'SELECT {",".join(mc_data_select)} FROM "{table}" WHERE Expression_Name = ?', + con, + params=(target_expression,), + ) + + # Note: Monte Carlo data does not need the Emulator_Execution_Info table. + + mc_data_info_df = mc_data_df.groupby( + [ + "Expression_DeclarationFileName", + "Expression_Subprogram", + "Expression_Name", + "Expression_DeclarationLineNumber", + "Assignment_Index", + "ValueId", + ], + as_index=False, + ).aggregate(func={"Particle_Value": list, "MC_Id": len}) + + # Multiple subprograms tracing the same expression would be ambiguous. + uniq_subprogram = mc_data_info_df["Expression_Subprogram"].unique() + if len(uniq_subprogram) != 1: + raise ValueError( + "Data ambiguity error: Expression_Subprogram unique values count after " + + f"filtering is not 1: len(uniq_subprogram)=={len(uniq_subprogram)}" + ) + assert len(uniq_subprogram) == 1 + + # Multiple declaration line numbers tracing the same expression would be ambiguous. + uniq_declaration_line_number = mc_data_info_df[ + "Expression_DeclarationLineNumber" + ].unique() + if len(uniq_declaration_line_number) != 1: + raise ValueError( + "Data ambiguity error: Expression_DeclarationLineNumber unique values count" + + " after filtering is not 1: " + + f"len(uniq_declaration_line_number)=={len(uniq_declaration_line_number)}" + ) + assert len(uniq_declaration_line_number) == 1 + + # When an expression has multiple assignments, keep only the last one. + max_assignment_index = mc_data_info_df["Assignment_Index"].max() + mc_data_info_df = mc_data_info_df[ + mc_data_info_df["Assignment_Index"] == max_assignment_index + ] + + mc_data_info_sr = mc_data_info_df.squeeze() + + mc_samples = mc_data_info_sr["Particle_Value"] + # Create return value + dv = DistributionalValue.from_samples(mc_samples) + + return TaggedDistributionalValue( + dv=dv, + representation_type=RepresentationTypes.MONTE_CARLO, + representation_size=dv.UR_order, + ) + + +def _load_scalar_mc_with_con( + con: Connection, table: str, target_expression: str +) -> list[TaggedDistributionalValue]: + """ + Load Monte Carlo samples data from SQLite database connection into a list + of TaggedDistributionalValue (one per unique grouping of metadata). + """ + + group_cols = [ + "Expression_DeclarationFileName", + "Expression_Subprogram", + "Expression_Name", + "Expression_DeclarationLineNumber", + "Assignment_Index", + "ValueId", + "MonteCarlo_Count", + ] + mc_data_select = group_cols + ["Particle_Value"] + + # Query the database + query = ( + f'SELECT {",".join(mc_data_select)} FROM "{table}" WHERE Expression_Name = ?' + ) + mc_data_df = pd.read_sql_query(query, con, params=(target_expression,)) + if mc_data_df.empty: + raise ValueError(f"No results found for query: {query}") + + # Group by all these columns, aggregate Particle_Value into list + grouped_df = mc_data_df.groupby(group_cols, as_index=False).agg( + {"Particle_Value": list} + ) + + distributions = [] + for _, row in grouped_df.iterrows(): + mc_samples = row["Particle_Value"] + try: + dv = DistributionalValue.from_samples(mc_samples) + except ValueError as e: + print( + f"Failed creating DistributionalValue.from_samples: {e}. " + f"Skipping sample" + ) + continue + distributions.append( + TaggedDistributionalValue( + dv=dv, + representation_type=RepresentationTypes.MONTE_CARLO, + representation_size=dv.UR_order, + mc_count=row["MonteCarlo_Count"], + ) + ) + + return distributions + + +def _load_scalar_weighted_samples_with_con( + con: Connection, table: str, target_expression: str +) -> list[TaggedDistributionalValue]: + """ + Load weighted samples data from SQLite database connection into a list of + TaggedDistributionalValue objects, grouped by MonteCarlo_Count. + """ + + weighted_samples_data_select = [ + "Expression_Name", + "Expression_Subprogram", + "Expression_DeclarationFileName", + "Expression_DeclarationLineNumber", + "ValueId", + "Position", + "Weight", + "MonteCarlo_Count", + ] + + weighted_samples_data_df = pd.read_sql_query( + f'SELECT {",".join(weighted_samples_data_select)} FROM "{table}" ' + "WHERE Expression_Name = ?", + con, + params=(target_expression,), + ) + + # Group by all key fields including MonteCarlo_Count + grouped = weighted_samples_data_df.groupby( + [ + "Expression_DeclarationFileName", + "Expression_Subprogram", + "Expression_Name", + "Expression_DeclarationLineNumber", + "ValueId", + "MonteCarlo_Count", + ], + as_index=False, + ).aggregate(func={"Position": list, "Weight": list}) + + distributions = [] + + for _, row in grouped.iterrows(): + positions = row["Position"] + masses = row["Weight"] + value_id = row["ValueId"] + mc_count = row["MonteCarlo_Count"] + try: + dv = DistributionalValue.from_weighted_samples(positions, masses) + distributions.append( + TaggedDistributionalValue( + dv=dv, + representation_type=RepresentationTypes.WEIGHTED_SAMPLES, + representation_size=dv.UR_order, + mc_count=mc_count, + ) + ) + except ValueError as e: + raise ValueError( + f"{e}: DistributionalValue.from_weighted_samples failed:\n" + f"{row['Expression_DeclarationFileName']}:\n" + f"{row['Expression_DeclarationLineNumber']}:\n" + f"({row['Expression_Name']}), ValueID:{value_id}\n" + ) from e + + return distributions + + +def _load_weighted_samples_with_con( + con: Connection, table: str, target_expression: str +) -> TaggedDistributionalValue: + """ + Load weighted samples data from SQLite database connection into a + TaggedDistributionalValue. + """ + + # Select only the needed columns and filter at the DB level. + query = f""" + SELECT + Expression_Subprogram, + Expression_DeclarationFileName, + Expression_DeclarationLineNumber, + Expression_Name, + ValueId, + Position, + Weight, + COUNT(*) as Id + FROM "{table}" + WHERE Expression_Name = ? + GROUP BY Expression_Subprogram, Expression_DeclarationFileName, + Expression_DeclarationLineNumber, Expression_Name, ValueId + """ + + # Use parameterized query for safety and potentially better caching + weighted_samples_data_info_df = pd.read_sql_query( + query, con, params=(target_expression,) + ) + + # Early exit if no data + if weighted_samples_data_info_df.empty: + raise ValueError(f"No data found for expression: {target_expression}") + + # Validate uniqueness (faster on aggregated data) + uniq_subprogram = weighted_samples_data_info_df["Expression_Subprogram"].unique() + if len(uniq_subprogram) != 1: + raise RuntimeError( + f"Data ambiguity error: Expression_Subprogram unique values count after " + f"filtering is not 1: len(uniq_subprogram)=={len(uniq_subprogram)}" + ) + + uniq_declaration_line_number = weighted_samples_data_info_df[ + "Expression_DeclarationLineNumber" + ].unique() + if len(uniq_declaration_line_number) != 1: + raise ValueError( + f"Data ambiguity error: Expression_DeclarationLineNumber unique values count " + f"after filtering is not 1: len(uniq_declaration_line_number)=={len(uniq_declaration_line_number)}" + ) + + # Get the single row + row = weighted_samples_data_info_df.iloc[0] + + # Need to fetch position and weight lists separately since GROUP BY doesn't + # support array aggregation in SQLite + positions_weights_query = f""" + SELECT Position, Weight + FROM "{table}" + WHERE Expression_Name = ? + ORDER BY Id + """ + positions_weights_df = pd.read_sql_query( + positions_weights_query, con, params=(target_expression,) + ) + + positions = positions_weights_df["Position"].tolist() + masses = positions_weights_df["Weight"].tolist() + + # Create return value + try: + dv = DistributionalValue.from_weighted_samples(positions, masses) + except ValueError as e: + raise ValueError( + f"ValueError {e}: DistributionalValue.from_weighted_samples: " + f"did not parse UxHw Dist_Value \n" + f"{row['Expression_DeclarationFileName']}:\n" + f"{row['Expression_DeclarationLineNumber']}:\n" + f"({row['Expression_Name']}):\n" + f" ValueID:{row['ValueId']}\n" + ) from e + + return TaggedDistributionalValue( + dv=dv, + representation_type=RepresentationTypes.WEIGHTED_SAMPLES, + representation_size=dv.UR_order, + ) + + +def _filter_duplicate_ux_writes(df: pd.DataFrame) -> pd.DataFrame: + """ + Filter out duplicate Evaluation rows from a database traced multiple times. + + A repeated trace can leave several rows under one Execution_ID for the same + expression, sometimes with a different representation type (e.g. Jupiter + data under an Athens Execution_ID). For such groups we keep the row whose Ux + UR_type matches the expected UR_Type from Emulator_Execution_Info, falling + back to the first row when none match (e.g. Atlas stores as Ux06 internally). + + Args: + df: Merged UxHw-data / execution-info rows, keyed by Execution_ID, + Expression_Name, ValueId, and Assignment_Index. + + Returns: + The same rows with each duplicate group reduced to a single row. + """ + group_cols = [ + "Execution_ID", + "Expression_Name", + "ValueId", + "Assignment_Index", + ] + + # Check if there are any duplicates at all (fast path) + if not df.duplicated(subset=group_cols, keep=False).any(): + return df + + result_rows = [] + + for _, group in df.groupby(group_cols, sort=False): + if len(group) == 1: + result_rows.append(group) + continue + + # Multiple rows: prefer the one whose Ux UR_type matches UR_Type + matching = [] + for idx, row in group.iterrows(): + ux_ur_type = _extract_ur_type_from_ux_string(row["Dist_Value"]) + expected_str = RepresentationTypes.from_uxhw_db(row["UR_Type"]) + if expected_str in STRING_TO_CORE_REPRESENTATION: + expected_int = STRING_TO_CORE_REPRESENTATION[expected_str] + if ux_ur_type == expected_int: + matching.append(idx) + + if matching: + result_rows.append(group.loc[matching[:1]]) + else: + # No Ux type matches (e.g., Atlas). Keep the first row + result_rows.append(group.iloc[:1]) + + result = pd.concat(result_rows, ignore_index=True) + + return result + + +def _load_uxhw( + db_path: str, + table: str, + target_expression: str, + ur_types: list[str], + ur_sizes: list[int], + value_id: str | None = None, +) -> list[TaggedDistributionalValue]: + """ + Load UxHw traced values from a SQLite database. + + Filters by ``target_expression``, ``ur_types``, and ``ur_sizes``. When + ``value_id`` is given, picks the row with that ValueId. Otherwise picks the + last database write for each group. + + Args: + db_path: Path to the SQLite database file. + table: Name of the table holding the traced values. + target_expression: Expression name to filter rows by. + ur_types: Representation types to include (e.g. ``["Athens"]``). + ur_sizes: Representation sizes to include. + value_id: Specific ValueId to select. If ``None``, the last write per + group is used. + + Returns: + The matching values as a list of ``TaggedDistributionalValue``. + """ + # Scope the connection with `closing()` so it is released on every exit + # path, including the error `raise`s below (the sibling loaders get this + # from `_load_with_connection`. This one has extra params so it opens its + # own connection). + with closing(_connect_to_database(db_path)) as con: + # Map each requested type to its DB name (currently an identity mapping; + # see RepresentationTypes.TO_UXHW_DB). + uxhw_db_ur_types = [] + for ur_type in ur_types: + uxhw_db_ur_types.append(RepresentationTypes.to_uxhw_db(ur_type)) + + uxhw_data_select = [ + "ValueId", + "Expression_Name", + "Expression_Subprogram", + "Expression_DeclarationFileName", + "Expression_DeclarationLineNumber", + "Execution_Info_Table_ID", + "Particle_Value", + "Dist_Value", + "Assignment_Index", + ] + + uxhw_data_query = f'SELECT {",".join(uxhw_data_select)} FROM "{table}" WHERE Expression_Name = ?' + params: list[str] = [target_expression] + if value_id is not None: + uxhw_data_query += " AND ValueId = ?" + params.append(value_id) + + uxhw_data_df = pd.read_sql_query(uxhw_data_query, con, params=tuple(params)) + if uxhw_data_df.empty: + raise ValueError("ValueError: " + uxhw_data_query + ": no results ") + + ex_info_select = [ + "Execution_ID", + "UR_Type", + "UR_Order", + "UR_Order_CoreLibrary", + "CorrelationTracking_Status", + ] + + if not uxhw_db_ur_types or not ur_sizes: + raise ValueError("ur_types and ur_sizes must be non-empty") + + ur_type_placeholders = ",".join(["?"] * len(uxhw_db_ur_types)) + size_placeholders = ",".join(["?"] * len(ur_sizes)) + ex_info_query = ( + f'SELECT {",".join(ex_info_select)} FROM "Emulator_Execution_Info" ' + f"WHERE (UR_Type IN ({ur_type_placeholders})) " + f"AND (UR_Order IN ({size_placeholders}) " + f"OR UR_Order_CoreLibrary IN ({size_placeholders}))" + ) + ex_info_params: list[str | int] = [*uxhw_db_ur_types, *ur_sizes, *ur_sizes] + + ex_info_df = pd.read_sql_query(ex_info_query, con, params=tuple(ex_info_params)) + if ex_info_df.empty: + print( + f"⚠️ WARNING: Execution info query returned no results:\n{ex_info_query}" + ) + raise ValueError("No results from Execution info query.") + + uxhw_data_info_df = ex_info_df.merge( + uxhw_data_df, + how="inner", + left_on=["Execution_ID"], + right_on=["Execution_Info_Table_ID"], + ) + + if uxhw_data_info_df.empty: + print( + "⚠️ WARNING: Merge of UxHw and Execution info dataframes returned no results." + ) + raise ValueError("No results after merging UxHw data and execution info.") + + uxhw_data_info_df = _filter_duplicate_ux_writes(uxhw_data_info_df) + + # With no ValueId anchor, several values may satisfy the filters. Pick the + # most recently written one per group. + if value_id is None: + # `.last()` takes the most recently written row, using row (write) order + # rather than the largest PC / Assignment_Index: a program can write + # from a high PC then jump back and overwrite, so the highest PC is not + # necessarily the last write. + uxhw_data_info_df = uxhw_data_info_df.groupby( + [ + "Expression_DeclarationFileName", + "Expression_Subprogram", + "Expression_Name", + "Expression_DeclarationLineNumber", + "UR_Type", + "UR_Order", + "UR_Order_CoreLibrary", + "CorrelationTracking_Status", + ], + as_index=False, + ).last() + + dist_values_sr = uxhw_data_info_df.apply( # type: ignore[call-overload] + _dist_value_from_row, axis="columns", result_type="reduce" + ) + + return cast(list[TaggedDistributionalValue], dist_values_sr.tolist()) + + +def _extract_ur_type_from_ux_string(dist_value: str) -> int | None: + """ + Extract the UR_type byte from a Ux hex string without full parsing. + + The hex buffer starts at the first character after "Ux", so the UR_type is + the first byte (hex chars 0-1). + + Args: + dist_value: The raw ``Dist_Value`` string from the database. + + Returns: + The UR_type byte as an int, or ``None`` if ``dist_value`` is not a + parseable Ux string. + """ + if not isinstance(dist_value, str) or "Ux" not in dist_value: + return None + try: + hex_str = dist_value.split("Ux", 1)[1] + # Byte 0: UR_type (2 hex chars) + ur_type_byte = bytes.fromhex(hex_str[0:2]) + return int(struct.unpack(">B", ur_type_byte)[0]) + except (ValueError, struct.error, IndexError): + return None + + +def _dist_value_from_row(uxhw_data_info_sr: pd.Series) -> TaggedDistributionalValue: + """ + Convert a database row of UxHw traced value data into a + TaggedDistributionalValue. + + Args: + uxhw_data_info_sr: One merged UxHw-data / execution-info row. + + Returns: + The row's distribution and metadata as a ``TaggedDistributionalValue``. + """ + + dist_value_raw = uxhw_data_info_sr["Dist_Value"] + dv = DistributionalValue.parse(dist_value_raw) + if dv is None: + raise ValueError(f"Parsing failed for {dist_value_raw!r}.") + + # Read metadata from the DB columns, never off the parsed Distribution. + representation_type = RepresentationTypes.from_uxhw_db(uxhw_data_info_sr["UR_Type"]) + # UR_Order is the atom count N. For Athens, UR_Order_CoreLibrary + # is N+3 (it also counts 3 internal sentinel Diracs). Use UR_Order so every + # output is labelled by build size N, including scalars that collapse to a + # single atom. External CSVs set both columns to N. + if representation_type == RepresentationTypes.ATHENS: + representation_size = uxhw_data_info_sr["UR_Order"] + else: + representation_size = uxhw_data_info_sr["UR_Order_CoreLibrary"] + + return TaggedDistributionalValue( + dv=dv, + representation_type=representation_type, + representation_size=representation_size, + correlation_tracking=uxhw_data_info_sr["CorrelationTracking_Status"], + ) diff --git a/src/signaloid/benchmarking/equivalent_mc/load_mc_test.py b/src/signaloid/benchmarking/equivalent_mc/load_mc_test.py new file mode 100644 index 0000000..d786f90 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/load_mc_test.py @@ -0,0 +1,110 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import sqlite3 +import unittest +from sqlite3 import Connection + +from signaloid.benchmarking.equivalent_mc.load import _load_mc_with_con + + +def _build_test_mc_db() -> Connection: + """Create an in-memory SQLite database with MonteCarlo test data.""" + db = sqlite3.connect(":memory:") + cursor = db.cursor() + cursor.execute("""CREATE TABLE MonteCarlo (ValueId TEXT, + Expression_Name TEXT, + Expression_Subprogram TEXT, + Expression_DeclarationFileName TEXT, + Expression_DeclarationLineNumber INTEGER, + Execution_Info_Table_ID INTEGER, + EmulatedCPU_PC INTEGER, + Assignment_Index INTEGER, + MC_Id INTEGER, + Particle_Value REAL ); + """) + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid1','var1','main','main.c',84,1,134250000,0,0,17.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid11','var1','main','main.c',84,1,134250000,1,0,1700.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid2','var2','main','main.c',85,1,134253000,0,0,-100.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid3','var3','main','main.c',86,1,134254000,0,0,201);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid1','var1','main','main.c',84,2,134250000,0,1,18.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid11','var1','main','main.c',84,2,134250000,1,0,1800.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid2','var2','main','main.c',85,2,134253000,0,1,-101.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid3','var3','main','main.c',86,2,134254000,0,1,202);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid1','var1','main','main.c',84,3,134250000,0,2,19.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid11','var1','main','main.c',84,3,134250000,1,0,1900.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid2','var2','main','main.c',85,3,134253000,0,2,-102.0);") + cursor.execute("INSERT INTO MonteCarlo VALUES\ + ('valueid3','var3','main','main.c',86,3,134254000,0,2,203);") + # Row layout mirrors the emulator's distributional-value trace output. + return db + + +class TestLoadMcWithCon(unittest.TestCase): + """_load_mc_with_con correctly loads per-variable MC data from an in-memory DB.""" + + db: Connection + + @classmethod + def setUpClass(cls) -> None: + cls.db = _build_test_mc_db() + + @classmethod + def tearDownClass(cls) -> None: + cls.db.close() + + def test__load_mc_with_con_vars(self) -> None: + var2_data = _load_mc_with_con(self.db, "MonteCarlo", "var2") + + self.assertEqual(var2_data.representation_type, "MonteCarlo") + self.assertEqual(var2_data.dv.UR_order, 3) + self.assertTrue(all([v < 99 for v in var2_data.dv.positions])) + self.assertEqual(len(var2_data.dv.masses), 3) + + var3_data = _load_mc_with_con(self.db, "MonteCarlo", "var3") + + self.assertEqual(var3_data.representation_type, "MonteCarlo") + self.assertEqual(var3_data.dv.UR_order, 3) + self.assertTrue(all([v > 200 for v in var3_data.dv.positions])) + self.assertEqual(len(var3_data.dv.masses), 3) + + var1_data = _load_mc_with_con(self.db, "MonteCarlo", "var1") + + self.assertEqual(var1_data.representation_type, "MonteCarlo") + self.assertEqual(var1_data.dv.UR_order, 3) + # Because of larger assignment index, _load_mc_with_con of var1 should get the + # bigger three values from the fixture + self.assertTrue(all([not (15 < v < 25) for v in var1_data.dv.positions])) + self.assertEqual(len(var1_data.dv.masses), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/print_uxhw_table_test.py b/src/signaloid/benchmarking/equivalent_mc/print_uxhw_table_test.py new file mode 100644 index 0000000..247d2ab --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/print_uxhw_table_test.py @@ -0,0 +1,120 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import io +import unittest +from contextlib import redirect_stdout + +from signaloid.benchmarking.equivalent_mc.equivalent_mc_main import ( + BLOW_UP_TABLE_TOKEN, + _print_uxhw_table, +) +from signaloid.benchmarking.types import TaggedDistributionalValue +from signaloid.distributional.dirac_delta import DiracDelta +from signaloid.distributional.distributional import DistributionalValue + + +def _tagged( + dv: DistributionalValue, + representation_type: str, + correlation_tracking: str = "Disabled", +) -> TaggedDistributionalValue: + """A UxHw carrier as produced by the tracing-table loader in load.py.""" + return TaggedDistributionalValue( + dv=dv, + representation_type=representation_type, + representation_size=dv.UR_order, + correlation_tracking=correlation_tracking, + ) + + +class TestPrintUxHwTableBlowUp(unittest.TestCase): + """``_print_uxhw_table`` degrades a blown-up representation gracefully + instead of overflowing on the variance read of the re-loaded dv.""" + + def test_blown_config_does_not_raise_and_emits_excluded_row(self) -> None: + """A config whose dv parks real mass at ~1e303 (e.g. EDA-ngspice Athens + order 32) would overflow ``DistributionalValue.calculate_variance`` + ((position - mean) ** 2). ``_print_uxhw_table`` must not raise and + must mark that row "blow-up / excluded" while still printing the + healthy rows' statistics.""" + healthy = _tagged( + DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=0.5), + DiracDelta(position=2.0, mass=0.5), + ] + ), + representation_type="Athens", + ) + blown = _tagged( + DistributionalValue( + dirac_deltas=[ + DiracDelta(position=1.0, mass=0.5), + DiracDelta(position=1e303, mass=0.5), + ] + ), + representation_type="Jupiter", + ) + + # Guard the premise: reading the blown dv's variance overflows. + with self.assertRaises(OverflowError): + blown.dv.calculate_variance() + + buffer = io.StringIO() + with redirect_stdout(buffer): + # The bug: this used to raise OverflowError and crash the sweep. + _print_uxhw_table(uxhw=[healthy, blown]) + output = buffer.getvalue() + + # The blown row is rendered as excluded for both Mean and Variance. + self.assertEqual(output.count(BLOW_UP_TABLE_TOKEN), 2) + self.assertIn("Jupiter", output) + # The healthy row still reports its real statistics (mean 1.5). + self.assertIn("Athens", output) + self.assertIn("1.5", output) + + def test_all_healthy_configs_print_statistics(self) -> None: + """With no blow-up, the table prints the real mean/variance and the + excluded token does not appear.""" + healthy = _tagged( + DistributionalValue( + dirac_deltas=[ + DiracDelta(position=10.0, mass=0.5), + DiracDelta(position=20.0, mass=0.5), + ] + ), + representation_type="Athens", + ) + + buffer = io.StringIO() + with redirect_stdout(buffer): + _print_uxhw_table(uxhw=[healthy]) + output = buffer.getvalue() + + self.assertNotIn(BLOW_UP_TABLE_TOKEN, output) + # tabulate renders the mean (15.0) and variance (25.0) of the two + # deltas. It normalises trailing ".0" away, so assert on the integers. + self.assertIn("15", output) + self.assertIn("25", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/equivalent_mc/py.typed b/src/signaloid/benchmarking/equivalent_mc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/signaloid/benchmarking/equivalent_mc/shared_xlim_test.py b/src/signaloid/benchmarking/equivalent_mc/shared_xlim_test.py new file mode 100644 index 0000000..54098a8 --- /dev/null +++ b/src/signaloid/benchmarking/equivalent_mc/shared_xlim_test.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +import numpy as np + +from signaloid.distributional.distributional import DistributionalValue +from signaloid.benchmarking.config import RepresentationTypes +from signaloid.benchmarking.types import TaggedDistributionalValue +from signaloid.benchmarking.equivalent_mc.equivalent_mc_utils import ( + _distribution_range, + _compute_shared_xlim, +) + + +def _tagged( + samples: np.ndarray, samples_type: bool = False +) -> TaggedDistributionalValue: + """Wrap float samples in a carrier, optionally tagged as the ``Samples`` + representation so the inverse-CDF takes the (unweighted) samples branch.""" + dv = DistributionalValue.from_samples(samples) + representation_type = RepresentationTypes.SAMPLES if samples_type else None + if samples_type: + setattr(dv, "representation_type", RepresentationTypes.SAMPLES) + return TaggedDistributionalValue(dv=dv, representation_type=representation_type) + + +class TestDistributionRange(unittest.TestCase): + """``_distribution_range`` measures a distribution's x-axis span.""" + + def test_full_range_spans_support(self) -> None: + samples = np.linspace(-5.0, 5.0, 1001) + full_range = _distribution_range(_tagged(samples).dv, robust=False) + assert full_range is not None + low, high = full_range + self.assertAlmostEqual(low, -5.0, places=6) + self.assertAlmostEqual(high, 5.0, places=6) + + def test_robust_range_clips_outliers(self) -> None: + # A clean body in [-1, 1] with a handful of extreme outliers that a + # full-support range would be stretched by. + body = np.random.default_rng(0).uniform(-1.0, 1.0, 10_000) + samples = np.concatenate([body, [1e6, -1e6, 5e5]]) + tagged = _tagged(samples, samples_type=True) + + full_range = _distribution_range(tagged.dv, robust=False) + robust_range = _distribution_range(tagged.dv, robust=True) + assert full_range is not None + assert robust_range is not None + full_low, full_high = full_range + robust_low, robust_high = robust_range + + # Full range is dominated by the outliers. The robust range is not. + self.assertLess(full_low, -1e5) + self.assertGreater(full_high, 1e5) + self.assertGreater(robust_low, -2.0) + self.assertLess(robust_high, 2.0) + + def test_empty_distribution_returns_none(self) -> None: + empty = DistributionalValue() + # inverse_cdf on an empty distribution yields NaN, which the helper maps to None. + self.assertIsNone(_distribution_range(empty, robust=False)) + + +class TestComputeSharedXLim(unittest.TestCase): + """``_compute_shared_xlim`` unions the plotted distributions' domains.""" + + def test_domain_spans_ground_truth_and_uxhw(self) -> None: + ground_truth = _tagged(np.linspace(-2.0, 2.0, 1001)) + uxhw = [_tagged(np.linspace(0.0, 6.0, 1001))] + + xlim = _compute_shared_xlim(ground_truth, uxhw, adversaries=[]) + + assert xlim is not None + low, high = xlim + # The union of [-2, 2] and [0, 6] is [-2, 6]. Padding widens it further. + self.assertLess(low, -2.0) + self.assertGreater(high, 6.0) + + def test_adversary_outliers_do_not_blow_up_domain(self) -> None: + ground_truth = _tagged(np.random.default_rng(1).normal(0.0, 1.0, 5000)) + uxhw = [_tagged(np.random.default_rng(2).normal(0.5, 1.0, 5000))] + + adv_body = np.random.default_rng(3).normal(0.0, 1.0, 5000) + adversaries = [ + _tagged(np.concatenate([adv_body, [1e6, -1e6]]), samples_type=True) + ] + + xlim = _compute_shared_xlim(ground_truth, uxhw, adversaries) + + assert xlim is not None + low, high = xlim + # Despite the ±1e6 adversary outliers, the domain stays close to the + # ground-truth / UxHw support. + self.assertGreater(low, -20.0) + self.assertLess(high, 20.0) + + def test_returns_none_when_no_ranges_available(self) -> None: + empty = TaggedDistributionalValue(dv=DistributionalValue()) + self.assertIsNone(_compute_shared_xlim(empty, uxhw=[], adversaries=[])) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/py.typed b/src/signaloid/benchmarking/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/signaloid/benchmarking/types.py b/src/signaloid/benchmarking/types.py new file mode 100644 index 0000000..934ef70 --- /dev/null +++ b/src/signaloid/benchmarking/types.py @@ -0,0 +1,338 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import re +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +from signaloid.distributional.distributional import DistributionalValue + +from signaloid.benchmarking.config import Correlations, ReportingMethods, VariableTypes + +# Only BenchmarkingVariable is part of the public API (it is the element type +# of LoadDataComputeEquivalentMCArgs["benchmarking_variables"] that callers +# construct). The remaining value/result types are populated on / read off a +# BenchmarkingVariable rather than constructed by callers, so they stay +# importable by explicit path but out of the advertised surface. +__all__ = [ + "BenchmarkingVariable", +] + + +@dataclass +class TaggedDistributionalValue: + """ + A :class:`DistributionalValue` paired with the loader metadata the + benchmarking surface needs. + + The metadata fields are populated directly from the SQLite columns by the + ``equivalent_mc/load.py`` loaders, so nothing here reads them off a + ``Distribution`` instance. ``dv`` is always a base + :class:`DistributionalValue` (never the analyses-side ``Distribution`` + subclass), keeping this surface free of that dependency. + + Attributes: + dv: The numeric distribution (positions, masses, mean, ...). + representation_type: Uncertain-representation type string (e.g. + ``"Athens"``, ``"MonteCarlo"``, ``"WeightedSamples"``). + representation_size: Build size N for the representation. For Athens this + is ``UR_Order``. Otherwise ``UR_Order_CoreLibrary``. + correlation_tracking: Correlation-tracking status string from the DB + (e.g. ``"Disabled"``, ``"Autocorrelation"``). ``None`` for + ground-truth and adversary loads that carry no correlation metadata. + mc_count: Monte Carlo sample count for scalar loads, ``None`` + otherwise. + """ + + dv: DistributionalValue + representation_type: str | None = None + representation_size: int | None = None + correlation_tracking: str | None = None + mc_count: int | None = None + + def __repr__(self) -> str: + """ + Reproduce ``Distribution.__repr__`` so the config string (the + ``UXHW_CONF`` key written to the EMCC CSV and used to join timing data) + stays byte-identical. + """ + base = f"{self.representation_type}-{self.representation_size}" + # Disabled correlation tracking is the implicit default and is omitted + # from the config string. Autocorrelation carries a suffix. + if self.correlation_tracking == Correlations.AUTOCORRELATION: + return f"{base}-{Correlations.AUTOCORRELATION}" + return base + + +@dataclass +class DistributionSamples: + """ + Per-variable distribution sample positions/weights and scalar Monte Carlo + outputs. Owned by ``BenchmarkingVariable.distribution_samples``. + """ + + values: list[float] = field(default_factory=list) + weights: list[float] = field(default_factory=list) + scalar_output_dict: dict[int, list[float]] = field(default_factory=dict) + + def set_values(self, values: list[float]) -> None: + """ + Set distribution sample positions. Weights unchanged. + + Args: + values: List of sample position values. + """ + self.values = values + + def set_weighted_values( + self, + values: list[float], + weights: list[float], + ) -> None: + """ + Set distribution sample positions and weights. + + Args: + values: List of sample position values. + weights: List of corresponding sample weights. + """ + self.values = values + self.weights = weights + + def empty_values(self) -> None: + """ + Clear stored sample data (positions, weights, scalar outputs). + """ + self.values = [] + self.weights = [] + self.scalar_output_dict = {} + + +@dataclass +class TimingMeasurements: + """ + Per-variable timing measurements collected during the UxHw/MC/native + pipeline phases. Owned by ``BenchmarkingVariable.timing_measurements``. + """ + + measurement_dict: dict[str, dict[str, float]] = field(default_factory=dict) + + def append( + self, + *, + config: str, + time: float, + e2e_time: float, + pin_dyn_inst_count: float, + db_time: float = 0.0, + db_dyn_inst_count: float = 0.0, + ) -> None: + """ + Record a timing measurement for a given configuration. + + Args: + config: Configuration key (e.g. representation type string). + time: In-application elapsed time in seconds. + e2e_time: End-to-end elapsed time in seconds. + pin_dyn_inst_count: Dynamic instruction count from PIN. + db_time: Database-access time in seconds. + db_dyn_inst_count: Dynamic instruction count for DB access. + """ + dictionary: dict[str, float] = {} + dictionary["In Application Time"] = time + dictionary["Database Time"] = db_time + dictionary["End-to-End Time"] = e2e_time + dictionary["Database Dyn. Inst. Count"] = db_dyn_inst_count + dictionary["PIN Dyn. Inst. Count"] = pin_dyn_inst_count + + self.measurement_dict[config] = dictionary + + +@dataclass +class EmccResults: + """ + Per-variable EMCC (Equivalent Monte Carlo Count) analysis state. Owned by + ``BenchmarkingVariable.emcc_results``. + """ + + equiv_mc_list: list[int] = field(default_factory=list) + emcc_data: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class UxhwDistanceRecord: + """ + One UxHw configuration vs. ground-truth Wasserstein distance. + + ``uxhw_conf`` is a union: the producer pipeline + (``analysis.compute_uxhw_distances``) stores a + ``TaggedDistributionalValue``, while the CSV-round-tripped loader path + (``measurement_loader.load_uxhw_distances``) stores its ``repr()`` string. + Consumers handle both shapes. + + ``blow_up_reason`` marks a degraded (blown-up) representation: when not + ``None`` the distances are set to ``inf`` and the config is reported as + "blow-up / excluded" rather than dropped, so it stays in the full matrix + (see ``BenchmarkingVariables.BLOW_UP_REASON``). In-memory only, not + persisted to the CSVs. + """ + + uxhw_conf: "TaggedDistributionalValue | str" + uxhw_distance: float + uxhw_binned_distance: float | None = None + blow_up_reason: str | None = None + + +@dataclass +class UxhwDistances: + """ + Per-variable UxHw-distance records. Owned by + ``BenchmarkingVariable.uxhw_distances``. + """ + + records: list[UxhwDistanceRecord] = field(default_factory=list) + + +@dataclass +class AsymptoticDistribution: + """ + Per-variable asymptotic distribution data. Owned by + ``BenchmarkingVariable.asymptotic_distribution``. + + Field shape varies by variable type: + - Distribution-typed variables populate ``mean``, + ``quantile_95``, ``quantile_99`` and ``samples``. + - Scalar-typed variables populate ``mean``, ``quantile_95``, + ``quantile_99``, ``is_normal`` and ``scale``. + + All fields default to ``None`` so a freshly-constructed instance + can be filled in incrementally by the generator + (``Benchmark.generate_asymptotic_distance_distributions``) or + the loader (``measurement_loader.load_asymptotic_dist``). + """ + + mean: float | None = None + quantile_95: float | None = None + quantile_99: float | None = None + # CDF value at ``mean``. Serialized to the asymptotic-distance + # CSV and round-tripped back by ``load_asymptotic_dist``, but no + # consumer reads this field at runtime. + mean_quantile: float | None = None + # Distribution-typed variables only. + samples: np.ndarray | None = None + # Scalar-typed variables only. + is_normal: bool | None = None + scale: float | None = None + + def value_for(self, reporting_method: str) -> float | None: + """ + Look up the value associated with a ReportingMethods string. + + Supports the dynamic, reporting-method-keyed access pattern in + ``compute_emcc_predictions``. + + Args: + reporting_method: One of ``ReportingMethods.MEAN``, + ``ReportingMethods.QUANTILE_95``, or + ``ReportingMethods.QUANTILE_99``. + + Returns: + The stored float for the requested method, or ``None`` if + the field has not been populated yet. + + Raises: + KeyError: If ``reporting_method`` is not a known method, so a + typo does not silently return ``None``. + """ + try: + return { + ReportingMethods.MEAN: self.mean, + ReportingMethods.QUANTILE_95: self.quantile_95, + ReportingMethods.QUANTILE_99: self.quantile_99, + }[reporting_method] + except KeyError as exc: + raise KeyError( + f"Unknown reporting method {reporting_method!r}; " + f"expected one of {{Mean, Quantile-95, Quantile-99}}." + ) from exc + + +class BenchmarkingVariable: + """ + Holds all data for a single benchmarked variable. + """ + + def __init__( + self, + name: str, + description: str, + value_id: str = "", + program: str = "main", + path: str = "", + line_number: str = "", + file_name: str = "main.c", + type: str = VariableTypes.DISTRIBUTION, + cla: str = "", + ): + """ + Initialise a BenchmarkingVariable. + + Args: + name: The name of the variable as traced in the application. + description: Human-readable description of the variable. + value_id: Unique identifier for the traced value. + program: Name of the sub-program owning the variable. + path: Filesystem path context for the variable. + line_number: Source line number at which the variable is traced. + file_name: Source file name in which the variable is declared. + type: Output type. One of VariableTypes constants. + cla: Command-line arguments used to isolate this variable. + """ + self.value_id = value_id + self.name = name + self.description = description + self.program = program + self.path = path + self.file_name = file_name + self.type = type + self.line_number = line_number + self.cla = cla + self.command_line_arguments: str = "" + self.timing_measurements: TimingMeasurements = TimingMeasurements() + self.distribution_samples: DistributionSamples = DistributionSamples() + self.emcc_results: EmccResults = EmccResults() + self.asymptotic_distribution: AsymptoticDistribution = AsymptoticDistribution() + self.formatted_description: str = re.sub(r"\s+", "-", self.description.lower()) + self.uxhw_distances: UxhwDistances = UxhwDistances() + + def __str__(self) -> str: + """ + Return a string representation of the BenchmarkingVariable. + """ + return ( + f"BenchmarkingVariable(" + f"name='{self.name}', " + f"description='{self.description}', " + f"type='{self.type}', " + f"file='{self.file_name}:{self.line_number}', " + f"program='{self.program}')" + ) diff --git a/src/signaloid/benchmarking/types_asymptotic_distribution_test.py b/src/signaloid/benchmarking/types_asymptotic_distribution_test.py new file mode 100644 index 0000000..8171df1 --- /dev/null +++ b/src/signaloid/benchmarking/types_asymptotic_distribution_test.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +import numpy as np + +from signaloid.benchmarking.types import AsymptoticDistribution +from signaloid.benchmarking.config import ReportingMethods + + +class TestAsymptoticDistributionDefaults(unittest.TestCase): + """A freshly-constructed AsymptoticDistribution has all fields None.""" + + def test_asymptotic_distribution_defaults_all_none(self) -> None: + """A freshly-constructed instance has every field set to ``None``.""" + asymptotic = AsymptoticDistribution() + + self.assertIsNone(asymptotic.mean) + self.assertIsNone(asymptotic.quantile_95) + self.assertIsNone(asymptotic.quantile_99) + self.assertIsNone(asymptotic.mean_quantile) + self.assertIsNone(asymptotic.samples) + self.assertIsNone(asymptotic.is_normal) + self.assertIsNone(asymptotic.scale) + + +class TestAsymptoticDistributionPopulation(unittest.TestCase): + """AsymptoticDistribution fields can be populated and read back correctly.""" + + def test_asymptotic_distribution_distribution_path_population(self) -> None: + """Distribution-typed variables populate ``mean``, the quantiles, + and ``samples``.""" + asymptotic = AsymptoticDistribution() + asymptotic.mean = 0.42 + asymptotic.quantile_95 = 0.5 + asymptotic.quantile_99 = 0.9 + asymptotic.samples = np.array([0.1, 0.2, 0.3]) + + self.assertEqual(asymptotic.mean, 0.42) + self.assertEqual(asymptotic.quantile_95, 0.5) + self.assertEqual(asymptotic.quantile_99, 0.9) + np.testing.assert_array_equal(asymptotic.samples, [0.1, 0.2, 0.3]) + + def test_asymptotic_distribution_scalar_path_population(self) -> None: + """Scalar-typed variables populate ``mean``, the quantiles, + ``is_normal``, and ``scale``.""" + asymptotic = AsymptoticDistribution() + asymptotic.mean = 0.1 + asymptotic.quantile_95 = 0.5 + asymptotic.quantile_99 = 0.9 + asymptotic.is_normal = True + asymptotic.scale = 0.05 + + self.assertEqual(asymptotic.mean, 0.1) + self.assertIs(asymptotic.is_normal, True) + self.assertEqual(asymptotic.scale, 0.05) + + def test_asymptotic_distribution_independent_instances(self) -> None: + """Two AsymptoticDistribution instances do not share field state.""" + a = AsymptoticDistribution() + b = AsymptoticDistribution() + a.mean = 0.9 + + self.assertIsNone(b.mean) + + +class TestAsymptoticDistributionValueFor(unittest.TestCase): + """AsymptoticDistribution.value_for dispatches methods to typed fields.""" + + def test_value_for_returns_the_typed_field(self) -> None: + """``value_for`` dispatches each ReportingMethods string to the + corresponding typed attribute.""" + cases = [ + (ReportingMethods.MEAN, "mean"), + (ReportingMethods.QUANTILE_95, "quantile_95"), + (ReportingMethods.QUANTILE_99, "quantile_99"), + ] + for method, attr in cases: + with self.subTest(method=method, attr=attr): + asymptotic = AsymptoticDistribution() + setattr(asymptotic, attr, 0.123) + + self.assertEqual(asymptotic.value_for(method), 0.123) + + def test_value_for_returns_none_when_field_unpopulated(self) -> None: + """``value_for`` returns ``None`` for a known method when the + underlying field has not yet been set — caller decides how to + handle missing data.""" + asymptotic = AsymptoticDistribution() + + self.assertIsNone(asymptotic.value_for(ReportingMethods.MEAN)) + + def test_value_for_raises_on_unknown_method(self) -> None: + """An unknown reporting method raises ``KeyError`` rather than + silently returning ``None`` — silent failure would hide typos.""" + asymptotic = AsymptoticDistribution() + asymptotic.mean = 0.5 + + with self.assertRaisesRegex(KeyError, "Unknown reporting method"): + asymptotic.value_for("not-a-real-method") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/types_benchmarking_variable_test.py b/src/signaloid/benchmarking/types_benchmarking_variable_test.py new file mode 100644 index 0000000..ca639a9 --- /dev/null +++ b/src/signaloid/benchmarking/types_benchmarking_variable_test.py @@ -0,0 +1,109 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.config import VariableTypes +from signaloid.benchmarking.types import BenchmarkingVariable + + +class TestBenchmarkingVariable(unittest.TestCase): + """Tests for BenchmarkingVariable construction and attribute defaults.""" + + def test_benchmarking_variable_lives_at_new_module_path(self) -> None: + """ + Confirm BenchmarkingVariable is importable from its new neutral module + and that an instance can be constructed with expected defaults. + """ + variable = BenchmarkingVariable( + name="test_expr", + description="A test expression", + ) + self.assertIsInstance(variable, BenchmarkingVariable) + self.assertEqual(variable.name, "test_expr") + self.assertEqual(variable.description, "A test expression") + self.assertEqual(variable.type, VariableTypes.DISTRIBUTION) + self.assertEqual(variable.distribution_samples.values, []) + self.assertEqual(variable.distribution_samples.weights, []) + self.assertEqual(variable.timing_measurements.measurement_dict, {}) + self.assertEqual(variable.formatted_description, "a-test-expression") + + def test_benchmarking_variable_distribution_samples_setters(self) -> None: + """ + Confirm BenchmarkingVariable.distribution_samples.set_values, + set_weighted_values, and empty_values update sample data correctly. + """ + variable = BenchmarkingVariable( + name="x", + description="x variable", + ) + variable.distribution_samples.set_values([1.0, 2.0, 3.0]) + self.assertEqual(variable.distribution_samples.values, [1.0, 2.0, 3.0]) + self.assertEqual(variable.distribution_samples.weights, []) + + variable.distribution_samples.set_weighted_values([0.5, 1.5], [0.3, 0.7]) + self.assertEqual(variable.distribution_samples.values, [0.5, 1.5]) + self.assertEqual(variable.distribution_samples.weights, [0.3, 0.7]) + + variable.distribution_samples.empty_values() + self.assertEqual(variable.distribution_samples.values, []) + self.assertEqual(variable.distribution_samples.weights, []) + self.assertEqual(variable.distribution_samples.scalar_output_dict, {}) + + def test_benchmarking_variable_timing_measurements_append(self) -> None: + """ + Confirm BenchmarkingVariable.timing_measurements.append records all + timing fields correctly. + """ + variable = BenchmarkingVariable( + name="y", + description="y variable", + ) + variable.timing_measurements.append( + config="Athens-16", + time=1.0, + e2e_time=1.5, + pin_dyn_inst_count=1000.0, + db_time=0.1, + db_dyn_inst_count=50.0, + ) + self.assertIn("Athens-16", variable.timing_measurements.measurement_dict) + record = variable.timing_measurements.measurement_dict["Athens-16"] + self.assertEqual(record["In Application Time"], 1.0) + self.assertEqual(record["End-to-End Time"], 1.5) + self.assertEqual(record["PIN Dyn. Inst. Count"], 1000.0) + self.assertEqual(record["Database Time"], 0.1) + self.assertEqual(record["Database Dyn. Inst. Count"], 50.0) + + def test_benchmarking_variable_str(self) -> None: + """ + Confirm __str__ returns a non-empty string containing the variable name. + """ + variable = BenchmarkingVariable( + name="z", + description="z variable", + ) + result = str(variable) + self.assertIn("z", result) + self.assertIn("BenchmarkingVariable", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/types_distribution_samples_test.py b/src/signaloid/benchmarking/types_distribution_samples_test.py new file mode 100644 index 0000000..ede4087 --- /dev/null +++ b/src/signaloid/benchmarking/types_distribution_samples_test.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.types import DistributionSamples + + +class TestDistributionSamples(unittest.TestCase): + """Exercise the DistributionSamples value/weight container.""" + + def test_distribution_samples_defaults(self) -> None: + """Confirm DistributionSamples initialises with empty values, + weights, and scalar_output_dict.""" + samples = DistributionSamples() + self.assertEqual(samples.values, []) + self.assertEqual(samples.weights, []) + self.assertEqual(samples.scalar_output_dict, {}) + + def test_distribution_samples_set_values(self) -> None: + """Confirm set_values updates values while leaving weights + unchanged.""" + samples = DistributionSamples() + samples.set_values([1.0, 2.0, 3.0]) + self.assertEqual(samples.values, [1.0, 2.0, 3.0]) + self.assertEqual(samples.weights, []) + + def test_distribution_samples_set_weighted_values(self) -> None: + """Confirm set_weighted_values updates both values and weights.""" + samples = DistributionSamples() + samples.set_weighted_values([0.5, 1.5], [0.3, 0.7]) + self.assertEqual(samples.values, [0.5, 1.5]) + self.assertEqual(samples.weights, [0.3, 0.7]) + + def test_distribution_samples_empty_values(self) -> None: + """Confirm empty_values clears values, weights, and + scalar_output_dict.""" + samples = DistributionSamples() + samples.set_weighted_values([0.5, 1.5], [0.3, 0.7]) + samples.scalar_output_dict = {10: [1.0, 2.0]} + + samples.empty_values() + self.assertEqual(samples.values, []) + self.assertEqual(samples.weights, []) + self.assertEqual(samples.scalar_output_dict, {}) + + def test_distribution_samples_set_values_then_empty(self) -> None: + """Confirm set_values followed by empty_values leaves clean + state.""" + samples = DistributionSamples() + samples.set_values([1.0, 2.0, 3.0]) + self.assertEqual(samples.values, [1.0, 2.0, 3.0]) + + samples.empty_values() + self.assertEqual(samples.values, []) + self.assertEqual(samples.weights, []) + self.assertEqual(samples.scalar_output_dict, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/types_emcc_results_test.py b/src/signaloid/benchmarking/types_emcc_results_test.py new file mode 100644 index 0000000..e993069 --- /dev/null +++ b/src/signaloid/benchmarking/types_emcc_results_test.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.types import EmccResults + + +class TestEmccResults(unittest.TestCase): + """EmccResults defaults, mutation, and per-instance isolation.""" + + def test_emcc_results_defaults(self) -> None: + """Confirm EmccResults initialises with empty lists for both fields.""" + results = EmccResults() + self.assertEqual(results.equiv_mc_list, []) + self.assertEqual(results.emcc_data, []) + + def test_emcc_results_equiv_mc_list_mutation(self) -> None: + """Confirm equiv_mc_list can be populated and read back.""" + results = EmccResults() + results.equiv_mc_list = [64, 128, 256] + self.assertEqual(results.equiv_mc_list, [64, 128, 256]) + + def test_emcc_results_emcc_data_mutation(self) -> None: + """Confirm emcc_data can be populated with dicts and read back.""" + results = EmccResults() + record = {"EMCC": 128, "EMCC_Predicted": 130} + results.emcc_data.append(record) + self.assertEqual(len(results.emcc_data), 1) + self.assertEqual(results.emcc_data[0]["EMCC"], 128) + + def test_emcc_results_independent_instances(self) -> None: + """Confirm two EmccResults instances do not share list state.""" + results_a = EmccResults() + results_b = EmccResults() + results_a.equiv_mc_list.append(64) + results_a.emcc_data.append({"EMCC": 64}) + self.assertEqual(results_b.equiv_mc_list, []) + self.assertEqual(results_b.emcc_data, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/types_timing_measurements_test.py b/src/signaloid/benchmarking/types_timing_measurements_test.py new file mode 100644 index 0000000..4d3064f --- /dev/null +++ b/src/signaloid/benchmarking/types_timing_measurements_test.py @@ -0,0 +1,92 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.types import TimingMeasurements + + +class TestTimingMeasurements(unittest.TestCase): + """TimingMeasurements accumulates per-config timing records.""" + + def test_timing_measurements_defaults(self) -> None: + """Confirm TimingMeasurements initialises with an empty measurement_dict.""" + timing = TimingMeasurements() + self.assertEqual(timing.measurement_dict, {}) + + def test_timing_measurements_append_all_fields(self) -> None: + """Confirm append stores the five measurement fields under the given config key.""" + timing = TimingMeasurements() + timing.append( + config="Athens-16", + time=1.0, + e2e_time=1.5, + pin_dyn_inst_count=1000.0, + db_time=0.1, + db_dyn_inst_count=50.0, + ) + self.assertIn("Athens-16", timing.measurement_dict) + record = timing.measurement_dict["Athens-16"] + self.assertEqual(record["In Application Time"], 1.0) + self.assertEqual(record["End-to-End Time"], 1.5) + self.assertEqual(record["PIN Dyn. Inst. Count"], 1000.0) + self.assertEqual(record["Database Time"], 0.1) + self.assertEqual(record["Database Dyn. Inst. Count"], 50.0) + + def test_timing_measurements_append_default_db_fields(self) -> None: + """Confirm db_time and db_dyn_inst_count default to 0.0 when omitted.""" + timing = TimingMeasurements() + timing.append( + config="Native-MC-256", + time=2.0, + e2e_time=2.5, + pin_dyn_inst_count=2000.0, + ) + self.assertIn("Native-MC-256", timing.measurement_dict) + record = timing.measurement_dict["Native-MC-256"] + self.assertEqual(record["Database Time"], 0.0) + self.assertEqual(record["Database Dyn. Inst. Count"], 0.0) + + def test_timing_measurements_append_multiple_configs(self) -> None: + """Confirm multiple configs accumulate independently in measurement_dict.""" + timing = TimingMeasurements() + timing.append( + config="Athens-16", + time=1.0, + e2e_time=1.5, + pin_dyn_inst_count=1000.0, + ) + timing.append( + config="Athens-32", + time=2.0, + e2e_time=2.5, + pin_dyn_inst_count=2000.0, + ) + self.assertEqual(len(timing.measurement_dict), 2) + self.assertEqual( + timing.measurement_dict["Athens-16"]["In Application Time"], 1.0 + ) + self.assertEqual( + timing.measurement_dict["Athens-32"]["In Application Time"], 2.0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/types_uxhw_distances_test.py b/src/signaloid/benchmarking/types_uxhw_distances_test.py new file mode 100644 index 0000000..067cf65 --- /dev/null +++ b/src/signaloid/benchmarking/types_uxhw_distances_test.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import unittest + +from signaloid.benchmarking.types import ( + TaggedDistributionalValue, + UxhwDistanceRecord, + UxhwDistances, +) +from signaloid.distributional.distributional import DistributionalValue + + +class TestUxhwDistances(unittest.TestCase): + """UxhwDistances and UxhwDistanceRecord carry per-config UxHw distances.""" + + def test_uxhw_distances_defaults_empty_records(self) -> None: + """A freshly-constructed instance has an empty ``records`` list.""" + distances = UxhwDistances() + + self.assertEqual(distances.records, []) + + def test_uxhw_distance_record_with_binned_distance(self) -> None: + """A record constructed with all three fields keeps every value.""" + record = UxhwDistanceRecord( + uxhw_conf="Athens-16", + uxhw_distance=0.123, + uxhw_binned_distance=0.234, + ) + + self.assertEqual(record.uxhw_conf, "Athens-16") + self.assertEqual(record.uxhw_distance, 0.123) + self.assertEqual(record.uxhw_binned_distance, 0.234) + + def test_uxhw_distance_record_default_binned_distance_is_none(self) -> None: + """``uxhw_binned_distance`` defaults to ``None`` when omitted — + some producers or CSV-loaded records may omit the binned distance.""" + record = UxhwDistanceRecord( + uxhw_conf="Athens-16", + uxhw_distance=0.5, + ) + + self.assertIsNone(record.uxhw_binned_distance) + + def test_uxhw_distance_record_accepts_non_string_conf(self) -> None: + """``uxhw_conf`` accepts the ``TaggedDistributionalValue`` carrier stored by + the producer pipeline (``analysis.compute_uxhw_distances``), in addition to + the ``str`` form stored by the CSV loader path.""" + carrier = TaggedDistributionalValue( + dv=DistributionalValue.from_samples([0.0, 1.0, 2.0]) + ) + record = UxhwDistanceRecord( + uxhw_conf=carrier, + uxhw_distance=0.0, + ) + + self.assertIs(record.uxhw_conf, carrier) + + def test_uxhw_distances_records_append(self) -> None: + """Records can be appended and read back.""" + distances = UxhwDistances() + distances.records.append(UxhwDistanceRecord(uxhw_conf="A", uxhw_distance=0.1)) + distances.records.append( + UxhwDistanceRecord( + uxhw_conf="B", + uxhw_distance=0.2, + uxhw_binned_distance=0.3, + ) + ) + + self.assertEqual(len(distances.records), 2) + self.assertEqual(distances.records[0].uxhw_conf, "A") + self.assertEqual(distances.records[1].uxhw_binned_distance, 0.3) + + def test_uxhw_distances_independent_instances(self) -> None: + """Two UxhwDistances instances do not share their records list.""" + first = UxhwDistances() + second = UxhwDistances() + first.records.append(UxhwDistanceRecord(uxhw_conf="A", uxhw_distance=0.1)) + + self.assertEqual(second.records, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/circuitpython/extended_ulab_numpy.py b/src/signaloid/circuitpython/extended_ulab_numpy.py index 4467db4..cfdd14b 100644 --- a/src/signaloid/circuitpython/extended_ulab_numpy.py +++ b/src/signaloid/circuitpython/extended_ulab_numpy.py @@ -204,7 +204,7 @@ def power(self, arr, power): def cumsum(self, arr): """Cumulative sum of a 1-D array. - ulab's numpy does not provide ``cumsum``; this mirrors + ulab's numpy does not provide ``cumsum``. This mirrors ``numpy.cumsum`` for the 1-D case used by this project. :param arr: The input array. @@ -256,7 +256,7 @@ def index_of(value): return index_of(values) def ndim(self, arr): - """The number of array dimensions; ``0`` for a scalar. + """The number of array dimensions. ``0`` for a scalar. :param arr: An array or scalar. @@ -268,7 +268,7 @@ def ndim(self, arr): return 0 def shape(self, arr): - """The shape tuple of an array; ``()`` for a scalar. + """The shape tuple of an array. ``()`` for a scalar. :param arr: An array or scalar. diff --git a/src/signaloid/distributional/distributional.py b/src/signaloid/distributional/distributional.py index 8807a2d..97bf0ea 100644 --- a/src/signaloid/distributional/distributional.py +++ b/src/signaloid/distributional/distributional.py @@ -280,7 +280,7 @@ def range(self) -> float: if self.neg_inf_dirac_delta.mass > 0 or self.pos_inf_dirac_delta.mass > 0: return float("inf") # After the special-value early returns the only remaining - # deltas are finite; drop zero-mass ones so they cannot widen + # deltas are finite. Drop zero-mass ones so they cannot widen # the reported support. finite_positions, _ = self._positive_mass_support() if finite_positions.size == 0: @@ -336,14 +336,14 @@ def cdf(self, x, *, treat_as_samples: bool = False): """Empirical right-continuous step-function CDF at ``x``. Args: - x: Evaluation point(s); scalar or 1-D array. + x: Evaluation point(s). Scalar or 1-D array. treat_as_samples: When True, ignore masses and treat the positions as equally-weighted samples, so the CDF is - ``(count of positions <= x) / N``; when False (default), - use the mass-weighted step CDF. + ``(count of positions <= x) / N``. When False (default), + uses the mass-weighted step CDF. Returns: - CDF value(s) in [0, 1]; ``float`` for scalar input, + CDF value(s) in [0, 1]. ``float`` for scalar input, ``np.ndarray`` for array input. NaN for NaN inputs and for empty / all-zero-mass distributions. """ @@ -399,14 +399,14 @@ def inverse_cdf(self, p, *, treat_as_samples: bool = False): support range ``[min, max]``). It also accepts array ``p``. Args: - p: Probability level(s) in [0, 1]; scalar or 1-D array. + p: Probability level(s) in [0, 1]. Scalar or 1-D array. treat_as_samples: When True, ignore masses and treat the - positions as equally-weighted samples (``np.quantile``); - when False (default), interpolate against the + positions as equally-weighted samples (``np.quantile``). + When False (default), interpolate against the mass-weighted cumulative distribution. Returns: - Interpolated position(s); ``float`` for scalar ``p``, + Interpolated position(s). ``float`` for scalar ``p``, ``np.ndarray`` for array ``p``. NaN when the distribution has no positions (samples) or no positive mass (weighted). """ diff --git a/src/signaloid/distributional/distributional_test.py b/src/signaloid/distributional/distributional_test.py index d20b740..cdca5ae 100644 --- a/src/signaloid/distributional/distributional_test.py +++ b/src/signaloid/distributional/distributional_test.py @@ -465,7 +465,7 @@ class TestPositiveMassSupport(unittest.TestCase): strictly-positive-mass Dirac deltas.""" def test_drops_zero_mass_keeps_positive(self) -> None: - """Zero-mass deltas are dropped; positive-mass ones are kept.""" + """Zero-mass deltas are dropped. Positive-mass ones are kept.""" dist = DistributionalValue( dirac_deltas=[ DiracDelta(position=1.0, mass=0.4), @@ -670,7 +670,7 @@ def test_quantile_known_two_point(self) -> None: self.assertAlmostEqual(dist.quantile(0.5), 3.0, places=12) def test_quantile_rejects_out_of_range(self) -> None: - """t must lie in [0, 1]; values outside raise ValueError.""" + """t must lie in [0, 1]. Values outside raise ValueError.""" dist = DistributionalValue(dirac_deltas=[DiracDelta(position=1.0, mass=1.0)]) with self.assertRaises(ValueError): dist.quantile(-0.1) @@ -817,7 +817,7 @@ def test_cdf_array_matches_scalar_elementwise(self) -> None: self.assertAlmostEqual(array_result[i], dist.cdf(float(x)), places=12) def test_cdf_returns_nan_for_nan_input(self) -> None: - """NaN input returns NaN; array NaNs propagate per slot.""" + """NaN input returns NaN. Array NaNs propagate per slot.""" dist = DistributionalValue( dirac_deltas=[ DiracDelta(position=1.0, mass=0.5), @@ -847,7 +847,7 @@ def test_cdf_returns_nan_for_zero_total_mass(self) -> None: def test_cdf_ignores_zero_mass_diracs(self) -> None: """Zero-mass Diracs — including non-finite placeholders — leave the - CDF unchanged; quantile/cdf do not call sort() so such placeholders + CDF unchanged. Quantile/cdf do not call sort() so such placeholders would otherwise reach searchsorted.""" dist = DistributionalValue( dirac_deltas=[ @@ -1147,7 +1147,7 @@ def test_central_moments_returns_nan_for_any_non_finite_mean(self) -> None: class TestFromWeightedSamples(unittest.TestCase): def test_normalises_masses_and_round_trips_positions(self) -> None: - """Masses are normalised to sum to 1; positions round-trip in order.""" + """Masses are normalised to sum to 1. Positions round-trip in order.""" dist = DistributionalValue.from_weighted_samples( [0.0, 1.0, 2.0], [1.0, 2.0, 1.0] ) @@ -1181,7 +1181,7 @@ def test_zero_total_mass_raises(self) -> None: DistributionalValue.from_weighted_samples([0.0, 1.0], [0.0, 0.0]) def test_negative_mass_raises(self) -> None: - # A negative mass would normalise to a negative "probability"; reject it + # A negative mass would normalise to a negative "probability". Reject it # at construction rather than build an invalid distribution. with self.assertRaises(ValueError): DistributionalValue.from_weighted_samples([1.0, 2.0], [1.0, -0.5]) @@ -1192,7 +1192,7 @@ class TestUxBinaryFormatDetection(unittest.TestCase): The Ux Binary Data format inserts a 3-byte marker (a 0xF0 start byte plus two 0x00 padding bytes) between the particle value and the - representation type. `parse` accepts both layouts; `export`/`bytes` + representation type. `parse` accepts both layouts. `export`/`bytes` always emit the correct Ux Binary Data format. """ diff --git a/src/signaloid/distributional_distance/README.md b/src/signaloid/distributional_distance/README.md index 47663e5..e638e27 100644 --- a/src/signaloid/distributional_distance/README.md +++ b/src/signaloid/distributional_distance/README.md @@ -5,7 +5,8 @@ Distance utilities for comparing Signaloid `DistributionalValue` instances. ## Wasserstein-p distance -Compute the Wasserstein-p distance between two `DistributionalValue`s. `p` is an integer ≥ 1; `p=1` and `p=2` (the canonical cases) have ergonomic shortcuts. +Compute the Wasserstein-p distance between two `DistributionalValue`s. `p` is an +integer ≥ 1. `p=1` and `p=2` (the canonical cases) have ergonomic shortcuts. ```python import numpy as np diff --git a/src/signaloid/distributional_distance/_validators.py b/src/signaloid/distributional_distance/_validators.py index 62b192b..05018d2 100644 --- a/src/signaloid/distributional_distance/_validators.py +++ b/src/signaloid/distributional_distance/_validators.py @@ -56,7 +56,7 @@ def _validate_wp_inputs( etc.), so we validate at the algorithmic boundary instead of duplicating the check in every uxhw wrapper. """ - # `bool` is a subclass of `int` in Python; reject it explicitly so + # `bool` is a subclass of `int` in Python. Reject it explicitly so # `p=True` isn't silently treated as `p=1`. if isinstance(p, bool) or not isinstance(p, (int, np.integer)) or p < 1: raise ValueError("p must be an integer greater than or equal to 1.") diff --git a/src/signaloid/distributional_distance/_validators_test.py b/src/signaloid/distributional_distance/_validators_test.py index e953bbe..9fc51e3 100644 --- a/src/signaloid/distributional_distance/_validators_test.py +++ b/src/signaloid/distributional_distance/_validators_test.py @@ -105,7 +105,7 @@ def _arrays(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: def test_returns_totals_when_all_inputs_valid(self) -> None: """Happy path: returns the (u_total, v_total) the caller will - divide by; both equal to the sum of their weights.""" + divide by. Both equal to the sum of their weights.""" u_values, u_weights, v_values, v_weights = self._arrays() u_total, v_total = _validate_wp_inputs( u_values, u_weights, v_values, v_weights, p=1 @@ -129,7 +129,7 @@ def test_rejects_non_integer_p(self) -> None: def test_rejects_non_1d_arrays(self) -> None: """2-D arrays would silently flow into the Wp kernel and produce - a wrong distance; rejected up-front.""" + a wrong distance. Rejected up-front.""" u_values, u_weights, v_values, v_weights = self._arrays() bad_2d = np.array([[1.0, 2.0], [3.0, 4.0]]) for label, args in ( @@ -183,7 +183,7 @@ def test_rejects_negative_weights(self) -> None: self.assertIn("non-negative", str(ctx.exception)) def test_rejects_zero_total_mass(self) -> None: - """All-zero weights mean the CDF can't be normalised; rejected.""" + """All-zero weights mean the CDF can't be normalised. Rejected.""" u_values, _, v_values, v_weights = self._arrays() zero_weights = np.zeros(3) with self.assertRaises(ValueError) as ctx: diff --git a/src/signaloid/distributional_distance/binned_wasserstein.py b/src/signaloid/distributional_distance/binned_wasserstein.py index 3de704d..819a974 100644 --- a/src/signaloid/distributional_distance/binned_wasserstein.py +++ b/src/signaloid/distributional_distance/binned_wasserstein.py @@ -180,10 +180,10 @@ def fill_cdf_values( """Evaluate the UxHw and empirical CDFs at every merged position. Fills `uxhw_cdf_vals` and `sample_cdf_vals` in place. For the UxHw - CDF, each `position[i]` lies within bin segment `uxhw_cdf_idx[i]`; - the value is linearly interpolated using the segment's slope, with + CDF, each `position[i]` lies within bin segment `uxhw_cdf_idx[i]`. + The value is linearly interpolated using the segment's slope, with clamps below the first / above the last boundary. For the empirical - CDF, each `position[i]` lies after step `sample_cdf_idx[i]`; the + CDF, each `position[i]` lies after step `sample_cdf_idx[i]`. The value is the cumulative weight at that step (left as 0.0 when the point precedes the first sample, courtesy of the caller's `zeros` initialisation of `sample_cdf_vals`). @@ -196,7 +196,7 @@ def fill_cdf_values( starting at 0). uxhw_cdf_widths: UxHw bin widths (length N). sample_cdf_vals: Output — empirical CDF values. Caller must - pre-initialise to zeros; entries with `sample_cdf_idx[i] == 0` + pre-initialise to zeros. Entries with `sample_cdf_idx[i] == 0` are left untouched. sample_cdf_idx: Per-position sample step index. sample_cdf_heights: Empirical cumulative CDF heights. @@ -272,7 +272,7 @@ def wasserstein_1_core( ) # `uxhw_cdf_idx[j]` is the linear segment of the UxHw CDF that - # position[j] lives in; `sample_cdf_idx[j]` is the constant segment + # position[j] lives in. `sample_cdf_idx[j]` is the constant segment # of the sample empirical CDF. uxhw_cdf_idx = np.empty(num_points_total, dtype=np.int32) sample_cdf_idx = np.empty(num_points_total, dtype=np.int32) @@ -308,7 +308,7 @@ def wasserstein_1_core( # Compute the trapezoid / triangle / rectangle segment sizes. # delta_left and delta_right are the relative CDF heights at the - # left and right of each trapezoid; left_pos / right_pos are the + # left and right of each trapezoid. left_pos / right_pos are the # corresponding x-positions. We use `sample_cdf_vals[:-1]` for # delta_right because the step-wise CDF is right-continuous — we # compare to the left (previous) CDF height. @@ -396,7 +396,7 @@ def _validate_binned_semantics( # `wasserstein_1_core` assumes bin_boundaries is strictly # increasing and that bin_widths[i] == boundaries[i+1] - boundaries[i]. # Non-monotonic boundaries would produce negative segment lengths - # and silently-wrong distances; width/boundary disagreement breaks + # and silently-wrong distances. width/boundary disagreement breaks # the CDF-merge integration. boundary_gaps = np.diff(bin_boundaries_arr) if np.any(boundary_gaps <= 0.0): @@ -484,7 +484,7 @@ def wasserstein_1_between_distribution_and_samples( # The empirical CDF is cumulative-in-position-order, so positions # (and their matching weights) must be sorted by position before - # cumsum. Callers may pass any ordering; we sort here. + # cumsum. Callers may pass any ordering. Sort here. sample_cdf_positions: np.ndarray = np.array(sample_positions, dtype=np.float64) sample_cdf_weights: np.ndarray = np.array(sample_weights, dtype=np.float64) sample_sort_order: np.ndarray = np.argsort(sample_cdf_positions) @@ -556,7 +556,7 @@ def binned_wasserstein_1_uxhw_wrapper( "Wasserstein-1." ) - # Find the TTR of the created binning; this is always a valid TTR. + # Find the TTR of the created binning. This is always a valid TTR. ttr = PlotData.bin_pdf_to_ttr( boundary_positions, bin_widths, diff --git a/src/signaloid/distributional_distance/binned_wasserstein_test.py b/src/signaloid/distributional_distance/binned_wasserstein_test.py index f533e54..2526512 100644 --- a/src/signaloid/distributional_distance/binned_wasserstein_test.py +++ b/src/signaloid/distributional_distance/binned_wasserstein_test.py @@ -165,7 +165,7 @@ def test_binned_wasserstein_1_uxhw_wrapper(self) -> None: """binned_wasserstein_1_uxhw_wrapper produces a deterministic, small-but-strictly-positive distance between a coarse 4-point symmetric uxhw and a 1000-sample N(0, 1) MC ground truth. The - RNG is seeded; the assertion has a tight window around the + RNG is seeded. The assertion has a tight window around the precomputed result so silent numerical regressions surface.""" rng = np.random.default_rng(seed=20260520) mc_sample = rng.standard_normal(DEFAULT_SAMPLE_SIZE) @@ -180,7 +180,7 @@ def test_binned_wasserstein_1_uxhw_wrapper(self) -> None: binned_dist=uxhw, ground_truth_dist=ground_truth ) - # Precomputed against the seeded RNG above; W1 is a metric so + # Precomputed against the seeded RNG above. W1 is a metric so # strictly > 0 for non-identical distributions. self.assertAlmostEqual(result, 0.18323214623666018, places=12) @@ -369,7 +369,7 @@ def test_translation_invariance(self) -> None: def test_binned_uxhw_wrapper_handles_unsorted_ground_truth(self) -> None: """The wrapper passes ground_truth.positions/masses straight in - without sorting; verify that two equivalent-but-permuted + without sorting. Verify that two equivalent-but-permuted ground truths produce the same distance.""" positions_sorted = [-2.0, -0.5, 0.5, 2.0] masses_sorted = [0.15, 0.35, 0.35, 0.15] @@ -393,11 +393,11 @@ def test_sample_coincident_with_boundary(self) -> None: merge-sort tie-break must put the boundary first so `wasserstein_1_core`'s segment-counter increments before the step-counter. `np.argsort` is not guaranteed stable across - numpy versions; `np.lexsort((types, positions))` pins the order. + numpy versions. `np.lexsort((types, positions))` pins the order. Hand-computed: uxhw is uniform on [0, 2] (heights=0.5, widths=1 - across boundaries [0, 1, 2]); sample is δ at x=1 (the interior - boundary). F_uxhw(x) = x/2 for x ∈ [0, 2]; F_sample is the unit + across boundaries [0, 1, 2]). Sample is δ at x=1 (the interior + boundary). F_uxhw(x) = x/2 for x ∈ [0, 2]. F_sample is the unit step at x=1. W1 = ∫_0^1 (x/2) dx + ∫_1^2 (1 − x/2) dx = 0.25 + 0.25 = 0.5. """ @@ -416,8 +416,8 @@ class TestBinnedWrapperGroundTruthValidation(unittest.TestCase): ground_truth would fail later with AttributeError.""" def test_non_dv_rejected_on_either_side(self) -> None: - """Message-format coverage lives in `_validators_test.py`; - here we only assert the wrapper raises `ValueError` on either + """Message-format coverage lives in `_validators_test.py`. + Here we only assert the wrapper raises `ValueError` on either side, mentioning the expected type.""" good = _dv_from_weighted_samples([-1.0, 0.0, 1.0], [0.2, 0.6, 0.2]) for side, binned, gt in ( @@ -461,7 +461,7 @@ def test_short_circuit_with_special_values_present(self) -> None: """ # Construct: 1 finite Dirac at x=1.0 plus a NaN-position Dirac # carrying non-zero mass. After `sort()` the special-value - # bucket gets populated; `positions` then includes nan/-inf/inf + # bucket gets populated. `positions` then includes nan/-inf/inf # (length 4), so the old check would not short-circuit. uxhw_finite_with_special = DistributionalValue( dirac_deltas=[ @@ -488,7 +488,7 @@ class TestBinnedUxStringWrapper(unittest.TestCase): """Parse paths for `binned_wasserstein_1_ux_string_wrapper`. Math coverage flows transitively through - `binned_wasserstein_1_uxhw_wrapper` (which has its own tests); here + `binned_wasserstein_1_uxhw_wrapper` (which has its own tests). Here we just verify that: - bogus ux strings surface a ValueError rather than crashing later (`DistributionalValue.parse` may either return None or @@ -499,7 +499,7 @@ class TestBinnedUxStringWrapper(unittest.TestCase): # SAMPLE_UX_STRING is a ~32-atom Gaussian-shaped TTR also used by # `uxdata_toolkit_test.py:37` and `sample_generator_test.py:30`. # Inlined rather than imported because test modules aren't part of - # the package API; copying matches the convention already used in + # the package API. Copying matches the convention already used in # those two callsites. SAMPLE_UX_STRING = "-0.000000Ux040000000000000001BCB03C52D58D3CE400000020C0048D2279B8AFF701B1807E6239F600BFFFF1F7A03E82B602A7EB881EDAA6C0BFFAFF0EC92B7D6E0321FCB58BB7F440BFF763B747227C880386DDE1E09EAEC0BFF47A086FFA91B003BA2C11EF08E580BFF1FDCC06ED8E9703F77BE72797F440BFEF88C5501503BA0429DAF9A7420E80BFEB75E0A582D1610454636C25A09D40BFE7B77D572D585D0456D709533085C0BFE43A6FD58615450472E978D476CD40BFE0E4BE4A8177310489FC4176BD8E80BFDB5AB694DC2F6D049CBBFB39689F80BFD51A3EFE52F0A004AAD48A9FB27AC0BFCDF7B07C22983104B59D8153294540BFC1E9102D4ACC1304BCB0EDEE5C1900BFA7D59C0E0AD59404C0374A760BE0C03FA7D59C0E0AD5A204C0374A760BE0C03FC1E9102D4ACC0504BCB0EDEE5C1C803FCDF7B07C22983104B59D81532945403FD51A3EFE52F0A904AAD48A9FB277003FDB5AB694DC2F6D049CBBFB39689F803FE0E4BE4A8177310489FC4176BD8E803FE43A6FD586153C0472E978D476D0C03FE7B77D572D58680456D709533082403FEB75E0A582D1560454636C25A0A1003FEF88C5501503D10429DAF9A7420AC03FF1FDCC06ED8EA203F77BE72797F7E03FF47A086FFA91AE03BA2C11EF08DE603FF763B747227C810386DDE1E09EAB203FFAFF0EC92B7D710321FCB58BB7F4403FFFF1F7A03E82AE02A7EB881EDAA32040048D2279B8AFD801B1807E6239FD40" # noqa: E501 diff --git a/src/signaloid/distributional_distance/ks_distance.py b/src/signaloid/distributional_distance/ks_distance.py index 7dcf86e..da71243 100644 --- a/src/signaloid/distributional_distance/ks_distance.py +++ b/src/signaloid/distributional_distance/ks_distance.py @@ -44,9 +44,9 @@ def _ks_distance( Args: u_positions: Sample positions for distribution u (any order). - u_masses: Non-negative masses for u; unnormalised/raw values allowed. + u_masses: Non-negative masses for u (unnormalised/raw values allowed). v_positions: Sample positions for distribution v (any order). - v_masses: Non-negative masses for v; unnormalised/raw values allowed. + v_masses: Non-negative masses for v (unnormalised/raw values allowed). Returns: KS distance in [0, 1]. @@ -59,7 +59,7 @@ def _ks_distance( u_weights = np.asarray(u_masses, dtype=np.float64) v_weights = np.asarray(v_masses, dtype=np.float64) - # KS has no `p` parameter; pass p=1 purely to satisfy the shared + # KS has no `p` parameter. Pass p=1 purely to satisfy the shared # validator (which guards against bool/<1). The validator's checks # on shapes, finiteness, non-negative masses, and positive totals # are independent of `p`. diff --git a/src/signaloid/distributional_distance/ks_distance_test.py b/src/signaloid/distributional_distance/ks_distance_test.py index 78d8111..fa34da6 100644 --- a/src/signaloid/distributional_distance/ks_distance_test.py +++ b/src/signaloid/distributional_distance/ks_distance_test.py @@ -212,8 +212,8 @@ def test_unnormalised_masses_normalise_internally(self) -> None: def test_non_dv_rejected_on_either_side(self) -> None: """Wrapper funnels through the shared `_require_distributional_pair` - check; both arguments are validated. Message-format coverage - lives in `_validators_test.py`; here we only assert the wrapper + check. Both arguments are validated. Message-format coverage + lives in `_validators_test.py`. Here we only assert the wrapper raises `ValueError` mentioning the expected type.""" good = _dv_from_weighted_samples([0.0, 1.0], [0.5, 0.5]) for side, dist_u, dist_v in ( diff --git a/src/signaloid/distributional_distance/scalar.py b/src/signaloid/distributional_distance/scalar.py index 7a45033..f42e4db 100644 --- a/src/signaloid/distributional_distance/scalar.py +++ b/src/signaloid/distributional_distance/scalar.py @@ -79,7 +79,7 @@ def relative_error_uxhw_wrapper( Returns: The relative error. When ``ground_truth_dist.positions[0] == 0`` and - ``test_dist.positions[0] != 0`` the result is ``+inf``; when + ``test_dist.positions[0] != 0`` the result is ``+inf``. When both are zero the result is ``NaN`` (the 0/0 indeterminate form). The numpy RuntimeWarning for both cases is suppressed. @@ -106,8 +106,8 @@ def signed_error_uxhw_wrapper( ) -> float: """Signed error between two scalar (single-Dirac) distributions. - Computes ``test_dist[0] - ground_truth_dist[0]``. Preserves sign; - same units as the inputs. The function is intended for scalar + Computes ``test_dist[0] - ground_truth_dist[0]``. Preserves sign. + Same units as the inputs. The function is intended for scalar comparisons. Each input must hold exactly one finite Dirac delta. Non-scalar inputs are rejected to avoid silently using only ``positions[0]``. @@ -119,7 +119,7 @@ def signed_error_uxhw_wrapper( compare against. Returns: - The signed error. Finite for any finite inputs; may be + The signed error. Finite for any finite inputs. May be positive, negative, or zero. Raises: @@ -139,8 +139,8 @@ def absolute_error_uxhw_wrapper( ) -> float: """Absolute error between two scalar (single-Dirac) distributions. - Computes ``|test_dist[0] - ground_truth_dist[0]|``. Magnitude only; - same units as the inputs. The function is intended for scalar + Computes ``|test_dist[0] - ground_truth_dist[0]|``. Magnitude only. + Same units as the inputs. The function is intended for scalar comparisons. Each input must hold exactly one finite Dirac delta. Non-scalar inputs are rejected to avoid silently using only ``positions[0]``. @@ -201,7 +201,7 @@ def absolute_error_uxhw_wrapper( ) distance: float = METRICS[args.metric](test_dist, ground_truth_dist) - # signed_error can be negative; compare |distance| to tolerance so the + # signed_error can be negative. Compare |distance| to tolerance so the # SUCCESS / FAILURE semantics are the same magnitude check across all # three metrics. abs() is a no-op for the non-negative ones. within = abs(distance) <= args.tolerance diff --git a/src/signaloid/distributional_distance/scalar_test.py b/src/signaloid/distributional_distance/scalar_test.py index 42952ea..374f1be 100644 --- a/src/signaloid/distributional_distance/scalar_test.py +++ b/src/signaloid/distributional_distance/scalar_test.py @@ -75,13 +75,13 @@ def test_negative_ground_truth_dist_uses_abs(self) -> None: self.assertAlmostEqual(distance, 0.01, places=12) def test_zero_ground_truth_dist_returns_positive_infinity(self) -> None: - """Division by zero is intentional — verify +inf specifically, + """Division by zero is intentional. Verify +inf specifically, not just any inf (`-inf` would also satisfy math.isinf).""" distance = relative_error_uxhw_wrapper(_single_dirac(1.0), _single_dirac(0.0)) self.assertEqual(distance, math.inf) def test_very_large_numbers(self) -> None: - """Relative error is scale-invariant; verify no overflow at 1e300.""" + """Relative error is scale-invariant. Verify no overflow at 1e300.""" distance = relative_error_uxhw_wrapper( _single_dirac(1.01e300), _single_dirac(1.0e300) ) @@ -194,8 +194,8 @@ def test_invalid_mass_rejected(self) -> None: class TestSignedError(unittest.TestCase): """Tests for signed_error_uxhw_wrapper — - test_dist[0] - ground_truth_dist[0]. Preserves sign; same units as - inputs; well-defined for any finite inputs (no zero-division + test_dist[0] - ground_truth_dist[0]. Preserves sign. Same units as + inputs and well-defined for any finite inputs (no zero-division edge case).""" def test_zero_when_identical(self) -> None: @@ -235,8 +235,8 @@ def test_both_negative_inputs(self) -> None: class TestAbsoluteError(unittest.TestCase): """Tests for absolute_error_uxhw_wrapper — - |test_dist[0] - ground_truth_dist[0]|. Magnitude only; same units - as inputs; well-defined for any finite inputs.""" + |test_dist[0] - ground_truth_dist[0]|. Magnitude only. Same units + as inputs and well-defined for any finite inputs.""" def test_zero_when_identical(self) -> None: """absolute_error(x, x) == 0 for any x.""" diff --git a/src/signaloid/distributional_distance/wasserstein.py b/src/signaloid/distributional_distance/wasserstein.py index 4995389..c4a4323 100644 --- a/src/signaloid/distributional_distance/wasserstein.py +++ b/src/signaloid/distributional_distance/wasserstein.py @@ -48,17 +48,17 @@ def _wp_1d_weighted_pair( ``W_p(u, v)^p = ∫_0^1 |F_u^{-1}(t) - F_v^{-1}(t)|^p dt``, which is the canonical 1D Wasserstein-p. Note: integrating ``∫|F_u(x) - F_v(x)|^p dx`` along x (the CDF formula) only equals - W_p^p when p = 1; for p ≥ 2 that integral is the energy distance, + W_p^p when p = 1. For p ≥ 2 that integral is the energy distance, not W_p. Args: u_positions: Sample positions for distribution u (any order). - u_masses: Non-negative masses for each u position; may be + u_masses: Non-negative masses for each u position. May be unnormalised — internally divided by their sum. v_positions: Sample positions for distribution v (any order). - v_masses: Non-negative masses for each v position; may be + v_masses: Non-negative masses for each v position. May be unnormalised — internally divided by their sum. - p: Wasserstein order (1 or 2 are exercised; any positive + p: Wasserstein order (1 or 2 are exercised. Any positive integer works). Returns: @@ -87,11 +87,11 @@ def _wp_1d_weighted_pair( u_cum[-1] = 1.0 v_cum[-1] = 1.0 - # Merged quantile breakpoints in [0, 1]; 0 prepended so the first + # Merged quantile breakpoints in [0, 1]. 0 prepended so the first # interval is covered. qs = np.unique(np.concatenate(([0.0], u_cum, v_cum))) - # F^{-1}(t) is constant on each interval (qs[i], qs[i+1]]; evaluate + # F^{-1}(t) is constant on each interval (qs[i], qs[i+1]]. Evaluate # at the midpoint and pick the smallest index k with cum[k] ≥ mid. mid_t = 0.5 * (qs[:-1] + qs[1:]) u_idx = np.clip(np.searchsorted(u_cum, mid_t, side="left"), 0, len(u_sorted) - 1) @@ -123,7 +123,7 @@ def wasserstein_p_distance( internally, so callers do not apply their own ``np.sqrt``. Masses may be `None` to request uniform weighting (matching the - `None`-means-uniform semantics of scipy / POT); otherwise they are + `None`-means-uniform semantics of scipy / POT). Otherwise they are treated as non-negative, possibly unnormalised masses. Args: @@ -215,8 +215,8 @@ def wasserstein_1_distance_with_weights( u_values: Sorted unweighted samples from distribution A. v_values: Sorted positions of weighted samples from distribution B. v_cum_weights: Cumulative sum of the sorted weights of - distribution B (the natural ``np.cumsum(masses)``; no - leading zero is required). + distribution B (the natural ``np.cumsum(masses)``). No + leading zero is required. all_values: Sorted concatenation of u_values and v_values. Returns: diff --git a/src/signaloid/distributional_distance/wasserstein_test.py b/src/signaloid/distributional_distance/wasserstein_test.py index dced28c..aa1fd7d 100644 --- a/src/signaloid/distributional_distance/wasserstein_test.py +++ b/src/signaloid/distributional_distance/wasserstein_test.py @@ -318,7 +318,7 @@ def test_w2_geq_w1(self) -> None: class TestGenericWassersteinP(unittest.TestCase): """`wasserstein_p_uxhw_wrapper` and `normalized_wasserstein_p_uxhw_wrapper` - are the generic surfaces; the W1 / W2 (+ normalised) wrappers are + are the generic surfaces. The W1 / W2 (+ normalised) wrappers are thin delegates. Verify delegation parity for p in {1, 2} and that the generic accepts non-canonical p without crashing.""" @@ -547,7 +547,7 @@ def test_all_wrappers_reject_non_dv(self) -> None: bypassed the helper in any single wrapper would be caught. Message-format coverage (which argument name appears, which type - name, etc.) lives in `_validators_test.py`; here we just verify + name, etc.) lives in `_validators_test.py`. Here we just verify the wrapper raises `ValueError` mentioning the expected type.""" good = self._good_dv() wrappers = ( diff --git a/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas_test.py b/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas_test.py index f8a9b67..47164f2 100644 --- a/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas_test.py +++ b/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas_test.py @@ -295,7 +295,7 @@ def _fixture_path(name: str) -> str: return os.path.join(here, name) def test_invalid_ttr_ux_string_falls_back_to_uniform_histogram(self) -> None: - """The fixture is a Ux string that is not a valid TTR; the fallback + """The fixture is a Ux string that is not a valid TTR. The fallback should produce a uniform-width histogram with shape matching plotting_resolution.""" with open(self._fixture_path("invalid_ttr_ux_string.dat")) as f: @@ -320,8 +320,8 @@ def test_invalid_ttr_ux_string_falls_back_to_uniform_histogram(self) -> None: self.assertAlmostEqual(total_area, 1.0, places=6) def test_clustered_repeated_positions_fallback_succeeds(self) -> None: - """Heavy duplication / tight clusters must not crash the fallback; - shape and density invariants still hold.""" + """Heavy duplication / tight clusters must not crash the fallback. + Shape and density invariants still hold.""" samples = np.concatenate( [ np.full(200, 1.0), diff --git a/src/signaloid/distributional_information_plotting/plot_wrapper.py b/src/signaloid/distributional_information_plotting/plot_wrapper.py index 0df951a..fef6f93 100644 --- a/src/signaloid/distributional_information_plotting/plot_wrapper.py +++ b/src/signaloid/distributional_information_plotting/plot_wrapper.py @@ -51,8 +51,8 @@ def plot( no_special_y: bool = False, save: bool = False, verbose: bool = False, - x_lim: tuple[float, float] | None = None, - y_lim: tuple[float, float] | None = None, + xlim: tuple[float, float] | None = None, + ylim: tuple[float, float] | None = None, x_label: str | None = None, x_tick_label_rotation: float | None = None, font_size: int = 20, @@ -67,8 +67,8 @@ def plot( no_special_y: Flag toggling the plotting of special values, e.g., `NaN`, `INF`, and `-INF`. save: Flag toggling if the plot should be saved to a file or just shown. verbose: Flag controlling printing verbosity. - x_lim: Input x-axis limits for the plot. - y_lim: Input y-axis limits for the plot. + xlim: Input x-axis limits for the plot. + ylim: Input y-axis limits for the plot. x_label: x-axis label. x_tick_label_rotation: Rotation of x-axis tick labels. font_size: Font size to use for the plot labels. @@ -198,7 +198,7 @@ def plot( for i, ax in enumerate(axes): if i == 0: if len(plot_data.positions): - if x_lim is None: + if xlim is None: # This prevents the plots failing if mean value is # incorrect and way off the range. if ( @@ -212,12 +212,12 @@ def plot( min_x = min(plot_data.dist.mean, plot_data.min_range) max_x = max(plot_data.dist.mean, plot_data.max_range) range_spacing = 0.05 * (max_x - min_x) - x_lim = (min_x - range_spacing, max_x + range_spacing) - ax.set_xlim(*x_lim) + xlim = (min_x - range_spacing, max_x + range_spacing) + ax.set_xlim(*xlim) - if y_lim is None: - y_lim = (0, 1.1 * plot_data.max_value) - ax.set_ylim(*y_lim) + if ylim is None: + ylim = (0, 1.1 * plot_data.max_value) + ax.set_ylim(*ylim) ax.set_xlabel(x_label if x_label else "Distribution Support") ax.set_ylabel("Probability Density") diff --git a/src/signaloid/distributional_information_plotting/sample_generator.py b/src/signaloid/distributional_information_plotting/sample_generator.py index 019297b..895f372 100644 --- a/src/signaloid/distributional_information_plotting/sample_generator.py +++ b/src/signaloid/distributional_information_plotting/sample_generator.py @@ -67,8 +67,8 @@ def sample_from_distributional_value( For particle distributions (single Dirac delta), returns identical copies of the particle position. For distributions with non-finite Dirac deltas (NaN, -Inf, +Inf), samples are drawn from a mixture: with probability - equal to the total finite mass, a finite sample is drawn via inverse CDF; - otherwise a non-finite value is chosen based on its relative mass. + equal to the total finite mass, a finite sample is drawn via inverse CDF. + Otherwise a non-finite value is chosen based on its relative mass. Args: distributional_value: The parsed distributional value to sample from. diff --git a/src/signaloid/out.dat b/src/signaloid/out.dat new file mode 100644 index 0000000..b691540 --- /dev/null +++ b/src/signaloid/out.dat @@ -0,0 +1,100 @@ +4.850327178256621963e+00 +6.848267583883947296e+00 +5.210944579836551682e+00 +3.233215222075699113e+00 +5.897096941853664731e+00 +7.629650956655302352e+00 +-4.953898141910515474e-01 +8.159821201587657669e+00 +6.006895592063975720e+00 +6.005740168534201118e+00 +9.237675468825123914e+00 +-3.927877756397357700e-03 +9.041611702607381673e+00 +1.387341217391892645e+00 +8.136739212764434459e-01 +4.172044836708691307e+00 +1.165638609758492095e+00 +3.582125401037927759e+00 +4.726803020674331002e+00 +4.089668950066265851e-01 +4.290889137496524341e+00 +4.315675385108691309e+00 +6.084072557755018984e+00 +1.501367064049112576e+00 +7.887318597398504938e-01 +-4.714694690751508599e-01 +1.774107057684483735e+00 +4.412607631170569533e+00 +5.068356325948126795e+00 +6.810884052655161724e-01 +5.486939427818301240e+00 +7.690203179160623570e+00 +9.125742454449209617e+00 +7.500691358417268972e+00 +9.449057662002364744e-01 +2.320352556034750879e+00 +6.558301471772987057e+00 +3.262308075165714083e+00 +-3.355407279487510053e-01 +5.479223783132843861e-01 +7.618472303592408679e+00 +3.240814646902165475e+00 +-3.986416148417910588e-01 +8.608757383107379368e+00 +8.726771071390819756e+00 +-2.680250878353067634e-01 +5.275396449850120462e+00 +5.563161463599430867e+00 +3.305879248055860753e+00 +6.434193623505916726e+00 +6.373809659036711928e+00 +7.257519079252347183e+00 +6.451236539484616062e-01 +9.470571935915295114e+00 +9.853678882348983592e+00 +3.502606197027343438e+00 +2.467936970214724024e+00 +1.817538028561818397e-01 +-1.205341136862663198e-01 +6.737425662049654207e-01 +-3.717207522914540707e-01 +9.319199936957627273e+00 +6.919068005103573560e-01 +4.554752531622908940e+00 +1.090777667429968067e+00 +1.298065098841230391e-01 +-2.110047653085689312e-02 +-7.104863659926397013e-02 +6.671657281880726487e+00 +-2.795368917751783755e-04 +1.060418688491762129e+00 +8.014305724492746252e-01 +5.700968011507696609e+00 +3.773652623527242067e+00 +8.151733545793096170e-01 +6.855193590365169509e-01 +9.307565985993798918e+00 +6.163988210345446639e+00 +9.841344749106384349e-01 +8.033904166517496392e+00 +1.388577360696183982e+00 +-1.818559471708045550e-01 +-4.765806299761057296e-01 +4.523139358203554505e-01 +5.541947359624813663e-01 +6.257373189328736984e+00 +8.050802390235787698e+00 +4.296624310624639898e-01 +8.656915363669950292e-01 +3.659973670032748316e+00 +2.716808935991826157e+00 +7.468186708887329495e+00 +9.546506192431326809e+00 +9.976619302398788136e+00 +6.751524385360343494e+00 +5.080552971376557814e+00 +7.552074895585523251e-01 +6.669656002944876150e+00 +1.820740770428783684e+00 +6.798112745408566582e-01 diff --git a/src/signaloid/plot.png b/src/signaloid/plot.png new file mode 100644 index 0000000000000000000000000000000000000000..6405afd4863e2733d361f5f9f30aca82facda008 GIT binary patch literal 77908 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 0.0 @@ -234,7 +234,7 @@ def kolmogorov_smirnov_wrapper( ) detail = f"Combined p-value: {p_value}" else: - # The one-sample test takes a raw sample; use the support positions + # The one-sample test takes a raw sample. Use the support positions # of the first distribution as the observed sample. accept = kolmogorov_smirnov_wrapper( sample=dist.positions, From 5dcc9b2bd45554e655c2d132d62f2ac84fddfed4 Mon Sep 17 00:00:00 2001 From: Michael Selby <149940738+michaelselby-signaloid@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:19:49 +0000 Subject: [PATCH 2/2] Add issue template --- .../00-public-bug-issue-template-v1.yml | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml diff --git a/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml b/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml new file mode 100644 index 0000000..b9138e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml @@ -0,0 +1,81 @@ +name: "Bug Report" +description: "File a structured report to help us reproduce and fix an issue." +title: "[Bug]: " +type: "bug" +body: + - type: markdown + attributes: + value: | + ### Thank you for reporting a bug! + To help us resolve this as quickly as possible, please provide as much detail as you can. + Before submitting, please ensure you are using the latest version of our tools. + + - type: textarea + id: what-happened + attributes: + label: "What happened?" + description: "A clear and concise description of the bug." + placeholder: "e.g., I expected the Signaloid Cloud Developer Platform (SCDP) to return a specific distribution, but instead it..." + validations: + required: true + + - type: textarea + id: reproduction-steps + attributes: + label: "Steps to Reproduce" + description: "How can we make this happen again? You can provide a list of steps, specific inputs, or a relevant code snippet." + placeholder: | + 1. Run 'signaloid-cli load...' + 2. Set parameters to... + 3. See error... + validations: + required: true + + - type: dropdown + id: environment + attributes: + label: "Environment" + description: "Where did you encounter the issue?" + options: + - Signaloid Cloud Developer Platform (Browser) + - Signaloid CLI / Local Execution + - Signaloid Compute Modules (e.g, C0-microSD) + - Documentation / Website + - Other + validations: + required: true + + - type: input + id: version + attributes: + label: "Version / Commit Hash" + description: "Which version of the Signaloid toolchain or API are you using?" + placeholder: "e.g., v2.1.0 or commit a1b2c3d" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: "Operating System" + options: + - Linux + - macOS + - Windows + - Other (Cloud Platform) + + - type: textarea + id: logs + attributes: + label: "Relevant log output or Trace" + description: "Please paste any compiler errors, CLI output, or console logs here." + render: shell + placeholder: "Paste logs here..." + + - type: textarea + id: visual-evidence + attributes: + label: "Screenshots or Diagrams" + description: "Drag and drop images here." + validations: + required: false

    vlsSyY{Dax@w&2JZV0jj+d>IBAW#Td({uN6MFn- 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 z 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 I6NI1t1GC5^IsgCw literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index c3fb216..3d81f49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,12 @@ packages = [ { include = "signaloid", from = "src" } ] +# The build-template assets are a submodule, so they must be listed explicitly +# to ship in the built distributions. Requires the submodule to be checked out +# at build time. +include = [ + { path = "src/signaloid/benchmarking/assets/**/*", format = ["sdist", "wheel"] }, +] [tool.poetry-dynamic-versioning] enable = true @@ -46,19 +52,41 @@ matplotlib = [ { version = ">=3.10.0", python = ">=3.10,<3.14" }, { version = ">=3.10.5", python = ">=3.10,<3.15" }, ] -pillow = ">=12.2.0" +pillow = ">=12.3.0" pyyaml = ">=6.0.0" scipy = [ { version = ">=1.13.0,<1.16.0", python = ">=3.10,<3.11" }, { version = ">=1.14.0", python = ">=3.11" }, ] +# Benchmarking sub-package application dependencies. These ship as plain +# (non-optional) dependencies so the benchmarking timing harness works on a +# straight install. Only the Google-Sheets reporting backend below remains an +# optional extra (see [tool.poetry.extras]). +pandas = "^2.0" +tabulate = "^0.9.0" +tqdm = "^4.66.2" +POT = ">=0.9.3" +gspread = { version = "^6.2.1", optional = true } +google-api-python-client = { version = "^2.171.0", optional = true } +oauth2client = { version = "^4.1.3", optional = true } +# These packages are transitive dependencies (coming via the Google-Sheets +# backend above). Here we pin them to their minimum secure versions. +# They are installed with the ``sheets`` extra. +pyasn1 = { version = ">=0.6.4", optional = true } +# rsa >=4.3 caps Python at <4, so the floor is scoped to python <4. For +# python >=4 it falls back to rsa's older unbounded release +# transitively, matching the existing resolution split. +rsa = { version = ">=4.7", optional = true, python = "<4" } +cryptography = { version = ">=50.0.0", optional = true } +httplib2 = { version = ">=0.32.0", optional = true } [tool.poetry.extras] -sheets = ["gspread", "google-api-python-client", "oauth2client"] +sheets = ["gspread", "google-api-python-client", "oauth2client", "pyasn1", "rsa", "cryptography", "httplib2"] [tool.poetry.scripts] signaloid-uxdata-toolkit = "signaloid.uxdata_toolkit:main" +signaloid-benchmarking = "signaloid.benchmarking.automation.benchmark_application:main" [tool.mypy] warn_unused_configs = true diff --git a/src/signaloid/benchmarking/__init__.py b/src/signaloid/benchmarking/__init__.py new file mode 100644 index 0000000..986ff09 --- /dev/null +++ b/src/signaloid/benchmarking/__init__.py @@ -0,0 +1,59 @@ +# Copyright (c) 2026, Signaloid. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +"""Benchmarking tooling for distributional workloads. + +Provides the timing harness and the equivalent-Monte-Carlo analysis that compares +Signaloid (UxHw) computations against Monte-Carlo references. The application-level +dependencies (``pandas``, ``tabulate``, ``tqdm``, ``POT``) install with the package. +The optional Google Sheets reporting backend is the ``sheets`` extra (install +``signaloid[sheets]`` to enable it). + +The public API is the ``signaloid-benchmarking`` console script plus the symbols +re-exported here (the equivalent-Monte-Carlo analysis is also available as a library +API). Modules under ``automation`` are internal orchestration and not part of +the API. +""" + +from signaloid.benchmarking.config import ( + Correlations, + DistanceMetrics, + ReportingMethods, + RepresentationTypes, + VariableTypes, +) +from signaloid.benchmarking.equivalent_mc import ( + LoadDataComputeEquivalentMCArgs, + anderson_darling_test, + load_data_and_compute_equivalent_mc, +) +from signaloid.benchmarking.types import BenchmarkingVariable + +__all__ = [ + "load_data_and_compute_equivalent_mc", + "LoadDataComputeEquivalentMCArgs", + "BenchmarkingVariable", + "DistanceMetrics", + "RepresentationTypes", + "Correlations", + "ReportingMethods", + "VariableTypes", + "anderson_darling_test", +] diff --git a/src/signaloid/benchmarking/assets b/src/signaloid/benchmarking/assets new file mode 160000 index 0000000..e59a566 --- /dev/null +++ b/src/signaloid/benchmarking/assets @@ -0,0 +1 @@ +Subproject commit e59a5667bc6e58e17f28458302eadeacc373b919 diff --git a/src/signaloid/benchmarking/automation/README.md b/src/signaloid/benchmarking/automation/README.md new file mode 100644 index 0000000..1aef61e --- /dev/null +++ b/src/signaloid/benchmarking/automation/README.md @@ -0,0 +1,556 @@ +# Benchmarking Automation + +The signaloid.benchmarking.automation package enables the benchmarking +applications running with Signaloid UxHw technology for computing with +distributions, against Monte Carlo baselines. The tool compiles the subject +application from source code for native Monte Carlo execution, generates the +ground truth result dataset and databases used for the comparison to the Monte +Carlo baselines. It runs the UxHw variant of the application, computes +equivalent Monte Carlo (EMCC) metrics, and collects timing data. The tool writes +all results to CSV data files, Markdown reports, and (optionally) uploads to a +Google Sheet. + +## Requirements and dependencies + +The tool requires the following system dependencies, Python dependencies, and +third-party dependencies. + +### System dependencies +- gcc or g++ +- GNU Scientific Library (on Ubuntu: libgsl-dev). +- Python (3.10+; see root-level pyproject.toml) +- GNU Make (make) +- bash +- lscpu +- hyperfine + +C and C++ files are compiled separately and linked with `c++`. Applications that +link against GSL need `-lgsl -lgslcblas -lm`. + +### Python +This tool is part of the `signaloid.benchmarking` package. Create and activate a +virtual environment, then install the package with pip: +``` +python -m venv .venv +source .venv/bin/activate +pip install . +``` +For development, you can install in editable mode with `pip install -e .` +instead. + +The benchmarking pipeline's application dependencies (`pandas`, `tabulate`, +`tqdm`, `POT`) are non-optional. Optional dependencies is group `sheets`, needed +for the Google Sheets upload (see [Google Sheets](#google-sheets-optional)): +install with `pip install ".[sheets]"`. + +Commands in this guide assume you are using the virtual environment, so that the +`signaloid-benchmarking` entry point and `python -m +signaloid.benchmarking.automation` are available. + +### Signaloid UxHw SDK + +The benchmarking tool needs the Signaloid UxHw SDK to compile applications for +UxHw. Set the path to the UxHw SDK via command-line parameter +`--path-to-uxhw-sdk` (for example, `/opt/Signaloid-UxHw-SDK`). + +### Application requirements + +A benchmarkable application is a project which can connect to the [Signaloid +Cloud Developer Platform](https://signaloid.io), plus a small amount of +benchmarking metadata. Minimal layout: + +``` +your-app/ +├── src/ +│ └── main.c # UxHw program: writes each output to an array, selects one via `-S ` +├── signaloid.yaml # REQUIRED: declares the trace + benchmarking variables +└── src/config.mk # OPTIONAL: SOURCES/CFLAGS for the native-MC build +``` + +The target application must: +1. **Be a Git repository** — the current commit hash versions the output files. +2. **Contain a `signaloid.yaml` at its root** — see [Application + Configuration](#application-configuration). This is what makes a repo + benchmarkable. Without it the run aborts at "Loading application info". +3. **Have its source in a `src/` directory.** + +The build file is **optional**. Native compilation (for the native-MC baseline) +is resolved in this order: a `Makefile` at the root with a `local-build` target, +else `src/config.mk` with `SOURCES` (and optional `CFLAGS`). The `Makefile` is +**not** required and a `config.mk` alone is enough (as in the [C project +template](https://github.com/signaloid/Signaloid-Demo-General-C)). If neither is +present (or if `config.mk` defines no `SOURCES`), the native-MC baseline is +skipped: the UxHw benchmark and distance analysis still run but it does not +compute the native-vs-UxHw speedup. + +See the [Signaloid documentation for more details about using GitHub repositories +with UxHw](https://docs.signaloid.io/docs/api/guides/builds/builds-repository/). + + +### Intel PIN + +Intel PIN is necessary for the dynamic instruction count on any timing run. The +tool uses it via command-line argument `--path-to-pin` or environment variable +`PIN_ROOT`. A timing run fails if neither is set. + +The dynamic instruction count (`pinDynInstCount`) is from the `inscount0` tool. + +Basic installation instructions: +1. Download a PIN kit for your platform from Intel's + [Pin binary-instrumentation tool downloads](https://www.intel.com/content/www/us/en/developer/articles/tool/pin-a-binary-instrumentation-tool-downloads.html) + (validated against PIN 4.2). +2. Extract it to a directory `