From 515f0452a4b2effb12b078eb2c3625b481b453ca Mon Sep 17 00:00:00 2001 From: Michael Selby <149940738+michaelselby-signaloid@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:04:36 +0000 Subject: [PATCH 1/2] version update --- .../00-public-bug-issue-template-v1.yml | 81 --- .github/workflows/signaloid-python.yaml | 18 + README.md | 39 +- pyproject.toml | 2 +- src/signaloid/benchmarking/assets | 2 +- .../benchmarking/automation/README.md | 55 +- .../benchmarking/automation/arguments.py | 16 +- .../benchmarking/automation/benchmark.py | 6 +- .../automation/benchmark_application.py | 30 +- .../benchmarking/automation/build.py | 38 +- .../automation/config_layer_test.py | 34 ++ ...y => measure_dynamic_instructions_test.py} | 69 ++- .../automation/measure_process_time.py | 246 +++++++++ .../automation/measure_process_time_test.py | 313 ++++++++++++ .../automation/measurement_loader.py | 19 +- .../automation/measurement_loader_test.py | 117 +++++ .../automation/sheets_preflight_test.py | 153 ++++++ .../benchmark_timing/get-timing-template.sh | 30 +- .../benchmark_timing/get-timings.sh | 197 ++++--- .../get_timing_template_test.py | 128 ++++- .../benchmark_timing/get_timings_test.py | 92 ++++ src/signaloid/benchmarking/config.py | 32 +- src/signaloid/benchmarking/types.py | 10 +- .../distributional/distributional.py | 23 +- .../binned_wasserstein.py | 28 +- .../plot_histogram_dirac_deltas.py | 308 ++++++++++- .../plot_histogram_dirac_deltas_test.py | 483 +++++++++++++++++- .../sample_generator.py | 17 +- .../sample_generator_test.py | 42 ++ src/signaloid/out.dat | 100 ---- src/signaloid/plot.png | Bin 77908 -> 0 bytes 31 files changed, 2294 insertions(+), 434 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/00-public-bug-issue-template-v1.yml rename src/signaloid/benchmarking/automation/{path_to_pin_test.py => measure_dynamic_instructions_test.py} (52%) create mode 100644 src/signaloid/benchmarking/automation/measure_process_time.py create mode 100644 src/signaloid/benchmarking/automation/measure_process_time_test.py create mode 100644 src/signaloid/benchmarking/automation/sheets_preflight_test.py create mode 100644 src/signaloid/benchmarking/benchmark_timing/get_timings_test.py delete mode 100644 src/signaloid/out.dat delete mode 100644 src/signaloid/plot.png 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 61c881a..7a1893a 100644 --- a/.github/workflows/signaloid-python.yaml +++ b/.github/workflows/signaloid-python.yaml @@ -47,6 +47,24 @@ jobs: echo "github.event.release.tag_name:" ${{ github.event.release.tag_name }} echo "contains(github.event.pull_request.labels.*.name, 'ci:upload-binaries'):" ${{ contains(github.event.pull_request.labels.*.name, 'ci:upload-binaries') }} + - name: Check submodules are up to date + # Independent of the matrix, so run it once. The fetch below reuses the + # credentials actions/checkout persisted into each submodule, so this + # works whether the assets remote is private or public. + if: ${{ matrix.args.host == 'ubuntu-22.04' && matrix.python-version == '3.10' }} + run: | + git submodule foreach --quiet --recursive ' + branch=$(git config -f "$toplevel/.gitmodules" "submodule.$name.branch" || echo HEAD) + git fetch --quiet origin "$branch" + pinned=$(git rev-parse HEAD) + latest=$(git rev-parse FETCH_HEAD) + if [ "$pinned" != "$latest" ]; then + echo "::error::Submodule $sm_path is behind origin/$branch. Pinned $pinned, latest $latest. Update it with: git submodule update --remote $sm_path && git add $sm_path" + exit 1 + fi + echo "Submodule $sm_path is up to date with origin/$branch ($pinned)" + ' + - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/README.md b/README.md index 4b1f629..716fc89 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ benchmarks for UxHw Core microarchitectures Athens and Jupiter for precisions 8, 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 \ @@ -53,7 +52,10 @@ python -m signaloid.benchmarking.automation \ ``` 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 +UxHw. The Intel Pin tool is optional and off by default. Export `PIN_ROOT` and +pass `--measure-dynamic-instructions` to also measure the dynamic instruction +count. Without that flag the run never uses Pin, even when `PIN_ROOT` is set, +and reports the count as missing. Arguments `-u/--representation-types`, `-s/--representation-sizes`, `-c/--uncertainty-correlation_types`, `-r/--reporting-methods` can also be supplied using a YAML file with `--config `. @@ -81,11 +83,13 @@ dist_value = DistributionalValue.parse(ux_binary_buffer) ### Create Distribution Plots Create plots to visualize distributional information by using the [`plot` function](./src/signaloid/distributional_information_plotting/plot_wrapper.py) -with a `DistributionalValue` object containing Ux Data. The `plot` function is a -wrapper function for the `PlotHistogramDiracDeltas` class for plotting a -distributional value as a histogram with variable bin widths. +with a `PlotData` object built from a `DistributionalValue` containing Ux Data. +The `plot` function is a wrapper function for the `PlotHistogramDiracDeltas` +class for plotting a distributional value as a histogram with variable bin +widths. ```python +from signaloid.distributional_information_plotting.plot_histogram_dirac_deltas import PlotData from signaloid.distributional_information_plotting.plot_wrapper import plot # Intermediate code which writes to ux_string @@ -93,5 +97,28 @@ from signaloid.distributional_information_plotting.plot_wrapper import plot # Create distributional value object from Ux String dist_value = DistributionalValue.parse(ux_string) -plot(dist_value) +plot(PlotData(dist_value)) ``` + +For plotting from raw samples, saving to a file, and the other `plot` options, +see the package [README.md](src/signaloid/distributional_information_plotting/README.md). + +### Sample from Ux Data +Draw random samples from a distributional value with the +[`sample_generator` function](./src/signaloid/distributional_information_plotting/sample_generator.py). +Samples of the finite part of the distribution are drawn by inverse transform +sampling of the binned distribution. Distributions that also carry non-finite +mass (`NaN`, `-Inf`, `+Inf`) are sampled as a mixture, with each sample drawn +from the finite or the non-finite part in proportion to their masses. + +```python +from signaloid.distributional_information_plotting.sample_generator import sample_generator + +# Intermediate code which writes to ux_string +# ... + +samples = sample_generator(ux_string, n_samples=1000) +``` + +To sample from a `DistributionalValue` that is already parsed, use +`sample_from_distributional_value` from the same module. diff --git a/pyproject.toml b/pyproject.toml index 3d81f49..42e76b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ requires = ["poetry-core>=1.9.1", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" [tool.poetry.dependencies] -python = ">=3.10" +python = ">=3.10,<4" numpy = [ { version = ">=2.0.0", python = ">=3.10,<3.13" }, { version = ">=2.1.0", python = ">=3.10,<3.14" }, diff --git a/src/signaloid/benchmarking/assets b/src/signaloid/benchmarking/assets index e59a566..15baa38 160000 --- a/src/signaloid/benchmarking/assets +++ b/src/signaloid/benchmarking/assets @@ -1 +1 @@ -Subproject commit e59a5667bc6e58e17f28458302eadeacc373b919 +Subproject commit 15baa381fbe25bc33c50fa5ee2701f9e0648efb4 diff --git a/src/signaloid/benchmarking/automation/README.md b/src/signaloid/benchmarking/automation/README.md index 1aef61e..6c81649 100644 --- a/src/signaloid/benchmarking/automation/README.md +++ b/src/signaloid/benchmarking/automation/README.md @@ -18,11 +18,10 @@ third-party dependencies. ### System dependencies - gcc or g++ - GNU Scientific Library (on Ubuntu: libgsl-dev). -- Python (3.10+; see root-level pyproject.toml) +- 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`. @@ -34,6 +33,8 @@ virtual environment, then install the package with pip: python -m venv .venv source .venv/bin/activate pip install . +# Or, to also get the optional Google Sheets upload: +pip install ".[sheets]" ``` For development, you can install in editable mode with `pip install -e .` instead. @@ -87,11 +88,21 @@ See the [Signaloid documentation for more details about using GitHub repositorie with UxHw](https://docs.signaloid.io/docs/api/guides/builds/builds-repository/). -### Intel PIN +### Intel PIN (Optional) -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. +Intel PIN adds a dynamic instruction count to a timing run. It is optional and +off by default. The tool turns it on only when you pass +`--measure-dynamic-instructions`. The kit location comes from the `PIN_ROOT` +environment variable. + +That flag is the single switch. A run without it never uses PIN, even when +`PIN_ROOT` is set in your shell. So you do not need to unset anything to turn +the measurement off. + +Passing the flag is an explicit opt-in, so an unset `PIN_ROOT`, or a kit that +is missing or not built, is a hard error. Without the flag a timing run still +completes. It logs that it skipped the count and reports `pinDynInstCount` as +missing. The dynamic instruction count (`pinDynInstCount`) is from the `inscount0` tool. @@ -100,8 +111,11 @@ Basic installation instructions: [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 `` 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): +3. Build the `inscount0` counter once. The kit does not ship it prebuilt. + ``` + make -C /source/tools/ManualExamples obj-intel64/inscount0.so + ``` +4. `export PIN_ROOT=`, then run with `--measure-dynamic-instructions`. ### Google Sheets (Optional) @@ -116,7 +130,9 @@ To enable it you need: pip install ".[sheets]" ``` A plain `pip install .` does **not** pull in the Sheets stack (`gspread`, - `google-api-python-client`, `oauth2client`). + `google-api-python-client`, `oauth2client`). Passing `--write-sheets` + without it raises `RuntimeError` at startup (before the pipeline runs), + naming the modules it could not find. 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 @@ -188,7 +204,7 @@ signaloid-benchmarking [OPTIONS] | 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. | +| `--measure-dynamic-instructions` | `False` | Also measure dynamic instruction counts with Intel PIN, using the kit that `PIN_ROOT` points at. This flag is the only way to switch the measurement on, so a run without it never invokes PIN even when `PIN_ROOT` is set. Passing it without a usable `PIN_ROOT` is an error. | | `--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`. | @@ -306,12 +322,12 @@ output): 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 + 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 + info. Optimisation could change the traced values. To guard against that, + 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. @@ -325,8 +341,8 @@ output): 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. +10. **UxHw Timings**. Measures execution time for each UxHw configuration. It + also measures dynamic instruction counts when Intel PIN is enabled. 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. @@ -405,7 +421,8 @@ underlying bash scripts used by the pipeline: (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 + instruction count comes from Intel PIN when PIN is enabled, and is left + unset otherwise). 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 @@ -476,6 +493,8 @@ Notes on the schema: - Missing numeric fields (e.g. `dbTime` on native runs) are serialised as `null`. - `dbDynInstCount` for UxHw rows is `0`. +- `pinDynInstCount` is `null` when the run had no Intel PIN kit configured. + Intel PIN is optional and off by default, so this is the common case. - `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 diff --git a/src/signaloid/benchmarking/automation/arguments.py b/src/signaloid/benchmarking/automation/arguments.py index ab24ce9..366a7e7 100644 --- a/src/signaloid/benchmarking/automation/arguments.py +++ b/src/signaloid/benchmarking/automation/arguments.py @@ -273,15 +273,15 @@ def create_argument_parser() -> ArgumentParser: ) parser.add_argument( - "--path-to-pin", - dest="path_to_pin", - type=str, - default=None, + "--measure-dynamic-instructions", + dest="measure_dynamic_instructions", + action="store_true", 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'." + "Also measure dynamic instruction counts, using the Intel PIN " + "kit that PIN_ROOT points at. Off by default. This flag is the " + "only way to switch the measurement on, so a run without it " + "never invokes PIN even when PIN_ROOT is set. Passing it " + "without a usable PIN_ROOT is an error." ), ) diff --git a/src/signaloid/benchmarking/automation/benchmark.py b/src/signaloid/benchmarking/automation/benchmark.py index e2abacb..dec77d6 100644 --- a/src/signaloid/benchmarking/automation/benchmark.py +++ b/src/signaloid/benchmarking/automation/benchmark.py @@ -78,7 +78,7 @@ def __init__( self, path_to_application: str, path_to_uxhw_sdk: str = DEFAULT_UXHW_SDK_PATH, - path_to_pin: str | None = None, + measure_dynamic_instructions: bool = False, has_analytic_ground_truth: bool = False, path_to_ground_truth_file: str = "", ground_truth_size: int = DEFAULT_GROUND_TRUTH_SIZE, @@ -116,7 +116,7 @@ def __init__( """ 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.measure_dynamic_instructions = measure_dynamic_instructions self.has_analytic_ground_truth = has_analytic_ground_truth if self.has_analytic_ground_truth: self.path_to_ground_truth_file = os.path.expanduser( @@ -410,7 +410,7 @@ def get_application_info(self) -> None: # 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, + measure_dynamic_instructions=self.measure_dynamic_instructions, path_to_application=self.path_to_application, application_name=self.application_name, application_version=self.application_version, diff --git a/src/signaloid/benchmarking/automation/benchmark_application.py b/src/signaloid/benchmarking/automation/benchmark_application.py index 9aa01c5..5affa4c 100644 --- a/src/signaloid/benchmarking/automation/benchmark_application.py +++ b/src/signaloid/benchmarking/automation/benchmark_application.py @@ -19,6 +19,7 @@ # DEALINGS IN THE SOFTWARE. import datetime +import importlib.util import os import sys import traceback @@ -62,6 +63,11 @@ TOTAL_STEPS = 15 LOG_FILE_PREFIX = "benchmarking_automation_error" +# Top-level modules the Google Sheets upload imports (see report_writer's +# write_results_to_spreadsheet and _get_credentials). They ship in the +# optional `sheets` extra, so a plain `pip install .` leaves them absent. +SHEETS_MODULE_NAMES = ("gspread", "googleapiclient", "oauth2client") + def _use_color() -> bool: if os.environ.get("NO_COLOR"): @@ -91,12 +97,28 @@ def _resolve_sheets_credentials(args: Namespace) -> str | None: ``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. + RuntimeError: If ``--write-sheets`` is set but the ``sheets`` extra + is not installed, no credentials file resolves on disk, or the + Drive folder / Sheets template IDs are unset. """ if not args.write_sheets: return None + # The upload's dependencies are imported lazily inside + # write_results_to_spreadsheet, so without this check a missing `sheets` + # extra only surfaces at step 15, once the whole pipeline has run. + # find_spec locates the modules without importing them, keeping the + # opted-out path free of the extra's import cost. + missing_module_names = [ + module_name + for module_name in SHEETS_MODULE_NAMES + if importlib.util.find_spec(module_name) is None + ] + if missing_module_names: + raise RuntimeError( + "--write-sheets requires the `sheets` extra, but " + f"{', '.join(missing_module_names)} could not be found. " + 'Install it with: pip install ".[sheets]"' + ) credentials_path = resolve_google_credentials_path( google_credentials=args.google_credentials, ) @@ -248,7 +270,7 @@ def _run_pipeline(args: Namespace, *, credentials_path: str | None) -> None: 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, + measure_dynamic_instructions=(args.measure_dynamic_instructions), 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, diff --git a/src/signaloid/benchmarking/automation/build.py b/src/signaloid/benchmarking/automation/build.py index 11e560f..00ff180 100644 --- a/src/signaloid/benchmarking/automation/build.py +++ b/src/signaloid/benchmarking/automation/build.py @@ -27,15 +27,15 @@ from signaloid.benchmarking.config import ( EquivMC, TimingFormat, - get_repo_root, get_resources_dir, + get_timing_script, ) def export_timing_env( *, path_to_uxhw_sdk: str, - path_to_pin: str | None, + measure_dynamic_instructions: bool, path_to_application: str, application_name: str, application_version: str, @@ -53,9 +53,11 @@ def export_timing_env( 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. + measure_dynamic_instructions: Whether to measure dynamic instruction + counts with Intel PIN. Off by default. When True the kit is + taken from ``PIN_ROOT``, which must already be set. When False + ``PIN_ROOT`` is removed from the environment, so an inherited + value cannot switch the measurement on behind the caller's back. path_to_application: Path to the application source tree. application_name: Application name (e.g. ``Finance-...``). application_version: Application version string. @@ -70,14 +72,24 @@ def export_timing_env( ``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 flag is the single switch for the dynamic instruction count. + # Without it we drop PIN_ROOT, so a kit exported in the caller's shell + # profile cannot silently turn the measurement on. get-timings.sh reads + # the same variable and skips the count when it is absent. + if measure_dynamic_instructions: + pin_root = os.environ.get("PIN_ROOT", "") + if not pin_root: + raise ValueError( + "--measure-dynamic-instructions needs an Intel PIN kit, but " + "PIN_ROOT is not set. Export it to the kit directory, for " + "example 'export PIN_ROOT=~/pin-external-4.2', or drop the " + "flag to skip the dynamic instruction count." + ) + os.environ["PIN_ROOT"] = os.path.expanduser(pin_root) + else: + os.environ.pop("PIN_ROOT", None) # 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 @@ -200,6 +212,8 @@ def run_timing_script( if len(native_mc_sizes) == 0: native_mc_sizes = "50" + quoted_timing_script = shlex.quote(str(get_timing_script())) + bash_cmd = f""" TRACES=( {traces_lines}) @@ -207,7 +221,7 @@ def run_timing_script( REPRESENTATION_TYPES=({representations}) REPRESENTATION_SIZES=({rep_sizes}) CORRELATION_TRACKING_TYPES=({correlations_str}) -. $SIGNALOID_PYTHON_DIR/src/signaloid/benchmarking/benchmark_timing/get-timings.sh +. {quoted_timing_script} """ stderr_log = os.path.join(logs_dir, "timing_script_stderr.log") # Use tee so stderr streams to terminal diff --git a/src/signaloid/benchmarking/automation/config_layer_test.py b/src/signaloid/benchmarking/automation/config_layer_test.py index cac17c6..c37b08b 100644 --- a/src/signaloid/benchmarking/automation/config_layer_test.py +++ b/src/signaloid/benchmarking/automation/config_layer_test.py @@ -95,6 +95,40 @@ def test_cli_overrides_config_value(self) -> None: self.assertEqual(args.reporting_methods, ["Quantile-95"]) self.assertEqual(args.representation_types, ["Athens"]) + def test_config_enables_dynamic_instructions(self) -> None: + # measure_dynamic_instructions is an argparse dest, so it is a valid + # config key. A YAML sweep can turn the measurement on without the + # flag being typed on the command line. + config = _base_config() + config["measure_dynamic_instructions"] = 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.measure_dynamic_instructions) + + def test_config_defaults_dynamic_instructions_off(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.assertFalse(args.measure_dynamic_instructions) + + def test_stale_path_to_pin_config_key_rejected(self) -> None: + # --path-to-pin was removed. An old config still carrying it must + # fail loudly rather than being silently ignored, which would leave + # the user thinking PIN is still configured. + config = _base_config() + config["path_to_pin"] = "~/pin-external-4.2" + config_path = _write_config(self.tmp_path, config) + self._set_argv(["prog", "--config", config_path]) + + with self.assertRaises(ValueError) as caught: + load_config(create_argument_parser()) + self.assertIn("path_to_pin", str(caught.exception)) + 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 diff --git a/src/signaloid/benchmarking/automation/path_to_pin_test.py b/src/signaloid/benchmarking/automation/measure_dynamic_instructions_test.py similarity index 52% rename from src/signaloid/benchmarking/automation/path_to_pin_test.py rename to src/signaloid/benchmarking/automation/measure_dynamic_instructions_test.py index 9c3eb2c..8746ca0 100644 --- a/src/signaloid/benchmarking/automation/path_to_pin_test.py +++ b/src/signaloid/benchmarking/automation/measure_dynamic_instructions_test.py @@ -23,7 +23,6 @@ import unittest from pathlib import Path -from typing import Optional from signaloid.benchmarking.automation.arguments import ( create_argument_parser, @@ -50,11 +49,11 @@ def _base_argv() -> list[str]: ] -def _export_kwargs(tmp_path: Path, path_to_pin: Optional[str]) -> dict: +def _export_kwargs(tmp_path: Path, measure_dynamic_instructions: bool) -> dict: """Minimal valid kwargs for ``export_timing_env``.""" return dict( path_to_uxhw_sdk="~/project-uxhw-sdk", - path_to_pin=path_to_pin, + measure_dynamic_instructions=measure_dynamic_instructions, path_to_application=str(tmp_path), application_name="demo", application_version="abc1234", @@ -65,8 +64,13 @@ def _export_kwargs(tmp_path: Path, path_to_pin: Optional[str]) -> dict: ) -class TestPathToPin(unittest.TestCase): - """``--path-to-pin`` parsing and its ``PIN_ROOT`` export side effect.""" +class TestMeasureDynamicInstructions(unittest.TestCase): + """``--measure-dynamic-instructions`` parsing and its ``PIN_ROOT`` effect. + + The flag is the single switch for the dynamic instruction count. These + tests pin the property the design rests on, which is that a run without + the flag never measures, whatever ``PIN_ROOT`` holds. + """ def setUp(self) -> None: # Snapshot and restore ``os.environ`` so ``export_timing_env`` side @@ -83,25 +87,56 @@ def _restore_environ() -> None: self.addCleanup(tmp_dir.cleanup) self.tmp_path = Path(tmp_dir.name) - def test_parser_accepts_path_to_pin(self) -> None: + def test_parser_defaults_to_off(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") + args = parser.parse_args(_base_argv()) + self.assertFalse(args.measure_dynamic_instructions) - def test_parser_path_to_pin_defaults_to_none(self) -> None: + def test_parser_accepts_flag(self) -> None: parser = create_argument_parser() - args = parser.parse_args(_base_argv()) - self.assertIsNone(args.path_to_pin) + args = parser.parse_args(_base_argv() + ["--measure-dynamic-instructions"]) + self.assertTrue(args.measure_dynamic_instructions) + + def test_parser_rejects_removed_path_to_pin(self) -> None: + # --path-to-pin is gone. Argparse must reject it rather than + # silently ignoring a stale invocation. + parser = create_argument_parser() + with self.assertRaises(SystemExit): + parser.parse_args(_base_argv() + ["--path-to-pin", "/opt/pin"]) - def test_export_timing_env_exports_pin_root(self) -> None: - export_timing_env(**_export_kwargs(self.tmp_path, "/opt/pin-test")) + def test_flag_keeps_pin_root(self) -> None: + os.environ["PIN_ROOT"] = "/opt/pin-test" + export_timing_env(**_export_kwargs(self.tmp_path, True)) 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. + def test_flag_expands_user_in_pin_root(self) -> None: + os.environ["PIN_ROOT"] = "~/pin-kit" + export_timing_env(**_export_kwargs(self.tmp_path, True)) + self.assertEqual(os.environ["PIN_ROOT"], os.path.expanduser("~/pin-kit")) + + def test_flag_without_pin_root_raises(self) -> None: + os.environ.pop("PIN_ROOT", None) + with self.assertRaises(ValueError) as caught: + export_timing_env(**_export_kwargs(self.tmp_path, True)) + self.assertIn("PIN_ROOT", str(caught.exception)) + + def test_flag_with_empty_pin_root_raises(self) -> None: + # Set but empty is not a usable kit. Treat it as unset rather than + # letting the shell look for a kit at "/". + os.environ["PIN_ROOT"] = "" + with self.assertRaises(ValueError): + export_timing_env(**_export_kwargs(self.tmp_path, True)) + + def test_no_flag_drops_inherited_pin_root(self) -> None: + # The whole point of the redesign. A kit exported in the caller's + # shell profile must not switch the measurement on. + os.environ["PIN_ROOT"] = "/opt/pin-test" + export_timing_env(**_export_kwargs(self.tmp_path, False)) + self.assertNotIn("PIN_ROOT", os.environ) + + def test_no_flag_with_no_pin_root_is_fine(self) -> None: os.environ.pop("PIN_ROOT", None) - export_timing_env(**_export_kwargs(self.tmp_path, None)) + export_timing_env(**_export_kwargs(self.tmp_path, False)) self.assertNotIn("PIN_ROOT", os.environ) diff --git a/src/signaloid/benchmarking/automation/measure_process_time.py b/src/signaloid/benchmarking/automation/measure_process_time.py new file mode 100644 index 0000000..0caa735 --- /dev/null +++ b/src/signaloid/benchmarking/automation/measure_process_time.py @@ -0,0 +1,246 @@ +# 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 math +import os +import shlex +import subprocess +import sys +import tempfile +import time +from typing import IO + +# Longest stderr excerpt kept for a failure message. Benchmark binaries can +# be chatty. The useful part is at the end. +_STDERR_EXCERPT_LIMIT = 2000 + + +def scaled_repetitions( + *, + single_time: float, + target_total_time: float, + min_repetitions: int, + max_repetitions: int, +) -> int: + """ + Scale a repetition count so the total run time approaches a target. + + This mirrors the compute_time_scaled_repetitions shell function. + run_uxhw_benchmarks already uses that helper. Both timing paths + therefore size their repetition counts the same way. The rule is to + divide the target by one run's cost, round up, then clamp. + + A non-positive single_time means the run was too fast to measure. + That case falls back to max_repetitions, matching the shell helper. + + Args: + single_time: Measured duration of one run, in seconds. + target_total_time: Total measurement time aimed for, in seconds. + min_repetitions: Lower clamp on the returned count. + max_repetitions: Upper clamp on the returned count. + + Returns: + The repetition count to use. + """ + if single_time <= 0: + return max_repetitions + repetitions = math.ceil(target_total_time / single_time) + return max(min_repetitions, min(repetitions, max_repetitions)) + + +def _read_stderr_tail(stderr_sink: IO[bytes]) -> str: + """ + Read the last few kilobytes written to a stderr sink. + + Only the tail is read back. A chatty benchmark can write far more + than a failure message needs. + + Args: + stderr_sink: The temporary file the child wrote its stderr to. + + Returns: + The decoded tail of the file, at most _STDERR_EXCERPT_LIMIT bytes. + """ + stderr_sink.seek(0, os.SEEK_END) + written = stderr_sink.tell() + stderr_sink.seek(max(0, written - _STDERR_EXCERPT_LIMIT)) + return stderr_sink.read().decode("utf-8", errors="replace") + + +def time_once(*, command: str, use_shell: bool) -> float: + """ + Run a command once and return its wall-clock duration. + + The command's stdout is discarded. Benchmark binaries print their + results there. This helper's own stdout carries the measurement back + to the shell layer. + + Stderr goes to a temporary file rather than a pipe. The child writes + straight to that file descriptor. Nothing accumulates in this + process, so the measurement stays undisturbed. Only the tail is read + back, and only when the command fails. + + Args: + command: The command line to run. + use_shell: Run via the system shell when True. Otherwise split + the command with shlex and execute it directly. + + Returns: + The elapsed wall-clock time in seconds. + + Raises: + RuntimeError: If the command exits non-zero. Timing a failed run + would feed a meaningless number into the report. + """ + arguments: str | list[str] = command if use_shell else shlex.split(command) + with tempfile.TemporaryFile() as stderr_sink: + start = time.perf_counter() + completed = subprocess.run( + arguments, + shell=use_shell, + stdout=subprocess.DEVNULL, + stderr=stderr_sink, + ) + elapsed = time.perf_counter() - start + if completed.returncode != 0: + raise RuntimeError( + f"command exited {completed.returncode}: {command}\n" + f"stderr: {_read_stderr_tail(stderr_sink)}" + ) + return elapsed + + +def measure_mean_time( + *, + command: str, + use_shell: bool, + target_total_time: float, + min_repetitions: int, + max_repetitions: int, +) -> float: + """ + Time a command repeatedly and return the mean wall-clock duration. + + One warmup run is timed first and then discarded. It primes caches. + It also sizes the measured repetition count via scaled_repetitions. + + Args: + command: The command line to benchmark. + use_shell: Whether to run the command through the system shell. + target_total_time: Total measurement time aimed for, in seconds. + min_repetitions: Lower clamp on the repetition count. + max_repetitions: Upper clamp on the repetition count. + + Returns: + The mean elapsed wall-clock time in seconds. + + Raises: + RuntimeError: If any run of the command exits non-zero. + """ + warmup_time = time_once(command=command, use_shell=use_shell) + repetitions = scaled_repetitions( + single_time=warmup_time, + target_total_time=target_total_time, + min_repetitions=min_repetitions, + max_repetitions=max_repetitions, + ) + total = 0.0 + for _ in range(repetitions): + total += time_once(command=command, use_shell=use_shell) + return total / repetitions + + +def main() -> None: + """ + Parse command-line arguments and emit the mean wall-clock time. + + The bash layer calls this through python3 -m + signaloid.benchmarking.automation.measure_process_time. Stdout + carries a single float in seconds. + """ + parser = argparse.ArgumentParser( + description=( + "Time a command over a scaled number of repetitions and print " + "the mean wall-clock duration in seconds." + ), + ) + parser.add_argument( + "command", + help="The command line to benchmark, as a single string.", + ) + parser.add_argument( + "--target-total-time", + type=float, + default=30.0, + help=( + "Total measurement time to aim for, in seconds. Matches the " + "shell layer's TIMING_TARGET_TOTAL_TIME." + ), + ) + parser.add_argument( + "--min-repetitions", + type=int, + default=2, + help="Lower clamp on the repetition count.", + ) + parser.add_argument( + "--max-repetitions", + type=int, + default=20, + help="Upper clamp on the repetition count.", + ) + parser.add_argument( + "--no-shell", + dest="use_shell", + action="store_false", + default=True, + help=( + "Execute the command directly instead of through the system " + "shell. The command is split with shlex." + ), + ) + args = parser.parse_args() + + if args.min_repetitions < 1: + parser.error(f"--min-repetitions must be >= 1, got {args.min_repetitions}") + if args.max_repetitions < args.min_repetitions: + parser.error( + f"--max-repetitions ({args.max_repetitions}) must be >= " + f"--min-repetitions ({args.min_repetitions})" + ) + + print( + measure_mean_time( + command=args.command, + use_shell=args.use_shell, + target_total_time=args.target_total_time, + min_repetitions=args.min_repetitions, + max_repetitions=args.max_repetitions, + ) + ) + + +if __name__ == "__main__": + try: + main() + except (OSError, RuntimeError, ValueError) as exc: + print(f"measure_process_time: {exc}", file=sys.stderr) + sys.exit(1) diff --git a/src/signaloid/benchmarking/automation/measure_process_time_test.py b/src/signaloid/benchmarking/automation/measure_process_time_test.py new file mode 100644 index 0000000..2baef0b --- /dev/null +++ b/src/signaloid/benchmarking/automation/measure_process_time_test.py @@ -0,0 +1,313 @@ +# 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 +import tempfile +import unittest +from unittest.mock import patch + +from signaloid.benchmarking.automation.measure_process_time import ( + measure_mean_time, + scaled_repetitions, + time_once, +) + +# A command that exits 0 and does nothing else. It is spelled via the running +# interpreter so the tests do not depend on which coreutils are present. +_NOOP_COMMAND = f"{shlex.quote(sys.executable)} -c pass" +_FAILING_COMMAND = f"{shlex.quote(sys.executable)} -c 'raise SystemExit(3)'" + +# A command whose text carries a shell redirection. Under the shell the +# redirection creates the target file. Executed directly the two tokens +# reach the interpreter as ignored extra arguments and no file appears. +_REDIRECT_TEMPLATE = f"{shlex.quote(sys.executable)} -c pass > {{target}}" + + +def _noisy_command(*, byte_count: int) -> str: + """ + Build a command that writes many bytes to stderr and then fails. + + Args: + byte_count: Number of bytes the command writes to stderr. + + Returns: + A command line suitable for time_once. + """ + script = "\n".join( + [ + "import sys", + f"sys.stderr.write('x' * {byte_count})", + "raise SystemExit(4)", + ] + ) + return f"{shlex.quote(sys.executable)} -c {shlex.quote(script)}" + + +class TestScaledRepetitions(unittest.TestCase): + """scaled_repetitions mirrors the compute_time_scaled_repetitions shell + helper. It divides, rounds up, then clamps.""" + + def test_divides_target_by_single_run_cost(self) -> None: + """A 1s run against a 10s target gives 10 repetitions.""" + self.assertEqual( + scaled_repetitions( + single_time=1.0, + target_total_time=10.0, + min_repetitions=2, + max_repetitions=20, + ), + 10, + ) + + def test_rounds_the_quotient_up(self) -> None: + """ + A non-integer quotient rounds up. The target total time is then + met rather than undershot. + """ + self.assertEqual( + scaled_repetitions( + single_time=3.0, + target_total_time=10.0, + min_repetitions=1, + max_repetitions=20, + ), + 4, + ) + + def test_clamps_to_the_maximum_for_a_fast_command(self) -> None: + """A very fast run would demand a huge count, so the max applies.""" + self.assertEqual( + scaled_repetitions( + single_time=0.001, + target_total_time=30.0, + min_repetitions=2, + max_repetitions=20, + ), + 20, + ) + + def test_clamps_to_the_minimum_for_a_slow_command(self) -> None: + """ + A run already longer than the target still repeats + min_repetitions times. A mean is then always taken over more + than one sample. + """ + self.assertEqual( + scaled_repetitions( + single_time=120.0, + target_total_time=30.0, + min_repetitions=2, + max_repetitions=20, + ), + 2, + ) + + def test_non_positive_single_time_falls_back_to_the_maximum(self) -> None: + """ + A run too fast to measure yields a non-positive time. The shell + helper returns max_reps in that case and so does this one. + """ + for single_time in (0.0, -1.0): + with self.subTest(single_time=single_time): + self.assertEqual( + scaled_repetitions( + single_time=single_time, + target_total_time=30.0, + min_repetitions=2, + max_repetitions=20, + ), + 20, + ) + + +class TestExecutionMode(unittest.TestCase): + """time_once picks direct execution or the shell as asked.""" + + def test_no_shell_passes_a_split_argument_list(self) -> None: + """ + Without a shell the command is split by shlex and handed to + subprocess as a list. Quoted whitespace survives as one argument. + """ + with patch("subprocess.run") as run_mock: + run_mock.return_value = subprocess.CompletedProcess([], 0) + time_once(command="prog --flag 'a b'", use_shell=False) + positional, keyword = run_mock.call_args + self.assertEqual(positional[0], ["prog", "--flag", "a b"]) + self.assertFalse(keyword["shell"]) + + def test_shell_passes_the_command_unsplit(self) -> None: + """With a shell the command string is handed over verbatim.""" + with patch("subprocess.run") as run_mock: + run_mock.return_value = subprocess.CompletedProcess([], 0) + time_once(command="prog --flag 'a b'", use_shell=True) + positional, keyword = run_mock.call_args + self.assertEqual(positional[0], "prog --flag 'a b'") + self.assertTrue(keyword["shell"]) + + def test_no_shell_leaves_a_redirection_uninterpreted(self) -> None: + """ + Direct execution must not honour shell metacharacters. The UxHw + call site relies on this. + """ + with tempfile.TemporaryDirectory() as directory: + target = os.path.join(directory, "redirected.txt") + command = _REDIRECT_TEMPLATE.format(target=shlex.quote(target)) + time_once(command=command, use_shell=False) + self.assertFalse(os.path.exists(target)) + + def test_shell_honours_a_redirection(self) -> None: + """ + Shell execution does honour metacharacters. This is the + counterpart that proves the two modes really differ. + """ + with tempfile.TemporaryDirectory() as directory: + target = os.path.join(directory, "redirected.txt") + command = _REDIRECT_TEMPLATE.format(target=shlex.quote(target)) + time_once(command=command, use_shell=True) + self.assertTrue(os.path.exists(target)) + + +class TestTimeOnce(unittest.TestCase): + """time_once measures a single run and rejects failed commands.""" + + def test_returns_a_positive_duration(self) -> None: + """A successful command yields a positive elapsed time.""" + self.assertGreater( + time_once(command=_NOOP_COMMAND, use_shell=False), + 0.0, + ) + + def test_non_zero_exit_raises(self) -> None: + """ + A failed command raises rather than returning a duration. Timing + a run that did not complete would feed a meaningless number into + the report. + """ + with self.assertRaises(RuntimeError) as raised: + time_once(command=_FAILING_COMMAND, use_shell=True) + self.assertIn("exited 3", str(raised.exception)) + + def test_failure_message_keeps_only_the_stderr_tail(self) -> None: + """ + A chatty failing command must not paste its whole stderr into the + message. Only the tail is reported, so the message stays bounded. + """ + noisy_bytes = 50_000 + with self.assertRaises(RuntimeError) as raised: + time_once(command=_noisy_command(byte_count=noisy_bytes), use_shell=False) + message = str(raised.exception) + self.assertIn("exited 4", message) + self.assertLess(len(message), noisy_bytes) + + +class TestMeasureMeanTime(unittest.TestCase): + """measure_mean_time discards a warmup run and averages the rest.""" + + def test_returns_a_positive_mean(self) -> None: + """The mean over the scaled repetitions is a positive duration.""" + mean = measure_mean_time( + command=_NOOP_COMMAND, + use_shell=False, + target_total_time=0.05, + min_repetitions=2, + max_repetitions=3, + ) + self.assertGreater(mean, 0.0) + + def test_runs_warmup_plus_the_scaled_repetitions(self) -> None: + """ + The warmup run is timed and then discarded. With the clamp + pinning the count at 2, that means three executions in total. + """ + with patch( + "signaloid.benchmarking.automation.measure_process_time.time_once", + return_value=1.0, + ) as time_once_mock: + mean = measure_mean_time( + command=_NOOP_COMMAND, + use_shell=False, + target_total_time=1.0, + min_repetitions=2, + max_repetitions=2, + ) + self.assertEqual(time_once_mock.call_count, 3) + self.assertEqual(mean, 1.0) + + def test_propagates_a_failing_command(self) -> None: + """A command that fails on its warmup run raises immediately.""" + with self.assertRaises(RuntimeError): + measure_mean_time( + command=_FAILING_COMMAND, + use_shell=True, + target_total_time=0.05, + min_repetitions=2, + max_repetitions=2, + ) + + +class TestCommandLineInterface(unittest.TestCase): + """The module entry point prints a single float for the shell layer.""" + + def test_prints_a_parseable_float(self) -> None: + """ + Stdout carries only the mean. The bash layer can then capture it + with a plain command substitution. + """ + completed = subprocess.run( + [ + sys.executable, + "-m", + "signaloid.benchmarking.automation.measure_process_time", + "--target-total-time", + "0.05", + "--min-repetitions", + "2", + "--max-repetitions", + "2", + "--no-shell", + _NOOP_COMMAND, + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertGreater(float(completed.stdout.strip()), 0.0) + + def test_reports_a_failing_command_on_stderr(self) -> None: + """A failed benchmark exits non-zero with a named cause.""" + completed = subprocess.run( + [ + sys.executable, + "-m", + "signaloid.benchmarking.automation.measure_process_time", + _FAILING_COMMAND, + ], + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 1) + self.assertIn("measure_process_time:", completed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/automation/measurement_loader.py b/src/signaloid/benchmarking/automation/measurement_loader.py index 78d9eba..a5be0ce 100644 --- a/src/signaloid/benchmarking/automation/measurement_loader.py +++ b/src/signaloid/benchmarking/automation/measurement_loader.py @@ -217,7 +217,10 @@ def load_measurement_data( 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): + # `pin_dyn_inst_count` is not required either: it is None on + # every run made without an Intel PIN kit, which is the + # default. + if None in (time, e2e_time): continue for variable in benchmarking_variables: # Collapse internal whitespace, matching the normalization @@ -233,15 +236,21 @@ def load_measurement_data( ) 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. + # Reference and Native rows are not UxHw configurations. + # Skip them by name. They used to be filtered out by the + # `pin_dyn_inst_count is None` check below, which no longer + # identifies them now that a PIN-less run leaves that field + # None on genuine UxHw rows too. + if config.startswith("Reference") or config.startswith("Native"): + continue + # UxHw path: only ingest rows with all four numeric fields. + # `pin_dyn_inst_count` is excluded: it is None on every run + # made without an Intel PIN kit, which is the default. if None in ( time, db_time, e2e_time, db_dyn_inst_count, - pin_dyn_inst_count, ): continue for variable in benchmarking_variables: diff --git a/src/signaloid/benchmarking/automation/measurement_loader_test.py b/src/signaloid/benchmarking/automation/measurement_loader_test.py index 9e6b4d2..15dc7bb 100644 --- a/src/signaloid/benchmarking/automation/measurement_loader_test.py +++ b/src/signaloid/benchmarking/automation/measurement_loader_test.py @@ -275,6 +275,123 @@ def test_load_measurement_data_populates_timing_measurements(self) -> None: self.assertEqual(native["End-to-End Time"], 1.5) self.assertEqual(native["PIN Dyn. Inst. Count"], 300.0) + def test_load_measurement_data_ingests_rows_without_pin_counts(self) -> None: + """A run made without Intel PIN still populates every measurement. + + Intel PIN is optional and off by default, so ``pinDynInstCount`` is + None on every row. Those rows must still be ingested. Dropping them + would empty the measurement set and fail the configuration-count + check with a message that never mentions PIN. + """ + 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: (None), + }, + ], + }, + { + 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: (None), + }, + ], + }, + ] + + 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 Dyn. Inst. Count"], 100.0) + # Recorded as missing rather than as a plausible-looking zero. + self.assertIsNone(uxhw["PIN Dyn. Inst. Count"]) + + native = measurement_dict["Native-MC-50"] + self.assertEqual(native["In Application Time"], 0.5) + self.assertIsNone(native["PIN Dyn. Inst. Count"]) + + def test_load_measurement_data_skips_reference_and_native_rows(self) -> None: + """Reference and Native rows are not UxHw configurations. + + They used to be filtered out because their PIN count was the only + missing field. A PIN-less run leaves that field missing on genuine + UxHw rows too, so they are now skipped by name instead. + """ + variable = _make_variable(name="x", description="x var", cla="-S 0") + + runs = [ + { + TimingFormat.META_KEY_COMMAND_LINE_ARGUMENTS: "-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: (None), + }, + { + TimingFormat.JSON_KEY_MEASUREMENT_CONFIG: ("Reference-50-1"), + TimingFormat.JSON_KEY_MEASUREMENT_TIME: 0.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME: 1.0, + TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME: 1.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT: (100.0), + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT: (None), + }, + # Bare "Native-", not "Native-MC-", so it reaches the UxHw + # branch and exercises the Native half of the name skip. + { + TimingFormat.JSON_KEY_MEASUREMENT_CONFIG: ("Native-50-1"), + TimingFormat.JSON_KEY_MEASUREMENT_TIME: 0.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_TIME: 1.0, + TimingFormat.JSON_KEY_MEASUREMENT_E2E_TIME: 1.5, + TimingFormat.JSON_KEY_MEASUREMENT_DB_DYN_INST_COUNT: (100.0), + TimingFormat.JSON_KEY_MEASUREMENT_PIN_DYN_INST_COUNT: (None), + }, + ], + }, + ] + + 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 + self.assertIn("Athens-16", measurement_dict) + self.assertNotIn("Reference-50-1", measurement_dict) + self.assertNotIn("Native-50-1", measurement_dict) + class TestLoadTimingDataToDfs(unittest.TestCase): """Exercise load_timing_data_to_dfs joining timings into emcc_data.""" diff --git a/src/signaloid/benchmarking/automation/sheets_preflight_test.py b/src/signaloid/benchmarking/automation/sheets_preflight_test.py new file mode 100644 index 0000000..8e5f3f2 --- /dev/null +++ b/src/signaloid/benchmarking/automation/sheets_preflight_test.py @@ -0,0 +1,153 @@ +# 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 importlib.machinery +import importlib.util +import tempfile +import unittest +from argparse import Namespace +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from signaloid.benchmarking.automation import benchmark_application +from signaloid.benchmarking.automation.benchmark_application import ( + SHEETS_MODULE_NAMES, + _resolve_sheets_credentials, +) + + +def _make_args(*, write_sheets: bool, google_credentials: str | None) -> Namespace: + """ + Build the argument subset that _resolve_sheets_credentials reads. + + Args: + write_sheets: Whether the user opted in to the Sheets upload. + google_credentials: Explicit credentials path, or None. + + Returns: + A Namespace carrying just those two attributes. + """ + return Namespace( + write_sheets=write_sheets, + google_credentials=google_credentials, + ) + + +def _find_spec_missing(module_names: set[str]) -> Any: + """ + Build a find_spec replacement with a fixed view of the Sheets stack. + + Every name in ``SHEETS_MODULE_NAMES`` is answered from + ``module_names`` alone, so these tests behave identically whether or + not the `sheets` extra is installed in the running environment. Any + other name is delegated to the real ``importlib.util.find_spec``. + + Args: + module_names: Sheets modules to report as not installed. + + Returns: + A callable suitable for patching ``importlib.util.find_spec``. + """ + real_find_spec = importlib.util.find_spec + present_spec = importlib.machinery.ModuleSpec("present", loader=None) + + def fake_find_spec(name: str, *args: Any, **kwargs: Any) -> Any: + if name in SHEETS_MODULE_NAMES: + return None if name in module_names else present_spec + return real_find_spec(name, *args, **kwargs) + + return fake_find_spec + + +class TestSheetsPreflight(unittest.TestCase): + """_resolve_sheets_credentials validates the `sheets` extra up front.""" + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.credentials_file = Path(tmp_dir.name) / "creds.json" + self.credentials_file.write_text("{}") + + def test_opted_out_skips_the_module_check(self) -> None: + """ + Without --write-sheets the extra is irrelevant, so a missing + module must not raise: the run does not touch the upload. + """ + args = _make_args(write_sheets=False, google_credentials=None) + with patch( + "importlib.util.find_spec", + _find_spec_missing(set(SHEETS_MODULE_NAMES)), + ): + self.assertIsNone(_resolve_sheets_credentials(args)) + + def test_missing_module_raises_naming_the_extra(self) -> None: + """ + A missing Sheets module raises at startup, and the message names + both the module and the command that installs it. + """ + args = _make_args( + write_sheets=True, + google_credentials=str(self.credentials_file), + ) + with patch( + "importlib.util.find_spec", + _find_spec_missing({"gspread"}), + ): + with self.assertRaises(RuntimeError) as raised: + _resolve_sheets_credentials(args) + message = str(raised.exception) + self.assertIn("gspread", message) + self.assertIn('pip install ".[sheets]"', message) + + def test_module_check_precedes_the_credentials_check(self) -> None: + """ + With no credentials *and* no extra, the missing extra is reported. + Fixing credentials first would only surface the import error at + step 15, which is the failure mode this check exists to prevent. + """ + args = _make_args(write_sheets=True, google_credentials=None) + with patch( + "importlib.util.find_spec", + _find_spec_missing(set(SHEETS_MODULE_NAMES)), + ): + with self.assertRaises(RuntimeError) as raised: + _resolve_sheets_credentials(args) + self.assertIn("`sheets` extra", str(raised.exception)) + + def test_installed_extra_passes_through_to_credentials(self) -> None: + """ + With every module present the check is transparent: resolution + continues and returns the credentials path. + """ + args = _make_args( + write_sheets=True, + google_credentials=str(self.credentials_file), + ) + with patch("importlib.util.find_spec", _find_spec_missing(set())): + with patch.object( + benchmark_application, "TARGET_FOLDER_ID", "folder-id" + ), patch.object(benchmark_application, "TEMPLATE_ID", "template-id"): + resolved = _resolve_sheets_credentials(args) + self.assertEqual(resolved, str(self.credentials_file)) + + +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 index f08b5f2..068e618 100644 --- a/src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh +++ b/src/signaloid/benchmarking/benchmark_timing/get-timing-template.sh @@ -11,9 +11,10 @@ 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: +# Intel PIN kit location (PIN_ROOT). PIN is optional and off by default. +# The Python tool passes it through only with --measure-dynamic-instructions. +# For standalone use, uncomment the line below and point it at your Pin +# kit directory. Leave it commented out to skip the instruction count. # export PIN_ROOT="/path/to/pin-external--gcc-linux" TRACES=( @@ -38,5 +39,24 @@ 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 +# You need to source get-timings.sh for the variables set above to take +# effect. Its location is resolved from the installed `signaloid` package, so +# this works for both a pip install and a source checkout. We try +# BENCHMARKING_PYTHON, then `python3`, then `python`, since PEP 394 is not +# honoured on every platform. A candidate has to both run and import the +# package, so the loop settles on one that can do the real work below. +GET_TIMINGS_SH="" +for BENCHMARKING_PYTHON in "${BENCHMARKING_PYTHON:-}" python3 python; do + [ -n "$BENCHMARKING_PYTHON" ] || continue + GET_TIMINGS_SH=$("$BENCHMARKING_PYTHON" -c 'import signaloid.benchmarking.config as config +print(config.get_timing_script())' 2>/dev/null) && [ -n "$GET_TIMINGS_SH" ] && break + GET_TIMINGS_SH="" +done + +if [ -z "$GET_TIMINGS_SH" ]; then + echo "Could not locate get-timings.sh. Set \$BENCHMARKING_PYTHON to a Python 3 interpreter that can import the signaloid package." >&2 + return 1 2>/dev/null || exit 1 +fi + +export BENCHMARKING_PYTHON +. "$GET_TIMINGS_SH" diff --git a/src/signaloid/benchmarking/benchmark_timing/get-timings.sh b/src/signaloid/benchmarking/benchmark_timing/get-timings.sh index 6d79151..63e4466 100644 --- a/src/signaloid/benchmarking/benchmark_timing/get-timings.sh +++ b/src/signaloid/benchmarking/benchmark_timing/get-timings.sh @@ -9,6 +9,11 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +# Directory containing the `signaloid` package, for `python3 -m signaloid...`. +# Derived from this script's own location, so it resolves to `src/` in a source +# checkout and to site-packages in an installed wheel. +PACKAGE_IMPORT_ROOT=$(cd -- "$SCRIPT_DIR/../../.." &>/dev/null && pwd) + # =========================================================================== # Utility functions # =========================================================================== @@ -22,6 +27,36 @@ warn() { echo "$*" >&2 } +# Check that a command exists and reports itself as Python 3. +# Usage: is_python3 python3 +is_python3() { + command -v "$1" &>/dev/null \ + && "$1" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' &>/dev/null +} + +# Resolve the interpreter used for the `-m signaloid...` helpers below, into +# BENCHMARKING_PYTHON. The Python tool exports it already (its own +# sys.executable). For standalone use we try `python3` first, then a `python` +# that is Python 3, since PEP 394 is not honoured on every platform. +resolve_benchmarking_python() { + local candidate + + if [[ -n "${BENCHMARKING_PYTHON:-}" ]]; then + is_python3 "$BENCHMARKING_PYTHON" \ + || die "\$BENCHMARKING_PYTHON ($BENCHMARKING_PYTHON) is not a working Python 3 interpreter." + return + fi + + for candidate in python3 python; do + if is_python3 "$candidate"; then + BENCHMARKING_PYTHON=$candidate + return + fi + done + + die "No Python 3 interpreter found. Install python3, or set \$BENCHMARKING_PYTHON to one." +} + log_section() { echo "===============================================================" echo "=== $* ===" @@ -69,7 +104,7 @@ default_array() { # Python (see signaloid.benchmarking.automation.benchmarking_utils # `parse_timing_intermediate_stream`). compute_average() { - "${BENCHMARKING_PYTHON:-python3}" -c "values = $1; print(sum(values) / len(values))" + "$BENCHMARKING_PYTHON" -c "values = $1; print(sum(values) / len(values))" } # Scale repetitions so the total measurement time approaches $target_total, @@ -193,14 +228,20 @@ should_skip_representation() { return 1 } -# Run hyperfine and return the mean time. -# Usage: mean_time=$(run_hyperfine_mean "command to benchmark") -run_hyperfine_mean() { +# Return the mean wall-clock time of a command via the measure_process_time +# Python helper. Same PYTHONPATH-prefix shape as parse_cpu_time. +# The repetition count is scaled from a warmup run against the same +# TIMING_* knobs run_uxhw_benchmarks uses, so both timing paths agree. +# Usage: mean_time=$(measure_process_time "command to benchmark" [--no-shell]) +measure_process_time() { local cmd="$1" shift - rm -f hyperfine.json - hyperfine "$@" --warmup 2 --style none --export-json hyperfine.json "$cmd" >/dev/null 2>>"${LOGS_DIR:-/tmp}/hyperfine.log" - jq '.results[0].mean' hyperfine.json + PYTHONPATH="$PACKAGE_IMPORT_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + "$BENCHMARKING_PYTHON" -m signaloid.benchmarking.automation.measure_process_time \ + --target-total-time "$TIMING_TARGET_TOTAL_TIME" \ + --min-repetitions "$TIMING_MIN_REPETITIONS" \ + --max-repetitions "$TIMING_MAX_REPETITIONS" \ + "$@" "$cmd" } # Extract the seconds value from a `CPU time used:` line in one or @@ -213,8 +254,8 @@ run_hyperfine_mean() { # 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 "$@" + PYTHONPATH="$PACKAGE_IMPORT_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + "$BENCHMARKING_PYTHON" -m signaloid.benchmarking.automation.parse_cpu_time "$@" } # Read a single-value DB metric via the read_db_metrics Python helper. @@ -222,8 +263,8 @@ 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 \ + PYTHONPATH="$PACKAGE_IMPORT_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + "$BENCHMARKING_PYTHON" -m signaloid.benchmarking.automation.read_db_metrics \ "$db" --metric "$metric" } @@ -272,7 +313,6 @@ parse_args() { # =========================================================================== validate_required_vars() { - require_var SIGNALOID_PYTHON_DIR require_var PATH_TO_UXHW_SDK require_var APPLICATION_PATH require_var APPLICATION_NAME @@ -439,20 +479,22 @@ run_uxhw_benchmarks() { 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" + if [[ $PIN_ENABLED -eq 1 ]]; then + $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" 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) + PROCESS_E2E_TIME=$(measure_process_time "$APPLICATION_PATH/src/$PROGRAM-$UXHW_TESTCASE $CLA" --no-shell) # The emulated RISC-V dynamic instruction count (dbDynInstCount, the 5th # field) was produced by the now-removed UxHw `sf` emulator pass; emit @@ -642,8 +684,8 @@ run_uxhw_tracing() { # 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 \ + PYTHONPATH="$PACKAGE_IMPORT_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + "$BENCHMARKING_PYTHON" -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 @@ -656,8 +698,8 @@ run_uxhw_tracing() { 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 \ + PYTHONPATH="$PACKAGE_IMPORT_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + "$BENCHMARKING_PYTHON" -m signaloid.benchmarking.automation.merge_tracing_dbs \ "$TRACING_DB_ABS" "${tracing_dbs[@]}" # Cleanup temporary binaries and config files @@ -791,9 +833,14 @@ run_native_mc_benchmarks() { 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 }') + # An empty last_measured_instructions means PIN was off, so there + # is nothing to scale. Emit the `?` sentinel rather than letting + # awk turn the empty string into a plausible-looking 0. + local pin_dynamic_instructions="?" + if [[ -n "$last_measured_instructions" ]]; then + pin_dynamic_instructions=$(awk -v instructions="$last_measured_instructions" -v scale="$scaling_factor" \ + 'BEGIN { printf "%.10f", instructions * scale }') + fi emit_measurement "Native-MC-$REFERENCE_PRECISION" "$result" "?" "$process_e2e_time" "?" "$pin_dynamic_instructions" continue @@ -828,33 +875,40 @@ run_native_mc_benchmarks() { 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+="]" + # Stays empty when PIN is off, which suppresses the extrapolated + # instruction count below. + local pin_dyn_inst_array="[]" + if [[ $PIN_ENABLED -eq 1 ]]; then + rm -f inscount.out + + # Use at most 20 repetitions for dynamic instruction count. This + # caps the PIN loop only, the timing loop above uses the full + # $NATIVE_MC_REPETITION. + local native_mc_repetition_pin=$((NATIVE_MC_REPETITION < 20 ? NATIVE_MC_REPETITION : 20)) + 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+="]" + fi local process_e2e_time - process_e2e_time=$(run_hyperfine_mean "$APPLICATION_PATH/src/$PROGRAM-native $CLA $CLA_FOR_MULTIPLE_EXECUTIONS $REFERENCE_PRECISION") + process_e2e_time=$(measure_process_time "$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 @@ -875,7 +929,14 @@ run_native_mc_benchmarks() { 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") + # With PIN off there are no counts to average, and compute_average + # would divide by zero. Leave the value empty so the extrapolation + # block above emits the `?` sentinel instead of a fabricated count. + if [[ $PIN_ENABLED -eq 1 ]]; then + last_measured_instructions=$(compute_average "$pin_dyn_inst_array") + else + last_measured_instructions="" + fi done } @@ -931,10 +992,11 @@ main() { [[ -f "$SCRIPT_DIR/get_timings_local.sh" ]] && source "$SCRIPT_DIR/get_timings_local.sh" + resolve_benchmarking_python validate_required_vars ORIG_PWD=$(pwd) - RESOURCES_DIR="${BENCHMARKING_RESOURCES_DIR:-$SCRIPT_DIR/../../assets/template/coreClass}" + RESOURCES_DIR="${BENCHMARKING_RESOURCES_DIR:-$SCRIPT_DIR/../assets/template/coreClass}" CLA_HASH=$(echo -n "$CLA" | md5sum | cut -d ' ' -f1) @@ -975,20 +1037,35 @@ main() { 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). + # Intel PIN is optional and off by default. A run opts in implicitly by + # setting PIN_ROOT. The Python tool only passes it through when + # --measure-dynamic-instructions was given, and removes it otherwise. With + # PIN off we skip the dynamic instruction count entirely and emit no + # pinDynInstCount samples, so the `?` sentinel on the MEASUREMENT lines + # stays unresolved and the Python reader reports the count as missing. PIN_ROOT="${PIN_ROOT:-}" - PIN_TOOL="$PIN_ROOT/source/tools/ManualExamples/obj-intel64/inscount0.so" - INSTRUCTION_COUNT_COMMAND="$PIN_ROOT/pin -t $PIN_TOOL --" + if [[ -n "$PIN_ROOT" ]]; then + PIN_ENABLED=1 + PIN_TOOL="$PIN_ROOT/source/tools/ManualExamples/obj-intel64/inscount0.so" + INSTRUCTION_COUNT_COMMAND="$PIN_ROOT/pin -t $PIN_TOOL --" + else + PIN_ENABLED=0 + PIN_TOOL="" + INSTRUCTION_COUNT_COMMAND="" + fi - # Check Pin tool availability for benchmarks that need it + # Check Pin tool availability for benchmarks that need it. Setting PIN_ROOT + # is an explicit opt-in, so a missing or unbuilt kit stays fatal. 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" + if [[ $PIN_ENABLED -eq 1 ]]; then + if [[ ! -x "$PIN_ROOT/pin" ]]; then + die "Intel Pin not found at '$PIN_ROOT/pin'. Set PIN_ROOT 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 + else + echo "---> Skipping the dynamic instruction count. Pass --measure-dynamic-instructions with PIN_ROOT set to measure it." fi fi diff --git a/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py b/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py index e69ffcc..fd801b8 100644 --- a/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py +++ b/src/signaloid/benchmarking/benchmark_timing/get_timing_template_test.py @@ -28,6 +28,7 @@ """ import os +import shutil import subprocess import tempfile import unittest @@ -38,7 +39,7 @@ _TEMPLATE = Path(__file__).resolve().parent / "get-timing-template.sh" -def _source_template(application_path: Path, signaloid_python_dir: Path) -> str: +def _source_template(application_path: Path, benchmarking_python: Path) -> str: """Source the template and echo APPLICATION_NAME / APPLICATION_VERSION. Returns combined stdout. Tests parse it with simple substring checks. @@ -51,7 +52,7 @@ def _source_template(application_path: Path, signaloid_python_dir: Path) -> str: result = subprocess.run( ["bash", "-c", cmd], env={ - "SIGNALOID_PYTHON_DIR": str(signaloid_python_dir), + "BENCHMARKING_PYTHON": str(benchmarking_python), "APPLICATION_PATH": str(application_path), "PATH": os.environ["PATH"], "HOME": os.environ.get("HOME", ""), @@ -72,33 +73,29 @@ 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() + self.stub_benchmarking_python = self._make_stub_benchmarking_python() - def _make_stub_signaloid_python_dir(self) -> Path: - """Provide a stub SIGNALOID_PYTHON_DIR with a no-op get-timings.sh. + def _make_stub_benchmarking_python(self) -> Path: + """Provide a stub interpreter that resolves 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. + The template ends by asking ``$BENCHMARKING_PYTHON`` for the path of + the bundled ``get-timings.sh`` and sourcing the result. For these tests + we only care about the variable assignments at the top of the template, + so the stub interpreter ignores its arguments and prints the path of an + empty get-timings.sh. """ - fake_get_timings = ( - self.tmp_path - / "src" - / "signaloid" - / "benchmarking" - / "benchmark_timing" - / "get-timings.sh" - ) - fake_get_timings.parent.mkdir(parents=True) + fake_get_timings = self.tmp_path / "get-timings.sh" fake_get_timings.write_text("# no-op stub for tests\n") - return self.tmp_path + + stub_python = self.tmp_path / "stub-python" + stub_python.write_text(f'#!/usr/bin/env bash\necho "{fake_get_timings}"\n') + stub_python.chmod(0o755) + return stub_python 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) + out = _source_template(app_path, self.stub_benchmarking_python) self.assertIn("NAME=MyDemo", out) def test_template_application_version_is_non_empty_for_non_git_path( @@ -106,7 +103,7 @@ def test_template_application_version_is_non_empty_for_non_git_path( ) -> None: app_path = self.tmp_path / "demo" app_path.mkdir() - out = _source_template(app_path, self.stub_signaloid_python_dir) + out = _source_template(app_path, self.stub_benchmarking_python) version_line = next( line for line in out.splitlines() if line.startswith("VERSION=") ) @@ -118,5 +115,92 @@ def test_template_application_version_is_non_empty_for_non_git_path( ) +class TestGetTimingTemplateInterpreterDiscovery(unittest.TestCase): + """The template must find an interpreter without BENCHMARKING_PYTHON set. + + PEP 394 asks for a ``python3`` on the PATH, but it is not honoured + everywhere, so the template also accepts a ``python`` that is Python 3. + """ + + # The template shells out to these while populating APPLICATION_NAME and + # APPLICATION_VERSION, so a restricted PATH still has to provide them. + _REQUIRED_TOOLS = ("basename", "sed", "date") + + def setUp(self) -> None: + tmp_dir = tempfile.TemporaryDirectory() + self.addCleanup(tmp_dir.cleanup) + self.tmp_path = Path(tmp_dir.name) + + self.stub_get_timings = self.tmp_path / "get-timings.sh" + self.stub_get_timings.write_text("# no-op stub for tests\n") + + def _make_bin_dir(self, *interpreter_names: str) -> Path: + """Build a PATH directory holding only the named interpreters. + + Each named interpreter is a stub that ignores its arguments and prints + the path of an empty get-timings.sh, standing in for + ``print(config.get_timing_script())``. The tools the template itself + shells out to are symlinked in, so nothing else leaks in from the + real PATH. + + Args: + interpreter_names: Names to create, e.g. ``"python"``. Passing + none yields a directory with no interpreter at all. + + Returns: + The directory to use as the entire PATH. + """ + bin_dir = self.tmp_path / ("bin-" + "-".join(interpreter_names or ("none",))) + bin_dir.mkdir() + + for tool in self._REQUIRED_TOOLS: + resolved = shutil.which(tool) + assert resolved is not None, f"{tool} is needed to run this test" + (bin_dir / tool).symlink_to(resolved) + + for name in interpreter_names: + stub = bin_dir / name + stub.write_text(f'#!/bin/sh\necho "{self.stub_get_timings}"\n') + stub.chmod(0o755) + + return bin_dir + + def _source_with_path(self, bin_dir: Path) -> subprocess.CompletedProcess[str]: + """Source the template with `bin_dir` as the whole PATH. + + BENCHMARKING_PYTHON is deliberately absent so the template has to + discover an interpreter by name. + """ + app_path = self.tmp_path / "Signaloid-Demo-MyDemo" + app_path.mkdir(exist_ok=True) + bash = shutil.which("bash") + assert bash is not None, "bash is needed to run this test" + return subprocess.run( + [bash, "-c", f'source {_TEMPLATE} && echo "NAME=$APPLICATION_NAME"'], + env={"PATH": str(bin_dir), "APPLICATION_PATH": str(app_path)}, + capture_output=True, + text=True, + check=False, + ) + + def test_python3_on_path_is_used(self) -> None: + result = self._source_with_path(self._make_bin_dir("python3")) + self.assertEqual(result.returncode, 0, f"stderr={result.stderr!r}") + self.assertIn("NAME=MyDemo", result.stdout) + + def test_bare_python_is_used_when_python3_is_absent(self) -> None: + """The case this class exists for: only `python`, and it is Python 3.""" + result = self._source_with_path(self._make_bin_dir("python")) + self.assertEqual(result.returncode, 0, f"stderr={result.stderr!r}") + self.assertIn("NAME=MyDemo", result.stdout) + + def test_no_interpreter_fails_loudly(self) -> None: + """With no interpreter the template must error, not silently no-op.""" + result = self._source_with_path(self._make_bin_dir()) + self.assertNotEqual(result.returncode, 0, "expected a non-zero exit") + self.assertIn("Could not locate get-timings.sh", result.stderr) + self.assertIn("BENCHMARKING_PYTHON", result.stderr) + + if __name__ == "__main__": unittest.main() diff --git a/src/signaloid/benchmarking/benchmark_timing/get_timings_test.py b/src/signaloid/benchmarking/benchmark_timing/get_timings_test.py new file mode 100644 index 0000000..835a1a5 --- /dev/null +++ b/src/signaloid/benchmarking/benchmark_timing/get_timings_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. + +""" +Regression coverage for the build-resources fallback in get-timings.sh. + +``get-timings.sh`` resolves the bundled coreClass build templates from +``BENCHMARKING_RESOURCES_DIR`` when the Python tool exports it, and otherwise +falls back to a path relative to its own location. The Python tool always +exports it, so the fallback only runs for standalone bash use and went +unnoticed while it pointed one directory level too high. These tests pin the +fallback to the same directory ``get_resources_dir()`` returns. +""" + +import re +import unittest + +from pathlib import Path + +from signaloid.benchmarking.config import get_resources_dir + +_SCRIPT = Path(__file__).resolve().parent / "get-timings.sh" + +# Matches the default in `RESOURCES_DIR="${BENCHMARKING_RESOURCES_DIR:-...}"`. +_FALLBACK_PATTERN = re.compile( + r'RESOURCES_DIR="\$\{BENCHMARKING_RESOURCES_DIR:-(?P[^}]+)\}"' +) + + +def _fallback_resources_dir() -> Path: + """Resolve the script's own default for the build-resources directory. + + Returns: + The directory the fallback expands to, with ``$SCRIPT_DIR`` + substituted for the script's location and ``..`` segments collapsed. + """ + match = _FALLBACK_PATTERN.search(_SCRIPT.read_text()) + assert match is not None, "could not find the RESOURCES_DIR fallback" + default = match.group("default") + assert "$SCRIPT_DIR" in default, f"fallback is not script-relative: {default}" + return Path(default.replace("$SCRIPT_DIR", str(_SCRIPT.parent))).resolve() + + +class TestGetTimingsResourcesFallback(unittest.TestCase): + """The standalone fallback must find the bundled build templates.""" + + def test_fallback_directory_exists(self) -> None: + fallback = _fallback_resources_dir() + self.assertTrue( + fallback.is_dir(), + f"fallback build-resources directory does not exist: {fallback}", + ) + + def test_fallback_matches_python_resolver(self) -> None: + """The bash fallback and get_resources_dir() must not diverge.""" + self.assertEqual(_fallback_resources_dir(), get_resources_dir().resolve()) + + def test_fallback_contains_expected_templates(self) -> None: + """Guard the files copy_build_resources() reads from that directory.""" + fallback = _fallback_resources_dir() + for relative_path in ( + "C0/init.S", + "common/startup.cpp", + "C0Pro/Makefile.pro", + "C0/Makefile", + ): + with self.subTest(relative_path=relative_path): + self.assertTrue( + (fallback / relative_path).is_file(), + f"missing build template: {relative_path}", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/signaloid/benchmarking/config.py b/src/signaloid/benchmarking/config.py index 02722cf..cfaf5eb 100644 --- a/src/signaloid/benchmarking/config.py +++ b/src/signaloid/benchmarking/config.py @@ -18,7 +18,6 @@ # 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 @@ -312,34 +311,19 @@ class TimingFormat: MISSING_VALUE = "?" -def get_repo_root() -> Path: +def get_timing_script() -> Path: """ - Find the repository root. + Path to the bundled timing bash script. - Checks the SIGNALOID_PYTHON_DIR env var first, then walks upward from - this file looking for pyproject.toml. + Resolved relative to this package, so it works from both a source checkout + and an installed wheel. The script locates its own dependencies (the + ``signaloid`` import root and the bundled assets) from its own location, so + sourcing it by this path needs no environment setup. Returns: - The repository root directory. - - Raises: - RuntimeError: If the repository root cannot be located. + The ``benchmark_timing/get-timings.sh`` file inside this package. """ - 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." - ) + return Path(__file__).parent / "benchmark_timing" / "get-timings.sh" def get_resources_dir() -> Path: diff --git a/src/signaloid/benchmarking/types.py b/src/signaloid/benchmarking/types.py index 934ef70..77e952b 100644 --- a/src/signaloid/benchmarking/types.py +++ b/src/signaloid/benchmarking/types.py @@ -134,7 +134,7 @@ class TimingMeasurements: pipeline phases. Owned by ``BenchmarkingVariable.timing_measurements``. """ - measurement_dict: dict[str, dict[str, float]] = field(default_factory=dict) + measurement_dict: dict[str, dict[str, float | None]] = field(default_factory=dict) def append( self, @@ -142,7 +142,7 @@ def append( config: str, time: float, e2e_time: float, - pin_dyn_inst_count: float, + pin_dyn_inst_count: float | None = None, db_time: float = 0.0, db_dyn_inst_count: float = 0.0, ) -> None: @@ -153,11 +153,13 @@ def append( 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. + pin_dyn_inst_count: Dynamic instruction count from PIN, or + ``None`` when the run was made without an Intel PIN kit. + Intel PIN is optional and off by default. db_time: Database-access time in seconds. db_dyn_inst_count: Dynamic instruction count for DB access. """ - dictionary: dict[str, float] = {} + dictionary: dict[str, float | None] = {} dictionary["In Application Time"] = time dictionary["Database Time"] = db_time dictionary["End-to-End Time"] = e2e_time diff --git a/src/signaloid/distributional/distributional.py b/src/signaloid/distributional/distributional.py index 97bf0ea..5838cb1 100644 --- a/src/signaloid/distributional/distributional.py +++ b/src/signaloid/distributional/distributional.py @@ -59,7 +59,28 @@ }, } +# Representation type byte, as carried at offset 11 of the Ux Binary layout. +# 0x00 is not a core: it is the "not stated" value, for a value assembled in +# Python rather than read off a core (raw samples, a collapse result). Keeping +# it as the constructor default matters because `TTR_NATIVE_UR_TYPES` below +# makes the byte decide how a value is binned, and defaulting to a real core +# would silently claim a provenance the value does not have. +UR_TYPE_UNSPECIFIED = 0x00 UR_TYPE_ATHENS = 0x04 +UR_TYPE_JUPITER = 0x06 +UR_TYPE_ATLAS = 0x07 + +# Cores whose Dirac deltas are a TTR by construction. Their values should be +# binned by the TTR route even when `check_is_full_valid_TTR` says otherwise: +# curing and zero-mass dropping routinely leave such a value with a +# non-power-of-2 Dirac count, which fails the check without making the value +# any less TTR-shaped. +# +# Every other type — Jupiter, whose Dirac deltas are particles rather than a +# TTR, and `UR_TYPE_UNSPECIFIED` — is left to `check_is_full_valid_TTR` to +# route: the TTR binning when the value does happen to be a valid TTR, the +# non-uniform binning when it does not. +TTR_NATIVE_UR_TYPES: frozenset[int] = frozenset({UR_TYPE_ATHENS, UR_TYPE_ATLAS}) # First byte of the Ux Binary Data format, used to distinguish it from the legacy format UX_BINARY_FORMAT_MARKER = 0xF0 @@ -69,7 +90,7 @@ class DistributionalValue: def __init__( self, particle_value: float | None = None, - UR_type: int = UR_TYPE_ATHENS, + UR_type: int = UR_TYPE_UNSPECIFIED, dirac_deltas: list[DiracDelta] | None = None, double_precision: bool = True, ) -> None: diff --git a/src/signaloid/distributional_distance/binned_wasserstein.py b/src/signaloid/distributional_distance/binned_wasserstein.py index 819a974..1bf34f2 100644 --- a/src/signaloid/distributional_distance/binned_wasserstein.py +++ b/src/signaloid/distributional_distance/binned_wasserstein.py @@ -546,9 +546,6 @@ def binned_wasserstein_1_uxhw_wrapper( # the finite_dirac_deltas access above). plot_interface = PlotData(binned_dist) - boundary_positions, bin_widths, bin_heights = PlotData.create_binning( - binned_dist.finite_dirac_deltas, 0, False - ) if plot_interface.plotting_ttr_order is None: raise ValueError( "PlotData failed to resolve plotting_ttr_order for the input " @@ -556,22 +553,17 @@ def binned_wasserstein_1_uxhw_wrapper( "Wasserstein-1." ) - # Find the TTR of the created binning. This is always a valid TTR. - ttr = PlotData.bin_pdf_to_ttr( - boundary_positions, - bin_widths, - bin_heights, - plot_interface.plotting_ttr_order, - ) - # Rebuild the binning from the valid TTR via the TTR binning method. - bin_boundaries, bin_widths, bin_heights = PlotData.create_binning( - ttr, plot_interface.plotting_ttr_order, True - ) - + # `PlotData` has already selected the binning that suits this input: + # the TTR binning route when the value is a full valid TTR, and the + # non-uniform binning (one bin per Dirac delta, boundaries following the + # Dirac spacing) when it is not. Recomputing the TTR route here + # unconditionally would bin a non-TTR value as though it were a TTR, + # which is exactly what `_construct_plot_data` avoids, and would leave + # the measured distance disagreeing with the plotted distribution. return wasserstein_1_between_distribution_and_samples( - bin_boundaries=list(bin_boundaries), - bin_heights=list(bin_heights), - bin_widths=list(bin_widths), + bin_boundaries=list(plot_interface.positions), + bin_heights=list(plot_interface.masses), + bin_widths=list(plot_interface.widths), sample_positions=list(ground_truth_dist.positions), sample_weights=list(ground_truth_dist.masses), ) diff --git a/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas.py b/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas.py index 33008c3..f3737bb 100644 --- a/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas.py +++ b/src/signaloid/distributional_information_plotting/plot_histogram_dirac_deltas.py @@ -30,7 +30,10 @@ from signaloid.circuitpython.extended_ulab_numpy import np # type: ignore[no-redef] from signaloid.distributional.dirac_delta import DiracDelta -from signaloid.distributional.distributional import DistributionalValue +from signaloid.distributional.distributional import ( + TTR_NATIVE_UR_TYPES, + DistributionalValue, +) class PlotData: @@ -56,6 +59,12 @@ def __init__( # Maximum number of bins for plotting MAX_BINS: int = 1024 + # Closest an internal bin boundary of a non-uniform binning may sit to the Dirac + # delta it is placed against, as a fraction of the interval between that Dirac + # delta and its neighbour. Only keeps the extremal bins from collapsing to zero + # width, so it is deliberately tiny: see `create_non_uniform_binning`. + MIN_INTERIOR_WEIGHT: float = 1e-6 + @classmethod def from_samples( cls, @@ -194,7 +203,8 @@ def _determine_boundary_positions( Args: finite_sorted_dirac_deltas: The input Dirac deltas with finite and - sorted positions. + sorted positions. At least two are required, since the extremal + boundaries are placed relative to an adjacent Dirac delta. exponent: The TTR order, i.e., the base-2 logarithm of the number of Dirac deltas in the TTR. The number of bins in the output binning is twice the number of Dirac deltas in the TTR. @@ -202,9 +212,17 @@ def _determine_boundary_positions( Returns: (boundary_positions, boundary_probabilities): The internal boundary positions and boundary probabilities that are intermediaries to get a binning. + Raises: + ValueError: When given fewer than two Dirac deltas. """ number_of_finite_dirac_deltas = len(finite_sorted_dirac_deltas) + if number_of_finite_dirac_deltas < 2: + raise ValueError( + "plot_histogram_dirac_deltas: _determine_boundary_positions requires " + f"at least two Dirac deltas, got {number_of_finite_dirac_deltas}" + ) + number_of_boundaries = 2 * number_of_finite_dirac_deltas + 1 boundary_positions = np.array([np.nan] * number_of_boundaries) boundary_probabilities = np.array([np.nan] * number_of_boundaries) @@ -433,7 +451,7 @@ def create_binning( Args: finite_sorted_dirac_deltas: The input Dirac deltas with finite - and sorted positions. + and sorted positions. At least two are required. exponent: The TTR order, i.e., the base-2 logarithm of the number of Dirac deltas in the TTR. The number of bins in the output binning is twice the number of Dirac deltas in the TTR. @@ -441,8 +459,20 @@ def create_binning( Returns: (boundary_positions, bin_widths, bin_heights): The boundary positions, bin widths, and bin heights that describe the output binning. + Raises: + ValueError: When given fewer than two Dirac deltas. Both bins that + surround a lone Dirac delta would be bounded on the outside by a + boundary placed relative to a Dirac delta that does not exist, so + the binning has no widths. Callers plot a lone Dirac delta as a + Dirac delta instead, which is what `_construct_plot_data` does. """ + if len(finite_sorted_dirac_deltas) < 2: + raise ValueError( + "plot_histogram_dirac_deltas: create_binning requires at least two " + f"Dirac deltas, got {len(finite_sorted_dirac_deltas)}" + ) + ( boundary_positions, boundary_probabilities, @@ -456,6 +486,212 @@ def create_binning( return (boundary_positions, bin_widths, bin_heights) + @staticmethod + def _cell_boundary_positions( + positions: np.ndarray, interior_weight: float + ) -> np.ndarray: + """ + Places one bin boundary between each pair of adjacent Dirac deltas, at the + fraction `interior_weight` of the way from the left to the right Dirac delta. + The two extremal boundaries are mirror images of their inner neighbours + about the extremal Dirac deltas, which is the (d/dx) = 0 boundary condition. + + Args: + positions: Strictly increasing Dirac delta positions. At least two are + required, since every boundary is placed relative to an adjacent + Dirac delta. + interior_weight: Position of an internal boundary within the interval + between two adjacent Dirac deltas. `0.5` places it at the midpoint. + Returns: + boundary_positions: The (N + 1) boundary positions of the N cells. + Raises: + ValueError: When given fewer than two positions. + """ + + number_of_cells = len(positions) + if number_of_cells < 2: + raise ValueError( + "plot_histogram_dirac_deltas: _cell_boundary_positions requires at " + f"least two positions, got {number_of_cells}" + ) + + boundary_positions = np.array([np.nan] * (number_of_cells + 1)) + boundary_positions[1:-1] = (1 - interior_weight) * positions[ + :-1 + ] + interior_weight * positions[1:] + boundary_positions[0] = 2 * positions[0] - boundary_positions[1] + boundary_positions[-1] = 2 * positions[-1] - boundary_positions[-2] + + return boundary_positions + + @staticmethod + def _local_mean_boundary_positions(positions: np.ndarray) -> np.ndarray | None: + """ + Places the bin boundaries so that every bin is centred on the Dirac delta it + holds, which preserves each Dirac delta's own mean and not just the mean of + the whole distribution. This is the property the TTR binning method has, and + the property `create_binning` gets from using two bins per Dirac delta. + + Centring bin `i` on Dirac delta `i` means (b[i] + b[i + 1]) / 2 == p[i], so + the boundaries follow the recurrence b[i + 1] = 2 * p[i] - b[i]: the whole + binning is determined by the first boundary alone. Writing e[i] for the + distance p[i] - b[i], the width of bin `i` is 2 * e[i] and the recurrence is + e[i + 1] = g[i] - e[i], for gaps g[i] = p[i + 1] - p[i]. Each e[i] is + therefore affine in e[0] with an alternating sign, so requiring every + boundary to stay strictly between the two Dirac deltas it separates, + 0 < e[i] < g[i], is a set of linear constraints on e[0]. + + Those constraints have no solution for arbitrary Dirac delta spacing, since + the recurrence is undamped and alternating gaps drive e[i] out of its + interval. Where there is a solution, this takes the midpoint of it, which is + the choice furthest from every constraint and so has the widest bins. The + constraints are solved in exact arithmetic, so a feasible interval that is + narrow enough can still yield boundaries that rounding pushes onto or past a + Dirac delta. The constructed boundaries are therefore re-checked and also + rejected in that case. + + Args: + positions: Strictly increasing Dirac delta positions, at least two. + Returns: + boundary_positions: The (N + 1) boundary positions of the N Dirac deltas, or + `None` when no first boundary exists that solve the constraints + or when rounding pushes a bin boundary beyond the next Dirac delta. + """ + + gaps = positions[1:] - positions[:-1] + + # Intersect the constraints 0 < e[i] < g[i] to bound e[0]. + lowest, highest = 0.0, float(np.inf) + constant, sign = 0.0, 1.0 + for gap in gaps: + if sign > 0: + lowest = max(lowest, -constant) + highest = min(highest, float(gap) - constant) + else: + lowest = max(lowest, constant - float(gap)) + highest = min(highest, constant) + constant, sign = float(gap) - constant, -sign + + if not lowest < highest: + return None + + boundary_positions = np.array([np.nan] * (len(positions) + 1)) + distance_to_dirac_delta = (lowest + highest) / 2 + boundary_positions[0] = positions[0] - distance_to_dirac_delta + for i, position in enumerate(positions): + boundary_positions[i + 1] = position + distance_to_dirac_delta + if i + 1 < len(positions): + distance_to_dirac_delta = ( + positions[i + 1] - position - distance_to_dirac_delta + ) + + # The constraints are solved in exact arithmetic, so re-check the result + # rather than trust that rounding kept every bin valid and populated. + if not ( + bool(np.all(boundary_positions[1:] > boundary_positions[:-1])) + and bool(np.all(positions > boundary_positions[:-1])) + and bool(np.all(positions < boundary_positions[1:])) + ): + return None + + return boundary_positions + + @staticmethod + def create_non_uniform_binning( + finite_sorted_dirac_deltas: list[DiracDelta], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Creates a binning with one bin per Dirac delta, where the bin holding a + Dirac delta carries exactly that Dirac delta's mass and the internal bin + boundaries lie between adjacent Dirac deltas. The bins therefore have + non-uniform widths that follow the spacing of the Dirac deltas, and there + are no empty bins between Dirac deltas, i.e., the binning reads the input + as a continuous distribution with no gaps in its support. + + The total mass of the binning always equals that of the input. Where the + Dirac delta spacing allows it, every bin is also centred on the Dirac delta + it holds, so each Dirac delta's own mean is preserved and not merely the + mean of the whole distribution, which is the property the TTR binning method + has. That is not solvable for every spacing (see + `_local_mean_boundary_positions`), and where it is not, the boundaries fall + back to a single placement fraction shared by all of them, solved to match + the mean of the whole distribution. That mean is then exact, unless matching + it would collapse an extremal bin to zero width and the placement is + therefore clamped to `MIN_INTERIOR_WEIGHT`. The mean is then approximate, + but never further off than placing every boundary at a midpoint would leave + it. + + Args: + finite_sorted_dirac_deltas: The input Dirac deltas with finite, + strictly increasing positions. At least two are required. + Returns: + (boundary_positions, bin_widths, bin_heights): The boundary positions, + bin widths, and bin heights that describe the output binning. + Raises: + ValueError: When given fewer than two Dirac deltas. A lone Dirac delta + has no adjacent Dirac delta to place a bin boundary against, so it + has no bin width. Callers plot it as a Dirac delta instead, which is + what `_construct_plot_data` does. + """ + + if len(finite_sorted_dirac_deltas) < 2: + raise ValueError( + "plot_histogram_dirac_deltas: create_non_uniform_binning requires " + f"at least two Dirac deltas, got {len(finite_sorted_dirac_deltas)}" + ) + + positions = np.array([dd.position for dd in finite_sorted_dirac_deltas]) + masses = np.array([dd.mass for dd in finite_sorted_dirac_deltas]) + + # Preserving every Dirac delta's own mean also preserves the mean of the + # whole distribution, so prefer it wherever the spacing admits it. + boundary_positions = PlotData._local_mean_boundary_positions(positions) + if boundary_positions is not None: + bin_widths = boundary_positions[1:] - boundary_positions[:-1] + return (boundary_positions, bin_widths, masses / bin_widths) + + target_mean = float(np.sum(positions * masses)) + + def mean_of(interior_weight: float) -> float: + boundaries = PlotData._cell_boundary_positions(positions, interior_weight) + return float(np.sum(masses * (boundaries[:-1] + boundaries[1:]) / 2)) + + # The mean of the binning is affine in `interior_weight`, so two + # evaluations determine the value that reproduces the input mean. The mean + # can also be independent of `interior_weight` (e.g., for two Dirac + # deltas, where the mirrored boundaries centre both cells on their Dirac + # delta for any placement), in which case any placement is mean-exact. + mean_at_zero = mean_of(0.0) + mean_at_one = mean_of(1.0) + if abs(mean_at_one - mean_at_zero) < 1e-15: + interior_weight = 0.5 + else: + interior_weight = (target_mean - mean_at_zero) / ( + mean_at_one - mean_at_zero + ) + + # Keep the placement off the ends of [0, 1], where an extremal bin would + # collapse to zero width: the width of an internal bin is a convex + # combination of the two gaps adjacent to its Dirac delta, so it stays + # positive throughout, but the width of an extremal bin is twice the weight + # times the extremal gap. The bound is only there to keep the widths + # positive, so it is as small as it can be: clamping is what makes the mean + # of the binning approximate, and a placement near an end is the correct + # answer for a value whose extremal Dirac deltas are much closer together + # than the rest, where the density really is that much higher there. + interior_weight = min( + max(interior_weight, PlotData.MIN_INTERIOR_WEIGHT), + 1 - PlotData.MIN_INTERIOR_WEIGHT, + ) + + boundary_positions = PlotData._cell_boundary_positions( + positions, interior_weight + ) + bin_widths = boundary_positions[1:] - boundary_positions[:-1] + bin_heights = masses / bin_widths + + return (boundary_positions, bin_widths, bin_heights) + @staticmethod def _bin_pdf_expected_dirac_delta( boundary_positions: np.ndarray, bin_widths: np.ndarray, bin_heights: np.ndarray @@ -605,7 +841,24 @@ def _construct_plot_data(self) -> None: "plot_histogram_dirac_deltas: plotting_resolution must be a power of 2!" ) - if self.dist.check_is_full_valid_TTR(): + # Athens / Atlas values come off a TTR core, so bin them by the TTR + # route regardless of what `check_is_full_valid_TTR` reports. That check + # fails for reasons that say nothing about whether the value is + # TTR-shaped — `drop_zero_mass_positions` and `cure` leave a + # non-power-of-2 Dirac count, or the reconstructed boundaries tie rather + # than strictly increase — and demoting those values to the non-uniform + # binning measures them against a support model their core never used. + # The route is safe on an invalid TTR because `bin_pdf_to_ttr` below + # projects whatever it is handed onto a valid TTR before the TTR binning + # method sees it; the `except` still catches the cases it cannot. + # Jupiter's Dirac deltas are particles, not a TTR, so it keeps the + # valid-TTR gate and otherwise falls through to the non-uniform binning. + use_ttr_route = ( + self.dist.UR_type in TTR_NATIVE_UR_TYPES + or self.dist.check_is_full_valid_TTR() + ) + + if use_ttr_route: try: # Create the binning such that the average of two bins surrounding a Dirac delta # is the Dirac delta itself. @@ -632,21 +885,34 @@ def _construct_plot_data(self) -> None: except (ValueError, TypeError): pass - # Non-TTR input (or TTR pipeline failed): use a uniform-width histogram - # at `plotting_resolution`. The TTR binning method assumes the input - # forms a valid TTR, so applying it to arbitrary Dirac deltas (e.g. raw - # samples) produces meaningless boundaries. - positions = np.array([dd.position for dd in finite_dirac_deltas]) - masses = np.array([dd.mass for dd in finite_dirac_deltas]) - pos_min = float(positions[0]) - pos_max = float(positions[-1]) - edges = np.linspace(pos_min, pos_max, self.plotting_resolution + 1) - hist, _ = np.histogram(positions, bins=edges, weights=masses) - bin_widths = edges[1:] - edges[:-1] - self.positions = edges - self.masses = np.divide( - hist, - bin_widths, - out=np.zeros_like(hist, dtype=np.float64), - where=bin_widths != 0, + # For non-TTR input, use the non-uniform binning instead, which places one bin per Dirac delta with boundaries + # following the spacing of the Dirac deltas. This reads the input as a + # continuous distribution with no gaps in its support, whereas a + # uniform-width histogram leaves empty bins wherever the Dirac deltas are + # sparser than the bin width. + # A binning needs two bins, so it needs two Dirac deltas. The order is only + # 0 when the caller asked for a `plotting_resolution` of 2. + reduction_order = max(self.plotting_ttr_order, 1) + if len(finite_dirac_deltas) > 2**reduction_order: + # More Dirac deltas than the plot can resolve. Reduce them to + # (2 ** `reduction_order`) of them, which is the same number of Dirac + # deltas the valid-TTR path represents the value with, via the same two + # steps that path uses: bin the Dirac deltas without assuming a valid + # TTR, then take the TTR of that binning. Both steps preserve the total + # mass and the mean. + boundary_positions, bin_widths, bin_heights = PlotData.create_binning( + finite_dirac_deltas, 0, False + ) + finite_dirac_deltas = PlotData.bin_pdf_to_ttr( + boundary_positions, + bin_widths, + bin_heights, + reduction_order, + ) + + boundary_positions, bin_widths, bin_heights = ( + PlotData.create_non_uniform_binning(finite_dirac_deltas) ) + + self.positions = boundary_positions + self.masses = bin_heights 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 47164f2..5b41ae9 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 @@ -161,6 +161,403 @@ def test_create_binning_property_preserve_ttr(self) -> None: ) +class TestCreateBinningGuards(unittest.TestCase): + """Covers rejection of input that `PlotData.create_binning()` cannot bin.""" + + def test_create_binning_rejects_fewer_than_two_dirac_deltas(self) -> None: + """A lone Dirac delta leaves both extremal boundaries to be placed relative + to a Dirac delta that does not exist. That must fail loudly rather than + return `NaN`-valued boundaries, widths, and heights.""" + for input_ensemble in ([], [DiracDelta(2.5, mass=1.0)]): + for use_ttr_binning in (False, True): + with self.subTest( + number_of_dirac_deltas=len(input_ensemble), + use_ttr_binning=use_ttr_binning, + ): + with self.assertRaises(ValueError) as raised: + PlotData.create_binning(input_ensemble, 0, use_ttr_binning) + self.assertIn("at least two Dirac deltas", str(raised.exception)) + + with self.assertRaises(ValueError): + PlotData._determine_boundary_positions([DiracDelta(2.5, mass=1.0)], 0, True) + + def test_smallest_plotting_resolution_bins_a_valid_ttr(self) -> None: + """A `plotting_resolution` of 2 puts `plotting_ttr_order` at 0, which would + reduce a value to a single Dirac delta. The binning must still come out + finite, with the requested number of bins and unit total mass.""" + positions = np.array([-1.5, -0.5, 0.5, 1.5]) + masses = np.full(4, 0.25) + dirac_deltas = [ + DiracDelta(position, mass=mass) for position, mass in zip(positions, masses) + ] + + for plotting_resolution in (2, 4, 8): + with self.subTest(plotting_resolution=plotting_resolution): + dist = DistributionalValue( + dirac_deltas=[ + DiracDelta(dd.position, mass=dd.mass) for dd in dirac_deltas + ] + ) + pd = PlotData(dist, plotting_resolution=plotting_resolution) + + self.assertEqual(len(pd.masses), plotting_resolution) + self.assertFalse(bool(np.any(np.isnan(pd.positions)))) + self.assertFalse(bool(np.any(np.isnan(pd.masses)))) + + widths = pd.positions[1:] - pd.positions[:-1] + self.assertTrue(np.all(widths > 0)) + self.assertAlmostEqual(float(np.sum(widths * pd.masses)), 1.0, places=9) + + +class TestCreateNonUniformBinning(unittest.TestCase): + """Covers `PlotData.create_non_uniform_binning()`, the binning used for input + that does not form a full valid TTR.""" + + @staticmethod + def _random_ensembles( + mass_distribution: str, number_of_testcases: int = 500, seed: int = 20260731 + ) -> list[list[DiracDelta]]: + """Random ensembles of Dirac deltas with strictly increasing positions. The + generator is seeded, so a failure here is reproducible rather than a flake.""" + rng = np.random.default_rng(seed) + ensembles: list[list[DiracDelta]] = [] + + for _ in range(number_of_testcases): + number_of_dirac_deltas = int(rng.integers(2, 400 + 1)) + positions = np.unique( + rng.uniform( + rng.uniform(-100, 0), + rng.uniform(0, 100), + number_of_dirac_deltas, + ) + ) + if len(positions) < 2: + continue + + masses: np.ndarray + if mass_distribution == "equal": + masses = np.full(len(positions), 1.0) + elif mass_distribution == "uniform": + masses = rng.uniform(0, 1, len(positions)) + else: + # Heavily skewed masses, i.e., a few Dirac deltas hold almost all + # of the probability mass. + masses = rng.exponential(1.0, len(positions)) ** 3 + masses /= sum(masses) + + ensembles.append( + [ + DiracDelta(position, mass=mass) + for position, mass in zip(positions, masses) + ] + ) + + return ensembles + + def test_create_non_uniform_binning_property_one_bin_per_dirac_delta(self) -> None: + """ + Tests that the binning created by `PlotData.create_non_uniform_binning()` + holds exactly one bin per input Dirac delta, that the bin boundaries are + strictly increasing (so no bin is empty or inverted), that each Dirac delta + lies strictly inside its own bin, and that each bin carries exactly the + mass of the Dirac delta it holds. + """ + probability_threshold: float = 1e-15 + + for mass_distribution in ("equal", "uniform", "skewed"): + for input_ensemble in self._random_ensembles(mass_distribution): + positions = np.array([dd.position for dd in input_ensemble]) + masses = np.array([dd.mass for dd in input_ensemble]) + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + self.assertEqual(len(bin_widths), len(input_ensemble)) + self.assertEqual(len(boundary_positions), len(input_ensemble) + 1) + self.assertTrue( + np.all(boundary_positions[1:] > boundary_positions[:-1]) + ) + self.assertTrue(np.all(positions > boundary_positions[:-1])) + self.assertTrue(np.all(positions < boundary_positions[1:])) + self.assertTrue( + np.all( + np.abs(bin_widths * bin_heights - masses) + < probability_threshold + ) + ) + + # The boundary placement clamp in `PlotData.create_non_uniform_binning()`. + INTERIOR_WEIGHT_CLAMP: float = PlotData.MIN_INTERIOR_WEIGHT + + @staticmethod + def _mean_of_binning(boundary_positions: np.ndarray, masses: np.ndarray) -> float: + """Mean of the binning that holds `masses`, one mass per bin.""" + bin_centres = (boundary_positions[1:] + boundary_positions[:-1]) / 2 + return float(np.sum(masses * bin_centres)) + + @classmethod + def _solved_interior_weight( + cls, positions: np.ndarray, masses: np.ndarray + ) -> float: + """The unclamped boundary placement that makes the mean of the binning equal + the mean of the input, recomputed here so the test can tell whether + `PlotData.create_non_uniform_binning()` had to clamp. Recovering it from the + returned boundaries instead would compare a rounded quotient against the + clamp bound, which is not reliable at the bound itself.""" + target_mean = float(np.sum(positions * masses)) + mean_at_zero = cls._mean_of_binning( + PlotData._cell_boundary_positions(positions, 0.0), masses + ) + mean_at_one = cls._mean_of_binning( + PlotData._cell_boundary_positions(positions, 1.0), masses + ) + if abs(mean_at_one - mean_at_zero) < 1e-15: + return 0.5 + return (target_mean - mean_at_zero) / (mean_at_one - mean_at_zero) + + def test_create_non_uniform_binning_property_preserves_mean(self) -> None: + """ + Tests that the binning created by `PlotData.create_non_uniform_binning()` + has the same total mass and the same mean as the input Dirac deltas. + + Unevenly spaced Dirac deltas can need a boundary placement very close to one + of the two Dirac deltas the boundary separates. For a 3-Dirac-delta input, + for example, the mirrored extremal boundaries centre the outer two bins on + their Dirac deltas, which forces the placement to (p1 - p0) / (p2 - p0) + regardless of the masses. `MIN_INTERIOR_WEIGHT` therefore only has to keep + an extremal bin from collapsing to zero width, so the mean should come out + exact here. The property tested still allows for the clamp, requiring the + mean to be no worse than a plain midpoint placement when it does bind. + """ + probability_threshold: float = 1e-12 + position_threshold: float = 1e-9 + clamped_ensembles: int = 0 + total_ensembles: int = 0 + + for mass_distribution in ("equal", "uniform", "skewed"): + for input_ensemble in self._random_ensembles(mass_distribution): + positions = np.array([dd.position for dd in input_ensemble]) + masses = np.array([dd.mass for dd in input_ensemble]) + input_mean = float(np.sum(positions * masses)) + total_ensembles += 1 + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + probabilities = bin_widths * bin_heights + binning_mean = self._mean_of_binning(boundary_positions, masses) + self.assertLess( + abs(float(np.sum(probabilities)) - 1.0), probability_threshold + ) + + interior_weight = self._solved_interior_weight(positions, masses) + if ( + self.INTERIOR_WEIGHT_CLAMP + <= interior_weight + <= 1 - self.INTERIOR_WEIGHT_CLAMP + ): + self.assertLess( + abs(binning_mean - input_mean), + position_threshold * max(abs(input_mean), 1.0), + ) + continue + + clamped_ensembles += 1 + midpoint_mean = self._mean_of_binning( + PlotData._cell_boundary_positions(positions, 0.5), masses + ) + self.assertLessEqual( + abs(binning_mean - input_mean), + abs(midpoint_mean - input_mean) + + position_threshold * max(abs(input_mean), 1.0), + ) + + # Clamping should stay rare. If it becomes common, the placement solve has + # regressed rather than the inputs having become pathological. + self.assertLess(clamped_ensembles, total_ensembles // 100 + 1) + + def test_create_non_uniform_binning_preserves_local_means(self) -> None: + """Where the Dirac delta spacing admits it, every bin is centred on the + Dirac delta it holds, so the binning preserves each Dirac delta's own mean + and not just the mean of the whole distribution. This is what the TTR + binning method preserves, and what a single shared boundary placement + cannot: one scalar can only match one moment of the whole distribution.""" + rng = np.random.default_rng(3) + smoothly_spaced_positions = { + "uniform spacing": np.linspace(-5.0, 5.0, 64), + "geometric spacing": np.exp(np.linspace(-2.0, 2.5, 32)), + "quadratic spacing": np.linspace(0.0, 4.0, 24) ** 2, + "two Dirac deltas": np.array([0.0, 1.0]), + } + + for label, positions in smoothly_spaced_positions.items(): + with self.subTest(spacing=label): + masses = rng.uniform(0.5, 1.5, len(positions)) + masses /= masses.sum() + input_ensemble = [ + DiracDelta(position, mass=mass) + for position, mass in zip(positions, masses) + ] + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + bin_centres = (boundary_positions[1:] + boundary_positions[:-1]) / 2 + scale = float(positions[-1] - positions[0]) + self.assertLess( + float(np.max(np.abs(bin_centres - positions))), 1e-12 * scale + ) + self.assertTrue(np.all(bin_widths > 0)) + self.assertTrue( + np.all(np.abs(bin_widths * bin_heights - masses) < 1e-15) + ) + self.assertAlmostEqual( + float(np.sum(bin_widths * bin_heights)), 1.0, places=12 + ) + + def test_local_mean_boundary_positions_reports_when_unsolvable(self) -> None: + """The recurrence that centres every bin on its Dirac delta is undamped, so + alternating gaps drive a boundary out from between the two Dirac deltas it + separates. There is then no solution, which must be reported rather than + returned as an invalid binning; the caller falls back to a single shared + boundary placement, which still preserves mass and the mean.""" + positions = np.array([0.0, 10.0, 10.5, 20.5, 21.0, 31.0, 31.5]) + self.assertIsNone(PlotData._local_mean_boundary_positions(positions)) + + masses = np.full(len(positions), 1.0 / len(positions)) + input_ensemble = [ + DiracDelta(position, mass=mass) for position, mass in zip(positions, masses) + ] + input_mean = float(np.sum(positions * masses)) + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + self.assertTrue(np.all(bin_widths > 0)) + self.assertAlmostEqual(float(np.sum(bin_widths * bin_heights)), 1.0, places=12) + self.assertLess( + abs(self._mean_of_binning(boundary_positions, masses) - input_mean), + 1e-9 * max(abs(input_mean), 1.0), + ) + + def test_local_mean_boundary_positions_centres_every_bin(self) -> None: + """The solved boundaries must centre each bin on its Dirac delta and stay + strictly between the Dirac deltas they separate.""" + positions = np.array([0.0, 1.0, 2.5, 4.0, 4.5]) + + boundary_positions = PlotData._local_mean_boundary_positions(positions) + + self.assertIsNotNone(boundary_positions) + assert boundary_positions is not None + bin_centres = (boundary_positions[1:] + boundary_positions[:-1]) / 2 + self.assertTrue(np.allclose(bin_centres, positions, atol=1e-12)) + self.assertTrue(np.all(boundary_positions[1:] > boundary_positions[:-1])) + self.assertTrue(np.all(positions > boundary_positions[:-1])) + self.assertTrue(np.all(positions < boundary_positions[1:])) + + def test_create_non_uniform_binning_preserves_mean_when_dirac_deltas_crowd( + self, + ) -> None: + """An interior Dirac delta very close to one neighbour needs boundaries very + close to the interval ends. A single shared placement solved for the whole + distribution's mean puts it at ~0.0034 here, which a large clamp would block + and perturb the mean by percent. Centring every bin on its own Dirac delta + is solvable for this spacing, so both the local means and the mean of the + whole distribution must come out exact.""" + positions = np.array([-38.75449121, -38.44464168, 51.59049523]) + masses = np.full(3, 1.0 / 3.0) + input_ensemble = [ + DiracDelta(position, mass=mass) for position, mass in zip(positions, masses) + ] + input_mean = float(np.sum(positions * masses)) + + # A single shared placement would have to sit here, well inside the clamp + # that used to block it. + interior_weight = self._solved_interior_weight(positions, masses) + self.assertLess(interior_weight, 0.01) + self.assertGreater(interior_weight, PlotData.MIN_INTERIOR_WEIGHT) + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + self.assertTrue(np.all(bin_widths > 0)) + self.assertAlmostEqual(float(np.sum(bin_widths * bin_heights)), 1.0, places=12) + # The mean must be exact, not merely close: a clamp that blocked this + # placement left it 1.2% out. + self.assertLess( + abs(self._mean_of_binning(boundary_positions, masses) - input_mean), + 1e-12 * abs(input_mean), + ) + bin_centres = (boundary_positions[1:] + boundary_positions[:-1]) / 2 + self.assertTrue(np.allclose(bin_centres, positions, atol=1e-12)) + + def test_create_non_uniform_binning_rejects_fewer_than_two_dirac_deltas( + self, + ) -> None: + """A lone Dirac delta has no adjacent Dirac delta to place a boundary + against, so it has no bin width. That must fail loudly rather than return + `NaN`-valued boundaries, widths, and heights.""" + for input_ensemble in ([], [DiracDelta(2.5, mass=1.0)]): + with self.subTest(number_of_dirac_deltas=len(input_ensemble)): + with self.assertRaises(ValueError) as raised: + PlotData.create_non_uniform_binning(input_ensemble) + self.assertIn("at least two Dirac deltas", str(raised.exception)) + + for positions in (np.array([]), np.array([2.5])): + with self.subTest(number_of_positions=len(positions)): + with self.assertRaises(ValueError) as raised: + PlotData._cell_boundary_positions(positions, 0.5) + self.assertIn("at least two positions", str(raised.exception)) + + def test_single_dirac_delta_value_plots_without_binning(self) -> None: + """`PlotData` must not route a single-Dirac-delta value into any binning: + it plots the Dirac delta itself, with no `NaN` in the output.""" + dist = DistributionalValue(dirac_deltas=[DiracDelta(2.5, mass=1.0)]) + + pd = PlotData(dist) + + self.assertEqual(len(pd.positions), 1) + self.assertEqual(len(pd.masses), 1) + self.assertFalse(bool(np.any(np.isnan(pd.positions)))) + self.assertFalse(bool(np.any(np.isnan(pd.masses)))) + self.assertAlmostEqual(float(pd.positions[0]), 2.5, places=12) + + def test_create_non_uniform_binning_two_dirac_deltas(self) -> None: + """With two Dirac deltas the mirrored extremal boundaries centre each bin + on its Dirac delta, so the mean is exact for any boundary placement.""" + input_ensemble = [DiracDelta(0.0, mass=0.4), DiracDelta(1.0, mass=0.6)] + + ( + boundary_positions, + bin_widths, + bin_heights, + ) = PlotData.create_non_uniform_binning(input_ensemble) + + probabilities = bin_widths * bin_heights + bin_centres = (boundary_positions[1:] + boundary_positions[:-1]) / 2 + + self.assertEqual(len(bin_widths), 2) + self.assertAlmostEqual(float(np.sum(probabilities)), 1.0, places=12) + self.assertAlmostEqual( + float(np.sum(probabilities * bin_centres)), 0.6, places=12 + ) + + class TestPlotDataFromSamples(unittest.TestCase): """Tests for PlotData.from_samples() (delegates to DistributionalValue).""" @@ -285,19 +682,43 @@ def test_plot_from_samples_with_special_values_succeeds(self) -> None: self.assertTrue(result) -class TestNonTTRHistogramFallback(unittest.TestCase): - """Covers the uniform-width histogram fallback used when - `is_full_valid_TTR` is False or the TTR pipeline raises.""" +class TestNonTTRBinningFallback(unittest.TestCase): + """Covers the non-uniform binning fallback used when `is_full_valid_TTR` + is False or the TTR pipeline raises.""" @staticmethod def _fixture_path(name: str) -> str: here = os.path.realpath(os.path.dirname(__file__)) return os.path.join(here, name) - def test_invalid_ttr_ux_string_falls_back_to_uniform_histogram(self) -> None: + def _assert_gap_free_density(self, pd: PlotData, dist: DistributionalValue) -> None: + """The fallback binning has one bin per Dirac delta, holds no empty bins, + and preserves the total mass and the mean of the input exactly. Call this + after constructing the `PlotData`, which cures the `DistributionalValue`.""" + self.assertIsNotNone(pd.plotting_resolution) + assert pd.plotting_resolution is not None + assert pd.plotting_ttr_order is not None + expected_number_of_bins = min( + len(dist.finite_dirac_deltas), 2**pd.plotting_ttr_order + ) + self.assertEqual(len(pd.positions), expected_number_of_bins + 1) + self.assertEqual(len(pd.masses), expected_number_of_bins) + + widths = pd.positions[1:] - pd.positions[:-1] + self.assertTrue(np.all(widths > 0)) + self.assertTrue(np.all(pd.masses > 0)) + + total_area = float(np.sum(widths * pd.masses)) + self.assertAlmostEqual(total_area, 1.0, places=9) + + bin_centres = (pd.positions[1:] + pd.positions[:-1]) / 2 + binning_mean = float(np.sum(widths * pd.masses * bin_centres)) + assert dist.mean is not None + self.assertAlmostEqual(binning_mean, dist.mean, places=9) + + def test_invalid_ttr_ux_string_falls_back_to_non_uniform_binning(self) -> None: """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.""" + should produce a gap-free, non-uniform-width binning.""" with open(self._fixture_path("invalid_ttr_ux_string.dat")) as f: ux_data = f.read().strip() dist = DistributionalValue.parse(ux_data) @@ -307,17 +728,12 @@ def test_invalid_ttr_ux_string_falls_back_to_uniform_histogram(self) -> None: pd = PlotData(dist) - self.assertIsNotNone(pd.plotting_resolution) - assert pd.plotting_resolution is not None - self.assertEqual(len(pd.positions), pd.plotting_resolution + 1) - self.assertEqual(len(pd.masses), pd.plotting_resolution) + self._assert_gap_free_density(pd, dist) + # The widths must genuinely vary. A uniform-width histogram is what this + # binning replaces. widths = pd.positions[1:] - pd.positions[:-1] - self.assertTrue(np.allclose(widths, widths[0])) - - # Density should integrate to ~1 over the finite-mass region. - total_area = float(np.sum(widths * pd.masses)) - self.assertAlmostEqual(total_area, 1.0, places=6) + self.assertFalse(np.allclose(widths, widths[0])) def test_clustered_repeated_positions_fallback_succeeds(self) -> None: """Heavy duplication / tight clusters must not crash the fallback. @@ -333,15 +749,38 @@ def test_clustered_repeated_positions_fallback_succeeds(self) -> None: ) pd = PlotData.from_samples(samples) - self.assertIsNotNone(pd.plotting_resolution) + self._assert_gap_free_density(pd, pd.dist) + + def test_raw_samples_are_reduced_to_plotting_ttr_order(self) -> None: + """With more Dirac deltas than the plot can resolve, the fallback reduces + them to the same number of Dirac deltas the valid-TTR path uses, and + still holds the invariants.""" + rng = np.random.default_rng(0) + pd = PlotData.from_samples(rng.normal(3.0, 1.5, 5000)) + + assert pd.plotting_ttr_order is not None + self.assertEqual(len(pd.masses), 2**pd.plotting_ttr_order) + self._assert_gap_free_density(pd, pd.dist) + + def test_non_uniform_binning_leaves_no_gaps_where_uniform_would(self) -> None: + """exp() applied to the positions of a valid normal TTR is not a valid + TTR. Its Dirac deltas span a wide range, so a uniform-width histogram + would leave most bins empty. The non-uniform binning leaves none.""" + positions = np.exp(np.linspace(-2.0, 2.5, 16)) + masses = np.full(16, 1.0 / 16) + dist = DistributionalValue.from_weighted_samples(positions, masses) + self.assertFalse(dist.is_full_valid_TTR) + + pd = PlotData(dist) + assert pd.plotting_resolution is not None - self.assertEqual(len(pd.positions), pd.plotting_resolution + 1) - self.assertEqual(len(pd.masses), pd.plotting_resolution) + uniform_edges = np.linspace( + float(positions[0]), float(positions[-1]), pd.plotting_resolution + 1 + ) + uniform_counts, _ = np.histogram(positions, bins=uniform_edges) + self.assertGreater(int(np.sum(uniform_counts == 0)), 0) - widths = pd.positions[1:] - pd.positions[:-1] - self.assertTrue(np.allclose(widths, widths[0])) - total_area = float(np.sum(widths * pd.masses)) - self.assertAlmostEqual(total_area, 1.0, places=6) + self._assert_gap_free_density(pd, dist) def dirac_deltas_to_ttr( diff --git a/src/signaloid/distributional_information_plotting/sample_generator.py b/src/signaloid/distributional_information_plotting/sample_generator.py index 895f372..edbcac9 100644 --- a/src/signaloid/distributional_information_plotting/sample_generator.py +++ b/src/signaloid/distributional_information_plotting/sample_generator.py @@ -144,8 +144,23 @@ def _sample_finite( """Sample from the finite part of a distributional value via inverse CDF.""" distributional_value.drop_zero_mass_positions() distributional_value.combine_dirac_deltas() + finite_deltas = distributional_value.finite_dirac_deltas + + if len(finite_deltas) == 0: + raise ValueError( + "sample_generator: the finite part of the distributional value carries " + "no mass, so there is nothing to sample from." + ) + + # Dropping zero-mass Dirac deltas and combining coincident ones can leave a + # single Dirac delta even when the input had many, e.g. when every Dirac + # delta shares one position. A lone Dirac delta has no binning to invert, so + # return identical copies of its position, as for a particle distribution. + if len(finite_deltas) == 1: + return np.full(n_samples, finite_deltas[0].position) + boundary_positions, bin_widths, bin_heights = PlotData.create_binning( - distributional_value.finite_dirac_deltas, 0, False + finite_deltas, 0, False ) cdf_values = generate_cdf_values(boundary_positions, bin_widths, bin_heights) return generate_samples(cdf_values, boundary_positions, n_samples) diff --git a/src/signaloid/distributional_information_plotting/sample_generator_test.py b/src/signaloid/distributional_information_plotting/sample_generator_test.py index b649489..3474817 100644 --- a/src/signaloid/distributional_information_plotting/sample_generator_test.py +++ b/src/signaloid/distributional_information_plotting/sample_generator_test.py @@ -22,13 +22,18 @@ import unittest import numpy as np +from signaloid.distributional.dirac_delta import DiracDelta from signaloid.distributional.distributional import DistributionalValue from signaloid.distributional_information_plotting.sample_generator import ( + sample_from_distributional_value, sample_generator, ) SAMPLE_UX_STRING = "-0.000000Ux040000000000000001BCB03C52D58D3CE400000020C0048D2279B8AFF701B1807E6239F600BFFFF1F7A03E82B602A7EB881EDAA6C0BFFAFF0EC92B7D6E0321FCB58BB7F440BFF763B747227C880386DDE1E09EAEC0BFF47A086FFA91B003BA2C11EF08E580BFF1FDCC06ED8E9703F77BE72797F440BFEF88C5501503BA0429DAF9A7420E80BFEB75E0A582D1610454636C25A09D40BFE7B77D572D585D0456D709533085C0BFE43A6FD58615450472E978D476CD40BFE0E4BE4A8177310489FC4176BD8E80BFDB5AB694DC2F6D049CBBFB39689F80BFD51A3EFE52F0A004AAD48A9FB27AC0BFCDF7B07C22983104B59D8153294540BFC1E9102D4ACC1304BCB0EDEE5C1900BFA7D59C0E0AD59404C0374A760BE0C03FA7D59C0E0AD5A204C0374A760BE0C03FC1E9102D4ACC0504BCB0EDEE5C1C803FCDF7B07C22983104B59D81532945403FD51A3EFE52F0A904AAD48A9FB277003FDB5AB694DC2F6D049CBBFB39689F803FE0E4BE4A8177310489FC4176BD8E803FE43A6FD586153C0472E978D476D0C03FE7B77D572D58680456D709533082403FEB75E0A582D1560454636C25A0A1003FEF88C5501503D10429DAF9A7420AC03FF1FDCC06ED8EA203F77BE72797F7E03FF47A086FFA91AE03BA2C11EF08DE603FF763B747227C810386DDE1E09EAB203FFAFF0EC92B7D710321FCB58BB7F4403FFFF1F7A03E82AE02A7EB881EDAA32040048D2279B8AFD801B1807E6239FD40" SAMPLE_UX_STRING_WITH_SPECIAL_VALUES = "nanUx0400000000000000017FF8000000000000000000063FF0000000000000155555555555550040080000000000001555555555555500000000000000000015555555555555007FF80000000000001555555555555500FFF000000000000015555555555555007FF00000000000001555555555555500" +# A well-formed Ux String whose 32 Dirac deltas all share the position 0.0, so +# the support collapses to a single point once coincident deltas are combined. +SAMPLE_UX_STRING_COINCIDENT_POSITIONS = "Ux040000000000000001000000000000000000000020000000000000000001B1807E6239F600000000000000000002A7EB881EDAA6C000000000000000000321FCB58BB7F44000000000000000000386DDE1E09EAEC0000000000000000003BA2C11EF08E580000000000000000003F77BE72797F44000000000000000000429DAF9A7420E8000000000000000000454636C25A09D4000000000000000000456D709533085C000000000000000000472E978D476CD4000000000000000000489FC4176BD8E800000000000000000049CBBFB39689F80000000000000000004AAD48A9FB27AC0000000000000000004B59D8153294540000000000000000004BCB0EDEE5C1900000000000000000004C0374A760BE0C0000000000000000004C0374A760BE0C0000000000000000004BCB0EDEE5C1C80000000000000000004B59D8153294540000000000000000004AAD48A9FB277000000000000000000049CBBFB39689F8000000000000000000489FC4176BD8E8000000000000000000472E978D476D0C000000000000000000456D7095330824000000000000000000454636C25A0A10000000000000000000429DAF9A7420AC0000000000000000003F77BE72797F7E0000000000000000003BA2C11EF08DE6000000000000000000386DDE1E09EAB2000000000000000000321FCB58BB7F440000000000000000002A7EB881EDAA320000000000000000001B1807E6239FD40" # ============================================================================ @@ -61,6 +66,43 @@ def test_samples_are_finite(self) -> None: self.assertIsInstance(samples, np.ndarray) self.assertTrue(np.all(np.isfinite(samples))) + def test_coincident_positions_returns_identical_samples(self) -> None: + """Many Dirac deltas at one position should return n identical samples.""" + n = 10 + samples = sample_generator(SAMPLE_UX_STRING_COINCIDENT_POSITIONS, n) + + self.assertEqual(len(samples), n) + self.assertTrue(np.all(samples == 0.0)) + + def test_coincident_positions_with_special_values(self) -> None: + """A collapsed finite support mixed with NaN mass should still sample.""" + # Dirac deltas that all share one position, plus mass on NaN, so both + # branches of the mixed case run against a support that collapses to a + # single position. + dirac_deltas = [DiracDelta(0.0, mass=0.9 / 32) for _ in range(32)] + dirac_deltas.append(DiracDelta(np.nan, mass=0.1)) + dist_value = DistributionalValue(dirac_deltas=dirac_deltas) + + n = 1_000 + np.random.seed(42) + samples = sample_from_distributional_value(dist_value, n) + + self.assertEqual(len(samples), n) + self.assertGreater(np.sum(np.isnan(samples)), 0) + self.assertTrue(np.all(samples[~np.isnan(samples)] == 0.0)) + + def test_zero_finite_mass_raises(self) -> None: + """A distributional value with no mass at all should raise ValueError.""" + dist_value = DistributionalValue.parse(SAMPLE_UX_STRING) + if dist_value is None: + raise ValueError("Failed to parse Ux-data into DistributionalValue.") + + for dd in dist_value.dirac_deltas: + dd.mass = 0.0 + + with self.assertRaises(ValueError): + sample_from_distributional_value(dist_value, 10) + def test_invalid_ux_data_raises(self) -> None: """Should raise ValueError for invalid Ux-data.""" with self.assertRaises(ValueError): diff --git a/src/signaloid/out.dat b/src/signaloid/out.dat deleted file mode 100644 index b691540..0000000 --- a/src/signaloid/out.dat +++ /dev/null @@ -1,100 +0,0 @@ -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 deleted file mode 100644 index 6405afd4863e2733d361f5f9f30aca82facda008..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77908 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 Date: Thu, 17 Sep 2026 14:06:41 +0000 Subject: [PATCH 2/2] Add public 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