diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..c11f60f3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,53 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +# Python files +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 88 + +# CUDA files +[*.cu] +indent_style = space +indent_size = 4 +max_line_length = 100 + +# Markdown files +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +# YAML files +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Configuration files +[*.{json,toml,cfg}] +indent_style = space +indent_size = 2 + +# Shell scripts +[*.sh] +indent_style = space +indent_size = 2 + +# Makefiles require tabs +[Makefile] +indent_style = tab + +# reStructuredText +[*.rst] +indent_style = space +indent_size = 3 +max_line_length = off diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..076215aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.npz binary +*.png binary +*.jpg binary +*.whl binary +*.tar.gz binary + +# Frozen receipts include byte hashes; retain their line endings and whitespace. +benchmarks/results/tls_survey_2026-09-10/** -text -whitespace +docs/validation/release-prepared-20260927/** -text -whitespace diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..fc654fe1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,186 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + # CPU test suite: cuvarbase/tests/conftest.py stubs pycuda when it is + # not installed, so the pure-CPU tests (sparse BLS ground truth, TLS + # grids/models/stats, NUFFT-LRT algorithm, frequency grids, kernel + # inventory, lazy-import contract, input validation) run and every + # test that touches a device skips. GPU kernels are validated on a + # CUDA pod before releases (see tools/README.md). + test-cpu: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + # The `test` extra of pyproject.toml is listed by hand here because + # installing `.[test]` would pull in the runtime dependency pycuda, + # which cannot build without a CUDA toolkit (no binary wheels), so + # the runners have no way to satisfy it. Keep this line in step with + # [project.optional-dependencies] test. batman-package is a small C + # extension that builds from sdist in well under a minute. + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install "numpy>=1.22" "scipy>=1.8" pytest nfft astropy batman-package transitleastsquares + + - name: Run CPU test suite (GPU tests skip via stubbed pycuda) + run: | + # testpaths / -rs / --strict-markers come from [tool.pytest.ini_options] + python -m pytest -rs -v --tb=short + + release-tools: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install host dependencies + run: python -m pip install numpy scipy matplotlib pytest batman-package + - name: Check benchmark qualification and monitoring recovery + run: python -m pytest -q benchmarks/tls_survey tools/test_watch_jobs.py tools/test_benchmark_archive.py + - name: Keep raw benchmark evidence out of Git + run: python tools/check_repository_artifacts.py + + # Packaging smoke test: build the sdist and wheel, check the metadata, + # install each artifact into a clean environment (pycuda absent) and + # import/run it from OUTSIDE the source tree. This catches + # missing-subpackage, missing-package-data and metadata bugs that + # source-tree testing hides. + package-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + ls -l dist/ + # setup.cfg's universal=1 once mis-tagged the wheel py2.py3 + ls dist/cuvarbase-*-py3-none-any.whl + + - name: Check metadata (twine) + run: | + python -m twine check --strict dist/* + + - name: Install wheel (no deps), smoke-import, run the shipped tests + run: | + python -m venv /tmp/smoke-wheel + /tmp/smoke-wheel/bin/pip install --upgrade pip + /tmp/smoke-wheel/bin/pip install "numpy>=1.22" "scipy>=1.8" astropy nfft pytest + /tmp/smoke-wheel/bin/pip install --no-deps dist/*.whl + /tmp/smoke-wheel/bin/python tools/ci_wheel_smoke.py + # The shipped test package must pass from the installed wheel, + # away from the checkout (the command INSTALL.rst advertises). + mkdir -p /tmp/pyargs-run && cd /tmp/pyargs-run + /tmp/smoke-wheel/bin/python -m pytest --pyargs cuvarbase -rs -p no:cacheprovider + + - name: Install sdist (no deps) and smoke-import + run: | + python -m venv /tmp/smoke-sdist + /tmp/smoke-sdist/bin/pip install --upgrade pip + /tmp/smoke-sdist/bin/pip install "numpy>=1.22" "scipy>=1.8" + /tmp/smoke-sdist/bin/pip install --no-deps dist/*.tar.gz + /tmp/smoke-sdist/bin/python tools/ci_wheel_smoke.py + + - name: Upload dist/ + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + # Docs build: autodoc imports the package with pycuda mocked + # (conf.py autodoc_mock_imports), so the API pages build without a + # GPU. The plot-directive figures DO need a CUDA device; on the runner + # they fail as "Exception occurred in plotting ..." warnings and the + # pages keep their source listings. Those are the only warnings + # tolerated -- any other WARNING/ERROR (bad docstring markup, broken + # cross-references, missing toctree entries) fails the job. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install docs dependencies + run: | + python -m pip install --upgrade pip + pip install "numpy>=1.22" "scipy>=1.8" -r docs/requirements.txt + + - name: Build HTML docs + run: | + python -m sphinx -b html -E -a -w warnings.log docs/source docs/build/html + + - name: Fail on any warning other than the expected plot-directive GPU failures + run: | + # -w logs each warning as ":: WARNING: " (or + # ERROR:/CRITICAL:); the plot directive's traceback continuation + # lines carry no such prefix. Keep only the diagnostic lines that + # are not the GPU-less "Exception occurred in plotting ". + grep -E ': (WARNING|ERROR|CRITICAL): ' warnings.log \ + | grep -v -E ': WARNING: Exception occurred in plotting ' > unexpected.log || true + if [ -s unexpected.log ]; then + echo "::error::unexpected Sphinx warnings:" + cat unexpected.log + exit 1 + fi + echo "docs build clean (plot-directive GPU failures only)" + + - name: Upload HTML + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/build/html/ + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 + + - name: Lint with flake8 (errors only) + run: | + # Syntax errors, undefined names and invalid escape sequences + # (W605: a SyntaxError on future Pythons) are real failures + flake8 cuvarbase --count --select=E9,F63,F7,F82,W605 --show-source --statistics + + - name: Lint with flake8 (style, advisory) + run: | + flake8 cuvarbase --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.gitignore b/.gitignore index e9cab74f..976bac4f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ coverage.xml *.mo *.pot -# Django stuff: +# Runtime logs *.log # Sphinx documentation @@ -63,8 +63,6 @@ target/ .ipynb_checkpoints .idea/* -tools/repos -Untitled*.ipynb # vim backups *.swp @@ -72,13 +70,26 @@ Untitled*.ipynb # LaTeX *.aux *.pdf +!docs/figures/*.pdf # misc -scripts/saved_results .DS_Store work/ *.png +# ... except published documentation and benchmark figures +!docs/**/*.png *.gif -*HAT*txt -testing/* -custom_test_ce.py + +# RunPod configuration (contains credentials) +.runpod.env + +# Local service credentials +.env +.env.* +!.env.example +!.env.sample + +# Downloaded benchmark archives and restored validation output +.benchmark-archives/ +docs/validation/**/*.log +docs/validation/**/*.xml diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..f90c977f --- /dev/null +++ b/.mailmap @@ -0,0 +1,11 @@ +# Canonical author identities for git shortlog / blame. +John Hoffman John Hoffman +John Hoffman John +John Hoffman John Hoffman +John Hoffman John +John Hoffman John Hoffman +John Hoffman John +John Hoffman John Hoffman +John Hoffman John Hoffman +Attila Bódi astrobatty +Michael Coughlin Michael Coughlin diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0b22cdd0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,21 @@ +# Long-running cuvarbase work + +The user has requested automatic monitoring so they do not have to return to +ask whether a background job failed. Follow [docs/JOB_MONITORING.md](docs/JOB_MONITORING.md). + +- Before leaving a long-running or paid cloud job unattended, register it with + the persistent job monitor and verify its budget guard and collector are live. +- A process exiting, an archive copying, or a finalizer completing is not enough + to call the work successful. Inspect required step results and scientific + qualifications separately from preservation and shutdown receipts. +- Handle queued monitoring events in this conversation: acknowledge receipt, + inspect the checkpoint, and continue already-authorized work. No new permission + is required for routine fixes within existing scope and budgets. +- Preserve completed and partial experiments. Never silently replace failed + numerical results, weaken their gates, or rerun them until they pass. Record + operational repairs separately and resume from verified checkpoints. +- Verify the actual command's environment and package import before expensive + stages. Source-tree pytest and a standalone script can have different import + paths; install the intended wheel or explicitly use the intended source path. +- Keep final release review open until required validation and backup checks + have been reviewed. Acknowledge failures honestly rather than marking them done. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c6221755..21b254a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,247 @@ +.. note:: + + Current release performance comparisons, recovery qualifications and + search-cost projections are in ``docs/TRANSIT_BENCHMARKS.md``. + Scoped engineering measurements below describe individual development + changes; they are not current competitor or PyPI upgrade benchmarks. + TLS measurements predating the observation-level default refer to the + retained ``method='binned'`` engine unless stated otherwise. + What's new in cuvarbase *********************** +* **1.0.1 (prepared; unpublished)** + * Preserve the existing June ``v1.0.0`` tag and prepare the reviewed 1.x candidate under a distinct version. The package, source distribution, release notes and local tag are prepared together; publication is deferred. + * Retain ``execution='baseline'`` for observation-level TLS. The survey optimization bundle requires ``execution='experimental'``: its original 5,111/5,120 exact pairs and nine mismatches still fail the aggregate gate. Scalar, batch and permutation-FAP routing preserve the explicit choice. + * Complete the sustained-throughput follow-up with seven strictly qualified TLS/GTLS panels and four BLS execution-only panels. Five panels remain unavailable after repeatability or memory failures; all original failures remain preserved. + * Record the expanded A40 suite (2,091 passed, one expected failure, zero skips) and the separately corrected installed-wheel release gate (14 numerical/runtime checks and six dependency preflights). Only the version declaration changes package code after GPU validation. + * Add persistent job monitoring, an independent heartbeat watchdog, durable chat delivery and verified R2 preservation so collection success cannot hide validation failure. + +* **1.0 development (included in 1.0.1)** + * Planned first major PyPI release since 0.2.5 (2023). Includes the unreleased internal 0.4.0 and tagged-but-never-published 0.2.6 (below), plus the subsequent 1.0 development work. The June ``v1.0.0`` tag is an earlier snapshot and does not contain every item below. + * The September comparison in ``docs/TRANSIT_BENCHMARKS.md`` uses actual PyPI 0.2.5 with warmed kernels and reusable memory. BLS also fixes the old float32-fold failure on absolute BJD-scale timestamps. + * **TLS observation-level default:** ``tls_search`` / ``tls_search_gpu`` / ``tls_transit`` / ``tls_search_batch`` now use the pinned GTLS numerical search, including its broad duration domain and full candidate/harmonic refinement, without phase binning. Fused residual kernels, device-side winner reduction and replayed native prefix scans remove repeated work while preserving the numerical evaluation. Physical workspaces do not narrow the searched durations. Install ``cuvarbase[tls]`` (CuPy 13 for CUDA 12 + batman; Python 3.9–3.13). ``method='binned'`` explicitly retains the previous approximate engine and ``method='legacy'`` retains the old shared-memory kernel. The standard path requires three observations; its SDE uses full refinement, and FAP nulls run the same complete search. A finite-candidate filter fixes native GTLS mask handling before refinement; fractional default SDE windows are rejected before GPU work, with an explicit integer-window override available. See ``docs/TRANSIT_BENCHMARKS.md`` for current measurements. + * **TLS sparse-bin traversal:** fine histograms skip template-integral evaluation for empty bins, reusing existing shared memory while preserving the histogram, template, trial grids, normalization, refinement and float32 coordinate-addition sequence. ``docs/TLS_NUMERICS.md`` and ``benchmarks/results/tls_accuracy_2026-09-09/`` document the paired validation and the separate accuracy limits of phase binning, duration priors and search sampling. The bin-cap warning now explains that candidate refinement cannot recover a period excluded by the coarse search. + * **BREAKING (Sep-2026 audit): every public entry point now validates its input and raises** ``ValueError``. Non-finite ``t``/``y``/``dy``, ``dy <= 0``, mismatched array lengths, an empty light curve, fewer observations than the method needs (4 for Lomb-Scargle, 3 for standard TLS and NUFFT-LRT, 2 elsewhere), and non-finite or non-positive frequency grids used to be accepted silently: a single NaN timestamp gave a finite BLS or CE periodogram with the wrong argmax, ``dy = 0`` gave an all-NaN PDM spectrum, an undocumented power of ``-1`` at every Lomb-Scargle frequency, or a TLS chi2 off by a factor 1.3e3 - and a NaN per-frequency ``q`` bound, ``qmax >= 1`` or a Keplerian grid built from fewer than ``min_obs_per_transit`` points crashed the kernel with ``cuMemcpyDtoH failed: an illegal memory access``, which **destroys the process's CUDA context**, so every later GPU call in the same interpreter failed too. The checks run on the host before any device work (kernel compilation included), so a rejected call leaves the context untouched and the next call succeeds. The two helpers are public: ``cuvarbase.utils.check_lightcurve(t, y, dy=None, min_n=..., name=...)`` and ``cuvarbase.utils.check_freqs(freqs, name=...)``; the messages name the offending array, the number of offending entries and the first few of their indices. **Nothing changes for valid finite input** (results are bit-identical). Pipelines that fed NaN-containing arrays and read an all-zero or ``-1`` periodogram as "no detection" must now filter their input (``m = np.isfinite(t) & np.isfinite(y) & (dy > 0)``). Related guards: ``fmin_transit`` / ``transit_autofreq`` raise instead of returning a NaN frequency grid when the light curve cannot hold ``min_obs_per_transit`` samples in one transit; the binned BLS q bounds are checked (finite, ``0 < qmin <= qmax <= 1``) before the ``uint32`` bin-count cast in ``BLSMemory.setdata`` / ``BLSBatchMemory.set_freqs``; ``single_bls`` rejects a non-finite ``freq``/``q``/``phi0``, a non-positive ``freq`` and a ``q`` outside ``[0, 1]``; ``NFFTAsyncProcess.run`` rejects a non-integer or non-positive ``nf``. The unweighted conditional entropy (``weighted=False``, the default) never reads ``dy`` but still validates it when one is given; pass ``dy=None`` to skip that check. + * **API freeze (Sep 2026)** + * **Top-level namespace.** ``cuvarbase.`` now resolves exactly the names in ``cuvarbase.__all__`` (the process classes ``GPUAsyncProcess``, ``NFFTAsyncProcess``, ``ConditionalEntropyAsyncProcess``, ``LombScargleAsyncProcess``, ``PDMAsyncProcess``; the memory classes ``NFFTMemory``, ``ConditionalEntropyMemory``, ``LombScargleMemory``, ``BLSMemory``, ``BLSBatchMemory``; the functions ``nfft_adjoint_async``, ``conditional_entropy``, ``conditional_entropy_fast``, ``lomb_scargle_async``) plus the submodules (``cuvarbase.bls``, ``cuvarbase.tls``, ...); everything else lives in its module. The unpublished v1.0 branch also resolved any public name of ``cuvarbase.bls`` -- and, by accident, ``cuvarbase.np``, ``cuvarbase.cuda`` and ~36 other names -- as ``cuvarbase.``; that fallback is gone (no PyPI release ever had it: 0.2.5's ``__init__`` held only ``__version__``). Migration for code written against that branch: ``from cuvarbase.bls import eebls_gpu`` (or ``cuvarbase.bls.eebls_gpu``) instead of ``cuvarbase.eebls_gpu``. + * **NUFFT-LRT quarantined** (maintainer decision D1): ``cuvarbase.nufft_lrt`` stays importable (``from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess``) but ``NUFFTLRTAsyncProcess``/``NUFFTLRTMemory`` are not in the top-level namespace, the EXPERIMENTAL ``UserWarning`` is emitted when ``NUFFTLRTAsyncProcess`` is constructed rather than at import (so ``from cuvarbase import *`` and BLS/LS/PDM users never see it), and the module and its ``run()`` signature are outside the 1.x API-stability promise. The injection-recovery re-validation (release-plan Phase 4, 2026-09-06, ``benchmarks/results/nufft_lrt_validation_2026-09-06/``) passed the correctness gate -- the default path is correct on BJD-scale times and recovers random-epoch transits -- but also showed that the defaults a 1.x freeze would lock in should still change (the automatic epoch grid costs 4-9 % of completeness against a finer one, PSD whitening gave no gain over a flat PSD, ``run()`` returns a tuple or an array depending on ``epochs``), so the module stays experimental in 1.0 with the measured numbers on its docs page. + * **Deprecated** (kept for 1.x, removed in 2.0; each warns with ``stacklevel=2``): ``cuvarbase.core`` (``DeprecationWarning`` at import; import ``GPUAsyncProcess``/``ensure_context`` from ``cuvarbase.base``); ``BLSMemory.allocate_pinned_arrays`` (use ``allocate_host_arrays``); the PDM ``(t, y, w, freqs)`` 4-tuple input (the warning now says its third element is the normalized weights, not the uncertainties; pass ``(t, y, dy)`` and ``freqs=``); ``GPUAsyncProcess(reader=, function_kwargs=, device=)`` (accepted since 0.2.5, never read; ``device != 0`` now emits a ``UserWarning`` that ``CUDA_DEVICE`` selects the device). + * **Removed.** Shipped in 0.2.5 but never used by the package: ``cuvarbase.utils.tophat_window``, ``cuvarbase.utils.gaussian_window``, ``cuvarbase.utils.get_autofreqs`` (``cuvarbase.utils.autofrequency`` remains). Never released: the ``cuvarbase.`` fallback above, ``tls_stats.signal_detection_efficiency(window_length=)`` (use ``kernel_size=``), ``tls_stats.signal_to_noise(n_transits=)`` (it was already ignored), ``tls_search_gpu(durations=)`` (a warned no-op), ``tls_stats.pink_noise_correction``, ``tls_grids.estimate_n_evaluations``, ``tls._next_pow2``; ``TLSMemory.allocate_pinned_arrays`` is renamed ``allocate_host_arrays`` without an alias. + * **Keyword-only parameters** on the 1.0-new entry points: everything after the data/grid arguments must be passed by keyword -- ``tls_search_gpu(t, y, dy, periods=None, *, ...)``, ``tls_search_batch(lightcurves, *, ...)``, ``tls_transit(t, y, dy, *, ...)``, ``eebls_gpu_batch(lightcurves, freqs, *, ...)``, ``keplerian_freq_grid(period_min, period_max, baseline, *, ...)``, ``uniform_freq_grid(period_min, period_max, baseline, *, ...)``, ``convert_bls_power(power, y, dy, *, convention=...)``. The pre-1.0 BLS signatures (``eebls_gpu``, ``eebls_gpu_fast*``, ``eebls_transit*``) are unchanged. + * **Explicit** ``__all__`` in every user-facing module (``bls``, ``bls_frequencies``, ``ce``, ``cunfft``, ``lombscargle``, ``pdm``, ``tls``, ``tls_grids``, ``tls_models``, ``tls_stats``, ``utils``, ``cufinufft_backend``, ``nufft_lrt``): star-imports and the Sphinx API reference no longer publish ``np``, ``cuda``, ``gpuarray``, ``threading`` or module constants. Nothing is renamed. ``cuvarbase/tests/test_api_freeze.py`` pins the whole freeze (namespace, quarantine, shims, removals, keyword-only markers, ``__all__`` coverage of every name the docs reference, and docstring defaults against signatures). + * **BLS** + * **BLS survey performance (July 2026 implementation):** fused histograms, input staging, host overhead reduction and frequency chunking. The current measured upgrade and competitor comparisons are in ``docs/TRANSIT_BENCHMARKS.md``; the earlier development timings are superseded. The implementation changes are: + * Fused-noverlap kernels (``full_bls_no_sol_fused``, ``full_bls_batch_fused``): for power-of-two ``noverlap`` with ``dphi=0`` (the defaults), one launch histograms at ``noverlap``-times finer phase resolution and evaluates every shifted bin grid from it — ``noverlap``-x fewer folds and shared-memory atomics, per-frequency fixed costs paid once. Other settings keep the multi-pass host loop (bit-compatible fallback) + * Conflict-scatter permutation of staged lightcurve data (``utils.conflict_scatter_perm``): time-sorted dense cadences put warp-adjacent samples in the same phase bin at nearly every trial frequency, serializing shared-memory atomics — a TESS-like 2-minute cadence ran 3x slower than randomly ordered input. Staging buffers now store a deterministic golden-stride order (binning is a sum; order is semantically free) + * Host-path overhead: ``np.dot`` -> ``np.einsum`` in the per-lightcurve path (BLAS ddot spawns an nproc threadpool; on CPU-quota-limited containers — RunPod/K8s — the burst trips CFS bandwidth throttling and froze the process ~90 ms per 100 ms period, an 8x end-to-end penalty at TESS scale under default OpenBLAS settings); Python ``max()`` -> ``np.max`` over the per-frequency bin-count arrays (2.4-9 ms per call at survey grid sizes, paid inside every launch); ``eebls_gpu_batch`` allocates one ``BLSBatchMemory`` per call (not per chunk), uploads the frequency grid once, transfers only populated slots, and accepts ``memory=`` to reuse staging/device buffers across calls (``BLSBatchMemory`` transfer/get methods take ``n_lcs_active``/``nfreq_active``) + * Occupancy-aware frequency chunking: when the (fused) histogram's shared-memory request would cap resident blocks below the thread limit (Kepler-scale ``qmin``), launches proceed in 8192-frequency chunks sized to their own bin counts (+32% on the Kepler config, dormant elsewhere); batch kernels take an explicit output-row-pitch argument (``bls_stride``) + * Optimized kernel variant (``bls_optimized.cu``) with bank-conflict fixes and warp shuffles; ``eebls_gpu_fast_optimized()`` and ``eebls_gpu_fast_adaptive()`` (automatic block sizing; the v1.0 re-benchmark with warm kernel cache measures ~1.0-1.3x over fixed blocks — earlier 1.4-5.3x gains were dominated by per-call kernel handling that the cache now amortizes) + * Thread-safe kernel caching with LRU eviction + * Selectable power conventions (issue #17): all BLS entry points accept ``convention=`` ('chi2ratio' default, 'snr', 'loglik') and ``convert_bls_power()`` converts standalone periodograms. 'snr' equals astropy's ``objective='snr'`` power at the same solution; 'loglik' is the log-likelihood gain over the constant weighted-mean model (astropy's ``objective='likelihood'`` equals it divided by 1 - r, r = in-transit weight fraction) — both relations verified against astropy in the test suite + * ``eebls_gpu_fast`` (and ``_optimized``/``_adaptive``): the ``noverlap`` parameter is now honored — the periodogram is the elementwise max over ``noverlap`` passes with the phase-bin grid shifted by ``1/noverlap`` of the finest bin between passes. Previously ``noverlap`` was silently ignored on the fast path (its docstring recommended a manual ``dphi`` re-run workaround, now removed). Runtime scales linearly with ``noverlap`` (default 2); pass ``noverlap=1`` for the old single-pass behavior + * Sparse BLS (Panahi & Zucker 2021) on GPU and CPU, with ground-truth correctness tests; ``eebls_transit`` auto-selects sparse vs standard BLS by dataset size. The sparse path (kernels + CPU) honors per-frequency ``qmin``/``qmax`` duration bounds, and ``eebls_transit`` passes its Keplerian ``qmin_fac``/``qmax_fac`` constraints through, so results are comparable across the ``sparse_threshold`` boundary. **BREAKING:** all ``sparse_bls_cpu``/``sparse_bls_gpu`` arguments after ``freqs`` are keyword-only — pre-1.0 positional calls (e.g. passing ``ignore_negative_delta_sols`` positionally) would have silently landed on the new ``qmin`` parameter and returned an all-zero periodogram; they now raise TypeError. Bound values are validated (finite, ``qmin >= 0``, ``qmax > 0``, ``qmin <= qmax``) instead of silently rejecting every candidate box + * ``sparse_bls_cpu`` vectorized with prefix sums (the previous pure-Python pair loop recomputed slice sums, O(N³) — minutes per frequency at the ndata=500 sparse threshold; now ~3 ms) + * Multi-lightcurve batch mode: ``eebls_gpu_batch()`` + ``BLSBatchMemory`` + * **Fixed nondeterministic bogus BLS peaks from degenerate all-weight boxes (Jul 2026, root cause of the instability reported in PR #65):** the ``bls_value`` upper bound ``w < 1.f - 1e-10f`` was a float32 no-op (1e-10 underflows against 1.0f), so a trial box capturing all the statistical weight — routine for single-site data near cycles-per-day aliases with wide boxes — passed the guard with ``1 - w`` equal to atomicAdd-roundoff noise and ``ybar`` roundoff around zero, producing run-to-run-varying spurious power (``sparse_bls.cu``'s ``MAX_W_COMPLEMENT = 1e-9`` had the same underflow). The bound is now a float32-meaningful ``1e-4`` complement across ``bls_common.cuh``/``bls_batch.cu``/``sparse_bls.cu`` and the CPU mirrors (``single_bls`` returned a literal NaN on an all-weight box; ``sparse_bls_cpu`` uses the same complement for GPU/CPU parity). Regression tests cover the deterministic CPU case, repeat-stability on single-site data, and 500 ppm shallow-transit recovery (guarding against absolute-amplitude thresholds as an alternative "fix") + * **Fixed two ``eebls_gpu_batch`` defects (Jul 2026):** (a) the batch path was single-pass while the fast/adaptive paths do ``noverlap`` phase-shifted passes (the batch kernel's ``noverlap`` argument was a silent no-op, like the single-LC fast kernels' before this release) — the batch periodogram diverged from ``eebls_gpu_fast`` at small ndata (corr 0.77 at ndata=200); it now runs the same host-side multi-pass + elementwise max and matches at corr>0.999 with identical peaks. (b) The batch kernel was recompiled on every call (~0.6–0.9 s vs 2–10 ms of kernel work) — the entire "~12x slower at TESS scale" regression; it now goes through the same LRU kernel cache as the single-LC paths, and with a warm cache batch beats a single-LC ``eebls_gpu_fast`` loop at every measured scale (~10x at ndata=200, ~5x at ndata=20,000; RTX A5000). The large-ndata inefficiency UserWarning is retired + * Fixed ``convention='snr'``/``'loglik'`` scaling on the fast path's memory-reuse pattern: ``BLSMemory`` now records the :math:`\\chi^2_0` of the data loaded at ``setdata`` time and the conversion uses it, so calls that reuse a preloaded memory (``transfer_to_device=False``) while passing different ``y``/``dy`` arguments no longer scale the power by the wrong null model + * Keplerian frequency grids: ``cuvarbase.bls_frequencies.keplerian_freq_grid()`` — period-dependent spacing derived from the duration and stellar-density assumptions; ``return_qvals=True`` also returns the per-frequency Keplerian duration fraction, which ``eebls_gpu_batch`` accepts as array ``qmin``/``qmax`` for duration-constrained batch searches + * Fixed ``mod1_fast`` integer overflow for t*f >= 2^31 (corrupted phases on long-baseline data) + * **Fixed silent accuracy loss for absolute timestamps (e.g. BJD ~2.45e6 days):** all BLS paths now subtract ``floor(min(t))`` in float64 before casting times to float32; previously the float32 phase fold lost nearly all phase information at BJD scale. **Convention:** ``phi0`` phases (both reported solutions and inputs to ``single_bls``/``eebls_gpu_custom``/``hone_solution``) are in the ORIGINAL input timescale — internally phases are folded relative to ``floor(min(t))`` and re-referenced as ``(phi ± epoch*freq) % 1`` in float64 (PR #65, @astrobatty). An earlier iteration reported phases relative to ``floor(min(t))`` itself + * **Fixed a float32 fold-order precision loss in ``single_bls``** (exposed by PR #65's non-zero-epoch tests): the reference folded as ``(t*f - phi0) mod 1``, subtracting at magnitude ``t*f`` where float32 resolution is only ``ulp(t*f)/2`` (~1.5e-5 phase for a 1-yr baseline, ~2.4e-4 for 10 yr), so points within that fuzz of a box edge could get the wrong membership relative to the GPU kernels, which wrap into [0, 1) *before* binning (~1e-7 resolution; hardware-probed: nvcc does not FMA-contract the kernels' ``mod1(t*f)``, so wrap-first is bit-identical to the kernel fold). ``single_bls`` now wraps first. Also: ``bin_and_phase_fold_custom`` folds with the float32-cast frequency (double freqs are used only for epoch re-referencing) and takes float64 ``phi_values`` so its epoch conversion matches ``single_bls`` bit for bit, and ``sparse_bls_cpu``/``sparse_bls_gpu`` re-reference solution phases with the caller's float64 frequencies (the float32 copies put phases off by ``epoch*|f64-f32|``, up to ~0.07 cycles at BJD epochs). ``eebls_transit_gpu`` now always returns a 3-tuple (``sols=None`` on the fast/optimized paths) and ``eebls_transit(use_optimized=True)`` respects an explicit ``block_size`` + * Fixed ``reduction_max`` in the optimized kernel silently dropping half the per-block candidates (``use_optimized=True`` paths) + * Fixed ``eebls_transit`` sparse path crashing with TypeError on documented kwargs (rho, samples_per_peak, ...) + * ``compile_bls`` validates block_size (power of 2, >= 32) and raises a clear error when no requested kernel functions are loadable; ``_reduction_max`` now applies the same validation (its old power-of-two assert was always true under Python 3 division) + * **Sep-2026 audit fixes (correctness; every item below was reproduced on device before the fix and is covered by a regression test that fails on the pre-fix tree):** + * BLS: fixed a 32-bit overflow in the phase-fold kernels. ``eebls_gpu`` / ``eebls_transit`` on more than ~2^31 (ndata x frequencies-per-batch) threads silently returned zero powers, powers above 1 and the wrong peak (e.g. a TESS 2-minute year, 262,800 points). The kernels now index in 64 bits and the host caps ``freq_batch_size`` at ``len(freqs)`` and at ``(2**31 - 1) // ndata``. + * BLS: ``eebls_gpu`` sized its device bin buffers from ``count_tot_nbins(grid-wide min nbins0, grid-wide max nbinsf)``, which is not an upper bound over the batches (``count_tot_nbins`` is non-monotone in ``nbins0``), so Keplerian-q grids could overrun them with an illegal memory access. Buffers are now sized from the actual maximum over the batches, with a bounds check before every launch. + * BLS: the host q ladder (``dnbins`` / ``count_tot_nbins`` / ``_fast_box_widths``) now forms ``floor(dlogq * nbins)`` in float32, exactly as the kernels do (``dlogq`` is a ``float`` kernel argument). The float64 host product landed on the other side of an integer for some non-default ``dlogq`` (0.35, 0.65, 0.7, ...; e.g. ``0.65 * 180`` = 117.0 vs ``floorf`` = 116), so ``eebls_gpu`` could size a frequency's bin row from a shorter ladder than the device iterated (host 180 cells vs device 476 for ``nbins0=180, nbinsf=296, dlogq=0.65``) and the fold kernel's atomics ran into the next row, and ``eebls_transit``'s ``(q, phi)`` re-scan could walk a different ladder than the kernel. **Results are unchanged at the default ``dlogq`` values** (0.2 for ``eebls_gpu``, 0.3 for the fast paths; bit-identical for every ``nbins <= 200000``); at other ``dlogq`` the buffer sizing and reported solutions now agree with the kernel at every frequency. + * BLS: ``eebls_gpu`` now honours per-frequency ``qmin`` / ``qmax`` arrays per frequency (up to the kernels' bin quantization: the window searched at frequency ``i`` is ``[1/ceil(1/qmin_i), 1/floor(1/qmax_i)]``). It used to collapse them to one batch-wide (min, max) window, so most Keplerian-grid solutions fell outside their own duration window and the periodogram depended on ``freq_batch_size`` and on the free device memory (up to 2.3e-2 difference between a 24 GB and a 7 GB card). **Results change** for any call with array bounds. + * BLS: ``eebls_transit`` now computes the periodogram for ``ndata >= sparse_threshold`` with the fused fast shared-memory kernel and recovers the best-fit ``(q, phi)`` at the ``n_solutions`` (default 10) highest peaks; other entries of ``solutions`` are ``None``. Use ``eebls_transit_gpu`` / ``eebls_gpu`` for a solution at every frequency. **Results change** on this default path. ``eebls_gpu``'s ``max_memory`` / ``nstreams`` kwargs, which this path used to forward, no longer apply and are ignored with a ``UserWarning`` (use ``eebls_transit_gpu`` or ``eebls_gpu`` if you need them). + * BLS: ``eebls_transit``'s ``(q, phi)`` re-scan now derives its bin ladder from the q bounds exactly as ``BLSMemory.setdata`` does (in the bounds' own dtype). It promoted them to float64 first, so with float32 ``qvals`` (the documented override, e.g. ``keplerian_freq_grid(return_qvals=True)`` output) the re-scan could walk a ladder one bin off the kernel's (``1/float32(0.025)`` is 40 in float32 but 39 once promoted) and report a box the kernel never evaluated. The default path (float64 ``qvals`` from ``transit_autofreq``) is bit-identical. + * BLS: ``single_bls`` rejects a ``q`` outside ``[0, 1]`` with ``ValueError`` (a negative duration, or one wider than a full phase cycle, used to return a silent power of 0). To state its input checks precisely: ``freq``, ``q`` and ``phi0`` must be finite, ``freq`` must be positive, ``q`` must lie in ``[0, 1]`` -- ``q = 0``, the sparse paths' no-solution sentinel, still evaluates to a power of 0 -- and ``phi0`` may be any finite phase (``phi0 = 0`` and negative phases are valid and wrap). + * BLS: the sparse path now centres the flux in float64 before the float32 cast. ``sparse_bls_gpu`` / ``sparse_bls_cpu`` / ``single_bls`` accumulated float32 prefix sums of uncentered ``w*y``, which on magnitude-scale fluxes cost up to 1.1e-2 in relative power (moving the argmax in 8/20 seeds) and produced powers above 1 - up to 52 - when one point was far more precise than the rest. **Results change** for ``eebls_transit(ndata < sparse_threshold)`` and every direct sparse call. + * BLS: removed the ``use_simple`` sparse kernel (``sparse_bls_simple.cu``). It still carried the pre-PR#65 ``MAX_W_COMPLEMENT 1E-9`` bound, which compiles to ``W > 1``, so an all-weight box divided roundoff by roundoff and returned powers up to 4.6 in pure noise on single-site data. Passing ``use_simple`` now raises ``TypeError`` (``compile_sparse_bls`` and ``eebls_transit`` name the removal; ``sparse_bls_gpu`` gives Python's generic unexpected-keyword message). + * BLS: ``eebls_gpu`` and ``eebls_gpu_custom`` no longer reserve ~90% of free device memory per call; the default budget is half of free memory, the batch never exceeds the frequency grid, and only ``min(nstreams, nbatches)`` scratch buffer sets are allocated (for ``eebls_gpu_custom`` this changes the automatic ``freq_batch_size`` and the device allocation, not the results). + * BLS: the fast shared-memory kernels now evaluate the widest box allowed by ``qmax``. The box-width loop stopped one rung short, so ``qmax`` itself was never tested - with ``qmin=0.025``, ``qmax=0.1`` the widest box searched was ``q=0.075`` and an on-grid ``q=0.1`` transit was recovered at 73% of its exact power. **Results change** (power can only rise) for ``eebls_gpu_fast`` / ``_optimized`` / ``_adaptive``, ``eebls_gpu_batch`` and ``eebls_transit(ndata >= sparse_threshold)`` at the frequencies where the ladder lands on the widest box - 26-34% of a Keplerian grid. The scalar defaults ``qmin=0.01``, ``qmax=0.5`` are unaffected. + * BLS: ``eebls_gpu_batch(noverlap=0)`` used to launch nothing and return an all-zero (or, on memory reuse, stale) periodogram. ``noverlap`` is now validated as a positive integer, as on the fast paths. + * BLS: ``sparse_bls_gpu`` raises a clear ``ValueError`` naming the shared-memory requirement, the device limit and the largest usable ``ndata`` (about 2,000 points on a 48 KB device) instead of failing with a bare ``cuLaunchKernel failed: invalid argument``. + * BLS: ``BLSMemory.fromdata(max_ndata=..., max_nfreqs=...)`` no longer raises ``TypeError: got multiple values for argument``, and reusing a ``BLSMemory`` with a different number of frequencies now raises a ``ValueError`` that names both counts instead of a pycuda ``ary and self must be the same size``. + * BLS: ``test_kernel_drift.py`` now checks every ``kernels/*.cu`` and ``*.cuh``: a ``#define`` present in more than one file must carry the same value everywhere, and a device/global function defined in more than one file must have one body (sanctioned variants listed explicitly). This is the check that would have caught the ``MAX_W_COMPLEMENT`` drift above. + * BLS docs: corrected ``eebls_gpu_fast``'s ``max_nblocks`` default (5000, not 200), ``eebls_gpu``'s ``dlogq`` default (0.2, not 0.5) and its ``noverlap`` description (phase-shifted bin grids, not overlapping q bins), ``eebls_transit_gpu``'s ``fmin_frac`` default (1.0, not 1.5), and replaced ``eebls_gpu_fast_optimized``'s '20-30% speedup' claim with the measured parity (both entry points launch the same fused kernel at power-of-two ``noverlap``). ``eebls_gpu_custom`` now documents that ``phi_values`` are absolute phases in the input timescale, ``eebls_gpu_batch`` states that batching removes per-call host overhead rather than raising kernel throughput, and the fast paths document the discrete q ladder and the phase-misalignment power loss near ``qmin``. + * **Sep-2026 audit performance work (measured on one shared NVIDIA A40; read every ratio as indicative of that machine, not as a portable number. Bit-neutral unless the bullet says otherwise):** + * BLS: ``eebls_gpu``, ``eebls_gpu_custom``, ``hone_solution`` and ``sparse_bls_gpu`` now take their kernels from the same LRU cache the fast and batch paths use instead of compiling on every call (pycuda re-runs an ``nvcc --preprocess`` subprocess on every ``SourceModule``, even when its own disk cache holds the cubin). **Bit-neutral** -- the same compiled kernels, so no returned number changes. Measured on an NVIDIA RTX A40 (shared GPU; medians of repeated calls with the compiler cache warm): ``sparse_bls_gpu`` at 200 points / 500 frequencies 305 -> 5.2 ms, ``eebls_transit`` on the same data 327 -> 3.8 ms, ``eebls_gpu`` at 150 points / 300 frequencies 338 -> 10 ms. + * BLS: ``eebls_gpu_fast_adaptive`` and ``eebls_transit(use_optimized=True)`` now run the fused-noverlap kernel wherever it applies (power-of-two ``noverlap`` with ``dphi = 0``), as ``eebls_gpu_fast`` already did. They were loading a kernel dictionary without it and silently falling back to the ``noverlap``-pass host loop: one launch instead of two, measured at 1.9-2.3x less GPU time on an NVIDIA RTX A40 (shared) across ZTF-, HAT- and TESS-scale grids. Powers agree with the multi-pass path to 2.3e-6 absolute with identical argmax -- the same float32 accumulation-order difference the multi-pass path does not pin down between its own runs. + * BLS: single-call ``eebls_gpu_fast`` / ``eebls_gpu_fast_optimized`` / ``eebls_transit`` no longer build a whole ``BLSMemory`` per call. Only the buffers actually used for asynchronous transfers are page-locked (``nbins0``/``nbinsf`` are rebuilt in ``setdata`` before any transfer reads them, and ``bls`` is an async destination only when a stream is attached), and a two-entry per-thread pool reuses memories of the same ``(ndata, nfreqs)``. The pool is skipped wherever it would be visible to the caller (a stream attached, ``transfer_to_host=False``, or caller-sized ``max_ndata``/``max_nfreqs``); set ``cuvarbase.bls._MEMORY_POOL_MAX_SIZE = 0`` to disable it. **Bit-neutral** -- the staged host buffers, the uploaded device arrays and the normalization scalars are bit-identical to the allocate-per-call path. Measured on an NVIDIA RTX A40 (shared): ``eebls_gpu_fast(memory=None)`` 3.7x (150 points / 60K frequencies), 2.7x (20K points / 1.8K frequencies), 1.2x (6K points / 300K frequencies); ``eebls_transit(use_fast=True)`` 2.3x / 2.4x / 1.4x on the same three. + * BLS: ``eebls_gpu``, ``eebls_gpu_custom``, ``single_bls`` and ``sparse_bls_cpu`` compute their per-light-curve normalization with ``np.einsum`` instead of ``np.dot``, finishing the change already made to ``BLSMemory.setdata``. On CPU-quota-limited containers (RunPod, Kubernetes) the BLAS threadpool burst trips CFS throttling and stalls the process: measured on a 7.65-core-quota container with 96 CPUs visible, the prologue's median went 0.60 -> 0.17 ms, its slowest call 98 ms, and its 12 cgroup throttle events per 50 calls went to zero. On an NVIDIA RTX A40 (shared) at 20,000 points: ``single_bls`` 93.9 -> 0.65 ms, ``eebls_gpu`` 6.4x, ``eebls_gpu_custom`` 11.7x. Summation-order change only: ``ybar``/``YY`` move by 0-19 float64 ulps, the returned float32 powers by 1-5 float32 ulps, every argmax is unchanged and every returned ``(q, phi)`` solution is bitwise identical. + * BLS: the per-frequency Python loop that re-phases the reported ``(q, phi)`` solutions to the input timescale is vectorized (``eebls_gpu``, ``sparse_bls_gpu``, ``sparse_bls_cpu``), ``BLSMemory.setdata`` derives ``chi2_0`` from ``yy`` and the weight sum instead of making a second pass over the light curve, and ``conflict_scatter_perm`` is memoized on ``ndata``. **Bit-neutral** for the powers and the solutions (bitwise identical on every deterministic path, for float64, float32, list and numpy-scalar frequency grids alike); ``chi2_0``, which only scales the ``'snr'`` and ``'loglik'`` conventions and not the default ``'chi2ratio'``, moves by at most 2.1e-15 relative for float64 inputs and by ~1e-7 (one float32 ulp) when ``y``/``dy`` are float32. Measured on an NVIDIA RTX A40 (shared): re-phasing 39 -> 10 ms at 60,121 frequencies and 74 -> 24 ms at 117,403; ``eebls_gpu`` 1.19x, ``sparse_bls_gpu`` 1.18x, ``setdata`` 1.4x at 20,000 points. + * **Results change (float64 rounding).** BLS: ``cuvarbase.bls.transit_autofreq`` and ``cuvarbase.bls_frequencies.keplerian_freq_grid`` solve the Ofir (2014) duty-cycle spacing recursion with numpy instead of a Python loop with one ``q`` evaluation per frequency, which cost 0.2-10 s per call at survey grid sizes -- more than the GPU search it fed. Both gain ``method='vectorized'`` (the new default) and ``method='recursion'`` (the original scalar loop). The solver converges to a fixed point of the *same* recursion (a continuum seed followed by defect correction), so the grid length is identical and every frequency agrees to ~1e-15 relative -- one to two float64 ulps (at most 9.4e-16 in the audit sweep, at most 2.5e-10 of one grid step; an independent re-measurement on another host gave 1.04e-15) -- measured over ZTF/HAT/TESS/Kepler baselines, stellar densities from 0.05 to 5 and oversampling from 0.5 to 10. The float32 grid ``keplerian_freq_grid`` returns is bitwise identical, as is the float32 grid the kernels actually search, and the sparse-path periodogram is bitwise identical end to end. Pass ``method='recursion'`` if you need float64 grids bit-identical to cuvarbase < 1.0. Measured on the audit host: ``transit_autofreq`` 9.8-14.9x (a 1.5M-frequency 10-year grid 4.2 s -> 0.39 s), ``keplerian_freq_grid`` 5.2-13.6x, and ``eebls_transit(freqs=None, use_fast=True)`` 12.4x at ZTF scale, 4.8x at TESS scale and 2.5x on the 200-point sparse path. + * **Lomb-Scargle / NFFT** + * Multiharmonic generalized Lomb-Scargle on GPU (``LombScargleAsyncProcess(nharmonics=H)`` for ``H>1`` no longer raises ``NotImplementedError``). The GPU NFFT already produces the weight spectrum to 2H harmonics and the ``w*(y-ybar)`` spectrum to H; the per-frequency 2H x 2H generalized-LS solve runs on the host in float64 (reusing the tested ``mhdirect_sums``/``mhgls_from_sums`` math), which agrees with the ``lomb_scargle_direct_sums`` float64 reference to float64 roundoff on the host and, end to end on the device after the Sep-2026 psi-table and grid-sizing fixes, to 5.7e-7 in float32 and 7.4e-10 with ``use_double=True`` for H=2,3. Suited to occasional multiharmonic searches rather than survey-scale throughput + * **Dropped the abandoned ``scikit-cuda`` dependency** (`issue #63 `_): the cuFFT calls (the only thing scikit-cuda 0.5.3 was used for) now go through a minimal in-house ``ctypes`` binding, ``cuvarbase._cufft`` (Plan/fft/ifft/cufftEstimate1d, lazily loaded). No cuvarbase module imports scikit-cuda anymore, and its numpy>=1.24 compatibility shim is gone. Validated on an RTX A5000: full LS/NFFT suite green, FFT matches scipy, and the binding is within ~2% of the old scikit-cuda cuFFT performance (both call ``cufftExecC2C``) + * Memory classes refactored into ``cuvarbase.memory`` (behavior-preserving) + * ``NFFTAsyncProcess.estimate_m``/``get_m`` now implement the rigorous L1-norm *truncation* bound (NFFT3 guide p. 11: ``max|E| <= 4 exp(-m pi (1 - 1/(2 sigma - 1))) ||y||_1``) when the data is available — with ``autoset_m=True`` the filter radius is the smallest ``m`` whose truncation-error bound meets the requested tolerance, replacing the jakevdp/nfft ``N``-based heuristic (which guaranteed the tolerance only for ``max|y| <= 1``; it remains the fallback when ``m`` is sized before the data is seen, e.g. the Lomb-Scargle buffer layouts). Resolves the package's only TODO. In double precision the realized error tracks this bound down to ~1e-10 absolute (A5000-validated); in single precision a genuine ~1e-3 absolute floor remains (float32 trig on large phase arguments) — use ``use_double=True`` for tolerances below ~1e-2 + * **Fixed a float32 ``PI`` literal in ``cunfft.cu``'s phase-factor kernels** (``nfft_shift``/``normalize``): its 2.8e-8 relative error, multiplied by un-reduced phase arguments up to ``2*pi*|k0|`` and amplified by the Gaussian deconvolution, imposed an m-independent ~1e-3 absolute error floor on the NFFT *even in double precision* (an earlier note here described that floor as inherent — it was this bug). After the fix the float64 NFFT error follows the truncation bound over 9 decades (m=12 reference config: 3.4e-3 → 1.2e-10); float32 behavior is unchanged. Also typed the ``modflt``/``diffmod`` device helpers with ``FLT`` (they hardcoded float32 in double mode) + * **Kernel hygiene (Jul 2026): the remaining float32 ``PI`` literals flagged in that diagnosis are resolved.** ``lomb.cu``'s was live in the direct-sums kernels (``use_fft=False``): in double-precision mode the float32 pi (relative error 2.8e-8) enters the un-reduced phase ``2*pi*f*(t+0.5)``, so the periodogram was evaluated on a frequency axis stretched by 1+2.8e-8 — measured 1.2e-4 absolute power errors at f·T ~ 3e3 against a float64 CPU port of the kernel, now at float64 roundoff (3.7e-10; 1.2e-8 for raw BJD-scale epochs through the low-level API). float32-mode results are bit-identical, and a regression test pins the double-precision path. ``nufft_lrt.cu``'s literal (unreferenced) moved under the same ``DOUBLE_PRECISION`` guard and its hardcoded float32 helpers (``fmaxf``/``fmodf``/``fabsf`` on ``FLT`` operands) are retyped — the double-mode matched filter now matches a float64 reference exactly instead of to ~3e-9. ``tls.cu``'s literal was dead code in a float32-only kernel and is removed (A5000-validated: TLS and float32 NUFFT-LRT outputs bit-identical). See `PI_HYGIENE.md `_ + * NUFFT-LRT ``compute_nufft`` docstring/pipeline-test mock corrected to the transform's actual phase convention (``exp(2*pi*i*f_k*t)``; at the time of that July fix with absolute ``t``, and since the Sep-2026 NFFT epoch fix below with ``t - floor(min(t))``, i.e. phases relative to ``memory.epoch``; device-verified at corr=1.0 vs the exact adjoint DFT). The matched filter is unaffected — data and template share the transform, so the common phase cancels + * ``batched_run_const_nfreq``'s ``batch_size>1`` "multi-stream overhead" diagnosed (Jul 2026): the method builds ``batch_size`` memory sets (pinned buffers + cuFFT plan each) on every call while a single survey-scale periodogram already saturates the GPU, so the setup cost scales with ``batch_size`` with little compute to gain. **Superseded (Sep 2026):** that per-call setup is now paid once and reused across calls (see the memory-reuse entry below), so the remaining cost of a larger ``batch_size`` is device memory. Amortized over large calls, ``batch_size=4`` is ~10% faster per lightcurve than 1; the default stays 1 and the docstring now carries the guidance + * Optional cuFINUFFT backend (``use_cufinufft=True``) as a cross-check; the custom NFFT kernel remains the default. cufinufft Plans are now cached per problem shape (creation dominated the per-call cost, making the backend 0.63-0.84x the custom kernel's speed); ``free_plan_cache()`` releases the cached GPU resources + * Fixed ``lomb_scargle_simple`` double-applying inverse-variance weights (largest-error points previously got the most weight) + * Fixed ``fap_baluev`` returning exactly 0 for significant peaks (issue #14): the false-alarm probability is now evaluated in log space with ``expm1``, staying positive down to the float64 limit instead of underflowing at FAP ≲ 1e-16 + * Fixed ``lomb_scargle_async`` (direct-sums branch) gating the device→host result copy on ``transfer_to_device`` instead of ``transfer_to_host``: callers with data already on the GPU got a stale/empty periodogram back, and the copy could not be suppressed + * ``lomb_scargle_async(use_cufinufft=True)`` now raises ImportError when cufinufft is not installed instead of silently running the custom NFFT path + * Improved ``memory_requirement`` estimation (PR #59; fixes the previous NameError and now accounts for cuFFT work areas and per-batch buffers) + * Lightcurves are normalized (mean-subtracted ``t`` and ``y``) before processing for numerical stability (PRs #57/#60) + * **Sep-2026 audit fixes (correctness; reproduced on device before the fix, regression-tested against the pre-fix tree):** + * **Fixed the Lomb-Scargle NFFT w-spectrum being gridded with the psi tables of the differently sized yw grid** (root cause: ``LombScargleMemory.allocate_grids`` gave the w grid ``precomp_psi=False`` and the yw grid's ``q1/q2/q3`` while sizing it ~2x longer, displacing every point's Gaussian window by a fraction of a cell; effect: every default-path power was biased by 3e-3..2.4e-2, in float32 and float64 alike -- results move by up to ~1e-2 toward the exact GLS, now <= ~2e-4 (float32) and ~4e-8 (double) vs astropy on a 300-point 1-yr grid; tests: ``TestLombScargleAccuracy``, ``test_lombscargle.py`` tolerances tightened 1e-2 -> 1e-4). + * **Fixed ``floorf()`` on the double-precision grid coordinate in ``fast_gaussian_grid``** (root cause: the coordinate was rounded to float32 before flooring, depositing ~n0*ng/2^24 points one cell off; effect: ``use_double=True`` was *less* accurate than float32 on dense grids -- 2.3e-3 -> 2e-8 vs astropy on a 1000-point 3-yr, 109K-frequency grid; tests: ``test_fast_grid_double_precision_floor``, ``test_long_baseline_dense_grid_vs_astropy``). + * **Fixed aliased garbage for frequency grids that do not start near zero (``fmin >= ~fmax/2``)** (root cause: the NFFT grids were sized ``sigma*count`` with ``k0`` shaved off while the ``lomb`` kernel reads modes ``k0..k0+nf-1``; effect: bands such as ``run(minimum_frequency=20, maximum_frequency=30)``, ``lomb_scargle_simple`` and ``batched_run_const_nfreq`` on them returned powers of 1e4..1e36 with a wrong best frequency, and ``nf <= 8`` at ``k0=50`` returned -1 everywhere; grids are now sized from their top mode and padded to 7-smooth (cuFFT-fast) lengths -- the gridded arrays grow by ``(nf + k0)/nf`` but the cuFFT work area shrinks, so ``memory_requirement()`` for a default ``k0=1``, ``nf=1e5`` grid falls by about 2.3x, ``sigma < 3`` raises, and ``lomb_scargle_async`` hard-checks the grids; default-grid results change numerically only (FFT length; 1.9e-4 -> 1.7e-4 float32) and memory grows by ``(nf+k0)/nf``; tests: ``TestLombScargleNarrowBands``, ``TestNFFTGridChecks``, ``TestNextFastLen``). + * **Fixed wrong NFFT magnitudes for absolute-time input** (root cause: ``NFFTMemory.fromdata`` cast absolute times straight to float32 -- 0.25 d spacing at BJD scale; effect: ``NFFTAsyncProcess`` ``|ghat|`` relative error 0.8 at ``t + 2457000.5``; a float64 epoch ``floor(min t)`` is now subtracted first and stored as ``memory.epoch``, and phases are relative to it (multiply by ``exp(2 pi i f epoch)`` on the host for absolute phases); nothing changes for ``min(t)`` in [0, 1); NUFFT-LRT inherits the fix; tests: ``test_absolute_times_bjd``). + * **Fixed ``nharmonics > 1`` being ignored with ``use_fft=False`` and ``python_dir_sums=True``** (root cause: the direct-sum branch returned before the multiharmonic host solve and the direct-sum kernel only forms the H=1 moments; effect: the single-harmonic periodogram was returned, 0.5 off in power with a wrong peak; both paths now run the float64 host multiharmonic direct sums, and ``floating_mean=False``/``window=True`` with ``nharmonics > 1`` raise ``ValueError`` instead of silently returning the floating-mean result; tests: ``TestMultiharmonicDirectSums``). + * **Fixed ``amplitude_prior`` being ignored for ``nharmonics > 1``** (root cause: ``_mh_power_from_spectra`` was called without ``reg_kwargs``; effect: the unregularized power was returned, 0.9 away from the ridge reference; now 5.7e-7 (float32) / 7e-10 (double) from it; the ``run()`` docstring now says the prior is a standard deviation, not a variance; tests: ``TestAmplitudePrior``). + * **Fixed silent evaluation of non-uniform frequency grids on the implied uniform grid** (root cause: ``check_k0`` inspected only ``freqs[0:2]`` while every kernel evaluates ``fmin + i*df``; effect: concatenated or thinned grids returned powers under the wrong labels -- corr 0.009 with astropy at the user's frequencies -- through every entry point; the whole grid is now validated (spacing and first mode to 1e-6 of ``df`` with a dtype-aware rounding allowance, so ``autofrequency``/``arange`` grids of any size pass) and ``ValueError`` names the first offending point; also ``nf=1`` raises ``ValueError`` instead of ``IndexError`` and ``dy=None`` gives unit weights as documented instead of ``TypeError``; tests: ``TestCheckK0``, ``TestRunGridValidation``). + * **Fixed stale results from ``run()`` after ``LombScargleAsyncProcess.preallocate()``** (root cause: the memories were created on the null stream, which ``finish()`` does not synchronize; effect: reads after ``finish()`` were nondeterministically stale (1 to 29 of 30 on an A40, depending on load and FFT length); the memories now live on ``self.streams`` and user-supplied streams are added to it; tests: ``TestPreallocate``). + * **Changed ``batched_run_const_nfreq(only_return_best_freqs=True)`` to return the Baluev false-alarm probability of the best peak** (root cause: it returned ``1 - FAP``, which is exactly 1.0 for every FAP below 1e-16, with ``d_K=3`` even for multiharmonic runs; effect: the second return value is now the FAP itself -- small is significant -- with ``d_K = 2*nharmonics + 1``, evaluated at the best index only; ``freqs=None`` no longer drops the last ``autofrequency`` point; ``fap_baluev`` accepts ``dy=None``; tests: ``TestBatchedBestFreqs``, ``TestFapBaluevInputs``). + * **Fixed ``TypeError`` on the cuFINUFFT backend with ``use_double=True``** (root cause: ``complex64`` and float32 scaling were hard-coded; effect: the transform now runs in complex128 with ``eps=1e-12`` when the memory is double, 1e-13 from the float64 direct sums; tests: ``TestCufinufftBackend``). + * **Improved float32 NFFT accuracy on high-frequency bands and made a fractional ``minimum_frequency`` well defined** (root cause: ``nfft_shift``/``normalize`` used the first mode ``k0 = f0*spp*T`` as a float and evaluated their phases un-reduced in float32 (arguments up to ~1e5 rad); effect: ``k0`` is rounded to the integer mode -- a fractional ``minimum_frequency`` now gives the nearest integer mode's transform instead of a leakage mixture -- and the phases are reduced modulo one cycle exactly; float32 powers on bands with large ``k0`` move toward the exact GLS (5.7e-4 -> 8.6e-5 at 15-20 c/d over 1 yr; 1.4e-3 -> 8.5e-4, peak 3.3e-4 -> 4.6e-5 at 30-50 c/d over 10 yr); bit-identical for ``k0=1`` grids and in double; tests: ``test_minimum_frequency_rounds_to_an_integer_mode``, ``test_large_k0_band_matches_exact_dft``). + * **Documented that double-precision Lomb-Scargle runs are not bitwise reproducible either** (the float64 NFFT gridding is a compare-and-swap ``atomicAdd`` with the same order dependence as the float32 one; measured on an A40: 5 of 19 repeats of a ``N = 300``/``nf = 1,500`` batched run differ, by at most 6.7e-15 relative). ``docs/source/lomb.rst`` said ``use_double=True`` results were unaffected; the one test that asserted it (``TestBatchedMemoryReuse::test_per_call_use_double_matching_the_process_is_accepted``) now compares to ``rtol=1e-12``. Compare periodograms of either precision with a tolerance. + * **Documented** the uniform-grid requirement, the ``floating_mean=False`` (unweighted-mean centring) and ``window=True`` (4x astropy's window of ones) conventions, the -1 sentinel for non-finite input, the float32 error floor (~1e-4 for ``f*T <~ 1e4``, ~1e-3 at survey scale) with the ``use_double`` recommendation for FAP-grade work, and the multiharmonic/``amplitude_prior``/``dy=None`` behaviour in ``docs/source/lomb.rst`` and ``LombScargleAsyncProcess.run``; fixed the class docstring example. + * **Sep-2026 audit performance work (measured on one shared NVIDIA A40; read every ratio as indicative of that machine, not as a portable number. Bit-neutral unless the bullet says otherwise):** + * Lomb-Scargle: ``batched_run_const_nfreq`` reuses its GPU memory, cuFFT plans and pinned host buffers across calls (and uses the set ``preallocate`` built when it fits), no longer materializes an all-True frequency mask when ``ignore_freq_mask`` is not given, and validates the shared frequency grid once per call instead of once per lightcurve. Results are unchanged: bitwise in double precision and at ZTF scale (N = 300, nf = 219,000), and at large N to the float32 NFFT gridding-atomic run-to-run noise that the *unchanged* tree also shows against itself (measured up to ~6e-8 in absolute power at N = 65,000 / nf = 210,000 and ~4e-7 at nf = 30,000, i.e. ~1e-4 to ~3e-4 relative on powers near zero) -- so compare float32 Lomb-Scargle periodograms with a tolerance, not with ``np.array_equal``. Best frequencies and false-alarm probabilities are bitwise unchanged. Measured 3.5-3.7x per call at nf = 365,000 and 2.6-5.0x at ZTF scale on a *shared* NVIDIA A40. The reused device memory is held until the process object is dropped (set ``proc._batch_memory = None`` to release it early); a per-call ``nharmonics=`` keys and allocates its own set (``use_double`` is not a per-call option -- see the precision fix below), and a keyword that hands the memory a buffer or fixes its size (``t_g=``, ``lsp_c=``, ``n0_buffer=``, ``nf=``, ``k0=``, ...) opts the call out of the cache entirely. + * Lomb-Scargle: the multiharmonic (``nharmonics > 1``) host solve now solves every frequency in one stacked ``numpy.linalg.solve`` instead of a Python loop. Measured on a *shared* NVIDIA A40: 63-295x on the host solve alone (H = 1-3 at nf = 2,000-20,000; an independent re-measurement on the same pod under heavier load saw 48-136x, so the ratio is machine- and load-dependent) and 48-80x on a whole ``batched_run_const_nfreq`` call at N = 1200, nf = 7,995, H = 2-3, ``use_double=True``. Bit-neutral to ~1e-15 (bitwise for ``nharmonics = 1``); the regularization and the float64 host precision are unchanged. + * Lomb-Scargle: host-side reductions use numpy instead of the Python builtins ``sum``/``min``/``max`` on arrays (``weights``, ``LombScargleMemory.setdata``, ``fap_baluev``, the direct-sum paths). Measured 2.0x per lightcurve at N = 65,000 (1.7x with ``use_double=True``) on a *shared* NVIDIA A40, and ~150 ms per lightcurve saved at N = 1e6. Near-bit-neutral: the weight normalization moves by the last ulp (one float32 ulp in the default single-precision path, ~1e-15 in double), which is also what now makes ``cuvarbase.memory.lombscargle_memory.weights`` agree with ``cuvarbase.utils.weights`` bit for bit. + * Lomb-Scargle: the user guide (``docs/source/lomb.rst``) gains a *Reusing device memory across calls* section: what has to match for ``batched_run_const_nfreq`` to reuse a memory set, that ``preallocate``'s set is preferred, that the cached set holds device memory until the process object is dropped or ``proc._batch_memory = None``, which keywords opt a call out, and -- pre-existing behaviour that was never written down -- that the float32 path is NOT bitwise reproducible in general -- not even through the same buffers -- because the NFFT gridding accumulates with ``atomicAdd`` whose summation order is not fixed. + * **Fixed ``precomp_psi=False`` raising ``AttributeError``** (Phase 2 verification carry-over; root cause: ``nfft_adjoint_async`` dispatched on ``fast_grid`` alone and then dereferenced the psi tables ``q1/q2/q3`` that ``NFFTMemory`` only allocates with ``precomp_psi=True`` -- on every release; effect: ``NFFTAsyncProcess.run(..., precomp_psi=False)`` and the same keyword through ``LombScargleAsyncProcess`` now grid with the inline-psi ``slow_gaussian_grid`` kernel instead of crashing, agreeing with the default path to float32 roundoff; the default ``precomp_psi=True`` path is unchanged, and a memory flagged ``precomp_psi=True`` without its tables raises a clear ``ValueError``; tests: ``TestPrecompPsiDispatch`` (CPU, fake kernels), ``TestPrecompPsiFalseOnDevice``). + * **Fixed ``batched_run_const_nfreq(only_return_best_freqs=True)`` computing the Baluev FAP with the process-level ``nharmonics``** (Phase 2 verification carry-over; root cause: ``d_K`` was ``2 * self.nharmonics + 1`` while a per-call ``nharmonics=`` keyword -- honoured by the memory settings and the periodogram -- was ignored; effect: ``batched_run_const_nfreq(nharmonics=2, only_return_best_freqs=True)`` on a default process returned a ``d_K=3`` FAP for a 2-harmonic peak; the effective per-call value is now resolved once and fed to the new pure helper ``_baluev_d_K``; nothing changes when the keyword is not given; tests: ``TestBaluevDKUsesEffectiveNharmonics``). + * **Fixed a per-call ``use_double=`` silently running the float32 kernels on float64 buffers** (Sep-2026 readiness review; root cause: ``LombScargleAsyncProcess.run``/``batched_run_const_nfreq``/``allocate``/``allocate_for_single_lc``/``preallocate`` and ``NFFTAsyncProcess.run``/``allocate`` forwarded their keywords to the memory constructors, which take ``use_double`` too, while the ``lomb``/``cunfft`` kernels are compiled and prepared once, at construction, in the process precision; effect: ``run(..., use_double=True)`` on a default process allocated float64/complex128 device buffers that the float32 kernels read as float32 and returned a wrong periodogram with a plausible float64 dtype -- on every earlier release, and the 1.0 memory-reuse notes briefly presented the keyword as a supported per-call override; now a per-call ``use_double`` that differs from the process precision raises ``ValueError`` (naming ``LombScargleAsyncProcess(use_double=...)`` as the fix) before any device work, an equal value is accepted and ignored, a ``memory=`` allocated at the other precision is rejected the same way, and ``lomb_scargle_simple(..., use_double=True)`` builds its process in double precision; ``nharmonics=`` remains a per-call override; tests: ``TestPerCallUseDoubleIsRejected`` in ``test_lombscargle.py`` and ``test_nfft.py``, ``test_per_call_use_double_matching_the_process_is_accepted``). + * **Fixed the NFFT kernels re-deriving the integer first mode from float32 arguments** (Sep-2026 readiness review; root cause: ``nfft_shift``/``normalize`` computed ``k0 = rint(f0 * spp * (xf - x0))`` from their float32 ``f0``, ``spp`` and ``xf - x0``, whose combined rounding reaches half a mode from ``k0 ~ 2e6`` upward -- about 0.5% of grids in [2e6, 3e6), ~18% in [4e6, 5e6) -- and the two kernels associate the product differently, so one could shift the band by one mode while the other did not; effect: in the float32 build, bands with ``fmin * samples_per_peak * T`` above ~2e6 (e.g. a 200 c/d band over 10 years at 5 samples per peak) could come back shifted or internally inconsistent by one mode while ``check_k0`` accepted the grid; the host now computes ``k0`` once in float64 (``cuvarbase.cunfft._first_mode``, exact to beyond 1e9) and passes the integer to both kernels, ``lomb_scargle_async`` hands the NFFT ``k0 * df`` instead of ``freqs[0]`` so a float32 grid cannot reintroduce the rounding, and a first mode outside int32 raises ``ValueError``; bit-identical wherever the float32 product rounded correctly (all of ``k0 < ~2e6``, and every double-precision run); the float32 build remains inaccurate for other reasons at such ``k0`` (its grid coordinate is only good to ~2 cells of a 1e7-point grid), so use ``use_double=True`` there; tests: ``TestFirstModeIsExactOnTheHost``). + * ``batched_run_const_nfreq`` now validates the shared frequency grid (``check_freqs``/``check_k0``) before it compiles the kernels and creates its streams, as ``run()`` already did, so a rejected grid leaves the device untouched as the validation notes promise (Sep-2026 readiness review; its two grid-rejection cases in ``TestEntryPointsRejectBadGrids`` no longer need a GPU). + * **PDM** (community contribution by @astrobatty — PR #62) + * Fast shared-memory CUDA kernels for all four variants: ``binned_step_fast``, ``binned_linterp_fast``, ``binless_tophat_fast``, ``binless_gauss_fast`` + * Backward-compatible ``(t, y, err)`` input API for ``PDMAsyncProcess.run()`` with automatic frequency grids; the legacy ``(t, y, w, freqs)`` format is deprecated (emits DeprecationWarning) + * Unit tests for all kernel variants and new Sphinx documentation (``docs/source/pdm.rst``) + * Batch APIs (issue #33): ``PDMAsyncProcess.batched_run_const_nfreq`` processes a lightcurve collection in memory-bounded chunks that share one frequency grid (peak GPU memory scales with ``batch_size``, not the number of lightcurves), and ``large_run`` auto-picks ``batch_size`` from the free GPU memory. A ``scripts/benchmark_pdm.py`` GPU-vs-CPU benchmark + correctness check was added + * Fixed the CPU reference functions (``binless_pdm_cpu``, ``pdm2_cpu``, ``pdm2_single_freq``) mutating the caller's ``t``/``y`` arrays in place + * **Fixed an out-of-bounds bin read in the ``binned_step`` PDM kernel** (root cause: the variance loop of ``var_step_function`` did not wrap ``(int)(PHASE * NBINS)`` with ``% NBINS``, so an observation whose float32 phase rounds to exactly 1.0 -- ``t*f`` in ``(-3e-8, 0)`` cycles after mean-centering -- indexed ``bin_means[NBINS]``; effect: ``kind='binned_step'`` deviated by up to 0.02 from ``binned_step_fast`` and the float32-fold reference at such frequencies and now agrees to float32 round-off, all other results are bit-identical; tests: ``test_pdm.py::test_binned_step_phase_exactly_one_no_oob_read``) + * **Fixed the deprecated PDM ``(t, y, w, freqs)`` input format returning a flat spectrum of 1.0 when the weights were not normalized** (root cause: the host-side weighted mean and variance assumed ``sum(w) == 1`` but the legacy path passed the caller's weights through unchanged; effect: ``PDMAsyncProcess.run()`` now normalizes legacy weights, so raw ``1/err**2`` or all-ones weights give the same result as the modern ``(t, y, err)`` path (bit-identical on the A40) and already-normalized weights are unaffected; tests: ``test_pdm.py::test_deprecated_format_normalizes_weights``) + * **Documented the PDM statistic that the kernels actually compute** (``docs/source/pdm.rst``, the PDM notebook and ``PDMAsyncProcess.run``: the returned power is ``1 - SS_within/SS_total`` with normalized weights and no degrees-of-freedom correction, not Stellingwerf's ``1 - Theta``; for pure noise it sits at ``(M - 1)/(N - 1)`` -- about 0.4 for 20 points in 10 bins -- values are not comparable across ``nbins``/``dphi``/``N``, and ``M`` (occupied bins) varies with frequency for gappy data; tests: ``test_pdm.py::test_pdm2_cpu_is_ss_ratio_without_dof_correction``, ``::test_pdm2_cpu_noise_floor_is_M_minus_1_over_N_minus_1``, ``::test_gpu_binned_step_statistic_and_noise_floor``) + * **Corrected PDM documentation drift** (``dphi`` is the tophat half-width / Gaussian standard deviation in cycles, not a 'phase width'; the ``*_fast`` kernels are numerically equivalent but were measured at only 0.7-2.0x on Ada, so 'substantially quicker' is replaced by 'may be faster on some GPUs; benchmark'; a new 'Numerical notes' section explains the float32-only phase fold with the ``T * f_max * nbins <~ 1e5`` criterion and measured deviations; tests: ``test_pdm.py::test_run_docstring_states_statistic_and_dphi_semantics``) + * **A PDM lightcurve given as a ``(t, y)`` 2-tuple now raises ``ValueError`` naming the expected shape** (root cause: the input validator accepted any tuple of two or more elements, so ``run``/``large_run`` reached the ``(t, y, err)`` unpack and died with a raw ``not enough values to unpack (expected 3, got 2)`` -- unlike every other entry point, which names the lightcurve and the expected tuple; effect: ``PDMAsyncProcess.run lightcurve 1: must be a (t, y, err) tuple; got 2 elements``, and a batch that mixes the deprecated ``(t, y, w, freqs)`` tuples with 3-tuples is rejected the same way instead of failing on the unpack; Sep 2026 review; tests: ``TestPDMTupleShape``). + * **A constant ``y`` is rejected by every PDM entry point, the deprecated format included** (root cause: the input validator only required two observations, but the statistic ``1 - SS_within / SS_total`` divides by the weighted variance of ``y``, which is zero for any number of equal values -- audit id 115, row 227 -- so the spectrum was all NaN with no warning; effect: ``ValueError: ... y is constant (all N values equal v); ...`` before any device work; Sep 2026 review; tests: ``TestPDMConstantY``). + * **The deprecated ``(t, y, w, freqs)`` format's weights must be finite and strictly positive** -- the same rule as ``err`` on the ``(t, y, err)`` format -- so a zero weight, which some pre-1.0 pipelines used to mask a point while keeping the array shapes, now raises ``ValueError`` (``w must be finite and > 0``). The binned kernels happened to skip zero-weight points (empty bins are skipped), but ``binless_tophat`` divided 0/0 when a masked point's window held no other point, and the ``(t, y, err)`` format cannot express a masked point at all; drop masked points from the arrays instead. Disclosed following the Sep 2026 review (the rejection itself dates from the audit validators). + * **Conditional Entropy** (community contribution — PR #61) + * Optional log-probability periodogram via ``compute_log_prob=True`` + * Lightcurves normalized before processing; 32-bit overflow guard for large ``nfreq x ndata`` runs; clear error for the unsupported ``use_fast`` + ``weighted`` combination + * CE is now in **maintenance mode**: it keeps working, but no new development is planned — for an actively developed GPU CE/AOV search see `periodfind `_ + * **Sep-2026 audit performance work (measured on one shared NVIDIA A40; read every ratio as indicative of that machine, not as a portable number. Bit-neutral unless the bullet says otherwise):** + * **Conditional entropy ``use_fast=True`` sizes its CUDA grid from the device.** The shared-memory kernels used to launch ``floor(2 * shmem_lim / shmem)`` blocks when the lightcurve fitted in shared memory (34 blocks at 300 observations, 5 at 2000, 3 in double precision -- whatever the GPU) and were capped at 200 blocks otherwise; the grid is now ``num_SMs x blocks-resident-per-SM``, capped at the number of trial frequencies, and ``max_nblocks`` no longer defaults to 200 (it still caps the grid when you pass one). **Bit-neutral**: each block owns one frequency and strides by ``gridDim.x``, so no returned value changes -- verified with ``np.array_equal`` across grid sizes from 1 to 4096 in single and double precision. Measured on **one NVIDIA A40 shared with other jobs** (ratios within a single alternating A/B session, not portable numbers), at 100,000 trial frequencies with 10 x 5 bins: kernel time 1.3x-39x faster and whole-``run()`` wall time 1.25x-22.6x faster, the largest gains at 1000-2000 observations. + * **``use_fast=True`` is now the faster conditional-entropy path in single precision, not the slower one.** With the grid fixed it beat the default kernels by 1.2x at (300 obs, 1e5 frequencies), 1.9x at (2000, 1e5) and 8x at (10,000, 1e5) on the same shared A40, and was within noise of them for small grids. The docstring and ``docs/source/ce.rst`` no longer say it "is not generally faster on current GPUs". + * **Conditional entropy ``use_fast=True`` no longer allocates the global histogram its kernels never read.** That array is ``nfreq x phase_bins x mag_bins`` uint32 -- 20 MB per resident lightcurve for a 100,000-frequency 10 x 5 search -- and ``run(memory=...)`` zero-filled it on every call. ``memory_requirement()`` reflects the saving. **Bit-neutral** (verified ``np.array_equal`` with and without the array, single and double precision). The standard kernels raise a clear ``ValueError`` if handed a memory object allocated this way. No measurable change in wall time on the A40; the win is device memory, which is usually what limits batch size. + * **PDM ``run()`` reuses its device buffers across calls of the same shape.** It used to allocate five device arrays, a page-locked host buffer and a synchronous frequency upload every single call, including every chunk of ``batched_run_const_nfreq`` and ``large_run``. The frequency grid is re-uploaded only when it changed, peak device memory is unchanged, and each call still returns its own result array, so a periodogram kept from an earlier ``run()`` is never overwritten (passing your own ``gpu_data``/``pow_cpus`` bypasses the cache as before). The cache keeps a *private copy* of the grid for that comparison (Sep 2026 review): at 000c299 it stored ``np.asarray(f, float32)``, which is the caller's own array for a float32 grid, so a grid modified in place between two same-shape calls compared equal to itself, the device kept the old grid, and the powers came back labelled with the new one (float64 grids, and ``batched_run_const_nfreq``/``large_run``, were never affected). **Bit-neutral.** On **one shared NVIDIA A40**: a ``run()`` at 200-1000 observations with 500-20,000 frequencies is ~1.3x-2.7x (A40, shared; largest at short lightcurves and small grids) faster, a 64-lightcurve ``batched_run_const_nfreq`` (250 points, 5000 frequencies) is 2.0x faster, and kernel-bound sizes (>= 10,000 observations) are unchanged. + * **Sep-2026 audit fixes (correctness; reproduced on device before the fix, regression-tested against the pre-fix tree):** + * **Fixed CE binning of the brightest point** (root cause: ``setdata`` normalizes y to [0, 1] and took ``floor(y * mag_bins)``, giving the brightest point the out-of-range index ``mag_bins``, which no kernel clamped -- the standard kernel spilled the count into the next phase bin / next frequency / one element past ``bins_g``, the shared-memory kernels aliased bin 0, and ``compute_mag_bin_fracs`` dropped the point; effect: every unweighted CE run shifts by O(1/N) -- max ``|GPU - float64 reference|`` 4.5e-1 (N=5), 6.1e-2 (N=60), 7.0e-3 (N=500) before, <= 3.3e-7 after; the standard and fast kernels now agree to 2.4e-7 (was 4.6e-3) and the standard kernel's output no longer depends on the order of the frequency grid; tests: ``TestCEBrightestPoint``, ``test_fast`` tightened from ``2e-2*max`` to 1e-5/1e-10). + * **Fixed the weighted-CE ``max_phi`` truncation** (root cause: ``histogram_data_weighted`` skipped a magnitude bin by the distance to its LOWER edge only, so bins below the datum lost their mass and the brightest point lost all of it; also ``dm * p_phi / pmn`` overflowed to inf for tiny bin masses; effect: ``weighted=True`` results change -- histogram masses now within 6e-3 of the ``scipy.special.ndtr``-integrated masses (was up to 4.2), CE within 6e-4 of the exact-mass CE (was 2e-2 .. 5e-2), finite at any ``max_phi``; ``weighted=False`` is bit-identical; tests: ``TestCEWeighted``). + * **Fixed ``use_double=True, use_fast=True`` crashing with ``misaligned address``** (root cause: the fast kernels used a byte remainder as an element offset when padding the shared-memory ``Hc`` array, and the host computed its pad after the lightcurve block; effect: any (phase_bins, mag_bins) with ``(mag_bins + 1) * phase_bins`` odd killed the CUDA context, e.g. (5, 4), (7, 6), (3, 4); no result change where it ran before; tests: ``TestCEDoubleFast``, ``test_fast`` parametrized over the odd layouts). + * **Fixed ``balanced_magbins`` / ``widen_mag_range`` being ignored when passed to the ``ConditionalEntropyAsyncProcess`` constructor** (root cause: the constructor never stored them and the three memory-kwargs builders did not forward them; effect: constructor callers now get balanced bins (was identical to uniform bins); unsupported combinations -- weighted+use_fast, weighted+balanced, weighted+log_prob, use_fast+balanced, use_fast+log_prob, balanced+log_prob, balanced+mag_overlap>0 -- now raise ``ValueError`` from the constructor and from per-call kwargs instead of silently running another kernel; balanced bin edges are now the midpoints between adjacent sorted groups (widths sum to 1, floored at 1e-6) so quantized magnitudes no longer give ``-inf``; tests: ``TestCEBalanced``, balanced parametrization of ``test_inject_and_recover`` / ``test_time_shift_invariance`` fixed to actually run the balanced kernel). + * **Fixed ``ConditionalEntropyAsyncProcess.preallocate()``** (root cause: it never uploaded the frequency grid -- every later ``run()`` evaluated all frequencies at f = 0 -- and bound the memory to ``stream=None`` so ``finish()`` did not cover the result copy; effect: preallocate-then-run goes from constant / stale output to bit-identical with the fresh path; ``run(memory=...)`` re-uploads a changed grid of the same length and raises for a different length or too few memory objects; the fast kernels launch on the memory's stream; tests: ``TestCEPreallocate``). + * **Fixed CE recompiling its CUDA module on every call** (root cause: the compile gate looked for a prepared function named ``'ce_wt'`` that no compile ever produced; effect: one nvcc build per process instead of one per ``run``/``large_run`` call, results unchanged; tests: ``TestCEReuse``). + * **Fixed ``run(memory=..., set_data=False)`` accumulating histograms across calls** (root cause: ``bins_g`` was only zeroed on the ``set_data=True`` path; effect: repeated calls are now idempotent (counts no longer grow 3000 -> 9000; ``compute_log_prob`` no longer corrupted); tests: ``TestCEReuse.test_set_data_false_repeat_is_idempotent``). + * **Fixed float32 (or integer / non-Python-float) frequency arrays being rejected** with "number of frequency grids (nf) does not match number of lightcurves (1)" (root cause: ``isinstance(freqs[0], float)`` misclassified a float32 grid as a list of per-lightcurve grids; effect: any 1-D numeric array or list of scalars is accepted by ``run``/``large_run``/``allocate``; tests: ``TestCEFrequencyInput``). + * **Documented** that the CE periodogram is Graham et al. (2013)'s ``H(m|phi)`` plus ``sum_m p(m) log(dm_m)``, the mass-weighted mean of the log magnitude-bin widths -- with ``mag_overlap = 0`` this is the constant ``log(1 / mag_bins)``, but with ``mag_overlap > 0`` the unweighted kernels use the truncated width ``min(mag_overlap + 1, mag_bins - m) / mag_bins`` for the top bins while the weighted kernel uses the constant ``(mag_overlap + 1) / mag_bins``, so weighted and unweighted spectra differ by a constant; the offset is frequency-independent in every case, so the best frequency is still the argmin -- that ``compute_log_prob`` returns the Poisson log-likelihood under the phase-independent null (also minimized at the true frequency), the actual ``mag_bins`` default (5), what ``use_fast`` does, the full unsupported-option matrix, and the ``preallocate`` reuse pattern (``docs/source/ce.rst``, class docstring). + * **Fixed ``allocate()`` + ``run(memory=...)`` evaluating every frequency at f = 0** (root cause: ``allocate`` only creates a zero-filled ``freqs_g``, and nothing uploaded the grid unless the caller remembered ``transfer_freqs_to_gpu()``; effect: the memory-reuse path now uploads the grid on the first ``run`` -- ``ConditionalEntropyMemory`` tracks whether its grid is on the device -- instead of returning the f = 0 spectrum; ``transfer_freqs_to_gpu(freqs=...)`` accepts a replacement grid and raises ``ValueError`` if it does not fit the allocation, and keeps a private copy of the grid it uploaded (Sep 2026 review: it stored the caller's own float32 array, so a grid modified in place between two ``run(memory=...)`` calls compared equal to itself and stayed stale on the device); tests: ``TestCEBrightestPoint.test_no_write_past_bins``, ``TestCEPreallocate``). + * **Fixed ``balanced_magbins=True`` putting the brightest point(s) in the faintest magnitude bin** for some ``(mag_bins, N)`` (root cause: group boundaries were ``int(i * (len(y) / mag_bins))``, and for 471 of the 37,810 combinations with ``mag_bins`` in 2..20 and ``N`` up to 2000 -- e.g. (7, 61), (7, 115), (11, 353) -- the float product fell short of ``len(y)``, so the last sorted points were never assigned and kept ``ybins = 0``; effect: boundaries are now ``(arange(mag_bins + 1) * N) // mag_bins``, every point is assigned and each bin holds ``floor(N / mag_bins)`` points or one more; balanced results change for the affected combinations; tests: ``TestCEBalanced.test_balanced_bin_bounds_cover_every_point``, ``TestCEBalanced.test_balanced_brightest_point_on_gpu_ragged_n``). + * **``use_fast=True`` with ``compute_log_prob=True`` now raises ``ValueError``** (root cause: ``conditional_entropy_fast`` only launches the shared-memory CE kernels, so a process built with both options -- or a per-call ``compute_log_prob=True`` on a ``use_fast`` process -- silently returned the plain conditional entropy instead of the requested Poisson log-likelihood, and the option matrix documented at 000c299 omitted the pair; effect: the constructor, ``ConditionalEntropyMemory`` and the per-call kwargs of ``run``/``large_run``/``batched_run_const_nfreq``/``preallocate`` reject it like the other unsupported combinations, and per-call option kwargs are now validated on the host before the kernels are compiled; Sep 2026 review; tests: ``TestCEBalanced.test_use_fast_with_log_prob_raises_everywhere``). + * **``run(memory=...)`` (and ``run`` on the memory from ``preallocate``) rejects per-call option kwargs that disagree with the memory** (root cause: the kernels dispatch on the memory object's ``weighted`` / ``compute_log_prob`` / ``balanced_magbins`` flags and its ``phase_bins`` / ``mag_bins`` histogram, so ``run(data, memory=mem, balanced_magbins=True)`` -- or any of ``weighted``, ``compute_log_prob``, ``mag_bins``, ``phase_bins``, ``mag_overlap``, ``phase_overlap``, ``max_phi``, ``use_double``, ``widen_mag_range`` -- was silently ignored although ``docs/source/ce.rst`` said it raised; effect: a mismatch raises ``ValueError`` naming the option and the memory's value, and the memory's own option combination is re-checked against the process's ``use_fast`` (a ``weighted=True`` memory run through the fast kernels had its float magnitudes read as bin indices); the checks run before the kernels are compiled; per-call options that match the memory, and unrelated kwargs such as ``block_size``, are unaffected; Sep 2026 review; tests: ``TestCEMemoryOptionMismatch``). + * **A constant ``y`` is rejected by every conditional-entropy entry point** (root cause: the input validator added for the Sep-2026 audit (defect 23) only required two observations, but ``setdata``'s ``(y - min) / (max - min)`` is 0/0 for any number of equal magnitudes -- audit id 115, rows 221/227 of the input-handling matrix; the NaN bin indices were cast to uint32 (a platform-defined value; 0 on x86-64 numpy 1.26) and the spectrum was flat garbage with no warning; effect: ``ValueError: ... y is constant (all N values equal v); ...`` from ``run``/``large_run``/``batched_run_const_nfreq`` before any device work; two distinct magnitudes remain enough; Sep 2026 review; tests: ``TestCEConstantY``). + * **Earlier TLS binned/legacy development (historical; the observation-level default is documented above)** + * GPU Transit Least Squares (``cuvarbase.tls``) with Ofir (2014) period grids, golden-tested against the reference ``transitleastsquares`` package + * **TLS batch engine (July 2026):** the default fast path folds each lightcurve/period pair into weighted phase bins, scans integrated transit templates with an analytic weighted depth fit, and refines selected candidates against individual observations. The coarse spectrum supplies SDE; refinement sharpens candidate parameters. Periods are grouped by required bin count, template tables are reused, and duration-grid construction is vectorized. The legacy per-point path remains available with ``use_fast=False``. Timing, component evidence and independent recovery qualifications are in ``docs/TRANSIT_BENCHMARKS.md``. Batch validation checks input lengths, duration bounds, block sizes and refinement counts; offsets are 64-bit. + * TLS epoch (t0) grid is now duration-scaled (stride = duration / oversample, floor 30, cap 20,000 epochs): the previous fixed 30-epoch grid missed transits narrower than ~1/30 of the period entirely, which broke Keplerian-mode searches for most periods > ~3.5 d. The oversample factor is caller-tunable via ``t0_oversample`` on ``tls_search``/``tls_search_gpu``/``compile_tls`` (default 3.0; choose the resolution using injection recovery and null calibration for the intended cadence). Mirrored in ``tls_grids.t0_grid_size()`` + * Removed the TLS kernels' bitonic phase sort: it was incomplete for non-power-of-2 sizes and its output order was never consumed — pure wasted per-period work; results are unchanged + * Added golden accuracy tests against the reference ``transitleastsquares`` package (``test_tls_golden.py``) + * TLS hardening: ``tls_search_gpu`` now raises ValueError when the shared-memory layout exceeds the 48 KB budget (~3,500 points) instead of failing at kernel launch; failed trial periods (1e30 chi2 sentinel) are masked out of the best-fit search and the SDE statistic (previously they collapsed the SDE, and drove the since-removed heuristic FAP to 1); ``signal_to_noise`` no longer inflates by sqrt(n_transits); ``false_alarm_probability``'s heuristic is no longer misattributed to Hippke & Heller (2019); batman template failures now warn instead of silently substituting a trapezoid + * **Fixed the default TLS duration window** (root cause: ``tls_search_gpu``/``tls_search`` without ``qmin``/``qmax`` searched a constant fractional-duration window [0.005, 0.15] at every trial period while the default Ofir grid runs to span/2, so beyond P ~ 60 d for a Sun-like star (18.5 d for an M dwarf) no trial duration was physical; effect: a P = 365 d transit on a 1400-d baseline came back at 182.5 d with half the depth; fix: the window is now the per-period Keplerian one ``[qmin_fac, qmax_fac] x q_kep(P; R_star, M_star, R_planet)`` from the new ``tls_grids.duration_window()`` on every entry point, with new ``R_planet``/``qmin_fac``/``qmax_fac``/``duration_window`` keyword arguments; the old window remains as the opt-in ``duration_window='fixed'`` and warns when it is unphysical for the grid; the legacy ``use_fast=False`` path always runs the per-period-bounds kernel, so ``n_durations`` is now honoured on the default window too (previously, without ``qmin``/``qmax``, the fast path forced 15 with a warning and the legacy standard kernel hard-coded 15); user period grids reaching sub-Roche periods (where 2*q_kep >= 1, below ~0.07 d for a Sun-like star) now raise ``ValueError`` where the constant window accepted them silently; default-path periods/depths change at long periods and by a few percent elsewhere; tests ``test_tls_basic.py::TestDefaultDurationWindow``, ``test_tls_fast.py::TestDurationWindowDefault``, ``test_tls_golden.py::TestLongPeriodDurationWindow``). + * **Removed the TLS ``'FAP'`` result key and added an opt-in null bootstrap** (root cause: the value was a fixed piecewise function of the SDE, discontinuous at SDE = 7 and unrelated to the null distribution, which itself moves with the period grid and baseline; effect: 21-23% of pure-noise light curves received FAP < 0.01; fix: no TLS result carries ``'FAP'`` unless requested through ``tls_search_batch(fap_null_draws=N, fap_seed=...)`` -- the bootstrap is batch-only, and ``tls_search``/``tls_search_gpu``/``tls_transit`` now raise ``TypeError`` on ``fap_null_draws``/``fap_seed`` (or any other unknown keyword) instead of silently dropping them -- which permutes each light curve's fluxes over its times N times, searches the identical grid and returns the empirical exceedance ``(1 + #null SDE >= observed)/(N + 1)`` plus ``'SDE_null'``; ``tls_stats.false_alarm_probability`` survives only as an explicitly heuristic helper that warns; the false 'SDE > 7 for 1% false alarm' and 'preserves the false-alarm calibration' claims are gone from the docs; tests ``test_tls_basic.py::TestFAPRemoved``, ``test_tls_fast.py::TestFAPKey``). + * **Fixed the meaning of the TLS ``'T0'`` key** (root cause: it was a fold phase relative to floor(min t) on the fast path, a phase relative to t = 0 on the legacy path, and an absolute time that could precede the first observation on the batch path; fix: ``'T0'`` is now the absolute mid-transit time of the first transit at or after ``min(t)`` (``min(t) <= T0 < min(t) + period``, the reference package's convention) on every path and ``'t0_phase'`` (phase relative to floor(min t)) is returned everywhere; ``TLSMemory.setdata`` subtracts floor(min t) in float64 before the float32 cast so the legacy path is BJD-safe; fold with ``((t - T0)/period) % 1``; ``examples/tls_example.py`` and the docs updated; tests ``test_tls_fast.py::TestT0Semantics``, ``test_tls_basic.py::TestT0Convention``). + * **TLS SDE now uses the reference package's definition** (root cause: the signal residue was ``1 - chi2/max(chi2)`` and the running median was zero-padded; effect: identical under the null but up to 2x lower SDE for strong signals, so published SDE thresholds did not transfer, and inflated detrended power at the grid edges; fix: ``SR = chi2_min/chi2``, ``SDE_raw = (1 - mean SR)/std SR``, edge-extended running median identical to ``transitleastsquares.stats.running_median``, on every path; ``tls_stats.signal_residue(chi2_null=)`` is deprecated and ignored; SDE values change on every path (a strong-signal SDE of 14.95 becomes 22.80, equal to the reference's ``spectra()`` on the same spectrum); tests ``test_tls_basic.py::TestReferenceSRDefinition``, ``TestRunningMedianEdges``, ``test_tls_golden.py::TestSDEParityWithReference``). + * **TLS SNR is the delta-chi-squared significance** (root cause: it used ``max(chi2)`` over the grid and the coarse chi2; fix: ``SNR = sqrt(chi2_0 - chi2_min)`` with the float64 constant-model chi2 and the refined best-fit chi2 on all paths, documented as distinct from the reference's ``depth/std*sqrt(n_in_transit)``; test ``test_tls_fast.py::TestSNRDefinition``). + * **TLS accepts period grids in any order** (root cause: the running-median detrend and the period-uncertainty neighbour walk assumed ascending periods; effect: a descending grid, which the reference package returns, gave a negative ``period_uncertainty`` and a shuffled grid changed the SDE; fix: grids are validated and sorted on entry and every per-period output array is returned in the caller's order; ``transfer_to_device=False`` with a non-ascending grid raises ``ValueError``; tests ``test_tls_basic.py::TestSortedPeriodGrid``, ``test_tls_fast.py::TestUnsortedPeriodGrid``). + * **A flat or noiseless light curve returns SDE = 0 from TLS** (root cause: every trial period fails the kernels' depth check and the wrappers raised ``RuntimeError`` (or returned ``{'error': ...}`` on the batch path); fix: all three paths return a null result with SDE = 0, NaN best-fit parameters and the message under ``'error'``, with a warning, like the reference package; tests ``test_tls_fast.py::TestFlatLightCurve``, ``test_tls_basic.py::TestFailedPeriodMasking``). + * **TLS docs state the fixed baseline and the coarse epoch grid cost** (no code change: the model's out-of-transit level is fixed at exactly 1 with the measured sensitivity to a normalization offset, and ``t0_oversample=3`` loses 11-17% of SDE for narrow transits; raise it to 10 for sensitivity-critical searches). + * **TLS rejects ``dy=None``, ``n_durations < 2`` and unknown keywords on every path** (root cause: the shared validator accepts ``dy=None`` for the entry points that document unit weights, but TLS has no such convention, so the fast path built all-NaN weights and returned the flat-light-curve null result (SDE = 0) while the legacy path raised a bare ``TypeError``; the legacy ``use_fast=False`` path forwarded ``n_durations`` to the kernel unchecked, whose log-spaced duration step is 0/0 at ``n_durations=1``, again a null result for a good light curve; and ``tls_search``/``tls_search_gpu``/``tls_transit`` read only ``n_template`` from ``**kwargs`` and dropped any other keyword without a word; fix: ``dy=None`` raises ``ValueError`` on ``tls_search``/``tls_search_gpu``/``tls_transit``/``tls_search_batch``, ``n_durations`` must be an integer >= 2 on both paths (the fast path keeps its cap of 64), and an unknown keyword raises ``TypeError`` (with a pointer to ``tls_search_batch`` when it is ``fap_null_draws``/``fap_seed``); no result changes for valid input; tests ``test_tls_basic.py::TestTlsInputGuards``). + * **Sep-2026 audit performance work (measured on one shared NVIDIA A40; read every ratio as indicative of that machine, not as a portable number. Bit-neutral unless the bullet says otherwise):** + * TLS: ``tls_transit`` no longer builds the ``(n_periods x n_durations)`` Keplerian duration table that nothing downstream reads. It takes its per-period duration bounds from ``tls_grids.duration_window``, the same helper the other TLS entry points use, so all four entry points now share one window implementation. Bit-neutral: the bounds are bitwise identical. Measured on an NVIDIA A40 (shared GPU, so ratios only): 1.22x at 2,486 trial periods, 1.74x at 42,001, 1.37x at 171,688. + * TLS: ``tls_search_batch`` computes its per-lightcurve statistics one lightcurve at a time instead of on a thread pool. The work is GIL-bound NumPy/SciPy, so the pool made it slower -- the statistics alone were 21.7 ms sequentially versus 40.3 ms on 8 threads. Bit-neutral. Measured on an NVIDIA A40 (shared GPU, ratios only): 2.16x for 64 TESS-FFI-scale lightcurves, 1.61x for 8, 1.20x for 16 TESS-year-scale ones. Per-lightcurve warnings now appear in lightcurve order rather than in worker-thread order. + * TLS: ``tls_models.generate_template_tables`` memoizes its result on ``(n_table, limb_dark, u, oversample)`` in a small LRU, so repeated searches no longer rebuild the batman reference transit behind the fast kernel's template tables. Each call still returns fresh, writable arrays. The key also records ``BATMAN_AVAILABLE``: with batman not installed the trapezoid is the template (warned once at import) and its tables are cached under that key, while a trapezoid substituted for a batman call that *failed* is never cached, so that warning keeps firing on every call. Bit-neutral. Measured on an NVIDIA A40 (shared GPU, ratios only): 1.19x on a single 2,486-period search of a 1,310-point lightcurve, 1.04x at 42,001 periods, no measurable change at 171,688. + * TLS: combined effect of the three changes above, measured against 1.0's previous state on an NVIDIA A40 (shared GPU, ratios only, both versions loaded side by side in one process): ``tls_transit`` 1.44x / 1.62x / 1.50x at TESS-FFI / TESS-year / Kepler-4yr scale, and ``tls_search_batch`` 2.41x for 64 TESS-FFI lightcurves. All bit-neutral. + * **Experimental** (UserWarning at first construction; quarantined outside the top-level namespace; not yet validated for science use) + * NUFFT-LRT matched filter (``cuvarbase.nufft_lrt``, contributed by **Jamila Taaki** / @xiaziyna) — **reinstated** with a GPU rewire. The data and each transit template are now transformed with the GPU adjoint NFFT (``NFFTAsyncProcess``), which takes the raw non-uniform times directly over the full baseline — fixing both defects that got it cut (the earlier path computed a uniform-grid RFFT on the host, never invoking the GPU, and its ``median(dt)*nf`` grid silently truncated multi-season/gappy data). The per-template matched-filter combination still runs on the host. CPU tests verify the rewired pipeline is sensitive to data across the full baseline; it remains EXPERIMENTAL; the full injection-recovery validation of the fixed code ran on 2026-09-06 (see the D1 entry above and ``docs/source/nufft_lrt.rst``) + * NUFFT-LRT detectors: ``NUFFTLRTAsyncProcess.run(..., detector='matched' | 'marginal' | 'sequential', systematics_basis=None, coeff_prior_mean=None, coeff_prior_cov=None)`` selects the stationary PSD-whitened matched filter (default), Detector A of Taaki, Kamalabadi & Kemball (2020) — systematics coefficients marginalized under a Gaussian prior, evaluated in the whitened frequency domain through the Woodbury identity — or the papers' sequential baseline (least-squares cotrend against the basis, then the filter on the residual). ``systematics_basis`` is an ``(n, K)`` array; ``'marginal'`` also needs ``coeff_prior_cov``. Test status: CPU tests of the Detector-A algebra (the Woodbury path against a dense inverse of the realified covariance) and of the pipeline; GPU behavioural tests (NFFT against the exact adjoint DFT, multi-season detection, BJD-scale invariance, the Sep-2026 regression tests); the injection-recovery re-validation after the Sep-2026 fixes ran on 2026-09-06: Detector A now equals the sequential baseline exactly (3/44/98/100 % at depths 0.004/0.008/0.016/0.032 with a shared-systematics basis, against 0/0/2/16 % for basis-free BLS), a non-zero-mean basis changes nothing (5.5e-7), and the whitened filter is 6-10 % more complete than BLS in OU red noise but no better than a flat-PSD filter. Outside the 1.x API-stability promise (see the D1 entry) + * **Sep-2026 audit fixes to NUFFT-LRT (correctness; reproduced on device before the fix, regression-tested against the pre-fix tree):** + * NUFFT-LRT (experimental): absolute timestamps are now safe. ``NUFFTLRTAsyncProcess.run`` subtracts ``floor(min(t))`` in float64 before anything is cast to the device precision, and shifts any supplied ``epochs`` into the same frame. BJD-scale input previously returned a different statistic on all three detectors (correlation ~0.5 with the epoch-relative result, different argmax). + * NUFFT-LRT (experimental, BREAKING): ``epochs=None`` is now a real epoch search. It scans ``clip(ceil(2 P / duration), 8, 96)`` epochs per (period, duration) cell and RETURNS A TUPLE ``(snr, best_epoch)`` of two ``(len(periods), len(durations))`` arrays instead of a single array; ``best_epoch`` is a transit mid-time in the caller's time scale. Previously it evaluated one phase-0 template per cell, which recovered 0 of 12 transits injected at random epochs. Explicit ``epochs`` are unchanged and still return the ``(nP, nD, nE)`` array. The grid is tunable with ``epoch_oversample``/``min_epochs``/``max_epochs``, and costs that many transforms per cell. + * NUFFT-LRT (experimental): ``detector='sequential'`` fits the systematics basis WITH an intercept (basis columns and data are centred before the least-squares solve), so basis vectors need not be zero-mean. A column mean of 1% on relative flux previously dropped the statistic at the true period from ~25 to ~5. + * NUFFT-LRT (experimental): ``detector='marginal'`` (Detector A) with the default ``estimate_psd=True`` estimates the noise PSD from the basis-projected residual instead of from ``y - V mu``, which still contained the realized systematics and whitened the transit away (~4x higher statistic at the true template; Detector A now matches its sequential baseline instead of trailing it). + * NUFFT-LRT (experimental): the NFFT oversampling default is ``sigma = 4`` (was 2), so every returned mode ``k = 0..nf-1`` is inside the Gaussian window's accuracy band. Statistic values move slightly (~0.1 at n = 600) and become reproducible run to run; with ``sigma = 2`` the modes ``k >= nf/2`` carried O(1) aliasing error in double precision as well as single. + * NUFFT-LRT (experimental): supplied power spectra are validated (``len(psd) == nf``, finite, non-negative) and floored once per run for every detector; ``eps_floor`` now defaults to ``1e-3`` of the positive median (was ``1e-12``, i.e. no effective floor). A zero bin in a supplied PSD previously returned ~1e6 (matched) or NaN (marginal). + * NUFFT-LRT (experimental): a singular ``coeff_prior_cov`` is handled in the correct limit. The Detector A response matrix is formed as ``C (I + G C)^-1`` with a linear solve instead of ``pinv(pinv(C) + G)``, so a zero prior variance pins that mode to its prior mean rather than becoming an improper flat prior; non-symmetric, non-positive-semidefinite, wrong-shape and non-finite priors now raise ``ValueError``. + * NUFFT-LRT (experimental): ``run`` validates its inputs (equal-length finite ``t``/``y``, ``N >= 3``, positive finite periods and durations, finite epochs, finite basis) and raises ``ValueError`` instead of producing garbage or a numpy broadcast error; ``dy`` is accepted, ignored and warned about (no detector uses it - the noise model is the PSD). + * NUFFT-LRT (experimental): an empty systematics basis (``(n, 0)``) is rejected with ``ValueError`` for ``detector='marginal'`` and ``'sequential'`` before any transform runs. ``'marginal'`` with ``K = 0`` used to fall through to the plain matched filter and, after the Detector A precompute was hoisted out of the template loop, died in numpy after the data transforms; ``'sequential'`` silently ran the filter on the untouched data. Use ``detector='matched'`` for no systematics model. + * NUFFT-LRT (experimental): the per-template matched-filter reduction is one shared helper (``_matched_filter_statistic``) used by ``run()``, by Detector A's ``K = 0`` limit and by the single-template reference wrapper ``_compute_matched_filter_snr`` that the CPU tests exercise, so the tests cover the shipped arithmetic instead of a duplicate that had gone dead; bit-neutral on the ``run()`` path (same operations in the same order). New CPU tests run ``run()`` end to end with the GPU transform replaced by the exact adjoint DFT. + * ``benchmarks/nufft_lrt/validate.py`` / ``summarize_lrt_validation.py``: the white-noise null mean/std of the statistic is reported as the configuration's calibration constant (std expected ~1.8-2.7 for the harness's ground sampling at ``nf = 2n``) instead of against the N(0, 1) expectation the module documents as false by design. + * NUFFT-LRT (experimental): one NFFT buffer set (device arrays, cuFFT plan, pinned host buffer) is now allocated per ``run()`` and reused for the data, the basis vectors and every template, instead of one per transform. Measured on an A40: 0.15 ms per template at n = 600 and 0.29 ms at n = 6000; the shipped example (81k templates) runs in 18 s. Results are unchanged to float32 NFFT noise (3.6e-6 relative; 4.1e-8 in double). + * NFFT: ``NFFTAsyncProcess.run(memory=...)`` is now safe to reuse. The gridding buffer is zeroed on every call (the kernels accumulate with atomic adds, so a second transform on the same memory summed onto the first) and the stream is synchronized before the host buffer is returned when ``transfer_to_host=True``. The default fresh-memory path is unaffected. + * Docs: the NUFFT-LRT page of the documentation (``docs/source/nufft_lrt.rst``, formerly ``docs/NUFFT_LRT_README.md``) rewritten. The statistic is documented as a whitened correlation that is NOT N(0, 1) - its null standard deviation is 1.8-2.7 for ground-based sampling even with the true PSD and grows with ``nf``, so detection thresholds must be calibrated empirically per configuration. The PSD convention is stated with a formula, both return shapes are given, ``dy`` is documented as unused, Detector A's prior is noted to act ~2.2-2.4x wider than specified (frequency-domain Gram overcount), self-whitening is quoted at 24-28% of the statistic at threshold, and the injection-recovery claims are limited to what the pre-fix campaign actually measured (re-validation pending). ``NFFTAsyncProcess``'s sigma/``autoset_m`` docstring defaults were corrected to match the code. + * **Known limitations and deferred work** + * The current transit comparison tests actual PyPI cuvarbase, Astropy and periodfind BLS, and public GTLS on the same GPU as cuvarbase TLS. Failed CPU TLS calls and fBLS literature estimates do not serve as measured speedup denominators; see ``docs/TRANSIT_BENCHMARKS.md`` and ``docs/BENCHMARK_PROVENANCE.md``. No CETRA benchmark is included. + * **Packaging / infrastructure** + * **BREAKING:** requires Python 3.9+ + * Lazy CUDA context: ``import cuvarbase`` no longer creates a CUDA context or requires a GPU. The eager ``import pycuda.autoprimaryctx`` (which retained+pushed the primary context at package import) is gone; the context is now retained on first GPU use via ``cuvarbase.base.ensure_context`` — wired into every kernel-compile function, ``GPUAsyncProcess.__init__``, and each ``*Memory`` class's ``__init__``. ``import cuvarbase`` and the CPU-only helpers (``sparse_bls_cpu``, ``single_bls``, ``fap_baluev``) therefore run on GPU-less machines. The ``pycuda`` package remains an import dependency of the GPU modules (they ``import pycuda.driver``), but importing them allocates no context. ``CUDA_DEVICE`` is now read at first GPU use rather than at import. The packaging smoke test proves the GPU-less import (pycuda absent) + * True pinned host buffers: host transfer arrays in every ``*Memory`` class (BLS, batch BLS, NFFT, Lomb-Scargle, Conditional Entropy, TLS) and the PDM result buffer are now page-locked (pinned) by default via ``cuvarbase.memory._host.host_array``, so ``set_async``/``get_async`` host<->device copies overlap with computation instead of staging through a synchronous bounce buffer. If pinning fails (e.g. the OS locked-memory limit is hit) it warns once and falls back to page-aligned memory; pass ``pinned=False`` to opt out. Previously these were only page-aligned (``cuda.aligned_zeros``), so async transfers silently ran synchronously. **Migration note:** device-to-host copies into these buffers are now *genuinely* asynchronous — results from ``run()`` on the async processes (LS/CE/PDM/NFFT) must not be read before calling ``finish()`` (the batched entry points synchronize internally; the internal BLS/TLS consumers that relied on the old effectively-synchronous copies now sync before reading) + * Fixed wheel/sdist omitting the ``base``/``memory`` subpackages (pip installs of the v1.0 branch were unimportable) + * Lazy module imports via PEP 562 ``__getattr__`` in ``cuvarbase/__init__.py`` (importing the package does not import the GPU modules). Historical note: the interim scikit-cuda numpy shim this enabled was removed along with the scikit-cuda dependency itself (see the Lomb-Scargle/NFFT section) + * Fixed CUDA kernel lookup crashing for editable installs (``pip install -e .``) on Python < 3.12 when cuvarbase is imported from outside the source tree; kernel paths now resolve relative to the package directory + * GitHub Actions CI: CPU test suite (GPU tests stubbed/skipped) on Python 3.9-3.14, a build-wheel/sdist-install-import packaging check that also runs ``pytest --pyargs cuvarbase`` from the installed wheel, a docs build, and the flake8 error class enforced + * ``cuvarbase/tests/conftest.py`` (moved from the repository root so it ships in the wheel and loads under ``pytest --pyargs cuvarbase``) stubs pycuda so the suite runs on GPU-less machines; tests that touch a device skip instead of failing + * Removed vestigial ``cuvarbase.periodograms`` scaffolding + * Single-sourced the device/global functions shared by ``bls.cu`` and ``bls_optimized.cu`` into ``bls_common.cuh``, inlined via a ``//{INCLUDE ...}`` directive expanded at load time (``_module_reader``). Removes the drift hazard that once let the ``reduction_max`` s>32 bug be fixed in only one copy; the kernel-drift test now asserts the include mechanism. Functionally equivalent; not bit-identical for the standard kernel — the shared header adopted the optimized variant's float literals, so ``store_best_sols``/``bls_value`` in the standard kernel now do a few divisions in float32 (under fast-math) instead of double-then-truncate, shifting reported solutions by ~1-2 ulp at most + * Transit benchmark tools and retained evidence in ``benchmarks/``; current release results in ``docs/TRANSIT_BENCHMARKS.md`` + * Build backend is ``setuptools>=77`` with PEP 639 license metadata: ``license = "GPL-3.0-only"`` (SPDX expression, ``License-Expression`` in the wheel/sdist metadata) plus ``license-files = ["LICENSE.txt"]``; the ``License :: OSI Approved`` classifier is gone (redundant under PEP 639) + * Dependency floors raised to ``numpy>=1.22`` and ``scipy>=1.8``: the declared ``numpy>=1.17`` / ``scipy>=1.3`` had no Python 3.9 wheels, so they could not be installed on any supported interpreter and were tested nowhere; 1.22/1.8 are the oldest that install on 3.9 and pass the CPU suite there + * Python 3.13 and 3.14 added to the classifiers and to the CI matrix (3.9-3.14) + * The ``test`` extra now pulls ``batman-package`` and ``transitleastsquares`` (limb-darkened TLS templates and the TLS reference comparisons) and no longer pulls ``matplotlib`` (every plotting import sits behind ``plot=False``); a ``docs`` extra (``sphinx>=7,<9``, ``matplotlib>=3.7``) mirrors ``docs/requirements.txt`` + * pytest is configured in ``pyproject.toml`` (``[tool.pytest.ini_options]``): ``testpaths = cuvarbase/tests``, ``-rs --strict-markers``, a registered ``gpu`` marker, and ``filterwarnings`` that silence only the two deliberate library ``UserWarning``\ s (batman not available; NUFFT-LRT EXPERIMENTAL) so any other warning stays visible + * ``setup.py``, ``setup.cfg``, ``requirements.txt`` and ``requirements-dev.txt`` removed: ``pyproject.toml`` is the single source of packaging metadata. ``setup.cfg``'s ``universal=1`` had tagged the wheel ``py2.py3-none-any`` (it is now ``py3-none-any``) and ``setup.py``'s ``setup_requires=['pytest-runner']`` fetched pytest-runner on every build; ``MANIFEST.in`` now names ``LICENSE.txt`` (the file that exists) and no longer includes ``requirements.txt`` + * ``cuvarbase/kernels/wavelet.cu`` removed: it shipped in every wheel but nothing loaded it and it was unfinished. A new orphan-kernel guard (``cuvarbase/tests/test_kernel_inventory.py``) asserts that every packaged ``kernels/*.cu`` stem is referenced by a loader, every ``find_kernel('...')`` literal has a file, and every ``*.cuh`` is ``//{INCLUDE}``\ d somewhere; it runs against the installed package, so it doubles as a package-data check under ``--pyargs``. ``tools/ci_wheel_smoke.py`` now imports every ``_SUBMODULES`` entry and ``cuvarbase.tests`` and performs the same kernel-inventory check on the installed wheel/sdist + * CI: ``permissions: contents: read``; the package-smoke job runs ``twine check``, installs the wheel and the sdist into clean environments (no pycuda), runs the smoke script and ``pytest --pyargs cuvarbase`` from outside the checkout, and uploads ``dist/``; a docs job builds the Sphinx HTML and fails on any warning other than the expected plot-directive GPU failures; the hard flake8 select gained ``W605`` + * The frozen v1 correctness and packaging record is retained in ``docs/validation/README.md``; superseded gate logs and release orchestration remain in Git history. + * **Docs** + * Dockerfile removed (never installed cuvarbase; rebuild queued for 1.1) + * Transit benchmark claims corrected in September 2026: independent recovery/null calibration, actual PyPI baseline, tested CPU/GPU BLS alternatives, and source-verified GTLS comparisons; see ``docs/TRANSIT_BENCHMARKS.md`` + * Corrected the nifty-ls reference to Garrison et al. (arXiv:2409.08090) + +* **0.4.0** *(never released — folded into 1.0.0)* + * **BREAKING CHANGE:** Dropped Python 2.7 support - now requires Python 3.9+ (importlib.resources.files) + * Removed ``future`` package dependency and all Python 2 compatibility code + * Modernized codebase: removed ``__future__`` imports and ``builtins`` compatibility layer + * Updated minimum dependency versions: numpy>=1.17, scipy>=1.3 + * Added modern Python packaging with ``pyproject.toml`` + * Added Docker support for easier installation with CUDA 11.8 + * Added GitHub Actions CI: CPU test suite on Python 3.9-3.12 + packaging smoke test (GPU validation remains manual) + * Updated classifiers to reflect Python 3.9-3.12 support + * Cleaner, more maintainable codebase (89 lines of compatibility code removed) + * Includes the post-0.2.6 development that never shipped in any release: + * Added Sparse BLS implementation for efficient transit detection with small datasets + * New ``sparse_bls_cpu`` function that avoids binning and grid searching + * New ``eebls_transit`` wrapper that automatically selects between sparse (CPU) and standard (GPU) BLS + * Based on algorithm from Panahi & Zucker 2021 (https://arxiv.org/abs/2103.06193) + * More efficient for datasets with < 500 observations + * NUFFT LRT implementation for transit detection + * Refactored codebase organization with base/, memory/, and periodograms/ modules + +* **0.2.6** *(tagged May 2025, never published to PyPI)* + * pycuda 2025 compatibility fixes; content folded into 1.0.0 + * **0.2.5** * swap out pycuda.autoinit for pycuda.autoprimaryctx to handle "cuFuncSetBlockShape" error @@ -58,4 +300,3 @@ What's new in cuvarbase * GLS * False alarm probability: ``fap_baluev`` * Implements `Baluev 2008 `_ false alarm probability measure based on extreme value theory - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a8434b81 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,264 @@ +# Contributing to cuvarbase + +Thank you for your interest in contributing to cuvarbase! This document provides guidelines and standards for maintaining code quality and consistency. + +## Code of Conduct + +Please be respectful and constructive in all interactions with the project community. + +## Development Setup + +### Prerequisites + +- Python 3.9 or later +- CUDA-capable GPU (NVIDIA) — only for running the GPU tests; the CPU suite, flake8 and the docs build run anywhere +- CUDA Toolkit (12.4 is what 1.0 is validated against; other 11.x/12.x toolkits may work) +- PyCUDA >= 2017.1.1 (avoid 2024.1.2) + +### Installation for Development + +```bash +git clone https://github.com/johnh2o2/cuvarbase.git +cd cuvarbase +pip install -e .[test] +``` + +### Running Tests + +```bash +pytest +``` + +The pytest configuration lives in `pyproject.toml` and the pycuda stub in `cuvarbase/tests/conftest.py`, so a bare `pytest` (or `pytest --pyargs cuvarbase` from an installed wheel) runs the CPU suite on any machine and skips the GPU tests when no device is present. + +### Day-to-day workflow + +- **CPU suite** (runs anywhere): `pytest` +- **Lint** (the CI hard-fails on this class only): `flake8 cuvarbase --select=E9,F63,F7,F82` +- **Docs build** (pycuda is mocked; only the plot-directive figures need a GPU): `make -C docs html` +- **GPU validation** before a release or after touching a kernel: run the full suite on a CUDA device as described in [tools/README.md](tools/README.md) + +## Code Standards + +### Python Version Support + +- **Minimum Python version**: 3.9 +- **Tested versions**: 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 +- Do not use Python 2.7 compatibility code + +### Naming Conventions + +Follow PEP 8 naming conventions: + +- **Classes**: `PascalCase` (e.g., `GPUAsyncProcess`, `NFFTMemory`) +- **Functions**: `snake_case` (e.g., `conditional_entropy`, `lomb_scargle_async`) +- **Variables**: `snake_case` (e.g., `block_size`, `max_frequency`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_BLOCK_SIZE`) +- **Private members**: prefix with `_` (e.g., `_compile_and_prepare_functions`) + +#### CUDA/GPU Specific Naming + +For clarity in GPU code, we use suffixes to indicate memory location: +- `_g`: GPU memory (e.g., `t_g`, `freqs_g`) +- `_c`: CPU/host memory (e.g., `ce_c`, `results_c`) +- `_d`: Device functions (in CUDA kernels) + +### Code Style + +#### Imports + +Group imports in the following order, separated by blank lines: +1. Standard library imports +2. Third-party imports (numpy, scipy, pycuda, etc.) +3. Local application imports + +```python +import sys +import warnings + +import numpy as np +import pycuda.driver as cuda +from pycuda.compiler import SourceModule + +from .base import GPUAsyncProcess +from .utils import find_kernel +``` + +#### Type Hints + +While not required for all code, type hints are encouraged for public APIs: + +```python +def autofrequency( + t: np.ndarray, + nyquist_factor: float = 5, + samples_per_peak: float = 5, + minimum_frequency: float = None, + maximum_frequency: float = None +) -> np.ndarray: + """Generate frequency grid for periodogram.""" + ... +``` + +#### Docstrings + +Use NumPy-style docstrings for all public functions and classes: + +```python +def function_name(param1, param2, param3=None): + """ + Brief description of function. + + Longer description if needed, explaining the purpose and behavior + in more detail. + + Parameters + ---------- + param1 : type + Description of param1 + param2 : type + Description of param2 + param3 : type, optional (default: None) + Description of param3 + + Returns + ------- + return_type + Description of return value + + Raises + ------ + ExceptionType + When this exception is raised + + Examples + -------- + >>> result = function_name(1, 2) + >>> print(result) + 3 + + See Also + -------- + related_function : Related functionality + + Notes + ----- + Additional information about implementation details or caveats. + """ + ... +``` + +#### Comments + +- Use inline comments sparingly and only when the code is not self-explanatory +- Prefer descriptive variable names over comments +- Document complex algorithms with block comments or docstrings + +### CUDA Kernel Conventions + +For CUDA kernels (`.cu` files): + +- Use `__global__` for GPU kernel functions +- Use `__device__` for device-only functions +- Document kernel parameters and thread/block organization +- Use descriptive names: `kernel_name` or `operation_type` + +Example: +```cuda +__global__ void compute_periodogram( + FLT *t, // observation times + FLT *y, // observation values + FLT *freqs, // frequency grid + FLT *output, // output periodogram + unsigned int n, // number of observations + unsigned int nf // number of frequencies +) { + // Kernel implementation +} +``` + +### Memory Management + +- Always check for GPU memory allocation failures +- Use CUDA streams for asynchronous operations +- Clean up GPU resources in class destructors or context managers +- Document memory ownership and transfer patterns + +### Testing + +- Write unit tests for new functionality +- Tests should be in `cuvarbase/tests/` +- Use `pytest` for test framework +- Mock GPU operations when appropriate to allow CPU-only testing +- Test edge cases and error conditions + +Example test structure: +```python +def test_function_name(): + """Test brief description.""" + # Setup + data = np.array([...]) + + # Execute + result = function_name(data) + + # Assert + assert result.shape == expected_shape + np.testing.assert_allclose(result, expected, rtol=1e-5) +``` + +### Documentation + +- Update documentation when changing public APIs +- Include examples in docstrings +- Add entries to CHANGELOG.rst for significant changes +- Update README.md if changing installation or usage + +### API stability policy + +- cuvarbase follows [semantic versioning](https://semver.org/): breaking changes only land in a new major version. +- Within 1.x the public API is the set of names in each user-facing module's `__all__` (and the lazily resolved names in `cuvarbase.__all__`); anything prefixed with `_` is internal. +- A public name is never removed or changed incompatibly within 1.x without first emitting a `DeprecationWarning` for at least one minor release, with the replacement named in the warning. +- Result-changing bug fixes are allowed in minor/patch releases but must be called out in CHANGELOG.rst. +- `cuvarbase.nufft_lrt` is **outside** this promise until its injection-recovery re-validation lands: it is importable, warns `EXPERIMENTAL` at first construction, and may change incompatibly in a 1.x release. +- Kernel-level behaviour that is not exposed through a Python signature (block sizes, shared-memory layouts) carries no stability promise. + +## Pull Request Process + +1. **Fork and branch**: Create a feature branch from `master` +2. **Make changes**: Follow the code standards above +3. **Test**: Ensure all tests pass +4. **Document**: Update docstrings and documentation +5. **Commit**: Use clear, descriptive commit messages +6. **Pull Request**: Submit PR with description of changes + +### Commit Messages + +Use clear, descriptive commit messages: +- Start with a verb in imperative mood (e.g., "Add", "Fix", "Update") +- Keep first line under 72 characters +- Add detailed description if needed + +Examples: +``` +Add support for weighted conditional entropy + +Fix memory leak in BLS computation + +Update documentation for NUFFT LRT method +- Add examples +- Clarify parameter descriptions +- Fix typos +``` + +## Performance Considerations + +When contributing GPU code: +- Profile before optimizing +- Document any performance-critical sections +- Consider memory bandwidth vs. computation tradeoffs +- Test with various GPU architectures when possible + +## Historical process material + +The current [benchmark tools and evidence](benchmarks/README.md) and [release validation record](docs/validation/README.md) are kept with the code. Planning notes, release drafts, cloud-specific helpers and superseded campaigns are archived in [Git history](https://github.com/johnh2o2/cuvarbase/tree/f0dc98136ae34b34465b152be1af84faf063eb44). To inspect an old file, use `git show f0dc98136ae34b34465b152be1af84faf063eb44:`. diff --git a/INSTALL.rst b/INSTALL.rst index 873da7c1..9462ef28 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -1,158 +1,111 @@ Install instructions ******************** -These installation instructions are for Linux/BSD-based systems (OS X/macOS, Ubuntu, etc.). Windows users, your suggestions and feedback is welcome if we can make your life easier! +These instructions describe the v1 candidate on ``v1.0-fixes``. As of +10 September 2026, `PyPI `_ still provides +0.2.5, which has no TLS implementation. The commands below install the candidate +from its Git branch. -Installing the Nvidia Toolkit ------------------------------ +Requirements +------------ -``cuvarbase`` requires PyCUDA and scikit-cuda, which both require the Nvidia toolkit for access to the Nvidia compiler, drivers, and runtime libraries. +* **Python 3.9 – 3.14** +* An **NVIDIA GPU** with a working CUDA driver, and the **CUDA toolkit** (``nvcc`` must be on your ``PATH``). cuvarbase 1.0 is validated against **CUDA 12.4** (every archived release-gate record was produced with it); other 11.x/12.x toolkits may well work but are untested. +* `PyCUDA `_ >= 2017.1.1 (except 2024.1.2), installed automatically as a dependency. -Go to the `NVIDIA Download page `_ and select the distribution for your operating system. Everything has been developed and tested using **version 8.0**, so it may be best to stick with that version for now until we verify that later versions are OK. +GPU execution requires Linux or Windows via WSL2. NVIDIA dropped CUDA support on macOS in 2019, so modern Macs cannot run the GPU code. ``import cuvarbase`` itself needs neither a GPU nor pycuda, and the pure-numpy helpers in ``cuvarbase.utils`` (``check_lightcurve``, ``autofrequency``, ...), ``cuvarbase.bls_frequencies``, ``cuvarbase.tls_grids``, ``cuvarbase.tls_models`` and ``cuvarbase.tls_stats`` work on any machine. The method modules — ``cuvarbase.bls`` (including its CPU routines ``sparse_bls_cpu`` and ``single_bls``), ``cuvarbase.lombscargle`` (including ``fap_baluev``), ``ce``, ``pdm``, ``tls`` — import ``pycuda.driver`` at module top, so they need the pycuda *package* installed; a device is only touched at the first GPU call. See *GPU-less installs* below. -.. warning:: +Installing the CUDA toolkit +--------------------------- - Make sure that your ``$PATH`` environment variable contains the location of the ``CUDA`` binaries. You can test this by trying - ``which nvcc`` from your terminal. If nothing is printed, you'll have to amend your ``~/.bashrc`` file: - - ``echo "export PATH=/usr/local/cuda/bin:${PATH}" >> ~/.bashrc && . ~/.bashrc`` - - The ``>>`` is not a typo -- using one ``>`` will *overwrite* the ``~/.bashrc`` file. Make sure you change ``/usr/local/cuda`` to the appropriate location of your Nvidia install. - - **Also important** - - Make sure your ``$LD_LIBRARY_PATH`` and ``$DYLD_LIBRARY_PATH`` are also similarly modified to include the ``/lib`` directory of the CUDA install: - - ``echo "export LD_LIBRARY_PATH=/usr/local/cuda/lib:${LD_LIBRARY_PATH}" >> ~/.bashrc && . ~/.bashrc`` - ``echo "export DYLD_LIBRARY_PATH=/usr/local/cuda/lib:${DYLD_LIBRARY_PATH}" >> ~/.bashrc && . ~/.bashrc`` - - -Using conda ------------ - -`Conda `_ is a great way to do this in a safe, isolated environment. - -First create a new conda environment (named ``pycu`` here) that will use Python 2.7 (python 2.7, 3.4, 3.5, and 3.6 -have been tested), with the numpy library installed. - -.. code:: bash - - conda create -n pycu python=2.7 numpy - -.. note:: - - The numpy library *has* to be installed *before* PyCUDA is installed with pip. - The PyCUDA setup needs to be able to access the numpy library for building against it. You can do this with - the above command, or alternatively just do ``pip install numpy && pip install cuvarbase`` - -Then activate the virtual environment - -.. code:: bash - - source activate pycu - -and then use ``pip`` to install ``cuvarbase`` +Get the toolkit for your distribution from the `NVIDIA download page `_ (or your package manager). Then make sure the CUDA binaries and libraries are visible: .. code:: bash - pip install cuvarbase + export PATH=/usr/local/cuda/bin:$PATH + export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH +Verify with ``which nvcc`` — if nothing prints, PyCUDA will not be able to compile kernels. Adjust ``/usr/local/cuda`` to your install location (versioned paths like ``/usr/local/cuda-12.4`` also work). -Installing with just ``pip`` ----------------------------- +Installing cuvarbase +-------------------- -**If you don't want to use conda** the following should work with just pip +In a fresh virtual environment (venv or conda, Python 3.9+): .. code:: bash - pip install numpy - pip install cuvarbase - - -Troubleshooting PyCUDA installation problems --------------------------------------------- - -The ``PyCUDA`` installation step may be a hiccup in this otherwise orderly process. If you run into problems installing ``PyCUDA`` with pip, you may have to install PyCUDA from source yourself. It's not too bad, but if you experience any problems, please submit an `Issue `_ at the ``cuvarbase`` Github page and I'll amend this documentation. - -Below is a small bash script that (hopefully) automates the process of installing PyCUDA in the event of any problems you've encountered at this point. - -.. code-block:: bash - - PYCUDA="pycuda-2017.1.1" - PYCUDA_URL="https://pypi.python.org/packages/b3/30/9e1c0a4c10e90b4c59ca7aa3c518e96f37aabcac73ffe6b5d9658f6ef843/pycuda-2017.1.1.tar.gz#md5=9e509f53a23e062b31049eb8220b2e3d" - CUDA_ROOT=/usr/local/cuda - - # Download - wget $PYCUDA_URL - - # Unpack - tar xvf ${PYCUDA}.tar.gz - cd $PYCUDA + pip install 'cuvarbase @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' - # Configure with current python exe - ./configure.py --python-exe=`which python` --cuda-root=$CUDA_ROOT - python setup.py build - python setup.py install +numpy, scipy and pycuda are installed automatically (astropy is only needed by the test suite). PyCUDA builds against your CUDA toolkit during installation, so set the environment variables above first. See *GPU-less installs* for the path without CUDA dependencies. -If everything goes smoothly, you should now test if ``pycuda`` is working correctly. +Optional extras: .. code:: bash - python -c "import pycuda.autoinit; print 'Hurray!'" + pip install 'cuvarbase[tls] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' + pip install 'cuvarbase[cufinufft] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' + pip install 'cuvarbase[test] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' -If everything works up until now, we should be ready to install ``cuvarbase`` +``tls`` installs the standard TLS dependencies; ``cufinufft`` enables the optional +cuFINUFFT Lomb-Scargle backend; ``test`` supplies pytest, nfft, astropy, batman and +transitleastsquares. In a checkout, ``pip install -r docs/requirements.txt`` +installs Sphinx and matplotlib for building the documentation. -.. code:: bash +The standard TLS engine requires both CuPy and ``batman-package`` and does not +substitute an approximate template when either is absent. The ``tls`` extra +uses CuPy 13 and supports Python 3.9–3.13; the current GPU validation uses +Python 3.11, CuPy 13.6 and CUDA 12.4. For a different CUDA runtime, install its +matching CuPy 13 wheel and ``batman-package`` separately (only one CuPy +distribution per environment). See the `CuPy installation guide +`_. - pip install cuvarbase +``method='binned'`` keeps the earlier TLS engine and its optional analytic +template fallback. For GPU testing of all engines, install ``.[test,tls]``. Installing from source ---------------------- -You can also install directly from the repository. Clone the ``git`` repository on your machine: - .. code:: bash - - git clone https://github.com/johnh2o2/cuvarbase -Then install! - -.. code:: bash + git clone --branch v1.0-fixes https://github.com/johnh2o2/cuvarbase + cd cuvarbase + pip install -e . - cd cuvarbase - python setup.py install +GPU-less installs +----------------- -The last command can also be done with pip: +To use the pure helpers (frequency grids, TLS duration grids and statistics, +``check_lightcurve``, ...) without installing CUDA dependencies, skip dependency +resolution: .. code:: bash - pip install -e . + pip install numpy scipy + pip install --no-deps 'cuvarbase @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' +``import cuvarbase`` and the pure modules listed under *Requirements* then work; importing a method module (``cuvarbase.bls``, ``cuvarbase.lombscargle``, ...) raises ``ImportError`` because pycuda is absent. The test suite ships its own pycuda stub (``cuvarbase/tests/conftest.py``), so ``pytest --pyargs cuvarbase`` also runs on such a machine: the CPU tests pass and the GPU tests skip. +Verifying the installation +-------------------------- -Troubleshooting on a Mac ------------------------- - -Nvidia offers `CUDA for Mac OSX `_. After installing the -package via downloading and running the ``.dmg`` file, you'll have to make a couple of edits to your -``~/.bash_profile``: - -.. code:: sh - - export DYLD_LIBRARY_PATH="${DYLD_LIBRARY_PATH}:/usr/local/cuda/lib" - export PATH="/usr/local/cuda/bin:${PATH}" +.. code:: bash -and then source these changes in your current shell by running ``. ~/.bash_profile``. + python -c "import cuvarbase; print(cuvarbase.__version__)" # works even without a GPU or pycuda + python -c "from cuvarbase.bls import eebls_gpu_fast; print('GPU BLS ready')" # needs pycuda -Another important note: **nvcc (8.0.61) does not appear to support the latest clang compiler**. If this is -the case, running ``python example.py`` should produce the following error: +For a real end-to-end check on a GPU machine, install the test extra and run the test suite (on a GPU-less machine the same command runs the CPU tests and skips the rest): .. code:: bash - nvcc fatal : The version ('80100') of the host compiler ('Apple clang') is not supported + pip install 'cuvarbase[test] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' + pytest --pyargs cuvarbase + +Troubleshooting +--------------- -You can fix this problem by temporarily downgrading your clang compiler. To do this: +* **``nvcc`` not found / ``CompileError`` at first GPU call** — the CUDA toolkit is missing from ``PATH``. Kernels are compiled at first use (then cached), so a working ``nvcc`` is required at runtime, not just install time. +* **PyCUDA fails to build** — check that the toolkit version matches your driver (``nvidia-smi`` shows the maximum supported CUDA version) and that you are not hitting the excluded ``pycuda==2024.1.2``. +* **``import cuvarbase`` succeeds but GPU calls fail** — importing no longer initializes CUDA (new in 1.0.0); the context is created at first GPU use, which is where driver problems will surface. ``python -c "import pycuda.autoprimaryctx"`` isolates driver/toolkit issues from cuvarbase itself. +* **Selecting a GPU** — set the ``CUDA_DEVICE`` environment variable before the first GPU call. -- `Download Xcode command line tools 7.3.1 `_ -- Install. -- Run ``sudo xcode-select --switch /Library/Developer/CommandLineTools`` until ``clang --version`` says ``7.3``. +If you hit something not covered here, please open an `issue `_. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..d5193ef1 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +include CHANGELOG.rst +include INSTALL.rst +include LICENSE.txt +include README.md +include README.rst +recursive-include cuvarbase/kernels *.cu *.cuh diff --git a/README.md b/README.md new file mode 100644 index 00000000..a59c4e33 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# cuvarbase + +**GPU-accelerated time series analysis tools for astronomy** — period-finding and transit-detection algorithms (BLS, TLS, Lomb-Scargle, PDM, CE) built on [PyCUDA](https://mathema.tician.de/software/pycuda/) and [CuPy](https://docs.cupy.dev/en/v13.6.0/install.html). Created by John Hoffman, (c) 2017. + +**Faster transit searches for TESS and ZTF.** v1 BLS is **1.8–4.3× faster than PyPI 0.2.5** in the measured batches; separated TESS supports the recovery comparison. **TLS is 3.6–4.6× faster than GTLS for one lightcurve and 1.5–2.4× faster in batches**, using its observation-level numerical search and full refinement, without phase binning. + +![BLS and TLS search times on TESS and ZTF cadences](https://raw.githubusercontent.com/johnh2o2/cuvarbase/v1.0-fixes/docs/figures/transit_benchmarks_20260910.png) + +Hollow markers: one lightcurve. Filled markers: time per lightcurve in a 16-source batch. Each comparison uses the same inputs and device: A40 for BLS, RTX A6000 for TLS. TLS batches compare one cuvarbase worker with the fastest eligible GTLS pool of 1, 2 or 4 workers. Times include each API's normal output work. The original campaign gate failed when four-worker GTLS ran out of memory during the separated-TESS warmup. The figure uses a separately audited, post hoc report of the completed configurations. The report preserves that failure and separates search time from GTLS's additional diagnostics. [Results and methodology](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TRANSIT_BENCHMARKS.md) · [PDF figure](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/figures/transit_benchmarks_20260910.pdf) + +## Transit-search performance + +cuvarbase is built for processing millions of lightcurves. **TESS's Quick-Look Pipeline adopted cuvarbase's GPU BLS starting in Sector 59** ([Kunimoto et al. 2023](https://arxiv.org/abs/2302.01293)). + +**BLS does less repeated work.** For each trial period, v1 reuses folded phase histograms across multiple phase offsets. Disabling this fusion made diagnostic API calls 1.35–1.57× slower. Vectorized host scans and Keplerian-grid construction remove Python loops over large grids; grid construction alone was 11–17× faster. The batch API amortizes allocation and dispatch across lightcurves. Both releases receive warmed kernels and reusable memory in these comparisons. + +Against external BLS implementations, measured batch searches were **19–57× faster than the strongest tested CPU settings** (Astropy or periodfind) and **1.5–11.9× faster than periodfind GPU**. The report identifies comparisons whose recovery and false-positive results support the stated 5-point criterion. + +**TLS preserves the search while removing repeated work.** The standard engine evaluates GTLS's sample windows and transit templates, using its depth estimates and full refinement. Fused kernels reuse residual calculations and reduce winning trials without storing the full residual tensor. Reusable CUDA graphs replay the native cumulative-sum operations with fewer Python dispatches. Smaller GPU workspaces do not narrow the duration search. Invalid candidates are excluded before ranking, correcting a GTLS mask-handling defect documented in the comparison. + +The release keeps `execution='baseline'` as the default. The later survey optimization bundle requires `execution='experimental'`: 5,111 of 5,120 held-out results matched exactly, with nine chi2/SDE differences despite identical selected periods and detection decisions. The [execution-mode documentation](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TLS_EXECUTION.md) explains the numerical limits and separate release validation. + +**Thin transits use the same default.** There is no phase-bin cap or separate narrow-transit accuracy preset. All **184 independent injection and noise-only cases** matched corrected GTLS's numerical searches exactly, including ordinary, high-impact, eccentric and dense-M-dwarf regimes. The [numerical validation](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TLS_NUMERICS.md) also records extreme stress tests and shared floating-point limits. Both engines still need observed transits, an appropriate period domain and enough signal. The earlier phase-binned engine remains available explicitly as `method='binned'`; its much larger historical speed ratios do not describe the new default. + +For a concrete QLP-oriented upgrade result, BLS on separated TESS sectors was **2.73× faster in batches**, or **10.18× faster including a fresh grid**, with the same **89/128** detected injections as PyPI. Paired confidence bounds support less than a 5-percentage-point recovery loss and less than a 5-point false-positive increase on this test population. Other PyPI comparisons remain inconclusive under that criterion. + +The [benchmark report](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TRANSIT_BENCHMARKS.md) separates full API time, search computation and output diagnostics. [Cost projections](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TLS_COST_ANALYSIS.md) cover GPU search rental; preprocessing, I/O and candidate vetting are additional work. + +## Features + +- **Box Least Squares ([BLS](https://adsabs.harvard.edu/abs/2002A%26A...391..369K))** — the production-validated transit search behind the TESS QLP: standard, adaptive, and batched multi-lightcurve GPU paths, plus sparse BLS ([Panahi & Zucker 2021](https://arxiv.org/abs/2103.06193)) for small datasets (< 500 observations, GPU and CPU) +- **Transit Least Squares ([TLS](https://ui.adsabs.harvard.edu/abs/2019A%26A...623A..39H/abstract))** — limb-darkened transit templates, Ofir (2014) period grids, and a GTLS-compatible observation-level default with full refinement, plus a survey wrapper (`tls_search_batch`) and an explicit approximate binned option +- **Generalized [Lomb-Scargle](https://arxiv.org/abs/0901.2573) periodogram** — NFFT-accelerated, with multiharmonic support and Baluev false-alarm probabilities +- **Phase Dispersion Minimization ([PDM](https://www.stellingwerf.com/rfs-bin/index.cgi?action=PageView&id=29))** — binned and binless variants with fast shared-memory kernels; to our knowledge the only GPU PDM in existence +- **Conditional Entropy period finder ([CE](https://adsabs.harvard.edu/abs/2013MNRAS.434.2629G))** — maintenance mode: it works and will keep working, but for an actively developed GPU CE/AOV search we recommend [periodfind](https://github.com/scope-ml/periodfind) +- **Non-equispaced fast Fourier transform ([NFFT](http://epubs.siam.org/doi/abs/10.1137/0914081))** — the adjoint operation that powers the fast Lomb-Scargle + +**Experimental** (emits a `UserWarning` at first construction; outside the 1.x stability promise): the NUFFT-based likelihood-ratio transit search `cuvarbase.nufft_lrt`, contributed by **Jamila Taaki** ([@xiaziyna](https://github.com/xiaziyna)) — a frequency-domain matched filter for box transits in correlated noise, with marginalized and sequential systematics-aware detectors. Its Sep-2026 fixes were re-validated by an injection-recovery campaign (the default path is correct on BJD-scale times; the systematics-aware detectors recover 98% of 1.6%-deep transits where basis-free BLS and the then-current binned TLS recover 2% or less; PSD whitening itself gave no gain over a flat PSD, and BLS/TLS were more complete in white noise) — see the [NUFFT-LRT page](https://johnh2o2.github.io/cuvarbase/nufft_lrt.html). It stays experimental because its defaults and `run()` conventions may still change; it is importable as `cuvarbase.nufft_lrt` but deliberately not exported from the top-level namespace. + +## Installation + +Requirements: an NVIDIA GPU, the CUDA Toolkit (1.0 is validated against CUDA 12.4; `nvcc` on your `PATH`), and Python 3.9-3.14. + +The benchmarks describe the v1 candidate on `v1.0-fixes`. [PyPI](https://pypi.org/project/cuvarbase/) still provides 0.2.5 as of 10 September 2026. To install this candidate: + +```bash +pip install 'cuvarbase @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' +``` + +For a development checkout, clone the `v1.0-fixes` branch and `pip install -e '.[test,tls]'` (Python 3.9–3.13 for the TLS extra). PyCUDA builds against your CUDA toolkit during installation, so a CUDA-less machine needs the `--no-deps` path described in INSTALL.rst (see the link below). + +Notes: + +- `import cuvarbase` does **not** create a CUDA context or require a GPU (or even pycuda) — the context is created lazily on first GPU use. The pure helpers in `cuvarbase.utils`, `cuvarbase.bls_frequencies`, `cuvarbase.tls_grids`, `cuvarbase.tls_models` and `cuvarbase.tls_stats` work without pycuda; the method modules (`cuvarbase.bls` with `sparse_bls_cpu`/`single_bls`, `cuvarbase.lombscargle` with `fap_baluev`, ...) import `pycuda.driver` at module top, so they need the pycuda package installed but touch no device until the first GPU call. See [INSTALL.rst](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/INSTALL.rst) for the `--no-deps` install path on CUDA-less machines. +- Device selection follows the `CUDA_DEVICE` environment variable, read at first GPU use (e.g. `CUDA_DEVICE=1 python script.py`; for multiple GPUs, split jobs across processes). +- `pip install 'cuvarbase[tls] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes'` includes CuPy 13 for CUDA 12 and [batman-package](https://github.com/lkreidberg/batman), required by the standard TLS engine (Python 3.9–3.13). See INSTALL.rst for other CUDA runtimes; the `cufinufft` extra enables the alternative cuFINUFFT Lomb-Scargle backend. + +## Quick Start + +```python +import numpy as np +from cuvarbase import bls + +# Generate some sample time series data +t = np.sort(np.random.uniform(0, 10, 1000)).astype(np.float32) +y = np.sin(2 * np.pi * t / 2.5) + np.random.normal(0, 0.1, len(t)) +dy = np.ones_like(y) * 0.1 # uncertainties + +# Define frequency grid +freqs = np.linspace(0.1, 2.0, 5000).astype(np.float32) + +# Standard BLS (returns power array and best (q, phi) solutions per frequency) +power, solutions = bls.eebls_gpu(t, y, dy, freqs) +best_freq = freqs[np.argmax(power)] +print(f"Best period: {1/best_freq:.2f} (expected: 2.5)") +``` + +Full documentation — including Lomb-Scargle, TLS, CE, and PDM walkthroughs — is at **https://johnh2o2.github.io/cuvarbase/**; two runnable notebooks (Lomb-Scargle and PDM) are in [notebooks/](https://github.com/johnh2o2/cuvarbase/tree/v1.0-fixes/notebooks/). + +## What's New in v1.0.1 + +The 1.0.1 candidate is a major modernization and the planned first PyPI release since the `0.2.x` line, with faster transit searches and Keplerian grid construction ([measured results](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/TRANSIT_BENCHMARKS.md)), the new observation-level TLS engine, correct results on absolute BJD-scale timestamps (silently wrong before), sparse BLS, batched BLS, Keplerian frequency grids, multiharmonic GPU Lomb-Scargle, a PDM/CE overhaul contributed by [@astrobatty](https://github.com/astrobatty) (PRs #57-#62, #65), and Python 3.9-3.14 + numpy 2.x support without scikit-cuda. The standard TLS extra supports Python 3.9-3.13. Version 1.0.1 preserves the existing June `v1.0.0` tag; publication is pending. + +The expanded release suite passed **2,091 GPU tests, with one expected notebook failure and zero skips**, on an NVIDIA A40 on 24–25 September 2026. A separate installed-wheel check passed all 14 numerical/runtime checks and six dependency preflights on 27 September. The [validation receipts](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) bind these results to the tested sources and wheel. These checks do not override the benchmark's failed experimental exactness gate or its unavailable timing panels. + +The complete list: [CHANGELOG.rst](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/CHANGELOG.rst), with release notes in [docs/RELEASE_NOTES_v1.0.1.md](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/RELEASE_NOTES_v1.0.1.md) and measured performance in [docs/BENCHMARK_RESULTS.md](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/BENCHMARK_RESULTS.md). + +## Testing + +```bash +pytest +``` + +The test suite runs **on CPU**: `cuvarbase/tests/conftest.py` stubs `pycuda`, so a bare `pytest` (or `pytest --pyargs cuvarbase` from an installed wheel) runs the pure-CPU tests anywhere and the GPU-dependent tests skip (this is what CI does on Python 3.9-3.14). A CUDA-capable GPU is needed only to exercise the GPU kernels themselves, which are validated on-device before releases. + +## Contributing + +Contributions are very welcome — see the [Contributing Guide](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/CONTRIBUTING.md) for development setup, code standards, testing requirements, and the PR process, and the [issue tracker](https://github.com/johnh2o2/cuvarbase/issues) for bug reports and feature requests. + +## Citation + +If you use cuvarbase in your research, please cite [Hoffman (2022), ASCL record ascl:2210.030](https://ui.adsabs.harvard.edu/abs/2022ascl.soft10030H/abstract): + +```bibtex +@MISC{2022ascl.soft10030H, + author = {{Hoffman}, John}, + title = "{cuvarbase: GPU-Accelerated Variability Algorithms}", + keywords = {Software}, + howpublished = {Astrophysics Source Code Library, record ascl:2210.030}, + year = 2022, + month = oct, + eid = {ascl:2210.030}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2022ascl.soft10030H}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} +``` + +If you use the sparse BLS method, please also cite [Panahi & Zucker (2021)](https://arxiv.org/abs/2103.06193). + +## A Personal Note + +This project was created as part of a PhD thesis, intended mainly for myself and against the very wise advice of two advisors trying to help me stay on track. Joel Hartman -- legendary author of `vartools` -- and Gaspar Bakos both showed me an incredible amount of patience. I had promised Gaspar a catalog of variable stars from HAT telescopes, something that should have taken maybe a month but instead took years due to an irrational and irresponsible level of perfectionism, and even at the end wasn't comprehensive or useful, and which I never published. To both of you: thank you. + +Much to my absolute delight this repository has -- organically! -- become useful to several people in the astro community; an ADS search in late 2025 found roughly two dozen papers (~430 citations) using cuvarbase in some shape or form. The biggest source of pride was seeing the Quick Look Pipeline adopt cuvarbase for TESS ([Kunimoto et al. 2023](https://ui.adsabs.harvard.edu/abs/2023RNAAS...7...28K/abstract)). + +Though usage is modest, to put this in personal context it is by far the most useful product of my PhD, and the fact that, amidst a lot of bumbling about for 5 years accomplishing very little, something productive somehow found its way into my thesis has given me a lot of relief and happiness. + +I want to personally thank people who have given their time and support to this project, including Kevin Burdge, Attila Bodi, Jamila Taaki, and to everyone in the community that has used this tool. + +## Future Plans and Call for Contributors + +In the years since 2017, I moved away from astrophysics and life has gone on. With coding agents finally good enough that a limited time investment can bring a lot of return, I would really like to encourage interested people to become official **contributors** so that I can pass the torch onto the larger community. With the world awash in GPUs and time-series datasets orders of magnitude larger than a decade ago, something like `cuvarbase` seems even more relevant today than when it started — and where others have built better tools for a given method (e.g. [periodfind](https://github.com/scope-ml/periodfind) for conditional entropy), we would rather point you to them than duplicate the effort. + +**If you're interested in contributing, please see our [Contributing Guide](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/CONTRIBUTING.md)!** + +## License & Acknowledgments + +Licensed under GPLv3 — see [LICENSE.txt](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/LICENSE.txt). + +The observation-level TLS engine adapts [GTLS](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111), with the original MIT notices crediting Michael Hippke and Quanquan Hu retained in the source. The [comparison](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/docs/GTLS_COMPARISON.md) documents the shared algorithm and cuvarbase's execution changes. + +Special thanks to Joel Hartman (author of the original `vartools`), Gaspar Bakos, Kevin Burdge, Attila Bódi ([@astrobatty](https://github.com/astrobatty) — PDM, CE, Lomb-Scargle, and BLS contributions throughout v1.0), and **Jamila Taaki** ([@xiaziyna](https://github.com/xiaziyna) — the NUFFT likelihood-ratio transit search; see Taaki, Kamalabadi & Kemball 2020, *Bayesian Methods for Joint Exoplanet Transit Detection and Systematic Noise Characterization*, and the [reference implementation](https://github.com/star-skelly/code_nova_exoghosts)) — and to all users and contributors who have made cuvarbase useful to the astronomy community. + +For questions, issues, or contributions: https://github.com/johnh2o2/cuvarbase/issues diff --git a/README.rst b/README.rst index 3809cd40..19bac62f 100644 --- a/README.rst +++ b/README.rst @@ -4,56 +4,12 @@ cuvarbase .. image:: https://badge.fury.io/py/cuvarbase.svg :target: https://badge.fury.io/py/cuvarbase - -Active development happening! see `v1.0` branch ------------------------------------------------ - -John Hoffman -(c) 2017 - -``cuvarbase`` is a Python library that uses `PyCUDA `_ to implement several time series tools used in astronomy on GPUs. - -See the `documentation `_. - -This project is under active development, and currently includes implementations of - -- Generalized `Lomb Scargle `_ periodogram -- Box-least squares (`BLS `_ ) -- Non-equispaced fast Fourier transform (adjoint operation) (`NFFT paper `_) -- Conditional entropy period finder (`CE `_) -- Phase dispersion minimization (`PDM2 `_) - - Currently operational but minimal unit testing or documentation (yet) - -Hopefully future developments will have - -- (Weighted) wavelet transforms -- Spectrograms (for PDM and GLS) -- Multiharmonic extensions for GLS - - -Dependencies ------------- - -- `PyCUDA `_ **<-essential** -- `scikit cuda `_ **<-also essential** - - used for access to the CUDA FFT runtime library -- `matplotlib `_ (for plotting utilities) -- `nfft `_ (for unit testing) -- `astropy `_ (for unit testing) - - -Using multiple GPUs -------------------- - -If you have more than one GPU, you can choose which one to -use in a given script by setting the ``CUDA_DEVICE`` environment -variable: - -.. code:: sh - - CUDA_DEVICE=1 python script.py - -If anyone is interested in implementing multi-device load-balancing -solution, they are encouraged to do so! At some point this may -become important, but for the time being manually splitting up the -jobs to different GPU's will have to suffice. +``cuvarbase`` is a Python library that uses `PyCUDA +`_ to implement several time +series tools used in astronomy on GPUs (BLS, Lomb-Scargle, conditional +entropy, PDM, and more). + +The full project README is `README.md +`_ (the +canonical document; this file is just a pointer). Documentation: +https://johnh2o2.github.io/cuvarbase/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..e234efd4 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,25 @@ +# Benchmarks and validation + +Complete inputs, per-case results, logs and source snapshots are stored in +[checksum-verified archives](../docs/BENCHMARK_ARCHIVES.md). Restore the relevant +study before running a reproduction. The reports, selected figures and small +summary tables below remain available directly in Git. + +The [transit benchmark report](../docs/TRANSIT_BENCHMARKS.md) is the source for the README's performance claims. It compares v1 BLS with PyPI 0.2.5 and tested CPU/GPU alternatives, and v1 TLS with public GTLS, on observed TESS and ZTF cadences with synthetic transits and noise. + +| Directory | Purpose | +|---|---| +| [tls_reference/](tls_reference/README.md) | Standard observation-level TLS: numerical parity, independent injections/nulls, full public-call and component timings | +| [results/tls_reference_2026-09-10/](results/tls_reference_2026-09-10/README.md) | Current default TLS versus full GTLS, including thin-transit regimes | +| [transit/](transit/README.md) | Timing figure, recovery analysis and transit benchmark workers | +| [results/transit_2026-09-08/](results/transit_2026-09-08/README.md) | BLS competitor benchmark and initial TLS experiment | +| [tls_sensitivity/](tls_sensitivity/README.md) | Independent TLS recovery, exclusive timing and numerical-resolution tools | +| [results/tls_sensitivity_2026-09-09/](results/tls_sensitivity_2026-09-09/README.md) | Historical binned TLS sensitivity study, resolution tradeoffs and secondary BLS control | +| [tls_accuracy/](tls_accuracy/README.md) | TLS approximation diagnostics, kernel parity/timing and focused high-impact recovery tools | +| [results/tls_accuracy_2026-09-09/](results/tls_accuracy_2026-09-09/README.md) | Narrow-transit accuracy limits and validation of sparse-bin traversal | +| [tls_profile/](tls_profile/README.md) | Supplementary TLS profiling and CPU failure diagnostics | +| [results/tls_profile_2026-09-08/](results/tls_profile_2026-09-08/README.md) | TLS component measurements and numerical comparisons | +| [nufft_lrt/](nufft_lrt/README.md) | Validation tools for the experimental NUFFT-LRT detector | +| [results/nufft_lrt_validation_2026-09-06/](results/nufft_lrt_validation_2026-09-06/README.md) | Independent validation supporting the NUFFT-LRT documentation | + +Benchmarks are separate from the [release correctness checks](../docs/validation/README.md). Superseded timing claims and their provenance are documented in the [historical-claim audit](../docs/BENCHMARK_PROVENANCE.md); the original campaigns remain in Git history. diff --git a/benchmarks/archives/nufft_lrt_validation_2026-09-06.json b/benchmarks/archives/nufft_lrt_validation_2026-09-06.json new file mode 100644 index 00000000..492a18aa --- /dev/null +++ b/benchmarks/archives/nufft_lrt_validation_2026-09-06.json @@ -0,0 +1,35 @@ +{ + "schema": 1, + "id": "nufft_lrt_validation_2026-09-06", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/nufft_lrt_validation_2026-09-06.tar.gz", + "bucket": "cuvarbase", + "bytes": 290682, + "sha256": "d49f303beb26a6c6b86015415ec27b3b13c7912850636ffbff95cfcecf581376" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/nufft_lrt_validation_2026-09-06/README.md",5457,"7ef8c27a5eac29d3b80dd7d9f8e43dc4a86b540241b0b8fa76277987dec484b7",true], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/launch_campaign.sh",1516,"a793f87b9289ba4ed6eb63bbb85ef2f6e11b90ddff6857bf5b0cbe635e88b8ea",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/A_white.log",3535,"b6a901e804efb613bac5a9ac687bc6028081751014d4b71298add37060dd26ca",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/B_whitebjd.log",2863,"ed5888bc307efc99d885d5fb2f416d455cff3fcefb766fca0eddf6e95e39ece4",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/C_red1x.log",2907,"1972ec629a6f8f27c839e89a1b403bf70c41dbe8c2c97ec9a5082ba957883cf4",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/D_red3x.log",2907,"e5a65358722dceac8355dd5c392fd5bd5101b5e76081d9af3810f28d2a727347",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/E2_nzm_seq.log",2537,"c202c3743ddf3e4f9062a8f873d1d3afd084ab3f5c722c69e79d4b04f8f09ce9",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/E_redsys_main.log",2799,"70c6787dbdb3ded718bd97b103323708b246c1ffa87544e975646876ee39ab62",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/F2_nzm_marg.log",2540,"1fe082a08eddabd081d149da6bbf14884e88c654777ea8ab68b527cb87c46686",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/F_redsys_det.log",2568,"845008f980da9ba2f0fd04050e853629190aaad6a5cdd40e1113de5562021aed",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/null_calibration_check.log",427,"b35241b9557445d4412d4472ad339cce6a8e01e1be91f927f34449494671c66a",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/p4_first_nfft_ls_lrt.log",26475,"876f2f0d049294d3c93fcd15dbc9102d37a8e0f510db8f228dc2339ad9ad1431",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/logs/p4_full_suite.log",199301,"b03c9f79c52cadd8884d912d4f906860578e3aa81ef4e87edadd94614d773161",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/nufft_lrt_validation_2026-09-06.json",1611552,"d7bfbda00979a28844f3642e2f3bc084ddff5a0872b7109f18f77e2d297e2bef",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/null_calibration_check.py",1715,"f7874d059b661eb06d5f70bb17d3c5ccb41b633e64fd4c2d154e8ef1b9ef7b2b",false], + ["benchmarks/results/nufft_lrt_validation_2026-09-06/summary.md",13967,"ee710eeeb5a0d3c0028c8a22d6dba96f89252570e9529c462e7ae9e174c9449a",true] + ] +} diff --git a/benchmarks/archives/tls_accuracy_2026-09-09.json b/benchmarks/archives/tls_accuracy_2026-09-09.json new file mode 100644 index 00000000..580a6822 --- /dev/null +++ b/benchmarks/archives/tls_accuracy_2026-09-09.json @@ -0,0 +1,78 @@ +{ + "schema": 1, + "id": "tls_accuracy_2026-09-09", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/tls_accuracy_2026-09-09.tar.gz", + "bucket": "cuvarbase", + "bytes": 1216942, + "sha256": "6233ccbfc85a5436c126c0e193996fe4557421263dee43b99163100ba734445b" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/tls_accuracy_2026-09-09/.gitattributes",203,"2a68d31082cceab8f3a1254391c1b47056a03814143b2bbfe3551bb394a69405",true], + ["benchmarks/results/tls_accuracy_2026-09-09/README.md",2870,"87d6fee47674be48938345ecac82708b2bc206177bd8dd213d7fc5704a9cd205",true], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/README.md",6751,"b6bf7e1f49e0f7abac42f0326929a6555495a85331f421a72e7635b4fea5ef9c",true], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/SHA256SUMS.json",1037,"fabe447ce9d821accee804bfd10d494ffe3149a731b29c3bbf69f51371620149",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/cases.csv",1340830,"a0ca7c5b0345396544aac2f75792bdf144d593b2c4404cd5b21f036bbd5b7db1",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/convergence.csv",390907,"302eea156c51471fa80bccbe161f62efd0947a3b3b81d2f86c133392c7d9fdfe",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/manifest.json",8703,"2fbe31499110d801b10a8f5f141debec779e877979a1a9a6d1c701ad9fbd9c9f",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/source_snapshots/diagnose.py",29613,"cbeaf0ed786f888f5b5f3f048988962cb0f3b56ca21c5f1538fc9435d255201a",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/source_snapshots/test_diagnose.py",6414,"0bf72d44ffad7df92b344cff6303eda25145e08dd2844487cf3914154befe57a",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/source_snapshots/tls_fast.cu",22785,"ee8c4c84b35dc262b9e45d9048d04b67b5f04f629dfe97deb9786cebd73e7c16",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/source_snapshots/tls_grids.py",19484,"2022262fad42ef693384393ad1cc0cde51d680604e62bd1ce8ad7173ec9cd875",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/source_snapshots/tls_models.py",21792,"918facb338f2daa3fd075254c81dfd6a891b95b8be22ec19ed71feca36bc1d09",false], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/summary.csv",64497,"e1e7cde675d5af81ae271ea31d81c2fa4605e920ecdd9a4ea8bb675048c9684f",true], + ["benchmarks/results/tls_accuracy_2026-09-09/accuracy/validation.json",4697,"7706c03a77f9b61865199d83a52c2ca01e7be79745c21b71964d617dd37a3bcf",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/README.md",15507,"db1ce2a8a9a1929d33804c2156d8a37ea806231a3c9bc5ced2f21b3324adc3f6",true], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/SHA256SUMS.json",2646,"0aa5e8b9462b7b9393bb71996723561d7783873a48ba22764625b931c6425e05",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/analysis.json",13418,"930338b7d171ff60b4c774a3ce3de2461a235710292d21a192b5da6240d3a91c",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/configs/gtls.json",197,"50e56be95691d8be92c393e1b51c30796c4ae54a88ccd75af0564775da071b34",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/configs/v1_defaults.json",129,"fd8a7212c7b0ba87b46fcbf9408a1e3232c509b98975483a2fe4ab83a8c807b5",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/configs/v1_fine.json",130,"daf859627ce23833b93a56fa24533fd4a4083301f4550d74c558cced2db7ceeb",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/configs/v1_wide.json",145,"e27dfee090d6edf554d141c1858edb6d349972157e4b5a9dea0e3f647472d4b8",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/design.json",9613,"b58495839e82939a7f1cccecf736cee8fe23316b8bbe15a4537fe3c8c65089ca",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/generation-environment.json",617,"8805a2f18ba07d4d9a938bf9395ef708f9ae1815189aa44a91f7e8c521755404",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/inputs/manifest.json",867918,"4cfae50c4f0a76e28fdfee3648fbb1a0fedf3fac62af2ead2363182f0893cb1b",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/provenance.json",4613,"8c924ceb31e4ad93eb2375ee42bce68d96613394a7c0ace1f42b57da9790bc0a",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/reanalysis-validation.json",786,"d9905c413ab67ada1651e04c7b21c71fa54a287a8d052d373bb6d8605f50a998",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/calibration/gtls.json",824339,"69682d2e11d0c7878d01f7aec2f28e58bc1dfa06ac9be68eaa6898e991e8375d",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/calibration/v1_defaults.json",144033,"4cabc1949e0925505df47a38451b4bb3516746882860970c46018a057cf369ce",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/calibration/v1_fine.json",143695,"9b1408225ed73feb77ef84760a9a323eaaf6f71344859215d791d70b4c36049e",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/calibration/v1_wide.json",144079,"4f8a5af97b453607246e70d65216d15ad6d2d641acbc6e1a3b54ed1dc2791b22",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/injections/gtls.json",825029,"cccc7ae7cceeddb554d381bce118886e110a0de284fe548c8b87e90a81640188",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/injections/v1_defaults.json",144597,"b9f95c5f2a06a373cb0b4121c1814935f1eb650cfda1210dfd354811c95f757b",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/injections/v1_fine.json",144264,"2a508bd778ef8dce047113659d25e24337186f3437dfbdbfd77b50d1a543a429",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/injections/v1_wide.json",144766,"eccad86862685fe1374442a2383958687166ea3d3cab5bd03f0288ea0f4ce2ad",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/nulls/gtls.json",824405,"b6db9ac27c9e66c60d88a266d3b572779db23cb5455a6a0de4ca17bff2fdbd62",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/nulls/v1_defaults.json",144126,"4dd3d11d3b80ff395a633554588b381beabea4b695f057c2cc88ce503717269f",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/nulls/v1_fine.json",143688,"8bdf35b234e1fef683d54f9373d96a64fb5506df8bf29bc77d1eb9f49086db18",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/results/nulls/v1_wide.json",144138,"3fd93b60689444f4ced4391e92e0a9d85fdbcf12178da97aaf869128d9f04de7",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/search-environment.json",1184,"54263a88e5c6ffb51b7f503e88e5e5a3e4022846ba2807ac0df55af13cd1dbf4",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/source_snapshots/high_impact.py",32370,"54ff60d8e50eabd1913fc5c7bba6e560ac9b44384e82c06d7b085ffd11917b35",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/thresholds.json",1198,"2fb5125a98a351bf2851419ae9e1f419c419c846a7b708798ae4c9b1b88f5ffe",false], + ["benchmarks/results/tls_accuracy_2026-09-09/high-impact/validation.json",8684,"61381adc22ab0650bef101de57fb9cdfc2894fd923300236975889933e309bbc",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/README.md",5277,"a5b12dfb41d1fa09795b910f637effa69583bcf2a0afe150e4d5856bb1faa6d5",true], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/SHA256SUMS.json",1468,"af8d52dd35d03f1cc83ecf86b16425a6b314cd553ab8223c470dabb558b4e7b0",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/baseline_tls_fast.cu",22785,"ee8c4c84b35dc262b9e45d9048d04b67b5f04f629dfe97deb9786cebd73e7c16",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/grid-verification.json",8241,"42d7412b3cc81f2ec341a315361c1a024cc0194d54d56fd3045410eb98d58c3b",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/input-verification.json",3501,"dd7e3c74979d95b8a9ebe554a71ff0e72505bcf0a84a694ecbfa48d1149971ba",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/source-hashes.json",3854,"a92289af519fc53bd7ad4dae7730021a72b5d8d8846fda7dac714cc537cec773",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/summary.json",16475,"ada97000aebd57d90bedb00d61f264c987ffe778be16db274404f97c02309e50",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_200s_fine.json",78286,"a85a1c19ac718fa1a83a598830ad826167a8e2dfe61fe8b4b4cb63eb716178e9",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_200s_original.json",80262,"447c0dec8aabc52302a353405824978904e9d15fd3ecc114203ceb9498c5b9e9",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_200s_parity.json",338945,"d632f0ab86c5a7c930f2c9fbed925b8a5bbe58ddb63e32c643af21efadf99e8a",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_gap_fine.json",78367,"cb7d537cc5604c0dd882d4066ddd1adc814e769e52362b78ec6b322f2a0a0f86",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_gap_original.json",80436,"2e3e01fa938eaf0ac9a9e5f47a2957f2da3981a941c1cb5cef85350b01ad71d8",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tess_gap_parity.json",338885,"10ceb448372321e699b63cea91086f8906467572b4d984a96d3168612d51a901",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/tests.log",2317,"38d2d7b6be0251085498cdbbba691bc55e7f2c419c17e3ff498f1553c97ff2af",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/ztf_fine.json",78430,"47599c791c80bf0e70f84ffed6327de28f2ce93e4f27a20afb90c75fed35d8d1",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/ztf_original.json",80270,"ff90025c4f0f0a63c48f7b1f95e0604bc0012865a7d67a9eaadfa034f19a81bf",false], + ["benchmarks/results/tls_accuracy_2026-09-09/kernel/ztf_parity.json",336881,"a516e2178aa37ff6ecbf8e6d44766823416523af30eb65fce57c92395fc234e7",false] + ] +} diff --git a/benchmarks/archives/tls_profile_2026-09-08.json b/benchmarks/archives/tls_profile_2026-09-08.json new file mode 100644 index 00000000..de761108 --- /dev/null +++ b/benchmarks/archives/tls_profile_2026-09-08.json @@ -0,0 +1,131 @@ +{ + "schema": 1, + "id": "tls_profile_2026-09-08", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/tls_profile_2026-09-08.tar.gz", + "bucket": "cuvarbase", + "bytes": 1063880, + "sha256": "038e618568e5f905f8a3ac786dce09a5436e0251ba1d64aa1c25bdd303cd8158" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/tls_profile_2026-09-08/.gitattributes",74,"6bf6a4ef8a3c547980885942fc2bd2c3bc37c57ad13843d40136a3c73c078b90",true], + ["benchmarks/results/tls_profile_2026-09-08/ARCHIVE.md",767,"89dac31c3c7ae1bfe417af222c694db5f3606a4b7846fa9bcdb19d804d945072",true], + ["benchmarks/results/tls_profile_2026-09-08/README.md",6779,"fb2d385729e8cc27dc9b263816a359da282c22f43a914370178fb36d4411675c",true], + ["benchmarks/results/tls_profile_2026-09-08/ablation_output_comparison.csv",815,"de83b3a873237d814797fd3f8b127a72720ef8ecb92251af5794e8eedae19c53",true], + ["benchmarks/results/tls_profile_2026-09-08/analysis_summary.json",5769,"f9d42eff45df65631cf927118847d7299524bcf2d0e53d70ccd21441044b82d2",false], + ["benchmarks/results/tls_profile_2026-09-08/figures/tls_components.pdf",26258,"91a589b87e28c2bf0833f335b9b89c359d84eb17fd38a52d646bbd9c7f4e3c42",false], + ["benchmarks/results/tls_profile_2026-09-08/figures/tls_components.png",157238,"b4f4695d4557cede420b6e6a60f44064800096184407e64bee3bad1883732c8a",true], + ["benchmarks/results/tls_profile_2026-09-08/figures/tls_components.svg",121273,"9821788d6c74785567dfb17e4efdcb9c2c2e520818dcbd9308f3d56a83ec86eb",false], + ["benchmarks/results/tls_profile_2026-09-08/phase_timings.csv",22039,"60677fdf3d73c3274e2b815029ef502caa5b16720c06997042648f88793195eb",true], + ["benchmarks/results/tls_profile_2026-09-08/protocol.json",1122,"7fa663b9dad7ae31f7413d4eacb2f27ca82838e17d735f7fa0a397f63b8b8d73",false], + ["benchmarks/results/tls_profile_2026-09-08/results/cpu-quota-period.txt",14,"f8dd67122c0673381e26b4c16fa128efe0f1d8b894bc2c4423928dd9a7971d19",false], + ["benchmarks/results/tls_profile_2026-09-08/results/gaia_cpu_failure.execution.json",403,"1823b5fb3afd62b9ac9350c1823f9458cc549638002839e80970740605ee54b8",false], + ["benchmarks/results/tls_profile_2026-09-08/results/gaia_cpu_failure.json",3473,"bbe3c25bdf0bd106d9748fe4f8e64ac14e69552de8745845b0b0c674bf6a95ed",false], + ["benchmarks/results/tls_profile_2026-09-08/results/gaia_cpu_failure.log",139,"c7e4b8ddf976f86fc100b7fa36ca61de41cc1d2772ddaed764819f193b3cbf2d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/install.json",528088,"d645224705dd9c24a74f4dd0f971a2bbfb970902539300a1493c06a4a0f47d51",false], + ["benchmarks/results/tls_profile_2026-09-08/results/installed-source-hashes.json",10747,"8e08c1411e60bcccdecac37d70083eae9feef8344512ed15c48928b0e091a2af",false], + ["benchmarks/results/tls_profile_2026-09-08/results/jobs.json",4818,"0471f500808c0bbbae032359e75fc51ff106d200ed28975a42593f9cdbd945cc",false], + ["benchmarks/results/tls_profile_2026-09-08/results/lscpu.txt",3253,"7c2d117ee08cb7138ce936d388b603810452a292a3b6293bd8914a6055b63f8f",false], + ["benchmarks/results/tls_profile_2026-09-08/results/nvcc.txt",209,"3c71ed984d2ca98770b0cba5c23a849a87209351e105433929c3062fb66d1915",false], + ["benchmarks/results/tls_profile_2026-09-08/results/nvidia-smi.txt",1778,"fd0a4b94b3fff55a043664cce27866880ed2c23c2f96a98f84baa5a1b6443c18",false], + ["benchmarks/results/tls_profile_2026-09-08/results/pip-freeze.txt",971,"22b212fa48c7c4c8c2caaf28e05b30962785ce4433c8e5648f02013f021c85b0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ps1_cpu_failure.execution.json",400,"184447c377db39b6b41fe5f84222bca328c2d53ac91cdc0569f7fa80c8ca975a",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ps1_cpu_failure.json",3486,"9ffd4a0ae31ba9a97e3a64b30803b56cd316bfa68380199fe4111c9434b214a7",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ps1_cpu_failure.log",138,"d68539d7aa348906071d7098b93c7715730725e3072fdf99f435cb93a1dd80de",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_cpu_failure.execution.json",406,"fabdafa6a0f4159650fb5aada016d37499dfe8a8c8149104f8f2663f556ff8be",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_cpu_failure.json",5810,"fca7e2919003d051c85b64da784f7b6244a6364684058ba772724ede110d20dc",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_cpu_failure.log",323,"8498e862b2ee154f53118b5c566c94f1286271bb140b1e3a69ac86505938112a",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.execution.json",512,"52f2b8fa065e4599b932979ac3a669ab18432b8722d45bb6a0785452f8e74bb3",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.json",7594,"bcb3e9ac6ecd666e72138e12bbae1c4fa7c0d35a3587026f888a9d84ba2dbd9f",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.log",1521,"f55ef410a1ddf98ef43dd96d81348c06e5c7157596544b3d0ea043b9c91321d6",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.search_multi_periods.both.py",16287,"e546e019f8ef4722aaf6de50c013664bb852128fe0f9e025fadac3e67ad78c0d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_both.search_multi_periods.instrumented.py",18281,"6e382d5563dbb42cbd745527922c0868b9050939e717ca6fc6e816982cb45acd",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_native.execution.json",518,"d7046cdd501a44ac846d39b2dd7c572ac097587445440baf4814433d6e3258df",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_native.json",7061,"4eaae3117c430cc1d78b7e97d1fc26f0de8f83d0958dc48f3e71427feb7abcde",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_native.log",1520,"8d960ef9412911ed12fc4392896d7ca215f8cb40c5f6a9b445c06a00f04ebd17",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_native.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_native.search_multi_periods.instrumented.py",18435,"23d4366fde18e22081569a194b650d0531b5e345e544904cd73a56202e7a60dd",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.execution.json",513,"d06e1d11db025f75194158d5108ccd2b34a44367dd589b2d086ac7e1367fc841",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.json",7537,"34234b633e4b7b0c44293410838ae387645e8d838795f3621d172063aa857ac4",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.log",1521,"05d4567616d76102a9ea2dc56cdfcd5b435e99040c01a977224f1e66d470fa7c",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.search_multi_periods.instrumented.py",18327,"8e683b6a48e9269cbb05e12d7d371bd47082cff7f450d6c5fcb3ba897937c73d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_head_union.search_multi_periods.union.py",16329,"c4ead0787b9338e8d5910f527ca7bebf530a87629fb6f371816caf2d2b32b10a",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_pypi_native.execution.json",517,"651c4084c6151fc077052b7a514a142660e3f8e4a382e3d5ae891b7f8fd2aa57",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_pypi_native.json",6660,"b5ff7ff2cb8f5bed67273e2bee8fbcbe4fd7d015c50ec82bea7d8c9ac3f703b9",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_pypi_native.log",1539,"93cf167d6553187c74d9f7f6f1fa410a38e67a5eb3ad464112ff0f963f78ec38",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_pypi_native.power.instrumented.py",3147,"f2e05faba6bd29d84a4d4790c6f2006124aeef9282e62cdbac24cd8d1e163047",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_gtls_pypi_native.search_multi_periods.instrumented.py",16192,"01bfb3832caea7839ffedc2e2583243c435fb10880166f7c24458fe001b15cb3",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_v1_release_native.execution.json",519,"cd0d7f0df49499edf3802300e418574d1a7c7c1bbedf7500a5d1600976415452",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_v1_release_native.json",13983,"290082a79afda75f6cad9d2c0f0a97136d79881ae2ea895532796e526427e036",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_v1_release_native.log",56,"60e13e4749af642ee218c55dfbbe5f3442718267abc2e460c229be4a3ba8df03",false], + ["benchmarks/results/tls_profile_2026-09-08/results/rubin_v1_release_native.tls_search_batch.instrumented.py",20359,"a957438453d46d9f2e2eb68045b4e460591d975a613b6469230bd88724622216",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_cpu_failure.execution.json",398,"6fd2787b8a46056f7a93f712127a825076f1608b728ffc0a75debba8728502f8",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_cpu_failure.json",5769,"f49265679a79cf800971d594cfc8c8b1a9316bbb4dfa07c764788f95fe8b189d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_cpu_failure.log",325,"800384c4a429b38a6814da13b7b2337c99ddb893fc028a144111dbc2921c3283",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.execution.json",505,"8da00b60b3c1e62d51ca1e6d1b166ef64cf619c23563585d2b7666af3ee40e99",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.json",7603,"731aa809aa2499c41d3020a4e3c9be9bd1074871fe4fd1c605589d0b741bf792",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.log",1521,"77bc723ab3d0ba57a1389ad33fa8e7cb72420e602216aadcb370546e1b4265db",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.search_multi_periods.both.py",16287,"e546e019f8ef4722aaf6de50c013664bb852128fe0f9e025fadac3e67ad78c0d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_both.search_multi_periods.instrumented.py",18281,"6e382d5563dbb42cbd745527922c0868b9050939e717ca6fc6e816982cb45acd",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_native.execution.json",511,"7783658940afbe8474eb631b8034852f3974a132873b7e1af97d0065655c36c6",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_native.json",7047,"36945e1bf0404a67d2a4f26518e345b78350bc3414488a172ecd5876ec24274c",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_native.log",1521,"58a99f79209b7fe142f080ac8f344e3cfbd0cc7e1904ec412bf25e1ae07f0c59",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_native.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_native.search_multi_periods.instrumented.py",18435,"23d4366fde18e22081569a194b650d0531b5e345e544904cd73a56202e7a60dd",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.execution.json",507,"4cefc3e12d924696f1bcb8f1276c39d37b19060e886164d5f9f4ba04cc2f24a4",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.json",7544,"1fdefc672c7f1b1e97afb9a3239d5924c4d7e1280d64f776815a978127b7753f",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.log",1521,"0639effeb07ccdf69292a00038909ef32e0deae178e8d0b53a5123be7365b32e",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.search_multi_periods.instrumented.py",18327,"8e683b6a48e9269cbb05e12d7d371bd47082cff7f450d6c5fcb3ba897937c73d",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_head_union.search_multi_periods.union.py",16329,"c4ead0787b9338e8d5910f527ca7bebf530a87629fb6f371816caf2d2b32b10a",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_pypi_native.execution.json",511,"0e20ef07421c663b893d544dd0dbd614d2d51cb02b982b698c6af0a472dc4410",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_pypi_native.json",6672,"255c7b956a5c342d7573ff613abd26997fd814958e59adf21d0e3c25539a67a7",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_pypi_native.log",1539,"eb1b6da472c715d5e2b4f52f568ed96e41edd55c89f35418125fe068e5b5d3bb",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_pypi_native.power.instrumented.py",3147,"f2e05faba6bd29d84a4d4790c6f2006124aeef9282e62cdbac24cd8d1e163047",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_gtls_pypi_native.search_multi_periods.instrumented.py",16192,"01bfb3832caea7839ffedc2e2583243c435fb10880166f7c24458fe001b15cb3",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_v1_release_native.execution.json",514,"a85991c5f74932718f5fb757505642b6114aafc154d841c8188d17efe2c631d7",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_v1_release_native.json",13988,"70a45d61d419c1f1339d2a402e5dc3be5ab40b766fa410c0c4ffd47279e0afad",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_v1_release_native.log",57,"da83e7c39c2803ff72d9ac1ee9b6c730f7dbd1dc5f37024e4f2072da7da600a9",false], + ["benchmarks/results/tls_profile_2026-09-08/results/ztf_v1_release_native.tls_search_batch.instrumented.py",20359,"a957438453d46d9f2e2eb68045b4e460591d975a613b6469230bd88724622216",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/LICENSE",1071,"75a7ecc268b9718a55c49aab3379aebe7a954ac3f62309de4ba0622dd56fd55d",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/METADATA",5350,"c0637bc8cd6aef3e5631a84f89a98c6bced86e0353831aea836d8dde73b5c085",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/RECORD",2052,"26548790bb8c317106db8ef11fa93a2c1133004a0a740b3e107b598e04f9b7b9",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/WHEEL",92,"7b0c04b9e8a8d42d977874ef4f5ee7f1d6542603afc82582b7459534b0a53fda",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/entry_points.txt",79,"d10b07ef7d2c3d35c191ed739b85ace7cf3b35658675e99587ba057ac356cb2c",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares-1.32.dist-info/top_level.txt",20,"87e085aa8e0868e095fd4930b0381df10c09a6c60f16b098a64f6d86b92fc047",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/JAA546A14limb1-4.csv",12386,"b046864aa1bff468c29ac24b2aba0c5e382129b19c47b6059b3575c8bfdf6e0f",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/__init__.py",1077,"b26486d7189db098df2d861e9719b42e94b41d871e256f4d8a0473d529f7b8ef",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/catalog.py",6422,"487f4ba2a273b908d0d92449ac01f7198d7ccdfea04ea88cb303f3008230694f",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/command_line.py",4292,"7462c71725d516a64dd734e787c7dfef399a2148ca9d02097f5e7a4810b90e13",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/core.py",7136,"454421aa8d6cfdd27d55e7744b0c6255cf5f2ff881d7843e8f63ba68a9cb4ffd",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/fap.csv",23488,"c81faf2a8cc3e0f68ff11aaad9a3b5c1ba22b7f1b8204948040827eed0643847",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/grid.py",4894,"4cc5f72cda146e73f30871b51ad71369d29e4af602e614174056ec11b6d84f42",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/helpers.py",3941,"8225a59afc2754a578d0f75f72fdcb2d56d98579ccbf2b20865b8c78f1c37033",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/interpolation.py",1611,"b9b437f70d85b39c27db3a0f0d4f5f950a6374809eadabaaf70adf382cb33152",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/ld_claret_tess.csv",12620,"a52913ea398f0657638cef52d4de577f83928e2c2534343936420dd4ea79d569",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/main.py",18063,"35bb8a719e7d49c5696e1818b90016e49b05d3e61686a0d3d6903ee726a93a31",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/results.py",1973,"7303a7475b78cc7628924cc5b10e2a58d0857265b3e3954cefd8aefd98be08ac",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/stats.py",17636,"b4829c8be58bb85f39738a6d0750c164a56edf9d4f85d86fe6d8cb341d96612e",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/tls_constants.py",4848,"d44708cff7477d9674c6fe845db1bb1f5d0fefab83cbd487eece7021103e1300",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/transit.py",5340,"c15ce7d964f742d960e207168cf3823f843f16df1bdd35702b293806c108a080",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/validate.py",7195,"1d92be0bcf0b97fba66b53114e86b84c12245e27f56ba50942ddc22f4b2e0249",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/version.py",48,"b492ab2b7b469253180114c88597beb3fec9aa6ab1f892a341629a0fbff4952a",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/gputls-0.4.4-py3-none-any.whl",55888,"5a5f3b0af79266d870d70f70e343dc578bb29c0d52a3eb4c7aaca4f1c58a5098",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/gtls-head.tar",460800,"236ef06e60dac3032f8e939b353f545ab616b45922f7d376adce72cb3c07ae7a",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/source-v1.tar",1628160,"19ff05aeb665bf7b4e8159ea7dc9fd4dc358b82fc7c54e5ba1efebd8a6f909ab",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/transitleastsquares-1.32-py3-none-any.whl",47543,"aa3d5e8cfb8e9407cad4a311335ae3099747e8f34a6e68308ed0a2ef608725c0",false], + ["benchmarks/results/tls_profile_2026-09-08/sources/transitleastsquares-pypi.json",7624,"f6a1feddb7b6f130b526a4d2d598762d07cf39b725101e37f6737db52a1eff95",false], + ["benchmarks/results/tls_profile_2026-09-08/timing_summary.csv",1236,"c0a2e0f775eddbfdd6abf058022e41a03c94e6f897da12c4c1777f287292b424",true], + ["benchmarks/results/tls_profile_2026-09-08/verification.json",1091,"741a26534d2f54e683fd5e90bff456bf13780e606425c222c71deb375ed99996",false] + ] +} diff --git a/benchmarks/archives/tls_reference_2026-09-10.json b/benchmarks/archives/tls_reference_2026-09-10.json new file mode 100644 index 00000000..79aa443e --- /dev/null +++ b/benchmarks/archives/tls_reference_2026-09-10.json @@ -0,0 +1,178 @@ +{ + "schema": 1, + "id": "tls_reference_2026-09-10", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/tls_reference_2026-09-10.tar.gz", + "bucket": "cuvarbase", + "bytes": 29113642, + "sha256": "2f1af3e342ed0793772ecb20fe20acbcd50d9a6c44785d9eba6407adda5fc7cd" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/tls_reference_2026-09-10/.gitattributes",110,"844f1a5407728361a099c7781d1c57985c5b29bce29df9d91805c3a540a8e56b",true], + ["benchmarks/results/tls_reference_2026-09-10/README.md",6575,"b12f2c682cc978f0ae25bf8377959cb94728c20cb2d6478b24635a3390606cb1",true], + ["benchmarks/results/tls_reference_2026-09-10/figure-provenance.json",2626,"2e44755b9a54ff075e86bb26ac357a9c10bfb225f01a7b118159cdf45e5426b6",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/README.md",2772,"1029a3dc66964bca8158c6b2b82dd138ff7b09fcd99903bdcbe3ddc737a77328",true], + ["benchmarks/results/tls_reference_2026-09-10/inputs/SHA256SUMS",696,"92a2171af04754bec289b437f3395f1466e7664e7744a31694e0a0cdf0107256",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/arrays.npz",19877103,"44ce0199faf2509c591bf4da1f25be1872ef77d8e229095e1ac89f3814bbd9b4",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/bank.json",173058,"258413b2cf79163af2ce108ad63c46d2df58460a619f529a122b3244f181b3a0",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/export.json",1113,"6db32872fbd109a6497fa52ddac5d0357bfe6f34c5036ce9c83d77dbe84c47b9",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/manifests/long_period.json",13520,"622f5e7aa611bc58ccd370d1c8e61b549190f088285d22eb1cc9860528b0c465",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/manifests/main.json",556938,"aebf04ba2e518471a4273a9724a99bc9302e32c49033da068cecf8946efc44cd",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/manifests/selected_grid.json",65908,"24f4c21691b5b604416eeb8d48cf02f52c8548ab1992c49f4819a4ea98fa88d9",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/manifests/stronger_controls.json",7200,"4f4d9023691a46b69f89ceeb6599d487009c0762ea6ce0504a756d41eb82985e",false], + ["benchmarks/results/tls_reference_2026-09-10/inputs/manifests/supplement.json",88605,"a436c2e4871bc849723aaa54226af0a70a6668e5cc2668d1662fe0cc28b5a8c1",false], + ["benchmarks/results/tls_reference_2026-09-10/rental-ledger.json",9735,"6d8546de42c8f674a20909bedd4044d068e6165d851999c0af7a764e6f8a11a0",false], + ["benchmarks/results/tls_reference_2026-09-10/reporting_acceptance.json",44691,"1d6454cad6a6de7c830f24bea27ca35d608017f89874f6ee55ab5eeedb7cd2d4",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/README.md",4531,"d7778588b98970f443f4648044c8e7acf3f02580417e867a68d5e1357bbd1af3",true], + ["benchmarks/results/tls_reference_2026-09-10/sources/generation_environment.json",526,"0bf76648825cb23dfc3b2b5f93b3c37f009eb24c26854c8d9686bc6ccf97cfe0",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/gpu_tests.json",18900,"0c87955dbc913ba3e2c30d6f210ef97e9a963846f4c89a83c1812fc5108773a2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/input_restoration_proof.json",411628,"2c572a4db0c8e4dd4a1c9c5d49525e7701eb551d2b5386541c610698942073eb",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/input_restoration_sources.tar.gz",7038,"cd0a13daa2236dcbc236d354cad85474d316e8ef05428225fd7ec3e6f59ff4a2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/manifest.json",6038,"0c57ba652c17bae8397170012baa88c06294a5e38a8045c593bdfeb7c1d5f349",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/production_sources.tar.gz",448840,"f8c6930e1804adbedf355e3d11cf0826a5d28e6d73475307bd2c4a82fd40bb94",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/publication_files.json",1376,"0e21beb50da6d8e29f59eb4c7fefac456d1c486489febede4cf240e1c5572947",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/result_postprocessing_sources.tar.gz",12055,"6c170c136388cec4e805834f3c4f944c72f778d1f40b629c38b4b1956db8c006",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/scientific_sources.tar.gz",54768,"f2d5cf669e0859544ae7af220613dca0e362043cebc93b7b6df5f45d8be88143",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/stress_compaction_proof.json",1276,"a2576f384193716656e9ad0c275225930a34528cc368482141330a4eb6dee01c",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/README.md",4606,"f73abf4915e2ef4245033ff09db0b3ad2bac2008334bbd1d423de4232270e83b",true], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/allocation.py",4695,"e9d7d13905c32fd523c2fb6c60121c17ba55707145dcc10ca7a6b48a11aa7361",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/collection-support.json",18863,"a65805f3e54e0df3fa0d5f3c9728260f8c7ff4e7cd28b4c375c7c0c857965750",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/collection-support.tar.gz",560405,"f39fc0d0e68c4a3f2bb082c8058d98cd91250f3de98c23b73c2de7b1cf786c9c",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/cpu-quota.json",48,"fb3cdcc58afa659317750ddb9e7edc988be0b3b1fb0adb6e7df61f2cc617cce0",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/cpu.txt",3550,"2f405643050023153099db2eed1e59744be066f3276d5d7d96439e3cfa18f8d2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/freeze.txt",1105,"b609f3f7278e826841967433f66e3e853e2649396d03f5aba46f70a37b09cab8",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/gpu.xml",47009,"dda3b0300e9fb90b4b9125efcb325e3acfc59a2a3f5c05b690f144d4fa743a78",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/install.json",544955,"cf86ac9591e72666d6bd2d0c6a7522dfc438eb63156ecdccc0d0f82f610e8609",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/environment/runtime.json",366,"ef54bf266f00be605ba0e91c6c3f9f6a21c3655a4635dcd91baf102c60e0730e",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/execution-sources.json",2230,"b89e4be7f0de7966849eac137cd2b56f9816acd45b2c44b91552f87e5f8ea225",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/execution-sources.tar.gz",59991,"3826aff0f65042079159291d751b45965faaf717a50677af316ed4bd9f58ffd7",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/README.md",914,"da60caefd1c4a3f8e6ee0c164b39ea7d714a1c4fceffa1a7d28a8210f6c08bef",true], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/manifest.json",1123,"1c520bac68487368b598afa47edfd4953bffc5fc53f52c58f359244060f63050",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/acceptance.json",659,"2a868c269144bc93926d40462eea3df6de601f6458a8f80f190663b9891ce625",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/logs/preflight.log",2929,"86ba7376b7ee487699b6ed33cc24d07cd305cf054d90bb189f9c72c1fdaf2367",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/preflight.json",192976,"7308d33a6076c7e012597e2fe5e2cd7962fe0822a947e4f979017ce3b70d10bb",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/source-audit.json",12192,"089a045b3c9f7df566462a676a603b8c2b6e48cf34685ca0986837a6ef1114c1",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/status.json",1326,"f454e957742cb2034475c43caa714ac710e6c683d9a7de7c63277e3a02b10f11",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unexpected_gpu_process/terminal.json",735,"885500e74f29f58fa53ef2319dcc9e4d31ef5310db52228678d5228a9d127bf0",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unmapped_host_pid/acceptance.json",644,"f62b29e1311480eb8d19ab88d5a1d3ead6f58086cdfff4a7f74d4496ce46d1fc",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unmapped_host_pid/execution-sources.tar.gz",49802,"b4b67c41e925e8ad3e032a4ce641d6e99b34df10a224c241de474f90cbd38dd3",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unmapped_host_pid/logs/public.log",835,"6e9e81fd8abd390215a909e55b8a02c1ed388486d95c21967f6e64a9653186f4",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unmapped_host_pid/public/tess_solar/candidate_graph_1worker/record.json",2587,"8a697de85d437a6f9710866eaeb8f4c81073adf13ef9d7ede1120349d78a9fbb",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/unmapped_host_pid/terminal.json",640,"3e72ebd41804fd6314c77b626978dee67459dd2bbca068d8b67f754f75c497f2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/hardware-timing02-allocation.json",714,"b9779462762980b84c4b9b685ced697adb0107d62d1badba6b1fee49b78ae2ad",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/hardware.json",1795,"233880933d2bae72cd08fc647c43050249b04c14e9f0bd34bc83becf34c75831",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/manifest.json",6303,"56a236dcb61fdec4821fa368711fc54f2fe3e0addbfcc6404710b9c4deb7c933",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/protocol.json",6075,"0f28803ebda37a555053968cc9708abca53a0b9647f23d675766af8295924eb0",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/public-replay-proof.json",910,"1869f1083972b2d6250c68fb2ac9eeb32df9f06950fde9bc9ace54e6e39ece78",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/public-verification.json",1025,"edf1fac30cbbb85fd8bcf084f4a91f905f64b6bcc20fdd42edf8c27750fa5374",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/report_completed.py",29979,"e43a99acbc6305611e3d4356526f697aecbb65bf87cd37ecb0346ce2bed70aa4",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/reporting-cpu-tests.json",521,"74baf97fe0f594ccc63cd805b19a3631ec22680b3c42f91befe2a5cffb0f5ea2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/reporting-protocol.json",2145,"5de0221ba0ee28d89030741cb5739d928949a6611cb7a69039de39f6ebdd7fd2",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/reporting-tests.log",99,"8ebc47f261b07efebf60c56a9cb3b54c1110eea6e9207f1c477d917557f44714",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/test_report_completed.py",9863,"4568202e5a2ca9318d5200a63d875b8a82889015d649ad056c52bf36b4ccd4fc",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/timing-origin-records.json",55220,"68385d3d37ed525531c0350ee33de785f5a1a053c93f47fa19e9b4b44a843786",false], + ["benchmarks/results/tls_reference_2026-09-10/sources/timing/timing-origin-records.tar.gz",2053066,"b96741a90911d577e4fcc927f8434b1533101fdb061d50eac27b07566bb26afd",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/README.md",7340,"636b655a882637d7a5274fa66306068ddd20f08ff5d35d985fea2430bba0ff98",true], + ["benchmarks/results/tls_reference_2026-09-10/stress/array_digests.json.gz",292000,"ff4985d50892d0c0a0011da05b98a1dac83a014c46f3954a76a9961f75744341",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/cases.csv",19546,"1a8c6f764535177823a82f9ea98b334eb14b3d78b1ce79415b961c35b8c19777",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/comparisons.json.gz",19468,"5386c5f7a77924e811e6a5011da545891e5a542f75b64d22c7087cb8e3016536",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/README.md",7077,"8c61b2c59d78cc08f34246c8aded3eb63654fc15ee48198a8128da9041966273",true], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/dependencies.txt",1105,"b609f3f7278e826841967433f66e3e853e2649396d03f5aba46f70a37b09cab8",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/environment.json",888,"2415a629a0870c7ecf431fb485a0d4e202436f7ef3132ac2d50817a388b7326e",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/execution-sources-v3.tar.gz",21184,"0e3cb85b90d689afe430069154b38b3113a7354f041ff431805826389f6413db",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/files.json",1052,"88716c6c4b5d4ff58d9b27765eaf6e68eb6b9d5a89c42f877aa5300d8a742843",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/postprocessing_sources.tar.gz",4771,"789fda614866f926ef3932902bc7d1f2557ef88049881e17dc4aa9ef9921d80e",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/publication_files.json",1439,"b33688a894054231d29574f7f01411b9ae71c31c5e3d8200aca3c14bd2b02005",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/records.tar.gz",281448,"e6bbfc2959a309708b7245e9688f28f1a6ee13b22d607d066950ebfd5922ac54",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/replay_staging_validation.json",330,"9e6ab47ef0777496ece97393b4c079c3d0012f0cbfa5823ddebce0280f4ecdca",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/runs.csv",2420,"8a06458118ad252da54cc5e42d82a555dcc7e40c709057e8d59417202c329b07",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/source_manifest.json",1460,"86b9cb7425e2a3059db0b79c7dae4db58e862e8e6e01342586681181958fa7df",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/summary.json",12131,"60a9d45a073a0b2046286c512065e7122a756a6a6b12dad2b1cb489f67235322",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/files.json",1076,"9dfce3c556f6bd2901d02bc8e8cc429b4b65fb3c694dbac7d3333d5785116590",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/long_period_inputs.json",13520,"622f5e7aa611bc58ccd370d1c8e61b549190f088285d22eb1cc9860528b0c465",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/publication_files.json",1467,"bd254e50bc77f858bce7a4630b91c6fe0f31627fd54439eb41e6fcddbf7731c2",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/selected_grid_inputs.json",65908,"24f4c21691b5b604416eeb8d48cf02f52c8548ab1992c49f4819a4ea98fa88d9",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/source_identities.json",9610,"4036bb5ad83f65c37146e01ab663c2640241c85f021b94fca452a442a4b9f335",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/stronger_controls_inputs.json",7200,"4f4d9023691a46b69f89ceeb6599d487009c0762ea6ce0504a756d41eb82985e",false], + ["benchmarks/results/tls_reference_2026-09-10/stress/validation.json",4240,"e2a86c6ff70c6f11300acd405d147e983074350fb171b3ad545b4e7cf8f18eb2",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/README.md",2450,"04e2782a6272e3680eca7d6bc1964e68679e1c54c5d81573e62e6fc1715a24f3",true], + ["benchmarks/results/tls_reference_2026-09-10/supplement/acceptance.json",8838,"629cb99b10201ce105b1da2dcde7bc990965dda6a41194836b6ef93e04185150",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/array_digests.json.gz",547033,"021c7e7c746dfb8162541724d7e2be31613414209f25fcb6897ee6f3317a136b",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/cases.csv",12308,"14943f6b8f4b357f484ac26c138d44b14dbcd14e1b892d42e5bb8ff5ed3cfb22",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/collection.json",3154,"7fbd59bad36d4a3f93095be95a8aa3cddc9e58b848ac97bad807238d364d2c41",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/comparisons.json",59223,"6b02b287d8ab1e9b4e45b97ac6374d244be1c16d9355dd5e6868f11e6812c041",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/execution_environment.json",3016,"78be5e74d262b9a5b46724c09fffe649855704c8d2f83e6ca794d99d529c4d82",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/files.json",1026,"04098051eb3ba64e12d5c7c99b900711e71a761d2f39cd046593f1d70d33980b",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/input_manifest.json",88605,"a436c2e4871bc849723aaa54226af0a70a6668e5cc2668d1662fe0cc28b5a8c1",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/publication_files.json",1538,"0170fc688fc96724c678fe7e39cfd6bd0f7fb66f8c5c9f6f8b5a05e7ec84d1c0",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/seal.json",13545,"5c97c809531f1b26ab95ee5a4f2ec7d7791a3da44df977af719ca4a41a2b3486",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/strata.csv",2010,"76c489f4cc0ae0f9094291c1514658a9f2ee763ac2aa80555352e41f5894293c",false], + ["benchmarks/results/tls_reference_2026-09-10/supplement/validation.json",456,"c8da00f137ec62c232a832a6694e2c37dc6bdf37dccd84f16fd09c14f0ac5148",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/README.md",4829,"f4f36ed0d0c9aa1b76057ddbd939e9a68f9ae160918f998841e3bbe1e67e0609",true], + ["benchmarks/results/tls_reference_2026-09-10/timing/acceptance.json",3364,"1e70b9f8226b0f531506d39cfccfe4063980de6eedd4e6d7f344446301a44dfe",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/monitor.jsonl",14002,"4cbd95ce7f7e5789f9a844e07abb54f9614a5f46ac837d0fc3bc9ab00bbc492b",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/ownership.json",3045,"a0483dbffcd0eec4baf85315af1f44511f8e66a0227716863cfd7d5d35729c1d",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/plan.json",170885,"51f71c30d50163bbb25190daa0b4104dd883a5e34b3308010eddefc539b14771",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/summary.json",3604,"c0b73c9a055533c37c446655967d538d422c97ce780371c469d97132791c872d",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/tess_gap.json",60137,"e1bc896b2e5163f41dc28e7626a7420e48f7176b3e3eb1b4896de767243770bb",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/tess_solar.json",60245,"c721dc39e1d3c352ed2cfbb1c03b763751b5e331e9bf2281225e66bed362cb00",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/candidate/ztf_solar.json",60141,"b06e0c45af135c2b97ab7a6acab12957268d7c0530cd6909cde8c992ddbc965e",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/monitor.jsonl",67738,"d64c866465f34b89432e56246a9b139ca9e74ce23dec2a9756eb331ed0718ceb",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/ownership.json",3824,"ae26ea4465e84d5dc096266545f1d573b3aa8d7aeff0c37b51421660124315f0",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/plan.json",172447,"d6ed6dd3786f745f941a39a900b31ea612c9958b87a152afcace9b2e5b13b4a4",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/summary.json",4396,"3aa31e65feb8bad55d664fbfc7c21d4db101b3663653f20f6bfc495451820ee0",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/tess_gap.json",51281,"48353696628aa787bae750b0bbd5c5c88e7cc04eaea7aa595ef03a976eda8cc7",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/tess_solar.json",51375,"f53782ea00fffd02a61bc123d07b21e0b9954265359b2d5ec8d0652927bd632b",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/components/gtls/ztf_solar.json",51245,"b6febb4760cfbc36c747d489720a596e171d461f86f890abc202d0199498ea9e",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/components_candidate.log",592,"1aad98366dae24cac72c8d1c97d601daa5c04a31f90955de26c331fdf5a68d76",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/components_gtls.log",1175,"f18047401e215813b1f42a3e74eaa124e1e0fdd9fb1bbf05196577409763e3d0",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/normalize.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/preflight.log",2417,"f5da71c4a347f7945777c86444db03b67d0532bba52c104a88c502d8f4b68231",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/public.log",12811,"c84bbe2e5421aa1be39284894bac19506d288b1e84ac9b73dc1e9233e0cbf615",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/logs/summarize.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/pipeline-plan.json",4461,"b3b99f213ec7777a507d3778fc8ec27dd31187d2c0098f4206d46a01bbc86ace",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/preflight.json",191512,"712dbfc48e3a92b9d99c02a85493788e53c518a93756d47b7f1c10280ac28f4a",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/prepared.json",286036,"49ca66e864083f5646c288108786bdda09398c1402e4ab1ae0d389bc48fa5a88",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/monitor.jsonl",1214688,"8b321b8e71038d3c8c3149e679735255b53bf472a85413d8d102346ef33b12af",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/plan.json",322998,"b073315641b55e3502ad6ea233c5b2a2d73760810fcf6b9bf460505af45d1352",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/summary.json",329867,"8734a3f0131779e01158321b78d2be9604a791d72d422d6bf027f9af19e43df4",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_gap/candidate_graph_1worker/record.json",393540,"86725134b15a3c2fe5298998447a23eac9210cb60c46097a122431ef9927bc26",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_gap/gtls_graph_1worker/record.json",272336,"1ace825ae54a921c0a13924db1775826255e9ab6a49e41dc856f3657763dda34",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_gap/gtls_graph_2worker/record.json",254105,"66294d4d69cab8c3e3466e97ab4fcda644ef4a9614fc841125f9a9e76b0b5e07",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_gap/gtls_graph_4worker/record.json",40588,"b3bff3dbc77aec586d67585630418aef4de0b8dd840f7785a0ea0c264bbd8f05",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_solar/candidate_graph_1worker/record.json",393600,"ea0386f39d16e0f1703252c683453d48c1b0f70fa573e869668f0a2aa606049e",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_solar/gtls_graph_1worker/record.json",272701,"4799dbd96fa7d5a0ce2978a84a0aebf8c2ccf3bba1d0c7936cefdc94dc82ea9c",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_solar/gtls_graph_2worker/record.json",254475,"1874132c52f408c1d494aee6f3889116470f980d9e753057d12a4eedf79b013c",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/tess_solar/gtls_graph_4worker/record.json",274801,"beb58f60a6e21ac9254394183d3396bca145e832238ff21f204d124adfa19e58",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/ztf_solar/candidate_graph_1worker/record.json",393935,"072213e2bcc55e19db86a78943bb2fe40e05108991d8b2d4353a60a358c17e53",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/ztf_solar/gtls_graph_1worker/record.json",272058,"7ed33b3de103da6875920f27b1af383e7d02741c744870bf8a5e29f434092fe3",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/ztf_solar/gtls_graph_2worker/record.json",253884,"0c0af0fbf3af91209a3a43b912295356528d2365b60e66d13581819cf96e8ebc",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/public/ztf_solar/gtls_graph_4worker/record.json",274179,"b92c719d6a83f3302ed957cb9be8b253297f66c4f693c523adafa56b3a922023",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/raw-files.json",6679,"eeffa9d45bd981cedf49b7717afdf4c4d62c842edd97551a771b8b6f2e91c8a7",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/status.json",5769,"de67187b0f415567d49f1eb506c624897166d4cefb1b38ec3d76f099fc734b93",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/summary.json",576015,"ea0765ce93bbe52b7c1834250fe390c428f5e50c636917f16581d671aa2cd10a",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/terminal.json",816,"2903a567c8afb0eda907d5882359db1e606dc9a063247ef3f7d2be85afd90911",false], + ["benchmarks/results/tls_reference_2026-09-10/timing/timing_analysis.json",36590,"2faa9cff04b1a472a6893095e28008c514768a106ea8947b7654cdb396573c0f",false], + ["benchmarks/results/tls_reference_2026-09-10/timing_analysis.json",50729,"d428e8aadc337678db1112c6a0adf5de1cdcc2c4a7b4fc8283ce1900f607bb1b",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/README.md",5247,"b5f0e6ed990c71040eddeea19c238b2b9d73164fc6c45526f9c07ded99a12089",true], + ["benchmarks/results/tls_reference_2026-09-10/validation/acceptance.json",8843,"213472ad4bb0c2feb9ec0705617928d6ba3366dd9b217e6c1cea6df21965d69c",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/array_digests.json.gz",3435457,"00f7da0b2b8fa4f67a0171430148ab1d3be9ecc8e75226c4c444630a9df41c6c",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/cases.csv",78470,"cb93f1b585cd4d865809d333bef38d39c0bad081420b9889c5594789315166a9",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/collection.json",2697,"83eb7cc4a64f840329b630e4874e09ce1f897a0b6bd0baa5ef212c61d7c7da92",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/comparisons.json",455564,"acf324c7c81df72a8804288e1cc9d4dbcb10379d03952c8b5f752c49edfc2325",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/execution_environment.json",3388,"7d198e2562f600b73877f287757ec238aadaecefb27d6205c059895008a779fa",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/files.json",1030,"726c62e55065282defd1dc32d8a220993ec3c31ace586c662bea35532c488931",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/input_manifest.json",556938,"aebf04ba2e518471a4273a9724a99bc9302e32c49033da068cecf8946efc44cd",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/publication_files.json",1542,"323f9fc7073d8d67bce95ce611becb7006dc423b5095044f5e324d653b199dcc",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/seal.json",14238,"4405a82a797e7366ad99fb607679a9649145ae93e42285c7c08f7eb438295c3d",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/strata.csv",16488,"a927362fc64e6059666bbf18420962eccee3d6f25be098bd5098a0986ce32a34",false], + ["benchmarks/results/tls_reference_2026-09-10/validation/validation.json",460,"c853e98268d54486ee0540335266856e5e0fb7fc25c56e2222acdec8d4136174",false] + ] +} diff --git a/benchmarks/archives/tls_sensitivity_2026-09-09.json b/benchmarks/archives/tls_sensitivity_2026-09-09.json new file mode 100644 index 00000000..bc1936cd --- /dev/null +++ b/benchmarks/archives/tls_sensitivity_2026-09-09.json @@ -0,0 +1,106 @@ +{ + "schema": 1, + "id": "tls_sensitivity_2026-09-09", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/tls_sensitivity_2026-09-09.tar.gz", + "bucket": "cuvarbase", + "bytes": 47411140, + "sha256": "35d9bcea2f0348a3b9d37279a2203a8c0b0fe871c8daa4ed1794dfbdd695f8bf" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/tls_sensitivity_2026-09-09/.gitattributes",35,"3e42af38b7ff4ec6da3fc88c2c98928889d91daeff9cc3ff83957a562986a6d6",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/HATPI.md",3872,"e708434470b683863dbc6a488c6f7262e8cabb41631f816345b73f53b201c544",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/METHODS.md",10820,"ba06063aa7199b391fd2736ac7f4b4edec2c1bba7f9f8dde8a0dc20f042cd068",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/README.md",12976,"fb515928835262bf387beb8d75866b2a7261f4a6c687f5a940b5f258ed4be4aa",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/SHA256SUMS.json",8959,"34d03406b95a0d54fd8e043e1662d93fba5a0004c4e0cfaed913c5fc71a9843f",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/analysis-environment.json",240,"0b3548c75596e946dd5fed9b4e8ef10706f5889962daa062b492ab2b1d15eb25",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/analysis-verification.json",1657,"a4c0253484b4a1d3660bf0d97c65b259bbdcdd539a81f68704c1fffea2f2cc45",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/binning-diagnostic.csv",354977,"7c4f197ef23fa74572748f54301d94fbe740c3ad88d5155f74b20b4d6cdaa9e8",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/bls-calibration-freeze.json",349,"ffb9c9864b934957acc957d9e86858b093a8eea537b53bfb3d03ba0639688907",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/bls-thresholds.json",924,"2fdad8a8fae68957fabd2c8428b895fa85093076083134da3b008decce99b70a",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/bls_analysis.json",26199,"56ef54be92f82940ceb69947f9363dac52b04e8258a9409c1cdaf98f9f7c7fa6",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/bls_summary.csv",1032,"ec6e74e2bba96d6dd651dfce27c849e79bb37dd058472029aeaa4d5f42655e1c",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/cadences/manifest.json",2861,"6e4717b392796ff415fd52a5c39acea015f41c0ab81ff3f0d674b7bc4fa79798",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/cadences/tess_200s.npz",129507,"6c9dad45878a9f5d9fbd11a25d0d4a9da9797c6fdb430129c5282b7f4d50bc26",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/cadences/tess_gap.npz",1847006,"2b9746cf9a0f961967208843ecf6b5de949941bfd1f4e9861cf6dce0d0012634",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/cadences/ztf.npz",5566548,"fc4c52956954c642f30948a7124418b948da6dcef7c190ff7c210af7c3eb6afa",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/calibration-freeze.json",242,"de1d3bfe78ba4af6e384d66c89e70ae3265efdf6b27fb02e366763bbe2beeb09",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/gtls_tess_200s.json",140,"44d528561ffa570f7e3c4afcf05d5de068e733ee653c0be60d440936da1f7561",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/gtls_tess_gap.json",140,"f0fc03e5145ed514e30744575f530ea713e4ad349ae5f944efe5cd9c610f5865",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/gtls_ztf.json",165,"0df74ad0af618fb880d850ef6c34081cc9e69c1513fd366fbe77b64ab5d6ae61",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/v1_bls_control.json",88,"08404307668420cfe66ddf41e5d116ffeb8a85751407f64d36de319698ca47ff",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/v1_fine.json",100,"d142d44efca3bad84ba5952e845e48563fc9dd8610c8e598484c7ffca172e297",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/v1_original.json",82,"8a1604b3411061395ba72d8688f1d67891dfb0fec2ee65ff3a88d49ab90ff44e",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/configs/v1_resolved.json",99,"5e59c6d8fb684c83cee98f91005d871b294648979e1c9f9380b0def226472765",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/cross-node-probes.json",1530,"e8e507368d6fb93ea2923e40d659a00b3d33749c0f48f7e599e2fb1875e6ea04",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/design.json",4434,"0098c9690d71e9acd32e58cc5c914871b4791925dd1b61eb0d3ed883c7384775",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/evidence/inputs.json.gz",7471612,"096292f4cdf81799b4f6ee84d1f2a4b9901d3225f419e0b067d02df92f2e720c",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/evidence/provenance.json",9308,"69d47072884015086b63d986ccab56a7d5e505c0e4702aa1fef713a421a57b1e",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/evidence/searches.json.gz",14543522,"f97d11a34093e87d2d6f69eb0a6391acf88a402fd42fbc01d82c7256c157d649",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/execution-harness.json.gz",20222,"092432a9f26ae2ad6cd2d6aa207c4bbbc96d9fa9d96425b75ffc9d86168c51f4",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/execution-outcomes.json",6492,"146be272995b979db8c67bcd9427aeb450b191c7d821bb0cd19ca0cddca09daf",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s.npz",1180613,"9cabf7b53a00a15ac41285cc345759da4474559c17056d82de1caf69bfecdc70",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s/gtls/summary.json",1934,"b5e634bf53f1d030b48e9d7326a9b5a12c038177a57b9837268249eec5f7872e",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s/v1_bls/summary.json",3519,"2622f7866bd76efcf367b0868b089ffece32b6bd481ca81ec6df3da933d40d1a",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s/v1_tls_fine/summary.json",4823,"9f6ffd89c0844bbf0c3f4a3260b8956cf3d12158941b4f31b71ae62087c0df61",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s/v1_tls_intermediate/summary.json",4837,"4dc7fb1f8a576ec57f986cf7d2880f144b069dae91758dbad7c46da758266db8",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/binned300s/v1_tls_original/summary.json",4807,"0c8e0aac1237f8730d9e0e9d8d09444831a76923fdd3fa1f6560778152d3d979",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s.npz",6936075,"b5ac723b8e464112d0eeb710d9e181e264d52c858a9814e7693b41e3c5fd9fe7",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s/gtls/summary.json",1931,"4ea2f97e58212d8c9a30fe4c2abad3138795c7575d96a472bce58b0d824a3f84",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s/v1_bls/summary.json",3526,"e9e988d31595bd439ee05a10fb3abe76455f3812b3863ae63d971a4454695d6c",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s/v1_tls_fine/summary.json",4814,"bf25ee2f1e906284f5f7ebaef75a36d42fd49fb4e6981970feb5bb9795258258",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s/v1_tls_intermediate/summary.json",4840,"06d502d0cf61639679e27ccfbadf8afee0e50fcce0392dc00cb4dbd93cba0228",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/native30s/v1_tls_original/summary.json",4799,"b182108fe3b4c91bc2cacb2f8501548c8a5d7f0ba849ec2a630b74da9fe6e96a",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost/status.json",648,"649094513316551bf70862b954165f87ca1880049c19111c4dcb33c6e37043c3",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi_analysis.json",5671,"ad22d00216ca5d4f909bf1a9ef65e04ec9bb72eb49de1be1f9aa3ad0b38115c1",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/hatpi_timing.csv",1520,"0595cd46dd3815476994a5bb5cf03893475ab282b3212dfc7fc44d81b7007488",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/paired_comparisons.csv",1188,"a40e6882dee787e2bbb20dd9d624c77ca96988a8b124cc489db3e15a7976e17d",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/probe-records.json.gz",17068,"9856eea78d87a643ce3d8bd4ef9e6d245f5d324b97bb532b525a03b41fa56975",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/probes/tess_200s.npz",589727,"f96b5b1d14cb06aa0edc00d1de1625989fad0cec51dcf4dc26d46fa9d790e469",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/probes/tess_gap.npz",2402120,"56a1f7160baa02fcecb7c0eb20098785ae66b39d6b7ef15ad14503fcc66655ea",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/probes/ztf.npz",6974239,"15d17cce4930d79bd9276b0d4ba26d2497ae2ea767e50f7e94131fc3c9feae4b",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/recovery_analysis.json",30918,"cdef0b4642d422a271975ea0220aba8912aa7b53807b0d08cb305c61690ed587",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/recovery_by_snr.csv",4217,"7111fc4dfafbe587106336f0ba5ba62a5c88e4ed77afd17c7ff423504efc5b75",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/recovery_summary.csv",1413,"12e02f884a960eef00d2d4a532abfb41dfce2a2f428452a8aedfc81a28945862",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/rental-ledger.json",12550,"c9afeed5579b6dd12d71c2f163050871ef6b69f584286787df57e0f7f7932851",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/requirements-search.txt",526,"4b487abedaf0642554f40f99bf8b531c8c6877a88a23a9d26ebcc7666b2627bf",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/source-verification.json",9640,"b50d2a19c08e95bab2efee72fbcf074e8da96e689afd74c22c1f71245614698e",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/thresholds.json",2763,"962c817d20cd38b49cce8bce6c2e63827130787664f10d0ae52bcd081ae6d0ee",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/plan.json",26552,"aab5d193613e93e221f83a765c500627b0d306911533af9c25a98ffc6bdc0fcc",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/status.json",3620,"f942c198f7711cddddcf26168deae5890911aed296880158eea49adee26b278c",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/gtls_tess_200s/batch16/summary.json",61438,"e7dd1d249143ebdb09750ec97b0aae45d3d330867d49e6aa5ec47e735d35d385",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/gtls_tess_200s/single/summary.json",70642,"a012a1b943e1e7160fa6154a1c4f61257827e19a08ead7a455bd30f162a50f83",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_fine/batch16/summary.json",67719,"d63fe169ff5ab9a2b35091060770d24e1c64c2525ab1218c4830f244e4d65590",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_fine/single/summary.json",77012,"6b1cf0bec191a34d8bd862b3bfa6923afce7b6b1e533caa0c4e5a16410118cd2",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_original/batch16/summary.json",67764,"77cf6df6a2604f3c6605a2e88654e2fdef224e666d74900040534e96de704b2a",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_original/single/summary.json",77098,"38509ac6e558ae5d5b98003eec4c716209687b97892954629168bd9fbb0e5f51",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_resolved/batch16/summary.json",67786,"d20bb88762facd4da9c55bcf59c4e856d8477b8de28f8eca4861ccac4bd96674",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_200s/v1_resolved/single/summary.json",77133,"8a1ee88dbabffec9d5085e5a3931dd25ec2f61690c1d0eb78a677b503f8ed098",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/gtls_tess_gap/batch16/summary.json",61413,"6e31ff4fca2ec3a64b00ceacd69924f77f172e4a677e3ed63938205bbb5a8a36",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/gtls_tess_gap/single/summary.json",70502,"dedd0a201ca99023b8cfcfbe9228e23641d264ef71b80912867f04d43cd327e8",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_fine/batch16/summary.json",67765,"01dba285dd2b74563d747c16edd41fadb46a06471c2e89abb890a6e40a1c8995",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_fine/single/summary.json",76919,"da6115aa1a838fdbba2f81cf0764eb54590f4f6a833eff272e29a47d0b97d76d",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_original/batch16/summary.json",67779,"e5737362097b94a3ce8ba458d4ddd1d2883a7dd457544849c14abaca74a63f5c",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_original/single/summary.json",77066,"413d6babe7ce9a4e8e2bde0107ce5f787445892dfe22feb83e45f4600b729c42",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_resolved/batch16/summary.json",67757,"68bfda99745b62afae003ba0c1b0d6596989dcd0330416c949e435e1e11d62df",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/tess_gap/v1_resolved/single/summary.json",77000,"1d6623cfe132caef61027e581754d0ec94f10abca25b21679f3991acd821c3b6",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/gtls_ztf/batch16/summary.json",61383,"763bc22c6809070006e6c229ab958c5515d551ec6d2b535f6be1d79326798e04",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/gtls_ztf/single/summary.json",70528,"1bb466b6c459e056fadc1f8b75a50b050193569346f2d4c6ebf9af7bedae9cc7",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_fine/batch16/summary.json",67692,"cdf8c1a7721b13fc1011ff66486f7e32f120c2fd7d517e428fa64e3795c3e0c2",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_fine/single/summary.json",76820,"6030022b4faf783f10bced6abe48bdaeb72824f312629140c2c7b2a6e8309fa4",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_original/batch16/summary.json",67686,"460496f5f5d0ea0083ade0afc846ad21c0ab283e2ae4a15ed7a8174526b06822",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_original/single/summary.json",76945,"2da2b466aacedb2ad11629dcb05aead8cdc8c780aa0732cb8c84a043b656ff43",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_resolved/batch16/summary.json",67716,"dfe41978d82a8070807187ee73d6cbf42ad970ef9f02891081a9a1349f672019",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing/ztf/v1_resolved/single/summary.json",76892,"ea4dcdc52219d3be080b3b247370405e5eacd318a7b115149d78a91afa93b355",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.csv",4631,"32f9e599a29d2a35baa291847cef745a8b3d0cf78e8910b3a142dbb0fb4c02af",true], + ["benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.json",42320,"4e76cbb1df7dc806702aad5d587a6ad54967bf077fab762f2d42b8d20afd520e",false], + ["benchmarks/results/tls_sensitivity_2026-09-09/validation.json",688,"7090c2d2cc14304562002fff2c3b2687a1413f82fe8e349e3688c4c62b80435f",false] + ] +} diff --git a/benchmarks/archives/tls_survey_2026-09-10.json b/benchmarks/archives/tls_survey_2026-09-10.json new file mode 100644 index 00000000..e7971724 --- /dev/null +++ b/benchmarks/archives/tls_survey_2026-09-10.json @@ -0,0 +1,525 @@ +{ + "schema": 1, + "id": "tls_survey_2026-09-10", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/tls_survey_2026-09-10.tar.gz", + "bucket": "cuvarbase", + "bytes": 8373342, + "sha256": "98fe017fe7196781a0bcef038be4f34615e151f6a13f33533514fc0de8da240c" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/tls_survey_2026-09-10/FINAL_ASSEMBLY.json",45041,"d196522a42be6fcebe6ae1f5f3f60c8017e8d2f853d8b5c371fafb276352ba8e",false], + ["benchmarks/results/tls_survey_2026-09-10/FINAL_DELIVERY.json",4750,"4ac4d4648c5ea0ca8b310331090a5cc11481ab3063b0f94e4cdeedde01aaa571",false], + ["benchmarks/results/tls_survey_2026-09-10/FINAL_HANDOFF.md",14049,"98a5c018b1d342b7632d471eb1fb5a0110ddbb4592f15e66c5ab9b677d866e47",true], + ["benchmarks/results/tls_survey_2026-09-10/FINAL_PUBLICATION.json",562,"9d7415f868b8e731cbabbe682a8e8ab8c5b5b9c3c02324e121ca6765d5a65ff5",false], + ["benchmarks/results/tls_survey_2026-09-10/LITERATURE_ADDENDUM.md",1997,"05277389fa7c668d4317b4483aba545a3d60aa5ac905a536ed43667b084affb1",true], + ["benchmarks/results/tls_survey_2026-09-10/README.md",26909,"e3402b8b362d6495415a4c1ea5c2b6b7541e27ffb322011595a05809252c17ee",true], + ["benchmarks/results/tls_survey_2026-09-10/authorization.json",695,"f39e39830adcde8aeb1d76c2b9be273b38658762663bbb14311c4104d7c6dcfb",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/BLS_SUPPLEMENT_SIDECAR.md",7318,"c79097953be5a4d87ef16bfa291fa4ca5feb3c5a3375438b02d8f63402da68f8",true], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/bls_supplement_sidecar.py",43059,"d80b5bc4cbc4660e812e0f549c296519a71faa952dc9cc2eb8e55245d33fd4ce",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/host-test-receipt-v2.json",4249,"c32b252c56897f2af66627b7f56f3649593a9b09937f6bf2127b8bcf7a0d57b3",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/index.json",3059,"b5d5f423cac4e16d50fb18da0e9779320d1c6da6d368d0fe3aba918da9b7a198",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/launch-v2.json",3273,"8f94f7860b591a42da6b9c222a31015b8e3c894a8f2e68efb18fa0dfcfc459a2",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/operations-tests-v2.log",169,"92926bc0b5bb47b81c411db8f68621d4d2e4b1e12d460eace4df02c88a233583",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/prospective-review-v2.json",18195,"5f8ab5d23bee3f1cd447041ae7d731ac535fe4bac16cc80e9dcfbefa1d4f70d9",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/bls-supplement-sidecar-plan.json",8056,"e5222ea8827a840dc4748983b10d5d64184f485fe1aa3aa32532cb2ef8c4b537",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/bls_supplement_sidecar.py",42580,"2380de9dae28da009ec29627911b849c5113c48214464fdf8d1fc2bffd5a1a6f",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/prospective-review.json",18194,"85f6733fbf3e9d1598bba98691ff956987e83ab0af7b292e54de3741feb743d6",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/rejection.json",510,"4fcb84a0097582bd830bf00d5ac25e549f5e359e8cf3ff7d4f624736d2021405",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/seal.json",33987,"5ebf449affd6ccd4e946914491031a173e153d996d6bc71986e8f845ef94c249",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/test_bls_supplement_sidecar.py",19610,"13504de85ffb70c4f817f0763196dfa97e887fc9841bcbaa22ba0beea30412ac",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/remote-preflight-v2.json",918,"c2c70e9f4c6faa33511a80c2c11ad713d57805e91a6c60defc29a162bb7f9254",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/root-launch-review-v2.json",1608,"495323765149487bef72f5a4dc65858b8083277a7645cc9048a2dbe4d4b9ad60",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/seal-v2.json",34280,"20972e579f99e4bb97c45adc7ccdb138be636fc951d57682d675241f132942aa",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/sidecar-plan-v2.json",8120,"683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/startup-status-v2.json",2598,"bf60a838cd4dd560b6d18d5cf1d11bef480f048cbee56f9238f10995fd3dc072",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/survey-tests.log",260,"b3bfd564ebb60458f745ac3a5a4a8d6188aca0638efae77aeb29e47e0ec7fe09",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/test_bls_supplement_sidecar.py",20722,"d404f7d8982f3b40a0968244525830b3b6e47f0224f193edb63ac02f130cb766",false], + ["benchmarks/results/tls_survey_2026-09-10/bls-response-final.json",184075,"dae5b12409e35e1e178fc709a51167d9a93eb0fec05e9d86d7eec96e5780156c",false], + ["benchmarks/results/tls_survey_2026-09-10/boundaries-final.json",61239,"15d7320a1c2b4a685d5d154a7fa3e791fc81f47c719ffa8865372a08f624b6af",false], + ["benchmarks/results/tls_survey_2026-09-10/budget-amendment-exact-heldout.json",631,"346f1d5cf4427014a05b406b8b655ab8fc39b4e49fbb4ddcfc882ccc2a099bec",false], + ["benchmarks/results/tls_survey_2026-09-10/budget-amendment.json",541,"47049b6ef2d412952f75fb68a1b1952382d90708ec4c16dd59ed2753694ccdfd",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/README.md",5140,"b266cbe233c0687c6de58d4ba738ae7de322d5bcf7578d9602a0b389b6995183",true], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/artifact-manifest.json",1631,"a073d9e4aad46d6ce77b8485c5b43d9798024194fa01b1edad497fcb8146709a",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/audit.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/audit.stdout.json",45178,"dc99a3ef1b1867dc62a8ad3d4c6218f646f71faab4371b6ea98d953f5a013eb1",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/audit_calibration.py",18177,"be72429d5a2d067dd532d909c9b944a7711da2dd42876639509a495eb043e42e",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/execution.json",1069,"3b2d5a49c47b7018896ce75e8a7a436c6d61663a7c652b829f05ebfcfadc2ea0",false], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/review-copies/README-before-bank-clarification.md",3836,"197852be5d81d8a65bec8ddfd38642e812b480a82fe397cb4c00133bdd182122",true], + ["benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/review-copies/artifact-manifest-before-bank-clarification.json",1081,"62a546286fde247a12e315bc51b40386217bebaae8157fa5d14dbf9f1759f81c",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/INVENTORY.json",11922,"7c0b12164bc44e111ccffa5cda9210c63fc7d87ea3081a9ae0c4cb17c588f546",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/README.md",5372,"4883bda3f9f663ff861aa6d273a0b5e2c79e5d048ed4a3ebaf1d1ae4b1462ef1",true], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-SHA256SUMS",503,"eb9e22ceaae594d00191a6386516cc3fbf8adf836df10d6aba9d87975e0ddcbe",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-export.json",944,"b551c7688e7a9de41cab8c702d51753ae341990c16254db9a802b2021af63912",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-local-verification-execution.json",3048,"15a2ded1a722ae18a82bac479cbd44900b3e9290066df720f133d5adce1c25a0",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-package-execution.json",2979,"7412a20e6e18dd9c1a0d693c723c3cab678766fabefd0d8df599713dce51f94a",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-promotion.json",1249,"ab17142080735eefc02cfa552cc65bbf840a1b8e3f92935fbc9a818382643675",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-transfer.json",723,"c88cf13256f5fa322ef6aa9b4a75a4165736c17fea022bb1ea4efafeefa1a29b",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/checkpoint-stage1-execution-v1.json",3882,"d4d74ea08eff052fad90d4f42b25058aac197de756a99666d43439a401be3952",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/checkpoint.json",3215,"63210b2a19df5a46818293297a3a0c07cc4467120713f7718ddc2ea8e32b6722",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/local-verification.json",1832,"36b7b81bb2e1fa4ac447f065daaeff34c80e2e7016ee3d29745ec241319cfa1b",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/bank-export-execution.json",2272,"9cebabe89ef79dc62f9152dc911d7a31f7dd568014c92fe03ffc4c724c7e5f25",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/bank-export-launch.json",1597,"a6bd0de218c921ba24915bacf5ed836bdab50468dc769da3bb24faf388c39683",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/bank-export.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/bank-export.stdout.json",1050,"da1b7190ca791c4f3ae81199e283aa6a9e784a4efee547cb26679795c0739bb0",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/stage1-receipt.json",77099,"a3eb2899c7ded2b9fac0ac3a60cf5458ef1e704e7be915e51dd46f923406a5af",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/stage1-local-verification.json",3737,"23b4e83546e175af74080e534f3b8fe970840792c09a490151c0274a03683f4c",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/stage1-transfer.json",633,"0e8783d32854e02adb4a4c5a995a4a875639990cb9a4f4fd0989223e6bfe7530",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/helpers/checkpoint_bank.py",16615,"b6cf36a4b6953f637719a2a562884a28737c8d510420e2b3295b667df1981ef7",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/helpers/checkpoint_stage1.py",14286,"73d6d911dcff59a51c8bec849788c6d1aa034273730078e0c959b21ae1f33fb3",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/helpers/inputs.py",20263,"f4b1bec01312ba884b6c4787ee675ea582e032cd1efdecf96d694f1b45a82866",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/CHECKPOINT_BANK_WORKFLOW.md",7585,"d23814806ce310e9b0c8916ffdcb73eb643b7b4998ec9c29cf3d2aeb2eb43b37",true], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/bank-export-command.json",7829,"83641fa786fb6a259c9d81ba993980225272b80a2257583a795e78a1d0ad493d",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/checkpoint-root-preflight-v1.json",890,"6022a9bebb5a0df9044a99588343047a2e76df453d0ff286535b59e1740de592",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/exactness-plan.json",8923,"317177812ddcb9683afadc34c7112e133d85f2c746b50e8ae3256773e0b8c525",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/seal-final.json",252235,"1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-bank-review-correction-v1.json",1517,"be252434dde8162a15a38d6c43c06f582e90f5147b1e04942eb5768c03319545",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-bank-synthetic-receipt-v1.json",4226,"9a7b8a0141b77e210bbc8dafbf72238303469663911edbd8de87d325642ab04b",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-bank-synthetic-receipt-v2.json",4276,"57a43288232afebcddc2a641638c00007f5f925b207a0e7ccbb52d2a1f9d4f16",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-stage1-synthetic-repeat-receipt-v1.json",1196,"a935b316433632b953c4c065ea58530bf9c6266fef034424d27bc3a22171884b",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/test_checkpoint_bank_synthetic.py",7137,"fd756e9d6355e2fa5b65db91c66b27e358fe7fdfa425242e0e4a031b2a1460d4",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/test_checkpoint_stage1_synthetic.py",6163,"0082cc231dc727566885cf6bdc50423280c1c4aec1984e1528fb6bb548ff79d4",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/README.md",4798,"548539e63c25f789d399fad58de81bbb68c238d3965e06b52090964a62c7ab2c",true], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/artifact-manifest-v1.json",1973,"b20e4f633bd00a9cc22b813fb91de4e9446b634c808b9502f44684d6358737f8",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/capacity-plan-v1.json",1783,"8fa023280d7712f2af2642d2d57f1d3863865a94b56086c43bcb300b74b7b299",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/capacity_guard.py",10395,"663d118cbde0b1a3d3a984b88b9f3b26e17ca480de38f2255445b6a0ea33e36c",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/integration-verification-v1.json",5363,"aaf9590d3eccf64fc06cdd4892986c1a13fbd19200033b79233c40af89469cfd",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/launch-command-v1.sh",1034,"0fe745951a94019414a391968c88569b37f2c90117436f923bcb8f63e42b7290",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/launch-inventory-v1.json",2965,"dd3427ecacc3d8eea37744dcaacd5aaf0cadd761f6435a63bbda36a1894b89da",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/operational-addendum-v1.md",2543,"d12fab55a9be2f424ba2611b650db93d869b570dce4e7579f60c796da09b2d78",true], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/preparation-receipt-v1.json",796,"841f696202201ff198ed5ce8bbd54d803e56196b000595d2ee208e24a03dddd7",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/root-launch-verification-v1.json",4225,"f23a68d9e0e3b25a28e51976c9ec62fb16757136eb9536d6fae3a52ef5b53bd3",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/root-offline-review-v1.json",3530,"237cb55c2f3e34c9e3b008fb0a32b215bea36b17af8fc699d9bfa9a4ef083263",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/startup-readiness-v1.json",1141,"e8eb7d2a42d0c28fe2ed708e8f9acb207109c81bc2830ee94e7241e6fd2c516c",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/test_capacity_guard.py",14353,"5a8306362cf9140e678f07f7f68c29e9cb684e081a3c2c88379c136faf89601b",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v1.json",1111,"d6362748d647c62e89eb5da3200da4ba5d30863140d74d70b2f03be357c87934",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v1.log",2367,"82f8c1428bc77b7102269d6e49118da5c5f8f3f83d5bfe715df5f8ee3c39d7f6",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v2.json",1126,"bc94f6eb9370410c7198ce5b53e11e18b1f3c12e3d365afe682af07d0e73e506",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v2.log",2555,"22390e05fe8963b308757a7aa47fad72a77c42b44250178c15035f5802544bca",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v3.json",1126,"7fb9a44dc721f3d91e05c063cec2eb6f90a90b3543add4053365ae532f593358",false], + ["benchmarks/results/tls_survey_2026-09-10/capacity-contingency/tests-v3.log",2555,"2de66911d42f939cf0271c6ab1b75bdf8e030946bbc1913a9974783e8fea3a52",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/final-ledger.json",2522,"a389f5e7ac38c20868e9381c77c67974ae4a492f4a0784f067c86787d798ea8a",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/original-rental-closed-ledger.json",989,"b6c37085608bfee139298b96b98399ef2dbf04618f4946af121a051ba1efdca7",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/primary-archive-receipt.json",565,"6d4660676e1b28c4af18eabfe4a1c867c351b07f165df45561b9aa25c17baf3e",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/primary-collection-state.json",1670,"3ad52f46237a5b0cd662cfdc43eab367d5272e8c8cacb902da60956798695d68",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/reviewed-sidecar-plan-v2.json",8120,"683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03",false], + ["benchmarks/results/tls_survey_2026-09-10/collection/supplement-collection-state.json",2479,"1195aa1a17900f7a2c275787b96b95670d044913931f1045eb921b0a78e65d42",false], + ["benchmarks/results/tls_survey_2026-09-10/collector-launch.json",628,"90ea651dee62c700532ec0e7aa2b6d1daa48f63127e1f67e7276e67440da091f",false], + ["benchmarks/results/tls_survey_2026-09-10/completion-launch.json",914,"6924cb50f6de89d8683474d18f214eade220159852b94c2e10896af03752b145",false], + ["benchmarks/results/tls_survey_2026-09-10/development-cohort-provenance.json",229928,"cf75bbed2226a98292e34008e572ba7767aa21a1bf55aad4114173d1efe219ae",false], + ["benchmarks/results/tls_survey_2026-09-10/development-grid-audit.json",4626,"e95e1500eac7a8a68dc6416f2994dac7f42829a42e70f6f91bd9be7f7b643bcb",false], + ["benchmarks/results/tls_survey_2026-09-10/development-manifest.json",164701,"58d512bc03ab05ca3958df0b71c9e3ef9a180ab2fd1593389547eb2ac054c6e5",false], + ["benchmarks/results/tls_survey_2026-09-10/development-promoted-baseline-parity.json",187788,"e0944b95ce94c6e932cc79e23aeefb5bffb5c757a9ad092be4ba869e056bf2bc",false], + ["benchmarks/results/tls_survey_2026-09-10/development-snr-final.json",70450,"6cf06ce911dfb9ec966817b84947770a1dc39aa515c57d393a89d890d9dfc734",false], + ["benchmarks/results/tls_survey_2026-09-10/development-snr.json",70712,"11f241309e47d0432bf2567a158ef47fafd27feda54204b79bb58ecc7b926f25",false], + ["benchmarks/results/tls_survey_2026-09-10/exactness-plan.json",8923,"317177812ddcb9683afadc34c7112e133d85f2c746b50e8ae3256773e0b8c525",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/README.md",2381,"c66d18b90552aad96c1be271c58f9b69a3425d90c02108fbddd4895658e809fa",true], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/agent-check-v1.json",1189,"16702c3f97d0b8a16a1b44fa4c6afc6b5727e3e541af0e910ed23866fbad2b90",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/agent-check-v2.json",1710,"436c963922b06a31082757f8810ff021ac59e41280fb47a9f589d164854be5ef",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/artifact-manifest.json",1086,"15f7178c2726ef71633744d697bceaa2576b4b92982c6ef675489f97fa7ec8ad",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/assemble_final_delivery.py",17543,"558d8ce57d9d71a9e755dc07c2e48c0873dd011f15700e26a782a6a2fbf60484",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/replay_synthetic_check.py",5128,"3ff83cd82b30328f29fc31a84e68c8b9799af08a408fcfe7cdf9b4fc389cebdb",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/root-replay.json",653,"bd0952e3e98041fcee99d05c1a11149127e0990be449723bad925fe615e1d8ab",false], + ["benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/synthetic-fixture.zip",16448,"25dcbb85604a307b1b7bed1524c1790c0891cf82dde3e9024ebb24316629901e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv",11564,"1c9e5838536d74109a7d2f46fd30b848c3bd3ea1db824a64250a83b09c8c126e",true], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json",3622,"56cb4396058add14b56209293e83a8d4c5f636c0fdbdee371b9c430859791937",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.pdf",37582,"9ad98e07b35f61786bb543f15ac0f161b10017b8f77279d1ffcd1d20e6089178",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png",176203,"a817403cb1109caba9054c5f29225cabd711e8bc01f09199e6693f319ea84325",true], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.svg",141458,"188e59c035563c53c02ec5209e69574b4ee05557d8d074f4a1a8b67269bf08cc",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.csv",11774,"e276fa4ae9dee406534e806aa7a5d609767e00bc120197bd6e20d2ccef39a853",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.data.json",17886,"3485dc10a18ac3ba7a5da881606f711ea4127fbf7ae047fd74c6cb4f88bfcde4",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.pdf",39141,"e7361693e83dce92dd4a2a5ea05f60bfa893623c13839f6c46656f8fdd8ac281",false], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.png",239324,"448ae9a9a0590da722909d64c227f6a3766c564f2c73490f8721b30a982cdc98",true], + ["benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.svg",165610,"af0bdc4765a56349a9744a9eb34c77e0a040083c657552c8b20bc228bf2b1c2e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-launch-preflight.json",1642,"fa365520ccf9508702f2579fcc92ede82aa1fa279d338bce62499e6168197ae7",false], + ["benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md",40309,"29ca13e7a19f2b91d88cc56a990e1eaded38b930dc0029d9fce3e07ed1452fcc",true], + ["benchmarks/results/tls_survey_2026-09-10/final-report/exactness.csv",974,"e1b146b4d60df4a55d68d486c13468da92a2f8fda76178401cea14ad7fbe3ff5",true], + ["benchmarks/results/tls_survey_2026-09-10/final-report/exactness_mismatches.csv",1769,"7ad04a9918df399503f483188e889b4b3f6eb1e88da5f480ad579c44fb92aab4",true], + ["benchmarks/results/tls_survey_2026-09-10/final-report/paired_contrasts.csv",14269,"ddc4e2f439311471df3ea357e92caaacbba3f4b8c60e96cdcac6e4c10c523534",true], + ["benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json",2285,"c197d74723bea2791c4189db44360994b7d34c15e173c9acd839cabee0797d49",false], + ["benchmarks/results/tls_survey_2026-09-10/final-report/recovery_fpr.csv",6548,"6c21124797ed997663236b1394dadd3560c832d17f301762e8a8aa079ed4f3ba",true], + ["benchmarks/results/tls_survey_2026-09-10/final-report/snr_cases.csv",615337,"43ebb65a5a2a149ebab9836f80ed9873c9f8e67b9f842af1c951ef349b70c19e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-report/snr_descriptive.csv",35692,"2592227efa26dee46ec42c4f412d944463473c79141a201662628177eb068207",false], + ["benchmarks/results/tls_survey_2026-09-10/final-report/subgroups.csv",31544,"c1829e3fbde9cf357ce4232823de7c54c911956fd20daa579901387079af0b37",false], + ["benchmarks/results/tls_survey_2026-09-10/final-report/thresholds.csv",7928,"49b474bcd2456e30e5143ff91025713da17ca61a969ea285ec48ba05369bc396",true], + ["benchmarks/results/tls_survey_2026-09-10/final-science/detection-results.json",211159,"e4bb50e577e77d9148f044ed92de5d2b1df5966155f419fabe8b16c8f528e7ae",false], + ["benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json",15195724,"1931a7a9f7ab7c34f7e882406926da7e44b446c7d8c7fa4b57120dde4fe4c9eb",false], + ["benchmarks/results/tls_survey_2026-09-10/final-science/heldout-snr-final.json",2568112,"0b662ca2b11ea286cc9fced82d65152ad0aee2bd41b76ba137a93e449f6b9dd3",false], + ["benchmarks/results/tls_survey_2026-09-10/final-science/reviewed-exactness-plan.json",8923,"317177812ddcb9683afadc34c7112e133d85f2c746b50e8ae3256773e0b8c525",false], + ["benchmarks/results/tls_survey_2026-09-10/final-science/reviewed-science-seal.json",252235,"1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807",false], + ["benchmarks/results/tls_survey_2026-09-10/final-science/thresholds.json",59643,"caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/binding.json",5161,"6eab10250a5f2c8d796ef64b0047c6d6b2b9dbe7a9ee017a977781929363a542",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/inventory.json",23847,"8ff846bf0bce846d9ff6b8239468671264fb53306099f6162af696f4065bea2e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/measure/campaign.json",2920,"e480d70533bde23832ee3576ae3549124beed1c4f1d2b18cd816dd1b6539eb31",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/packaging-warnings.json",3,"37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/reviewed-sidecar-plan.json",8120,"683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/reviewed-supplement-seal.json",34280,"20972e579f99e4bb97c45adc7ccdb138be636fc951d57682d675241f132942aa",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/state-at-archive.json",8235,"7df1774462d30caa215d060a036ee6642e20b761e728b6393dc6e3a4d1c9eb30",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/bls-execution-mixed-w1-b1/result.json",13450,"dc3fb85761ef9e43a32bef0b35277b00c0c9ce3ec9586652059c117bfa22c43d",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/bls-execution-mixed-w2-b1/result.json",13450,"30a93191c5e24fc498ff2ad1be4283de4086a39dd2dd9de8c58d65208e19212d",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/bls-execution-mixed-w4-b1/result.json",13449,"d772a5b184b747810caed97ac40e6b25e57dc465e4b42e3f864f96ad72e5d1cd",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/campaign.json",5378,"e84b251c0c341abd55e674dcf527d95607279e6a83cec801c841b809d19dc679",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/tuning-seal.json",1997,"87eb356e2c4495a5a69551cc61a7ace04b916060eef8588f9d0bbc1cad2c8286",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/baseline-tess_gap_long-w4-b8/result.json",1187791,"4b757e7bd7caf9ec5e2e7fe5dc452f6854bc18459094e832a9ea9b34ce557965",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/baseline-tess_solar-w4-b8/result.json",2208300,"71eb601fde77cf1b0ddeb72031add483b7749e82f7cbbccb325ee8fd6511d622",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/baseline-varied-w4-b8/result.json",3060749,"2ffe628e5768aadf6dae11c8eebe256a01431f2e1a8f34a9a4f5e55079262bfc",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/baseline-ztf_solar-w4-b8/result.json",1185788,"f5c2959cfd56cea83cf396dd5b01dcb6398d86058ee17269ebb9a69c73eb362e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/campaign.json",29689,"9e8a45da5fc0aadc670ffb36f10671416b96c18eb6846e4d65bccaa4e651e78c",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/candidate-tess_gap_long-w4-b4/result.json",1219878,"97b1b38932775a6b9143d65c2b936ba15d719201a4c9f88584dc9c63aebb9552",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/candidate-tess_solar-w4-b4/result.json",2396152,"32b9f3e42d549178fdce80e8d4617f4236b712abbd9bd1aee69c2d8d2a40c096",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/candidate-ztf_solar-w4-b4/result.json",1238862,"0a41ac472b946d813e94ce52bb4287d18887921697b607ef20a4fe4cc2f5bc26",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/gtls-tess_gap_long-w2-b1/result.json",235658,"538a248c67dfb1e866ee23433ea8166af46337bfc3b3c1c6337ab018be17d875",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/gtls-tess_solar-w2-b1/result.json",1110353,"b08d5f390e64e5e427d2b6ed999c23ed2afafeac5e0ad8183673869fbbf9084c",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/gtls-varied-w2-b1/result.json",1246260,"7c262ecdb32e96fede63e2ddeeb05a2ec6f7cb16ed8b111bd1549b3c8d252397",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/gtls-ztf_solar-w2-b1/result.json",635042,"531a910d92659e9517823517d4b453729ce1ec1420c0e0422174290814b8bf44",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/measurements.csv",10212,"51b711f467a00e96f6ca317cb9a9756b1b6c13ee7bb1cf8eb1015545293b64fd",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_gap_long/baseline/result.json",332404,"cf168303788ce7afa128d63e8b327b965b4b10d6a2cdc189045e4dc018265d41",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_gap_long/candidate/result.json",334334,"150e5052b1001a2965837aa80f6e92bd677e9cf6e40f89f977a7b4f7ebe59f06",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_gap_long/gtls/result.json",240373,"95c516310378901d385b1e02a9506dad223172f26cef2a2bae69ab3beafde965",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_solar/baseline/result.json",331703,"f42c99d608252372966a24145b46ef508fd493d2b2db3e4f7b7d50db0f4ea116",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_solar/candidate/result.json",333636,"9581a2653a9764f77e9eb77fc01b7052ca3e7ee39ca1e90ab350dc6f0ec345d2",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/tess_solar/gtls/result.json",240012,"ec850b858ea9eae2a392e8a15fde38295ab7162ff4f8cc786cc4b6b509848e51",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/varied/baseline/result.json",1844430,"ad7b28a2aa1af8eb6261f3f1fdbedd793d6111f699ed906734ce857530a22f78",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/varied/candidate/result.json",1845155,"73ca0112771a1f612b7f6eb2a8d13d6c076306f8ee0cc39d6802a464e4f7e24e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/varied/gtls/result.json",1357284,"67f9f8ebc010da322716ba6bb9d6b94c1009951264a64122605689b69fd07126",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/ztf_solar/baseline/result.json",331802,"f8fa017651e6c72c89bbfcae6b05988f2a65838af36294b2e756663f47a431ff",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/ztf_solar/candidate/result.json",333697,"fa235cdc6dcbfe195077dd4c80de312315b6c8ccbb5a9836867b57916a8c3674",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/qualification/ztf_solar/gtls/result.json",239555,"25e56c3e13be74248edce0e076992700159ac7e27e0c13f7832686e137260a91",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/baseline-mixed-w1-b1/result.json",485933,"5756b5328046a4d4fa9c4a0c0ad01d27f760bb334eb876bcecc76a496b2816f6",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/baseline-mixed-w2-b1/result.json",880889,"4ad703a9f8fe11baf1941a897e0d166e727e99a278a1c511b7f56e4571eff416",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/baseline-mixed-w4-b1/result.json",1670644,"895b32152fc6a245f84ca587246bebbafa0ae0df5473fbabd8e1b58ef649ae5e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/baseline-mixed-w4-b4/result.json",1608855,"a6404881033a46581a9bfd9fb0e98c2b8272ff544e6857ae1ffe8a3f4ab3485e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/baseline-mixed-w4-b8/result.json",1595332,"865aff84a19c5cdf7bbcdfb340ff532b4075bc5cd0d5129cd87d22b12df3fe00",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/bls-mixed-w1-b1/result.json",229537,"f842478481dc726445415c719fcabbaec4374af65b130d6527b10abc5542e88d",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/campaign.json",26383,"8f32c398b0e6b675b41e89474ff3ca5b3c381ee83e79b96f38d3fd9045de273b",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/candidate-mixed-w1-b1/result.json",507310,"6dc9d9d994e9b2f7af7ececea63a43579917b60bf60f1d16d472b1bc132a54e5",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/candidate-mixed-w2-b1/result.json",904294,"408893df65c04f003da99f7cedc4e585976ac97ea92ebac7f32c5be17bfee2bc",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/candidate-mixed-w4-b1/result.json",1698105,"b49ef328a27bd304ea0421178beb2f683732651445f56cf5fa3a6578696ed168",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/candidate-mixed-w4-b4/result.json",1616930,"84baef4fe7468ed9b34635b16e0e3aac584cc9397b0e10ae2adb8304eb37683d",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/candidate-mixed-w4-b8/result.json",1612660,"caf09168894957b54b4f5422e25ca27636f82e3e6a80702ea8537fd211d9efa7",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/gtls-mixed-w1-b1/result.json",354471,"9d1e2b55376817dceaa2e4d7772b543ca36903283b25c9d75afb0890e37a8222",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/gtls-mixed-w2-b1/result.json",617911,"f7f99694e82d2a0f2359db24d85acd3e4e9039be602fe143d2a45e19215623dd",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/gtls-mixed-w2-b4/result.json",576367,"4d53596be93ea45c041683df221cc0a825c6546b6089686c3d86515e4207f9bb",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/gtls-mixed-w2-b8/result.json",264370,"d9614a0e8cc5caf698138830ecde7b295e90ac7ac4b31b10e988504072593f67",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-tune-final/gtls-mixed-w4-b1/result.json",593246,"53929edaac5d89d13e28222094a96fb31388403c20d147a0607ba3c6abd4ebdc",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING.md",6181,"9eaead3bd1808b842741221e6bdefb9889a357bc716074e1cd2a755a578bd9ff",true], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md",6724,"90347ca045d43584c73e272d0f26fc7235973685f2edaf07e4db0d28e5d6d766",true], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.transformation.json",3701,"cf064095d5955675b2724d70f646b151177428430e43a15c411ad512dd1039a0",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/collection-verification.json",2330,"8a888ea34124c0cb2bea00b190b08268416fc3578b814f0e3687f8fa59ecde46",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json",14719,"ea0d23240cecae058f2dd8d6965eba3019d7666656ff875784d561e76d7f1f9e",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.csv",11564,"1c9e5838536d74109a7d2f46fd30b848c3bd3ea1db824a64250a83b09c8c126e",true], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.data.json",3622,"56cb4396058add14b56209293e83a8d4c5f636c0fdbdee371b9c430859791937",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.pdf",37582,"9ad98e07b35f61786bb543f15ac0f161b10017b8f77279d1ffcd1d20e6089178",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.png",176203,"a817403cb1109caba9054c5f29225cabd711e8bc01f09199e6693f319ea84325",true], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.svg",141458,"188e59c035563c53c02ec5209e69574b4ee05557d8d074f4a1a8b67269bf08cc",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/timing-artifact-inventory.json",2505,"a13329f4230c5b4fafffcb8808ad4840424a2c08ec06d208165a23470983a2e3",false], + ["benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/timing-verification.json",117265,"d817dcc1339ccaac97319cf37c398dbd3435cccddecbf1c675631f997b02a461",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/README.md",10819,"a8daed6a372038b0183dde8e0a39be181b1a7cec5564c0fffed984b70ad76dda",true], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/artifact-manifest.json",3317,"d50d7b2d13414f3dfec33771188a660474b32783c53a7fc7ec081f8d85d1ff95",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/diagnose_eight.py",13221,"26f9386e4433b6a0f1c3fbe0a97836821f19937249092b3a80ddd15e08b5cb91",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/diagnosis.json",52164,"15c59cb73670d122ba894b033567860d77b99b310a377a894d52b9ead2fa26f7",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/eight-cases.csv",2359,"7e633bec343948a2f8c503bcb1bee5d1ac59720ed68336632a4cda599a460162",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.pdf",28497,"9c06cd8f31a0759b99f2a3131ebedf09c3da2f633ec2767dd926bfe5818082d1",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.png",191376,"9ce37769662082a515b20b7fd03a76d5511b15b2b1d6fd0523d5c07738fdfc6c",true], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.receipt.json",5833,"b7a4a5681f31be6be5a40177aee6b4c4512572fafdb7c2ff1d0988d76a0646bd",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.svg",29444,"b8f1b0aa97f83fe7f941bd41870c1eeb09400b7b6a6cf99f7e32e78912dd80a8",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/integration.json",1226,"413af8ff063a1eac8467bbb24359f88f4c9860f3b1a35c537a45f78cea3a98f5",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/plot_depths.py",7088,"376851aba2f713e4afe712e470c6e6290c0809b28a98d020efbb893db4409a3a",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/integration.json",2281,"ba5a35cfe32b7a6ff0252235ba67ad2d3ef4b591487f9275869cd939e90cb4a3",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/run-manifest.json",6193,"b4dcaecc28a509b1146f1dcdb0635f921ac490b02f3512f53faad561a1630932",false], + ["benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/run.log",5505,"567d85c92556457c61a3600f5bdfb66d0157a40c8e70f68edbf6c88f354f83e5",false], + ["benchmarks/results/tls_survey_2026-09-10/hatpi-repeat-diagnostic-summary.json",1405,"4968aed06238dd2617b6bd9e30e515899b39aa067d329fd0f842439727182633",false], + ["benchmarks/results/tls_survey_2026-09-10/hatpi-repeat-diagnostic.json",5439379,"60aed1825d230b4634448e1f793d0c2ea6cdafd4f525cc1b215bebf11cf64c42",false], + ["benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/README.md",1667,"c2db382fd67574628d516e23ccf1cf46fa23304e7d5000544c2ebaccdb8490e3",true], + ["benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/artifact-manifest.json",576,"c01006d7c841a8e36e9e13ed9ec98a5d6fbb46e9b94ed47137a61ffdf2a0f352",false], + ["benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/check.py",2822,"8e8544cdc4035477ea7e90ae01b9a05ee438274287aec0a7286f98bb9b4485ff",false], + ["benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/review.json",3919,"0a157c3998e8ae199c90877765561a093d94d1a3b7447cfea51ec7e9c8c37e82",false], + ["benchmarks/results/tls_survey_2026-09-10/host-profile.json",8332,"b1b9905b58a1016b44045c3f6b8d1761d024f6aa9c3d9481d5054f54858160f1",false], + ["benchmarks/results/tls_survey_2026-09-10/host-tests-final-inventory-fixed.log",5646,"a93f88013ddc3f93e30ce5ae90ca254f0e9114ebb431c0133d77d06b376414b0",false], + ["benchmarks/results/tls_survey_2026-09-10/host-tests-final.log",7763,"e0b5d102bf7db0ac96e00d5266caa49e7bdff0379caeee5d285792a3292c5769",false], + ["benchmarks/results/tls_survey_2026-09-10/host-tests.log",5532,"05351dae13675383911b4f541016a03605cd7de09379c63c4dcce9481f355852",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/README.md",8603,"3596969e144972bbb0b5fc77d6a6a122b59cd53e673ff7c94ca4dd2a9d693488",true], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/artifact-manifest.json",2799,"088a7ebc822665d19a7e16709c42f947b83351e13554f79fa5484ff4df39b553",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit-v2.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit-v2.stdout.json",24424,"4c526604ba7422dade7312a3774d26d2ff12aabf1e0eb2a5b3712431c47fe63f",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit.stdout.json",281981,"fd19843dfb093ba1bfe3001da74e646203a6a830f43d404ca450bb518a713d15",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit_injections.py",21987,"164c40803e6738155eb3e77a0166ecce69e3906247a434d5117e279fcee4fdd3",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/audit_injections_v1.py",21266,"6a7ed58524d805faf22ab7206e1b7536669509cf5d1cc9f29667b9124f890fbb",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/checker-correction.diff",2259,"42c461ed8190fc0ca096569d57700e4ef90dab040f2c46f238698b53ac5d0760",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/execution-v2.json",1254,"361e2958ee4c01f8098b9926396bdbc270895eb6dee982fef6f5e32508e3ef63",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/execution.json",1249,"8b56c38c8e97fd8363df714cf67e3fedb5ef7e5dd3fdb8b5729ebb166d178b5e",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/initial-worker-handles.json",5970,"2b814d3d695b168d5ed56c62e3b72dd2b8d045144dbf4517bc0a98652f69ae53",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/review-copies/README-before-archive-clarification.md",6118,"f9080b001bda438a46775449def47fa270dac260d39a0a9bde715f2fe46412ee",true], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/review-copies/artifact-manifest-before-archive-clarification.json",2222,"dfb8aa5ac37e13c0764d81545e9ab1112af6d6659c7ee96fdc2c71ebf84669e2",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/status-20260912T013105Z.json",4092,"5a5ff3b043f0c51056705dcd11b3788012d21a8c1e5993169c6f04a68fd61397",false], + ["benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/status-20260912T013246Z.json",4030,"c7af43fd934e94a22eed87ea4821dd47a67e94ce3c75a0928debf9f5adafa8cf",false], + ["benchmarks/results/tls_survey_2026-09-10/integration-review-final.json",3251,"25164a57f10228f927ee0e3a6b974498c373cd097803c4bde156539af7a81d11",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/README.md",11831,"0240b2fa2d78f9f5fec1197d54fc9534196310748bced07733901d7feed05ae5",true], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/artifact-manifest.json",3574,"a52122d940a41a19368cc12907558793d61e2c1f512cbcca65063671adb3b2de",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/audit.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/audit.stdout.json",24750,"d3da9ce47ee598ce071fb7625a6374e59c09c25582a7e2fc58618f533b4f3cb6",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/audit_nulls.py",22668,"4f8d93496421daab3f179e179a8bb2596c8dbbe452cff47a288f204c2de68eef",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/capture-execution.json",697,"58d62fddaed584b7134dbf92ac93f81771a0b905617d58968541abb907f55390",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/capture.stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/capture_initial_handles.py",1903,"5b257865fe03fb113177bdeef81b5a9ae3e21fc93b8e39f9ab6201bf216b19ad",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/check_preparation.py",6461,"de30509054e10187295b644429b6fb76c459210f9a2af96700cca63442e38ebb",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execute_once.py",2991,"da7e6b4c661fab47ff1c6e83967e9e2a9518749793465f09f3a5386266156ca1",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execution-started.json",1335,"ef583635e53c84ab01dbd5f7f85cbf4b1996b8bd85b30448c22c27e05d2d6a6d",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execution.json",1766,"affcddbb464de5be6d1486d233020f1f1eb6a62e903da3ac6afbc02adefaa8be",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/initial-worker-handles.json",10328,"4f3f360eba16035041ed7c191c74adbc91130699c16242925cf3f52c89520d29",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/preparation-checks.json",2156,"a9219402ce8d2fb948209db9214c011cace4a72325a3fa03e1c5ffb6d4343043",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/review-copies/README.md",9606,"72075712007882f76e349da2a2e2cf008d4ff011f0f38b21b940d09179b5f97e",true], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/review-copies/artifact-manifest.json",2357,"a638a8d6f0c21bcb278d9a868d6b8b4bfb2028ffa0ed83d7213215b770fe408d",false], + ["benchmarks/results/tls_survey_2026-09-10/null-completion-audit/source-diff-from-injection-audit.patch",11911,"28ba6d056db18f9a0962e3287d486ac67be6384fc5f56fc4811fb27b57ba401d",false], + ["benchmarks/results/tls_survey_2026-09-10/ops-host-tests-integration-final.json",6247,"d909b6e7ec2abfc31681c780908baa9a5c818c8b7a75c3fe8796167597604b0d",false], + ["benchmarks/results/tls_survey_2026-09-10/ops-host-tests-integration-final.log",98,"107d7fd045deedfbe1d5a89276634561c999386acb0e79338f373971957e4f0e",false], + ["benchmarks/results/tls_survey_2026-09-10/provenance.json",420,"e6bb1b908d6540c73a448fa3a3c649f0fe1beeaa3b54da2e4df6020209b55426",false], + ["benchmarks/results/tls_survey_2026-09-10/quotes.json",348,"15344bfc4d8afa63585d8f2e82a9cf0aea4167de09a3a679630cd1d63247af14",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md",2047,"4bcae3c4b03a7e5823e7950bad6adde66546f3bb781a8bd5d0891a3f481c9b09",true], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/full-gpu-suite.log",7267,"49b6bcc27a8f07cba762964219404f21cd16be665cd8a0db6c41e619b6850e90",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/full-gpu-suite.xml",290731,"249d002e4459670c3bc5e8034ba09ad9b4a7148edde4a4e4c88578fe985e7cdf",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/installation.json",11372,"441c5469831ea3b3d43b6bd08b9369861d7c4e83b57e1603f3cb81ac30192924",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/r2-readback.json",295,"583c4f1fdbdcca1bd5f7c48b4083c7e9000dcadf8d225c3e7413089dc5918d35",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/receipt.json",2360,"0ec321da8dffc4d2ae9deff5908d07f55ddd42fae1554b160406019e63dae436",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/refreshed-package-verification.json",1260,"8f2ea903f6bca14058a13844303d3aa5bc0fe71b3260500344e09461ba69b1ff",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/release-gate.log",1476,"7dd589a27c6b4000ce8b4edeeede787d3d70b9abe9206e6cb4496aba1c7a694e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/summary.json",791,"01ec5eec285314ed6ee9e556940e3170ccfd09ee7446f6fa1110a0457aab2948",false], + ["benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/tested-source-inventory.json",13202,"e9797b291d4200ee159269a13ebcbccdca4fa8732fce33db1515badd236e5784",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/README.md",9046,"736a2440f5062db83feab24599c087f22a49fa45dd921d3428f96f47ec86a428",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/artifact-inventory.json",30641,"f5d9e96680d2114cafcfcc306a6b8061dc22ea69ebaa161330530ece33c23203",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/branch/application-intent.json",556,"414b993ff7a89650163df486fe35ab66593cabcf169f040d51ad665d00b90c06",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/branch/application.json",807,"ba8d1777504a36117b77a9d99cc4444eb8149a86bb21314a5b1d941ddcf66761",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/branch/artifact-inventory.json",497,"edf82f12f2ec42bf7a2fb80b87db2de06d4f0204238b763d06932a20f827a6d3",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/branch/integration-plan.json",43841,"c800f45747f919a81186961b8b77cb1f855532e85e8ae8378e6ee25fa40aead2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/collection/archive-receipt.json",871,"f1464503180e18625a6d6243747f40b5608bc8c88c70ce36970fa1c133c2b6e2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/collection/collection-verification.json",635,"58818ce02f560b10528df51fa1b2d4493ea7ba1c70e037c18366389db3955d60",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/binding.json",1066,"2cc003b09c617071829fa17a48285f16253163a71b0aaac23e9aadd8df1592bf",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/collection-inventory.json",78176,"68f0c32d019e7a71ec11205927e63868259b15b13ecd1a1d0795664953c7cda5",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/data-transfer-inventory.json",12533,"ad8a4dcdb08631e87c8279215e503e0ae96ac84ec4ed5511ab76332c3ed217aa",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/data-transfer-receipt.json",608,"83ded75ad32868955e31bcf168805561dc7f6fc813ed12ae75e616acc512d7df",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/dev-final/manifest.json",188593,"a1d18d6cf2d09fc4450a6f4ce9cf6f794e05685f65c755bbac5e7429c3116328",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/allocation.json",98,"7f57494e9da9788876d8121fd2c232046468085f0fbe87c521bea2c5806b24f4",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/bootstrap-install.json",56611,"9531442ec12ad2e483ea6c7d2aafabfcbe35dbdbd7ce78e2a0fc13dd1baf40d9",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/bootstrap-install.log",448,"d8fd093841e4456f496a5e5cc5047a7035d2c31afdf81f2f12b8b59142f1740c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/cpu.txt",3253,"7c2d117ee08cb7138ce936d388b603810452a292a3b6293bd8914a6055b63f8f",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/freeze-all.txt",1156,"252584e6512da8cfabd63efbd2a3eab6dfa35dee605cc8e2f2fa83cd3d22fc5a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/gpu.xml",19322,"10b508be3ae71db57aa671b28f176879220c4659333a6c29aca236bbdd813042",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/install.json",732675,"22f90711358e691aaf6bb8ff65ecccf5a258cf2cb37c991c4aaa682cdebada7c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/install.log",21538,"ab233163e2a6b02c2ab34271c459faefca7b7fcc4e5668e0f27bd10c3ffaf0ed",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/nvcc.txt",209,"3c71ed984d2ca98770b0cba5c23a849a87209351e105433929c3062fb66d1915",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/receipt.json",3296,"b55e031376c35b7940f346fe589092689da179d6651c159300098e1e011e1be7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment/bootstrap-install.json",19860,"a18310ccc4a55afbc561c21f9fc8b9c8dbfb4325c5b7351fa9cdfba6dd302139",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment/bootstrap-install.log",1515,"98b6243d6b740602bc596b34b3bd469895c5e220743cd3f7e3cd8c05fc2011af",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment/install.log",34921,"8777ec81819444fc218aec3cab17732f9c84c11807a7412adb05c8ba30db7c0b",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/RUNBOOK.md",12566,"77988d161002a5bc96f9394485d35253b8432c23ea6e0f76e76dd7cea50e2b2a",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/artifact-inventory.json",1567,"68b1146d045b147cd8a2f90a7365a48a25765ba16ed4b9c87075d6f07b05a12a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/build-tool-metadata.json",2576,"e21581bf01b305f00b5c37610d40b383a952a1c2ec73f704e2544d2cb9b8e6cb",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/collect_device_inventory.py",1234,"89a422e6dd22753880b2ed9a9d2776640f3c74b3143ceb50038dafacbfeb9905",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/development-validation-plan.json",3490,"56dc97b664dfbebc5f02541563c2bc9ce8c7fc7ffacd81482a75d4bf4c9ca811",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/device-test-inventory.json",10413,"8dc0dc28bb9c659acaa8a251219e3ec639acff0a6649a9fb6cfd9b5c8392513a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/integrate.py",37608,"6edeb1e7536300dd1d8e11781a6b3dc4d2fa0f7b55f4d7453b8101294a4d5f6e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/offline-tests.xml",5707,"2585f701edce2b6224ccb865b32c5d878f27a61fe99d464029a801c35f136c32",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/protocol.json",29035,"1aeca13d85142a89822b5ee1b6b64dbdcd259e5d360981f6be4db05f6d77ac03",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/pytest-device.ini",148,"eb8dc510ec8c748b7222a4bbf47e3950b5f930ccd937ca0eea29b619378ad45f",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/test_integrate.py",15721,"03222c3211cc9ff46ba20fe015df3ae17b78043971efb83e81e765c707d752a8",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/original-collector-final.json",1670,"3ad52f46237a5b0cd662cfdc43eab367d5272e8c8cacb902da60956798695d68",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/original-environment/cpu-quota.json",106,"af54324f6f5f76542201a017f62d24f3adb42c6bebd548e610b1b541c2040d25",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/original-environment/cpu.txt",3253,"7c2d117ee08cb7138ce936d388b603810452a292a3b6293bd8914a6055b63f8f",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/original-environment/gpu.xml",19322,"a624db150a70e7a8d2b801eed63475094b8a64d41642cca0e49ec350564bb6ed",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/campaign.json",15302,"1b5c06cf878247e280fa59d8782d3a256d8714ac56525f126ee7fe09d9bb2d19",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/device-tests.xml",13405,"5c57b9af6e2bcc774dddc9fbe8002eab55ab585e12ebf5a692427225de808522",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/device_tests/execution.json",1484,"d1106f85b8dc89a70b23b56cc5c3158dbf37a44806aa62412bb174c5ad341979",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/device_tests/stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/device_tests/stdout.txt",179,"b3363936b8873387058f09708f37d99260abcb815170443b1fdbe286b46ae0be",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/execution.json",1579,"6b432f6a1b3b1cf708888da1987a94fc61564a223d225f94e0138de76b026335",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/hatpi_short_development_0000-full-0.json",5522,"d9f7f7b78831f0614d9454b5df59192f35ccabb5eb05722bb4a34d603846abbe",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/hatpi_short_development_0001-full-0.json",5524,"3c36dbe7d5dd5f534afeb7092fbf3cfe6db14b47956d5583dfe4a14c0e2749f8",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/receipt.json",89781,"31c51dc33b205a9d43d246b8e9150766f9a9ef0ae1dd949649fdac53fb519798",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_eccentric_development_0000-full-0.json",5520,"8cf61b955e31554a33c403d86d40b248f0470caf46b23f9d7b61b2e3854ff2e2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_gap_long_development_0000-full-0.json",5533,"38b871b33b66e0e6ee338dc053af94cf6d386264b604058999846a4d4f2f128f",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_grazing_smeared_development_0000-full-0.json",5542,"af7ee172c784b3aacd0297ab483875132d5cac6a589a208037a85a9f0f07eed6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_highimpact_development_0000-full-0.json",5521,"8457baee882c60a8d9691b40ba89220105919497ac441077a5ad41c379c0eb69",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_mdwarf_development_0000-full-0.json",5528,"190dfba5d43df334b1d79522b74b33d9492d216debb17766d8d0a5cc23fcaf0c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_solar_development_0000-fast-0.json",5517,"ffc33e2a64c040d5445c98a9d0d721f540b6d068a55d2edb7779fb11834f8971",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/tess_solar_development_0000-full-0.json",5515,"b80d864613fddbb6a84762a2c26a134c6d27bc0255a0e1040996a497d204a1fb",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/ztf_highimpact_development_0000-full-0.json",5553,"b43700399919bfa95ce329706d2a42007f4d785f1cf29a95af66567f544bb10e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/ztf_mdwarf_development_0000-full-0.json",5558,"a69ce51cb1a2ba6b086feab7ea5ca600db7d73026ddbce517552a9cbd243a193",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/results/ztf_solar_development_0000-full-0.json",5543,"b797453a6d1f118ed08fd8acc6cea9ebde25468aef7978f55e51934b36a80b2e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/frozen_optimized_precursor/stdout.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/execution.json",1539,"7c420d4a12b4332515ee90d32b40d1ee5c8fae2756d4cd6daadeaa3efe1f661c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/hatpi_short_development_0000-full-0.json",5522,"d9f7f7b78831f0614d9454b5df59192f35ccabb5eb05722bb4a34d603846abbe",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/hatpi_short_development_0001-full-0.json",5524,"3c36dbe7d5dd5f534afeb7092fbf3cfe6db14b47956d5583dfe4a14c0e2749f8",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/receipt.json",85560,"66df2384cbdac6c77b68cf836d11101fe8ab784f190dd733cd56d666176d9cdf",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_eccentric_development_0000-full-0.json",5520,"8cf61b955e31554a33c403d86d40b248f0470caf46b23f9d7b61b2e3854ff2e2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_gap_long_development_0000-full-0.json",5533,"38b871b33b66e0e6ee338dc053af94cf6d386264b604058999846a4d4f2f128f",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_grazing_smeared_development_0000-full-0.json",5542,"af7ee172c784b3aacd0297ab483875132d5cac6a589a208037a85a9f0f07eed6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_highimpact_development_0000-full-0.json",5521,"8457baee882c60a8d9691b40ba89220105919497ac441077a5ad41c379c0eb69",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_mdwarf_development_0000-full-0.json",5528,"190dfba5d43df334b1d79522b74b33d9492d216debb17766d8d0a5cc23fcaf0c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_solar_development_0000-fast-0.json",5517,"ffc33e2a64c040d5445c98a9d0d721f540b6d068a55d2edb7779fb11834f8971",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/tess_solar_development_0000-full-0.json",5515,"b80d864613fddbb6a84762a2c26a134c6d27bc0255a0e1040996a497d204a1fb",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/ztf_highimpact_development_0000-full-0.json",5553,"b43700399919bfa95ce329706d2a42007f4d785f1cf29a95af66567f544bb10e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/ztf_mdwarf_development_0000-full-0.json",5558,"a69ce51cb1a2ba6b086feab7ea5ca600db7d73026ddbce517552a9cbd243a193",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/results/ztf_solar_development_0000-full-0.json",5543,"b797453a6d1f118ed08fd8acc6cea9ebde25468aef7978f55e51934b36a80b2e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/immutable_6ced75d/stdout.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/execution.json",1535,"db411b83c0dd3959616cfff0517665ce9986a00fd35f7d4071e90338d28a17c1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/hatpi_short_development_0000-full-0.json",5600,"c7dabd14aeedae7953d17378df82d64a8a2139bd553c04f688e51da5eceac1f3",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/hatpi_short_development_0001-batch-fap-0.json",5886,"392b6bd460268c246a77de8e3532a2e76f25c38fc19e4a9534ad0e4b3ba24f3d",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/hatpi_short_development_0001-full-0.json",5602,"014ecb56808b41b3d992bde3dfe80f79360f1f08ee59c0ddc79b91ac9bca1aaa",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/receipt.json",127438,"328be41b269575990540fc0f15e98e314d3281a7be1737f8ec6752cd2676d9b5",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_eccentric_development_0000-full-0.json",5598,"9185f3a01a705bf96f9a9fc3db62fd8654f50cbf203861025527c68591af57ab",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_gap_long_development_0000-full-0.json",5611,"b1e4c003075d94f0a0cbc93ed48b04f279bae662890f31a87308e7a34cf90ac6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_grazing_smeared_development_0000-full-0.json",5620,"933a06cd4ce90129c71798df63a5ef183281d8c12e1fd5cf61ae050fd2e818eb",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_highimpact_development_0000-full-0.json",5599,"3eb62bb675c4ec67e2c498b707d486580fac34243f286ca9d2780f7daff98a6a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_mdwarf_development_0000-full-0.json",5606,"2d139ac4f8bd78e36d7b85c461da5ce9376e4dc1126ac6883fb3ff440a2de928",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_solar_development_0000-batch-fap-0.json",5877,"0147677404d92febd59f0235dbb106e9a4cf180ac4e5580b5fa9bb0c28fe698d",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_solar_development_0000-batch-fap-1.json",5877,"5e11d6b96cd029b669daaeecaf93cce9dc2ca2a032104dccde5c9e7469899ef1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_solar_development_0000-convenience-0.json",5600,"4742c263a169a25f585c51672beec04c25c73aee758c48b95c9ff54534c8ebda",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_solar_development_0000-fast-0.json",5595,"f9779cf3948e44bce840e61e577837b890d9a51655747ef3d1a06f74bfb369b1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/tess_solar_development_0000-full-0.json",5593,"83d41d763a8f8fd0f1c6220ba65542fc8bd87de2e85d16f589256f8145af7cff",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/ztf_highimpact_development_0000-full-0.json",5631,"00209ea7885313e6525c1d26d3a29326582aa90afe3f331e3dc0dff2ce8bbb6c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/ztf_mdwarf_development_0000-full-0.json",5636,"176af080e8749da34c71035a25ca62ca587e99fe28aec7713a809328663780b3",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/ztf_solar_development_0000-batch-fap-0.json",5905,"29ae7854bfa7fef0952cbebb9fb20b791827aa3b0bec42fa133192620d2223d3",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/results/ztf_solar_development_0000-full-0.json",5621,"b1dfa78647133ed213bcb44d31e132e7d9c3655f31aa4fdd0a9ef7188e35a27b",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_baseline/stdout.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/execution.json",1555,"5a716b19a9a10dbbc61200d635588e9d49ddfacec1021dd11c7b49145e4e982c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/hatpi_short_development_0000-full-0.json",5603,"afcf4aaea8f489437c995c87dd0f95264deb82cb2ec449a0534ac6140de94e1a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/hatpi_short_development_0001-batch-fap-0.json",5889,"c1e46c30dc76087131278e1a939e2c5908dda30bf9450d04022fd481b98502f7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/hatpi_short_development_0001-full-0.json",5605,"13614e50d1aa034709d2b38c38ef2340b4fcd0ae0ff185bc03d1df438a03527c",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/receipt.json",133000,"891d83927fbfb635e419d29f8d72970cc8e72af648a25832cce4efa4dae0dc9b",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_eccentric_development_0000-full-0.json",5601,"052b546e6b16f286c3295f7c5250feea7697ee06eb9fb3b6538c6248a7455f54",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_gap_long_development_0000-full-0.json",5614,"5b9205301693a692a02a1c7f6971a3bad9335b40c71e53b4a611c7f6ad2525f6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_grazing_smeared_development_0000-full-0.json",5623,"936b3bbe638c68f5af0389518d14399608a34d6d396f52fb2d114ba2aba753e2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_highimpact_development_0000-full-0.json",5602,"21743d706e792d14c6f7b668515be963a641c7140cf11eaa4409741ca50f6745",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_mdwarf_development_0000-full-0.json",5609,"9af595093b7a2e0ce3772e7b0d0f96ccf5ad518a256850821f529f71e49d62cf",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_solar_development_0000-batch-fap-0.json",5880,"c30512192e52ebec37226a44a25a5607b007e1c28c24ad214c578daea6ff0a01",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_solar_development_0000-batch-fap-1.json",5880,"a10666918cff4c956acf6e39670780c6abcdfba2964f501924296758d1e47a14",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_solar_development_0000-convenience-0.json",5603,"34c7fbe209d23a52e75eb0deba6ad17f4d401b09cd0c6e7e5e31d2b143c4bdc2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_solar_development_0000-fast-0.json",5598,"5f6faf7ddfd386b31147c528e8ca7158855e6e1b5e313cac91a31be8f31b6e2a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/tess_solar_development_0000-full-0.json",5596,"75a461501e5d5820e2be33aecda1dada21b101a71f04ca42bf54516468f6ceb7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/ztf_highimpact_development_0000-full-0.json",5634,"47169c7d30b5b167ac4ef8fb920e1eadc4cdade82bb04110d50d8f424e32ba05",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/ztf_mdwarf_development_0000-full-0.json",5639,"36d9e504e840a26dd37ef556b1f58426c6245b579516c69f7dd68840774b1cd3",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/ztf_solar_development_0000-batch-fap-0.json",5908,"274e58db0243fa1d9cda93b337d650fd2f3ec3fcceca5468f549fe0130c56c3a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/results/ztf_solar_development_0000-full-0.json",5624,"0eed837af0e5a825059cb896ed949d429e6a34c6207bccd070a3f384cade3af1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/stderr.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/release_experimental/stdout.txt",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-derived/original-freeze.txt",1251,"02e751c1ef4e2d7e6ab8217adae8019ae13af4d666a7a9d67022ce6f57e95c6e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-derived/original-install.json",788794,"e0963adc4899a08bcfb7de2f0db3f04cd46aaea951ba31d70d34138940d20513",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-derived/requirements.txt",1156,"e629af0ef28323c2c570c932010d02f5022f662434ccb490e593dbc8c5e014e1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-derived/setup.json",2327,"65cb0e2a2e6bd7cddcca6b402d54051d97e75a423766c0b460af0648ff3fc5c9",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-execution-v2.json",473,"b72b17375ee34848f7503efc5fbec85d2fcb19b6c39f0e48fcfa6c7373263300",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-execution.json",467,"b8ef8861e474c0fe2371e0bcdde414e571ed6efd74603b90032ba1d62d589ed4",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-runner-v2.log",145,"2396e4d5553360ad3064d8704640e2d4b463531a385c57acfa3bad3a47872de7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-runner.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/source-transfer-receipt.json",473,"e6a6531e6a2b886b194922650122aa308fa796371ed57b4cc1af1a2084e0dfc9",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/transfer-inventory.json",105736,"01f43057e7537fee4010aa1f12af0bdd4d814547523e873b79d3659106a8ce8e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/RUNBOOK.md",5207,"64c9cc6651bfe2565b285c026ad735125d3710165ba35727ff1861fa065ee7f4",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/artifact-inventory.json",1793,"87efd82135079c6229363751f94616741ed9e391295b695669ea8827718902b7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/assembly.json",11355,"423215eda6b10fe66fde6d2100025bdf002aecded827e34332e7e2136566b21e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/build-freeze.txt",45,"fd4cef72faa88738cf7d65f3238c1951392e24bf944049a0fda8e2d94ecf06fa",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/host-freeze.txt",7481,"a3767921edb33f29716fc900243dd2b48fa648d23d2cb21e4251f68e2b49aa21",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/host-suite.xml",137911,"8e29022f8173f170349c9bff8972e6392088cdd7efa1d64037ebc7835f418b26",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/validation.json",12523,"b816404eb585f89cf26b6856b64468e552914c3e864f1d7b731cd9c1bd1bc3f7",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/wheel-tests.xml",39736,"9b9f8eabcc4712b77bc66940804bcd92c6de2822d73dc354749ad965274a8643",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/host/wheel-verification.json",9885,"21f1417c7426c65742425256a291897f90518a4f9d6c3968e3cebde9afbd333a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/README.md",3287,"b30a5292c72e414227a3ab426c5eaef7a053eebe3e615563d62b87b3596bae11",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/artifact-inventory.json",816,"c1c479d47d77d9822133cbb0455d5c35fc85f1a813ef9bff857c78d37ae3e345",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/audit_wiring.py",19045,"45685a375625af4ecb6c95f3dc957d9e0e4fb2f25b63fc73afb0079b991d868e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/checker-review-history-v1/audit_wiring.py",19018,"8b67e34df12c5c57aaf2ebb80f9d85e57d7c376e00b7a171ae977d03a73c2b5b",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/checker-review-history-v1/correction.json",776,"b8032e946bd61069d84d58bac84632debda2d7c6055ea34b95a0facd0b0edce6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/receipt.json",92046,"7b4cf7c6a88d04553ffa816b3993183f4d40a5d17a8c2ddd63a986c464784ab4",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/final-ledger.json",2522,"a389f5e7ac38c20868e9381c77c67974ae4a492f4a0784f067c86787d798ea8a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/attempt/creation-ambiguous.json",1361,"1131a8375448e0b9279974235df5699b873be2f750b4b33d9edbc8791c16c7c2",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/attempt/request.json",991,"8f3622b82213baff3f67370a1a870fb32c228a08e7d8c75873780724c871a09a",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/capacity-rejection-closure-intent.json",1560,"df0bba257ea6cb480c352326f3864420bd78515b0f527baf642cc3e8cd0e7b10",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/capacity-rejection-closure.json",1858,"dc732b3d47cfc8cbc1ff59c0d286cf62176a4c2b8aef03054e5b5beb30fbb5b6",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/explicit-capacity-reconciliation.json",2416,"68b5b431adfa9a22961488785b753959289308e6594353674cafffd922aff2a5",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/attempt/reconciliation.json",406,"57697060d5c107041ccc08a534405e1bde76dd0427b6ee080f342a6b0268ce2e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/attempt/request.json",1178,"d6ec205860bdf5b868dee666cb83151c6bbc5f8b18d624ad85be172d4cf208bf",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/attempt/termination.json",1295,"98ae0ee66413d7a3d49f04e4f2cb351d5d009e543fc926598cc785943133a9de",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/independent-review.json",1750,"2d8adc5668958a3fa5c4785437fca8adc601e0ec2a7869ebdcc104edefe7c64e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/review-plan.json",6602,"792bd2f7247c8a3a63d4d2fe08f73eb6e65a9bde7fb30e0fcb910679ed353a82",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/root-final-plan-review.json",790,"13373ff4c05966cba273c6c1034996cc8cfe3c7725066e1055347e7214321366",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/provenance.json",76254,"b177535d904228954bec69e2b1445c9048478615f6b2486c7ca4a8f586c09aa8",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/publication-verification.json",1052,"c34efd43366dc414fea9732483addc4aeea51f1bda8ce9bf652a36c52dba6c98",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/install-v2.sh",2501,"143378e52422c4e47f81f8a7494d48c942742584651108137e97064b4c9538eb",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/install.sh",2560,"398e7d9f5b74e9a3af007e1f5c6f247e682ceb9635a133360afe050bc6db95ee",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/pack_results.py",2868,"fac595a0ff47fcfb54dd36143c570ebcc91063dbb483c67a64bd235c0b1efa25",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/root-binding-review.json",1227,"3ce581aadbe47f6baac94c9077542c788b0956d527455cf9909685772fc3be44",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/run_install.py",1102,"a29b7ce66b9629547fc868de0ab04fe43406f0f19566fc50fe3af13c79b111c1",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/run_install_v2.py",1114,"73ae2f9c24008846177159635ea14646ab1555bb6d4e67861c6aec1a3956e9d0",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/README.md",3708,"30e1cc71bb2856026c0fb83f1cf915a19ce87d76560c29002c21c40a7372bea5",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/artifact-inventory.json",1099,"03b4dcaf7d7b04be0ca3b0770dc669cca7b31a0a74554e4c6c2c308a47b68817",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/data-transfer-inventory.json",12533,"ad8a4dcdb08631e87c8279215e503e0ae96ac84ec4ed5511ab76332c3ed217aa",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/verification-receipt.json",884,"dd435e07a1c2ffb998d228e13ecece913a29a5361c1d7fb0b7b372e808d09cc0",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/verify_data_transfer.py",6515,"332a830a51e2f64e964e5a26ff5b74d6267ae333777b8121b23cde14e98899ac",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/README.md",8224,"e9f250844b7c5fe1e929a78fed35a7f1cfbc0f047dcbe71946c8543d62a7e701",true], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/artifact-inventory.json",1225,"03f7caae310bd5116aa9eac9390dce85e6119195637218ae5fb05dd49e339a68",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/transfer-inventory.json",105736,"01f43057e7537fee4010aa1f12af0bdd4d814547523e873b79d3659106a8ce8e",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/verification-receipt.json",1045,"d2aa4f70548808ea9f00a339c6a5bb7623f6f53fe524f0ef5eefdb5cfb16ef41",false], + ["benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/verify_transfer.py",5584,"7f381583d6a4f80d072f1eb1d1fce9b5c391428136ded10f0c31d39d419d77ed",false], + ["benchmarks/results/tls_survey_2026-09-10/root-final-launch-review.json",4351,"4d12a1e82a472c5b25ecb655c0bed011034d8123c25a3b5c09181cf4cca8e42f",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/README.md",3123,"b035054b354ef78f056c32b5ba71d7dbd55fc3a47e02693e5f9365412817c07c",true], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/calibration-timing-checkpoint-20260911T1008Z.json",8497,"6a3007fb4e1f338ec40ec5e4ef47e2d49b212831f42a166e01919786500da10a",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/index.json",1026,"6099e56c4ac72827b6b3fcfde5a7213bfe00fbb596b92982975fbec6f5a1582a",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/mdwarf-first20-check-20260911T1022Z.json",13359,"6941f3f39c4b6bd20ab0001a8b49cc7b3c4f8dfa013b88d104e51517e085a043",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/remaining-envelope-20260911T1009Z.json",5747,"f86462826abc434b68c2f408c370da5c52ef5c9a82b9df53ed72edbf4bbf6a06",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-planning/root-envelope-review-20260911T1009Z.json",1543,"367552bb25f02b668395d4858c6cb000a605d51dbe08445e40c730ceb52aae59",false], + ["benchmarks/results/tls_survey_2026-09-10/runtime-projection-selected-final.json",10905,"e2d720e40ff98594623c7e364636aedaafb7ac45c450686a769ffdf66c2a875a",false], + ["benchmarks/results/tls_survey_2026-09-10/seal-final-interpretation-v2.json",16623,"463d21043394cc14f6f3676b59702ee6577bd1faed2f9fe28ed24680fc255fe7",false], + ["benchmarks/results/tls_survey_2026-09-10/seal-final.json",252235,"1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/README.md",1274,"8c29f812e58b7897c91dc348f818c6506bb74670373cae535ab8b949eda70945",true], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/default-window-control.json",4043,"1508fc1f8def869060b7da023250f9a0b01f1c6f64ae9e42e5174100235e79d9",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/independent-postcheck-summary.json",5126,"8d159df085f582a21aebef2c8fcff8b62de1c84f1295c320f57c678681b29a95",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/long-window-pilot.json",3882,"0dc500e09ada78ee628e1caa84f079b019a38596ad66cc89af84eafb0381116c",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/root-execution.json",1239,"108f659472604b5150b7910c2faad67104ab68609fb16f5495ee76099d2e58bc",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/root-post-action-aggregate.json",1307,"17397a06c448572de74d48d3569b5cf21a220980c8bc4ce3d1bbc5d0b05d9ebb",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/summary.json",2495,"a7dd9f8f446ec208306ddfb281be25370eb0b80485fd06b22abb67e1669729ca",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-r2-archive-20260924/summary.json",4690,"34ccf6c8d489c4205b99295848413f0358cf611788eb00a64c5de163aba21e90",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/01-sep9-execution-v1.json",1426,"755db34b7b07cedaba8a3633be4ca8f9b1ff21d8af0262b4ed0e0320d7e2060f",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/02-sep9-post-action-check.json",639,"fdb6cc490e6b62a2195ef107d3239ee056bd0b1d207e440919e68f822afcbf5a",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/03-execution.json",1618,"3f358f965fb4be08ede36726053db98fb6ea9ac1bd83f3b4df737384aca90a1b",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/04-root-post-action-check.json",888,"16930d66625c5402b508a7325fcc86cfa5ba307a06913bc9ab4bd448555423ca",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/05-execution.json",1565,"7673876dfb42f83d6dbb34bf70e9438da808504e658c69366f9ca7edc01210ea",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/06-root-post-action-check.json",638,"2cc951c82f9bfb5c616b21d27b1e0ab631decfb6a6714ce357ca12973553a537",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/07-root-sep8-hardlink-preflight-v1.json",974,"eee8cd3d1b9913a3883e00c436d871e3a9b0b4fd5210be7ffffc198910bce29d",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/08-INVENTORY.json",4310,"e565bf448a6ab7c915ef90c1c2eedf8dbeb8e4dd757c77000f1eada02918ecc6",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/09-INVENTORY.json",1486,"33c958e11b84a22c7985fef6df1d1657196bdc84adb1892f85ce4d728e20be69",false], + ["benchmarks/results/tls_survey_2026-09-10/storage-reclamation/summary.json",3806,"3621803a61c81cea5fafc286d35238f158ad4a9ff366168c35778277159c67f0",false], + ["benchmarks/results/tls_survey_2026-09-10/survey-host-tests-final.json",2786,"af49ff9cac6d41c4249cda85998bbc08b9e68ef7791a23c8d91eb3311905dd30",false], + ["benchmarks/results/tls_survey_2026-09-10/survey-host-tests-final.log",180,"726f91eca8fb5f4c6aac60880288d74000ffc2fb2a8bdb35defbc9991f193abf",false], + ["benchmarks/results/tls_survey_2026-09-10/survey-host-tests-integration-final.json",6165,"739541f2ffc24374cdff8852c5ddbf74f941bac57cd0fddae95c6e0517879284",false], + ["benchmarks/results/tls_survey_2026-09-10/survey-host-tests-integration-final.log",180,"e33b5a6c2d78ef01dacd2b8eee4d7a43da7e376f8a6861aee7e4cd5e530381ee",false], + ["benchmarks/results/tls_survey_2026-09-10/target-snr-label-20260911.json",1680,"a25738e98388ae5d66b591657bf3220caa6aa34bd07556fa2af7afc918d457bb",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md",2662,"608a4c85095e495ec07a1fc017d00155859b69976742d8aea27eef15422bbf87",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REVIEW.md",3210,"448a63307425498f4f4608d3b64d3877e2a6a09de93f4f3e39ab3611a859986b",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/STATUS.md",1342,"0cd3581532250e5278e37efd14d030db2829a4ba0eb4cdb5c38893d8c33f21dd",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/completion.json",2807,"370eee4e70c2326ef7cb096dd6742696cba97ee44a1fd87ca5550f4232b9197a",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/failure-review.json",29675,"d03b940b8a4d1e526e6a221cea7dfa23249e16305ee5e887ad078db55ef9da98",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv",9204,"30a79d0745f1105700e02b97368b481b2c91d48d529ec3d47640b005ae719f33",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/review.json",867,"ccaeb6eb68209083881f9378d4b100ec355dd08bc6625554b4e814924cb9ac92",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/summary.json",19942,"ec827b1365ba4d39191431bf62378612b1103e4635bf6f9f656c8dfd62e0fa7d",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png",117537,"1a640aa30c395b1e5f7ce47dc88310f2ec8821e04bc558f9fea04a2f382312f7",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.svg",103718,"7dd42d8f0e167827e767c65b89a1f8652dd0bdbc8ae6864d2d026b6a486d3e1c",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/AUDIT.md",3556,"75d3a95211653eaf8b2281e3dfd4321d8f8b60901272e383ea3e3ace906c2775",true], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/audit.json",14888,"425d63c35e26067facee0312732fda8f9f827170e756efb77a4a4f85795aac2a",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/bls-mixed-w1-b1__result.json",229537,"f842478481dc726445415c719fcabbaec4374af65b130d6527b10abc5542e88d",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/campaign.json",26383,"8f32c398b0e6b675b41e89474ff3ca5b3c381ee83e79b96f38d3fd9045de273b",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w2-b4__result.json",576367,"4d53596be93ea45c041683df221cc0a825c6546b6089686c3d86515e4207f9bb",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w2-b8__result.json",264370,"d9614a0e8cc5caf698138830ecde7b295e90ac7ac4b31b10e988504072593f67",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w4-b1__result.json",593246,"53929edaac5d89d13e28222094a96fb31388403c20d147a0607ba3c6abd4ebdc",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/transfer-and-source-hashes.json",4388,"089f449397d17a2c496290179df208f266b3b96f62b5e40da7cd16b8f10c16b6",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/verification.json",1328,"aa3485207256bc63e0d1195f96c07daf953a488e9b47c6a7304fa24107476a92",false], + ["benchmarks/results/tls_survey_2026-09-10/throughput-tuning-final.json",26383,"8f32c398b0e6b675b41e89474ff3ca5b3c381ee83e79b96f38d3fd9045de273b",false] + ] +} diff --git a/benchmarks/archives/transit_2026-09-08.json b/benchmarks/archives/transit_2026-09-08.json new file mode 100644 index 00000000..60b70ef8 --- /dev/null +++ b/benchmarks/archives/transit_2026-09-08.json @@ -0,0 +1,1268 @@ +{ + "schema": 1, + "id": "transit_2026-09-08", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/transit_2026-09-08.tar.gz", + "bucket": "cuvarbase", + "bytes": 125915703, + "sha256": "c4fc8f5981cd7a7f418bce7e7a72cb2f49edf8ad78ef837a5847f1f4798171a1" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["benchmarks/results/transit_2026-09-08/.gitattributes",74,"6bf6a4ef8a3c547980885942fc2bd2c3bc37c57ad13843d40136a3c73c078b90",true], + ["benchmarks/results/transit_2026-09-08/ARCHIVE.md",2159,"bafea2e6872dcd2a8cb1287b96015aab2a2d30c78d4e85cafba137e4601d373e",true], + ["benchmarks/results/transit_2026-09-08/PROTOCOL.md",11124,"8cdcffec2e5e6f6ad8ae021a36835c9ffd61738b77ee754611679b171beb0b7b",true], + ["benchmarks/results/transit_2026-09-08/README.md",23272,"5be4ca6d91c9f46f5ae035bbf2cdf3d0a8ebac2e4155332deec7f44c85f26089",true], + ["benchmarks/results/transit_2026-09-08/analysis-code-manifest.json",3309,"3a53f6e26449167d405a51223a368150e555faeeb009a25fa69c1fd20532fdcc",false], + ["benchmarks/results/transit_2026-09-08/benchmark_story.pdf",42138,"b668f724540b16baf3422a127e4b42ce5e7a0271ed446214663b6e6072741763",false], + ["benchmarks/results/transit_2026-09-08/benchmark_story.png",335687,"c6c3bac0afe4abfbc69fed1179f0c2dbe5e7ba328ebcafa5fb3e1805c8965137",true], + ["benchmarks/results/transit_2026-09-08/benchmark_story.svg",71782,"0f1201bc288bcbe445a798fb97067d0d3364bf0c1199d6d5d4c06052f9b2f6dd",false], + ["benchmarks/results/transit_2026-09-08/claims-before.json",1021,"0706f850dfac607c5c222e69035fbf164b2e0d05ac6c14b1361742774a86df64",false], + ["benchmarks/results/transit_2026-09-08/component_ablations.csv",2490,"0926114bb02dcf11e7a837e6676850bb55f3d4b41aef119d0eda4bb864ba9b8b",true], + ["benchmarks/results/transit_2026-09-08/component_analysis.json",66195,"447b7f440bed5e5d07e4159fb512d71442d65d84268954b4eb4604dd0ff663b5",false], + ["benchmarks/results/transit_2026-09-08/component_phases.csv",22976,"254f8bd1d09a11b07842819be57a2df3731af58c5cf034e555d3a0dd5c05f8f7",true], + ["benchmarks/results/transit_2026-09-08/component_summary.csv",2854,"4bf1c12e65307a46978d9a966a2891b3a6751ac19a51b31309eaa0fed6232b80",true], + ["benchmarks/results/transit_2026-09-08/components.execution.json",14145,"aecab5da70538b0637cf62d6291976b65fab1781c12a93aaa701093020bfd87e",false], + ["benchmarks/results/transit_2026-09-08/components.json",5711,"cf823013a1e2f7cac7f4935592b2e2d8d6ce1c9d1a268ed6bb637ef976df9ac5",false], + ["benchmarks/results/transit_2026-09-08/components.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/transit_2026-09-08/configs.json",3024,"1f971e375b276f2080d715e46d3f05c0afcd86e2fd50a8bd71fd4000c5422cb1",false], + ["benchmarks/results/transit_2026-09-08/cpu-operational-amendment.json",627,"49e0dcd359f94d41d0721d81ec0562ca8ac6042204dea0678fbcff2d3a84b939",false], + ["benchmarks/results/transit_2026-09-08/cpu-operational-selection.json",56982,"a2180fa00e982dd1cbef69ef72d0290ce0e2574451584205be9bf87ef004a259",false], + ["benchmarks/results/transit_2026-09-08/cpu_operations.execution.json",3433,"90bc786327d8048fa7a68faeb8d4983b65e07f97afdab06db1834f96f5457a63",false], + ["benchmarks/results/transit_2026-09-08/cpu_operations.json",1564,"e2e894606713813b2cc15023b974b4bb7fe880032ad8e529a494a927fe4ec0ca",false], + ["benchmarks/results/transit_2026-09-08/cross-pod-full-spectra.json",1189,"5a3f48e8baa45fe33f1470524c05186fd2c102316b2ef2001a2272ab85dd4c94",false], + ["benchmarks/results/transit_2026-09-08/cross-pod-preflight.json",1687,"599cc186367361bf2347a7c55f9267820cbc99217581cac2ff1f201abdac7ccd",false], + ["benchmarks/results/transit_2026-09-08/final-verification.json",4917,"cedd856eab8dcdb06d7960ae0683edf91c82249f514c010dec785a8bfc35d121",false], + ["benchmarks/results/transit_2026-09-08/harness-freeze.json",1611,"7f1a6ffb26f4c849902e810e2252025494875458a7cfba027fde792334aead7c",false], + ["benchmarks/results/transit_2026-09-08/inputs/cadence_sources/real-manifest.json",4880,"99d8ad6367b40ca25214a1463fa60b74b2a8ec962fcf5ebcfd51aa41fec34658",false], + ["benchmarks/results/transit_2026-09-08/inputs/cadence_sources/real_ztf_heldout.npz",1641802,"cfa12cf370838b9c040eb122e94d4dcc661381b61b39bda94b94d11b581ce650",false], + ["benchmarks/results/transit_2026-09-08/inputs/calibration-manifest.json",4079,"9d8106018441a3bda15318884160cd3a4f419773a60285389dc7abf1fcef4053",false], + ["benchmarks/results/transit_2026-09-08/inputs/manifest.json",8095,"68af2fe79738479ecd9008b87969763ea8e33047cb820076f672a514aba0820d",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_200s_calibration.npz",16013802,"302e99eaa070be9130a99ea9f248172f449eb03a54b4ee96ef87591654f90a73",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_200s_heldout.npz",31972656,"e2e42d08417442113072426e366840bb2c276f88b6a70b0b83b303aa35718676",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_200s_tune.npz",8042595,"ee72923cb93dacbf4bf059e9f7545938ea8ee3b2db367fca9b53e6f8ff99e051",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_gap_calibration.npz",9245085,"6d3fdf3424009f7e7b950ba70018d2f588b842fe0761390198c08d295a35044b",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_gap_heldout.npz",16362546,"13d2ca720adda683cbc326eb099858aa1e41410459d01e2a22a5e64be8624150",false], + ["benchmarks/results/transit_2026-09-08/inputs/tess_gap_tune.npz",5714999,"b262bb9a0534e116b43b8d1ba716527af12f5ca1ed94bfa2b3e0bcc1f71f1ccc",false], + ["benchmarks/results/transit_2026-09-08/inputs/ztf_calibration.npz",10087242,"b1d5f02e1de2e410677fb44b07f5f07459567e9e5d47632b2194e213821620fa",false], + ["benchmarks/results/transit_2026-09-08/inputs/ztf_heldout.npz",13309615,"96ade437009b111a3785b903226eb26ca2629e428c272459e318f0b023ca0e79",false], + ["benchmarks/results/transit_2026-09-08/inputs/ztf_tune.npz",8474730,"299508b42dab363170e93ee465b24ce609a7f4fdc48e14e6694315f20428f04e",false], + ["benchmarks/results/transit_2026-09-08/modern-freeze.txt",1272,"6eedb65e3495cc78e7dfa2a2e22ab8e97af35e92a48e5df34b361d8bee958c4d",false], + ["benchmarks/results/transit_2026-09-08/operational-probe-agreement.json",6926,"6e3717716df5cd203a0370a958d414611534bdb46a87a45c71a403a98cd39410",false], + ["benchmarks/results/transit_2026-09-08/operational-selection.json",652,"1f2f51d9832c8931c4eb8711c3b21f58381ac665edca6b05f1c2c39d82a098fb",false], + ["benchmarks/results/transit_2026-09-08/paired_comparisons.csv",15262,"02fdb9a1f9f544a3f660d9cbab6ffdea5ed9af3ee0ce8f817c8876abdfe9f4f7",true], + ["benchmarks/results/transit_2026-09-08/pilot.execution.json",22915,"9c60f39f0132273ab6b0239c89f4c0a3d0b22352c102274f45cb86bfaa845318",false], + ["benchmarks/results/transit_2026-09-08/pilot.json",7296,"be5885beda2a3dacb2b76f023276a610d0fd2c8f883044201d9ac4f144433d5c",false], + ["benchmarks/results/transit_2026-09-08/pilot.log",3498,"44b9cb3aeb1f188cae8792965a281395c881ed3ee993a27b76931f7b300c8909",false], + ["benchmarks/results/transit_2026-09-08/pilot2.execution.json",18585,"4c75560a77a8f0cd656a546db75693651076955283a152a281b2390732ea1662",false], + ["benchmarks/results/transit_2026-09-08/pilot2.json",5963,"35beb46dfd375ab9783126c664e913e529c4a96bdb380156780f377d7659d5c3",false], + ["benchmarks/results/transit_2026-09-08/pilot2.log",2921,"b40401c01d7f49122a41770082488617a36094ba0557b03bb3928251d35268a3",false], + ["benchmarks/results/transit_2026-09-08/preflight2_validation_a.json",865,"51fe25534a47272e81ff7ae28fc68d2de8da3b966a91613f05b3048d1084e8bf",false], + ["benchmarks/results/transit_2026-09-08/preflight2_validation_b.json",865,"e06c0813c8b7426775e0c0565f6a95e10b81e0dae2309df8c38c1e04ab9fc050",false], + ["benchmarks/results/transit_2026-09-08/preflight_validation_a.json",862,"31eb858ddda348bef289d8d18333289aafab6a63c57374cdf091a1fa6494df50",false], + ["benchmarks/results/transit_2026-09-08/preflight_validation_b.json",862,"84bcb37503f93de8bc161db6b948f376e20885ade325f88bde27095522372313",false], + ["benchmarks/results/transit_2026-09-08/provenance-verification.json",699,"2731c41bc59252dc48252a9fce7661581972110717de769844debcf9254217a9",false], + ["benchmarks/results/transit_2026-09-08/ranker-equivalence.json",1435,"0aacb4f7032cb92729dfd1c435391a27f390e0e1931045e3262dee52bf605eb2",false], + ["benchmarks/results/transit_2026-09-08/recovery_analysis.json",234857,"6b0ec3b65972a0a1f79108fa1de76f71f66ffe55b4ef82b03cf219be534d0e0e",false], + ["benchmarks/results/transit_2026-09-08/recovery_by_snr.csv",12725,"2f30a1c0066a046993999cae3a7ebf6554da3320c3569d8db5fc1c72902f2e9c",true], + ["benchmarks/results/transit_2026-09-08/recovery_summary.csv",6177,"99e00e7f660ad2471e4394d14a9308799fdad73dcbe6e3c95080eeb250736e50",true], + ["benchmarks/results/transit_2026-09-08/rental-ledger.json",1628,"e98f66e38cc09d90c0d2eb4026d05f47ab5e2fcf46ec069183218f011954f175",false], + ["benchmarks/results/transit_2026-09-08/repeat_selection.execution.json",23132,"df32cd28607eef2d0255cf99541c9bf1d2f93890f874cd2abf26f389b2225203",false], + ["benchmarks/results/transit_2026-09-08/repeat_selection.json",9193,"04b5bade192e5cbbe6ba1e382cf0b62f67cc359f2d4fbc5b5cf80eb3b8cc002e",false], + ["benchmarks/results/transit_2026-09-08/repeat_selection.log",3766,"0a78609ce9e8fc0174e6919819609db3bb7f054a073bb989b3709563e877e03d",false], + ["benchmarks/results/transit_2026-09-08/results/build-dependency-install.json",272848,"6d80cfeb3d00d55eabad0a5bb02dbe9c0393e809d04f329e9a5dbfdff2b21697",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu/execution.json",624,"53ec6f356bf80feecf9e8c8408f1dfabc9a1a18d4b1bf90d7b6d2aa05fe58843",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu/stdout.log",62882,"02c7ec478579bcadd8bf2f26d573bcf3db38711f9b22fb2fd0007fbbe8695ced",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu/summary.json",79760,"38e798a39afff45e3cb44dda100f934b0a07c06429e85062344a6b5ad239efcd",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu_sources/execution.json",636,"267a03b6f9a2dfded310121a43beb195dbb1cf2b3d5020e7a40a55fbf65a1ea2",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu_sources/stdout.log",63014,"952737cc6241851866123f0e652ce30ae3334ed8ff5880a5f9a5504f9c151b6c",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_cpu_sources/summary.json",80150,"df22914b526bf02eb7f146e8e3ef7b3c784fa625b718e7eeb2ef039afd363e69",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_gpu/execution.json",634,"937bb9cccf5afe2937e776aba3fcf42ab97308332bd13f2f595b874c2d677c6a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_gpu/stdout.log",62933,"5e89d52da8a94c89cc7c10615902595bcdb28d044a762497033c3ea2d8b64ce0",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_gpu/summary.json",81915,"ce6e588d1996512cf49de1fe13f24a7fb3bc380714badb6dec530c88315b40ba",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_pypi/execution.json",664,"3c0836154e0caf38dc75cc0ace4d2ff2e93dd2575d6d9ca5bb7179ab84226b44",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_pypi/stdout.log",63381,"b309e437ba96163e04a5969b33e5b36622664e1ffa5e6202f325e1bfddef03f3",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_pypi/summary.json",81691,"f9c6943006a4e9a89852649edb2ffb53862259bcf8135ad3f1d75df080dde786",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1/execution.json",632,"7d7edcda691aaaab0110cf8cde955fb9adb5a874a90548f5a588ff1b3bfc9116",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1/stdout.log",63091,"2b4001b92869401d04183007850364368c43873d4344e1414510c7902a1aa365",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1/summary.json",86741,"c2ce7cb0772a19baf4019589141aa72818de1ebc6930df05bd0dc46c23aa86ee",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1_batch/execution.json",711,"0739f4896d29d2161feac2e8831f4918f0423da38d1146133be21cb554ba65f6",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1_batch/stdout.log",63274,"1b17c7ef355c33737058538158cc56793daf095c47d25ecd889ff08105db1de2",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_bls_v1_batch/summary.json",86997,"5bd9b208099e44990a1fc14c3f235fd58cecc93ce9739ff119b4a82372ac93dc",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls/execution.json",307,"06865ee2640edbe6a142ae5f8e809af35c4338ab0c0d56b82cc3d1d47cb723b5",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls/summary.json",96499,"8ebafdc2b1528e0ed86a2198785300e7629daf065cc3e4be17706d3d7bbef3d3",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part000/execution.json",842,"1a46a5d2f57573b3c02abfefe16edd96dbc9137652aa833270892494e220d39b",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part000/stdout.log",35301,"d0e4a1c86fb885383da363668f1cfce13faecf304ffd9ec8f2fc2ed37d39acbe",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part000/summary.json",46396,"67782ccd6ce1db403dba9746303e3a31f3a2aef91ebb02e7b4631d99e06fa198",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part064/execution.json",880,"995c27396381cadd2712a6c674ff0853d3a18b4bef6baf4e59d26a0331214ba4",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part064/stdout.log",35342,"8539aa80a10bdc2756b835ec8b79771f5308a9d18b38876ca8fb4b46781cb95d",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls__part064/summary.json",46475,"26d57a24416c724e90a5794b88844453cf931d082977ac6f300c26cdb1032c23",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls_batch/execution.json",694,"2dca3f34759cdca18e15428b639419c8da27ecf85d59d306f558e987b14c741c",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls_batch/stdout.log",70554,"15e4422df0dbab5dd17d7e5582bdcd8b600d208cef01a3847b5a832967e8c851",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_gtls_batch/summary.json",90328,"b1bca79b3d978905d55230399609c08653a6694a99b75a081ef3b9e2433389f6",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_tls_v1/execution.json",624,"10f215ddd54161218ef5984f000be90d49500b3ef2521f133673220e88df3aa3",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_tls_v1/stdout.log",70133,"1f6b530c18dd91905d52a7c46abc71af030620b7fa9de42189b5140049f6ee93",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_200s_tls_v1/summary.json",95378,"40d49c90118522556a0f161914d4512724f7533a66308c322de2b7c08119479d",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_cpu/execution.json",629,"9b7253c3aa46eef0a46675d0caf5bcb12288c9baf42a4a1420595a60a60b6443",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_cpu/stdout.log",62998,"585bc661cdd758c6e81dd0cc99fbae9bc7df2ff0437cfc8bcc94103f7ea090f8",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_cpu/summary.json",81973,"f151d9ff5fd432d88fd82edf32728b02e0fa31271506878a8b9f4f7b266f57ef",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_gpu/execution.json",614,"33dd9fcda7a60eb0d98f29b38ca5b3742b2a7aeb361b6cdd0b30e124983c8175",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_gpu/stdout.log",63144,"8b7e28481dcb93ac69b8391e6592f720ad8a4abccdb5923ae6c33d75dc5182ae",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_gpu/summary.json",82101,"7503b1adede72d68677c627ee7076fb4cc56b33b6887c8e216c5b6e603fd1617",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_pypi/execution.json",682,"a814b53c173d3a322d8b365c0265b2bc02c8b5f7e31d702e87a098f134c58236",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_pypi/stdout.log",63544,"1c9c4d12b54b76ca558f93a72b51e02649b20badb2fee9d4f18cf30e0b0ed12e",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_pypi/summary.json",81874,"680cf88fc5260b1d990666113cabfa2cefd8c2dacaacfaddd9bfc36807b19e7a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1/execution.json",671,"c7ab5c41a5395609b5ca552761c0d023ab62b3517056823deab9e9e80eda459c",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1/stdout.log",63228,"1febb58362d2743f5c47e53b0cec23a66d442797347023061dc38dd40ccdc24a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1/summary.json",86921,"36ecaff92268b8de9542c478ded771f7eff2c93b4e6b5d25be2b9ce7061bc91b",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1_batch/execution.json",751,"687637636e5e142849248ac408473d0eb3df922f0d557428c2fca39282db143f",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1_batch/stdout.log",63344,"3517befca5559670a65aac6bb18bfa8eb817439eff0b7f13472ab4251e0cbd94",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_bls_v1_batch/summary.json",87112,"565238355c725ae76bf3d913fa0c971deac844155a0cb8ce566e47c08a6e38d1",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls/execution.json",304,"3864408515422822de15c98df5fd8ed4cdcfe0c6ef4cf5f4509b483c49b75400",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls/summary.json",96349,"be31b20ecf8bdd450d7e7d355365dd031f9c36ce4f34b963d02bba6db7bb609c",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part000/execution.json",837,"b2c0f14d65150d8d2a5d07f166872a4df7c639c4ce6884125f01293e121e7d48",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part000/stdout.log",35296,"9e6bc3ef462402d3c9f97eb1ecce5c516ccd022beab9f6cda5985d00880c190a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part000/summary.json",46389,"479a7d8f27a4d686bae4164cb029dcbe09eef0c09762f6b06616ede1f6f394fa",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part064/execution.json",878,"c330b0574fe2ab3dbc7c24124c195b13fad3e681220a9781326c924c457be617",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part064/stdout.log",35329,"0c0dc3dc7bb090b475579b22d5ce2a3991aa2b5cd9ec60547641722c6ea75f4a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls__part064/summary.json",46460,"7c6d60c25a683116286e0c03e56d302f0f5053cbd6aebc5a6a65e870a7163600",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls_batch/execution.json",691,"239c916d044005e1e747b2546aa7e8dfc82bd899c75192879b3d7fad6c3e8e26",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls_batch/stdout.log",71041,"0156009acbc69aebc1b001248e1a14004cc2f972720dbb114afe1b7cb4e6e912",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_gtls_batch/summary.json",90237,"609e5a33c0713e00c33caaa170010fe3fa1e9407b4d083f7ae294d61c91a53e0",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_tls_v1/execution.json",623,"dc5e27df2125a2a4ba4ee66dbe0739ea0875536ae326abbe60a95550d08545bf",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_tls_v1/stdout.log",70182,"99f8e10ae4d299072f49d1331f78142f91fdaa937aa1d92491862b4c3e6035e1",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_tess_gap_tls_v1/summary.json",95426,"739bc77a86bb1a64bd792722ed57cbea3aec7ba84061764327af5c8947ef8a93",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_cpu/execution.json",614,"e7f87d57db0489d745e4a7b5a49054b6ae0cf7e8f767cfb71cfd42daf26628ff",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_cpu/stdout.log",63088,"056983fe2d44dd8d40d69f5a3f7ea356eaa3830a54f224a9109dc5abf66cb486",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_cpu/summary.json",82052,"f57be1aa92b6126b82a711080e417cc82cb0ca0568bb4a8a7eae6f92029ff02f",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_gpu/execution.json",598,"56d924012bff8880cf641a05fd212446b29aa48be128e37229bb91c07624eb5b",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_gpu/stdout.log",63220,"223d7c80e20694bd19ae559f40f14bd45df361c7f4b64b55fd3830d9378e75e1",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_gpu/summary.json",82168,"9d419a3ba9d524cdcee376ddb833c45be9646997cad4a5c711f70b8272b2e002",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_pypi/execution.json",624,"b4dfde8cf9621989b4653a896596468229e64920680593a10b1a6ded71939187",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_pypi/stdout.log",63577,"f9c6983ce890300777d788caec3b225199e8b0ea715b8c47bb42049813c787a4",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_pypi/summary.json",81852,"0b54f37b7a119cf6ff45116a0eb1d1d2bf940b2bdfa620b515f2c389ab156476",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1/execution.json",614,"cb93ae6786d30f134d76f3e0b733ab23e73ed7bc6dbb5b2ab7beb2ba20477fe5",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1/stdout.log",63265,"20c56edd1b380866e041ac862cbf167cbb3add2a8566c5d4ca5d3dc9b9eeacab",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1/summary.json",86902,"4040bec73166611cf738df905af91777f17315e5a797bf3c7e2b7100c53379cd",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1_batch/execution.json",694,"6fe0159664161d71369c45718f77f89266568adec3d6bfb868757bda9db14f50",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1_batch/stdout.log",63416,"816db7080da12e26e0221708bf221490be60a33a5ce65a67d07dc2223480170b",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_bls_v1_batch/summary.json",87127,"fe1327e17b8e074e0893b7287700cce732bdb9655ffb0b0ed18b67ec83bbe512",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls/execution.json",290,"c1754deac6a42d9788fb90b4503c49c01dc80cbb311d31a308c8c5c98810c13a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls/summary.json",95962,"b6e9973bd9c835fe24f9dbe735dca6224863c8849cf640658151b2620d882c29",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part000/execution.json",824,"cc78359002613f3dc66d15d76c6fbdee5e1a5531311b076cf60b3dca043f22b9",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part000/stdout.log",35433,"ddb4e234453ddc63625e814c4f7d2a90d6eafe9b4f11dd4025f9cddd8609b6e7",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part000/summary.json",46517,"fb5a4818449e3c5d63ffdf8a4f0d7615a76825f39dd9742860ab0bb9beec7552",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part064/execution.json",862,"c509e4bc8a0c123584cccb8698e5db8e461cf2c7d283c89f16d7374b7fd4e03d",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part064/stdout.log",35464,"413a4944430fc26b7a0fa7e639911bb81aa5f64c13937e7f76fb10e5d4bb9a1c",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls__part064/summary.json",46586,"183ccc28a8c5ae488f61c03b66a776052f7f1068ed86469bfe5032a6cc676341",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls_batch/execution.json",675,"5dca8c0d8daa3f2c665e5501ac67fc3e82007caaed4161d1ba5151c7c488e9c6",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls_batch/stdout.log",71851,"fae29f6c181a7c1fbd7306068053adcc9b4459e7c6ecf31891a95e5f2a5a2bfc",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_gtls_batch/summary.json",90458,"e577d44f61c352229c3b4d16445c89ad242652290a828331e20983bd94b563f8",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_tls_v1/execution.json",608,"382a76c097e0fea7f18cde675a95b265e0eb99db3f08bb4a796c8e6b1f8fc5d4",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_tls_v1/stdout.log",70243,"7e49a270d992a3da56d5b6a21cf75684110c085dffa21b26418819df938a609a",false], + ["benchmarks/results/transit_2026-09-08/results/calibration_ztf_tls_v1/summary.json",95476,"aac5de0b549bec56e3417ea52f5dd4f255c50550df68a246768e74ffb4c8f258",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_pypi/execution.json",629,"e4024aa11423d3a6ec13cd5d137bdcee42125a443376c7bb24e70c019336a98a",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_pypi/instrumented_bls.py",6231,"bde5feee2341f9154e8e319dc0f8f46f68c6b37d47282f5bbcac6b42b3105ded",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_pypi/stdout.log",374,"caa03706d129c6d0210bdb06a88a2bcdb9ab69206d4c097907e45adf1b461a99",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_pypi/summary.json",3092,"04bce5771e8fca77653c2a4ddf7e9e6fcfc236b59740e73a6c8970b6fd5aaf13",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1/execution.json",598,"b78655a05a181d2c925c3395d4e98d1a7f7a925dacb0444f78f9952648834266",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1/summary.json",3986,"c846b8e10875fe581a8e0c3feae509965c4a4e3ea84826edabb634f091c5025f",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_no_scatter/execution.json",642,"41e0cdc447451bfefddc4f64fe2b6706acc42a42a671c7db229a54ef654e1008",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_no_scatter/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_no_scatter/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_no_scatter/summary.json",4011,"97ade387c112000f31636356a8f2e864d8f55d073f02caaf9024adeabb2ee644",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_unfused/execution.json",630,"48c812fed78ba73cf4e43f65822b681ad6157929a5c84118a11e225d60d7c159",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_unfused/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_unfused/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_bls_v1_unfused/summary.json",3694,"657febf79d98f69f0ba1e578980725c2fb5965d20de6e48c7ea5b6652e500a24",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/execution.json",654,"6d71943ad2415ab137e7594cf3ed8411f79c6b373d749c8a89ea989f2d76cc0e",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/profile.search_multi_periods.both.py",16287,"e546e019f8ef4722aaf6de50c013664bb852128fe0f9e025fadac3e67ad78c0d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/profile.search_multi_periods.instrumented.py",18281,"6e382d5563dbb42cbd745527922c0868b9050939e717ca6fc6e816982cb45acd",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_both/summary.json",7498,"70d305f5505e2ca05f4d6c9268c438e2e9c68f7d1da4a7c2f1b7f268de78a594",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_native/execution.json",659,"820900600e8fde8084d4bc92292c116c8011cf1d82079425fde58d53dc15ee0f",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_native/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_native/profile.search_multi_periods.instrumented.py",18435,"23d4366fde18e22081569a194b650d0531b5e345e544904cd73a56202e7a60dd",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_native/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_gtls_native/summary.json",6948,"fa0d8b1fa3a50e64bcf70d02b50b4750232cfe8712f5b5f27e09b32005161d20",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_tls_v1_native/execution.json",639,"1cc1ef8099ef18b315a0ebf6577ae6d230bd6cc677496c69f45fc0a2b4dddcc7",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_tls_v1_native/profile.tls_search_batch.instrumented.py",20359,"a957438453d46d9f2e2eb68045b4e460591d975a613b6469230bd88724622216",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_tls_v1_native/stdout.log",24,"c455312fe73f8a41f601a5452671f0023f8610b5a28baf6341d8c6b44ff5f4d0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_200s_tls_v1_native/summary.json",6352,"15cab1024918944698f3294326e4d7a7d4eed4f462f31062d066a669bacf2a27",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_pypi/execution.json",643,"ec334352b0f13c78c57f1297d52e5c7d33e089d9e14f633e7687a766cb5aa127",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_pypi/instrumented_bls.py",6231,"bde5feee2341f9154e8e319dc0f8f46f68c6b37d47282f5bbcac6b42b3105ded",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_pypi/stdout.log",374,"caa03706d129c6d0210bdb06a88a2bcdb9ab69206d4c097907e45adf1b461a99",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_pypi/summary.json",3090,"b0962f9c49d2a807dd69a384a6a6606b484bedea38a8c696c8f8f69324e4f4ea",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1/execution.json",636,"f479b1f86cc485cb577868b42a5c1d56c8b4f90516f2c630925abb94f125dc1a",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1/summary.json",4017,"b9b1e51d802068e121f6349d847d54ff70ed8c52bee4d6d1fb539b5cb8d7e992",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_no_scatter/execution.json",680,"5bfd122d4f3cd290c9a4d3165d31a87a1e89cd0252b8578c9792d0cddad0d3cb",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_no_scatter/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_no_scatter/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_no_scatter/summary.json",4036,"98b5533254782e5e8e6eb60a40cad90a3b4d649e741e952bb253a97d6b94e5a1",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_unfused/execution.json",670,"f07c9c0ce8a1e8d7a14e44205bd2b20e658ea3fc8e4ef7e9e032f15a766b8db0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_unfused/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_unfused/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_bls_v1_unfused/summary.json",3733,"304b448b974a8e4ac443111eaf58f48ce16c56b4f6ca98db6aa3d23dcae34f9e",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/execution.json",651,"3d2d19c2ae12e148af8bc003a54c53551006b17ddcad5383f8ce2eb15b600dd6",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/profile.search_multi_periods.both.py",16287,"e546e019f8ef4722aaf6de50c013664bb852128fe0f9e025fadac3e67ad78c0d",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/profile.search_multi_periods.instrumented.py",18281,"6e382d5563dbb42cbd745527922c0868b9050939e717ca6fc6e816982cb45acd",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_both/summary.json",7458,"80e96c494b344001af248d118d15ad6791a8f58c4f9d7cf1957c7cde5de83277",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_native/execution.json",656,"6d5096072214d9033daad3189d95779f65d7991bb12c3dec395a345c78fee14a",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_native/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_native/profile.search_multi_periods.instrumented.py",18435,"23d4366fde18e22081569a194b650d0531b5e345e544904cd73a56202e7a60dd",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_native/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_gtls_native/summary.json",6904,"e46d079c34837016ca79939e38b618ee4c7fa1c93bdd1d8b2b10e6d10643e482",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_tls_v1_native/execution.json",636,"94fd60ac309491da52aecf8a9db78c74612c60b6cb6ee646daec6f335a71d17a",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_tls_v1_native/profile.tls_search_batch.instrumented.py",20359,"a957438453d46d9f2e2eb68045b4e460591d975a613b6469230bd88724622216",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_tls_v1_native/stdout.log",24,"c455312fe73f8a41f601a5452671f0023f8610b5a28baf6341d8c6b44ff5f4d0",false], + ["benchmarks/results/transit_2026-09-08/results/component_tess_gap_tls_v1_native/summary.json",6343,"5806096e8e835d618723560a230135e782769c0225cbc2630d5dd4456e7a5a5e",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_pypi/execution.json",590,"0e347e7161cd187aee3705bb2a06302ea338e36d6ed9beb6902b26da9b39c39c",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_pypi/instrumented_bls.py",6231,"bde5feee2341f9154e8e319dc0f8f46f68c6b37d47282f5bbcac6b42b3105ded",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_pypi/stdout.log",374,"caa03706d129c6d0210bdb06a88a2bcdb9ab69206d4c097907e45adf1b461a99",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_pypi/summary.json",3031,"0e5b5191366a2b8c8b01718a697d2ff4efe287b89873e694a435f13041ac77c1",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1/execution.json",578,"1c6053db94e55fa9301f428d5f87fb157c09bbbef0fc9fada2367d3bfcccca21",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1/summary.json",3948,"968b6f3a1ce1804a9800f42de457d70f04a44101fecd773b0f138874eacd05fa",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_no_scatter/execution.json",624,"6566fbea74dbff58e5822420e58481c87b4a506e0ba9cc2bbbdc5ff3f9a944bc",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_no_scatter/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_no_scatter/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_no_scatter/summary.json",3966,"b0b02524faab337c9a711619eaa610ecb0a208036b6d866807b23b76f1bc3268",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_unfused/execution.json",615,"dc9699d5bf3af83d4cb81a56590fa1fdc0ce01e5f8f5f095c17914015594fe68",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_unfused/instrumented_bls.py",6048,"a702d6f362757874dee5ed8413d008c512542276264d03e693b578d06a204c4d",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_unfused/stdout.log",24,"ea48dc33d0fca8b288ff2586aa7b41453b3667528bd7558c9349a0367d08adfa",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_bls_v1_unfused/summary.json",3655,"4575176430e7d64627fbf4594bba6d29a80cebf2f15b82d13a43c3a6f7cd7201",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/execution.json",637,"1a4d20f294a5a36298209e4679732d429ed138fe4daa26ab2bb3482212dc3058",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/profile.search_multi_periods.both.py",16287,"e546e019f8ef4722aaf6de50c013664bb852128fe0f9e025fadac3e67ad78c0d",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/profile.search_multi_periods.instrumented.py",18281,"6e382d5563dbb42cbd745527922c0868b9050939e717ca6fc6e816982cb45acd",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_both/summary.json",7446,"da8a6effc23ce15809ff589e7d87a25f663f3d316fd9e4b48d013ef53e12b620",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_native/execution.json",642,"3a3c7e2d8a435a129d562be4967fea9d5fa9cf0511773549fd20764ed3e3aded",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_native/profile.power.instrumented.py",4117,"b1660d678b75f2740cda5d30bb8a10eb29a2be91777c089177507d4707cb83f0",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_native/profile.search_multi_periods.instrumented.py",18435,"23d4366fde18e22081569a194b650d0531b5e345e544904cd73a56202e7a60dd",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_native/stdout.log",313,"9a90c46b1b617e7ffb47155ad15e028e6ce594830d3bfbf3787c5f023b0e4ff1",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_gtls_native/summary.json",6914,"e6f35860ab69de6d2077aa47ee22c8913880259f98468a83245e9ba5cf3e76ab",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_tls_v1_native/execution.json",620,"109d6cf0c61644cd26455ff53fdd4bea8dbe134234927517fd71beb127026e71",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_tls_v1_native/profile.tls_search_batch.instrumented.py",20359,"a957438453d46d9f2e2eb68045b4e460591d975a613b6469230bd88724622216",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_tls_v1_native/stdout.log",24,"c455312fe73f8a41f601a5452671f0023f8610b5a28baf6341d8c6b44ff5f4d0",false], + ["benchmarks/results/transit_2026-09-08/results/component_ztf_tls_v1_native/summary.json",6323,"6abe58f96443c5016c7bbb75a1af99bb75cd6aa5b32b3a0cd570db3064185bcc",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_periods/execution.json",682,"6a2250c94080463777dfea3993195ee3b4bdc7b1191c53f2bd90417e0a140696",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_periods/stdout.log",7634,"8f38e40231a126c3d5fea7c09fd08014b26820aaa942462389333b85c5a5a954",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_periods/summary.json",22774,"29ce7f6979f5e1865dabeb2e43659cf90968e4cbe0fa9057bf806947d94edc40",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_sources/execution.json",624,"cc79bfaddbebcbd4bd8cc4527d498513ce5be7e8d8f44d7e09841508790d1b52",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_sources/stdout.log",7634,"8f38e40231a126c3d5fea7c09fd08014b26820aaa942462389333b85c5a5a954",false], + ["benchmarks/results/transit_2026-09-08/results/cpu_schedule_tune_sources/summary.json",23012,"a3c35b38707acf757e510be7e672fdf55f9349a1d8e73d7c3f01c318fc9f42c7",false], + ["benchmarks/results/transit_2026-09-08/results/extra-modern-install.json",538,"a4429dbc932af7f7697ee0c780e9d779f980c5c67440ee30db037bb39de60a30",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu/execution.json",612,"141cbb54c3f228944824144d6e0d5c4feeddd998864e05b0eb807b989069d359",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu/stdout.log",126018,"2447d4c3fdb32f7b1b5baa9566a2d1aa3ed5a721dbc7af43c02a6b3801d0e14d",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu/summary.json",158503,"df9a8b5dc7e02b6c7f8a1d27399afcadf8dc184ba6efd67fe246c1a3ba4d69cb",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu_sources/execution.json",624,"4950a9233c3692783e62c0d17d1eab98c9e110abada2121012d99724f600561c",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu_sources/stdout.log",126250,"a0cc53f619619df53869ae8e15454ee305e60cf555a0cd2a4b6bd6fdfc4b473e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_cpu_sources/summary.json",158995,"0659b14bf13943b70b9a5fd463e74c253ad104ec689f1b447c7b8bd48ef580f9",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_gpu/execution.json",622,"6fa146b247d71490cb7d3a9ac2c973424496e63f13ff3cec17be8309ad5c1a1d",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_gpu/stdout.log",126098,"b617869c054636104ca6c77421fd7845e53c6b27b9921a74011264e6b910943b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_gpu/summary.json",160687,"376ffcf35a9019fc9dd4e9acf142a60f2158e47e6903f64a7d6a74598ff37c41",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_pypi/execution.json",652,"5630430adb8a88b68dcef812f8063003e4fe3650bdc41fa643903d3db33bf91a",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_pypi/stdout.log",126656,"a3d7eacc9d7c2886f05ef6fb76dfe197413e159b16c4a5bc4a4d034e88a79799",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_pypi/summary.json",160574,"db1df9faa8f78bced521586ffb9417c56fa788cd361ee312147727b5bc53e672",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1/execution.json",620,"fd8384c06d9352669d6e11adc1a51a9085a63c5860a24969c46cbbb2392ad043",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1/stdout.log",126428,"7978b97b57403a7050dd5c1062ea64f203eb28aea3ba2e0a2b115fdf917e5776",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1/summary.json",165686,"1eac25d68fe94b92ad5794b8f969b4a67060ce8f67cfe5dde06bf801fcadc86d",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1_batch/execution.json",700,"6d762265af89a0a30e1f71f52f9ebde0a37f693855cafe3cf8264930c0a23191",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1_batch/stdout.log",126722,"3c235b07aa9da06da2cff525d971eb875e4cfa58437dc0be2f5420eb04e1513c",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_bls_v1_batch/summary.json",166052,"41252533f9c6a3d559dc7b93377ca87059438c7b9e48b7954ef2dd9c9d1a880b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls/execution.json",374,"5906b315ff893bb42815adc508f1c8af5f40d5ecb352bdd2ad9b68a0d2db5c8f",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls/summary.json",189338,"860476fcce8135a94aeaa0ebc22747137b8208c25d698509417f2c73ecd017d9",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part000/execution.json",830,"c28720be64fd5a29da4a40e6e54706c11f618baeed733df4717d56eee9bddce4",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part000/stdout.log",35379,"0b7827faed21e2c437aac04c963a1816a92cb7ba84efb1445e6d1be230443e8c",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part000/summary.json",46465,"d78a53dd00b3d7d9ff7c6499beed0e89f4e012ad6b2d03f50d9bd298976d49d7",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part064/execution.json",867,"4294072810f410c3596081d971a6f2cfb61b05b8ca4ddfd685421e346976a786",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part064/stdout.log",35403,"27e04b2bddfda24c148632354476822592f918d3afc43f05708a40573bb48815",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part064/summary.json",46528,"b011f05e1f6af85330db5e38c6dc2d6a96fe37735ffac171cae8f82228ebeafb",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part128/execution.json",904,"287084113ae070309ada853313cd21b455ec7dfd75b2c6bd47f756f5610af847",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part128/stdout.log",35365,"fac0fe4b6beacd96ed61f37ff3e1dd300a7df9e67cc3014078ffa7f57a272b84",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part128/summary.json",46525,"00d6cf9b4354288c6771a282f19c0306ca2e4d9d41c97de519debd905875f41b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part192/execution.json",904,"109dcc44a80630221f5bd8c14b674745d74b0c9c1f1816f9fb10181fa247afc8",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part192/stdout.log",35380,"fb7a80aa95877cf09059c38305d15e2c54a6cd6898c035eb1516fea38c0a34f6",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls__part192/summary.json",46542,"7c97fcba99114084b4cad69922c0dc537ee365942e4daa0f60d3d0daf3af1477",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls_batch/execution.json",683,"c08eb49e76147fcd326285783b0f5b85a4f1dea72111bdf7e128c6695436d369",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls_batch/stdout.log",140964,"beb31a74c384c222f6b2bff701df191fca6a03f69bcad233792111322ed18525",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_gtls_batch/summary.json",177883,"493c6d367025f34f780d13cd1797efd2fa3923620540dc47a18706e06ae6d02c",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_tls_v1/execution.json",612,"75a274f115c8af846e5d83c46b99f59644df560782b297f247d3ad06a2e5c56f",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_tls_v1/stdout.log",140465,"a2088f0e8ed6b8518d40ced7e960e3569d0020935fef6b01ffc15bf716441359",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_200s_tls_v1/summary.json",182855,"cf78fb01bc68216ee199fa5ab74c8a9e3ca7a416fc3b61535b28024607a3b1bf",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_cpu/execution.json",617,"89153100b56dbb4f2997fb625fa2a38ee1e865960b3ef5b7b14b61414cd0d6ff",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_cpu/stdout.log",126232,"217efc47d4078b87015dd31272d45a881b381261da9fc2c727930bc58c84963e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_cpu/summary.json",160815,"d7583bac907c18f54cda0cd6877285177a88c6e507557393fb9b4d1951318f60",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_gpu/execution.json",598,"b2ec00846235b310a5cf8a58973b2e9a39985c3cf6ddf25c6ed5f0d861d067e8",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_gpu/stdout.log",126498,"d078c41f9f15c88a477005f47ab7873911c15392e68dc0293b8b31b81ab6905b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_gpu/summary.json",161063,"87523093a68d6b8c3ddea7b9a018c0dd4735a5eee01c976cad5dddfe27dcb0f6",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_pypi/execution.json",670,"9cdd465ab756ca3d70f544a0cabf40b5d86ae3a6a1a546742c97d7e931660197",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_pypi/stdout.log",126941,"9a5413e51ee29d7d2803772294b0df65916a09dfdb54de528c2bad00b81bf316",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_pypi/summary.json",160879,"30937108165e88922c46644414cf4259e3e2f28c278fbddc8deaac81dd52443a",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1/execution.json",658,"2231290505581a5cc7a6988027e06792a04bfd7aede89fed7802fedb77d31d42",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1/stdout.log",126688,"e02195a67b923f33ecb0eccad5bbd6814f1bc9e64fb165d63959fcfca8895ded",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1/summary.json",165989,"b653f06e092bbb879fa338d7e8995982ad15c4d10df4f9c169e7b49dcc4d1e73",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1_batch/execution.json",738,"5740af0a8eb9a2c745e399d5f6876e41ca0eb5d47c980e057c8e568a246f1bc8",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1_batch/stdout.log",126987,"1bc4df104b11cd6f8c261e5466d07dd978653e35dd5a9fc959043f763a0b0ccf",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_bls_v1_batch/summary.json",166361,"1697231b50185dfbe008af3cecb44feecb58edd9dba7542084cd791062c133d2",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls/execution.json",369,"5b8d1b6cf398eb7f736e6743af18fe2630dc57c8e0061d3f462dcc0d9e000a3b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls/summary.json",189083,"5f2e833328c2f94ad43e5407554aadf276140dd411250feb0fbd46484549a569",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part000/execution.json",827,"737635e2c5c5f40444f32544438d9c78344ecd8a60613f8d4126ffddd1428b99",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part000/stdout.log",35377,"e974007b29b64714cd6852b4ca18fb06231c45e91ccdfeb62b7ea9875c06f89b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part000/summary.json",46463,"fe86aac71cb7e2a1e1144baea50910972f682ed618db6cffbdde066b22ad76b2",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part064/execution.json",866,"80137c1d9504191284145055e9726cb449ee12605ebcc9da0a5680a35e87faf5",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part064/stdout.log",35411,"f10bc4b45509010cd07fa65104eedddb2a343069644c5d56b80c6296363bf5a1",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part064/summary.json",46534,"8e2e4e9f5f4aeb83cb4bcb3a57ec4f77941cfd4d21e72bf8d526a383b0e950bd",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part128/execution.json",901,"2765e78fb6be00af07363b0f202072543654bd34dfeda392fb1d1d647ffbfd5e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part128/stdout.log",35364,"2b30d5f4301074c435830fcfa6d0717f974991d79afc4f2e49855a1aa138c003",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part128/summary.json",46521,"8aee2b1be6a9fa47df7976f147d12cd82a23ba7f1d3941f68aaad8912a00e5fb",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part192/execution.json",901,"dba38927b27764dcd96740068506e6a6ad9df5cd852bab75d2dce0785100afa9",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part192/stdout.log",35380,"b0388df489b8643eb67f878b1900525d966f2f2e8b08c0770df689c007856414",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls__part192/summary.json",46537,"93c537d6958a003fc67054aed26877459021c9a5e58d82db64d151a3404d26e9",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls_batch/execution.json",679,"b1ba5ae3ead3a8acea4b3f532df8c9827293eb8c433a9c21499fdb603472f8ee",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls_batch/stdout.log",141449,"e7717fbd618cfb095cb51d3fb30dfa52145c281a0e4dddb3093886e8eda64c04",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_gtls_batch/summary.json",177789,"6726baf69fe31d5b2d44bc14718014f65c25b986dc9f5d61796f7eb9e2d4f69e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_tls_v1/execution.json",611,"94b2125fcf3db351cc6a652e47a3d0603ee98e7c103828ed0b0046fbee92a56d",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_tls_v1/stdout.log",140642,"aefa77c9803b46105b9c258b7dc1701edb62d8da0634d8ba2a7fb4577bec65bc",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_tess_gap_tls_v1/summary.json",183030,"d5aba2a38b65d28ddeb0acb1d6425dd149661fb0fd9bf1fcd374f4a40c496cb1",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_cpu/execution.json",603,"3576f6388280ea92fa787421e7650fa47839b69f6f6b025ced69895e811be3ae",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_cpu/stdout.log",126366,"d2eec5b40f289deb9a54036f4e6516bf927d5d2e943f3172eb02d50c0a891ea4",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_cpu/summary.json",160939,"19e62d11d3a3362baaebb6b0de36eeb22e40c90a9c2b8c6c864801907d6ec319",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_gpu/execution.json",587,"2ddd9b5696ce16bcca5588b0147a7d14c962ad047e11c4a889dd1ae25703dab9",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_gpu/stdout.log",126632,"b628cd8cc4bfb7f7792ca5dfd6b27f6bfa56bf6c4278767a51b49cb6419c4729",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_gpu/summary.json",161188,"af2ae8236c5ba4dcbc0bd813b6b235248d8807ed717ccd4923531601c2aa1e8f",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_pypi/execution.json",612,"9e7645802de759b31d54152b959eafff641ae65bc3fe38a974509092986b86ed",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_pypi/stdout.log",126996,"ad7ca86fee81c58f13c4afe432c971644782a0607886fd599614abd164996221",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_pypi/summary.json",160878,"c6318dcb8d4bf0ba6217156fb501168d4ad4521554360ffb216cfb1c53947569",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1/execution.json",601,"47def2b30a0998ad804989f584c3bc1cc77d3302f98d12230a81502bd32eb559",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1/stdout.log",126703,"d40973be557b0ec22644aa9eb3ad946b0c324409d5a8ec7ef360d07204fdf118",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1/summary.json",165948,"f7eda20afafc094e38f63dbc30ea869df8417bdf94f2f07401ae5541413d3ce6",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1_batch/execution.json",682,"79c06da7dbf8a4cc2a05b73eaf5fc1d78ef9de51b01ad38cd36687978d78fcc4",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1_batch/stdout.log",126977,"d37189644aa9ca255c1adca6b99de76c7f1b169497b132634295e25fd6847dd3",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_bls_v1_batch/summary.json",166296,"f9931efcf15c9d80895e4cb7166be04976db6c01ac0a0f35c309db4052594750",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls/execution.json",343,"617b2713539542d6049bb3a31153f4599fc1d9aadc2c5ecea770856a3520a32b",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls/summary.json",188293,"9f9da96fd284b30879b616dcf34bcb18fd54b19d2d093968dbe0cf646d320717",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part000/execution.json",813,"efc0d7f429bd8c4c5ddebbd32ee52d39e29de4738f02409b680bfd543733617e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part000/stdout.log",35514,"ffa1ed1facffd80fa1c59a1b404186cb9d5d9db0fc0e6243bdc917bed36a282f",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part000/summary.json",46590,"e0b7891d3bd4bacbc8cfcc2dfdf2c3e6ea678809e76ac8e62315c0cb0b085d00",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part064/execution.json",851,"dca40cd3a31a77a38bb07e7f2be464790d3ddb6b857553c3c99300ffe80c08c6",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part064/stdout.log",35537,"c050bd29cab042f11a6fe946e1fc32a6ee33aac3f3779e7a39a28654f11372ed",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part064/summary.json",46651,"348aa0835fe0dd451ab18e40dc0f788b91158375cf3c1d33520e79084c0bad34",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part128/execution.json",885,"aa9c4984659f48604486bfcdd6290f113886d1a1fa33a804d085601b29092adb",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part128/stdout.log",35478,"7e8514f8872f019aa4cd1e777c39f15b8d1251c5cd5fa1d51eaa82657fa18804",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part128/summary.json",46627,"1e76175d7dd94b909c8317ef97e4803c84bc1fc96a770df37520c473a8bd6de1",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part192/execution.json",887,"38f036982a5c86cca77293e2ca5046cc5d9add64d963abef0e6850e0b82d9021",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part192/stdout.log",35523,"88444a104bd4af67a5cd182f82ea67228e4457ab152e4c87e7ee4bd9d29c333c",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls__part192/summary.json",46670,"a1298b0b2b414e2dee38996e9779d8e05c86c17cb6d4a1fab7a8e49ee21f632a",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls_batch/execution.json",665,"4a3407ad62e5bfb3cc14d6a9fc38bde9d9f4d7fa1ad3321371d6345212f4c4c3",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls_batch/stdout.log",142404,"e59d855d7bcc4904008129ab3bfcad74722805b348f794cdef0a78aa196789f7",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_gtls_batch/summary.json",178155,"d534f37fc186ec55f8b4e58c4b026fcf25405ffa94d4df5d748ac09c29e858fa",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_tls_v1/execution.json",594,"abb7df19ca4c00602ea767a66c536f3137bab8d610afb4e20fc6a965fcbfac5a",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_tls_v1/stdout.log",140857,"a71058ed0c6f520a981f17fcdee16274f76a97547518031c7e2f8834b5aa140e",false], + ["benchmarks/results/transit_2026-09-08/results/heldout_ztf_tls_v1/summary.json",183237,"ada908761d837e3c44de40d3357c7ddd9ac1a490c36290bea1985b6defbc9ecf",false], + ["benchmarks/results/transit_2026-09-08/results/legacy-freeze.txt",395,"243424c7d89978b3e44beca59324a50cc3d0559f91505794eafd5fdaf2f891c6",false], + ["benchmarks/results/transit_2026-09-08/results/legacy-install.json",125091,"185d662bfc5143c3228f12b12f110727bf148cf55d87e697a66a88b7f74489d5",false], + ["benchmarks/results/transit_2026-09-08/results/modern-freeze.txt",1272,"6eedb65e3495cc78e7dfa2a2e22ab8e97af35e92a48e5df34b361d8bee958c4d",false], + ["benchmarks/results/transit_2026-09-08/results/periodfind-build-adjustment.json",273,"d8fee018a196cd17c6400881b148d65da7c7e8b66cb0f7c43e1ff0f10676c127",false], + ["benchmarks/results/transit_2026-09-08/results/periodfind-cpu-install.json",10270,"5dc6b885492719e8536cfeae117684c4f5cf1ccdb65f714449523fb1e78c417b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy10_w7/execution.json",618,"b4601c30b145e251ff8973c3a4789c5bb8ab72245561e9bdbf9f16bfbb48797c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy10_w7/stdout.log",446,"c91569d3f0ec7c6ed8a2cad65ee25d4a13940058ed309050db6d7ebcba059b78",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy10_w7/summary.json",1581,"0c47fb71c552d9d887b8b12565e1985f7ea257d11fa2cef972db4994caf24546",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy3_w7/execution.json",615,"c714192ae3f9aa8e9fb7e11eed3f18e92f49cb55bf536ea82a617294afd82cf7",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy3_w7/stdout.log",446,"b143044e3146856fd5b19e4e574d034c26ae4748aa2c62eda723cb48e21a915c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_astropy3_w7/summary.json",1578,"7607e47feca922135f1c323791b57d0072f74a3195cd350b3096f347ae36e7d2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_fbls2/execution.json",600,"ed935ee440db4b661880230f06848de12ed3d81fcbfa90d9c6638d2f0f51d811",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_fbls2/stdout.log",11715,"b6ced051685610e494f514e9170b25fb9f3e91b17acc9752efdc7b99b3902217",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_fbls2/summary.json",1577,"48f14b3e04469a5d85f5383592f27619988dec5abc84f3804f0b31288d40164b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_gtls_pypi/execution.json",622,"fef07de8dfdc3dec2c9baae0f1d7321a0aef107b2937b14d82b65511dacb32fc",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_gtls_pypi/stdout.log",1135,"aff57f297f8761ea771cea1d5e41ba268b26f91407d4a9629ea46bd6bc493d4f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_gtls_pypi/summary.json",1665,"9d862a020ef444a739618c99b8184d55a5db5715e273b01e1e7d6519c659a9ae",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu1/execution.json",600,"52e5f087eb51090b48554312ab0228aa8ff2f41a694b84231e0e27f534baad1e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu1/stdout.log",79,"fa7e6d49a4dc3770781b022cbfd98af0dd0aee3d3ca6ea5d8794aad00b9c06c2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu1/summary.json",917,"f1efb02135f6cb9fdb24e4e6d8b4f95782d85f2c84daf1aa35eb32b3a8c8eb03",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu2/execution.json",600,"dc88709e72bf79d92e95b3a504132ed8b26fa82e57ad3198305369de63f9aa0b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu2/stdout.log",79,"fa7e6d49a4dc3770781b022cbfd98af0dd0aee3d3ca6ea5d8794aad00b9c06c2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pf_gpu2/summary.json",917,"41cc1a76d718aef60b3704d1949bed47bedd11aebb4c54fab33ba8018c9abcf8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pypi_bls4/execution.json",601,"3e7afdcbb6f8d1fd2c766a743b6c729e199d864b0d5b8d941e245d85ceca63a8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pypi_bls4/stdout.log",799,"e5eac41771787ede202e0213bf54bccb27401eb432b0813cfa8da62df5f1ac33",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_pypi_bls4/summary.json",3425,"fe1f2892d6a0b00fcf05f824df445f01c69277073eb3eb1aca1ace29e9cf455e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_bls_batch4/execution.json",607,"58973101d51af066b68c11f1400a4b0d057c0c35f003949aa4c67cab1fc877e1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_bls_batch4/stdout.log",447,"0c87e8e34dd38d814dbdbb380fdfa48a5ef41512fed2b0833dc5821c4cf835d6",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_bls_batch4/summary.json",8329,"94eacf4fcc580c02a30eec123755d6c42d595d525c26e21f5e871330938a12a2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls4/execution.json",608,"8b38d6bc2c67b904b281f0980eb3faf96609c9197dec3fac7e74845a4f86226f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls4/stdout.log",476,"a927d445f6dab5666675a8a2d6bf434e511c538fb949fcb20c0170e44eac44fd",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls4/summary.json",8377,"d95210d7ff6695f78afb70756285dc24b825734281e93f4bd940c5da137f60c5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls8/execution.json",608,"3fb2678adbb1e8b6c474bf389abc04a9ce63df151c6bf53ac34f49c9d9d973dd",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls8/stdout.log",475,"972c243d0f2d509bf9c32b78345ed1ec2edc3336bb18d3ba6a9d078ee212a296",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_200s_v1_tls8/summary.json",8375,"ea5bfb1639781acaf1d1e4035407f07a275eba31501622ccf19d45fa34abeca6",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy10_w7/execution.json",614,"d9e1e0e73a115a742031f45363797f11b247f0df78772efb15cae9e980620bfb",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy10_w7/stdout.log",449,"7643e72862b70b3906e3cd40241847e5ad11ecc58a1cb8aa1ca93d7289f1d454",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy10_w7/summary.json",1581,"4f368e9ab49726761cbf43fea7da1910b6eaffedf5148bf33cefb9d81ee7b964",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy3_w7/execution.json",612,"b076bb2508ab353c6a112575b265a628d8e36b533ec298deb384f39a32add733",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy3_w7/stdout.log",448,"582e856fb9ed348f2a5b02ac54ce03b90a6bce734258d490450cd3aac312d72e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_astropy3_w7/summary.json",1579,"de37ab5955e36f28b2d0df120f56cf861cec2a4c09800da3f94537c7b25d1d90",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_fbls2/execution.json",598,"67f099e49faa864a8fa2625936dc949f48b978ff60f2650936e142c0bf5c69c4",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_fbls2/stdout.log",20829,"75eda28e1bf7a8fcee73469b6ec5aa62a15c2695652f6f33814ae3c050f7a3b5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_fbls2/summary.json",1576,"c3bb47d05071832c0ff687e4b6ec3ae3234f6e46e4aafe4f72d0213666305132",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_gtls_pypi/execution.json",619,"6019cfa0d6ee7900677b837473edfdaa83781fb55fc802681dfbbf6174cb2fc1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_gtls_pypi/stdout.log",1372,"736f02afc132301b72e28bc7a9e4aaa8e240a554257d8b34e981d44e3e55efb7",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_gtls_pypi/summary.json",3064,"ff8dfd6348f9dce7d1acc0afacab0650eac0bdfd0038b0146c289226bd8e48a2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu1/execution.json",595,"34eba9ea4572064d20776659008ed6c6f7222c66eb1b479b1ee72ae32aeb856b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu1/stdout.log",452,"1f4d826b9c7f73aa8b2cbebb436bb284332269d5736c0806e20bcb46c1ae204d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu1/summary.json",3664,"2ea8078e5dddef83a2038573a1d3ce8a80231ed0c475cbebd8070a5885ff9563",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu2/execution.json",595,"b075d6f03e97ea6193b37bec773fdbeb35a0db6dd1a8d9efe8014b9bcee608de",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu2/stdout.log",451,"d3f07af2c6e6e0aea65b756edd4e4f85c1699655fad82654dea194acdaca5ea9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pf_gpu2/summary.json",3662,"2e6fb4e6dcf4eb3e76174c37d14fc2ed06fcb3ae9e646ec1eff515aea7a27237",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pypi_bls4/execution.json",596,"92102ad5cd6326454d089c6303579431f0df71986839558e4fff9e31352c4f2f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pypi_bls4/stdout.log",801,"6cbfbbba9f0158a8fd84d98af15d30a1454c817c449fb0bd79d08dfa7612bfd4",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_pypi_bls4/summary.json",3425,"7b9bea9d8a14a416d3bbea682f41d2de01dd1148360af1f9b51212a582dd91f5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_bls_batch4/execution.json",605,"a712ed1ec8e037bd87f32b1f2e5275ea0d8b56c57a5af9696bdec76fbcb27e37",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_bls_batch4/stdout.log",452,"905b1ebc15268fcc0d68125426d6691b8c757982df7ce53774418e94ce55a171",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_bls_batch4/summary.json",8332,"70d52f745978eaea9e155aea925ad51d6602782b30d7137fb36f58fd03b496eb",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_tls4/execution.json",603,"4ba7d612baafd7b644b9245319298f4273455f8d29dbf84f2216faf2c8883515",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_tls4/stdout.log",479,"02371aaf6f6e235f802049048264626bf983ad546d731df2b42e945d604d00a8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_tess_gap_v1_tls4/summary.json",8380,"2724e31b4512b51d3639b4b2823beac62f9ebf2ce63fee1b2444c2ebf2b59141",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy10_w7/execution.json",599,"47a379022aa79000c5737d7e61860e5c945790d7a53c2792cc4f57d574e34ac9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy10_w7/stdout.log",450,"a438085c35550f5bb7299ce869622cba7a16153cd95497bc6c0226e4f5427312",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy10_w7/summary.json",1570,"1b9eab6efb390a6a731d7ebe689e00ebe447d0e4ea79c83c5434b4defc0baec8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy3_w7/execution.json",595,"99a61d2cc7ea5afc099bcab5343a3b2c554b9eb2f687f8073fbc22c1bd2d8226",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy3_w7/stdout.log",450,"18674abef6711819d7ec0344a3e9de864d4939f3747fd4a17945c83e474eb7d6",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_astropy3_w7/summary.json",1570,"9062f80c2b4001df36a199135ef5bfd6664a29b52e53b72ad2bcb0286db27416",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_fbls2/execution.json",585,"24ef1f8cef57aea79439ef84eefb2960b3a64491d893c4597756dc881756b9e5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_fbls2/stdout.log",19658,"4e5e3a8431554d17a424fc718b5a32dfafe82617d10079bd2bcd82fa5be11c44",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_fbls2/summary.json",1021,"6454a22d3673c7e6e7941fd613ea6e0a088ae5b0ff34e88d3ba5a55cbc7b4686",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_gtls_pypi/execution.json",603,"385e5c9ef5e92f3c15145e39bd1d34aaf7830161d0579a89aef6f0cd2bc4f8aa",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_gtls_pypi/stdout.log",1373,"4f7c6b349d2f5414269e6a695dfb7feb377a46184c50b8f614f1ea813c698df1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_gtls_pypi/summary.json",3056,"1deab89025a10df9147b224e161d44ce025e094dc02c425d0637e4488a05ce5b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu1/execution.json",579,"c926a9adc87a5e748ff82a7212871640434722e0fd7081edaffdad7c5e7400a1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu1/stdout.log",451,"a79339c64998052a96d12317800dc66892a7628a1de6dde877f06d2f311892c4",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu1/summary.json",3652,"462943a8414b6347b4762b881e9db8eefa4b3bf41f0fe9f6f733f11239efb5eb",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu2/execution.json",580,"d132ad08b2638d4231f9dc839a1d14358b924b54fcce0bdcab3b0bd600c7ba14",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu2/stdout.log",451,"4dd284a6fce7f236fa15c33b8adc700ba9f548e9e30d2f5853e13a98508d2080",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pf_gpu2/summary.json",3649,"81db4b529216fd4d4b7d113a19bdff0da144fc246ce7e646f815e3ca61a52e67",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pypi_bls4/execution.json",583,"7f158f21508b86ca64432970c19bbfe16df518ca2221a706e39ac5a0e42bc224",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pypi_bls4/stdout.log",799,"6e8978aa070673aa6e45aad64303c2b9fa368fa16c13c5df155c7298f9c877d4",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_pypi_bls4/summary.json",3411,"25902e2ee1433c63bbd5a06f0962c27c414bdd2e7242a90f0948216056854e2d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_batch4/execution.json",590,"c4759caaa4434fc104a5ce1f7175db398ffb7c79198b42b400741fbc6bcfec02",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_batch4/stdout.log",451,"7390c79de84fb888244902f8d8a8205f445168f5ee736e32baf891e24140199a",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_batch4/summary.json",8320,"6198310fb020c1a4ce1e2538af96d7e67ceee38e9c74d31155c04145662c2daf",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_sparse/execution.json",574,"c1c7e6444512c8d57402c11745cb9f1f92f2ddd8e9f70cc1b408d0526fc9a154",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_sparse/stdout.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_bls_sparse/summary.json",885,"09ee799ec3c4189092069727376727dd7045f3e65061fed989f57052872115bf",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_tls4/execution.json",589,"52299aaf476f94db9bdfdd948d843ec5a4c1658af81d64359ca09bd36f0081bd",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_tls4/stdout.log",479,"bcd07d3a331d137904bf9fd2d10c90af623022c7a1ea84621cc8b102aed9ec21",false], + ["benchmarks/results/transit_2026-09-08/results/pilot2_ztf_v1_tls4/summary.json",8368,"44e14c617e9e91cb8abf970d88d23f1ab2910f4c07c105c34ba8f2a30c0faf7b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy10/execution.json",593,"1b387f2378dd32ae90e897338c350247e55cd2e614195dc779459b366cd4ce38",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy10/stdout.log",445,"7fc81cf993d204d5d28ebe731720de6c14ff9eb267a8a013d6fcdf1ba7183f98",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy10/summary.json",1559,"c833301f4f3727e013050e776f46005b5c477f184a58a35f90ad124bd509901e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy3/execution.json",591,"ff4d8b561f3ddeabebf876a8d350c7950f52e2f5ee16c81cb13a272126dadbb3",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy3/stdout.log",446,"fe741e7a212e012129d5750f35b96dfd2d1b607dcb54c5598f67c4ac1b700df5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_astropy3/summary.json",1561,"9d123760111a9ae1a593e66b2eed5201500098c7511c52df304f79d7aa50f252",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_fbls2/execution.json",600,"79897f2988553c9ca7fe9145097c576a8972756f995e1b784c896f4728c87099",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_fbls2/stdout.log",606,"7eea1394c43bdee2d72612738b07edd4c134b7eaa814fe33abb4ba24f187c1c9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_fbls2/summary.json",1560,"be70a09c4b097864921a008b399f1c068ea6a9865cb483c2004adce7f6b0a279",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_default/execution.json",606,"8cceaf7cbc773017e2d7c0ab3dfddca1b7d4221aba5d13e6ca004ccedd1c31ae",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_default/stdout.log",761,"381ce89307d214bc7b811925ff3f6a29769ad1ac0dad0e172baf6a75f076e714",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_default/summary.json",3129,"8ee0cc5416ed5f7d6b0b2ef04aa7bb90f7419db373da4ef5c9610db0feefdf4a",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_density/execution.json",625,"d2070a44ae4b5fceb422bdd2a36afc24834e5e61501bb97b6ab68f143a06ce7d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_density/stdout.log",761,"63dd7af69324cc3fd90e183d98e467b4529d0aa7a296e62e1d6f59c890b9fc16",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_gtls_density/summary.json",3150,"9816d64024bcbcb6c4bf97db7c50c2572f893e9670a83f89a8062102aee22f77",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_cpu1/execution.json",610,"579e081c01f13e473736ac51b9fce7d7afdc83a231973bc98c4694e95841f985",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_cpu1/stdout.log",446,"8046ca0ef0a765b063ad996d5a383a68dd1478e423bdbf8114cc840d2fc77518",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_cpu1/summary.json",2778,"a89f440cf9a8972b1c5645308b338cc0638af42e6385fa1b8614a4e7de7f32f3",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_gpu1/execution.json",597,"e7ed7668ce1d9a82f7f8de12457314593f9c6a8a23bf40f32870daf37e10a0d8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_gpu1/stdout.log",1422,"562a1b63893056dac391776f595133f2b8553f0ce3f5eb6b2559c7109a647381",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pf_gpu1/summary.json",2390,"c113773e7849dd8da44e0df4d25a024e56b63e598fe36117f46a822afc1d7839",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pypi_bls3/execution.json",597,"67f2cc9d1ea308f3da7ec0bc57a7e4b9f1450d1bad6682445169f548cc22c1b5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pypi_bls3/stdout.log",797,"2536c3264630dc553d4823948c41b6f2cf3f39397427ae97628481502e25cda6",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_pypi_bls3/summary.json",3422,"9f3d213fd94d86f15971e98c0421e23beefdd7feec88e0e59e93b5719393adca",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls3/execution.json",592,"fd15213e537a67f6a1719af4fe379337af230309289333843c96fde26428a9d9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls3/stdout.log",449,"dcf01a7d81c682b5510e9d411add1d5aab48eda6a0595ff88e20d174209cd76c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls3/summary.json",8327,"9a5c895f5d1368763176b816c973117f4c630e21a8a0512457d7b2839b99cf2d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls4/execution.json",593,"1e9f71e3d7ffb5736b8c971cdad7438c32d37831fc52de23e25d2190aaf51255",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls4/stdout.log",447,"dbb98dcc32590dd9a522f24789a4284e8050334ccd07d887856ddacad22389a2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls4/summary.json",8328,"e3174268d0eda8f95bca79d7de986c0c97715e6293d0694ff2871c705dc04054",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls_opt4/execution.json",621,"4f9062cd3fac4639af332ce31581c6af379fac98f0cf924bdf70e407de222005",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls_opt4/stdout.log",446,"f5b3b43811ccf35b5d3a3203e6afa9ddea6ef3d219cc6d0afdc7bdc7167a141d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_bls_opt4/summary.json",8350,"e27cf240082eac185bef1a568abacb6d702a63ce59ac93a4ee44cd7f776134b8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_tls8/execution.json",606,"945c305b8e189cf8767b604d14f9ae874ae4683c368d16b28e824b766686831a",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_tls8/stdout.log",557,"ec246450fe7f9863f5a9b23365a25f470aa5f41d2eff3c4fd3965c9fe7dfeab8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_200s_v1_tls8/summary.json",1553,"c583a1182411e7767c7b2a0d3fa57e646f0ea068626cd49a1591f985c9000ecc",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy10/execution.json",590,"f0a5d65bada19e47513d1a8857fe3e73cfc771ef6357a9230c759ef209e3768c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy10/stdout.log",450,"a9180d1389e81d8b4ef96c712f785134114d6e7a49233857c14b20a17be6c20b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy10/summary.json",1564,"5c277839a193506aed9c05e0d902b398f24ad2eebeb96cb85a610c02d9c3ae3e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy3/execution.json",588,"2969625b0ad20588c88089635589e505a436b311ebf4c6acf0b2cb2f49a3a1c7",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy3/stdout.log",447,"834967cea99b78c90b5e807ecdfccc31d8d7aa75ae56ada732879b56d462a735",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_astropy3/summary.json",1559,"b9d486f657fe1e5f1470967beac17988cbfc2795a6ba1b48986b0b72d9c2f98f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_fbls2/execution.json",597,"dc7472ca048b5bee1afb57deda0afb864260482cf52977f4f4ccf23abf142cd2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_fbls2/stdout.log",606,"7eea1394c43bdee2d72612738b07edd4c134b7eaa814fe33abb4ba24f187c1c9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_fbls2/summary.json",1558,"050a85e08a881f114f18e93b3f276ed368e6808b5cc2bc1cfc703720a88fe272",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_default/execution.json",604,"ad69ecc6d5945497e2d72211ad55165e19579427c9d43edcbd43ab3521839858",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_default/stdout.log",1353,"4eb2c8856d6258f89553a82e8c77abdc6e8009c7783ee199d8e07248249a5627",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_default/summary.json",3129,"b2ce0e5a27cc2ece20b4817a0539e0bd344c3b899ef8e54f88e298176d0acc13",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_density/execution.json",620,"319b59605396cfc3500df5db4b4280803abde0733cd612988572b294bcdf63fc",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_density/stdout.log",1353,"8334b5a56b05a63fb0685da140b15e52328b04979ef092740393b2d82871680b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_gtls_density/summary.json",3151,"107358cf30de90090747a9018bc04ba7c3dc0d99ad58b31fd8dc8f6116f9a4a1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_cpu1/execution.json",609,"440060c5e4cc1aa72edb158b479cbaac96075817ba8226777ee248271c15b568",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_cpu1/stdout.log",450,"b0fe556409144158c0f00b7dd2604451794b1397aae4257c35ce487370be1ddb",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_cpu1/summary.json",2779,"d7cb265bf4bd5ddc8c9e0cbc67a0273f0cefde571ca5d7feb198efcf72ddc94b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_gpu1/execution.json",592,"a62e43f5d2fc657f627b40946e329d695b938f7c0bf4fa19d2cedee8b97cd2d7",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_gpu1/stdout.log",1422,"562a1b63893056dac391776f595133f2b8553f0ce3f5eb6b2559c7109a647381",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pf_gpu1/summary.json",2388,"ba3493ae87702d2d108118145b6e301bc71d9a1b752483485cbd894cabe48b74",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pypi_bls3/execution.json",593,"b5e9f89a386c589943c6d7fe53481fc7d126e76e7e89f6d344f952b679732711",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pypi_bls3/stdout.log",801,"18ff42665206dc9c79a338933d5261784495cf0ffd03ebc5eb9bf56dfcd1d27a",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_pypi_bls3/summary.json",3424,"d20d6994e7bc4fd926bc2bea8ee128e5a5d3ac76f2d32d99e823f93eee41a60c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls3/execution.json",589,"7b693a99fe1a18b4ea9cbe918f7d2345f83448af8da11cd1c607c5e79e035bc0",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls3/stdout.log",450,"f62003b452450675df62b647b558771612fc65b8ab410fa52dac1555e0ee1800",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls3/summary.json",8329,"2873a90ea1c67159a41bec4b40dcf7b39e8ed202cdb666bb0aadd88a1d746f94",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls4/execution.json",590,"907e807ee5f58c947e49f3ff51308c2bb18d162236354ae71d0aed0326e58243",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls4/stdout.log",452,"424d4583909ded8eabe5f66ab6754ebcc353d3d38e257ae81f55f437f6ecaee1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls4/summary.json",8331,"7803545309792f00108a3638ad565cd7fa68239a7af5b216501492fb69d0fa92",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls_opt4/execution.json",618,"091048b23d49fc43205ddc4e77643b5aee0ec2d6c7324b4e3c1abf9f3b017e6c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls_opt4/stdout.log",452,"4a54b03a09ca51cffab68eddda6380c1209cb854ac9dedaf7212828038144037",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_bls_opt4/summary.json",8354,"77e00eaad256ae8f237e5ae7b10647462e21776e0a742d149de355793079ede8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_tls8/execution.json",603,"ce80dfaee55c9e9246bf4c437eb7c19e4ee6b23b35f381750ed20bac14a0e217",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_tls8/stdout.log",479,"75c864e8f25922b1e755a1a5920b9def8fc660926a2c04e8cc7f1952a88d4ae5",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_tess_gap_v1_tls8/summary.json",8379,"dbce319a608a79675e02e7f93b0f0dbaf2ad63d6e7668fff102f9369a286da9d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy10/execution.json",574,"588ee7ac652f47015f151ec94195d68d47a180b855e0e13e258fde1bc49a99e1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy10/stdout.log",449,"b7b0bb96137928d6931e67e3c7ac6ac1e75d03ab1d80e674c1a160d8b1a3ccb8",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy10/summary.json",1552,"74d169883338e847bfeac90b621d49aefe99c2a5d9ba92f67bb5d71418814e9c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy3/execution.json",572,"ae7f808d8423deed5298e0a1f9d059402fd6025e37daa1b7606bc15a0b565884",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy3/stdout.log",449,"980f8241513aed915c8ba7323e8b9b2d90d3b219db32c6578ae212a1deb0f148",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_astropy3/summary.json",1548,"46c7366010e4e131550c0d5d48c39dfd2692e6dc75731198c8d89d9b8ef734fe",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_fbls2/execution.json",581,"3972cfe5a1020e768ec47b11297a3c95c81879f6e60c0a58ad355fe434f6c338",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_fbls2/stdout.log",606,"7eea1394c43bdee2d72612738b07edd4c134b7eaa814fe33abb4ba24f187c1c9",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_fbls2/summary.json",1548,"48686c4de269c18431157417a215054d80ba67e25691ee6563efe2933e39e3f2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_default/execution.json",587,"1cacfd797f402d9313e02f1570e2f229cb8983868993eeb4f1d04470402be04f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_default/stdout.log",1355,"42262f6058971bab11e35c8846c0365c31a7858c719efbfc02a218782c83d654",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_default/summary.json",3122,"fc6486e052f84f8c66bcf1768051c3e58affd27cd57f5423a453e3d47b1e1aa0",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_density/execution.json",607,"96170c3c115c5d7b63943d69d1d24e6a24e63c3fa2af1045717d752a1a8c9d9d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_density/stdout.log",1356,"51a78233d169a1b10a6b0b3ee014345ff0f87ab0c2bd1070f8424bb0dbe93a05",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_gtls_density/summary.json",3144,"1eaac52c293152cee8315ef9e1d26fb5d6100afadb3f6bb8c89a28a90304cf81",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_cpu1/execution.json",593,"63d19222a01eebec35ab16f2d6fc6c4864d512fbb486b5c058ce646291faaee1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_cpu1/stdout.log",449,"0852846f54cbdfc265354429301f3aa06ac20cec405a73dc83a0b7762fa9a912",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_cpu1/summary.json",2768,"5528d1497ecf07032c027addb4058f080bc6066f6b5e85fb54db026f3b66211d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_gpu1/execution.json",578,"3b26866bf87262e423ed6cfe5fb465e535d2af59bbc498802a6d0ffaed94804a",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_gpu1/stdout.log",1422,"562a1b63893056dac391776f595133f2b8553f0ce3f5eb6b2559c7109a647381",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pf_gpu1/summary.json",2378,"992a6f98d235ba6f4902938b73463c4e03d50a19567917f45d30534fc5a0440f",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pypi_bls3/execution.json",580,"d3b19718cd20a57b427da0100e31562969b73665a39926fc22c401a61a9da5fe",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pypi_bls3/stdout.log",799,"a3060976dbd9903d1da3df8f809d4cfc9b2c6e2ed83bdd9f06c73461546648ba",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_pypi_bls3/summary.json",3412,"ab0abdf2afd4c4a42d89510c5755a889ce40b4b9e903b85c67c26ea3a59b329c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls3/execution.json",574,"b772aa56abc14b18ff8927ec2d1c3b46d48f2ad62d6cb0df69628317b2a2167b",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls3/stdout.log",451,"79d7eeac80ce183991f443c313a10d7c23812af156823195aa1a5c06f5e22014",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls3/summary.json",8319,"e54d63a4fd8ee78a2914ef383fe0873f264cf24afdb78d24de0f16eb150556e2",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls4/execution.json",574,"04681fdcf4b7b13a1fe3aabea1e5bfee0eeb09478e7bd4c0c95786d4d62404aa",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls4/stdout.log",451,"485a6c39be8eb53a9377e8fd9e47aae16f41a6426e8768bbd96e2d41d19b4c4e",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls4/summary.json",8320,"d1b117581de9914b8cc9f159ae932113e49275831142fa5320c89ba249b01f1d",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls_opt4/execution.json",604,"22e860a0da974abb93b6319228e220153fa8a85a480e43f542ddd8f1bd80b60c",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls_opt4/stdout.log",451,"37bbfbce7783d412d2cde407cab60337aff3ced9eed54213c2e5b0193b47bcec",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_bls_opt4/summary.json",8342,"d8c4bda943c8c8585a9ce8c7e79702a7aa06b667a44c32de7cdf8829d1e05b68",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_tls8/execution.json",588,"bf0bc99f0e9fb06203ebf45d739f903f17efa1bedb7b30ad076c01a3c643f972",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_tls8/stdout.log",478,"8d0f616203f7f7912f3a8cf77fd3e495deaea854714669d9511a3e5260bcf3f1",false], + ["benchmarks/results/transit_2026-09-08/results/pilot_ztf_v1_tls8/summary.json",8368,"832f9d00d327cd81580918bdaf712f2a03442219482801998a968586608130d1",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_200s_gtls/execution.json",701,"be3085f512573c1aa786269f9f51a9779d969210be542fd216c1be2b59be2c02",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_200s_gtls/stdout.log",821,"5dc63057d662a68985bf62033d1037ded6049bd1793fe2ac2e0595450874d2b8",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_200s_gtls/summary.json",4069,"ba332d4120f3aafdc4c9058eb62c94b94ffb04be2965e9193d5c85c9505fa0e6",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_gap_gtls/execution.json",698,"55491c9da6947087aef036a755ffbc438d975c274f19f8e3a1698f0203ba7534",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_gap_gtls/stdout.log",825,"a16dae8230c5993b202c4f6769fce895e20fdb7aeb0a4c5d96c2c6bae0711d3b",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_tess_gap_gtls/summary.json",4070,"0d4f32af5ffb4884a56ff3b64dfa2a7c6563cd96693c1ba9506364bc90fb135f",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_ztf_gtls/execution.json",683,"6982d173dcc0e5c2f78e80b9ea69168789f5f8f0bb452fbc3c0c415f7c60b26b",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_ztf_gtls/stdout.log",825,"e615eca6663b28821d69492b4f0dc2b4830f881904d895e5894b9251c012bb04",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_a_ztf_gtls/summary.json",4062,"9db5b86fbb8d9dc1a390ec1f18b14489156b185a67bddc1655638df1bd4e99b2",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_200s_gtls/execution.json",700,"da1a8e7aa023fe65e035b51313ea93861b23b6591b236c785fe347e685903222",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_200s_gtls/stdout.log",821,"5dc63057d662a68985bf62033d1037ded6049bd1793fe2ac2e0595450874d2b8",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_200s_gtls/summary.json",4069,"96aaa6fd99ced6b1dcece83345c11e9640893eb7ee10e9d9aa472a180d7b7072",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_gap_gtls/execution.json",698,"d382bccd7963fb4549487dccf31d75725bce11fa87ec351072130d13614158b1",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_gap_gtls/stdout.log",825,"a16dae8230c5993b202c4f6769fce895e20fdb7aeb0a4c5d96c2c6bae0711d3b",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_tess_gap_gtls/summary.json",4074,"b8e4f14ac4f2c0607919a373b9fd99926260257dc76801f7d3a8d0b772052741",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_ztf_gtls/execution.json",681,"c900e2aa3d914d6effe7b89f87b4e666a816ade442b193c461c352b4ce868846",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_ztf_gtls/stdout.log",825,"e615eca6663b28821d69492b4f0dc2b4830f881904d895e5894b9251c012bb04",false], + ["benchmarks/results/transit_2026-09-08/results/preflight2_validation_b_ztf_gtls/summary.json",4059,"672e52873f467420b675cc52c4faaef7e373e3b1feee65199fda30ab4e40c129",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_200s_gtls/execution.json",699,"5845e99658832f206a0e1874f05785bfa3a631951bd1b7381bab3df4e1f80465",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_200s_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_200s_gtls/summary.json",1704,"ed1133661ce02906117293e4a5fb7ae1dbd91920c0def3162aff24964798fec7",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_gap_gtls/execution.json",696,"453a8f19b70378813d87a264b2aa30821a66a06384c1e8d7f41fe76a604808ac",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_gap_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_tess_gap_gtls/summary.json",1702,"443c51018857bbaeda82c481f287da071dcf1ad7c772cccc643ae713a22202e0",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_ztf_gtls/execution.json",679,"94f1ba3e3a831752dd38c5a2bccb0eb3a3a3dfc210120bccbbb66ea1faec8735",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_ztf_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_a_ztf_gtls/summary.json",1692,"9a34a4c416aa9ba86bd654c7101272b9b7eb25fe09c17a0b391feaf6e879e310",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_200s_gtls/execution.json",699,"236763200d5e4a32fc71f3d3cf4891572d81ff0066fb099942c4a48e7de23113",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_200s_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_200s_gtls/summary.json",1704,"fdbb6bb8579aa240b7d2bcca45dc6ddb2909a5797de0c6d6c2661d651ecf72e2",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_gap_gtls/execution.json",696,"57951d4fa50b442c3448d138a3ac580822a1d090e9a98006786c51f0518eb22d",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_gap_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_tess_gap_gtls/summary.json",1702,"9c27514dfd45719c01fc5a98379d14e2d675b3e0528a76a5163d7006cf8fd294",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_ztf_gtls/execution.json",681,"9c28cb38c9e396c83417d28b3f147f7c5f71b154497a4a24960c44e4c50a4aa4",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_ztf_gtls/stdout.log",715,"3bfe47bafbd73fe1bc0f17fd759837556addfbc2125243d8a1c09e431390269c",false], + ["benchmarks/results/transit_2026-09-08/results/preflight_validation_b_ztf_gtls/summary.json",1692,"c271f3bba1ecb68e594b6eedb71cad15c78013b88bee3c29a365adca2e930a0d",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_200s_v1_batch_reused/execution.json",774,"49e08625ed44cfd1795e94fcfe9f75c1709739083ad55b35587702cbf447ae7b",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_200s_v1_batch_reused/stdout.log",7632,"2e38c5010dc31ff41cf9a5d8bb1f6ce44ca4ede21d306d1acb03ecdd636a4017",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_200s_v1_batch_reused/summary.json",17770,"8969374c7028eecc672907e78f023d156094e9f38cefb2e153750df5f89a4323",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_gap_v1_batch_reused/execution.json",771,"8529b6fe18f98714b89c74b54f1e561c1d37764c4874f822881dc81c019c86f3",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_gap_v1_batch_reused/stdout.log",7689,"3f06f856804cd365367fe29d63ef3bc95facf9a6e3f87b6874f425302aa4e387",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_tess_gap_v1_batch_reused/summary.json",17817,"57201ff46732ecab20a3fa8e9975db4bb0dec3a54a23e05791af537e2ed6cbc8",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_ztf_v1_batch_reused/execution.json",736,"f742d9161cabcff457e591162d131fed99c0f954cc0d9bbc86bc93e28feacccb",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_ztf_v1_batch_reused/stdout.log",7676,"db224da06f9c895a31b59e528e60d5870804dba05cbd095d7597427aa6cd0c7f",false], + ["benchmarks/results/transit_2026-09-08/results/probe2_ztf_v1_batch_reused/summary.json",17772,"6ec0c2d43d30cc972ca83afe1c4da6d2fdc6c88382da3874c63588db3815cb56",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_fresh/execution.json",771,"02d7e83fb17dd83c37a8fbb2944181ce00285670625fd783bc9921ce021060ee",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_fresh/stdout.log",7633,"fe9dea08e3230899b31efd3d563f6734df006a04199f88db0359d92d996ad45f",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_fresh/summary.json",17773,"d74323337e308ea30212dfc94faebb322f57f9248167ff50055737bad1b821db",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_reused/execution.json",772,"1c1db254a4ed58f83d2c855d30fcab1fbf6e7049925c88150365cc89eff554f3",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_reused/stdout.log",873,"257602c1c40554d29de9935603f2dd340036fb4e51675d9b2816bee2ad4bf4d0",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_200s_v1_batch_reused/summary.json",2233,"6818f857b40f24cecc3385fcabe23a94a024836a07e919a2a11509dada356d0e",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_gtls_fast_w4/execution.json",708,"14d8d46db1639d57c80b569543a6b58f6dc4f267a89842ede060f14716ad8c37",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_gtls_fast_w4/stdout.log",18295,"9dbad68441c0ed8f5d74425d5ed858ff7af8ee7c869c4d0fedb5891dbee325df",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_gtls_fast_w4/summary.json",8218,"de402be514402499244fc3b8cfea8b0f544126dbbdde707dca8f7e4211681710",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_fresh/execution.json",768,"8eb4a41b9a462432b3873067ad53061d0aa0e0676d8778d9617af9dece020f8b",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_fresh/stdout.log",7689,"965c54998e6bc1146d0f4449b78897f2c4fd19adb063379c888071585e323b58",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_fresh/summary.json",17818,"9231df5334ca7d4a5c6ad880e79961649305ff9ae08cca1858c2434786299d63",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_reused/execution.json",770,"143c14706d1d11f04e24c3f1762121caaa781e5bdc5cdbc4b3174599dc120d17",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_reused/stdout.log",873,"257602c1c40554d29de9935603f2dd340036fb4e51675d9b2816bee2ad4bf4d0",false], + ["benchmarks/results/transit_2026-09-08/results/probe_tess_gap_v1_batch_reused/summary.json",2230,"797c1686731edb8b15ec5acaaca1c5e5c969ea8d3e928e7171a267cfc726ca51",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w2/execution.json",684,"4e41b2dc354ead572247ab835662363339e873248a2ae7d5f4b262b7c8ef998a",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w2/stdout.log",3012,"371159f611336cfc53e297f269d127e935a09007b8270919f9a07c3d77b7d544",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w2/summary.json",5543,"987f7517e488c00e5dda82e98bf665f64c30aa5c8f6a3184e55621660114c195",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w4/execution.json",692,"99b356809b23b83e82ad976d72414b65c4198d0fd39b37857a1b8c0b5b08a66a",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w4/stdout.log",5734,"740873be7897a9cdfbf6b26c7a96b731ae6e8fb6c160502a9424cc8f92cad44a",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_gtls_fast_w4/summary.json",8214,"a275111dcd5e0767e4abff8a5bb5a9c1125b0af291a7accef41882807609ab49",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_fresh/execution.json",731,"eef63150fa4a51b81da1b10ce12d3ae1769ae182ab4baf702b4d3587f3b5d816",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_fresh/stdout.log",7681,"e742964dd0515ba4414167737be2ab567008bd7c3efd5a095499a08db9a99b0f",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_fresh/summary.json",17776,"1b64610b3980b2d1f91935d7178c78198ca21b42a9f571350a7d5bcbc0a6abf8",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_reused/execution.json",733,"0e9468de08d62e76c9be8a5458e5d87a06d6adf2f5dc6c7e891a524f53982e04",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_reused/stdout.log",873,"257602c1c40554d29de9935603f2dd340036fb4e51675d9b2816bee2ad4bf4d0",false], + ["benchmarks/results/transit_2026-09-08/results/probe_ztf_v1_batch_reused/summary.json",2198,"3e2d0689ed635057db367cadb297112353a9579b7eefdc1f1c5357a0c77f8add",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers1/execution.json",690,"ef6d9672dcd57c2211e412fe669ca7627eb91f0845c93ef213c543d9ea6ea581",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers1/stdout.log",3808,"63391a6ad590eea86b2d93b60278254ac4bbab10fe703c1b45ace75cfa53eae1",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers1/summary.json",6123,"b8b789bf727132446d7101530b94902936340574818e25dc46a7cf94303cf791",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers2/execution.json",691,"2f8a7f4ff5c1f8e94d2931aea889a57b8609dadee3d99fa8037f61e1079c197b",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers2/stdout.log",3808,"63391a6ad590eea86b2d93b60278254ac4bbab10fe703c1b45ace75cfa53eae1",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_astropy_workers2/summary.json",6126,"0147849ee08487dd47b2ad4782212cc133d830ab23ad0aae16667e9c5b06f9d2",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads1/execution.json",713,"edcea0cbe0bc61b588fbde6d4ba97c3d6721276faaa48f5f38dbd138cd9325ca",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads1/stdout.log",3807,"3c4b582d32ea6cbceb541e3190c7dbc04eb67340c5f33e647115da13a18b9c4e",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads1/summary.json",8220,"a1e8b74a0ff77700307354164035824efb6d15b45a3330d740490029c2e634b8",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads2/execution.json",711,"d796594b5f49444c014d1a9b116ec918bb8ed4822ea54e1f697cefd5baa43fec",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads2/stdout.log",3807,"3c4b582d32ea6cbceb541e3190c7dbc04eb67340c5f33e647115da13a18b9c4e",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_periodfind_cpu_threads2/summary.json",8222,"b93ee94f0d003dab6682283359de0368591f73b951c12d5ca96ce59d56d7989f",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block128/execution.json",743,"49b4fe4c45ecd12bfe94ffb12d46b3769b584ca96583783cf2af760518c766ad",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block128/stdout.log",4155,"fbb73996d246adb2e4cdcb9dbefe88bd9791ef73b56ac6235faa5b1040226691",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block128/summary.json",7930,"9144fc2335e99ff51f9e1a27fa9249ff1f1a86215c29124471aca28a89e9b28a",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block64/execution.json",740,"b4812ff602354000c2d4bb344d90ff4f87b3db17b3b1799c7be01a12df1ee7a7",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block64/stdout.log",4156,"59ce378c06c173d1a693f6dc7428ab951d351e7eefd629a7ea12f22a82bbda99",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_block64/summary.json",7933,"a5e659dc798bdaa6a0839e46af8e19413d71e605c9d03cb9ef01b7aafd8584bb",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_bls_dense4/execution.json",726,"25188fc7be3a0054196fba7020343ff789fc2946bd0a8495361d73d80dd6f9c0",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_bls_dense4/stdout.log",4157,"62330b751c4c3a9014b2545cd6725f5350be1f49008c359ca53d3f41e3eaeb37",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_pypi_bls_dense4/summary.json",7910,"ca88d0b32e7e3068a12a53c25c05c3e8b817a5711708a649f53c0799e50d36d8",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_block128/execution.json",732,"92d90cdfdbfc1fbcc4808672f7009dc3720290ecbae6f8bd9d5cb4d4913ece3a",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_block128/stdout.log",3806,"20d30c5a9a5bf8ded86dbb080bba031e60c35a1e31d44a80556f84e49c372e42",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_block128/summary.json",12947,"deab670c00b1f1fef5e499074a8082c3226e0c0bc840af7bd438b757b9f2ab98",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_bls_dense4/execution.json",713,"d68d83f0d1d20cf31110289d2b2df4e5a344bb5bb73b3ebc2bff9da3f50457d7",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_bls_dense4/stdout.log",3806,"4a179191e242b3d15fbb80b6ec33b07a165807aa1f775e2873cdd06cb629c8ed",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_200s_v1_bls_dense4/summary.json",12925,"e385ffef8d12452fbdc2345e8acb05d18c6dccf3962292ce449242be83ca6c81",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block128/execution.json",738,"86ff1348b74e61f1c7749c24382e37452b75f4bb9094736acfa287300afb32b5",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block128/stdout.log",4184,"93a06becb424520f628b13a7aecc16302d452cea7d826c4e768504f9c3725111",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block128/summary.json",7957,"db1fe2955cf873898484ae380fac708a25fb0475f6b4a3fd43cce88a64d37d29",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block64/execution.json",734,"9afb2ad343486008339ee3b351f16accf348445fc5081124c0a5f5699ca646af",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block64/stdout.log",4178,"53e33e905d91462c6b924743c9dc3645e89b2e4b86db789f66e5d3e0fc57eb89",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_block64/summary.json",7950,"1d80f71f869188fa529adbc1e4c0c1c49fa880d7930249b39ac03da6c746bb40",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_bls_dense4/execution.json",723,"75c1e43af59a7d65622e06e895e5c0088ad5d137fb1e8c961951141e75583a2e",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_bls_dense4/stdout.log",4180,"66fe416f295f22cb1b64a301cb7033b6643a79c9dc41336505886674dc9faca8",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_tess_gap_pypi_bls_dense4/summary.json",7928,"e7491bfd6c1b19f764749ab21b544dcdb22d098f2a5097d0ee9944a0647a72f9",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_gtls_fast/execution.json",672,"63c754df1047e9f5da4d8a63e8a3abd3165f09eb6eb699024bcb18c2f5c68872",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_gtls_fast/stdout.log",825,"e615eca6663b28821d69492b4f0dc2b4830f881904d895e5894b9251c012bb04",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_gtls_fast/summary.json",3566,"2039853d4fbbabdcfcebfd2ae38e44f2602261bff1bf12c665e7118c59a2fb6f",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block128/execution.json",703,"8136ffc47096bf70dec2365995294fd4f851504623fd9e0324a0b57c7af62c9c",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block128/stdout.log",4178,"65a032f334bafa5eb794c2c078dece2dc732a1460c7c1697eab880ddffd6b6a0",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block128/summary.json",7915,"73ca5bc018a06342373b69dac42f30edc33dc83e29049e346cbd6a3a1f05b90c",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block64/execution.json",701,"4d26f47ff5eaf700f72a3feaac0f35303a48408386be0eeeb0ca7b0dd0f5805d",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block64/stdout.log",4180,"8061723961d9a7a01da07cfcbbf867590210af48a54ba17218ec70bc96d9851b",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_pypi_block64/summary.json",7916,"cfcab231cd834af6dbf833d97e35eb105671e26aba28876c27c436143c6b69f6",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block128/execution.json",692,"98b251e12e72f8dfb601d9d2f5e01df96b546be25e7ee78b047a62e270e50176",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block128/stdout.log",3829,"5fa008a6a7af41e9170b4bd95707e0239980dcf5059f5dc00fcdee0ed6602af4",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block128/summary.json",12930,"fd2900f07a0df331fe70f969d3cd0f87bccb75aac3a7ac851268ca5b0bb56848",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block64/execution.json",690,"c7c2669b7d9dbcf0c4e24ac742951a4488c6107f74907fb312d8bb16a30a486e",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block64/stdout.log",3828,"2db1ee9da20c13e83533710de0910a9281c44814f331bae1ef0f2bd7f275e434",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_screen_ztf_v1_block64/summary.json",12924,"f82569059a725385e87c3a6b83b7c2734657e4d72b4c3a28cd133c336776c495",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_astropy3_w7/execution.json",677,"57fa0309e9c640b1b5d99cbb43aaa81c580b385adc11dd66f37fb45f339c7f52",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_astropy3_w7/stdout.log",3808,"63391a6ad590eea86b2d93b60278254ac4bbab10fe703c1b45ace75cfa53eae1",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_astropy3_w7/summary.json",6123,"6cf9c0c8d33f59e9ad5c8832aa415dfa9afaf61611bbb5a7d8661a8a736c9e07",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls8/execution.json",678,"3f5c02bb4a1f9f4365f21b7a3804d6243e721d133afb7fa2f9105a1dedaac62b",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls8/stdout.log",3804,"ef78e91130bca1c4a062b40109751cbcb16fdf69f87393b9cd6140837c3f104f",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls8/summary.json",12901,"1a41966e8a3772dc548b105e344ba9af1b47aad0d766906964caa8ed69882656",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls_opt4/execution.json",687,"83195ec631897e3efc5e78184af409a64d87cc695fa62c9f92a78c23217e636f",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls_opt4/stdout.log",3807,"f76a39710ce7e03f4f189e8e8128be1e8b3b35971ca5ce27a5154f0ee4d73f83",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_bls_opt4/summary.json",12907,"66debc3f0ba91a1f834efc6ed8455709dc7a7e9ca856f5f1f6a4dba642ce3842",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls4/execution.json",671,"a3552eea1685339af68c6b6ca0fe8874c6e4b0dab6560bb105f229e38d7147f6",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls4/stdout.log",4250,"f4c37763a5afe6044953e6f3b82d2c8dc6c25e3c987db4ecd1a143a6593a61fe",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls4/summary.json",13435,"6c496b283b28bcdb51908d1c32bf61d479772d646044246e9784dca137916b2b",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls8/execution.json",671,"6e02aa6cb752fa9d2c44108a1548c38f36e98b8428d33b86acbf2909da7ba475",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls8/stdout.log",4249,"e4534dca7023fe93b3d20fb9a0f1e214e029e3b20e75c44d06bcedc255b830ba",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_tess_200s_v1_tls8/summary.json",13435,"be12761023243faa5df71ef4478910e22bd88d486b25042479c4994ec1d99039",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_default/execution.json",639,"3998a2fc4b4b2348af61e9cbd10cbd30915fdb6609c9eff471cc9b15c691dbc2",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_default/stdout.log",1413,"26cec6252ef99ccc215d776f83b64578dff1c391747cd9801fa92829d0f87079",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_default/summary.json",3530,"83c9a770575efcdb9af983597b14cebadbce83f06895f18df16df4d294fade52",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_density/execution.json",659,"f108d1d3a2bd79949a2d138e1f6ec2f55261c2b4963ce9eaaee5bfca30f3dd9d",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_density/stdout.log",1413,"26cec6252ef99ccc215d776f83b64578dff1c391747cd9801fa92829d0f87079",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_gtls_density/summary.json",3550,"0fc8a33bee452332f54c678706a340c0a9c8b21694d5c85bbe5e44c89e219e7c",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual3/execution.json",685,"db94ab8ec26eba00c78f9e6954b8c04edea8e518732bcbdc11737c5645bdecd6",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual3/stdout.log",4177,"e4a72f2185e36c8b1369b023f50e214344d07bd6782c795c27f4e45ddd0dfcc2",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual3/summary.json",7891,"ab870ee8b924e898083af9681a44a3784ba8433691e4e506f6ab711e881514c6",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual4/execution.json",684,"c133e02999513a3895ec9df76c4ecceaf7d24157c615b7357e9eb594f812efb7",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual4/stdout.log",4178,"0962ec6f4a14067238b5a458318ad2fcebd932fae2ed0207a4e24983385d2f0a",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_pypi_bls_manual4/summary.json",7889,"df49ab98899777a741a8a713a39b51487fb6c119bcad77898999c24f9185f64a",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_v1_bls8/execution.json",660,"118d805d2557e3aee621f827a61ddfcd7ba84cf452640756192c0f4923a9f156",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_v1_bls8/stdout.log",3828,"6dc4a27dc2bd327ee2454ba137ed2bbe1cb6142709874071afd03d6ad43192fd",false], + ["benchmarks/results/transit_2026-09-08/results/repeat_tune_ztf_v1_bls8/summary.json",12905,"8412cddb3d5d8f9af4fe070dec9a521e5b893449ce54fefe0e1638b7758971b6",false], + ["benchmarks/results/transit_2026-09-08/results/rustup-init.sh",29915,"7d0ea0f8eba7fa1ebfe998091cd7ec4501e33ec5ca6b884eb4d894d7da5170af",false], + ["benchmarks/results/transit_2026-09-08/results/rustup-init.sha256",98,"d380e1289789f9c1aa12907f264b8f43b98d1f792b91b8fcee44c01159069fda",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy10_w7/execution.json",701,"548aafc890317d1730c5121c629a31e04158ac124a78d0b4ef2cd68a42869108",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy10_w7/stdout.log",15718,"9c3d401d56ac093b28bddf9993e8525a56f8bf1df2403af4c2d4814e7d268609",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy10_w7/summary.json",20607,"b6a694ce272ab8f2e984dbec8c6f5c6d9a6852e566ab03c90dd4fd951cda3b2b",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers1/execution.json",708,"5f984c4b91c36110327866a48fee3b1beef2f50f7ea8e454709b4c885e94d116",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers1/stdout.log",15719,"b34078dd333f7cb5c715f2ae3cf55abaadf4f6e3bd6e460efc65e2b8d720ea8b",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers1/summary.json",20769,"08e7783f3c3c0643c02c7564a93d54385afd3988e2b79d8dce99cbe9d0de91e9",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers2/execution.json",708,"4dbed505cd4ff0f125face058226a685b3dcecc58427827305cdf3dd32ba044d",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers2/stdout.log",15726,"c77e300978e16fc5c8056bcaae3dcbfff4bfef85db9196bb96e2132c101f9a72",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_astropy_workers2/summary.json",20776,"3d0c120bff11fe678f6c59253282586705b88ac926984f854bd9e3a0b0a95df4",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_fbls_native/execution.json",641,"410f198e57cb7330d02854790f92ecd09c9ff42b520794d0747a75bab7d1f758",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_fbls_native/stdout.log",7840,"659a71c3679a9f87b1ed4575b7aaa8b0ecd1e35b000bfff8d5c58862c98c2a4d",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_fbls_native/summary.json",3492,"acbe46333b5f4176881fc0d40bc36f04cfea9adc92ccc550f1b8c89ec2dd2a48",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_gtls_fast/execution.json",721,"2d40b3793feab01f863b1d56acb146d95523daca5ca09368015559154e46e99a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_gtls_fast/stdout.log",17807,"5d1a8fe9d99b11cc5e2b67e04e7739b271f2da75d25ac6b50f440a6997687de4",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_gtls_fast/summary.json",24346,"e01a5c9808d92a4d28f673a8e60c9bde5548ff4c823ade90243658989c426aa3",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads1/execution.json",730,"9a789174f98f576cef824a5137fd14b945543e026a6df0ddcc542ad68087fd6e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads1/stdout.log",15686,"38ffb34c1f579671d1ea5e8848aae13e4ae01a016646e370aa431ef6e6d5ba2d",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads1/summary.json",22835,"4387a6a4b09b7c0d18b1228db5c6eedf4eae07f58d891ebd5e32fbf3f653773b",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads2/execution.json",727,"cffd7b51bc6119687a56e3d2b3d5aedd02956e42589bfdde3d391d056b30c249",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads2/stdout.log",15706,"0fd49b684191f0d0440c4746e66f9e71d7db8f89491c777944bbe2272fd241e3",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_periodfind_cpu_threads2/summary.json",22854,"32ae09968cf4ebca8cfe85e5adacf00519f6d46ddf6acb3449ab68f4fac61205",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pf_gpu64/execution.json",700,"0503e5a61411292d345efe1f638db3ef4db7107c08568f42b8e047e6c794f1d1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pf_gpu64/stdout.log",15744,"132d07cd607bce716a13a982c8c263a4b3a6d04916b512da46c6d5890a0aaf69",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pf_gpu64/summary.json",22733,"72c96e79b57bb854edaf43acce5126bbcc29147473b458cbc578a346617cf4a9",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block128/execution.json",758,"8ad69988d321597567b3558f768e48f0758dd578fd4edc7bb8b104f0ff3cf97e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block128/stdout.log",16108,"88ebd41f196882a6913c7c2adfebe0c5c86168b6c4cb9ca7c95ece419f51462e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block128/summary.json",22611,"12f819c0a917b84d54efa25b18c695f3836a92f6efd1d90bfb9d00877e650484",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block64/execution.json",757,"fdaf5bf7aede1328dcb951419737806350fa30d26e0f4c52656446b47c630bcb",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block64/stdout.log",16109,"5448c7cd8e8fde2212a18d83613f911f806d1350ea889d67aa54a01f4315be4e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_block64/summary.json",22611,"9cb2e1a7ef87990338f8e6b41040d3e0ada987b41fd35748e1682d241bdfef8a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_dense4/execution.json",743,"62cfc9f0987cca7ad8243375f749ef27f3c3002bf0be4e511323c457ba2090e3",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_dense4/stdout.log",16106,"303d5aeebd7fefe3bc6993d32fe16b4c2d486395cc5d15c96fd8ff6d49168d21",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_dense4/summary.json",22535,"cce588e5c5a00b7621a6ed545c4fe3da5e42257885f12f16ac7b82a6ba2eca3e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_manual8/execution.json",721,"14c9ef9da4cc1ecfd6b00e35979620c6d94ad995a5e05d3528dd11d8d0ae0fc1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_manual8/stdout.log",16085,"c60441570cf28b5a5b2891c8db9cfd186cfe397ec10e8b3dd8207f1d217accd5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_pypi_bls_manual8/summary.json",22491,"96dea62a1a4fd7e564d43b150e221e60835373be14d50268b11dae39c687594c",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block128/execution.json",748,"ad91e164b654aeaf5c35742f23ed9509f083d86b9db4077ba39de4a9bac69b8a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block128/stdout.log",15766,"e194cb9a5255806c0fd71eecd32460ea083bfee15121f4a2aa37575a64987a10",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block128/summary.json",27631,"a578fc8a1ca46e9bb22517aa3a2b5a9eebd1390eab92890016ed5522661b6d12",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block64/execution.json",745,"5eef7380e73cc01ff5d19dcd0c2ef089c18dc39dfe509b9bdbd741987fe08afe",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block64/stdout.log",15776,"eacde6e63ed25c0514cde31101813573f141aa0fffd8c18f0f036a2618e214ef",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_block64/summary.json",27639,"d1f4f145a2dbbba23ec865650c999c4f7ca518fd5c24f25985122b05d39774e5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_bls_dense4/execution.json",732,"022fb6b74d5e6baec7eb8ffb36f481aaac4d522a5ef3da30ce528d444eb286ea",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_bls_dense4/stdout.log",15766,"ac7eadb325c899c16e5b7bfcf876cb831b3ecad80fd403c4fde32fc15524fcc9",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_200s_v1_bls_dense4/summary.json",27447,"c585501e15cb2cdaa5de566244ecb50523ee91a5f21bf980911caea179917985",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_astropy10_w7/execution.json",698,"5b454ee0b44f4ab7d3ebdbad465608edb2c4b6cd53fb768517143a85a7003366",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_astropy10_w7/stdout.log",15776,"0e481f8344abc3269faa4ec2477910bfc3856ddf6244e330395d3509df1aa93a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_astropy10_w7/summary.json",20661,"82fe11cc8e75b4687370db8f50d8ad5841d002f937e0a50d69d90813f9443e6c",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_fbls_native/execution.json",638,"fdbb20db0a24ea64935d54d62b190d77ad0aca09938a97e88d20f02feeb3a954",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_fbls_native/stdout.log",2559,"2838054c1fdb31d48a3589902c83398322fcb9aa668587557908d0da8a0ffba4",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_fbls_native/summary.json",1074,"e0bcb60b99c00d679b3f19a2b1500df0dadb22805b4c5adb51f3e584d7a9a38a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast/execution.json",718,"317cefebcda26be277b41e081506a76a636bc8b59f34952be5317d274fc28685",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast/stdout.log",17834,"ad86b97c52c8ab3f70a80bfa38c77008ea162e48423a986b1d3afdd08e3d27fd",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast/summary.json",24370,"981db5f4fa9fbc2c658bef9905e7de4acb255e0ba0454ed2c2917567731a3588",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast_w2/execution.json",701,"7fd6bc8078b50063ebb5323e47bcfc024dcd651235be10d42d147500e90c58e5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast_w2/stdout.log",3009,"a31579bc31d747bc21b118880eec53a93fa151644a933819ea3cd45e36bfeb93",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_fast_w2/summary.json",5550,"f0bcd94c94cfde9ac098a1c73ef349aca2594f26075ecb476b814d6e7b8ef2e1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_w2/execution.json",672,"8bddc3bfbfeac2a2f007fa4621baf1440f593ee57e1a42e9f8f7318466d21d34",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_w2/stdout.log",4178,"defe6804b065f623fcdea9af64e9a736751bfc1aeb53177638cc68cb0fae7d80",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_gtls_w2/summary.json",5525,"32b16cc2611dfd8d8f3419a26e008bf5aa4f583d92ca83aad32f7f40e929deea",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block128/execution.json",756,"37945e4ad9db2648acf1cbdb2c06f58395478eebac26be78227fc9f46f4f3340",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block128/stdout.log",16179,"1697c8e11956df0586b14d96003f57d2c77422318edd22fa9e2b49e83577cc4c",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block128/summary.json",22680,"2e369f45134abc6c3cd91ca5da789825de89336292237dde82591606e4183179",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block64/execution.json",752,"3bc94ccb19dc76c4a95f61c1b8762ec896798ce54ef829602d06e1ccd4106036",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block64/stdout.log",16172,"83aaa921209a82c2f189176f5c2da1faa17be6c3dc00a874b28307ee1aceac82",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_block64/summary.json",22672,"2b3913cefa62bb679ef75e69df58549c7102c99c6e1a16c0e8ada3991d7f1bc1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_dense4/execution.json",739,"5d140c28265b60bbe16b836db2795b2edf7a784be202c9e8a6d68dc5e0b4d30a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_dense4/stdout.log",16173,"b8e6729624a4eb24241ce4b4dcc01fa7a13d2a455e728c9f316c77ec9ced72e9",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_dense4/summary.json",22601,"f16cbe2cc745d476b5b24ba7264d472280764ce6bc299ed1c8f2aba85ac1de93",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_manual8/execution.json",720,"1de6d7b0a35d840573bb3d6385f89cce670031fad0a12279895b48c5d84b04a2",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_manual8/stdout.log",16161,"37d72e3118c6bf64363f36c205ea90dd79127b7f4477097dedfbd175df3e7fbe",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_pypi_bls_manual8/summary.json",22566,"acb6aa108faea8ebfd7d267946b46295b5bbb8d877d36f02f8e3f92c9289b7de",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block128/execution.json",745,"0b4032d78f5332bd54a3d8ca0029bbc4359e4bf1adeaf9ca4e7a2377aacdf8fb",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block128/stdout.log",15832,"9435e3bac8f1a26c53f6fe6a39f7c6d2009ea2348b3f786f1b40d9d24e12b9a5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block128/summary.json",27696,"c0ffd3d52ce66356578bd86c530873dc5da460616d6c156244e920118c867ab7",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block64/execution.json",742,"0e4587892de4efca3aee8445c38a748b125093806beb07ba57bebc369cbeac63",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block64/stdout.log",15840,"1a0e41c5d19adfaa5c377afcc36b47385bf4f6f9289757afcd24f853df63527b",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_block64/summary.json",27702,"bf5afa01a3c98a53595224ffc3b400d00bab5267d46f12047e0b677058fb733f",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_bls_dense4/execution.json",728,"92590199051def596569b48f7b5b4d1047464f002f2dbf6b0b27d39983a034f8",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_bls_dense4/stdout.log",15840,"bea461e5261b8759af8a6faed1ddcd33cc584747ab18af8a3fb7949e88d056b1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_tess_gap_v1_bls_dense4/summary.json",27517,"d0f7a011615e8e6cdf24f57097551174c8aee9265bca95f2f4640cf99a9a3a90",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_astropy10_w7/execution.json",683,"bb8fddae776d71c978a7cb95f92b4bc17863a34e39ab7e8d7932216704a1ee20",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_astropy10_w7/stdout.log",15769,"bd948547e6ea0a05a748f04649fc6cf119650954872de1502a97dedc9156255f",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_astropy10_w7/summary.json",20644,"22246b3b485e6723352a15f1f7eee309593f0efaf311a1519fefb0e5851e20f3",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_fbls_native/execution.json",624,"76c760def771d1a220df5774b8077f9b1168aa9305b9f3d6953596085da3fb3a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_fbls_native/stdout.log",453,"43ccd019fa74564ad3d74702ed2afd94f071e036b6da4643fdbf93122d4a5b4a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_fbls_native/summary.json",5652,"d5ba341368db6f8d57b32f4dfdfacf1da4ab9e82ac59f58f22ab340dc2db730f",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_gtls_fast/execution.json",703,"8a64e773cc021b87286a2d1a017d82de1dbd65fbc8031d68b0e726083ef92f21",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_gtls_fast/stdout.log",17889,"5ce0893e8bf1305e66ed6805da29a95f7cbfed063f1a96d1523d3d6bce4d2c14",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_gtls_fast/summary.json",24416,"ce5aed209b0a668f8ccce4177bcda669bae78ba5c3ee545a024382a70638bac1",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block128/execution.json",720,"9d69f31c68eae6e8217c1271ec14c78b5797ee8ae4f5b7f14df5c8d3e48d5878",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block128/stdout.log",16163,"a0c4f5986395a3efac95de14e6dece5129a7fb142f6ed0ed16a41dd4a82e0a45",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block128/summary.json",22632,"91c6fc3c6a5539dc5f38320bc0f9f20a7c4f09bbd729687d81d0c80595ae85ae",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block64/execution.json",719,"3965340a9580f6a70f094f2d7ee1450b6c7c6ccfe05b9281750774b847f3dc10",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block64/stdout.log",16157,"13713e308171ddd3c691780439b33beb7fbcd2ef52bbafa4814986ab6e4b52bb",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_block64/summary.json",22625,"830f2f4c3c099fbeea110d4780dc39f19b7ba60b13763d7bbc594820815fa816",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_dense4/execution.json",723,"642f334aae29068e746ec2de26fe5d79282ffd2857608d0252c5f8c1ce428f5b",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_dense4/stdout.log",16158,"983cd478d27d48c4895c15f61a57d1bc02245c8a4ffce1f2d52779195a3f8351",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_dense4/summary.json",22575,"1fbb2d76113d01aa280ffb1ecdee698288080d2b1487817164037cf7af8caef0",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_manual8/execution.json",706,"3ac4860cb391dce4dbf5d727c2c4aa9c70c62c82b07a7b08a5560e4ae7767ecd",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_manual8/stdout.log",16155,"e29dfe595df21778a7eaed3bdc4835fc1f5fc9cb7e85646713c3322e98cf87c5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_pypi_bls_manual8/summary.json",22550,"6bb58b3ba05d2a8ba732d29ee16a2e891aa83414584545d2bb51697e9902238a",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block128/execution.json",711,"a92f62b81d065d8d54f0c7cd69b3a9a8d508d5481434afe2fdf91a620839bef9",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block128/stdout.log",15813,"b41a4db8e11258a63f948f57aac3d8c4881e02cd406f97bd785bb147ce5f64f5",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block128/summary.json",27644,"27866462e5da0f40f200ca2894ffd771b633c19cea85b4c3e5491531fb499083",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block64/execution.json",708,"69bb199ccf59b04cf8b2be8aad2877c832264b766386d780f7f6fa216b8faa06",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block64/stdout.log",15820,"e6992fc6c005d579aa347186bb7f7856ecebf789159d39c91447626ef250b4ff",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_block64/summary.json",27650,"238e454c8756638f192393b1670b4d059fc25d0776252ccab896a150d739893e",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_bls_dense4/execution.json",712,"415eb44a58b19895a07332aef4a547804814fa7b5e0016795633dad53ddf9424",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_bls_dense4/stdout.log",15805,"ad45cc235000dc2b751b3f56d4335ef040b32e195a556b963387663e26f100c3",false], + ["benchmarks/results/transit_2026-09-08/results/screen_ztf_v1_bls_dense4/summary.json",27473,"86e399c91969c29a47bcf4be5d756dfb7e6259438b0091d233a18f78f3b5114e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_batch16/execution.json",726,"f118746734817cbb2e40a9784125e87809cef79f9d08c656d6424c2ea1cb9532",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_batch16/stdout.log",7662,"c416799d3116afa833b13925f3c556ed85f9241b70b02a99b424b34b0c1013a3",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_batch16/summary.json",18906,"20a9bf348cce45e89e2d14fcc72679cd41bdc3f1d8c2396258b39c62f84e3568",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_single/execution.json",665,"d00d33a2a76a1116958e0940bc3be17b6011b93c2acded07f42bf16a5d2d140d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_single/stdout.log",478,"e255e062c4dee88730674747d7fbcb5aa0cbd9cfdc7133f8177357eccd2badef",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_single/summary.json",3041,"aeafbff828bc4000972c9f10cc925549673ba664271a18fbad972ca894b94470",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_sources_batch16/execution.json",657,"6b86c3d50bc163af707089a3a19de3edb725121a3f5c7af989886c263a509b8d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_sources_batch16/stdout.log",7662,"c416799d3116afa833b13925f3c556ed85f9241b70b02a99b424b34b0c1013a3",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_cpu_sources_batch16/summary.json",19139,"2e0a40512db83ba1c8f10621a2140bda1eeeb87834b38eb00dde664314933fd2",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_batch16/execution.json",735,"c9ae87438c5f9b615ec1533252b56e84590d53a9cfff2c5c362488b152ccac74",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_batch16/stdout.log",7652,"72c43c8f88d4464c0f3c078ff9a2762b1797cfcbd296e7a99300a4c9f9228f03",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_batch16/summary.json",20972,"44658fb12cbf9b54628055c01fab78e0424a2f10be71c03a277e2dcbdc019e00",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_single/execution.json",674,"aa51ab4e174434c55e098338e02fe58ad0483da2ecb92ed73331d2fa8669185c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_single/stdout.log",477,"2ae19136e034d9d5b4b8cde191b99fa34e0f17f17edc9545f8ae9305a1d648d4",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_gpu_single/summary.json",5139,"b4797773533bd7bbc83b465facbd9c2cbd681994efcce4d644fe8f3d5460bacb",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_batch16/execution.json",764,"02ecd64ea0f746bb09d7f83a61dfdca16a6608c56df9aac2aaea2db1fc80fde8",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_batch16/stdout.log",8007,"f671e097763680fba58e618691bf5b6a85b556fba892044db46df0b350a8636d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_batch16/summary.json",20656,"967b887351bd3a2f91f7290430c0495661e18f45f085ee428f0a2e606c0dd626",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_fresh_grid/execution.json",692,"ea6140db9b5cb6d9eaef7f24e1e84aa440a5c5c6687808c06fd206733d08dd57",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_fresh_grid/stdout.log",375,"d0e7346b73cc76e5aa4f03293d14b88fc1b6a63243a3dc09159e722cb43fb49e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_fresh_grid/summary.json",4149,"d4baa72609f27165c6dfcdc71b144555dc83d25f57cedba2ad187f9176e742c3",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_single/execution.json",703,"516fa9a53bfa0ceb97f5994bf143ded2b037020ed54d1aa7b22af43e06c1088c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_single/stdout.log",829,"dbf88dcca5cb4d37739eacfe981942096d0ad4967651386e0626021ac9d2efbc",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_pypi_single/summary.json",4828,"81fc4502c36e0b8ec3944d2459835113b80d9364b6b9e25204b01bf136916573",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_batch16/execution.json",782,"17925ce685814484f5ff7efbcfc5958bff3c443089178e711a3948a7fd0b82bf",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_batch16/stdout.log",7647,"3bac956e68c3defddf25b87630d35cecbbe862c1641b5b9fe9e4db0328a0d25a",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_batch16/summary.json",25664,"16a38061d2a74e7a448b91032123b8536b5a8edaf798595b86764e6eb4ea3a9d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_fresh_grid/execution.json",660,"f7c2619e11d83b64680f71879e49471994851346d7fc7c1764f72bd8a639a56d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_fresh_grid/stdout.log",25,"f57761ad0676c39c90172fc9fee04f43016379842a8af79bfba0d22b73b8fb7c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_fresh_grid/summary.json",9031,"e7a54788b93db849e471fe693ef02be367aa602cad2e53e0bc6d7d6da25792d1",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_single/execution.json",669,"069e928ad5466e00a704baa3feefff8b5ec1f40f0ab53cb4a8834ac5632d2852",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_single/stdout.log",477,"34a29010667efef09f0826df1a62ff40fe876baa9fd2cab7df88b3aade27c5e7",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_bls_v1_single/summary.json",9819,"3922104cf3104b6ff67170ba8f9243b7c16509f50366879d3854014001b01e32",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_batch16/execution.json",761,"77ec5c6528eac7302642202f1c2f310607f8bc9443653e185b655a12b5207e72",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_batch16/stdout.log",8852,"be00f86771875535d0b3b255765b980894b66cb5ff4c5981fa1f41a9364259a1",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_batch16/summary.json",25161,"fb5c790393f0f6373a2322a2ce2ffc06eeecaeb3a09f24d2cbdac038181e378c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_single/execution.json",700,"8d6c2e13a60c4bdbaa0dd5829b98d288c1f1f7aaa4109317d06536151b3ed40c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_single/stdout.log",823,"30fbd7b4305c2758e1a1304ea17651a2c8b1ce3f9b07c374c03384dd96185080",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_gtls_single/summary.json",5066,"3f300b8c20ada95a20253a639bda1e375cafa92c06bb190baec6f7c404861bcb",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_batch16/execution.json",727,"f1915d2d29ca9ed72c6c81d519ee5d635702119c0592ab9050b019329632d570",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_batch16/stdout.log",8526,"675fba38c0c0469439e14e01d2ca0dd0dc1b7d13afa2ff2ebb727fab8a08344e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_batch16/summary.json",31165,"b7155c9b73bdf8ce4896b72940457a8eca3b982266f340ed6c18e63f7dbbcac9",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_single/execution.json",665,"b40e230e08c32dfb2e7e2edd74096907175621018851d9397970250dc86c74f6",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_single/stdout.log",533,"2188d9ec0820d50b0cf84ed663760bd4a9623a455f7d738b760c1f4e68f9c527",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_200s_tls_v1_single/summary.json",10356,"b2a88d607f6f9c63a5af4dc0d78de4decb15c8cb940ccf3f5ea599a01fbdbb93",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_batch16/execution.json",730,"bff96cd7931ca9427724454664f7cacee7a50fc8abfc62fd0e736df6c1a29e49",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_batch16/stdout.log",7688,"fd774d7b7d22366e7eb003e3a9b8a35dfe1447bb28aa14fd745f072d00f6b1d2",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_batch16/summary.json",20997,"06f95ba3811398e8cfc3e61dd13b5afac200aa8c05f762d7cdd0d36fa6a6e339",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_single/execution.json",668,"c6deb93bfe3e9a6126e7c244b9dc1eb3323d7d893a15f59ace6aaefc294f3c8d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_single/stdout.log",480,"58300cf476aa1ed4311ab187a14207949699556a211b1486d8ad0a0a4d727097",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_cpu_single/summary.json",5119,"46b66c8962e47db01c3323139e6c4869bf07226f09d975442e5927ea7fba5e97",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_batch16/execution.json",713,"0ecd533a26f8e85f3f9d8586ebce27b37c3042f80ab8783c90cea7e17f2bad13",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_batch16/stdout.log",7684,"3c4063444fa7c8d7d94e0e7a3271182381a7baad79a3073ad4734e0361c72e21",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_batch16/summary.json",20974,"597179c5e10c1cc7eda3c589f60fb8e9b418d7ea648159551ac3a53ddb35dddd",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_single/execution.json",652,"1b6778096a7054269ae9f40dc3a1454794c39a728702df33132def44387fb1f9",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_single/stdout.log",480,"64c53182323aae36efd9be4b754e7ffad38add1799fe4ac8c59f3cccc63beaee",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_gpu_single/summary.json",5108,"50287bf0bb4d6bcf5d9840393a272888ac581a0b1d01a747f210ab5df05eae5b",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_batch16/execution.json",781,"341ebbfd7c8fc27a904dc97c14786df0b6a5ae9d67c8a1d5808c4ff1fa347c2d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_batch16/stdout.log",8037,"6220fbe9583fdd97b89ce550974577ebb31512aef8e0ac6671d59541eaea05b6",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_batch16/summary.json",20696,"bfe57830c92b642d00dc5a8287b23bb0b6021211bd1a0ebb43c8c61745203b4a",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_fresh_grid/execution.json",709,"36775c916c836dd4e38c63ebf1e974e50fa901bb569aaf59b8052c4b7ba7338e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_fresh_grid/stdout.log",375,"d0e7346b73cc76e5aa4f03293d14b88fc1b6a63243a3dc09159e722cb43fb49e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_fresh_grid/summary.json",4152,"8c965ec72d865c13a52e926ba59c0e1cdf1e8d85ff2bfdafb80455afe777ec5d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_single/execution.json",720,"7805babdbe127400bd349defae9b820f7805fc495ef0a67b371a7969e73e36e1",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_single/stdout.log",830,"92c214aab1b95c1a5f76d79069dd30125e5d3dbbe629b5d89e4ead3f7beb0d8a",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_pypi_single/summary.json",4831,"514c20ab823fbcab9b648185a2a4f16132252dcd91faa6172cc0fdde4c300744",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_batch16/execution.json",820,"b0de3134c7d02b73d1435e33fc3deee17c82015c0426ba3d83be5adfb89a5af0",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_batch16/stdout.log",7689,"ec3f14e242993533fa006593ca1cc71423e3b293dc06c94bac7ea5506c9bb5f6",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_batch16/summary.json",25772,"81ae2ca76536fc5144b94ad8a1f724b3b0b2079f7e7c585ad7ac444baa8c6491",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_fresh_grid/execution.json",697,"21e6245915c544d12c838d6656ea38030009fcd0fdfda1707c59f8ca0e6ed7a8",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_fresh_grid/stdout.log",25,"f57761ad0676c39c90172fc9fee04f43016379842a8af79bfba0d22b73b8fb7c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_fresh_grid/summary.json",9062,"597f64b10a6572472df7d8bf1887a14066cbbb2044b4e196e791ad930013a691",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_single/execution.json",710,"28cddc3273080adbe7eb27e7b1eef466ae484e3b1a400621539e62317b66100d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_single/stdout.log",481,"6a1bb5a674bf57419db56e7aa95a812a0c4784799de11b0dc0e62bb357d4f6a7",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_bls_v1_single/summary.json",9850,"caf13a0e872c950784858eba28bcd271ea2939b0d65fcea98dfd147f4b74f7e7",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_batch16/execution.json",760,"95483321cb75241d4f4a9a0987778cc0bd01da964cd4433c8861eb3b8b4057eb",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_batch16/stdout.log",9454,"03b6dcb19b4744d865b879127183a7fc3bb24358b615b5df6828615cc4c52ad1",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_batch16/summary.json",25165,"d92709eb6686080dee9389ec43200fc535ed0bfb5ea0c1c5f06081aa4517c2eb",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_single/execution.json",698,"19f165a612b8ab1cf6e0bc31d2767c41f96447849b7ce01a0ee4753139372111",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_single/stdout.log",824,"99cde118d31cc6949a3db217334c93429134dd105dde5f4b9c9cc6d473ae12e5",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_gtls_single/summary.json",5034,"79a595828cd23e1937290a7d91d48ed8b5f1931ceb25c329e422c3419eba8d82",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_batch16/execution.json",721,"2b14a9702b0d4a585d43fe9095fc16251a72f5d0dc6afbfd8bd3153ed0c29cec",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_batch16/stdout.log",8562,"5af875e23cdc386803a858d0e62ca3a5b2914142b126b8c3360c9165f61a64ed",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_batch16/summary.json",31233,"cc79f7ff4af388c5e6f097c5c065e36e11f7f1abf7dd13099b41c26a884c8fc7",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_single/execution.json",661,"19e292d3b84b9bff29d03c9d09677af64852742fff106a9e2c99c99dfe91f1c6",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_single/stdout.log",533,"0c7df3a65bac7f83261aff1ce46fd134c909a89afaab322f11ba69161472b3c4",false], + ["benchmarks/results/transit_2026-09-08/results/timing_tess_gap_tls_v1_single/summary.json",10334,"73d6b7ee6554c60e825826d3ff1d06f8bda793348e9401613ee2e5e392618571",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_batch16/execution.json",713,"ff54209780762429fef20fafddfbbd0d011e5b3fdb5477c31d801c17b903ec92",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_batch16/stdout.log",7691,"cccbee8cc3370aee0daaacee346dfa6022e607d498e52688611050986a6ac519",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_batch16/summary.json",20994,"05343976e02e9d2c5dfff164ad84bc365bf474f79c50c22bab2d411ea3fdfd73",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_single/execution.json",652,"5dc8e90929e7c0250aaa1fdecf149b27cec3fcaaa56dc67d008149dae17cc455",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_single/stdout.log",476,"5f53184a4e56486128854e8dcb8870a35958c89935412ea058fbde59a0ee0167",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_cpu_single/summary.json",5110,"eb83b59c65bcf6258ea35c1a2f5b060d88ad03247b08dd3c15b9514cb85b93da",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_batch16/execution.json",699,"f969fd8881b29707e5be1ce8cbbdca88b059a719e38882bd6e426cb8342afaef",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_batch16/stdout.log",7694,"26614b3b1681428a836f50369a11a39d0ef3fe26e8cf5d3701292f64ddf04972",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_batch16/summary.json",20989,"b996400d4664563e133d472990c42d3e97e8818c317dbaa143c0df536af32959",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_single/execution.json",638,"8d4714f3781469354c24785bc27f3553d367c60b3d22820aa0babf4d363d0875",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_single/stdout.log",477,"5698e709412d81e8e5b4ac14b33ae5901a3751ac6dd4364a7052b0beb55809ad",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_gpu_single/summary.json",5107,"ce90f48dd85e5c9b628834dc6ec07686d7dcc8acc033e1c3dee79181e04b3c46",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_batch16/execution.json",726,"b88010f5b59a1fbe4136b697a215134210a8382979a8dabf46512e8d18a3f222",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_batch16/stdout.log",8043,"3f8d2d130df3d7213758db048d7ac1f8fd78e4f41665d2cce550da6520f251e2",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_batch16/summary.json",20664,"6a913006bffa83ffc0d5daa370640bcfff4dd4863ded9292d68e30199fb4d83c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_fresh_grid/execution.json",653,"c187d8e935fe318371b7ebbbd7c625fb91f738d4ec2539eeee86ee9b90b09739",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_fresh_grid/stdout.log",375,"d0e7346b73cc76e5aa4f03293d14b88fc1b6a63243a3dc09159e722cb43fb49e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_fresh_grid/summary.json",4103,"a32e3a43820fa20b84b4ea1f43925158fdeaa7eb5fb43a41eef6a2b76bcb31d4",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_single/execution.json",664,"13e2f6c9651bc86086fd062de37fcaf66c473bdfe15c740ea10f2e5d3b064105",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_single/stdout.log",826,"8a14ff2f979c1e9b0eed51841a2c0def95c87e3b3218c5648f618d54e6204ff7",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_pypi_single/summary.json",4774,"d4fc537db8da18105585b234d6420865f5d4c1ff78b892f5e340ca83cd2927cc",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_batch16/execution.json",761,"f00e7629aea96a276938b9201beebbdd94bc6ea49253b1e79b621a93625c182f",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_batch16/stdout.log",7690,"9f48163073de0158e2116300d82e3c1a0d57770828dd107d95f715ee6919a399",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_batch16/summary.json",25722,"97bc4a2be694eacfbff0b2352df1034b61609043823529eb8d8bb27ce19ad2a6",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_fresh_grid/execution.json",641,"bbb56ab60303d7645b9b7a64ad9892bb50111bbf80e3c6bd208d8c1782b2b35e",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_fresh_grid/stdout.log",25,"f57761ad0676c39c90172fc9fee04f43016379842a8af79bfba0d22b73b8fb7c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_fresh_grid/summary.json",9008,"dabee76867ce25edb6a508c4a9c7fa21f26e6ccf2486e5bd6286dbee496e8203",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_single/execution.json",652,"1760d54d94592eb4cd5ccb8d5772ae4b938ce40867c756eb00ba1caf4e53934d",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_single/stdout.log",476,"2244fdb8a967fe66d4c3997aaf2040ac75c418c33544bf42c295f7f710481f1b",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_bls_v1_single/summary.json",9795,"4e38e1792992d57fc0a621bb6b378d8d19f631ec56a0e46998caf9ab93708085",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_batch16/execution.json",745,"2f47a1af01cd4d24862a938e1a68875b756fe9a707407d7ac21a0ad3b31a95e0",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_batch16/stdout.log",10042,"923ce410c8d556919c40fed3aee67fe17bc2c7668f08044ea392a2b2b0a9b258",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_batch16/summary.json",25185,"dbd73235c74ff8861b2c765e4bffb946b6f1976eb815587fe3b9b3816db5a764",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_single/execution.json",684,"30f9081039b74beb20685366847268d8d2ec6fa069b3e29f3becda5aee3ca0ff",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_single/stdout.log",823,"437ff2dec59fb57656c6de72fb8b05d7767e28a4606490090ea952eb6cd8809f",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_gtls_single/summary.json",5046,"7b797d6b75832d16a7376dbbbd256d5eaa404123c2b7d5bc9d2fda37e022450a",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_batch16/execution.json",708,"a0bb62934fc17be26c702ada71139738951549c3dc06e9aef398b8dd60dae6ec",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_batch16/stdout.log",8570,"44001a6211d6e463d0c94b108f3fea17a8670ecd743c01207a369676d6401e4c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_batch16/summary.json",31203,"72519386cc664cac95b05b98c99aedc1e97f6ff5cd5c5c18830a063772352102",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_single/execution.json",647,"4e20267e752647d7c2e72afe21b7a70733d212f58c42f9d43c70351fa262f55c",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_single/stdout.log",530,"dadc57b275f136527fe65a31525ac539952c25b22df533eb68e84f9468f8edb2",false], + ["benchmarks/results/transit_2026-09-08/results/timing_ztf_tls_v1_single/summary.json",10322,"da351fcb252d593800a24e45e92c9cb8c50664dd8476191a7d247de4ae759cf0",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_astropy3_w7/execution.json",693,"431a79194827286200bed5673af6cb6359d479008a1787e32f64a0ee43c8b7fb",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_astropy3_w7/stdout.log",14319,"5f64371d451f1be7e97ed1b111aa9acc9c51636882a553b4e8bf0d58a9076799",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_astropy3_w7/summary.json",18820,"e95c6e8dc4aa2a2d03837ec0f794af5f55fc0feee3bead6996688515b0cb91aa",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_default/execution.json",688,"9cfe02582844463db9b18f1bc41234f6f29e419fa10e538a288f2d1c39bfd5af",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_default/stdout.log",15485,"c8c1bfdccdc1f2622533202035304639a51fe17cc4b0ac169eab5e4425906554",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_default/summary.json",21409,"daf8525b63fbcbaf38598a0af4fe9d1282fbae479e2f70af2c4635fab89364d0",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_density/execution.json",706,"09b7632da9b7d2e703fb88c30714d80400f4fe395c5ca5447bf69a0d6ba5545a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_density/stdout.log",15484,"903f09d533ce4a58159959a2e02efe329d3ae08d76a7d51c110ea7a9c92922aa",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_gtls_density/summary.json",21429,"4e3c919ee2c4fbe9cf637a65605348a1d2777caf2184f2c60b6f43738a0ade21",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu1/execution.json",692,"d9b85f00912392d4c216aadd1f3ebd1c04b6aa18f7725359a7c4b01c33b0cdef",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu1/stdout.log",14319,"28f5bc8b275fb844a56238a30f72202e0d568595dc9146e61d8d0fe8282c2bae",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu1/summary.json",20919,"c06e13ce5b9341ea7f93c1cdbda6ba0b2873aedb5bf3d0a2830a92674272aba1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu2/execution.json",693,"dee5378f83fb676472336a19231fad624bb8a886d6918fa9db5aff49379945b8",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu2/stdout.log",14316,"1045522b7ae0c0f1753cd77c0b2ede7d4848d0ba818aac4c32f74f11508bae80",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_cpu2/summary.json",20918,"a062ae658842fbce027d59d55bca3f47c00c24906823ec70dbc69191bd78d7f5",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu1/execution.json",679,"b2e8112afe4d444e5cfcb13ff0fdd6fe5cac97297b276ac9007a038e760a8f91",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu1/stdout.log",79,"fa7e6d49a4dc3770781b022cbfd98af0dd0aee3d3ca6ea5d8794aad00b9c06c2",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu1/summary.json",1156,"5e887d1e9be59a5211ec2d6c6fd895b3185d1687d57428106ed525991c349956",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu2/execution.json",680,"74f97ebfe71309b14eff141fd9960133b08526310a5c3c11cd5b28b8f9dd61c9",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu2/stdout.log",79,"fa7e6d49a4dc3770781b022cbfd98af0dd0aee3d3ca6ea5d8794aad00b9c06c2",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pf_gpu2/summary.json",1156,"f97f38852572966d5c40dbf746c54c5ff729f541e6cbc742b898fce6c957fff1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls3/execution.json",679,"db345a45f2231350995b54a197b922f7c17e38782775b23590b22140e084ea3b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls3/stdout.log",14692,"67d5c549d0c70d3a8f577302f989cc713c1d7b678ba712a94dc2b0761e3b3db0",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls3/summary.json",20688,"db5fe04007f0b3004396b4b74532c2454a60b4fb76bc733dbd420fd98f33e0ba",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual3/execution.json",721,"6e43a5b05c4dd41c043e53b4b7e2a4a40a5a3db0910ca70b41520a48ffd74521",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual3/stdout.log",14692,"362dd5cf8ec7411250aa27fbf7a7315e20f626218fa026a6fc61c69e844a7af3",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual3/summary.json",20715,"2b78920820e336e4885babd7e5212cc257ce20e2f2b880f9e4573437568216cf",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual4/execution.json",720,"6ceb801a90fe1fe07ccf88694889de3bd674e4a288f361c482ff0f60ef443e9d",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual4/stdout.log",14687,"10fd5606d3cffb279ea5de02b79f8e3e529779def80670582967558b99905904",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_pypi_bls_manual4/summary.json",20710,"c281974a30dbe38894c1ccbef3893ce5c626e571a147790f69db56e74d06ff6c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls1/execution.json",675,"5a3a3a9af7cf217b8eab315391a008b1929fcaefd1b0b3590ea7712a0732ef06",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls1/stdout.log",14360,"310aa8f16572b7f24a8c5be61888963ff8824b88bf2cb4d697056a960e138ccf",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls1/summary.json",25610,"789986ddca336b37f3ece7e924fbf7648ecc5af93db1cd27671dfeea7eb10c1a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls3/execution.json",674,"281f65c5e9d25184236510a5ebcf8b073b4a8a4476a04405ce288b735345c43c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls3/stdout.log",14348,"320fadbf468b65375fc83d99dc0980bdd036c4ffa569ffe3ad644717c1272a40",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls3/summary.json",25599,"6f0418672ff8012b5b0589224983e9127b77822d9b5903d74603b4b2e30620a1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls8/execution.json",695,"119f7a47c56aee3eb444c4f339754884bd5fb0d171a763a7efe908b5e06ea972",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls8/stdout.log",14360,"4dca9537928057f9f634eefd393c498266adda3c9a23120de75255b0c27eeb28",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls8/summary.json",25633,"3958739aebdfd829bba8043e472611f27f2887c9016272cee52a14737c9e0943",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls_opt4/execution.json",704,"1e78867fcd8cee1d612d451531f20b0d98d6ce5ad7ce10fb77adaad5f08bca33",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls_opt4/stdout.log",14358,"8a48522c6dc6ceae3e6b70adbb470845959a976450bd0f20c0356fb253e4d823",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_bls_opt4/summary.json",25633,"9d393a1b17a0aeb7bbb0ba36d4ce3730b32ad63d9f1b8535802a9f905e50972e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls4/execution.json",689,"cfab58ca36a5bfa0ab22efa285288e539be39ef3287a4341ea86edd3b10e8526",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls4/stdout.log",15278,"8644db87cb5aad9b3fbd0e8ca84d1ccf1609c5a7910d22cbedc1e5236407d722",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls4/summary.json",26736,"31f7abe2687ba85f8e467c8404f961aefdc3e67e51e671f2172a6932571f8b36",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls8/execution.json",689,"3340402809bba503a120dd149069da0a193b63c88c39b27e0b207384cad36051",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls8/stdout.log",15280,"3540879d6de9da413498d7220d510a1fcd6797d49c7bbef4169801cf81ca3a1a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_200s_v1_tls8/summary.json",26739,"aff4dca8a49fe9a9d4e0f9416ee9eba116802f74c25823226ebec25c319aec88",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_astropy3_w7/execution.json",689,"467ac2b485e10bc09330a3de9621634b431bd9f84c76c458faee840b0771864a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_astropy3_w7/stdout.log",14360,"2d739e6f200eebe35fec571f2e7b33a40595e61617dafe38aab707294ec70c24",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_astropy3_w7/summary.json",18861,"2ad6b71a81c532d8892a5b486b287625ed625899e39d9613714774b0ef38fdac",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_default/execution.json",685,"071af24321b370fb7ce9e6be27185755f0e2711ae85cc2e674e92f27b6eda8f5",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_default/stdout.log",17119,"466c4f217144c6aced89c6f18a823d60b9a20ae25df61d7d900a14216da1807c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_default/summary.json",22463,"bd6a0a25aacc16c3e2ef04d3ea54d6d5a122c5480522396843ffc1cac08eabfe",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_density/execution.json",702,"b82ea13dde009065552f83b201a06ba538026ca73bc4821f0219c258e975795b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_density/stdout.log",16283,"94a506c5bf9b742a44d5671fde120af91a6a04b61a6faf0c839fc50ffc0bb455",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_gtls_density/summary.json",21456,"aaca8f57fda522a667c9dd2e41b376a24089bda5fa351aa2b151f1d7010e2da9",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu1/execution.json",688,"97f6b2cc592f5c491d70d0c947f89352bff0fb3513f9fd56dbb132c1990a27c6",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu1/stdout.log",14363,"8f30cedf199764735e9375f02c9f08fd179fe496959e854941b62d5dfa963082",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu1/summary.json",20963,"0440fbd61e6831c38bad4030fa14464247c470117bd88db4befb54ac9e610a9a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu2/execution.json",689,"528bcd4898f4ecb80ce41b25739a26902dbe0ade7fa306c55d3bd8c9ab58e5ea",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu2/stdout.log",14361,"51e84d5bc7a6348e46cf0ee8a63c2125be92a04c8859a7fab082928e2e4a370e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_cpu2/summary.json",20959,"5eee960d9c8aa23ddfd9604023e37f19730977c25fc3daf87b0ec852aaaf1850",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu1/execution.json",673,"8ab0d17e343f507c80557a1a1200b6df18de6584f9a7c4cd5c5c2a909a959d49",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu1/stdout.log",14400,"bdc52cfc029db76d35a9143eaab7b0d67755f218d8391420f559eb5693d367aa",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu1/summary.json",20982,"bc3c6db0e52b61f76bd3bd66d53a0c71d0793462d724d45c98c7b9832bb48c6a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu2/execution.json",673,"ab21bc1bf12b207898a25893e6e02b58b3795b0c7d7a97e5668288c28041c471",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu2/stdout.log",14403,"6d7bccd53dc3531e1be0b84dab154f97e490ff56507003cd45a3cabbb0637d38",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pf_gpu2/summary.json",20985,"edf2ef4e9fc4c3a5e1e8c218c2b5d4339f7f46711d20d9271a2376fc8bbbd6cc",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls3/execution.json",677,"bea2bf27250862bbad4b0aeba93bd8c832585381f25216a7377cfc6489fb2e4c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls3/stdout.log",14762,"ef40063176431384f3ee329a35c79f8b07dd8371bc267dd10b229074efbe94df",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls3/summary.json",20755,"a17666684100d021d16b9f9f06cf232d64eaf6e39fa0236b0133b3d8655d5f25",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual3/execution.json",717,"f3efa332e4480be00661c8f0cc5f055519ea82a52170be04db64bba8f0421e8e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual3/stdout.log",14760,"398d3d3a400a9c4c1023412eda86bdac28e3aa1fb7df699bd87d14ab556ba8b2",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual3/summary.json",20781,"b4cdb40da8ba68f02a6394acffbdf8e482f55eb1efa2c51690adf94278d0afd4",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual4/execution.json",716,"c29adf5f7e0734566217e4360be3c13553472d553764439e0f8ea58be21f6bb5",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual4/stdout.log",14767,"1917186381ad10d2fb7fb8858bd1699e86db881c8e5d7d7cdaf78c93ed773aba",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_pypi_bls_manual4/summary.json",20788,"fc425cab2f17abe473cbc84f4a1e91ee23059e012dce87a1ec0db22b32c2a6cd",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls1/execution.json",669,"fd0cb23f2ed669209604e3bc4f44972e473dae32c2bfb1f6f8ae63f070b0e50d",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls1/stdout.log",14428,"fdcf910afa64f8376f5efeab3819c755e651d38c8a528b48180a62d8a16297ca",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls1/summary.json",25677,"06b516e5860af6d908934790a8ce5ac2d9bea72deedd3953d516bee745146e18",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls3/execution.json",672,"db10ef334d4a9aad9bbd4c68414331d394759bda8767833f032b3efc308a3f20",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls3/stdout.log",14414,"b1a0e6647b43641d558db6d0d439732c9b55365b3ef67f5a7b9039c5521000b3",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls3/summary.json",25662,"d80a13ce877e18b560d70898ab1ed94d4c0cd247b7b9f2d7112fca5512542dd2",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls8/execution.json",692,"5cde73f0e496952a16afeeeae9d5e1323ec88c4c703f5a2918b7f8b2dbc250da",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls8/stdout.log",14426,"399c9afd79f889608fb8ae0cc23cff0fcc1f88551818576a743be4d1f8f71c2e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls8/summary.json",25697,"09841323b9eb1fa24ea0f9c03c55fa8c18ab847d6c5344030b665851ea088d68",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls_opt4/execution.json",699,"cd76c2f37be70e3dcc367f24cbc7c589681be7ad984f064021941945c822a8ad",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls_opt4/stdout.log",14433,"48c93cedac323e27e4d981d527eceb694fcf49c5eeab661279e1ae144ae10a9c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_bls_opt4/summary.json",25703,"6b4d97bfd1fa37fad2576341251782ed06b0978bd4db80d413a8ece2c416c64e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls4/execution.json",685,"1f6b5db7f30fff02485bea3cb6151092751801c0f5b6db34ef771ddd8a35e4f4",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls4/stdout.log",15283,"d907e66f1fcb2f1ba0c5dc2ce7d4a2d5a638f740b07ea513456142f9fc42d009",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls4/summary.json",26740,"07635db2d755215b9f6ba7220bd9e4aea7063b5fcd6e14c0af079c8e7b0e1583",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls8/execution.json",685,"2e9c255953e88ab97b796dc3a193c737dbc216c92de07702b1f5db78a81a9dfb",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls8/stdout.log",15270,"a925f8096f8a0a96299f7f208ff79f3d1be25b582e86b7d24b445d64420c51f1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_tess_gap_v1_tls8/summary.json",26726,"b95dfc3e7bfa28c14be4e4c251ba936a1c1d2f5bd76e1a9577d003532c6037df",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_astropy3_w7/execution.json",676,"ed1b8f81148e8891dd4a79b1356f08c50f75ef8297dbfa63cda8d7d355066265",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_astropy3_w7/stdout.log",15780,"bc281d4f37eab2529776cec5b7e563ab180e9c41a91c365814a4e89f0ed88e9e",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_astropy3_w7/summary.json",20653,"63caa6514948cbecb96d8fe7ef6e753bdda0ea3eef42586f4cd0b80cf3efa219",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_default/execution.json",670,"b654da36d0e2edadc1805d2e795b73bdc25d6fcd64b34b1b42bf964fe1e11060",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_default/stdout.log",18438,"e0b31f3b9b4168fdcaa0ce1bf1e13c379cb1267756abad5ca3962e469411d36a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_default/summary.json",24338,"834b0651c2ec02d01331dc9b4bac3ddf88bd69240e3e45c7c82f4b5dfe00aef6",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_density/execution.json",689,"e201b35200acebfa275ee2e1d31e97eab57034f9c2e6acb3a09db05a42461eef",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_density/stdout.log",18435,"ac72af2f370762ea1dedb46be92dde8706bb989af6715f7f49429fabfdd59527",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_gtls_density/summary.json",24354,"d6e0570f55e1daf15591baa97f7fa67c917d8cf0733b66d4d50d504992fc398d",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu1/execution.json",674,"0a55711ff21c3bd336d6366764c69b924488aab9d50660543216dacb8b08965a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu1/stdout.log",15765,"ccbc2e89963fe800fb46b840511f47fb1ef5ce47a39d8bd060a29fe56be1c107",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu1/summary.json",22738,"c07ab86efe2168657622ef014ce4ad8852597b4f4f1cc941a5068d9482664e9a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu2/execution.json",675,"92b165909bdf33d7d928f0d1fc5bd3401389cab3c379d3d43770a046f60683c1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu2/stdout.log",15778,"96ffa779a5c3919b6c69976149a2a6537ad448d8e8b64bc7a54c3dc4e1026f50",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_cpu2/summary.json",22751,"68673791d74203e936a57020451d88ecf8fa30ec7bc07b9583f04b4f9c3ffa3b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu1/execution.json",658,"e69d87a0c56f7b44976e3d3221bf48544bf3dcca88198836f7893e8980076185",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu1/stdout.log",15805,"eab2119f9e7900f868527971de09ab282fc90d4aded6708ac87995e1520ed38b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu1/summary.json",22760,"cdb17454111c7e9ecfdacf85baf8fb150bd376d4c6398214e149401c34eed047",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu2/execution.json",660,"16d34379a89eb098e9b267ad2c90c988cc895201754dada836f5a7c70620712b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu2/stdout.log",15824,"34adbcc392edd6ba68d07300c5c43ee94333a4d30567d0f571e55452a0211f08",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pf_gpu2/summary.json",22779,"d01956d88851d8e896d4418a3b66673ad141df77665be60dea407817e1d18156",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls3/execution.json",663,"8f67cf791460b13216ab33e532695a64e4adcf8af563bb14bac9e7ab35d2567a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls3/stdout.log",16152,"180e05bef6e7f20dd252219b596d0c91c3bc96955c6c9a271282b5bd0c395f02",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls3/summary.json",22519,"cfb402e1c7bc85b3d29d39a118e5d410ebe6a5bc627d517df79ad783aba2065c",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual3/execution.json",703,"84be671f343b644aba095098b74dd2ddf1b21634181ffc649e3f81515076d0f1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual3/stdout.log",16152,"e814425be7e1b18bec5d1253eb63a2a1fbd64a65a1a468435fef636f60feef39",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual3/summary.json",22547,"94eb1a061a9726c0446387220d5f12b7683e8c996f7a776e7cf3accb48c056ef",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual4/execution.json",703,"d9688ddb05aa1afd9d7ef76d545b434f8f7269a41f299276adedbd11b0606da7",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual4/stdout.log",16161,"89e57194b183e3f46cd7aff1fed4d85eb968e8001fabd736b51b5bf4be10e909",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_pypi_bls_manual4/summary.json",22556,"cc97e495dd6ac0a7481dafb104e0e9621ab1194055831c06b69a9476c37b4c84",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls1/execution.json",656,"46512a45058c5b4ec4465c6b0616f72e8fa0b4f2c8fa6ac023055379e01769e4",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls1/stdout.log",15808,"1353435e02deb3241986ccdea90b0ab155e230f6269ec851fbeb650da83f5fe1",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls1/summary.json",27430,"710d1edef92d8cfa414eb67c71dca3b2b6262e2393d832a4b0e98f3fe8c52cea",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls3/execution.json",656,"d829b92c0954dff3e59885b14e1fcd75ccfeca35f242668f4a9f90cdea35be14",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls3/stdout.log",15814,"b8b1773d1ff4dd7b45e03885f9eb465e4dac880184d763b677592f6a20a1df1b",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls3/summary.json",27434,"b919b5a6ba998b34596a702585783dd052b8bc2ef56a9b0fd93ab0a9cf1ddc95",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls8/execution.json",678,"ca34aa38a6f9524c5be9183ddc988703c8932c66c917e4a6b03881a58adcb419",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls8/stdout.log",15827,"3d13f6fdf0c2f71a16bbbe369c1659db7a2af76403e220900bd2bc0c9b78643f",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls8/summary.json",27472,"00c0e1d4d4b79611a41c1adbb4fdf5a3f08be1d3997c3c7dcff3f4a917e22f58",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls_opt4/execution.json",686,"b745d0a967704d2a784fa2358cef22f5235e1383ba2e64953eb89f2364492323",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls_opt4/stdout.log",15816,"b16f46efe6e35be21a20802ff90c28b93f415ce570a92c7643a742d6b3e94d2d",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_bls_opt4/summary.json",27459,"349ec7fe0d1e0cf820d28d2ec02b9858bb2bd78bb25375662fd551622feaf2f6",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls4/execution.json",669,"fe9b5ec7f110e218c5da09f0824f19ece7f65a96790925c054844146471a9f6a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls4/stdout.log",17602,"90723bba2bcd0cb631f67d3551bb93fce39c9b36abaeac0528e55885cafd36f4",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls4/summary.json",29623,"da801183265273e7eb7553560093f579d401be3913412b06e1680e28ebce5aa5",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls8/execution.json",669,"c26bfc78cfbf16b2991a5e0725ba9620cdfcf41b7f80e32c8218f7df277cb36f",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls8/stdout.log",17583,"f886942a5ee223b2440d5662208c00872778cdb6e081295707c8f49282d6120a",false], + ["benchmarks/results/transit_2026-09-08/results/tune_ztf_v1_tls8/summary.json",29604,"76702ff1d82dbc52e541ba829907cdfc86dad1200c60e1c051a319c3cc518350",false], + ["benchmarks/results/transit_2026-09-08/runtime_by_cohort.csv",5075,"ff406bce8cc95babe55a455f675b98635f1455ffaffa3ae3658783137c2c98fa",true], + ["benchmarks/results/transit_2026-09-08/runtime_cohort_analysis.json",21526,"8cf6a673b8c1398d21de462b35fde76bf8f5420218123a3f6cf2e0c7990ea89e",false], + ["benchmarks/results/transit_2026-09-08/runtime_cohort_ratios.csv",875,"0c62ba795f068289ce5d50c103e8ae1a74ed85310006257d4eac9873043ec64d",true], + ["benchmarks/results/transit_2026-09-08/screen_batch_context.execution.json",2425,"d4023e56e964c05be91bef14dcc6bf89836520d432adb397e8fb0d433066c45c",false], + ["benchmarks/results/transit_2026-09-08/screen_batch_context.json",1111,"510e4fcbb780e1df383da9cc02ba588d9ab0d53d2562ef14c5bf3c0a3e2bbf24",false], + ["benchmarks/results/transit_2026-09-08/screen_batch_context.log",378,"68ee38efb3a855f0d775ce85745e7adcaaeb4deabe145a57faa4fec4c0fa2a10",false], + ["benchmarks/results/transit_2026-09-08/screen_blocks.execution.json",16408,"9b08f4867717b69c67f1f2ff43e5aed66775afc963261c7fc4c216761bd97a1e",false], + ["benchmarks/results/transit_2026-09-08/screen_blocks.json",7337,"2e837e872a3a81a40285df0439cfc04a08b69cae3bce3cd1b799c260beb37540",false], + ["benchmarks/results/transit_2026-09-08/screen_blocks.log",2245,"f2bb9e7ec6fb1f345d1bc9d3bd5550323e052250f1e6f9d04f4d9fec243dafba",false], + ["benchmarks/results/transit_2026-09-08/screen_remaining.execution.json",15600,"dce5c529f3aa6d9d90bbb40ef44e9640f98bb0c81b927726dc5bcf890c5e43ee",false], + ["benchmarks/results/transit_2026-09-08/screen_remaining.json",6483,"f4ce7aa5f129bac6c4383455e5b95173072d33dff70d7768d7ffba76a4cdc16c",false], + ["benchmarks/results/transit_2026-09-08/screen_remaining.log",2286,"fa5fd5270efbd6a4b40847993d0f243b0ec522d7e3c8d28bdb4a2ff6f830c303",false], + ["benchmarks/results/transit_2026-09-08/selection.json",87349,"5f31dd98786241e1d42f3917ca8210bfb6dca226069d53d9c6109fb7fd3dd0db",false], + ["benchmarks/results/transit_2026-09-08/sources/astropy-pypi.json",1954198,"0aa8f86e0bca4d100cf7017cb8f50b3d5a3e4c9f9624fd6e24f28f58f5a24bad",false], + ["benchmarks/results/transit_2026-09-08/sources/cuvarbase-pypi-bls.py",39829,"6c47eebf3ebe3c7e458ba21882cfcc8011d91b00fa9f5d493c43b2d44e8bee06",false], + ["benchmarks/results/transit_2026-09-08/sources/cuvarbase-pypi.json",21455,"b135d1a90581f407fc1321d704b0b6064dba4e8aa2b54a92b74850222f79a88c",false], + ["benchmarks/results/transit_2026-09-08/sources/gputls-pypi.json",7436,"bd5a76e231bc17ff5dd4f7a23404f3fb0d2988e0ff87eee9db403eff0d2346a7",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/GPUFun.cu",41674,"6d4da1b66b3aaa830061def28237ffba74a959f77b3e5a56d895d4aeace0655e",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/GPUFun.py",41733,"e1013a4ac3fb0877fb51b5a67d1dcaee8eaf7ed54b41179f1ff7fdcd591dcc85",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/GPUFun_bak.cu",40636,"eb6061e31a19a1e06b6cd677e0af253bb05b41dd2390ac90b62573d2400ec766",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/__init__.py",122,"14d455c3f31d539f5472adf9b36330a042dd547362f7d4612d146d7b457c813d",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/_worker.py",15200,"9cc405dfa957b171c5659000faafaca1617edf332406f720268b3b293d9015fb",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/compare.py",491,"52b0aec50e12d0ed4f26d600425cb9d32cf46139124171847950f1eda02c1384",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/constants.py",5417,"1c3a84b46c9d7e7b0e09f757a6466d38c852c533311b971a71cc1e59b37eb40a",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/core.py",68778,"dc5ad7b322ef9cd475250c95c224e5fced0c4f42893c02b8383ed83c2522e92d",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/core_bak_new.py",57607,"6220a818f8ef835b2b277d9b2bb07ef82da5ea78f0c110f77c4a6190eab15250",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/core_update_bak.py",63599,"f00566cda099997bccb9fdcd302f7f4057795fcc5f0efe1fc45cd67b0b0417b6",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/grid.py",4769,"b3f2f6b601f4bdd2b103d53f8ac8b6d38f3ac2ef8f4d4793984e155db750d7a5",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/helpers.py",3902,"4bdb67f63e17d9cae60378a130543bb9411215c719ab58f180f5ce83bb8fcfbe",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/interpolation.py",1611,"b9b437f70d85b39c27db3a0f0d4f5f950a6374809eadabaaf70adf382cb33152",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/main.py",8400,"07b56a401c42a661e8f5dc9e098e558ef07ce4131b2b112297d3a7265639710d",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/move.sh",415,"6c8642dc06cd91a9e901163e4376d7dc8ac21cd3719a5bdd7d63a106cebaf306",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/results.py",976,"e7f1a10e62915d016db244a27f62141022f37f9b9a082f21acc96f7aed083ad1",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/stats.py",18345,"7b6eac3090282c294980e931ef48cfa1a84db9ffbdf27cafdebd8e18f923112a",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/transit.py",7240,"2f7c4d2c61c132e41089c8f5667f7e0640c15443d523d4a3b935f56d5c182a08",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/validate.py",7920,"f442df670eebf613e50a671f2a20022e17798ee5c60b06f23fe385a2db9ce38c",false], + ["benchmarks/results/transit_2026-09-08/sources/gtls-head/version.py",46,"cdbbfebc80445e145382ec2c3307c9936a0cb876413f6793341780e7eeb78cac",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind-build-source-audit.json",244,"cfd2023218f3dbf0d81e405250f52ebd3fac9e584b961db1d40a0d0708dafae5",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind-pypi.json",13601,"ac0ef9d24e56060e1f6dbfca381e4b8550d5ea765ea712846411465a73622d42",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.clang-format",100,"a6026c4722ab12786bc5b92ab0a3ee63bed216d94d9bf65d743c6a7e90801472",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.github/workflows/docker-publish.yml",2341,"d252e40952fc1a8197e7fa461bef698c986c0ba9f9dee7e7558845d6d1e2ccb3",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.github/workflows/docs.yml",1108,"84de7a5d47eca0070903e0187cea8e5084ae51d65b72505014239b2fb52a7484",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.github/workflows/tests.yml",2233,"245cb27d8243552c9cebbe017d66290e8bc851933141c9819698439eb0155cc3",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.gitignore",2380,"df10531608edcfc83f83653e5eef40bc6a73151e68aff4e0a62c41ff17d42b92",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/.pre-commit-config.yaml",1237,"d36c3134e45fcd08e70c6112010295b1419ac94b672395efa4f0f131a0a1aadc",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/Dockerfile",1806,"97038d2d338ddfd963131a9349c2859b25ca4643ef209191c35f5f9df14edd60",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/Dockerfile.gpu",2569,"effef1a59dd15f956e0bb96cc04e99f2c628aa131d822db546acd975a8ce7add",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/LICENSE",1553,"16d86d7c7da69dd9ecc9daa21ae7bfbf1b71daa336978edd88ceef76de4d46f9",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/MANIFEST.in",170,"e455b961a12c485c4fb1326e18c5e01ccbbc6cae5f876e0affe5256322e2b9fb",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/README.md",9580,"925a6c757f6ba44e340f3094f284bf1ce55994360317ca61c73a6d1a957d6cc0",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/plot_throughput.py",5577,"6411daf89843db61569803ef54b6d356c49497faa8fd7975d991568ebc7daa1c",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/run_bench.sh",1571,"a81ed290e7a7971f80ac17b960c7b3d0bfe280b495a6a0bc11cd23bce197c6e2",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/throughput_bench.py",8497,"f4a7a60f5d7dd76e33bbcec8db45952ba1b24f9c3062d34f04f82110189aef60",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/throughput_results.csv",6315,"e870223bb4edb0aee3b693e71fd3d8316a9fc4d8c110aa3a91b590a5ea75c545",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/throughput_results_1gpu.csv",4166,"f8836595b5bde4705f8415a06bd0f21adf564f91bcae2b8efae63259d2029fca",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/benchmarks/throughput_results_2gpu.csv",2234,"57397172e0caddd2131f0e8fc155378c4442ceb1c7b47b238aac2e98b0b16bdf",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/api/core.md",704,"cd87a8f2d6d60a6adf6c692b7998f5b2305e2a076864f50175b9a163afb8b345",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/api/cpu.md",599,"21f9f64b4e537f204ce98dd4742f3b1eef30788c66fcad53d8007293eb0ac05b",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/api/gpu.md",1130,"cfb258c83d270d23c3a51bb9719b6309c8a93bdacc055b2f334670d01425259b",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/api/index.md",717,"447e6a391f1ac5293e0ee6bb4a0d34408f049f842677fceaa9f28f67ce551c12",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/api/utils.md",255,"1703c490d48be3443cf579beb025af5d5a2a7e696948a5aa326a3651098ae3ee",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/benchmarks.md",5094,"5c5a3154340ace52c4cf68df03f98b22026a3b071a6d0c4832c9257e37c7c8eb",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/getting-started/installation.md",1552,"aa860899894d68fcb82679b39a6f495037789eace1bbedaaff54772e5dff82cc",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/getting-started/quickstart.md",4183,"669d9f576989c4a4fb4ff99d771391f4d8289d7c87a408e37f1c783d5b198263",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/index.md",1747,"9a3b006658b390c627f338fcbb4ef72016b1363de8d08c9849d91c926415d28f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/requirements.txt",80,"49599a823a37f442a11a198378de440b696cf442a29e88a959fc315d34725c7f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/throughput_points.png",201800,"69af182ba7e2edc31758296d3819bb15d839a4bcc7a4c2015df9ebf643376c35",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/docs/user-guide/algorithms.md",4889,"5f5d8ede7391262d3d76678ad36964f1f3910bb48726b4be551ca42101ed3246",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/mkdocs.yml",2306,"4d3edd262047a8f897c13e9c8b3c30baa7d676ffa464fbdfa4c1d42f3f9813c9",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/__init__.py",16359,"056942e5a8514e5b2865bd4e8abde9b316eb0c15db0f575a3dc4efb4ce124a81",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/_utils.py",2596,"cb64dfcf1ccb29ba24f0415defa538a034a1934a0fb7b85c806bce065e6337dc",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/aov.pyx",6759,"ae91a3226835fda47db8f0d37cf5ab95103d44b16315006e8a84d1c377fd24c7",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/bls.pyx",6041,"b0e7de7fac14f9c0987c7d76df6282af89f1d5b055a6576aecd92a68f3985c63",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/ce.pyx",7394,"3a94ef2c2eecc6de0ee6b861cfb5ca3f307f5f74be0c70e806e22564a1f850da",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cpu/__init__.py",44976,"9b5f41481e9b519cb89442bd0d52c8153fe24673c677acabdff0106982e250ab",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/aov.cu",22677,"399a053d1d14cec88a5844332c4b46fccf9ebacb717d2cd4f2b7d80fe3f2c39f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/aov.h",11821,"e4d6979a2e3b25a362fd4b5050e463bad4dd099b1152c3f8797ef7371705e597",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/bls.cu",18937,"a50f963d274a3e90064a127fce7acc40c28cf97ba79ad773d577f68ccb1b87ef",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/bls.h",1604,"125ff66d91f230b86b6ef5d50cb50b49aaf1f4e9d65810d529b26db4252d3b4c",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/ce.cu",23114,"7f5dc4e4b9963adfeb6b92fe375ae94fd58b459ad769b2c6046f48e3684c40d1",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/ce.h",14149,"1552f464a9b7bf633c3777709c7e86e7899eb273a0d37ad33a88d735fdfd0545",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/errchk.cuh",898,"9f443aa7b136e5784b5ad98002baf5020c1f74fdc27d8f2806efe5e9095ee380",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/fpw.cu",15932,"0c4cff5ac1c5e48b987df048e925930c4489d0e53495df836d0bfc72b0627ed7",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/fpw.h",1445,"4ddbe254691437a6422f3453cf31da50a49bf5135175924e13f0eb358d08696c",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/ls.cu",14338,"a89b6f580b0c7f7cb94b48f307dcf645b2d95d1276efb983fe5bbfa40d54db44",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/ls.h",7372,"494b7e335bf58ad9f30c4fbea44fb7fb473c8944522d05e58f5a9bbbb185b4f8",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/mf.cu",19243,"8eadeb67071d528541026d09eba73a2198fcd093b11a537de6ec86a0f7bb255d",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/mf.h",2145,"7743725045c9a078d8588af32211eaf3ec01da98e82d69d5fe8835016c4be06a",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/mhf.cu",17576,"8275124306aa4d15a671a0c9a4dfaeb72fc73ee7209b2c72a1ac231cb8102d6f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/mhf.h",1487,"696039be821f317d09043d10758de0841f9ff1e0b0f39f506c1ab76185cbf2c2",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/vn.cu",15550,"ac6bd2360f98ca7221d35f3c0c2283c94f5a4e70ddb9952b20584717beee9235",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/cuda/vn.h",2353,"c6c095322daa0c64231e5845a650a55af4fc123821c4f3eb56606c3889420741",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/fpw.pyx",5768,"febe6904f3a7a8e98c89704e99ea017daa5d354e228d5643ec507221a5c4edbc",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/gpu/__init__.py",665,"442371f3b4372a06e4a9c1d11df1451b05d3a49235b3188289a4835c0b3dfa38",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/ls.pyx",5995,"619256137a6ffa49e5e06a21d30f0a0e3ebc4802d7a6b9ae6833967108b0c3d6",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/mf.pyx",5877,"4338853ad13c239b8d0298362f1f0699667d6f859fa0fed2f532555030bdd34f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/mhf.pyx",5889,"0d3a73300ab2bfa5bac4bddecbb64d2e8492f9d0991b571bdffbf74e2d8baebb",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/periodfind/vn.pyx",6043,"5fe0fb0e48a63e382ad2827730913cd73f0b1b271c41795e0f547f28f160ebd6",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/pyproject.toml",2146,"7d1f22e652fae83705954bae82f58e39a594f8107a6b1e7694b6c1feec754ab7",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/Cargo.lock",20473,"c7edbabf6b2371e95a2ec434054ad1edacac75565ba12a3de2b65006355120bd",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/Cargo.toml",247,"b3ad7d7be56775841985a731fae485b5eb850c7245897f6b5c4f591d7ae395df",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/README.md",8267,"05f2c1e9c71d79781ca835d83e2ff8e116978289ca33b16d40d5d6a5b34761b4",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/pyproject.toml",861,"48bcbd11cfc816947593fc9622c63a82c1e6f88c5fafa04be48a95861e003cae",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/aov.rs",2684,"373af7ca002cb984b994f39151a3d93bb5b07485fe10da53ce4503273e033b09",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/basicstats.rs",13245,"26aa206abc3a9f44ecca41f2287b99ecbbcfc116a0fd56d0494327e0485d3471",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/bls.rs",4676,"e41d11ec4b4372a032fd0f9e330061a241421288cd019d9a3a8101cb8a4c0e43",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/ce.rs",2973,"b394fc28ef59baccb789d4dd72f46be0f63e613bdcc978aeb6b8d1bdfdaf6c74",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/dmdt.rs",6182,"9d1044905ef7012fb21a6b323497317f983acd62f3be48abe249e69246a3747d",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/fold.rs",1028,"4e3b5f78d1f86a4a30239f59fd19cd6ee908fdcbe85a03fdb2201533f201886a",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/fourier.rs",12915,"ff12b3a8dbc069eafa31844cc104c9cc0698d9ec3ba9630fcbeb53d3e7937f34",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/fpw.rs",2175,"e353aa504dbd343aa5dbbe154beff15c9059b1668addb1130937f8262b069b95",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/highcadence.rs",3795,"937699abcb34d84d4c7121a564a91a6de1fc30f7e4871ef8b45d5811e2740497",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/lib.rs",41623,"0abe243a2d86dfc65cd9ab560ffa0f36c5b87ff8910bfcdd8f4daaa84df2d6b2",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/ls.rs",2546,"441639e9e3f50519604a2b8d467d4f7d5bcac3d74e82db0bbddddb434ab1bc79",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/mf.rs",24921,"999d219119e958af68d708851910f089bcf12d4d2a389a473cf4a496cf14e9aa",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/mhf.rs",19430,"b58e6b30c15b9b33ade5e07c3999b0eafac19e08deff61fe604b7e513093d2a3",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/peaks.rs",12591,"b435634ffe1cdbf5b019b1e28af198417e22299a5d114fff71af3dc08e109cd8",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/rust/src/vn.rs",11430,"0c1bb7044dec6be9fc5c6f2f6d4a0ab3d466f1c5f1be47d710ef07b9a9e153ab",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/setup.py",8722,"4ac08554be0cdfc3fbef319d545e2cbc2346841a1aa77d50543ce7c0162c7d36",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/bench_cpu_vs_cuda.py",4349,"926ee9033b6704a3c059eba6242f12e77f585202ef8ff2bc7a0dce363a42e320",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/bench_cuda_opts.py",9862,"94c6d7776b2087d8f89750fe7b4864b0611cd13aa4481b6f3893ae7f742f3a54",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/test_batch_features.py",10583,"b7d0968ad37c22c698f8421e0b4801006b223f5ad7a04d232e8979016ec3ca3f",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/test_cpu_standalone.py",47169,"7667011b59bcd17e4844268c49b694933b71c773b3572cb629dd54d498f90ad4",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/test_cpu_vs_cuda.py",22877,"aa3c34e0f95f843acf11f2e4b86db4bfdab442ac907d6a4bdc8892f5483c03d5",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/test_fourier.py",8296,"99d72cc619002a210de97467a0095d46ce876d6d455770bc436131996bd523d0",false], + ["benchmarks/results/transit_2026-09-08/sources/periodfind/tests/test_periodfind.py",51729,"3726f4e6855396de54993f2ecc5cbff3fb27a7ae6dc0bf85b7ec3b54d8ba24c8",false], + ["benchmarks/results/transit_2026-09-08/sources/qlp-tic261136679-s0001.fits",80640,"88717888d4f8be862db8e395ab134cd5e5391a10b0b6e8a81d8a2abac7b9da75",false], + ["benchmarks/results/transit_2026-09-08/sources/qlp-tic261136679-s0027.fits",198720,"4ae15c58eb5d457cb23cf387e207bd5b1208b401c69221d7dbaf9085a039f424",false], + ["benchmarks/results/transit_2026-09-08/sources/qlp-tic261136679-s0067.fits",714240,"94355faec5d89c22b04de4e2669879e2a56f8bea6794fc0dc3684539e89c3ccd",false], + ["benchmarks/results/transit_2026-09-08/sources/tess-downloads.json",1060,"3c5ce95a4efba32e4d8be0e1ba2b333ff2472392a62517155ab48194111b33a8",false], + ["benchmarks/results/transit_2026-09-08/sources/v1-archive-git-verification.json",408,"84912f81dc2cede58748c6d4c8d25219fcbcd41a93bf518346fe0ee524d9b7e9",false], + ["benchmarks/results/transit_2026-09-08/speedups.csv",1550,"a68649f6a63de7be806ad4e4c32b64cf80745f7c5c41e9b2b5244881657cb910",true], + ["benchmarks/results/transit_2026-09-08/timing-methods-before-cpu-scheduling.json",13204,"e4adee6a401ebd89df76aa7b8fced2dc389144d0fcc75db91b3fdceaf2c6a8ab",false], + ["benchmarks/results/transit_2026-09-08/timing-methods.json",13342,"509e1adb12b74f8425f4a7583f1b61139f1a201d82673c1855ffe1398aed92e5",false], + ["benchmarks/results/transit_2026-09-08/timing_analysis.json",396769,"787d0c184abc4dc2eaef89fa2448ac87389753c396260310b235bd70da140f99",false], + ["benchmarks/results/transit_2026-09-08/timing_summary.csv",8541,"0c38212cc7cdbdd9b7f344357cb3c6de3c59b5858a8750d7e29797de7c29757f",true], + ["benchmarks/results/transit_2026-09-08/timings.execution.json",31499,"4d409f2fb776bd7a13471cfbdfd37b49f98b559546a5abd11a78f5dc057db6e5",false], + ["benchmarks/results/transit_2026-09-08/timings.json",13046,"b5bfdb6caff217f746c3a471eaf0c543cf6c5f3875c923a00351694d06a05903",false], + ["benchmarks/results/transit_2026-09-08/timings.log",0,"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",false], + ["benchmarks/results/transit_2026-09-08/tune_injections.execution.json",34844,"50557165b22372da51c46b1f6a80205026f23275c0bea96aa2bda09d94e4b508",false], + ["benchmarks/results/transit_2026-09-08/tune_injections.json",14122,"70257e01f0bc00d15ebfb1591fb005cea01d3126158e2d9a8d53bcc6c355ec4d",false], + ["benchmarks/results/transit_2026-09-08/tune_injections.log",4679,"29176813ced1a202b95bad2e7f58218415c834f9493e82932fd081e446dba0c5",false], + ["benchmarks/results/transit_2026-09-08/validation-distribution.json",2499,"7194b465964b87d11958604461973c1975b3ffa4d0e624a9efcc1241d1c4687f",false], + ["benchmarks/results/transit_2026-09-08/validation-methods.json",7577,"a9ef5f82dae22bc2ddbd6cadfbddee29f5f2a2bbe33473da666428a026518e16",false], + ["benchmarks/results/transit_2026-09-08/validation.json",12295,"461a8484a6182fcdd1d6bb5b39ee9c2bf1fde99729236d8243c48c7a9299e2d7",false], + ["benchmarks/results/transit_2026-09-08/validation_a.json",4122,"0be651a1566b415f87c79b04ece5697d8905c0e81944a9cb8835fb64a3ea79dd",false], + ["benchmarks/results/transit_2026-09-08/validation_b.json",4350,"71f13a16ded5f962cca46c8f91e68ba2d47409b917f91d3c0d299272f10edcb6",false], + ["benchmarks/results/transit_2026-09-08/validation_main.execution.json",28812,"7a3664b28d27200f6aa67ecede1b80ca9a5a83103e772c6a1fdcf84cfb1b5963",false], + ["benchmarks/results/transit_2026-09-08/validation_main.json",10811,"1d6607e5a5d7843788472b9ff087c8b468f52c1c05f0b5fbd47d7b7437078045",false], + ["benchmarks/results/transit_2026-09-08/validation_main.log",3516,"2907a5c6f62c700520052dce8eb59a028b18ef6056865db5a27a6cc6092553bd",false] + ] +} diff --git a/benchmarks/archives/validation-release-prepared-20260927.json b/benchmarks/archives/validation-release-prepared-20260927.json new file mode 100644 index 00000000..6059c12f --- /dev/null +++ b/benchmarks/archives/validation-release-prepared-20260927.json @@ -0,0 +1,28 @@ +{ + "schema": 1, + "id": "validation-release-prepared-20260927", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/validation-release-prepared-20260927.tar.gz", + "bucket": "cuvarbase", + "bytes": 39631, + "sha256": "ce9c787c6db964b50dd84dff5d835cfa5fd05666167f4daec47a54f4f8b43dbf" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["docs/validation/release-prepared-20260927/checks.json",3292,"e306a30f42c9909687e08d4f7c6795100962cad06cf16e09ae50fab516df0dd4",true], + ["docs/validation/release-prepared-20260927/host-suite.log",37417,"64b817a0a978d460a16d9af524186db7383d5db0d999f3d3f4280209a1464f17",false], + ["docs/validation/release-prepared-20260927/host-suite.xml",494714,"7334e2eeab840b5751b016f59bb62e550cf272d84a200976ddc5addd9725c188",false], + ["docs/validation/release-prepared-20260927/package-verification.json",10205,"2480a7d9be165d3b63d9c5ff7512c00189f08978343cf0f2565702366901e81b",true], + ["docs/validation/release-prepared-20260927/release-tools-with-batman.log",379,"6dd9d7d6248fd93d21c8c55492a9ce03236919b3bbe0aadc272381452905f7e8",false], + ["docs/validation/release-prepared-20260927/release-tools-with-batman.xml",32875,"59d9a61c9572889a5f4b969e0ec97f42cc5e4581b0fa2cc7292e89af83097af8",false], + ["docs/validation/release-prepared-20260927/sdist-installed-smoke.log",1883,"474ea321b21ad0fe5c6c3b3ac9b9f54d3e0fab1f7a9935dbb9b68d2b34c7a2fe",false], + ["docs/validation/release-prepared-20260927/twine-check.log",530,"429938d5e7c3145bbd9f6487f1015376ac6e6fa08f18e02f3143d141694d2047",false], + ["docs/validation/release-prepared-20260927/wheel-installed-smoke.log",1231,"2eb7208a5089661c5ba446ffd1b5a80093d7ea7dbc528302e80731870657a266",false] + ] +} diff --git a/benchmarks/archives/validation-tls-default-20260910.json b/benchmarks/archives/validation-tls-default-20260910.json new file mode 100644 index 00000000..fcfdd38f --- /dev/null +++ b/benchmarks/archives/validation-tls-default-20260910.json @@ -0,0 +1,33 @@ +{ + "schema": 1, + "id": "validation-tls-default-20260910", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/validation-tls-default-20260910.tar.gz", + "bucket": "cuvarbase", + "bytes": 24276, + "sha256": "fbeaff2c7f46f41d7afccb27d62a12799c7ba7c376295b850b2a8cea1932f89e" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["docs/validation/tls-default-20260910/.gitattributes",8,"705fd4d6451a31d36b3df7de96f83f30ac976c9b4a6d1e51671d8e2f33e2d0da",true], + ["docs/validation/tls-default-20260910/README.md",2244,"a104ba160afbad55f55db35ccf05bd950247af8c4e356ebf97b2a94fc8df9598",true], + ["docs/validation/tls-default-20260910/bls-source-continuity.json",25301,"f5a71d4ab76085f8ddab9fad3b921f64a9a930eb04b99284b19683ebd0fd7adf",false], + ["docs/validation/tls-default-20260910/cpu-suite.json",1307,"dd25bfc51b6305693a35a5ad2c6adc152633d336458f6c9e2d3740e40a8a2087",false], + ["docs/validation/tls-default-20260910/cpu-suite.log",36554,"70088c1e62d7fd92e7eeac96079e954bf187e0c9d6abb02591f3efd22d77d92b",false], + ["docs/validation/tls-default-20260910/distribution.json",1924,"25b5b7ae43d6526d630b7c1ec47e2444fd285f1a5fca72fa6cef1a62fe59bae5",false], + ["docs/validation/tls-default-20260910/docs-build.json",501,"5787374b477cb17a9c61b39d307545280d87b75c9dae5d3aefdce35b73db96df",false], + ["docs/validation/tls-default-20260910/docs-build.log",8240,"017b53cc29e448926f57606c11876041d6734c99dd80c285f0aff6be60028dd2",false], + ["docs/validation/tls-default-20260910/docs-warnings.log",3548,"65ffe55c1731ac952633bebaac604961886f63328ce5568a2435b4897e329895",false], + ["docs/validation/tls-default-20260910/host-environment.json",532,"945b11966abad283b8e658947b8a1fe3a8e5a0be254e4c2af623032c7d114476",false], + ["docs/validation/tls-default-20260910/receipt.json",18900,"0c87955dbc913ba3e2c30d6f210ef97e9a963846f4c89a83c1812fc5108773a2",false], + ["docs/validation/tls-default-20260910/tests.log",4037,"375fd3c9f134441854c1a0c0e4369b9cfdf3acaffa4a55181a84d2120d96bed4",false], + ["docs/validation/tls-default-20260910/tests.xml",37551,"fe186ff94c4f020dbc12e520ef3ba54577c9ecce133644d4609fec635e6b8847",false], + ["docs/validation/tls-default-20260910/wheel-tests.log",179,"af8fa3a4a6e1eef19766efae6324b5a5c181d74e647d58122b45d222f9e4220c",false] + ] +} diff --git a/benchmarks/archives/validation-v1.0.0.json b/benchmarks/archives/validation-v1.0.0.json new file mode 100644 index 00000000..30d7bd31 --- /dev/null +++ b/benchmarks/archives/validation-v1.0.0.json @@ -0,0 +1,32 @@ +{ + "schema": 1, + "id": "validation-v1.0.0", + "source_commit": "403c75d7425e92b8a3d112672a04177189117a61", + "archive": { + "key": "benchmark-evidence/20260928/validation-v1.0.0.tar.gz", + "bucket": "cuvarbase", + "bytes": 66423, + "sha256": "8cadf337f0bba6289a442bac6e3cae3c45ee0d905cc5870400306d0f28d6fb55" + }, + "columns": [ + "path", + "bytes", + "sha256", + "kept_in_git" + ], + "files": [ + ["docs/validation/v1.0.0/.gitattributes",54,"c03490fb1e9480ac3d401dac00f3265bd04aa633e1bab6778620ebbd9c13fda1",true], + ["docs/validation/v1.0.0/build.log",23825,"070717bdf4b46620628f03e9f299b5300cf341c9cdf4145f2ce6f74f257e33f4",false], + ["docs/validation/v1.0.0/docs_build.log",6189,"5a20a55717c9fbc45a65e317250a74ea1701c30198dc022d1bcbb171ca029a58",false], + ["docs/validation/v1.0.0/docs_figures.log",1241,"97d6ffea1bf68b571e0fd77332f71594e3649c63ccfacd84f2cfe15e2fef5403",false], + ["docs/validation/v1.0.0/env_record.txt",6010,"5a253984a2e10e6bac3b7e0af3d057e086c1de9469a18c1e3942b056b81869cd",false], + ["docs/validation/v1.0.0/release_gate.log",1474,"9b8f3cdbfd3e82016a144b4499df2c460a9e1aff7d55719551baec242145362e",false], + ["docs/validation/v1.0.0/sdist_pyargs.log",185249,"5db90a478f02ff1aee6d9473e81f49329d38d1f9783cd5f4946c7cc7420d566c",false], + ["docs/validation/v1.0.0/sdist_smoke.log",752,"9561f30dc096f103c0d37cd1228be135cc96ad6c7fa06bee1df3b9769b6bae9c",false], + ["docs/validation/v1.0.0/source_provenance_final.log",145,"36f6a96c796f20ab682e35e3d5d871538b63bb47d288a0121e29bafa48be1202",false], + ["docs/validation/v1.0.0/suite_full.log",199301,"a57b2acf369d755adb8eee643f8922c83196acd8fbee6aec78455ee8434663c1",false], + ["docs/validation/v1.0.0/twine_check.log",118,"67995ee1319a718222466563ea3f8daa6d06203b8917108d89b07753852fa820",false], + ["docs/validation/v1.0.0/wheel_pyargs.log",185249,"14a6e3f778090066d786bd873789821f9282627343e68605c867a6b03f14ecd3",false], + ["docs/validation/v1.0.0/wheel_smoke.log",752,"0650fa25101419bce42a46e80df2778f1faccc69fccf40c280b99423d60d912d",false] + ] +} diff --git a/benchmarks/nufft_lrt/README.md b/benchmarks/nufft_lrt/README.md new file mode 100644 index 00000000..ebb058f4 --- /dev/null +++ b/benchmarks/nufft_lrt/README.md @@ -0,0 +1,7 @@ +# NUFFT-LRT validation + +`validate.py` generates seeded null lightcurves and transit injections, searches the selected detector configurations, and records recovery and timings. It requires a CUDA environment with the cuvarbase test dependencies. `summarize.py` derives the tables used in the experimental detector documentation. Both accept `--help`; the validation tool can split work by configuration/arm and merge the outputs. + +The TLS comparator is the earlier **binned** engine, retained today as `method='binned'`. The dated NUFFT-LRT tables do not compare against the new observation-level TLS default. Use the [current transit report](../../docs/TRANSIT_BENCHMARKS.md) for standard TLS validation and speed claims. + +The [September 2026 validation record](../results/nufft_lrt_validation_2026-09-06/README.md) specifies the frozen source, protocol, process split and measured results. Its launch script is a historical execution record; use the maintained entry points here for a new run. diff --git a/benchmarks/nufft_lrt/summarize.py b/benchmarks/nufft_lrt/summarize.py new file mode 100644 index 00000000..004bbbb7 --- /dev/null +++ b/benchmarks/nufft_lrt/summarize.py @@ -0,0 +1,330 @@ +"""Render the NUFFT-LRT validation JSON as tables (markdown or rst). + +Usage: + python benchmarks/nufft_lrt/summarize.py results.json [--rst] + +Prints the null calibration, the protocol, one completeness table per +configuration (plus the arm cost), the epoch recovery of the arms that +report a best epoch, and -- when the JSON holds paired configurations +(``white`` / ``white_bjd``, ``red_sys`` / ``red_sys_nzm``, harness +version 2) -- the one-to-one comparison of their per-lightcurve +statistics. ``--rst`` emits reStructuredText for ``docs/source/nufft_lrt.rst``. +""" +import argparse +import json + +import numpy as np + + +TITLES = {'white': 'White noise', + 'white_bjd': 'White noise, absolute times (BJD-scale, ' + 't + 2457000 d)', + 'red_1x': 'Red noise, sigma_red = sigma_white', + 'red_3x': 'Red noise, sigma_red = 3 sigma_white', + 'red_sys': 'Red noise + shared systematics ' + '(PCA basis + population prior)', + 'red_sys_nzm': 'Red noise + shared systematics, ' + 'non-zero-mean basis columns'} +ARM_ORDER = ['lrt', 'lrt_auto', 'lrt_marg', 'lrt_seq', 'lrt_flat', + 'bls', 'tls'] +ARM_LABEL = {'lrt': 'LRT (explicit epoch grid)', + 'lrt_auto': 'LRT, default path (epochs=None)', + 'lrt_marg': 'LRT Detector A (marginal)', + 'lrt_seq': 'LRT sequential cotrend', + 'lrt_flat': 'LRT, flat PSD', + 'bls': 'BLS (eebls_gpu_fast)', + 'tls': 'TLS (tls_search_batch, delta-chi2)'} +PAIRS = [('white', 'white_bjd'), ('red_sys', 'red_sys_nzm')] +# within-configuration arm contrasts the docs quote (A minus B) +CONTRASTS = [('lrt', 'bls'), ('lrt_auto', 'lrt'), ('lrt', 'lrt_flat'), + ('lrt_marg', 'lrt_seq'), ('lrt_seq', 'bls'), ('lrt', 'tls')] +N_BOOT = 2000 + + +def _arms(cfg): + return sorted(cfg['methods'], + key=lambda n: ARM_ORDER.index(n) if n in ARM_ORDER else 99) + + +class Md: + def h(self, text): + return '### %s\n' % text + + def table(self, header, rows, title=None): + out = [] + if title: + out.append('**%s**\n' % title) + out.append('| ' + ' | '.join(header) + ' |') + out.append('|---|' + '---:|' * (len(header) - 1)) + for r in rows: + out.append('| ' + ' | '.join(r) + ' |') + return '\n'.join(out) + '\n' + + +class Rst: + def h(self, text): + return '%s\n%s\n' % (text, '-' * len(text)) + + def table(self, header, rows, title=None): + out = ['.. list-table::%s' % ((' ' + title) if title else ''), + ' :header-rows: 1', ''] + for r in [header] + rows: + out.append(' * - ' + r[0]) + out.extend(' - ' + c for c in r[1:]) + return '\n'.join(out) + '\n' + + +def _period_hit(p_found, p_true, tol=0.01): + if p_found is None or not np.isfinite(p_found): + return False + return any(abs(p_found - x) / x < tol + for x in (p_true, 2 * p_true, 0.5 * p_true)) + + +def _detections(m, depth, p_true): + """(stat, period_ok) arrays of one arm's injections at one depth.""" + inj = m['injections'][depth] + stat = np.asarray(inj['stat'], float) + ok = np.array([_period_hit(p, p_true) for p in inj['p_found']]) + return stat, ok + + +def cell_uncertainty(m, depth, p_true, rng): + """1-sigma uncertainty of a completeness cell: the null-threshold + sampling error (bootstrap of the null maxima, N_BOOT resamples, + completeness re-evaluated at each resampled 95th percentile) and + the binomial error (half-width of the z = 1 Wilson interval), + added in quadrature. Returns (completeness, sigma).""" + nulls = np.asarray(m['null_stats'], float) + stat, ok = _detections(m, depth, p_true) + n = len(stat) + p = float(np.mean((stat > m['null_max_p95']) & ok)) + boot = np.empty(N_BOOT) + for b in range(N_BOOT): + thr = np.percentile(rng.choice(nulls, len(nulls), replace=True), 95) + boot[b] = np.mean((stat > thr) & ok) + s_thr = float(boot.std()) + z = 1.0 + s_bin = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n) + return p, float(np.hypot(s_thr, s_bin)) + + +def completeness_table(fmt, cfg, rng): + depths = sorted({d for m in cfg['methods'].values() + for d in m['completeness']}, key=float) + p_true = cfg['config']['p_true'] + header = ['arm', 'null p95'] + ['depth %s' % d for d in depths] \ + + ['ms/search'] + rows = [] + for name in _arms(cfg): + m = cfg['methods'][name] + row = [ARM_LABEL.get(name, name), '%.3f' % m['null_max_p95']] + for d in depths: + if d not in m['completeness']: + row.append('--') + continue + if 'injections' in m and 'null_stats' in m: + p, sig = cell_uncertainty(m, d, p_true, rng) + row.append('%.0f +- %.0f%%' % (100 * p, 100 * sig)) + else: + row.append('%.0f%%' % (100 * m['completeness'][d])) + sps = m.get('seconds_per_search') + if sps is None: + row.append('--') + else: + ms = 1e3 * sps + row.append('%.0f' % ms if ms >= 100 else '%.3g' % ms) + rows.append(row) + return fmt.table(header, rows) + + +def contrast_table(fmt, cfg): + """Paired (same-lightcurve) completeness differences A - B within a + configuration: b = detected by A only, c = by B only, difference + (b - c) / n with sigma sqrt(b + c) / n (McNemar), each arm at its own + fixed null-p95 threshold.""" + depths = sorted({d for m in cfg['methods'].values() + for d in m['completeness']}, key=float) + rows = [] + for a, b_ in CONTRASTS: + if a not in cfg['methods'] or b_ not in cfg['methods']: + continue + ma, mb = cfg['methods'][a], cfg['methods'][b_] + if 'injections' not in ma or 'injections' not in mb: + continue + row = ['%s - %s' % (a, b_)] + for d in depths: + if d not in ma['injections'] or d not in mb['injections']: + row.append('--') + continue + da = np.asarray(ma['injections'][d]['detected'], bool) + db = np.asarray(mb['injections'][d]['detected'], bool) + n = len(da) + bb, cc = int(np.sum(da & ~db)), int(np.sum(db & ~da)) + row.append('%+.0f +- %.0f%%' % (100.0 * (bb - cc) / n, + 100.0 * np.sqrt(bb + cc) / n)) + rows.append(row) + if not rows: + return '' + return fmt.table(['A - B', *['depth %s' % d for d in depths]], rows) + + +def epoch_table(fmt, cfg): + rows = [] + for name in _arms(cfg): + m = cfg['methods'][name] + er = m.get('epoch_recovery') or {} + if not er: + continue + for d in sorted(er, key=float): + e = er[d] + rows.append([ARM_LABEL.get(name, name), d, + '%d' % e['n_detected'], + '%.0f%%' % (100 * e['frac_within_half_duration']), + '%.3f' % e['median_abs_error_d'], + '%.3f' % e['max_abs_error_d']]) + if not rows: + return '' + return fmt.table(['arm', 'depth', 'detections', + 'same transit (within dur/2)', + 'median abs. error (d)', 'max abs. error (d)'], rows) + + +def paired_stats(a, b): + """Per-arm one-to-one comparison of two configurations that saw the + same lightcurves: max relative difference of the per-search + statistic (null + injections), how many injections found a + different best period, and how many detection decisions differ + (each configuration at its own null-p95 threshold).""" + out = {} + for name in a['methods']: + if name not in b['methods']: + continue + ma, mb = a['methods'][name], b['methods'][name] + sa = np.asarray(ma['null_stats'], float) + sb = np.asarray(mb['null_stats'], float) + pdiff, ddiff, n_inj = 0, 0, 0 + for d in ma['injections']: + ia, ib = ma['injections'][d], mb['injections'][d] + sa = np.concatenate([sa, np.asarray(ia['stat'], float)]) + sb = np.concatenate([sb, np.asarray(ib['stat'], float)]) + pdiff += sum(pa != pb for pa, pb in zip(ia['p_found'], + ib['p_found'])) + ddiff += sum(da != db for da, db in zip(ia['detected'], + ib['detected'])) + n_inj += len(ia['stat']) + scale = np.maximum(np.abs(sa), np.abs(sb)) + scale[scale == 0] = 1.0 + rel = np.abs(sa - sb) / scale + out[name] = dict(n=int(len(sa)), max_rel=float(rel.max()), + median_rel=float(np.median(rel)), + n_inj=n_inj, period_diff=int(pdiff), + decision_diff=int(ddiff), + p95_a=ma['null_max_p95'], p95_b=mb['null_max_p95']) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('path') + ap.add_argument('--rst', action='store_true') + args = ap.parse_args() + fmt = Rst() if args.rst else Md() + with open(args.path) as f: + r = json.load(f) + meta = r['meta'] + cfgs = {c['name']: c for c in r['configs']} + + label = [] + if meta.get('gpu'): + label.append('GPU: %s' % ', '.join(np.atleast_1d(meta['gpu']))) + if meta.get('git_sha'): + label.append('commit: %s' % ', '.join( + s[:9] for s in np.atleast_1d(meta['git_sha']))) + if meta.get('date'): + label.append('date: %s' % ', '.join(np.atleast_1d(meta['date']))) + if label: + print('; '.join(label) + '\n') + + cal = r.get('snr_calibration') + if cal: + print(fmt.h('LRT statistic null calibration (white noise, fixed ' + 'template)')) + print('mean = %.3f, std = %.3f over %d realizations (the pre-fix ' + 'Sep-2026 campaign, sigma = 2 NFFT: 0.007, 1.812). ' + 'Calibration constant of this configuration, not a ' + 'pass/fail check: the statistic is a whitened correlation, ' + 'not N(0,1), because the NFFT modes of irregular sampling ' + 'are not orthogonal; its null std depends on the sampling, ' + 'nf and the PSD estimator. This is why the thresholds below ' + 'are empirical null percentiles.\n' + % (cal['mean'], cal['std'], cal['n'])) + + print(fmt.h('Protocol')) + print('%d-point ground-like irregular sampling over %.0f d; trial ' + 'grid %d periods (injected P = %.2f d on-grid), box duration ' + '%.2f d; thresholds = 95th percentile of %d null search maxima; ' + 'completeness over %d injections per depth, period hit within ' + '1%% (incl. 2:1 aliases). Depths are fractions of the flux; ' + 'sigma_white = %g.\n' + % (meta['ndata'], meta['baseline'], meta['n_periods'], + meta['p_true'], meta['dur_true'], meta['n_null'], + meta['n_inj'], meta['sigma_white'])) + + rng = np.random.RandomState(0) + print('Completeness cells are "p +- sigma" with sigma the quadrature ' + 'sum of the null-threshold sampling error (bootstrap of the ' + 'null maxima) and the binomial (Wilson, z = 1) error; the ' + 'paired-difference rows use the same lightcurves for both ' + 'arms (McNemar sigma = sqrt(b + c) / n, thresholds fixed).\n') + for cfg in r['configs']: + print(fmt.h(TITLES.get(cfg['name'], cfg['name']))) + print(completeness_table(fmt, cfg, rng)) + ct = contrast_table(fmt, cfg) + if ct: + print('Paired completeness differences (A - B, same ' + 'lightcurves):\n') + print(ct) + et = epoch_table(fmt, cfg) + if et: + print('Epoch recovery among detections (arms that return a ' + 'best epoch; "same transit" = within half the injected ' + 'duration, which any correct-period detection meets; ' + 'the errors show the grid resolution):\n') + print(et) + print('(compute: %.0f s)\n' % cfg.get('wall_s', float('nan'))) + + pairs = [(a, b) for a, b in PAIRS if a in cfgs and b in cfgs] + if pairs: + print(fmt.h('Paired configurations (same lightcurves)')) + print('Each pair saw identical noise and injections (shared ' + 'sub-seed) and differs only in the time origin ' + '(white / white_bjd: + 2457000 d, an integer, so every ' + 'method\'s floor(min t)-anchored grid keeps its phase) or ' + 'in the basis column offsets (red_sys / red_sys_nzm). ' + 'Differences beyond float32 rounding would indicate a ' + 'time-scale or centring defect.\n') + for a, b in pairs: + ps = paired_stats(cfgs[a], cfgs[b]) + if not ps: + continue + rows = [] + for name in sorted(ps, key=lambda n: ARM_ORDER.index(n) + if n in ARM_ORDER else 99): + p = ps[name] + rows.append([ARM_LABEL.get(name, name), + '%d' % p['n'], + '%.1e' % p['max_rel'], + '%.1e' % p['median_rel'], + '%d / %d' % (p['period_diff'], p['n_inj']), + '%d / %d' % (p['decision_diff'], p['n_inj']), + '%.3f / %.3f' % (p['p95_a'], p['p95_b'])]) + print(fmt.table(['arm', 'searches', 'max rel. diff', + 'median rel. diff', 'best period differs', + 'detection differs', 'null p95 (%s / %s)' + % (a, b)], rows, + title='%s vs %s' % (a, b))) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/nufft_lrt/validate.py b/benchmarks/nufft_lrt/validate.py new file mode 100644 index 00000000..81afad37 --- /dev/null +++ b/benchmarks/nufft_lrt/validate.py @@ -0,0 +1,855 @@ +"""NUFFT-LRT injection-recovery validation vs BLS (and TLS). + +The question this answers (audit follow-up, July 2026; re-run after the +Sep-2026 correctness fixes as Phase 4 of the 1.0 release plan): does the +PSD-whitened matched filter actually buy detection performance in +correlated noise, what does it cost in white noise -- i.e. when is it +the right tool -- and does the PUBLIC DEFAULT PATH (``epochs=None`` on +absolute, BJD-scale times) perform like the explicit-epoch search? + +Protocol (per noise configuration): + +1. NULL RUNS: generate signal-free lightcurves, run each method's search + over the identical period range, record the maximum statistic. The + 95th percentile of the null maxima is that method's detection + threshold at a fixed 5% per-search false-alarm rate. This + self-calibration is what makes methods with different statistics + (LRT SNR, BLS power, TLS SDE) comparable -- and it is exactly where + red noise hurts BLS/TLS: their null maxima inflate, raising the bar. +2. INJECTION RUNS: inject box transits (random epoch, fixed + period/duration, swept depth) into fresh noise; a detection requires + the statistic to exceed the null threshold AND the best period to + land within 1% of the truth or its 2:1 aliases. +3. Completeness(depth) per method per noise config + the LRT statistic's + null calibration (mean/std on white noise at ONE fixed template). + This is a calibration CONSTANT of the configuration, not a pass/fail + check: the statistic is NOT N(0,1) by design (the NFFT modes of + irregular sampling are not orthogonal, so the frequency-diagonal + whitened correlation is over-dispersed even with the true PSD; null + std ~1.8-2.7 for this harness's ground sampling at nf = 2n -- see the + cuvarbase.nufft_lrt module docstring; the pre-fix campaign measured + 1.81 with the sigma = 2 NFFT). Expect a mean near 0 and a std above + 1; the std is what a threshold must be scaled by if it is ever + quoted in "sigma" units. + +Configurations (``CONFIG_ORDER``): ``white``; ``red_1x`` / ``red_3x`` +(OU red noise at 1x / 3x the white level); ``red_sys`` (1x red plus +three shared systematics modes, searched with a PCA basis + coefficient +prior estimated from a signal-free population, as in Taaki et al. 2020); +and two PAIRED configurations added for the Phase-4 re-validation: + +* ``white_bjd``: the ``white`` lightcurves on absolute timestamps, + ``t + 2457000.5`` d relative to the generated sampling (every other + configuration is handed ``t + 0.5`` d, so the two members differ by + exactly ``BJD_OFFSET`` = 2457000 d and every method's internal + ``floor(min t)``-anchored grid -- the automatic epoch grid, the BLS + phase bins, the TLS epoch grid -- has the same phase in both; a + fractional offset would re-phase those grids by the fraction and the + comparison would measure grid alignment, not the float64 epoch + subtraction it is meant to test); +* ``red_sys_nzm``: the ``red_sys`` data searched with the SAME basis + plus a constant offset per column (``BASIS_COLUMN_OFFSETS``; the PCA + columns are unit-norm over 600 points, rms 0.041, so the offsets are + 0.12-0.49 of a column's rms -- cotrending vectors that are far from + zero-mean) with the unchanged coefficient prior -- exercises the + intercept of the sequential cotrend and Detector A's centring (both + centre the basis, so the fixed module should be invariant). + +Each configuration draws its noise and injections from its OWN +``RandomState(seed + CONFIG_SEED_OFFSET[name])``; a paired +configuration shares its partner's seed and therefore sees exactly the +same lightcurves, so the per-lightcurve statistics recorded in the +JSON can be compared one-to-one (``summarize_lrt_validation.py`` does) +rather than only through completeness. + +Arms: ``lrt`` (explicit epoch grid of ``round(2P / min duration)`` +clipped to 8..96 epochs per period, epochs anchored at the first +observation in the caller's time scale), ``lrt_auto`` (the PUBLIC +DEFAULT: one ``run(t, y, periods, durations=...)`` call with +``epochs=None``, i.e. the module's automatic per-cell epoch grid and its +returned best epoch), ``bls`` (``eebls_gpu_fast``, its own q ladder), +``tls`` (``tls_search_batch``; the per-search score is its +un-normalized delta-chi-squared statistic ``SNR = sqrt(chi2_0 - +chi2_min)``, NOT the SDE: an SDE over the 32-point trial spectrum is +bounded by ``sqrt(31)`` and saturates), ``lrt_flat`` (PSD = ones; +``red_1x`` and ``red_3x``), ``lrt_marg`` (Detector A, default +``estimate_psd``) and ``lrt_seq`` (OLS cotrend + matched filter; both on +the systematics configurations). + +Template grids: the LRT arms search durations {0.12, 0.21, 0.30} d +against the injected 0.22 d box (the nearest template recovers 97.7% of +the matched-filter statistic when centred). The explicit arm uses +round(2P / 0.12) epochs for EVERY duration (88 at P = 5.3 d, up to +0.030 d of misalignment), the default path ceil(2P / duration) per +cell (89/51/36 epochs at P = 5.3 d for the three durations, up to +0.030/0.052/0.074 d) -- the source of the default path's 4-9% deficit +against the explicit arm in the 2026-09-06 campaign. BLS's q ladder +(0.005..0.08, dlogq 0.3) has 0.2385 d at P = 5.3 d with P/200 phase +bins, so the comparators are slightly better matched to the injection +than the LRT grid is; the depth sweeps of each configuration bracket its +own detectability transition ([0.002, 0.003, 0.004, 0.008] in white +noise, [0.004, 0.006, 0.008, 0.016] at 1x red, [0.008, 0.016, 0.024, +0.032] at 3x red, [0.004, 0.008, 0.016, 0.032] with systematics). The +true period AND its 2P alias are placed on the shared period grid (P/2 +already falls within 1% of a grid point). + +Resolution: with n_inj = 200 the binomial 1-sigma of a completeness is +0.035 at p = 0.5; the null-p95 threshold from n_null = 200 maxima +carries its own sampling error (a common shift for all injections of +that arm). ``summarize_lrt_validation.py`` propagates both (bootstrap of +the null set + Wilson interval) into a per-cell uncertainty and reports +paired (same-lightcurve) arm differences within a configuration. + +Noise model: white Gaussian + an exact Ornstein-Uhlenbeck (AR(1) in +continuous time) red component generated directly at the irregular +sample times (x_{i+1} = x_i e^{-dt/tau} + N(0, s^2(1-e^{-2 dt/tau}))), +so no uniform-grid interpolation is involved. The OU PSD is a Lorentzian +~ 1/(1+(2 pi f tau)^2) -- "stellar activity"-like low-frequency power. + +Run on a GPU machine (full campaign, ~7 GPU-hours sequential on an A40; +four to six concurrent processes give ~2x throughput, the GPU's +context switching caps it there). +The configurations are independent, and within a configuration the +arms are too (the noise and injections are drawn from the +configuration's own seed in an order that does not depend on which +arms run), so the campaign can be split into parallel processes by +configuration and by arm and merged afterwards; ``--merge`` checks that +parts of the same configuration saw identical injections: + python benchmarks/nufft_lrt/validate.py --configs white --arms lrt,bls,tls --out a.json + python benchmarks/nufft_lrt/validate.py --configs white --arms lrt_auto --skip-calibration --out b.json + python benchmarks/nufft_lrt/validate.py --configs red_sys,red_sys_nzm --out c.json + ... + python benchmarks/nufft_lrt/validate.py --merge a.json b.json c.json ... --out merged.json +The LRT null calibration runs in every process whose selection includes +``white`` unless ``--skip-calibration``. ``--quick`` runs smoke-test sizes. +""" +import argparse +import json +import os +import subprocess +import sys +import time + +# One BLAS thread per process: the per-template host algebra is tiny and +# OpenBLAS's default (one thread per host core, 96 on the RunPod host +# against a ~8-CPU container quota) only adds spin-wait contention, +# especially when several campaign processes share the GPU. +for _v in ('OPENBLAS_NUM_THREADS', 'OMP_NUM_THREADS', 'MKL_NUM_THREADS'): + os.environ.setdefault(_v, '1') + +import numpy as np # noqa: E402 + + +CONFIG_ORDER = ['white', 'white_bjd', 'red_1x', 'red_3x', + 'red_sys', 'red_sys_nzm'] +# Paired configurations share a sub-seed (identical noise + injections). +CONFIG_SEED_OFFSET = {'white': 1, 'white_bjd': 1, 'red_1x': 2, 'red_3x': 3, + 'red_sys': 4, 'red_sys_nzm': 4} +CALIBRATION_SEED_OFFSET = 100 +# Every configuration's times are t_base + REL_OFFSET; the BJD-scale +# configuration adds BJD_OFFSET on top, i.e. t_base + 2457000.5 d. +REL_OFFSET = 0.5 +BJD_OFFSET = 2457000.0 +BASIS_COLUMN_OFFSETS = (0.01, -0.005, 0.02) +HARNESS_VERSION = 2 # 1 = Jul/Sep-2026 (single rng, 60/60); 2 = Phase 4 + + +# ---------------------------------------------------------------- data + +def make_times(rng, mode='ground', baseline=90.0, n=600): + """Irregular sampling. 'ground': nightly visibility windows with + per-night jitter and random weather losses (the sampling regime the + NUFFT path exists for).""" + if mode == 'ground': + nights = np.arange(int(baseline)) + keep = rng.rand(len(nights)) > 0.35 # weather + nights = nights[keep] + per_night = max(1, int(round(n / max(len(nights), 1)))) + t = (nights[:, None] + + 0.25 * rng.rand(len(nights), per_night)).ravel() + t = np.sort(t[:n]) + return t + # 'space': near-uniform short-cadence with a mid-campaign gap + t = np.linspace(0, baseline, n) + 1e-3 * rng.randn(n) + gap = (t > 0.45 * baseline) & (t < 0.55 * baseline) + return np.sort(t[~gap]) + + +def ou_noise(rng, t, sigma_red, tau): + """Exact OU process sampled at irregular times t.""" + x = np.zeros(len(t)) + x[0] = sigma_red * rng.randn() + for i in range(1, len(t)): + a = np.exp(-(t[i] - t[i - 1]) / tau) + x[i] = x[i - 1] * a + sigma_red * np.sqrt(1 - a * a) * rng.randn() + return x + + +def box_transit(t, period, epoch, duration, depth): + phase = np.fmod(t - epoch, period) / period + phase[phase < 0] += 1 + phase[phase > 0.5] -= 1 + y = np.zeros_like(t) + y[np.abs(phase) <= duration / (2 * period)] = -depth + return y + + +def make_lc(rng, t, sigma_white, sigma_red, tau, inject=None, + sys_modes=None, sys_amps=None): + y = 1.0 + sigma_white * rng.randn(len(t)) + if sigma_red > 0: + y += ou_noise(rng, t, sigma_red, tau) + if sys_modes is not None: + y += sys_modes @ (rng.randn(sys_modes.shape[1]) * sys_amps) + if inject is not None: + y += box_transit(t, **inject) + dy = np.full(len(t), sigma_white) # what a pipeline would believe: + return y, dy # formal (white) errors only + + +# ------------------------------------------------------------- methods +# +# Every search is called as search(t, y, dy) and returns +# (max statistic, best period, best epoch or nan) in the caller's time +# scale. + +class LRTSearch: + """PSD-whitened NUFFT matched filter over a (period, duration, + epoch) template grid. + + ``auto_epochs=False`` (the ``lrt`` arm): one ``run()`` call per + period with an explicit epoch grid that scales with period so + template misalignment stays below ~half the narrowest trial duration + (``epoch_oversample`` boxes per duration; a fixed epoch count would + leave long periods unsearchable for box overlap). The epochs are + anchored at the first observation, in the caller's time scale, as a + user with absolute timestamps would write them. + + ``auto_epochs=True`` (the ``lrt_auto`` arm): the public default path, + ONE ``run(t, y, periods, durations=durations)`` call with + ``epochs=None`` and every other argument at its default; the module + builds its own per-(period, duration) epoch grid and returns the max + over it plus the best epoch. + """ + + def __init__(self, periods, durations, epoch_oversample=2.0, + max_epochs=96, flat_psd=False, auto_epochs=False, + run_kwargs=None, **proc_kwargs): + from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess, epoch_grid + self.proc = NUFFTLRTAsyncProcess(**proc_kwargs) + self.periods = np.asarray(periods, dtype=np.float64) + self.durations = np.asarray(durations, dtype=np.float64) + self.epoch_oversample = epoch_oversample + self.max_epochs = max_epochs + self.flat_psd = flat_psd + self.auto_epochs = auto_epochs + self.run_kwargs = dict(run_kwargs or {}) + if auto_epochs: + # the module's own grid at its defaults (2.0, 8, 96) + self.n_templates = sum(len(epoch_grid(P, d)) + for P in self.periods + for d in self.durations) + else: + self.n_templates = sum( + self._n_epochs(P) * len(self.durations) + for P in self.periods) + + def _n_epochs(self, P): + n = int(round(self.epoch_oversample * P / self.durations.min())) + return int(min(max(n, 8), self.max_epochs)) + + def __call__(self, t, y, dy): + kwargs = dict(self.run_kwargs) + if self.flat_psd: + nf = 2 * len(t) + kwargs.update(estimate_psd=False, + psd=np.ones(nf, dtype=np.float32), nf=nf) + if self.auto_epochs: + snr, best_epoch = self.proc.run(t, y, self.periods, + durations=self.durations, + **kwargs) + i, j = np.unravel_index(int(np.argmax(snr)), snr.shape) + return (float(snr[i, j]), float(self.periods[i]), + float(best_epoch[i, j])) + best = (-np.inf, np.nan, np.nan) + t_ref = float(np.min(t)) + for P in self.periods: + epochs = t_ref + np.linspace(0, P, self._n_epochs(P), + endpoint=False) + snr = self.proc.run(t, y, np.array([P]), + durations=self.durations, + epochs=epochs, **kwargs) + k = int(np.argmax(snr)) + m = float(snr.ravel()[k]) + if m > best[0]: + best = (m, float(P), + float(epochs[np.unravel_index(k, snr.shape)[2]])) + return best + + +class BLSSearch: + def __init__(self, periods, qvals): + from cuvarbase.bls import eebls_gpu_fast + self._bls = eebls_gpu_fast + self.freqs = np.sort(1.0 / periods).astype(np.float64) + self.qmin, self.qmax = qvals + + def __call__(self, t, y, dy): + power = self._bls(t, y, dy, self.freqs, + qmin=self.qmin, qmax=self.qmax) + i = int(np.argmax(power)) + return float(power[i]), float(1.0 / self.freqs[i]), np.nan + + +class TLSSearch: + def __init__(self, periods, qvals): + from cuvarbase import tls + # Preserve the dated NUFFT-LRT campaign's binned comparator. + self._tls = getattr(tls, '_tls_search_batch_binned', tls.tls_search_batch) + self.periods = np.asarray(periods, dtype=np.float64) + q = np.full(len(self.periods), qvals[0]), \ + np.full(len(self.periods), qvals[1]) + self.qmin, self.qmax = q + + def __call__(self, t, y, dy): + r = self._tls([(t, y, dy)], periods=self.periods, + qmin=self.qmin, qmax=self.qmax)[0] + if 'error' in r: + return 0.0, np.nan, np.nan + # un-normalized per-search score (sqrt of the chi2 improvement + # of the best template over the constant model); the SDE of a + # 32-point spectrum is bounded by sqrt(31) and would saturate + return float(r['SNR']), float(r['period']), np.nan + + +# ------------------------------------------- shared systematics (paper) + +def make_systematics_modes(t, baseline): + """Three plausible shared instrument/site modes: a slow drift, a + within-night 'airmass' parabola, and a long-period thermal-like + oscillation.""" + m1 = (t - t.mean()) / (0.5 * baseline) + night = np.floor(t) + tn = t - night - 0.125 # hours from mid-window + m2 = (tn / 0.125) ** 2 - 0.5 + m3 = np.sin(2 * np.pi * t / (0.4 * baseline)) + M = np.stack([m1, m2, m3], axis=1) + return M / np.std(M, axis=0) + + +def build_basis_from_population(rng, t, baseline, sigma_white, sigma_red, + tau, amps, n_pop=60, K=3): + """Paper-style systematics model: PCA basis from a population of + signal-free lightcurves sharing the true modes, plus a Gaussian + prior on coefficients from per-lightcurve least-squares fits. The + population is row-centred, so the PCA modes are exactly zero-mean + (the ``red_sys_nzm`` configuration adds column offsets afterwards).""" + M = make_systematics_modes(t, baseline) + pop = np.empty((n_pop, len(t))) + for i in range(n_pop): + c = rng.randn(M.shape[1]) * amps + y, _ = make_lc(rng, t, sigma_white, sigma_red, tau) + pop[i] = y + M @ c + pop -= pop.mean(axis=1, keepdims=True) + # PCA over the population (as in Taaki et al. 2020) + _, _, VT = np.linalg.svd(pop, full_matrices=False) + V = VT[:K].T + coeffs = pop @ V # per-lightcurve LS fits (V orthonormal) + prior_mean = coeffs.mean(axis=0) + prior_cov = np.cov(coeffs.T) + return M, V, prior_mean, prior_cov + + +def period_hit(p_found, p_true, tol=0.01): + if not np.isfinite(p_found): + return False + for target in (p_true, 2 * p_true, 0.5 * p_true): + if abs(p_found - target) / target < tol: + return True + return False + + +def epoch_error(epoch_found, epoch_true, p_found, p_true): + """Smallest |epoch_found - epoch_true| modulo the transit spacing the + found period implies (``min(p_found, p_true)``: a 2P alias still + lands on true transits; a P/2 alias on every other template + transit). nan when no epoch was reported.""" + if not (np.isfinite(epoch_found) and np.isfinite(p_found)): + return np.nan + wrap = min(float(p_found), float(p_true)) + d = np.fmod(epoch_found - epoch_true, wrap) + d = abs(d) + return float(min(d, wrap - d)) + + +# ------------------------------------------------------------ protocol + +def run_config(cfg, methods, n_null, n_inj, depths, t_base, log=print): + """One configuration. The data are generated on ``t_base`` (relative + times) from the configuration's own RandomState and handed to every + search at ``t = t_base + cfg['t_offset']``; injected epochs are + recorded in that same (caller) time scale. Per-lightcurve results + are kept so paired configurations can be compared one-to-one.""" + rng = np.random.RandomState(cfg['seed']) + t_off = float(cfg.get('t_offset', 0.0)) + t = t_base + t_off + out = {'config': {k: v for k, v in cfg.items() + if k != 'name' and not k.startswith('_')}, + 'methods': {}} + p_true, dur_true = cfg['p_true'], cfg['dur_true'] + sys_kw = dict(sys_modes=cfg.get('_sys_modes'), + sys_amps=cfg.get('_sys_amps')) + wall = {name: 0.0 for name in methods} + n_calls = {name: 0 for name in methods} + + # one untimed search per arm on a throwaway lightcurve (its own + # RandomState, so the configuration's draws are untouched): kernel + # compilation and first-call allocation stay out of the per-search + # cost, and out of the first configuration a process runs + y_w, dy_w = make_lc(np.random.RandomState(0), t_base, cfg['sigma_white'], + cfg['sigma_red'], cfg['tau'], **sys_kw) + for search in methods.values(): + search(t, y_w, dy_w) + + def timed(name, search, y, dy): + t0 = time.time() + r = search(t, y, dy) + wall[name] += time.time() - t0 + n_calls[name] += 1 + return r + + # 1. null threshold per method + nulls = {name: [] for name in methods} + t_start = time.time() + for i in range(n_null): + y, dy = make_lc(rng, t_base, cfg['sigma_white'], cfg['sigma_red'], + cfg['tau'], **sys_kw) + for name, search in methods.items(): + stat, _, _ = timed(name, search, y, dy) + nulls[name].append(stat) + if (i + 1) % 25 == 0 or i + 1 == n_null: + log(' [%s] null %d/%d (%.0f s)' % (cfg['name'], i + 1, n_null, + time.time() - t_start)) + + for name in methods: + arr = np.sort(np.asarray(nulls[name])) + thresh = float(np.percentile(arr, 95)) + out['methods'][name] = { + 'null_max_median': float(np.median(arr)), + 'null_max_p95': thresh, + 'completeness': {}, + 'epoch_recovery': {}, + 'null_stats': [round(float(v), 7) for v in nulls[name]], + 'injections': {}, + } + out['injected_epochs'] = {} + + # 2. injections, swept depth + for depth in depths: + hits = {name: 0 for name in methods} + rec = {name: {'stat': [], 'p_found': [], 'epoch_found': [], + 'detected': []} for name in methods} + epochs_true = [] + for i in range(n_inj): + epoch = rng.rand() * p_true # relative frame + epochs_true.append(round(epoch + t_off, 7)) + y, dy = make_lc(rng, t_base, cfg['sigma_white'], cfg['sigma_red'], + cfg['tau'], + inject=dict(period=p_true, epoch=epoch, + duration=dur_true, depth=depth), + **sys_kw) + for name, search in methods.items(): + stat, p_found, e_found = timed(name, search, y, dy) + det = bool(stat > out['methods'][name]['null_max_p95'] + and period_hit(p_found, p_true)) + hits[name] += det + r = rec[name] + r['stat'].append(round(float(stat), 7)) + r['p_found'].append(round(float(p_found), 7) + if np.isfinite(p_found) else None) + r['epoch_found'].append(round(float(e_found), 7) + if np.isfinite(e_found) else None) + r['detected'].append(det) + if (i + 1) % 50 == 0 or i + 1 == n_inj: + log(' [%s] depth %s inj %d/%d (%.0f s)' + % (cfg['name'], depth, i + 1, n_inj, + time.time() - t_start)) + out['injected_epochs'][str(depth)] = epochs_true + for name in methods: + m = out['methods'][name] + m['completeness'][str(depth)] = hits[name] / n_inj + m['injections'][str(depth)] = rec[name] + # epoch recovery among detections (arms that report an epoch) + errs = [epoch_error(e, e_true, p, p_true) + for e, p, e_true, det in zip(rec[name]['epoch_found'], + rec[name]['p_found'], + epochs_true, + rec[name]['detected']) + if det and e is not None] + if errs: + errs = np.asarray(errs) + m['epoch_recovery'][str(depth)] = { + 'n_detected': int(len(errs)), + 'frac_within_half_duration': + float(np.mean(errs <= 0.5 * dur_true)), + 'median_abs_error_d': float(np.median(errs)), + 'max_abs_error_d': float(np.max(errs)), + } + for name in methods: + out['methods'][name]['seconds_per_search'] = ( + wall[name] / max(n_calls[name], 1)) + out['methods'][name]['n_templates'] = getattr( + methods[name], 'n_templates', None) + return out + + +def snr_calibration(rng, t, proc_kwargs, n=200): + """Null mean/std of the LRT statistic on pure white noise at ONE + fixed template: the calibration constant of this (sampling, nf, PSD + estimator) configuration. The statistic is a whitened correlation, + not N(0,1): with irregular sampling the NFFT modes are not + orthogonal and the null std is ~1.8-2.7 for the harness's ground + sampling at nf = 2n even with the true PSD (module docstring of + cuvarbase.nufft_lrt). A mean far from 0 would indicate a + normalization bug; a std above 1 is expected.""" + from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess + proc = NUFFTLRTAsyncProcess(**proc_kwargs) + vals = [] + for i in range(n): + y = 1 + 1e-3 * rng.randn(len(t)) + # ONE fixed template (epochs=None now scans an epoch grid and + # returns (max, best_epoch); the calibration is single-template) + snr = proc.run(t, y - np.mean(y), np.array([3.7]), + durations=np.array([0.15]), + epochs=np.array([0.0])) + vals.append(float(snr[0, 0, 0])) + v = np.asarray(vals) + return {'mean': float(v.mean()), 'std': float(v.std()), + 'n': n} + + +# ----------------------------------------------------------- bookkeeping + +def _gpu_name(): + try: + import pycuda.driver as cuda + cuda.init() + return cuda.Device(0).name() + except Exception: # noqa: BLE001 -- label only + return None + + +def _git_sha(): + """``HEAD`` plus ``-dirty`` when the tree has uncommitted changes + (the archived campaign should point at a commit that contains the + harness that produced it).""" + try: + sha = subprocess.check_output( + ['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL, + text=True).strip() + dirty = subprocess.check_output( + ['git', 'status', '--porcelain', '--untracked-files=no'], + stderr=subprocess.DEVNULL, text=True).strip() + return sha + ('-dirty' if dirty else '') + except Exception: # noqa: BLE001 -- label only + return None + + +def merge_results(paths): + """Merge per-process JSONs (``--configs`` subsets of one campaign) + into one campaign JSON: identical protocol meta required, configs + ordered by CONFIG_ORDER, the calibration taken from the file that + has it.""" + parts = [] + for p in paths: + with open(p) as f: + parts.append(json.load(f)) + keys = ['seed', 'n_null', 'n_inj', 'n_periods', 'ndata', 'baseline', + 'p_true', 'dur_true', 'depths', 'sigma_white', + 'harness_version', 'rel_offset', 'bjd_offset', 'lrt_sigma', + 'lrt_nf'] + ref = parts[0]['meta'] + for part, p in zip(parts, paths): + if not part['meta'].get('complete', False): + raise ValueError('%s is an incomplete checkpoint (its process ' + 'did not finish); refusing to merge it' % p) + have = [c['name'] for c in part['configs']] + if have != list(part['meta'].get('configs_run', have)): + raise ValueError('%s holds configs %s but claims %s' + % (p, have, part['meta'].get('configs_run'))) + for k in keys: + if part['meta'].get(k) != ref.get(k): + raise ValueError('meta %r differs in %s: %r vs %r' + % (k, p, part['meta'].get(k), ref.get(k))) + merged = {'meta': dict(ref), 'snr_calibration': None, 'configs': []} + merged['meta']['gpu'] = sorted({str(part['meta'].get('gpu')) + for part in parts}) + merged['meta']['git_sha'] = sorted({str(part['meta'].get('git_sha')) + for part in parts}) + merged['meta']['date'] = sorted({str(part['meta'].get('date')) + for part in parts}) + merged['meta']['merged_from'] = [str(p) for p in paths] + cals = [part['snr_calibration'] for part in parts + if part.get('snr_calibration')] + if cals: + merged['snr_calibration'] = cals[0] + seen = {} + for part in parts: + for cfg in part['configs']: + name = cfg['name'] + if name not in seen: + seen[name] = cfg + continue + # the same configuration run in another process with other + # arms: identical protocol, identical injections required + base = seen[name] + if cfg['config'] != base['config']: + raise ValueError('config %r: protocol differs between ' + 'parts' % name) + if cfg['injected_epochs'] != base['injected_epochs']: + raise ValueError('config %r: parts did not see the same ' + 'injections' % name) + dup = set(cfg['methods']) & set(base['methods']) + if dup: + raise ValueError('config %r: arm(s) %s appear twice' + % (name, sorted(dup))) + base['methods'].update(cfg['methods']) + base['wall_s'] = base.get('wall_s', 0.0) + cfg.get('wall_s', 0.0) + merged['configs'] = [seen[n] for n in CONFIG_ORDER if n in seen] + merged['configs'] += [c for n, c in seen.items() if n not in CONFIG_ORDER] + merged['meta']['configs_run'] = [c['name'] for c in merged['configs']] + merged['meta']['wall_s_total'] = float(sum( + c.get('wall_s', 0.0) for c in merged['configs'])) + return merged + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--out', default='nufft_lrt_validation.json') + ap.add_argument('--quick', action='store_true', + help='smoke-test sizes') + ap.add_argument('--seed', type=int, default=20260711) + ap.add_argument('--skip-tls', action='store_true') + ap.add_argument('--n-null', type=int, default=None) + ap.add_argument('--n-inj', type=int, default=None) + ap.add_argument('--configs', default=None, + help='comma-separated subset of %s (default: all); ' + 'the LRT null calibration runs when the ' + 'selection includes "white"' + % ','.join(CONFIG_ORDER)) + ap.add_argument('--arms', default=None, + help='comma-separated subset of the arms each selected ' + 'configuration would run (default: all of them); ' + 'parts of one configuration merge with --merge') + ap.add_argument('--skip-calibration', action='store_true', + help='do not run the LRT null calibration') + ap.add_argument('--merge', nargs='+', metavar='JSON', default=None, + help='merge these per-process JSONs into --out and ' + 'exit') + args = ap.parse_args() + + if args.merge: + merged = merge_results(args.merge) + with open(args.out, 'w') as f: + json.dump(merged, f, indent=1) + print('merged %d configs -> %s' % (len(merged['configs']), args.out)) + return 0 + + selected = (list(CONFIG_ORDER) if args.configs is None + else [s.strip() for s in args.configs.split(',') if s.strip()]) + unknown = [s for s in selected if s not in CONFIG_ORDER] + if unknown: + ap.error('unknown config(s) %s; choose from %s' + % (unknown, CONFIG_ORDER)) + + rng = np.random.RandomState(args.seed) + + n_null = args.n_null or (12 if args.quick else 200) + n_inj = args.n_inj or (8 if args.quick else 200) + n_periods = 16 if args.quick else 32 + # depth sweeps bracket each configuration's own detectability + # transition (transition depths from the pre-fix campaign) + if args.quick: + depths = {'white': [0.004, 0.008], 'red_1x': [0.008, 0.016], + 'red_3x': [0.016, 0.032], 'red_sys': [0.008, 0.016]} + else: + depths = {'white': [0.002, 0.003, 0.004, 0.008], + 'red_1x': [0.004, 0.006, 0.008, 0.016], + 'red_3x': [0.008, 0.016, 0.024, 0.032], + 'red_sys': [0.004, 0.008, 0.016, 0.032]} + + # The sampling and the population basis come from the master rng in + # a fixed order, so every process of a split campaign builds the + # same t, V, prior. + t = make_times(rng, 'ground', baseline=90.0, n=600) + p_true, dur_true = 5.3, 0.22 + periods = np.exp(np.linspace(np.log(2.0), np.log(18.0), n_periods)) + # inject exactly on the shared grid: completeness then measures + # detection, not grid-resolution luck (all methods share the grid); + # the 2P alias is put on the grid too so that period_hit's 2:1 + # credit is real (P/2 = 2.65 falls within 1% of a grid point anyway) + periods[np.argmin(np.abs(periods - p_true))] = p_true + periods[np.argmin(np.abs(periods - 2 * p_true))] = 2 * p_true + durations = np.array([0.12, 0.21, 0.30]) + qvals = (0.005, 0.08) + + sigma_w = 3e-3 + + def cfg_seed(name): + return int(args.seed + CONFIG_SEED_OFFSET[name]) + + configs = [ + dict(name='white', sigma_white=sigma_w, sigma_red=0.0, tau=1.0, + p_true=p_true, dur_true=dur_true, depths=depths['white'], + seed=cfg_seed('white'), t_offset=REL_OFFSET), + dict(name='white_bjd', sigma_white=sigma_w, sigma_red=0.0, tau=1.0, + p_true=p_true, dur_true=dur_true, depths=depths['white'], + seed=cfg_seed('white_bjd'), t_offset=REL_OFFSET + BJD_OFFSET, + paired_with='white'), + dict(name='red_1x', sigma_white=sigma_w, sigma_red=1.0 * sigma_w, + tau=0.8, p_true=p_true, dur_true=dur_true, + depths=depths['red_1x'], + seed=cfg_seed('red_1x'), t_offset=REL_OFFSET), + dict(name='red_3x', sigma_white=sigma_w, sigma_red=3.0 * sigma_w, + tau=0.8, p_true=p_true, dur_true=dur_true, + depths=depths['red_3x'], + seed=cfg_seed('red_3x'), t_offset=REL_OFFSET), + ] + + # shared-systematics config (the paper's core contrast): PCA basis + + # coefficient prior estimated from a signal-free population, exactly + # as Taaki et al. (2020) do with Kepler PCA modes + sys_amps = np.array([6.0, 3.0, 6.0]) * sigma_w + true_modes, V_est, mu_c, cov_c = build_basis_from_population( + rng, t, 90.0, sigma_w, 1.0 * sigma_w, 0.8, sys_amps, + n_pop=20 if args.quick else 60) + sys_cfg = dict(sigma_white=sigma_w, sigma_red=1.0 * sigma_w, + tau=0.8, p_true=p_true, dur_true=dur_true, + depths=depths['red_sys'], + sys_amp_over_white=[float(a / sigma_w) for a in sys_amps], + _sys_modes=true_modes, _sys_amps=sys_amps) + configs.append(dict(sys_cfg, name='red_sys', seed=cfg_seed('red_sys'), + t_offset=REL_OFFSET)) + # the SAME data searched with a basis whose columns are not + # zero-mean (constant offsets of 0.12-0.49 column-rms added to the + # unit-norm PCA modes; the prior is unchanged, as a user with + # un-centred cotrending vectors would have) + col_off = np.asarray(BASIS_COLUMN_OFFSETS[:V_est.shape[1]], np.float64) + V_nzm = V_est + col_off[None, :] + configs.append(dict(sys_cfg, name='red_sys_nzm', + seed=cfg_seed('red_sys_nzm'), t_offset=REL_OFFSET, + paired_with='red_sys', + basis_column_offsets=[float(c) for c in col_off])) + configs = [c for c in configs if c['name'] in selected] + + eo = 1.0 if args.quick else 2.0 + lrt = LRTSearch(periods, durations, epoch_oversample=eo) + lrt_auto = LRTSearch(periods, durations, auto_epochs=True) + methods = { + 'lrt': lrt, + 'lrt_auto': lrt_auto, + 'bls': BLSSearch(periods, qvals), + } + if not args.skip_tls: + methods['tls'] = TLSSearch(periods, qvals) + print('LRT templates per search: %d explicit-epoch, %d automatic ' + '(epochs=None)' % (lrt.n_templates, lrt_auto.n_templates), + flush=True) + + # the flat-PSD arm isolates what the whitening itself buys; it runs + # on the red-noise configs (in white noise the estimated PSD is + # ~flat and the arms coincide) + lrt_flat = LRTSearch(periods, durations, epoch_oversample=eo, + flat_psd=True) + + # joint (Detector A) and sequential-detrend arms for the + # systematics configs, sharing the population-estimated basis/prior + def marg_seq(V): + return (LRTSearch(periods, durations, epoch_oversample=eo, + run_kwargs=dict(detector='marginal', + systematics_basis=V, + coeff_prior_mean=mu_c, + coeff_prior_cov=cov_c)), + LRTSearch(periods, durations, epoch_oversample=eo, + run_kwargs=dict(detector='sequential', + systematics_basis=V))) + lrt_marg, lrt_seq = marg_seq(V_est) + lrt_marg_nzm, lrt_seq_nzm = marg_seq(V_nzm) + + arms = (None if args.arms is None + else [a.strip() for a in args.arms.split(',') if a.strip()]) + + results = {'meta': dict(seed=args.seed, n_null=n_null, n_inj=n_inj, + n_periods=n_periods, + ndata=len(t), baseline=90.0, + p_true=p_true, dur_true=dur_true, + depths=depths, sigma_white=sigma_w, + durations=[float(d) for d in durations], + qvals=list(qvals), + harness_version=HARNESS_VERSION, + rel_offset=REL_OFFSET, bjd_offset=BJD_OFFSET, + configs_run=[c['name'] for c in configs], + arms=arms, complete=False, + gpu=_gpu_name(), git_sha=_git_sha(), + date=time.strftime('%Y-%m-%d'), + lrt_sigma=float(lrt.proc.sigma), + lrt_nf=int(2 * len(t))), + 'snr_calibration': None, 'configs': []} + + if 'white' in selected and not args.skip_calibration: + print('LRT statistic null calibration on white noise...', + flush=True) + # 1000 draws: with 200 the sample std/mean of the 2026-09-06 + # campaign came out 1.58/0.35 where 5000 draws from the same + # seed give 1.81/0.03 (see benchmarks/results/nufft_lrt_validation_2026-09-06/) + results['snr_calibration'] = snr_calibration( + np.random.RandomState(args.seed + CALIBRATION_SEED_OFFSET), + t, {}, n=40 if args.quick else 1000) + print(' mean=%.3f std=%.3f (calibration constant: mean ~0 ' + 'expected; std is NOT ~1 by design -- the pre-fix campaign ' + 'measured 1.81 at nf = 2n with the sigma = 2 NFFT)' + % (results['snr_calibration']['mean'], + results['snr_calibration']['std']), flush=True) + + for cfg in configs: + t0 = time.time() + print('config %s (seed %d, t_offset %g) ...' + % (cfg['name'], cfg['seed'], cfg['t_offset']), flush=True) + if cfg['name'] == 'red_sys_nzm': + cfg_methods = {'lrt_marg': lrt_marg_nzm, 'lrt_seq': lrt_seq_nzm} + else: + cfg_methods = dict(methods) + if cfg['name'] in ('red_1x', 'red_3x'): + cfg_methods['lrt_flat'] = lrt_flat + if cfg['name'] == 'red_sys': + cfg_methods['lrt_marg'] = lrt_marg + cfg_methods['lrt_seq'] = lrt_seq + if arms is not None: + missing = [a for a in arms if a not in cfg_methods] + if missing: + ap.error('arm(s) %s are not run on config %r (available: ' + '%s)' % (missing, cfg['name'], sorted(cfg_methods))) + cfg_methods = {a: cfg_methods[a] for a in arms} + r = run_config(cfg, cfg_methods, n_null, n_inj, cfg['depths'], t, + log=lambda s: print(s, flush=True)) + r['name'] = cfg['name'] + r['wall_s'] = time.time() - t0 + results['configs'].append(r) + for name, m in r['methods'].items(): + print(' %-8s null_p95=%8.3f %.3f s/search completeness=%s' + % (name, m['null_max_p95'], m['seconds_per_search'], + {d: c for d, c in m['completeness'].items()}), + flush=True) + # checkpoint after every config + with open(args.out, 'w') as f: + json.dump(results, f, indent=1) + + results['meta']['complete'] = True + with open(args.out, 'w') as f: + json.dump(results, f, indent=1) + print('wrote', args.out) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/benchmarks/results/.gitignore b/benchmarks/results/.gitignore new file mode 100644 index 00000000..962498fb --- /dev/null +++ b/benchmarks/results/.gitignore @@ -0,0 +1,40 @@ +# Raw evidence is restored on demand and must stay outside Git. +* +!*/ +!.gitignore +!.gitattributes +!*.md +!*.rst +# Curated figures and small result tables are explicitly retained. +!/tls_accuracy_2026-09-09/accuracy/summary.csv +!/tls_profile_2026-09-08/ablation_output_comparison.csv +!/tls_profile_2026-09-08/figures/tls_components.png +!/tls_profile_2026-09-08/phase_timings.csv +!/tls_profile_2026-09-08/timing_summary.csv +!/tls_sensitivity_2026-09-09/hatpi_timing.csv +!/tls_sensitivity_2026-09-09/recovery_by_snr.csv +!/tls_sensitivity_2026-09-09/timing_analysis.csv +!/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv +!/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png +!/tls_survey_2026-09-10/final-figures/survey-throughput.png +!/tls_survey_2026-09-10/final-report/exactness.csv +!/tls_survey_2026-09-10/final-report/exactness_mismatches.csv +!/tls_survey_2026-09-10/final-report/paired_contrasts.csv +!/tls_survey_2026-09-10/final-report/recovery_fpr.csv +!/tls_survey_2026-09-10/final-report/thresholds.csv +!/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.csv +!/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.png +!/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.png +!/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv +!/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png +!/transit_2026-09-08/benchmark_story.png +!/transit_2026-09-08/component_ablations.csv +!/transit_2026-09-08/component_phases.csv +!/transit_2026-09-08/component_summary.csv +!/transit_2026-09-08/paired_comparisons.csv +!/transit_2026-09-08/recovery_by_snr.csv +!/transit_2026-09-08/recovery_summary.csv +!/transit_2026-09-08/runtime_by_cohort.csv +!/transit_2026-09-08/runtime_cohort_ratios.csv +!/transit_2026-09-08/speedups.csv +!/transit_2026-09-08/timing_summary.csv diff --git a/benchmarks/results/nufft_lrt_validation_2026-09-06/README.md b/benchmarks/results/nufft_lrt_validation_2026-09-06/README.md new file mode 100644 index 00000000..3bcf4644 --- /dev/null +++ b/benchmarks/results/nufft_lrt_validation_2026-09-06/README.md @@ -0,0 +1,56 @@ +# NUFFT-LRT injection-recovery re-validation, 2026-09-06 (Phase 4 of the 1.0 release plan) + +The campaign that decided D1 (official vs experimental) for +`cuvarbase.nufft_lrt` in 1.0.0, run on the fixed module (Sep-2026 +correctness fixes: float64 epoch subtraction, automatic epoch grid for +`epochs=None`, centred sequential cotrend, Detector A PSD from the +basis-projected residual, `sigma = 4` NFFT, PSD/prior validation, +per-run NFFT buffer reuse). + +| | | +|---|---| +| harness | `scripts/nufft_lrt_validation.py` (harness version 2) at commit `2f9736a`; tables by `scripts/summarize_lrt_validation.py` | +| GPU / stack | one NVIDIA A40 (RunPod, CUDA 12.4, Python 3.11, pycuda 2026.1, numpy 2.4.6, cufinufft 2.5.1, batman-package 2.5.3, transitleastsquares 1.32) | +| protocol | 600-point ground-based sampling over 90 d; 32 log-spaced trial periods 2-18 d with P = 5.3 d and 2P on the grid; box transits of 0.22 d at random epochs; `sigma_white = 3e-3`; per configuration and arm: null p95 threshold from 200 signal-free light curves, then 200 injections per depth (4 depths); detection = statistic above threshold and best period within 1 % of P, 2P or P/2 | +| configurations | `white`, `white_bjd` (the white light curves on `t + 2457000.5` d, paired), `red_1x`, `red_3x` (OU red noise, tau 0.8 d, at 1x / 3x sigma_white), `red_sys` (1x red + three shared systematics modes, PCA basis + population prior), `red_sys_nzm` (the same light curves searched with non-zero-mean basis columns, paired) | +| arms | `lrt` (explicit epoch grid), `lrt_auto` (the public default path, `epochs=None`), `lrt_flat` (PSD = ones; red configs), `lrt_marg` (Detector A), `lrt_seq` (least-squares cotrend + filter), `bls` (`eebls_gpu_fast`), `tls` (`tls_search_batch`, delta-chi2 statistic) | +| split | 8 processes (`launch_campaign.sh`), 12:13-15:43 UTC, 79,458 s of process compute in total; started from a clean checkout of `2f9736a` | +| seed | 20260711 (per-configuration sub-seeds: paired configurations share one, so they see identical light curves) | + +## Files + +- `nufft_lrt_validation_2026-09-06.json` -- the merged campaign (`--merge` of the 8 process JSONs): `meta`, `snr_calibration`, and per configuration the protocol, and per arm the null p95, completeness, epoch recovery, seconds per search, **and the per-light-curve records** (200 null maxima; per injection the statistic, best period, best epoch and decision), from which every number in the docs, the uncertainties (bootstrap of the null threshold + Wilson) and the paired comparisons are recomputed by the summarizer. +- `summary.md` -- `summarize_lrt_validation.py` output (the `--rst` form of the same is in `docs/source/nufft_lrt.rst`). +- `logs/A_white.log` ... `logs/F2_nzm_marg.log` -- the 8 process logs (progress, per-arm null p95, seconds per search, completeness). +- `logs/p4_first_nfft_ls_lrt.log` -- the first device run of the CPU-landed `cunfft.cu` change (`test_nfft.py`, `test_lombscargle.py`, `test_nufft_lrt*.py`): 210 passed, 1 failed -- the failure is a test asserting bitwise equality of two double-precision LS runs (float64 `atomicAdd` order differs at 6.7e-15 relative); fixed in `2f9736a` by comparing to rounding. +- `logs/p4_full_suite.log` -- the full GPU suite at 954f037 + that test fix: 1785 passed, 1 xfailed, 0 failed, 0 skipped (1,786 collected) in 8 min 6 s. +- `launch_campaign.sh` -- the process split used. + +## Headline numbers (completeness, 200 injections per depth; see `summary.md` for uncertainties and the paired differences) + +| configuration | depths | lrt | lrt_auto (default path) | lrt_flat | lrt_marg | lrt_seq | bls | tls | +|---|---|---|---|---|---|---|---|---| +| white | 0.002/0.003/0.004/0.008 | 13/47/82/99 % | 10/42/74/99 % | -- | -- | -- | 13/60/91/100 % | 16/65/90/100 % | +| white_bjd | same | identical to white (max rel. diff of any statistic 5e-8; 0 of 800 decisions differ) | identical | -- | -- | -- | identical | identical | +| red_1x | 0.004/0.006/0.008/0.016 | 4/25/56/100 % | 4/24/52/99 % | 4/22/54/100 % | -- | -- | 2/17/47/100 % | 4/22/62/100 % | +| red_3x | 0.008/0.016/0.024/0.032 | 0/12/57/89 % | 0/10/48/83 % | 0/18/63/90 % | -- | -- | 0/7/48/88 % | 0/17/62/93 % | +| red_sys | 0.004/0.008/0.016/0.032 | 0/0/6/34 % | 0/0/5/34 % | -- | 3/44/98/100 % | 3/43/98/100 % | 0/0/2/16 % | 0/0/0/0 % | +| red_sys_nzm | same | -- | -- | -- | identical to red_sys (max rel. diff 5.5e-7; 0 of 800 decisions differ) | identical | -- | -- | + +Null calibration of the single-template statistic on white noise: the +JSON's 200 draws give mean 0.348, std 1.579; 5000 draws from the same +seed (`snr_calibration` with `n=5000`, run on the same pod) give mean +0.030 +- 0.026, std 1.808 -- the 200-draw sample is an unlucky one, and +the harness now draws 1000 by default. The statistic is exactly odd in +the data (verified: `S(-y) = -S(y)` to 2e-9), so its null mean is zero +by construction; the std is the calibration constant (1.81, unchanged +from the pre-fix campaign's 1.812 and independent of `sigma`). + +Cost on the A40 under the 8-process split: 3.4-6.7 s per LRT search +(7,473 templates for the explicit grid, ~5,900 for the default path); +1.2-1.6 ms for BLS and 8-11 ms for TLS. Single-process timings are +~2.5x lower for the LRT arms (0.23 ms per template). + +The pre-fix campaign this supersedes: `analysis/audit-sep2026/campaign/` +(60 nulls / 60 injections, `sigma = 2`, explicit epochs only, relative +times, zero-mean basis; `ALGORITHM_AUDIT.md` section 6). diff --git a/benchmarks/results/nufft_lrt_validation_2026-09-06/summary.md b/benchmarks/results/nufft_lrt_validation_2026-09-06/summary.md new file mode 100644 index 00000000..788481d9 --- /dev/null +++ b/benchmarks/results/nufft_lrt_validation_2026-09-06/summary.md @@ -0,0 +1,238 @@ +GPU: NVIDIA A40; commit: 2f9736ae4; date: 2026-09-06 + +### LRT statistic null calibration (white noise, fixed template) + +mean = 0.348, std = 1.579 over 200 realizations (the pre-fix Sep-2026 campaign, sigma = 2 NFFT: 0.007, 1.812). Calibration constant of this configuration, not a pass/fail check: the statistic is a whitened correlation, not N(0,1), because the NFFT modes of irregular sampling are not orthogonal; its null std depends on the sampling, nf and the PSD estimator. This is why the thresholds below are empirical null percentiles. + +### Protocol + +600-point ground-like irregular sampling over 90 d; trial grid 32 periods (injected P = 5.30 d on-grid), box duration 0.22 d; thresholds = 95th percentile of 200 null search maxima; completeness over 200 injections per depth, period hit within 1% (incl. 2:1 aliases). Depths are fractions of the flux; sigma_white = 0.003. + +Completeness cells are "p +- sigma" with sigma the quadrature sum of the null-threshold sampling error (bootstrap of the null maxima) and the binomial (Wilson, z = 1) error; the paired-difference rows use the same lightcurves for both arms (McNemar sigma = sqrt(b + c) / n, thresholds fixed). + +### White noise + +| arm | null p95 | depth 0.002 | depth 0.003 | depth 0.004 | depth 0.008 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 8.588 | 13 +- 3% | 47 +- 5% | 82 +- 3% | 99 +- 1% | 5737 | +| LRT, default path (epochs=None) | 8.719 | 10 +- 3% | 42 +- 5% | 74 +- 4% | 99 +- 1% | 4491 | +| BLS (eebls_gpu_fast) | 0.038 | 13 +- 3% | 60 +- 4% | 91 +- 2% | 100 +- 0% | 1.55 | +| TLS (tls_search_batch, delta-chi2) | 4.662 | 16 +- 3% | 65 +- 4% | 90 +- 2% | 100 +- 1% | 11.4 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.002 | depth 0.003 | depth 0.004 | depth 0.008 | +|---|---:|---:|---:|---:| +| lrt - bls | +0 +- 2% | -12 +- 3% | -10 +- 2% | -1 +- 1% | +| lrt_auto - lrt | -4 +- 1% | -5 +- 3% | -8 +- 2% | +0 +- 1% | +| lrt - tls | -4 +- 2% | -18 +- 3% | -9 +- 3% | -0 +- 1% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 0.002 | 26 | 96% | 0.034 | 0.140 | +| LRT (explicit epoch grid) | 0.003 | 94 | 96% | 0.027 | 0.170 | +| LRT (explicit epoch grid) | 0.004 | 163 | 100% | 0.019 | 0.093 | +| LRT (explicit epoch grid) | 0.008 | 198 | 100% | 0.015 | 0.044 | +| LRT, default path (epochs=None) | 0.002 | 19 | 95% | 0.030 | 0.135 | +| LRT, default path (epochs=None) | 0.003 | 84 | 99% | 0.025 | 0.149 | +| LRT, default path (epochs=None) | 0.004 | 147 | 99% | 0.020 | 0.128 | +| LRT, default path (epochs=None) | 0.008 | 198 | 100% | 0.020 | 0.091 | + +(compute: 10253 s) + +### White noise, absolute times (BJD-scale, t + 2457000 d) + +| arm | null p95 | depth 0.002 | depth 0.003 | depth 0.004 | depth 0.008 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 8.588 | 13 +- 3% | 47 +- 5% | 82 +- 3% | 99 +- 1% | 5740 | +| LRT, default path (epochs=None) | 8.719 | 10 +- 3% | 42 +- 5% | 74 +- 4% | 99 +- 1% | 4487 | +| BLS (eebls_gpu_fast) | 0.038 | 13 +- 3% | 60 +- 4% | 91 +- 2% | 100 +- 0% | 1.53 | +| TLS (tls_search_batch, delta-chi2) | 4.662 | 16 +- 3% | 65 +- 4% | 90 +- 2% | 100 +- 1% | 11.4 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.002 | depth 0.003 | depth 0.004 | depth 0.008 | +|---|---:|---:|---:|---:| +| lrt - bls | +0 +- 2% | -12 +- 3% | -10 +- 2% | -1 +- 1% | +| lrt_auto - lrt | -4 +- 1% | -5 +- 3% | -8 +- 2% | +0 +- 1% | +| lrt - tls | -4 +- 2% | -18 +- 3% | -9 +- 3% | -0 +- 1% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 0.002 | 26 | 96% | 0.034 | 0.140 | +| LRT (explicit epoch grid) | 0.003 | 94 | 96% | 0.027 | 0.170 | +| LRT (explicit epoch grid) | 0.004 | 163 | 100% | 0.019 | 0.093 | +| LRT (explicit epoch grid) | 0.008 | 198 | 100% | 0.015 | 0.044 | +| LRT, default path (epochs=None) | 0.002 | 19 | 95% | 0.030 | 0.135 | +| LRT, default path (epochs=None) | 0.003 | 84 | 99% | 0.025 | 0.149 | +| LRT, default path (epochs=None) | 0.004 | 147 | 99% | 0.020 | 0.128 | +| LRT, default path (epochs=None) | 0.008 | 198 | 100% | 0.020 | 0.091 | + +(compute: 10249 s) + +### Red noise, sigma_red = sigma_white + +| arm | null p95 | depth 0.004 | depth 0.006 | depth 0.008 | depth 0.016 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 11.364 | 4 +- 2% | 25 +- 4% | 56 +- 5% | 100 +- 1% | 4383 | +| LRT, default path (epochs=None) | 11.186 | 4 +- 2% | 24 +- 4% | 52 +- 5% | 99 +- 1% | 3412 | +| LRT, flat PSD | 1.779 | 4 +- 2% | 22 +- 4% | 54 +- 5% | 100 +- 1% | 4377 | +| BLS (eebls_gpu_fast) | 0.124 | 2 +- 1% | 17 +- 3% | 47 +- 5% | 100 +- 0% | 1.22 | +| TLS (tls_search_batch, delta-chi2) | 12.283 | 4 +- 1% | 22 +- 3% | 62 +- 4% | 100 +- 0% | 8.56 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.004 | depth 0.006 | depth 0.008 | depth 0.016 | +|---|---:|---:|---:|---:| +| lrt - bls | +2 +- 2% | +8 +- 3% | +10 +- 3% | -0 +- 0% | +| lrt_auto - lrt | +0 +- 1% | -1 +- 2% | -4 +- 2% | -0 +- 0% | +| lrt - lrt_flat | -0 +- 1% | +2 +- 2% | +3 +- 3% | +0 +- 0% | +| lrt - tls | -0 +- 2% | +3 +- 3% | -5 +- 3% | -0 +- 0% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 0.004 | 7 | 100% | 0.017 | 0.060 | +| LRT (explicit epoch grid) | 0.006 | 50 | 96% | 0.017 | 0.144 | +| LRT (explicit epoch grid) | 0.008 | 113 | 100% | 0.018 | 0.107 | +| LRT (explicit epoch grid) | 0.016 | 199 | 100% | 0.017 | 0.061 | +| LRT, default path (epochs=None) | 0.004 | 7 | 100% | 0.011 | 0.021 | +| LRT, default path (epochs=None) | 0.006 | 48 | 94% | 0.033 | 1.447 | +| LRT, default path (epochs=None) | 0.008 | 104 | 100% | 0.019 | 0.080 | +| LRT, default path (epochs=None) | 0.016 | 198 | 99% | 0.019 | 0.110 | +| LRT, flat PSD | 0.004 | 8 | 88% | 0.020 | 0.960 | +| LRT, flat PSD | 0.006 | 45 | 98% | 0.018 | 0.115 | +| LRT, flat PSD | 0.008 | 107 | 100% | 0.018 | 0.069 | +| LRT, flat PSD | 0.016 | 199 | 100% | 0.018 | 0.061 | + +(compute: 12206 s) + +### Red noise, sigma_red = 3 sigma_white + +| arm | null p95 | depth 0.008 | depth 0.016 | depth 0.024 | depth 0.032 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 14.121 | 0 +- 0% | 12 +- 4% | 57 +- 6% | 89 +- 3% | 4383 | +| LRT, default path (epochs=None) | 13.972 | 0 +- 0% | 10 +- 3% | 48 +- 5% | 83 +- 4% | 3413 | +| LRT, flat PSD | 5.097 | 0 +- 1% | 18 +- 6% | 63 +- 6% | 90 +- 3% | 4376 | +| BLS (eebls_gpu_fast) | 0.204 | 0 +- 1% | 7 +- 2% | 48 +- 4% | 88 +- 2% | 1.2 | +| TLS (tls_search_batch, delta-chi2) | 34.507 | 0 +- 1% | 17 +- 3% | 62 +- 4% | 93 +- 2% | 8.55 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.008 | depth 0.016 | depth 0.024 | depth 0.032 | +|---|---:|---:|---:|---:| +| lrt - bls | -0 +- 0% | +6 +- 2% | +10 +- 3% | +1 +- 2% | +| lrt_auto - lrt | +0 +- 0% | -2 +- 1% | -9 +- 2% | -6 +- 2% | +| lrt - lrt_flat | -0 +- 0% | -6 +- 2% | -6 +- 3% | -0 +- 1% | +| lrt - tls | -0 +- 0% | -4 +- 2% | -6 +- 3% | -4 +- 1% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 0.016 | 25 | 100% | 0.010 | 0.046 | +| LRT (explicit epoch grid) | 0.024 | 114 | 99% | 0.013 | 0.219 | +| LRT (explicit epoch grid) | 0.032 | 178 | 100% | 0.015 | 0.097 | +| LRT, default path (epochs=None) | 0.016 | 21 | 100% | 0.017 | 0.100 | +| LRT, default path (epochs=None) | 0.024 | 96 | 100% | 0.026 | 0.070 | +| LRT, default path (epochs=None) | 0.032 | 166 | 100% | 0.018 | 0.102 | +| LRT, flat PSD | 0.008 | 1 | 100% | 0.095 | 0.095 | +| LRT, flat PSD | 0.016 | 36 | 97% | 0.012 | 2.597 | +| LRT, flat PSD | 0.024 | 126 | 98% | 0.015 | 0.219 | +| LRT, flat PSD | 0.032 | 179 | 100% | 0.015 | 0.069 | + +(compute: 12202 s) + +### Red noise + shared systematics (PCA basis + population prior) + +| arm | null p95 | depth 0.004 | depth 0.008 | depth 0.016 | depth 0.032 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 11.965 | 0 +- 0% | 0 +- 0% | 6 +- 2% | 34 +- 4% | 5735 | +| LRT, default path (epochs=None) | 11.616 | 0 +- 0% | 0 +- 0% | 5 +- 2% | 34 +- 4% | 4482 | +| LRT Detector A (marginal) | 12.104 | 3 +- 1% | 44 +- 5% | 98 +- 1% | 100 +- 0% | 5524 | +| LRT sequential cotrend | 12.220 | 3 +- 2% | 43 +- 5% | 98 +- 1% | 100 +- 0% | 5412 | +| BLS (eebls_gpu_fast) | 0.188 | 0 +- 0% | 0 +- 0% | 2 +- 1% | 16 +- 3% | 1.51 | +| TLS (tls_search_batch, delta-chi2) | 114.971 | 0 +- 0% | 0 +- 0% | 0 +- 0% | 0 +- 0% | 11.4 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.004 | depth 0.008 | depth 0.016 | depth 0.032 | +|---|---:|---:|---:|---:| +| lrt - bls | +0 +- 0% | +0 +- 0% | +3 +- 1% | +17 +- 3% | +| lrt_auto - lrt | +0 +- 0% | +0 +- 0% | -0 +- 0% | +0 +- 2% | +| lrt_marg - lrt_seq | +0 +- 0% | +0 +- 0% | +0 +- 0% | +0 +- 0% | +| lrt_seq - bls | +3 +- 1% | +43 +- 5% | +95 +- 7% | +84 +- 6% | +| lrt - tls | +0 +- 0% | +0 +- 0% | +6 +- 2% | +34 +- 4% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 0.016 | 11 | 100% | 0.016 | 0.052 | +| LRT (explicit epoch grid) | 0.032 | 67 | 100% | 0.017 | 0.080 | +| LRT, default path (epochs=None) | 0.016 | 10 | 100% | 0.009 | 0.086 | +| LRT, default path (epochs=None) | 0.032 | 67 | 100% | 0.017 | 0.104 | +| LRT Detector A (marginal) | 0.004 | 6 | 100% | 0.010 | 0.108 | +| LRT Detector A (marginal) | 0.008 | 87 | 100% | 0.016 | 0.090 | +| LRT Detector A (marginal) | 0.016 | 195 | 100% | 0.014 | 0.052 | +| LRT Detector A (marginal) | 0.032 | 200 | 100% | 0.016 | 0.052 | +| LRT sequential cotrend | 0.004 | 6 | 100% | 0.010 | 0.108 | +| LRT sequential cotrend | 0.008 | 86 | 100% | 0.017 | 0.090 | +| LRT sequential cotrend | 0.016 | 195 | 100% | 0.014 | 0.052 | +| LRT sequential cotrend | 0.032 | 200 | 100% | 0.016 | 0.052 | + +(compute: 21204 s) + +### Red noise + shared systematics, non-zero-mean basis columns + +| arm | null p95 | depth 0.004 | depth 0.008 | depth 0.016 | depth 0.032 | ms/search | +|---|---:|---:|---:|---:|---:|---:| +| LRT Detector A (marginal) | 12.104 | 3 +- 1% | 44 +- 5% | 98 +- 1% | 100 +- 0% | 6725 | +| LRT sequential cotrend | 12.220 | 3 +- 2% | 43 +- 5% | 98 +- 1% | 100 +- 0% | 6595 | + +Paired completeness differences (A - B, same lightcurves): + +| A - B | depth 0.004 | depth 0.008 | depth 0.016 | depth 0.032 | +|---|---:|---:|---:|---:| +| lrt_marg - lrt_seq | +0 +- 0% | +0 +- 0% | +0 +- 0% | +0 +- 0% | + +Epoch recovery among detections (arms that return a best epoch; "same transit" = within half the injected duration, which any correct-period detection meets; the errors show the grid resolution): + +| arm | depth | detections | same transit (within dur/2) | median abs. error (d) | max abs. error (d) | +|---|---:|---:|---:|---:|---:| +| LRT Detector A (marginal) | 0.004 | 6 | 100% | 0.010 | 0.108 | +| LRT Detector A (marginal) | 0.008 | 87 | 100% | 0.016 | 0.090 | +| LRT Detector A (marginal) | 0.016 | 195 | 100% | 0.014 | 0.052 | +| LRT Detector A (marginal) | 0.032 | 200 | 100% | 0.016 | 0.052 | +| LRT sequential cotrend | 0.004 | 6 | 100% | 0.010 | 0.108 | +| LRT sequential cotrend | 0.008 | 86 | 100% | 0.017 | 0.090 | +| LRT sequential cotrend | 0.016 | 195 | 100% | 0.014 | 0.052 | +| LRT sequential cotrend | 0.032 | 200 | 100% | 0.016 | 0.052 | + +(compute: 13344 s) + +### Paired configurations (same lightcurves) + +Each pair saw identical noise and injections (shared sub-seed) and differs only in the time origin (white / white_bjd: + 2457000 d, an integer, so every method's floor(min t)-anchored grid keeps its phase) or in the basis column offsets (red_sys / red_sys_nzm). Differences beyond float32 rounding would indicate a time-scale or centring defect. + +**white vs white_bjd** + +| arm | searches | max rel. diff | median rel. diff | best period differs | detection differs | null p95 (white / white_bjd) | +|---|---:|---:|---:|---:|---:|---:| +| LRT (explicit epoch grid) | 1000 | 5.2e-08 | 0.0e+00 | 3 / 800 | 0 / 800 | 8.588 / 8.588 | +| LRT, default path (epochs=None) | 1000 | 3.5e-08 | 0.0e+00 | 2 / 800 | 0 / 800 | 8.719 / 8.719 | +| BLS (eebls_gpu_fast) | 1000 | 1.3e-06 | 0.0e+00 | 0 / 800 | 0 / 800 | 0.038 / 0.038 | +| TLS (tls_search_batch, delta-chi2) | 1000 | 1.4e-07 | 0.0e+00 | 0 / 800 | 0 / 800 | 4.662 / 4.662 | + +**red_sys vs red_sys_nzm** + +| arm | searches | max rel. diff | median rel. diff | best period differs | detection differs | null p95 (red_sys / red_sys_nzm) | +|---|---:|---:|---:|---:|---:|---:| +| LRT Detector A (marginal) | 1000 | 5.5e-07 | 1.4e-08 | 3 / 800 | 0 / 800 | 12.104 / 12.104 | +| LRT sequential cotrend | 1000 | 8.4e-08 | 0.0e+00 | 2 / 800 | 0 / 800 | 12.220 / 12.220 | + diff --git a/benchmarks/results/tls_accuracy_2026-09-09/.gitattributes b/benchmarks/results/tls_accuracy_2026-09-09/.gitattributes new file mode 100644 index 00000000..b831c6b4 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/.gitattributes @@ -0,0 +1,5 @@ +# Preserve the exact bytes recorded by the evidence checksum inventories. +* -text +*.csv whitespace=cr-at-eol +# Captured test output is retained verbatim, including warning indentation. +*.log -whitespace diff --git a/benchmarks/results/tls_accuracy_2026-09-09/README.md b/benchmarks/results/tls_accuracy_2026-09-09/README.md new file mode 100644 index 00000000..5690d549 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/README.md @@ -0,0 +1,44 @@ +# TLS accuracy and computational efficiency + +This 9 September 2026 audit evaluates the earlier phase-binned TLS engine, +retained as `method='binned'`. “Defaults” below refers to that frozen engine. +See the [current transit report](../../../docs/TRANSIT_BENCHMARKS.md) for the +observation-level default. These measurements remain historical evidence. + +This audit separates the sensitivity cost of cuvarbase's fast TLS approximation +from the effect of optimizing its implementation. Binning is inexpensive in +many ordinary transit examples, but a universal 1–2% SNR-loss bound is false. +The duration prior and search grids can matter more than binning alone. + +| Evidence | Question answered | +|---|---| +| [Expected-SNR diagnostic](accuracy/README.md) | What do the fixed template, phase bins and coarse grids lose at the true period across physical transit regimes? | +| [Kernel validation](kernel/README.md) | Does skipping empty bins accelerate the same search while preserving its numerical results? | +| [High-impact recovery pilot](high-impact/README.md) | On new noisy TESS inputs, can GTLS recover narrow transits that the then-default binned search misses, and how do alternative cuvarbase settings behave? | + +The CPU diagnostic uses 19 physical regimes and three search configurations, +plus observed TESS/ZTF cadences. The focused GPU pilot independently calibrates +each method before testing new injections and nulls. The engineering timings +compare the original and optimized CUDA kernels with identical inputs and +settings; they are not a new competitor sensitivity experiment. + +At identical fine-resolution settings, the optimized ZTF search uses 23% less +time (1.30× faster); fine-resolution TESS timings are effectively unchanged. +All 169 TLS tests pass, and comparisons on 384 lightcurves preserve primary +periods and reported SNR within the recorded numerical checks. Separately, +the targeted high-impact pilot recovers 61/256 injections with the defaults, +112/256 with GTLS and 116/256 with a wider cuvarbase duration search. The pilot +does not establish equivalence between the latter two configurations. + +The pre-optimization cuvarbase reference is +`11317fb0ff1b68af05ae3f67de5f298c9a90e46b`; public GTLS is pinned to +`74e449c325792a763dde4fbffab98039c5e8c111`. Each subdirectory records the exact +sources, input hashes, configuration and validation applicable to its claims. +Large generated lightcurve arrays are kept outside the release repository and +can be regenerated using the frozen protocol. + +[The TLS numerical guide](../../../docs/TLS_NUMERICS.md) distinguishes the +current default from the retained binned engine. The separate +[calibrated survey benchmark](../tls_sensitivity_2026-09-09/README.md) supplied +the historical binned-TLS speed figure; current release claims are in the +[transit report](../../../docs/TRANSIT_BENCHMARKS.md). diff --git a/benchmarks/results/tls_accuracy_2026-09-09/accuracy/README.md b/benchmarks/results/tls_accuracy_2026-09-09/accuracy/README.md new file mode 100644 index 00000000..d5b320c7 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/accuracy/README.md @@ -0,0 +1,114 @@ +# Expected SNR retained by fast TLS + +This 9 September 2026 diagnostic evaluates the earlier phase-binned TLS +engine, retained as `method='binned'`. “API defaults” below means its frozen +settings. See the [current transit report](../../../../docs/TRANSIT_BENCHMARKS.md) +for the observation-level default. + +Phase binning has a small cost in many of these examples, but **1–2% is not a +universal upper bound**. This CPU diagnostic separates the fixed transit +template, its phase-bin approximation, and its epoch/duration grids. It +calculates expected white-noise SNR at the **true period**; it does not measure +detection recovery, false-positive rates, GTLS sensitivity, or speed. + +The table uses the API defaults: automatic bins, `t0_oversample=3`, and 15 +durations. “Bin loss” is the worst of 32 sampled phase offsets, relative to +the best unbinned fixed TLS template with the same fitted duration and epoch. +The box independently optimizes its duration and epoch; its loss is relative +to an oracle filter using the true physical signal. + +| Earth-size planet; stellar mass/radius in solar units | Period | Impact parameter | Bins across transit | Bin cap reached | Below duration prior | Additional bin SNR loss | Optimized box SNR loss | +| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | +| Sun | 10 d | 0 | 8.42 | No | No | 1.22% | 1.12% | +| Sun | 365.25 d | 0 | 6.12 | No | No | 2.07% | 1.12% | +| Sun | 365.25 d | 0.8 | 3.73 | No | No | 3.29% | 0.97% | +| Mass = radius = 0.1 | 365.25 d | 0 | 2.85 | Yes | No | 5.35% | 1.37% | +| Mass = radius = 0.1 | 365.25 d | 0.9 | 1.61 | Yes | No | 20.31% | 3.53% | +| Sun | 10 d | 0.95 | 2.84 | No | Yes | 5.16% | 1.13% | +| Sun, eccentricity 0.8 at periastron | 100 d | 0.5 | 2.10 | No | Yes | 10.24% | 1.10% | + +These bin-loss values isolate binning; the full search can additionally lose +SNR through the duration prior, grid spacing, candidate selection and other +steps. A duration below the prior cannot be repaired by increasing the number +of bins alone. The long-period examples describe a sufficiently observed +signal; whether a survey samples it depends on its baseline and cadence. + +All 19 regimes and three configurations are in [summary.csv](summary.csv). +The previous benchmark's `t0_oversample=4`, 16-duration configuration and its +8,192-bin, `t0_oversample=16`, 32-duration configuration are included separately. +Uniform configurations sample different integer bin indices and epoch phases: +their coarse-grid ranges are descriptive, **not paired comparisons of the same +ephemerides**. Observed-cadence configurations do share identical ephemerides. + +## Model and interpretation + +The physical signal comes from exposure-integrated `batman` models with known +unit baselines and quadratic limb darkening `[0.4804, 0.1867]`. The uniform +calculation uses 4,096 intervals per geometric transit and 64-point exposure +quadrature. It uses the pinned cuvarbase template and integral tables, with +float64 evaluation to isolate approximation errors from GPU roundoff. + +The catalog includes central, high-impact, grazing, long-period, dense-star, +and eccentric shapes. Shared limb darkening is a controlled assumption, not +an atmosphere model for every stellar class. White-dwarf entries are shape +and resolution stress cases; their physical eclipse depths may violate the +production depth gate, which this amplitude-invariant diagnostic omits. + +An additional 72 ephemerides use stored TESS/ZTF times, exposures and relative +errors, with synthetic flux and independent errors of +`0.001 * relative_error`. Thirteen have no sampled signal and are retained +without a retention ratio. This is not a completeness sample conditioned on +observability. Correlated noise, detrending, period errors and native SDE are +outside the calculation. + +| CSV quantity | Meaning | +| --- | --- | +| `template_snr_over_oracle` | Best unbinned fixed-template fit relative to the true signal filter. | +| `box_snr_over_oracle` | Best box shape relative to the same oracle; duration and epoch are free. | +| `physical_projection_retention` | Maximum possible SNR retained by the stored bin sums, given the true signal. | +| `physical_binned_over_unbinned` | Actual bin-averaged TLS filter relative to its best unbinned fit. | +| `own_template_binned_over_unbinned` | Compression control injecting the same pointwise TLS template. | +| `coarse_grid_native_selected_over_oracle` | Actual SNR of the trial selected by the native expected-score objective at the true period. | + +SNR uses each filter's **actual noise variance**. The coarse kernel's +`mean(T²)` normalization differs from the variance of its `mean(T)` filter; +`native_norm_over_noise` reports that distinction. An individual +`physical_binned_over_unbinned` ratio can exceed one when smoothing improves +a mismatched shape. It still obeys the compressed-oracle information bound. +Isolated bin, epoch and duration losses must not be added: jointly changing +them can change which trial wins. The full [tool documentation](../../../tls_accuracy/README.md) +defines every metric and assumption. + +## Evidence and reproduction + +[cases.csv](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/accuracy/cases.csv") contains 2,014 rows: 1,824 uniform cases and 190 +observed-cadence rows, including the 13 unsampled ephemerides. Each of the +59 sampled observed ephemerides has three configuration rows. +[manifest.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/accuracy/manifest.json") records versions, parameters, source revision +and hashes; its file paths are relative to this directory. Historical source +snapshots preserve the exact diagnostic, tests, template/grid code and kernel +used to define the calculation. + +[validation.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/accuracy/validation.json") records 12 passing mathematical tests, +projection-bound checks, fitting-boundary checks and numerical convergence. +Doubling integration resolution and exposure quadrature changes the checked +SNR quantities by less than **0.0015 percentage points**. +[convergence.csv](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/accuracy/convergence.csv") retains the refined run's comparison +columns, matched to the baseline by regime, configuration and offset index. +All packaged-file hashes are in [SHA256SUMS.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/accuracy/SHA256SUMS.json"). + +Run from the repository root with NumPy, SciPy and `batman-package` installed: + +```sh +python benchmarks/tls_accuracy/diagnose.py \ + --source-revision 11317fb0ff1b68af05ae3f67de5f298c9a90e46b \ + --cadences benchmarks/results/tls_sensitivity_2026-09-09/cadences \ + --out /tmp/cuvarbase-tls-accuracy +python -m pytest -q benchmarks/tls_accuracy/test_diagnose.py +``` + +For the uniform convergence run, omit `--cadences` and add +`--samples-per-transit 8192 --exposure-nodes 128`, choosing a different output +directory. The source revision is the benchmark's pre-optimization baseline; +this result archive does not establish the optimized kernel's timing or +numerical equivalence. diff --git a/benchmarks/results/tls_accuracy_2026-09-09/accuracy/summary.csv b/benchmarks/results/tls_accuracy_2026-09-09/accuracy/summary.csv new file mode 100644 index 00000000..192a01d4 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/accuracy/summary.csv @@ -0,0 +1,85 @@ +kind,profile,regime,config,cases,q,duration_hours,ingress_minutes,bins,bins_across_transit,bins_across_ingress,bin_cap,epoch_cap,duration_below_prior,depth_gate_caveat,template_snr_over_oracle_minimum,template_snr_over_oracle_median,template_snr_over_oracle_maximum,box_snr_over_oracle_minimum,box_snr_over_oracle_median,box_snr_over_oracle_maximum,physical_projection_retention_minimum,physical_projection_retention_median,physical_projection_retention_maximum,physical_binned_over_unbinned_minimum,physical_binned_over_unbinned_median,physical_binned_over_unbinned_maximum,own_template_binned_over_unbinned_minimum,own_template_binned_over_unbinned_median,own_template_binned_over_unbinned_maximum,native_norm_over_noise_minimum,native_norm_over_noise_median,native_norm_over_noise_maximum,own_template_uncapped_retention_minimum,own_template_uncapped_retention_median,own_template_uncapped_retention_maximum,epoch_grid_only_retention_minimum,epoch_grid_only_retention_median,epoch_grid_only_retention_maximum,duration_grid_only_retention_minimum,duration_grid_only_retention_median,duration_grid_only_retention_maximum,coarse_grid_native_selected_over_oracle_minimum,coarse_grid_native_selected_over_oracle_median,coarse_grid_native_selected_over_oracle_maximum,coarse_grid_native_selected_over_best_template_minimum,coarse_grid_native_selected_over_best_template_median,coarse_grid_native_selected_over_best_template_maximum,coarse_grid_native_amplitude_over_oracle_minimum,coarse_grid_native_amplitude_over_oracle_median,coarse_grid_native_amplitude_over_oracle_maximum +observed,tess_200s,mdwarf01_earth_10d,api_default,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,2048,7.84904877288392,0.6586655867808936,False,False,False,False,0.9999434102814441,0.999950804894593,0.9999815187177339,0.9838587977486604,0.9851671206368039,0.986413996041334,0.9890699020410364,0.9955294397992352,0.9964924963143649,0.9882165385134682,0.9942472883861152,0.9953676332981043,0.9871200128345058,0.9941770917925375,0.995352418484158,1.0068742319661015,1.0093377002370887,1.0123981894189238,,,,,,,,,,,,,,,,,, +observed,tess_200s,mdwarf01_earth_10d,benchmark_fine,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,8192,31.39619509153568,2.6346623471235744,True,False,False,False,0.9999434102814441,0.999950804894593,0.9999815187177339,0.9838587977486604,0.9851671206368039,0.986413996041334,0.9999671778576432,0.9999938755055913,0.9999960283890885,0.999312934153214,0.9995196374338966,0.9995554333791609,0.999097199493317,0.9994832242618495,0.9995539326819737,1.0005926193337624,1.0006367599701602,1.000767498674697,,,,,,,,,,,,,,,,,, +observed,tess_200s,mdwarf01_earth_10d,benchmark_original,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,4096,15.69809754576784,1.3173311735617872,False,False,False,False,0.9999434102814441,0.999950804894593,0.9999815187177339,0.9838587977486604,0.9851671206368039,0.986413996041334,0.9992411736634319,0.9997964645339581,0.9999899796523076,0.996629533406251,0.9976732245824418,0.9995248453761908,0.996176375155938,0.9975119530216378,0.9993404464934469,1.0022110351959486,1.0024814654056917,1.0026940424808284,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d,api_default,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9985649729599724,0.9986941758792797,0.9988712646743643,0.987440798718676,0.9886545892214753,0.9888178501184393,0.9868636011935192,0.9901379925949023,0.9945264972069154,0.9882451154408732,0.991282726442032,0.9957063695575893,0.9887642790552771,0.9922042185183123,0.9928370236933937,1.0045679388946485,1.0074397423499826,1.0089135696554243,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d,benchmark_fine,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,8192,134.77926549345705,1.2244330259191316,False,False,False,False,0.9985649729599724,0.9986941758792797,0.9988712646743643,0.987440798718676,0.9886545892214753,0.9888178501184393,0.9999962455314992,0.9999970334450838,0.9999995195898125,0.9997661701781293,0.9999931069434371,1.0001009067124746,0.9999476320568561,0.9999614341845546,0.9999670808763329,1.0000364532010075,1.0000378765990743,1.0000445658259058,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d,benchmark_original,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9985649729599724,0.9986941758792797,0.9988712646743643,0.987440798718676,0.9886545892214753,0.9888178501184393,0.9868636011935192,0.9901379925949023,0.9945264972069154,0.9882451154408732,0.991282726442032,0.9957063695575893,0.9887642790552771,0.9922042185183123,0.9928370236933937,1.0045679388946485,1.0074397423499826,1.0089135696554243,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d_b095,api_default,8,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9996642245997908,0.9996941608889434,0.999730720138589,0.987842934526379,0.9886670639540898,0.9895550920094051,0.9367707710220056,0.9431236449533128,0.9588243009992207,0.9364784647353255,0.9427232031194109,0.9588535366214002,0.9339034700310265,0.9457372255295776,0.961154536343036,1.040773029010549,1.0517009187293502,1.0569146647116394,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d_b095,benchmark_fine,8,0.005554756817606518,1.3331416362255644,6.920816077878065,8192,45.5045678498326,3.9371753687484103,False,False,True,False,0.9996642245997908,0.9996941608889434,0.999730720138589,0.987842934526379,0.9886670639540898,0.9895550920094051,0.9999844600401304,0.9999945833833381,0.9999982228373012,0.9993502882098837,0.9995282514398484,1.0000230708889788,0.9994050860034882,0.9996347336343885,0.9999446819100156,1.000275034284804,1.0003077234829225,1.0003314801087537,,,,,,,,,,,,,,,,,, +observed,tess_200s,sun_earth_10d_b095,benchmark_original,8,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9996642245997908,0.9996941608889434,0.999730720138589,0.987842934526379,0.9886670639540898,0.9895550920094051,0.9367707710220056,0.9431236449533128,0.9588243009992207,0.9364784647353255,0.9427232031194109,0.9588535366214002,0.9339034700310265,0.9457372255295776,0.961154536343036,1.040773029010549,1.0517009187293502,1.0569146647116394,,,,,,,,,,,,,,,,,, +observed,tess_gap,mdwarf01_earth_10d,api_default,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,2048,7.84904877288392,0.6586655867808936,False,False,False,False,0.991735891756963,0.9960553096704268,0.9995309667595541,0.9403082114732212,0.9637470996104558,0.9796825273146438,0.9944825472675731,0.9984170175771974,0.9998741968038835,0.9558303280088867,0.9930675372665365,0.9982236443003926,0.9888906100776451,0.9965194865224155,0.9995493052088604,1.007090080260951,1.011246365703189,1.0296785020768653,,,,,,,,,,,,,,,,,, +observed,tess_gap,mdwarf01_earth_10d,benchmark_fine,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,8192,31.39619509153568,2.6346623471235744,True,False,False,False,0.991735891756963,0.9960553096704268,0.9995309667595541,0.9403082114732212,0.9637470996104558,0.9796825273146438,0.9944825472675732,0.9984418232477471,0.999896697953052,0.9955290543781958,0.9989862272874533,1.0001584103660603,0.997538164442215,0.9998544128854518,0.9999688488965037,1.0012767645602554,1.0018824000578976,1.0025549981777324,,,,,,,,,,,,,,,,,, +observed,tess_gap,mdwarf01_earth_10d,benchmark_original,8,0.0038325433461347267,0.9198104030723344,4.631242407053159,4096,15.69809754576784,1.3173311735617872,False,False,False,False,0.991735891756963,0.9960553096704268,0.9995309667595541,0.9403082114732212,0.9637470996104558,0.9796825273146438,0.9944825472675732,0.9984391310925576,0.9998937062629253,0.9865957324432024,0.9946891460713891,0.9989377030363737,0.9942347638244904,0.9995585674171444,0.9998826070808141,1.0067401586103855,1.007516518032662,1.0108048200842912,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d,api_default,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9995764211431507,0.9996209622423708,0.9997533239894698,0.984896226749125,0.985658282098655,0.9880564538749246,0.9919482907891184,0.9955794221800534,0.9980658466110727,0.9882915704897864,0.9953044779997531,0.9971266884018352,0.987815273372106,0.9916535542947498,0.9970870570009437,1.0035124765485972,1.0048587172354102,1.011046189651855,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d,benchmark_fine,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,8192,134.77926549345705,1.2244330259191316,False,False,False,False,0.9995764211431507,0.9996209622423708,0.9997533239894698,0.984896226749125,0.985658282098655,0.9880564538749246,0.999666024714038,0.9998256740165328,0.9999167986326796,0.9997995444961103,0.9999300882844695,1.0000272460597444,0.9999417673816609,0.9999640339232556,0.9999809343070203,1.000053252235253,1.000064720273049,1.0000739993477754,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d,benchmark_original,8,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9995764211431507,0.9996209622423708,0.9997533239894698,0.984896226749125,0.985658282098655,0.9880564538749246,0.9919482907891184,0.9955794221800534,0.9980658466110727,0.9882915704897864,0.9953044779997531,0.9971266884018352,0.987815273372106,0.9916535542947498,0.9970870570009437,1.0035124765485972,1.0048587172354102,1.011046189651855,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d_b095,api_default,8,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9951928125354942,0.9973724676572171,0.9996269277283466,0.9626040008005303,0.9765745012414577,0.983958398282036,0.9523386374642979,0.9682097486763619,0.9803950917395161,0.931794030163657,0.9640946182466668,0.974119665087531,0.9262593567029127,0.9580973506373803,0.9712740706392832,1.043757277927425,1.052226121484496,1.0682445503783013,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d_b095,benchmark_fine,8,0.005554756817606518,1.3331416362255644,6.920816077878065,8192,45.5045678498326,3.9371753687484103,False,False,True,False,0.9951928125354942,0.9973724676572171,0.9996269277283466,0.9626040008005303,0.9765745012414577,0.983958398282036,0.9977857900548337,0.9983588256240774,0.9997957372099587,0.9993710141901407,0.999788907187626,0.9999537146059151,0.9985909111185023,0.9998767834637727,0.9999605544527433,1.0001996997370812,1.0005820887485957,1.0007774377388738,,,,,,,,,,,,,,,,,, +observed,tess_gap,sun_earth_10d_b095,benchmark_original,8,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9951928125354942,0.9973724676572171,0.9996269277283466,0.9626040008005303,0.9765745012414577,0.983958398282036,0.9523386374642979,0.9682097486763619,0.9803950917395161,0.931794030163657,0.9640946182466668,0.974119665087531,0.9262593567029127,0.9580973506373803,0.9712740706392832,1.043757277927425,1.052226121484496,1.0682445503783013,,,,,,,,,,,,,,,,,, +observed,ztf,mdwarf01_earth_10d,api_default,5,0.0038325433461347267,0.9198104030723344,4.631242407053159,2048,7.84904877288392,0.6586655867808936,False,False,False,False,0.9999566702329977,0.9999982288004634,1.0000000000000002,0.9622128089345743,0.9841565453255697,0.9998417983154803,0.9553732652367779,0.9994551106356454,1.0000000000000002,0.9481873139578855,0.9951214558916021,0.999636675037861,0.9530236656835488,0.9946487161765439,0.9996334183737373,1.0005111626028536,1.0168027622548812,1.0268825405991022,,,,,,,,,,,,,,,,,, +observed,ztf,mdwarf01_earth_10d,benchmark_fine,5,0.0038325433461347267,0.9198104030723344,4.631242407053159,8192,31.39619509153568,2.6346623471235744,True,False,False,False,0.9999566702329977,0.9999982288004634,1.0000000000000002,0.9622128089345743,0.9841565453255697,0.9998417983154803,0.9985889202524796,0.999715908608991,1.0000000000000002,0.9967665997084565,0.9995785760021655,0.9999909908698158,0.9966494689528398,0.9994600230615329,0.999990517779165,1.0000555121279155,1.00089720118027,1.0023735462692824,,,,,,,,,,,,,,,,,, +observed,ztf,mdwarf01_earth_10d,benchmark_original,5,0.0038325433461347267,0.9198104030723344,4.631242407053159,4096,15.69809754576784,1.3173311735617872,False,False,False,False,0.9999566702329977,0.9999982288004634,1.0000000000000002,0.9622128089345743,0.9841565453255697,0.9998417983154803,0.9985857379498905,0.9997158971165863,1.0000000000000002,0.9841956875183634,0.994149631713408,0.9999931148578621,0.983916924653485,0.9936817864630643,0.9999888528972776,1.000300930341885,1.0055492622898072,1.0094013277361935,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d,api_default,2,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9971562902573612,0.9973397434452533,0.9975231966331454,0.9713249275503849,0.9790473815939582,0.9867698356375316,0.949088343623578,0.9660286256970056,0.9829689077704333,0.9393723645699755,0.9606933151542234,0.9820142657384712,0.9791717511585153,0.9863465546848937,0.9935213582112722,1.012083509502993,1.0395044170376855,1.066925324572378,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d,benchmark_fine,2,0.01645254705730677,3.9486112937536246,2.1523236783734734,8192,134.77926549345705,1.2244330259191316,False,False,False,False,0.9971562902573612,0.9973397434452533,0.9975231966331454,0.9713249275503849,0.9790473815939582,0.9867698356375316,0.9999827929843139,0.9999831032948896,0.9999834136054654,0.9991690102140721,0.9995288448718918,0.9998886795297113,0.9999449325487995,0.999951034756923,0.9999571369650466,1.000093598088704,1.0003078982611273,1.0005221984335506,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d,benchmark_original,2,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9971562902573612,0.9973397434452533,0.9975231966331454,0.9713249275503849,0.9790473815939582,0.9867698356375316,0.949088343623578,0.9660286256970056,0.9829689077704333,0.9393723645699755,0.9606933151542234,0.9820142657384712,0.9791717511585153,0.9863465546848937,0.9935213582112722,1.012083509502993,1.0395044170376855,1.066925324572378,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d_b095,api_default,4,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9994527205942787,0.9996406049766224,0.9999706354295889,0.9813304084340809,0.9942418880221224,0.9963248368102036,0.9172619808132458,0.9598795183319309,0.9683568716484501,0.9123604543481609,0.9511965740061659,0.9617058002282137,0.9173612912637843,0.943526786900991,0.9611831475290533,1.03056286127088,1.0551730410978875,1.0962004118417124,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d_b095,benchmark_fine,4,0.005554756817606518,1.3331416362255644,6.920816077878065,8192,45.5045678498326,3.9371753687484103,False,False,True,False,0.9994527205942787,0.9996406049766224,0.9999706354295889,0.9813304084340809,0.9942418880221224,0.9963248368102036,0.99994115192134,0.9999935478492499,0.9999999375200365,0.999560445024594,0.9996550306304242,1.0000081260284803,0.9995010158324021,0.9998187321400498,0.9999564731908462,1.000069640660597,1.0003452952837875,1.0005619412860214,,,,,,,,,,,,,,,,,, +observed,ztf,sun_earth_10d_b095,benchmark_original,4,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9994527205942787,0.9996406049766224,0.9999706354295889,0.9813304084340809,0.9942418880221224,0.9963248368102036,0.9172619808132458,0.9598795183319309,0.9683568716484501,0.9123604543481609,0.9511965740061659,0.9617058002282137,0.9173612912637843,0.943526786900991,0.9611831475290533,1.03056286127088,1.0551730410978875,1.0962004118417124,,,,,,,,,,,,,,,,,, +uniform,,mdwarf01_earth_100d,api_default,32,0.0008256774096668374,1.9816257832004096,9.977128917306983,8192,6.763949339990732,0.5675877784067973,False,False,False,False,0.9999959789383664,0.9999959789383664,0.9999959789383664,0.9861288048659018,0.9861288048659018,0.9861288048659018,0.9830343202360601,0.9883810392322203,0.9943746288090465,0.9830382650694196,0.988384974502997,0.9943781451259956,0.9829898470174557,0.9883628540756736,0.9942274968967767,1.0057795125484539,1.0116259539295123,1.017082626723129,0.9829898470174557,0.9883628540756736,0.9942274968967767,0.8700478069081783,0.9658044179667529,0.9996237431106763,0.9999266587997269,0.9999266587997269,0.9999266587997269,0.9194020483837957,0.976144444702485,0.9859610087755551,0.9194057453709642,0.9761483698552436,0.9859649734014818,0.9145931856058829,0.9644826823777288,0.9759807794218052 +uniform,,mdwarf01_earth_100d,benchmark_fine,32,0.0008256774096668374,1.9816257832004096,9.977128917306983,8192,6.763949339990732,0.5675877784067973,True,True,False,False,0.9999959789383664,0.9999959789383664,0.9999959789383664,0.9861288048659018,0.9861288048659018,0.9861288048659018,0.9830343202360601,0.9883810392322203,0.9943746288090465,0.9830382650694196,0.988384974502997,0.9943781451259956,0.9829898470174557,0.9883628540756736,0.9942274968967767,1.0057795125484539,1.0116259539295123,1.017082626723129,0.999773211029891,0.9997750388453479,0.9997766893644895,0.9926122650893032,0.9978241630230653,0.9999998913257595,0.9996744238913714,0.9996744238913714,0.9996744238913714,0.976382685191849,0.9868060581495943,0.9939604133843931,0.9763866113025913,0.9868100261735302,0.993964410176548,0.9661216717473038,0.9770655927619349,0.9874118836018929 +uniform,,mdwarf01_earth_100d,benchmark_original,32,0.0008256774096668374,1.9816257832004096,9.977128917306983,8192,6.763949339990732,0.5675877784067973,True,False,False,False,0.9999959789383664,0.9999959789383664,0.9999959789383664,0.9861288048659018,0.9861288048659018,0.9861288048659018,0.9830343202360601,0.9883810392322203,0.9943746288090465,0.9830382650694196,0.988384974502997,0.9943781451259956,0.9829898470174557,0.9883628540756736,0.9942274968967767,1.0057795125484539,1.0116259539295123,1.017082626723129,0.9962020625948955,0.9966651856036477,0.997091451671536,0.9162175446859149,0.9619247319870123,0.9999951871093067,0.9978757814378565,0.9978757814378565,0.9978757814378565,0.9416604208572461,0.9782056965962569,0.9932816156089679,0.9416642073470619,0.9782096300374697,0.9932856096716242,0.9326366413821746,0.9667005400880355,0.9858832532096101 +uniform,,mdwarf01_earth_30d,api_default,32,0.0018424604359997135,1.3265715139197938,6.679094094892183,4096,7.546717945854827,0.6332770697379255,False,False,False,False,0.9999949109924381,0.9999949109924381,0.9999949109924381,0.9857855489271576,0.9857855489271576,0.9857855489271576,0.9878308040230163,0.9906904637011988,0.9935680077583903,0.9878357969841071,0.9906947901126759,0.9935725431527946,0.9878736820766151,0.9905096258869674,0.9929205881472839,1.006544299722879,1.009445717854728,1.012612857838336,0.9878736820766151,0.9905096258869674,0.9929205881472839,0.8729668021795862,0.9595741938187461,0.9999348841703827,0.9999237522133496,0.9999237522133496,0.9999237522133496,0.9339390742684411,0.9808391078270275,0.9921111197124118,0.9339438271156397,0.9808440993500662,0.9921161685990962,0.9295548407897355,0.9722981733820664,0.9845277380397301 +uniform,,mdwarf01_earth_30d,benchmark_fine,32,0.0018424604359997135,1.3265715139197938,6.679094094892183,8192,15.093435891709653,1.266554139475851,True,False,False,False,0.9999949109924381,0.9999949109924381,0.9999949109924381,0.9857855489271576,0.9857855489271576,0.9857855489271576,0.9972873225069016,0.9973651736637157,0.9974373792925756,0.9972908005011379,0.9973690765234572,0.9974415739013767,0.9972213235523614,0.9972852572115563,0.9973265140627424,1.002686613602296,1.0026915624443244,1.002698290468282,0.9998187502730058,0.9998189504231181,0.9998193161128529,0.9930920743089484,0.9990276725091833,0.9999884870302075,0.9996919185196944,0.9996919185196944,0.9996919185196944,0.9926774742555143,0.9960608401035025,0.9971923816859103,0.9926825260243959,0.996065909090446,0.9971974564313069,0.9900056556224657,0.9932572821585024,0.9946813832762407 +uniform,,mdwarf01_earth_30d,benchmark_original,32,0.0018424604359997135,1.3265715139197938,6.679094094892183,8192,15.093435891709653,1.266554139475851,False,False,False,False,0.9999949109924381,0.9999949109924381,0.9999949109924381,0.9857855489271576,0.9857855489271576,0.9857855489271576,0.9972873225069016,0.9973651736637157,0.9974373792925756,0.9972908005011379,0.9973690765234572,0.9974415739013767,0.9972213235523614,0.9972852572115563,0.9973265140627424,1.002686613602296,1.0026915624443244,1.002698290468282,0.9972213235523614,0.9972852572115563,0.9973265140627424,0.9188539010496497,0.9573365351213982,0.9980149029998642,0.9979469518546196,0.9979469518546196,0.9979469518546196,0.9500552138732418,0.9797068314924555,0.9957923353011117,0.950060048736014,0.9797118172533021,0.9957974029216253,0.9484441809183997,0.9775099418485758,0.9934129391379911 +uniform,,mdwarf01_earth_365d,api_default,32,0.00034813673533114534,3.05176662191282,15.365073701298044,8192,2.8519361358327426,0.23931607681389,True,False,False,False,0.9999916780128224,0.9999916780128224,0.9999916780128224,0.9862888539094582,0.9862888539094582,0.9862888539094582,0.9464816691047119,0.955463408828291,0.9594558903883107,0.9464895404126811,0.9554712219132964,0.9594635585407383,0.9447614329662504,0.9562968646243769,0.9596343015026763,1.042170524169208,1.0463149985879372,1.0560621804639925,0.9944890953222927,0.995412022120474,0.9963724851362303,0.8756917150998954,0.9539339553367152,0.9999913440380498,0.9999279198770271,0.9999279198770271,0.9999279198770271,0.9257935340639138,0.9413269395495891,0.9591472658906347,0.9258012385699502,0.9413347733255024,0.9591552479683096,0.8837050183077554,0.9132034653916784,0.9282087117737139 +uniform,,mdwarf01_earth_365d,benchmark_fine,32,0.00034813673533114534,3.05176662191282,15.365073701298044,8192,2.8519361358327426,0.23931607681389,True,True,False,False,0.9999916780128224,0.9999916780128224,0.9999916780128224,0.9862888539094582,0.9862888539094582,0.9862888539094582,0.9464816691047119,0.955463408828291,0.9594558903883107,0.9464895404126811,0.9554712219132964,0.9594635585407383,0.9447614329662504,0.9562968646243769,0.9596343015026763,1.042170524169208,1.0463149985879372,1.0560621804639925,0.9996821590374677,0.9996843853648012,0.9996867207402098,0.9657767752715287,0.9871989724395567,0.9998426841462257,0.9996657905787255,0.9996657905787255,0.9996657905787255,0.9277368590878446,0.941141636025187,0.9593546899970159,0.9277445797663415,0.941149468258994,0.9593626738008859,0.9090029159639401,0.9205252315669663,0.9293098635454142 +uniform,,mdwarf01_earth_365d,benchmark_original,32,0.00034813673533114534,3.05176662191282,15.365073701298044,8192,2.8519361358327426,0.23931607681389,True,True,False,False,0.9999916780128224,0.9999916780128224,0.9999916780128224,0.9862888539094582,0.9862888539094582,0.9862888539094582,0.9464816691047119,0.955463408828291,0.9594558903883107,0.9464895404126811,0.9554712219132964,0.9594635585407383,0.9447614329662504,0.9562968646243769,0.9596343015026763,1.042170524169208,1.0463149985879372,1.0560621804639925,0.9944890953222927,0.995412022120474,0.9963724851362303,0.9247247834329256,0.9738945380569473,0.9999708858755004,0.997840900897999,0.997840900897999,0.997840900897999,0.9050207626871365,0.943193264761231,0.9575044534918828,0.9050282943209972,0.9432011140688081,0.9575124218979802,0.8853356189370741,0.9185607998550718,0.9291592812640191 +uniform,,mdwarf01_earth_365d_b09,api_default,32,0.00019700863151618926,1.726977663870915,41.47229389308388,8192,1.6138947093806224,0.6459446185492113,True,False,False,False,0.9916247469064697,0.9916247469064697,0.9916247469064697,0.9646770178750348,0.9646770178750348,0.9646770178750348,0.7902265665952928,0.8755391915688623,0.9634004632300557,0.7969008125502274,0.882822894466422,0.9714393874955947,0.8514884970589517,0.908561049446885,0.9216770406509903,1.0412126739333336,1.1713594054905636,1.2898265123908337,0.9837115800070135,0.9875613452714875,0.9913967440948924,0.895806466427937,0.9835364201961942,0.9999983256922955,0.9975428378602261,0.9975428378602261,0.9975428378602261,0.7705089460493997,0.8636722038244535,0.9576669185586163,0.7770166572114343,0.8709667709673601,0.9657553641598899,0.7391036756860694,0.7975394037454757,0.9046830706333259 +uniform,,mdwarf01_earth_365d_b09,benchmark_fine,32,0.00019700863151618926,1.726977663870915,41.47229389308388,8192,1.6138947093806224,0.6459446185492113,True,True,False,False,0.9916247469064697,0.9916247469064697,0.9916247469064697,0.9646770178750348,0.9646770178750348,0.9646770178750348,0.7902265665952928,0.8755391915688623,0.9634004632300557,0.7969008125502274,0.882822894466422,0.9714393874955947,0.8514884970589517,0.908561049446885,0.9216770406509903,1.0412126739333336,1.1713594054905636,1.2898265123908337,0.9990075669103264,0.9990332798150356,0.9990512047645809,0.9371063579408111,0.9800451369616674,0.9999961018904604,0.9975428378602261,0.9975428378602261,0.9975428378602261,0.7789463313947279,0.8582099142266577,0.9619999939375595,0.7855253046323967,0.865458346924055,0.9701250366518894,0.7501799230267984,0.8041759544416887,0.9092399438486655 +uniform,,mdwarf01_earth_365d_b09,benchmark_original,32,0.00019700863151618926,1.726977663870915,41.47229389308388,8192,1.6138947093806224,0.6459446185492113,True,True,False,False,0.9916247469064697,0.9916247469064697,0.9916247469064697,0.9646770178750348,0.9646770178750348,0.9646770178750348,0.7902265665952928,0.8755391915688623,0.9634004632300557,0.7969008125502274,0.882822894466422,0.9714393874955947,0.8514884970589517,0.908561049446885,0.9216770406509903,1.0412126739333336,1.1713594054905636,1.2898265123908337,0.9837115800070135,0.9875613452714875,0.9913967440948924,0.9389223838492631,0.9864525149682326,0.9999562509326443,0.9975428378602261,0.9975428378602261,0.9975428378602261,0.7854893640430052,0.8645055478893612,0.9628929533081202,0.7921235996716132,0.8718071534482403,0.9710255379487222,0.7502360228642119,0.7967726102278614,0.9105743187765704 +uniform,,mdwarf02_earth_10d,api_default,32,0.005828712403996819,1.3988909769592366,3.6761592113486063,2048,11.937203003385486,0.5228315322806907,False,False,False,False,0.9997547305650385,0.9997547305650385,0.9997547305650385,0.9875122585286393,0.9875122585286393,0.9875122585286393,0.9941537949786982,0.9954813081337954,0.9967403475247648,0.9943863327210765,0.9956679759524858,0.9969063367269917,0.9953861120447689,0.9957850619035744,0.9961897710555512,1.0032813308161557,1.003856635769853,1.0044359225984336,0.9953861120447689,0.9957850619035744,0.9961897710555512,0.8760084675365979,0.9731784797459555,0.9995994922445811,0.9964403493936962,0.9964403493936962,0.9964403493936962,0.9391768220530152,0.9833396294965248,0.9921532693675478,0.9394072299334997,0.9835808718211952,0.9923966739390224,0.9322160073685082,0.9800888389397382,0.988558162934814 +uniform,,mdwarf02_earth_10d,benchmark_fine,32,0.005828712403996819,1.3988909769592366,3.6761592113486063,8192,47.748812013541944,2.0913261291227627,False,False,False,False,0.9997547305650385,0.9997547305650385,0.9997547305650385,0.9875122585286393,0.9875122585286393,0.9875122585286393,0.9996659609436862,0.9996664070775197,0.9996665602726222,0.9996997124817529,0.9996999918197877,0.9997003234902277,0.9997092260378547,0.9997117156924299,0.9997135426242206,1.0002614684061286,1.0002624816251868,1.0002638245346571,0.9997092260378547,0.9997117156924299,0.9997135426242206,0.9937515155849029,0.997758897123873,0.9999958007483916,0.9995612397177336,0.9995612397177336,0.9995612397177336,0.9945985999154827,0.9979822809664927,0.9990127109623353,0.9948426043989392,0.998227115567091,0.9992577983579194,0.9943461709973469,0.9977182976165555,0.9987592899028919 +uniform,,mdwarf02_earth_10d,benchmark_original,32,0.005828712403996819,1.3988909769592366,3.6761592113486063,2048,11.937203003385486,0.5228315322806907,False,False,False,False,0.9997547305650385,0.9997547305650385,0.9997547305650385,0.9875122585286393,0.9875122585286393,0.9875122585286393,0.9941537949786982,0.9954813081337954,0.9967403475247648,0.9943863327210765,0.9956679759524858,0.9969063367269917,0.9953861120447689,0.9957850619035744,0.9961897710555512,1.0032813308161557,1.003856635769853,1.0044359225984336,0.9953861120447689,0.9957850619035744,0.9961897710555512,0.9214853544572317,0.9758302476505208,0.9999165896320632,0.9999765527312687,0.9999765527312687,0.9999765527312687,0.9471822410558647,0.9804218590608318,0.9962145628736965,0.9474146129026455,0.9806623855700285,0.9964589638007101,0.9432470374328018,0.976873157784693,0.9929696971541228 +uniform,,sun_earth_1000d,api_default,32,0.0007633203361401163,18.31968806736279,9.977128839313751,8192,6.253120193659833,0.0567587773969849,False,False,False,False,0.9984680672138018,0.9984680672138018,0.9984680672138018,0.9887625513380801,0.9887625513380801,0.9887625513380801,0.9781046664207289,0.984276215037169,0.9943331385378947,0.97960461997375,0.9857773682480904,0.9957177343031272,0.9859905390104394,0.9871075930021174,0.9872875454605899,1.0057787342741062,1.011631725823468,1.0170926576573212,0.9859905390104394,0.9871075930021174,0.9872875454605899,0.884072399845845,0.968026485241829,0.9999992214275685,0.9996999847896665,0.9996999847896665,0.9996999847896665,0.9361466810221313,0.9730536723401247,0.990830589444159,0.9375829951521868,0.9745466122471045,0.9923508041764871,0.9288845103279667,0.9610186704969446,0.9805072123689316 +uniform,,sun_earth_1000d,benchmark_fine,32,0.0007633203361401163,18.31968806736279,9.977128839313751,8192,6.253120193659833,0.0567587773969849,True,True,False,False,0.9984680672138018,0.9984680672138018,0.9984680672138018,0.9887625513380801,0.9887625513380801,0.9887625513380801,0.9781046664207289,0.984276215037169,0.9943331385378947,0.97960461997375,0.9857773682480904,0.9957177343031272,0.9859905390104394,0.9871075930021174,0.9872875454605899,1.0057787342741062,1.011631725823468,1.0170926576573212,0.9997358809683335,0.9997371032215445,0.9997378821064646,0.9947194024621064,0.9992887428517662,0.9999997720455467,0.9994587366454768,0.9994587366454768,0.9994587366454768,0.9731384840892328,0.9820609664417796,0.9936837017729526,0.9746315541214547,0.9835677260888215,0.9952082939876086,0.9625421812348347,0.9745149722755859,0.9875979344642963 +uniform,,sun_earth_1000d,benchmark_original,32,0.0007633203361401163,18.31968806736279,9.977128839313751,8192,6.253120193659833,0.0567587773969849,True,False,False,False,0.9984680672138018,0.9984680672138018,0.9984680672138018,0.9887625513380801,0.9887625513380801,0.9887625513380801,0.9781046664207289,0.984276215037169,0.9943331385378947,0.97960461997375,0.9857773682480904,0.9957177343031272,0.9859905390104394,0.9871075930021174,0.9872875454605899,1.0057787342741062,1.011631725823468,1.0170926576573212,0.9954988236862311,0.9961373088791285,0.9967581892718715,0.9279623947908068,0.9774876146466156,0.9999782528512946,0.997531890087374,0.997531890087374,0.997531890087374,0.9631912490427877,0.9750755254315566,0.990583847189013,0.9646690571993423,0.9765715674337774,0.9921036833488431,0.9535797763615995,0.962876609110598,0.9823893894499731 +uniform,,sun_earth_100d_e08,api_default,32,0.0010262952506175853,2.4631086014822046,1.7839971981711977,2048,2.1018526732648146,0.02537240459621259,False,False,True,False,0.9989362370179266,0.9989362370179266,0.9989362370179266,0.9889535635287267,0.9889535635287267,0.9889535635287267,0.8966895252637631,0.9260647869227367,0.9877685534564198,0.8976442949711846,0.9270508982650589,0.9888002163716301,0.8963653806760162,0.924215076290491,0.9693063103833994,1.0138883845082125,1.0747157260224531,1.109857553323386,0.8963653806760162,0.924215076290491,0.9693063103833994,0.8868205621820894,0.9558084658136492,0.9999516335135937,0.8617794067154679,0.8617794067154679,0.8617794067154679,0.7917832559797242,0.8418258923175921,0.885814277311962,0.7926264226267278,0.842722349156791,0.886757577196657,0.7661070718140042,0.8247596876322246,0.8459798398495975 +uniform,,sun_earth_100d_e08,benchmark_fine,32,0.0010262952506175853,2.4631086014822046,1.7839971981711977,8192,8.407410693059258,0.10148961838485036,True,False,True,False,0.9989362370179266,0.9989362370179266,0.9989362370179266,0.9889535635287267,0.9889535635287267,0.9889535635287267,0.9865251786105553,0.9909283403070922,0.9950986836626206,0.9875747472128968,0.9918987308960827,0.9960074928118056,0.9918952542387023,0.9920279515016002,0.9924194408022403,1.0045576530528646,1.0068907951640373,1.0091577788198778,0.9977600415920435,0.9977798941912375,0.9978121070194117,0.9950930201572524,0.9982550075289746,0.9999999600482002,0.8617794067154679,0.8617794067154679,0.8617794067154679,0.8608728033012523,0.861691332591875,0.8626024676584061,0.8617895431155566,0.8626089440545657,0.863521049384983,0.8582862145012105,0.8593277133304177,0.8598707899641752 +uniform,,sun_earth_100d_e08,benchmark_original,32,0.0010262952506175853,2.4631086014822046,1.7839971981711977,4096,4.203705346529629,0.05074480919242518,False,False,True,False,0.9989362370179266,0.9989362370179266,0.9989362370179266,0.9889535635287267,0.9889535635287267,0.9889535635287267,0.96181025706828,0.9726387291419942,0.9924879774453252,0.9628343667344689,0.9736741234709175,0.9934780579257639,0.9676139645835645,0.9750824910384986,0.9824486760519037,1.0081757744126674,1.0234971776858652,1.0347458281293815,0.9676139645835645,0.9750824910384986,0.9824486760519037,0.9332968878726606,0.9755642122284329,0.9998443991440262,0.8617794067154679,0.8617794067154679,0.8617794067154679,0.8300394029322387,0.8590096351326597,0.8656744640380228,0.8309233083886445,0.8599243908670462,0.8665963171205769,0.8211631874078335,0.8496268307520392,0.8570677620419644 +uniform,,sun_earth_10d,api_default,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9987269682091525,0.9987269682091525,0.9987269682091525,0.9888002628627126,0.9888002628627126,0.9888002628627126,0.9865107775804257,0.9909606005922936,0.9942794025107684,0.987766895351046,0.9921197730944895,0.9954282542911498,0.9920365074389493,0.9920650093133117,0.9922876233112995,1.0046279229677555,1.0068694589834877,1.008971512921657,0.9920365074389493,0.9920650093133117,0.9922876233112995,0.885166315636112,0.9477299254890288,0.9997338789206937,0.9997206538151803,0.9997206538151803,0.9997206538151803,0.9217577508342697,0.9795329612522029,0.9933374155454938,0.9229326734684068,0.9807815273163526,0.9946035775189661,0.9146534104362534,0.9731470974488021,0.9875782885600443 +uniform,,sun_earth_10d,benchmark_fine,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,8192,134.77926549345705,1.2244330259191316,False,False,False,False,0.9987269682091525,0.9987269682091525,0.9987269682091525,0.9888002628627126,0.9888002628627126,0.9888002628627126,0.9999145228287019,0.9999146867327358,0.9999148725469262,0.9999617514226198,0.9999618397771024,0.9999619818888187,0.99996348911254,0.9999635131374788,0.9999635440264608,1.000031473992297,1.0000321094441311,1.0000325757006379,0.99996348911254,0.9999635131374788,0.9999635440264608,0.9953393549771835,0.9993395628304614,0.9999890394909564,0.9994329471183265,0.9994329471183265,0.9994329471183265,0.9911254668233059,0.9969768104084228,0.9981215608332662,0.9923888093264598,0.9982476113528125,0.999393820938898,0.9910925255884871,0.9969450283310027,0.9980883687590588 +uniform,,sun_earth_10d,benchmark_original,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9987269682091525,0.9987269682091525,0.9987269682091525,0.9888002628627126,0.9888002628627126,0.9888002628627126,0.9865107775804257,0.9909606005922936,0.9942794025107684,0.987766895351046,0.9921197730944895,0.9954282542911498,0.9920365074389493,0.9920650093133117,0.9922876233112995,1.0046279229677555,1.0068694589834877,1.008971512921657,0.9920365074389493,0.9920650093133117,0.9922876233112995,0.9316467370892912,0.973411703438504,0.9985280171280558,0.9974824160827277,0.9974824160827277,0.9974824160827277,0.9584631921378686,0.9817285124070423,0.9922722105314477,0.9596849015267084,0.982979877040278,0.9935370147365911,0.951250768945582,0.973748246244241,0.9863225117985867 +uniform,,sun_earth_10d_30min,api_default,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9996022496645357,0.9996022496645357,0.9996022496645357,0.9831600708101014,0.9831600708101014,0.9831600708101014,0.993614422616578,0.9942497681497806,0.9947954355618351,0.9939899815482928,0.9945548531553821,0.9950275366470043,0.9920365074389493,0.9920650093133117,0.9922876233112995,1.004714478273924,1.0068440954960023,1.0087641739966877,0.9920365074389493,0.9920650093133117,0.9922876233112995,0.9039633706756371,0.9571795299780477,0.999932687134277,0.9998939130237027,0.9998939130237027,0.9998939130237027,0.9405530284410634,0.9774905034410402,0.9942578006493341,0.9409272825833583,0.9778794553224384,0.994653424382553,0.9352009857081527,0.9697689745350069,0.9892000748916113 +uniform,,sun_earth_10d_30min,benchmark_fine,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,8192,134.77926549345705,1.2244330259191316,False,False,False,False,0.9996022496645357,0.9996022496645357,0.9996022496645357,0.9831600708101014,0.9831600708101014,0.9831600708101014,0.9999751435972066,0.9999751528625493,0.9999751581793634,0.9999788926808667,0.9999789413755262,0.9999790215454438,0.99996348911254,0.9999635131374788,0.9999635440264608,1.0000317322393015,1.000031848293851,1.0000319852943733,0.99996348911254,0.9999635131374788,0.9999635440264608,0.9952242448871158,0.9992800633776333,0.9999997426491157,0.9994273389740993,0.9994273389740993,0.9994273389740993,0.9955642437590116,0.9986018496661795,0.9989987204343144,0.9959603873371842,0.9989992019338774,0.9993962306203054,0.995531129895178,0.9985700479219368,0.9989682649631668 +uniform,,sun_earth_10d_30min,benchmark_original,32,0.01645254705730677,3.9486112937536246,2.1523236783734734,512,8.423704093341065,0.07652706411994573,False,False,False,False,0.9996022496645357,0.9996022496645357,0.9996022496645357,0.9831600708101014,0.9831600708101014,0.9831600708101014,0.993614422616578,0.9942497681497806,0.9947954355618351,0.9939899815482928,0.9945548531553821,0.9950275366470043,0.9920365074389493,0.9920650093133117,0.9922876233112995,1.004714478273924,1.0068440954960023,1.0087641739966877,0.9920365074389493,0.9920650093133117,0.9922876233112995,0.9365564381414765,0.9864451358270803,0.9999559765083489,0.99787927219161,0.99787927219161,0.99787927219161,0.9682995420376723,0.9887259156793111,0.9928468858943273,0.9686848367564515,0.9891193382279054,0.9932419482124261,0.9631485649135062,0.9818602071233489,0.9862418098444867 +uniform,,sun_earth_10d_b08,api_default,32,0.010034460133902433,2.4082704321365838,3.5879495514972994,512,5.137643588558046,0.12757153960879286,False,False,False,False,0.9988717871442115,0.9988717871442115,0.9988717871442115,0.9899139954098674,0.9899139954098674,0.9899139954098674,0.9710075160172335,0.978686223904979,0.9954851998459333,0.972099780890224,0.9797802355978098,0.9963341528086452,0.9806595839926061,0.9819523377313608,0.9825631730266506,1.0074996412023052,1.0168579389169619,1.0241820390041398,0.9806595839926061,0.9819523377313608,0.9825631730266506,0.8833898606062761,0.9645801874949207,0.9997748392785002,0.9992627509574138,0.9992627509574138,0.9992627509574138,0.9367252611036256,0.9609278067921173,0.9828293860039843,0.9377832802563543,0.9620131624093853,0.9839394791737058,0.9289786894358271,0.9470692389523132,0.9745513883529549 +uniform,,sun_earth_10d_b08,benchmark_fine,32,0.010034460133902433,2.4082704321365838,3.5879495514972994,8192,82.20229741692873,2.0411446337406858,False,False,False,False,0.9988717871442115,0.9988717871442115,0.9988717871442115,0.9899139954098674,0.9899139954098674,0.9899139954098674,0.9998243309989672,0.9998264594245059,0.9998287836703278,0.9998914246004501,0.9998916584194637,0.9998920119988526,0.9999019571323196,0.9999021253538701,0.9999022733612905,1.0000851717550252,1.000085313037636,1.000085430511019,0.9999019571323196,0.9999021253538701,0.9999022733612905,0.9950406740919324,0.9977628478046656,0.9999861481795151,0.9999079723734211,0.9999079723734211,0.9999079723734211,0.9929532136363205,0.9965046968338988,0.9986719117387983,0.9940747415393397,0.9976302360915805,0.9997998988378832,0.9928670126785282,0.9964256726607356,0.9985854577646287 +uniform,,sun_earth_10d_b08,benchmark_original,32,0.010034460133902433,2.4082704321365838,3.5879495514972994,512,5.137643588558046,0.12757153960879286,False,False,False,False,0.9988717871442115,0.9988717871442115,0.9988717871442115,0.9899139954098674,0.9899139954098674,0.9899139954098674,0.9710075160172335,0.978686223904979,0.9954851998459333,0.972099780890224,0.9797802355978098,0.9963341528086452,0.9806595839926061,0.9819523377313608,0.9825631730266506,1.0074996412023052,1.0168579389169619,1.0241820390041398,0.9806595839926061,0.9819523377313608,0.9825631730266506,0.9271530439282357,0.9790512717912725,0.9999980217982932,0.9999964470268261,0.9999964470268261,0.9999964470268261,0.9466628405395763,0.971487848219554,0.9951611401135539,0.9477320840606568,0.9725851312679992,0.9962851618411744,0.9295214596717224,0.953835277674051,0.9877615654143246 +uniform,,sun_earth_10d_b095,api_default,32,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9996983030774838,0.9996983030774838,0.9996983030774838,0.9887414682296886,0.9887414682296886,0.9887414682296886,0.948170427526552,0.9536052545473901,0.9590832562426767,0.9484330518829724,0.9538301791856545,0.9592904322930599,0.9439305701790506,0.9565167813206072,0.9596669562343377,1.0412736617481153,1.0458656290412445,1.0497238224054117,0.9439305701790506,0.9565167813206072,0.9596669562343377,0.8699892221955897,0.9738765927683317,0.9986958229407388,0.900391700940192,0.900391700940192,0.900391700940192,0.825879219686831,0.8747372946598028,0.9155532289518246,0.8261284601008465,0.8750012798531323,0.9158295319031493,0.8070822148259035,0.8510298577829103,0.8864979183615453 +uniform,,sun_earth_10d_b095,benchmark_fine,32,0.005554756817606518,1.3331416362255644,6.920816077878065,8192,45.5045678498326,3.9371753687484103,False,False,True,False,0.9996983030774838,0.9996983030774838,0.9996983030774838,0.9887414682296886,0.9887414682296886,0.9887414682296886,0.9996510034651618,0.9996512073127712,0.9996512616976242,0.9996589480075052,0.99966008865386,0.999661003564111,0.9996793997514302,0.9996827228660778,0.99968581253555,1.000299563323375,1.0003012738730646,1.0003027202754953,0.9996793997514302,0.9996827228660778,0.99968581253555,0.9931156141710655,0.997606011769789,0.9999983591195089,0.900391700940192,0.900391700940192,0.900391700940192,0.8985496284019072,0.8999042563270636,0.9001861828306367,0.898820799871122,0.9001758366066913,0.9004578481922919,0.8984174944257749,0.8997719122914517,0.9000533663228371 +uniform,,sun_earth_10d_b095,benchmark_original,32,0.005554756817606518,1.3331416362255644,6.920816077878065,512,2.8440354906145373,0.24607346054677565,False,False,True,False,0.9996983030774838,0.9996983030774838,0.9996983030774838,0.9887414682296886,0.9887414682296886,0.9887414682296886,0.948170427526552,0.9536052545473901,0.9590832562426767,0.9484330518829724,0.9538301791856545,0.9592904322930599,0.9439305701790506,0.9565167813206072,0.9596669562343377,1.0412736617481153,1.0458656290412445,1.0497238224054117,0.9439305701790506,0.9565167813206072,0.9596669562343377,0.9192083376717608,0.9724824155286863,0.9999700007838049,0.900391700940192,0.900391700940192,0.900391700940192,0.8542237342356791,0.8858720965445973,0.9151794824102872,0.8544815286832298,0.8861394420871953,0.9154556725694015,0.8274684426106499,0.8610688962869525,0.8866262494892829 +uniform,,sun_earth_10d_grazing,api_default,32,0.002213645642453429,0.531274954188823,15.93824862566469,512,1.1333865689361557,0.5666932844680779,False,False,True,False,0.9882783152151434,0.9882783152151434,0.9882783152151434,0.9593726811738569,0.9593726811738569,0.9593726811738569,0.6617588019583147,0.7960404788957498,0.9241001195008621,0.6696077327308887,0.8054260565533058,0.9350224506600696,0.7135595407306514,0.8112432921534427,0.9857409490695278,1.0938781791489613,1.2960742404925014,1.5469773565364475,0.7135595407306514,0.8112432921534427,0.9857409490695278,0.8984993233321926,0.9643669360241309,0.9999898964962649,0.5454592507813143,0.5454592507813143,0.5454592507813143,0.5178674959840786,0.5366643723488727,0.5546161390388591,0.524009773371726,0.5430295940795216,0.5611942815097808,0.506956905156632,0.5233528700337806,0.5371703333242253 +uniform,,sun_earth_10d_grazing,benchmark_fine,32,0.002213645642453429,0.531274954188823,15.93824862566469,8192,18.134185102978492,9.067092551489246,False,False,True,False,0.9882783152151434,0.9882783152151434,0.9882783152151434,0.9593726811738569,0.9593726811738569,0.9593726811738569,0.9987276421542449,0.9987289049450343,0.9987303498237712,0.9998732478973409,1.0000244418672821,1.00019664879269,0.9980732294831439,0.9980822815108406,0.9980854071011204,1.002659132879113,1.0027869709971424,1.0029827181569002,0.9980732294831439,0.9980822815108406,0.9980854071011204,0.9961353304524871,0.9990701596304761,0.9999999002338134,0.5454592507813143,0.5454592507813143,0.5454592507813143,0.5384722570283259,0.5389262990833775,0.5391183737687554,0.5448589215590581,0.545318348876304,0.5455127017042684,0.5383933517218668,0.5388470997244967,0.5390387574195487 +uniform,,sun_earth_10d_grazing,benchmark_original,32,0.002213645642453429,0.531274954188823,15.93824862566469,512,1.1333865689361557,0.5666932844680779,False,False,True,False,0.9882783152151434,0.9882783152151434,0.9882783152151434,0.9593726811738569,0.9593726811738569,0.9593726811738569,0.6617588019583147,0.7960404788957498,0.9241001195008621,0.6696077327308887,0.8054260565533058,0.9350224506600696,0.7135595407306514,0.8112432921534427,0.9857409490695278,1.0938781791489613,1.2960742404925014,1.5469773565364475,0.7135595407306514,0.8112432921534427,0.9857409490695278,0.9428832480457825,0.9833943101097404,0.9999952818323093,0.5454592507813143,0.5454592507813143,0.5454592507813143,0.5311449610800701,0.5379622042448655,0.5542944172160907,0.5374447186615062,0.5443428191862671,0.5608687438370268,0.5154057560269812,0.5276585057283638,0.5370599984604506 +uniform,,sun_earth_365d,api_default,32,0.0014938759233184694,13.095316343809703,7.131908987406019,4096,6.118915781912451,0.05554091416156182,False,False,False,False,0.9984843378717061,0.9984843378717061,0.9984843378717061,0.9887686415502815,0.9887686415502815,0.9887686415502815,0.9778367602602591,0.9834859974570557,0.9965724130323631,0.9793200591372095,0.9849750420630672,0.997785219509761,0.9840447350422501,0.9866827396900293,0.9881299822526673,1.0062165715714289,1.0120547475610624,1.0174690531718653,0.9840447350422501,0.9866827396900293,0.9881299822526673,0.8973954724976196,0.9660099005078315,0.9999674394015052,0.9997002119679227,0.9997002119679227,0.9997002119679227,0.9439719511163993,0.9721777921239977,0.9934347252042826,0.9454048654668923,0.9736535218932112,0.9949427222081552,0.9357414731789528,0.9610563129537925,0.9876341394627721 +uniform,,sun_earth_365d,benchmark_fine,32,0.0014938759233184694,13.095316343809703,7.131908987406019,8192,12.237831563824901,0.11108182832312365,True,True,False,False,0.9984843378717061,0.9984843378717061,0.9984843378717061,0.9887686415502815,0.9887686415502815,0.9887686415502815,0.9916497475760504,0.9938833427290057,0.9983520984790563,0.9931460977040931,0.9952845333645414,0.9992616986566591,0.995175481227887,0.9959986171276023,0.9967218566583623,1.002989495296807,1.0034471076145755,1.0039094856900102,0.9997237560204121,0.9997255992484886,0.9997267184130516,0.9953949540978494,0.998963263137017,0.9999921106478548,0.9994585605641891,0.9994585605641891,0.9994585605641891,0.9870346908496513,0.9915847815699755,0.9969143283120461,0.9885329728391534,0.993089970428142,0.9984276072241588,0.9839523091133577,0.9878249997880899,0.9939731482091808 +uniform,,sun_earth_365d,benchmark_original,32,0.0014938759233184694,13.095316343809703,7.131908987406019,8192,12.237831563824901,0.11108182832312365,False,False,False,False,0.9984843378717061,0.9984843378717061,0.9984843378717061,0.9887686415502815,0.9887686415502815,0.9887686415502815,0.9916497475760504,0.9938833427290057,0.9983520984790563,0.9931460977040931,0.9952845333645414,0.9992616986566591,0.995175481227887,0.9959986171276023,0.9967218566583623,1.002989495296807,1.0034471076145755,1.0039094856900102,0.995175481227887,0.9959986171276023,0.9967218566583623,0.9279191139310061,0.9795799393567259,0.9999793081848193,0.9975317259522414,0.9975317259522414,0.9975317259522414,0.9544095932926915,0.9813783052783784,0.9919509344954376,0.9558583515962193,0.9828680010847345,0.9934566791601412,0.9504598534255855,0.9780587508565555,0.9883423826987816 +uniform,,sun_earth_365d_b08,api_default,32,0.0009106139699481963,7.982442060565889,11.888979909414834,4096,3.729874820907812,0.09258738632018244,False,False,False,False,0.9985279624270164,0.9985279624270164,0.9985279624270164,0.990319069993329,0.990319069993329,0.990319069993329,0.9656715052977145,0.9684270833787403,0.9738102654381331,0.967092469139595,0.9698391504725854,0.9751155228904571,0.955699041212934,0.9708951045129129,0.9817079518627019,1.0257298908017687,1.0274780792395766,1.0293887202383358,0.955699041212934,0.9708951045129129,0.9817079518627019,0.881790484149893,0.9691182281312638,0.9999787941859227,0.99917944740678,0.99917944740678,0.99917944740678,0.9350457992096392,0.9599297515954484,0.9695613738852026,0.9364242508912042,0.9613448873902827,0.9709907086914144,0.9074922018183004,0.9389420111290059,0.9516086305546914 +uniform,,sun_earth_365d_b08,benchmark_fine,32,0.0009106139699481963,7.982442060565889,11.888979909414834,8192,7.459749641815624,0.1851747726403649,True,True,False,False,0.9985279624270164,0.9985279624270164,0.9985279624270164,0.990319069993329,0.990319069993329,0.990319069993329,0.9824811958346815,0.9880412467018946,0.9940373168639245,0.9839220283758542,0.9894013455961967,0.9952883195399387,0.9885442302612178,0.9902990636814042,0.9918601016245874,1.0052890268745638,1.0086567680391911,1.0118713092045861,0.9992593216866439,0.9992699552288842,0.9992814694526125,0.9943179227850424,0.9988497000091145,0.9999991760595991,0.9999323100993353,0.9999323100993353,0.9999323100993353,0.9769082742985206,0.9866179797173553,0.9933862695752665,0.978348439961614,0.9880724595025735,0.9948507272252523,0.9701669173665237,0.978724820852912,0.9878877580894847 +uniform,,sun_earth_365d_b08,benchmark_original,32,0.0009106139699481963,7.982442060565889,11.888979909414834,8192,7.459749641815624,0.1851747726403649,False,False,False,False,0.9985279624270164,0.9985279624270164,0.9985279624270164,0.990319069993329,0.990319069993329,0.990319069993329,0.9824811958346815,0.9880412467018946,0.9940373168639245,0.9839220283758542,0.9894013455961967,0.9952883195399387,0.9885442302612178,0.9902990636814042,0.9918601016245874,1.0052890268745638,1.0086567680391911,1.0118713092045861,0.9885442302612178,0.9902990636814042,0.9918601016245874,0.934680224040012,0.9828967324749528,0.9999983900869287,0.9999891781076816,0.9999891781076816,0.9999891781076816,0.9453121217824767,0.9799514065527382,0.9898187370378322,0.9467057081554395,0.9813960584247174,0.9912779354040167,0.9401825650302,0.9705104284703191,0.98257574213216 +uniform,,sun_jupiter_5d,api_default,32,0.02849306726733112,3.419168072079734,18.687132782914166,256,7.294225220436767,0.6644313878369481,False,False,False,False,0.9999994947351752,0.9999994947351752,0.9999994947351752,0.9858781046561391,0.9858781046561391,0.9858781046561391,0.9893768092224877,0.9899598738270359,0.990421529910401,0.9893771802794148,0.9899602714132009,0.9904219909586587,0.9894253682221739,0.9899191240914996,0.9902853785927107,1.009747934614613,1.0101978309588793,1.010774409670501,0.9894253682221739,0.9899191240914996,0.9902853785927107,0.8718887723303769,0.9702569403905876,0.9999996874892513,0.9997872908770075,0.9997872908770075,0.9997872908770075,0.9510367047660891,0.978237135688736,0.9895607481192471,0.9510371852917259,0.9782376299578006,0.9895612481097377,0.9434380331147877,0.9670168556314986,0.9800574006740286 +uniform,,sun_jupiter_5d,benchmark_fine,32,0.02849306726733112,3.419168072079734,18.687132782914166,8192,233.41520705397653,21.26180441078234,False,False,False,False,0.9999994947351752,0.9999994947351752,0.9999994947351752,0.9858781046561391,0.9858781046561391,0.9858781046561391,0.9999879608496334,0.9999879613484379,0.9999879618398089,0.9999878816355547,0.9999878836384641,0.999987885348031,0.9999878148048902,0.9999878232443116,0.9999878320378907,1.0000134796348268,1.000013493474008,1.0000135046253587,0.9999878148048902,0.9999878232443116,0.9999878320378907,0.9928169093511967,0.9977381095337949,0.9999986153621953,0.9994637431159179,0.9994637431159179,0.9994637431159179,0.9940973400775902,0.9990584448940347,0.9994516701317092,0.9940978423602623,0.9990589496833798,0.9994521751197373,0.9940822742211981,0.9990455604664261,0.9994377011122552 +uniform,,sun_jupiter_5d,benchmark_original,32,0.02849306726733112,3.419168072079734,18.687132782914166,512,14.588450440873533,1.3288627756738962,False,False,False,False,0.9999994947351752,0.9999994947351752,0.9999994947351752,0.9858781046561391,0.9858781046561391,0.9858781046561391,0.996872616927666,0.9971184672150306,0.9973644115231652,0.9968729574709343,0.9971187155327067,0.9973645945684236,0.9968425895200439,0.9970995742564721,0.997352771327716,1.0026583634854036,1.0029144999264485,1.003172631525686,0.9968425895200439,0.9970995742564721,0.997352771327716,0.9165971571907318,0.977607839818126,0.9999709432346919,0.9977465180232803,0.9977465180232803,0.9977465180232803,0.964166262080693,0.9834764095624233,0.9950897645825983,0.9641667492402365,0.9834769064787101,0.9950902673667078,0.9611148546446411,0.9803703511590074,0.9919774070066015 +uniform,,sun_jupiter_5d_grazing,api_default,32,0.008513522817865282,1.0216227381438339,30.648682144315018,256,2.179461841373512,1.089730920686756,False,False,True,False,0.9868916070749215,0.9868916070749215,0.9868916070749215,0.9574155518018255,0.9574155518018255,0.9574155518018255,0.8989883128043401,0.9216147592212465,0.9428164396972257,0.9109224895834995,0.9331910065350817,0.9552480439738643,0.897584345016672,0.926365074074037,0.9801168468513665,1.0880778184060342,1.0988699028113875,1.1364705771091574,0.897584345016672,0.926365074074037,0.9801168468513665,0.9014023067755425,0.9536948108702641,0.9999934816897172,0.8205488411208627,0.8205488411208627,0.8205488411208627,0.7509887737351196,0.7929231348853432,0.8362067575055497,0.7609637860443442,0.8034551405655506,0.8473136781292616,0.7205552172718417,0.7653231705744947,0.7922623505227158 +uniform,,sun_jupiter_5d_grazing,benchmark_fine,32,0.008513522817865282,1.0216227381438339,30.648682144315018,8192,69.74277892395239,34.87138946197619,False,False,True,False,0.9868916070749215,0.9868916070749215,0.9868916070749215,0.9574155518018255,0.9574155518018255,0.9574155518018255,0.9999086259785804,0.9999086267859028,0.999908627726479,1.0000216429747402,1.0000226072973395,1.0000238270266173,0.9998636535697956,0.9998642189392954,0.9998646175464796,1.0002163201536876,1.0002179262483826,1.0002198974863565,0.9998636535697956,0.9998642189392954,0.9998646175464796,0.9961961680983035,0.9984417679821964,0.999999990064345,0.8205488411208627,0.8205488411208627,0.8205488411208627,0.8086560441886285,0.8096031716738898,0.8098209753898922,0.8193970223188229,0.8203567300298538,0.8205774267248513,0.8086081923471649,0.8095552287177867,0.8097730572255004 +uniform,,sun_jupiter_5d_grazing,benchmark_original,32,0.008513522817865282,1.0216227381438339,30.648682144315018,512,4.358923682747024,2.179461841373512,False,False,True,False,0.9868916070749215,0.9868916070749215,0.9868916070749215,0.9574155518018255,0.9574155518018255,0.9574155518018255,0.9767278005346198,0.9777641652955871,0.9789476636796477,0.9841246824428914,0.9878164902216329,0.9894341390679626,0.9659673725755736,0.9751790780239524,0.9888957558357939,1.0103157855326512,1.0381235018283679,1.0558346831976386,0.9659673725755736,0.9751790780239524,0.9888957558357939,0.9443198797402707,0.9885608565403601,0.9999997863375214,0.8205488411208627,0.8205488411208627,0.8205488411208627,0.7914087717191701,0.8064226455202548,0.8171088806118197,0.8019206628627139,0.8171339585209725,0.8279621335859507,0.7804327135599342,0.7973200400157843,0.8050809459187873 +uniform,,sun_subneptune_10d,api_default,32,0.014591123361284897,3.501869606708375,6.783938550783663,512,7.470655160977867,0.2412067040278636,False,False,False,False,0.9991793890983774,0.9991793890983774,0.9991793890983774,0.9886868006505214,0.9886868006505214,0.9886868006505214,0.9835871776766573,0.9886982141998453,0.9955862712532744,0.9843944554250106,0.9894811360204425,0.9962565088366513,0.9884498771521696,0.9903247420444218,0.9920030123199959,1.0050593322100185,1.0088000288325059,1.0125498272816704,0.9884498771521696,0.9903247420444218,0.9920030123199959,0.8808574681816225,0.9342725380225576,0.997965862342553,0.9970914603753164,0.9970914603753164,0.9970914603753164,0.9455790318062942,0.9783671231389959,0.991881032519411,0.9463556215461467,0.9791706412417475,0.9926956493912946,0.9387804237047079,0.9705575908018902,0.9848131927089518 +uniform,,sun_subneptune_10d,benchmark_fine,32,0.014591123361284897,3.501869606708375,6.783938550783663,8192,119.53048257564588,3.8593072644458175,False,False,False,False,0.9991793890983774,0.9991793890983774,0.9991793890983774,0.9886868006505214,0.9886868006505214,0.9886868006505214,0.9999251039424731,0.9999251369483111,0.9999251600915191,0.9999497168187959,0.9999498516225264,0.9999498897488236,0.9999535869656682,0.9999536361755985,0.9999536526035151,1.000042049826081,1.0000421145464131,1.0000423729455277,0.9999535869656682,0.9999536361755985,0.9999536526035151,0.9941254771712374,0.9991003917363732,0.999972151207225,0.9998113655602713,0.9998113655602713,0.9998113655602713,0.9912534482172461,0.9968279759367917,0.9989412215600678,0.9920675496636461,0.9976466556583923,0.9997616368582977,0.991210693644561,0.9967868095915149,0.9988981589949443 +uniform,,sun_subneptune_10d,benchmark_original,32,0.014591123361284897,3.501869606708375,6.783938550783663,512,7.470655160977867,0.2412067040278636,False,False,False,False,0.9991793890983774,0.9991793890983774,0.9991793890983774,0.9886868006505214,0.9886868006505214,0.9886868006505214,0.9835871776766573,0.9886982141998453,0.9955862712532744,0.9843944554250106,0.9894811360204425,0.9962565088366513,0.9884498771521696,0.9903247420444218,0.9920030123199959,1.0050593322100185,1.0088000288325059,1.0125498272816704,0.9884498771521696,0.9903247420444218,0.9920030123199959,0.9241722893367461,0.984858899618839,0.9999784695114499,0.9998399293010926,0.9998399293010926,0.9998399293010926,0.9455680115654408,0.9791304226765742,0.9903424406702895,0.9463445922545364,0.9799345676656774,0.9911557939199866,0.9398784950763686,0.9707299806163461,0.9831919011473051 +uniform,,white_dwarf_earth_10d,api_default,32,0.0004088321942970829,0.09811972663129989,2.548516257731958,8192,3.349153335681703,1.4498225821764028,True,False,False,True,0.977713015323814,0.977713015323814,0.977713015323814,0.9454458390657225,0.9454458390657225,0.9454458390657225,0.950463963309913,0.9540412078208522,0.9583640057159925,0.9675947014448308,0.974492992395126,0.9793932183986623,0.9459413204193018,0.961399580492617,0.9886666717531938,1.0148809369369653,1.0764269353121751,1.1114879792845862,0.9828427484847688,0.9881799397691449,0.9940147524754881,0.9137501548284868,0.9842867589369051,0.9999952971918855,0.9999997225132953,0.9999997225132953,0.9999997225132953,0.8927214034991998,0.9355021915445227,0.9498870549546883,0.9130710029502211,0.9568269797806555,0.9715397463948969,0.8595256936792204,0.8900487411938149,0.9303650048545721 +uniform,,white_dwarf_earth_10d,benchmark_fine,32,0.0004088321942970829,0.09811972663129989,2.548516257731958,8192,3.349153335681703,1.4498225821764028,True,True,False,True,0.977713015323814,0.977713015323814,0.977713015323814,0.9454458390657225,0.9454458390657225,0.9454458390657225,0.950463963309913,0.9540412078208522,0.9583640057159925,0.9675947014448308,0.974492992395126,0.9793932183986623,0.9459413204193018,0.961399580492617,0.9886666717531938,1.0148809369369653,1.0764269353121751,1.1114879792845862,0.9997697873747445,0.9997705833169674,0.9997714046835996,0.9891754523963845,0.9971533401013091,0.9999947021361402,0.9998968553707399,0.9998968553707399,0.9998968553707399,0.8944746997997544,0.9307831149559829,0.9487209202428587,0.9148642656695211,0.9520003317616794,0.970347029622641,0.8802210823854133,0.898547534168744,0.9337527161866167 +uniform,,white_dwarf_earth_10d,benchmark_original,32,0.0004088321942970829,0.09811972663129989,2.548516257731958,8192,3.349153335681703,1.4498225821764028,True,False,False,True,0.977713015323814,0.977713015323814,0.977713015323814,0.9454458390657225,0.9454458390657225,0.9454458390657225,0.950463963309913,0.9540412078208522,0.9583640057159925,0.9675947014448308,0.974492992395126,0.9793932183986623,0.9459413204193018,0.961399580492617,0.9886666717531938,1.0148809369369653,1.0764269353121751,1.1114879792845862,0.9961223025537279,0.9965996683207199,0.9970606564508518,0.9527697292671778,0.9898027439302814,0.9999881813188868,0.9998712878766031,0.9998712878766031,0.9998712878766031,0.9029087221306842,0.9384215113782075,0.9487728962858561,0.9234905416817479,0.9598128455591916,0.9704001904604153,0.8732613641057859,0.9004289404650254,0.9299762133284828 +uniform,,white_dwarf_earth_1d,api_default,32,0.0018976416667577547,0.04554340000218611,1.1829241593601847,4096,7.772740267039763,3.364762053291192,False,False,False,True,0.9781276557015798,0.9781276557015798,0.9781276557015798,0.9461174317405563,0.9461174317405563,0.9461174317405563,0.9913404127842947,0.9913716429666484,0.9913945358710244,0.9970484816060704,1.0000073012468311,1.0029067585183344,0.9870933137620408,0.9909949382778231,0.9946854076936229,1.011774144444099,1.0173834271900715,1.0228588169342934,0.9870933137620408,0.9909949382778231,0.9946854076936229,0.9136069031587494,0.9872217582623286,0.9999942983665058,0.9998623285601662,0.9998623285601662,0.9998623285601662,0.9498657853810567,0.9691059702870811,0.9798764804955483,0.9711061535211866,0.9907765766953724,1.001787931037196,0.9330605860507444,0.9541583285911895,0.9621341979688911 +uniform,,white_dwarf_earth_1d,benchmark_fine,32,0.0018976416667577547,0.04554340000218611,1.1829241593601847,8192,15.545480534079527,6.729524106582384,True,False,False,True,0.9781276557015798,0.9781276557015798,0.9781276557015798,0.9461174317405563,0.9461174317405563,0.9461174317405563,0.9978149471654025,0.9978171397895531,0.9978195741052245,1.0008250698014383,1.000866862540696,1.000906803707808,0.9973385893229687,0.9974127943771403,0.9975417999624175,1.0051177578715778,1.0052053690480458,1.0052702403262943,0.9998289828460382,0.9998293395023863,0.9998296395368362,0.9971062183958017,0.99939147744406,0.9999998855155692,0.9999955750166452,0.9999955750166452,0.9999955750166452,0.9762709095946129,0.9782733606092233,0.9790608713352029,0.9981017343736844,1.000148963079404,1.0009540836803696,0.971671461735418,0.973445314620165,0.9738943564867198 +uniform,,white_dwarf_earth_1d,benchmark_original,32,0.0018976416667577547,0.04554340000218611,1.1829241593601847,8192,15.545480534079527,6.729524106582384,False,False,False,True,0.9781276557015798,0.9781276557015798,0.9781276557015798,0.9461174317405563,0.9461174317405563,0.9461174317405563,0.9978149471654025,0.9978171397895531,0.9978195741052245,1.0008250698014383,1.000866862540696,1.000906803707808,0.9973385893229687,0.9974127943771403,0.9975417999624175,1.0051177578715778,1.0052053690480458,1.0052702403262943,0.9973385893229687,0.9974127943771403,0.9975417999624175,0.9512534350364874,0.9863188106235612,0.9997899944324969,0.9994432083232118,0.9994432083232118,0.9994432083232118,0.953606112878988,0.9744381450966159,0.9795665446209941,0.9749301201334469,0.9962279866196835,1.0014710645497311,0.9500621300938668,0.9698160703718804,0.9734510440987385 diff --git a/benchmarks/results/tls_accuracy_2026-09-09/high-impact/README.md b/benchmarks/results/tls_accuracy_2026-09-09/high-impact/README.md new file mode 100644 index 00000000..849e17f0 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/high-impact/README.md @@ -0,0 +1,291 @@ +# Recovery of short, high-impact transits + +This 9 September 2026 pilot evaluates the earlier phase-binned TLS engine, +retained as `method='binned'`. Labels such as “v1 defaults” refer to its frozen +configuration. See the [current transit report](../../../../docs/TRANSIT_BENCHMARKS.md) +for the observation-level default; the measurements below remain historical +evidence. + +**GTLS recovered signals that cuvarbase's then-default TLS search missed in this +targeted experiment.** GTLS recovered 112/256 injected transits; the baseline +v1 defaults recovered 61/256. Widening v1's duration search raised recovery to +116/256. These results identify a meaningful limitation of those defaults; +they do not establish equivalent sensitivity between the widened search and +GTLS, or measure a new headline speedup. + +The injections are Earth-size planets crossing near the limb of a Sun-like +star: impact parameter `b = 0.94–0.96`, periods 2–6 days, on the frozen dense +TESS 200-second cadence. They are high-impact **but not geometrically +grazing**: the planet still passes fully inside the stellar disk. Their +durations are only **31.0–36.7%** of the central-transit estimate used by v1; +the default duration grid starts at 50%. All four configurations were frozen +before generating the pilot's light curves. + +| Search | Recovered / 256 | Recovery, with 95% Wilson interval | SNR 8 / 128 | SNR 10 / 128 | False positives / 256 | +| --- | ---: | --- | ---: | ---: | ---: | +| v1 defaults | 61 | 23.8% [19.0%, 29.4%] | 9 | 52 | 8 (3.1%) | +| v1 finer sampling, same duration window | 78 | 30.5% [25.2%, 36.4%] | 14 | 64 | 8 (3.1%) | +| v1 wider duration window | 116 | 45.3% [39.3%, 51.4%] | 29 | 87 | 8 (3.1%) | +| Public GTLS, `fast=True` | 112 | 43.8% [37.8%, 49.9%] | 30 | 82 | 7 (2.7%) | + +“Recovered” requires both the correct primary period and a native score above +that method's independently calibrated threshold. The false-positive Wilson +intervals are [1.6%, 6.0%] for each v1 configuration and [1.3%, 5.5%] for GTLS. +The nominal calibration target was 5%; these finite held-out samples do not +prove identical false-positive rates. No complete light-curve search failed. + +The light curves are paired across methods. Counts below show exactly which +methods detected different injected signals; differences are in percentage +points (pp). + +| Left minus right | Left only | Right only | Both | Neither | Recovery difference, conservative 95% interval | +| --- | ---: | ---: | ---: | ---: | --- | +| GTLS − v1 defaults | 56 | 5 | 56 | 139 | +19.9 pp [+11.4, +27.7] | +| v1 wider − v1 defaults | 56 | 1 | 60 | 139 | +21.5 pp [+13.9, +28.2] | +| v1 finer − v1 defaults | 19 | 2 | 59 | 176 | +6.6 pp [+1.1, +11.9] | +| v1 wider − GTLS | 16 | 12 | 100 | 128 | +1.6 pp [−5.2, +8.3] | +| v1 finer − GTLS | 8 | 42 | 70 | 136 | −13.3 pp [−21.1, −5.0] | + +The wider search's interval relative to GTLS includes differences exceeding +five percentage points in either direction. **This pilot has not passed a +5 pp equivalence test.** All intervals are descriptive for this targeted +population and the frozen thresholds; comparisons are not adjusted together +as a family. Per-SNR intervals and paired false-positive counts are retained +in [analysis.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/analysis.json"). + +## What the configurations test + +All v1 calls use the same pre-optimization numerical source, +[`11317fb`](https://github.com/johnh2o2/cuvarbase/tree/11317fb0ff1b68af05ae3f67de5f298c9a90e46b), +with a fixed normalized `batman` template (reference radius ratio 0.1), +`R_planet=1` Earth radius **for the duration prior**, solar stellar parameters, +`refine_top_k=50`, and `refine_oversample=33`. The later kernel optimization +is absent from this experiment. + +| Configuration | Phase/epoch oversampling | Durations | Window relative to central estimate | Phase bins | +| --- | ---: | ---: | --- | --- | +| `v1_defaults` | 3 | 15 | [0.5, 2] | Automatic: 256–512 | +| `v1_fine` | 16 | 32 | [0.5, 2] | 8,192 | +| `v1_wide` | 3 | 25 | [0.1857492861, 2] | Automatic: 256–2,048 | + +The wider grid uses `qmin_fac = 0.5 * 4**(-10/14)`: it retains the original +15 logarithmic widths and prepends ten shorter widths. Automatic bin counts +also increase when the minimum duration decreases. Thus the improvement +cannot be attributed exclusively to the duration prior or exclusively to +binning. The finer configuration changes bin, epoch and duration sampling +while retaining the original duration window. Its partial improvement does +not show that increasing bins alone fixes an excluded duration. The default +automatic bins do not reach the 8,192-bin cap in this short-period pilot. + +GTLS uses pinned +[`74e449c`](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111), +`fast=True`, one worker, `T0_fit_margin=0.125`, and `duration_grid_step=1.1`. +Its accepted stellar bounds are in [configs/gtls.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/configs/gtls.json"), +but the pinned implementation also uses internal host and CUDA duration +limits. Every GTLS row records its actual template, integer duration cache +and nominal CUDA width envelope. Every injected signal has a nominally +eligible cache width within 4.9% of its geometric duration in sample units. +This checks width coverage; it does not equate the template, sampled epochs, +depth estimate or score. `fast=True` returns before GTLS's subsequent +candidate refinement. + +The result answers whether this particular native GTLS configuration can +find signals missed by default v1. It does not isolate phase-bin information +loss, benchmark canonical CPU TLS, or characterize all transiting planets. +The separate [expected-SNR diagnostic](../accuracy/README.md) isolates +several approximation costs at the true period. + +## Frozen protocol and retained evidence + +[design.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/design.json") was frozen at **19:46:34 UTC on 2026-09-09**. +It specifies 256 calibration nulls, 256 injections and 256 independent test +nulls, with seed `2026090943`. The base cadence has 9,736 samples over +25.7568 days in one band. Each case randomly drops 0–3% of samples and retains +at least five in-transit samples across two events. Every search receives +the full 3,084-period grid spanning 0.600289–12.878375 days. + +Periods are log-uniform from 2–6 days, epochs uniform over a period, and +impact parameters uniform from 0.94–0.96. The radius ratio is 0.0092, orbits +are circular, and both the injected models and cuvarbase templates use +quadratic limb darkening `[0.4804, 0.1867]`. The injected `batman` model +integrates each 200-second exposure with seven subsamples. + +The noise scale sets the weighted-centered latent signal's **oracle +white-noise SNR** to 8 or 10, balanced within each split. This is an input +definition, not the algorithms' reported SNR or SDE. Nulls use the same +latent-signal noise-scale recipe. Independent Gaussian noise is supplemented +by an Ornstein–Uhlenbeck process with amplitude 0.25 times the median error +and correlation time 0.15 days. The generator supports unequal relative +errors, but this frozen cadence has constant relative error 3: white errors +are therefore equal within each light curve. Flux has a known unit baseline; +detrending and real stellar variability are outside this experiment. + +For each method, calibration sorts 256 null scores and freezes the +zero-based order statistic 243, the “higher” 95th percentile. Detection +requires a score **strictly above** this threshold. Period recovery requires +`abs(P_found/P_true - 1) * observed_baseline <= 0.5 * true_duration`; +harmonics do not count. The frozen runner retains failed cases as injection +misses or null scores of minus infinity. + +All calibrations completed by **20:05:16 UTC**. [thresholds.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/thresholds.json") +was frozen at **20:05:21 UTC**, and the first held-out run started at +**20:05:22 UTC**. The runner requires thresholds before opening held-out +arrays and refuses calibration if held-out results already exist. Every +held-out record contains the threshold-file hash. These recorded gates, +timestamps and hashes support the chronology; they are not an external +attestation of operator actions. + +The conservative paired interval subtracts confidence limits for the two +discordant-cell probabilities. Four one-sided exact binomial bounds, each +with tail probability 0.0125, give at least 95% coverage by Bonferroni. +[validation.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/validation.json") independently checks every count and +recovery flag, Wilson intervals, paired intervals, numerical source pins, +threshold chronology, and the privately retained input-array hashes. + +The full grid was supplied to every method, but native outputs can contain +masked or nonfinite trial entries. GTLS deliberately masks residuals above +100 times the median before forming its spectrum +([pinned core.py](https://github.com/Farthing-0/GTLS/blob/74e449c325792a763dde4fbffab98039c5e8c111/src/gputls/core.py#L890)). +Such entries occur in 36/256 GTLS injection spectra and 2/256 default-v1 +injection spectra; every light curve still returns a valid result and stays +in the analysis. The validation receipt records all splits. Raw residuals +are unavailable here, so individual excluded trials cannot be diagnosed +from this compact archive. GTLS's native cleaner also drops the `t=0` +sample in 251/256 cases per split, a difference of one sample when present. +The sole retained warning concerns an unclosed baseline CUDA source file; +there were no template-fallback warnings. + +The archive preserves the original scalar results under [results/](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived directory: benchmarks/results/tls_accuracy_2026-09-09/high-impact/results"), +the [input manifest](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/inputs/manifest.json"), four configurations, thresholds, +analysis, [generation receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/generation-environment.json"), and the exact +[runner snapshot](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/source_snapshots/high_impact.py"). Generated light curves +(approximately 90 MB) remain outside the release repository and were +independently hash-checked before publication. Full periodograms were not +retained; their hashes are recorded. [provenance.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/provenance.json") and +[SHA256SUMS.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/SHA256SUMS.json") bind the public artifacts. + +## Environment and timing scope + +The [search environment receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/search-environment.json") records one NVIDIA +A40, driver 570.211.01, with Python +3.11.10. Recorded package versions are NumPy 2.2.6, SciPy 1.15.3, +`batman-package` 2.5.3, PyCUDA 2025.1.2, and `cupy-cuda12x` 13.6.0. +Distribution metadata reports cuvarbase 1.0.0 and GTLS 0.5.1; the verified +source hashes and commits identify the numerical code actually used. +The CPU quota was 7.65 cores; OpenMP/OpenBLAS/MKL threads were one and +Numba threads four. Inputs were generated separately on macOS with Python +3.9.6, NumPy 1.26.4, SciPy 1.12.0 and `batman-package` 2.5.3. + +`operational_api_seconds` in the analysis retains synchronized public-API +elapsed time: cuvarbase batches of 16, GTLS one light curve at a time. These +are single passes with first-call compilation included and no excluded +warmup. They exclude result serialization and input verification. They are +operational accounting, **not a controlled timing comparison or a new +equivalent-sensitivity speedup**. + +## Reproduce the scalar analysis without a GPU + +Run from the repository root with NumPy and SciPy installed. Use a fresh +scratch root because the frozen runner refuses to overwrite an existing +analysis if last-bit floating-point values differ across environments. +The exact snapshot is required: the design verifies its SHA-256. + +```sh +python - <<'PY' +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +published = Path('benchmarks/results/tls_accuracy_2026-09-09/high-impact').resolve() +scratch = Path(tempfile.mkdtemp(prefix='cuvarbase-tls-reanalysis-')) +for name in ('design.json', 'thresholds.json'): + shutil.copyfile(published / name, scratch / name) +for name in ('configs', 'inputs', 'results'): + shutil.copytree(published / name, scratch / name) +subprocess.run([sys.executable, str(published / 'source_snapshots/high_impact.py'), + 'analyze', '--root', str(scratch)], check=True) + +def compare(a, b): + assert type(a) is type(b) + if isinstance(a, dict): + assert a.keys() == b.keys() + for key in a: + compare(a[key], b[key]) + elif isinstance(a, list): + assert len(a) == len(b) + for x, y in zip(a, b): + compare(x, y) + elif isinstance(a, float): + assert abs(a - b) <= 1e-12 + else: + assert a == b + +compare(json.loads((published / 'analysis.json').read_text()), + json.loads((scratch / 'analysis.json').read_text())) +print('Verified; regenerated analysis:', scratch / 'analysis.json') +PY +``` + +[reanalysis-validation.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_accuracy_2026-09-09 "Archived file: benchmarks/results/tls_accuracy_2026-09-09/high-impact/reanalysis-validation.json") records a successful +CPU replay with Python 3.9.6, NumPy 1.26.4 and SciPy 1.12.0. All counts, +strings, flags and hashes match exactly; the largest floating-point +difference is below `7e-18`. + +## Regenerate and rerun the experiment + +Use the frozen generation versions above for the closest input reproduction. +Generate into an empty directory after copying only the published design +and configurations: + +```sh +export TLS_PILOT_RELEASE="$PWD/benchmarks/results/tls_accuracy_2026-09-09/high-impact" +export TLS_PILOT_RUN="$(mktemp -d)" +cp "$TLS_PILOT_RELEASE/design.json" "$TLS_PILOT_RUN/design.json" +cp -R "$TLS_PILOT_RELEASE/configs" "$TLS_PILOT_RUN/configs" +python "$TLS_PILOT_RELEASE/source_snapshots/high_impact.py" generate \ + --root "$TLS_PILOT_RUN" \ + --cadence benchmarks/results/tls_sensitivity_2026-09-09/cadences/tess_200s.npz +``` + +Generation creates a new timestamped manifest, so its hash need not equal +the historical manifest even if the case arrays match. Regeneration on a +different numerical stack may also change model values. Keep the newly +generated inputs, manifest, thresholds and results together. + +For searches, use a Linux CUDA host with the recorded Python 3.11 search +packages. The earlier [search dependency pins](../../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/requirements-search.txt") +provide the compatible stack. Install cuvarbase from a checkout of +`11317fb0ff1b68af05ae3f67de5f298c9a90e46b` and GTLS from the frozen +[source archive](../../../../docs/BENCHMARK_ARCHIVES.md#tls_profile_2026-09-08 "Archived file: benchmarks/results/tls_profile_2026-09-08/sources/gtls-head.tar"). +The GTLS source installation may omit `.cu` resources; copy the unmodified +`src/gputls/*.cu` files into its installed package directory. The runner +checks 37 cuvarbase and 19 GTLS numerical source files and stops if any +differs. Set `TLS_PILOT_BASELINE` to the absolute baseline checkout path. + +```sh +export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMBA_NUM_THREADS=4 +export LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONUTF8=1 +for method in v1_defaults v1_wide v1_fine gtls; do + python "$TLS_PILOT_RELEASE/source_snapshots/high_impact.py" run \ + --root "$TLS_PILOT_RUN" --source-root "$TLS_PILOT_BASELINE" \ + --method "$method" --split calibration +done +python "$TLS_PILOT_RELEASE/source_snapshots/high_impact.py" calibrate \ + --root "$TLS_PILOT_RUN" +for method in v1_defaults v1_wide v1_fine gtls; do + for split in injections nulls; do + python "$TLS_PILOT_RELEASE/source_snapshots/high_impact.py" run \ + --root "$TLS_PILOT_RUN" --source-root "$TLS_PILOT_BASELINE" \ + --method "$method" --split "$split" + done +done +python "$TLS_PILOT_RELEASE/source_snapshots/high_impact.py" analyze \ + --root "$TLS_PILOT_RUN" +``` + +GPU roundoff and GTLS's memory-dependent period chunks can affect individual +scores on another machine. Preserve reruns as new linked results with their +own frozen thresholds, keeping the published measurements intact. diff --git a/benchmarks/results/tls_accuracy_2026-09-09/kernel/README.md b/benchmarks/results/tls_accuracy_2026-09-09/kernel/README.md new file mode 100644 index 00000000..b29a7389 --- /dev/null +++ b/benchmarks/results/tls_accuracy_2026-09-09/kernel/README.md @@ -0,0 +1,94 @@ +# TLS kernel efficiency at fixed search settings + +These 9 September 2026 measurements concern the earlier phase-binned TLS +engine, retained as `method='binned'`. API defaults below refer to that +engine; the [current transit report](../../../../docs/TRANSIT_BENCHMARKS.md) +covers the observation-level default. The timings below remain historical +measurements of this specific kernel optimization. + +The optimized search skips empty phase-bin runs when the lightcurve has fewer +than one observation per four bins. It retains the reference kernel's +successive float32 coordinate additions, weighted histogram, template, +duration/epoch grids, score normalization, and exact candidate refinement. +Denser lightcurves use the original traversal. + +The table compares the frozen pre-optimization CUDA kernel from commit +`11317fb0ff1b68af05ae3f67de5f298c9a90e46b` with the optimized kernel on the same A40. +Both run through the same Python host code. These are batch API measurements: +16 fixed lightcurves (eight injections and eight nulls), the complete period +grid, and five alternating paired repetitions after both variants warm. +Times are median batch-call durations divided by 16. Source preparation on the +host, allocations, transfers, synchronization, GPU search/refinement, and host +results are included; imports, compilation, context setup, grid creation, +synthetic data generation, and disk I/O are excluded. + +| Cadence | Settings | Reference seconds/source | Optimized seconds/source | Speedup | +|---|---|---:|---:|---:| +| tess_200s | fine | 0.038244 | 0.038033 | 1.006× | +| tess_200s | original | 0.001621 | 0.001604 | 1.011× | +| tess_gap | fine | 1.203128 | 1.204362 | 0.999× | +| tess_gap | original | 0.025036 | 0.025594 | 0.978× | +| ztf | fine | 3.759983 | 2.899791 | 1.297× | +| ztf | original | 0.061261 | 0.060357 | 1.015× | + +The fine ZTF batch uses about 23% less time (1.30× faster). Fine TESS timings +are effectively unchanged. At the original settings the differences are +small relative to observed call-to-call timing variation; individual JSONs +retain all five paired measurements, and `summary.json` gives their ranges. + +`fine` uses 8,192 bins, epoch oversampling 16, and 32 durations. +`original` means the earlier benchmark settings: automatic bins, epoch +oversampling 4, and 16 durations. These are explicit benchmark settings; +the public API defaults are epoch oversampling 3 and 15 durations. Both +settings refine 50 candidates with epoch oversampling 33. + +For numerical parity, 128 existing lightcurves per cadence (64 injections and +64 nulls) were searched twice with each kernel at the fine settings. TESS200s +used all 3,084 trial periods; gapped TESS and ZTF used 4,096 evenly selected +periods each. Full-grid timing calls also retained numerical comparisons. +The timing sources are subsets of these cohorts: 384 distinct lightcurves, +not an expanded recovery sample. + +Across the recorded comparisons, primary periods and valid-period masks all +matched. The maximum relative delta-chi-squared difference was +1.14e-06; the maximum absolute native SDE +difference was 1.87e-05. The relative-score +comparison divides by `max(abs(reference delta-chi-squared), 1)`. Full counts, +any changes in coarse best-fit parameters, and reference-versus-reference repeated +run differences are in `summary.json`. Eleven coarse epoch/duration choices +changed only at the original settings, where sparse traversal is disabled; +the reference also changed one coarse choice between repeated runs. Fine ZTF +had no coarse changes. The score differences are comparable to the +reference's own repeated-run variation; exact tie identity was not recorded. +All 169 TLS tests passed; `tests.log` +includes the warnings. A compact synthetic regression checks the numerical +sensitivity of template-tail integral subtraction. + +This is an engineering runtime and numerical-parity check using the earlier +[transit benchmark inputs](../../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived directory: benchmarks/results/transit_2026-09-08/inputs"). It does not +establish new recovery-rate or false-positive equivalence, or reduce the +scientific approximation from phase binning. + +To reproduce one full-grid timing on a CUDA machine with the package and its +TLS dependencies installed, run from the repository root: + +```sh +python benchmarks/tls_accuracy/kernel_benchmark.py \ + --inputs benchmarks/results/transit_2026-09-08/inputs \ + --baseline-kernel benchmarks/results/tls_accuracy_2026-09-09/kernel/baseline_tls_fast.cu \ + --profile ztf --sources 16 --period-limit 0 --reps 5 \ + --nbins 8192 --t0-oversample 16 --n-durations 32 \ + --out ztf_fine.json +``` + +For the original benchmark settings use `--nbins 0 --t0-oversample 4 +--n-durations 16`. For a parity cohort use the fine settings with +`--sources 128 --period-limit 4096 --reps 2`. Individual JSONs retain complete +configuration, input-array, grid, source, package, hardware, and timing +provenance. `grid-verification.json` confirms that the recorded input grids +reconstruct exactly in the recorded Linux/NumPy environment; float64 +transcendental results can differ by platform. `source-hashes.json` identifies +the tested implementations and +`input-verification.json` checks the published inputs and installed sources +against the run records. `SHA256SUMS.json` inventories these compact evidence +files. diff --git a/benchmarks/results/tls_profile_2026-09-08/.gitattributes b/benchmarks/results/tls_profile_2026-09-08/.gitattributes new file mode 100644 index 00000000..d0e95c49 --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the exact bytes used by the evidence SHA256 manifests. +* -text diff --git a/benchmarks/results/tls_profile_2026-09-08/ARCHIVE.md b/benchmarks/results/tls_profile_2026-09-08/ARCHIVE.md new file mode 100644 index 00000000..4d4d2c23 --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/ARCHIVE.md @@ -0,0 +1,5 @@ +# TLS component evidence + +The report, tables, figures, per-job JSON results/logs, source snapshots and original verification receipt are retained here. The original full snapshot is recoverable at [commit f0dc981](https://github.com/johnh2o2/cuvarbase/tree/f0dc981/analysis/tls-profile-20260908). Its manifests refer to the original paths. + +Large input/output periodograms and transport archives were not committed. The verification receipt describes checks against the complete local archive; it does not imply that all checked arrays are present in a fresh checkout. Cloud provisioning records and duplicate working scripts were retired during repository cleanup. The maintained diagnostic entry points are in [benchmarks/tls_profile](../../tls_profile/README.md). diff --git a/benchmarks/results/tls_profile_2026-09-08/README.md b/benchmarks/results/tls_profile_2026-09-08/README.md new file mode 100644 index 00000000..74fdcb2f --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/README.md @@ -0,0 +1,40 @@ +# TLS component measurements + +This 8 September 2026 component audit measures the earlier phase-binned cuvarbase TLS engine, retained as `method='binned'`. Its figure and timings remain historical evidence. The [current transit report](../../../docs/TRANSIT_BENCHMARKS.md) covers the observation-level default and current release claims. + +The audit used two retained ZTF-like and Rubin-like inputs. It explains GTLS host-loop overhead and the stages of CPU TLS failures. Equivalent detection sensitivity was not established for the binned TLS comparison below. + +![TLS components](figures/tls_components.png) + +| One-source warm API, A40 | ZTF-like | Rubin-like | +|---|---:|---:| +| GTLS PyPI 0.4.4 | 17.52 s | 23.34 s | +| GTLS upstream 0.5.1 | 15.78 s | 23.36 s | +| Upstream, batched duration union | 11.83 s | 18.26 s | +| Upstream, both loops batched | 2.70 s | 4.06 s | +| Frozen cuvarbase v1 | 0.489 s | 0.683 s | + +These are medians of three uninstrumented warm calls on one source per profile, with complete original period grids (219,127 / 313,007 trials). The figure stacks separately instrumented, synchronized wall phases, with the ordinary medians/ranges marked as diamonds. Phase wall times include launch/wait overhead and are not GPU kernel-busy traces. Individual profiles and ordinary calls vary; their totals must not be mixed to compute exact percentages. + +On the ZTF example, a 13.91-second upstream profile spends 8.71 seconds making one cumulative-sum call per period and 2.63 seconds repeatedly combining duration masks. Together these consume 81.5% of that profile. Replacing the duration union with a single array reduction leaves the complete periods and chi-square spectrum bit-identical on both examples. Replacing the flux prefix-sum loop with a batched cumulative sum adds a change in floating-point summation order: maximum chi-square differences are 2.08e-6 / 3.84e-6, and SDE changes are 0.000223 / 0.002964. Both examples retain the same best periods. These two host-operation changes yield 5.85× / 5.76× faster GTLS APIs. They are diagnostic patches, not published GTLS performance or proof of numerical equivalence on all inputs. + +After these patches, statistics and final diagnostics cost roughly 1–2 seconds, and candidate refinement remains substantial. Source inspection identifies Python sorting of large masked spectra and refinement padded to the coarse chunk size as further possible overhead. We have not measured a patch for those and do not count hypothetical gains. Warm CUDA module compilation/lookup is negligible in these profiles. + +The measured cuvarbase binned engine folds into weighted phase bins, evaluates integrated templates and analytically solves their weighted depths, then refines a limited set of candidates against individual observations. GPU launches cover many periods and lightcurves, with cached kernels/templates and duration-dependent phase-bin sizes. That engine was introduced in commit c89516a425d113672d3d7564dc6cd9318bd9036c. + +GTLS and the measured cuvarbase binned engine are in the same template-search family, but they do not compute the same numerical search. GTLS sorts individual samples by phase, samples templates in numbers of observations, estimates depth from an unweighted window mean and template overshoot, then evaluates weighted residuals. The binned engine fits weighted template depth analytically on phase bins before local refinement. Template reference geometries, duration/epoch grids, refinement candidate policies, and the spectrum used for SDE also differ. Equal limb-darkening coefficients or a shared period array do not remove those differences. A short GTLS residual-kernel phase does not establish that cuvarbase has a faster version of that particular kernel; much of the work occurs in different places and at different fidelity. + +The input-signal criticism in the earlier BLS discussion was overstated. A sinusoid or noise input does not invalidate a timing comparison when the same arrays and fixed search are supplied to every implementation. Signal injections become necessary for recovery/sensitivity claims. Independently searching each band and pooling a normalized common transit are different workloads, which affects representativeness rather than fairness within the original per-band timing contract. GTLS also has a mean-depth gate, so signal/noise values can influence how much residual work it executes. + +The CPU TLS errors have two concrete causes in transitleastsquares 1.32: + +* On the retained PS1/Gaia examples, a short duration rounds to a template with no in-transit samples; template-cache construction attempts a minimum of an empty array. The search has not started. +* On ZTF/Rubin, the period search completes, but the output model uses `int(number_of_observations / number_of_predicted_transit_occurrences)` samples. That becomes zero for sparse, long-baseline lightcurves, and generating the returned plotting model fails. The ZTF search found the correct 1.668944585-day period and SDE 29.22 before this failure. Calling this a failed period search would be inaccurate. + +The failure diagnostics retain tracebacks, selected local variables, and the already-computed ZTF/Rubin spectra. They do not repair the numerical search. First-call failure times include compilation and are not successful warm CPU API benchmarks; batch timeouts are not converted into speedups. Pinned CPU source is under [sources/cpu-tls](../../../docs/BENCHMARK_ARCHIVES.md#tls_profile_2026-09-08 "Archived directory: benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls"), especially `main.py` and `transit.py`. + +Evidence: [timing_summary.csv](timing_summary.csv), [phase_timings.csv](phase_timings.csv), [ablation_output_comparison.csv](ablation_output_comparison.csv), [verification.json](../../../docs/BENCHMARK_ARCHIVES.md#tls_profile_2026-09-08 "Archived file: benchmarks/results/tls_profile_2026-09-08/verification.json"), and all raw JSON/NPZ outputs under [results](../../../docs/BENCHMARK_ARCHIVES.md#tls_profile_2026-09-08 "Archived directory: benchmarks/results/tls_profile_2026-09-08/results"). The transferred archive is 49,305,600 bytes with SHA256 `3f271d3d46d6baf49926952a6e888889b64488900d34e05e2b606906e33063ef`. All 95 transferred files and 14 diagnostic jobs are accounted for. Installed runtime source hashes match the pinned archives; two upstream `.cu` reference snapshots are not installed by GTLS, whose actual runtime CUDA string in `GPUFun.py` is verified. + +The A40 rental was $0.49/hour with a 7.65-CPU-equivalent quota on an Intel Xeon Gold 6342 host. This pod also served the completed recovery campaign. All three campaign nodes are now terminated and verified absent. Total estimated rental, including earlier campaigns once, is $8.03 against the authorized $50. See the [final ledger](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/rental-ledger.json") and [measured speed/recovery report](../transit_2026-09-08/README.md). + +Git includes the reports, figures, measurement records and verification receipts. Full input/output arrays for this earlier campaign remain in the local archive; see [archive contents](ARCHIVE.md). diff --git a/benchmarks/results/tls_profile_2026-09-08/ablation_output_comparison.csv b/benchmarks/results/tls_profile_2026-09-08/ablation_output_comparison.csv new file mode 100644 index 00000000..77e84b17 --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/ablation_output_comparison.csv @@ -0,0 +1,5 @@ +profile,variant,original_s,modified_s,original_over_modified,exact_periods_and_chi2,same_finite_mask,max_abs_chi2_difference,max_relative_chi2_difference,original_period,modified_period,delta_sde +ztf,union,15.779030878096819,11.829535190016031,1.3338673603519189,True,True,0.0,0.0,1.6689445850788929,1.6689445850788929,0.0 +ztf,both,15.779030878096819,2.6985989324748516,5.847119662063331,False,True,2.0815059542655945e-06,0.00032710272167026994,1.6689445850788929,1.6689445850788929,0.0002231597900390625 +rubin,union,23.36214793100953,18.262864843010902,1.2792159462292738,True,True,0.0,0.0,4.970180586560595,4.970180586560595,0.0 +rubin,both,23.36214793100953,4.0561326295137405,5.759710064956689,False,True,3.840774297714233e-06,0.00022093440251869505,4.970180586560595,4.970180586560595,0.002964019775390625 diff --git a/benchmarks/results/tls_profile_2026-09-08/figures/tls_components.png b/benchmarks/results/tls_profile_2026-09-08/figures/tls_components.png new file mode 100644 index 00000000..55995a3d Binary files /dev/null and b/benchmarks/results/tls_profile_2026-09-08/figures/tls_components.png differ diff --git a/benchmarks/results/tls_profile_2026-09-08/phase_timings.csv b/benchmarks/results/tls_profile_2026-09-08/phase_timings.csv new file mode 100644 index 00000000..6b034765 --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/phase_timings.csv @@ -0,0 +1,177 @@ +job,profile_repeat,phase,category,exclusive_s,inclusive_s,calls +rubin_gtls_head_both,0,GTLS input/template setup,Setup / remaining API work,0.02731812745332718,0.02731812745332718,1 +rubin_gtls_head_both,0,GTLS setup and allocations,Setup / remaining API work,0.007236182689666748,0.007236182689666748,2 +rubin_gtls_head_both,0,GTLS CUDA module lookup/compile,Setup / remaining API work,9.639561176300049e-05,9.639561176300049e-05,1 +rubin_gtls_head_both,0,GTLS duration-mask union,Duration-mask union,0.005914624780416489,0.005914624780416489,31 +rubin_gtls_head_both,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.042686864733695984,0.042686864733695984,31 +rubin_gtls_head_both,0,GTLS folding/sorting,Other coarse search / transfers,0.33553096279501915,0.33553096279501915,31 +rubin_gtls_head_both,0,GTLS reorder/weights,Other coarse search / transfers,0.01050952821969986,0.01050952821969986,31 +rubin_gtls_head_both,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,0.024954531341791153,0.024954531341791153,31 +rubin_gtls_head_both,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.07780595868825912,0.07780595868825912,31 +rubin_gtls_head_both,0,GTLS transit residual kernel,Other coarse search / transfers,0.2549600191414356,0.2549600191414356,31 +rubin_gtls_head_both,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.04999883845448494,0.04999883845448494,31 +rubin_gtls_head_both,0,GTLS result/statistics processing,Statistics / final diagnostics,2.05530908331275,2.05530908331275,3 +rubin_gtls_head_both,0,GTLS candidate refinement,Candidate refinement,0.9163180962204933,0.9163180962204933,2 +rubin_gtls_head_both,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.06864684447646141,0.06864684447646141,1 +rubin_gtls_head_both,0,GTLS full search call,Setup / remaining API work,0.0674877017736435,3.91745563223958,1 +rubin_gtls_head_both,0,API preparation/remaining host work,Setup / remaining API work,0.0023382827639579773,3.9471120424568653,1 +ztf_gtls_head_native,0,GTLS input/template setup,Setup / remaining API work,0.01984405890107155,0.01984405890107155,1 +ztf_gtls_head_native,0,GTLS setup and allocations,Setup / remaining API work,0.004544522613286972,0.004544522613286972,2 +ztf_gtls_head_native,0,GTLS CUDA module lookup/compile,Setup / remaining API work,8.425116539001465e-05,8.425116539001465e-05,1 +ztf_gtls_head_native,0,GTLS duration-mask union,Duration-mask union,2.6295381262898445,2.6295381262898445,31 +ztf_gtls_head_native,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.04144638031721115,0.04144638031721115,31 +ztf_gtls_head_native,0,GTLS folding/sorting,Other coarse search / transfers,0.1482488512992859,0.1482488512992859,31 +ztf_gtls_head_native,0,GTLS reorder/weights,Other coarse search / transfers,0.00508904829621315,0.00508904829621315,31 +ztf_gtls_head_native,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,8.71030891686678,8.71030891686678,31 +ztf_gtls_head_native,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.030102360993623734,0.030102360993623734,31 +ztf_gtls_head_native,0,GTLS transit residual kernel,Other coarse search / transfers,0.07385526970028877,0.07385526970028877,31 +ztf_gtls_head_native,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.02118295431137085,0.02118295431137085,31 +ztf_gtls_head_native,0,GTLS result/statistics processing,Statistics / final diagnostics,1.442806575447321,1.442806575447321,3 +ztf_gtls_head_native,0,GTLS candidate refinement,Candidate refinement,0.6330719403922558,0.6330719403922558,2 +ztf_gtls_head_native,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.10383562371134758,0.10383562371134758,1 +ztf_gtls_head_native,0,GTLS full search call,Setup / remaining API work,0.04684858024120331,13.890963401645422,1 +ztf_gtls_head_native,0,API preparation/remaining host work,Setup / remaining API work,0.0013560988008975983,13.912163559347391,1 +rubin_gtls_head_native,0,GTLS input/template setup,Setup / remaining API work,0.028137557208538055,0.028137557208538055,1 +rubin_gtls_head_native,0,GTLS setup and allocations,Setup / remaining API work,0.005863916128873825,0.005863916128873825,2 +rubin_gtls_head_native,0,GTLS CUDA module lookup/compile,Setup / remaining API work,0.00010169297456741333,0.00010169297456741333,1 +rubin_gtls_head_native,0,GTLS duration-mask union,Duration-mask union,4.0768484100699425,4.0768484100699425,31 +rubin_gtls_head_native,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.04498661682009697,0.04498661682009697,31 +rubin_gtls_head_native,0,GTLS folding/sorting,Other coarse search / transfers,0.3381597772240639,0.3381597772240639,31 +rubin_gtls_head_native,0,GTLS reorder/weights,Other coarse search / transfers,0.010670889168977737,0.010670889168977737,31 +rubin_gtls_head_native,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,13.352823246270418,13.352823246270418,31 +rubin_gtls_head_native,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.07950641959905624,0.07950641959905624,31 +rubin_gtls_head_native,0,GTLS transit residual kernel,Other coarse search / transfers,0.25460947677493095,0.25460947677493095,31 +rubin_gtls_head_native,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.05136486142873764,0.05136486142873764,31 +rubin_gtls_head_native,0,GTLS result/statistics processing,Statistics / final diagnostics,2.0696683935821056,2.0696683935821056,3 +rubin_gtls_head_native,0,GTLS candidate refinement,Candidate refinement,0.9601034037768841,0.9601034037768841,2 +rubin_gtls_head_native,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.09300055727362633,0.09300055727362633,1 +rubin_gtls_head_native,0,GTLS full search call,Setup / remaining API work,0.0856749378144741,21.423382598906755,1 +rubin_gtls_head_native,0,API preparation/remaining host work,Setup / remaining API work,0.0025142058730125427,21.454034361988306,1 +ztf_gtls_head_both,0,GTLS input/template setup,Setup / remaining API work,0.019568484276533127,0.019568484276533127,1 +ztf_gtls_head_both,0,GTLS setup and allocations,Setup / remaining API work,0.0038705207407474518,0.0038705207407474518,2 +ztf_gtls_head_both,0,GTLS CUDA module lookup/compile,Setup / remaining API work,8.462369441986084e-05,8.462369441986084e-05,1 +ztf_gtls_head_both,0,GTLS duration-mask union,Duration-mask union,0.004666734486818314,0.004666734486818314,31 +ztf_gtls_head_both,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.02955678477883339,0.02955678477883339,31 +ztf_gtls_head_both,0,GTLS folding/sorting,Other coarse search / transfers,0.14882000908255577,0.14882000908255577,31 +ztf_gtls_head_both,0,GTLS reorder/weights,Other coarse search / transfers,0.0049779824912548065,0.0049779824912548065,31 +ztf_gtls_head_both,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,0.00782066211104393,0.00782066211104393,31 +ztf_gtls_head_both,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.028721235692501068,0.028721235692501068,31 +ztf_gtls_head_both,0,GTLS transit residual kernel,Other coarse search / transfers,0.07356001809239388,0.07356001809239388,31 +ztf_gtls_head_both,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.019942138344049454,0.019942138344049454,31 +ztf_gtls_head_both,0,GTLS result/statistics processing,Statistics / final diagnostics,1.414280954748392,1.414280954748392,3 +ztf_gtls_head_both,0,GTLS candidate refinement,Candidate refinement,0.6461798287928104,0.6461798287928104,2 +ztf_gtls_head_both,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.10503512620925903,0.10503512620925903,1 +ztf_gtls_head_both,0,GTLS full search call,Setup / remaining API work,0.04695470631122589,2.5344713255763054,1 +ztf_gtls_head_both,0,API preparation/remaining host work,Setup / remaining API work,0.0014428868889808655,2.5554826967418194,1 +rubin_v1_release_native,0,v1 grid/configuration,Setup / remaining API work,0.01845831423997879,0.01845831423997879,1 +rubin_v1_release_native,0,v1 kernel lookup/grid transfers,Setup / remaining API work,0.006975576281547546,0.006975576281547546,1 +rubin_v1_release_native,0,v1 template tables,Setup / remaining API work,6.471201777458191e-05,6.471201777458191e-05,1 +rubin_v1_release_native,0,v1 host lightcurve preparation,Setup / remaining API work,0.00022464990615844727,0.00022464990615844727,1 +rubin_v1_release_native,0,v1 buffer allocations/transfers,Setup / remaining API work,0.0015679411590099335,0.0015679411590099335,1 +rubin_v1_release_native,0,v1 lightcurve transfers,Setup / remaining API work,0.00028812140226364136,0.00028812140226364136,1 +rubin_v1_release_native,0,v1 coarse search kernel,Other coarse search / transfers,0.5769607163965702,0.5769607163965702,1 +rubin_v1_release_native,0,v1 coarse spectrum transfer,Other coarse search / transfers,0.0006308145821094513,0.0006308145821094513,1 +rubin_v1_release_native,0,v1 candidate selection/refinement/transfers,Candidate refinement,0.002890743315219879,0.002890743315219879,1 +rubin_v1_release_native,0,v1 parameter-spectrum transfers,Other coarse search / transfers,0.0017793364822864532,0.0017793364822864532,1 +rubin_v1_release_native,0,v1 CPU statistics/results,Statistics / final diagnostics,0.03769994527101517,0.03769994527101517,1 +rubin_v1_release_native,0,v1 final packaging,Setup / remaining API work,2.250075340270996e-06,2.250075340270996e-06,1 +rubin_v1_release_native,0,API preparation/remaining host work,Setup / remaining API work,0.03446563705801964,0.682008758187294,1 +rubin_v1_release_native,1,v1 grid/configuration,Setup / remaining API work,0.014202553778886795,0.014202553778886795,1 +rubin_v1_release_native,1,v1 kernel lookup/grid transfers,Setup / remaining API work,0.01898166537284851,0.01898166537284851,1 +rubin_v1_release_native,1,v1 template tables,Setup / remaining API work,4.287436604499817e-05,4.287436604499817e-05,1 +rubin_v1_release_native,1,v1 host lightcurve preparation,Setup / remaining API work,0.00018129125237464905,0.00018129125237464905,1 +rubin_v1_release_native,1,v1 buffer allocations/transfers,Setup / remaining API work,0.006663475185632706,0.006663475185632706,1 +rubin_v1_release_native,1,v1 lightcurve transfers,Setup / remaining API work,0.00026756152510643005,0.00026756152510643005,1 +rubin_v1_release_native,1,v1 coarse search kernel,Other coarse search / transfers,0.5797881446778774,0.5797881446778774,1 +rubin_v1_release_native,1,v1 coarse spectrum transfer,Other coarse search / transfers,0.0006185844540596008,0.0006185844540596008,1 +rubin_v1_release_native,1,v1 candidate selection/refinement/transfers,Candidate refinement,0.0022079087793827057,0.0022079087793827057,1 +rubin_v1_release_native,1,v1 parameter-spectrum transfers,Other coarse search / transfers,0.0015526488423347473,0.0015526488423347473,1 +rubin_v1_release_native,1,v1 CPU statistics/results,Statistics / final diagnostics,0.04099884629249573,0.04099884629249573,1 +rubin_v1_release_native,1,v1 final packaging,Setup / remaining API work,2.3618340492248535e-06,2.3618340492248535e-06,1 +rubin_v1_release_native,1,API preparation/remaining host work,Setup / remaining API work,0.026014942675828934,0.6915228590369225,1 +ztf_gtls_head_union,0,GTLS input/template setup,Setup / remaining API work,0.020822584629058838,0.020822584629058838,1 +ztf_gtls_head_union,0,GTLS setup and allocations,Setup / remaining API work,0.003733571618795395,0.003733571618795395,2 +ztf_gtls_head_union,0,GTLS CUDA module lookup/compile,Setup / remaining API work,8.464977145195007e-05,8.464977145195007e-05,1 +ztf_gtls_head_union,0,GTLS duration-mask union,Duration-mask union,0.005272336304187775,0.005272336304187775,31 +ztf_gtls_head_union,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.03629231080412865,0.03629231080412865,31 +ztf_gtls_head_union,0,GTLS folding/sorting,Other coarse search / transfers,0.14645036682486534,0.14645036682486534,31 +ztf_gtls_head_union,0,GTLS reorder/weights,Other coarse search / transfers,0.004973050206899643,0.004973050206899643,31 +ztf_gtls_head_union,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,8.28770862147212,8.28770862147212,31 +ztf_gtls_head_union,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.030085299164056778,0.030085299164056778,31 +ztf_gtls_head_union,0,GTLS transit residual kernel,Other coarse search / transfers,0.07333921641111374,0.07333921641111374,31 +ztf_gtls_head_union,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.02103511616587639,0.02103511616587639,31 +ztf_gtls_head_union,0,GTLS result/statistics processing,Statistics / final diagnostics,1.6397212594747543,1.6397212594747543,3 +ztf_gtls_head_union,0,GTLS candidate refinement,Candidate refinement,0.7431692443788052,0.7431692443788052,2 +ztf_gtls_head_union,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.12471194565296173,0.12471194565296173,1 +ztf_gtls_head_union,0,GTLS full search call,Setup / remaining API work,0.058133114129304886,11.174710102379322,1 +ztf_gtls_head_union,0,API preparation/remaining host work,Setup / remaining API work,0.0015242286026477814,11.197056915611029,1 +ztf_v1_release_native,0,v1 grid/configuration,Setup / remaining API work,0.009487230330705643,0.009487230330705643,1 +ztf_v1_release_native,0,v1 kernel lookup/grid transfers,Setup / remaining API work,0.004803221672773361,0.004803221672773361,1 +ztf_v1_release_native,0,v1 template tables,Setup / remaining API work,4.13842499256134e-05,4.13842499256134e-05,1 +ztf_v1_release_native,0,v1 host lightcurve preparation,Setup / remaining API work,0.0001550428569316864,0.0001550428569316864,1 +ztf_v1_release_native,0,v1 buffer allocations/transfers,Setup / remaining API work,0.0012255087494850159,0.0012255087494850159,1 +ztf_v1_release_native,0,v1 lightcurve transfers,Setup / remaining API work,0.00025852397084236145,0.00025852397084236145,1 +ztf_v1_release_native,0,v1 coarse search kernel,Other coarse search / transfers,0.40344369038939476,0.40344369038939476,1 +ztf_v1_release_native,0,v1 coarse spectrum transfer,Other coarse search / transfers,0.00047143176198005676,0.00047143176198005676,1 +ztf_v1_release_native,0,v1 candidate selection/refinement/transfers,Candidate refinement,0.0019293874502182007,0.0019293874502182007,1 +ztf_v1_release_native,0,v1 parameter-spectrum transfers,Other coarse search / transfers,0.002106405794620514,0.002106405794620514,1 +ztf_v1_release_native,0,v1 CPU statistics/results,Statistics / final diagnostics,0.02924473211169243,0.02924473211169243,1 +ztf_v1_release_native,0,v1 final packaging,Setup / remaining API work,2.93925404548645e-06,2.93925404548645e-06,1 +ztf_v1_release_native,0,API preparation/remaining host work,Setup / remaining API work,0.028258655220270157,0.4814281538128853,1 +ztf_v1_release_native,1,v1 grid/configuration,Setup / remaining API work,0.010052695870399475,0.010052695870399475,1 +ztf_v1_release_native,1,v1 kernel lookup/grid transfers,Setup / remaining API work,0.005383044481277466,0.005383044481277466,1 +ztf_v1_release_native,1,v1 template tables,Setup / remaining API work,4.9486756324768066e-05,4.9486756324768066e-05,1 +ztf_v1_release_native,1,v1 host lightcurve preparation,Setup / remaining API work,0.0001546330749988556,0.0001546330749988556,1 +ztf_v1_release_native,1,v1 buffer allocations/transfers,Setup / remaining API work,0.0011174492537975311,0.0011174492537975311,1 +ztf_v1_release_native,1,v1 lightcurve transfers,Setup / remaining API work,0.0002354942262172699,0.0002354942262172699,1 +ztf_v1_release_native,1,v1 coarse search kernel,Other coarse search / transfers,0.4035860076546669,0.4035860076546669,1 +ztf_v1_release_native,1,v1 coarse spectrum transfer,Other coarse search / transfers,0.003311406821012497,0.003311406821012497,1 +ztf_v1_release_native,1,v1 candidate selection/refinement/transfers,Candidate refinement,0.0056806206703186035,0.0056806206703186035,1 +ztf_v1_release_native,1,v1 parameter-spectrum transfers,Other coarse search / transfers,0.0013230033218860626,0.0013230033218860626,1 +ztf_v1_release_native,1,v1 CPU statistics/results,Statistics / final diagnostics,0.04881870746612549,0.04881870746612549,1 +ztf_v1_release_native,1,v1 final packaging,Setup / remaining API work,1.959502696990967e-06,1.959502696990967e-06,1 +ztf_v1_release_native,1,API preparation/remaining host work,Setup / remaining API work,0.02412481978535652,0.5038393288850784,1 +rubin_gtls_pypi_native,0,GTLS setup and allocations,Setup / remaining API work,0.0032102689146995544,0.0032102689146995544,2 +rubin_gtls_pypi_native,0,GTLS CUDA module lookup/compile,Setup / remaining API work,9.904056787490845e-05,9.904056787490845e-05,1 +rubin_gtls_pypi_native,0,GTLS duration-mask union,Duration-mask union,3.9108527339994907,3.9108527339994907,31 +rubin_gtls_pypi_native,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.05428471788764,0.05428471788764,31 +rubin_gtls_pypi_native,0,GTLS folding/sorting,Other coarse search / transfers,0.3357042819261551,0.3357042819261551,31 +rubin_gtls_pypi_native,0,GTLS reorder/weights,Other coarse search / transfers,0.01070912554860115,0.01070912554860115,31 +rubin_gtls_pypi_native,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,12.768409565091133,12.768409565091133,31 +rubin_gtls_pypi_native,0,GTLS transit residual kernel,Other coarse search / transfers,0.22689523175358772,0.22689523175358772,31 +rubin_gtls_pypi_native,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.0897066667675972,0.0897066667675972,31 +rubin_gtls_pypi_native,0,GTLS result/statistics processing,Statistics / final diagnostics,1.978220671415329,1.978220671415329,3 +rubin_gtls_pypi_native,0,GTLS candidate refinement,Candidate refinement,0.8728385083377361,0.8728385083377361,2 +rubin_gtls_pypi_native,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.05590669810771942,0.05590669810771942,1 +rubin_gtls_pypi_native,0,GTLS input/template setup,Setup / remaining API work,0.10227765142917633,20.40911516174674,1 +rubin_gtls_pypi_native,0,API preparation/remaining host work,Setup / remaining API work,0.002727378159761429,20.4118425399065,1 +ztf_gtls_pypi_native,0,GTLS setup and allocations,Setup / remaining API work,0.002143029123544693,0.002143029123544693,2 +ztf_gtls_pypi_native,0,GTLS CUDA module lookup/compile,Setup / remaining API work,0.00013354793190956116,0.00013354793190956116,1 +ztf_gtls_pypi_native,0,GTLS duration-mask union,Duration-mask union,3.275159403681755,3.275159403681755,31 +ztf_gtls_pypi_native,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.05708964541554451,0.05708964541554451,31 +ztf_gtls_pypi_native,0,GTLS folding/sorting,Other coarse search / transfers,0.15095634013414383,0.15095634013414383,31 +ztf_gtls_pypi_native,0,GTLS reorder/weights,Other coarse search / transfers,0.005278110504150391,0.005278110504150391,31 +ztf_gtls_pypi_native,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,10.721305180341005,10.721305180341005,31 +ztf_gtls_pypi_native,0,GTLS transit residual kernel,Other coarse search / transfers,0.06941511854529381,0.06941511854529381,31 +ztf_gtls_pypi_native,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.035640325397253036,0.035640325397253036,31 +ztf_gtls_pypi_native,0,GTLS result/statistics processing,Statistics / final diagnostics,1.3907533213496208,1.3907533213496208,3 +ztf_gtls_pypi_native,0,GTLS candidate refinement,Candidate refinement,0.6254303492605686,0.6254303492605686,2 +ztf_gtls_pypi_native,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.10070474073290825,0.10070474073290825,1 +ztf_gtls_pypi_native,0,GTLS input/template setup,Setup / remaining API work,0.07238953188061714,16.506398644298315,1 +ztf_gtls_pypi_native,0,API preparation/remaining host work,Setup / remaining API work,0.0017532706260681152,16.508151914924383,1 +rubin_gtls_head_union,0,GTLS input/template setup,Setup / remaining API work,0.027487222105264664,0.027487222105264664,1 +rubin_gtls_head_union,0,GTLS setup and allocations,Setup / remaining API work,0.00571504607796669,0.00571504607796669,2 +rubin_gtls_head_union,0,GTLS CUDA module lookup/compile,Setup / remaining API work,8.94404947757721e-05,8.94404947757721e-05,1 +rubin_gtls_head_union,0,GTLS duration-mask union,Duration-mask union,0.00651959702372551,0.00651959702372551,31 +rubin_gtls_head_union,0,GTLS chunk allocations/transfers,Setup / remaining API work,0.05184264853596687,0.05184264853596687,31 +rubin_gtls_head_union,0,GTLS folding/sorting,Other coarse search / transfers,0.33687848970294,0.33687848970294,31 +rubin_gtls_head_union,0,GTLS reorder/weights,Other coarse search / transfers,0.01063661277294159,0.01063661277294159,31 +rubin_gtls_head_union,0,GTLS row-wise flux prefix sums,Per-period prefix-sum loop,12.293479107320309,12.293479107320309,31 +rubin_gtls_head_union,0,GTLS error prefixes/out-of-transit terms,Other coarse search / transfers,0.07907585427165031,0.07907585427165031,31 +rubin_gtls_head_union,0,GTLS transit residual kernel,Other coarse search / transfers,0.2545378729701042,0.2545378729701042,31 +rubin_gtls_head_union,0,GTLS reductions/chunk cleanup,Other coarse search / transfers,0.050893206149339676,0.050893206149339676,31 +rubin_gtls_head_union,0,GTLS result/statistics processing,Statistics / final diagnostics,2.2422652691602707,2.2422652691602707,3 +rubin_gtls_head_union,0,GTLS candidate refinement,Candidate refinement,0.9240027517080307,0.9240027517080307,2 +rubin_gtls_head_union,0,GTLS best-period fit/diagnostics,Statistics / final diagnostics,0.06667240709066391,0.06667240709066391,1 +rubin_gtls_head_union,0,GTLS full search call,Setup / remaining API work,0.07502305135130882,16.397631354629993,1 +rubin_gtls_head_union,0,API preparation/remaining host work,Setup / remaining API work,0.0022021234035491943,16.427320700138807,1 diff --git a/benchmarks/results/tls_profile_2026-09-08/timing_summary.csv b/benchmarks/results/tls_profile_2026-09-08/timing_summary.csv new file mode 100644 index 00000000..40447900 --- /dev/null +++ b/benchmarks/results/tls_profile_2026-09-08/timing_summary.csv @@ -0,0 +1,11 @@ +job,native_median_s,native_min_s,native_max_s,first_api_s,profile_mean_s +rubin_gtls_head_both,4.0561326295137405,3.957253374159336,4.120800603181124,9.62958874180913,3.947136726230383 +ztf_gtls_head_native,15.779030878096819,14.526475351303816,16.528089467436075,14.870834633708,13.912187345325947 +rubin_gtls_head_native,23.36214793100953,22.09135302901268,24.13728368282318,23.783879909664392,21.454068809747696 +ztf_gtls_head_both,2.6985989324748516,2.5857475884258747,2.754643104970455,3.2728078439831734,2.5555093958973885 +rubin_v1_release_native,0.6832563430070877,0.6779014393687248,0.6908206939697266,2.957486543804407,0.6868196651339531 +ztf_gtls_head_union,11.829535190016031,11.275036677718163,14.284316055476665,12.331406474113464,11.197082705795765 +ztf_v1_release_native,0.48888593167066574,0.47084595263004303,0.49337057024240494,0.8699922561645508,0.4926929362118244 +rubin_gtls_pypi_native,23.336721900850534,19.792948201298714,25.073554646223783,24.299411721527576,20.411881506443024 +ztf_gtls_pypi_native,17.516636807471514,16.92928121238947,17.542989261448383,16.465052902698517,16.50819793716073 +rubin_gtls_head_union,18.262864843010902,16.479172106832266,18.305770684033632,19.1515152156353,16.427344106137753 diff --git a/benchmarks/results/tls_reference_2026-09-10/.gitattributes b/benchmarks/results/tls_reference_2026-09-10/.gitattributes new file mode 100644 index 00000000..46b76525 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/.gitattributes @@ -0,0 +1,3 @@ +# Preserve original evidence bytes, including csv.writer CRLF terminators. +* -text +*.csv whitespace=cr-at-eol diff --git a/benchmarks/results/tls_reference_2026-09-10/README.md b/benchmarks/results/tls_reference_2026-09-10/README.md new file mode 100644 index 00000000..347763e0 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/README.md @@ -0,0 +1,41 @@ +# Observation-level TLS: validation and timing + +This is the evidence for cuvarbase v1's new default TLS engine. It evaluates individual observations with pinned GTLS's templates, sample windows and full refinement. The earlier phase-binned engine and its larger historical speed ratios are separate studies. + +**The independent 160-case study and separately sealed 24-case null supplement match corrected GTLS exactly.** All APIs succeeded. Untouched GTLS has nine cases with different final spectra and SDE values caused by its invalid-candidate mask defect; all 184 selected periods and the studies' descriptive SDE > 8 recovery/null decisions still agree. The [benchmark report](../../../docs/TRANSIT_BENCHMARKS.md) explains the numerical comparison and speed results; [TLS numerics](../../../docs/TLS_NUMERICS.md) explains the algorithm. + +**TLS is 3.6–4.6× faster for one lightcurve and 1.5–2.4× faster per lightcurve in 16-source batches** than the qualifying GTLS comparisons. All times below are median seconds per lightcurve, including each API's normal output work. + +| Cadence | Single v1 / GTLS | Single speedup | Batch v1 / GTLS | Batch speedup | GTLS batch workers | +| --- | ---: | ---: | ---: | ---: | ---: | +| TESS: dense sector | 0.149 / 0.533 s | 3.58× | 0.161 / 0.319 s | 1.98× | 4 | +| TESS: separated sectors | 1.554 / 6.037 s | 3.88× | 1.534 / 3.684 s | 2.40× | 2 | +| ZTF g/r | 3.231 / 14.899 s | 4.61× | 3.116 / 4.545 s | 1.46× | 4 | + +The original campaign **failed its all-configurations gate** because four-worker GTLS exhausted GPU memory during the separated-TESS warmup, before any measured repetitions. This report uses a separate, explicitly **post hoc assessment of the 11 completed configurations**, retaining the original numerical checks and fastest-eligible-pool rule. The original failure is preserved; failed or incomplete calls never supply a speed denominator. [Original gate](../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing/acceptance.json") · [Reporting assessment](../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/reporting_acceptance.json"). + +| Evidence | Contents | +| --- | --- | +| [Main validation](validation/README.md) | 160 independent inputs across eight TESS/ZTF regimes; original acceptance, per-regime outcomes, complete comparisons and output hashes | +| [Supplementary nulls](supplement/README.md) | 24 separately sealed null inputs, extending the three timing cohorts to 16 each | +| [Numerical stress tests](stress/README.md) | Selected edge cases and annual-period thin transits, with shared misses and input-handling differences retained | +| [Timing records](timing/README.md) | Five single calls, three 16-source batch repetitions, GTLS pools of one/two/four workers, and separate common-search components | +| [Exact inputs](inputs/README.md) | A portable 209-case array bank, original metadata and byte-identity verification | +| [Executed sources](sources/README.md) | Original scientific and timing source snapshots, seals and production-test source identities | +| [Rental ledger](../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/rental-ledger.json") | Actual rental intervals, storage estimates, interrupted-run accounting and verified termination | + +The single timing source is selected by its declared input identity and paired API success. It is never selected for its elapsed time, recovery or SNR. Each measured search included in the report must reproduce its own frozen scientific outputs, and complete returned-object hashes must repeat. The 184-case sensitivity study uses the single-worker reference; GTLS pools qualify on the 16-source timing cohort, which is not a separate pooled injection/recovery study. Failures and incomplete calls are excluded from successful timing denominators and retained in the evidence. Common-search components are measured separately; GTLS's extra SNR/pink-noise diagnostics are not attributed to a slower fitting kernel. + +The main validation ran on an A40. Supplementary validation and TLS timing ran on an RTX A6000, selected for availability before timing. Within the TLS comparison, both implementations use the same A6000, inputs, period grids and CPU allocation. Environment receipts record the allocations, package pins and numerical-library thread settings. The batch comparison uses the fastest eligible tested GTLS pool. Repetitions on fixed inputs do not estimate runtime variation across an entire source population; cost projections retain that limit. + +A fixed SDE of 8 is not a calibrated false-alarm threshold across these cadences. Whole-spectrum equivalence is the primary accuracy evidence; identical decisions at that descriptive threshold are an additional check. Small cohorts do not establish a one- or two-percentage-point completeness margin. Both engines retain GTLS's sample-window approximation, and neither can recover unobserved transits or guarantee detection through arbitrary noise. + +The exact production sources passed [265 TLS tests on an A40](../../../docs/validation/tls-default-20260910/README.md). The pinned native reference is [GTLS 74e449c](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111); its MIT notices are retained. The host-mask correction and literal-native comparisons are explicit in the [implementation comparison](../../../docs/GTLS_COMPARISON.md). + +Input and result collection interruptions are documented in the collection receipts. The completed main acceptance is original; it was recovered from complete members of a truncated download and was not reconstructed. Reexecuting the same inputs does not create additional independent samples. Large output arrays remain outside this repository, with their numerical identities, retained/removed/missing status and reproduction route preserved. + +The [figure provenance](../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/figure-provenance.json") records the plotted data, renderer and output hashes. + +To reproduce the study, start with the [maintained validation tools](../../tls_reference/README.md) and [timing protocol](../../tls_reference/timing/README.md). The [topline figure](../../../docs/figures/transit_benchmarks_20260910.png) combines this TLS campaign with the separately dated [BLS evidence](../transit_2026-09-08/README.md). + +The [long-control diagnostic](stress/diagnostic/README.md) reproduces the original coarse discrepancy through shared float32 cumulative-sum variability. Native repeats can change masks and SDE; identical saved intermediates give identical native and fused scores. All nine repeats retain the selected period and final fit, with the true annual period tied with the one-third alias. The original stress comparison stays failed, and no universal bitwise-repeatability claim is made. diff --git a/benchmarks/results/tls_reference_2026-09-10/inputs/README.md b/benchmarks/results/tls_reference_2026-09-10/inputs/README.md new file mode 100644 index 00000000..7f466df9 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/inputs/README.md @@ -0,0 +1,52 @@ +# Exact numerical inputs + +This 20.8 MB bank stores the original observation, uncertainty, flux, injected +signal, exposure, band and period arrays. Repeated arrays are stored once; +restoration performs no physical-signal generation or random draws. NumPy and +the Python standard library are sufficient. + +| Study label | Cases | Scope | +| --- | ---: | --- | +| `main` | 160 | Independent full-grid confirmation | +| `supplement` | 24 | Separately sealed null population | +| `selected_grid` | 21 | Development numerical stresses | +| `long_period` | 2 | Selected-grid long-period parents | +| `stronger_controls` | 2 | Same long-period signals with half the original noise | + +`bank.json` identifies the lossless array archive, each unique dtype/shape/byte +identity, and every unchanged original manifest under `manifests/`. The +[restoration proof](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/sources/input_restoration_proof.json") independently +checked all 209 cases and all 1,463 numerical arrays against their original +files. All arrays and metadata match. The two stronger controls have different +NPZ container encodings after restoration; both container hashes are recorded. +Container encoding is separate from the identity of the numerical inputs. + +From the repository root, restore the primary population into a new directory: + +```sh +python benchmarks/tls_reference/inputs.py restore \ + --bank benchmarks/results/tls_reference_2026-09-10/inputs --study main \ + --manifest benchmarks/results/tls_reference_2026-09-10/validation/input_manifest.json \ + --out reproduced-inputs +``` + +The output keeps `original_manifest.json` unchanged and creates a separately +labeled reproduction manifest. The [full comparison and timing workflow](../../../tls_reference/README.md) +uses that manifest without changing an original scientific seal. For the +supplement, use `--study supplement` and its original manifest in the +`supplement/` directory. + +The three development labels have no independent-study seal. Their selected +grids contain truth and aliases; use them as numerical fixtures, not blind +recovery or throughput evidence. Their original manifests are under `stress/` +as `selected_grid_inputs.json`, `long_period_inputs.json` and +`stronger_controls_inputs.json`. The control manifest originally lacked +per-array hashes; its bank entries explicitly derive those hashes only after +verifying the original NPZ file hash and metadata. This does not invent a +new source identity or seal. + +The earlier 21-case generator version was not retained. Its original identity +and numerical vectors remain available here. The maintained generator +separately recreated those vectors exactly on the original CPU environment; +the [source notes](../sources/README.md) distinguish that check from preserving +the historical generator bytes. diff --git a/benchmarks/results/tls_reference_2026-09-10/sources/README.md b/benchmarks/results/tls_reference_2026-09-10/sources/README.md new file mode 100644 index 00000000..05cf7ef4 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/sources/README.md @@ -0,0 +1,72 @@ +# Source and environment identities + +`scientific_sources.tar.gz` preserves the exact source bytes used to generate +the main and supplementary populations, instrument and compare the searches, +prove when the native correction is a no-op, execute the cohorts, and produce +their compact summaries. Its 17 members include the physical-signal helper +and the two stronger-control generator. `manifest.json` gives each member's +SHA256 and connects the archive to both unchanged study seals. + +`production_sources.tar.gz` is the complete cuvarbase source/test snapshot +checked before confirmation. `gpu_tests.json` records 265 passing TLS tests +with no failures, errors or skips and identifies that exact snapshot. The +independent per-case output receipts separately verify the production source +identity used by the scientific runs. + +The original scientific source archive is an audit trail. The maintained +[reproduction tools](../../../tls_reference/README.md) provide the portable +command-line workflow and record their own executing hashes. Their packaging +changes do not rewrite the original seals or create another independent +population. Development-only scripts, prototype engines, and internal draft +notes are not included; their historical identities remain visible where the +original seals recorded them. + +`generation_environment.json` distinguishes distribution versions from module +version strings. The original CPU environment was Python 3.9.6 on macOS 26 +arm64, NumPy 1.26.4, SciPy 1.12.0 and **batman-package 2.5.3**. That distribution +reports `batman.__version__ == "2.5.1"`; installing a package inferred from the +module string would not reproduce the recorded environment. Numerical input +regeneration was checked on that original environment. Cross-platform +floating-point equality is not assumed; the exact input bank supplies a +separate route that does not regenerate the physical signals. + +`input_restoration_proof.json` records an independent byte, dtype, shape and +metadata check for all 209 restored cases and their 1,463 numerical arrays. +The exact restorer and verifier sources are in +`input_restoration_sources.tar.gz`. The verifier retains its original local +paths as an audit artifact; use the maintained `inputs.py` command for a +portable replay. All numerical arrays match. The two stronger controls have +different NPZ container encodings after restoration, which does not change +any stored numerical value; their original and restored container hashes are +both recorded. + +The earlier 21-case development input manifest names a historical generator +version whose source bytes were not retained. Its numerical vectors and +original generator identity remain available, and its actual search/comparison +harness is included here. The maintained generator separately reproduced all +21 input arrays and NPZ container hashes on the original CPU environment. +This check does not recover the missing historical source version or make +those development cases independent evidence. Both long-period parents and +the two stronger controls have their exact generation source in this archive. + +`result_postprocessing_sources.tar.gz` preserves the selected-grid metric +packager, outcome-table generator and original collection auditors. These +checks extract saved results and authenticate recovered records; they do not +create new search measurements or reconstruct a scientific acceptance gate. +The main collection receipt distinguishes the nine uncollected output +containers from the 405 archives removed under the original retention rule. + +The supplemental collection audit authenticates its original 24-case acceptance, +all 72 records and 48 comparisons. Its nine uncollected retained NPZ containers +remain separate from 63 predeclared prunes. The stress packager explicitly +marks the three original solar-control output containers as uncollected and +leaves their unavailable truth-grid ranks blank; it preserves the original +failed comparison and does not substitute later diagnostic runs. +For the invalid-row fixture, it stores numerical and JSON identities for two +oversized transit-time lists instead of duplicating their values. Original +record hashes, all other output identities and comparisons remain unchanged. + +The later [focused repeatability diagnostic](../stress/diagnostic/README.md) +has its own exact source archive and compact raw vectors. Its repeated +executions investigate the retained solar-control failure; they do not add +independent scientific samples or modify either completed population gate. diff --git a/benchmarks/results/tls_reference_2026-09-10/sources/timing/README.md b/benchmarks/results/tls_reference_2026-09-10/sources/timing/README.md new file mode 100644 index 00000000..b44a97af --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/sources/timing/README.md @@ -0,0 +1,93 @@ +# Timing sources and reporting provenance + +`execution-sources.tar.gz` preserves the exact measurement driver, tools, tests +and protocol executed for this campaign. Its SHA-256 and member inventory are +in `execution-sources.json`. `protocol.json` is the unchanged full measurement +declaration. The recorded hardware and dependency files are in `environment/`; +`hardware-timing02-allocation.json` and `hardware.json` identify the successful allocation. + +The original all-configuration campaign failed because one optional GTLS pool +ran out of memory during warmup. `reporting-protocol.json` explicitly records +the subsequent post hoc scope. `report_completed.py` replays the exact original +audit, accounts for every attempt, checks all original prerequisites, adds full +returned-object repeatability and recomputes medians and pool selection. Its +42 CPU tests and their receipt are retained. The derived figure input remains +bound to the separate root `reporting_acceptance.json`; the failed original +acceptance is never replaced. + +`timing-origin-records.tar.gz` contains the exact accepted origin manifests and +compact records for the 48 timing inputs. Numerical vectors are restored from +the shared public input bank, avoiding a second input archive. +`collection-support.tar.gz` contains final collection receipts and supporting +snapshot files; the 43 original timing files live in `timing/`. Each archive +has a corresponding member/hash inventory. Previous preflight failures remain +in `failed-attempts/`, with no headline timing denominator. + +## Recompute the report with CPU tools only + +The following workflow was tested using only files in this repository. It +restores the original inputs, reconstructs the verified collection layout, and +runs the reporting assessor. It performs no search, GPU or cloud work. Use +NumPy and a new scratch directory from the repository root: + +```sh +python - <<'PY' +from pathlib import Path +import json +import shutil +import tarfile +from benchmarks.tls_reference import inputs + +published = Path('benchmarks/results/tls_reference_2026-09-10') +scratch = Path('tls-report-replay') +scratch.mkdir() + +def unpack(name, target): + target.mkdir() + with tarfile.open(published / 'sources/timing' / name) as archive: + for member in archive: + path = Path(member.name) + if not member.isfile() or path.is_absolute() or '..' in path.parts: + raise ValueError(member.name) + destination = target / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(archive.extractfile(member).read()) + +unpack('collection-support.tar.gz', scratch / 'checkpoint') +unpack('timing-origin-records.tar.gz', scratch / 'origin') +index = json.loads((scratch / 'checkpoint/file-index.json').read_text()) +prefix = 'timing-continuation/results/timing/' +for name, entry in index.items(): + if name.startswith(prefix): + source = published / 'timing' / name[len(prefix):] + if inputs.sha(source) != entry['sha256']: + raise ValueError(name) + destination = scratch / 'checkpoint/files' / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + +for study, manifest in [('main', 'validation/input_manifest.json'), + ('supplement', 'supplement/input_manifest.json')]: + inputs.restore_bank(published / 'inputs', study, published / manifest, + scratch / ('restored-' + study)) +manifest = scratch / 'origin/diagnostic/results/timing_manifest.json' +for case in json.loads(manifest.read_text())['cases']: + source = scratch / ('restored-' + case['study_id']) / case['file'] + if inputs.sha(source) != case['sha256']: + raise ValueError('Original input container differs: ' + case['file']) + shutil.copy2(source, manifest.parent / case['file']) +PY + +python benchmarks/tls_reference/timing/report_completed.py \ + --checkpoint tls-report-replay/checkpoint \ + --sources benchmarks/results/tls_reference_2026-09-10/sources/timing \ + --manifest tls-report-replay/origin/diagnostic/results/timing_manifest.json \ + --output tls-report-replay/assessed +``` + +The resulting `timing_analysis.json` must have SHA-256 +`d428e8aadc337678db1112c6a0adf5de1cdcc2c4a7b4fc8283ce1900f607bb1b`. +The reporting receipt has a fresh timestamp; its checked content is otherwise +identical. [public-replay-proof.json](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/sources/timing/public-replay-proof.json") records the +successful replay. To execute new measurements instead, follow the +[maintained timing workflow](../../../../tls_reference/timing/README.md). diff --git a/benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/README.md b/benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/README.md new file mode 100644 index 00000000..face9342 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/sources/timing/failed-attempts/README.md @@ -0,0 +1,6 @@ +These attempts are retained as failed timing preflights and supply no speed denominators. + +- `unmapped_host_pid`: the earlier single-source attempt rejected an NVML host PID absent from the visible container PID list before any warmup. Ownership of that PID was not established. +- `unexpected_gpu_process`: the revised full campaign proved empty-device/worker-birth/call/clean-exit ownership for candidate1 and native1/2. During native4 preflight, another GPU PID appeared and remained after the four owned workers exited. All sixteen returned case outputs matched their frozen search fingerprints, but the exclusivity gate failed. The process identity was not established. No headline repetitions or component measurements ran. + +The full campaign retry uses unchanged numerical sources and unchanged ownership, output and repetition gates. Exact execution-source and allocation receipts are preserved separately. diff --git a/benchmarks/results/tls_reference_2026-09-10/stress/README.md b/benchmarks/results/tls_reference_2026-09-10/stress/README.md new file mode 100644 index 00000000..d17ccc8a --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/stress/README.md @@ -0,0 +1,122 @@ +# Selected-grid numerical stress tests + +These 25 development fixtures test numerical behavior beyond the independent +full-grid population. **19/25** passed the complete corrected-GTLS/cuvarbase +comparison; **17/25** passed the untouched-GTLS comparison. Every original +outcome remains in `cases.csv` and `comparisons.json.gz`. The five expected +input/reference exceptions and one additional coarse-search discrepancy are +listed below; none is counted as an exact numerical success. + +The period grids contain the true period and aliases. These are mathematical +stress tests, not blind recovery measurements or population-level sensitivity +bounds. A shared missed signal establishes numerical agreement only. SDE is +descriptive on these selected grids; no SDE threshold is a pass criterion for +the two stronger controls. + +The fixtures include ordinary, high-impact and grazing transits, shortened +eccentric transits, dense M dwarfs, a compact star, heteroscedastic and correlated +noise, phase wrap and ties, large time origins, long periods, flat data and +invalid rows. Passband baselines are assumed removed and injected depths and +shapes are achromatic. Both APIs receive only `t`, `y` and `dy`. + +## Long-period examples and fixed-noise controls + +The four rows below use a 365.25-day signal on **77,888 observations** in eight +synthetic repetitions of an observed TESS campaign, spaced 180 days apart. +The baseline is 1,285.75675 days; four transit events are sampled. The solar +host has an approximately 7.98-hour transit and the 0.1-solar-mass/radius host +a 1.73-hour transit. Both use 97 selected periods spanning 0.6–730.5 days. + +| Host | White-noise oracle SNR | Selected period, days | Selection relative to truth | Corrected GTLS / cuvarbase | True-grid residual rank | +| --- | ---: | ---: | --- | --- | ---: | +| Solar | 10 | 547.875 | 3:2 alias | Exact | 25 | +| Dense M dwarf | 10 | 223.718542 | Wrong period | Exact | 26 | +| Solar | 20 | 121.75 | 1:3 alias | Final results exact; coarse discrepancy | Unavailable from collected original arrays | +| Dense M dwarf | 20 | 365.244890 | Fundamental; drift 0.25 transit widths | Exact | 2 | + +Untouched GTLS selects the same period in all four rows. The corrected-GTLS +SDEs are approximately 1.7620, 1.4813, 3.2434 and 3.6912, respectively. The +stronger controls preserve each original physical signal, period grid and +noise realization, halve the Gaussian **and** OU noise amplitudes, and halve +`dy`. The quoted oracle SNR excludes OU noise. These are two predeclared +controlled examples; they do not estimate blind long-period completeness. +The strict fundamental criterion is accumulated period drift no larger than +half the physical transit duration. Aliases are reported separately. + +The solar SNR-20 run differs at one coarse residual (absolute difference +`3.9872248e-8`) and its winning start/width, changing the coarse normalized +power. Its complete **final** corrected-reference spectra, public fields and +final fit match cuvarbase. This failed the original strict gate and stopped +the execution before timing; its receipt is preserved as a failed exact +comparison. It is not silently replaced by a later run. + +A separate [repeatability diagnostic](diagnostic/README.md) reproduces that +difference by changing only the shared float32 flux cumulative sum. Native +repeats can also change a depth-cutoff decision, mask and final SDE. Identical +intermediate inputs give identical native/fused window scores in the inspected +cases. All nine diagnostic runs retain the same selected alias and final fit; +the true period and alias tie for the minimum residual. These later repeats +do not turn the original failed comparison into a pass. + +## Explicit exceptions and retained evidence + +- Flat data: untouched GTLS returns period 0.6 with nonfinite SDE; the corrected + reference raises a division-by-zero error. cuvarbase returns no candidate. +- Invalid rows: both native variants clean the data; cuvarbase explicitly + rejects the supplied NaN. This tests API behavior rather than search parity. +- Three sparse/long-period inputs hit native zero-width cache construction + failures. cuvarbase omits unrepresentable cache rows and completes; these + are reference-unsupported extensions, not parity successes. + +The fixture named `compact_star_native_extension` actually completes and +matches both references; its retained name does not imply a native failure. +`source_identities.json` preserves the different development source versions. +The [source notes](../sources/README.md) disclose the unretained historical +21-case generator version; its exact numerical inputs remain in the bank. + +`array_digests.json.gz` preserves all 75 records' numerical identities and +compact derived metrics. Sixty-three available NPZ archives were independently +checked array by array. The M-control corrected archive was restored from its +byte-identical native archive after verifying the original no-op receipt and +expected container hash. The three solar-control output containers were not +collected; their original records and comparator hashes survive, and their +unknown truth-grid ranks are left unavailable. This collection loss is +separate from the observed coarse numerical discrepancy. + +The invalid-row fixture makes each native variant generate 534,984 predicted +transit times across its extreme input time span. Those two oversized lists +are represented by their float64 shape and numerical hash, plus the original +JSON-value hash, under `transit_times_identity`. Their original record hashes +remain unchanged. This compact representation removes duplicate diagnostic +output; it does not change any comparison or outcome. + +Restore inputs with [inputs.py](../../../tls_reference/README.md) using study +labels `selected_grid`, `long_period` or `stronger_controls` and the matching +original manifests in this directory. These fixtures have no independent +study seal; use the maintained per-case `validate.py run --replay --positive-origin` workflow. +The [source archive](../sources/README.md) includes the exact original search +instrumentation and the result packager. `files.json` hashes the original +compact stress files; `publication_files.json` additionally covers this README. + +For example, restore and rerun the M-dwarf control using new output directories: + +```sh +python benchmarks/tls_reference/inputs.py restore \ + --bank benchmarks/results/tls_reference_2026-09-10/inputs --study stronger_controls \ + --manifest benchmarks/results/tls_reference_2026-09-10/stress/stronger_controls_inputs.json \ + --out reproduced-controls +for backend in gtls gtls_corrected candidate; do + python benchmarks/tls_reference/validate.py run \ + --case reproduced-controls/dense_long_mdwarf_snr20.npz \ + --backend "$backend" --engine-root . --replay --positive-origin \ + --out "reproduced-control-results/$backend" +done +python benchmarks/tls_reference/validate.py compare \ + --reference reproduced-control-results/gtls_corrected/record.json \ + --candidate reproduced-control-results/candidate/record.json \ + --threshold 8 --out reproduced-control-comparison.json +``` + +The threshold comparison is recorded for transparency; it is not a selected-grid +recovery acceptance criterion. A rerun is regression evidence on the original +inputs and does not create another independent scientific sample. diff --git a/benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/README.md b/benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/README.md new file mode 100644 index 00000000..88848d99 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/README.md @@ -0,0 +1,130 @@ +# Cumulative-sum repeatability diagnostic + +The original solar SNR-20 discrepancy was reproduced and isolated to the +**float32 flux cumulative sum used by both implementations**. Changing only +that intermediate array reproduces the residual and winning-window difference. +Using identical intermediate inputs, the native and fused scoring kernels +agree bitwise on every inspected window and packed winner. The original +[failed comparison](../README.md) remains failed. + +This diagnostic repeats one existing development input on another RTX A6000: +77,888 observations, a 365.25-day signal, white-noise oracle SNR 20 and 97 +selected periods. The source, input arrays and search settings are unchanged. +Three uncaptured full searches per implementation are followed by two +instrumented searches and 30 scan repetitions per selected row. Instrumented +searches copy intermediate buffers and can change execution scheduling; they +remain separate from the uncaptured runs. No independent sensitivity samples +or performance measurements are added. + +## What changed, and what did not + +All 97 captured rows agree in phases, sorted observation indices, flux, +inverse variance, edge corrections and error cumulative sums. The flux +cumulative sums differ in 27 rows. Even the phase ties at period 0.6 days have +identical sorted indices in these captures. + +At period **1.05052417069 days**, two flux-prefix variants change the minimum +residual from `0.000522289890796` to `0.000522329763044`: the original absolute +difference `3.98722477257e-8`. The winning start changes from 76,728 to 72,520 +and width from 1,113 to 1,482 samples. Twenty-five evaluated trial windows +cross the depth cutoff. Both native scans and graph scans produce two variants +across the 30 repetitions. + +At period **0.704129632465 days**, a prefix variant changes a finite residual +`0.000522272545` into the `77888` no-valid-window sentinel. Thirty evaluated +depth-cutoff decisions change. Native full-search repeats consequently differ +in masks and final normalized spectra. This is not merely a harmless change +in the last displayed digit. + +| Implementation | Reported SDE across the three uncaptured runs | +| --- | --- | +| Untouched GTLS | 3.24657798, 3.23005271, 3.23005271 | +| Corrected GTLS | 3.24343755, 3.23427696, 3.24343755 | +| cuvarbase | 3.24343755, 3.24343755, 3.24343755 | + +All nine runs select **121.75 days**, the one-third alias, and have identical +shared final-fit fields. The injected 365.25-day period and selected alias +tie for the minimum residual in every run, both before and after refinement: +`0.000520125613548` and `0.000520052679349`, respectively. Both have rank one +when ties share a rank. This identifies a represented true-period peak and an +unresolved alias; it does not establish unique fundamental-period recovery. +The original uncollected truth-rank cells remain separate from these new +repeated-run measurements. + +cuvarbase's three full runs repeat exactly here, but its standalone graph +scans can also vary. This result does not establish universally deterministic +cuvarbase output, harmless cumulative-sum rounding, or equivalent sensitivity +at every threshold. It identifies inherited scan/depth-cutoff sensitivity on +this long input. The independent 160-case population and separate 24-null +population retain their own completed gates. + +## Evidence and reproduction + +`runs.csv` gives each repeat's period, SDE, truth rank and residual tie. +`summary.json` records the prefix-only interventions and explicit limits. +`records.tar.gz` preserves all original compact JSON/log files, including +every small numerical vector as dtype, shape and hexadecimal raw bytes. +All 79 original inventory files were collected and hash-verified. The 17 +larger or redundant NPZ files remain outside the repository; their identities +are retained, and the public compact vectors reproduce their small arrays +exactly. The whole diagnostic package is approximately 330 KB. + +`execution-sources-v3.tar.gz` contains the exact frozen probe, expected source +identities, protocol and CPU tests. Its unchanged source inventory is +`source_manifest.json`; version 3 is the executed protocol. The two earlier +protocol versions were amended before execution and contain no measurements. +`postprocessing_sources.tar.gz` preserves the independent decoder and this +result packager. `environment.json` and `dependencies.txt` record the execution +environment. The reported elapsed time includes instrumentation and is not a +benchmark. + +For a GPU rerun, first install the [recorded search environment](../../../../tls_reference/README.md#reproduce-the-numerical-comparison). +From the repository root, stage the frozen sources in a new workspace: + +```sh +mkdir -p reproduced-prefix-diagnostic/candidate reproduced-prefix-diagnostic/frozen +tar -xzf benchmarks/results/tls_reference_2026-09-10/sources/production_sources.tar.gz \ + -C reproduced-prefix-diagnostic/candidate +tar -xzf benchmarks/results/tls_reference_2026-09-10/sources/scientific_sources.tar.gz \ + -C reproduced-prefix-diagnostic/frozen +cp -R reproduced-prefix-diagnostic/frozen/main reproduced-prefix-diagnostic/validation +tar -xzf benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/execution-sources-v3.tar.gz \ + -C reproduced-prefix-diagnostic +python benchmarks/tls_reference/inputs.py restore \ + --bank benchmarks/results/tls_reference_2026-09-10/inputs --study stronger_controls \ + --manifest benchmarks/results/tls_reference_2026-09-10/stress/stronger_controls_inputs.json \ + --out reproduced-prefix-diagnostic/inputs +``` + +The restorer verifies every original numerical array. Its NPZ container +encoding can differ, so bind a **reproduction-only** expectation to that new +container while preserving all original code identities and the original +expectation file: + +```sh +python - <<'PY' +import hashlib, json +from pathlib import Path + +root = Path("reproduced-prefix-diagnostic") +original = root / "diagnostic/expected.json" +restored = root / "inputs/dense_long_solar_snr20.npz" +expected = json.loads(original.read_text()) +expected["reproduction"] = { + "original_expectation_sha256": hashlib.sha256(original.read_bytes()).hexdigest(), + "original_input_container_sha256": expected["input_sha256"], + "restoration_receipt_sha256": hashlib.sha256((root / "inputs/reproduction.json").read_bytes()).hexdigest(), + "scope": "Same bank-verified arrays; another execution is not independent evidence", +} +expected["input_sha256"] = hashlib.sha256(restored.read_bytes()).hexdigest() +(root / "replay-expected.json").write_text(json.dumps(expected, indent=2) + "\n") +PY +python reproduced-prefix-diagnostic/diagnostic/long_control_probe.py \ + --root reproduced-prefix-diagnostic \ + --input reproduced-prefix-diagnostic/inputs/dense_long_solar_snr20.npz \ + --expected reproduced-prefix-diagnostic/replay-expected.json \ + --output reproduced-prefix-diagnostic/results --max-seconds 180 +``` + +The rerun preserves the original failed gate. Different scan variants or +hardware can produce different diagnostic outcomes; each must remain visible. diff --git a/benchmarks/results/tls_reference_2026-09-10/supplement/README.md b/benchmarks/results/tls_reference_2026-09-10/supplement/README.md new file mode 100644 index 00000000..64f3e06f --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/supplement/README.md @@ -0,0 +1,43 @@ +# Supplementary null population + +All **24 of 24** separately predeclared null inputs +passed the corrected-GTLS/cuvarbase numerical and public-result comparison. +Untouched GTLS matched on **24 of 24** inputs. +This population has its own unchanged manifest, seal, stream and acceptance +receipt; it is not merged into the main 160-case confirmation counts. + +Each regime supplies `null_0008` through `null_0015`. Together with the main +study's eight nulls, these define 16 distinct noise-only inputs per timing +regime. These inputs support the planned single-source and 16-source batch +timing campaign. The supplementary numerical comparisons do not themselves +measure latency or throughput. + +| Regime | Native / corrected / cuvarbase nulls above SDE 8 (of 8) | +| --- | ---: | +| TESS, ordinary solar | 1 / 1 / 1 | +| Gapped TESS, ordinary solar | 4 / 4 / 4 | +| ZTF, ordinary solar | 7 / 7 / 7 | + +SDE 8 is descriptive and uncalibrated. `strata.csv` retains each regime's exact +binomial interval, discordance bound and any failures. The methods' numerical +agreement does not imply that this threshold has the same false-positive rate +on different cadences. See the [main confirmation](../validation/README.md) +for the shared numerical endpoints, noise model, correction and limitations. + +The original input arrays are in the compact bank. The +[timing reproduction route](../../../tls_reference/timing/README.md#reproduce-the-published-timing-cohort) +restores both populations and preserves their separate provenance through +validation and timing. `array_digests.json.gz` retains complete numerical +identities and the correction/no-op evidence; `validation.json` documents +verification and the original retention rule. + +This population ran on a separate RTX A6000 host; the main confirmation used +an A40. Its original acceptance and complete compact results were recovered +unchanged after the subsequent development-control gate stopped the pipeline. +All 72 raw records and 48 comparisons were independently verified. Nine +retained output NPZ containers were not collected; their complete numerical +identities and prior on-host verification survive. The other 63 archives had +already been removed under the original retention rule. These collection +losses and the later control outcome do not alter this separately sealed +24-case gate. [Collection details](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/supplement/collection.json") and +[execution environment](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/supplement/execution_environment.json") keep those scopes explicit. diff --git a/benchmarks/results/tls_reference_2026-09-10/timing/README.md b/benchmarks/results/tls_reference_2026-09-10/timing/README.md new file mode 100644 index 00000000..a71f69b8 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/timing/README.md @@ -0,0 +1,80 @@ +# TLS timing results + +On the recorded RTX A6000 allocation, cuvarbase's observation-level TLS search +had **3.6–4.6× lower single-source latency** and **1.5–2.4× better throughput** +than the fastest eligible tested GTLS pool on 16 distinct noise-only inputs. +These are warm, complete public API calls. The [figure data](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing_analysis.json") +is bound to the separate [reporting assessment](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/reporting_acceptance.json"). + +| Cadence | Single cuvarbase / GTLS (s) | Single speedup | Batch cuvarbase / GTLS (s/source) | Batch speedup | GTLS batch workers | +| --- | ---: | ---: | ---: | ---: | ---: | +| Dense TESS | 0.149 / 0.533 | 3.58× | 0.161 / 0.319 | 1.98× | 4 | +| Gapped TESS | 1.554 / 6.037 | 3.88× | 1.534 / 3.684 | 2.40× | 2 | +| Sparse ZTF | 3.231 / 14.899 | 4.61× | 3.116 / 4.545 | 1.46× | 4 | + +Each single-source median uses five repetitions of the fixed null0000 input. +Each batch median uses three repetitions of the same 16 distinct null inputs +for every method and pool. Both methods use one worker for the single-source +comparison; cuvarbase uses one worker for batches, while GTLS pools of 1, 2 and +4 are tested. Pool selection uses the lowest eligible batch median. Batch times +are divided by the actual 16 returned sources. No extrapolated survey scaling +or additional worker multiplier enters the denominator. + +The allocation had one **NVIDIA RTX A6000**, **7.65 CPU cores of cgroup quota** +on an Intel Xeon Gold 6342 host, and one numerical library thread per worker. +The [hardware receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/sources/timing/hardware.json") preserves the actual +GPU identity, quota and hourly bundle rate. Startup and full warmup are recorded +separately. Timed calls include construction, validation, cache creation, full +search, final fit and completed GPU work. File loading, supplied period-grid +generation and result hashing are outside the measured interval. + +## A failed configuration remains excluded + +The [original full campaign acceptance](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing/acceptance.json") is **failed**. Gapped +TESS with four GTLS workers ran out of GPU memory during warmup while requesting +an additional 1,623,613,440-byte array. It completed no measured repetitions. +Its [complete failure record](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing/public/tess_gap/gtls_graph_4worker/record.json") +remains present; no elapsed time from this failure enters a speed ratio. + +After observing that failure, a separately labeled **post hoc reporting +assessment** retained only complete comparisons. All 12 planned configurations +are terminal and accounted for; 11 completed. The original campaign rejection +and [original normalized output](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing/timing_analysis.json") remain unchanged. The +separate assessment replays the original final audit, requires every other +original prerequisite, verifies all source/input/ownership receipts, and checks +complete returned-object stability as well as the original frozen search +fingerprints. It does not rerun measurements or relax numerical rules. + +The included data contain 30 measured single calls and 33 measured batches, +covering 558 complete returned objects. The underlying numerical study is +separate: 160 independent cases and 24 additional nulls. These timing receipts +do not establish a universal recovery or false-positive margin. All 48 timing +inputs have a trace proving the disclosed native host correction is a no-op, +so the conditional extra corrected-native timing was unnecessary. + +## Where elapsed time goes + +Separate instrumented calls measure the search through final window selection. +The native endpoint follows the final GPU argmin; cuvarbase's endpoint also +includes transfer of its compact winner fields. These identify the same search +stage, with a small difference in endpoint scope. They are not headline public +API denominators. + +| Cadence | cuvarbase common search (s) | GTLS common search (s) | Search speedup | GTLS after-search work (s) | +| --- | ---: | ---: | ---: | ---: | +| Dense TESS | 0.167 | 0.463 | 2.77× | 0.110 | +| Gapped TESS | 1.563 | 6.249 | 4.00× | 0.076 | +| Sparse ZTF | 3.086 | 16.181 | 5.24× | 0.313 | + +All 30 instrumented outputs match their own complete literal API result and +frozen search fingerprint. GTLS's later physical and per-transit SNR/pink-noise +diagnostics explain part of its public-call cost. The common-search comparison +shows that a substantial improvement remains before that work. Stage timings +are inclusive and can overlap; separately computed medians need not add to the +median total. Raw records retain the individual repetitions and stage values. + +[raw-files.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/timing/raw-files.json") inventories all 43 original timing files. +[Source and CPU replay instructions](../sources/timing/README.md) reproduce the +reporting assessment from the public evidence without a GPU. Earlier failed +preflight attempts are retained under +[failed-attempts](../sources/timing/failed-attempts/README.md). diff --git a/benchmarks/results/tls_reference_2026-09-10/validation/README.md b/benchmarks/results/tls_reference_2026-09-10/validation/README.md new file mode 100644 index 00000000..084cd654 --- /dev/null +++ b/benchmarks/results/tls_reference_2026-09-10/validation/README.md @@ -0,0 +1,83 @@ +# Independent full-search confirmation + +All **160 of 160** planned inputs passed the exact +corrected-GTLS/cuvarbase numerical and public-result checks. The untouched +GTLS comparison passed on **151 of 160** inputs. +`acceptance.json` records the completed gate, source seal, counts and limits; +`comparisons.json` retains the per-case differences. + +Each of eight regimes contains three independent injections at each white-noise +oracle SNR 6, 8, 10 and 12, plus eight independent nulls. The full period arrays +come from the pinned native grid arithmetic at oversampling 3; no true period +was inserted. Both searches received identical arrays with a common positive +time origin and the same search bounds. Separate tests cover automatic-grid +dispatch. The injections use exposure-integrated physical transit signals; +nulls independently draw from the same four latent noise recipes. Their +additional OU noise is excluded from the quoted white-noise oracle SNR. +Recovery is conditional on at least five in-transit observations and two +sampled events; every proposal count is retained in the input manifest. +Passband baselines are assumed already removed. Transit depths and shapes are +achromatic, and both APIs receive only `t`, `y` and `dy`; retained band labels +do not imply a fitted multiband model. + +The following outcomes use the predeclared, **uncalibrated SDE threshold 8**. +Recovery additionally requires period drift over the observed baseline to be +no more than half the physical transit duration. These counts are descriptive; +they do not establish a common false-positive rate or useful recovery in every +regime. The order in each cell is untouched GTLS / corrected GTLS / cuvarbase. + +| Regime | Recovered / 12: native / corrected / cuvarbase | Nulls above 8 / 8: native / corrected / cuvarbase | +| --- | ---: | ---: | +| TESS, ordinary solar | 1 / 1 / 1 | 0 / 0 / 0 | +| TESS, high impact solar | 3 / 3 / 3 | 0 / 0 / 0 | +| TESS, eccentric solar | 4 / 4 / 4 | 0 / 0 / 0 | +| TESS, 0.1 solar mass/radius | 8 / 8 / 8 | 0 / 0 / 0 | +| ZTF, ordinary solar | 8 / 8 / 8 | 8 / 8 / 8 | +| ZTF, high impact solar | 8 / 8 / 8 | 8 / 8 / 8 | +| ZTF, 0.1 solar mass/radius | 6 / 6 / 6 | 8 / 8 / 8 | +| Gapped TESS, ordinary solar | 6 / 6 / 6 | 4 / 4 / 4 | + +`strata.csv` separates all four injection SNR levels and the null mixture in +each regime, retains failures, and supplies exact binomial intervals and +simultaneous discordance bounds. Three injections per SNR and eight nulls per +regime cannot establish a one- or two-percentage-point population margin. +The primary evidence is identical complete numerical searches, supplemented +by these recovery/null cross-checks; a shared miss is not a successful detection. + +The corrected reference filters masked/nonfinite candidates before the native +first candidate sort. Native CUDA kernels, templates and scoring formulas are +unchanged. Literal GTLS outcomes remain separate. 151 +corrected executions were reused only after a complete trace proved that the +correction was a no-op. All nine differing literal searches selected the same +primary period and made the same strict SDE > 8 decision as the corrected +reference and cuvarbase. Their final power spectra and reported SDE values +differ as well as intermediate arrays; all these differences are preserved. +Fitted native SNRs remained identical in those nine cases. +See the [correction explanation](../../../../docs/GTLS_COMPARISON.md#invalid-candidate-correction). + +`array_digests.json.gz` contains every retained numerical-output identity, +dtype, shape, compact fit result, record hash and no-op trace. Full NPZ outputs +were compared before the predeclared retention step. `validation.json` +records verification of the retained archives and explicit receipts for the +matching archives removed after comparison. Large output tensors stay outside +the repository; their complete numerical identities remain in this archive. + +The published primary receipts come from a complete reexecution after an +earlier run's result collection failed. All 160 original inputs, seeds, source +hashes, settings, sample counts and gates remained unchanged. Earlier +uncollected output reports are not counted as completed evidence, and repeated +executions are not additional independent samples. The completed main search, +its original acceptance and its on-host compact verification were recovered +from complete members of a truncated final download. Every original record, +comparison and numerical-output digest survived. Of 75 retained NPZ output +archives, 66 were recovered and independently checked; nine were not collected. +Those nine are disclosed collection losses, not predeclared pruning. +The separate 405 matching output archives had already been removed under the +original retention rule. [Collection details](../../../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/validation/collection.json") preserve the +original acceptance hash, recovery evidence and missing-container identities. + +Restore the exact inputs and replay the comparison with the +[maintained tools](../../../tls_reference/README.md). The +[source archive](../sources/README.md) preserves the exact executed scientific +code and distinguishes the original generation environment from the GPU +execution environment. `files.json` hashes the original compact result files. diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/.gitattributes b/benchmarks/results/tls_sensitivity_2026-09-09/.gitattributes new file mode 100644 index 00000000..6e2c2f99 --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/.gitattributes @@ -0,0 +1,2 @@ +* -text +*.csv whitespace=cr-at-eol diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/HATPI.md b/benchmarks/results/tls_sensitivity_2026-09-09/HATPI.md new file mode 100644 index 00000000..c072e3e9 --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/HATPI.md @@ -0,0 +1,25 @@ +# HATPI cost pilot + +A study with the same 4,096 calibration nulls, 2,048 injections and 4,096 independent test nulls per method projects to **$27.29 of GPU search time at native 30-second cadence**, or **$3.46 after five-minute time averaging**, for three cuvarbase TLS grids plus GTLS. The secondary BLS control adds about $0.03 or $0.006 respectively. This is a pricing pilot, not a HATPI sensitivity result. + +The synthetic example has **102 clear eight-hour nights within a 196-day season**, 195.33 days between the first and last observations, **97,920 measurements per source**, and **17,377 common trial periods from 0.6 to 12 days**. Five-minute averages reduce each source to **9,792 measurements**. Two injected transits and two nulls are retained; all ten configurations returned valid API outputs. + +[HATPI's specifications](https://hatpi.org/about) give 30/45-second exposures and a broad optical band. The observing nights, fluxes and errors here are wholly simulated. No observed HATPI lightcurve was available from the authenticated data service during this experiment, so these numbers must not be described as measured HATPI survey performance. + +| Search | Native 30-second data: time / source | Five-minute averages: time / source | +|---|---:|---:| +| BLS v1 | 21.19 ms | 4.58 ms | +| TLS original grid | 65.60 ms | 5.88 ms | +| TLS intermediate grid | 48.95 ms | 31.83 ms | +| TLS fine grid | 0.235 s | 0.209 s | +| Public GTLS, one worker | 19.231 s | 2.232 s | + +The pilot uses an A40 and the same prepared-array API boundary as the TLS study. Its software and CPU-quota context are recorded in [hatpi_analysis.json](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/hatpi_analysis.json"); a separate CPU-model snapshot was not retained for this pilot. cuvarbase uses three warmed four-source batch repetitions. To bound pilot cost, GTLS uses three distinct single-source calls after a first-source warmup. The GTLS sample includes one injection and two nulls; the cuvarbase batch includes two of each. Full [records](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived directory: benchmarks/results/tls_sensitivity_2026-09-09/hatpi-cost"), [timing ranges](hatpi_timing.csv), initialization and first-call values are retained. These small, differently aggregated samples support rough pricing, not an apples-to-apples headline speed ratio or an established recovery match. + +For planning, allow roughly **$30–40 for the native-cadence experiment** or **$5–10 for the five-minute experiment**, including room for setup and generation. The measured search projections use `$0.49/hour × 10,240 cases × sum of four methods' seconds per source / 3,600`. More seasons, a different period grid, real residual noise, different GTLS memory behavior or extra BLS competitors can change the price. A fixed budget does not guarantee a sensitivity conclusion. + +Five-minute time averaging combines adjacent observations once before searching. Phase binning happens separately for every trial period inside TLS. Time averaging can erase information from short ingress, narrow transits and other fast variability; both preprocessing choices would need inclusion in a future recovery test. It cannot be assumed harmless because its timings are cheaper. + +HATPI's high observation count increases folding, sorting and per-observation work. Its one-season period grid here is much shorter than the long-baseline ZTF grid, reducing GTLS's per-period host overhead. Those effects pull relative timing in different directions. Even bin-count cost is not universally monotonic: the intermediate TLS grid was faster than the automatic grid on the native pilot, while the fine grid was slower. This pilot did not profile the cause of that difference. + +A full HATPI study would first need an observed cadence and a frozen choice of native versus time-averaged inputs. [Generator and worker](../../tls_sensitivity/hatpi_cost.py) · [Verified timing arithmetic](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/hatpi_analysis.json") · [Combined experiment rental ledger](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/rental-ledger.json"). diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/METHODS.md b/benchmarks/results/tls_sensitivity_2026-09-09/METHODS.md new file mode 100644 index 00000000..ba7c3572 --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/METHODS.md @@ -0,0 +1,67 @@ +# Independent TLS study: methods and scope + +The question is whether cuvarbase can search faster while retaining recovery within a stated tolerance at independently calibrated false-alarm thresholds. It is a comparison of complete numerical searches, not a claim that cuvarbase and GTLS implement identical computations. [design.json](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/design.json") records the frozen settings, seeds, counts, decision rule and amendments. + +## Observations and injections + +| Cadence example | Original observations | Baseline | Common TLS trial periods | Period range | +|---|---:|---:|---:|---:| +| TESS sector 67, 200-second exposures | 9,736 | 25.76 days | 3,084 | 0.6003–12.8784 days | +| TESS sectors 1 and 27, 30/10-minute exposures | 4,295 | 734.85 days | 99,043 | 0.6000–27.4579 days | +| ZTF g/r, sparse seasonal sampling | 1,317 | 2,743.77 days | 312,064 | 0.6000–10 days | + +The [cadence manifest](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/cadences/manifest.json") identifies the original files and their hashes in the [earlier evidence archive](../transit_2026-09-08/ARCHIVE.md). These are three observed cadence examples, including a deliberately separated pair of TESS sectors. They are not random samples of their surveys. The long TESS gap matters computationally: maintaining transit alignment over a longer baseline requires a finer period grid. + +Each cadence has 4,096 calibration nulls, 2,048 independent injections and 4,096 independent test nulls. Injections are balanced at white-noise oracle SNR 6, 8, 10 and 14, with 512 at each level. This SNR describes the injected signal and white uncertainties; it is neither native SDE nor a correlated-noise significance estimate. + +Fluxes are newly simulated on the retained observing times. Each case independently drops 0–3% of observations. Periods are log-uniform from 0.8 days to the smaller of 12 days and 0.8 times the search maximum. Radius ratios are 0.025, 0.05 or 0.10; impact parameters are uniform from 0 to 0.85. The exposure-integrated batman model uses seven sub-exposures, solar stellar density, circular orbits and quadratic limb darkening `[0.4804, 0.1867]`. Draws must contain at least five in-transit observations and two observed events; rejection counts are retained. + +Noise combines heteroscedastic independent Gaussian errors and an Ornstein–Uhlenbeck residual with amplitude 0.25 times the median uncertainty and correlation time 0.15 days for TESS or one day for ZTF. The noise-scale mixture is also used for nulls. Known unit band baselines and achromatic transits are supplied. This tests controlled recovery conditional on observability, not real-flux survey completeness, chromatic modeling or a complete QLP pipeline. + +## Search definitions and execution + +Numerical sources are cuvarbase [`1032caf`](https://github.com/johnh2o2/cuvarbase/tree/1032caf029570dc4841db1c594a2cbb1654e8fd8) and public GTLS [`74e449c`](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111). [Source verification](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/source-verification.json") checks all 69 installed cuvarbase files and 19 GTLS files against their Git archives. The [search dependency pins](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/requirements-search.txt") describe the Python 3.11 / CUDA 12.4 environment; [analysis versions](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/analysis-environment.json") are recorded separately. GTLS's source installation omitted its CUDA resource files; the pinned, unmodified `.cu` files were copied into the installed package. UTF-8 locale and the CUDA library path were set explicitly. + +All methods receive byte-identical observations and trial periods within a case. cuvarbase searches durations 0.5–2 times the central circular duration, with these predeclared alternatives: + +| Configuration ID | Display name | Phase bins | Epoch oversampling | Durations | +|---|---|---:|---:|---:| +| `v1_original` | Original | Automatic, 256–1,024 here | 4 | 16 | +| `v1_resolved` | Intermediate | 4,096 | 8 | 16 | +| `v1_fine` | Fine reference | 8,192 | 16 | 32 | + +All retain top-50 observation-level refinement. The fine reference is not assumed exact. [The numerical explanation](../../../docs/TLS_NUMERICS.md) distinguishes compression, epoch sampling, duration sampling and refinement. + +GTLS uses public fast mode, `duration_grid_step=1.1`, `T0_fit_margin=0.125`, stellar-radius bounds 0.5–2 solar radii and a fixed solar mass. These approximately align the physical search window, but the actual duration/epoch grids and objectives differ. Its selected recovery schedules use one worker for dense TESS and two for separated TESS and ZTF, on one A40 per shard. + +Old-input probes exposed GPU memory failures with four concurrent ZTF GTLS calls. The corrected policy uses two workers and releases unused CuPy memory-pool blocks before and after successful calls through the [public CuPy API](https://docs.cupy.dev/en/stable/user_guide/memory.html). This changes client memory management; GTLS's numerical source is unmodified. All initial four-worker ZTF calibration results were superseded and recomputed. This amendment preceded generation or inspection of the new held-out cohorts. Remaining failures are retained, not removed. + +Four-case old-input probes were repeated on 20 additional GPUs. [Their records](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/probe-records.json.gz") and [identical input files](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived directory: benchmarks/results/tls_sensitivity_2026-09-09/probes") support the [cross-node comparison](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/cross-node-probes.json"): primary periods agree; dense-TESS and ZTF scores agree exactly in those probes; separated-TESS scores differ by at most 0.00941 native SDE. GTLS's memory-dependent chunking means this does not guarantee universal bitwise repeatability. + +## Calibration and statistical decision + +Each method/cadence has its own threshold: the higher empirical 95th percentile of its 4,096 calibration-null scores, with strict exceedance. [The freeze receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/calibration-freeze.json") records the threshold file's hash before held-out outcomes were examined. Native SDE values are not equated across algorithms. + +A detection must exceed its threshold and return a primary period whose accumulated phase drift over the full cadence baseline is at most half the injected duration. Half/double/third-period aliases are recorded separately. Failed injections count as misses; failed null scores are minus infinity. Partial spectra and all execution failures are reported. + +[Execution outcomes](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/execution-outcomes.json") count invalid candidates and masked trial periods separately for calibration, injections and test nulls. The original ZTF attempts superseded by the pre-test memory-policy amendment are excluded from the frozen cohorts. + +For each of nine v1-setting/cadence comparisons with GTLS, require a lower confidence bound on the recovery difference greater than −5 percentage points, and both false-positive difference bounds inside ±2 points. Paired discordant-cell Clopper–Pearson bounds use `alpha = 0.05/27` per primary one-sided difference bound, divided between its two cell bounds. This accounts for the nine recovery lower bounds and 18 false-positive bounds together. Marginal Wilson intervals, per-SNR counts and nominal diagnostic contrasts are reported separately. + +The target population is the equally weighted four-SNR mixture. Passing means supported noninferiority within the stated margins on these cases at the nominal 5% false-alarm operating point. It does not establish exact equality, a per-SNR guarantee, performance at every detection threshold or unconditional survey sensitivity. The main figure may select the fastest predeclared setting that passes for each cadence; all three settings remain reported. If none passes, the timing remains explicitly unqualified. + +The secondary BLS control uses the same observations and TLS-restricted period grid, `noverlap=4`, `qmin_fac=0.5`, `qmax_fac=2` and `dlogq=0.1`, with its own null calibration. It compares complete searches, including different ranking statistics; it does not isolate template shape or replace the earlier BLS competitor benchmark. A box's optimal width can be shorter than a transit's contact duration; the earlier BLS benchmark includes separately tuned duration bounds. Secondary contrasts are nominal and outside the primary decision family. + +## Timing and retained evidence + +Final timings run sequentially on one otherwise idle A40, with randomized configuration order, explicit synchronization, workload warmup and five repetitions. The fixed earlier-data subset contains eight injections, two per SNR, and eight nulls. Single-source latency averages 16 separate API calls per repetition; batch throughput divides a 16-source call by 16. GTLS single-source latency uses one worker and is contextual when recovery was calibrated for a concurrent batch schedule. + +Timed primary periods remain unchanged versus workload warmup. The largest score change is below 0.00001 for cuvarbase, 0.04511 for concurrent GTLS on separated TESS and 0.08654 for concurrent GTLS on ZTF. Single-worker GTLS scores are unchanged in these repetitions. The [timing analysis](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.json") reports each configuration separately. + +The boundary starts at prepared host observations and an explicit grid and ends with host periodograms and native candidates/scores. It includes transfers and API postprocessing. Imports, context initialization, grid creation, synthetic data generation, disk I/O, preprocessing and vetting are excluded; initialization and first API calls are retained separately. Disk caches may already be populated. The injection/null mixture is a controlled timing workload, not a survey occurrence-rate model. + +Distributed recovery timings are diagnostic only. They never enter the headline speed ratios. Cost projections use measured throughput and the recorded $0.49/hour A40 bundle; they are search-stage projections, not measured million-source runs or a survey's full bill. + +The compact evidence deduplicates input truth and source maps while preserving all scalar search records and hashes. The exporter verifies all prepared input arrays and the retained spectra for the first four cases per shard against the larger measurement archive. Other spectra were hashed during execution and discarded. Published receipts distinguish those original byte checks from summary-only reanalysis; omitted arrays cannot be re-verified from the compact checkout alone. + +[The execution-source archive](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/execution-harness.json.gz") stores exact UTF-8 harness sources indexed by SHA256. It includes both adapter revisions and both runner revisions found in the records. The adapter added opt-in cache release; the runner later corrected a local module-lookup collision and supplied BLS's missing validity flag. Primary GPU workers always loaded the intended generator, and TLS already supplied that flag. Numerical package sources stayed fixed. The maintained tools provide the portable reproduction interface; the archive preserves the bytes actually executed. diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/README.md b/benchmarks/results/tls_sensitivity_2026-09-09/README.md new file mode 100644 index 00000000..9d486d4d --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/README.md @@ -0,0 +1,116 @@ +# Independent TLS recovery and timing study + +This 9 September 2026 study measures the earlier phase-binned TLS engine, +retained as `method='binned'`. Its tables and figures remain historical +evidence. The [current transit report](../../../docs/TRANSIT_BENCHMARKS.md) +covers the observation-level default and current release claims. + +The predeclared recovery / false-positive matching criterion passes for **separated TESS with the original grid** and **dense TESS with the fine grid**. The ZTF comparison remains inconclusive under the strict two-sided false-positive margin. Every v1 setting passes the recovery-loss bound on every cadence. + +These results support bounded, workload-specific comparisons of complete searches. They do not establish identical algorithms or exactly equal detection sensitivity. The study uses 4,096 calibration nulls, 2,048 independent injections and 4,096 independent test nulls per cadence, with four primary methods: **122,880 search outcomes**. The secondary BLS control adds 30,720 outcomes. + +## Speed at the predeclared decision + +| Cadence | Displayed v1 grid | v1 batch time / source | GTLS batch time / source | GTLS / v1 | Recovery + false-positive match | +|---|---|---|---|---|---| +| TESS 200 s | Fine | 37.4 ms | 0.446 s | 11.9× | Pass | +| Separated TESS sectors | Original | 26.4 ms | 4.64 s | 175.5× | Pass | +| ZTF g/r | Original | 67.9 ms | 10.6 s | 155.6× | Inconclusive | + +The historical combined timing figure (`docs/figures/transit_benchmarks_20260909.png`, preserved in the [original Git history](../../../docs/BENCHMARK_ARCHIVES.md#original-git-history)) used the fastest predeclared passing binned setting for each TESS cadence. ZTF retained an explicitly unqualified original-grid timing. These settings superseded the earlier 93–284× binned-TLS headline; they do not measure the current observation-level default. BLS competitor measurements remain in the [earlier experiment](../transit_2026-09-08/README.md). + +## Independent detection results + +A detection requires the primary period to align the injected transits over the full observing baseline and a native score above the independently frozen null threshold. Native SDE values are never equated between packages. Counts pool an equal mixture of white-noise oracle SNR 6, 8, 10 and 14; [per-SNR results](recovery_by_snr.csv) show the individual strata. + +| Cadence | Method / grid | Detected injections | False positives | Invalid injections / nulls | +|---|---|---|---|---| +| TESS 200 s | Original | 881/2,048 (43.02%) | 201/4,096 (4.91%) | 0 / 0 | +| TESS 200 s | Intermediate | 898/2,048 (43.85%) | 203/4,096 (4.96%) | 0 / 0 | +| TESS 200 s | Fine | 910/2,048 (44.43%) | 215/4,096 (5.25%) | 0 / 0 | +| TESS 200 s | GTLS | 886/2,048 (43.26%) | 232/4,096 (5.66%) | 0 / 0 | +| Separated TESS sectors | Original | 1137/2,048 (55.52%) | 188/4,096 (4.59%) | 0 / 0 | +| Separated TESS sectors | Intermediate | 1165/2,048 (56.88%) | 179/4,096 (4.37%) | 0 / 0 | +| Separated TESS sectors | Fine | 1172/2,048 (57.23%) | 175/4,096 (4.27%) | 0 / 0 | +| Separated TESS sectors | GTLS | 1118/2,048 (54.59%) | 195/4,096 (4.76%) | 0 / 0 | +| ZTF g/r | Original | 1620/2,048 (79.10%) | 201/4,096 (4.91%) | 0 / 0 | +| ZTF g/r | Intermediate | 1629/2,048 (79.54%) | 210/4,096 (5.13%) | 0 / 0 | +| ZTF g/r | Fine | 1626/2,048 (79.39%) | 202/4,096 (4.93%) | 0 / 0 | +| ZTF g/r | GTLS | 1546/2,048 (75.49%) | 235/4,096 (5.74%) | 12 / 32 | + +All three original-grid cuvarbase false-positive rates are lower than GTLS's observed rates. The dense-TESS and ZTF original-grid comparisons miss the two-sided matching rule because their lower confidence bounds extend beyond −2 percentage points. That is uncertainty about how much *lower* cuvarbase's false-positive rate could be, not evidence of an excess of false positives or an established recovery loss. The fine grid's dense-TESS pass does not prove the coarse grid is scientifically inadequate. + +GTLS has **34/4,096 calibration failures, 12/2,048 injection failures and 32/4,096 test-null failures on ZTF** after the documented memory-policy amendment; all are retained. Other primary configurations have no invalid API outcomes. Failed injections count as misses and failed null scores as minus infinity. Some valid cuvarbase TESS outputs mask individual trial periods with no admissible fit; partial-spectrum counts are in [recovery_analysis.json](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/recovery_analysis.json"). These differ from missing candidates or failed API calls. + +## Confidence bounds and decision rule + +All differences below are **cuvarbase minus GTLS, in percentage points**. Require the recovery lower bound to exceed −5 and both false-positive bounds to lie inside ±2. The bounds account jointly for all 27 predeclared one-sided checks; the [methods](METHODS.md) give the construction. No threshold, sample size, setting or primary criterion was changed after viewing the new test outcomes. + +| Cadence | v1 grid | Recovery difference | Recovery lower bound | False-positive difference | False-positive bounds | Joint decision | +|---|---|---|---|---|---|---| +| TESS 200 s | Original | -0.24 | -2.26 | -0.76 | [-2.26, +0.75] | Inconclusive | +| TESS 200 s | Intermediate | +0.59 | -1.57 | -0.71 | [-2.21, +0.80] | Inconclusive | +| TESS 200 s | Fine | +1.17 | -1.02 | -0.42 | [-1.97, +1.14] | Pass | +| Separated TESS sectors | Original | +0.93 | -1.06 | -0.17 | [-1.69, +1.35] | Pass | +| Separated TESS sectors | Intermediate | +2.29 | +0.35 | -0.39 | [-1.83, +1.06] | Pass | +| Separated TESS sectors | Fine | +2.64 | +0.73 | -0.49 | [-1.95, +0.97] | Pass | +| ZTF g/r | Original | +3.61 | +1.33 | -0.83 | [-2.79, +1.14] | Inconclusive | +| ZTF g/r | Intermediate | +4.05 | +1.97 | -0.61 | [-2.59, +1.37] | Inconclusive | +| ZTF g/r | Fine | +3.91 | +1.85 | -0.81 | [-2.75, +1.15] | Inconclusive | + +Passing applies to the specified mixture at a nominal 5% false-alarm operating point, conditional on the injection being observable. It does not guarantee every SNR stratum, stellar geometry, observing pattern or detection threshold. Inconclusive matching is not a demonstrated performance loss. [Frozen design](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/design.json") · [Threshold freeze receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/calibration-freeze.json") · [All analysis values](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/recovery_analysis.json"). + +## What finer sampling costs + +Moving from original to fine adds a net **29, 35 and 6 detections out of 2,048** for dense TESS, separated TESS and ZTF respectively: about **1.4, 1.7 and 0.3 percentage points**. Individual outcomes are not monotonic with resolution. The fine setting changes bins, epoch steps and duration sampling together; it is a diagnostic reference, not exact canonical TLS. + +| Cadence | Method / grid | One-source latency | Batch time / source | Batch time / original v1 | Batch repetition range | +|---|---|---|---|---|---| +| TESS 200 s | Original | 4.3 ms | 1.59 ms | 1.00× | 1.58 ms–1.83 ms | +| TESS 200 s | Intermediate | 8.15 ms | 6.09 ms | 3.83× | 5.95 ms–6.18 ms | +| TESS 200 s | Fine | 41.7 ms | 37.4 ms | 23.55× | 37.4 ms–37.7 ms | +| TESS 200 s | GTLS | 0.45 s | 0.446 s | 280.61× | 0.445 s–0.453 s | +| Separated TESS sectors | Original | 32.4 ms | 26.4 ms | 1.00× | 25.6 ms–26.7 ms | +| Separated TESS sectors | Intermediate | 0.168 s | 0.167 s | 6.33× | 0.166 s–0.168 s | +| Separated TESS sectors | Fine | 1.19 s | 1.18 s | 44.65× | 1.17 s–1.18 s | +| Separated TESS sectors | GTLS | 7.14 s | 4.64 s | 175.53× | 4.61 s–4.65 s | +| ZTF g/r | Original | 100 ms | 67.9 ms | 1.00× | 66.6 ms–68.9 ms | +| ZTF g/r | Intermediate | 0.531 s | 0.511 s | 7.53× | 0.508 s–0.515 s | +| ZTF g/r | Fine | 3.74 s | 3.72 s | 54.79× | 3.71 s–3.72 s | +| ZTF g/r | GTLS | 17.8 s | 10.6 s | 155.57× | 10.3 s–10.7 s | + +The original grid uses automatic 256–1,024 bins here, epoch oversampling 4 and 16 durations; intermediate uses 4,096 / 8 / 16; fine uses 8,192 / 16 / 32. All retain top-50 fits against individual observations. [The phase-binning explanation](../../../docs/TLS_NUMERICS.md) shows the retained shape and measures compression alone at known ephemerides. Those bin-only SNR losses are separate from this complete-search result. + +## Secondary box-search control + +Each cell gives **detected injections / 2,048; test false-positive rate**. The BLS control receives the exact TLS-study observations and period grid, with its own independent null calibration. + +| Cadence | BLS control | Original TLS | Intermediate TLS | Fine TLS | +|---|---|---|---|---| +| TESS 200 s | 765/2,048; 4.79% | 881/2,048; 4.91% | 898/2,048; 4.96% | 910/2,048; 5.25% | +| Separated TESS sectors | 1142/2,048; 4.71% | 1137/2,048; 4.59% | 1165/2,048; 4.37% | 1172/2,048; 4.27% | +| ZTF g/r | 1532/2,048; 5.10% | 1620/2,048; 4.91% | 1629/2,048; 5.13% | 1626/2,048; 4.93% | + +This is one fixed BLS setting, not the strongest possible BLS configuration. BLS's ranking statistic and epoch/duration search differ from TLS, and a box's optimal width can be shorter than a transit's contact duration. The control compares complete searches; it cannot attribute a difference solely to template shape. It also does not replace the earlier, separately tuned BLS-versus-PyPI/CPU/GPU experiment. [Secondary analysis and nominal paired bounds](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/bls_analysis.json") · [BLS calibration freeze](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/bls-calibration-freeze.json"). + +## Timing boundary and provenance + +Timing uses one otherwise idle A40 with a 7.65-CPU-equivalent allocation on a Xeon Gold 6342 host. Twenty-four isolated configuration processes run sequentially in randomized order. Each uses five synchronized, warmed repetitions on the same 16 earlier lightcurves: eight injections and eight nulls. Single latency is the mean of 16 separate calls per repetition; batch throughput is a 16-source call divided by 16. GTLS single-source latency uses one worker and is contextual where recovery was calibrated for concurrent batch execution. + +The timer includes API host work, transfers, periodograms, native candidates and synchronization from prepared arrays and an explicit grid. Imports, context initialization, grid construction, simulation, disk I/O, preprocessing and vetting are excluded. Initialization and first-call times are retained separately. Disk caches may already be populated. Distributed recovery runtimes never enter these speed ratios. The fixed timing mixture is not a survey occurrence-rate model, and 16-source throughput is not a measured million-source job. + +All primary periods stay unchanged across timed repetitions versus workload warmup. cuvarbase's largest native-score change is below 0.00001. Concurrent GTLS scores change by up to 0.04511 on separated TESS and 0.08654 on ZTF; its single-worker scores stay unchanged in these repetitions. The reported recovery applies to the documented execution policy, which includes GTLS's memory-dependent behavior. + +Numerical source pins are cuvarbase `1032caf029570dc4841db1c594a2cbb1654e8fd8` and GTLS `74e449c325792a763dde4fbffab98039c5e8c111`. The [source receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/source-verification.json") verifies every installed numerical file against its Git archive. GTLS numerical code is unmodified; the ZTF client releases unused CuPy blocks and limits concurrency to two after preflight memory failures. [Cross-node probes](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/cross-node-probes.json") record small GTLS score changes from memory-dependent chunking. The independent study uses the same frozen numerical versions throughout. + +[Timing records](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived directory: benchmarks/results/tls_sensitivity_2026-09-09/timing") · [Timing analysis](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.json") · [Machine-readable timing table](timing_analysis.csv) · [Methods and limitations](METHODS.md). + +## Evidence and reproduction + +The [compact evidence](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived directory: benchmarks/results/tls_sensitivity_2026-09-09/evidence") retains all scalar outcomes, truth, paired input hashes, output hashes and installed-source maps. Its receipt records original verification of every prepared input array and retained sampled spectrum, plus exact reconstruction of the full scalar summaries. Full observations and sampled periodograms remain in the larger measurement archive; unretained spectra were hashed during execution and discarded. A compact checkout can repeat summary analysis, not verify omitted bytes. + +The [execution-source archive](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/execution-harness.json.gz") preserves measured harness revisions by SHA256; [maintained tools and commands](../../tls_sensitivity/README.md) provide portable regeneration and analysis. The three cadence files and the seeds specify new input generation, subject to recorded software versions and floating-point reproducibility. [Analysis verification](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/analysis-verification.json") records exact agreement between the original and compact analyses. + +[HATPI cost pilot](HATPI.md) · [Rental and termination ledger](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/rental-ledger.json") · [Publication checks](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/validation.json") · [File hashes](../../../docs/BENCHMARK_ARCHIVES.md#tls_sensitivity_2026-09-09 "Archived file: benchmarks/results/tls_sensitivity_2026-09-09/SHA256SUMS.json"). + +All 37 study/preflight pods are terminated and confirmed absent. Estimated rental is **$35.77 for this study**, or **$43.80 including the earlier campaigns**. New-study container storage adds about **$0.81** at the documented rate; earlier storage is additional. These are elapsed-time estimates, not an invoice, and remain within the original $50 allowance. diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/hatpi_timing.csv b/benchmarks/results/tls_sensitivity_2026-09-09/hatpi_timing.csv new file mode 100644 index 00000000..f7f59792 --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/hatpi_timing.csv @@ -0,0 +1,11 @@ +mode,method,n_points,n_periods,seconds_per_source,min_seconds_per_source,max_seconds_per_source,source +binned300s,gtls,9792,17377,2.2321979478001595,1.8987360373139381,2.2355570271611214,hatpi-cost/binned300s/gtls/summary.json +binned300s,v1_bls,9792,17377,0.004582629539072514,0.004340575076639652,0.005619295872747898,hatpi-cost/binned300s/v1_bls/summary.json +binned300s,v1_tls_fine,9792,17377,0.2091867635026574,0.20778566040098667,0.20988792087882757,hatpi-cost/binned300s/v1_tls_fine/summary.json +binned300s,v1_tls_intermediate,9792,17377,0.031829966232180595,0.03076649270951748,0.031906078569591045,hatpi-cost/binned300s/v1_tls_intermediate/summary.json +binned300s,v1_tls_original,9792,17377,0.005882020108401775,0.005857151001691818,0.006717251613736153,hatpi-cost/binned300s/v1_tls_original/summary.json +native30s,gtls,97920,17377,19.23100583255291,16.788771454244852,20.688439525663853,hatpi-cost/native30s/gtls/summary.json +native30s,v1_bls,97920,17377,0.02118799090385437,0.021136139519512653,0.021252812817692757,hatpi-cost/native30s/v1_bls/summary.json +native30s,v1_tls_fine,97920,17377,0.23527014069259167,0.2348409928381443,0.2355652740225196,hatpi-cost/native30s/v1_tls_fine/summary.json +native30s,v1_tls_intermediate,97920,17377,0.04895193688571453,0.04867993760854006,0.049796623177826405,hatpi-cost/native30s/v1_tls_intermediate/summary.json +native30s,v1_tls_original,97920,17377,0.06559550948441029,0.06522428803145885,0.06862339377403259,hatpi-cost/native30s/v1_tls_original/summary.json diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/recovery_by_snr.csv b/benchmarks/results/tls_sensitivity_2026-09-09/recovery_by_snr.csv new file mode 100644 index 00000000..eec0c1c7 --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/recovery_by_snr.csv @@ -0,0 +1,49 @@ +profile,method,snr,n,detected,recall,marginal_wilson_95 +tess_200s,v1_original,6.0,512,9,0.017578125,"[0.009274933048931773, 0.033066484933693366]" +tess_200s,v1_original,8.0,512,93,0.181640625,"[0.15065587541144415, 0.2173670036621101]" +tess_200s,v1_original,10.0,512,284,0.5546875,"[0.5113892427160343, 0.5971712443142754]" +tess_200s,v1_original,14.0,512,495,0.966796875,"[0.9474733440447254, 0.9791679559639896]" +tess_200s,v1_resolved,6.0,512,12,0.0234375,"[0.013457118668315029, 0.040515780067557586]" +tess_200s,v1_resolved,8.0,512,96,0.1875,"[0.15606453821977367, 0.22358982160702806]" +tess_200s,v1_resolved,10.0,512,291,0.568359375,"[0.5251045053589303, 0.6105961034289569]" +tess_200s,v1_resolved,14.0,512,499,0.974609375,"[0.9570473392668004, 0.9851026017462445]" +tess_200s,v1_fine,6.0,512,14,0.02734375,"[0.016356725239303357, 0.04537049399873423]" +tess_200s,v1_fine,8.0,512,99,0.193359375,"[0.16148547959457243, 0.22980036098547674]" +tess_200s,v1_fine,10.0,512,297,0.580078125,"[0.5368866472243599, 0.6220769230700223]" +tess_200s,v1_fine,14.0,512,500,0.9765625,"[0.9594842199324425, 0.986542881331685]" +tess_200s,gtls_tess_200s,6.0,512,8,0.015625,"[0.007938224978408945, 0.03052603275313371]" +tess_200s,gtls_tess_200s,8.0,512,104,0.203125,"[0.1705465380866688, 0.2401251037487928]" +tess_200s,gtls_tess_200s,10.0,512,284,0.5546875,"[0.5113892427160343, 0.5971712443142754]" +tess_200s,gtls_tess_200s,14.0,512,490,0.95703125,"[0.9358001975831783, 0.9714553011701242]" +tess_gap,v1_original,6.0,512,22,0.04296875,"[0.0285446988298758, 0.06419980241682172]" +tess_gap,v1_original,8.0,512,186,0.36328125,"[0.32278335155989013, 0.4058154308643356]" +tess_gap,v1_original,10.0,512,419,0.818359375,"[0.78263299633789, 0.8493441245885558]" +tess_gap,v1_original,14.0,512,510,0.99609375,"[0.985870592855346, 0.9989281109196064]" +tess_gap,v1_resolved,6.0,512,30,0.05859375,"[0.04134837176993725, 0.08241341148542018]" +tess_gap,v1_resolved,8.0,512,198,0.38671875,"[0.34552794745577303, 0.42959675798144265]" +tess_gap,v1_resolved,10.0,512,426,0.83203125,"[0.7972032842381693, 0.861913958445854]" +tess_gap,v1_resolved,14.0,512,511,0.998046875,"[0.9890207217875779, 0.9996551422384565]" +tess_gap,v1_fine,6.0,512,30,0.05859375,"[0.04134837176993725, 0.08241341148542018]" +tess_gap,v1_fine,8.0,512,204,0.3984375,"[0.35693864618312077, 0.4414490207605898]" +tess_gap,v1_fine,10.0,512,427,0.833984375,"[0.7992907911560903, 0.8637036117790153]" +tess_gap,v1_fine,14.0,512,511,0.998046875,"[0.9890207217875779, 0.9996551422384565]" +tess_gap,gtls_tess_gap,6.0,512,27,0.052734375,"[0.036492305788731, 0.07563799671337895]" +tess_gap,gtls_tess_gap,8.0,512,177,0.345703125,"[0.30579403848936837, 0.387910301675115]" +tess_gap,gtls_tess_gap,10.0,512,404,0.7890625,"[0.7516376331702338, 0.8221820839899746]" +tess_gap,gtls_tess_gap,14.0,512,510,0.99609375,"[0.985870592855346, 0.9989281109196064]" +ztf,v1_original,6.0,512,154,0.30078125,"[0.2626619417224134, 0.34186771266717264]" +ztf,v1_original,8.0,512,453,0.884765625,"[0.8541970418460828, 0.9096035276171676]" +ztf,v1_original,10.0,512,503,0.982421875,"[0.9669335150663066, 0.9907250669510682]" +ztf,v1_original,14.0,512,510,0.99609375,"[0.985870592855346, 0.9989281109196064]" +ztf,v1_resolved,6.0,512,161,0.314453125,"[0.2757440988618599, 0.35592567728530355]" +ztf,v1_resolved,8.0,512,453,0.884765625,"[0.8541970418460828, 0.9096035276171676]" +ztf,v1_resolved,10.0,512,505,0.986328125,"[0.9720509995117902, 0.9933619030077497]" +ztf,v1_resolved,14.0,512,510,0.99609375,"[0.985870592855346, 0.9989281109196064]" +ztf,v1_fine,6.0,512,161,0.314453125,"[0.2757440988618599, 0.35592567728530355]" +ztf,v1_fine,8.0,512,452,0.8828125,"[0.8520591804664085, 0.9078642287457593]" +ztf,v1_fine,10.0,512,504,0.984375,"[0.9694739672468663, 0.9920617750215911]" +ztf,v1_fine,14.0,512,509,0.994140625,"[0.9829162193160559, 0.998005324207814]" +ztf,gtls_ztf,6.0,512,109,0.212890625,"[0.17963878194802874, 0.25041866114284533]" +ztf,gtls_ztf,8.0,512,434,0.84765625,"[0.8139486337000743, 0.8761858909926088]" +ztf,gtls_ztf,10.0,512,498,0.97265625,"[0.9546295060012657, 0.9836432747606967]" +ztf,gtls_ztf,14.0,512,505,0.986328125,"[0.9720509995117902, 0.9933619030077497]" diff --git a/benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.csv b/benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.csv new file mode 100644 index 00000000..7395ed1c --- /dev/null +++ b/benchmarks/results/tls_sensitivity_2026-09-09/timing_analysis.csv @@ -0,0 +1,25 @@ +profile,method,mode,seconds_per_source,min_seconds_per_source,max_seconds_per_source,initialization_s,first_api_s,period_changes_from_workload_warmup,max_native_score_change_from_workload_warmup,source +tess_gap,gtls_tess_gap,batch16,4.639073267229833,4.612089166301303,4.649945791694336,1.2518134526908398,9.037376783788204,0,0.045101165771484375,tess_gap/gtls_tess_gap/batch16/summary.json +ztf,v1_resolved,single,0.5313290792983025,0.5293291882844642,0.5365951873827726,0.7296710778027773,1.2029331866651773,0,1.0842335029792594e-08,ztf/v1_resolved/single/summary.json +tess_gap,v1_fine,single,1.1878745561698452,1.186162483296357,1.1903532342985272,0.561559272930026,1.8404499627649784,0,1.083938272472551e-08,tess_gap/v1_fine/single/summary.json +ztf,gtls_ztf,single,17.810167647432536,17.67497906042263,18.29731379961595,1.4095030892640352,18.705136327072978,0,0.0,ztf/gtls_ztf/single/summary.json +tess_gap,v1_resolved,single,0.16840443725232035,0.16771523468196392,0.1722160311182961,0.6205925904214382,0.30291248112916946,0,1.9780583233597326e-06,tess_gap/v1_resolved/single/summary.json +ztf,v1_original,batch16,0.06786697497591376,0.06658975349273533,0.0688981160055846,0.6505062226206064,1.482149863615632,0,4.827837877030561e-06,ztf/v1_original/batch16/summary.json +tess_gap,v1_fine,batch16,1.1799977569608018,1.1705220020376146,1.1822529199998826,0.8088390473276377,1.3227946106344461,0,1.2506852442584204e-08,tess_gap/v1_fine/batch16/summary.json +tess_200s,gtls_tess_200s,batch16,0.44620746141299605,0.44503256981261075,0.4525642767548561,1.036519119516015,0.7306742500513792,0,0.0,tess_200s/gtls_tess_200s/batch16/summary.json +ztf,v1_fine,single,3.739031899254769,3.723257339093834,3.8825258277356625,0.6864620968699455,3.831162940710783,0,7.0207377689257555e-09,ztf/v1_fine/single/summary.json +tess_gap,v1_resolved,batch16,0.16735717887058854,0.16639617423061281,0.1683393056737259,0.6044531874358654,0.30206884630024433,0,1.9790139305086996e-06,tess_gap/v1_resolved/batch16/summary.json +tess_200s,v1_fine,single,0.04171130305621773,0.040792185929603875,0.04222378449048847,0.581807691603899,0.18784398585557938,0,1.360789703142018e-08,tess_200s/v1_fine/single/summary.json +tess_gap,v1_original,single,0.032359939184971154,0.03207397228106856,0.033566554775461555,0.6885992754250765,1.041298657655716,0,9.497589978479937e-06,tess_gap/v1_original/single/summary.json +ztf,v1_fine,batch16,3.718244557501748,3.7149301226017997,3.7187420547707006,0.6741950269788504,3.826351437717676,0,3.5458285196909856e-09,ztf/v1_fine/batch16/summary.json +tess_200s,v1_original,batch16,0.0015901586739346385,0.0015814774669706821,0.0018274761969223619,0.7834725826978683,0.34995504282414913,0,1.6522827417375652e-06,tess_200s/v1_original/batch16/summary.json +tess_200s,v1_original,single,0.004301364300772548,0.0041943659307435155,0.006986110005527735,0.6585356127470732,0.3809413630515337,0,1.8178652370082204e-06,tess_200s/v1_original/single/summary.json +tess_200s,v1_resolved,single,0.00815132213756442,0.00785839045420289,0.009347875020466745,0.5520266555249691,0.13312296196818352,0,1.171007513178779e-06,tess_200s/v1_resolved/single/summary.json +tess_200s,gtls_tess_200s,single,0.4495374985272065,0.4441993695218116,0.45189068804029375,0.9297961816191673,0.7031398229300976,0,0.0,tess_200s/gtls_tess_200s/single/summary.json +ztf,v1_resolved,batch16,0.5110105257481337,0.5084141796687618,0.5145309929503128,0.7324394509196281,0.6679140962660313,0,2.1699250396522984e-08,ztf/v1_resolved/batch16/summary.json +ztf,v1_original,single,0.09999498480465263,0.0959246326237917,0.10785315837711096,0.6193472985178232,0.3696501273661852,0,6.369621686985738e-06,ztf/v1_original/single/summary.json +tess_gap,v1_original,batch16,0.026429354213178158,0.025621781940571964,0.02671800449024886,0.5949285924434662,0.41256908886134624,0,2.4353572207758134e-06,tess_gap/v1_original/batch16/summary.json +tess_200s,v1_fine,batch16,0.037449513329192996,0.03736264875624329,0.03773157449904829,0.6694381888955832,0.15410830080509186,0,7.3327228733433e-09,tess_200s/v1_fine/batch16/summary.json +tess_200s,v1_resolved,batch16,0.006092541618272662,0.005949426325969398,0.006175972172059119,0.5670362785458565,0.14106916449964046,0,1.2072746642388665e-06,tess_200s/v1_resolved/batch16/summary.json +ztf,gtls_ztf,batch16,10.557898497441784,10.31507902382873,10.731171887018718,1.193747740238905,19.70509222522378,0,0.08653545379638672,ztf/gtls_ztf/batch16/summary.json +tess_gap,gtls_tess_gap,single,7.142252389574423,7.046802740776911,7.321156895253807,1.275655196979642,8.397089812904596,0,0.0,tess_gap/gtls_tess_gap/single/summary.json diff --git a/benchmarks/results/tls_survey_2026-09-10/FINAL_HANDOFF.md b/benchmarks/results/tls_survey_2026-09-10/FINAL_HANDOFF.md new file mode 100644 index 00000000..dfb3714c --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/FINAL_HANDOFF.md @@ -0,0 +1,248 @@ +# Final collection and delivery handoff + +Prepared on 2026-09-11 from the current collector, sidecar, renderer and reviewed +**v2** plan. This document is an unsealed operational note. It does not amend the +historical prospective runbooks or authorize another run. At preparation, both +final local archives were **pending**; the primary workflow was in accuracy and +the armed v2 sidecar was waiting for primary completion. Check current receipts +before proceeding. Do not start a collector, sidecar, timing runner or GPU job +from this runbook. + +Known local roots: + +```sh +TLS_STUDY_WORK=/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910 +TLS_PRIMARY="$TLS_STUDY_WORK/collected" +TLS_SUPPLEMENT="$TLS_STUDY_WORK/bls-execution-supplement-run-v2" +TLS_EXTRACTED="$TLS_SUPPLEMENT/extracted" +TLS_DELIVERY="$TLS_STUDY_WORK/final-delivery" +df -h "$TLS_STUDY_WORK" +``` + +The active plan is `ops/bls-supplement-sidecar-plan-v2.json`, SHA256 +`683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03`. +The prospective seal is `evidence/bls-execution-supplement/seal-v2.json`, SHA256 +`20972e579f99e4bb97c45adc7ccdb138be636fc951d57682d675241f132942aa`. +Its remote mutable output is +`/workspace/tls-survey/supplementary/bls-execution-supplement-run-v2`. +The primary measurement is `evidence/throughput-final/campaign.json`, **not** +`evidence/throughput-measure-final/campaign.json`. + +**1. Verify collected bytes and execution status separately.** + +The original collector writes `collection-state.json`, +`completion-bundle-receipt.json`, `completion-bundle.tar` and `collected/` under +the work root. The sidecar writes its local state and `bundle.tar` under +`bls-execution-supplement-run-v2/`; its archive receipt is the local state's +`receipt` field. It verifies that archive but does **not** extract it. + +This local-only check streams both archives and checks every inventoried member. +Importing the sidecar with `runpy` does not run its lifecycle entry point; only +its read-only `verify_archive` function is called. Missing files are not verified +collection; check controller status to distinguish pending work from a failed +collection. A failed assertion needs review, not a replay of the experiment. + +```sh +python3 - <<'PY' +from pathlib import Path, PurePosixPath +import hashlib, json, runpy, tarfile +w = Path('/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910') +s = w/'bls-execution-supplement-run-v2' +def sha(p): + h = hashlib.sha256() + with Path(p).open('rb') as f: + for block in iter(lambda: f.read(1024*1024), b''): h.update(block) + return h.hexdigest() +required = [w/'collection-state.json', s/'sidecar-state.json', + w/'completion-bundle-receipt.json', w/'completion-bundle.tar', + w/'collected/completion/inventory.json', s/'bundle.tar'] +missing = [str(p) for p in required if not p.is_file()] +if missing: raise SystemExit('NOT COLLECTED; inspect controller status: ' + ', '.join(missing)) +primary = json.loads((w/'collection-state.json').read_text()) +side = json.loads((s/'sidecar-state.json').read_text()) +r = json.loads((w/'completion-bundle-receipt.json').read_text()) +assert primary['evidence_verified'] and side['evidence_verified'] +assert primary['receipt'] == r and primary['archive_sha256'] == r['sha256'] +assert r['reviewed_seal_sha256'] == '1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807' +assert r['reviewed_exactness_plan_sha256'] == '317177812ddcb9683afadc34c7112e133d85f2c746b50e8ae3256773e0b8c525' +assert sha(w/'collected/completion/inventory.json') == r['inventory_sha256'] +if r['archive_mode'] == 'verified_banks': + assert sha(w/'collected/completion/reviewed-science-seal.json') == r['reviewed_seal_sha256'] + assert sha(w/'collected/completion/reviewed-exactness-plan.json') == r['reviewed_exactness_plan_sha256'] +else: + assert r['archive_mode'] == 'raw_rescue' and r['outcome'] == 'failed_integrity_rescue' +assert (w/'completion-bundle.tar').stat().st_size == r['bytes'] +assert sha(w/'completion-bundle.tar') == r['sha256'] +def safe_name(name): + return (bool(name) and not PurePosixPath(name).is_absolute() and + all(part not in ('', '.', '..') for part in name.split('/')) and + chr(92) not in name) +with tarfile.open(w/'completion-bundle.tar', 'r') as archive: + members = archive.getmembers() + names = [m.name for m in members] + assert len(names) == len(set(names)) + assert all(m.isfile() and safe_name(m.name) for m in members) + raw = archive.extractfile('completion/inventory.json').read() + assert hashlib.sha256(raw).hexdigest() == r['inventory_sha256'] + inventory = json.loads(raw) + assert set(names) == set(inventory['files']) | {'completion/inventory.json'} + for name, expected in inventory['files'].items(): + h, size = hashlib.sha256(), 0 + with archive.extractfile(name) as stream: + for block in iter(lambda: stream.read(1024*1024), b''): + h.update(block); size += len(block) + assert (size, h.hexdigest()) == (expected['bytes'], expected['sha256']) + assert sha(w/'collected'/name) == expected['sha256'] +plan_sha = sha(w/'ops/bls-supplement-sidecar-plan-v2.json') +assert plan_sha == '683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03' +plan = json.loads((w/'ops/bls-supplement-sidecar-plan-v2.json').read_text()) +assert sha(w/'ops/bls_supplement_sidecar.py') == plan['remote_pins']['sidecar']['sha256'] +assert sha(w/'ops/cloud.py') == plan['local_pins']['cloud']['sha256'] +verifier = runpy.run_path(str(w/'ops/bls_supplement_sidecar.py')) +verified = verifier['verify_archive'](s/'bundle.tar', side['receipt'], plan_sha) +print('Primary:', primary['status'], r['outcome'], r['archive_mode']) +print('Supplement:', side['status'], verified['outcome'], side['receipt']['archive_mode']) +print('Collector handback:', side.get('collector_handed_back')) +print('Archive byte identities verified; numerical qualification is a separate result.') +PY +``` + +For a completed scientific delivery, the primary receipt must have +`outcome=complete` and `archive_mode=verified_banks`. `raw_rescue` / +`failed_integrity_rescue` preserves evidence only and may lack the reviewed +design files; the check above grants only byte verification in that mode. The supplement can also +preserve `failed_or_partial` outcomes: archive verification or collector +handback does not make its tuning or measurements complete. Keep those labels. + +**2. Access supplementary evidence without restarting anything.** + +After step 1, extract regular members into a fresh local directory. This refuses +an existing destination and uses neither `extractall` nor archived links. The +JSONs retain their original remote path strings; do not rewrite them or create +a `/workspace` mirror. The existing combined renderer accepts relocated files. + +```sh +python3 - <<'PY' +from pathlib import Path +import json, runpy, shutil, tarfile +w = Path('/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910') +s = w/'bls-execution-supplement-run-v2' +v = runpy.run_path(str(w/'ops/bls_supplement_sidecar.py')) +state = json.loads((s/'sidecar-state.json').read_text()) +plan_sha = v['sha'](w/'ops/bls-supplement-sidecar-plan-v2.json') +assert plan_sha == '683f8a373065cbb96b5afb7ea4d39d4f918f029d137e6fb7744e010492c19a03' +v['verify_archive'](s/'bundle.tar', state['receipt'], plan_sha) +destination = s/'extracted' +assert not s.is_symlink() and not destination.exists() +destination.mkdir() +with tarfile.open(s/'bundle.tar', 'r') as archive: + for member in archive.getmembers(): + assert member.isfile() and v['safe_member'](member.name) + target = destination/member.name + target.parent.mkdir(parents=True, exist_ok=True) + with archive.extractfile(member) as source, target.open('xb') as output: + shutil.copyfileobj(source, output) +print(destination) +PY +``` + +Inspect `extracted/state-at-archive.json`, `packaging-warnings.json`, +`tune/campaign.json`, `tune/tuning-seal.json` and `measure/campaign.json` for +execution status. For the combined renderer, require supplement receipt +`outcome=complete`, `archive_mode=verified_designs`, empty packaging warnings, +and both stages `complete`. Otherwise retain the existing qualified figure and +the supplemental failure evidence. +Reference-only configurations are diagnostics, not final timing bars. Native +BLS bars require valid ownership/accounting and three completed queues per +panel, each with at least 96 attempts and 120 seconds. API failures reduce the +successful-completion rate. The original BLS numerical qualification remains +**failed**, even when later outputs agree. Missing or interrupted products +remain unavailable; do not rerun them to obtain a figure. + +**3. Render the separate combined figure from collected products.** + +Run only after the required completed receipts exist. This is CPU rendering, +using the source snapshot preserved by the supplementary archive. It checks +the two supplement seal layers, science identity, separate tuning choice, +original cohorts/resources, queue accounting and full planned TLS exactness. +It does not recalibrate scores or infer detection equivalence. Renderer +rejection is a withheld figure, not permission to change a gate. + +```sh +"$TLS_STUDY_WORK/local-env/bin/python" \ + "$TLS_EXTRACTED/source-snapshots/candidate/benchmarks/tls_survey/plot_native_bls_comparison.py" \ + --primary "$TLS_PRIMARY/evidence/throughput-final/campaign.json" \ + --native-bls "$TLS_EXTRACTED/measure/campaign.json" \ + --supplement-seal "$TLS_EXTRACTED/reviewed-supplement-seal.json" \ + --supplement-binding "$TLS_EXTRACTED/binding.json" \ + --native-tuning "$TLS_EXTRACTED/tune/tuning-seal.json" \ + --science-seal "$TLS_PRIMARY/completion/reviewed-science-seal.json" \ + --exactness "$TLS_PRIMARY/evidence/exactness-final.json" \ + --output "$TLS_DELIVERY/survey-throughput-with-native-bls" +``` + +The output prefix produces `.png`, `.pdf`, `.svg`, `.csv` and `.data.json`. +Keep the original `collected/evidence/survey-throughput.*` unchanged, including +its missing qualified BLS bars. In the new figure, native BLS uses separate +hatched execution bars and a permanent failed-repeatability label. + +**4. Assemble the final report and its supporting tables.** + +Check every `outputs` entry in each report/figure provenance JSON against the +sibling file's SHA256 before copying. Preserve filenames and relative layout. +Publish compact final products under this study directory in new `final-report/`, +`final-science/`, `final-timing/` and `final-figures/` subdirectories; keep complete +archives, input banks, spectra and attempt journals in the work root. + +| Collected source | Required delivery | +| --- | --- | +| `collected/final-campaign/report/` | `RECOVERY.md`, `recovery_fpr.csv`, `paired_contrasts.csv`, `thresholds.csv`, `subgroups.csv`, `exactness.csv`, `exactness_mismatches.csv`, `snr_descriptive.csv`, `snr_cases.csv`, `provenance.json` | +| `collected/final-campaign/` | `detection-results.json`, `thresholds.json` | +| `collected/evidence/` | `exactness-final.json`, `heldout-snr-final.json`, original `survey-throughput.{png,pdf,svg,csv,data.json}` | +| `collected/evidence/throughput-final/` | `campaign.json`, `measurements.csv`, and every referenced result JSON at its existing relative path; retain the full archive for arrays and qualification logs | +| `bls-execution-supplement-run-v2/extracted/` | Binding, reviewed seal/plan, separate tuning seal/campaign, measure campaign and referenced result JSONs, inventory and packaging warnings; retain references and failed configurations | +| `final-delivery/` | New `survey-throughput-with-native-bls.{png,pdf,svg,csv,data.json}` when eligible | + +Link the two archive receipts and hashes, v2 seal/plan, source identities and +final ledger from the study README. Label any copied partial evidence explicitly. +Update the README's pending statements only after the corresponding products +verify. Scientific conclusions must use per-regime recovery and realized FPR +with their existing intervals, paired contrasts and sampling/SNR subgroups. +Report TLS held-out exactness as its actual planned count and +`exactness_qualified` value; successful execution is not a passing exactness +result. Preserve discrete-threshold limits, failed executions and unsupported +physics. Assigned target-SNR groups include unsampled injections; white-family +ceilings and OU responses of those white-selected filters are distinct. + +**5. Close provider termination and the cumulative ledger.** + +The sidecar must have verified its evidence before collector handback. The +original collector performs termination; this runbook does not. Read +`collection-state.json` for `phase=complete`, its `termination` receipt and +guard result, then `nodes/survey01/pod.json` for `termination_verified`, final +timestamps and estimated rental cost. A stopped process or empty GPU alone +does not establish provider termination. This fresh provider query is read-only: + +```sh +python3 - <<'PY' +from pathlib import Path +import json, runpy +w = Path('/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910') +cloud = runpy.run_path(str(w/'ops/cloud.py')) +node = json.loads((w/'nodes/survey01/pod.json').read_text()) +assert node['termination_verified'] +pods = cloud['api']('query {myself {pods {id}}}')['myself']['pods'] +assert node['id'] not in {p['id'] for p in pods} +print('Provider confirms the owned rental is absent.') +PY +python3 "$TLS_STUDY_WORK/ops/cloud.py" spend +``` + +Reconcile `authorization.json`, the prior ledger it identifies, +`budget-amendment-30.json` and all final `nodes/*/pod.json` estimates. Include +recorded storage charges and collection time; do not add projected workload +costs or the supplement's one-hour cap a second time. The user ceiling is +**$100 cumulative**, not another allowance. Record the final cumulative +estimate and provider-confirmed termination separately from invoice evidence. +If collection, termination or a required result remains pending, report that +specific remaining item rather than declaring the study complete. diff --git a/benchmarks/results/tls_survey_2026-09-10/LITERATURE_ADDENDUM.md b/benchmarks/results/tls_survey_2026-09-10/LITERATURE_ADDENDUM.md new file mode 100644 index 00000000..0e5e8398 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/LITERATURE_ADDENDUM.md @@ -0,0 +1,34 @@ +# Additional published population comparison + +Read-only literature follow-up, 2026-09-11. This note was added during the +held-out injection search; it changes no frozen input, method, threshold, +tolerance, sample size, or interpretation gate. The prospective +[literature audit](../../../docs/TLS_LITERATURE.md) remains unchanged. + +The 2025 SPLS preprint reports **60.2% biweight+TLS versus 56.8% +biweight+BLS recovery** over 10,000 injections into Kepler light curves: +a 3.4 percentage-point difference. Figure 13's incorrect-period recoveries, +6.5% and 6.3%, are distinct from null false-positive rates. +[Figure 13](https://arxiv.org/html/2512.02356v1/3_3_2_all.png) + +The searches share approximately 39,029 trial periods. Astropy BLS uses +15 logarithmic durations and 15 bins per duration; TLS's minimum depth is +1 ppm. Method-specific thresholds target empirical 10% FPR, without a +described independent calibration/test-null split. Recovery includes +half/double-period aliases. Detrending windows use injected durations; +injections are central, circular transits with periods 10–480 days. The ROC +positive population excludes incorrect-period maxima, whereas the separate +recovery comparison includes all injections. The paper supplies neither +BLS resolution convergence nor a paired uncertainty interval for this +aggregate TLS–BLS difference. +[Methods and results, §§III.1.1–III.1.3](https://arxiv.org/html/2512.02356v1#S3.SS1) + +This is additional evidence of a population-specific TLS recovery advantage. +It does not establish the advantage over our independently tuned comparator, +or supply an approximation allowance for the cuvarbase campaign. + +The reviewer inspected three additional primary papers; this was the most +relevant population comparison. The parent independently checked the linked +methods and visually verified Figure 13. The downloaded figure remains in +the external work directory; its SHA256 is +`eb63ff47e4ec6e17e406d802ea46238f0656b994b1b712077b3b3ac9090aecd2`. diff --git a/benchmarks/results/tls_survey_2026-09-10/README.md b/benchmarks/results/tls_survey_2026-09-10/README.md new file mode 100644 index 00000000..b26bf8af --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/README.md @@ -0,0 +1,375 @@ +# Survey TLS study — collected results; release validated + +The frozen science and timing campaigns are complete, their archives were +verified locally, and the original rental was terminated. The +[84-product publication receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/FINAL_PUBLICATION.json") and +[source-to-copy inventory](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/FINAL_ASSEMBLY.json") bind the collected science, +report tables, original failed timing receipts and figures. Execution completion +does not grant numerical qualification: the experimental TLS candidate matched +**5,111/5,120** original held-out results, and the frozen zero-mismatch contract +**failed**. All selected periods, recovery/alias flags and both frozen threshold +decisions agreed. The nine chi2/SDE differences remain preserved in the +[exactness report](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json") and +[mismatch table](final-report/exactness_mismatches.csv). The +[default-preserving release](release-validation/README.md) is now applied: +`execution="baseline"` retains the original default, and +`execution="experimental"` selects the optimization bundle. Separate GPU wiring +validation passed **24/24 paired comparisons and 86/86 device tests** in +**177.825 seconds**, with a passing independent audit. These fixed-case wiring +checks do not requalify experimental sensitivity or historical throughput; the +figure's “Optimized” label refers to the **opt-in experimental candidate**. + +The [final recovery report](final-report/RECOVERY.md) compares observation-level, +GTLS-compatible TLS with native GPU BLS selected separately for each regime. +At the independently calibrated 5% target, the existing simultaneous intervals +support a TLS recovery advantage in dense solar, high-impact, eccentric and +M-dwarf TESS populations. They also preserve a severe smeared grazing failure: +TLS recovered **1/256**, versus **109/256** for BLS. The three ZTF populations +and long-gap TESS have negative point differences but simultaneous intervals +crossing zero; the small HATpi-like recovery difference also crosses zero. +The [paired contrasts](final-report/paired_contrasts.csv) retain all ten regimes +at both 5% and 1% targets. These are common target FPRs, not identical realized +FPRs: [independent test-null rates and intervals](final-report/recovery_fpr.csv) +remain explicit. No pooled advantage, universal sensitivity claim or +sub-percentage equivalence follows from this finite experiment. + +The [held-out expected-SNR diagnostics](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/heldout-snr-final.json") +cover all 2,560 injections, with [descriptive groups](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/snr_descriptive.csv") +and [sampling/target-SNR recovery](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/subgroups.csv"). They use common +matched-filter definitions, not package SDE/SNR equivalence. White-noise responses +are the enumerated template-family ceilings; OU responses evaluate those same +white-selected filters, not independently OU-optimized maxima. Unsampled and +few-event signals remain included. The [frozen science seal](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/seal-final.json") +and [auxiliary plan](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/exactness-plan.json") preceded held-out generation, with +**zero operative allowance** for approximation losses in every regime. Native +GTLS compatibility and this synthetic-flux/cadence coverage do not establish +canonical CPU TLS equivalence or universal physical coverage. + +The [final throughput figure](final-figures/survey-throughput-with-native-bls.png) +([PDF](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.pdf"), +[values/provenance](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json")) +shows **seven available and nine unavailable** backend/panel results. Both local +qualification gates and the unchanged baseline/candidate pairing passed for +ZTF solar and long-gap TESS. Their median-rate ratios are **1.850×** and +**1.007×**, respectively. The [exact values CSV](final-figures/survey-throughput-with-native-bls.csv) +retains independently tuned worker/batch settings, three-repetition ranges, +cold preparation, amortized cost and sampled memory. The +[collected timing note](final-timing/reporting/TIMING_LINKED.md) explains those +boundaries and the unchanged exclusions; its linked edition records a corrected +prose description of the already-correct cost formula. These are qualified +finite timing cohorts, not global sensitivity preservation. Baseline dense +TESS failed its post-queue gate, baseline varied failed its pre-queue gate, +and the candidate varied reference failed before selected-pool measurement. +Public GTLS gap and varied failed with out-of-memory errors in their first +queues. The [original final campaign](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/campaign.json") +and every failed reference/result remain unchanged. The varied-size workload +therefore has no qualifying throughput result. + +Native BLS has no qualifying original timing setting. The separate execution +supplement also produced **no rates**: its launcher set four CPU-thread variables +but omitted `VECLIB_MAXIMUM_THREADS` and `NUMEXPR_NUM_THREADS`. The frozen runner +rejected their recorded unset values before creating workers. All +[three development pilot receipts](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/tune/campaign.json") +retain that allocation-precheck failure; the +[measurement campaign](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/native-bls/measure/campaign.json") contains +four explicitly unavailable panels. This launcher/validation integration failure +is separate from BLS's earlier numerical-repeatability failure. The +[launch audit](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json") pins the actual +launcher source and all failed pilot receipts. No replacement +trial, passing tolerance or BLS speed bar was fabricated. The requested complete +native BLS and varied-queue throughput comparisons remain unfulfilled. + +[Primary collection](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/primary-collection-state.json") and +[supplement collection](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/supplement-collection-state.json") both verified +all archived bytes; the supplement handed control back before original teardown. +The [closed original ledger](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/original-rental-closed-ledger.json") +retains the original compute estimate of **$20.9600**. The +[final ledger](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/final-ledger.json"), including release validation and +elapsed container storage, records **$71.85225 cumulative estimated spend** and +**$73.75634 conservatively including reserves**, within the existing **$100 +total**, not a new allowance. The conservative total retains the full $1.50 +uncertainty reserve for the rejected rental request; this is not an observed +charge. These are estimates, not invoices. Both actual rentals are verified +absent and all owned controls are closed. Bulk arrays and journals remain in the +verified archives identified by [FINAL_ASSEMBLY.json](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/FINAL_ASSEMBLY.json") and the +[release collection](release-validation/README.md#full-outputs-and-reproduction). + +## Final requirements and remaining work + +| Requirement | Collected evidence and remaining limitation | +| --- | --- | +| Preserve the observation-level default | Baseline-default/explicit-experimental release applied; 24/24 fixed-case wiring pairs and 86/86 device tests passed, independently audited. The experimental candidate still failed aggregate exactness at 5,111/5,120 despite identical stored periods/flags/decisions; release wiring does not requalify it. | +| Compare TLS with strong BLS | Completed blind recovery with separately frozen BLS settings for all ten regimes; final tables preserve positive TLS regimes, uncertain differences and severe grazing failure. No general TLS/BLS ranking is claimed. | +| Freeze accuracy limits before held-out evaluation | Reviewed science/auxiliary identities preceded input generation; all approximation allowances are zero. No post-evaluation gate was relaxed. | +| Independently calibrate and measure detection | 512 calibration nulls, 256 injections and 256 independent test nulls per regime; all 20,480 search outcomes valid. Both FPR targets, existing marginal/simultaneous intervals, discrete threshold limits and sampling subgroups are collected. | +| Explain losses on common inputs | Completed 2,560-injection white/OU diagnostics and physical boundary checks. Family-response diagnostics are descriptive and cannot replace blind recovery or attribute every implementation loss. | +| Measure sustained throughput | Independent tuning, three long-queue repetitions, cold/amortized cost and sampled memory are reported for seven eligible panels. Only ZTF solar and long-gap TESS permit paired TLS speed ratios. Native BLS and all varied-size panels remain unavailable; their failed receipts are preserved. | +| Preserve reproducibility and close spending | Both original archives, 84 compact study products and separate release-validation evidence verified. Release applied; both actual rentals absent and all owned controls closed. Final estimated cumulative cost is $71.85225, or $73.75634 including conservative reserves, within $100. Missing native BLS/varied-queue measurements remain unfulfilled. | + +## Dated execution history and prospective evidence + +The following material preserves the state and wording of earlier checkpoints. +Statements that measurements, collection or teardown were pending describe those +checkpoints; the collected results and remaining limitations above are current. + +Separately, [old-study storage reclamation](../../../docs/STUDY_STORAGE.md) +reduced the retained file footprint by **39.62 GB**: 26.15 GB of archive-backed +NPZ copies, followed by 13.47 GB from exact compression of 489 retained tar +archives. The [independent postcheck](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/summary.json") +passed; restoration starts with the shared archive kit, then the unchanged NPZ +kits. No active survey data was removed. + +The [scientific protocol](../../tls_survey/README.md) declares the populations, +development tuning, independent calibration, recovery endpoints, uncertainty, +and approximation limits. The [throughput protocol](../../tls_survey/THROUGHPUT_PROTOCOL.md) +declares separate operating-configuration tuning and long-queue measurements. +Their [scientific seal](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/seal-final.json"), [auxiliary plan](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/exactness-plan.json"), +and [interpretation](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/seal-final-interpretation-v2.json") were reviewed before any +final input generation. The [launch review](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/root-final-launch-review.json") +verifies all 15 scientific sources, 82 candidate package files and 79 immutable +baseline package files. Launching this experiment does not qualify its results. + +Development completed with **392 valid injection-search outcomes** and **3,136 +valid null-search outcomes**, covering 80 injections and 640 null light curves. +All ten regimes have **zero operative allowance** for expected-SNR loss, +recovery loss or increased false-positive rate. The small development samples +did not establish a positive protected advantage that could fund approximation. +BLS configuration selection maximized development recovery, with finer +resolution breaking ties; speed did not select the control. + +The detached workflow started on **2026-09-11 at 02:56 UTC**, initially tuning +each competitor's batch size and concurrency. Its collection controllers must +verify the final evidence before provider termination. The +[selected-configuration projection](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-projection-selected-final.json") +estimates 28.02 hours for the science searches and 4.84 hours for the additional +baseline comparisons; throughput tuning and measurement have separate planning allowances. +These are planning estimates, not measured final throughput or guaranteed +completion times. The existing $30 study guard remains within the user's $100 +cumulative authorization. + +A [separately reviewed capacity fallback](capacity-contingency/operational-addendum-v1.md) +was armed at **2026-09-12 06:13 UTC** to protect the same $29.90 trigger and +$30 study cap against local disk-full failures. Its cutoff remains +**2026-09-13 11:51:51 UTC**, with no new allowance. The ordinary collector +retains evidence and teardown ownership; closure must also verify that the +fallback and its independent wake process have exited after provider absence. + +A [verified capacity checkpoint](capacity-checkpoint/README.md) was secured locally +at **2026-09-12 06:57 UTC**. It preserves all 10,240 frozen input cases as exact +arrays and original manifests, plus completed calibration/injection receipts +and a partial snapshot of 3,360 null outcomes. This backup does not establish +final science, throughput, collection completion or teardown. + +A [reviewed runtime checkpoint](runtime-planning/README.md) records the completed +ZTF high-impact calibration timings and the first M-dwarf calls. Those timings +support keeping the frozen forecast and full workload unchanged. At that +checkpoint, the remaining planning envelope left 11.38 hours before the study +guard for reporting, archives, transfers and overruns; four M-dwarf calls per +method do not establish a runtime bound. + +[Development throughput tuning](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-final.json") completed at +**2026-09-11 04:06 UTC**, with 12 of 16 attempted configurations eligible. +The frozen selections are baseline four workers/batch eight, candidate four +workers/batch four, and public GTLS two workers/batch one. All five eligible +candidate settings matched the baseline's complete spectra on the 24-source +tuning cohort. These development rates are not final throughput estimates or +held-out sensitivity qualification. + +BLS has no qualifying timing setting: its single-worker trial changed the +selected likelihood score by −0.00003052 at the same period during the queue, +violating the predeclared repeatability gate. GTLS also has retained memory +failures and one post-queue spectrum-repeat failure. Those trials cannot supply +performance denominators; the original qualified figure must show missing BLS +timing panels. +These changes alone do not establish altered calibrated detection decisions. +BLS remains in the independent recovery comparison. The accuracy campaign +started after tuning, generated all 5,120 calibration nulls, and began searching +that bank with four workers at **2026-09-11 04:23 UTC**. +Calibration and threshold calculation completed at **2026-09-11 18:18 UTC**. +The [independent completion audit](calibration-completion-audit/README.md) +verified all 10,240 outcomes: 512 unique cases for each method in every regime, +paired input-file identities, source hashes, and exact shard membership. +All 40 thresholds match independent recomputation of the frozen order-statistic +rule. The 38 valid zero-score TLS grazing nulls remain included. Calibration +exceedance counts are not independent-test false-positive rates; realized FPR, +recovery and their uncertainty still require the held-out searches. +All 2,560 injections finished generation at **18:27 UTC**, followed by the +separate 2,560 test nulls at **18:36 UTC**. The +[bank preparation check](heldout-bank-preparation/README.md) verifies the +completed manifest counts, roles and recorded hash separation; it does not +replace search-time or archive verification of the held-out input bytes. +The blind injection search completed at **2026-09-12 01:32 UTC**, with all +5,120 method outcomes valid and all four workers exiting normally. The +[independent injection audit](injection-completion-audit/README.md) verified +the original bytes of all 2,560 input files, returned period-grid identities, +paired shard coverage, frozen sources/settings, and retention of unsampled, +few-event and few-point cases. Its initial checker hash-convention error and +corrected receipt are both retained. These checks do not establish recovery +or numerical equivalence. The separate test-null search completed at +**2026-09-12 08:28 UTC**, with all 5,120 outcomes valid and all four workers +exiting normally. Its [independent completion audit](null-completion-audit/README.md) +passed in one execution at **13:45 UTC**, verifying all 2,560 original input +files, paired shard coverage, returned grids, frozen source/settings bindings, +and retention of all latent sampling cases. The earlier 31 preparation checks +and original live worker handles remain preserved. This structural audit does +not estimate FPR or recovery. No scientific settings or tolerances changed +after freezing. +The [exclusion audit](throughput-tuning-exclusions-audit/AUDIT.md) links the +original failed receipts and records the exact differences. It also verifies +that public GTLS's automatic internal period batching exposes no supported +override omitted by this queue-batch/concurrency study. + +Reporting those missing panels alone does not complete the requested BLS +throughput comparison. A separate [native BLS execution supplement](../../tls_survey/BLS_EXECUTION_PROTOCOL.md) +has therefore been prepared. It keeps the science-selected BLS settings and +grids, tunes execution settings on development inputs, and measures the same +independent timing cohorts after the primary campaign. It records every +numerical discrepancy and API failure; no new numerical passing tolerance is +introduced. Failed API calls consume elapsed time and reduce successful +throughput. The original repeatability failure remains explicit beside any +supplementary execution rates. Additional per-attempt journaling overhead is +included. The supplementary GPU envelope is capped at one hour ($0.49), inside +the existing study guard. Its [prospective seal](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/seal-v2.json") +and [launch review](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/root-launch-review-v2.json") were +completed before arming a waiting sidecar at **2026-09-11 05:05 UTC**. No +supplementary GPU work has started. The original collector is paused; after +primary completion, the sidecar must finish its bounded attempt and verify all +supplementary evidence locally before resuming that collector for primary +verification and provider teardown. The independent budget guard remains active. + +The integrated checks passed **186 survey tests** and **70 operations tests**; +the [receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/host-test-receipt-v2.json") retains commands, +source identities and complete logs. A synthetic figure was rendered and +visually checked; its values are not measurement results. An +[unlaunched first plan](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/rejected-prospective-v1/rejection.json") +was rejected because its heartbeat files could race primary archive collection. +The reviewed replacement keeps every mutable supplementary file outside the +primary archive's input trees. All original scientific and timing definitions +remain unchanged. + +The [final handoff guide](FINAL_HANDOFF.md) records the current v2 collection +paths, local archive and design checks, supplementary extraction, combined +figure command, required delivery tables, and provider/ledger closure. It is +an unsealed operational note; its future products remain pending. The original +frozen launch runbooks retain their historical prospective wording. + +## Available evidence + +- [Host profile](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/host-profile.json"): isolated candidate-ranking and duration-group + allocation measurements. These are CPU component measurements, not GPU + end-to-end speedups. +- [Authorization](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/authorization.json"): the user's updated **$100 cumulative** + ceiling and the preceding ledger's **$50.258718277017** estimated expenditure. + This is not an additional $100 allowance. Rental estimates are not invoices. +- [Development grid audit](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/development-grid-audit.json"): the rejected original + coarse-grid design. The final development policy increases period resolution + for high-impact, eccentric, grazing and HATpi-like strata before held-out + generation. The failure remains part of the evidence. +- [BLS response diagnostic](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/bls-response-final.json"): all four search resolutions + evaluated at the known injected period on the original 80 development inputs, + with reconstructed box responses and common white/OU expected-SNR definitions. + Unsupported settings remain recorded. This diagnoses discretization; it is + separate from the blind-search comparison and configuration selection. +- [Expected-SNR diagnostic](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/development-snr-final.json") and + [physical boundaries](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/boundaries-final.json"): the final cloud development + cohort, identified by manifest `a1d18d6c…`. The first compares ideal-box and + native-template filter responses on common inputs; the second checks exposure + integration and joint physical extremes. Annual-period boundary examples are + known-transit diagnostics, not annual-period blind recovery. +- [Development cohort provenance](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/development-cohort-provenance.json"): the older + local manifest `546f8319…` has identical times, bands and exposures, but small + floating-point differences in periods, physical signals, fluxes and errors. + Its original diagnostics and inputs remain separate dated evidence. +- [Grazing development diagnosis](grazing-development-diagnosis/README.md): a + reviewed, reproducible CPU explanation using all eight original smeared, + grazing development cases. Their noiseless window means fall below the + native 10-ppm gate throughout the near-truth coarse width envelope; seven + remain below across all cached widths. These are float64 diagnostics, not + native GPU gate traces. The retained TLS 0/8 and BLS 5/8 are period-recovery + counts, not new equal-FPR detection rates. The template-family SNR remains + substantial, while the actual gate and ranking causes are unresolved. + This analysis was added after freezing without changing the experiment. + Its compact artifacts and input hashes are included; the original NPZs + remain outside git. [Integration verification](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/integration.json") + checks every original artifact and all 115 sealed local files. + The [diagnostic figure](grazing-development-diagnosis/figure/grazing-depths.png) + separates physical depths from the window means used by the gate; its + [PDF](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.pdf"), + [SVG](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.svg"), and + [source/data receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.receipt.json") + preserve the same development-only scope. This figure does not replace + the pending sustained-throughput figure. + +The BLS response receipt calls its finest configuration `bls_convergence`; its +parameters equal the later blind-search name `bls_strong`. It runs at 78 of 80 +known true periods. The full blind grid makes that configuration inapplicable +to all eight separated-TESS development inputs because other trial periods +exceed its shared-memory limit. Those cases still test the three applicable +resolutions. Known-period applicability cannot substitute for full-grid +applicability or justify removing trial periods using the injected truth. + +The numerical target is the full observation-level GTLS-compatible default, +including its complete candidate/harmonic refinement. The currently implemented +changes remove host sorting work, bound temporary duration-group allocation, +combine winner transfers, and skip an unused refinement calculation. A guarded +short-row path batches the installed CUB single-tile scan agent while preserving +its floating-point addition tree; unsupported builds and longer rows retain the +native graphs. Its startup canary checks bitwise parity, including subnormals. +No approximate screen is added. Component and development timings are not yet +evidence of sustained production throughput. + +The final numerical code passed **342 TLS GPU tests**. The host suite passed +**762 tests**, with 18 skips and one expected failure. The preceding two host +failures exposed the missing declaration of the newly packaged kernel in the +inventory test; both the [failed run](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/host-tests-final.log") and +[corrected run](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/host-tests-final-inventory-fixed.log") are retained. +The scientific and reporting harness passed **130 CPU tests** after the final +launch integration fixes. The [test receipt](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/survey-host-tests-integration-final.json") +identifies the tested Python sources and the +[complete log](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/survey-host-tests-integration-final.log"). A separate +[operations suite](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/ops-host-tests-integration-final.json") passed **45 tests**, +covering orchestration, archive collection and the guarded development probe. +The [integration review](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/integration-review-final.json") records the corrected +output paths, design identities, report/figure artifact checks and timing-source +checks. These are harness checks; they do not supply missing science results. +A later [wording clarification](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/target-snr-label-20260911.json"), checked with +the 19 renderer tests, distinguishes assigned target SNR from realized SNR. +Unsampled injections can realize zero and remain in their original target +groups; the grouping rules and scientific calculations are unchanged. + +The [final development baseline comparison](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/development-promoted-baseline-parity.json") +matched **79/80** complete stored TLS fingerprints. All 32 cases using the new +short-row scan matched. HATpi development case 0001, which uses the long-row +fallback, changed its chi-squared hash and SDE by about −0.00000334; its selected +period, finite mask and period-recovery flag matched. This is a retained +numerical discrepancy, not aggregate bitwise qualification. Its cause is not +assigned from the fallback status alone. A [separate repeat diagnostic](../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/hatpi-repeat-diagnostic-summary.json") +completed 24 calls: eight baseline calls in single-worker processes, eight +candidate calls in single-worker processes and eight candidate calls with four +workers. All matched the original baseline, including complete public and +captured internal outputs; none reproduced the original discrepancy. This +finite quiet probe leaves its cause unresolved and does not replace 79/80 with +80/80. The planned held-out comparison checks +all 5,120 injection/test-null outcomes and both frozen threshold decisions. +Those baseline decisions reuse the candidate TLS cuts; there is no separate +baseline calibration pass. TLS and selected BLS share the same calibration +inputs and each receives its own threshold. That calibration bank is independent +of development and the later test-null bank. + +The [literature audit](../../../docs/TLS_LITERATURE.md) and existing known-period +template-response diagnostics answer different questions. The diagnostics do not measure TLS's +blind-search advantage. The original TLS publication reports a substantial +recovery advantage on its Kepler-like population; neither that result nor the +small development template-response differences establish the outcome for the +current GTLS-compatible TESS/ZTF implementation. The present study calibrates +each detector separately and reports each physical regime. + +A [dated literature addendum](LITERATURE_ADDENDUM.md) records a further +published Kepler population comparison and its methodological limits. It +does not amend the frozen campaign. + +Final claims will distinguish exact implementation qualification from finite +population evidence. Sparse sampling, template mismatch, shared native float32 +scan variability, and unachievable false-positive targets caused by discrete +scores remain explicit limitations. + diff --git a/benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/BLS_SUPPLEMENT_SIDECAR.md b/benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/BLS_SUPPLEMENT_SIDECAR.md new file mode 100644 index 00000000..b23c3b84 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/bls-execution-supplement/BLS_SUPPLEMENT_SIDECAR.md @@ -0,0 +1,133 @@ +# Separate native-BLS supplement sidecar + +Implementation: `bls_supplement_sidecar.py`. This is a new private operational +wrapper. It never edits or invokes the frozen primary controller, changes its +scientific settings, provisions a rental, or directly terminates a provider +resource. The original `collect_on_complete.py` retains teardown ownership. + +No sidecar has been armed. The original collector, primary controller, active +science workers, rental guard and wake lock are untouched by this implementation. + +## Review and immutable inputs + +Root first reviews the finished supplemental protocol, runner, renderer and +sidecar sources. The prospective supplement seal has schema1 and kind +`native_bls_execution_supplement`, the original science/auxiliary identities, +all executed `remote_files` and lifecycle `local_files` hashes, and budget +`{"gpu_cap_seconds":3600,"cleanup_reserve_seconds":120}`. Include the existing +original tuning receipt in `remote_files`: + +`/workspace/tls-survey/evidence/throughput-tune-final/campaign.json` + +SHA256: `8f32c398b0e6b675b41e89474ff3ca5b3c381ee83e79b96f38d3fd9045de273b`. + +Its `binding_rule` names these existing/future primary paths: + +- `primary_tuning_path`: `/workspace/tls-survey/evidence/throughput-tune-final/campaign.json` +- `primary_measurement_path`: `/workspace/tls-survey/evidence/throughput-final/campaign.json` +- `primary_state_path`: `/workspace/tls-survey/completion/state.json` +- `primary_bundle_receipt_path`: `/workspace/tls-survey/completion/bundle.json` + +The future measurement is never dynamically resealed. A separate binding +receipt records its actual bytes and the original configuration/cohort/resource +identities mechanically, after successful primary completion. Both stage +commands carry the prospective seal SHA and this binding SHA. Missing required +primary results/cohort/resource fields fail preparation; no rows are dropped. + +`bls-supplement-sidecar-plan.template.json` provides concrete commands and paths. +It deliberately contains unarmable placeholders. Before reviewing the final +plan SHA, fill the finished source/price/seal hashes, absolute primary-wait +cutoff, and verify the recorded original collector and guard process identities. +The collector PID/start-time/command identities were read without signals. +The template keeps the original collector command and uses `resume_stopped`. +Root may instead explicitly choose `start_absent` only if the original process +has already exited; the sidecar never kills it. + +## Conditional launch and ownership + +Only root pauses the original collector after reviewing the replacement. The +local sidecar refuses to start unless that exact collector is stopped (or absent +under the explicit alternative), its collection state has not advanced toward +teardown, the reviewed rental is active, and the original budget guard remains +active. It rechecks those conditions while waiting and before handback. + +After root has reviewed the final plan and safely paused the collector, the +new operational command is: + +```sh +python3 WORK/ops/bls_supplement_sidecar.py local \ + --plan WORK/ops/bls-supplement-sidecar-plan.json \ + --plan-sha256 REVIEWED_PLAN_SHA256 --arm +``` + +Run that local command in the same kind of persistent process root used for the +original collector. The sidecar uploads only its own identical source and its +reviewed plan; root must have already uploaded the other prospectively sealed +supplemental files. No direct GPU command is required. + +The remote sidecar waits for primary `archive_ready`, `outcome=complete`, +`archive_mode=verified_banks`, all seven successful execution stages, matching +science/auxiliary/controller identities and archive receipt, primary controller +exit, absence of all primary-owner descendants, and an empty GPU. An exactness +qualification failure does not masquerade as an execution failure. Primary +execution failure skips the supplement and preserves the failed preparation. + +The one-hour clock starts before post-primary supplemental binding/preparation. +It covers primary-archive byte verification, setup, tuning and measurement. +Both stages share the same hard epoch deadline; the sidecar additionally uses +monotonic time. It reserves the final120seconds for cleanup. It does not start a +stage whose fixed minimum runtime cannot fit before that reserve. The runner +also guards every queue, preserves partial attempt journals and closes workers. + +Supplemental children inherit only the distinct +`CUVARBASE_SURVEY_BLS_SUPPLEMENT_OWNER` marker. The sidecar checks exact marker, +PID, Linux process start ticks and actual GPU process ownership. Cleanup signals +only those identified descendants, including independently sessioned workers. +Foreign GPU work invalidates the supplement and is never signaled. + +## Evidence and restart behavior + +All mutable remote output is under +`/workspace/tls-survey/supplementary/bls-execution-supplement-run-v2`. +This directory is outside every input tree recursively archived by the frozen +primary controller. Stage outputs, binding, heartbeats, locks and logs all stay +there. The static prospective seal may remain in `evidence`; it never changes. +Validation rejects mutable output under primary archive trees and rejects stage +arguments that point back into those trees. +Every result, partial journal, spectrum, driver log, binding, reviewed design and +source snapshot is archived. The original reviewed designs are retained before +work and kept separate from changed actual-source snapshots. All sources named +by the prospective seal are retained, including the renderer and imported +frozen dependencies. + +Archives contain an exact file inventory. Local verification checks full tar +bytes, exact member coverage, every file hash, the actual reviewed plan against +its externally approved SHA, and the actual retained supplement seal against +that plan's pin. If the reviewed seal is unavailable, only explicitly failed +raw rescue is allowed. All supplement archive validation is labeled byte-only; +none grants numerical or detection qualification. + +Only after complete local byte verification does the sidecar resume the exact +original stopped collector with `SIGCONT`, or start its original command under +the reviewed absent-process alternative. The original collector independently +verifies the primary bundle and performs provider termination. The sidecar does +not stop the budget guard or wake lock. + +Persistent local/remote locks prevent duplicate controllers. A remote restart +with any persisted launch intent or preparation start preserves an interrupted +attempt and packages its partials; it never repeats GPU work or resets the hour. +Local transfer retries resume collection only. A handback intent prevents a +second collector launch after an ambiguous interruption. Already downloaded +bytes can still be verified after independent guard termination; such rental +loss is reported explicitly and does not become a successful primary collection. + +Offline verification: + +```sh +WORK/local-env/bin/python -m unittest discover -s WORK/ops \ + -p test_bls_supplement_sidecar.py -v +``` + +Twenty-five bounded tests currently pass, including ownership gates, source +and design tampering, startup owner reload, interrupted preparation/launch, +partial archives, interrupted transfers, and evidence-before-handback ordering. diff --git a/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/README.md b/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/README.md new file mode 100644 index 00000000..e19bc819 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/README.md @@ -0,0 +1,33 @@ +# Independent calibration completion audit + +The read-only audit passed on 2026-09-11 at 18:26:50 UTC. It found no discrepancy in the completed calibration or its thresholds. + +- Four complete shards contain exactly 10,240 unique method outcomes: 512 unique names in each of 20 regime/method groups. Actual shard membership exactly matches the manifest's index modulo four allocation. +- TLS and the frozen selected BLS method use identical input names and byte identities within every regime. All 5,120 original calibration NPZ files were hashed (7,292,425,446 bytes total) and match their manifest and search receipts. +- The pinned scientific seal, all 15 scientific sources, the complete 82-file production source inventory, each runner identity, and all four threshold source-receipt links match. Actual selected BLS methods and rankers match the seal. All outcomes are valid with finite selected scores; no API errors appear. +- All 40 persisted threshold dictionaries exactly match independent standard-library recomputation. At 512 calibration scores, the 5% point uses ascending rank 488 and the 1% point uses rank 508. Under the exchangeable-null design, their marginal bounds are respectively 25/513 (4.8733%) and 5/513 (0.97466%). +- Every cut has one score equal to the threshold, so there are 24 strict exceedances at the 5% point and four at the 1% point. No cut has additional boundary-tie conservatism. The actual frozen detection code uses `score > cut`; equality is excluded. +- The 38 zero-score TLS grazing nulls are valid successful-no-candidate outcomes and remain in calibration. They do not produce ties at either chosen cut. + +The calibration bank is paired between TLS and BLS; method-specific thresholds are estimated separately from that shared bank. It is independent of development and the test-null bank. Calibration exceedance fractions are order-statistic properties, not measured independent-test false-positive rates. This audit makes no held-out recovery claim and does not establish a universal physical or noise model. + +`audit_calibration.py` uses only Python's standard library, built-in sorting, and exact rational rank arithmetic. It never imports the campaign's threshold implementation or a GPU package. It reads only calibration products, the frozen seal, their source files, and calibration input bytes. It writes its receipt to stdout; the SSH caller stores stdout locally. The candidate's source files and completed calibration products were rehashed at the end to check stability. No remote files or controllers were changed and no held-out outcomes were read. + +The full result is `audit.stdout.json`; `execution.json` records the exact arguments, source hash, transport, exit status, and stdout/stderr hashes. Empty stderr and exit status zero are retained. To replay this exact audit on Linux, provide a read-only tree containing the original raw calibration NPZ files at its recorded `/workspace/tls-survey` paths, together with the candidate, final campaign, and evidence directories below. This requires the original live files, a raw-rescue archive, or separately retained original containers; the normal numerical-array bank alone is insufficient. Run the command from a separate writable directory containing `audit_calibration.py`, so the new receipt is written outside the input tree: + +```sh +python3 audit_calibration.py \ + --repo /workspace/tls-survey/candidate \ + --campaign /workspace/tls-survey/final-campaign \ + --seal /workspace/tls-survey/evidence/seal-final.json \ + --expected-seal-sha256 1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 \ + --expected-thresholds-sha256 caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa \ + --expected-thresholds-bytes 59643 > new-audit.json +``` + +The script checks threshold receipt paths literally, so the read-only input tree must be mounted at these original Linux paths. Relocation alone does not change input, score, threshold, or source hashes; do not edit original receipts to make a different layout pass. + + +The normal completed `verified_banks` archive intentionally omits covered original NPZ containers. It preserves the original manifests and search/source receipts plus an exact numerical-array bank. The frozen `benchmarks/tls_reference/inputs.py verify --bank ...` command verifies that bank's checksums, original manifest links, and every numerical dtype, shape and value identity. Its `restore` command produces numerically identical inputs with new NPZ containers and a reproduction manifest; those container hashes are not required to equal the historical originals. + +Consequently, bank verification and numerical replay are separate from rerunning this audit's original-container hash checks. The retained live audit and bank-export receipts document the original NPZ verification before packaging. Do not replace the original manifest or weaken the audit to claim that restored containers reproduce those historical bytes. This documentation clarification changes no audit source, result, input or scientific criterion. The earlier README and inventory are preserved under `review-copies/`. diff --git a/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/review-copies/README-before-bank-clarification.md b/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/review-copies/README-before-bank-clarification.md new file mode 100644 index 00000000..ca4c1306 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/calibration-completion-audit/review-copies/README-before-bank-clarification.md @@ -0,0 +1,28 @@ +# Independent calibration completion audit + +The read-only audit passed on 2026-09-11 at 18:26:50 UTC. It found no discrepancy in the completed calibration or its thresholds. + +- Four complete shards contain exactly 10,240 unique method outcomes: 512 unique names in each of 20 regime/method groups. Actual shard membership exactly matches the manifest's index modulo four allocation. +- TLS and the frozen selected BLS method use identical input names and byte identities within every regime. All 5,120 original calibration NPZ files were hashed (7,292,425,446 bytes total) and match their manifest and search receipts. +- The pinned scientific seal, all 15 scientific sources, the complete 82-file production source inventory, each runner identity, and all four threshold source-receipt links match. Actual selected BLS methods and rankers match the seal. All outcomes are valid with finite selected scores; no API errors appear. +- All 40 persisted threshold dictionaries exactly match independent standard-library recomputation. At 512 calibration scores, the 5% point uses ascending rank 488 and the 1% point uses rank 508. Under the exchangeable-null design, their marginal bounds are respectively 25/513 (4.8733%) and 5/513 (0.97466%). +- Every cut has one score equal to the threshold, so there are 24 strict exceedances at the 5% point and four at the 1% point. No cut has additional boundary-tie conservatism. The actual frozen detection code uses `score > cut`; equality is excluded. +- The 38 zero-score TLS grazing nulls are valid successful-no-candidate outcomes and remain in calibration. They do not produce ties at either chosen cut. + +The calibration bank is paired between TLS and BLS; method-specific thresholds are estimated separately from that shared bank. It is independent of development and the test-null bank. Calibration exceedance fractions are order-statistic properties, not measured independent-test false-positive rates. This audit makes no held-out recovery claim and does not establish a universal physical or noise model. + +`audit_calibration.py` uses only Python's standard library, built-in sorting, and exact rational rank arithmetic. It never imports the campaign's threshold implementation or a GPU package. It reads only calibration products, the frozen seal, their source files, and calibration input bytes. It writes its receipt to stdout; the SSH caller stores stdout locally. The candidate's source files and completed calibration products were rehashed at the end to check stability. No remote files or controllers were changed and no held-out outcomes were read. + +The full result is `audit.stdout.json`; `execution.json` records the exact arguments, source hash, transport, exit status, and stdout/stderr hashes. Empty stderr and exit status zero are retained. To reproduce on Linux, mount a read-only copy of the archived study at its recorded `/workspace/tls-survey` paths, including the candidate, final campaign, and evidence directories below. Run the command from a separate writable directory containing `audit_calibration.py`, so the new receipt is written outside the mounted archive: + +```sh +python3 audit_calibration.py \ + --repo /workspace/tls-survey/candidate \ + --campaign /workspace/tls-survey/final-campaign \ + --seal /workspace/tls-survey/evidence/seal-final.json \ + --expected-seal-sha256 1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 \ + --expected-thresholds-sha256 caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa \ + --expected-thresholds-bytes 59643 > new-audit.json +``` + +The script checks threshold receipt paths literally, so the read-only archive must be mounted at these original Linux paths. Relocation alone does not change input, score, threshold, or source hashes; do not edit original receipts to make a different layout pass. diff --git a/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/README.md b/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/README.md new file mode 100644 index 00000000..251d694c --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/README.md @@ -0,0 +1,86 @@ +# Verified partial capacity checkpoint — 2026-09-12 + +The checkpoint was **secured locally at 06:57:26 UTC**. Its complete input +backup contains all **10,240 frozen cases** across calibration, injections and +test nulls: **71,680 array uses and 30,714 unique arrays**, verified using the +unchanged, captured [exporter](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/helpers/inputs.py"). The [promotion receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-promotion.json") +binds the published archive and local bank to the [numerical verification](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/local-verification.json"). +This is an actual checkpoint record, separate from the unchanged +[prospective workflow](plans/CHECKPOINT_BANK_WORKFLOW.md). + +| Split | Frozen input cases secured | Outcomes in the Stage1 snapshot | Snapshot status | +|---|---:|---:|---| +| Calibration | 5,120 | 10,240 valid | Four completed shards | +| Injections | 2,560 | 5,120 valid | Four completed shards | +| Test nulls | 2,560 | 3,360 valid | Four running shards; partial | + +Stage1 captured individual files at approximately **06:36:13 UTC**, with no +claim of a simultaneous snapshot across shards. Its [local receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/stage1-local-verification.json") +binds the eight completed calibration/injection shards and thresholds to the +earlier completion audits. The original raw null-completion audit remains +required after all null searches finish. This checkpoint does **not** establish +final recovery, false-positive rates, baseline/candidate numerical qualification, +sustained throughput, final collection completion or provider teardown. Both +`complete_campaign` and `scientific_qualification` remain false. + +The original NPZ containers are **not preserved by this checkpoint**. The bank +preserves every original numerical array's dtype, shape and values, the original +manifest bytes and NPZ hashes, metadata, and exporter hashes. A later exact-array +restoration can reconstruct numerical inputs; it need not reproduce the original +compressed NPZ bytes. No signals or inputs were regenerated here. + +## Actual execution and retained locations + +The export ran once with one CPU thread at nice 19, overlapping the ongoing +science search, and completed in **234.565 s** with exit 0 and unchanged source +pins. The [raw launch/execution receipts](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/bank-export-execution.json") +are retained without alteration. The export wrapper used its exact Popen handle +and `wait()`; it did not record /proc start ticks. + +Packaging used an actual **180 s** command limit and completed in **5.575 s**; +the prospective workflow's 600 s limit was not used. Transfer took **47.737 s**; +local archive/exact-array verification took **6.728 s**. These elapsed times +include their recorded command boundaries and are checkpoint operations, not +search-throughput benchmarks. The [package execution](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-package-execution.json"), +[transfer](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-transfer.json"), [local execution](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-local-verification-execution.json") +and [promotion](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/bank-promotion.json") retain the actual commands. Root +promoted the verified tar from its `.partial` download name; the original local +verification receipt still correctly records the earlier transport path. + +The bulk files remain outside git under +`/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/capacity-checkpoint-20260912/`: + +| Product | Bytes | SHA256 | +|---|---:|---| +| `stage1.tar` | 65,628,160 | `6eeb65a620a323f8ee01f17a24d2b044c96de4acd8484b56cd0eab3d3bed4d3f` | +| `bank-only.tar` | 1,038,684,160 | `a1b0b1d0d3f379c5dbf00dcae83c9d1ef8a88bfe8c2761b56334cf33a6c41e56` | + +The extracted original sources/receipts are in `stage1/`; the verified bank is +`bank-recovered/input-bank/`. Its `bank.json` SHA256 is +`2dfcda710cd4c6ab925fec1b43b72c10dc163cff1eeef58c1f441746d05be67f`; +its `arrays.npz` SHA256 is +`350569a2f867c2282e1065761e214992dcb6a373ebf8ed955ebd57f45f6a50b3`. +These archive/bank identities come from the retained actual verification chain; +assembling this compact directory did not re-read the large numerical files. + +## Scope of these compact copies + +[INVENTORY.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/INVENTORY.json") records each selected original small file's +source path, destination, byte count and SHA256. It includes the reviewed designs, +helpers, prospective commands, original raw export receipts, actual operations, +and synthetic checks. Full archives, arrays ZIPs, large input manifests and +search-result shards are intentionally kept at the verified external locations. +Their original membership and hashes are in the retained +[Stage1 receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/provenance/stage1-receipt.json") and +[bank package inventory](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/actual/checkpoint.json"). + +Validation history is retained as history. The Stage1 helper's initial small +check preceded capture, but the retained [synthetic driver and repeat receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-stage1-synthetic-repeat-receipt-v1.json") +were created **after the actual Stage1 capture**; they do not backdate the earlier +inline check. Both bank test iterations remain unchanged; the retained bank +driver corresponds to the final v2 receipt. An independent reviewer incorrectly +reported a JSON newline defect, then retracted it after checking character values. +The [correction](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/validation/checkpoint-bank-review-correction-v1.json") is retained; +ordinary strict JSON parsing was used throughout, with no normalization exception. +No new tests, remote operations or scientific changes were performed to assemble +this documentation directory. diff --git a/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/CHECKPOINT_BANK_WORKFLOW.md b/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/CHECKPOINT_BANK_WORKFLOW.md new file mode 100644 index 00000000..4d75941c --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/capacity-checkpoint/plans/CHECKPOINT_BANK_WORKFLOW.md @@ -0,0 +1,56 @@ +# Prepared bank-only checkpoint workflow + +Prepared on 2026-09-12; no real packaging, transfer or local array verification was run while preparing this workflow. Root must confirm the existing export completed with exit 0, unchanged pins and a published bank before executing. This is a partial evidence backup, never a replacement for final collection or scientific qualification. Stage1 is already locally verified separately. + +The new helper is `capacity-contingency/checkpoint_bank.py`, SHA256 **b6cf36a4b6953f637719a2a562884a28737c8d510420e2b3295b667df1981ef7**. It fixes the reviewed science/auxiliary/exporter/three-manifest and Stage1 receipt/archive identities in source. The original exporter and all original sources, receipts and input banks remain unchanged. It preserves the original launch/execution/stdout/stderr bytes; no JSON normalization is used. Export completion is linked by the original wrapper's Popen handle followed by wait(), plus matching PID/start/command/pins. No /proc start-tick evidence was recorded, and none is inferred. + +1. Root reviews this helper, the successful export execution receipt and current free space. Upload only this new helper to the already-existing sibling tool directory: + +```sh +python3 /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/ops/cloud.py put survey01 /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/capacity-contingency/checkpoint_bank.py /workspace/tls-capacity-checkpoint-20260912-tools/checkpoint_bank.py +``` + +2. Run one CPU-only package command, retaining its exact stdout and exit status in a fresh local receipt. The helper validates every metadata gate before creating its fresh output. It writes an uncompressed tar, verifies its exact regular-file membership and byte inventory, then publishes the archive. A failure or timeout leaves any new partial output in place; do not reuse it automatically. + +```sh +python3 /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/ops/cloud.py ssh survey01 'env PYTHONDONTWRITEBYTECODE=1 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMBA_NUM_THREADS=1 nice -n 19 timeout --signal=TERM --kill-after=30s 600 /workspace/tls-survey/modern/bin/python -B /workspace/tls-capacity-checkpoint-20260912-tools/checkpoint_bank.py package --checkpoint-root /workspace/tls-capacity-checkpoint-20260912 --output /workspace/tls-capacity-checkpoint-20260912/bank-package --helper-sha256 b6cf36a4b6953f637719a2a562884a28737c8d510420e2b3295b667df1981ef7' +``` + +Require exit 0 and `status=bank_archive_verified_remotely`. The actual archive SHA256 and size are future products of this command; preserve those values from its stdout independently of the later download. The published paths are `/workspace/tls-capacity-checkpoint-20260912/bank-package/bank-only.tar` and adjacent `package-receipt.json`. Its members are the exact seven bank files, four original export receipts, original Stage1 receipt, reviewed helper source and an outer `checkpoint.json` byte inventory. It does not copy full case NPZs or ongoing search files. + +3. Download the small package receipt to the fresh local path below, compare its fields with the already-recorded successful remote stdout, and record its SHA. Confirm the two destination paths do not already exist before the SCP calls; the transport itself overwrites existing paths. + +```sh +python3 /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/ops/cloud.py get survey01 /workspace/tls-capacity-checkpoint-20260912/bank-package/package-receipt.json /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/capacity-checkpoint-20260912/bank-package-receipt.json +python3 /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/ops/cloud.py get survey01 /workspace/tls-capacity-checkpoint-20260912/bank-package/bank-only.tar /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/capacity-checkpoint-20260912/bank-only.tar.partial +``` + +4. After successful transfer, use the following local invocation. The downloaded receipt must already match the independently retained remote stdout; this block does not establish that external comparison by itself. It reads the actual archive SHA from that reviewed receipt rather than inventing a future hash. It checks the complete external archive SHA, every safe member and original metadata pin, then calls `verify_bank` from the exact captured Stage1 exporter, checking all 10,240 cases / 71,680 array uses. It streams array verification without restoring case NPZs. + +```python +import json, os, pathlib, subprocess +w = pathlib.Path('/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910') +c = w / 'capacity-checkpoint-20260912' +r = json.loads((c / 'bank-package-receipt.json').read_bytes()) +helper_sha = 'b6cf36a4b6953f637719a2a562884a28737c8d510420e2b3295b667df1981ef7' +assert r['status'] == 'bank_archive_verified_remotely' +assert r['helper_sha256'] == helper_sha +assert r['archive'] == '/workspace/tls-capacity-checkpoint-20260912/bank-package/bank-only.tar' +assert r['complete_campaign'] is False and r['scientific_qualification'] is False +assert (c / 'bank-only.tar.partial').stat().st_size == r['bytes'] +env = dict(os.environ, PYTHONDONTWRITEBYTECODE='1', OMP_NUM_THREADS='1', + OPENBLAS_NUM_THREADS='1', MKL_NUM_THREADS='1', NUMBA_NUM_THREADS='1') +subprocess.run([ + str(w / 'local-env/bin/python'), '-B', + str(w / 'capacity-contingency/checkpoint_bank.py'), 'verify', + '--archive', str(c / 'bank-only.tar.partial'), '--archive-sha256', r['sha256'], + '--exporter', str(c / 'stage1/candidate/benchmarks/tls_reference/inputs.py'), + '--output', str(c / 'bank-recovered'), '--helper-sha256', helper_sha, +], env=env, check=True) +``` + +Only exit 0 plus `bank-recovered/local-verification.json` with `status=partial_input_checkpoint_secured_locally` establishes local input backup. `bank-recovered/input-bank` is then usable by the existing bank verifier/restorer; this workflow does not restore it. The tar keeps its `.partial` transport name to avoid confusing download completion with scientific completion; its successful verification receipt gives its exact verified SHA. Failures retain `bank-recovered.partial` and must be reported, never promoted or retried over existing paths. + +For a bank of B bytes, this adds approximately B remote archive bytes and 2B local bytes (download plus extracted bank), with metadata overhead; it does not allocate the approximately 14.6 GB original case containers again. Remote packaging performs several sequential byte reads of B; local verification performs archive reads plus the unchanged exporter's checksum and numerical-array checks. The 600-second package timeout is a bounded operational limit, not a measured runtime prediction. Root should use the actual exported byte count and current free space before transfer. No GPU work, search criteria, controllers, collection handback or provider lifecycle is changed. + +Validation: ten tiny synthetic checks passed, including actual frozen-exporter export and verification, byte/status preservation, failed export refusal, external hash rejection, immutable Stage1 descendant refusal, and unsafe/duplicate tar refusal with retained partials. Driver: `capacity-contingency/test_checkpoint_bank_synthetic.py` (SHA fd756e9d6355e2fa5b65db91c66b27e358fe7fdfa425242e0e4a031b2a1460d4). Receipt: `capacity-contingency/checkpoint-bank-synthetic-receipt-v2.json` (SHA 57a43288232afebcddc2a641638c00007f5f925b207a0e7ccbb52d2a1f9d4f16). Its three-case pins are changed only in the imported module's memory, not the operational helper source or CLI. diff --git a/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/README.md b/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/README.md new file mode 100644 index 00000000..5c3e9e64 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/README.md @@ -0,0 +1,74 @@ +# Unlaunched capacity contingency + +Prepared only. No provider API request, mutation, process launch, signal, or +change to the original guard/controller/sidecar was performed in preparation. +Root must review the source, plan and test receipt before using the launch file. + +The only authorized pod is `survey01` / `okideq277lpb4a`. This fallback retains +the original study rental cap of **$30**, with its **$29.90 trigger**. It is not +a new compute allowance. The current original node inventory has one pod at +$0.49/hour, created at epoch `1789080637.819206`, and no completed-node entries. +The original `cloud.spend()` evaluated at the plan's snapshot epoch exactly +matches the independently derived $15.336237914995353 rental estimate. + +The fixed trigger is **2026-09-13 11:51:51.288594 UTC**, epoch +`1789300311.2885938`. The $30 accounting time is **12:04:05.982471 UTC**, +about 12 minutes 15 seconds later. No retry or restart recalculates a fresh +allowance. The pinned formula includes any completed-node estimates, although +there are none in this inventory. This reproduces the existing study rental +accounting; it is not a new reconciliation of the user's overall budget. + +`capacity-plan-v1.json` pins the standalone source, unchanged `ops/cloud.py`, +complete node-record bytes and inventory, active pod ID, rate, accounting +snapshot, and derived cutoff. At startup the code rechecks all of these, +imports the exact verified cloud bytes without a bytecode-cache write, reads +the cloud configuration once into memory, and verifies the provider's current +pod identity/rate. Unrelated provider pods are never mutation targets. An +already absent owned pod produces an `already_absent` startup receipt and +exits without a mutation. A changed local record, source, inventory, rate, or +plan fails startup rather than silently updating the reviewed plan. + +After startup validation, the one-time exclusive readiness receipt is flushed +and fsynced at `startup-readiness-v1.json`. It contains the Python process PID, +plan/source/accounting identities, and fixed wall/monotonic deadlines; it never +contains credentials. **That readiness file does not exist yet.** If writing +it fails, startup exits without arming or issuing a mutation. Root should +verify its complete JSON, expected identities and live process after launch; +file existence alone is insufficient. A partial or existing receipt refuses +another launch and must be inspected before any separately reviewed retry. + +Once armed, the loop neither reads local accounting/configuration nor writes +state or logs. It checks provider presence at intervals of at most 20 seconds +(apart from bounded request time) so normal collection can end it early. It +attempts termination at the earlier of the fixed wall cutoff and the startup +monotonic deadline, including when the wall clock moves backward. At cutoff a +failed presence query cannot prevent a termination request. Failed mutations +and failed absence verification retry; only verified provider absence permits +successful exit. Even if a previous mutation succeeded but its verification +failed, the next iteration checks again. Removed pods are not replaced with +new IDs. The unchanged cloud API function keeps curl's `--max-time 30`; its +in-memory subprocess wrapper adds a 35-second parent timeout. + +`launch-command-v1.sh` contains the concrete, unexecuted command. Python +`subprocess.Popen` passes the exact caffeinate/Python/guard/plan argument list +with `start_new_session=True` and all three child streams set to `DEVNULL`. +The launcher prints only the spawned wrapper PID and creates no PID/log file. +Thus no disk log can fill or kill the guard. The command wraps the fallback +in its own `caffeinate -i -s` process; +the original guard's existing wake lock may release if that guard crashes. +The new wrapper exits when this fallback exits. No original guard PID, wake +lock, source, or collector identity is changed. + +The fallback does not claim a provider can be terminated through a sustained +network/provider outage, or while the host is shut down or forcibly asleep. +Its unchanged trigger and roughly 12-minute margin are retained, and request +retries continue without local-write dependencies. The normal collector still +owns the usual evidence verification and accounting receipt updates. This +fallback intentionally performs no such writes after arming, even if it has +to enforce the cap before evidence collection completes. + +Offline tests use only fake provider calls and temporary fixtures inside this +directory. `tests-v3.json` and `tests-v3.log` retain the final 27-test +command/results (earlier v1/v2 receipts remain available); +`preparation-receipt-v1.json` records the no-launch preparation and accounting +cross-check. No readiness receipt has been manufactured by the tests. diff --git a/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/operational-addendum-v1.md b/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/operational-addendum-v1.md new file mode 100644 index 00000000..adebcaa5 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/capacity-contingency/operational-addendum-v1.md @@ -0,0 +1,41 @@ +# Capacity fallback armed: launch snapshot + +Root armed the reviewed fallback and verified it at **2026-09-12 06:13:34 +UTC**. [Root's launch receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-contingency/root-launch-verification-v1.json") records the +complete startup identity and live process checks. The original preparation +README, plan, source, tests and inventory retain their prospective wording and +original bytes; this separate addendum records the later operational action. + +Only `survey01` / `okideq277lpb4a` is in scope. The existing **$29.90 study +trigger**, **$30 study rental cap**, and cutoff **2026-09-13 +11:51:51.288594 UTC** are unchanged. This is no new compute allowance. The +fallback protects enforcement if local disk-full errors stop the original +guard's logging or state writes. It has no required disk writes after its +one-time [startup readiness receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-contingency/startup-readiness-v1.json"). + +The verified fallback is **PID 70575, process group 70575**; its independent +`caffeinate` child is **PID 70576**, parent 70575, in the same process group. +Both started at 01:13:08 America/Chicago on September 12. The original guard +1805 and wake process 1835 remained active. The original collector 11947 +remained paused under the waiting supplementary watcher 18854. This is a +launch snapshot, not continuous liveness evidence. + +The ordinary collector still owns evidence verification, provider termination +after collection, and normal accounting updates. The existing supplementary +workflow controls its resume. Neither workflow nor the frozen scientific +settings changed. The fallback exits harmlessly once it verifies the owned +pod is absent; at the unchanged cutoff it instead requests termination and +retries until absence is verified, without requiring local logging or saves. + +**Required closure:** after provider absence is independently verified, +explicitly verify that the original fallback **70575** and wake child +**70576** have both exited. Match their recorded command/start identities to +avoid confusing reused PIDs. Preserve that later check separately; no exit +verification is claimed in this launch record. Also complete the ordinary +collector's existing evidence and budget reconciliation requirements. + +The [launch inventory](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/capacity-contingency/launch-inventory-v1.json") binds this addendum, the +unchanged prepared artifacts, root's 27-test offline replay, the reviewed +launch command and the actual readiness/launch receipts. The copied compact +evidence is byte-identical to the private study artifacts. No additional live +operation or test was performed while preparing this addendum. diff --git a/benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/README.md b/benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/README.md new file mode 100644 index 00000000..4a595d82 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-assembly-preparation/README.md @@ -0,0 +1,47 @@ +# Final delivery copy preparation + +This additive helper was prepared during the frozen injection search on +2026-09-11. These files are **synthetic helper validation, not scientific or +throughput measurements**. No live result, source, setting, threshold or +controller was changed. + +After the actual archives have been verified, the supplement extracted and +the combined figure rendered as described in [the handoff](../FINAL_HANDOFF.md), +run: + +```sh +python3 assemble_final_delivery.py --work-root /path/to/collected-study-work \ + --destination /path/to/fresh-compact-delivery +``` + +The normal complete collection, both figure sets, reviewed design identities +and all required compact products must exist. Partial/rescue collections need +separate review; this helper refuses them. It verifies archive, inventory and +product hashes, preserves relative timing layouts, and writes source/destination +hashes and sizes in `ASSEMBLY.json`. Failed numerical qualifications remain +false. Failed fresh qualification receipts listed only under `unavailable` +are retained; an originally absent reference is explicitly recorded. Bulk +arrays and journals remain in the referenced external archives. + +The root reviewer checked the helper against the actual frozen collector, +controller, campaign and renderer schemas. This exposed and corrected omission +of unavailable-only qualification references. The original agent check receipts +are retained as `agent-check-v1.json` and `agent-check-v2.json`; v1 precedes +that correction. The root independently verified 48 copied files with the +corrected helper. + +The 51-file fixture is only 15,369 uncompressed bytes. Its archive and figure +files contain mock bytes for testing the copy contract; it cannot validate +archive-member verification, scientific inference or figure rendering. Those +remain the responsibilities of the existing collection and rendering checks. +Replay the standalone copy check with Python 3.9 or later: + +```sh +python3 replay_synthetic_check.py +``` + +The replay verifies all 48 copies, retained failure labels and the missing +original reference, then checks refusal of an existing destination, a tampered +reference, a missing expected reference and incomplete collection. It uses a +temporary directory and never reads the real campaign. `root-replay.json` +records its result. `artifact-manifest.json` inventories this preparation. diff --git a/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv new file mode 100644 index 00000000..f7fbc582 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv @@ -0,0 +1,39 @@ +scope,backend,workers,batch_size,rate_contract,original_numerical_qualification_passed,rate_available,missing_reason,median_lightcurves_per_second,minimum_lightcurves_per_second,maximum_lightcurves_per_second,attempted_count,successful_count,failed_count,completion_fraction,selected_mismatch_count,complete_output_mismatch_count,cold_first_cohort_including_startup_seconds,sampled_gpu_peak_bytes,sampled_worker_rss_peak_bytes,total_measured_compute_usd,usd_per_million_successful_steady,usd_per_million_successful_cold_amortized,cold_amortized_successful_lightcurves_per_second,heldout_exact_cases,heldout_planned_cases,heldout_aggregate_exactness_qualified,timing_cohort_paired_tls_qualification,science_seal_sha256 +tess_solar,baseline,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 773, in run + raise RuntimeError('Post-queue required-output qualification failed') +RuntimeError: Post-queue required-output qualification failed +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,candidate,4,4,original_qualified_timing,True,True,,8.203064070063173,8.03909495391051,8.209117438111315,,,,,,,10.244131383951753,3793158144,1436946432,0.049432611720913296,16.59271583746912,17.171267508767286,7.926678158244034,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,gtls,2,1,original_qualified_timing,True,True,,2.4249058679058546,2.2541474660971055,2.4430499132698036,,,,,,,18.114802494179457,8530690048,946552832,0.05042464483484703,56.13047207834772,60.102580374872865,2.264646713371647,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,baseline,4,8,original_qualified_timing,True,True,,0.7704690916588798,0.766305891609122,0.7731550532308258,,,,,,,87.00128701515496,3893821440,1691283456,0.05091131530951501,176.66005370579282,217.89290678209875,0.6246697660848016,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,candidate,4,4,original_qualified_timing,True,True,,0.7759979115902728,0.7740321475147041,0.7761346975505068,,,,,,,86.91488581197336,3617718272,1592274944,0.0505553960809316,175.40138842922278,216.6162422253286,0.6283513632810858,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,gtls,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 135002, 'task': 17, 'indices': [1], 'started': 2174109.762783667, 'ended': 2174114.16471629, 'api_seconds': 4.40193262277171, 'error': 'Traceback (most recent call last):\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 388, in worker\n results = public_call(internal_backend, selected,\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in public_call\n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in \n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/main.py"", line 108, in power\n = core.search_multi_periods(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/core.py"", line 768, in search_multi_periods\n ootrGPU = cp.empty((singleCalcPeriods,len(singleDurations),(tSize)),dtype=cp.float32)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/cupy/_creation/basic.py"", line 32, in empty\n return cupy.ndarray(shape, dtype, order=order)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""cupy/_core/core.pyx"", line 167, in cupy._core.core.ndarray.__new__\n File ""cupy/_core/core.pyx"", line 254, in cupy._core.core._ndarray_base._init\n File ""cupy/cuda/memory.pyx"", line 875, in cupy.cuda.memory.alloc\n File ""cupy/cuda/memory.pyx"", line 1579, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1600, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1271, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1292, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc\n File ""cupy/cuda/memory.pyx"", line 1537, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\n File ""cupy/cuda/memory.pyx"", line 1540, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\ncupy.cuda.memory.OutOfMemoryError: Out of memory allocating 1,623,613,440 bytes (allocated so far: 5,202,540,544 bytes).\n', 'outputs': [], 'scalars': [], 'profile': None, 'host_peak_rss_bytes': 607035392, 'scalar_match': True, 'membership_match': False} +",,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,baseline,4,8,original_qualified_timing,True,True,,0.4526328675575552,0.45192487562283545,0.45536185460471756,,,,,,,149.75658527994528,2610364416,2114220032,0.0864766228854889,300.70973821582606,371.0422156394032,0.36683456861251196,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,candidate,4,4,original_qualified_timing,True,True,,0.8372887160281638,0.8268017921195354,0.8389944113055334,,,,,,,85.62675408506766,2526478336,1746231296,0.054814661220877636,162.5617406583236,197.82563648879187,0.6880357547532657,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,gtls,2,1,original_qualified_timing,True,True,,0.11810723225948463,0.11536748914060276,0.12267161573125425,,,,,,,272.7577719227411,48298983424,1791160320,0.3304126220632254,1152.4367179485798,1276.1735606382663,0.10665564254679936,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,baseline,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 753, in run + raise RuntimeError('Pre-queue required-output qualification failed') +RuntimeError: Pre-queue required-output qualification failed +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,candidate,,,original_qualified_timing,False,False,Fresh one-worker required-output qualification failed,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,gtls,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 141402, 'task': 55, 'indices': [55], 'started': 2180771.54452009, 'ended': 2180774.500208829, 'api_seconds': 2.955688739195466, 'error': 'Traceback (most recent call last):\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 388, in worker\n results = public_call(internal_backend, selected,\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in public_call\n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in \n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/main.py"", line 108, in power\n = core.search_multi_periods(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/core.py"", line 768, in search_multi_periods\n ootrGPU = cp.empty((singleCalcPeriods,len(singleDurations),(tSize)),dtype=cp.float32)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/cupy/_creation/basic.py"", line 32, in empty\n return cupy.ndarray(shape, dtype, order=order)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""cupy/_core/core.pyx"", line 167, in cupy._core.core.ndarray.__new__\n File ""cupy/_core/core.pyx"", line 254, in cupy._core.core._ndarray_base._init\n File ""cupy/cuda/memory.pyx"", line 875, in cupy.cuda.memory.alloc\n File ""cupy/cuda/memory.pyx"", line 1579, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1600, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1271, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1292, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc\n File ""cupy/cuda/memory.pyx"", line 1537, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\n File ""cupy/cuda/memory.pyx"", line 1540, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\ncupy.cuda.memory.OutOfMemoryError: Out of memory allocating 1,377,618,944 bytes (allocated so far: 5,464,751,104 bytes).\n', 'outputs': [], 'scalars': [], 'profile': None, 'host_peak_rss_bytes': 1913155584, 'scalar_match': True, 'membership_match': False} +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 diff --git a/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png new file mode 100644 index 00000000..365ed696 Binary files /dev/null and b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png differ diff --git a/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.png b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.png new file mode 100644 index 00000000..b7e858cb Binary files /dev/null and b/benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput.png differ diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md b/benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md new file mode 100644 index 00000000..136edfba --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md @@ -0,0 +1,482 @@ +# Survey recovery and implementation qualification + +These tables format the existing sealed analysis. Rates remain separate by regime; interval bounds are copied from the source JSON. No new inferential statistics or pooled detection rates are calculated. + +Native TLS versus the selected native GPU BLS measures blind detection at separately calibrated operating points. Baseline versus optimized TLS exactness is a separate comparison of the original held-out executions. Package SDE, BLS power and expected matched-filter SNR are not interchangeable. + +Finite synthetic-flux population on fixed observed or synthetic cadences. Exact implementation qualification is separate. No universal completeness or sub-percentage noninferiority established. Marginal calibrated target FPR is not certainty about realized conditional FPR. + +## Frozen BLS control + +| Regime | Selected configuration | Ranker | +| --- | --- | --- | +| tess_solar | bls_strong | likelihood | +| tess_highimpact | bls_strong | detrended | +| tess_eccentric | bls_strong | likelihood | +| tess_mdwarf | bls_strong | detrended | +| ztf_solar | bls_medium | raw | +| ztf_highimpact | bls_strong | likelihood | +| ztf_mdwarf | bls_strong | likelihood | +| tess_gap_long | bls_fine | likelihood | +| tess_grazing_smeared | bls_medium | likelihood | +| hatpi_short | bls_strong | likelihood | + +## Primary operating point: 5% target FPR + +Recovery and observed FPR cells show successes/denominator, rate, and the existing 95% marginal interval. Failed executions remain in each planned denominator; a failure is not a detection. + +| Regime | TLS recovery | BLS recovery | TLS observed FPR | BLS observed FPR | +| --- | --- | --- | --- | --- | +| tess_solar | 73/256 (28.52%; 23.07–34.47%) | 40/256 (15.62%; 11.40–20.66%) | 14/256 (5.47%; 3.02–9.01%) | 20/256 (7.81%; 4.84–11.81%) | +| tess_highimpact | 128/256 (50.00%; 43.71–56.29%) | 53/256 (20.70%; 15.91–26.19%) | 17/256 (6.64%; 3.92–10.42%) | 18/256 (7.03%; 4.22–10.88%) | +| tess_eccentric | 83/256 (32.42%; 26.73–38.53%) | 28/256 (10.94%; 7.39–15.42%) | 19/256 (7.42%; 4.53–11.35%) | 9/256 (3.52%; 1.62–6.57%) | +| tess_mdwarf | 154/256 (60.16%; 53.87–66.20%) | 60/256 (23.44%; 18.39–29.11%) | 10/256 (3.91%; 1.89–7.07%) | 15/256 (5.86%; 3.32–9.48%) | +| ztf_solar | 183/256 (71.48%; 65.53–76.93%) | 197/256 (76.95%; 71.30–81.97%) | 8/256 (3.12%; 1.36–6.06%) | 10/256 (3.91%; 1.89–7.07%) | +| ztf_highimpact | 147/256 (57.42%; 51.11–63.56%) | 159/256 (62.11%; 55.86–68.08%) | 16/256 (6.25%; 3.61–9.95%) | 13/256 (5.08%; 2.73–8.53%) | +| ztf_mdwarf | 103/256 (40.23%; 34.18–46.52%) | 124/256 (48.44%; 42.17–54.74%) | 15/256 (5.86%; 3.32–9.48%) | 8/256 (3.12%; 1.36–6.06%) | +| tess_gap_long | 40/256 (15.62%; 11.40–20.66%) | 59/256 (23.05%; 18.03–28.70%) | 12/256 (4.69%; 2.45–8.04%) | 13/256 (5.08%; 2.73–8.53%) | +| tess_grazing_smeared | 1/256 (0.39%; 0.01–2.16%) | 109/256 (42.58%; 36.44–48.89%) | 11/256 (4.30%; 2.16–7.56%) | 12/256 (4.69%; 2.45–8.04%) | +| hatpi_short | 3/256 (1.17%; 0.24–3.39%) | 0/256 (0.00%; 0.00–1.43%) | 10/256 (3.91%; 1.89–7.07%) | 4/256 (1.56%; 0.43–3.95%) | + +Paired differences below are TLS minus BLS in percentage points. Both marginal and the existing simultaneous-family bounds are shown; an interval crossing zero does not establish an advantage. These intervals do not establish sub-percentage equivalence. + +| Regime | Recovery: marginal | Recovery: simultaneous | FPR: marginal | FPR: simultaneous | +| --- | --- | --- | --- | --- | +| tess_solar | +12.89 [+5.30, +20.00] | +12.89 [+1.37, +23.50] | -2.34 [-8.36, +3.77] | -2.34 [-11.38, +6.87] | +| tess_highimpact | +29.30 [+21.37, +36.14] | +29.30 [+17.05, +39.77] | -0.39 [-7.28, +6.51] | -0.39 [-10.73, +9.98] | +| tess_eccentric | +21.48 [+13.55, +28.61] | +21.48 [+9.34, +32.15] | +3.91 [-2.85, +10.51] | +3.91 [-6.28, +13.80] | +| tess_mdwarf | +36.72 [+28.31, +43.82] | +36.72 [+23.67, +47.52] | -1.95 [-8.35, +4.52] | -1.95 [-11.55, +7.79] | +| ztf_solar | -5.47 [-10.81, +0.14] | -5.47 [-13.55, +3.03] | -0.78 [-5.79, +4.27] | -0.78 [-8.35, +6.85] | +| ztf_highimpact | -4.69 [-12.06, +2.85] | -4.69 [-15.71, +6.67] | +1.17 [-3.72, +6.00] | +1.17 [-6.22, +8.47] | +| ztf_mdwarf | -8.20 [-16.77, +0.64] | -8.20 [-20.98, +5.14] | +2.73 [-2.98, +8.32] | +2.73 [-5.89, +11.14] | +| tess_gap_long | -7.42 [-13.46, -1.06] | -7.42 [-16.50, +2.21] | -0.39 [-5.86, +5.09] | -0.39 [-8.63, +7.88] | +| tess_grazing_smeared | -42.19 [-49.37, -33.54] | -42.19 [-53.06, -28.72] | -0.39 [-6.62, +5.85] | -0.39 [-9.75, +9.00] | +| hatpi_short | +1.17 [-1.51, +3.75] | +1.17 [-3.05, +5.55] | +2.34 [-2.32, +6.86] | +2.34 [-4.72, +9.23] | + +Independent calibration used 512 nulls per method and regime, with strict threshold exceedance. Stored attainable marginal FPR bound(s): 4.8733%. This discrete bound is marginal over calibration sets, not certainty about the conditional FPR of the realized threshold. Ties can make the operating point more conservative. All scores, ranks, exceedance counts, tie counts and zero-score counts are in [thresholds.csv](thresholds.csv). + +| Regime | Method | Failed injections | Failed nulls | Ties at threshold | Extra tie conservatism | +| --- | --- | --- | --- | --- | --- | +| tess_solar | tls | 0 | 0 | 1 | False | +| tess_solar | bls | 0 | 0 | 1 | False | +| tess_highimpact | tls | 0 | 0 | 1 | False | +| tess_highimpact | bls | 0 | 0 | 1 | False | +| tess_eccentric | tls | 0 | 0 | 1 | False | +| tess_eccentric | bls | 0 | 0 | 1 | False | +| tess_mdwarf | tls | 0 | 0 | 1 | False | +| tess_mdwarf | bls | 0 | 0 | 1 | False | +| ztf_solar | tls | 0 | 0 | 1 | False | +| ztf_solar | bls | 0 | 0 | 1 | False | +| ztf_highimpact | tls | 0 | 0 | 1 | False | +| ztf_highimpact | bls | 0 | 0 | 1 | False | +| ztf_mdwarf | tls | 0 | 0 | 1 | False | +| ztf_mdwarf | bls | 0 | 0 | 1 | False | +| tess_gap_long | tls | 0 | 0 | 1 | False | +| tess_gap_long | bls | 0 | 0 | 1 | False | +| tess_grazing_smeared | tls | 0 | 0 | 1 | False | +| tess_grazing_smeared | bls | 0 | 0 | 1 | False | +| hatpi_short | tls | 0 | 0 | 1 | False | +| hatpi_short | bls | 0 | 0 | 1 | False | + +### Primary target white-noise oracle SNR subgroups + +These are preassigned latent target SNR levels. Unsampled signals can have realized SNR zero and remain in their assigned groups. The held-out diagnostic computes the realized centered signal norm; none of these quantities is a package-reported detection score. + +| Regime | Level | TLS recovery | BLS recovery | +| --- | --- | --- | --- | +| tess_solar | 6.0 | 1/64 (1.56%; 0.04–8.40%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_solar | 8.0 | 5/64 (7.81%; 2.59–17.30%) | 2/64 (3.12%; 0.38–10.84%) | +| tess_solar | 10.0 | 22/64 (34.38%; 22.95–47.30%) | 6/64 (9.38%; 3.52–19.30%) | +| tess_solar | 12.0 | 45/64 (70.31%; 57.58–81.09%) | 32/64 (50.00%; 37.23–62.77%) | +| tess_highimpact | 6.0 | 2/64 (3.12%; 0.38–10.84%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_highimpact | 8.0 | 20/64 (31.25%; 20.24–44.06%) | 1/64 (1.56%; 0.04–8.40%) | +| tess_highimpact | 10.0 | 47/64 (73.44%; 60.91–83.70%) | 16/64 (25.00%; 15.02–37.40%) | +| tess_highimpact | 12.0 | 59/64 (92.19%; 82.70–97.41%) | 36/64 (56.25%; 43.28–68.63%) | +| tess_eccentric | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_eccentric | 8.0 | 5/64 (7.81%; 2.59–17.30%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_eccentric | 10.0 | 27/64 (42.19%; 29.94–55.18%) | 2/64 (3.12%; 0.38–10.84%) | +| tess_eccentric | 12.0 | 51/64 (79.69%; 67.77–88.72%) | 26/64 (40.62%; 28.51–53.63%) | +| tess_mdwarf | 6.0 | 6/64 (9.38%; 3.52–19.30%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_mdwarf | 8.0 | 30/64 (46.88%; 34.28–59.77%) | 1/64 (1.56%; 0.04–8.40%) | +| tess_mdwarf | 10.0 | 55/64 (85.94%; 74.98–93.36%) | 21/64 (32.81%; 21.59–45.69%) | +| tess_mdwarf | 12.0 | 63/64 (98.44%; 91.60–99.96%) | 38/64 (59.38%; 46.37–71.49%) | +| ztf_solar | 6.0 | 12/64 (18.75%; 10.08–30.46%) | 19/64 (29.69%; 18.91–42.42%) | +| ztf_solar | 8.0 | 52/64 (81.25%; 69.54–89.92%) | 54/64 (84.38%; 73.14–92.24%) | +| ztf_solar | 10.0 | 58/64 (90.62%; 80.70–96.48%) | 62/64 (96.88%; 89.16–99.62%) | +| ztf_solar | 12.0 | 61/64 (95.31%; 86.91–99.02%) | 62/64 (96.88%; 89.16–99.62%) | +| ztf_highimpact | 6.0 | 5/64 (7.81%; 2.59–17.30%) | 4/64 (6.25%; 1.73–15.24%) | +| ztf_highimpact | 8.0 | 31/64 (48.44%; 35.75–61.27%) | 39/64 (60.94%; 47.93–72.90%) | +| ztf_highimpact | 10.0 | 52/64 (81.25%; 69.54–89.92%) | 59/64 (92.19%; 82.70–97.41%) | +| ztf_highimpact | 12.0 | 59/64 (92.19%; 82.70–97.41%) | 57/64 (89.06%; 78.75–95.49%) | +| ztf_mdwarf | 6.0 | 3/64 (4.69%; 0.98–13.09%) | 5/64 (7.81%; 2.59–17.30%) | +| ztf_mdwarf | 8.0 | 28/64 (43.75%; 31.37–56.72%) | 32/64 (50.00%; 37.23–62.77%) | +| ztf_mdwarf | 10.0 | 31/64 (48.44%; 35.75–61.27%) | 40/64 (62.50%; 49.51–74.30%) | +| ztf_mdwarf | 12.0 | 41/64 (64.06%; 51.10–75.68%) | 47/64 (73.44%; 60.91–83.70%) | +| tess_gap_long | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_gap_long | 8.0 | 4/64 (6.25%; 1.73–15.24%) | 8/64 (12.50%; 5.55–23.15%) | +| tess_gap_long | 10.0 | 10/64 (15.62%; 7.76–26.86%) | 20/64 (31.25%; 20.24–44.06%) | +| tess_gap_long | 12.0 | 26/64 (40.62%; 28.51–53.63%) | 31/64 (48.44%; 35.75–61.27%) | +| tess_grazing_smeared | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 11/64 (17.19%; 8.90–28.68%) | +| tess_grazing_smeared | 8.0 | 0/64 (0.00%; 0.00–5.60%) | 29/64 (45.31%; 32.82–58.25%) | +| tess_grazing_smeared | 10.0 | 1/64 (1.56%; 0.04–8.40%) | 33/64 (51.56%; 38.73–64.25%) | +| tess_grazing_smeared | 12.0 | 0/64 (0.00%; 0.00–5.60%) | 36/64 (56.25%; 43.28–68.63%) | +| hatpi_short | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 8.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 10.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 12.0 | 3/64 (4.69%; 0.98–13.09%) | 0/64 (0.00%; 0.00–5.60%) | + +### Primary sampling subgroups + +Sampling groups overlap; their counts must not be added. An unrepresented group has no estimated recovery interval. + +| Regime | Level | TLS recovery | BLS recovery | +| --- | --- | --- | --- | +| tess_solar | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | three_plus_events | 73/256 (28.52%; 23.07–34.47%) | 40/256 (15.62%; 11.40–20.66%) | +| tess_solar | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | three_plus_events | 128/256 (50.00%; 43.71–56.29%) | 53/256 (20.70%; 15.91–26.19%) | +| tess_highimpact | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | one_event | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| tess_eccentric | two_events | 36/117 (30.77%; 22.57–39.97%) | 15/117 (12.82%; 7.36–20.26%) | +| tess_eccentric | three_plus_events | 47/136 (34.56%; 26.62–43.19%) | 13/136 (9.56%; 5.19–15.79%) | +| tess_eccentric | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | three_plus_events | 154/256 (60.16%; 53.87–66.20%) | 60/256 (23.44%; 18.39–29.11%) | +| tess_mdwarf | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | unsampled | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| ztf_solar | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | three_plus_events | 183/254 (72.05%; 66.10–77.48%) | 197/254 (77.56%; 71.92–82.54%) | +| ztf_solar | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | one_event | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| ztf_highimpact | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | three_plus_events | 147/255 (57.65%; 51.33–63.79%) | 159/255 (62.35%; 56.09–68.32%) | +| ztf_highimpact | one_to_four_points | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| ztf_highimpact | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | two_events | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| ztf_mdwarf | three_plus_events | 103/253 (40.71%; 34.60–47.04%) | 124/253 (49.01%; 42.70–55.35%) | +| ztf_mdwarf | one_to_four_points | 0/18 (0.00%; 0.00–18.53%) | 2/18 (11.11%; 1.38–34.71%) | +| ztf_mdwarf | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_gap_long | unsampled | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_gap_long | one_event | 0/28 (0.00%; 0.00–12.34%) | 0/28 (0.00%; 0.00–12.34%) | +| tess_gap_long | two_events | 5/125 (4.00%; 1.31–9.09%) | 9/125 (7.20%; 3.35–13.23%) | +| tess_gap_long | three_plus_events | 35/102 (34.31%; 25.19–44.37%) | 50/102 (49.02%; 38.99–59.11%) | +| tess_gap_long | one_to_four_points | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_gap_long | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | three_plus_events | 1/256 (0.39%; 0.01–2.16%) | 109/256 (42.58%; 36.44–48.89%) | +| tess_grazing_smeared | one_to_four_points | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_grazing_smeared | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| hatpi_short | unsampled | 0/10 (0.00%; 0.00–30.85%) | 0/10 (0.00%; 0.00–30.85%) | +| hatpi_short | one_event | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| hatpi_short | two_events | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| hatpi_short | three_plus_events | 3/241 (1.24%; 0.26–3.59%) | 0/241 (0.00%; 0.00–1.52%) | +| hatpi_short | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| hatpi_short | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | + +## Secondary operating point: 1% target FPR + +Recovery and observed FPR cells show successes/denominator, rate, and the existing 95% marginal interval. Failed executions remain in each planned denominator; a failure is not a detection. + +| Regime | TLS recovery | BLS recovery | TLS observed FPR | BLS observed FPR | +| --- | --- | --- | --- | --- | +| tess_solar | 53/256 (20.70%; 15.91–26.19%) | 19/256 (7.42%; 4.53–11.35%) | 7/256 (2.73%; 1.11–5.55%) | 4/256 (1.56%; 0.43–3.95%) | +| tess_highimpact | 112/256 (43.75%; 37.58–50.06%) | 33/256 (12.89%; 9.04–17.62%) | 6/256 (2.34%; 0.86–5.03%) | 7/256 (2.73%; 1.11–5.55%) | +| tess_eccentric | 73/256 (28.52%; 23.07–34.47%) | 6/256 (2.34%; 0.86–5.03%) | 6/256 (2.34%; 0.86–5.03%) | 1/256 (0.39%; 0.01–2.16%) | +| tess_mdwarf | 144/256 (56.25%; 49.94–62.42%) | 37/256 (14.45%; 10.38–19.37%) | 1/256 (0.39%; 0.01–2.16%) | 3/256 (1.17%; 0.24–3.39%) | +| ztf_solar | 176/256 (68.75%; 62.68–74.38%) | 192/256 (75.00%; 69.23–80.18%) | 0/256 (0.00%; 0.00–1.43%) | 1/256 (0.39%; 0.01–2.16%) | +| ztf_highimpact | 131/256 (51.17%; 44.87–57.45%) | 156/256 (60.94%; 54.67–66.95%) | 1/256 (0.39%; 0.01–2.16%) | 2/256 (0.78%; 0.09–2.79%) | +| ztf_mdwarf | 98/256 (38.28%; 32.30–44.54%) | 122/256 (47.66%; 41.40–53.97%) | 5/256 (1.95%; 0.64–4.50%) | 1/256 (0.39%; 0.01–2.16%) | +| tess_gap_long | 23/256 (8.98%; 5.78–13.18%) | 47/256 (18.36%; 13.81–23.66%) | 2/256 (0.78%; 0.09–2.79%) | 0/256 (0.00%; 0.00–1.43%) | +| tess_grazing_smeared | 0/256 (0.00%; 0.00–1.43%) | 97/256 (37.89%; 31.92–44.14%) | 1/256 (0.39%; 0.01–2.16%) | 0/256 (0.00%; 0.00–1.43%) | +| hatpi_short | 0/256 (0.00%; 0.00–1.43%) | 0/256 (0.00%; 0.00–1.43%) | 5/256 (1.95%; 0.64–4.50%) | 1/256 (0.39%; 0.01–2.16%) | + +Paired differences below are TLS minus BLS in percentage points. Both marginal and the existing simultaneous-family bounds are shown; an interval crossing zero does not establish an advantage. These intervals do not establish sub-percentage equivalence. + +| Regime | Recovery: marginal | Recovery: simultaneous | FPR: marginal | FPR: simultaneous | +| --- | --- | --- | --- | --- | +| tess_solar | +13.28 [+5.63, +20.44] | +13.28 [+1.67, +23.95] | +1.17 [-3.02, +5.28] | +1.17 [-5.20, +7.45] | +| tess_highimpact | +30.86 [+22.82, +37.77] | +30.86 [+18.42, +41.43] | -0.39 [-5.26, +4.50] | -0.39 [-7.76, +7.01] | +| tess_eccentric | +26.17 [+17.78, +33.61] | +26.17 [+13.30, +37.26] | +1.95 [-1.73, +5.46] | +1.95 [-3.68, +7.50] | +| tess_mdwarf | +41.80 [+33.16, +48.98] | +41.80 [+28.35, +52.67] | -0.78 [-3.75, +2.28] | -0.78 [-5.54, +3.98] | +| ztf_solar | -6.25 [-11.41, -0.77] | -6.25 [-14.10, +2.07] | -0.39 [-2.47, +1.69] | -0.39 [-4.03, +3.10] | +| ztf_highimpact | -9.77 [-17.13, -2.04] | -9.77 [-20.76, +1.92] | -0.39 [-2.47, +1.69] | -0.39 [-4.03, +3.10] | +| ztf_mdwarf | -9.38 [-18.08, -0.36] | -9.38 [-22.35, +4.24] | +1.56 [-1.94, +4.91] | +1.56 [-3.81, +6.87] | +| tess_gap_long | -9.38 [-15.45, -2.90] | -9.38 [-18.52, +0.46] | +0.78 [-1.63, +3.14] | +0.78 [-3.09, +4.82] | +| tess_grazing_smeared | -37.89 [-45.02, -29.42] | -37.89 [-48.72, -24.74] | +0.39 [-1.69, +2.47] | +0.39 [-3.10, +4.03] | +| hatpi_short | +0.00 [-1.70, +1.70] | +0.00 [-3.10, +3.10] | +1.56 [-1.94, +4.91] | +1.56 [-3.81, +6.87] | + +Independent calibration used 512 nulls per method and regime, with strict threshold exceedance. Stored attainable marginal FPR bound(s): 0.9747%. This discrete bound is marginal over calibration sets, not certainty about the conditional FPR of the realized threshold. Ties can make the operating point more conservative. All scores, ranks, exceedance counts, tie counts and zero-score counts are in [thresholds.csv](thresholds.csv). + +| Regime | Method | Failed injections | Failed nulls | Ties at threshold | Extra tie conservatism | +| --- | --- | --- | --- | --- | --- | +| tess_solar | tls | 0 | 0 | 1 | False | +| tess_solar | bls | 0 | 0 | 1 | False | +| tess_highimpact | tls | 0 | 0 | 1 | False | +| tess_highimpact | bls | 0 | 0 | 1 | False | +| tess_eccentric | tls | 0 | 0 | 1 | False | +| tess_eccentric | bls | 0 | 0 | 1 | False | +| tess_mdwarf | tls | 0 | 0 | 1 | False | +| tess_mdwarf | bls | 0 | 0 | 1 | False | +| ztf_solar | tls | 0 | 0 | 1 | False | +| ztf_solar | bls | 0 | 0 | 1 | False | +| ztf_highimpact | tls | 0 | 0 | 1 | False | +| ztf_highimpact | bls | 0 | 0 | 1 | False | +| ztf_mdwarf | tls | 0 | 0 | 1 | False | +| ztf_mdwarf | bls | 0 | 0 | 1 | False | +| tess_gap_long | tls | 0 | 0 | 1 | False | +| tess_gap_long | bls | 0 | 0 | 1 | False | +| tess_grazing_smeared | tls | 0 | 0 | 1 | False | +| tess_grazing_smeared | bls | 0 | 0 | 1 | False | +| hatpi_short | tls | 0 | 0 | 1 | False | +| hatpi_short | bls | 0 | 0 | 1 | False | + +### Secondary target white-noise oracle SNR subgroups + +These are preassigned latent target SNR levels. Unsampled signals can have realized SNR zero and remain in their assigned groups. The held-out diagnostic computes the realized centered signal norm; none of these quantities is a package-reported detection score. + +| Regime | Level | TLS recovery | BLS recovery | +| --- | --- | --- | --- | +| tess_solar | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_solar | 8.0 | 3/64 (4.69%; 0.98–13.09%) | 1/64 (1.56%; 0.04–8.40%) | +| tess_solar | 10.0 | 12/64 (18.75%; 10.08–30.46%) | 1/64 (1.56%; 0.04–8.40%) | +| tess_solar | 12.0 | 38/64 (59.38%; 46.37–71.49%) | 17/64 (26.56%; 16.30–39.09%) | +| tess_highimpact | 6.0 | 1/64 (1.56%; 0.04–8.40%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_highimpact | 8.0 | 12/64 (18.75%; 10.08–30.46%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_highimpact | 10.0 | 41/64 (64.06%; 51.10–75.68%) | 6/64 (9.38%; 3.52–19.30%) | +| tess_highimpact | 12.0 | 58/64 (90.62%; 80.70–96.48%) | 27/64 (42.19%; 29.94–55.18%) | +| tess_eccentric | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_eccentric | 8.0 | 4/64 (6.25%; 1.73–15.24%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_eccentric | 10.0 | 22/64 (34.38%; 22.95–47.30%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_eccentric | 12.0 | 47/64 (73.44%; 60.91–83.70%) | 6/64 (9.38%; 3.52–19.30%) | +| tess_mdwarf | 6.0 | 4/64 (6.25%; 1.73–15.24%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_mdwarf | 8.0 | 23/64 (35.94%; 24.32–48.90%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_mdwarf | 10.0 | 54/64 (84.38%; 73.14–92.24%) | 7/64 (10.94%; 4.51–21.25%) | +| tess_mdwarf | 12.0 | 63/64 (98.44%; 91.60–99.96%) | 30/64 (46.88%; 34.28–59.77%) | +| ztf_solar | 6.0 | 9/64 (14.06%; 6.64–25.02%) | 14/64 (21.88%; 12.51–33.97%) | +| ztf_solar | 8.0 | 48/64 (75.00%; 62.60–84.98%) | 54/64 (84.38%; 73.14–92.24%) | +| ztf_solar | 10.0 | 58/64 (90.62%; 80.70–96.48%) | 62/64 (96.88%; 89.16–99.62%) | +| ztf_solar | 12.0 | 61/64 (95.31%; 86.91–99.02%) | 62/64 (96.88%; 89.16–99.62%) | +| ztf_highimpact | 6.0 | 2/64 (3.12%; 0.38–10.84%) | 3/64 (4.69%; 0.98–13.09%) | +| ztf_highimpact | 8.0 | 23/64 (35.94%; 24.32–48.90%) | 37/64 (57.81%; 44.82–70.06%) | +| ztf_highimpact | 10.0 | 48/64 (75.00%; 62.60–84.98%) | 59/64 (92.19%; 82.70–97.41%) | +| ztf_highimpact | 12.0 | 58/64 (90.62%; 80.70–96.48%) | 57/64 (89.06%; 78.75–95.49%) | +| ztf_mdwarf | 6.0 | 3/64 (4.69%; 0.98–13.09%) | 4/64 (6.25%; 1.73–15.24%) | +| ztf_mdwarf | 8.0 | 25/64 (39.06%; 27.10–52.07%) | 31/64 (48.44%; 35.75–61.27%) | +| ztf_mdwarf | 10.0 | 31/64 (48.44%; 35.75–61.27%) | 40/64 (62.50%; 49.51–74.30%) | +| ztf_mdwarf | 12.0 | 39/64 (60.94%; 47.93–72.90%) | 47/64 (73.44%; 60.91–83.70%) | +| tess_gap_long | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| tess_gap_long | 8.0 | 2/64 (3.12%; 0.38–10.84%) | 5/64 (7.81%; 2.59–17.30%) | +| tess_gap_long | 10.0 | 5/64 (7.81%; 2.59–17.30%) | 12/64 (18.75%; 10.08–30.46%) | +| tess_gap_long | 12.0 | 16/64 (25.00%; 15.02–37.40%) | 30/64 (46.88%; 34.28–59.77%) | +| tess_grazing_smeared | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 8/64 (12.50%; 5.55–23.15%) | +| tess_grazing_smeared | 8.0 | 0/64 (0.00%; 0.00–5.60%) | 22/64 (34.38%; 22.95–47.30%) | +| tess_grazing_smeared | 10.0 | 0/64 (0.00%; 0.00–5.60%) | 31/64 (48.44%; 35.75–61.27%) | +| tess_grazing_smeared | 12.0 | 0/64 (0.00%; 0.00–5.60%) | 36/64 (56.25%; 43.28–68.63%) | +| hatpi_short | 6.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 8.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 10.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | +| hatpi_short | 12.0 | 0/64 (0.00%; 0.00–5.60%) | 0/64 (0.00%; 0.00–5.60%) | + +### Secondary sampling subgroups + +Sampling groups overlap; their counts must not be added. An unrepresented group has no estimated recovery interval. + +| Regime | Level | TLS recovery | BLS recovery | +| --- | --- | --- | --- | +| tess_solar | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | three_plus_events | 53/256 (20.70%; 15.91–26.19%) | 19/256 (7.42%; 4.53–11.35%) | +| tess_solar | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_solar | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | three_plus_events | 112/256 (43.75%; 37.58–50.06%) | 33/256 (12.89%; 9.04–17.62%) | +| tess_highimpact | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_highimpact | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | one_event | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| tess_eccentric | two_events | 30/117 (25.64%; 18.02–34.54%) | 3/117 (2.56%; 0.53–7.31%) | +| tess_eccentric | three_plus_events | 43/136 (31.62%; 23.92–40.14%) | 3/136 (2.21%; 0.46–6.31%) | +| tess_eccentric | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_eccentric | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | three_plus_events | 144/256 (56.25%; 49.94–62.42%) | 37/256 (14.45%; 10.38–19.37%) | +| tess_mdwarf | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_mdwarf | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | unsampled | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| ztf_solar | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | three_plus_events | 176/254 (69.29%; 63.22–74.91%) | 192/254 (75.59%; 69.83–80.74%) | +| ztf_solar | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_solar | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | one_event | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| ztf_highimpact | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_highimpact | three_plus_events | 131/255 (51.37%; 45.06–57.66%) | 156/255 (61.18%; 54.90–67.19%) | +| ztf_highimpact | one_to_four_points | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| ztf_highimpact | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| ztf_mdwarf | two_events | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| ztf_mdwarf | three_plus_events | 98/253 (38.74%; 32.70–45.04%) | 122/253 (48.22%; 41.92–54.57%) | +| ztf_mdwarf | one_to_four_points | 0/18 (0.00%; 0.00–18.53%) | 2/18 (11.11%; 1.38–34.71%) | +| ztf_mdwarf | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_gap_long | unsampled | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_gap_long | one_event | 0/28 (0.00%; 0.00–12.34%) | 0/28 (0.00%; 0.00–12.34%) | +| tess_gap_long | two_events | 3/125 (2.40%; 0.50–6.85%) | 6/125 (4.80%; 1.78–10.15%) | +| tess_gap_long | three_plus_events | 20/102 (19.61%; 12.41–28.65%) | 41/102 (40.20%; 30.61–50.37%) | +| tess_gap_long | one_to_four_points | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_gap_long | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | unsampled | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | one_event | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | two_events | 0/0 — unrepresented | 0/0 — unrepresented | +| tess_grazing_smeared | three_plus_events | 0/256 (0.00%; 0.00–1.43%) | 97/256 (37.89%; 31.92–44.14%) | +| tess_grazing_smeared | one_to_four_points | 0/1 (0.00%; 0.00–97.50%) | 0/1 (0.00%; 0.00–97.50%) | +| tess_grazing_smeared | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | +| hatpi_short | unsampled | 0/10 (0.00%; 0.00–30.85%) | 0/10 (0.00%; 0.00–30.85%) | +| hatpi_short | one_event | 0/2 (0.00%; 0.00–84.19%) | 0/2 (0.00%; 0.00–84.19%) | +| hatpi_short | two_events | 0/3 (0.00%; 0.00–70.76%) | 0/3 (0.00%; 0.00–70.76%) | +| hatpi_short | three_plus_events | 0/241 (0.00%; 0.00–1.52%) | 0/241 (0.00%; 0.00–1.52%) | +| hatpi_short | one_to_four_points | 0/0 — unrepresented | 0/0 — unrepresented | +| hatpi_short | grid_unreachable | 0/0 — unrepresented | 0/0 — unrepresented | + +## Comparable expected-SNR diagnostics + +The native family and ideal box are evaluated at the known period with the same sampled signal, weights, and fitted constant. Templates are selected by the white diagonal-error matched-filter objective; their white responses are the enumerated family ceilings. OU values evaluate those same white-selected filters using the actual OU covariance variance, not an independently OU-optimized family maximum. The white native-family optimum is an optimistic ceiling: the actual blind search and native depth/ranking need not attain it. These are descriptive diagnostics, not package SNR/SDE values or a measured blind-search advantage. + +Cells show the observed median relative native-family/ideal-box advantage and observed minimum–maximum, in percent; these ranges are not confidence intervals. Finite/total counts expose undefined ratios, including zero-signal cases. Detected/missed groups use original TLS decisions; misses include invalid executions and do not isolate a causal effect. No new tests, approximation allowances, or inferential intervals are calculated. + +All held-out injections. + +| Regime | Group | White-noise family/box advantage | OU-noise family/box advantage | +| --- | --- | --- | --- | +| tess_solar | all | 256/256 finite; +0.951% [+0.499, +1.408] | 256/256 finite; +0.927% [-1.411, +1.894] | +| tess_highimpact | all | 256/256 finite; +0.978% [+0.304, +1.517] | 256/256 finite; +0.471% [-1.552, +2.182] | +| tess_eccentric | all | 256/256 finite; +0.903% [-18.531, +1.592] | 256/256 finite; +0.663% [-29.780, +2.260] | +| tess_mdwarf | all | 256/256 finite; +1.359% [-2.053, +2.404] | 256/256 finite; +0.890% [-5.138, +2.856] | +| ztf_solar | all | 254/256 finite; +0.589% [-0.851, +2.025] | 254/256 finite; +0.580% [-0.737, +2.767] | +| ztf_highimpact | all | 256/256 finite; +0.166% [-51.353, +2.381] | 256/256 finite; +0.179% [-51.518, +2.432] | +| ztf_mdwarf | all | 256/256 finite; +0.348% [-37.484, +3.317] | 256/256 finite; +0.308% [-37.868, +3.790] | +| tess_gap_long | all | 255/256 finite; +0.953% [-44.885, +2.698] | 255/256 finite; +0.445% [-50.768, +2.412] | +| tess_grazing_smeared | all | 256/256 finite; +0.936% [-3.714, +3.582] | 256/256 finite; +0.708% [-4.038, +4.190] | +| hatpi_short | all | 246/256 finite; +0.904% [-18.204, +1.564] | 246/256 finite; +0.333% [-21.526, +3.539] | + +Original TLS decisions at 5% target FPR. + +| Regime | Group | White-noise family/box advantage | OU-noise family/box advantage | +| --- | --- | --- | --- | +| tess_solar | tls_detected | 73/73 finite; +0.953% [+0.554, +1.279] | 73/73 finite; +0.910% [-1.102, +1.810] | +| tess_solar | tls_missed_including_failures | 183/183 finite; +0.951% [+0.499, +1.408] | 183/183 finite; +0.938% [-1.411, +1.894] | +| tess_highimpact | tls_detected | 128/128 finite; +0.997% [+0.304, +1.517] | 128/128 finite; +0.507% [-1.552, +1.878] | +| tess_highimpact | tls_missed_including_failures | 128/128 finite; +0.948% [+0.319, +1.433] | 128/128 finite; +0.471% [-1.249, +2.182] | +| tess_eccentric | tls_detected | 83/83 finite; +0.929% [-3.120, +1.477] | 83/83 finite; +0.753% [-6.395, +1.940] | +| tess_eccentric | tls_missed_including_failures | 173/173 finite; +0.885% [-18.531, +1.592] | 173/173 finite; +0.490% [-29.780, +2.260] | +| tess_mdwarf | tls_detected | 154/154 finite; +1.359% [-2.053, +2.339] | 154/154 finite; +0.855% [-5.138, +2.638] | +| tess_mdwarf | tls_missed_including_failures | 102/102 finite; +1.394% [-0.513, +2.404] | 102/102 finite; +0.909% [-4.560, +2.856] | +| ztf_solar | tls_detected | 183/183 finite; +0.613% [-0.851, +1.919] | 183/183 finite; +0.596% [-0.611, +2.767] | +| ztf_solar | tls_missed_including_failures | 71/73 finite; +0.476% [-0.530, +2.025] | 71/73 finite; +0.440% [-0.737, +2.178] | +| ztf_highimpact | tls_detected | 147/147 finite; +0.101% [-1.439, +2.059] | 147/147 finite; +0.160% [-1.520, +2.282] | +| ztf_highimpact | tls_missed_including_failures | 109/109 finite; +0.211% [-51.353, +2.381] | 109/109 finite; +0.206% [-51.518, +2.432] | +| ztf_mdwarf | tls_detected | 103/103 finite; +0.461% [-6.636, +2.862] | 103/103 finite; +0.516% [-7.971, +3.192] | +| ztf_mdwarf | tls_missed_including_failures | 153/153 finite; +0.263% [-37.484, +3.317] | 153/153 finite; +0.219% [-37.868, +3.790] | +| tess_gap_long | tls_detected | 40/40 finite; +0.967% [+0.572, +1.907] | 40/40 finite; +0.562% [-1.840, +2.412] | +| tess_gap_long | tls_missed_including_failures | 215/216 finite; +0.953% [-44.885, +2.698] | 215/216 finite; +0.436% [-50.768, +2.367] | +| tess_grazing_smeared | tls_detected | 1/1 finite; +0.933% [+0.933, +0.933] | 1/1 finite; +0.631% [+0.631, +0.631] | +| tess_grazing_smeared | tls_missed_including_failures | 255/255 finite; +0.940% [-3.714, +3.582] | 255/255 finite; +0.708% [-4.038, +4.190] | +| hatpi_short | tls_detected | 3/3 finite; +0.917% [+0.824, +1.210] | 3/3 finite; +0.060% [-0.002, +1.708] | +| hatpi_short | tls_missed_including_failures | 243/253 finite; +0.903% [-18.204, +1.564] | 243/253 finite; +0.338% [-21.526, +3.539] | + +Original TLS decisions at 1% target FPR. + +| Regime | Group | White-noise family/box advantage | OU-noise family/box advantage | +| --- | --- | --- | --- | +| tess_solar | tls_detected | 53/53 finite; +0.953% [+0.554, +1.279] | 53/53 finite; +0.958% [-0.961, +1.810] | +| tess_solar | tls_missed_including_failures | 203/203 finite; +0.951% [+0.499, +1.408] | 203/203 finite; +0.922% [-1.411, +1.894] | +| tess_highimpact | tls_detected | 112/112 finite; +0.992% [+0.304, +1.517] | 112/112 finite; +0.507% [-1.552, +1.878] | +| tess_highimpact | tls_missed_including_failures | 144/144 finite; +0.957% [+0.319, +1.433] | 144/144 finite; +0.471% [-1.249, +2.182] | +| tess_eccentric | tls_detected | 73/73 finite; +0.938% [-3.120, +1.477] | 73/73 finite; +0.853% [-6.395, +1.940] | +| tess_eccentric | tls_missed_including_failures | 183/183 finite; +0.877% [-18.531, +1.592] | 183/183 finite; +0.468% [-29.780, +2.260] | +| tess_mdwarf | tls_detected | 144/144 finite; +1.359% [-2.053, +2.339] | 144/144 finite; +0.904% [-5.138, +2.638] | +| tess_mdwarf | tls_missed_including_failures | 112/112 finite; +1.386% [-0.513, +2.404] | 112/112 finite; +0.875% [-4.560, +2.856] | +| ztf_solar | tls_detected | 176/176 finite; +0.621% [-0.851, +1.919] | 176/176 finite; +0.600% [-0.611, +2.767] | +| ztf_solar | tls_missed_including_failures | 78/80 finite; +0.471% [-0.530, +2.025] | 78/80 finite; +0.450% [-0.737, +2.178] | +| ztf_highimpact | tls_detected | 131/131 finite; +0.101% [-1.439, +1.668] | 131/131 finite; +0.149% [-1.520, +1.981] | +| ztf_highimpact | tls_missed_including_failures | 125/125 finite; +0.209% [-51.353, +2.381] | 125/125 finite; +0.224% [-51.518, +2.432] | +| ztf_mdwarf | tls_detected | 98/98 finite; +0.446% [-6.636, +2.862] | 98/98 finite; +0.503% [-7.971, +3.192] | +| ztf_mdwarf | tls_missed_including_failures | 158/158 finite; +0.269% [-37.484, +3.317] | 158/158 finite; +0.233% [-37.868, +3.790] | +| tess_gap_long | tls_detected | 23/23 finite; +0.877% [+0.572, +1.907] | 23/23 finite; +0.561% [-0.730, +2.412] | +| tess_gap_long | tls_missed_including_failures | 232/233 finite; +0.957% [-44.885, +2.698] | 232/233 finite; +0.441% [-50.768, +2.367] | +| tess_grazing_smeared | tls_detected | 0/0 finite — unavailable | 0/0 finite — unavailable | +| tess_grazing_smeared | tls_missed_including_failures | 256/256 finite; +0.936% [-3.714, +3.582] | 256/256 finite; +0.708% [-4.038, +4.190] | +| hatpi_short | tls_detected | 0/0 finite — unavailable | 0/0 finite — unavailable | +| hatpi_short | tls_missed_including_failures | 246/256 finite; +0.904% [-18.204, +1.564] | 246/256 finite; +0.333% [-21.526, +3.539] | + +Full native/box SNR distributions are in [snr_descriptive.csv](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/snr_descriptive.csv"); the measured case values and original decision join are in [snr_cases.csv](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/snr_cases.csv"). + +## Baseline versus optimized TLS: finite implementation qualification + +**Aggregate exactness is withheld. Original mismatches or unavailable valid executions remain failures, regardless of diagnostic repeats.** + +This checks the full available period/chi-squared/mask hashes, selected period/SDE, recovery and both frozen-threshold decisions. It does not establish universal numerical or physical equivalence. BLS is absent from this comparison. + +| Regime | Split | Planned | Compared | Exact | Mismatches | Candidate invalid | Baseline invalid | Pending repeats | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| tess_solar | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_solar | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_highimpact | injections | 256 | 256 | 255 | 1 | 0 | 0 | 0 | +| tess_highimpact | nulls | 256 | 256 | 255 | 1 | 0 | 0 | 0 | +| tess_eccentric | injections | 256 | 256 | 255 | 1 | 0 | 0 | 0 | +| tess_eccentric | nulls | 256 | 256 | 254 | 2 | 0 | 0 | 0 | +| tess_mdwarf | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_mdwarf | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_solar | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_solar | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_highimpact | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_highimpact | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_mdwarf | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| ztf_mdwarf | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_gap_long | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_gap_long | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_grazing_smeared | injections | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| tess_grazing_smeared | nulls | 256 | 256 | 256 | 0 | 0 | 0 | 0 | +| hatpi_short | injections | 256 | 256 | 254 | 2 | 0 | 0 | 0 | +| hatpi_short | nulls | 256 | 256 | 254 | 2 | 0 | 0 | 0 | + +Individual implementation failures are retained in [exactness_mismatches.csv](exactness_mismatches.csv); the original source JSON retains every diagnostic repeat and any full-array mismatch artifacts. + +## Machine-readable tables and provenance + +[Recovery/FPR](recovery_fpr.csv), [paired contrasts](paired_contrasts.csv), [all subgroups](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/subgroups.csv"), [thresholds](thresholds.csv), [per-regime exactness](exactness.csv), [provenance](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json"). + +All interval bounds in the CSVs preserve the original JSON values. Displayed percentages are rounded only for readability. + +| Source | SHA256 | +| --- | --- | +| recovery | e4bb50e577e77d9148f044ed92de5d2b1df5966155f419fabe8b16c8f528e7ae | +| seal | 1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 | +| exactness | 1931a7a9f7ab7c34f7e882406926da7e44b446c7d8c7fa4b57120dde4fe4c9eb | +| snr | 0b662ca2b11ea286cc9fced82d65152ad0aee2bd41b76ba137a93e449f6b9dd3 | +| renderer | e3fdeb74daa7393ee927d79b62c24f25b47c4904e774d536fdae8800dc8eb97f | diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/exactness.csv b/benchmarks/results/tls_survey_2026-09-10/final-report/exactness.csv new file mode 100644 index 00000000..2cecfa3d --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/exactness.csv @@ -0,0 +1,21 @@ +regime,split,planned,compared,exact,mismatches,candidate_invalid,baseline_invalid,pending_repeat_diagnostics +tess_solar,injections,256,256,256,0,0,0,0 +tess_solar,nulls,256,256,256,0,0,0,0 +tess_highimpact,injections,256,256,255,1,0,0,0 +tess_highimpact,nulls,256,256,255,1,0,0,0 +tess_eccentric,injections,256,256,255,1,0,0,0 +tess_eccentric,nulls,256,256,254,2,0,0,0 +tess_mdwarf,injections,256,256,256,0,0,0,0 +tess_mdwarf,nulls,256,256,256,0,0,0,0 +ztf_solar,injections,256,256,256,0,0,0,0 +ztf_solar,nulls,256,256,256,0,0,0,0 +ztf_highimpact,injections,256,256,256,0,0,0,0 +ztf_highimpact,nulls,256,256,256,0,0,0,0 +ztf_mdwarf,injections,256,256,256,0,0,0,0 +ztf_mdwarf,nulls,256,256,256,0,0,0,0 +tess_gap_long,injections,256,256,256,0,0,0,0 +tess_gap_long,nulls,256,256,256,0,0,0,0 +tess_grazing_smeared,injections,256,256,256,0,0,0,0 +tess_grazing_smeared,nulls,256,256,256,0,0,0,0 +hatpi_short,injections,256,256,254,2,0,0,0 +hatpi_short,nulls,256,256,254,2,0,0,0 diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/exactness_mismatches.csv b/benchmarks/results/tls_survey_2026-09-10/final-report/exactness_mismatches.csv new file mode 100644 index 00000000..ecf0b208 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/exactness_mismatches.csv @@ -0,0 +1,10 @@ +regime,split,name,input_sha256,differences,candidate_valid,baseline_valid,candidate_error,baseline_error,repeat_status +tess_highimpact,injections,tess_highimpact_injections_0125,f97f3b93098c92361e08e1b2abf774856a022ec3a107223927d17abf17c9c75e,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +tess_highimpact,nulls,tess_highimpact_nulls_0220,cb3f0dde603e6dc543b06c1f9597135bfeac268a238dd5fe69176efa9c22d81c,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +tess_eccentric,injections,tess_eccentric_injections_0018,f2612f36da70126a9b04dea2ba07794c66ea958b84ef7e262895bd08497e6188,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +tess_eccentric,nulls,tess_eccentric_nulls_0101,3eefb12dd7d8ab27bb1f4f25931eff1f00ffff9ad54a9c1aa4f463465b61a550,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +tess_eccentric,nulls,tess_eccentric_nulls_0137,b446929b9ec092714d69f2b558ccfdfbdd82be7746920ebd7e1fa98fc53657aa,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +hatpi_short,injections,hatpi_short_injections_0004,b903fad17e93517c5d7e0793674763fd1214e402fe9839c35caac2f130d817a1,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +hatpi_short,injections,hatpi_short_injections_0031,8d7a1222c0d58dcbeb1ddec027c93ac47078755edc27fe36d90c9f245fee3a6c,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +hatpi_short,nulls,hatpi_short_nulls_0193,ef4fb793e79c6b36a2351ef9c88342f69c2e2ea13004de5768328369af8778cc,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete +hatpi_short,nulls,hatpi_short_nulls_0251,62523881f6a56077022437ba999ed26ef374b0eb027a51dc054757da343381fa,spectrum/chi2; candidate_period_score_recovery,True,True,,,complete diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/paired_contrasts.csv b/benchmarks/results/tls_survey_2026-09-10/final-report/paired_contrasts.csv new file mode 100644 index 00000000..9bc7e660 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/paired_contrasts.csv @@ -0,0 +1,81 @@ +regime,target_fpr,endpoint,bound,n,tls_only,bls_only,difference,interval_lower,interval_upper,stored_individual_confidence,construction +tess_solar,0.05,recovery,marginal,256,38,5,0.12890625,0.05301895960634103,0.1999885297331419,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.05,recovery,simultaneous_family,256,38,5,0.12890625,0.013677220233607137,0.2349720215805475,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.05,fpr,marginal,256,8,14,-0.0234375,-0.08358370023374452,0.03770873244587032,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.05,fpr,simultaneous_family,256,8,14,-0.0234375,-0.11378726541842446,0.06869115109404421,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.01,recovery,marginal,256,39,5,0.1328125,0.05633666103647047,0.20436087181564305,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.01,recovery,simultaneous_family,256,39,5,0.1328125,0.016661498149790482,0.2395413493560759,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.01,fpr,marginal,256,6,3,0.01171875,-0.030171939147462884,0.05276855458148131,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_solar,0.01,fpr,simultaneous_family,256,6,3,0.01171875,-0.051969992585299246,0.07448140706236109,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.05,recovery,marginal,256,75,0,0.29296875,0.2137256102148199,0.361395134530731,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.05,recovery,simultaneous_family,256,75,0,0.29296875,0.17047644856341773,0.39774015691976633,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.05,fpr,marginal,256,14,15,-0.00390625,-0.07277376652734832,0.0651099600191266,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.05,fpr,simultaneous_family,256,14,15,-0.00390625,-0.10730177205980831,0.099773425608183,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.01,recovery,marginal,256,79,0,0.30859375,0.2281776174034516,0.37772548425318214,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.01,recovery,simultaneous_family,256,79,0,0.30859375,0.18418219682949769,0.4142755470574769,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.01,fpr,marginal,256,6,7,-0.00390625,-0.05263794787670656,0.04504102372016667,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_highimpact,0.01,fpr,simultaneous_family,256,6,7,-0.00390625,-0.07756658322759415,0.07007169466218183,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.05,recovery,marginal,256,57,2,0.21484375,0.13550003210627412,0.28608211470014605,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.05,recovery,simultaneous_family,256,57,2,0.21484375,0.09339316168010664,0.32146710612623125,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.05,fpr,marginal,256,19,9,0.0390625,-0.028494413431014387,0.10508841994592531,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.05,fpr,simultaneous_family,256,19,9,0.0390625,-0.06275590539526223,0.1380115431716229,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.01,recovery,marginal,256,69,2,0.26171875,0.17783881198765086,0.33607753463345935,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.01,recovery,simultaneous_family,256,69,2,0.26171875,0.13298248478648006,0.3725850601867362,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.01,fpr,marginal,256,6,1,0.01953125,-0.017290909616564272,0.05457691426394981,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_eccentric,0.01,fpr,simultaneous_family,256,6,1,0.01953125,-0.03683684736409428,0.07497921957412955,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.05,recovery,marginal,256,94,0,0.3671875,0.2831131279929324,0.43823040358009924,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.05,recovery,simultaneous_family,256,94,0,0.3671875,0.23669266062945146,0.47517776604985884,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.05,fpr,marginal,256,10,15,-0.01953125,-0.08349062939545526,0.04521543509999486,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.05,fpr,simultaneous_family,256,10,15,-0.01953125,-0.11552874023782281,0.07791934603484421,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.01,recovery,marginal,256,107,0,0.41796875,0.3315918948467302,0.48980564608892235,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.01,recovery,simultaneous_family,256,107,0,0.41796875,0.2835084636122833,0.5266602128442532,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.01,fpr,marginal,256,1,3,-0.0078125,-0.03749703255065096,0.02280764333728385,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_mdwarf,0.01,fpr,simultaneous_family,256,1,3,-0.0078125,-0.05544968828346976,0.03981873055049632,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.05,recovery,marginal,256,3,17,-0.0546875,-0.10810580030133371,0.0014230110291638537,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.05,recovery,simultaneous_family,256,3,17,-0.0546875,-0.13549470979132186,0.0303001398299877,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.05,fpr,marginal,256,6,8,-0.0078125,-0.057919550913105994,0.042708566908034436,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.05,fpr,simultaneous_family,256,6,8,-0.0078125,-0.08348158192265732,0.06848691650389155,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.01,recovery,marginal,256,2,18,-0.0625,-0.11406214112064857,-0.007691772962470332,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.01,recovery,simultaneous_family,256,2,18,-0.0625,-0.141031875524833,0.02067710479688799,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.01,fpr,marginal,256,0,1,-0.00390625,-0.024665137680600906,0.016922488380280466,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_solar,0.01,fpr,simultaneous_family,256,0,1,-0.00390625,-0.04031776395541911,0.031033962681686567,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.05,recovery,marginal,256,12,24,-0.046875,-0.12055683416821214,0.028502579654092836,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.05,recovery,simultaneous_family,256,12,24,-0.046875,-0.1571170940077443,0.06669784690411368,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.05,fpr,marginal,256,8,5,0.01171875,-0.037194600779760174,0.05997869393823143,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.05,fpr,simultaneous_family,256,8,5,0.01171875,-0.062234682175147396,0.08472144881133357,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.01,recovery,marginal,256,8,33,-0.09765625,-0.1713443571001541,-0.020434445192845707,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.01,recovery,simultaneous_family,256,8,33,-0.09765625,-0.2076031066507355,0.019202276674090246,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.01,fpr,marginal,256,0,1,-0.00390625,-0.024665137680600906,0.016922488380280466,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_highimpact,0.01,fpr,simultaneous_family,256,0,1,-0.00390625,-0.04031776395541911,0.031033962681686567,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.05,recovery,marginal,256,16,37,-0.08203125,-0.16768135419547966,0.006353316796313513,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.05,recovery,simultaneous_family,256,16,37,-0.08203125,-0.20982442621751127,0.05137050799056661,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.05,fpr,marginal,256,13,6,0.02734375,-0.029801946114665696,0.08322770055124726,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.05,fpr,simultaneous_family,256,13,6,0.02734375,-0.05888072931507783,0.1114460116570914,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.01,recovery,marginal,256,16,40,-0.09375,-0.18079873340567582,-0.003599413740864227,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.01,recovery,simultaneous_family,256,16,40,-0.09375,-0.22353293462315868,0.04241822117260741,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.01,fpr,marginal,256,5,1,0.015625,-0.019350052641689708,0.04906294813567555,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +ztf_mdwarf,0.01,fpr,simultaneous_family,256,5,1,0.015625,-0.03807671425277054,0.0687269852453854,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.05,recovery,marginal,256,4,23,-0.07421875,-0.1346175312731417,-0.010637120093813394,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.05,recovery,simultaneous_family,256,4,23,-0.07421875,-0.1649760872032709,0.022069250024376923,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.05,fpr,marginal,256,8,9,-0.00390625,-0.05857023570804133,0.050943374613452594,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.05,fpr,simultaneous_family,256,8,9,-0.00390625,-0.0862559885822961,0.0787509575421446,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.01,recovery,marginal,256,3,27,-0.09375,-0.15449517837806492,-0.028974258824104232,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.01,recovery,simultaneous_family,256,3,27,-0.09375,-0.18517092329082527,0.004559739352563839,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.01,fpr,marginal,256,2,0,0.0078125,-0.016317801351536536,0.031350547463748824,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_gap_long,0.01,fpr,simultaneous_family,256,2,0,0.0078125,-0.030936516032969874,0.04822625080999568,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.05,recovery,marginal,256,0,108,-0.421875,-0.4937416712669717,-0.3353524727740188,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.05,recovery,simultaneous_family,256,0,108,-0.421875,-0.5305732227942764,-0.2871570400827575,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.05,fpr,marginal,256,11,12,-0.00390625,-0.06619778816182587,0.05854701518917046,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.05,fpr,simultaneous_family,256,11,12,-0.00390625,-0.09752776768281557,0.0900090987958228,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.01,recovery,marginal,256,0,97,-0.37890625,-0.4502006835292507,-0.29423187846353466,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.01,recovery,simultaneous_family,256,0,97,-0.37890625,-0.4871612698000275,-0.24739290723896076,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.01,fpr,marginal,256,1,0,0.00390625,-0.016922488380280466,0.024665137680600906,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +tess_grazing_smeared,0.01,fpr,simultaneous_family,256,1,0,0.00390625,-0.031033962681686567,0.04031776395541911,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.05,recovery,marginal,256,3,0,0.01171875,-0.015114128697811965,0.03754616721149952,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.05,recovery,simultaneous_family,256,3,0,0.01171875,-0.03053615016991809,0.055450909176624076,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.05,fpr,marginal,256,9,3,0.0234375,-0.023195762847809488,0.0686302233814882,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.05,fpr,simultaneous_family,256,9,3,0.0234375,-0.04723936820478654,0.09225047914076565,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.01,recovery,marginal,256,0,0,0.0,-0.016971623041129022,0.016971623041129022,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.01,recovery,simultaneous_family,256,0,0,0.0,-0.031035183574840884,0.031035183574840884,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.01,fpr,marginal,256,5,1,0.015625,-0.019350052641689708,0.04906294813567555,0.95,Bonferroni exact binomial bounds on the two discordant probabilities +hatpi_short,0.01,fpr,simultaneous_family,256,5,1,0.015625,-0.03807671425277054,0.0687269852453854,0.99875,Bonferroni exact binomial bounds on the two discordant probabilities diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/recovery_fpr.csv b/benchmarks/results/tls_survey_2026-09-10/final-report/recovery_fpr.csv new file mode 100644 index 00000000..13adb906 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/recovery_fpr.csv @@ -0,0 +1,41 @@ +regime,target_fpr,method,configuration,ranker,detected,n_injections,recovery,false_positives,n_nulls,fpr,failed_injections,failed_nulls,aliases_including_fundamental,recovery_interval95_lower,recovery_interval95_upper,fpr_interval95_lower,fpr_interval95_upper +tess_solar,0.05,tls,tls,native,73,256,0.28515625,14,256,0.0546875,0,0,73,0.2306739562256708,0.34469054084595635,0.03021717535292447,0.0900547978340569 +tess_solar,0.05,bls,bls_strong,likelihood,40,256,0.15625,20,256,0.078125,0,0,44,0.11403373981161252,0.20663267534771496,0.04837229150657823,0.11808012478450235 +tess_solar,0.01,tls,tls,native,53,256,0.20703125,7,256,0.02734375,0,0,53,0.15909676373371917,0.2618882553027363,0.011062786808498114,0.05552459054714268 +tess_solar,0.01,bls,bls_strong,likelihood,19,256,0.07421875,4,256,0.015625,0,0,21,0.045273751053621344,0.11347692881153072,0.004273272485754633,0.039520756533374925 +tess_highimpact,0.05,tls,tls,native,128,256,0.5,17,256,0.06640625,0,0,130,0.4371086489403644,0.5628913510596356,0.039156799438977076,0.1041955690615258 +tess_highimpact,0.05,bls,bls_strong,detrended,53,256,0.20703125,18,256,0.0703125,0,0,54,0.15909676373371917,0.2618882553027363,0.04220124193023629,0.10884934667326522 +tess_highimpact,0.01,tls,tls,native,112,256,0.4375,6,256,0.0234375,0,0,113,0.37581636723213185,0.5006476615159835,0.008648616196162343,0.05031290342281495 +tess_highimpact,0.01,bls,bls_strong,detrended,33,256,0.12890625,7,256,0.02734375,0,0,33,0.09042083411624686,0.1762455944331089,0.011062786808498114,0.05552459054714268 +tess_eccentric,0.05,tls,tls,native,83,256,0.32421875,19,256,0.07421875,0,0,86,0.2672621250614532,0.38530257754550207,0.045273751053621344,0.11347692881153072 +tess_eccentric,0.05,bls,bls_strong,likelihood,28,256,0.109375,9,256,0.03515625,0,0,28,0.07392025520532458,0.15418752971930533,0.016199410860957122,0.06568621418387195 +tess_eccentric,0.01,tls,tls,native,73,256,0.28515625,6,256,0.0234375,0,0,75,0.2306739562256708,0.34469054084595635,0.008648616196162343,0.05031290342281495 +tess_eccentric,0.01,bls,bls_strong,likelihood,6,256,0.0234375,1,256,0.00390625,0,0,6,0.008648616196162343,0.05031290342281495,9.889279722355363e-05,0.021570890352533498 +tess_mdwarf,0.05,tls,tls,native,154,256,0.6015625,10,256,0.0390625,0,0,155,0.5387453741752993,0.6619993431702311,0.018888275719460015,0.07066228590012934 +tess_mdwarf,0.05,bls,bls_strong,detrended,60,256,0.234375,15,256,0.05859375,0,0,61,0.18387802870585904,0.29113555774332106,0.03316183202667131,0.0948008584555743 +tess_mdwarf,0.01,tls,tls,native,144,256,0.5625,1,256,0.00390625,0,0,145,0.4993523384840165,0.6241836327678681,9.889279722355363e-05,0.021570890352533498 +tess_mdwarf,0.01,bls,bls_strong,detrended,37,256,0.14453125,3,256,0.01171875,0,0,38,0.10384866418338659,0.19367285361748135,0.0024232387538726603,0.033863282636291465 +ztf_solar,0.05,tls,tls,native,183,256,0.71484375,8,256,0.03125,0,0,184,0.6553094591540437,0.7693260437743291,0.013586185897148497,0.06064405087196582 +ztf_solar,0.05,bls,bls_medium,raw,197,256,0.76953125,10,256,0.0390625,0,0,197,0.7130236816002221,0.819681436200814,0.018888275719460015,0.07066228590012934 +ztf_solar,0.01,tls,tls,native,176,256,0.6875,0,256,0.0,0,0,177,0.6268318317795951,0.7437640291637292,0.0,0.014306362729039375 +ztf_solar,0.01,bls,bls_medium,raw,192,256,0.75,1,256,0.00390625,0,0,192,0.6922867037913965,0.8018239353369248,9.889279722355363e-05,0.021570890352533498 +ztf_highimpact,0.05,tls,tls,native,147,256,0.57421875,16,256,0.0625,0,0,149,0.5111299229592188,0.6355688203803116,0.03614276078982248,0.0995135311004295 +ztf_highimpact,0.05,bls,bls_strong,likelihood,159,256,0.62109375,13,256,0.05078125,0,0,163,0.5585883860676195,0.6807600511718108,0.027312525005386005,0.08527212595645979 +ztf_highimpact,0.01,tls,tls,native,131,256,0.51171875,1,256,0.00390625,0,0,133,0.4487068479030514,0.5744562280442491,9.889279722355363e-05,0.021570890352533498 +ztf_highimpact,0.01,bls,bls_strong,likelihood,156,256,0.609375,2,256,0.0078125,0,0,160,0.5466706383112345,0.6695156296045671,0.0009475342881075704,0.027934914204054754 +ztf_mdwarf,0.05,tls,tls,native,103,256,0.40234375,15,256,0.05859375,0,0,113,0.3417647376907699,0.46521134883973414,0.03316183202667131,0.0948008584555743 +ztf_mdwarf,0.05,bls,bls_strong,likelihood,124,256,0.484375,8,256,0.03125,0,0,128,0.42169622251474875,0.5474196792624141,0.013586185897148497,0.06064405087196582 +ztf_mdwarf,0.01,tls,tls,native,98,256,0.3828125,5,256,0.01953125,0,0,106,0.32298405823401255,0.4453882055205323,0.006371420454805813,0.04498832111637432 +ztf_mdwarf,0.01,bls,bls_strong,likelihood,122,256,0.4765625,1,256,0.00390625,0,0,126,0.4140122531039423,0.5396616126072175,9.889279722355363e-05,0.021570890352533498 +tess_gap_long,0.05,tls,tls,native,40,256,0.15625,12,256,0.046875,0,0,42,0.11403373981161252,0.20663267534771496,0.02445234396943696,0.08044902822855146 +tess_gap_long,0.05,bls,bls_fine,likelihood,59,256,0.23046875,13,256,0.05078125,0,0,61,0.180318563799186,0.2869763183997779,0.027312525005386005,0.08527212595645979 +tess_gap_long,0.01,tls,tls,native,23,256,0.08984375,2,256,0.0078125,0,0,24,0.057807257482065985,0.13175848654719313,0.0009475342881075704,0.027934914204054754 +tess_gap_long,0.01,bls,bls_fine,likelihood,47,256,0.18359375,0,256,0.0,0,0,48,0.13812765290471316,0.236552473196494,0.0,0.014306362729039375 +tess_grazing_smeared,0.05,tls,tls,native,1,256,0.00390625,11,256,0.04296875,0,0,2,9.889279722355363e-05,0.021570890352533498,0.021642040604303178,0.07558093377042933 +tess_grazing_smeared,0.05,bls,bls_medium,likelihood,109,256,0.42578125,12,256,0.046875,0,0,111,0.3644311796196885,0.48887007704078106,0.02445234396943696,0.08044902822855146 +tess_grazing_smeared,0.01,tls,tls,native,0,256,0.0,1,256,0.00390625,0,0,0,0.0,0.014306362729039375,9.889279722355363e-05,0.021570890352533498 +tess_grazing_smeared,0.01,bls,bls_medium,likelihood,97,256,0.37890625,0,256,0.0,0,0,98,0.3192399488281891,0.4414116139323805,0.0,0.014306362729039375 +hatpi_short,0.05,tls,tls,native,3,256,0.01171875,10,256,0.0390625,0,0,3,0.0024232387538726603,0.033863282636291465,0.018888275719460015,0.07066228590012934 +hatpi_short,0.05,bls,bls_strong,likelihood,0,256,0.0,4,256,0.015625,0,0,0,0.0,0.014306362729039375,0.004273272485754633,0.039520756533374925 +hatpi_short,0.01,tls,tls,native,0,256,0.0,5,256,0.01953125,0,0,0,0.0,0.014306362729039375,0.006371420454805813,0.04498832111637432 +hatpi_short,0.01,bls,bls_strong,likelihood,0,256,0.0,1,256,0.00390625,0,0,0,0.0,0.014306362729039375,9.889279722355363e-05,0.021570890352533498 diff --git a/benchmarks/results/tls_survey_2026-09-10/final-report/thresholds.csv b/benchmarks/results/tls_survey_2026-09-10/final-report/thresholds.csv new file mode 100644 index 00000000..8b6915f3 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-report/thresholds.csv @@ -0,0 +1,41 @@ +regime,method,configuration,ranker,attainable_marginal_fpr,calibration,calibration_scores_above,calibration_scores_at_threshold,calibration_strict_exceedance_fraction,calibration_zero_scores,decision,extra_conservatism_from_ties,marginal_fpr_upper_bound,n,rank_1based,target_fpr,value +tess_solar,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,7.208757400512695 +tess_solar,bls,bls_strong,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,148.17723083496094 +tess_solar,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,8.045271873474121 +tess_solar,bls,bls_strong,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,172.89230346679688 +tess_highimpact,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,7.663411617279053 +tess_highimpact,bls,bls_strong,detrended,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,7.209781652608563 +tess_highimpact,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,8.72483296426546 +tess_highimpact,bls,bls_strong,detrended,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,8.031864999238893 +tess_eccentric,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,7.886537075042725 +tess_eccentric,bls,bls_strong,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,156.35879516601562 +tess_eccentric,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,9.101484298706055 +tess_eccentric,bls,bls_strong,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,197.4506378173828 +tess_mdwarf,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,8.732659339904785 +tess_mdwarf,bls,bls_strong,detrended,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,7.195139943286286 +tess_mdwarf,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,10.082538604736328 +tess_mdwarf,bls,bls_strong,detrended,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,8.182490558509917 +ztf_solar,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,10.934158325195312 +ztf_solar,bls,bls_medium,raw,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,0.031805649399757385 +ztf_solar,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,12.117471694946289 +ztf_solar,bls,bls_medium,raw,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,0.034117940813302994 +ztf_highimpact,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,11.53281021118164 +ztf_highimpact,bls,bls_strong,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,49.10021209716797 +ztf_highimpact,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,13.06347370147705 +ztf_highimpact,bls,bls_strong,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,52.970184326171875 +ztf_mdwarf,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,11.998270034790039 +ztf_mdwarf,bls,bls_strong,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,50.67043685913086 +ztf_mdwarf,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,12.6873779296875 +ztf_mdwarf,bls,bls_strong,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,54.972225189208984 +tess_gap_long,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,10.617395401000977 +tess_gap_long,bls,bls_fine,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,81.55892181396484 +tess_gap_long,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,12.432835578918457 +tess_gap_long,bls,bls_fine,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,98.37548828125 +tess_grazing_smeared,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,38,strict exceedance,False,0.04873294346978557,512,488,0.05,7.612651665488954 +tess_grazing_smeared,bls,bls_medium,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,41.80722427368164 +tess_grazing_smeared,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,38,strict exceedance,False,0.009746588693957114,512,508,0.01,8.717121702476923 +tess_grazing_smeared,bls,bls_medium,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,53.666297912597656 +hatpi_short,tls,tls,native,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,6.821170330047607 +hatpi_short,bls,bls_strong,likelihood,0.04873294346978557,independent split-conformal order statistic,24,1,0.046875,0,strict exceedance,False,0.04873294346978557,512,488,0.05,509.07257080078125 +hatpi_short,tls,tls,native,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,7.739882469177246 +hatpi_short,bls,bls_strong,likelihood,0.009746588693957114,independent split-conformal order statistic,4,1,0.0078125,0,strict exceedance,False,0.009746588693957114,512,508,0.01,628.8975830078125 diff --git a/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING.md b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING.md new file mode 100644 index 00000000..578c0dd3 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING.md @@ -0,0 +1,31 @@ +# Collected throughput results + +The opt-in experimental TLS candidate has a qualified timing-cohort median speed ratio of **1.850× on ZTF solar** and **1.007× on long-gap TESS**. These are finite-cohort timing comparisons. Full held-out exactness is **5111/5120; aggregate qualification is WITHHELD**. The frozen figure label “Optimized” refers to this experimental candidate, not the release default. + +The combined figure preserves 7 available and 9 unavailable backend/panel results. No native BLS execution rate was obtained: all three development worker-count pilots failed the launcher/thread-environment check before workers were created. Original BLS numerical repeatability also remains failed, independently. + +[Figure (PNG)](survey-throughput-with-native-bls.png) · [PDF](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.pdf") · [SVG](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.svg") · [exact values CSV](survey-throughput-with-native-bls.csv) · [renderer provenance](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.data.json"). + +| Workload | Engine | Workers / batch | Median LC/s [3-repetition range] | Cold first cohort (s) | USD/million, steady / cold-amortized | Sampled GPU / worker RSS (GB) | +|---|---|---:|---:|---:|---:|---:| +| tess_gap_long | Baseline TLS | 4 / 8 | 0.770469 [0.766306–0.773155] | 87.001 | 176.66 / 217.89 | 3.894 / 1.691 | +| ztf_solar | Baseline TLS | 4 / 8 | 0.452633 [0.451925–0.455362] | 149.757 | 300.71 / 371.04 | 2.610 / 2.114 | +| tess_solar | Experimental TLS | 4 / 4 | 8.203064 [8.039095–8.209117] | 10.244 | 16.59 / 17.17 | 3.793 / 1.437 | +| tess_gap_long | Experimental TLS | 4 / 4 | 0.775998 [0.774032–0.776135] | 86.915 | 175.40 / 216.62 | 3.618 / 1.592 | +| ztf_solar | Experimental TLS | 4 / 4 | 0.837289 [0.826802–0.838994] | 85.627 | 162.56 / 197.83 | 2.526 / 1.746 | +| tess_solar | Public GTLS | 2 / 1 | 2.424906 [2.254147–2.443050] | 18.115 | 56.13 / 60.10 | 8.531 / 0.947 | +| ztf_solar | Public GTLS | 2 / 1 | 0.118107 [0.115367–0.122672] | 272.758 | 1152.44 / 1276.17 | 48.299 / 1.791 | + +Each backend was tuned independently on development inputs: workers 1/2/4 at batch 1, then batches 4/8 at the eligible winning worker count. Frozen winners were baseline 4/8, experimental 4/4, public GTLS 2/1. This is the best eligible setting in the explored conditional space, not a global optimum. Native GTLS internal period grouping retained its pinned memory heuristic; that internal knob was not independently tuned. + +All available rows use three whole-cohort queue repetitions, each ≥96 calls and ≥120 seconds, on the same A40, 7.65 CPU quota, 49,999,998,976-byte RAM allocation and $0.49/hour compute rate. Ordinary panels repeat 16 fresh null inputs; the varied panel uses 96 distinct deterministically masked null inputs. The varied workload has no qualifying result. Min/max are the observed repetition range, not confidence intervals. + +Queue wall time includes dispatch, public API validation, template preparation, transfers, search/refinement, result construction and scalar checking. Explicit grids were regenerated once per worker/configuration, byte checked and charged to cold preparation; imports, context creation, input loading and first-cohort full-output checks were also recorded separately and amortized. Existing filesystem/compiler caches were retained. The cold column is the first complete cohort including setup, not single-lightcurve cold latency. Projected cost uses pooled measured rates (so it need not equal inverse median), excludes data acquisition, detrending and vetting, and is not a million-source run. Memory values are sampled lower bounds in decimal GB; container lifetime peaks are not per-configuration peaks. Exact repetition counts, prep timings, cost and source hashes are retained in [timing-verification.json](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/timing-verification.json"). + +The short-row prefix dispatch was active in all four experimental ZTF workers with zero recorded fallback calls. TESS solar and long-gap rows used its shape fallback. Thus the strongest measured improvement coincides with the intended short-row path; these observations alone do not isolate each optimization’s causal contribution. + +Exclusions remain unchanged: baseline TESS solar failed post-queue full-output/selected-score qualification after all three queues; baseline varied failed pre-queue qualification; the experimental varied fresh one-worker reference failed its post-queue gate, so the selected pool was never launched; public GTLS gap and varied passed initial full-output checks but failed with API out-of-memory errors in their first queues, leaving no complete repetitions. The primary BLS comparison had no qualifying development setting. Original failed receipts and unavailable-only reference receipts remain in the verified collection. Scalar period stability does not convert a changed SDE/spectrum into a passed gate, and none of these events is assigned as the cause of the nine held-out differences. + +The supplemental BLS launcher set OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS and NUMBA_NUM_THREADS to `1`, but left VECLIB_MAXIMUM_THREADS and NUMEXPR_NUM_THREADS unset (`null` in each receipt). The runner required all six recorded values to be `1` and raised `ValueError: Numerical CPU threads must remain one` before Pool creation. All worker-count pilots 1/2/4 failed this same allocation precheck; batches 4/8 had no winner to advance. Measurement completed with four explicitly unavailable panels and zero configurations. This is a launcher/validation integration failure, not a measured native BLS API, numerical, or actual multithreading failure. No rerun, replacement bar, relaxed gate or successful-throughput claim was made. [Raw launch provenance and source pins](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json"). + +Collection byte verification and safe supplement extraction are recorded in [collection-verification.json](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/collection-verification.json"). The original strict figure outputs were rechecked against their archived inventory and provenance and remain unchanged. Completion/collection status is separate from scientific and numerical qualification. Lifecycle and final ledger verification are owned by the parent task. diff --git a/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md new file mode 100644 index 00000000..cc12288a --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md @@ -0,0 +1,33 @@ +# Collected throughput results + +The opt-in experimental TLS candidate has a qualified timing-cohort median speed ratio of **1.850× on ZTF solar** and **1.007× on long-gap TESS**. These are finite-cohort timing comparisons. Full held-out exactness is **5111/5120; aggregate qualification is WITHHELD**. The frozen figure label “Optimized” refers to this experimental candidate, not the release default. + +The combined figure preserves 7 available and 9 unavailable backend/panel results. No native BLS execution rate was obtained: all three development worker-count pilots failed the launcher/thread-environment check before workers were created. Original BLS numerical repeatability also remains failed, independently. + +[Figure (PNG)](../../final-figures/survey-throughput-with-native-bls.png) · [PDF](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.pdf") · [SVG](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.svg") · [exact values CSV](../../final-figures/survey-throughput-with-native-bls.csv) · [renderer provenance](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json"). + +| Workload | Engine | Workers / batch | Median LC/s [3-repetition range] | Cold first cohort (s) | USD/million, steady / cold-amortized | Sampled GPU / worker RSS (GB) | +|---|---|---:|---:|---:|---:|---:| +| tess_gap_long | Baseline TLS | 4 / 8 | 0.770469 [0.766306–0.773155] | 87.001 | 176.66 / 217.89 | 3.894 / 1.691 | +| ztf_solar | Baseline TLS | 4 / 8 | 0.452633 [0.451925–0.455362] | 149.757 | 300.71 / 371.04 | 2.610 / 2.114 | +| tess_solar | Experimental TLS | 4 / 4 | 8.203064 [8.039095–8.209117] | 10.244 | 16.59 / 17.17 | 3.793 / 1.437 | +| tess_gap_long | Experimental TLS | 4 / 4 | 0.775998 [0.774032–0.776135] | 86.915 | 175.40 / 216.62 | 3.618 / 1.592 | +| ztf_solar | Experimental TLS | 4 / 4 | 0.837289 [0.826802–0.838994] | 85.627 | 162.56 / 197.83 | 2.526 / 1.746 | +| tess_solar | Public GTLS | 2 / 1 | 2.424906 [2.254147–2.443050] | 18.115 | 56.13 / 60.10 | 8.531 / 0.947 | +| ztf_solar | Public GTLS | 2 / 1 | 0.118107 [0.115367–0.122672] | 272.758 | 1152.44 / 1276.17 | 48.299 / 1.791 | + +Each backend was tuned independently on development inputs: workers 1/2/4 at batch 1, then batches 4/8 at the eligible winning worker count. Frozen winners were baseline 4/8, experimental 4/4, public GTLS 2/1. This is the best eligible setting in the explored conditional space, not a global optimum. Native GTLS internal period grouping retained its pinned memory heuristic; that internal knob was not independently tuned. + +All available rows use three whole-cohort queue repetitions, each ≥96 calls and ≥120 seconds, on the same A40, 7.65 CPU quota, 49,999,998,976-byte RAM allocation and $0.49/hour compute rate. Ordinary panels repeat 16 fresh null inputs; the varied panel uses 96 distinct deterministically masked null inputs. The varied workload has no qualifying result. Min/max are the observed repetition range, not confidence intervals. + +Queue wall time includes dispatch, public API validation, template preparation, transfers, search/refinement, result construction and scalar checking. Explicit grids were regenerated once per worker/configuration, byte checked and charged to cold preparation; imports, context creation, input loading and first-cohort full-output checks were also recorded separately and amortized. Existing filesystem/compiler caches were retained. The cold column is the first complete cohort including setup, not single-lightcurve cold latency. Steady USD/million uses the median repetition rate; cold-amortized USD/million uses summed queue elapsed plus preparation divided by total calls. Costs exclude data acquisition, detrending and vetting, and do not represent a million-source run. Memory values are sampled lower bounds in decimal GB; container lifetime peaks are not per-configuration peaks. Exact repetition counts, prep timings, cost and source hashes are retained in [timing-verification.json](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/timing-verification.json"). + +The short-row prefix dispatch was active in all four experimental ZTF workers with zero recorded fallback calls. TESS solar and long-gap rows used its shape fallback. Thus the strongest measured improvement coincides with the intended short-row path; these observations alone do not isolate each optimization’s causal contribution. + +Exclusions remain unchanged: baseline TESS solar failed post-queue full-output/selected-score qualification after all three queues; baseline varied failed pre-queue qualification; the experimental varied fresh one-worker reference failed its post-queue gate, so the selected pool was never launched; public GTLS gap and varied passed initial full-output checks but failed with API out-of-memory errors in their first queues, leaving no complete repetitions. The primary BLS comparison had no qualifying development setting. Original failed receipts and unavailable-only reference receipts remain in the verified collection. Scalar period stability does not convert a changed SDE/spectrum into a passed gate, and none of these events is assigned as the cause of the nine held-out differences. + +The supplemental BLS launcher set OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS and NUMBA_NUM_THREADS to `1`, but left VECLIB_MAXIMUM_THREADS and NUMEXPR_NUM_THREADS unset (`null` in each receipt). The runner required all six recorded values to be `1` and raised `ValueError: Numerical CPU threads must remain one` before Pool creation. All worker-count pilots 1/2/4 failed this same allocation precheck; batches 4/8 had no winner to advance. Measurement completed with four explicitly unavailable panels and zero configurations. This is a launcher/validation integration failure, not a measured native BLS API, numerical, or actual multithreading failure. No rerun, replacement bar, relaxed gate or successful-throughput claim was made. [Raw launch provenance and source pins](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json"). + +Collection byte verification and safe supplement extraction are recorded in [collection-verification.json](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/collection-verification.json"). The original strict figure outputs were rechecked against their archived inventory and provenance and remain unchanged. Completion/collection status is separate from scientific and numerical qualification. Lifecycle and final ledger verification are owned by the parent task. + +Reporting erratum: the byte-preserved source [TIMING.md](TIMING.md) incorrectly described steady projected cost as using pooled rates. The stored table values were already correct. This linked edition corrects that sentence and figure paths only; [the transformation receipt](../../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.transformation.json") records the unchanged source and formula evidence. diff --git a/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.csv b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.csv new file mode 100644 index 00000000..f7fbc582 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.csv @@ -0,0 +1,39 @@ +scope,backend,workers,batch_size,rate_contract,original_numerical_qualification_passed,rate_available,missing_reason,median_lightcurves_per_second,minimum_lightcurves_per_second,maximum_lightcurves_per_second,attempted_count,successful_count,failed_count,completion_fraction,selected_mismatch_count,complete_output_mismatch_count,cold_first_cohort_including_startup_seconds,sampled_gpu_peak_bytes,sampled_worker_rss_peak_bytes,total_measured_compute_usd,usd_per_million_successful_steady,usd_per_million_successful_cold_amortized,cold_amortized_successful_lightcurves_per_second,heldout_exact_cases,heldout_planned_cases,heldout_aggregate_exactness_qualified,timing_cohort_paired_tls_qualification,science_seal_sha256 +tess_solar,baseline,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 773, in run + raise RuntimeError('Post-queue required-output qualification failed') +RuntimeError: Post-queue required-output qualification failed +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,candidate,4,4,original_qualified_timing,True,True,,8.203064070063173,8.03909495391051,8.209117438111315,,,,,,,10.244131383951753,3793158144,1436946432,0.049432611720913296,16.59271583746912,17.171267508767286,7.926678158244034,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,gtls,2,1,original_qualified_timing,True,True,,2.4249058679058546,2.2541474660971055,2.4430499132698036,,,,,,,18.114802494179457,8530690048,946552832,0.05042464483484703,56.13047207834772,60.102580374872865,2.264646713371647,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_solar,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,baseline,4,8,original_qualified_timing,True,True,,0.7704690916588798,0.766305891609122,0.7731550532308258,,,,,,,87.00128701515496,3893821440,1691283456,0.05091131530951501,176.66005370579282,217.89290678209875,0.6246697660848016,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,candidate,4,4,original_qualified_timing,True,True,,0.7759979115902728,0.7740321475147041,0.7761346975505068,,,,,,,86.91488581197336,3617718272,1592274944,0.0505553960809316,175.40138842922278,216.6162422253286,0.6283513632810858,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,gtls,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 135002, 'task': 17, 'indices': [1], 'started': 2174109.762783667, 'ended': 2174114.16471629, 'api_seconds': 4.40193262277171, 'error': 'Traceback (most recent call last):\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 388, in worker\n results = public_call(internal_backend, selected,\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in public_call\n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in \n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/main.py"", line 108, in power\n = core.search_multi_periods(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/core.py"", line 768, in search_multi_periods\n ootrGPU = cp.empty((singleCalcPeriods,len(singleDurations),(tSize)),dtype=cp.float32)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/cupy/_creation/basic.py"", line 32, in empty\n return cupy.ndarray(shape, dtype, order=order)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""cupy/_core/core.pyx"", line 167, in cupy._core.core.ndarray.__new__\n File ""cupy/_core/core.pyx"", line 254, in cupy._core.core._ndarray_base._init\n File ""cupy/cuda/memory.pyx"", line 875, in cupy.cuda.memory.alloc\n File ""cupy/cuda/memory.pyx"", line 1579, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1600, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1271, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1292, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc\n File ""cupy/cuda/memory.pyx"", line 1537, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\n File ""cupy/cuda/memory.pyx"", line 1540, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\ncupy.cuda.memory.OutOfMemoryError: Out of memory allocating 1,623,613,440 bytes (allocated so far: 5,202,540,544 bytes).\n', 'outputs': [], 'scalars': [], 'profile': None, 'host_peak_rss_bytes': 607035392, 'scalar_match': True, 'membership_match': False} +",,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +tess_gap_long,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,baseline,4,8,original_qualified_timing,True,True,,0.4526328675575552,0.45192487562283545,0.45536185460471756,,,,,,,149.75658527994528,2610364416,2114220032,0.0864766228854889,300.70973821582606,371.0422156394032,0.36683456861251196,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,candidate,4,4,original_qualified_timing,True,True,,0.8372887160281638,0.8268017921195354,0.8389944113055334,,,,,,,85.62675408506766,2526478336,1746231296,0.054814661220877636,162.5617406583236,197.82563648879187,0.6880357547532657,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,gtls,2,1,original_qualified_timing,True,True,,0.11810723225948463,0.11536748914060276,0.12267161573125425,,,,,,,272.7577719227411,48298983424,1791160320,0.3304126220632254,1152.4367179485798,1276.1735606382663,0.10665564254679936,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +ztf_solar,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,True,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,baseline,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 753, in run + raise RuntimeError('Pre-queue required-output qualification failed') +RuntimeError: Pre-queue required-output qualification failed +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,candidate,,,original_qualified_timing,False,False,Fresh one-worker required-output qualification failed,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,gtls,,,original_qualified_timing,False,False,"Traceback (most recent call last): + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 141402, 'task': 55, 'indices': [55], 'started': 2180771.54452009, 'ended': 2180774.500208829, 'api_seconds': 2.955688739195466, 'error': 'Traceback (most recent call last):\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 388, in worker\n results = public_call(internal_backend, selected,\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in public_call\n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/candidate/benchmarks/tls_survey/throughput.py"", line 125, in \n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/main.py"", line 108, in power\n = core.search_multi_periods(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/gputls/core.py"", line 768, in search_multi_periods\n ootrGPU = cp.empty((singleCalcPeriods,len(singleDurations),(tSize)),dtype=cp.float32)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-survey/modern/lib/python3.11/site-packages/cupy/_creation/basic.py"", line 32, in empty\n return cupy.ndarray(shape, dtype, order=order)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""cupy/_core/core.pyx"", line 167, in cupy._core.core.ndarray.__new__\n File ""cupy/_core/core.pyx"", line 254, in cupy._core.core._ndarray_base._init\n File ""cupy/cuda/memory.pyx"", line 875, in cupy.cuda.memory.alloc\n File ""cupy/cuda/memory.pyx"", line 1579, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1600, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1271, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1292, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc\n File ""cupy/cuda/memory.pyx"", line 1537, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\n File ""cupy/cuda/memory.pyx"", line 1540, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\ncupy.cuda.memory.OutOfMemoryError: Out of memory allocating 1,377,618,944 bytes (allocated so far: 5,464,751,104 bytes).\n', 'outputs': [], 'scalars': [], 'profile': None, 'host_peak_rss_bytes': 1913155584, 'scalar_match': True, 'membership_match': False} +",,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 +varied,bls,,,native_execution_only,False,False,No valid execution tuning selection,,,,,,,,,,,,,,,,,5111,5120,False,False,1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807 diff --git a/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.png b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.png new file mode 100644 index 00000000..365ed696 Binary files /dev/null and b/benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/survey-throughput-with-native-bls.png differ diff --git a/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/README.md b/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/README.md new file mode 100644 index 00000000..7300243e --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/README.md @@ -0,0 +1,179 @@ +# Eight grazing, smeared development transits + +This post-freeze diagnosis explains a development warning, not final detection +performance. All eight predeclared development cases in this regime are +included. The eight saved TLS peaks all miss the declared period criterion; +the selected BLS method/ranker recovers cases 1, 3, 5, 6 and 7. These 0/8 and +5/8 are **period-recovery counts**, not newly evaluated thresholded detections +at a common false-positive rate. No search was +rerun, no calibration or held-out outcomes were inspected, and no scientific +sources, settings, thresholds, tolerances, inputs or plans were changed. + +The evidence points to the native absolute-depth gate interacting with exposure +smearing and sample-window averaging. It does **not** establish a complete +causal explanation of the actual GPU decisions. The numerical treatment of +very small deficits and the native candidate/ranking procedure remain unresolved. + +## Input and calculation provenance + +All eight original cloud-development NPZs were downloaded without computation +on the rental. Their bytes match the original manifest +`a1d18d6cf2d09fc4450a6f4ce9cf6f794e05685f65c755bbac5e7429c3116328`. +The older local development cohort was not substituted. Inputs are retained in +`inputs/`; `diagnosis.json` pins every input and the existing development +search/SNR receipts. `diagnose_eight.py` contains the bounded CPU calculation; +`eight-cases.csv` is a compact table. `run-manifest.json` records the exact +command, environment, source/receipt hashes, eight input hashes and output +hashes. The eight NPZs remain outside git; an archived development bank can +supply their original bytes for reproduction. + +Run with an environment containing NumPy, SciPy and batman-package: + +```sh +OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 NUMBA_NUM_THREADS=1 \ +python diagnose_eight.py --repo /path/to/cuvarbase --study /path/to/study \ + --manifest /path/to/original/development/manifest.json \ + --inputs /path/to/original/eight/npzs --output /path/to/diagnostic-output +``` + +The study folder supplies `scientific/development-snr-final.json`, +`scientific/bls-response-final.json`, and the four +`scientific/dev-promoted-search-{0,1,2,3}.json` receipts. The manifest's fixed +SHA guard prevents substitution of an older or newly generated cohort. + +The new CPU calculations reproduce the saved known-period native-family and +ideal-box expected white SNR to within 1e-8 absolute. That is an explanatory +cross-check, not an amendment to any scientific acceptance tolerance. The +physical signal, errors, noisy flux and period grid come from the original +NPZs. Only intrinsic/smeared midpoint depths are newly evaluated with the +unchanged physical model and 64 exposure quadrature nodes. + +## What the physical inputs establish + +These are Earth-size planets around a solar star, with impact parameters +0.99905–1.00215 and no full-transit interval. Geometric contact durations are +18.54–24.69 minutes. Every observation has a **30-minute exposure**, with +every ninth timestamp retained from the 200-second TESS cadence. This is one +fixed exposure length, not a mixed-exposure population. There are 1,082 samples, +7–17 nonzero transit samples and 4–11 observed events per case. + +Exposure integration reduces the midpoint depth to 36.9–49.4% of the intrinsic +midpoint depth. Stored sampled peaks are only 6.65–11.42 ppm, although the +unintegrated midpoint depths are 14.30–23.77 ppm. Error amplitudes were scaled +by the existing generator to produce target white signal norms 6/8/10/12; +median errors are 1.62–4.55 ppm. These are controlled synthetic detectability +cases, not estimates of occurrence or realistic TESS population recovery. + +All eight have a supplied period within the frozen recovery tolerance. Their +nearest-grid drift is 0.010–0.301 of the allowed half-duration. Therefore a +structurally unreachable period grid does not explain these eight misses. + +## The absolute floor is on a window mean + +The unchanged native kernel requires an **unweighted sample-window mean +deficit strictly greater than 10 ppm**, before applying template overshoot +scaling. This is neither the physical peak depth nor the reported fitted +depth. See `tls_ref_mean_depth`, `tls_ref_window`, and `tls_ref_full_window` in +`cuvarbase/kernels/tls_reference.cu`; the default is in +`cuvarbase/tls_reference.py:raw_search`. Flux is checked but not renormalized by +`tls_reference_math.preprocess_inputs`. + +For each original signal we enumerated every cyclic sample start and every +width in the host translation of the frozen coarse logical chunk's admissible +width union near the truth. This is broader than the coarse epoch-stride +schedule. The table gives the **maximum** float64 mean, not just the +mean for the best-shaped filter. The same noiseless maxima occur at the true +period and its nearest supplied trial period. + +| Case | Target white SNR | Coarse-envelope maximum noiseless mean, ppm | Coarse-envelope maximum noisy mean at truth, ppm | Native-family expected white SNR | Selected BLS known-period expected white SNR | BLS period recovery | +|---:|---:|---:|---:|---:|---:|:---:| +| 0 | 6 | 9.666 | 11.007 | 5.862 | 5.894 | No | +| 1 | 8 | 6.715 | 6.992 | 7.826 | 7.422 | Yes | +| 2 | 10 | 5.937 | 6.394 | 9.770 | 9.753 | No | +| 3 | 12 | 8.760 | 10.016 | 11.518 | 11.032 | Yes | +| 4 | 6 | 7.035 | 5.890 | 5.934 | 5.833 | No | +| 5 | 8 | 9.286 | 8.186 | 7.976 | 7.865 | Yes | +| 6 | 10 | 8.824 | 7.644 | 9.479 | 9.343 | Yes | +| 7 | 12 | 8.063 | 8.447 | 11.974 | 11.962 | Yes | + +Thus the noiseless signals would fail this mean-depth gate at truth and the +nearest grid point in ideal float64 arithmetic, even where a sampled peak exceeds +10 ppm. With the original noise added, six still have no ideal-float64 +gate-passing window. Cases 0 and 3 have respectively only two and one. At the +nearest supplied period, case 5's maximum noisy mean is 8.745 ppm; the +gate-passing counts remain unchanged for all eight. + +This result concerns the **coarse logical chunk's width envelope**, not every +width that a differently grouped full-refinement stage could inspect. A +separate exhaustive enumeration over all 34 cached widths finds seven of the +eight noiseless signals still below 10 ppm at truth and nearest grid. Case 0 +reaches 10.050 ppm with width 4, excluded by its coarse near-truth minimum +width of 5. With noise, six cases still have no passing all-cache window; +case 0 has four and case 3 has one. These are separate `all_cache` fields in +the JSON, not a replay of actual full-refinement membership or decisions. + +The eight best native-family shapes use 6–15 samples, all within the nominal +near-truth admissibility envelope. Their means are only 4.34–7.83 ppm. Each +best row's signal length equals its scanned width: literal zero-flux padding +is not present in these particular best shapes. Width exclusion or padding +therefore does not explain the good shape-family ceiling in this table. + +The BLS kernel fits a weighted centered box and accepts downward signals +without this absolute 10-ppm flux-deficit floor (`bls_common.cuh:bls_value`). +That difference is a supported mechanism for differing sensitivity at small +absolute depth. Removing or changing the native gate was not tested here. + +## Why the actual GPU failure is not fully attributed + +Native mean depth is formed as `1 - (prefix[end] - prefix[start-1]) / width` +from **float32 cumulative raw flux near one**. Our diagnostic sums deficits in +float64, and does not emulate that prefix operation or its reduction tree. +At the best-family windows, one float32 prefix ULP divided by width corresponds +to approximately 2.03–13.56 ppm. This illustrates numerical sensitivity at the +scale of the gate; it uses the nominal cumulative magnitude for unit flux, +not the actual GPU prefixes, and is not an error bound or measured GPU error. +An independent eight-input inspection found the frontend's epoch shift is +zero and the diagnostic/native translated fold orders agree at truth and +nearest grid in all 16 checks. + +Cases 1, 2 and 6 have maximum observed point deficits below 10 ppm even after +rounding the input flux to float32 (8.285, 8.702 and 8.821 ppm). No exact mean +of those rounded values could exceed the gate at any period. Nevertheless, +the receipts report finite selected periods but omit fitted depths and prefix +intermediates. This diagnostic does not trace native gate decisions or attribute +misses to cancellation; that explanation remains a hypothesis. The original spectra are represented +by hashes in these receipts, which also prevents tracing truth-period rank, +coarse candidate membership, or a particular losing template from them. + +## Shape SNR, aliases and noise + +The existing known-period native cache-family and ideal-box diagnostics use +the same fitted weighted constant and the same white or OU-noise variance. +The OU values evaluate the **fixed white-optimal filters** with OU variance; +they are not separately OU-optimal family ceilings. +They do not compare package SNR/SDE labels. Native versus ideal-box expected +white SNR ranges from −0.552% to +2.293%, with median +0.813%; the median OU +advantage is +0.703%. The retained actual selected BLS configuration has +96.76–100% of ideal-box white SNR at the true period. These ceilings retain +substantial expected signal, including cases with target norm 10 or 12 whose +saved TLS peaks miss the period criterion. This best available native-template +ceiling does not measure the actually admitted, scored or ranked statistic, +or attribute any blind-search miss. It excludes native depth estimation/gating, +coarse scheduling, candidate refinement and rank selection. + +The TLS peaks all fail the declared alias criterion as well as exact-period +recovery. BLS case 0 selects a near-half-period peak, but accumulated drift is +2.16 times the allowed alias tolerance. BLS case 2 selects 5.924786 days near +the 5.929636-day truth, but accumulated drift is 2.58 times the allowed +tolerance; another unused ranker selected a recoverable nearby peak, which +does not alter the frozen likelihood result. BLS case 4 selects an unrelated +0.814574-day peak. Its white-normalized observed response to the fixed native +shape is 4.49 versus expected 5.93, consistent with an adverse noise realization; +this is descriptive, not a causal intervention. Both target-SNR-6 cases are +BLS misses. No alias rule or recovery tolerance was relaxed. + +The defensible conclusion is that this development subgroup exposes a serious +absolute-depth/window-mean limitation worth reporting, with numerical gate +behavior and native ranking still unresolved. These eight predeclared +development cases cannot quantify final population recovery, establish universal +equivalence, or identify what any one hypothetical change would recover. diff --git a/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.png b/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.png new file mode 100644 index 00000000..9df6eafa Binary files /dev/null and b/benchmarks/results/tls_survey_2026-09-10/grazing-development-diagnosis/figure/grazing-depths.png differ diff --git a/benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/README.md b/benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/README.md new file mode 100644 index 00000000..3808b972 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/heldout-bank-preparation/README.md @@ -0,0 +1,9 @@ +# Held-out bank preparation check + +The metadata check passed at 2026-09-11 18:37:15 UTC. All three completed manifests match the frozen scientific seal: 5,120 calibration nulls, 2,560 injections and 2,560 separate test nulls, with the planned counts in every regime. Injection generation completed at 18:27:18 UTC, test-null generation at 18:36:18 UTC, and the blind injection search started immediately afterward. + +The check confirms the split/null roles, 64-node exposure integration, absence of truth insertion into the trial grid, seven recorded array hashes per lightcurve, and unique names and recorded input-file and flux-array hashes within and across banks. Shared timestamps and other common cadence arrays are allowed. + +These are checks of completed manifest metadata and recorded hashes. This check does not rehash the held-out NPZ files or read detection outcomes. Hash disjointness checks reuse of recorded byte identities; it does not establish statistical independence or detection performance. Search-time input verification and final archive verification remain required. The frozen generator defines the separate random streams and populations. + +`review.json` preserves the result and manifest identities. `check.py` is the exact Python body executed read-only on the rental, using only the standard library. For reproduction, mount the archived manifests and scientific seal read-only at their original `/workspace/tls-survey/final-campaign` and `/workspace/tls-survey/evidence` paths, then run `python3 check.py`. The printed campaign stage is a dated observation and will differ after the search advances. Do not overwrite the original receipt. diff --git a/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/README.md b/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/README.md new file mode 100644 index 00000000..dd53723e --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/README.md @@ -0,0 +1,69 @@ +# Independent injection completion audit + +The corrected structural audit passed at 2026-09-12 01:35:58 UTC with no campaign discrepancy. The original four workers (PIDs 62060–62063, starttime ticks 209815123) were observed running with the expected commands and working directory before the audit. Their stage completed at 01:32:21 UTC with four exit codes zero. At 01:32:46 UTC all four original process handles were absent and every injection receipt was complete. A matching zombie would not have satisfied the audit's exit gate. + +The audit verified: + +- Exactly 2,560 unique injection inputs and 5,120 valid method outcomes: 256 inputs in each of ten regimes, paired between TLS and its development-selected BLS comparator. Each of four shards contains exactly its planned 640 inputs and 1,280 outcomes, assigned by manifest index modulo four. +- All 2,560 complete original NPZ file hashes (3,646,206,730 bytes), their stored metadata, array inventories, and period-array hashes match the manifest. All 5,120 returned period-grid hashes match the corresponding original input grid. Grid and search settings agree with the frozen regime declarations; denser grid oversampling remains distinct from search-statistic oversampling. +- All 15 scientific sources and the complete 82-file production source inventory match the scientific seal. Runner, manifest, campaign, threshold, and method/ranker bindings match. Threshold bytes were hashed without reading or applying their values. +- Every selected score is finite; every selected period is finite and positive or represents the frozen valid zero-score/no-candidate contract. Unselected diagnostic rankers do not redefine selected-detector validity. No API errors occur. +- Both methods retain every sparse-sampling case: 13 unsampled, 34 one-event, 248 two-event, and 22 cases with one to four in-transit observations. These categories overlap. Each regime retains 64 inputs at each target/latent SNR label (6, 8, 10, 12). Labels and event counts are checked against original metadata, not independently regenerated physics. + +`audit-v2.stdout.json` is the final receipt. Its SHA is `4c526604ba7422dade7312a3774d26d2ff12aabf1e0eb2a5b3712431c47fe63f`. The injection manifest SHA is `7f06664e99aea638c8dd04897413c4717b3e15918c44a1d460691939a4637d3a`. Selected score/period values, recovery flags, alias flags, and recovery contrasts are not reported or analyzed. No independent test-null outcome was read. + +## Retained checker correction + +The first audit used the search-receipt array hash when checking the manifest's per-array period identity. Those formats differ in the pre-existing sealed code: + +- `generate.py` uses `tls_reference/cases.py`, which imports `tls_reference/validate.py::array_hash`: JSON-encoded dtype string (or structured dtype description), JSON shape, then contiguous bytes. +- `run.py` uses `tls_survey/common.py::array_hash`: `str(dtype)`, `str(shape)`, then contiguous bytes. + +Consequently, that checker reported 2,560 manifest-period-hash mismatches even though all complete NPZ hashes and all returned grid hashes passed. This was an external checker error, not a scientific gate change or campaign-data mismatch. The correction adds the original manifest hash convention and uses it only for the manifest comparison; it also labels both conventions in the receipt. `checker-correction.diff` gives the complete source change. The repeated audit uses identical scientific criteria and original artifacts. No scientific/operational source, input, setting, threshold, or controller was changed. + +The initial failed result remains in `audit.stdout.json`, with empty `audit.stderr.txt`, nonzero status in `execution.json`, and its executed source preserved as `audit_injections_v1.py` (matching the source SHA in that execution record). The corrected source is `audit_injections.py`; its execution record and empty stderr are `execution-v2.json` and `audit-v2.stderr.txt`. Both executions used one CPU thread for array decoding and hashing, no GPU calls, and no cuvarbase or campaign imports. They took about 35 and 40 seconds respectively while the controller independently continued its planned test-null stage. + +## Reproduction and scope + +The exact execution arguments, interpreter, source hashes, initial process-handle receipt hash, timestamps, and output hashes are retained in the execution records. **Replaying this exact original-file audit requires the original raw NPZ containers**, available from the live study, a raw-rescue archive that retains them, or separately retained originals. A normal completed `verified_banks` archive excludes original NPZ files covered by a verified input bank and is therefore insufficient by itself to rerun this audit's original-NPZ-hash checks. + +If the original raw NPZs and the other required study files are available, use Python with NumPy, mount them read-only at the recorded Linux paths below, and run from a separate writable directory containing the audit script and `initial-worker-handles.json`: + +```python +from pathlib import Path +import subprocess +import sys + +with open('new-audit.json', 'x') as output: + subprocess.run([ + sys.executable, '-B', 'audit_injections.py', + '--repo', '/workspace/tls-survey/candidate', + '--campaign', '/workspace/tls-survey/final-campaign', + '--seal', '/workspace/tls-survey/evidence/seal-final.json', + '--seal-sha256', '1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807', + '--thresholds-sha256', 'caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa', + '--initial-handles-json', Path('initial-worker-handles.json').read_text(), + ], stdout=output, check=True) +``` + +A later replay with the original raw NPZs can verify original file identities and recorded exit statuses; it cannot independently re-observe historical process exits on the original host. The retained live handle observations and completion-stage receipt document those checks. Do not edit original receipts to accommodate a different directory layout. + +For a normal completed bank archive, the supported reproduction route is **separate numerical-array verification and restoration**. `benchmarks/tls_reference/inputs.py::export_bank` preserves the exact numerical arrays, metadata, and original manifests. `verify_bank` checks every stored numerical identity. `restore_bank` writes new NPZ containers and a reproduction manifest with their recomputed container hashes plus the original NPZ hashes; it does not guarantee reproduction of the original container bytes. The historical original-file audit and export/verification receipts supply provenance for the originals; bank verification does not newly recheck unavailable original NPZ bytes. + +For the usual final bank location, run these commands from a writable directory, with the archived study mounted read-only at `/workspace/tls-survey`: + +```sh +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py verify \ + --bank /workspace/tls-survey/final-campaign/input-bank +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py restore \ + --bank /workspace/tls-survey/final-campaign/input-bank \ + --study injections \ + --manifest /workspace/tls-survey/final-campaign/input-bank/manifests/injections.json \ + --out restored-injections +``` + +If the archive instead uses the additional-input bank, select its location from the retained bank and archive receipts. Restoration writes into the new `restored-injections` directory outside the archive; these are the same frozen numerical inputs, not a newly independent population. Keep its reproduction manifest and `reproduction.json` distinct from the original manifests. Do not substitute restored containers into this exact original-file audit or replace the original hashes to make it pass. + +The prior delivered README and inventory are retained under `review-copies/`. This clarification changes documentation and the unsealed inventory only; the audit code, executed receipts, original-file checks, scientific gates, and remote study are unchanged. + +This audit establishes structural completeness, recorded input/source/grid bindings, and retention of stored sampling strata. Complete NPZ hashes protect all arrays; only period-array hashes are separately recomputed. Returned full-grid identities do not independently prove every internal template/trial was evaluated. Numerical equivalence belongs to the separate frozen exactness stage. This receipt makes no recovery, equal-FPR sensitivity, approximation-equivalence, or universal-coverage claim. diff --git a/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/review-copies/README-before-archive-clarification.md b/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/review-copies/README-before-archive-clarification.md new file mode 100644 index 00000000..2cd401c9 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/injection-completion-audit/review-copies/README-before-archive-clarification.md @@ -0,0 +1,49 @@ +# Independent injection completion audit + +The corrected structural audit passed at 2026-09-12 01:35:58 UTC with no campaign discrepancy. The original four workers (PIDs 62060–62063, starttime ticks 209815123) were observed running with the expected commands and working directory before the audit. Their stage completed at 01:32:21 UTC with four exit codes zero. At 01:32:46 UTC all four original process handles were absent and every injection receipt was complete. A matching zombie would not have satisfied the audit's exit gate. + +The audit verified: + +- Exactly 2,560 unique injection inputs and 5,120 valid method outcomes: 256 inputs in each of ten regimes, paired between TLS and its development-selected BLS comparator. Each of four shards contains exactly its planned 640 inputs and 1,280 outcomes, assigned by manifest index modulo four. +- All 2,560 complete original NPZ file hashes (3,646,206,730 bytes), their stored metadata, array inventories, and period-array hashes match the manifest. All 5,120 returned period-grid hashes match the corresponding original input grid. Grid and search settings agree with the frozen regime declarations; denser grid oversampling remains distinct from search-statistic oversampling. +- All 15 scientific sources and the complete 82-file production source inventory match the scientific seal. Runner, manifest, campaign, threshold, and method/ranker bindings match. Threshold bytes were hashed without reading or applying their values. +- Every selected score is finite; every selected period is finite and positive or represents the frozen valid zero-score/no-candidate contract. Unselected diagnostic rankers do not redefine selected-detector validity. No API errors occur. +- Both methods retain every sparse-sampling case: 13 unsampled, 34 one-event, 248 two-event, and 22 cases with one to four in-transit observations. These categories overlap. Each regime retains 64 inputs at each target/latent SNR label (6, 8, 10, 12). Labels and event counts are checked against original metadata, not independently regenerated physics. + +`audit-v2.stdout.json` is the final receipt. Its SHA is `4c526604ba7422dade7312a3774d26d2ff12aabf1e0eb2a5b3712431c47fe63f`. The injection manifest SHA is `7f06664e99aea638c8dd04897413c4717b3e15918c44a1d460691939a4637d3a`. Selected score/period values, recovery flags, alias flags, and recovery contrasts are not reported or analyzed. No independent test-null outcome was read. + +## Retained checker correction + +The first audit used the search-receipt array hash when checking the manifest's per-array period identity. Those formats differ in the pre-existing sealed code: + +- `generate.py` uses `tls_reference/cases.py`, which imports `tls_reference/validate.py::array_hash`: JSON-encoded dtype string (or structured dtype description), JSON shape, then contiguous bytes. +- `run.py` uses `tls_survey/common.py::array_hash`: `str(dtype)`, `str(shape)`, then contiguous bytes. + +Consequently, that checker reported 2,560 manifest-period-hash mismatches even though all complete NPZ hashes and all returned grid hashes passed. This was an external checker error, not a scientific gate change or campaign-data mismatch. The correction adds the original manifest hash convention and uses it only for the manifest comparison; it also labels both conventions in the receipt. `checker-correction.diff` gives the complete source change. The repeated audit uses identical scientific criteria and original artifacts. No scientific/operational source, input, setting, threshold, or controller was changed. + +The initial failed result remains in `audit.stdout.json`, with empty `audit.stderr.txt`, nonzero status in `execution.json`, and its executed source preserved as `audit_injections_v1.py` (matching the source SHA in that execution record). The corrected source is `audit_injections.py`; its execution record and empty stderr are `execution-v2.json` and `audit-v2.stderr.txt`. Both executions used one CPU thread for array decoding and hashing, no GPU calls, and no cuvarbase or campaign imports. They took about 35 and 40 seconds respectively while the controller independently continued its planned test-null stage. + +## Reproduction and scope + +The exact execution arguments, interpreter, source hashes, initial process-handle receipt hash, timestamps, and output hashes are retained in the execution records. To reproduce the archive content checks, use Python with NumPy, mount a read-only archived study at the recorded Linux paths below, and run from a separate writable directory containing the audit script and `initial-worker-handles.json`: + +```python +from pathlib import Path +import subprocess +import sys + +with open('new-audit.json', 'x') as output: + subprocess.run([ + sys.executable, '-B', 'audit_injections.py', + '--repo', '/workspace/tls-survey/candidate', + '--campaign', '/workspace/tls-survey/final-campaign', + '--seal', '/workspace/tls-survey/evidence/seal-final.json', + '--seal-sha256', '1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807', + '--thresholds-sha256', 'caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa', + '--initial-handles-json', Path('initial-worker-handles.json').read_text(), + ], stdout=output, check=True) +``` + +A later replay can verify archived content and recorded exit statuses; it cannot independently re-observe historical process exits on the original host. The retained live handle observations and completion-stage receipt document those checks. Do not edit original receipts to accommodate a different directory layout. + +This audit establishes structural completeness, recorded input/source/grid bindings, and retention of stored sampling strata. Complete NPZ hashes protect all arrays; only period-array hashes are separately recomputed. Returned full-grid identities do not independently prove every internal template/trial was evaluated. Numerical equivalence belongs to the separate frozen exactness stage. This receipt makes no recovery, equal-FPR sensitivity, approximation-equivalence, or universal-coverage claim. diff --git a/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/README.md b/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/README.md new file mode 100644 index 00000000..8beec743 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/README.md @@ -0,0 +1,95 @@ +# Independent test-null completion audit + +**Passed in one execution at 2026-09-12 13:45:16 UTC.** The unchanged, previously reviewed checker ran after the original test-null search completed at 08:28:24 UTC, all four workers recorded exit code zero, and their original `/proc` identities were absent. The audit took 40.777 seconds, returned exit code zero, and produced no stderr or failed checks. No frozen source, input, setting, threshold, sample count, or controller changed; no GPU work was launched. + +The [actual audit receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/null-completion-audit/audit.stdout.json") verifies **2,560 unique paired inputs and 5,120 valid outcomes**, with exactly 256 names in each of 20 regime/method groups and the declared four-shard allocation. It hashed all 2,560 original NPZ containers (3,646,157,332 bytes), checked their metadata and stored grids, checked returned grid identities, and confirmed the frozen method/ranker, seal, threshold, 15 scientific-source and 82 production-source bindings. The receipt SHA256 is `d3da9ce47ee598ce071fb7625a6374e59c09c25582a7e2fc58618f533b4f3cb6`. + +All latent sampling cases remain included: eight unsampled, 32 with one event, 259 with two events, and 26 with one to four sampled points. These overlapping categories describe the stored latent signal used to define noise scale; **no transit is inserted into null flux**. IID noise-scale mixture counts need not be balanced. This structural audit does not apply thresholds or estimate FPR, recovery, or numerical equivalence. + +[execution-started.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execution-started.json"), [execution.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execution.json"), and [execute_once.py](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/null-completion-audit/execute_once.py") preserve the exact invocation, interpreter/environment, source and handle identities, timestamps, output hashes, and single attempt. The earlier preparation README and inventory are preserved unchanged under [review-copies](review-copies/README.md). No test-null outcome record or input manifest was opened during that preparation. + +The preparation captured the original four live worker handles at 2026-09-12 03:20:39.701587 UTC. PIDs 88217–88220 were all running, each with starttime ticks 212311381, the expected command, working directory `/workspace/tls-survey/candidate`, and executable `/usr/bin/python3.11`. Their recorded `search-nulls` stage began at 2026-09-12 01:32:21.530751 UTC. `initial-worker-handles.json` preserves the complete original stage metadata, raw `/proc/PID/stat`, raw command-line bytes, parsed handles, and the campaign-state hash at capture. `capture_initial_handles.py` and `capture-execution.json` document this read-only metadata capture; it did not open outcome files. + +`audit_nulls.py` is a minimal adaptation of the corrected injection audit (`../injection-completion-audit/audit_injections.py`, SHA256 `164c40803e6738155eb3e77a0166ecce69e3906247a434d5117e279fcee4fdd3`). The full difference is retained in `source-diff-from-injection-audit.patch`. The prepared null-audit source SHA256 is `4f8d93496421daab3f179e179a8bb2596c8dbbe452cff47a288f204c2de68eef`. + +The differences are limited to the following: + +- Stage and file names select `search-nulls`, `inputs-nulls/manifest.json`, and `nulls-search-{0..3}.json`. Split/cohort/null fields must identify test nulls. The frozen count is still 256 inputs per regime, ten regimes, and four shards: 2,560 paired inputs and 5,120 method outcomes. +- Null noise-scale labels are IID draws from the equal four-level mixture (6, 8, 10, 12); realized counts need not be balanced. The added pure helper checks allowed support and total population size. It imposes neither an empirical frequency test nor forced balance. All cases are retained. +- Sampling summaries are explicitly called **latent**. They describe the stored signal used to define sampling and noise scale; no transit is inserted into the null flux. They do not count detected events or imply that a sampled signal was present in the noise-only data. + +All other structural checks retain the corrected injection audit's definitions. Before opening the input manifest or outcome receipts, the audit requires the matching original stage to be complete, all four recorded worker exit codes to be zero, and each original `/proc` identity to be absent or replaced by a different starttime. A same-identity zombie does not pass. The original commands and stage identity must remain unchanged. + +The audit then checks exact manifest-index-modulo-four membership, unique paired names and input identities, selected BLS method/ranker settings, complete scientific and production source inventories, original seal and threshold byte hashes, every original NPZ container hash, stored metadata and array inventories, declared grids, both stored and returned period-array identities, valid finite selected outcomes, and retention of every latent sampling stratum. Threshold values and recovery/alias fields are not inspected or applied. Scores are checked only for validity and finiteness, never aggregated, reported, compared to truth, or compared to thresholds. No calibration or injection outcome is read. + +The two existing hash conventions remain separate: manifest period arrays use JSON-encoded dtype and shape plus contiguous bytes (`tls_reference/validate.py`); returned grids use `str(dtype)` and `str(shape)` plus contiguous bytes (`tls_survey/common.py`). Complete original NPZ hashes protect all stored arrays; only period-array hashes are additionally recomputed. Other per-array hashes are not separately recomputed. Stored physical labels are checked for retention and consistency, not regenerated. Returned grid identities alone do not prove evaluation of every internal template or establish numerical equivalence. + +## Preparation validation + +`preparation-checks.json` records **31 passing static and synthetic checks** under Python 3.9.6 / NumPy 1.26.4. The validator parses and compiles the audit without executing its main function, compares unchanged helper ASTs with the corrected injection source, verifies split paths and the early worker-exit gate, excludes recovery/alias accesses and threshold-value parsing, and exercises only the changed mixture helper and retained pure strata/hash helpers on synthetic inputs. It never opens outcome records. This is preparation validation, not a completed campaign audit. + +The executed local validation command was: + +```sh +/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/local-env/bin/python -B \ + /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/null-completion-audit/check_preparation.py \ + --source /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/null-completion-audit/audit_nulls.py \ + --injection-source /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/injection-completion-audit/audit_injections.py +``` + +## Executed command and reproduction invocation + +The actual local invocation was: + +```sh +/Applications/Xcode.app/Contents/Developer/usr/bin/python3 -B \ + /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/null-completion-audit/execute_once.py +``` + +The launcher used the unchanged `ops/cloud.py ssh survey01` helper to execute the exact reviewed source with `/workspace/tls-survey/modern/bin/python -B -c`; it wrote no remote source file. The remote environment was Python 3.11.10 / NumPy 2.2.6. The launcher refuses to replace existing execution evidence. Its recorded study-directory layout is retained for provenance; the direct invocation below provides the separate reproduction route. + +Use Python with NumPy on the original Linux host after all four original workers have exited normally. The example below is a concrete invocation from a separate writable directory containing the reviewed `audit_nulls.py` and captured `initial-worker-handles.json`. It creates new output files exclusively; it does not overwrite previous evidence. The script also caps numerical-library threads at one before importing NumPy. + +```python +from pathlib import Path +import hashlib +import subprocess +import sys + +assert hashlib.sha256(Path('audit_nulls.py').read_bytes()).hexdigest() == \ + '4f8d93496421daab3f179e179a8bb2596c8dbbe452cff47a288f204c2de68eef' +assert hashlib.sha256(Path('initial-worker-handles.json').read_bytes()).hexdigest() == \ + '4f3f360eba16035041ed7c191c74adbc91130699c16242925cf3f52c89520d29' +with open('audit.stdout.json', 'x') as output, open('audit.stderr.txt', 'x') as errors: + subprocess.run([ + sys.executable, '-B', 'audit_nulls.py', + '--repo', '/workspace/tls-survey/candidate', + '--campaign', '/workspace/tls-survey/final-campaign', + '--seal', '/workspace/tls-survey/evidence/seal-final.json', + '--seal-sha256', '1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807', + '--thresholds-sha256', 'caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa', + '--initial-handles-json', Path('initial-worker-handles.json').read_text(), + ], stdout=output, stderr=errors, check=True) +``` + +The actual execution receipt preserves the exact command, interpreter/environment, reviewed source and initial-handle hashes, timestamps, exit status, and output hashes. Any later reproduction must write to a new directory and retain its own result, including failure; it must not replace this original execution. + +## Original-container and archive reproduction limits + +Replaying the exact original-file checks requires **original raw NPZ containers**, available from the live study, a raw-rescue archive retaining them, or separately retained originals. A normal completed `verified_banks` archive excludes original NPZ files covered by a verified input bank and cannot alone support a new check of their original container bytes. With original raw NPZs and the other study files available, mount a read-only copy at the recorded `/workspace/tls-survey` Linux paths and run from a separate writable directory as above. Literal recorded command/working-directory checks must not be bypassed by changing receipts. A later replay can check recorded exits but cannot independently re-observe historical process exits on the original host; the retained live capture and original completion observation provide that evidence. + +For a normal bank archive, use **separate numerical-array verification and restoration**. `tls_reference/inputs.py::export_bank` preserves exact numerical arrays, metadata, and original manifests. `verify_bank` verifies the bank and numerical identities. `restore_bank` creates new NPZ containers and a reproduction manifest with recomputed container hashes plus original NPZ hashes; it does not guarantee original container bytes. Historical original-file audits and bank-export/verification receipts document the originals. Do not replace original hashes or substitute restored containers into this exact audit to make it pass. + +For the usual final bank location, from a writable directory with the archived study mounted read-only at `/workspace/tls-survey`: + +```sh +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py verify \ + --bank /workspace/tls-survey/final-campaign/input-bank +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py restore \ + --bank /workspace/tls-survey/final-campaign/input-bank \ + --study nulls \ + --manifest /workspace/tls-survey/final-campaign/input-bank/manifests/nulls.json \ + --out restored-nulls +``` + +If the archive instead uses an additional-input bank, select it from the retained bank/archive receipts. Restoration writes into the new directory outside the archive and reproduces the same numerical population, not a newly independent null sample. Keep the reproduction manifest and `reproduction.json` distinct from original manifests. This audit makes no false-positive-rate, recovery, sensitivity-equivalence, or universal-coverage claim. diff --git a/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/review-copies/README.md b/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/review-copies/README.md new file mode 100644 index 00000000..90604d67 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/null-completion-audit/review-copies/README.md @@ -0,0 +1,80 @@ +# Prepared independent test-null completion audit + +**Prepared only; the completion audit has not been run.** No test-null outcome record or input manifest was opened during preparation. Root review and successful completion of the existing test-null search are required before executing the command below. This audit changes no frozen source, input, setting, threshold, sample count, or controller and launches no GPU work. + +The preparation captured the original four live worker handles at 2026-09-12 03:20:39.701587 UTC. PIDs 88217–88220 were all running, each with starttime ticks 212311381, the expected command, working directory `/workspace/tls-survey/candidate`, and executable `/usr/bin/python3.11`. Their recorded `search-nulls` stage began at 2026-09-12 01:32:21.530751 UTC. `initial-worker-handles.json` preserves the complete original stage metadata, raw `/proc/PID/stat`, raw command-line bytes, parsed handles, and the campaign-state hash at capture. `capture_initial_handles.py` and `capture-execution.json` document this read-only metadata capture; it did not open outcome files. + +`audit_nulls.py` is a minimal adaptation of the corrected injection audit (`../injection-completion-audit/audit_injections.py`, SHA256 `164c40803e6738155eb3e77a0166ecce69e3906247a434d5117e279fcee4fdd3`). The full difference is retained in `source-diff-from-injection-audit.patch`. The prepared null-audit source SHA256 is `4f8d93496421daab3f179e179a8bb2596c8dbbe452cff47a288f204c2de68eef`. + +The differences are limited to the following: + +- Stage and file names select `search-nulls`, `inputs-nulls/manifest.json`, and `nulls-search-{0..3}.json`. Split/cohort/null fields must identify test nulls. The frozen count is still 256 inputs per regime, ten regimes, and four shards: 2,560 paired inputs and 5,120 method outcomes. +- Null noise-scale labels are IID draws from the equal four-level mixture (6, 8, 10, 12); realized counts need not be balanced. The added pure helper checks allowed support and total population size. It imposes neither an empirical frequency test nor forced balance. All cases are retained. +- Sampling summaries are explicitly called **latent**. They describe the stored signal used to define sampling and noise scale; no transit is inserted into the null flux. They do not count detected events or imply that a sampled signal was present in the noise-only data. + +All other structural checks retain the corrected injection audit's definitions. Before opening the input manifest or outcome receipts, the audit requires the matching original stage to be complete, all four recorded worker exit codes to be zero, and each original `/proc` identity to be absent or replaced by a different starttime. A same-identity zombie does not pass. The original commands and stage identity must remain unchanged. + +The audit then checks exact manifest-index-modulo-four membership, unique paired names and input identities, selected BLS method/ranker settings, complete scientific and production source inventories, original seal and threshold byte hashes, every original NPZ container hash, stored metadata and array inventories, declared grids, both stored and returned period-array identities, valid finite selected outcomes, and retention of every latent sampling stratum. Threshold values and recovery/alias fields are not inspected or applied. Scores are checked only for validity and finiteness, never aggregated, reported, compared to truth, or compared to thresholds. No calibration or injection outcome is read. + +The two existing hash conventions remain separate: manifest period arrays use JSON-encoded dtype and shape plus contiguous bytes (`tls_reference/validate.py`); returned grids use `str(dtype)` and `str(shape)` plus contiguous bytes (`tls_survey/common.py`). Complete original NPZ hashes protect all stored arrays; only period-array hashes are additionally recomputed. Other per-array hashes are not separately recomputed. Stored physical labels are checked for retention and consistency, not regenerated. Returned grid identities alone do not prove evaluation of every internal template or establish numerical equivalence. + +## Preparation validation + +`preparation-checks.json` records **31 passing static and synthetic checks** under Python 3.9.6 / NumPy 1.26.4. The validator parses and compiles the audit without executing its main function, compares unchanged helper ASTs with the corrected injection source, verifies split paths and the early worker-exit gate, excludes recovery/alias accesses and threshold-value parsing, and exercises only the changed mixture helper and retained pure strata/hash helpers on synthetic inputs. It never opens outcome records. This is preparation validation, not a completed campaign audit. + +The executed local validation command was: + +```sh +/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/local-env/bin/python -B \ + /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/null-completion-audit/check_preparation.py \ + --source /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/null-completion-audit/audit_nulls.py \ + --injection-source /Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/evidence/injection-completion-audit/audit_injections.py +``` + +## Invocation after completion and root review + +Use Python with NumPy on the original Linux host after all four original workers have exited normally. The example below is a concrete invocation from a separate writable directory containing the reviewed `audit_nulls.py` and captured `initial-worker-handles.json`. It creates new output files exclusively; it does not overwrite previous evidence. The script also caps numerical-library threads at one before importing NumPy. + +```python +from pathlib import Path +import hashlib +import subprocess +import sys + +assert hashlib.sha256(Path('audit_nulls.py').read_bytes()).hexdigest() == \ + '4f8d93496421daab3f179e179a8bb2596c8dbbe452cff47a288f204c2de68eef' +assert hashlib.sha256(Path('initial-worker-handles.json').read_bytes()).hexdigest() == \ + '4f3f360eba16035041ed7c191c74adbc91130699c16242925cf3f52c89520d29' +with open('audit.stdout.json', 'x') as output, open('audit.stderr.txt', 'x') as errors: + subprocess.run([ + sys.executable, '-B', 'audit_nulls.py', + '--repo', '/workspace/tls-survey/candidate', + '--campaign', '/workspace/tls-survey/final-campaign', + '--seal', '/workspace/tls-survey/evidence/seal-final.json', + '--seal-sha256', '1b81c75bd1a498c0dbed607e3221da1f374fc05be765de6dd2670c8d2f2b0807', + '--thresholds-sha256', 'caccda435f944e04682dd298a1b0fae659060f63e13ce281c8ae9cb850d373aa', + '--initial-handles-json', Path('initial-worker-handles.json').read_text(), + ], stdout=output, stderr=errors, check=True) +``` + +The eventual execution receipt should preserve the exact command, interpreter/environment, reviewed source and initial-handle hashes, timestamps, exit status, and output hashes, including any failed run. No completion-audit output currently exists in this preparation set. + +## Original-container and archive reproduction limits + +Replaying the exact original-file checks requires **original raw NPZ containers**, available from the live study, a raw-rescue archive retaining them, or separately retained originals. A normal completed `verified_banks` archive excludes original NPZ files covered by a verified input bank and cannot alone support a new check of their original container bytes. With original raw NPZs and the other study files available, mount a read-only copy at the recorded `/workspace/tls-survey` Linux paths and run from a separate writable directory as above. Literal recorded command/working-directory checks must not be bypassed by changing receipts. A later replay can check recorded exits but cannot independently re-observe historical process exits on the original host; the retained live capture and eventual completion observation provide that evidence. + +For a normal bank archive, use **separate numerical-array verification and restoration**. `tls_reference/inputs.py::export_bank` preserves exact numerical arrays, metadata, and original manifests. `verify_bank` verifies the bank and numerical identities. `restore_bank` creates new NPZ containers and a reproduction manifest with recomputed container hashes plus original NPZ hashes; it does not guarantee original container bytes. Historical original-file audits and bank-export/verification receipts document the originals. Do not replace original hashes or substitute restored containers into this exact audit to make it pass. + +For the usual final bank location, from a writable directory with the archived study mounted read-only at `/workspace/tls-survey`: + +```sh +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py verify \ + --bank /workspace/tls-survey/final-campaign/input-bank +python3 /workspace/tls-survey/candidate/benchmarks/tls_reference/inputs.py restore \ + --bank /workspace/tls-survey/final-campaign/input-bank \ + --study nulls \ + --manifest /workspace/tls-survey/final-campaign/input-bank/manifests/nulls.json \ + --out restored-nulls +``` + +If the archive instead uses an additional-input bank, select it from the retained bank/archive receipts. Restoration writes into the new directory outside the archive and reproduces the same numerical population, not a newly independent null sample. Keep the reproduction manifest and `reproduction.json` distinct from original manifests. This audit makes no false-positive-rate, recovery, sensitivity-equivalence, or universal-coverage claim. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md b/benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md new file mode 100644 index 00000000..1489c9d1 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md @@ -0,0 +1,17 @@ +# Installed-wheel release gate, September 27 + +The unchanged release wheel passed all **14 numerical/runtime checks** and all six dependency preflights on an NVIDIA A40. All 86 installed package files matched the previously built wheel byte for byte. + +The September 24–25 full GPU suite already passed 2,091 tests, with one expected notebook failure, no unexpected failures, and zero skips. Its separate gate launcher then failed to import `cuvarbase` because the package had not been installed in that environment. This isolated follow-up installed the same wheel and ran the unchanged gate successfully. The original failure receipt remains preserved. + +This repairs validation setup; it changes neither numerical sources nor the benchmark qualifications. The GPU was terminated after checksum-verified collection. + +[Gate output](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/release-gate.log") · [Execution receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/receipt.json") · [Installed-file verification](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/installation.json") · [Summary and cost](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/summary.json"). + +[Full GPU suite output](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/full-gpu-suite.log") · [JUnit results](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/full-gpu-suite.xml") · [Tested source inventory](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/tested-source-inventory.json"). + +All eight objects in the private R2 prefix `release-validation-20260927/gate-installed-wheel-a3ddc876` passed full SHA256 read-back. [Verification receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/r2-readback.json"). + +After review, the 1.0.0 wheel and source distribution were rebuilt to include the updated README. All 86 package files in those artifacts match the GPU-validated wheel exactly; wheel changes are confined to distribution metadata. Those intermediate artifacts passed strict metadata checks, and all 12 focused report/README tests passed. [Intermediate package comparison and artifact hashes](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/refreshed-package-verification.json"). + +The final prepared version is **1.0.1**, preserving the earlier June `v1.0.0` tag. Its only package-file change is the version declaration; all numerical sources are unchanged. [Final release preparation](../../../../docs/RELEASE_PREPARATION.md) contains the current artifact verification. Publication remains deferred. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/README.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/README.md new file mode 100644 index 00000000..4f4f5a10 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/README.md @@ -0,0 +1,52 @@ +# Release wiring validation — completed 12 September 2026 + +The release preserves baseline `6ced75d` execution by default and exposes the bundled optimizations through explicit `execution="experimental"`. The [branch application receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/branch/application.json") records the 14 applied files, all 86 resulting package hashes, and the three restored default files checked against Git baseline bytes. The [copy plan](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/branch/integration-plan.json") preserves exact before/after maps; 72 other package files were unchanged. + +The separate development-only GPU check passed **24 exact paired comparisons and all 86 device tests** in **177.825 seconds**, inside its predeclared 900-second cap. This validates release wiring on the fixed cases; it does **not** requalify experimental sensitivity or historical throughput. The original study remains **5,111/5,120 exact held-out comparisons**, with nine discrepancies and failed zero-mismatch qualification; its original development result remains 79/80. No tolerance, historical receipt or held-out population was changed. + +## What was checked + +| Check | Result and evidence | +|---|---| +| Default execution against immutable baseline | 12/12 exact pairs: eleven full-observation development cases plus one TESS fast-path case | +| Experimental execution against frozen optimized precursor | 12/12 exact pairs on the same inputs; this is not an experimental-versus-baseline equality claim | +| Device tests | 86/86 passed; exact node inventory, no failures or skips | +| Dispatch | Default avoided experimental imports; experimental short-prefix kernel and graph fallback were both observed | +| Public APIs | Convenience, batch and fixed-seed FAP output paths exercised for both release modes; these are additional API checks, not extra prespecified control pairs | +| Host and wheel | Broad host run: 872 passed, 18 skipped and one expected failure. Focused host and unpacked-wheel runs: 219 passed each | +| Cleanup | Every GPU stage exited successfully; GPU empty at completion. Both real rented pods are absent and all owned controls are closed | + +The unchanged [protocol](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/protocol.json"), [fixed eleven-case plan](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/development-validation-plan.json"), [complete original 80-case manifest](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/dev-final/manifest.json"), [binding](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/binding.json"), [campaign](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/campaign.json") and [device XML](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/results-attempt-1/device-tests.xml") retain the exact trial grids, identities and execution records. Each of the four worker directories under `gpu/results-attempt-1/` contains its original receipt and complete result metadata JSON. Full binary arrays remain in the external archive below. Pair comparison removes only the two expected execution-selector metadata fields; scientific values, masks, array dtypes/shapes/bytes and scalar bit patterns retain exact comparison. + +The [independent audit](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/receipt.json") recomputed all 24 pairs, checked 58 saved outputs and 820 arrays, and matched the exact device inventory. There are 56 call records and 58 output records because the two release-mode TESS batch calls each contain a duplicate input result. Its checker correction history is retained. The audit did not rerun GPU work. [Host validation](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/host/validation.json"), [host XML](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/host/host-suite.xml"), [wheel XML](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/host/wheel-tests.xml") and [wheel verification](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/host/wheel-verification.json") retain the earlier local evidence, including the failed initial wheel metadata build; their historical pending labels describe their original creation time. + +Neither finite cases nor these passing pairs establish universal equivalence or bitwise determinism. The scientific failures that motivated opt-in execution remain relevant, including difficult thin/grazing regimes described in the main study. + +## Environment and setup history + +The integration ran on a **new NVIDIA A40** with a **20 GB container disk**, zero persistent volume, the original CUDA 12.4.1 image, Python 3.11.10, NVCC 12.4.131 and all **64 pinned package versions**. CPU quota remained **7.65 cores**, memory limit **49,999,998,976 bytes**, and reported GPU memory **46,068 MiB**. The Linux CPU description was byte-identical to the original receipt. + +The host driver changed from **570.195.03** in the original study to **570.211.01**. The GPU UUID changed from `GPU-bd0c2d60-9d88-0e9c-91ca-bcf8b371d7fa` to `GPU-f62d0cdc-bf50-9862-e2b9-476e4c7d103e`. These differences are recorded in the [original GPU report](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/original-environment/gpu.xml"), [validation GPU report](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/gpu.xml") and [root binding review](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/root-binding-review.json"). This new allocation is not a repeated survey throughput measurement. + +The first installation failed after 33.400 seconds because PyCUDA's build could not import NumPy. Its [execution receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-execution.json") and [complete install log](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment/install.log") are retained. The second installer first installed the already pinned NumPy 2.2.6 and completed the unchanged requirements in 126.493 seconds; see its [execution receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/setup-execution-v2.json"), [installer](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/setup-ops/install-v2.sh"), [64-version environment receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/receipt.json") and [installation report](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/environment-v2/install.json"). No GPU integration attempt ran before setup succeeded. Setup time is separate from the 177.825-second integration clock, which includes context/JIT/canary work. + +The original 80 GB rental request received an explicit capacity rejection. A separately reviewed 20 GB request succeeded; the [first rejection and control closure](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rejected-80gb/capacity-rejection-closure.json") and [actual rental termination](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/rental-20gb/attempt/termination.json") are both retained. The first request is described as “no allocation observed,” not as a fabricated pod termination. Its full $1.50 uncertainty reserve remains in the conservative budget. The [final ledger](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/lifecycle/final-ledger.json") records approximately **$71.85225 observed elapsed-cost estimate** and **$73.75634 conservative total with reserves**, within the existing $100 authorization; these are estimates, not invoices. + +## Full outputs and reproduction + +This compact folder contains metadata, logs, XML and source maps only. [Provenance](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/provenance.json") maps every copied file to its exact original source path, SHA256, size and collected archive member where applicable; [artifact inventory](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/artifact-inventory.json") pins all published files. The original [archive receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/collection/archive-receipt.json") and [local collection verification](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/collection/collection-verification.json") establish that all **448 original files** were downloaded and verified before teardown. + +The retained local artifacts are: + +| External artifact | Bytes | SHA256 | +|---|---:|---| +| [Complete outputs and all three source trees]() | 358,677,468 | `354ee1ade51d9f8579d063b1c548f206c239c2f049b7712047f6d7b4631b399e` | +| [Frozen source/runner transfer]() | 1,418,579 | `e1d0fe2ba33237665453b33b464c01e230317b66d39aa67fbd7e304fd4c6f40e` | +| [Original input/setup transfer]() | 15,111,320 | `634239ec084d487c932cdfecc8e5153bc53f0163e4cf00809752fddf3bca2183` | +| [Host-verified release wheel]() | 542,446 | `05c58466dd1943c3fbac07f77a5d80a5692c1e7c07cf78db4d21b142c2689134` | + +These are local workspace artifacts, not public downloads. Preserve their exact bytes when relocating them and update only the external location record. The complete archive contains all arrays needed to independently inspect the existing result; the compact [audit checker](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/audit_wiring.py") shows how the recorded comparisons were verified. + +For a new run, verify the source and data archives against these SHAs, use the [source transfer instructions](transfer/sources/README.md) and [data transfer instructions](transfer/data/README.md) to assemble fresh roots, and retain the full 80-case manifest with only the eleven pinned NPZ inputs. Follow the unchanged [integration runbook](gpu/ops/RUNBOOK.md) and setup versions, create a **new** binding on the actual host, then run once into a fresh results directory with the fixed 900-second cap. Do not overwrite this binding or any result receipt. The retained v2 installer documents the pinned-NumPy prerequisite missing from the first setup attempt. The wheel checks were local; the GPU validation used isolated exact source trees through `PYTHONPATH`. + +A future execution requires its own resource authorization within the remaining total budget. These archived instructions do not leave any rental or background job running. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/RUNBOOK.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/RUNBOOK.md new file mode 100644 index 00000000..7cb28b92 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/gpu/ops/RUNBOOK.md @@ -0,0 +1,207 @@ +# Prepared release integration — unlaunched + +Nothing in this directory has run on a GPU, provisioned a resource, contacted a +provider, changed a scientific source, or modified the release checkout. Root +owns the original study lifecycle and any later short rental. This runner has +no cloud API, provisioning or provider-termination commands. + +The complete fixed plan is copied byte-for-byte as +`development-validation-plan.json` (SHA256 +`56dc97b664dfbebc5f02541563c2bc9ce8c7fc7ffacd81482a75d4bf4c9ca811`). +It includes eleven original development inputs, full original period grids, +zero numerical tolerance and two immutable-control/release pairs. No calibration +or held-out input is accepted. The earlier local `WORK/development` and +`WORK/development-v2` arrays have different hashes and must not be substituted. +Use **`WORK/collected/dev-final/` after normal collection**. The original +completion archive preserves those NPZs verbatim. + +`protocol.json` fixes the original baseline79, optimized precursor82 and staged +release86 package-source hashes. The original default files remain exact +6ced75d bytes. Three separate full package roots are required. Neither the +scientific runner nor its thresholds are imported or edited here. Comparison +is of complete public numerical outputs, arrays, masks and metadata, excluding +only `search_configuration.execution` and +`search_configuration.experimental_execution`. Every original result is saved +as a typed JSON tree plus a compressed NPZ, preserving float words including +NaN payloads and signed zero. Comparisons record missing/failed results and the +changed paths. They do not retry or substitute any call. + +## Setup evidence and prerequisites + +Original survey evidence is not yet locally collected. Required files after +normal primary → supplement → collection → provider termination are: + +- `WORK/collected/evidence/freeze.txt` +- `WORK/collected/evidence/install.json` +- `WORK/collected/evidence/gpu.xml`, `cpu.txt`, `cpu-quota.json` for comparison +- `WORK/collection-state.json`, the original collector’s actual state path; it must have `status=phase=complete`, + `evidence_verified=true`, and recorded owned-provider absence for + `okideq277lpb4a`. + +Use the SHAs from the verified collection inventory, not freshly declared +unknown evidence. `setup` requires the original freeze AND install-report pins. +It retains both raw receipts, checks their common distribution versions, and +emits only exact `name==version` requirements. The local cuvarbase and gputls +project entries are explicitly excluded: cuvarbase is supplied through one of +the three pinned source roots, and native GTLS is not executed by this wiring +check. Other unportable or unpinned dependency lines fail preparation. + +Original `pip freeze` may omit build tools. `build-tool-metadata.json` is the +root's separate read-only observation at 2026-09-12 15:40:35 UTC, SHA256 +`e21581bf01b305f00b5c37610d40b383a952a1c2ec73f704e2544d2cb9b8e6cb`: +Python3.11.10, pip26.2.1, setuptools84.0.0, wheel0.48.0. Those exact observed +build-tool versions supplement missing freeze entries; contradictions fail. +This is not retroactively labeled an original installation receipt. The original +freeze/install are still mandatory. Do not use the local macOS host/build +environment freezes or an older study's requirements. + +Root may provision **only after the original lifecycle and teardown finish**, +within the existing budget. Expected prerequisite is the same single A40/SM86, +Python3.11.10/CUDA12.4.1-devel image and NVCC12.4.131 toolchain described in the +prototype README. Record actual image/driver/GPU/CPU/memory identity and any +differences. Setup/provisioning/install time and cost are recorded separately +from the 900-second integration envelope. Do not perform an unpinned upgrade. +Create a fresh Python3.11 venv, bootstrap the three exact observed build-tool +pins, then install the derived exact requirements with a new pip report. Use +`--no-build-isolation` for builds after the pinned build prerequisites are +installed, so installation cannot silently select newer isolated build tools. +Save the final `pip freeze --all`. An installation problem is evidence and must +be resolved before the GPU envelope starts; no different numerical package +versions are silently accepted. The runtime verifies every derived package +version and the observed Python3.11.10 interpreter before starting a GPU subprocess. + +No project installation is necessary: fresh processes select one complete +pinned source root via `PYTHONPATH`, and assert the actual imported package path. +Set CUDA PATH/library paths normally, and preserve compiler flags required by +the short-prefix guard. The runner fixes one numerical-library thread per +process, NUMBA_NUM_THREADS=4, CUPY_ACCELERATORS=cub, and PYTHONNOUSERSITE=1. It +requires one visible GPU and checks compute-context ownership between/during +stages. Missing build support or skipped device coverage cannot pass the full +integration check. + +## Concrete command sequence for later reviewed operation + +The following is a recipe, not a launched command. `WORK` below is a task-specific +variable, not a system environment variable. Transfer the reviewed ops directory, +three full source roots and exact development originals only when root authorizes +the new rental. On the GPU host, the paths passed to `bind` must be the actual +local copies there. Copies of the final termination/setup receipts accompany +that bundle. No SSH, upload, setup or rental is performed by this code. + +```sh +WORK=/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910 +cd "$WORK/release-integration-ops" +# CPU-only; fresh output directory, with original verified receipt SHAs. +../local-env/bin/python integrate.py setup \ + --freeze "$WORK/collected/evidence/freeze.txt" --freeze-sha256 ORIGINAL_FREEZE_SHA \ + --install-report "$WORK/collected/evidence/install.json" --install-report-sha256 ORIGINAL_INSTALL_SHA \ + --output setup-derived +``` + +After pinned setup and transfer to the later GPU host, run this CPU-only binding +step. `ROOT` here denotes a task-specific example directory containing the full +verified copies; replace it deliberately when preparing the final launch. + +```sh +ROOT=/workspace/release-integration +"$ROOT/venv/bin/python" "$ROOT/ops/integrate.py" bind \ + --baseline-root "$ROOT/baseline" --precursor-root "$ROOT/precursor" \ + --release-root "$ROOT/release" --manifest "$ROOT/dev-final/manifest.json" \ + --inputs "$ROOT/dev-final" --setup "$ROOT/setup-derived/setup.json" \ + --setup-sha256 DERIVED_SETUP_SHA --termination "$ROOT/original-collector-final.json" \ + --termination-sha256 VERIFIED_FINAL_COLLECTOR_SHA --output "$ROOT/binding.json" +``` + +Root reviews and pins the concrete binding bytes. The eventual single launch is: + +```sh +"$ROOT/venv/bin/python" "$ROOT/ops/integrate.py" run \ + --binding "$ROOT/binding.json" --binding-sha256 REVIEWED_BINDING_SHA \ + --output "$ROOT/results-attempt-1" +``` + +An existing output directory is refused. There is no resume/retry CLI. An +interruption preserves its original stage, logs, receipt and any complete +outputs. Further experiments would require separate root review and new output +paths; the tool never rewrites failed outcomes. + +## Fixed execution and interpretation + +A single wall-clock **and monotonic** 900-second deadline begins before the first +GPU ownership query/subprocess. Cold contexts, JIT, short-prefix compilation and +canary, input preparation/transfers, output compression, and focused device +tests are included. The last30seconds are reserved for owned-child termination +and ownership checks. No stage starts in the last60seconds. CUDA work can block +Python signal handling. Each stage is therefore wrapped by Linux GNU +`/usr/bin/timeout`, which remains an independent guardian if the Python parent +dies; it sends TERM at remaining35seconds and KILL5seconds later. The parent +also enforces both deadlines and stops scheduling on SIGINT/SIGTERM. It +terminates only its owned subprocess session, escalating to SIGKILL if needed. +GPU PID ownership requires a matching Linux session, inherited unique owner +marker and stable `/proc` start ticks; unsupported NVML/PID-namespace mappings +fail closed. A bounded release poll tolerates short NVML cleanup delays inside +the existing reserve. Final nonempty/unknown GPU ownership or either exceeded +deadline prevents a successful result. +Provider teardown remains root's separate mandatory responsibility. Driver-level +cleanup failure is retained and is not labeled successful GPU emptiness. + +Four fresh sequential workers execute, in order: immutable6ced75d, release +implicit baseline default, frozen optimized precursor, release experimental. +Each runs all eleven full searches plus TESS-solar0000 with `full=False` to +exercise final winner fitting. All original arrays/grids/settings stay intact. +The two release workers additionally exercise `tls_transit` on TESS-solar0000 +and batch/FAP calls on the fixed three cases with two null permutations, +seed20260912. TESS-solar uses two copies of its original lightcurve in a batch; +ZTF-solar and HATpi0001 each use one. This tests a multi-lightcurve batch while +keeping each case's original complete grid. It does not substitute a shared +cross-regime grid or create a new physical population. During fast and batch +smokes, lightweight wrappers record calls to the selected backend and require +all observed/FAP calls and final full fitting to remain there; wrappers are +restored immediately. No such instrumentation is added to the eleven primary +scalar comparisons. + +The final fresh process runs the staged +`test_tls_reference_prefix.py` and `test_tls_reference_short_prefix.py` suites. +They include actual separate module/graph-buffer ownership, thread caches, +packed winner bits, literal/graph prefixes, guarded short scans, fallback, +compiler/canary failure and runtime-fault propagation. The host routing suite +was already validated locally and is not rerun for scientific evidence here. +Device-test errors/skips, missing short-module dispatch, or missing native graph +fallback mark coverage unavailable/failed. Exactly86 device node IDs were +collected locally with inert CuPy imports and no test execution; +`device-test-inventory.json` freezes those IDs. Actual JUnit names must match +that complete inventory, not merely report a positive count. A pinned pytest +configuration and disabled third-party plugin autoload replace inherited +PYTEST selection/plugin environment settings. Baseline workers must never import +experimental backend/short-prefix modules, even after their batch/FAP calls. + +Planning proxy from the two pinned exploratory receipts: the eleven-case sums +were27.60164s literal and18.48342s prototype, suggesting about92.17s for two +passes of each. These literal timings used the then-current optimized precursor +with native prefix, **not** immutable6ced75d, and cannot establish the new +baseline's latency. Extra API calls, fresh contexts, hashing/compression and +focused device tests consume the remaining cap. This is a margin estimate, +not a guaranteed runtime or a throughput benchmark; exceeding it preserves +partial work and fails the attempt. + +No result here requalifies experimental sensitivity or waives the frozen +zero-mismatch gate. Original development remains79/80; original held out +remains5111/5120 exact, with nine unresolved chi2/SDE differences. The new +release wiring has no historical benchmark result of its own. Control/new-path +variations, missing results and byte mismatches are retained separately without +causal attribution, retries or tolerance relaxation. + +Offline preparation tests: `../local-env/bin/python -m pytest -q test_integrate.py` +passed31 small synthetic/mock checks. Those tests perform no CUDA imports or +cloud operations. `offline-tests.xml` is their retained receipt. + +The pre-review drafts and their previous offline-test receipts are retained in +`pre-review-v1/`, `pre-review-v2/` and `pre-review-v3/`. Independent review identified and corrected +terminal GPU/deadline pass checks, device-suite deselection, interruption/parent +death protection and one editable-freeze parser edge. No draft was launched. + +Cleanup also checks live marked processes in the owned Linux session after its +timeout leader exits, using fresh owner/session/start-tick evidence. It escalates +to KILL after5seconds for TERM-ignoring survivors even if the leader is already +gone. Two synthetic interruption cases and a mocked `/proc` fixture cover this +branch; no real process was signalled during preparation. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/host/RUNBOOK.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/host/RUNBOOK.md new file mode 100644 index 00000000..f72eb7b8 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/host/RUNBOOK.md @@ -0,0 +1,76 @@ +# Local release staging: baseline default, experimental opt-in + +The complete detached checkout is `../release-validation-checkout`, based on +`6ced75d6d75bfaafa39b78c557fcba86f4651d92`. It is separate from the active +`v1.0-fixes` working tree. No branch integration, commit, remote operation, +GPU execution, or shared-environment installation has occurred. + +`assembly.json` pins all 82 precursor package sources before copying only the +nine differing package/test files. The reviewed eight-file production overlay +was then applied exactly. `validation.json` verifies all 82 original sources +remain unchanged and records all 86 staged package source hashes. The three +default backend/math/kernel files are byte-identical to baseline 6ced75d. +`release-staging.patch` is the complete tracked-plus-new source/doc patch from +that baseline. The original prototype's source snapshots and inventories remain +unchanged under `../default-preserving-release-prototype/`. + +The staged implementation uses `execution='baseline'` by default and requires +`execution='experimental'` for the optimization bundle. Invalid selectors and +experimental use with another method are rejected. Result metadata, scalar, +convenience, direct frontend, batch and FAP paths retain that selection. Engine +module caches and thread-local prefix caches are separate. The experimental +math adapter imports only five unchanged baseline helpers and contains the two +optimized functions; no global rebinding is used. + +Existing default tests remain. Shared mathematical and GPU prefix contracts +now run for both engines; packing and guarded short-prefix tests explicitly use +the experimental engine. New host tests exercise complete engine-module loading +and cache orchestration with fake CUDA dependencies, plus immutable source pins. +A separate GPU regression exercises real compiled module/graph-buffer isolation +and proves baseline does not dispatch the short-prefix cache. GPU regressions +are authored but not executed here. + +Host results: focused 219 passed; full CPU selection 872 passed, 18 skipped, +1117 deselected, one existing xfail. Device modules skip without CuPy. Exact +summaries are in `host-suite.xml`. The first no-isolation wheel attempt in the +old shared host environment emitted invalid UNKNOWN metadata; its artifact is +retained in `wheels/UNKNOWN-0.0.0-py3-none-any.whl` and is not a release wheel. +A separate build-only virtual environment with setuptools77.0.3 and wheel0.45.1 +produced `wheels/cuvarbase-1.0.0-py3-none-any.whl`. `verify_wheel.py` verified +its metadata and every staged Python/CUDA/header source byte, then extracted it +to a fresh directory. All 219 focused tests also passed with imports from that +unpacked wheel (`wheel-tests.xml`). No package was installed into the host test +environment. Both environment freezes are retained. + +Executed commands (all local): + +```sh +cd ../release-validation-checkout +../local-env/bin/python -m pytest cuvarbase/tests/test_tls_reference_frontend.py cuvarbase/tests/test_tls_reference_math.py cuvarbase/tests/test_tls_execution_isolation.py cuvarbase/tests/test_kernel_inventory.py -q +../local-env/bin/python -m pytest -m 'not gpu' -q --junitxml=../release-validation-evidence/host-suite.xml +../release-validation-build-env/bin/python -m pip wheel --no-deps --no-build-isolation --no-cache-dir --disable-pip-version-check --wheel-dir ../release-validation-evidence/wheels . +cd ../release-validation-evidence +PYTHONPATH="$PWD/unpacked-wheel" ../local-env/bin/python -m pytest --pyargs cuvarbase.tests.test_tls_reference_frontend cuvarbase.tests.test_tls_reference_math cuvarbase.tests.test_tls_execution_isolation cuvarbase.tests.test_kernel_inventory -q --junitxml=wheel-tests.xml +``` + +Do not overwrite the retained XML/archive outputs when reproducing; use fresh +result paths. `verify_wheel.py CHECKOUT WHEEL FRESH_OUTPUT` prints its verification +receipt. `wheel-verification.json` records the exact original invocation paths. + +Remaining validation is the already predeclared bounded GPU integration plan at +`../default-preserving-release-prototype/development-validation-plan.json`. +Its eleven fixed development IDs and exact NPZ pins, installation prerequisites, +separate fresh-process controls and 900-second GPU cap remain unchanged. Follow +that prototype README after the original primary → supplement → collection → +provider-termination sequence finishes. Setup/provisioning costs are separate +and remain within the existing authorization; no new allowance or GPU rental +is created here. Existing device prefix/short-prefix tests and selected TLS +contracts must run from this full checkout; record both dispatch branches and +all failures. Root approval is required for the later operational handoff. + +This staging work does not requalify the optimized algorithm. Original +qualification remains 79/80 development and 5111/5120 held out, with nine +chi2/SDE differences and no changed selected-period or frozen-threshold decisions +in those original comparisons. The zero-mismatch gate failed. The new routing +has no historical benchmark result of its own. Baseline restoration does not +promise native floating-point determinism or undo allocator/driver history. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/README.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/README.md new file mode 100644 index 00000000..cf613cd0 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/independent-audit/README.md @@ -0,0 +1,19 @@ +# Independent collected release-wiring audit + +The local audit passed against the pinned final collection. It verified 410 selected collected members, including all 247 package sources (79 immutable baseline, 82 frozen precursor, 86 release), the reviewed runner and protocol, the unchanged 80-case development manifest, and the 11 specified original input files. It independently checked all 58 stored result JSON/NPZ pairs and their 820 typed arrays, rebuilt the normalized fingerprints from those original bytes, and reproduced all 24 prespecified exact comparisons. The 56 worker call records produce 58 results because each release worker includes a two-lightcurve TESS batch. + +The paired checks are 12 release-baseline versus immutable-baseline comparisons and 12 release-experimental versus frozen-precursor comparisons: eleven full searches and one fast search per pair. Only the two predeclared execution metadata fields are removed before comparison. All other typed fields, array bytes, dtypes, shapes, masks, NaN words and signed zero remain included. The convenience and batch/FAP outputs were checked for stored-byte integrity, selector metadata and selected-engine traces; those extra outputs do not create additional prespecified paired comparisons. + +The exact 86-node device XML inventory passed without skips, errors or failures. The baseline workers recorded no experimental-backend imports; experimental execution recorded both the short prefix kernel and long-row graph fallback. All five recorded stages exited normally with an empty GPU, and the recorded campaign finished within its 900-second cap in 177.825134370476 seconds. These are checks of immutable recorded execution evidence, not a new live GPU or provider observation. + +This establishes finite development-case release wiring, not universal equivalence or renewed sensitivity qualification. The original 79/80 development and 5,111/5,120 held-out exactness results and all failed zero-tolerance gates remain unchanged. This audit does not claim experimental execution equals baseline execution, requalify throughput, or independently repeat the original recovery study. The transport archive SHA was already verified by root; this audit did not rehash the entire transport archive. + +The first external checker draft reached its final stage-receipt check and rejected a schema assumption: campaign stage rows add the role field, while their standalone execution receipts omit it. The pinned runner explicitly constructs this addition. The correction checks that exact construction, preserving every other field. The initial checker and correction record are retained in checker-review-history-v1; no collected output, scientific definition or gate changed. + +Reproduce with Python and NumPy using the collected directory, which contains extracted/ and collection-verification.json. The output must be a new path; no overwrite is supported: + +```sh +python -B audit_wiring.py --collection /path/to/release-integration-collected --output /path/to/new-audit-receipt.json +``` + +No cuvarbase import, test execution, GPU use, process action or provider request occurs. The source records the original artifact pins, while the receipt records the command, local Python/NumPy versions and every checked member hash. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/README.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/README.md new file mode 100644 index 00000000..e404fc51 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/data/README.md @@ -0,0 +1,31 @@ +# Final collected data and setup transfer + +Prepared locally from the verified final collection. This is a separate supplement to `../release-validation-transfer-prep/source-bundle.tar.gz`; it does not replace that immutable source bundle. No installation, GPU tests, provider operations or scientific searches occurred during preparation. + +The 15,111,320-byte archive contains the original **80-case** development manifest unchanged, exactly the **11 predeclared original NPZ files** (14,619,794 bytes), original collected freeze/install/GPU/CPU/quota evidence, the final collector receipt, collection provenance, and setup derived by the unchanged pinned integration runner. All selected originals match final archive inventory `6cd942436ee8e1bfcf5acce8f74747f136031b817abd30b5b012e9aae4b78fe9`. The original complete manifest must not be rewritten to eleven rows. No earlier development or regenerated input is used. + +| Artifact | SHA256 | +|---|---| +| data-setup-bundle.tar.gz | 634239ec084d487c932cdfecc8e5153bc53f0163e4cf00809752fddf3bca2183 | +| data-transfer-inventory.json | ad8a4dcdb08631e87c8279215e503e0ae96ac84ec4ed5511ab76332c3ed217aa | +| verify_data_transfer.py | 332a830a51e2f64e964e5a26ff5b74d6267ae333777b8121b23cde14e98899ac | +| setup-derived/setup.json | 65cb0e2a2e6bd7cddcca6b402d54051d97e75a423766c0b460af0648ff3fc5c9 | +| setup-derived/requirements.txt | e629af0ef28323c2c570c932010d02f5022f662434ccb490e593dbc8c5e014e1 | +| original-collector-final.json | 3ad52f46237a5b0cd662cfdc43eab367d5272e8c8cacb902da60956798695d68 | + +Transfer the archive, external inventory and verifier to a fresh incoming directory on the separately authorized integration host. First extract and verify the earlier source bundle into `/workspace/release-integration` using its own runbook. Then check the verifier SHA and use this exact command from the incoming directory: + +```sh +python3 verify_data_transfer.py --archive data-setup-bundle.tar.gz --archive-sha256 634239ec084d487c932cdfecc8e5153bc53f0163e4cf00809752fddf3bca2183 --inventory data-transfer-inventory.json --inventory-sha256 ad8a4dcdb08631e87c8279215e503e0ae96ac84ec4ed5511ab76332c3ed217aa --destination /workspace/release-integration +``` + +The destination must already exist. Verification checks all members and every destination collision before publishing missing files. It refuses overwrite; any interrupted partial extraction remains for review instead of being resumed or silently replaced. Paths are disjoint from the earlier source bundle. + +The CPU-only `integrate.py setup` derivation has completed, producing 64 exact dependency versions from the original collected freeze/install and the separately identified observed build-tool metadata. Use those files with the separately reviewed installer; do not substitute local host/build freezes or upgrade versions. Once actual Linux Python/package pins match, bind on that host under the unchanged runner's runbook: + +```sh +cd /workspace/release-integration +venv/bin/python ops/integrate.py bind --baseline-root baseline --precursor-root precursor --release-root release --manifest dev-final/manifest.json --inputs dev-final --setup setup-derived/setup.json --setup-sha256 65cb0e2a2e6bd7cddcca6b402d54051d97e75a423766c0b460af0648ff3fc5c9 --termination original-collector-final.json --termination-sha256 3ad52f46237a5b0cd662cfdc43eab367d5272e8c8cacb902da60956798695d68 --output binding.json +``` + +This does not authorize launching the 900-second validation run. Its fixed development plan, controls, preserved failures and budget remain unchanged. The historical 5,111/5,120 exact comparisons and failed zero-mismatch qualification do not qualify the new release wiring. diff --git a/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/README.md b/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/README.md new file mode 100644 index 00000000..c79a748f --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/release-validation/transfer/sources/README.md @@ -0,0 +1,85 @@ +# Release integration source transfer — prepared locally, unlaunched + +`source-bundle.tar.gz` is **1,418,579 bytes** and contains only the three separately pinned package roots, the reviewed integration operations files, and small provenance/verification files. All 263 tar members are regular files with safe relative paths; there are no symlinks, hardlinks, `.git` files, environments or NPZ inputs. The 262 payload files contain baseline **79**, frozen optimized precursor **82**, and staged release **86** package files, including `.cu`/`.cuh` kernels and their package tests/conftest. The final runner and its setup command are unchanged. + +The earlier 254.6 MB `baseline.tar` covers a full repository; only its 79 pinned package members were selected here. The previously tested release wheel is retained separately but does not include the other two controls or operations files. No existing combined bundle covered this complete source layout. The selective bundle is not an installed wheel and makes no new packaging or numerical qualification claim. + +| Transfer item | SHA256 | +| --- | --- | +| `source-bundle.tar.gz` | `e1d0fe2ba33237665453b33b464c01e230317b66d39aa67fbd7e304fd4c6f40e` | +| `transfer-inventory.json` | `01f43057e7537fee4010aa1f12af0bdd4d814547523e873b79d3659106a8ce8e` | +| `verify_transfer.py` | `7f381583d6a4f80d072f1eb1d1fce9b5c391428136ded10f0c31d39d419d77ed` | +| Original operations inventory | `68b1146d045b147cd8a2f90a7365a48a25765ba16ed4b9c87075d6f07b05a12a` | +| Original runner | `6edeb1e7536300dd1d8e11781a6b3dc4d2fa0f7b55f4d7453b8101294a4d5f6e` | +| Original integration protocol | `1aeca13d85142a89822b5ee1b6b64dbdcd259e5d360981f6be4db05f6d77ac03` | + +The fresh local `verified-layout/` was extracted only after verifying the complete archive hash, exact safe member set, every payload SHA/size, and the internal inventory bytes. The unchanged runner's CPU-only `protocol()` and `check_sources()` then confirmed all three maps. This performed no tests, CUDA imports, provider calls, setup, provisioning or remote writes. The original collector was still `status=phase=waiting`, and final collected development/freeze/install files were absent at preparation. Nothing here permits starting another rental before the original lifecycle closes. + +## Payload layout + +Extract directly into a fresh `/workspace/release-integration` on a later root-approved rental: + +```text +baseline/cuvarbase/ 79 exact baseline6ced75d files +precursor/cuvarbase/ 82 frozen optimized study files +release/cuvarbase/ 86 separately staged default-preserving release files +ops/ final runner, setup code, protocol, fixed plan and test pins +provenance/ original staging validation and local-only rental review/runbook +transfer-tools/ this source-transfer verifier +transfer-inventory.json exact internal/external inventory bytes +``` + +No project installation, `setup.py` or worktree metadata is needed: the reviewed runner selects one complete package root with `PYTHONPATH` in each fresh subprocess and checks the actual import path. Local rental/provider controller code and credentials are deliberately excluded. The copied rental runbook is provenance; the original local `WORK/release-validation-rental/rental.py` remains the lifecycle owner for any later rental. + +## Required later additions — not available in this bundle + +Wait for normal primary → supplement → verified collection → provider termination. Use the original verified collection inventory to pin these bytes: + +- The complete, unchanged **80-case** `WORK/collected/dev-final/manifest.json`, SHA256 `a1d18d6cf2d09fc4450a6f4ce9cf6f794e05685f65c755bbac5e7429c3116328`. +- Only the **11 original NPZs** named in `ops/development-validation-plan.json`, SHA256 `56dc97b664dfbebc5f02541563c2bc9ce8c7fc7ffacd81482a75d4bf4c9ca811`. Select their `cases[].file` paths from the original manifest, preserve those relative paths under `dev-final/`, and verify each file against its already frozen `input_sha256`. Do not rewrite the manifest to 11 rows. Do not substitute `WORK/development`, `development-v2`, regenerated ZIPs, calibration or held-out files. +- Original collected `evidence/freeze.txt` and `install.json`, plus `gpu.xml`, `cpu.txt`, `cpu-quota.json` for environment comparison. Their final verified inventory SHAs are still unknown here. The separate observed build-tool metadata in `ops/` does not replace them. +- The final original `WORK/collection-state.json`, copied byte-for-byte as `original-collector-final.json`. It must report complete/evidence-verified/provider-absence status for `okideq277lpb4a`; the current waiting receipt is not included. +- CPU-derived `setup-derived/{setup.json,requirements.txt,original-freeze.txt,original-install.json}` from the reviewed runner's `setup` command. Requirements must come from those original collected receipts, with the pinned observed build-tool supplement. No versions have been guessed or upgraded in this preparation. + +Prepare those later inputs/evidence as a **separate verified supplement** so this source bundle remains immutable. Check the runtime environment against the original frozen packages and Python3.11.10 before binding. Setup/provisioning costs remain separate from the unchanged 900-second GPU integration cap and within the reviewed local rental cap. The full setup/lifecycle prerequisites are in the copied `ops/RUNBOOK.md` and original local rental runbook. + +## Concrete later transfer instructions — none executed + +Root first verifies original teardown, final spending, and the separate rental review plan. After root authorizes a new host, set the task-specific host/SSH port from that rental's verified status receipt. No current study SSH alias is assumed or reused here. + +```sh +TLS_TRANSFER=/Users/johnhoffman/Documents/cuvarbase-tls-survey-20260910/release-validation-transfer-prep +: "${TLS_RELEASE_HOST:?Set the root-reviewed new host}" +: "${TLS_RELEASE_SSH_PORT:?Set its verified public SSH port}" +scp -P "$TLS_RELEASE_SSH_PORT" \ + "$TLS_TRANSFER/source-bundle.tar.gz" "$TLS_TRANSFER/transfer-inventory.json" \ + "$TLS_TRANSFER/verify_transfer.py" "root@$TLS_RELEASE_HOST:/workspace/" +``` + +On that later host, compare all three `sha256sum` values against the table above before running the verifier. Then extract to a new directory (before creating its venv); an existing destination is refused: + +```sh +sha256sum /workspace/source-bundle.tar.gz /workspace/transfer-inventory.json /workspace/verify_transfer.py +/usr/bin/python3 /workspace/verify_transfer.py \ + --archive /workspace/source-bundle.tar.gz \ + --archive-sha256 e1d0fe2ba33237665453b33b464c01e230317b66d39aa67fbd7e304fd4c6f40e \ + --inventory /workspace/transfer-inventory.json \ + --inventory-sha256 01f43057e7537fee4010aa1f12af0bdd4d814547523e873b79d3659106a8ce8e \ + --destination /workspace/release-integration +``` + +Retain the receiver's verification output. After the separately verified original inputs/closure/setup supplement is placed in this layout and pinned software is installed, use the unchanged binding step on the GPU host: + +```sh +TLS_INTEGRATION=/workspace/release-integration +: "${TLS_DERIVED_SETUP_SHA:?Set the reviewed derived setup SHA}" +: "${TLS_FINAL_COLLECTOR_SHA:?Set the verified final collector receipt SHA}" +"$TLS_INTEGRATION/venv/bin/python" "$TLS_INTEGRATION/ops/integrate.py" bind \ + --baseline-root "$TLS_INTEGRATION/baseline" --precursor-root "$TLS_INTEGRATION/precursor" \ + --release-root "$TLS_INTEGRATION/release" --manifest "$TLS_INTEGRATION/dev-final/manifest.json" \ + --inputs "$TLS_INTEGRATION/dev-final" --setup "$TLS_INTEGRATION/setup-derived/setup.json" \ + --setup-sha256 "$TLS_DERIVED_SETUP_SHA" --termination "$TLS_INTEGRATION/original-collector-final.json" \ + --termination-sha256 "$TLS_FINAL_COLLECTOR_SHA" --output "$TLS_INTEGRATION/binding.json" +``` + +Root reviews and pins that concrete binding before the separate `run` command described in the original runbook. This source-transfer check cannot qualify release wiring, experimental sensitivity, throughput, GPU behavior or provider termination. The historical development79/80 and held-out5111/5120 zero-mismatch failures remain unchanged. diff --git a/benchmarks/results/tls_survey_2026-09-10/runtime-planning/README.md b/benchmarks/results/tls_survey_2026-09-10/runtime-planning/README.md new file mode 100644 index 00000000..9995b94a --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/runtime-planning/README.md @@ -0,0 +1,49 @@ +# Runtime planning checkpoint — 2026-09-11 + +At 10:09 UTC the frozen calibration search had recorded 6,152 valid method +outcomes. High-impact calibration was complete, and the first four M-dwarf +calls per method agreed with the development-derived runtime forecast. +These are elapsed API-call timings under four-worker contention, not separately +tuned sustained-throughput measurements or detection-performance results. + +| Calibration regime | Calls per method | BLS mean / forecast | TLS mean / forecast | +| --- | ---: | ---: | ---: | +| ZTF high impact | 512 | 126.36 / 126.30 s | 16.17 / 17.76 s | +| ZTF M dwarf | 4 | 197.93 / 198.01 s | 24.51 / 24.79 s | + +The [timing snapshot](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-planning/calibration-timing-checkpoint-20260911T1008Z.json") records +per-regime counts, sums and descriptive timings, source-receipt identities, +stage timestamps and spending. The [remaining-work calculation](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-planning/remaining-envelope-20260911T1009Z.json") +uses the unchanged [frozen forecast](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-projection-selected-final.json"): +512 calibration calls minus completed calls, 256 future injection calls and +256 future independent test-null calls, per regime and selected method. +In-flight calls remain counted in full. The [independent arithmetic review](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-planning/root-envelope-review-20260911T1009Z.json") +recomputes these counts, costs and allowances; [the copy index](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-planning/index.json") +identifies the preserved source files by SHA-256. + +The [10:22 UTC follow-up](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/runtime-planning/mdwarf-first20-check-20260911T1022Z.json") preserves +the first 20 completed M-dwarf calls per method as 40 timing-only rows. BLS +averaged 196.78 s against a 198.01 s forecast (−0.62%); TLS averaged 25.01 s +against 24.79 s (+0.87%). These five waves support leaving the forecast +unchanged, but do not establish runtime tails. This follow-up used existing +receipts without inspecting detection scores or running additional GPU work. + +The remaining planning envelope was **38.32 hours**: 22.20 hours of scientific +searches, 4.84 hours of baseline comparisons, a 10-hour primary throughput +measurement allowance, the one-hour supplementary BLS limit, and an estimated +0.28 hours of further input preparation. The 10-hour measurement allowance is +a planning estimate, not an imposed timeout. Reporting, archive creation, +transfers, cleanup and additional timing variation consume the **11.38 hours** +left between that envelope and the existing study guard. + +That accounting projects **$74.58 cumulative compute before unmeasured overheads +and storage**, including the earlier $50.26 expenditure. Using the entire $30 +study cap would bring cumulative compute to $80.26, within the existing $100 +authorization. Rental estimates are not invoices. The evidence supports keeping +the full workload and current budget controls unchanged. + +Four simultaneous M-dwarf calls per method cannot establish runtime tails. +Held-out injections and independent test-null searches are still unmeasured; +input-preparation and baseline-comparison timings remain extrapolations. +This checkpoint neither guarantees a completion time nor changes any scientific +setting, acceptance tolerance, sample count or operational deadline. diff --git a/benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/README.md b/benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/README.md new file mode 100644 index 00000000..eb6e8618 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/README.md @@ -0,0 +1,7 @@ +# Verified old-study archive compression + +All 489 retained September 8/9 tar archives were compressed losslessly after the prior NPZ reclamation. The 26,447,624,704 original bytes are represented by 12,973,592,378 compressed bytes: **13.474 GB saved (50.95%)**, in addition to **26.148 GB** of earlier NPZ eviction. No cloud storage was created and no active survey data was removed. + +The [summary](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/summary.json") binds unchanged execution/aggregate receipts and a compact independent postcheck. The complete 759,464-byte audit, its checker and original inventory are preserved in the shared recovery kit. The postcheck rehashed all 489 compressed payloads, checked absent original tar paths, preparation/source/plan/journal bindings, and 489 original-matching complete decode receipts. It did not perform another decode or materialize the tars. + +Start with the [completed archive restoration command](/Users/johnhoffman/Documents/CUVARBASE_ARCHIVE_RESTORE_20260912/RESTORE_AFTER_COMPLETION.md), then the unchanged NPZ kits. The [storage guide](../../../../docs/STUDY_STORAGE.md) explains the two stages, metadata/path requirements, capacity needs, full-file codec control and cloud-storage policy. These are storage byte-preservation receipts, not scientific qualification. diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md new file mode 100644 index 00000000..fd3f37dd --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md @@ -0,0 +1,33 @@ +# September 24 throughput follow-up + +These are repeated original timing workloads on one new allocation, with unchanged numerical sources and full grids. Each available panel has three complete queues of at least 96 attempts and 120 seconds, in whole cohort cycles. + +The original experimental exactness outcome remains 5,111/5,120; its 9 mismatches still fail the aggregate gate. These timing repetitions do not requalify sensitivity. + +![Sustained throughput](throughput.png) + +| Workload | Method | Median / second | Observed range | API failures / attempts | Selected discrepancies | +| --- | --- | ---: | ---: | ---: | ---: | +| tess_solar | TLS baseline | unavailable | — | — | — | +| tess_solar | TLS experimental | 8.0722 | 8.0619–8.0813 | 0/2976 | 0 | +| tess_solar | GTLS | 2.476 | 2.4638–2.5016 | 0/928 | 0 | +| tess_solar | BLS execution | 6.3618 | 6.358–6.3626 | 0/2304 | 85 | +| tess_gap_long | TLS baseline | 0.77039 | 0.77038–0.77062 | 0/288 | 0 | +| tess_gap_long | TLS experimental | 0.77554 | 0.77082–0.77567 | 0/288 | 0 | +| tess_gap_long | GTLS | unavailable | — | — | — | +| tess_gap_long | BLS execution | 11.445 | 11.413–11.455 | 0/4128 | 421 | +| ztf_solar | TLS baseline | 0.45455 | 0.45066–0.45549 | 0/288 | 0 | +| ztf_solar | TLS experimental | 0.82349 | 0.82102–0.82982 | 0/384 | 0 | +| ztf_solar | GTLS | 0.12111 | 0.12048–0.12299 | 0/288 | 0 | +| ztf_solar | BLS execution | 29.851 | 29.78–29.88 | 0/10768 | 995 | +| varied | TLS baseline | unavailable | — | — | — | +| varied | TLS experimental | unavailable | — | — | — | +| varied | GTLS | unavailable | — | — | — | +| varied | BLS execution | 10.846 | 10.838–10.866 | 0/4032 | 153 | + +BLS rates count successful native completions and include failed-call elapsed time and per-attempt journal overhead. BLS selected discrepancies include the pre/post diagnostic comparisons and measured queues; they introduce no tolerance or numerical passing label. Its original exact qualification remains failed. TLS/GTLS rates require the unchanged strict timing gates. + +The CSV retains the selected worker/batch settings, comparison counts, cold preparation, sampled GPU memory, unavailable reasons and cost projections. BLS batches group serial native calls within a worker. Projected costs use the median successful rate at the recorded hourly price; they exclude acquisition, preprocessing and vetting and do not describe an actual million-source run. + +Verified evidence archive SHA256: `a0ffd2dcc13829f11eb1ad2355ff1be652d5b137eabf9a32286885934d61029a`. +Frozen follow-up design SHA256: `2165ec272d73b2b7092c5dfec224e2703927be1fec15a521debd811b690d12eb`. diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REVIEW.md b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REVIEW.md new file mode 100644 index 00000000..df36e9d0 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REVIEW.md @@ -0,0 +1,50 @@ +# Follow-up review, September 27 + +The completed follow-up has **11 reportable panels**: seven strict TLS/GTLS +timings and four BLS execution-only timings. All 21,232 measured BLS calls +completed, with no API failures. Its 1,654 selected-output discrepancies include +measured queues and diagnostic comparisons; they do not acquire numerical +qualification. Repeated calls are not independent scientific populations. + +Five panels stay unavailable: + +| Engine | Workload | Recorded failure | +| --- | --- | --- | +| Baseline TLS | TESS solar | The first measured queue changed the selected SDE for null 0015; its selected-period hash agreed. | +| Baseline TLS | Varied | Pre-queue power, chi2 and SDE differed for varied TESS solar null 0012; selected period agreed. | +| Experimental TLS | Varied | Pre-queue power, chi2 and SDE differed for varied TESS solar null 0011; selected period agreed. | +| GTLS | TESS long gap | Out-of-memory failure in the first measured queue. | +| GTLS | Varied | Pre-queue power, chi2 and SDE differed for varied long-gap null 0011, and two API calls ran out of memory. The compared selected period agreed. | + +[Machine-readable review](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/failure-review.json") binds these observations to the +original receipt hashes. The TLS varied-case SDE changes were approximately +`+4.29e-6` and `-2.38e-6`; the GTLS varied-case change was approximately `+0.0531`. +No tolerance was widened, failure replaced, or scientific experiment repeated. +These observations do not establish a cause or prove an absence of scientific +impact. Baseline mode preserves its implementation without promising bitwise +determinism under every execution history. + +The experimental/baseline median throughput ratio is **1.812** for ZTF solar +and **1.007** for long-gap TESS, where the paired complete-spectrum checks +passed. These are timing-cohort ratios. The original study's **5,111/5,120** +exactness outcome and its nine mismatches still fail the aggregate gate. + +The full GPU suite passed **2,091 tests**, with one expected notebook failure +and zero skips. Its separate release gate initially failed because the launcher +had not installed the package. The [isolated installed-wheel validation](../release-gate-20260927/README.md) +subsequently passed all 14 numerical/runtime checks and six dependency +preflights. That fixes validation setup and does not alter any timing outcome. + +The figure's horizontal margins were corrected so unavailable labels remain +inside their panels. [Review provenance](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/review.json") verifies that all 16 result +rows and the original exactness evidence stayed unchanged. The original figure +and report remain preserved locally and in the original immutable R2 bundle. + +Both rentals are terminated, and both evidence bundles passed full R2 checksum +read-back. Estimated follow-up compute was **$2.8053** for the benchmark and +**$0.0373** for the installed-wheel check. The cumulative conservative ledger, +including retained storage reserves, is **$78.1846** within the existing $100 +authorization. These are estimates and reserves, not provider invoices. + +Benchmark collection and review are complete. Release publication remains +pending; no tag or published package was changed by this review. diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/STATUS.md b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/STATUS.md new file mode 100644 index 00000000..464ae6cc --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/STATUS.md @@ -0,0 +1,13 @@ +# September 24 follow-up status + +The campaign has been collected and the owned GPU rental terminated. 11 of 16 panels have reportable results under their declared contracts. + +[Timing report](REPORT.md) · [Completed review and retained failures](REVIEW.md) · [Completion, GPU validation and cost receipt](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/completion.json"). + +The original experimental exactness and BLS qualification failures remain unchanged. BLS execution rates do not gain numerical qualification. + +R2 bucket `cuvarbase`, prefix `throughput-followup-20260924/resumed-65a59504`: all 7 objects passed full SHA256 read-back. Local archives remain intact. Benchmark review is complete; release publication is pending. + +The expanded GPU suite passed 2,091 tests, with one expected failure and zero skips. The additional gate initially failed because the validation launcher had not made the package importable. A [separate September 27 check](../release-gate-20260927/README.md) installed the unchanged wheel and passed all 14 numerical/runtime checks plus six dependency preflights. Its evidence is also verified in R2 and its rental is terminated. The original failed receipt remains intact. + +[Persistent monitoring](../../../../docs/JOB_MONITORING.md) now distinguishes validation failures from successful collection and queues follow-up work into the existing conversation. diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv new file mode 100644 index 00000000..1e16c377 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv @@ -0,0 +1,43 @@ +scope,backend,available,qualification,workers,batch_size,median_lightcurves_per_second,minimum_rate,maximum_rate,attempted,successful,api_failures,selected_discrepancies,selected_comparisons,complete_diagnostic_discrepancies,cold_preparation_seconds,sampled_gpu_peak_bytes,usd_per_million_successful,paired_experimental_speed_ratio,failure_reason +tess_solar,baseline,False,strict timing gates,,,,,,,,,,,,,,,,"Traceback (most recent call last): + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 3482, 'task': 7, 'indices': [8, 9, 10, 11, 12, 13, 14, 15], 'started': 4768000.047921224, 'ended': 4768004.145732233, 'api_seconds': 4.097811009734869, 'error': None, 'outputs': [], 'scalars': [{'case': 'tess_solar_nulls_0008.npz', 'fields': {'period': '0809a690f21d4f793860bb54d7d0df7d43a33f93c3b982206694ccca6e0bbfa6', 'SDE': '591f32e046a1f933088305544fda0ca045fb448d20c1c23ee422edacc0502518'}}, {'case': 'tess_solar_nulls_0009.npz', 'fields': {'period': '698a962152f083dd314e9060b5a53b40da7451cc6e8d01bcd65ca58b56f28f6c', 'SDE': 'f12ef6d0bbb6da1b17f20a53faa73d2a2a2a8dc2b77dd7c46c42e2bbf12b8ebf'}}, {'case': 'tess_solar_nulls_0010.npz', 'fields': {'period': '7cd30d6b2d422e2edfa11ac11a16909aa55b91f13ca79093105ff15eb6386f68', 'SDE': '29741ed3b4bf1a655676949c689e09bee8bbf090fa4d05a21b8986fc62455e65'}}, {'case': 'tess_solar_nulls_0011.npz', 'fields': {'period': '1322ad9910de561b1858d216ce592530f996b482a6b4d3c0aedac39ec66bda3d', 'SDE': '5a0d2f8dd34a7070c94d305bdeec23dc88e459d0a9483874e727d39b8c1e9e24'}}, {'case': 'tess_solar_nulls_0012.npz', 'fields': {'period': 'dd3eb1cde3f044cc382151c72e9ee781104e3640dc3b17dcba046121ae1d7fd8', 'SDE': '971b74628a3362267dda7b52fd81504d7516e241b1ab97b78726b069873a614b'}}, {'case': 'tess_solar_nulls_0013.npz', 'fields': {'period': 'ecf81586a7685717f71ab83aa7229744f6d7a5d60da09068776cbfca15d035ab', 'SDE': '824bd6207d102ee8837e993ece323b1a02b82fdf39de35c9c21008a7ecb3de02'}}, {'case': 'tess_solar_nulls_0014.npz', 'fields': {'period': '3b836097bc0d9e49aa187a06c160e3b1205efd310f10abc8efccdc070892f9ce', 'SDE': '51d7e07c8b9e498b7d93eb98355d612503120e73282ec9e539f27b3af3818fad'}}, {'case': 'tess_solar_nulls_0015.npz', 'fields': {'period': '3c9f35f237282d5748d68f8f9e0988177a2d4b58d17dfdac71d231de0581a9c9', 'SDE': '4385096764db97c021f68401e239d65955bfb40e80ed6683ff63af56516378c3'}}], 'profile': None, 'host_peak_rss_bytes': 363646976, 'scalar_match': False, 'membership_match': True} +" +tess_solar,candidate,True,strict timing gates,4,8,8.072180242679709,8.061897701016232,8.081324942596213,2976,2976,0,0,,0,10.760043038986623,3544317952,16.86175320905948,, +tess_solar,gtls,True,strict timing gates,2,4,2.4760218764863384,2.463763202103667,2.5015960260673413,928,928,0,0,,0,16.21672835573554,8549498880,54.97169164929311,, +tess_solar,bls,True,execution only; original exact gate failed,1,8,6.36183405713241,6.358009516602897,6.362576739201763,2304,2304,0,85,2336,32,3.9890154162421823,893517824,21.394948357465182,, +tess_gap_long,baseline,True,strict timing gates,4,8,0.7703886612603962,0.7703796967140845,0.7706210357991734,288,288,0,0,,0,87.03910730872303,3628204032,176.67849743326468,, +tess_gap_long,candidate,True,strict timing gates,4,8,0.775544617172059,0.7708215057844107,0.775666407776297,288,288,0,0,,0,85.7279324317351,3910598656,175.50390796009364,1.0066926684814226, +tess_gap_long,gtls,False,strict timing gates,,,,,,,,,,,,,,,,"Traceback (most recent call last): + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 761, in run + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 593, in run_queue + raise error +RuntimeError: Measured task failed numerical/membership gate: {'kind': 'complete', 'pid': 5310, 'task': 2, 'indices': [8, 9, 10, 11], 'started': 4773925.727578893, 'ended': 4773929.300325295, 'api_seconds': 3.5727464016526937, 'error': 'Traceback (most recent call last):\n File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 388, in worker\n results = public_call(internal_backend, selected,\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 125, in public_call\n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 125, in \n return [gtls(case[\'data\'][\'t\'], case[\'data\'][\'y\'], case[\'data\'][\'dy\'],\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-followup/sources/gtls/gputls/main.py"", line 108, in power\n = core.search_multi_periods(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-followup/sources/gtls/gputls/core.py"", line 768, in search_multi_periods\n ootrGPU = cp.empty((singleCalcPeriods,len(singleDurations),(tSize)),dtype=cp.float32)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""/workspace/tls-followup/venv/lib/python3.11/site-packages/cupy/_creation/basic.py"", line 32, in empty\n return cupy.ndarray(shape, dtype, order=order)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File ""cupy/_core/core.pyx"", line 167, in cupy._core.core.ndarray.__new__\n File ""cupy/_core/core.pyx"", line 254, in cupy._core.core._ndarray_base._init\n File ""cupy/cuda/memory.pyx"", line 875, in cupy.cuda.memory.alloc\n File ""cupy/cuda/memory.pyx"", line 1579, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1600, in cupy.cuda.memory.MemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1271, in cupy.cuda.memory.SingleDeviceMemoryPool.malloc\n File ""cupy/cuda/memory.pyx"", line 1292, in cupy.cuda.memory.SingleDeviceMemoryPool._malloc\n File ""cupy/cuda/memory.pyx"", line 1537, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\n File ""cupy/cuda/memory.pyx"", line 1540, in cupy.cuda.memory.SingleDeviceMemoryPool._try_malloc\ncupy.cuda.memory.OutOfMemoryError: Out of memory allocating 1,495,433,216 bytes (allocated so far: 4,818,000,896 bytes).\n', 'outputs': [], 'scalars': [], 'profile': None, 'host_peak_rss_bytes': 613711872, 'scalar_match': True, 'membership_match': False} +" +tess_gap_long,bls,True,execution only; original exact gate failed,1,8,11.444631609698977,11.413218263788075,11.455134587294893,4128,4128,0,421,4160,32,3.774877334944904,1175912448,11.893009382299478,, +ztf_solar,baseline,True,strict timing gates,4,8,0.4545545809017924,0.4506629868853666,0.4554894517251558,288,288,0,0,,0,151.8769503729418,2625044480,299.43843232440827,, +ztf_solar,candidate,True,strict timing gates,4,8,0.8234936950548055,0.8210153714631454,0.829816938586085,384,384,0,0,,0,83.54237468913198,2557935616,165.28494623392663,1.8116497548458834, +ztf_solar,gtls,True,strict timing gates,2,4,0.12110525907308327,0.12047964754572783,0.12298924599246262,288,288,0,0,,0,271.5421003680676,48282140672,1123.9075177484428,, +ztf_solar,bls,True,execution only; original exact gate failed,1,8,29.8505931246327,29.779999246402387,29.88016497414538,10768,10768,0,995,10800,32,5.470292201265693,895614976,4.5597456151982545,, +varied,baseline,False,strict timing gates,,,,,,,,,,,,,,,,"Traceback (most recent call last): + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 753, in run + raise RuntimeError('Pre-queue required-output qualification failed') +RuntimeError: Pre-queue required-output qualification failed +" +varied,candidate,False,strict timing gates,,,,,,,,,,,,,,,,"Traceback (most recent call last): + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 753, in run + raise RuntimeError('Pre-queue required-output qualification failed') +RuntimeError: Pre-queue required-output qualification failed +" +varied,gtls,False,strict timing gates,,,,,,,,,,,,,,,,"Traceback (most recent call last): + File ""/workspace/tls-followup/sources/candidate/benchmarks/tls_survey/throughput.py"", line 753, in run + raise RuntimeError('Pre-queue required-output qualification failed') +RuntimeError: Pre-queue required-output qualification failed +" +varied,bls,True,execution only; original exact gate failed,1,8,10.84638470773771,10.838038121594819,10.86552374315228,4032,4032,0,153,4224,192,18.688731756992638,1175912448,12.548984272520842,, diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png new file mode 100644 index 00000000..67d7a014 Binary files /dev/null and b/benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png differ diff --git a/benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/AUDIT.md b/benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/AUDIT.md new file mode 100644 index 00000000..399734a7 --- /dev/null +++ b/benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/AUDIT.md @@ -0,0 +1,28 @@ +# Frozen throughput tuning exclusions + +Read-only audit of the completed development pilot. No sources, gates, selections or GPU jobs were changed. These are tuning results, not final sustained-throughput or calibrated recovery results. + +The campaign completed **16 configurations: 12 eligible, 4 excluded**. Frozen selections are baseline **4 workers / batch 8**, candidate **4 / 4**, and public GTLS **2 / 1**. BLS has no qualifying setting. Its first configuration failed, so the predeclared rule stopped further BLS tuning. + +| Excluded configuration | Evidence and classification | +| --- | --- | +| GTLS 4 workers / batch 1 | Six GPU out-of-memory API failures across five distinct lightcurves, followed by three worker membership errors. Qualification stopped before any measured queue. | +| GTLS 2 / 4 | Post-queue repeatability failure for gapped-TESS development case 0003, worker 1: power and chi2 changed; SDE changed from 12.744205474853516 to 12.748966217041016. Period stayed 18.95005062135495 days; period arrays and finite masks matched. Its completed timing repetition is excluded. | +| GTLS 2 / 8 | Pre-queue out-of-memory failure requesting 1,837,246,464 bytes. The failed task contained gapped-TESS cases 0000–0007; the receipt does not identify the triggering member. | +| BLS 1 / 1 | Selected likelihood score changed from 138.0975799560547 to 138.09754943847656 (−0.000030517578125) on gapped-TESS case 0006 during measured task 22. Period stayed 12.847274301670177 days. API and membership checks passed, but the frozen exact selected-score gate failed. | + +All four excluded configurations passed GPU ownership checks with no foreign GPU processes. The score/spectrum changes are numerical qualification failures; **no calibrated threshold crossing or recovery change is established by this audit**. The BLS comparison in the scientific recovery campaign remains distinct from these throughput exclusions. + +The called public GTLS implementation exposes no period-batch or memory-fraction keyword. Its active core.py:620–631 chooses period groups from instantaneous free GPU memory, a fixed safety factor, and a cap of one-thirtieth of the period grid. Our queue batch size groups sequential lightcurve calls within each worker; it leaves this internal heuristic unchanged. This is the declared conditional optimum over queue batches and worker counts, not an optimum over modified GTLS allocation algorithms. + +Measurement has not yet run. Source inspection confirms that the absent BLS selection produces explicit missing panels for TESS solar, gapped TESS, ZTF solar and varied sampling; the other selected backends continue. The renderer marks missing results and excludes failed measurements from speed denominators. It separately requires the full held-out exactness receipt before presenting the global exactness status. + +The five original campaign/result files were downloaded and their bytes verified against remote SHA256 values. Four local timing/protocol source hashes also match the deployed sources; the three active public GTLS source hashes are recorded. All identities, exact cases, counts, deltas, and remote/local paths are in [audit.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/audit.json") and [transfer-and-source-hashes.json](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/transfer-and-source-hashes.json"). + +Original receipts: + +- [Completed campaign](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/campaign.json") +- [GTLS 4 / 1](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w4-b1__result.json") +- [GTLS 2 / 4](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w2-b4__result.json") +- [GTLS 2 / 8](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/gtls-mixed-w2-b8__result.json") +- [BLS 1 / 1](../../../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/originals/bls-mixed-w1-b1__result.json") diff --git a/benchmarks/results/transit_2026-09-08/.gitattributes b/benchmarks/results/transit_2026-09-08/.gitattributes new file mode 100644 index 00000000..d0e95c49 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the exact bytes used by the evidence SHA256 manifests. +* -text diff --git a/benchmarks/results/transit_2026-09-08/ARCHIVE.md b/benchmarks/results/transit_2026-09-08/ARCHIVE.md new file mode 100644 index 00000000..47965e66 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/ARCHIVE.md @@ -0,0 +1,17 @@ +# Transit evidence archive + +This directory contains the BLS and initial TLS report, timing figure, analysis tables, all nine frozen input datasets, per-job JSON results and logs, selected configurations, upstream source snapshots and original verification receipts. These support inspection of the timing arithmetic, recovery decisions and configuration selection. The public analysis tools are in [benchmarks/transit](../../transit/README.md). + +The files were originally published under `analysis/transit-recovery-20260908` at commit `f0dc981`. The reorganization preserves the input, result and analysis-record bytes. The report and timing figure have been updated for readability; removing recovery panels does not change the underlying recovery results. + +The full returned periodograms are about 20 GB and are not in Git. Their filenames and SHA256 values remain in the per-job records. Full-array verification receipts attest to checks against the original complete archive; a checkout alone cannot repeat those checks. Summary-based analysis and figure generation need only the committed files. + +The [original publication inventory and full harness](https://github.com/johnh2o2/cuvarbase/tree/f0dc981/analysis/transit-recovery-20260908) remain in Git history. Original manifests, source-hash records and verification receipts refer to that layout and its original document/figure bytes. Cloud coordination records, transport helpers, duplicate harness copies and intermediate publication receipts were retired from the working tree. For an exact historical file: + +```bash +git show f0dc981:scripts/benchmark_transit_recovery/worker.py +``` + +To regenerate the current figure without a GPU, see the [tool instructions](../../transit/README.md). Recomputing full-array validation requires restoring the omitted periodograms at the paths expected by the selected historical harness. A new GPU run is a new measurement and must record its own source, environment and timing provenance. + +The [rental ledger](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/rental-ledger.json") records the experiment cost and terminated resources. Viewing, checking summaries or plotting these records incurs no cloud expense. diff --git a/benchmarks/results/transit_2026-09-08/PROTOCOL.md b/benchmarks/results/transit_2026-09-08/PROTOCOL.md new file mode 100644 index 00000000..f7808518 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/PROTOCOL.md @@ -0,0 +1,43 @@ +This experiment compares transit-search speed together with recovery on shared inputs. It is designed for a practical cuvarbase upgrade decision, with TESS QLP and sparse ZTF workloads. It does not estimate the completeness of either survey's planet catalog. + +Frozen before inspecting held-out search results, 2026-09-08. The generated held-out inputs are already hashed in [inputs/manifest.json](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/inputs/manifest.json"). Pilot cases and tuning cases may be used to correct the harness and choose settings. Any later change to this protocol must be recorded explicitly. + +Three observing patterns: + +| Workload | Observations before independent losses | Baseline | Distinction | +|---|---:|---:|---| +| ZTF g/r | 1,317 | 2,744 days | Sparse, seasonal, uneven errors, two bands | +| TESS sector 67 | 9,736 | 25.76 days | Dense 200-second cadence, short baseline | +| TESS sectors 1 + 27 | 4,295 | 734.85 days | Separated observing windows, mixed 30/10-minute cadence | + +TESS times and quality flags come from public QLP FITS files for TIC 261136679, retrieved from MAST. ZTF uses the previously retained public g/r cadence. These are observed cadence examples, not random survey populations. Only times/quality and a ZTF relative-error pattern are used; fluxes are synthetic. The two-sector example is a controlled subset of observations, not the complete current QLP search for that star. + +QLP's published settings motivate the BLS search: solar density, minimum period at a/Rstar=2, maximum period half a single sector or the longest observed sector when separated, durations 0.5–2 times the circular central duration, samples_per_peak=2, dlogq=0.1, and phase overlap 3. ZTF's upper period is 10 days. The common explicit period grid is generated once and hashed. TLS uses the same underlying grid restricted to periods >=0.6 days; comparisons are within each algorithm. The figure must not interpret BLS/TLS row differences as a matched comparison of those two algorithms. + +Source: [Kunimoto et al., QLP Data Release Notes 003](https://arxiv.org/abs/2302.01293). Current FFI cadence regimes are described by [NASA TESS data products](https://heasarc.gsfc.nasa.gov/docs/tess/data-products.html). Data origin: [MAST QLP](https://archive.stsci.edu/hlsp/qlp). Our primary-source summary does not imply that the 2023 published protocol describes every detail of QLP's 2026 production deployment. + +Each profile has 32 tuning injections and 32 tuning nulls, followed by 128 independent held-out injections and 128 held-out nulls. Injections evenly cover white-noise oracle SNR 6, 8, 10, 14. Periods are log-uniform from 0.8 to min(12, 0.8 Pmax) days, with randomized epochs, radius ratios 0.025/0.05/0.10, and impact parameters uniform from 0 to 0.85. Independent batman models integrate over exposure times with seven sub-exposures. Noise combines known heteroscedastic Gaussian errors and a small correlated residual: an OU process with amplitude 0.25 times median error and correlation time 0.15 days for TESS / 1 day for ZTF. Oracle SNR is not native TLS SDE, QLP pink-noise SNR, or a detection threshold. + +Ephemerides are sampled conditional on at least five observed in-transit points and two observed transit events; all rejection counts are retained. This makes the target an observable-transit search benchmark. It must not be described as unconditional planet recovery. Each source loses an independently drawn 0–3% of its observations. All methods receive identical prepared arrays, with known unit band baselines and achromatic transits. Detrending, unknown multiband calibration, dilution and stellar-density inference are outside this benchmark. + +Recovery requires the reported primary period to accumulate no more than half an injected transit duration of drift over the entire baseline. Half/double/third aliases are tabulated separately and do not count as primary recovery. All BLS backends use the same QLP-inspired median-bin/MAD spectral ranking; TLS uses each public API's returned period and SDE. Native SDE values are never equated across methods. Tuning nulls set a method-specific empirical 95th-percentile threshold (higher order statistic), frozen for held-out detections. Held-out false positives and confidence intervals must accompany detection recall. With only 32 calibration nulls the achieved false-positive rate is uncertain; this is a relative sensitivity experiment, not a calibrated production false-alarm claim. Unthresholded period recovery is also reported, so threshold noise is visible. + +Choose public API configurations on the tuning set. Retain the fastest setting with period-recovery count within one tuning injection of the best setting for that implementation; use repeated timing before final selection when timings are close. Compare v1 BLS with the actual PyPI 0.2.5, and the fastest tested external CPU and GPU alternatives that meet the recovery requirement. Screen Astropy, periodfind CPU/GPU and fBLS. This does not establish a universal fastest competitor. For TLS, screen GTLS PyPI 0.4.4 and pinned upstream 0.5.1, including documented stellar-density constraints. The two vectorized GTLS diagnostic patches are explanatory ablations and cannot replace an unmodified competitor in the main figure. + +Pilot correction, before tuning/held-out selection: PyPI's fast BLS documents `noverlap` as unimplemented; passing 3 or 4 still executes one phase pass. Include this coarse public path as such, and use the documented repeated `dphi=i/noverlap` calls for actual phase oversampling. The adapter reuses the public memory/functions objects, keeps intermediate arrays on the GPU, takes their elementwise maximum, and returns one spectrum. No PyPI source is modified. Do not label the original one-pass pilot as a matched three/four-pass run. Optional Cython/matplotlib dependencies and a cuvarbase result-dictionary access error in the initial pilot are harness/setup corrections, not competitor algorithm failures. + +Calibration amendment, before any null calibration or held-out searches: use 128 **additional independent calibration nulls** per profile, with disjoint seeds retained in `calibration-manifest.json`, instead of the original 32 tuning nulls to set detection thresholds. Configuration selection continues to use the 32 tuning injections. The 128 held-out injections and 128 held-out nulls remain byte-identical. This reduces uncertainty in the false-positive operating point; the earlier 32-null description above records the initial plan and is superseded by this amendment. All achieved held-out false-positive rates still require confidence intervals and must not be assumed to equal exactly 5%. + +For equivalence claims, use paired held-out results and show the recall difference interval. A material sensitivity loss is five percentage points: report a one-sided 95% bound on v1-minus-comparator recall and only describe v1 as noninferior within this experiment when its lower bound exceeds -5 percentage points. A similar-looking curve or overlapping marginal confidence intervals is insufficient. If this condition fails, label the speed as an observed timing comparison with unresolved/different recovery; do not advertise it as equivalent-sensitivity speedup. Also inspect recovery by input SNR and achieved null rejection; a pooled count alone can conceal a tradeoff. + +Timing measures prepared host arrays and the supplied period grid to host periodograms and candidate/score. CPU/GPU transfers, package postprocessing, and common BLS ranking are included. Imports/context initialization, disk I/O, generation of synthetic input and explicit-grid construction are excluded and disclosed. Record initialization/first call separately. Final timings use randomized sequential method order and warmed repetitions, both one source and a batch of distinct sources. A batch reuses a compatible stellar-density/period grid; it is a throughput unit, not a measured complete survey or QLP pipeline. CPU search must use the allocated CPU capacity fairly (7.65 CPU equivalents, not all 96 logical host CPUs). Competitor parallelism is tuned for latency and throughput. No method runs concurrently with another timed method. + +The A40 pod costs $0.49/hour including its CPU allocation. Report rental-equivalent cost from measured sustained throughput and a CPU-only break-even hourly price, rather than inventing an unmeasured CPU rental price. Linear extrapolations per million searches must be labeled and omit startup, idle time and full-pipeline work. Track actual experiment rental separately; the user's total authorized RunPod budget is $50. + +Explain gains using implementation inspection plus measured ablations/components. In particular distinguish caching, batching and fewer GPU dispatches from changes in phase binning, template family, duration/epoch grids, fitted objective, and refinement. Do not attribute every historical speed gain to phase 5 or infer a kernel speedup from unequal host API work. + +Operational amendment, after tuning and before independent validation (2026-09-08): freeze serial GTLS scientific settings separately from concurrent throughput settings. Two GTLS workers preserved the tested TESS-gap outputs; four workers encountered an OOM there and are excluded. ZTF concurrency changed parts of the SDE spectrum while preserving the tested primary periods. Therefore each concurrent setting gets its own 128-null calibration and 256-case held-out evaluation, in addition to serial GTLS. Selected worker counts: 1 for dense TESS, 2 for separated TESS, 4 for ZTF. Two additional A40 rentals compute disjoint serial-GTLS recovery cases only, with matching source/input hashes and cross-machine preflights. All performance ratios use exclusive measurements on the original A40. These choices are recorded in operational-selection.json and validation-distribution.json; distributed evaluation elapsed times are never speed denominators. + +Timing clarification: supplement the explicit-grid boundary with measured fresh native Keplerian-grid-plus-single-BLS timings for v1 and PyPI. Use a retained observed time vector with the exact declared baseline, trim the endpoint to the shared period range, and verify frequency and duration arrays agree after the GPU's float32 cast. This boundary measures the grid-generation improvement relevant to independent per-star searches; the shared-grid batch remains a separate throughput workload. It does not add preprocessing or full-pipeline costs. Statistical intervals are nominal per comparison, not simultaneous confidence bounds across every plotted comparison. + +Operational CPU scheduling check, September 8 after scientific validation began: compare Astropy parallelism across period chunks versus across distinct light curves, using five timing repetitions on 16 tuning inputs. Scientific search settings remain frozen. Use the faster batch schedule only after all 128 calibration and 256 held-out spectra/candidates are verified bit-identical. The separate main timing remains on the original machine after other jobs finish; this does not select science parameters from held-out outcomes. diff --git a/benchmarks/results/transit_2026-09-08/README.md b/benchmarks/results/transit_2026-09-08/README.md new file mode 100644 index 00000000..3fd1fb3e --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/README.md @@ -0,0 +1,154 @@ +**Scope of this 8 September 2026 experiment:** its BLS competitor results remain current. Its TLS measurements concern the earlier phase-binned engine, retained as `method='binned'`, with a [subsequent binned-TLS study](../tls_sensitivity_2026-09-09/README.md) also archived. The [current transit report](../../../docs/TRANSIT_BENCHMARKS.md) covers the observation-level TLS default. The figure below retains the initial TLS comparison as historical evidence; measurements below retain their original interpretation. + +This benchmark measures the practical cuvarbase upgrade: prepared-array transit-search time, together with recovery on independent injections. The timing figure shows single-source latency and throughput for 16 distinct sources. Recovery and false-positive results are reported in the tables below. Numerical speed ratios are qualified by the sensitivity actually demonstrated below. + +The clearest supported upgrade is BLS on separated TESS sectors: **2.73× faster batch searches, or 10.18× faster fresh grid plus single-source search**, with the same 89/128 held-out transit detections as PyPI and a supported detection/false-positive comparison. Across the three examples, TLS batch searches are **93–284× faster than public GTLS**, but this experiment does **not establish equivalent detection sensitivity** for TLS. On ZTF, v1 detects more transits and also accepts more nulls. Those two findings belong together in any release claim. + +![BLS and TLS search time](benchmark_story.png) + +[Vector figure: PDF](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/benchmark_story.pdf") · [Editable SVG](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/benchmark_story.svg") · [Frozen protocol](PROTOCOL.md) + +Times include host preparation inside the API, transfers, periodograms and candidate ranking. Inputs, explicit period grids and GPU contexts are prepared before timing. Detrending, grid construction, imports, disk I/O, catalog vetting and idle time are outside these numbers. Initial setup and first API calls are retained in the timing table; these are fresh processes with already-populated disk caches, not pristine installations. + +| Workload | Comparison | Single-source speedup | Batch throughput speedup | Primary-period recovery | Detection + false positives | +|---|---|---:|---:|---|---| +| TESS 200 s | BLS v1 vs BLS PyPI 0.2.5 | 2.69× | 4.31× | Not established | Not established; see recovery difference | +| TESS 200 s | BLS v1 vs CPU Astropy | 19.66× | 43.99× | Not established | Not established; see recovery difference | +| TESS 200 s | BLS v1 vs BLS periodfind GPU | 13.20× | 11.90× | Noninferiority supported | Supported within 5 pp | +| TESS 200 s | TLS v1 vs GTLS upstream | 75.01× | 284.14× | Not established | Not established; see recovery difference | +| TESS separated sectors | BLS v1 vs BLS PyPI 0.2.5 | 2.12× | 2.73× | Noninferiority supported | Supported within 5 pp | +| TESS separated sectors | BLS v1 vs CPU periodfind | 49.20× | 57.10× | Noninferiority supported | Not established; see recovery difference | +| TESS separated sectors | BLS v1 vs BLS periodfind GPU | 4.92× | 3.51× | Noninferiority supported | Not established; see recovery difference | +| TESS separated sectors | TLS v1 vs GTLS upstream | 181.25× | 206.32× | Not established | Not established; see recovery difference | +| ZTF g/r | BLS v1 vs BLS PyPI 0.2.5 | 1.86× | 1.82× | Noninferiority supported | Not established; see recovery difference | +| ZTF g/r | BLS v1 vs CPU periodfind | 19.77× | 18.77× | Not established | Not established; see recovery difference | +| ZTF g/r | BLS v1 vs BLS periodfind GPU | 1.97× | 1.49× | Not established | Not established; see recovery difference | +| ZTF g/r | TLS v1 vs GTLS upstream | 214.54× | 92.51× | Noninferiority supported | Not established; see recovery difference | + +For dense TESS, the CPU batch comparison uses Astropy workers across independent sources. On the tuning inputs this is 1.11× faster than the period-parallel single-source scheduling policy. All 384 independent calibration/held-out spectra and candidates are bit-identical under both schedules. Single-source timing retains period-parallel workers. [Scheduling evidence](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/cpu-operational-selection.json"). + +“Supported” means the one-sided 95% lower bound on paired v1-minus-comparator detection recall exceeds −5 percentage points, and the corresponding upper bound on the false-positive increase is below +5 points. This is a pooled result for the equally weighted SNR mixture in this experiment; inspect the per-SNR tables for tradeoffs. It does not mean identical algorithms, exactly equal recall, or a universal sensitivity guarantee. Unmarked speed ratios are measured timing differences; they must not be advertised as demonstrated equivalent-sensitivity speedups. “Not established” can reflect a measured loss or insufficient precision; it does not itself prove inferiority. + +Independent per-star searches can also require a new period grid. The following measurements put each release’s native Keplerian grid construction, endpoint trimming and one BLS search inside the timer. The fresh grids have bit-identical GPU float32 frequencies and duration bounds; the small float64 differences are retained. This separate boundary is relevant to QLP workloads that cannot reuse one grid across sources. + +| Workload | v1 fresh grid + search | PyPI fresh grid + search | Speedup | Single-source detection match | +|---|---:|---:|---:|---| +| TESS 200 s | 6.15 ms | 25.9 ms | 4.22× | Not established | +| TESS separated sectors | 59.4 ms | 0.605 s | 10.18× | Supported within 5 pp | +| ZTF g/r | 0.179 s | 1.91 s | 10.68× | Not established | + +| Workload | Method | Correct primary period / 128 | Detected at correct period / 128 | False positives / 128 | Invalid held-out outputs | +|---|---|---:|---:|---:|---:| +| TESS 200 s | BLS v1 | 59 | 49 | 3 | 0 | +| TESS 200 s | BLS v1 batch | 59 | 49 | 3 | 0 | +| TESS 200 s | BLS PyPI 0.2.5 | 63 | 50 | 3 | 0 | +| TESS 200 s | BLS external CPU | 59 | 48 | 5 | 0 | +| TESS 200 s | BLS periodfind GPU | 50 | 39 | 7 | 0 | +| TESS 200 s | TLS v1 | 63 | 56 | 7 | 0 | +| TESS 200 s | GTLS single | 69 | 59 | 8 | 0 | +| TESS 200 s | GTLS batch | 69 | 59 | 8 | 0 | +| TESS separated sectors | BLS v1 | 95 | 89 | 6 | 0 | +| TESS separated sectors | BLS v1 batch | 95 | 89 | 6 | 0 | +| TESS separated sectors | BLS PyPI 0.2.5 | 96 | 89 | 7 | 0 | +| TESS separated sectors | BLS external CPU | 86 | 74 | 7 | 0 | +| TESS separated sectors | BLS periodfind GPU | 86 | 74 | 7 | 0 | +| TESS separated sectors | TLS v1 | 92 | 78 | 2 | 0 | +| TESS separated sectors | GTLS single | 95 | 74 | 2 | 0 | +| TESS separated sectors | GTLS batch | 95 | 74 | 2 | 0 | +| ZTF g/r | BLS v1 | 101 | 93 | 9 | 0 | +| ZTF g/r | BLS v1 batch | 101 | 93 | 9 | 0 | +| ZTF g/r | BLS PyPI 0.2.5 | 94 | 88 | 11 | 0 | +| ZTF g/r | BLS external CPU | 99 | 90 | 6 | 0 | +| ZTF g/r | BLS periodfind GPU | 99 | 90 | 6 | 0 | +| ZTF g/r | TLS v1 | 108 | 103 | 10 | 0 | +| ZTF g/r | GTLS single | 103 | 96 | 3 | 0 | +| ZTF g/r | GTLS batch | 103 | 96 | 3 | 0 | + +TLS can return a finite native candidate while representing trial periods with no admissible fit as NaN. These are distinct from an API exception or missing candidate. The experiment retains that native masking and calibrates the resulting score, and separately counts the masked trials: TESS 200 s TLS v1: 22 calibration / 0 held-out trial periods, across 4 / 0 light curves. The full returned spectra and counts are retained. + +Each method’s detection threshold is the higher empirical 95th percentile of 128 independent calibration nulls. The held-out set contains 128 injections and 128 new nulls per observing pattern. The per-SNR tables contain 32 injections at each white-noise oracle SNR (6, 8, 10, 14), with 95% Wilson intervals. This SNR excludes the additional correlated residual and is neither native TLS SDE nor QLP pink-noise SNR. Achieved false-positive rates and paired differences are in [recovery_summary.csv](recovery_summary.csv), [recovery_by_snr.csv](recovery_by_snr.csv), and [paired_comparisons.csv](paired_comparisons.csv). + +Real cadence, controlled flux: the experiment uses public ZTF g/r times and relative errors, plus public QLP times/quality flags for one dense TESS sector and a controlled pair of separated sectors. Fluxes are simulated exposure-integrated batman transits with heteroscedastic Gaussian noise and an OU residual. Injections require at least five observed in-transit points and two observed events. Known band baselines and achromatic transits are supplied. These three cadence examples are not a random catalog sample, injections into observed flux, unconditional survey completeness, or a reproduction of the full current QLP pipeline. + +A long gap matters to timing because the longer baseline requires finer trial-period spacing to keep a transit aligned. TESS 200 s uses 4,133 BLS / 3,084 TLS periods; separated TESS sectors use 128,964 / 99,043; ZTF uses 423,781 / 312,064. BLS and TLS have different minimum periods, so comparisons are within each algorithm. A simulated sinusoid or noise does not invalidate a fixed-grid timing comparison on identical arrays; transits are needed here to establish recovery. GTLS’s mean-depth gate can also make flux values affect its execution time. + +Configuration selection used only 32 tuning injections per survey. The fastest complete choice within one recovery of the best in each family was retained, with close timings repeated. CPU candidates included Astropy, periodfind’s Rust implementation and fBLS; GPU BLS included periodfind. This identifies the strongest tested competitor for these workloads, not the fastest code that could exist. Selected versions, settings, exclusions and repeat evidence are in [selection.json](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/selection.json"); operational choices are recorded separately when applicable. + +BLS gains come from reusing folded phase histograms for several phase offsets, a vectorized maximum-bin scan and grid generator, and a public batch API that amortizes per-source work. Both releases receive warmed kernels and reusable PyPI memory in this experiment; compilation caching is not credited as a cause of the remaining warm API ratio. Batch throughput has a separate recovery validation. Some tuned searches have different phase sampling and minimum-duration bounds, so the total upgrade ratio is not a pure kernel ablation. The separated-TESS PyPI comparison uses matching duration bounds and phase-pass counts. PyPI 0.2.5 documents `noverlap` as unimplemented in its fast kernel: the adapter uses the documented repeated `dphi` calls, public reusable memory, a GPU maximum and one final transfer. No installed package source is changed. + +| Selected BLS settings | v1 phase passes | PyPI phase passes | v1 minimum duration / central duration | PyPI minimum duration / central duration | +|---|---:|---:|---:|---:| +| TESS 200 s | 8 | 4 | 0.5 | 0.25 | +| TESS separated sectors | 4 | 4 | 0.25 | 0.25 | +| ZTF g/r | 8 | 3 | 0.5 | 0.5 | + +Both BLS releases use maximum duration 2 times the central duration and dlogq=0.1. The minimum-duration factor was among the tuning choices; the selected 0.25 values widen the initial QLP-inspired 0.5 lower bound. TLS v1 uses epoch oversampling 4 and 16 durations, with 50 candidates refined; its fractional-duration window is 0.5–2 times the central value. GTLS uses its documented fast mode, duration_grid_step=1.1 and stellar-radius bounds 0.5–2 solar radii at one solar mass. Those GTLS physical bounds do not make its discrete template-duration/epoch search identical to v1’s. These are selected benchmark settings, not a claim about the exact current QLP production configuration. + +The external BLS implementations have additional numerical differences. Astropy and periodfind accept scalar duration bounds, approximated here with 16 logarithmic period chunks covering the same density prior. periodfind searches brightening and dimming boxes, whereas cuvarbase/Astropy are configured for dimmings; its public output does not expose a dip-only switch. periodfind’s long-lightcurve GPU branch has a fixed 64-bin array, so the dense TESS comparison uses a valid capped setting. Unchecked larger-bin calls crashed and are excluded. fBLS native-grid pilots and failures remain in the evidence; failures and timeouts do not supply speedup denominators. + +TLS gains combine architecture and host orchestration. cuvarbase folds into weighted phase bins, evaluates integrated templates and analytically solves weighted depths, then refines a limited candidate set against observations. GTLS sorts phase-folded samples, represents template widths in observation counts, estimates depth from an unweighted window mean and template overshoot, and evaluates weighted residuals. Epoch/duration grids, geometry, refinement and SDE construction differ. These are related template searches, not numerically identical algorithms. The fast cuvarbase TLS engine predates phase 5; the whole gain cannot be attributed to that phase. + +The separate [TLS component audit](../tls_profile_2026-09-08/README.md) demonstrates substantial avoidable GTLS dispatch overhead. On its two retained diagnostic inputs, batching two Python/CuPy host operations makes GTLS about 5.8× faster while retaining the best periods; one operation is bit-identical and the other changes chi-square by a few parts in 10⁶ in absolute units. Those diagnostic patches are not the public competitor in this figure. The main comparison uses the unmodified public API, including its fast mode when selected by tuning. The remaining API ratio cannot be interpreted as the speed of an otherwise identical residual kernel. + +The new component experiment repeats explanatory measurements on all three cadence examples, using a retained tuning injection. The table reports ordinary uninstrumented API medians; synchronized wall-phase fractions are kept separately. The BLS rows disable one feature while retaining scientific settings. The GTLS row enables the two diagnostic host-loop changes. These ablations are not public competitors, are not independent additive savings, and cannot be multiplied to explain the full release ratio. + +| Workload | Diagnostic change | Baseline time | Changed time | Changed / baseline time | Same primary period | Maximum spectrum difference | +|---|---|---:|---:|---:|---|---:| +| TESS 200 s | Public unfused phase passes versus fused histogram | 5.43 ms | 8.51 ms | 1.57× | True | 1.02e-08 (BLS chi2 ratio) | +| TESS 200 s | Diagnostic chronological input versus conflict-scattered observation order | 5.43 ms | 5.12 ms | 0.94× | True | 2.79e-09 (BLS chi2 ratio) | +| TESS 200 s | Diagnostic batching of two GTLS host loops | 0.51 s | 0.369 s | 0.72× | True | 0.000865 (native SDE) | +| TESS separated sectors | Public unfused phase passes versus fused histogram | 27.2 ms | 36.7 ms | 1.35× | True | 1.68e-08 (BLS chi2 ratio) | +| TESS separated sectors | Diagnostic chronological input versus conflict-scattered observation order | 27.2 ms | 24.2 ms | 0.89× | True | 9.31e-09 (BLS chi2 ratio) | +| TESS separated sectors | Diagnostic batching of two GTLS host loops | 7.48 s | 2.81 s | 0.38× | True | 0.0387 (native SDE) | +| ZTF g/r | Public unfused phase passes versus fused histogram | 85 ms | 0.12 s | 1.42× | True | 2.61e-08 (BLS chi2 ratio) | +| ZTF g/r | Diagnostic chronological input versus conflict-scattered observation order | 85 ms | 60.2 ms | 0.71× | True | 9.31e-09 (BLS chi2 ratio) | +| ZTF g/r | Diagnostic batching of two GTLS host loops | 17 s | 2.06 s | 0.12× | True | 0.124 (native SDE) | + +The fusion ablation increases ordinary BLS API time by 1.35–1.57×. Observation scattering does not demonstrate a benefit on these retained cases: disabling it reduces the measured median by 6–29%. All these ablations retain the primary period, with small spectrum changes. They are three-repetition diagnostics on one tuning injection per cadence, not population sensitivity tests or a new optimized release. + +For GTLS, batching the two host loops improves the ordinary single-source API by TESS 200 s 1.38×, TESS separated sectors 2.66×, ZTF g/r 8.24×. This is a demonstrated opportunity for upstream improvement; the figure uses the unmodified public upstream interface. Residual-template evaluation and phase sorting remain after the loop changes. The remaining gap includes cuvarbase’s different search architecture and numerical approximation, so it cannot be advertised as a pure implementation speedup at identical sensitivity. + +The validation runs also provide an independent timing sanity check: mean GTLS batch time per injected source is about 3–5% greater than for null sources on these cadences. This is much smaller than the measured API ratio. It is not a causal signal-only ablation: the cohorts also contain independent missing-sample/noise draws and sequential timing variation. [Runtime by cohort](runtime_by_cohort.csv) · [Cohort ratios](runtime_cohort_ratios.csv). + +Timing-output audit: 4 source/repetition results change their best period relative to the retained validation run, all in GTLS’s separated-sector batch configuration (two null sources). Every timed repetition returns a valid finite result, and every calibrated null accept/reject decision is unchanged. Other parts of the GTLS batch spectra change by up to 2.60 native SDE units on these timing nulls. GTLS’s memory-dependent chunking and numerical variation mean full spectra and candidates must not be described as universally identical between calls. The full deltas are retained in [timing_analysis.json](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/timing_analysis.json"). PyPI fresh-grid float64 period rounding differences are also recorded; those remain numerically equal at relative tolerance 10⁻¹². + +[component_phases.csv](component_phases.csv) gives measured phase times and fractions; [component_summary.csv](component_summary.csv) reports profiling overhead and instrumentation differences; [component_ablations.csv](component_ablations.csv) retains numerical changes. These are synchronized wall regions, including dispatch/wait overhead, not GPU kernel-busy traces. The native GTLS fast-mode spectrum is in SDE units, so its deltas must not be described as chi-square deltas. + +| Fraction of synchronized diagnostic API time | TESS 200 s | Separated TESS | ZTF g/r | +|---|---:|---:|---:| +| v1 BLS: common host candidate ranking | 46.8% | 59.0% | 64.2% | +| v1 BLS: synchronized GPU kernel launches | 14.9% | 25.1% | 14.1% | +| PyPI BLS: host maximum-bin scan | 12.1% | 35.7% | 37.1% | +| GTLS: two host-loop regions | 31.7% | 63.6% | 89.1% | +| GTLS: phase folding / sorting | 16.2% | 8.7% | 3.6% | +| GTLS: residual kernel region | 29.4% | 21.9% | 3.6% | +| v1 TLS: coarse kernel region | 14.7% | 38.1% | 31.4% | +| v1 TLS: CPU statistics / results | 12.9% | 26.4% | 34.3% | + +These fractions belong to separately instrumented calls on one tuning injection per cadence, not the main batch timing. Synchronization and sequential measurement variation change the total runtime (including shorter profiled calls on some small problems), so do not multiply these percentages by the headline timings. They locate plausible bottlenecks: GTLS host dispatch on large grids and host candidate/statistics work after cuvarbase’s fast kernels. + +| Workload | v1 grid construction | PyPI grid construction | Grid-only speedup | +|---|---:|---:|---:| +| TESS 200 s | 1.8 ms | 20 ms | 11.07× | +| TESS separated sectors | 32.1 ms | 0.553 s | 17.21× | +| ZTF g/r | 0.139 s | 1.74 s | 12.54× | + +The earlier CPU TLS failures were concrete output/template edge cases in transitleastsquares 1.32. Sparse PS1/Gaia examples constructed a zero-sample transit template before searching. ZTF/Rubin completed the period search, then failed while constructing a zero-sample plotting model; ZTF had already found the correct 1.66894-day period. First-call failure times include compilation and are not successful CPU timings. This requested TLS comparison is v1 versus GTLS; those CPU failures are not converted to speed claims. + +| Workload | v1 BLS projected GPU cost / million | v1 TLS projected GPU cost / million | CPU hourly break-even for BLS | +|---|---:|---:|---:| +| TESS 200 s | $0.21 | $0.21 | $0.0111/h | +| TESS separated sectors | $2.14 | $3.08 | $0.0086/h | +| ZTF g/r | $7.50 | $8.54 | $0.0261/h | + +Costs use the actual A40 bundle price, $0.49/hour, and linearly project measured batch search time. A standalone CPU at the measured performance would need to cost below the listed break-even price to beat that GPU search cost. No standalone CPU rental was measured. These are search-only rental equivalents, not measured million-star jobs or complete QLP costs. Hardware: A40 and a 7.65-CPU-equivalent quota on an Intel Xeon Gold 6342 host; the 96 host logical CPUs are not the allocation. + +Provenance: frozen cuvarbase v1 commit `1032caf029570dc4841db1c594a2cbb1654e8fd8`; PyPI cuvarbase `0.2.5`; GTLS upstream commit `74e449c325792a763dde4fbffab98039c5e8c111`; periodfind commit `116b1b27c8db4c95035b5233efa6a1d21780afa5`. Actual PyPI cuvarbase has no TLS implementation. The legacy environment uses NumPy 1.23.5 / PyCUDA 2022.2.2 and the modern environment NumPy 2.2.6 / PyCUDA 2025.1.2: this measures usable software stacks, not an isolated package-source change. Full environment listings accompany the hardware records. periodfind’s CUDA architecture selection in setup.py was adapted to build on the A40; its numerical sources are unchanged. + +Per-job installed-source hashes, input/output SHA256 values, controller exit records and paired success vectors are retained here. The original source archives, frozen harness copies and hardware/transfer records are recoverable from the pinned Git snapshot described in [ARCHIVE.md](ARCHIVE.md). Full periodograms remain in the larger local archive. [provenance-verification.json](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/provenance-verification.json") records checks of the original complete archive: frozen inputs, actual validation worker/controller, installed code and exclusive final timing intervals. Auxiliary A40 nodes evaluated serial GTLS recovery only; their elapsed times never enter speed ratios. + +The maintained [benchmark tools](../../transit/README.md) provide summary analysis, timing plots and individual search workers. Reproduce original orchestration from the frozen harness in Git history; use its original layout when checking historical manifests. Full-array validation requires restoring the periodograms. [timing_summary.csv](timing_summary.csv) records timing boundaries and repetitions, and [speedups.csv](speedups.csv) derives ratios and costs. + +All three A40 nodes have been terminated and verified absent. Estimated total RunPod rental for the retained benchmark campaigns is **$8.03**, including the earlier **$3.77** once, against the authorized **$50** limit. This is elapsed rental × quoted rate, not an invoice. [Rental and termination ledger](../../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/rental-ledger.json"). + +The Git checkout includes the frozen transit inputs, per-job JSON records, source snapshots, figures and verification receipts. Full periodograms and transport archives remain in the local experiment archive. See [archive contents and reproduction boundaries](ARCHIVE.md) for what is included and which verification commands require the complete arrays. diff --git a/benchmarks/results/transit_2026-09-08/benchmark_story.png b/benchmarks/results/transit_2026-09-08/benchmark_story.png new file mode 100644 index 00000000..5ef8c3d4 Binary files /dev/null and b/benchmarks/results/transit_2026-09-08/benchmark_story.png differ diff --git a/benchmarks/results/transit_2026-09-08/component_ablations.csv b/benchmarks/results/transit_2026-09-08/component_ablations.csv new file mode 100644 index 00000000..74bca543 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/component_ablations.csv @@ -0,0 +1,10 @@ +profile,baseline,variant,meaning,baseline_s,variant_s,variant_over_baseline,periods_equal,powers_equal,max_abs_power_difference,candidate_period_equal,candidate_score_abs_difference,power_units +tess_200s,component_tess_200s_bls_v1,component_tess_200s_bls_v1_unfused,Public unfused phase passes versus fused histogram,0.005431227385997772,0.008508004248142242,1.5664975232074978,True,False,1.0244548320770264e-08,True,4.624027670985242e-06,BLS chi2 ratio +tess_200s,component_tess_200s_bls_v1,component_tess_200s_bls_v1_no_scatter,Diagnostic chronological input versus conflict-scattered observation order,0.005431227385997772,0.005118124186992645,0.9423512998530797,True,False,2.7939677238464355e-09,True,9.40100460411486e-06,BLS chi2 ratio +tess_200s,component_tess_200s_gtls_native,component_tess_200s_gtls_both,Diagnostic batching of two GTLS host loops,0.5102832280099392,0.3687116466462612,0.7225627385093667,True,False,0.0008647143840789795,True,2.384185791015625e-05,native SDE +tess_gap,component_tess_gap_bls_v1,component_tess_gap_bls_v1_unfused,Public unfused phase passes versus fused histogram,0.02717015892267227,0.03672560676932335,1.3516890671801514,True,False,1.6763806343078613e-08,True,9.661665565374733e-07,BLS chi2 ratio +tess_gap,component_tess_gap_bls_v1,component_tess_gap_bls_v1_no_scatter,Diagnostic chronological input versus conflict-scattered observation order,0.02717015892267227,0.024190403521060944,0.8903298501090159,True,False,9.313225746154785e-09,True,7.350682400542041e-06,BLS chi2 ratio +tess_gap,component_tess_gap_gtls_native,component_tess_gap_gtls_both,Diagnostic batching of two GTLS host loops,7.475522819906473,2.8081326372921467,0.37564364459090493,True,False,0.03867149353027344,True,0.0001220703125,native SDE +ztf,component_ztf_bls_v1,component_ztf_bls_v1_unfused,Public unfused phase passes versus fused histogram,0.08495102822780609,0.12026598677039146,1.4157096068088098,True,False,2.60770320892334e-08,True,1.928874747036957e-06,BLS chi2 ratio +ztf,component_ztf_bls_v1,component_ztf_bls_v1_no_scatter,Diagnostic chronological input versus conflict-scattered observation order,0.08495102822780609,0.06015884131193161,0.7081590719609497,True,False,9.313225746154785e-09,True,5.190047239977957e-06,BLS chi2 ratio +ztf,component_ztf_gtls_native,component_ztf_gtls_both,Diagnostic batching of two GTLS host loops,16.965853169560432,2.0584252700209618,0.12132754241408382,True,False,0.12395881116390228,True,0.0018558502197265625,native SDE diff --git a/benchmarks/results/transit_2026-09-08/component_phases.csv b/benchmarks/results/transit_2026-09-08/component_phases.csv new file mode 100644 index 00000000..963caf51 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/component_phases.csv @@ -0,0 +1,208 @@ +job,profile,phase,exclusive_mean_s,calls_mean,fraction_of_profile +component_tess_200s_bls_v1,tess_200s,Common candidate ranking,0.0014457982033491135,1.0,0.4678197517961959 +component_tess_200s_bls_v1,tess_200s,GPU kernel launches (synchronized),0.0004615914076566696,1.0,0.14935803437919817 +component_tess_200s_bls_v1,tess_200s,Host maximum-bin scan,3.50121408700943e-05,2.0,0.011328946884675214 +component_tess_200s_bls_v1,tess_200s,Host preparation and H2D,0.0006653200834989548,1.0,0.21527892039602195 +component_tess_200s_bls_v1,tess_200s,Kernel cache lookup,1.7799437046051025e-05,1.0,0.005759398650314217 +component_tess_200s_bls_v1,tess_200s,Memory pool / allocation,2.5019049644470215e-05,1.0,0.008095462816138611 +component_tess_200s_bls_v1,tess_200s,Remaining public API work,0.00035481713712215424,1.0,0.1148088748741111 +component_tess_200s_bls_v1,tess_200s,Spectrum D2H,8.514523506164551e-05,1.0,0.02755061020334486 +component_tess_200s_bls_pypi,tess_200s,Common candidate ranking,0.0017596632242202759,1.0,0.1564221426829327 +component_tess_200s_bls_pypi,tess_200s,GPU kernel launches (synchronized),0.003529401496052742,4.0,0.31373988886171594 +component_tess_200s_bls_pypi,tess_200s,Host maximum-bin scan,0.0013583861291408539,4.0,0.12075132672340753 +component_tess_200s_bls_pypi,tess_200s,Host preparation and H2D,0.0038889311254024506,1.0,0.34569963786755303 +component_tess_200s_bls_pypi,tess_200s,Remaining public API work,0.0007130689918994904,1.0,0.06338700386439083 +component_tess_200s_bls_v1_unfused,tess_200s,Common candidate ranking,0.0012189913541078568,1.0,0.17289076703847478 +component_tess_200s_bls_v1_unfused,tess_200s,GPU kernel launches (synchronized),0.004324117675423622,8.0,0.6132939492550893 +component_tess_200s_bls_v1_unfused,tess_200s,Host maximum-bin scan,0.00010992959141731262,8.0,0.015591424267545438 +component_tess_200s_bls_v1_unfused,tess_200s,Host preparation and H2D,0.0005663968622684479,1.0,0.08033263536757856 +component_tess_200s_bls_v1_unfused,tess_200s,Memory pool / allocation,2.714991569519043e-05,1.0,0.0038506997886024992 +component_tess_200s_bls_v1_unfused,tess_200s,Remaining public API work,0.0007249452173709869,1.0,0.10281970767862719 +component_tess_200s_bls_v1_unfused,tess_200s,Spectrum D2H,7.911399006843567e-05,1.0,0.011220816604082228 +component_tess_200s_bls_v1_no_scatter,tess_200s,Common candidate ranking,0.0014533791691064835,1.0,0.4838771090792731 +component_tess_200s_bls_v1_no_scatter,tess_200s,GPU kernel launches (synchronized),0.0003926306962966919,1.0,0.13071950547951322 +component_tess_200s_bls_v1_no_scatter,tess_200s,Host maximum-bin scan,3.634020686149597e-05,2.0,0.012098834642231692 +component_tess_200s_bls_v1_no_scatter,tess_200s,Host preparation and H2D,0.0006233919411897659,1.0,0.20754741552520478 +component_tess_200s_bls_v1_no_scatter,tess_200s,Kernel cache lookup,1.7268583178520203e-05,1.0,0.005749271961462328 +component_tess_200s_bls_v1_no_scatter,tess_200s,Memory pool / allocation,2.520531415939331e-05,1.0,0.00839166736948638 +component_tess_200s_bls_v1_no_scatter,tess_200s,Remaining public API work,0.00036853738129138947,1.0,0.12269805872926888 +component_tess_200s_bls_v1_no_scatter,tess_200s,Spectrum D2H,8.685886859893799e-05,1.0,0.028918137213559625 +component_tess_200s_tls_v1_native,tess_200s,API remainder,0.0004906486719846725,1.0,0.09929887825798822 +component_tess_200s_tls_v1_native,tess_200s,v1 CPU statistics/results,0.0006386563181877136,1.0,0.12925308802302818 +component_tess_200s_tls_v1_native,tess_200s,v1 buffer allocations/transfers,0.0003417748957872391,1.0,0.06916937863325931 +component_tess_200s_tls_v1_native,tess_200s,v1 candidate selection/refinement/transfers,0.0008424967527389526,1.0,0.170506896807802 +component_tess_200s_tls_v1_native,tess_200s,v1 coarse search kernel,0.00072491355240345,1.0,0.14671007321084656 +component_tess_200s_tls_v1_native,tess_200s,v1 coarse spectrum transfer,6.70701265335083e-05,1.0,0.01357384358640791 +component_tess_200s_tls_v1_native,tess_200s,v1 final packaging,1.7844140529632568e-06,1.0,0.00036113480770325426 +component_tess_200s_tls_v1_native,tess_200s,v1 grid/configuration,0.0003332439810037613,1.0,0.06744286775718321 +component_tess_200s_tls_v1_native,tess_200s,v1 host lightcurve preparation,0.00022405944764614105,1.0,0.045345790348050266 +component_tess_200s_tls_v1_native,tess_200s,v1 kernel lookup/grid transfers,0.0008692238479852676,1.0,0.17591600260710683 +component_tess_200s_tls_v1_native,tess_200s,v1 lightcurve transfers,0.000230446457862854,1.0,0.0466384117004662 +component_tess_200s_tls_v1_native,tess_200s,v1 parameter-spectrum transfers,0.00015328079462051392,1.0,0.0310214045882215 +component_tess_200s_tls_v1_native,tess_200s,v1 template tables,2.353079617023468e-05,1.0,0.004762229671936546 +component_tess_200s_gtls_native,tess_200s,API remainder,0.030899541452527046,1.0,0.058698553536938446 +component_tess_200s_gtls_native,tess_200s,GTLS CUDA module lookup/compile,8.279271423816681e-05,1.0,0.0001572778216351179 +component_tess_200s_gtls_native,tess_200s,GTLS chunk allocations/transfers,0.0329737551510334,31.0,0.06263884967420909 +component_tess_200s_gtls_native,tess_200s,GTLS duration-mask union,0.04017096199095249,31.0,0.07631107945983459 +component_tess_200s_gtls_native,tess_200s,GTLS error prefixes/out-of-transit terms,0.012317284941673279,31.0,0.02339862585628776 +component_tess_200s_gtls_native,tess_200s,GTLS folding/sorting,0.0852726474404335,31.0,0.16198884597393953 +component_tess_200s_gtls_native,tess_200s,GTLS full search call,0.0015978123992681503,1.0,0.0030352967148240726 +component_tess_200s_gtls_native,tess_200s,GTLS input/template setup,0.0045480262488126755,1.0,0.008639693332131932 +component_tess_200s_gtls_native,tess_200s,GTLS reductions/chunk cleanup,0.018665535375475883,31.0,0.035458129021632624 +component_tess_200s_gtls_native,tess_200s,GTLS reorder/weights,0.011350728571414948,31.0,0.02156249955217273 +component_tess_200s_gtls_native,tess_200s,GTLS result/statistics processing,0.00403103232383728,1.0,0.007657581813420113 +component_tess_200s_gtls_native,tess_200s,GTLS row-wise flux prefix sums,0.1267741583287716,31.0,0.24082751296469898 +component_tess_200s_gtls_native,tess_200s,GTLS setup and allocations,0.002985825762152672,2.0,0.005672047063253592 +component_tess_200s_gtls_native,tess_200s,GTLS transit residual kernel,0.15474050864577293,31.0,0.29395400721502146 +component_tess_200s_gtls_both,tess_200s,API remainder,0.04094011150300503,1.0,0.10221400576939524 +component_tess_200s_gtls_both,tess_200s,GTLS CUDA module lookup/compile,0.00014571473002433777,1.0,0.0003638017999609086 +component_tess_200s_gtls_both,tess_200s,GTLS chunk allocations/transfers,0.041688404977321625,31.0,0.10408224869040886 +component_tess_200s_gtls_both,tess_200s,GTLS duration-mask union,0.003837728872895241,31.0,0.00958154794294334 +component_tess_200s_gtls_both,tess_200s,GTLS error prefixes/out-of-transit terms,0.01274438202381134,31.0,0.031818534192649024 +component_tess_200s_gtls_both,tess_200s,GTLS folding/sorting,0.09245731867849827,31.0,0.23083554386834457 +component_tess_200s_gtls_both,tess_200s,GTLS full search call,0.0019599981606006622,1.0,0.004893471364408353 +component_tess_200s_gtls_both,tess_200s,GTLS input/template setup,0.006785642355680466,1.0,0.016941519244315348 +component_tess_200s_gtls_both,tess_200s,GTLS reductions/chunk cleanup,0.019195588305592537,31.0,0.04792507645395042 +component_tess_200s_gtls_both,tess_200s,GTLS reorder/weights,0.011461375281214714,31.0,0.028615287944032466 +component_tess_200s_gtls_both,tess_200s,GTLS result/statistics processing,0.0051626767963171005,1.0,0.01288950753848205 +component_tess_200s_gtls_both,tess_200s,GTLS row-wise flux prefix sums,0.004711395129561424,31.0,0.011762805504805341 +component_tess_200s_gtls_both,tess_200s,GTLS setup and allocations,0.004503676667809486,2.0,0.011244200760742442 +component_tess_200s_gtls_both,tess_200s,GTLS transit residual kernel,0.15493927150964737,31.0,0.3868324489255616 +component_tess_gap_bls_v1,tess_gap,Common candidate ranking,0.01718251220881939,1.0,0.5900512105694542 +component_tess_gap_bls_v1,tess_gap,GPU kernel launches (synchronized),0.007307970896363258,16.0,0.2509573118180117 +component_tess_gap_bls_v1,tess_gap,Host maximum-bin scan,0.0002911631017923355,17.0,0.00999860431337527 +component_tess_gap_bls_v1,tess_gap,Host preparation and H2D,0.0021207649260759354,1.0,0.07282752933660366 +component_tess_gap_bls_v1,tess_gap,Kernel cache lookup,1.9006431102752686e-05,1.0,0.0006526849825270525 +component_tess_gap_bls_v1,tess_gap,Memory pool / allocation,2.45608389377594e-05,1.0,0.0008434245569974239 +component_tess_gap_bls_v1,tess_gap,Remaining public API work,0.0018674489110708237,1.0,0.06412860222432279 +component_tess_gap_bls_v1,tess_gap,Spectrum D2H,0.00030694715678691864,1.0,0.010540632198707909 +component_tess_gap_bls_pypi,tess_gap,Common candidate ranking,0.011737870052456856,1.0,0.19317090094550338 +component_tess_gap_bls_pypi,tess_gap,GPU kernel launches (synchronized),0.02439713664352894,4.0,0.4015052854444064 +component_tess_gap_bls_pypi,tess_gap,Host maximum-bin scan,0.021706391125917435,4.0,0.35722350914860435 +component_tess_gap_bls_pypi,tess_gap,Host preparation and H2D,0.001999625936150551,1.0,0.03290797580088624 +component_tess_gap_bls_pypi,tess_gap,Remaining public API work,0.0009231492877006531,1.0,0.015192328660599672 +component_tess_gap_bls_v1_unfused,tess_gap,Common candidate ranking,0.012663584202528,1.0,0.33719033234726126 +component_tess_gap_bls_v1_unfused,tess_gap,GPU kernel launches (synchronized),0.021273473277688026,4.0,0.5664438605977158 +component_tess_gap_bls_v1_unfused,tess_gap,Host maximum-bin scan,0.00012584775686264038,4.0,0.003350919161827811 +component_tess_gap_bls_v1_unfused,tess_gap,Host preparation and H2D,0.0017589908093214035,1.0,0.046836242102173575 +component_tess_gap_bls_v1_unfused,tess_gap,Memory pool / allocation,2.2085383534431458e-05,1.0,0.000588062407521644 +component_tess_gap_bls_v1_unfused,tess_gap,Remaining public API work,0.0014199819415807724,1.0,0.0378095312631275 +component_tess_gap_bls_v1_unfused,tess_gap,Spectrum D2H,0.0002922266721725464,1.0,0.007781052120372412 +component_tess_gap_bls_v1_no_scatter,tess_gap,Common candidate ranking,0.014266159385442734,1.0,0.5565255466263479 +component_tess_gap_bls_v1_no_scatter,tess_gap,GPU kernel launches (synchronized),0.006024157628417015,16.0,0.23500351612074877 +component_tess_gap_bls_v1_no_scatter,tess_gap,Host maximum-bin scan,0.00018339604139328003,17.0,0.007154313885603349 +component_tess_gap_bls_v1_no_scatter,tess_gap,Host preparation and H2D,0.002499530091881752,1.0,0.09750713651166235 +component_tess_gap_bls_v1_no_scatter,tess_gap,Kernel cache lookup,2.464093267917633e-05,1.0,0.0009612473937908461 +component_tess_gap_bls_v1_no_scatter,tess_gap,Memory pool / allocation,2.779439091682434e-05,1.0,0.0010842643896097216 +component_tess_gap_bls_v1_no_scatter,tess_gap,Remaining public API work,0.0021777842193841934,1.0,0.08495576983134973 +component_tess_gap_bls_v1_no_scatter,tess_gap,Spectrum D2H,0.000430867075920105,1.0,0.016808205240887334 +component_tess_gap_tls_v1_native,tess_gap,API remainder,0.0032449327409267426,1.0,0.0935574661125321 +component_tess_gap_tls_v1_native,tess_gap,v1 CPU statistics/results,0.009152121841907501,1.0,0.2638727509764914 +component_tess_gap_tls_v1_native,tess_gap,v1 buffer allocations/transfers,0.0014069471508264542,1.0,0.04056491178549863 +component_tess_gap_tls_v1_native,tess_gap,v1 candidate selection/refinement/transfers,0.0007381774485111237,1.0,0.021283033313162284 +component_tess_gap_tls_v1_native,tess_gap,v1 coarse search kernel,0.013218048959970474,1.0,0.3811010169945912 +component_tess_gap_tls_v1_native,tess_gap,v1 coarse spectrum transfer,0.0002222154289484024,1.0,0.006406885480647716 +component_tess_gap_tls_v1_native,tess_gap,v1 final packaging,2.209097146987915e-06,1.0,6.369239302309446e-05 +component_tess_gap_tls_v1_native,tess_gap,v1 grid/configuration,0.0028517525643110275,1.0,0.08222134792866538 +component_tess_gap_tls_v1_native,tess_gap,v1 host lightcurve preparation,0.00015196017920970917,1.0,0.004381295531199928 +component_tess_gap_tls_v1_native,tess_gap,v1 kernel lookup/grid transfers,0.0029579810798168182,1.0,0.08528411425792347 +component_tess_gap_tls_v1_native,tess_gap,v1 lightcurve transfers,0.00022634491324424744,1.0,0.006525946218701848 +component_tess_gap_tls_v1_native,tess_gap,v1 parameter-spectrum transfers,0.0004830397665500641,1.0,0.013926937843742904 +component_tess_gap_tls_v1_native,tess_gap,v1 template tables,2.8114765882492065e-05,1.0,0.0008106011638200571 +component_tess_gap_gtls_native,tess_gap,API remainder,0.01227208785712719,1.0,0.0016206132498344924 +component_tess_gap_gtls_native,tess_gap,GTLS CUDA module lookup/compile,8.981674909591675e-05,1.0,1.1860916849398845e-05 +component_tess_gap_gtls_native,tess_gap,GTLS chunk allocations/transfers,0.03378216736018658,31.0,0.004461166565088524 +component_tess_gap_gtls_native,tess_gap,GTLS duration-mask union,1.1300838515162468,31.0,0.14923531223968514 +component_tess_gap_gtls_native,tess_gap,GTLS error prefixes/out-of-transit terms,0.16025540977716446,31.0,0.021162824408211624 +component_tess_gap_gtls_native,tess_gap,GTLS folding/sorting,0.6577333062887192,31.0,0.08685818773778514 +component_tess_gap_gtls_native,tess_gap,GTLS full search call,0.0022016502916812897,1.0,0.0002907429995400899 +component_tess_gap_gtls_native,tess_gap,GTLS input/template setup,0.011174468323588371,1.0,0.001475665073123299 +component_tess_gap_gtls_native,tess_gap,GTLS reductions/chunk cleanup,0.1123081911355257,31.0,0.014831065808698056 +component_tess_gap_gtls_native,tess_gap,GTLS reorder/weights,0.021856509149074554,31.0,0.002886301722615831 +component_tess_gap_gtls_native,tess_gap,GTLS result/statistics processing,0.08828630298376083,1.0,0.011658810958665907 +component_tess_gap_gtls_native,tess_gap,GTLS row-wise flux prefix sums,3.683847291395068,31.0,0.48647726452956724 +component_tess_gap_gtls_native,tess_gap,GTLS setup and allocations,0.002687370404601097,2.0,0.0003548856670204069 +component_tess_gap_gtls_native,tess_gap,GTLS transit residual kernel,1.655917888507247,31.0,0.21867529812331485 +component_tess_gap_gtls_both,tess_gap,API remainder,0.012644346803426743,1.0,0.004488254718199207 +component_tess_gap_gtls_both,tess_gap,GTLS CUDA module lookup/compile,9.878724813461304e-05,1.0,3.5065657359060405e-05 +component_tess_gap_gtls_both,tess_gap,GTLS chunk allocations/transfers,0.0357176810503006,31.0,0.012678397151658359 +component_tess_gap_gtls_both,tess_gap,GTLS duration-mask union,0.005258811637759209,31.0,0.0018666750060111492 +component_tess_gap_gtls_both,tess_gap,GTLS error prefixes/out-of-transit terms,0.15920797176659107,31.0,0.056512680454373035 +component_tess_gap_gtls_both,tess_gap,GTLS folding/sorting,0.6598600596189499,31.0,0.234224833593882 +component_tess_gap_gtls_both,tess_gap,GTLS full search call,0.0021453499794006348,1.0,0.0007615163769965524 +component_tess_gap_gtls_both,tess_gap,GTLS input/template setup,0.01433069072663784,1.0,0.005086841674688535 +component_tess_gap_gtls_both,tess_gap,GTLS reductions/chunk cleanup,0.11240757070481777,31.0,0.03990034577669771 +component_tess_gap_gtls_both,tess_gap,GTLS reorder/weights,0.021939393132925034,31.0,0.007787637137300904 +component_tess_gap_gtls_both,tess_gap,GTLS result/statistics processing,0.09422308206558228,1.0,0.03344555469876162 +component_tess_gap_gtls_both,tess_gap,GTLS row-wise flux prefix sums,0.039466336369514465,31.0,0.014009024995463188 +component_tess_gap_gtls_both,tess_gap,GTLS setup and allocations,0.002745244652032852,2.0,0.0009744558144164776 +component_tess_gap_gtls_both,tess_gap,GTLS transit residual kernel,1.6571626085788012,31.0,0.5882287169441922 +component_ztf_bls_v1,ztf,Common candidate ranking,0.04566686227917671,1.0,0.6423105964962214 +component_ztf_bls_v1,ztf,GPU kernel launches (synchronized),0.010050255805253983,1.0,0.1413582076593828 +component_ztf_bls_v1,ztf,Host maximum-bin scan,0.0001864694058895111,2.0,0.0026227174223835714 +component_ztf_bls_v1,ztf,Host preparation and H2D,0.006062814965844154,1.0,0.08527431276865431 +component_ztf_bls_v1,ztf,Kernel cache lookup,2.64681875705719e-05,1.0,0.00037227863921756617 +component_ztf_bls_v1,ztf,Memory pool / allocation,3.658421337604523e-05,1.0,0.0005145619108284459 +component_ztf_bls_v1,ztf,Remaining public API work,0.008393488824367523,1.0,0.11805555591942161 +component_ztf_bls_v1,ztf,Spectrum D2H,0.0006748437881469727,1.0,0.009491769183890296 +component_ztf_bls_pypi,ztf,Common candidate ranking,0.05024977773427963,1.0,0.34208604841881196 +component_ztf_bls_pypi,ztf,GPU kernel launches (synchronized),0.031496988609433174,3.0,0.21442244834334864 +component_ztf_bls_pypi,ztf,Host maximum-bin scan,0.05443061888217926,3.0,0.3705480136620152 +component_ztf_bls_pypi,ztf,Host preparation and H2D,0.006050091236829758,1.0,0.04118728275226757 +component_ztf_bls_pypi,ztf,Remaining public API work,0.004664739593863487,1.0,0.031756206823556614 +component_ztf_bls_v1_unfused,ztf,Common candidate ranking,0.04156269505620003,1.0,0.3128966386408033 +component_ztf_bls_v1_unfused,ztf,GPU kernel launches (synchronized),0.06964085251092911,8.0,0.5242775674985766 +component_ztf_bls_v1_unfused,ztf,Host maximum-bin scan,0.0013039801269769669,8.0,0.009816759910150163 +component_ztf_bls_v1_unfused,ztf,Host preparation and H2D,0.007113086059689522,1.0,0.053549480259403905 +component_ztf_bls_v1_unfused,ztf,Memory pool / allocation,3.361143171787262e-05,1.0,0.0002530371043120888 +component_ztf_bls_v1_unfused,ztf,Remaining public API work,0.010407499969005585,1.0,0.07835083246052242 +component_ztf_bls_v1_unfused,ztf,Spectrum D2H,0.0027703028172254562,1.0,0.02085568412623154 +component_ztf_bls_v1_no_scatter,ztf,Common candidate ranking,0.04121182672679424,1.0,0.6744202605860352 +component_ztf_bls_v1_no_scatter,ztf,GPU kernel launches (synchronized),0.009987063705921173,1.0,0.16343556308940707 +component_ztf_bls_v1_no_scatter,ztf,Host maximum-bin scan,0.00014768727123737335,2.0,0.002416861757025486 +component_ztf_bls_v1_no_scatter,ztf,Host preparation and H2D,0.005354398861527443,1.0,0.08762327133451499 +component_ztf_bls_v1_no_scatter,ztf,Kernel cache lookup,1.9431114196777344e-05,1.0,0.0003179848635912909 +component_ztf_bls_v1_no_scatter,ztf,Memory pool / allocation,2.4382025003433228e-05,1.0,0.00039900516338286027 +component_ztf_bls_v1_no_scatter,ztf,Remaining public API work,0.0037697572261095047,1.0,0.061691044845772314 +component_ztf_bls_v1_no_scatter,ztf,Spectrum D2H,0.0005924943834543228,1.0,0.009696008360270755 +component_ztf_tls_v1_native,ztf,API remainder,0.008227681741118431,1.0,0.08801654340068259 +component_ztf_tls_v1_native,ztf,v1 CPU statistics/results,0.0320548340678215,1.0,0.34291016384751033 +component_ztf_tls_v1_native,ztf,v1 buffer allocations/transfers,0.004712600260972977,1.0,0.05041356708379583 +component_ztf_tls_v1_native,ztf,v1 candidate selection/refinement/transfers,0.0015720482915639877,1.0,0.016817161994844225 +component_ztf_tls_v1_native,ztf,v1 coarse search kernel,0.029358403757214546,1.0,0.31406479975492796 +component_ztf_tls_v1_native,ztf,v1 coarse spectrum transfer,0.0003435332328081131,1.0,0.0036749850864943477 +component_ztf_tls_v1_native,ztf,v1 final packaging,1.8421560525894165e-06,1.0,1.970666990475083e-05 +component_ztf_tls_v1_native,ztf,v1 grid/configuration,0.009270580485463142,1.0,0.09917306907612222 +component_ztf_tls_v1_native,ztf,v1 host lightcurve preparation,0.00016255676746368408,1.0,0.0017389691566505707 +component_ztf_tls_v1_native,ztf,v1 kernel lookup/grid transfers,0.006630975753068924,1.0,0.07093560294658752 +component_ztf_tls_v1_native,ztf,v1 lightcurve transfers,0.0002206452190876007,1.0,0.0023603768489150394 +component_ztf_tls_v1_native,ztf,v1 parameter-spectrum transfers,0.0008868332952260971,1.0,0.00948699812103179 +component_ztf_tls_v1_native,ztf,v1 template tables,3.627501428127289e-05,1.0,0.00038805601253288414 +component_ztf_gtls_native,ztf,API remainder,0.005538733676075935,1.0,0.0003156931538975029 +component_ztf_gtls_native,ztf,GTLS CUDA module lookup/compile,9.682588279247284e-05,1.0,5.5188189404553404e-06 +component_ztf_gtls_native,ztf,GTLS chunk allocations/transfers,0.03372389078140259,31.0,0.0019221724793271945 +component_ztf_gtls_native,ztf,GTLS duration-mask union,3.6899108346551657,31.0,0.21031514730965487 +component_ztf_gtls_native,ztf,GTLS error prefixes/out-of-transit terms,0.15672518871724606,31.0,0.008932920774840739 +component_ztf_gtls_native,ztf,GTLS folding/sorting,0.6371670421212912,31.0,0.036316834289335805 +component_ztf_gtls_native,ztf,GTLS full search call,0.0024172496050596237,1.0,0.0001377768270164257 +component_ztf_gtls_native,ztf,GTLS input/template setup,0.029394283890724182,1.0,0.0016753963506321978 +component_ztf_gtls_native,ztf,GTLS reductions/chunk cleanup,0.10246017388999462,31.0,0.0058399586143552135 +component_ztf_gtls_native,ztf,GTLS reorder/weights,0.019939199090003967,31.0,0.001136481552471611 +component_ztf_gtls_native,ztf,GTLS result/statistics processing,0.2834222801029682,1.0,0.0161543195111555 +component_ztf_gtls_native,ztf,GTLS row-wise flux prefix sums,11.942916456609964,31.0,0.6807146151848842 +component_ztf_gtls_native,ztf,GTLS setup and allocations,0.0032957233488559723,2.0,0.00018784750436152183 +component_ztf_gtls_native,ztf,GTLS transit residual kernel,0.6376667730510235,31.0,0.03634531762912677 +component_ztf_gtls_both,ztf,API remainder,0.006823141127824783,1.0,0.003150220680201975 +component_ztf_gtls_both,ztf,GTLS CUDA module lookup/compile,0.00013399124145507812,1.0,6.186329315048029e-05 +component_ztf_gtls_both,ztf,GTLS chunk allocations/transfers,0.046900562942028046,31.0,0.021653827837530438 +component_ztf_gtls_both,ztf,GTLS duration-mask union,0.011204591020941734,31.0,0.005173120955015139 +component_ztf_gtls_both,ztf,GTLS error prefixes/out-of-transit terms,0.15666196309030056,31.0,0.072330286986961 +component_ztf_gtls_both,ztf,GTLS folding/sorting,0.6473332922905684,31.0,0.29887154408120653 +component_ztf_gtls_both,ztf,GTLS full search call,0.003210758790373802,1.0,0.0014823962381971664 +component_ztf_gtls_both,ztf,GTLS input/template setup,0.03356537967920303,1.0,0.015497019807058556 +component_ztf_gtls_both,ztf,GTLS reductions/chunk cleanup,0.10546543635427952,31.0,0.0486930275111713 +component_ztf_gtls_both,ztf,GTLS reorder/weights,0.02052878402173519,31.0,0.00947806864216026 +component_ztf_gtls_both,ztf,GTLS result/statistics processing,0.4485912397503853,1.0,0.2071130252100797 +component_ztf_gtls_both,ztf,GTLS row-wise flux prefix sums,0.03856702521443367,31.0,0.017806262266644987 +component_ztf_gtls_both,ztf,GTLS setup and allocations,0.003832675516605377,2.0,0.0017695330415601655 +component_ztf_gtls_both,ztf,GTLS transit residual kernel,0.6431059688329697,31.0,0.29691980344906227 diff --git a/benchmarks/results/transit_2026-09-08/component_summary.csv b/benchmarks/results/transit_2026-09-08/component_summary.csv new file mode 100644 index 00000000..ab4b7c7d --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/component_summary.csv @@ -0,0 +1,22 @@ +job,profile,native_median_s,synchronized_profile_mean_s,profile_over_native,instrumentation_max_abs_power_difference,grid_median_s,grid_float32_equal +component_tess_200s_bls_v1,tess_200s,0.005431227385997772,0.003090502694249153,0.5690247295145048,3.725290298461914e-09,0.0018043629825115204,True +component_tess_200s_bls_pypi,tess_200s,0.013805769383907318,0.011249450966715813,0.8148369463442381,1.8189894035458565e-12,0.01997949928045273,True +component_tess_200s_bls_v1_unfused,tess_200s,0.008508004248142242,0.007050644606351852,0.8287072268318848,3.725290298461914e-09,0.0015385188162326813,True +component_tess_200s_bls_v1_no_scatter,tess_200s,0.005118124186992645,0.0030036121606826782,0.5868580071417862,2.7939677238464355e-09,0.0017888061702251434,True +component_tess_200s_tls_v1_native,tess_200s,0.00926678255200386,0.004941130056977272,0.5332088056721258,3.0517578125e-05,, +component_tess_200s_gtls_native,tess_200s,0.5102832280099392,0.526410611346364,1.0316047685896326,0.0,, +component_tess_200s_gtls_both,tess_200s,0.3687116466462612,0.4005332849919796,1.0863049448943711,0.0,, +component_tess_gap_bls_v1,tess_gap,0.02717015892267227,0.029120374470949173,1.071777848404469,5.587935447692871e-09,0.032126251608133316,True +component_tess_gap_bls_pypi,tess_gap,0.04441707953810692,0.06076417304575443,1.368036207639964,7.105427357601002e-14,0.5528400354087353,True +component_tess_gap_bls_v1_unfused,tess_gap,0.03672560676932335,0.03755619004368782,1.022615916997136,7.450580596923828e-09,0.03138798475265503,True +component_tess_gap_bls_v1_no_scatter,tess_gap,0.024190403521060944,0.02563432976603508,1.0596900437695058,5.587935447692871e-09,0.03592348471283913,True +component_tess_gap_tls_v1_native,tess_gap,0.04133009910583496,0.034683845937252045,0.8391909694781109,1.9073486328125e-05,, +component_tess_gap_gtls_native,tess_gap,7.475522819906473,7.572496311739087,1.0129721350825636,0.0,, +component_tess_gap_gtls_both,tess_gap,2.8081326372921467,2.817207934334874,1.003231790736024,0.0,, +component_ztf_bls_v1,ztf,0.08495102822780609,0.07109778746962547,0.8369267441821712,1.1175870895385742e-08,0.13856496289372444,True +component_ztf_bls_pypi,ztf,0.13173441216349602,0.1468922160565853,1.115063358496464,6.039613253960852e-14,1.737047240138054,True +component_ztf_bls_v1_unfused,ztf,0.12026598677039146,0.13283202797174454,1.1044854122000747,1.862645149230957e-08,0.12039996683597565,True +component_ztf_bls_v1_no_scatter,ztf,0.06015884131193161,0.06110704131424427,1.015761606800173,9.313225746154785e-09,0.1287476047873497,True +component_ztf_tls_v1_native,ztf,0.08919495716691017,0.09347881004214287,1.048027971662303,1.1444091796875e-05,, +component_ztf_gtls_native,ztf,16.965853169560432,17.54467465542257,1.0341168510700445,0.0,, +component_ztf_gtls_both,ztf,2.0584252700209618,2.165924809873104,1.0522241644706625,0.0,, diff --git a/benchmarks/results/transit_2026-09-08/paired_comparisons.csv b/benchmarks/results/transit_2026-09-08/paired_comparisons.csv new file mode 100644 index 00000000..4c080eb7 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/paired_comparisons.csv @@ -0,0 +1,25 @@ +profile,v1,comparator,all_api_results_valid,period_noninferior,detection_noninferior,fpr_increase_upper_95,comparable_detection,period_comparison,detection_comparison,false_positive_comparison +ztf,bls_v1,bls_pypi,True,True,False,0.05416312509971166,False,"{'n': 128, 'v1_only': 13, 'comparator_only': 6, 'difference': 0.0546875, 'lower_95_one_sided': -0.044039628112424276, 'upper_95_one_sided': 0.15001826022836934, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 11, 'comparator_only': 6, 'difference': 0.0390625, 'lower_95_one_sided': -0.05555281681654244, 'upper_95_one_sided': 0.1311694359943246, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 3, 'comparator_only': 5, 'difference': -0.015625, 'lower_95_one_sided': -0.08394472292020715, 'upper_95_one_sided': 0.05416312509971166, 'noninferior_5pp': False}" +ztf,bls_v1,bls_cpu,True,False,False,0.10085311603099559,False,"{'n': 128, 'v1_only': 8, 'comparator_only': 6, 'difference': 0.015625, 'lower_95_one_sided': -0.07186843880752036, 'upper_95_one_sided': 0.10204272725358152, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}" +ztf,bls_v1,bls_gpu,True,False,False,0.10085311603099559,False,"{'n': 128, 'v1_only': 8, 'comparator_only': 6, 'difference': 0.015625, 'lower_95_one_sided': -0.07186843880752036, 'upper_95_one_sided': 0.10204272725358152, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}" +ztf,bls_v1_batch,bls_pypi,True,True,False,0.05416312509971166,False,"{'n': 128, 'v1_only': 13, 'comparator_only': 6, 'difference': 0.0546875, 'lower_95_one_sided': -0.044039628112424276, 'upper_95_one_sided': 0.15001826022836934, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 11, 'comparator_only': 6, 'difference': 0.0390625, 'lower_95_one_sided': -0.05555281681654244, 'upper_95_one_sided': 0.1311694359943246, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 3, 'comparator_only': 5, 'difference': -0.015625, 'lower_95_one_sided': -0.08394472292020715, 'upper_95_one_sided': 0.05416312509971166, 'noninferior_5pp': False}" +ztf,bls_v1_batch,bls_cpu,True,False,False,0.10085311603099559,False,"{'n': 128, 'v1_only': 8, 'comparator_only': 6, 'difference': 0.015625, 'lower_95_one_sided': -0.07186843880752036, 'upper_95_one_sided': 0.10204272725358152, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}" +ztf,bls_v1_batch,bls_gpu,True,False,False,0.10085311603099559,False,"{'n': 128, 'v1_only': 8, 'comparator_only': 6, 'difference': 0.015625, 'lower_95_one_sided': -0.07186843880752036, 'upper_95_one_sided': 0.10204272725358152, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 7, 'comparator_only': 4, 'difference': 0.0234375, 'lower_95_one_sided': -0.05581237962854699, 'upper_95_one_sided': 0.10085311603099559, 'noninferior_5pp': False}" +ztf,tls_v1,gtls,True,True,True,0.13412275564167567,False,"{'n': 128, 'v1_only': 7, 'comparator_only': 2, 'difference': 0.0390625, 'lower_95_one_sided': -0.03303544421252541, 'upper_95_one_sided': 0.10753415844651748, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 8, 'comparator_only': 1, 'difference': 0.0546875, 'lower_95_one_sided': -0.01539234702784957, 'upper_95_one_sided': 0.11923900757919224, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 10, 'comparator_only': 3, 'difference': 0.0546875, 'lower_95_one_sided': -0.028866912605089286, 'upper_95_one_sided': 0.13412275564167567, 'noninferior_5pp': True}" +ztf,tls_v1,gtls_batch,True,True,True,0.13412275564167567,False,"{'n': 128, 'v1_only': 7, 'comparator_only': 2, 'difference': 0.0390625, 'lower_95_one_sided': -0.03303544421252541, 'upper_95_one_sided': 0.10753415844651748, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 8, 'comparator_only': 1, 'difference': 0.0546875, 'lower_95_one_sided': -0.01539234702784957, 'upper_95_one_sided': 0.11923900757919224, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 10, 'comparator_only': 3, 'difference': 0.0546875, 'lower_95_one_sided': -0.028866912605089286, 'upper_95_one_sided': 0.13412275564167567, 'noninferior_5pp': True}" +tess_200s,bls_v1,bls_pypi,True,False,False,0.028408053443543902,False,"{'n': 128, 'v1_only': 1, 'comparator_only': 5, 'difference': -0.03125, 'lower_95_one_sided': -0.08860665064126931, 'upper_95_one_sided': 0.02995585496847638, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 3, 'comparator_only': 4, 'difference': -0.0078125, 'lower_95_one_sided': -0.07321982190274647, 'upper_95_one_sided': 0.05838736650819449, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 0, 'comparator_only': 0, 'difference': 0.0, 'lower_95_one_sided': -0.028408053443543902, 'upper_95_one_sided': 0.028408053443543902, 'noninferior_5pp': True}" +tess_200s,bls_v1,bls_cpu,True,False,False,0.026510160321804595,False,"{'n': 128, 'v1_only': 5, 'comparator_only': 5, 'difference': 0.0, 'lower_95_one_sided': -0.07600124951018705, 'upper_95_one_sided': 0.07600124951018705, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 5, 'comparator_only': 4, 'difference': 0.0078125, 'lower_95_one_sided': -0.06527634849272637, 'upper_95_one_sided': 0.08022549091866987, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 0, 'comparator_only': 2, 'difference': -0.015625, 'lower_95_one_sided': -0.05530259002244882, 'upper_95_one_sided': 0.026510160321804595, 'noninferior_5pp': False}" +tess_200s,bls_v1,bls_gpu,True,True,True,0.04469915623553228,True,"{'n': 128, 'v1_only': 15, 'comparator_only': 6, 'difference': 0.0703125, 'lower_95_one_sided': -0.03214104181201331, 'upper_95_one_sided': 0.16851484555165502, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 13, 'comparator_only': 3, 'difference': 0.078125, 'lower_95_one_sided': -0.011770806463988759, 'upper_95_one_sided': 0.1625526128329179, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 3, 'comparator_only': 7, 'difference': -0.03125, 'lower_95_one_sided': -0.10457234803253286, 'upper_95_one_sided': 0.04469915623553228, 'noninferior_5pp': False}" +tess_200s,bls_v1_batch,bls_pypi,True,False,False,0.028408053443543902,False,"{'n': 128, 'v1_only': 1, 'comparator_only': 5, 'difference': -0.03125, 'lower_95_one_sided': -0.08860665064126931, 'upper_95_one_sided': 0.02995585496847638, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 3, 'comparator_only': 4, 'difference': -0.0078125, 'lower_95_one_sided': -0.07321982190274647, 'upper_95_one_sided': 0.05838736650819449, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 0, 'comparator_only': 0, 'difference': 0.0, 'lower_95_one_sided': -0.028408053443543902, 'upper_95_one_sided': 0.028408053443543902, 'noninferior_5pp': True}" +tess_200s,bls_v1_batch,bls_cpu,True,False,False,0.026510160321804595,False,"{'n': 128, 'v1_only': 5, 'comparator_only': 5, 'difference': 0.0, 'lower_95_one_sided': -0.07600124951018705, 'upper_95_one_sided': 0.07600124951018705, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 5, 'comparator_only': 4, 'difference': 0.0078125, 'lower_95_one_sided': -0.06527634849272637, 'upper_95_one_sided': 0.08022549091866987, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 0, 'comparator_only': 2, 'difference': -0.015625, 'lower_95_one_sided': -0.05530259002244882, 'upper_95_one_sided': 0.026510160321804595, 'noninferior_5pp': False}" +tess_200s,bls_v1_batch,bls_gpu,True,True,True,0.04469915623553228,True,"{'n': 128, 'v1_only': 15, 'comparator_only': 6, 'difference': 0.0703125, 'lower_95_one_sided': -0.03214104181201331, 'upper_95_one_sided': 0.16851484555165502, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 13, 'comparator_only': 3, 'difference': 0.078125, 'lower_95_one_sided': -0.011770806463988759, 'upper_95_one_sided': 0.1625526128329179, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 3, 'comparator_only': 7, 'difference': -0.03125, 'lower_95_one_sided': -0.10457234803253286, 'upper_95_one_sided': 0.04469915623553228, 'noninferior_5pp': False}" +tess_200s,tls_v1,gtls,True,False,False,0.050442886486724896,False,"{'n': 128, 'v1_only': 4, 'comparator_only': 10, 'difference': -0.046875, 'lower_95_one_sided': -0.1304035236401384, 'upper_95_one_sided': 0.039980135998103995, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 2, 'comparator_only': 5, 'difference': -0.0234375, 'lower_95_one_sided': -0.08690653333419177, 'upper_95_one_sided': 0.042499413076704795, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 2, 'comparator_only': 3, 'difference': -0.0078125, 'lower_95_one_sided': -0.06506840892371638, 'upper_95_one_sided': 0.050442886486724896, 'noninferior_5pp': False}" +tess_200s,tls_v1,gtls_batch,True,False,False,0.050442886486724896,False,"{'n': 128, 'v1_only': 4, 'comparator_only': 10, 'difference': -0.046875, 'lower_95_one_sided': -0.1304035236401384, 'upper_95_one_sided': 0.039980135998103995, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 2, 'comparator_only': 5, 'difference': -0.0234375, 'lower_95_one_sided': -0.08690653333419177, 'upper_95_one_sided': 0.042499413076704795, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 2, 'comparator_only': 3, 'difference': -0.0078125, 'lower_95_one_sided': -0.06506840892371638, 'upper_95_one_sided': 0.050442886486724896, 'noninferior_5pp': False}" +tess_gap,bls_v1,bls_pypi,True,True,True,0.028210277628882136,True,"{'n': 128, 'v1_only': 0, 'comparator_only': 1, 'difference': -0.0078125, 'lower_95_one_sided': -0.042759031914220404, 'upper_95_one_sided': 0.028210277628882136, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 0, 'comparator_only': 0, 'difference': 0.0, 'lower_95_one_sided': -0.028408053443543902, 'upper_95_one_sided': 0.028408053443543902, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 0, 'comparator_only': 1, 'difference': -0.0078125, 'lower_95_one_sided': -0.042759031914220404, 'upper_95_one_sided': 0.028210277628882136, 'noninferior_5pp': True}" +tess_gap,bls_v1,bls_cpu,True,True,True,0.06527634849272637,False,"{'n': 128, 'v1_only': 11, 'comparator_only': 2, 'difference': 0.0703125, 'lower_95_one_sided': -0.011620283145100059, 'upper_95_one_sided': 0.14666559901285778, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 15, 'comparator_only': 0, 'difference': 0.1171875, 'lower_95_one_sided': 0.03868602843833399, 'upper_95_one_sided': 0.1859089016919275, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 4, 'comparator_only': 5, 'difference': -0.0078125, 'lower_95_one_sided': -0.08022549091866987, 'upper_95_one_sided': 0.06527634849272637, 'noninferior_5pp': False}" +tess_gap,bls_v1,bls_gpu,True,True,True,0.06527634849272637,False,"{'n': 128, 'v1_only': 11, 'comparator_only': 2, 'difference': 0.0703125, 'lower_95_one_sided': -0.011620283145100059, 'upper_95_one_sided': 0.14666559901285778, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 15, 'comparator_only': 0, 'difference': 0.1171875, 'lower_95_one_sided': 0.03868602843833399, 'upper_95_one_sided': 0.1859089016919275, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 4, 'comparator_only': 5, 'difference': -0.0078125, 'lower_95_one_sided': -0.08022549091866987, 'upper_95_one_sided': 0.06527634849272637, 'noninferior_5pp': False}" +tess_gap,bls_v1_batch,bls_pypi,True,True,True,0.028210277628882136,True,"{'n': 128, 'v1_only': 0, 'comparator_only': 1, 'difference': -0.0078125, 'lower_95_one_sided': -0.042759031914220404, 'upper_95_one_sided': 0.028210277628882136, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 0, 'comparator_only': 0, 'difference': 0.0, 'lower_95_one_sided': -0.028408053443543902, 'upper_95_one_sided': 0.028408053443543902, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 0, 'comparator_only': 1, 'difference': -0.0078125, 'lower_95_one_sided': -0.042759031914220404, 'upper_95_one_sided': 0.028210277628882136, 'noninferior_5pp': True}" +tess_gap,bls_v1_batch,bls_cpu,True,True,True,0.06527634849272637,False,"{'n': 128, 'v1_only': 11, 'comparator_only': 2, 'difference': 0.0703125, 'lower_95_one_sided': -0.011620283145100059, 'upper_95_one_sided': 0.14666559901285778, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 15, 'comparator_only': 0, 'difference': 0.1171875, 'lower_95_one_sided': 0.03868602843833399, 'upper_95_one_sided': 0.1859089016919275, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 4, 'comparator_only': 5, 'difference': -0.0078125, 'lower_95_one_sided': -0.08022549091866987, 'upper_95_one_sided': 0.06527634849272637, 'noninferior_5pp': False}" +tess_gap,bls_v1_batch,bls_gpu,True,True,True,0.06527634849272637,False,"{'n': 128, 'v1_only': 11, 'comparator_only': 2, 'difference': 0.0703125, 'lower_95_one_sided': -0.011620283145100059, 'upper_95_one_sided': 0.14666559901285778, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 15, 'comparator_only': 0, 'difference': 0.1171875, 'lower_95_one_sided': 0.03868602843833399, 'upper_95_one_sided': 0.1859089016919275, 'noninferior_5pp': True}","{'n': 128, 'v1_only': 4, 'comparator_only': 5, 'difference': -0.0078125, 'lower_95_one_sided': -0.08022549091866987, 'upper_95_one_sided': 0.06527634849272637, 'noninferior_5pp': False}" +tess_gap,tls_v1,gtls,True,False,False,0.04256125609955864,False,"{'n': 128, 'v1_only': 4, 'comparator_only': 7, 'difference': -0.0234375, 'lower_95_one_sided': -0.10085311603099559, 'upper_95_one_sided': 0.05581237962854699, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 8, 'comparator_only': 4, 'difference': 0.03125, 'lower_95_one_sided': -0.05071284055209956, 'upper_95_one_sided': 0.1108578478565928, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 1, 'comparator_only': 1, 'difference': 0.0, 'lower_95_one_sided': -0.04256125609955864, 'upper_95_one_sided': 0.04256125609955864, 'noninferior_5pp': True}" +tess_gap,tls_v1,gtls_batch,True,False,False,0.04256125609955864,False,"{'n': 128, 'v1_only': 4, 'comparator_only': 7, 'difference': -0.0234375, 'lower_95_one_sided': -0.10085311603099559, 'upper_95_one_sided': 0.05581237962854699, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 8, 'comparator_only': 4, 'difference': 0.03125, 'lower_95_one_sided': -0.05071284055209956, 'upper_95_one_sided': 0.1108578478565928, 'noninferior_5pp': False}","{'n': 128, 'v1_only': 1, 'comparator_only': 1, 'difference': 0.0, 'lower_95_one_sided': -0.04256125609955864, 'upper_95_one_sided': 0.04256125609955864, 'noninferior_5pp': True}" diff --git a/benchmarks/results/transit_2026-09-08/recovery_by_snr.csv b/benchmarks/results/transit_2026-09-08/recovery_by_snr.csv new file mode 100644 index 00000000..a34ef76d --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/recovery_by_snr.csv @@ -0,0 +1,97 @@ +profile,method,snr,n,period_recovered,detected,alias_recovered,alias_detected,recall,interval,period_recall,period_interval +tess_gap,gtls_batch,6.0,32,9,6,10,7,0.1875,"[0.08889544689565954, 0.3530915520137596]",0.28125,"[0.15564580497742764, 0.45374509425916565]" +tess_gap,gtls_batch,8.0,32,25,14,26,15,0.4375,"[0.2816533111504036, 0.6067440886314801]",0.78125,"[0.6124500635075647, 0.8897616374739581]" +tess_gap,gtls_batch,10.0,32,30,23,31,24,0.71875,"[0.5462549057408342, 0.8443541950225723]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_gap,gtls_batch,14.0,32,31,31,32,32,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +ztf,bls_v1,6.0,32,15,10,15,10,0.3125,"[0.17952541633609734, 0.4856667830095541]",0.46875,"[0.30869387108068835, 0.6355048288102536]" +ztf,bls_v1,8.0,32,25,22,25,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.78125,"[0.6124500635075647, 0.8897616374739581]" +ztf,bls_v1,10.0,32,29,29,29,29,0.90625,"[0.7578184527161212, 0.9675984487016337]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +ztf,bls_v1,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,bls_v1_batch,6.0,32,15,10,15,10,0.3125,"[0.17952541633609734, 0.4856667830095541]",0.46875,"[0.30869387108068835, 0.6355048288102536]" +ztf,bls_v1_batch,8.0,32,25,22,25,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.78125,"[0.6124500635075647, 0.8897616374739581]" +ztf,bls_v1_batch,10.0,32,29,29,29,29,0.90625,"[0.7578184527161212, 0.9675984487016337]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +ztf,bls_v1_batch,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_200s,bls_pypi,6.0,32,3,1,3,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.09375,"[0.032401551298366194, 0.24218154728387864]" +tess_200s,bls_pypi,8.0,32,7,5,10,6,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_200s,bls_pypi,10.0,32,24,16,24,16,0.5,"[0.33630882869327483, 0.6636911713067252]",0.75,"[0.5789344100575552, 0.8674759908149096]" +tess_200s,bls_pypi,14.0,32,29,28,29,28,0.875,"[0.7193169451834978, 0.9502986561251991]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +tess_200s,bls_cpu,6.0,32,2,1,2,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,bls_cpu,8.0,32,7,5,11,5,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_200s,bls_cpu,10.0,32,21,15,21,15,0.46875,"[0.30869387108068835, 0.6355048288102536]",0.65625,"[0.4831108797896222, 0.7958956207556682]" +tess_200s,bls_cpu,14.0,32,29,27,29,27,0.84375,"[0.682458504024696, 0.9313557971749429]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +tess_gap,bls_v1,6.0,32,10,7,10,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.3125,"[0.17952541633609734, 0.4856667830095541]" +tess_gap,bls_v1,8.0,32,22,19,22,19,0.59375,"[0.42260024875382873, 0.7448036515733456]",0.6875,"[0.5143332169904459, 0.8204745836639026]" +tess_gap,bls_v1,10.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +tess_gap,bls_v1,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_gap,bls_cpu,6.0,32,7,5,7,5,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_gap,bls_cpu,8.0,32,18,13,18,13,0.40625,"[0.2551963484266544, 0.5773997512461713]",0.5625,"[0.3932559113685198, 0.7183466888495963]" +tess_gap,bls_cpu,10.0,32,29,24,30,25,0.75,"[0.5789344100575552, 0.8674759908149096]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +tess_gap,bls_cpu,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,bls_gpu,6.0,32,14,7,14,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.4375,"[0.2816533111504036, 0.6067440886314801]" +ztf,bls_gpu,8.0,32,24,22,24,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.75,"[0.5789344100575552, 0.8674759908149096]" +ztf,bls_gpu,10.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +ztf,bls_gpu,14.0,32,30,30,30,30,0.9375,"[0.7985287685584542, 0.982689432968359]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_gap,bls_gpu,6.0,32,7,5,7,5,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_gap,bls_gpu,8.0,32,18,13,18,13,0.40625,"[0.2551963484266544, 0.5773997512461713]",0.5625,"[0.3932559113685198, 0.7183466888495963]" +tess_gap,bls_gpu,10.0,32,29,24,30,25,0.75,"[0.5789344100575552, 0.8674759908149096]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +tess_gap,bls_gpu,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_200s,gtls_batch,6.0,32,2,1,2,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,gtls_batch,8.0,32,12,5,15,5,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.375,"[0.22933887309454212, 0.5474559264692255]" +tess_200s,gtls_batch,10.0,32,24,22,24,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.75,"[0.5789344100575552, 0.8674759908149096]" +tess_200s,gtls_batch,14.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +tess_200s,tls_v1,6.0,32,4,1,4,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.125,"[0.04970134387480073, 0.2806830548165022]" +tess_200s,tls_v1,8.0,32,5,4,7,4,0.125,"[0.04970134387480073, 0.2806830548165022]",0.15625,"[0.0686442028250571, 0.3175414959753039]" +tess_200s,tls_v1,10.0,32,24,21,24,21,0.65625,"[0.4831108797896222, 0.7958956207556682]",0.75,"[0.5789344100575552, 0.8674759908149096]" +tess_200s,tls_v1,14.0,32,30,30,30,30,0.9375,"[0.7985287685584542, 0.982689432968359]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_200s,bls_v1_batch,6.0,32,2,1,2,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,bls_v1_batch,8.0,32,7,5,10,6,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_200s,bls_v1_batch,10.0,32,21,14,22,15,0.4375,"[0.2816533111504036, 0.6067440886314801]",0.65625,"[0.4831108797896222, 0.7958956207556682]" +tess_200s,bls_v1_batch,14.0,32,29,29,29,29,0.90625,"[0.7578184527161212, 0.9675984487016337]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +tess_200s,bls_gpu,6.0,32,2,0,2,0,0.0,"[0.0, 0.1071791982550706]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,bls_gpu,8.0,32,6,3,9,3,0.09375,"[0.032401551298366194, 0.24218154728387864]",0.1875,"[0.08889544689565954, 0.3530915520137596]" +tess_200s,bls_gpu,10.0,32,18,13,18,13,0.40625,"[0.2551963484266544, 0.5773997512461713]",0.5625,"[0.3932559113685198, 0.7183466888495963]" +tess_200s,bls_gpu,14.0,32,24,23,24,23,0.71875,"[0.5462549057408342, 0.8443541950225723]",0.75,"[0.5789344100575552, 0.8674759908149096]" +tess_200s,gtls,6.0,32,2,1,2,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,gtls,8.0,32,12,5,15,5,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.375,"[0.22933887309454212, 0.5474559264692255]" +tess_200s,gtls,10.0,32,24,22,24,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.75,"[0.5789344100575552, 0.8674759908149096]" +tess_200s,gtls,14.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +ztf,gtls,6.0,32,13,8,13,8,0.25,"[0.1325240091850904, 0.4210655899424448]",0.40625,"[0.2551963484266544, 0.5773997512461713]" +ztf,gtls,8.0,32,26,24,26,24,0.75,"[0.5789344100575552, 0.8674759908149096]",0.8125,"[0.6469084479862404, 0.9111045531043405]" +ztf,gtls,10.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,gtls,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,bls_cpu,6.0,32,14,7,14,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.4375,"[0.2816533111504036, 0.6067440886314801]" +ztf,bls_cpu,8.0,32,24,22,24,22,0.6875,"[0.5143332169904459, 0.8204745836639026]",0.75,"[0.5789344100575552, 0.8674759908149096]" +ztf,bls_cpu,10.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +ztf,bls_cpu,14.0,32,30,30,30,30,0.9375,"[0.7985287685584542, 0.982689432968359]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_gap,bls_pypi,6.0,32,10,7,10,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.3125,"[0.17952541633609734, 0.4856667830095541]" +tess_gap,bls_pypi,8.0,32,23,19,23,19,0.59375,"[0.42260024875382873, 0.7448036515733456]",0.71875,"[0.5462549057408342, 0.8443541950225723]" +tess_gap,bls_pypi,10.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +tess_gap,bls_pypi,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_200s,bls_v1,6.0,32,2,1,2,1,0.03125,"[0.005537860164003122, 0.15744263820012558]",0.0625,"[0.017310567031640967, 0.2014712314415458]" +tess_200s,bls_v1,8.0,32,7,5,10,6,0.15625,"[0.0686442028250571, 0.3175414959753039]",0.21875,"[0.11023836252604186, 0.3875499364924353]" +tess_200s,bls_v1,10.0,32,21,14,22,15,0.4375,"[0.2816533111504036, 0.6067440886314801]",0.65625,"[0.4831108797896222, 0.7958956207556682]" +tess_200s,bls_v1,14.0,32,29,29,29,29,0.90625,"[0.7578184527161212, 0.9675984487016337]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +ztf,tls_v1,6.0,32,19,15,19,15,0.46875,"[0.30869387108068835, 0.6355048288102536]",0.59375,"[0.42260024875382873, 0.7448036515733456]" +ztf,tls_v1,8.0,32,25,24,25,24,0.75,"[0.5789344100575552, 0.8674759908149096]",0.78125,"[0.6124500635075647, 0.8897616374739581]" +ztf,tls_v1,10.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,tls_v1,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,bls_pypi,6.0,32,11,7,11,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.34375,"[0.20410437924433172, 0.5168891202103778]" +ztf,bls_pypi,8.0,32,22,20,22,20,0.625,"[0.4525440735307745, 0.7706611269054578]",0.6875,"[0.5143332169904459, 0.8204745836639026]" +ztf,bls_pypi,10.0,32,29,29,29,29,0.90625,"[0.7578184527161212, 0.9675984487016337]",0.90625,"[0.7578184527161212, 0.9675984487016337]" +ztf,bls_pypi,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,gtls_batch,6.0,32,13,8,13,8,0.25,"[0.1325240091850904, 0.4210655899424448]",0.40625,"[0.2551963484266544, 0.5773997512461713]" +ztf,gtls_batch,8.0,32,26,24,26,24,0.75,"[0.5789344100575552, 0.8674759908149096]",0.8125,"[0.6469084479862404, 0.9111045531043405]" +ztf,gtls_batch,10.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +ztf,gtls_batch,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_gap,tls_v1,6.0,32,9,7,9,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.28125,"[0.15564580497742764, 0.45374509425916565]" +tess_gap,tls_v1,8.0,32,21,12,22,13,0.375,"[0.22933887309454212, 0.5474559264692255]",0.65625,"[0.4831108797896222, 0.7958956207556682]" +tess_gap,tls_v1,10.0,32,30,27,30,27,0.84375,"[0.682458504024696, 0.9313557971749429]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_gap,tls_v1,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_gap,bls_v1_batch,6.0,32,10,7,10,7,0.21875,"[0.11023836252604186, 0.3875499364924353]",0.3125,"[0.17952541633609734, 0.4856667830095541]" +tess_gap,bls_v1_batch,8.0,32,22,19,22,19,0.59375,"[0.42260024875382873, 0.7448036515733456]",0.6875,"[0.5143332169904459, 0.8204745836639026]" +tess_gap,bls_v1_batch,10.0,32,31,31,31,31,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" +tess_gap,bls_v1_batch,14.0,32,32,32,32,32,1.0,"[0.8928208017449293, 1.0]",1.0,"[0.8928208017449293, 1.0]" +tess_gap,gtls,6.0,32,9,6,10,7,0.1875,"[0.08889544689565954, 0.3530915520137596]",0.28125,"[0.15564580497742764, 0.45374509425916565]" +tess_gap,gtls,8.0,32,25,14,26,15,0.4375,"[0.2816533111504036, 0.6067440886314801]",0.78125,"[0.6124500635075647, 0.8897616374739581]" +tess_gap,gtls,10.0,32,30,23,31,24,0.71875,"[0.5462549057408342, 0.8443541950225723]",0.9375,"[0.7985287685584542, 0.982689432968359]" +tess_gap,gtls,14.0,32,31,31,32,32,0.96875,"[0.8425573617998744, 0.9944621398359969]",0.96875,"[0.8425573617998744, 0.9944621398359969]" diff --git a/benchmarks/results/transit_2026-09-08/recovery_summary.csv b/benchmarks/results/transit_2026-09-08/recovery_summary.csv new file mode 100644 index 00000000..d1516f10 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/recovery_summary.csv @@ -0,0 +1,25 @@ +profile,method,family,threshold,n_injections,n_calibration_nulls,n_heldout_nulls,period_recovered,detected,alias_recovered,alias_detected,false_positives,period_recall,detection_recall,false_positive_rate,period_interval,detection_interval,false_positive_interval,invalid_calibration,invalid_heldout,partial_spectra_calibration,partial_spectra_heldout,masked_trials_calibration,masked_trials_heldout +tess_gap,gtls_batch,GTLS,10.242493629455566,128,128,128,95,74,99,78,2,0.7421875,0.578125,0.015625,"[0.6601308077044309, 0.8101309791005885]","[0.4915184739711009, 0.6601788766111634]","[0.004295449710144082, 0.055180976679817195]",0,0,0,0,0,0 +ztf,bls_v1,BLS v1,7.94555263720784,128,128,128,101,93,101,93,9,0.7890625,0.7265625,0.0703125,"[0.7104932846593583, 0.8507869124950196]","[0.6435923144931466, 0.7963300021954198]","[0.03742976830393997, 0.12823480349360633]",0,0,0,0,0,0 +ztf,bls_v1_batch,BLS v1,7.945554731928342,128,128,128,101,93,101,93,9,0.7890625,0.7265625,0.0703125,"[0.7104932846593583, 0.8507869124950196]","[0.6435923144931466, 0.7963300021954198]","[0.03742976830393997, 0.12823480349360633]",0,0,0,0,0,0 +tess_200s,bls_pypi,BLS PyPI,6.220359293976545,128,128,128,63,50,66,51,3,0.4921875,0.390625,0.0234375,"[0.4070774941965585, 0.5777527707452151]","[0.31047019100495765, 0.4771535181798723]","[0.008002416396755481, 0.06664374505143222]",0,0,0,0,0,0 +tess_200s,bls_cpu,BLS CPU,5.882632997067927,128,128,128,59,48,63,48,5,0.4609375,0.375,0.0390625,"[0.3769811559809195, 0.5471701687279483]","[0.29592429097646555, 0.4613599480919116]","[0.016798610797803704, 0.08818702076683686]",0,0,0,0,0,0 +tess_gap,bls_v1,BLS v1,7.228796761775145,128,128,128,95,89,95,89,6,0.7421875,0.6953125,0.046875,"[0.6601308077044309, 0.8101309791005885]","[0.6108489916080531, 0.7683943848476078]","[0.021657844171923776, 0.09849752245094322]",0,0,0,0,0,0 +tess_gap,bls_cpu,BLS CPU,6.963092992681387,128,128,128,86,74,87,75,7,0.671875,0.578125,0.0546875,"[0.5865640159976144, 0.7471701552833669]","[0.4915184739711009, 0.6601788766111634]","[0.026740201381346355, 0.10858490029974707]",0,0,0,0,0,0 +ztf,bls_gpu,BLS GPU,7.83691528271781,128,128,128,99,90,99,90,6,0.7734375,0.703125,0.046875,"[0.6935732116243923, 0.8373675154135328]","[0.6189947723495539, 0.7754183391643332]","[0.021657844171923776, 0.09849752245094322]",0,0,0,0,0,0 +tess_gap,bls_gpu,BLS GPU,6.962940217722587,128,128,128,86,74,87,75,7,0.671875,0.578125,0.0546875,"[0.5865640159976144, 0.7471701552833669]","[0.4915184739711009, 0.6601788766111634]","[0.026740201381346355, 0.10858490029974707]",0,0,0,0,0,0 +tess_200s,gtls_batch,GTLS,7.17197322845459,128,128,128,69,59,72,59,8,0.5390625,0.4609375,0.0625,"[0.4528298312720518, 0.6230188440190805]","[0.3769811559809195, 0.5471701687279483]","[0.03200692569506119, 0.11848791104425865]",0,0,0,0,0,0 +tess_200s,tls_v1,TLS v1,7.488113024591011,128,128,128,63,56,65,56,7,0.4921875,0.4375,0.0546875,"[0.4070774941965585, 0.5777527707452151]","[0.35462313127053047, 0.5240189882636581]","[0.026740201381346355, 0.10858490029974707]",0,0,4,0,22,0 +tess_200s,bls_v1_batch,BLS v1,5.879720214059231,128,128,128,59,49,63,51,3,0.4609375,0.3828125,0.0234375,"[0.3769811559809195, 0.5471701687279483]","[0.3031862585769025, 0.46926771554970104]","[0.008002416396755481, 0.06664374505143222]",0,0,0,0,0,0 +tess_200s,bls_gpu,BLS GPU,5.708531056473186,128,128,128,50,39,53,39,7,0.390625,0.3046875,0.0546875,"[0.31047019100495765, 0.4771535181798723]","[0.2316056151523922, 0.38915100839194705]","[0.026740201381346355, 0.10858490029974707]",0,0,0,0,0,0 +tess_200s,gtls,GTLS,7.17197322845459,128,128,128,69,59,72,59,8,0.5390625,0.4609375,0.0625,"[0.4528298312720518, 0.6230188440190805]","[0.3769811559809195, 0.5471701687279483]","[0.03200692569506119, 0.11848791104425865]",0,0,0,0,0,0 +ztf,gtls,GTLS,11.482556343078613,128,128,128,103,96,103,96,3,0.8046875,0.75,0.0234375,"[0.7275592800992087, 0.864060387171622]","[0.6684443704768313, 0.8169871513864144]","[0.008002416396755481, 0.06664374505143222]",0,0,0,0,0,0 +ztf,bls_cpu,BLS CPU,7.836858828273049,128,128,128,99,90,99,90,6,0.7734375,0.703125,0.046875,"[0.6935732116243923, 0.8373675154135328]","[0.6189947723495539, 0.7754183391643332]","[0.021657844171923776, 0.09849752245094322]",0,0,0,0,0,0 +tess_gap,bls_pypi,BLS PyPI,7.2398353074361275,128,128,128,96,89,96,89,7,0.75,0.6953125,0.0546875,"[0.6684443704768313, 0.8169871513864144]","[0.6108489916080531, 0.7683943848476078]","[0.026740201381346355, 0.10858490029974707]",0,0,0,0,0,0 +tess_200s,bls_v1,BLS v1,5.879725024569434,128,128,128,59,49,63,51,3,0.4609375,0.3828125,0.0234375,"[0.3769811559809195, 0.5471701687279483]","[0.3031862585769025, 0.46926771554970104]","[0.008002416396755481, 0.06664374505143222]",0,0,0,0,0,0 +ztf,tls_v1,TLS v1,11.049633072212188,128,128,128,108,103,108,103,10,0.84375,0.8046875,0.078125,"[0.7709518905710295, 0.8965164519909334]","[0.7275592800992087, 0.864060387171622]","[0.04298737505098223, 0.13784693180479052]",0,0,0,0,0,0 +ztf,bls_pypi,BLS PyPI,7.808926968765494,128,128,128,94,88,94,88,11,0.734375,0.6875,0.0859375,"[0.6518470619103658, 0.8032449898364271]","[0.6027289441827964, 0.7613446972146379]","[0.04866313137138619, 0.14734091054261295]",0,0,0,0,0,0 +ztf,gtls_batch,GTLS,11.481840133666992,128,128,128,103,96,103,96,3,0.8046875,0.75,0.0234375,"[0.7275592800992087, 0.864060387171622]","[0.6684443704768313, 0.8169871513864144]","[0.008002416396755481, 0.06664374505143222]",0,0,0,0,0,0 +tess_gap,tls_v1,TLS v1,10.31433208781023,128,128,128,92,78,93,79,2,0.71875,0.609375,0.015625,"[0.6353658087171574, 0.7893867729131827]","[0.5228464818201277, 0.6895298089950423]","[0.004295449710144082, 0.055180976679817195]",0,0,0,0,0,0 +tess_gap,bls_v1_batch,BLS v1,7.2287964183017674,128,128,128,95,89,95,89,6,0.7421875,0.6953125,0.046875,"[0.6601308077044309, 0.8101309791005885]","[0.6108489916080531, 0.7683943848476078]","[0.021657844171923776, 0.09849752245094322]",0,0,0,0,0,0 +tess_gap,gtls,GTLS,10.234871864318848,128,128,128,95,74,99,78,2,0.7421875,0.578125,0.015625,"[0.6601308077044309, 0.8101309791005885]","[0.4915184739711009, 0.6601788766111634]","[0.004295449710144082, 0.055180976679817195]",0,0,0,0,0,0 diff --git a/benchmarks/results/transit_2026-09-08/runtime_by_cohort.csv b/benchmarks/results/transit_2026-09-08/runtime_by_cohort.csv new file mode 100644 index 00000000..0efaf982 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/runtime_by_cohort.csv @@ -0,0 +1,43 @@ +profile,method,cohort,sources,independent_search_calls,sources_per_call,mean_seconds_per_source,median_seconds_per_source,partial_spectra,p10_seconds_per_source,p90_seconds_per_source +tess_gap,gtls_batch,injected,128,8,16,4.8990518089267425,4.885993424104527,0,4.8154221996665,5.026309558888897 +tess_gap,gtls_batch,null,128,8,16,4.660858871618984,4.674457938992418,0,4.538111167494208,4.789638969348744 +ztf,bls_v1,injected,128,128,1,0.05576533958083019,0.05394321493804455,0,0.04727919325232506,0.06671627759933471 +ztf,bls_v1,null,128,128,1,0.056340247829211876,0.05500971898436546,0,0.04914063811302185,0.06554622910916805 +ztf,bls_v1_batch,injected,128,8,16,0.04714041852275841,0.04597083863336593,0,0.04494405211880803,0.0537684082519263 +ztf,bls_v1_batch,null,128,8,16,0.047158989880699664,0.046097249491140246,0,0.044734667520970106,0.055753399385139346 +tess_200s,bls_pypi,injected,128,128,1,0.007309697481105104,0.00709918886423111,0,0.006772932037711144,0.008117316290736198 +tess_200s,bls_pypi,null,128,128,1,0.007220855506602675,0.0071076080203056335,0,0.006834102049469948,0.00798143707215786 +tess_200s,bls_cpu,injected,128,128,1,0.08041361617506482,0.07957562245428562,0,0.07486833855509759,0.08670346476137639 +tess_200s,bls_cpu,null,128,128,1,0.0835109686886426,0.08348254300653934,0,0.07507981993257999,0.09155884645879268 +tess_gap,bls_v1,injected,128,128,1,0.021096457669045776,0.01944655179977417,0,0.017844080924987793,0.024544704332947732 +tess_gap,bls_v1,null,128,128,1,0.02091970175388269,0.020343458279967308,0,0.017946265265345575,0.024482327327132226 +tess_gap,bls_cpu,injected,128,128,1,0.9938949077622965,0.9850174728780985,0,0.9433870304375886,1.0525289915502072 +tess_gap,bls_cpu,null,128,128,1,0.9837480718852021,0.9787779301404953,0,0.9245284754782915,1.0345106210559607 +ztf,bls_gpu,injected,128,128,1,0.109088613971835,0.10671037621796131,0,0.09443246796727181,0.1253996815532446 +ztf,bls_gpu,null,128,128,1,0.10046657556085847,0.09341909736394882,0,0.08429172150790691,0.12404086366295815 +tess_gap,bls_gpu,injected,128,128,1,0.08122111315606162,0.07657330669462681,0,0.06910276040434837,0.10355343148112296 +tess_gap,bls_gpu,null,128,128,1,0.0763654770271387,0.07355976849794388,0,0.0649584662169218,0.09437146894633769 +tess_200s,gtls_batch,injected,128,8,16,0.4841046528017614,0.482275290065445,0,0.4732979335822165,0.49889174406416714 +tess_200s,gtls_batch,null,128,8,16,0.46825273107970133,0.46455985121428967,0,0.44336933735758066,0.5130724201444536 +tess_200s,tls_v1,injected,128,128,1,0.00514568280777894,0.00456123985350132,0,0.004334511235356331,0.006482760980725288 +tess_200s,tls_v1,null,128,128,1,0.005414932558778673,0.005137477070093155,0,0.005053740367293358,0.006380800530314445 +tess_200s,bls_v1_batch,injected,128,8,16,0.001740989595418796,0.0016576446359977126,0,0.0016160858795046806,0.0023273394908756018 +tess_200s,bls_v1_batch,null,128,8,16,0.0016396704886574298,0.0016348580829799175,0,0.0016221071127802134,0.0016689987387508154 +tess_200s,bls_gpu,injected,128,128,1,0.04485845612362027,0.042425502091646194,0,0.03328749984502792,0.06327201835811137 +tess_200s,bls_gpu,null,128,128,1,0.04446018105954863,0.04118843004107475,0,0.03532467782497406,0.059994104504585255 +ztf,bls_cpu,injected,128,128,1,1.1476335471088532,1.1478115636855364,0,1.0810175649821758,1.2145781934261322 +ztf,bls_cpu,null,128,128,1,1.1682926992943976,1.165103990584612,0,1.1078495617955924,1.2369877144694328 +tess_gap,bls_pypi,injected,128,128,1,0.04095346591202542,0.03945920243859291,0,0.038874903693795204,0.04613059237599373 +tess_gap,bls_pypi,null,128,128,1,0.04035210012807511,0.03968387842178345,0,0.03883342370390892,0.04476092904806137 +tess_200s,bls_v1,injected,128,128,1,0.0025761601864360273,0.0024713370949029922,0,0.0023601893335580826,0.002737502008676529 +tess_200s,bls_v1,null,128,128,1,0.0028031449764966965,0.0027113985270261765,0,0.002639639005064964,0.003066142648458481 +ztf,tls_v1,injected,128,128,1,0.08212186503806151,0.08022432401776314,0,0.07291828133165837,0.09607652500271797 +ztf,tls_v1,null,128,128,1,0.0779161614482291,0.07432696968317032,0,0.0729908138513565,0.08786393441259861 +ztf,bls_pypi,injected,128,128,1,0.10235669178655371,0.09777417220175266,0,0.09438199102878571,0.11228017807006836 +ztf,bls_pypi,null,128,128,1,0.10592819665907882,0.09725344181060791,0,0.09423061087727547,0.12626398392021657 +ztf,gtls_batch,injected,128,8,16,5.938393401447684,5.9234479640144855,0,5.852206010604277,6.123832888668403 +ztf,gtls_batch,null,128,8,16,5.701203616510611,5.690184727311134,0,5.634077923838049,5.762651454890147 +tess_gap,tls_v1,injected,128,128,1,0.02947801712434739,0.028472771868109703,0,0.02771340385079384,0.03209219984710217 +tess_gap,tls_v1,null,128,128,1,0.029538355651311576,0.028926583006978035,0,0.028300012275576592,0.03209763020277023 +tess_gap,bls_v1_batch,injected,128,8,16,0.01749469802598469,0.017703479737974703,0,0.015972457826137543,0.018957544583827257 +tess_gap,bls_v1_batch,null,128,8,16,0.017424570949515328,0.016874385299161077,0,0.015608423855155706,0.02018437534570694 diff --git a/benchmarks/results/transit_2026-09-08/runtime_cohort_ratios.csv b/benchmarks/results/transit_2026-09-08/runtime_cohort_ratios.csv new file mode 100644 index 00000000..b56b80dc --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/runtime_cohort_ratios.csv @@ -0,0 +1,22 @@ +profile,method,sources_per_call,injection_over_null_mean +tess_gap,gtls_batch,16,1.0511049452190386 +ztf,bls_v1,1,0.9897957806269427 +ztf,bls_v1_batch,16,0.9996061968674852 +tess_200s,bls_pypi,1,1.0123035247584158 +tess_200s,bls_cpu,1,0.9629108300117344 +tess_gap,bls_v1,1,1.0084492559809213 +tess_gap,bls_cpu,1,1.0103144658343772 +ztf,bls_gpu,1,1.0858199691075732 +tess_gap,bls_gpu,1,1.0635841785836986 +tess_200s,gtls_batch,16,1.0338533460029342 +tess_200s,tls_v1,1,0.9502764350105845 +tess_200s,bls_v1_batch,16,1.0617923585636566 +tess_200s,bls_gpu,1,1.008958017142085 +ztf,bls_cpu,1,0.9823168010910094 +tess_gap,bls_pypi,1,1.0149029612347713 +tess_200s,bls_v1,1,0.9190249551971624 +ztf,tls_v1,1,1.0539772944619052 +ztf,bls_pypi,1,0.9662837187343073 +ztf,gtls_batch,16,1.0416034579523128 +tess_gap,tls_v1,1,0.9979572821291591 +tess_gap,bls_v1_batch,16,1.0040246085067197 diff --git a/benchmarks/results/transit_2026-09-08/speedups.csv b/benchmarks/results/transit_2026-09-08/speedups.csv new file mode 100644 index 00000000..849ec076 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/speedups.csv @@ -0,0 +1,28 @@ +profile,mode,v1,comparator,speedup,cpu_break_even_hourly_usd +tess_200s,single,bls_v1,bls_pypi,2.6899613351186162, +tess_200s,single,bls_v1,bls_cpu,19.663348157308207,0.024919459091094994 +tess_200s,single,bls_v1,bls_gpu,13.198703232935545, +tess_200s,single,tls_v1,gtls,75.00756591951962, +tess_200s,batch16,bls_v1,bls_pypi,4.314666141397069, +tess_200s,batch16,bls_v1,bls_cpu,43.98861139263161,0.01113924682973917 +tess_200s,batch16,bls_v1,bls_gpu,11.898245808319865, +tess_200s,batch16,tls_v1,gtls,284.14295636443046, +tess_200s,fresh_grid,bls_v1,bls_pypi,4.215168041935089, +tess_gap,single,bls_v1,bls_pypi,2.121480683422156, +tess_gap,single,bls_v1,bls_cpu,49.198727050350996,0.009959607278020097 +tess_gap,single,bls_v1,bls_gpu,4.919613617157392, +tess_gap,single,tls_v1,gtls,181.24630004113678, +tess_gap,batch16,bls_v1,bls_pypi,2.733649899483515, +tess_gap,batch16,bls_v1,bls_cpu,57.09791174874634,0.008581749927321266 +tess_gap,batch16,bls_v1,bls_gpu,3.506151170480629, +tess_gap,batch16,tls_v1,gtls,206.31842799515374, +tess_gap,fresh_grid,bls_v1,bls_pypi,10.179471236685414, +ztf,single,bls_v1,bls_pypi,1.8600129475257032, +ztf,single,bls_v1,bls_cpu,19.76951348598323,0.024785637762275464 +ztf,single,bls_v1,bls_gpu,1.9739622189972725, +ztf,single,tls_v1,gtls,214.54440277466585, +ztf,batch16,bls_v1,bls_pypi,1.8179504871348415, +ztf,batch16,bls_v1,bls_cpu,18.77150134382025,0.0261033995643248 +ztf,batch16,bls_v1,bls_gpu,1.4916715240282257, +ztf,batch16,tls_v1,gtls,92.50644266058268, +ztf,fresh_grid,bls_v1,bls_pypi,10.677777553277089, diff --git a/benchmarks/results/transit_2026-09-08/timing_summary.csv b/benchmarks/results/transit_2026-09-08/timing_summary.csv new file mode 100644 index 00000000..95c4aed7 --- /dev/null +++ b/benchmarks/results/transit_2026-09-08/timing_summary.csv @@ -0,0 +1,43 @@ +profile,method,family,mode,n,job,median_total_s,seconds_per_source,min_total_s,max_total_s,reps,initialization_s,first_api_s,projected_gpu_usd_per_million +ztf,bls_v1,BLS v1,single,1,timing_ztf_bls_v1_single,0.060709379613399506,0.060709379613399506,0.05817347392439842,0.07475404441356659,5,0.31287648528814316,0.4160355143249035,8.263221114046043 +ztf,bls_v1,BLS v1,batch16,16,timing_ztf_bls_v1_batch16,0.8814151287078857,0.05508844554424286,0.8494790829718113,0.8814807534217834,3,0.29839877784252167,0.44058119505643845,7.498149532410833 +tess_200s,bls_pypi,BLS PyPI,single,1,timing_tess_200s_bls_pypi_single,0.009594578295946121,0.009594578295946121,0.007814127951860428,0.011594187468290329,5,0.5013919807970524,0.3546946309506893,1.3059287125037775 +tess_200s,bls_pypi,BLS PyPI,batch16,16,timing_tess_200s_bls_pypi_batch16,0.10624408721923828,0.006640255451202393,0.10548662394285202,0.10807739570736885,3,0.516373760998249,0.33975765854120255,0.9038125475247701 +tess_200s,bls_cpu,BLS CPU,single,1,timing_tess_200s_bls_cpu_single,0.07013540714979172,0.07013540714979172,0.06978021934628487,0.07059667259454727,5,0.24862141907215118,0.4410172589123249, +tess_200s,bls_cpu,BLS CPU,batch16,16,timing_tess_200s_bls_cpu_sources_batch16,1.0831729993224144,0.0676983124576509,1.0751698017120361,1.0862058028578758,3,0.2512398511171341,0.44033587723970413, +tess_gap,bls_v1,BLS v1,single,1,timing_tess_gap_bls_v1_single,0.019612248986959457,0.019612248986959457,0.019184213131666183,0.02008390799164772,5,0.23865936324000359,0.3999895416200161,2.6694450010028152 +tess_gap,bls_v1,BLS v1,batch16,16,timing_tess_gap_bls_v1_batch16,0.25186800584197044,0.015741750365123153,0.25106167793273926,0.25609927251935005,3,0.22031604126095772,0.38277536258101463,2.1426271330306514 +tess_gap,bls_cpu,BLS CPU,single,1,timing_tess_gap_bls_cpu_single,0.9648976847529411,0.9648976847529411,0.9388672448694706,1.0139840506017208,5,0.025617577135562897,1.0946842208504677, +tess_gap,bls_cpu,BLS CPU,batch16,16,timing_tess_gap_bls_cpu_batch16,14.381137169897556,0.8988210731185973,14.314714837819338,14.658780228346586,3,0.025798145681619644,1.1416033208370209, +ztf,bls_gpu,BLS GPU,single,1,timing_ztf_bls_gpu_single,0.11983802169561386,0.11983802169561386,0.10239343717694283,0.12168750539422035,5,0.06404359266161919,0.4639225900173187,16.311286286347443 +ztf,bls_gpu,BLS GPU,batch16,16,timing_ztf_bls_gpu_batch16,1.3147818483412266,0.08217386552132666,1.2921068035066128,1.3179080672562122,3,0.0755436085164547,0.3547443859279156,11.184776140402796 +tess_gap,bls_gpu,BLS GPU,single,1,timing_tess_gap_bls_gpu_single,0.09648468717932701,0.09648468717932701,0.09100404009222984,0.12821057438850403,5,0.029009409248828888,0.3533320166170597,13.132637977186176 +tess_gap,bls_gpu,BLS GPU,batch16,16,timing_tess_gap_bls_gpu_batch16,0.8830873034894466,0.055192956468090415,0.8760794401168823,0.8833278268575668,3,0.028008747845888138,0.3233819864690304,7.512374630378973 +tess_200s,tls_v1,TLS v1,single,1,timing_tess_200s_tls_v1_single,0.006087265908718109,0.006087265908718109,0.0053090862929821014,0.007795337587594986,5,0.6532808840274811,0.3495583198964596,0.8285445264644093 +tess_200s,tls_v1,TLS v1,batch16,16,timing_tess_200s_tls_v1_batch16,0.02524644136428833,0.0015779025852680206,0.025092795491218567,0.027611952275037766,3,0.6294963136315346,0.35291464626789093,0.21477007410592502 +tess_200s,bls_gpu,BLS GPU,single,1,timing_tess_200s_bls_gpu_single,0.047077253460884094,0.047077253460884094,0.02869986742734909,0.06828246638178825,5,0.008294008672237396,0.34470534697175026,6.407737276620335 +tess_200s,bls_gpu,BLS GPU,batch16,16,timing_tess_200s_bls_gpu_batch16,0.29298171028494835,0.018311356892809272,0.27921487763524055,0.29468749836087227,3,0.009878687560558319,0.4768342114984989,2.492379132632373 +tess_200s,gtls,GTLS,single,1,timing_tess_200s_gtls_single,0.45659099891781807,0.45659099891781807,0.45280682295560837,0.5005530156195164,5,1.3302765414118767,0.6659211292862892,62.14710818603635 +tess_200s,gtls,GTLS,batch16,16,timing_tess_200s_gtls_batch16,7.173598486930132,0.44834990543313324,7.132980853319168,7.222177769988775,3,1.2973153330385685,0.7009596154093742,61.02540379506536 +ztf,gtls,GTLS,single,1,timing_ztf_gtls_single,17.154249481856823,17.154249481856823,16.7149618268013,17.671402160078287,5,1.7354291193187237,18.951260082423687,2334.883957252734 +ztf,gtls,GTLS,batch16,16,timing_ztf_gtls_batch16,92.84267467260361,5.8026671670377254,91.84492887184024,93.88232457637787,3,1.3421119004487991,20.017838083207607,789.8074755134681 +ztf,bls_cpu,BLS CPU,single,1,timing_ztf_bls_cpu_single,1.2001948989927769,1.2001948989927769,1.119250688701868,1.2049834690988064,5,0.08470883220434189,1.3163165636360645, +ztf,bls_cpu,BLS CPU,batch16,16,timing_ztf_bls_cpu_batch16,16.545485273003578,1.0340928295627236,16.28184385970235,16.67664570733905,3,0.0732661671936512,1.260062389075756, +tess_gap,bls_pypi,BLS PyPI,single,1,timing_tess_gap_bls_pypi_single,0.04160700738430023,0.04160700738430023,0.04105076566338539,0.04204794019460678,5,0.5240104384720325,0.4329675920307636,5.663176005085309 +tess_gap,bls_pypi,BLS PyPI,batch16,16,timing_tess_gap_bls_pypi_batch16,0.6885189488530159,0.043032434303313494,0.6767539083957672,0.6904759742319584,3,0.5971486158668995,0.44540875405073166,5.857192446839893 +tess_200s,bls_v1,BLS v1,single,1,timing_tess_200s_bls_v1_single,0.0035668089985847473,0.0035668089985847473,0.003096986562013626,0.0037236735224723816,5,0.29103998094797134,0.3569226413965225,0.4854823359184795 +tess_200s,bls_v1,BLS v1,batch16,16,timing_tess_200s_bls_v1_batch16,0.024623941630125046,0.0015389963518828154,0.02400203049182892,0.025583025068044662,3,0.29183537513017654,0.35761331766843796,0.20947450345071653 +ztf,tls_v1,TLS v1,single,1,timing_ztf_tls_v1_single,0.07995663955807686,0.07995663955807686,0.07975418493151665,0.08191141858696938,5,0.6922066323459148,0.3379403054714203,10.88298705096046 +ztf,tls_v1,TLS v1,batch16,16,timing_ztf_tls_v1_batch16,1.003634687513113,0.06272716796956956,0.9575513191521168,1.0693909339606762,3,0.8541191928088665,0.3531450405716896,8.537864529191413 +ztf,bls_pypi,BLS PyPI,single,1,timing_ztf_bls_pypi_single,0.11292023211717606,0.11292023211717606,0.1125192940235138,0.11719745770096779,5,0.5208181776106358,0.5376644656062126,15.369698260393408 +ztf,bls_pypi,BLS PyPI,batch16,16,timing_ztf_bls_pypi_batch16,1.60236906260252,0.1001480664126575,1.5184672698378563,1.8347912579774857,3,0.6209818981587887,0.527594406157732,13.631264595056159 +tess_gap,tls_v1,TLS v1,single,1,timing_tess_gap_tls_v1_single,0.03959222137928009,0.03959222137928009,0.0343572236597538,0.047672588378190994,5,0.5827579386532307,0.4098588153719902,5.388941243290901 +tess_gap,tls_v1,TLS v1,batch16,16,timing_tess_gap_tls_v1_batch16,0.3618355728685856,0.0226147233042866,0.3591429367661476,0.3802635706961155,3,0.7420200034976006,0.38362566754221916,3.0781151164167873 +tess_gap,gtls,GTLS,single,1,timing_tess_gap_gtls_single,7.17594363540411,7.17594363540411,7.090526714920998,7.196748662739992,5,1.457155417650938,8.074638292193413,976.7256614855594 +tess_gap,gtls,GTLS,batch16,16,timing_tess_gap_gtls_batch16,74.65334658697248,4.66583416168578,73.74857684969902,74.70101902633905,3,1.6365491077303886,10.565233658999205,635.0718720072311 +ztf,bls_v1,BLS v1,fresh_grid,1,timing_ztf_bls_v1_fresh_grid,0.17865851148962975,0.17865851148962975,0.1745709739625454,0.19099607318639755,5,0.36056404933333397,0.656223863363266,24.317408508310717 +tess_200s,bls_pypi,BLS PyPI,fresh_grid,1,timing_tess_200s_bls_pypi_fresh_grid,0.02593012899160385,0.02593012899160385,0.025698699057102203,0.028312820941209793,5,0.6160987764596939,0.39152299612760544,3.5293786683016353 +tess_gap,bls_v1,BLS v1,fresh_grid,1,timing_tess_gap_bls_v1_fresh_grid,0.059447020292282104,0.059447020292282104,0.05837731435894966,0.0621049702167511,5,0.37472711876034737,0.4749140478670597,8.091399984227287 +tess_gap,bls_pypi,BLS PyPI,fresh_grid,1,timing_tess_gap_bls_pypi_fresh_grid,0.6051392331719398,0.6051392331719398,0.5962634719908237,0.6278022825717926,5,0.6015424951910973,1.0106159076094627,82.36617340395848 +tess_200s,bls_v1,BLS v1,fresh_grid,1,timing_tess_200s_bls_v1_fresh_grid,0.006151624023914337,0.006151624023914337,0.005375340580940247,0.007889803498983383,5,0.30468549206852913,0.35930396243929863,0.8373043810327848 +ztf,bls_pypi,BLS PyPI,fresh_grid,1,timing_ztf_bls_pypi_fresh_grid,1.9076758436858654,1.9076758436858654,1.6413278579711914,1.9913938380777836,5,0.7317790240049362,2.437200713902712,259.65587872390944 diff --git a/benchmarks/tls_accuracy/README.md b/benchmarks/tls_accuracy/README.md new file mode 100644 index 00000000..9ad7cd5c --- /dev/null +++ b/benchmarks/tls_accuracy/README.md @@ -0,0 +1,164 @@ +# Binned TLS accuracy and efficiency tools (2026-09-09) + +These tools characterize the earlier binned TLS engine, retained as +`method='binned'`. Their binning losses, kernel speedups and high-impact pilot +do not describe the standard observation-level TLS search. See the +[current benchmark and validation](../../docs/TRANSIT_BENCHMARKS.md). + +`diagnose.py` measures expected signal-to-noise retention at the **true period**. +It separates template shape, phase compression, and coarse epoch/duration +sampling. It does not measure detection completeness, a false-positive rate, +native SDE, GTLS sensitivity, or execution-time speedups. The corresponding +historical complete-search study is in `../tls_sensitivity`. + +The [2026-09-09 published diagnostic](../results/tls_accuracy_2026-09-09/accuracy/README.md) +contains the validated regime table, numerical outputs and provenance. + +The sibling `kernel_benchmark.py` measures the effect of the CUDA optimization +on identical inputs and search settings. `high_impact.py` runs a focused +complete-search comparison for high-impact transits. Those GPU experiments +answer different questions from this CPU diagnostic. + +Run on a CPU with NumPy, SciPy, and `batman-package` installed: + +```sh +python benchmarks/tls_accuracy/diagnose.py \ + --source-revision 11317fb0ff1b68af05ae3f67de5f298c9a90e46b \ + --cadences benchmarks/results/tls_sensitivity_2026-09-09/cadences \ + --out /path/outside/the/repository/tls-accuracy +python -m pytest -q benchmarks/tls_accuracy/test_diagnose.py +``` + +The run writes per-case and summary CSVs, a manifest with parameters, versions +and hashes, and snapshots of the model/grid/kernel sources from the requested +Git revision. It deliberately does not put generated results in this source +directory. Default work is 19 physical regimes, three frozen search settings, +32 phase offsets, and 72 additional injections into three observed cadences. + +## What is held fixed + +The signal is a noiseless, exposure-integrated `batman` transit. Exposure +integration uses 64-point Gauss-Legendre quadrature; uniform sampling resolves +the geometric transit with 4,096 intervals. All arithmetic is float64, while +the template and its integrated tables are the actual float32 tables generated +by the pinned source. Period rounding, GPU folding roundoff and accumulation +roundoff are deliberately excluded. Both signals and templates use known unit +out-of-transit baselines and quadratic limb darkening `[0.4804, 0.1867]`. + +The catalog includes Sun/Earth and Sun/Jupiter shapes, high impact parameters, +long periods, dense M dwarfs, and eccentric periastron transits. Its white-dwarf +examples are **shape and resolution stress cases**: the shared limb-darkening +law is a controlled assumption, not an atmosphere model for a white dwarf; +deep physical eclipses may also violate the production depth gate. The shape +ratios are amplitude invariant and do not apply that gate. Nothing in this +catalog establishes the occurrence rate or observability of these systems. + +Observed cases use only the stored TESS/ZTF times, exposure times and relative +errors. Eight predetermined random epochs per shape are retained, including +epochs with no sampled signal. No observed flux, fitted detrending, correlated +noise, or real survey selection is modeled. The quoted SNR denominator uses +independent errors of `0.001 * relative_error`; kernel weights include their +`1e-10` regularizer. Signal amplitude cancels from every retention ratio. + +## Reading the quantities + +For a noiseless flux deficit `s`, filter `f`, independent errors `sigma`, and +kernel weights `w = 1 / (sigma**2 + 1e-10)`, the expected SNR is + +```text +sum(w * s * f) / sqrt(sum(w**2 * sigma**2 * f**2)). +``` + +The oracle is `sqrt(sum(s**2 / sigma**2))`. The uniform calculation replaces +sums with integrals. The native coarse kernel instead divides its squared +numerator by `sum(B * mean(T**2))`. That is different from the actual variance +of its filter `mean(T)`. `native_norm_over_noise` quantifies the difference; +native score amplitudes must not be read as calibrated significance. + +| Output | Meaning | +| --- | --- | +| `template_snr_over_oracle` | Best unbinned fixed-template fit, allowing duration and epoch to vary, relative to the true signal filter. | +| `box_snr_over_oracle` | Optimized box shape relative to the same oracle. Its duration is free, rather than fixed to the full transit width. | +| `physical_projection_retention` | Best possible filter of the stored bin sums, assuming the true signal shape is known. This bounds the information loss from summation, separately from TLS's approximate template weights. | +| `physical_binned_over_unbinned` | Additional compression effect, holding that best TLS template's epoch and width fixed. Can exceed one if smoothing improves a mismatched shape. | +| `own_template_binned_over_unbinned` | Compression control: inject the same pointwise TLS template at the true geometric width, then compare its bin-averaged filter. | +| `own_template_uncapped_retention` | Same control if the requested automatic resolution could exceed 8,192 bins; a hypothetical diagnostic, not a supported production setting. | +| `epoch_grid_only_retention` | Uniform cases: fix the template width to the true geometric duration and discretize epoch, relative to the same filter at the true center. | +| `duration_grid_only_retention` | Uniform cases: discretize width, keeping the best continuous epoch fixed. Catalog signals are symmetric around conjunction. | +| `coarse_grid_native_selected_over_oracle` | Uniform cases: at the true period, select epoch and duration with the native coarse expected-score objective, then evaluate that filter's actual SNR. | +| `coarse_grid_best_snr_over_oracle` | The largest actual SNR among those same coarse-grid trials. It distinguishes grid/compression loss from the native normalization's choice. | + +Uniform cases cover every sub-bin offset at even spacing and vary the integer +phase-bin index independently, to sample the epoch grid as well. Sampled epoch +phases differ between configurations, so their coarse-grid ranges are +descriptive distributions, **not paired speed/accuracy comparisons on the same +ephemerides**. The observed-cadence cases use the same epoch in every setting. +Continuous TLS fits optimize width and epoch. The box fit optimizes the integrated signal +over both boundaries; observed-cadence boxes exhaust all contiguous intervals +whose endpoints have nonzero signal. These are oracle-assisted shape controls, +not a comparison to a particular BLS implementation. + +The grid calculation searches all native epoch trials overlapping the +deterministic signal, including every allowed log-spaced duration. Other +epochs have zero expected numerator and cannot win this calculation. Real +noise has nonzero numerator everywhere, so this pruning is not a proposed +search optimization. No refinement, candidate pruning across periods, or +periodogram standardization is simulated. +The isolated epoch, duration and bin losses must not be added; their joint +effect can change which coarse trial wins. + +`duration_below_prior`, `bin_cap`, and `epoch_cap` are separate flags. A narrow +transit excluded by the duration prior cannot be repaired by increasing the +number of bins alone. The bin cap assumes a GPU supporting the implementation +maximum of 8,192; a smaller device shared-memory limit can reduce it further. +`depth_gate_caveat` flags the compact-star stress cases, which are not a literal +production search simulation at their physical eclipse depth. + +The mathematical tests check weighted projection, an analytic optimized box +fit to a trapezoid, exhaustive observed interval fitting, circular contact +geometry, bin caps, and the distinction between mean squares and squared means. +For publication, also rerun with finer integration and phase-offset grids and +compare the resulting retention estimates. A small loss of expected SNR is +not a bound on missed detections near a chosen threshold. + +## GPU implementation check + +`kernel_benchmark.py` compares identical lightcurves and search settings using +the original CUDA kernel and the optimized kernel. It alternates timed calls +after warming both variants, retains every repetition, and compares period +scores, candidates and SDE. Timing includes the public batch API's host work, +transfers, search, refinement and results; it excludes imports, compilation, +grid creation and disk I/O. It does not measure recovery equivalence with GTLS. + +For the full-grid, fine-resolution ZTF comparison, on a CUDA installation: + +```sh +git show 11317fb0ff1b68af05ae3f67de5f298c9a90e46b:cuvarbase/kernels/tls_fast.cu \ + > /tmp/cuvarbase-tls-baseline.cu +python benchmarks/tls_accuracy/kernel_benchmark.py \ + --inputs benchmarks/results/transit_2026-09-08/inputs \ + --profile ztf --sources 16 --period-limit 0 --reps 5 \ + --nbins 8192 --t0-oversample 16 --n-durations 32 \ + --baseline-kernel /tmp/cuvarbase-tls-baseline.cu \ + --out /tmp/cuvarbase-tls-kernel-ztf.json +``` + +Without `--baseline-kernel`, the reference is the current source compiled with +empty-bin traversal disabled. That is useful for development, but differs +from timing the archived original source. [Published validation and all +configurations](../results/tls_accuracy_2026-09-09/kernel/README.md). + +## Focused high-impact recovery + +`high_impact.py` implements the frozen high-impact TESS pilot in stages: +protocol freeze, input generation, calibration runs, threshold freeze, +held-out runs and analysis. Each GPU method/split runs in a separate process. +Held-out execution requires the frozen thresholds; all failures remain in the +results. Its scalar analysis can be reproduced without a GPU. + +The [published pilot](../results/tls_accuracy_2026-09-09/high-impact/README.md) +contains the exact configurations, source pins, package versions and commands +for regenerating inputs or recomputing the analysis. Keep generated arrays +outside the repository. This focused experiment is too small for a tight +equivalence claim, and its operational API times are not a replacement for the +exclusive, repeated timing benchmark. diff --git a/benchmarks/tls_accuracy/diagnose.py b/benchmarks/tls_accuracy/diagnose.py new file mode 100644 index 00000000..943588e8 --- /dev/null +++ b/benchmarks/tls_accuracy/diagnose.py @@ -0,0 +1,591 @@ +#!/usr/bin/env python3 +"""CPU expected-SNR diagnostics for the fast TLS approximation. + +This is a known-period, noiseless-signal calculation, not a recovery test or +an implementation of GTLS. Expected filter SNR uses its actual white-noise +variance. Native coarse TLS scores are evaluated separately because their +bin-averaged T-squared normalization is not that variance. +""" + +import argparse +import csv +from dataclasses import asdict, dataclass +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import sys + +import batman +import numpy as np +import scipy +from scipy.integrate import cumulative_trapezoid +from scipy.optimize import brentq, differential_evolution, minimize + + +G, MSUN, RSUN, REARTH = 6.67430e-11, 1.98840e30, 6.95700e8, 6.371e6 +LD = [.4804, .1867] + + +@dataclass(frozen=True) +class Regime: + name: str + period: float + radius: float = 1. + mass: float = 1. + rp: float = .00916 + impact: float = 0. + eccentricity: float = 0. + omega: float = 90. + exposure_seconds: float = 200. + + +REGIMES = [ + Regime('sun_jupiter_5d', 5., rp=.1), + Regime('sun_subneptune_10d', 10., rp=.025, impact=.5), + Regime('sun_earth_10d', 10.), + Regime('sun_earth_10d_b08', 10., impact=.8), + Regime('sun_earth_10d_b095', 10., impact=.95), + Regime('sun_earth_10d_grazing', 10., impact=1.), + Regime('sun_jupiter_5d_grazing', 5., rp=.1, impact=1.05), + Regime('sun_earth_365d', 365.25), + Regime('sun_earth_365d_b08', 365.25, impact=.8), + Regime('sun_earth_1000d', 1000.), + Regime('sun_earth_100d_e08', 100., impact=.5, eccentricity=.8), + Regime('mdwarf02_earth_10d', 10., radius=.2, mass=.2, rp=.00916/.2), + Regime('mdwarf01_earth_30d', 30., radius=.1, mass=.1, rp=.00916/.1), + Regime('mdwarf01_earth_100d', 100., radius=.1, mass=.1, rp=.00916/.1), + Regime('mdwarf01_earth_365d', 365.25, radius=.1, mass=.1, rp=.00916/.1), + Regime('mdwarf01_earth_365d_b09', 365.25, radius=.1, mass=.1, + rp=.00916/.1, impact=.9), + Regime('white_dwarf_earth_1d', 1., radius=.012, mass=.6, + rp=.00916/.012, exposure_seconds=30.), + Regime('white_dwarf_earth_10d', 10., radius=.012, mass=.6, + rp=.00916/.012, exposure_seconds=30.), + Regime('sun_earth_10d_30min', 10., exposure_seconds=1800.), +] + +CONFIGS = [ + ('api_default', 3., 15, None), + ('benchmark_original', 4., 16, None), + ('benchmark_fine', 16., 32, 8192), +] + + +def source_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Template: + def __init__(self, source): + module = source_module(source, 'diagnostic_tls_models') + if not module.BATMAN_AVAILABLE: + raise RuntimeError('A physical batman template is required.') + self.t, self.s1, self.s2 = [np.asarray(x, dtype=np.float64) for x in + module.generate_template_tables(u=LD)] + self.knots = np.linspace(-1., 1., len(self.t)) + # Exact integral of the squared piecewise-linear point template. + self.point_norm = float(np.sum(np.diff(self.knots) * + (self.t[:-1]**2+self.t[:-1]*self.t[1:]+self.t[1:]**2)/3)) + + def point(self, x, center, width): + return np.interp(2 * (x-center) / width, self.knots, self.t, + left=0., right=0.) + + def averages(self, lo, hi, center, width): + a, b = 2*(lo-center)/width, 2*(hi-center)/width + return tuple((np.interp(b, self.knots, table) - + np.interp(a, self.knots, table)) / (b-a) + for table in (self.s1, self.s2)) + + +def parameters(regime, epoch=0.): + p = batman.TransitParams() + p.t0, p.per, p.rp = epoch, regime.period, regime.rp + p.a = (G*MSUN*regime.mass*(p.per*86400)**2/(4*np.pi**2))**(1/3) + p.a /= regime.radius*RSUN + p.ecc, p.w = regime.eccentricity, regime.omega + cosi = regime.impact / p.a * (1+p.ecc*np.sin(np.radians(p.w))) / (1-p.ecc**2) + p.inc = float(np.degrees(np.arccos(cosi))) + p.u, p.limb_dark = LD, 'quadratic' + return p + + +def separation(t, p): + """Projected separation in stellar radii; t0 is inferior conjunction. + + This independently solves Kepler's equation and is used only to locate + geometric contacts. All fluxes come from batman. Catalog eccentric cases + have omega=90 degrees, so inferior conjunction is also minimum separation. + """ + omega = np.radians(p.w) + f0 = np.pi/2 - omega + e0 = 2*np.arctan2(np.sqrt(1-p.ecc)*np.sin(f0/2), + np.sqrt(1+p.ecc)*np.cos(f0/2)) + mean = e0-p.ecc*np.sin(e0) + 2*np.pi*(np.asarray(t)-p.t0)/p.per + eccentric = mean.copy() + for _ in range(30): + step = (eccentric-p.ecc*np.sin(eccentric)-mean)/(1-p.ecc*np.cos(eccentric)) + eccentric -= step + if np.max(np.abs(step)) < 1e-14: + break + anomaly = 2*np.arctan2(np.sqrt(1+p.ecc)*np.sin(eccentric/2), + np.sqrt(1-p.ecc)*np.cos(eccentric/2)) + r = p.a*(1-p.ecc*np.cos(eccentric)) + angle = anomaly+omega + return r*np.sqrt(np.cos(angle)**2 + np.cos(np.radians(p.inc))**2*np.sin(angle)**2) + + +def durations(regime): + p = parameters(regime) + guess = p.per/np.pi*np.arcsin(np.sqrt((1+p.rp)**2-regime.impact**2)/ + (p.a*np.sin(np.radians(p.inc)))) + guess *= np.sqrt(1-p.ecc**2)/(1+p.ecc*np.sin(np.radians(p.w))) + + def contacts(level): + if float(separation(0., p)) >= level: + return 0., 0. + left = brentq(lambda t: float(separation(t, p))-level, -2*guess, 0., xtol=1e-14) + right = brentq(lambda t: float(separation(t, p))-level, 0., 2*guess, xtol=1e-14) + return left, right + + t1, t4 = contacts(1+p.rp) + t2, t3 = contacts(abs(1-p.rp)) + return t4-t1, max(0., t3-t2), p.a + + +def physical_signal(regime, times, exposures, epoch=0., exposure_nodes=64): + """Exposure integrals using Gauss-Legendre quadrature, in float64.""" + p = parameters(regime, epoch) + times = np.asarray(times, dtype=np.float64) + exposures = np.broadcast_to(np.asarray(exposures), times.shape) + result = np.empty_like(times) + nodes, weights = np.polynomial.legendre.leggauss(exposure_nodes) + for exposure in np.unique(exposures): + take = exposures == exposure + if exposure == 0: + result[take] = 1-batman.TransitModel(p, times[take]).light_curve(p) + else: + t = (times[take, None]+exposure*.5*nodes[None, :]).ravel() + flux = batman.TransitModel(p, t).light_curve(p).reshape((-1, exposure_nodes)) + result[take] = np.dot(1-flux, weights*.5) + return result + + +def expected_snr(signal, filt, errors, regularizer=1e-10): + weights = 1/(errors*errors+regularizer) + numerator = np.dot(weights*signal, filt) + variance = np.dot(weights*weights*errors*errors, filt*filt) + return float(numerator/np.sqrt(variance)) if variance > 0 else 0. + + +class UniformSignal: + def __init__(self, x, signal): + self.x, self.signal = np.asarray(x), np.asarray(signal) + self.cumulative = cumulative_trapezoid(self.signal, self.x, initial=0.) + self.oracle = float(np.sqrt(np.sum(np.diff(self.x) * + (self.signal[:-1]**2+self.signal[:-1]*self.signal[1:]+ + self.signal[1:]**2)/3))) + + def integral(self, lo, hi): + def antiderivative(z): + z = np.clip(z, self.x[0], self.x[-1]) + index = np.clip(np.searchsorted(self.x, z, side='right')-1, + 0, len(self.x)-2) + delta = z-self.x[index] + slope = ((self.signal[index+1]-self.signal[index]) / + (self.x[index+1]-self.x[index])) + return self.cumulative[index]+self.signal[index]*delta+.5*slope*delta*delta + return antiderivative(hi)-antiderivative(lo) + + def point_snr(self, template, center, width): + f = template.point(self.x, center, width) + # Integrate the whole filter even if a very wide duration prior + # extends outside the compact grid containing the signal. + variance = .5*width*template.point_norm + return float(np.trapz(self.signal*f, self.x)/np.sqrt(variance)) if variance > 0 else 0. + + def binned(self, template, center, width, binwidth, offset): + first = int(np.floor((center-.5*width)/binwidth+offset)) + last = int(np.ceil((center+.5*width)/binwidth+offset)) + lo = (np.arange(first, last)-offset)*binwidth + hi = lo+binwidth + f, f2 = template.averages(lo, hi, center, width) + numerator = float(np.dot(self.integral(lo, hi), f)) + variance = float(np.dot(f, f)*binwidth) + native_den = float(f2.sum()*binwidth) + return metrics(numerator, variance, native_den) + + def projection_retention(self, binwidth, offset): + """Best SNR possible from bin sums, given the true signal shape.""" + first = int(np.floor(self.x[0]/binwidth+offset)) + last = int(np.ceil(self.x[-1]/binwidth+offset)) + lo = (np.arange(first, last)-offset)*binwidth + integrals = self.integral(lo, lo+binwidth) + return float(np.sqrt(np.dot(integrals, integrals)/binwidth)/self.oracle) + + +def metrics(numerator, variance, native_den): + return dict(snr=numerator/np.sqrt(variance) if variance > 0 else 0., + native_score=numerator*numerator/native_den if native_den > 0 else 0., + native_norm_over_noise=np.sqrt(native_den/variance) if variance > 0 else np.nan) + + +def best_uniform_box(signal): + """Optimize both box boundaries through its epoch and duration.""" + bounds = [(-.4, .4), (.08, 2.5)] + def box_objective(z): + center, width = z + return -float(signal.integral(center-.5*width, center+.5*width)/np.sqrt(width)) + + box = differential_evolution(box_objective, bounds, seed=114, + tol=1e-10, polish=True) + if not box.success: + raise RuntimeError(box.message) + return box.x, -float(box.fun) + + +def best_uniform_fits(signal, template): + """Continuous fits; the box's width and epoch are both free.""" + tls = minimize(lambda z: -signal.point_snr(template, *z), [0., 1.], + method='Powell', bounds=[(-.4, .4), (.08, 2.5)], + options={'xtol': 1e-8, 'ftol': 1e-10}) + if not tls.success: + raise RuntimeError(tls.message) + box_fit, box_snr = best_uniform_box(signal) + return tls.x, -float(tls.fun), box_fit, box_snr + + +def automatic_bins(qmin, oversampling, cap=8192): + need = oversampling/max(qmin, 1e-6) + requested = 2**int(np.ceil(np.log2(max(256., need)))) + return min(cap, requested), requested + + +def epoch_trials(q, oversampling): + return min(20000, max(30, int(np.ceil(oversampling/q)))) + + +def correct_period_grid(evaluator, qmin, qmax, qtrue, epoch_phase, + oversampling, n_durations, support_width): + """Expected-score maximum near the transit, on the complete native grid. + + Trials whose window does not overlap the deterministic signal have zero + numerator and cannot win. Pruning those zero-overlap trials is exact for + this noiseless calculation, not an acceleration usable for real searches. + support_width is in units of the geometric transit duration. + """ + winner_native = None + winner_snr = None + for q in np.geomspace(qmin, qmax, n_durations): + width = q/qtrue + n = epoch_trials(q, oversampling) + half = .5*(width+support_width)*qtrue + first = int(np.floor((epoch_phase-half)*n))-1 + last = int(np.ceil((epoch_phase+half)*n))+1 + for index in range(first, last+1): + center = (index/n-epoch_phase)/qtrue + value = dict(evaluator(center, width), center=center, width=width) + if winner_native is None or value['native_score'] > winner_native['native_score']: + winner_native = value + if winner_snr is None or value['snr'] > winner_snr['snr']: + winner_snr = value + return winner_native, winner_snr + + +def flags(regime, duration, ingress, qmin, bins, requested, oversampling): + q = duration/regime.period + return dict(q=q, duration_hours=duration*24, ingress_minutes=ingress*1440, + qmin=qmin, bins=bins, requested_bins=requested, + bins_across_transit=q*bins, + bins_across_ingress=ingress/regime.period*bins, + bin_cap=requested > bins, + epoch_cap=oversampling/qmin > 20000, + duration_below_prior=q < qmin, + depth_gate_caveat=regime.rp > .5, + stellar_density_solar=regime.mass/regime.radius**3) + + +def uniform_cases(template, grids, offsets, samples_per_transit, exposure_nodes): + rng = np.random.default_rng(20260909) + rows = [] + for regime in REGIMES: + duration, full_duration, a = durations(regime) + ingress = .5*(duration-full_duration) + qtrue = duration/regime.period + x = np.linspace(-2., 2., 4*samples_per_transit+1) + flux = physical_signal(regime, x*duration, regime.exposure_seconds/86400, + exposure_nodes=exposure_nodes) + physical = UniformSignal(x, flux) + own = UniformSignal(x, template.point(x, 0., 1.)) + fit, fit_snr, box_fit, box_snr = best_uniform_fits(physical, template) + qmin, qmax = grids.duration_window(np.array([regime.period]), + R_star=regime.radius, M_star=regime.mass) + qmin, qmax = float(qmin[0]), min(float(qmax[0]), .333) + for label, oversampling, nd, fixed_bins in CONFIGS: + auto, requested = automatic_bins(qmin, oversampling) + bins = auto if fixed_bins is None else fixed_bins + duration_snrs = [physical.point_snr(template, fit[0], q/qtrue) + for q in np.geomspace(qmin, qmax, nd)] + for i, offset in enumerate(np.arange(offsets)/offsets): + # Cover every sub-bin offset and independently vary the + # integer bin index, to sample epoch-grid alignment too. + index = int(rng.integers(bins//4, 3*bins//4)) + epoch = (index+offset)/bins + h = 1/(qtrue*bins) + binned = physical.binned(template, *fit, h, offset) + own_binned = own.binned(template, 0., 1., h, offset) + uncapped_h = 1/(qtrue*max(bins, requested)) + uncapped = own.binned(template, 0., 1., uncapped_h, offset) + # Use the fixed geometric width for this isolated epoch + # test. Applying ceil(m/q) to a numerically fitted width + # makes tiny optimizer jitter change the entire epoch grid. + n = epoch_trials(qtrue, oversampling) + nearest = int(round(epoch*n)) + epoch_snr = max(physical.point_snr(template, (j/n-epoch)/qtrue, 1.) + for j in (nearest-1, nearest, nearest+1)) + centered_width_snr = physical.point_snr(template, 0., 1.) + evaluate = lambda c, w: physical.binned(template, c, w, h, offset) + native, best_snr = correct_period_grid( + evaluate, qmin, qmax, qtrue, epoch, oversampling, nd, + 1+regime.exposure_seconds/86400/duration+2*h) + rows.append(dict( + kind='uniform', regime=regime.name, config=label, offset_index=i, + bin_phase_offset=offset, epoch_phase=epoch, + **asdict(regime), a_over_rstar=a, + **flags(regime, duration, ingress, qmin, bins, requested, oversampling), + template_fit_width_over_duration=float(fit[1]), + template_fit_center_over_duration=float(fit[0]), + box_fit_width_over_duration=float(box_fit[1]), + box_fit_center_over_duration=float(box_fit[0]), + template_snr_over_oracle=fit_snr/physical.oracle, + box_snr_over_oracle=box_snr/physical.oracle, + physical_projection_retention=physical.projection_retention(h, offset), + physical_binned_over_unbinned=binned['snr']/fit_snr, + own_template_binned_over_unbinned=own_binned['snr']/own.oracle, + own_template_uncapped_retention=uncapped['snr']/own.oracle, + native_norm_over_noise=binned['native_norm_over_noise'], + epoch_grid_only_retention=epoch_snr/centered_width_snr, + duration_grid_only_retention=max(duration_snrs)/fit_snr, + coarse_grid_native_selected_over_oracle=native['snr']/physical.oracle, + coarse_grid_best_snr_over_oracle=best_snr['snr']/physical.oracle, + coarse_grid_native_selected_over_best_template=native['snr']/fit_snr, + coarse_grid_native_amplitude_over_oracle=np.sqrt(native['native_score'])/physical.oracle, + coarse_selected_width_over_duration=native['width'], + coarse_selected_center_over_duration=native['center'])) + print('uniform', regime.name, 'complete', flush=True) + return rows + + +def best_observed_box(phase, signal, errors): + """Exhaust all nonempty contiguous intervals with signal at both ends. + + Including an outer zero-signal observation only adds variance; trimming + it improves SNR. Thus these intervals contain a global optimum at the + known period, with arbitrary duration and epoch. No duration-prior or + trial-grid handicap is applied to this shape control. + """ + order = np.argsort(phase) + phase, signal, errors = phase[order], signal[order], errors[order] + weights = 1/(errors*errors+1e-10) + numerator = np.r_[0., np.cumsum(weights*signal)] + variance = np.r_[0., np.cumsum(weights*weights*errors*errors)] + positive = np.flatnonzero(signal > 0) + best = 0. + for j, start in enumerate(positive): + ends = positive[j:]+1 + values = (numerator[ends]-numerator[start])/np.sqrt(variance[ends]-variance[start]) + best = max(best, float(values.max())) + return best + + +def observed_cases(template, grids, cadence_dir, epochs, exposure_nodes): + rng = np.random.default_rng(421990) + regimes = [REGIMES[2], REGIMES[4], + Regime('mdwarf01_earth_10d', 10., radius=.1, mass=.1, rp=.00916/.1)] + rows = [] + for profile in ('tess_200s', 'tess_gap', 'ztf'): + with np.load(cadence_dir/f'{profile}.npz') as data: + times = np.array(data['t'], dtype=np.float64) + errors = 1e-3*np.array(data['relative_error'], dtype=np.float64) + exposures = np.array(data['exposure_days'], dtype=np.float64) + origin = np.floor(times.min()) + for regime in regimes: + duration, full_duration, a = durations(regime) + qtrue = duration/regime.period + qmin, qmax = grids.duration_window(np.array([regime.period]), + R_star=regime.radius, M_star=regime.mass) + qmin, qmax = float(qmin[0]), min(float(qmax[0]), .333) + for epoch_index, epoch_phase in enumerate(rng.random(epochs)): + epoch = origin+epoch_phase*regime.period + signal = physical_signal(regime, times, exposures, epoch=epoch, + exposure_nodes=exposure_nodes) + phase = ((times-epoch+.5*regime.period) % regime.period)-.5*regime.period + x = phase/duration + oracle = float(np.sqrt(np.dot(signal/errors, signal/errors))) + if oracle <= 0: + rows.append(dict(kind='observed', profile=profile, regime=regime.name, + epoch_index=epoch_index, epoch_phase=epoch_phase, + no_sampled_signal=True)) + continue + def objective(z): + return -expected_snr(signal, template.point(x, *z), errors) + # Discrete cadences can create local optima; use a broad, + # reproducible global fit followed by Powell refinement. + fit_global = differential_evolution(objective, [(-.5, .5), (.08, 2.5)], + seed=714, tol=1e-7, popsize=12) + fit = minimize(objective, fit_global.x, method='Powell', + bounds=[(-.5, .5), (.08, 2.5)], + options={'ftol': 1e-10, 'xtol': 1e-8}) + if fit.fun > fit_global.fun: + fit = fit_global + direct_snr = -float(fit.fun) + own_signal = template.point(x, 0., 1.) + own_snr = expected_snr(own_signal, own_signal, errors) + box_snr = best_observed_box(phase, signal, errors) + weights = 1/(errors*errors+1e-10) + for label, oversampling, nd, fixed_bins in CONFIGS: + auto, requested = automatic_bins(qmin, oversampling) + bins = auto if fixed_bins is None else fixed_bins + bindex = np.floor(((times-origin)/regime.period % 1)*bins).astype(int) + bphase = (((bindex+.5)/bins-epoch_phase+.5) % 1)-.5 + blo = (bphase-.5/bins)/qtrue + bhi = (bphase+.5/bins)/qtrue + averaged, squared = template.averages(blo, bhi, *fit.x) + own_averaged, _ = template.averages(blo, bhi, 0., 1.) + binned_snr = expected_snr(signal, averaged, errors) + own_binned_snr = expected_snr(own_signal, own_averaged, errors) + noise_variance = float(np.dot(weights*weights*errors*errors, averaged*averaged)) + native_den = float(np.dot(weights, squared)) + bin_signal = np.bincount(bindex, weights=weights*signal, minlength=bins) + bin_variance = np.bincount(bindex, weights=weights*weights*errors*errors, + minlength=bins) + valid_bins = bin_variance > 0 + compressed_oracle = np.sqrt(np.sum(bin_signal[valid_bins]**2/bin_variance[valid_bins])) + rows.append(dict(kind='observed', profile=profile, regime=regime.name, + config=label, epoch_index=epoch_index, epoch_phase=epoch_phase, + no_sampled_signal=False, n_observations=len(times), + n_signal_observations=int(np.sum(signal > 0)), + n_transit_events=int(len(np.unique(np.floor((times[signal>0]-epoch)/ + regime.period+.5)))), + **asdict(regime), a_over_rstar=a, + **flags(regime, duration, .5*(duration-full_duration), qmin, + bins, requested, oversampling), + template_fit_width_over_duration=float(fit.x[1]), + template_snr_over_oracle=direct_snr/oracle, + box_snr_over_oracle=box_snr/oracle, + physical_projection_retention=compressed_oracle/oracle, + physical_binned_over_unbinned=binned_snr/direct_snr, + own_template_binned_over_unbinned=own_binned_snr/own_snr if own_snr>0 else np.nan, + native_norm_over_noise=np.sqrt(native_den/noise_variance) if noise_variance>0 else np.nan)) + print('observed', profile, regime.name, 'complete', flush=True) + return rows + + +def write_csv(path, rows): + fields = list(dict.fromkeys(k for row in rows for k in row)) + with path.open('w', newline='') as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def summarize(rows): + keys = ('template_snr_over_oracle', 'box_snr_over_oracle', + 'physical_projection_retention', + 'physical_binned_over_unbinned', 'own_template_binned_over_unbinned', + 'own_template_uncapped_retention', 'native_norm_over_noise', + 'epoch_grid_only_retention', 'duration_grid_only_retention', + 'coarse_grid_native_selected_over_oracle', + 'coarse_grid_native_selected_over_best_template', + 'coarse_grid_native_amplitude_over_oracle') + result = [] + groups = sorted(set((r['kind'], r.get('profile', ''), r['regime'], r.get('config', '')) + for r in rows if r.get('config'))) + for kind, profile, regime, config in groups: + subset = [r for r in rows if (r['kind'], r.get('profile', ''), r['regime'], + r.get('config')) == (kind, profile, regime, config)] + row = dict(kind=kind, profile=profile, regime=regime, config=config, cases=len(subset)) + for flag in ('q', 'duration_hours', 'ingress_minutes', 'bins', + 'bins_across_transit', 'bins_across_ingress', 'bin_cap', + 'epoch_cap', 'duration_below_prior', 'depth_gate_caveat'): + row[flag] = subset[0][flag] + for key in keys: + values = np.array([r[key] for r in subset if key in r], dtype=float) + values = values[np.isfinite(values)] + if len(values): + for suffix, value in zip(('minimum', 'median', 'maximum'), + np.quantile(values, [0., .5, 1.])): + row[f'{key}_{suffix}'] = float(value) + result.append(row) + return result + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--source-root', type=Path, default=Path(__file__).resolve().parents[2]) + ap.add_argument('--source-revision', default='HEAD', + help='Git revision supplying the model, grids, and kernel provenance (default: HEAD).') + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--cadences', type=Path) + ap.add_argument('--offsets', type=int, default=32) + ap.add_argument('--samples-per-transit', type=int, default=4096) + ap.add_argument('--exposure-nodes', type=int, default=64) + ap.add_argument('--observed-epochs', type=int, default=8) + args = ap.parse_args() + if (args.offsets < 2 or args.samples_per_transit < 128 or + args.observed_epochs < 1 or args.exposure_nodes < 8): + ap.error('Use at least two offsets, 128 samples per transit, one observed epoch, and eight exposure nodes.') + args.out.mkdir(parents=True, exist_ok=True) + revision = subprocess.check_output(['git', '-C', str(args.source_root), 'rev-parse', + args.source_revision], text=True).strip() + snapshot_dir = args.out/'source_snapshots' + snapshot_dir.mkdir(exist_ok=True) + # Freeze sources before a potentially long run; concurrent local edits + # cannot alter the loaded mathematics or the recorded source hashes. + snapshot_paths = [] + for relative in ('cuvarbase/tls_models.py', 'cuvarbase/tls_grids.py', + 'cuvarbase/kernels/tls_fast.cu'): + snapshot = snapshot_dir/Path(relative).name + snapshot.write_bytes(subprocess.check_output( + ['git', '-C', str(args.source_root), 'show', f'{revision}:{relative}'])) + snapshot_paths.append(snapshot) + template_source, grids_source = snapshot_paths[:2] + diagnostic_sha256 = sha256(Path(__file__).resolve()) + template = Template(template_source) + grids = source_module(grids_source, 'diagnostic_tls_grids') + rows = uniform_cases(template, grids, args.offsets, args.samples_per_transit, + args.exposure_nodes) + if args.cadences: + rows.extend(observed_cases(template, grids, args.cadences, args.observed_epochs, + args.exposure_nodes)) + write_csv(args.out/'cases.csv', rows) + summary = summarize(rows) + write_csv(args.out/'summary.csv', summary) + sources = snapshot_paths.copy() + if args.cadences: + sources += [args.cadences/f'{p}.npz' for p in ('tess_200s', 'tess_gap', 'ztf')] + manifest = dict( + scope='Expected white-noise SNR at the true period; not detection completeness, native SDE, or GTLS.', + source_commit=revision, + diagnostic_sha256=diagnostic_sha256, + versions=dict(python=sys.version, numpy=np.__version__, scipy=scipy.__version__, batman=batman.__version__), + arguments={k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()}, + limb_darkening=LD, configs=CONFIGS, regimes=[asdict(r) for r in REGIMES], + source_sha256={str(path): sha256(path) for path in sources}, + output_sha256={name: sha256(args.out/name) for name in ('cases.csv', 'summary.csv')}, + rows=len(rows), no_sampled_signal_rows=sum(bool(r.get('no_sampled_signal')) for r in rows)) + (args.out/'manifest.json').write_text(json.dumps(manifest, indent=2)+'\n') + print('Wrote', len(rows), 'rows to', args.out, flush=True) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_accuracy/high_impact.py b/benchmarks/tls_accuracy/high_impact.py new file mode 100644 index 00000000..59fc5103 --- /dev/null +++ b/benchmarks/tls_accuracy/high_impact.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +"""Frozen, focused high-impact recovery pilot; synthetic flux on TESS cadence. + +Stages: freeze, generate, run (one method/split), calibrate, analyze. +Keep generated arrays outside the release repository. This pilot measures +recovery and false positives; it does not establish tight equivalence, isolate +binning alone, or provide a new headline timing benchmark. +""" +import argparse +import datetime +import hashlib +import importlib +import importlib.metadata +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import time +import traceback +import warnings + +import numpy as np + + +COUNTS = dict(calibration=256, injections=256, nulls=256) +METHODS = ('v1_defaults', 'v1_wide', 'v1_fine', 'gtls') +BASELINE = '11317fb0ff1b68af05ae3f67de5f298c9a90e46b' +GTLS_COMMIT = '74e449c325792a763dde4fbffab98039c5e8c111' +SEED = 2026090943 + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, + allow_nan=False).encode()).hexdigest() + + +def array_hash(value): + value = np.ascontiguousarray(value) + h = hashlib.sha256(value.dtype.str.encode()) + h.update(json.dumps(value.shape).encode()) + h.update(value.tobytes()) + return h.hexdigest() + + +def write(path, value, frozen=False): + path = Path(path) + text = json.dumps(value, sort_keys=True, indent=2, allow_nan=False) + '\n' + if frozen and path.exists(): + if path.read_text() != text: + raise ValueError('Refusing to replace frozen file: ' + str(path)) + return + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + '.tmp') + temporary.write_text(text) + temporary.replace(path) + + +def read(path): + return json.loads(Path(path).read_text()) + + +def utc(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def finite(value): + value = float(value) + return value if np.isfinite(value) else None + + +def configs(): + return dict( + v1_defaults=dict(backend='cuvarbase', t0_oversample=3., n_durations=15, + qmin_fac=.5, qmax_fac=2., nbins=None), + v1_wide=dict(backend='cuvarbase', t0_oversample=3., n_durations=25, + qmin_fac=.5 * 4.**(-10./14.), qmax_fac=2., nbins=None), + v1_fine=dict(backend='cuvarbase', t0_oversample=16., n_durations=32, + qmin_fac=.5, qmax_fac=2., nbins=8192), + gtls=dict(backend='gputls', fast=True, workers=1, T0_fit_margin=.125, + duration_grid_step=1.1, R_star_min=.5, R_star_max=2., + M_star_min=1., M_star_max=1.)) + + +def freeze(args): + root = args.root.resolve() + if (root / 'design.json').exists(): + raise ValueError('Design already exists; use the existing frozen design') + repo = Path(__file__).resolve().parents[2] + paths = subprocess.check_output( + ['git', 'ls-tree', '-r', '--name-only', BASELINE, 'cuvarbase'], + cwd=repo, text=True).splitlines() + sources = {} + for name in paths: + p = Path(name) + if p.suffix in ('.py', '.cu', '.cuh') and 'tests' not in p.parts: + data = subprocess.check_output(['git', 'show', BASELINE + ':' + name], cwd=repo) + sources[str(p.relative_to('cuvarbase'))] = hashlib.sha256(data).hexdigest() + archive = repo / 'benchmarks/results/tls_profile_2026-09-08/sources/gtls-head.tar' + gtls_sources = {} + with tarfile.open(archive) as source: + for member in source.getmembers(): + p = Path(member.name) + if member.isfile() and member.name.startswith('src/gputls/') and p.suffix in ('.py', '.cu', '.cuh'): + gtls_sources[str(p.relative_to('src/gputls'))] = hashlib.sha256(source.extractfile(member).read()).hexdigest() + with np.load(args.cadence, allow_pickle=False) as data: + grid = data['tls_periods'] + if len(grid) != 3084 or len(data['t']) != 9736: + raise ValueError('Pilot requires the frozen dense TESS 200-second cadence') + cadence = dict(file_sha256=sha(args.cadence), ndata=len(data['t']), + nperiods=len(grid), minimum_period=float(grid.min()), + maximum_period=float(grid.max()), + arrays={k: array_hash(data[k]) for k in data.files}) + design = dict( + frozen_at_utc=utc(), schema=1, seed=SEED, counts=COUNTS, + runner_sha256=sha(__file__), cadence=cadence, configs=configs(), + population=dict(R_star=1., M_star=1., rp=.0092, impact_uniform=[.94, .96], + period_loguniform_days=[2., 6.], epoch_uniform_one_period=True, + eccentricity=0., u=[.4804, .1867], + oracle_white_snr=[8., 10.], cases_per_snr=128), + generation=dict(exposure_subsamples=7, random_drop_uniform=[0., .03], + minimum_in_transit_points=5, minimum_events=2, + relative_errors='Frozen cadence relative_error array', + noise='Independent heteroskedastic Gaussian plus OU', + ou_amplitude_median_sigma=.25, ou_tau_days=.15, + scale='Weighted-centered injected signal has oracle white SNR8 or10; nulls use the same latent-signal noise-scale recipe', + flux_baseline=1.), + search=dict(periods='Full frozen3084period grid, not injection-limited', + v1_R_planet_earth_radii=1., v1_refine_top_k=50, + v1_refine_oversample=33., v1_batch_size=16, + cuvarbase_commit=BASELINE, gtls_commit=GTLS_COMMIT, + gtls_source_archive_sha256=sha(archive)), + statistics=dict(calibration='Strictly above higher95th percentile of256calibration scores, separately for each method', + heldout_gate='Threshold file must exist before either held-out run starts', + recovery='Primary period drift abs(P_found/P_true-1)*observed_baseline <=0.5*true_duration', + failures='Retained; invalid injection is a miss and invalid null score is minus infinity', + intervals='Marginal95%Wilson for rates; conservative paired discordant-cell Clopper-Pearson difference intervals', + inference='Focused descriptive pilot; no tight equivalence claim or post-result tuning'), + interpretation=['Widened v1 changes its duration window and automatically increases bin resolution; it is not a pure prior ablation.', + 'Fine v1 retains the default duration window and changes bins, epoch sampling and duration sampling.', + 'GTLS differs in template, sample-index duration/epoch search, depth estimation and native score.', + 'Timing is operational accounting, not a new headline speed benchmark.'], + expected_sources=dict(cuvarbase=sources, gputls=gtls_sources)) + for method, config in design['configs'].items(): + write(root / 'configs' / (method + '.json'), config, frozen=True) + write(root / 'design.json', design, frozen=True) + print(json.dumps(dict(design=str(root / 'design.json'), sha256=sha(root / 'design.json')))) + + +def load_design(root): + design = read(root / 'design.json') + if design['runner_sha256'] != sha(__file__): + raise ValueError('Runner bytes differ from the frozen protocol') + if design['counts'] != COUNTS or design['configs'] != configs(): + raise ValueError('Protocol configuration mismatch') + for method in METHODS: + if read(root / 'configs' / (method + '.json')) != design['configs'][method]: + raise ValueError('Frozen method config mismatch: ' + method) + return design + + +def make_case(cadence, split, index): + import batman + rng = np.random.default_rng(np.random.SeedSequence([SEED, list(COUNTS).index(split), index])) + raw = [cadence[k] for k in ('t', 'relative_error', 'band', 'exposure_days')] + keep = rng.random(len(raw[0])) > rng.uniform(0., .03) + t, relative, band, exposure = [v[keep] for v in raw] + target = (8., 10.)[index % 2] + for attempt in range(1, 1001): + period = float(np.exp(rng.uniform(np.log(2.), np.log(6.)))) + epoch = float(rng.uniform(0., period)) + impact = float(rng.uniform(.94, .96)) + a = (6.6743e-11 * 1.9884e30 * (period * 86400.)**2 / (4 * np.pi**2))**(1./3.) / 6.957e8 + pm = batman.TransitParams() + pm.t0, pm.per, pm.rp, pm.a = epoch, period, .0092, a + pm.inc, pm.ecc, pm.w = float(np.degrees(np.arccos(impact / a))), 0., 90. + pm.u, pm.limb_dark = [.4804, .1867], 'quadratic' + model = np.ones(len(t)) + for exp in np.unique(exposure): + take = exposure == exp + model[take] = batman.TransitModel(pm, t[take], supersample_factor=7, + exp_time=float(exp)).light_curve(pm) + inside = model < 1 - 1e-9 + events = np.unique(np.rint((t[inside] - epoch) / period).astype(int)) + if inside.sum() >= 5 and len(events) >= 2: + break + else: + raise RuntimeError('Observable injection sampling exhausted') + w = relative**-2 + signal = model - np.dot(w, model) / w.sum() + white_scale = np.sqrt(np.dot(w, signal * signal)) / target + dy = white_scale * relative + z = rng.normal(size=len(t)) + red = np.empty(len(t)) + red[0] = z[0] + decay = np.exp(-np.diff(t) / .15) + innovation = np.sqrt(1 - decay * decay) + for j in range(1, len(t)): + red[j] = decay[j-1] * red[j-1] + innovation[j-1] * z[j] + injected = split == 'injections' + y = (model if injected else np.ones(len(t))) + rng.normal(size=len(t)) * dy + .25 * np.median(dy) * red + duration = period / np.pi * np.arcsin(np.sqrt((1 + .0092)**2 - impact**2) / np.sqrt(a*a - impact**2)) + arrays = dict(t=t, y=y, dy=dy, band=band) + truth = dict(index=index, split=split, injected=injected, seed=[SEED, list(COUNTS).index(split), index], + period=period, epoch=epoch, rp=.0092, impact=impact, duration=float(duration), + baseline=float(np.ptp(t)), ndata=len(t), n_in_transit=int(inside.sum()), + observed_events=len(events), ephemeris_draws=attempt, + target_white_oracle_snr=target, + latent_white_oracle_snr=float(np.sqrt(np.sum((signal / dy)**2))), + median_sigma=float(np.median(dy)), + array_sha256={k: array_hash(v) for k, v in arrays.items()}) + return arrays, truth + + +def generate(args): + root = args.root.resolve() + design = load_design(root) + if sha(args.cadence) != design['cadence']['file_sha256']: + raise ValueError('Cadence differs from the frozen input') + manifest_path = root / 'inputs' / 'manifest.json' + if manifest_path.exists(): + raise ValueError('Input manifest already exists; use the frozen arrays') + manifest = dict(design_sha256=sha(root / 'design.json'), runner_sha256=sha(__file__), + generated_at_utc=utc(), splits={}) + with np.load(args.cadence, allow_pickle=False) as cadence: + for split, count in COUNTS.items(): + path = root / 'inputs' / (split + '.npz') + if path.exists(): + raise ValueError('Refusing to overwrite input arrays: ' + str(path)) + arrays = dict(tls_periods=cadence['tls_periods']) + truths = [] + for index in range(count): + data, truth = make_case(cadence, split, index) + truths.append(truth) + arrays.update({f'{k}_{index}': value for k, value in data.items()}) + metadata = dict(split=split, count=count, cases=truths, + design_sha256=sha(root / 'design.json'), + period_array_sha256=array_hash(cadence['tls_periods'])) + arrays['metadata'] = np.array(json.dumps(metadata, sort_keys=True, allow_nan=False)) + path.parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed(path, **arrays) + manifest['splits'][split] = dict(file_sha256=sha(path), metadata=metadata) + print(json.dumps(dict(generated=split, count=count)), flush=True) + write(manifest_path, manifest, frozen=True) + + +def verify_sources(package, expected): + path = Path(package.__file__).resolve().parent + actual = {} + for name, wanted in expected.items(): + source = path / name + if not source.is_file() or sha(source) != wanted: + raise ValueError('Installed numerical source differs from pin: ' + str(source)) + actual[name] = wanted + return dict(path=str(path), files=actual, source_map_sha256=digest(actual)) + + +def gtls_cache(model, truth): + overview = model.lc_cache_overview + widths = np.unique(overview['width_in_samples']).astype(int) + period = truth['period'] + seconds = period * 86400. + # Literal constants in the pinned GPUFun.getGPUCode; these differ from + # both Python module constants and the accepted stellar-bound arguments. + qlo = min(.15, 695508000 * .05 * (4 * seconds / (20848 * 1e15))**(1./3.) / seconds) + qhi = min(.15, (695508000 * 4 + 2 * 69911000) * (4 * seconds / (416970 * 1e15))**(1./3.) / seconds) + size = len(model.t) + correction = 1 + period / float(np.ptp(model.t)) + lo, hi = int(np.floor(qlo * size)), int(np.ceil(qhi * size * correction)) + eligible = widths[(widths >= lo) & (widths <= hi)] + wanted = truth['duration'] / period * size + nearest = int(eligible[np.argmin(np.abs(eligible - wanted))]) if len(eligible) else None + return dict(cache_sha256=array_hash(overview), width_in_samples=widths.tolist(), + fractional_duration=[float(v) for v in overview['duration']], + template={k: getattr(model, k) for k in ('per', 'rp', 'a', 'inc', 'ecc', 'w', 'u', 'limb_dark')}, + nominal_gpu_width_bounds_at_truth_period=[lo, hi], + nominal_gpu_eligible_width_count=len(eligible), + true_geometric_width_in_samples=float(wanted), nearest_eligible_width=nearest, + nearest_relative_width_error=abs(nearest / wanted - 1) if nearest is not None else None, + caveat='Nominal per-period CUDA envelope; publicGTLS may evaluate the union of duration choices across a memory-dependent period chunk.') + + +def run(args): + root = args.root.resolve() + design = load_design(root) + threshold_sha = None + if args.split != 'calibration': + thresholds = read(root / 'thresholds.json') # Before reading held-out input arrays. + if thresholds['design_sha256'] != sha(root / 'design.json'): + raise ValueError('Threshold protocol mismatch') + threshold_sha = sha(root / 'thresholds.json') + manifest = read(root / 'inputs/manifest.json') + input_path = root / 'inputs' / (args.split + '.npz') + if manifest['design_sha256'] != sha(root / 'design.json') or sha(input_path) != manifest['splits'][args.split]['file_sha256']: + raise ValueError('Input provenance mismatch') + config = design['configs'][args.method] + data = np.load(input_path, allow_pickle=False) + metadata = json.loads(str(data['metadata'])) + if metadata != manifest['splits'][args.split]['metadata']: + raise ValueError('Input metadata mismatch') + periods = data['tls_periods'] + if array_hash(periods) != design['cadence']['arrays']['tls_periods']: + raise ValueError('Period grid changed') + expected_package = config['backend'] + if expected_package == 'cuvarbase': + if args.source_root is None: + raise ValueError('--source-root must identify the baseline11317fb checkout') + sys.path.insert(0, str(args.source_root.resolve())) + package = importlib.import_module(expected_package) + sources = verify_sources(package, design['expected_sources'][expected_package]) + if expected_package == 'cuvarbase': + from cuvarbase import tls + tls_search_batch = getattr(tls, '_tls_search_batch_binned', tls.tls_search_batch) + from cuvarbase.base import ensure_context + from cuvarbase import tls_models + import pycuda.driver as driver + if not tls_models.BATMAN_AVAILABLE: + raise RuntimeError('A batman template is required; fallback is not permitted') + ensure_context() + synchronize = driver.Context.synchronize + batch_size = 16 + else: + import cupy as cp + from gputls import gtls + from gputls import constants as gtls_constants + synchronize = cp.cuda.runtime.deviceSynchronize + batch_size = 1 + output = root / 'results' / args.split / (args.method + '.json') + identity = dict(method=args.method, split=args.split, count=COUNTS[args.split], + design_sha256=sha(root / 'design.json'), runner_sha256=sha(__file__), + config=config, config_sha256=sha(root / 'configs' / (args.method + '.json')), + input_sha256=sha(input_path), thresholds_sha256=threshold_sha, + installed_sources=sources) + if output.exists(): + record = read(output) + if any(record[k] != value for k, value in identity.items()): + raise ValueError('Existing checkpoint has different provenance') + if record['status'] == 'complete': + print(json.dumps(dict(already_complete=str(output)))) + return + else: + record = dict(**identity, status='running', cases=[], segments=[], packages={}) + record['cpu_quota'] = {name: Path(name).read_text().strip() for name in + ('/sys/fs/cgroup/cpu.max', '/sys/fs/cgroup/cpu/cpu.cfs_quota_us', + '/sys/fs/cgroup/cpu/cpu.cfs_period_us') if Path(name).exists()} + record['threads'] = {name: os.getenv(name) for name in + ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA_NUM_THREADS')} + try: + record['gpu'] = subprocess.check_output( + ['nvidia-smi', '--query-gpu=name,uuid,memory.total,memory.free,driver_version', + '--format=csv,noheader'], text=True).strip() + except (OSError, subprocess.CalledProcessError): + record['gpu'] = None + for name in ('numpy', 'scipy', 'batman-package', 'pycuda', 'cupy-cuda12x', 'gputls', 'cuvarbase'): + try: + record['packages'][name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + pass + if expected_package == 'gputls': + record['gtls_host_duration_constants'] = {k: getattr(gtls_constants, k) for k in ('R_STAR_MIN', 'R_STAR_MAX', 'M_STAR_MIN', 'M_STAR_MAX', 'FRACTIONAL_TRANSIT_DURATION_MAX')} + record['segments'].append(dict(start_index=len(record['cases']), started_at_utc=utc())) + write(output, record) + for start in range(len(record['cases']), metadata['count'], batch_size): + stop = min(start + batch_size, metadata['count']) + truths = metadata['cases'][start:stop] + lightcurves = [] + for truth in truths: + i = truth['index'] + for key, wanted in truth['array_sha256'].items(): + if array_hash(data[f'{key}_{i}']) != wanted: + raise ValueError('Prepared input array changed') + lightcurves.append(tuple(data[f'{key}_{i}'] for key in ('t', 'y', 'dy'))) + synchronize() + begin = time.perf_counter() + caught = [] + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + if expected_package == 'cuvarbase': + kwargs = {k: config[k] for k in ('t0_oversample', 'n_durations', 'qmin_fac', 'qmax_fac', 'nbins')} + results = tls_search_batch(lightcurves, periods=periods, + R_star=1., M_star=1., R_planet=1., u=[.4804, .1867], + refine_top_k=50, refine_oversample=33., return_arrays=True, **kwargs) + if len(results) != len(lightcurves): + raise RuntimeError('Wrong number of public-API results') + else: + model = gtls(*lightcurves[0], verbose=False) + kwargs = {k: value for k, value in config.items() if k not in ('backend', 'workers')} + raw_periods, raw_power = model.power(periods=periods, R_star=1., M_star=1., + oversampling_factor=3, verbose=False, show_progress_bar=False, + transit_template='default', **kwargs) + p = np.asarray(np.ma.filled(raw_periods, np.nan), dtype=float) + s = np.asarray(np.ma.filled(raw_power, np.nan), dtype=float) + good = np.isfinite(p) & np.isfinite(s) + j = int(np.nanargmax(np.where(good, s, np.nan))) if good.any() else None + results = [dict(period=finite(p[j]) if j is not None else None, + SDE=finite(s[j]) if j is not None else None, + periods=p, chi2=s)] + synchronize() + elapsed = time.perf_counter() - begin + messages = sorted(set(str(w.message) for w in caught)) + if any('falling back to a trapezoid' in message for message in messages): + raise RuntimeError('Template fallback occurred: ' + '; '.join(messages)) + outputs = [] + for result, truth in zip(results, truths): + p = np.asarray(result['periods']) + power = np.asarray(result['chi2']) + found = finite(result['period']) if result['period'] is not None else None + score = finite(result['SDE']) if result['SDE'] is not None else None + valid = found is not None and score is not None and bool(np.isfinite(power).any()) and not result.get('error') + drift = abs(found / truth['period'] - 1) * truth['baseline'] if valid else None + row = dict(index=truth['index'], valid=bool(valid), period_found=found, score=score, + recovered=bool(valid and drift <= .5 * truth['duration']) if truth['injected'] else None, + drift_days=drift, finite_periods=int(np.isfinite(power).sum()), + spectrum_sha256=dict(periods=array_hash(p), power=array_hash(power)), + api_s=elapsed / len(truths), batch_size=len(truths), warnings=messages, + error=result.get('error')) + if expected_package == 'gputls': + row['actual_gtls_cache'] = gtls_cache(model, truth) + outputs.append(row) + except Exception: + elapsed = time.perf_counter() - begin + error = traceback.format_exc() + outputs = [dict(index=truth['index'], valid=False, period_found=None, score=None, + recovered=False if truth['injected'] else None, error=error, + api_s=elapsed / len(truths), batch_size=len(truths), + warnings=sorted(set(str(w.message) for w in caught))) for truth in truths] + record['cases'].extend(outputs) + write(output, record) + print(json.dumps(dict(method=args.method, split=args.split, completed=stop, + count=metadata['count'], batch_api_s=elapsed)), flush=True) + record.update(status='complete', finished_at_utc=utc()) + write(output, record) + + +def scores(record): + return np.array([row['score'] if row['valid'] and row['score'] is not None else -np.inf for row in record['cases']]) + + +def verified_record(root, method, split, design, manifest): + path = root / 'results' / split / (method + '.json') + record = read(path) + expected = dict(method=method, split=split, count=COUNTS[split], status='complete', + design_sha256=sha(root / 'design.json'), runner_sha256=sha(__file__), + config=design['configs'][method], + config_sha256=sha(root / 'configs' / (method + '.json')), + input_sha256=manifest['splits'][split]['file_sha256']) + if any(record.get(k) != v for k, v in expected.items()): + raise ValueError('Incomplete or inconsistent result: ' + str(path)) + if [row['index'] for row in record['cases']] != list(range(COUNTS[split])): + raise ValueError('Missing or duplicated cases: ' + str(path)) + package = design['configs'][method]['backend'] + if record['installed_sources']['files'] != design['expected_sources'][package]: + raise ValueError('Wrong numerical source: ' + str(path)) + return record + + +def calibrate(args): + root = args.root.resolve() + design = load_design(root) + manifest = read(root / 'inputs/manifest.json') + if (root / 'thresholds.json').exists(): + raise ValueError('Calibration already frozen; use existing thresholds') + # Enforce the planned chronology even if somebody bypassed the run gate. + for split in ('injections', 'nulls'): + if (root / 'results' / split).exists() and any((root / 'results' / split).iterdir()): + raise ValueError('Held-out execution already exists before calibration freeze') + frozen = dict(frozen_at_utc=utc(), design_sha256=sha(root / 'design.json'), + runner_sha256=sha(__file__), input_manifest_sha256=sha(root / 'inputs/manifest.json'), + threshold_rule='strict score>higher95thpercentile', methods={}) + for method in METHODS: + record = verified_record(root, method, 'calibration', design, manifest) + values = scores(record) + threshold = float(np.sort(values)[int(np.ceil(.95 * (len(values) - 1)))]) + if not np.isfinite(threshold): + raise ValueError('No finite calibration threshold for ' + method) + frozen['methods'][method] = dict(threshold=threshold, n=len(values), + failed=sum(not row['valid'] for row in record['cases']), + calibration_result_sha256=sha(root / 'results/calibration' / (method + '.json'))) + write(root / 'thresholds.json', frozen, frozen=True) + print(json.dumps(dict(thresholds_sha256=sha(root / 'thresholds.json'), methods=list(METHODS)))) + + +def wilson(values): + n = len(values) + k = int(np.sum(values)) + z = 1.959963984540054 + p = k / n + den = 1 + z*z/n + center = (p + z*z/(2*n)) / den + half = z * np.sqrt(p*(1-p)/n + z*z/(4*n*n)) / den + return dict(n=n, count=k, rate=p, wilson95=[max(0., center-half), min(1., center+half)]) + + +def paired(left, right): + from scipy.stats import beta + left, right = np.asarray(left, bool), np.asarray(right, bool) + n = len(left) + wins, losses = int(np.sum(left & ~right)), int(np.sum(~left & right)) + # Four one-sided Clopper-Pearson cell bounds; Bonferroni gives at least + #95% coverage for this complete two-sided paired-difference interval. + tail = .05 / 4 + def low(k): + return float(beta.ppf(tail, k, n-k+1)) if k else 0. + def high(k): + return float(beta.ppf(1-tail, k+1, n-k)) if k < n else 1. + return dict(n=n, left_only=wins, right_only=losses, + both=int(np.sum(left & right)), neither=int(np.sum(~left & ~right)), + difference=float(left.mean()-right.mean()), + conservative_paired95=[low(wins)-high(losses), high(wins)-low(losses)]) + + +def analyze(args): + root = args.root.resolve() + design = load_design(root) + manifest = read(root / 'inputs/manifest.json') + frozen = read(root / 'thresholds.json') + if frozen['design_sha256'] != sha(root / 'design.json') or frozen['input_manifest_sha256'] != sha(root / 'inputs/manifest.json'): + raise ValueError('Frozen calibration provenance mismatch') + report = dict(design_sha256=sha(root / 'design.json'), runner_sha256=sha(__file__), + input_manifest_sha256=sha(root / 'inputs/manifest.json'), + thresholds_sha256=sha(root / 'thresholds.json'), + scope='Focused high-impact recovery pilot; marginal/descriptive intervals, no tight equivalence claim.', + methods={}, comparisons={}, record_sha256={}) + vectors = {} + truth = manifest['splits']['injections']['metadata']['cases'] + levels = np.array([row['target_white_oracle_snr'] for row in truth]) + for method in METHODS: + cal = root / 'results/calibration' / (method + '.json') + if sha(cal) != frozen['methods'][method]['calibration_result_sha256']: + raise ValueError('Calibration changed after threshold freeze') + inputs = {split: verified_record(root, method, split, design, manifest) for split in COUNTS} + for split in ('injections', 'nulls'): + if inputs[split]['thresholds_sha256'] != sha(root / 'thresholds.json'): + raise ValueError('Held-out result was not run after this calibration freeze') + threshold = frozen['methods'][method]['threshold'] + recovered = np.array([row['valid'] and row['recovered'] for row in inputs['injections']['cases']], bool) + detection = recovered & (scores(inputs['injections']) > threshold) + false_positive = scores(inputs['nulls']) > threshold + vectors[method] = dict(detection=detection, false_positive=false_positive) + summary = dict(threshold=threshold, recovery=wilson(detection), false_positive=wilson(false_positive), + period_recovery=wilson(recovered), + per_snr={str(level): wilson(detection[levels == level]) for level in (8., 10.)}, + failed={split: sum(not row['valid'] for row in record['cases']) for split, record in inputs.items()}, + operational_api_seconds={split: float(sum(row['api_s'] for row in record['cases'])) for split, record in inputs.items()}) + if method == 'gtls': + audit = [row['actual_gtls_cache'] for row in inputs['injections']['cases'] if 'actual_gtls_cache' in row] + errors = [row['nearest_relative_width_error'] for row in audit if row['nearest_relative_width_error'] is not None] + summary['duration_cache_check'] = dict(cases_audited=len(audit), + no_nominally_eligible_cache_width=sum(row['nearest_eligible_width'] is None for row in audit), + nearest_relative_width_error_max=max(errors) if errors else None, + nearest_relative_width_error_median=float(np.median(errors)) if errors else None, + caveat='This checks nominal duration coverage, not equality of template or numerical search.') + report['methods'][method] = summary + for split in COUNTS: + report['record_sha256'][split + '/' + method] = sha(root / 'results' / split / (method + '.json')) + for left, right in [('gtls', 'v1_defaults'), ('v1_wide', 'v1_defaults'), + ('v1_fine', 'v1_defaults'), ('v1_wide', 'gtls'), ('v1_fine', 'gtls')]: + report['comparisons'][left + '_minus_' + right] = dict( + recovery=paired(vectors[left]['detection'], vectors[right]['detection']), + false_positive=paired(vectors[left]['false_positive'], vectors[right]['false_positive']), + per_snr={str(level): paired(vectors[left]['detection'][levels == level], vectors[right]['detection'][levels == level]) for level in (8., 10.)}) + write(root / 'analysis.json', report, frozen=True) + print(json.dumps(report, sort_keys=True, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + for name in ('freeze', 'generate', 'run', 'calibrate', 'analyze'): + p = sub.add_parser(name) + p.add_argument('--root', type=Path, required=True) + if name in ('freeze', 'generate'): + p.add_argument('--cadence', type=Path, required=True) + if name == 'run': + p.add_argument('--method', choices=METHODS, required=True) + p.add_argument('--split', choices=tuple(COUNTS), required=True) + p.add_argument('--source-root', type=Path) + p.set_defaults(function=globals()[name]) + args = parser.parse_args() + args.function(args) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_accuracy/kernel_benchmark.py b/benchmarks/tls_accuracy/kernel_benchmark.py new file mode 100644 index 00000000..6e4ed5b0 --- /dev/null +++ b/benchmarks/tls_accuracy/kernel_benchmark.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Compare TLS empty-bin traversal with the same search using dense traversal. + +This is an engineering performance/parity check, not a detection-sensitivity +study. Both variants use identical light curves, templates, trial grids and +statistics. Imports and compilation are excluded; each timed call includes +host preprocessing, transfers, the GPU search/refinement and host results. + +Use --baseline-kernel to compile an unmodified, compatible TLS CUDA source as +the reference. Without it, the reference is the current kernel compiled with +TLS_SKIP_EMPTY_BINS=0. Input archives are those published with the September 8 +transit benchmark. No cloud resources are created by this script. +""" +import argparse +import hashlib +from importlib import metadata +import json +from pathlib import Path +import platform +import sys +import time + +import numpy as np + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def array_sha(array): + """Hash dtype, shape and C-order bytes so array identity is explicit.""" + array = np.ascontiguousarray(array) + h = hashlib.sha256() + # Same convention as the archived TLS sensitivity study. + h.update(array.dtype.str.encode()) + h.update(json.dumps(array.shape).encode()) + h.update(array.tobytes()) + return h.hexdigest() + + +def dump(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, indent=2, allow_nan=False) + '\n') + temporary.replace(path) + + +def number(value): + value = float(value) + return value if np.isfinite(value) else None + + +def package_versions(): + versions = {} + for name in ('numpy', 'scipy', 'pycuda', 'batman-package'): + try: + versions[name] = metadata.version(name) + except metadata.PackageNotFoundError: + versions[name] = None + return versions + + +def compare(old, new, chi2_0, source_indices, rep): + """Compare delta-chi-squared, avoiding a loose tolerance on total chi2.""" + rows = [] + for i, (a, b) in enumerate(zip(old, new)): + old_score, new_score = chi2_0[i] - a['chi2'], chi2_0[i] - b['chi2'] + valid = np.isfinite(old_score) & np.isfinite(new_score) + diff = np.abs(old_score[valid] - new_score[valid]) + rel = diff / np.maximum(np.abs(old_score[valid]), 1.) + rows.append(dict( + rep=rep, source_index=source_indices[i], + same_valid=bool(np.array_equal(a['valid_periods'], b['valid_periods'])), + n_valid_compared=int(valid.sum()), + max_abs_score_diff=float(diff.max(initial=0.)), + max_rel_score_diff=float(rel.max(initial=0.)), + percentile99_rel_score_diff=float(np.percentile(rel, 99)) if len(rel) else 0., + same_period=bool(number(a['period']) == number(b['period'])), + old_period=number(a['period']), new_period=number(b['period']), + old_sde=number(a['SDE']), new_sde=number(b['SDE']), + delta_sde=number(b['SDE'] - a['SDE']), + old_snr=number(a['SNR']), new_snr=number(b['SNR']), + changed_coarse_t0=int(np.count_nonzero(a['best_t0_per_period'][valid] != b['best_t0_per_period'][valid])), + changed_coarse_duration=int(np.count_nonzero(a['best_duration_per_period'][valid] != b['best_duration_per_period'][valid])))) + return rows + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--inputs', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--baseline-kernel', type=Path, + help='unmodified baseline CUDA file with the same kernel ABI') + ap.add_argument('--profile', default='ztf', choices=('ztf', 'tess_gap', 'tess_200s')) + ap.add_argument('--nbins', type=int, default=8192, + help='fixed bins; 0 selects the public API automatic rule') + ap.add_argument('--period-limit', type=int, default=4096, + help='evenly selected trial periods; 0 uses the full grid') + ap.add_argument('--sources', type=int, default=4, + help='balanced injection/null subset, at most 256 sources') + ap.add_argument('--reps', type=int, default=5) + ap.add_argument('--t0-oversample', type=float, default=16.) + ap.add_argument('--n-durations', type=int, default=32) + ap.add_argument('--refine-top-k', type=int, default=50) + ap.add_argument('--refine-oversample', type=float, default=33.) + ap.add_argument('--block-size', type=int, default=None) + a = ap.parse_args() + if not 2 <= a.sources <= 256: + ap.error('--sources must be between 2 and 256') + if a.reps < 1 or a.period_limit < 0 or a.nbins < 0: + ap.error('reps must be positive; period-limit and nbins cannot be negative') + + from cuvarbase import tls + from pycuda import driver as cuda + + tls.ensure_context() + input_path = a.inputs / (a.profile + '_heldout.npz') + indices = list(range(a.sources // 2)) + list(range(128, 128 + (a.sources + 1) // 2)) + with np.load(input_path) as data: + lcs = [tuple(data[f'{k}_{i}'] for k in ('t', 'y', 'dy')) for i in indices] + periods = np.asarray(data['tls_periods'], dtype=np.float64) + truth = json.loads(str(data['metadata'])) + full_period_count = len(periods) + if a.period_limit and len(periods) > a.period_limit: + selected_periods = np.linspace(0, len(periods) - 1, a.period_limit).astype(int) + periods = periods[selected_periods] + else: + selected_periods = np.arange(len(periods)) + # Frozen benchmark worker's qtransit formula. The public helper uses + # slightly different stellar constants and is not substituted here. + q = np.arcsin(np.minimum(1., (1. / (periods * 8.6307)) ** (2. / 3.))) / np.pi + qmin, qmax = .5 * q, np.minimum(2. * q, .333) + kwargs = dict(periods=periods, qmin=qmin, qmax=qmax, + n_durations=a.n_durations, t0_oversample=a.t0_oversample, + nbins=a.nbins or None, block_size=a.block_size, + refine_top_k=a.refine_top_k, + refine_oversample=a.refine_oversample, + limb_dark='quadratic', u=[.4804, .1867], + return_arrays=True) + config = {k: v for k, v in kwargs.items() if k not in ('periods', 'qmin', 'qmax')} + config.update(profile=a.profile, source_indices=indices, + n_periods=len(periods), full_period_count=full_period_count, + period_selection='all' if len(periods) == full_period_count else 'evenly spaced indices', + duration_window='q = arcsin(min(1, (1/(P*8.6307))**(2/3)))/pi; qmin = .5*q; qmax = min(2*q, .333)', + n_repetitions=a.reps) + package = Path(tls.__file__).resolve().parent + device = cuda.Context.get_device() + output = dict( + status='running', config=config, + config_sha256=hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest(), + profile=a.profile, nbins=a.nbins or None, + n_periods=len(periods), source_indices=indices, + ndata=[len(lc[0]) for lc in lcs], + truth=[truth['cases'][i] for i in indices], + input_sha256=sha(input_path), + input_array_sha256=[{k: array_sha(v) for k, v in zip(('t', 'y', 'dy'), lc)} for lc in lcs], + grid_sha256={k: array_sha(v) for k, v in dict(periods=periods, qmin=qmin, qmax=qmax, + selected_indices=selected_periods).items()}, + runner_sha256=sha(__file__), + kernel_sha256=sha(tls.find_kernel('tls_fast')), + baseline_kernel_sha256=sha(a.baseline_kernel) if a.baseline_kernel else None, + reference='frozen CUDA source' if a.baseline_kernel else 'current CUDA source, dense traversal', + installed_sources={str(p.relative_to(package)): sha(p) for p in sorted(package.rglob('*')) + if p.is_file() and p.suffix in ('.py', '.cu', '.cuh') and 'tests' not in p.parts}, + hardware=dict(gpu=device.name(), compute_capability=device.compute_capability(), + gpu_memory_bytes=int(device.total_memory()), cuda_driver=cuda.get_driver_version(), + platform=platform.platform(), python=sys.version), + cpu_quota={str(p): p.read_text().strip() for p in ( + Path('/sys/fs/cgroup/cpu.max'), Path('/sys/fs/cgroup/cpu/cpu.cfs_quota_us'), + Path('/sys/fs/cgroup/cpu/cpu.cfs_period_us')) if p.exists()}, + packages=package_versions(), + boundary='Prepared host arrays and explicit grid through complete host results, including preprocessing, allocations, transfers, synchronization, search, refinement and statistics.', + exclusions='Imports, compilation, context initialization, grid creation, data generation and disk I/O.', + repetitions=[], comparisons=[], original_repeat_diff=[]) + dump(a.out, output) + + binned_search = getattr(tls, '_tls_search_batch_binned', tls.tls_search_batch) + reader = tls._module_reader + original_getter = tls._get_cached_fast_kernels + cache = {} + mode = 0 + + def get_kernels(block_size, nbins, t0_oversample, refine_nd=3): + key = (mode, block_size, nbins, float(t0_oversample), refine_nd) + if key not in cache: + def variant_reader(*args, **kw): + if mode == 0 and a.baseline_kernel: + return reader(str(a.baseline_kernel), *args[1:], **kw) + return '#define TLS_SKIP_EMPTY_BINS %d\n' % mode + reader(*args, **kw) + tls._module_reader = variant_reader + try: + cache[key] = tls.compile_tls_fast(block_size, nbins, t0_oversample, refine_nd) + finally: + tls._module_reader = reader + return cache[key] + + tls._get_cached_fast_kernels = get_kernels + elapsed = {0: [], 1: []} + chi2_0 = tls._preprocess_batch(lcs)[6] + reference_first = None + try: + for mode in (0, 1): + binned_search(lcs, **kwargs) + cuda.Context.synchronize() + for rep in range(a.reps): + pair = {} + order = (0, 1) if rep % 2 == 0 else (1, 0) + for mode in order: + cuda.Context.synchronize() + start = time.perf_counter() + pair[mode] = binned_search(lcs, **kwargs) + cuda.Context.synchronize() + elapsed[mode].append(time.perf_counter() - start) + if reference_first is None: + reference_first = pair[0] + elif rep == a.reps - 1: + output['original_repeat_diff'] = compare(reference_first, pair[0], chi2_0, indices, rep) + output['comparisons'].extend(compare(pair[0], pair[1], chi2_0, indices, rep)) + output['repetitions'].append(dict(rep=rep, order=list(order), + elapsed_s={str(k): v[-1] for k, v in elapsed.items()})) + dump(a.out, output) + output.update( + status='ok', elapsed_s={str(k): v for k, v in elapsed.items()}, + median_seconds_per_source={str(k): float(np.median(v) / len(lcs)) for k, v in elapsed.items()}, + speedup=float(np.median(elapsed[0]) / np.median(elapsed[1])), + compiled_registers={str(k): {n: int(f.num_regs) for n, f in v.items()} for k, v in cache.items()}) + dump(a.out, output) + except Exception: + import traceback + output.update(status='error', error=traceback.format_exc()) + dump(a.out, output) + raise + finally: + tls._module_reader = reader + tls._get_cached_fast_kernels = original_getter + print(json.dumps({k: output[k] for k in ('status', 'profile', 'nbins', 'n_periods', + 'ndata', 'median_seconds_per_source', 'speedup')}, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_accuracy/test_diagnose.py b/benchmarks/tls_accuracy/test_diagnose.py new file mode 100644 index 00000000..7af9a385 --- /dev/null +++ b/benchmarks/tls_accuracy/test_diagnose.py @@ -0,0 +1,148 @@ +"""Independent mathematical checks for the CPU SNR diagnostic.""" + +import importlib.util +from pathlib import Path +import sys + +import numpy as np +import pytest + +spec = importlib.util.spec_from_file_location('tls_accuracy_diagnose', Path(__file__).with_name('diagnose.py')) +d = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = d +spec.loader.exec_module(d) + + +def test_expected_snr_uses_actual_regularized_filter_variance(): + signal = np.array([.3, .8, .2]) + filt = np.array([.1, 1., .4]) + errors = np.array([.2, .05, .1]) + regularizer = .01 + coefficients = filt/(errors**2+regularizer) + expected = (signal@coefficients)/np.linalg.norm(errors*coefficients) + assert d.expected_snr(signal, filt, errors, regularizer) == pytest.approx(expected) + assert expected <= np.linalg.norm(signal/errors) + assert d.expected_snr(signal, signal, errors, 0.) == pytest.approx(np.linalg.norm(signal/errors)) + + +def test_projected_bin_signal_obeys_information_loss_identity(): + signal = np.array([.2, .5, 1., .1, .4, .9]) + errors = np.array([.3, .2, .1, .1, .4, .3]) + groups = np.array([0, 0, 0, 1, 1, 2]) + weights = 1/errors**2 + sums = np.bincount(groups, weights=weights*signal) + count = np.bincount(groups, weights=weights) + binned_filter = (sums/count)[groups] + coarse_squared = d.expected_snr(signal, binned_filter, errors, 0.)**2 + oracle_squared = np.dot(weights, signal*signal) + lost = np.dot(weights, (signal-binned_filter)**2) + assert coarse_squared+lost == pytest.approx(oracle_squared) + assert coarse_squared <= oracle_squared + + +def test_uniform_projection_has_analytic_triangle_loss(): + # A triangle over [-1, 1] has integral(s^2)=2/3. Each of two + # unit-width bins contains signal integral 1/2, giving SNR^2=1/2. + signal = d.UniformSignal(np.array([-1., 0., 1.]), np.array([0., 1., 0.])) + assert signal.oracle**2 == pytest.approx(2/3) + assert signal.projection_retention(1., 0.) == pytest.approx(np.sqrt(3/4)) + flat = d.UniformSignal(np.array([-1., 0., 1.]), np.ones(3)) + assert flat.projection_retention(1., 0.) == pytest.approx(1.) + + +@pytest.mark.parametrize('impact', [0., .8, .95]) +def test_contact_solver_matches_exact_circular_geometry(impact): + regime = d.Regime('geometry', 10., rp=.025, impact=impact) + duration, full, a = d.durations(regime) + expected = 10/np.pi*np.arcsin(np.sqrt((1+.025)**2-impact**2)/np.sqrt(a*a-impact*impact)) + expected_full = 10/np.pi*np.arcsin(np.sqrt((1-.025)**2-impact**2)/np.sqrt(a*a-impact*impact)) + assert duration == pytest.approx(expected, rel=1e-10) + assert full == pytest.approx(expected_full, rel=1e-10) + + +def test_optimal_box_recovers_analytic_trapezoid_width(): + # Unit full width; each ingress occupies fraction a. The best box + # excludes part of each ingress and has an analytic stationary point. + a = .1 + x = np.linspace(-2., 2., 40001) + signal = np.clip((.5-np.abs(x))/a, 0., 1.) + model = d.UniformSignal(x, signal) + fit, snr = d.best_uniform_box(model) + flat = 1-2*a + z = (-(2*flat-2*a)+np.sqrt((2*flat-2*a)**2+12*a*flat))/6 + width = flat+2*z + area = flat+2*z-z*z/a + assert fit[0] == pytest.approx(0., abs=2e-5) + assert fit[1] == pytest.approx(width, abs=3e-5) + assert snr == pytest.approx(area/np.sqrt(width), rel=1e-7) + full_width_snr = (1-a) + assert snr > full_width_snr + + +def test_observed_box_exhaustive_result_matches_all_intervals(): + rng = np.random.default_rng(15) + phase = rng.uniform(-1., 1., 21) + signal = np.maximum(0., 1-3*np.abs(phase)) + errors = rng.uniform(.05, .2, len(phase)) + optimum = d.best_observed_box(phase, signal, errors) + sorted_phase = np.sort(phase) + brute = max(d.expected_snr(signal, ((phase >= lo) & (phase <= hi)).astype(float), errors) + for lo in sorted_phase for hi in sorted_phase if hi >= lo) + assert optimum == pytest.approx(brute, rel=1e-13) + + +def test_automatic_bins_guarantee_minimum_until_cap(): + for q in np.geomspace(4/8192, .1, 100): + bins, requested = d.automatic_bins(q, 4.) + assert bins == requested + assert q*bins >= 4.-1e-12 + bins, requested = d.automatic_bins(1e-4, 4.) + assert bins == 8192 + assert requested == 65536 + assert 1e-4*bins < 1. + + +def test_coarse_normalization_distinguishes_mean_square_from_square_mean(): + # One bin with a half-on box template. Actual filter noise variance + # is mean(T)^2=1/4, while the native denominator is mean(T^2)=1/2. + value = d.metrics(.25, .25, .5) + assert value['snr'] == pytest.approx(.5) + assert np.sqrt(value['native_score']) == pytest.approx(.5/np.sqrt(2)) + assert value['native_norm_over_noise'] == pytest.approx(np.sqrt(2)) + + +def test_bin_integrals_match_independent_dense_point_filter(): + source = Path(__file__).resolve().parents[2]/'cuvarbase/tls_models.py' + template = d.Template(source) + x = np.linspace(-2., 2., 800001) + signal = template.point(x, 0., 1.) + model = d.UniformSignal(x, signal) + h, offset = .25, .31 + value = model.binned(template, 0., 1., h, offset) + index = np.floor(x/h+offset) + lo = (index-offset)*h + averaged, squared = template.averages(lo, lo+h, 0., 1.) + variance = np.trapz(averaged*averaged, x) + numerator = np.trapz(signal*averaged, x) + native_den = np.trapz(squared, x) + assert value['snr'] == pytest.approx(numerator/np.sqrt(variance), rel=2e-5) + assert value['native_score'] == pytest.approx(numerator*numerator/native_den, rel=2e-5) + + +def test_zero_signal_epoch_pruning_matches_complete_grid(): + source = Path(__file__).resolve().parents[2]/'cuvarbase/tls_models.py' + template = d.Template(source) + x = np.linspace(-2., 2., 10001) + model = d.UniformSignal(x, template.point(x, 0., 1.)) + qtrue, epoch, bins = .02, .413, 256 + h = 1/(qtrue*bins) + offset = epoch*bins % 1 + evaluate = lambda c, w: model.binned(template, c, w, h, offset) + native, best_snr = d.correct_period_grid(evaluate, .01, .04, qtrue, + epoch, 3., 3, 1+2*h) + complete = [] + for q in np.geomspace(.01, .04, 3): + n = d.epoch_trials(q, 3.) + complete.extend(evaluate((j/n-epoch)/qtrue, q/qtrue) for j in range(n)) + assert native['native_score'] == pytest.approx(max(r['native_score'] for r in complete)) + assert best_snr['snr'] == pytest.approx(max(r['snr'] for r in complete)) diff --git a/benchmarks/tls_profile/README.md b/benchmarks/tls_profile/README.md new file mode 100644 index 00000000..70a88a7f --- /dev/null +++ b/benchmarks/tls_profile/README.md @@ -0,0 +1,5 @@ +# TLS component diagnostics (2026-09-08) + +`profile_tls.py` measures synchronized GTLS and earlier **binned** cuvarbase API phases and compares two diagnostic GTLS host-loop changes. These profiles do not measure the new observation-level TLS default. `diagnose_cpu.py` records where the pinned reference CPU TLS API fails on retained inputs. Run either with `--help` for its arguments; searches require the corresponding backend environment and a CUDA device for GPU methods. + +The [component report](../results/tls_profile_2026-09-08/README.md) contains the measured timings, numerical differences and failure stages. These two diagnostic inputs are supplementary evidence; the [current transit benchmark](../../docs/TRANSIT_BENCHMARKS.md) provides the release comparison. diff --git a/benchmarks/tls_profile/diagnose_cpu.py b/benchmarks/tls_profile/diagnose_cpu.py new file mode 100644 index 00000000..bb2a71db --- /dev/null +++ b/benchmarks/tls_profile/diagnose_cpu.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Reproduce CPU TLS errors; retain explicitly selected traceback locals. + +The only numerical adapter is the existing shared period-grid factory. No +template guard or search repair is applied. Recovering already-computed arrays +from a failed call is labeled separately from a successful public API result. +""" +import argparse +import hashlib +import importlib +import json +from pathlib import Path +import time +import traceback + +import numpy as np + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--input', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--offset', type=int, default=4) + ap.add_argument('--threads', type=int, default=8) + args = ap.parse_args() + d = np.load(args.input) + meta = json.loads(str(d['metadata'])) + i = args.offset + order = np.argsort(d[f't_{i}']) + lc = (d[f't_{i}'][order], (d[f'y_{i}']/d[f'scale_{i}'])[order], + (d[f'dy_{i}']/d[f'scale_{i}'])[order]) + record = dict(status='running', args=vars(args), + input_sha256=hashlib.sha256(args.input.read_bytes()).hexdigest(), + runner_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + ndata=len(lc[0]), nperiods=len(d['periods']), truth=meta['cases'][i], + scope='CPU failure reproduction; first call including JIT; no failure repair') + + def save(): + args.out.write_text(json.dumps(record, indent=2, default=str) + '\n') + + save() + module = importlib.import_module('transitleastsquares.main') + record['main_source_sha256'] = hashlib.sha256(Path(module.__file__).read_bytes()).hexdigest() + module.period_grid = lambda **kwargs: d['periods'].copy() + from transitleastsquares import transitleastsquares + events = [] + started = time.perf_counter() + + def instrument(name): + original = getattr(module, name) + + def call(*a, **kw): + events.append(dict(function=name, event='enter', seconds=time.perf_counter()-started)) + try: + return original(*a, **kw) + finally: + events.append(dict(function=name, event='exit', seconds=time.perf_counter()-started)) + setattr(module, name, call) + + for name in ['get_cache', 'spectra', 'final_T0_fit', 'fractional_transit']: + instrument(name) + try: + result = transitleastsquares(*lc, verbose=False).power( + use_threads=args.threads, show_progress_bar=False, verbose=False, + R_star=1, M_star=1, R_star_min=.05, R_star_max=4, + M_star_min=.05, M_star_max=1, oversampling_factor=3, + T0_fit_margin=.125, duration_grid_step=1.1) + record.update(status='ok', period=float(result.period), SDE=float(result.SDE)) + except Exception as error: + record.update(status='error', error=traceback.format_exc()) + frames, arrays = [], {} + tb = error.__traceback__ + while tb: + frame = tb.tb_frame + if 'transitleastsquares' in frame.f_code.co_filename: + row = dict(file=frame.f_code.co_filename, function=frame.f_code.co_name, + line=tb.tb_lineno, scalars={}) + for name in ['samples', 'internal_samples', 'used_samples', 'maxwidth_in_samples', + 'duration', 'period', 'T0', 'SDE', 'depth', 'best_row']: + value = frame.f_locals.get(name) + if isinstance(value, (int, float, np.integer, np.floating)): + row['scalars'][name] = float(value) + for name in ['full_values', 'scaled_transit', 'downsampled_intransit_flux', 'transit_times']: + if name in frame.f_locals: + value = np.asarray(frame.f_locals[name]) + row[name] = dict(shape=list(value.shape), size=int(value.size), + minimum=float(np.min(value)) if value.size else None, + maximum=float(np.max(value)) if value.size else None) + if frame.f_code.co_name == 'power': + for name in ['chi2', 'test_statistic_periods', 'test_statistic_depths', 'power']: + if name in frame.f_locals: + value = np.asarray(frame.f_locals[name]) + if value.ndim == 1 and len(value) == len(d['periods']): + arrays[name] = value + record['completed_search_scalars_before_exception'] = row['scalars'] + frames.append(row) + tb = tb.tb_next + record['failure_frames'] = frames + if arrays: + np.savez_compressed(args.out.with_suffix('.npz'), **arrays) + record['salvaged_arrays'] = {k: list(v.shape) for k, v in arrays.items()} + record['salvaged_arrays_sha256'] = hashlib.sha256(args.out.with_suffix('.npz').read_bytes()).hexdigest() + record['salvage_warning'] = 'These arrays existed before a public API failure; not a successful API return.' + record.update(total_first_call_s=time.perf_counter()-started, function_events=events) + save() + print(json.dumps({k: record[k] for k in ['status', 'total_first_call_s', + 'completed_search_scalars_before_exception'] if k in record}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_profile/profile_tls.py b/benchmarks/tls_profile/profile_tls.py new file mode 100644 index 00000000..9af56bd1 --- /dev/null +++ b/benchmarks/tls_profile/profile_tls.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Synchronized wall-phase profiling and isolated GTLS host-loop ablations. + +Installed sources and CUDA kernels stay unchanged. Rebuilt Python functions +are retained separately; native runs are timed before instrumentation. +""" +import argparse +import ast +from contextlib import contextmanager +import hashlib +import importlib +import inspect +import json +from pathlib import Path +import platform +import sys +import textwrap +import time +import traceback +import warnings + +import numpy as np + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def write(path, obj): + Path(path).write_text(json.dumps(obj, indent=2, default=str) + '\n') + + +class Profiler: + def __init__(self, sync): + self.sync = sync + self.clear() + + def clear(self): + self.rows = {} + self.stack = [] + + @contextmanager + def segment(self, label): + self.sync() + state = [time.perf_counter(), 0.] + self.stack.append(state) + try: + yield + finally: + self.sync() + elapsed = time.perf_counter() - state[0] + self.stack.pop() + if self.stack: + self.stack[-1][1] += elapsed + row = self.rows.setdefault(label, dict(calls=0, inclusive_s=0., exclusive_s=0.)) + row['calls'] += 1 + row['inclusive_s'] += elapsed + row['exclusive_s'] += elapsed - state[1] + + +def assign_name(node): + if isinstance(node, ast.Assign): + return ast.unparse(node.targets[0]) + return '' + + +def call_name(node): + value = node.value if isinstance(node, (ast.Assign, ast.Expr)) else None + return ast.unparse(value.func) if isinstance(value, ast.Call) else '' + + +def segment_node(label, statements): + return ast.With(items=[ast.withitem(context_expr=ast.Call( + func=ast.Attribute(value=ast.Name(id='_TLS_PROFILE', ctx=ast.Load()), + attr='segment', ctx=ast.Load()), + args=[ast.Constant(label)], keywords=[]))], body=statements) + + +def partition(body, kind, scope='top'): + """Wrap contiguous statement groups; preserve branches and evaluation order.""" + default = {'gtls': 'GTLS setup and allocations', + 'v1': 'v1 grid/configuration', 'power': 'GTLS input/template setup'}[kind] + if scope == 'chunk': + default = 'GTLS duration-mask union' if kind == 'gtls' else 'v1 lightcurve transfers' + output, block, label = [], [], default + + def flush(): + nonlocal block + if block: + output.append(segment_node(label, block)) + block = [] + + for node in body: + name, call = assign_name(node), call_name(node) + new = None + is_chunk = isinstance(node, ast.For) and ( + (kind == 'gtls' and ast.unparse(node.target) == 'iterFlag') or + (kind == 'v1' and ast.unparse(node.target) == '(i0, i1)')) + if is_chunk: + flush() + node.body = partition(node.body, kind, 'chunk') + output.append(node) + label = 'GTLS result/statistics processing' if kind == 'gtls' else 'v1 final packaging' + continue + if kind == 'gtls': + if scope == 'top': + if name == 'GPUCode': new = 'GTLS CUDA module lookup/compile' + elif name == '(durations, indices)': new = 'GTLS setup and allocations' + elif name == 'raw_chi2': new = 'GTLS result/statistics processing' + elif call == 'search_multi_periods_again': new = 'GTLS candidate refinement' + elif call == 'search_single_periods': new = 'GTLS best-period fit/diagnostics' + elif name.startswith('chi2['): new = 'GTLS result/statistics processing' + else: + if name == 'single_lc_arr': new = 'GTLS chunk allocations/transfers' + elif name == 'fastFoldGPU': new = 'GTLS folding/sorting' + elif name == 'patchDataGPU': new = 'GTLS reorder/weights' + elif isinstance(node, ast.For) and ast.unparse(node.iter) == 'range(singleCalcPeriods)': + new = 'GTLS row-wise flux prefix sums' + elif name == 'cumsumGPU[:]' or name == 'cumsumGPU[:, :]': + new = 'GTLS row-wise flux prefix sums' + elif name == 'patchedDatasSize_local': new = 'GTLS error prefixes/out-of-transit terms' + elif name == 'calcAllLowestResidualsGPU': new = 'GTLS transit residual kernel' + elif name == 'start_idx': new = 'GTLS reductions/chunk cleanup' + elif kind == 'v1': + if scope == 'top': + if name == 'band_launches': new = 'v1 kernel lookup/grid transfers' + elif name == '(T_tab, S1_tab, S2_tab)': new = 'v1 template tables' + elif call == '_preprocess_batch': new = 'v1 host lightcurve preparation' + elif name == 'periods_g': new = 'v1 buffer allocations/transfers' + else: + if isinstance(node, ast.For) and ast.unparse(node.iter) == 'band_launches': + new = 'v1 coarse search kernel' + elif name == 'score_h': new = 'v1 coarse spectrum transfer' + elif name == 'rscore_h': new = 'v1 candidate selection/refinement/transfers' + elif isinstance(node, ast.If) and ast.unparse(node.test).startswith('return_arrays'): + new = 'v1 parameter-spectrum transfers' + elif isinstance(node, ast.FunctionDef) and node.name == '_finish_lc': + new = 'v1 CPU statistics/results' + elif kind == 'power': + if name == 'use_multi_gpu': new = 'GTLS full search call' + if new and new != label: + flush() + label = new + block.append(node) + flush() + return output + + +class Vectorize(ast.NodeTransformer): + def __init__(self, variant): + self.variant = variant + self.changes = [] + + def visit_For(self, node): + self.generic_visit(node) + if ast.unparse(node.iter) == 'range(start_idx + 1, end_idx)': + assert len(node.body) == 1 and call_name(node.body[0]) == 'cp.logical_or' + self.changes.append('Boolean duration union: loop to axis reduction') + return ast.parse('temp_bool = cp.any(durationBoolArrayGPU[start_idx:end_idx], axis=0)').body[0] + if self.variant == 'both' and ast.unparse(node.iter) == 'range(singleCalcPeriods)': + if len(node.body) == 1 and call_name(node.body[0]) == 'cp.cumsum': + self.changes.append('Flux prefix sums: row loop to batched axis scan') + return ast.parse('cumsumGPU[:] = cp.cumsum(patchedDatasGPU, axis=1)').body[0] + return node + + +def rebuild(owner, name, output, variant='native', instrument=False, kind=None, profiler=None): + original = getattr(owner, name) + source = textwrap.dedent(inspect.getsource(original)) + tree = ast.parse(source) + fn = tree.body[0] + changes = [] + if variant != 'native': + transformer = Vectorize(variant) + fn = transformer.visit(fn) + changes = transformer.changes + assert changes, 'Expected ablation sites were not found' + if instrument: + fn.body = partition(fn.body, kind) + ast.fix_missing_locations(tree) + rendered = ast.unparse(tree) + '\n' + suffix = 'instrumented' if instrument else variant + path = output.with_name(output.stem + f'.{name}.{suffix}.py') + path.write_text(rendered) + namespace = original.__globals__ + namespace['_TLS_PROFILE'] = profiler + local = {} + exec(compile(tree, str(path), 'exec'), namespace, local) + setattr(owner, name, local[name]) + return dict(function=name, original_sha256=hashlib.sha256(source.encode()).hexdigest(), + transformed_sha256=sha(path), file=path.name, changes=changes, + instrumentation=instrument) + + +def output_arrays(results): + arrays = {} + scalars = [] + for i, r in enumerate(results): + get = lambda name: r[name] if hasattr(r, '__getitem__') else getattr(r, name) + for name in ['periods', 'chi2']: + arrays[f'{name}_{i}'] = np.asarray(np.ma.filled(get(name), np.nan), dtype=np.float64) + scalars.append({name: float(get(name)) for name in ['period', 'SDE']}) + return arrays, scalars + + +def compare(a, b): + checks = {} + for name in a: + aa, bb = a[name], b[name] + same_mask = bool(np.array_equal(np.isfinite(aa), np.isfinite(bb))) + finite = np.isfinite(aa) & np.isfinite(bb) + checks[name] = dict(same_shape=aa.shape == bb.shape, same_finite_mask=same_mask, + exact=bool(np.array_equal(aa, bb, equal_nan=True)), + max_abs=float(np.max(np.abs(aa[finite] - bb[finite]))) if finite.any() else None) + return checks + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--backend', choices=['gtls', 'v1'], required=True) + ap.add_argument('--input', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--variant', choices=['native', 'union', 'both'], default='native') + ap.add_argument('--nsource', type=int, default=1) + ap.add_argument('--offset', type=int, default=4) + ap.add_argument('--reps', type=int, default=3) + ap.add_argument('--profile-reps', type=int, default=1) + args = ap.parse_args() + args.out.parent.mkdir(parents=True, exist_ok=True) + data = np.load(args.input) + periods = data['periods'] + lcs_raw = [(data[f't_{i}'], data[f'y_{i}'], data[f'dy_{i}'], data[f'scale_{i}']) + for i in range(args.offset, args.offset + args.nsource)] + + def prepare(): + result = [] + for t, y, dy, scale in lcs_raw: + order = np.argsort(t) + result.append((t[order], (y / scale)[order], (dy / scale)[order])) + return result + + record = dict(status='running', args=vars(args), input_sha256=sha(args.input), + runner_sha256=sha(__file__), python=sys.version, platform=platform.platform(), + source_files={}, transformations=[], + interpretation='New-host component diagnostic. Native API medians are uninstrumented. ' + 'Phase times include synchronization and instrumentation overhead; ' + 'ablations are diagnostic Python changes, not released GTLS.') + write(args.out, record) + try: + if args.backend == 'gtls': + import cupy as cp + import gputls + from gputls import gtls + core = importlib.import_module('gputls.core') + cp.cuda.Device(0).use() + sync = cp.cuda.runtime.deviceSynchronize + package = Path(gputls.__file__).parent + for p in sorted(package.rglob('*.py')): + record['source_files'][str(p.relative_to(package))] = sha(p) + if args.variant != 'native': + record['transformations'].append(rebuild(core, 'search_multi_periods', args.out, + variant=args.variant)) + + def run(): + return [gtls(*lc, verbose=False).power( + periods=periods, R_star=1, M_star=1, oversampling_factor=3, + T0_fit_margin=.125, duration_grid_step=1.1, + transit_template='default', verbose=False, show_progress_bar=False) + for lc in prepare()] + else: + from cuvarbase import tls + from cuvarbase.base import ensure_context + import pycuda.driver as drv + ensure_context() + sync = drv.Context.synchronize + package = Path(tls.__file__).parent + for p in sorted(package.rglob('*')): + if p.suffix in ['.py', '.cu', '.cuh']: + record['source_files'][str(p.relative_to(package))] = sha(p) + + binned_name = '_tls_search_batch_binned' if hasattr(tls, '_tls_search_batch_binned') else 'tls_search_batch' + + def run(): + return getattr(tls, binned_name)(prepare(), periods=periods, + qmin=data['qmin'], qmax=data['qmax'], n_durations=38, + t0_oversample=8, refine_top_k=50, refine_oversample=33, + R_star=1, M_star=1, oversampling_factor=3, return_arrays=True, + u=[.4804, .1867]) + sync() + first_start = time.perf_counter() + first = run() + sync() + record['first_api_s'] = time.perf_counter() - first_start + baseline_arrays, baseline_scalars = output_arrays(first) + times = [] + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + for _ in range(args.reps): + sync() + start = time.perf_counter() + result = run() + sync() + times.append(time.perf_counter() - start) + record['warnings'] = sorted(set(str(w.message) for w in caught)) + arrays, scalars = output_arrays(result) + np.savez_compressed(args.out.with_suffix('.npz'), **arrays) + record.update(native_times_s=times, native_median_s=float(np.median(times)), + native_results=scalars, first_vs_last=compare(baseline_arrays, arrays), + native_outputs_finite=all(np.isfinite(v) for r in scalars for v in r.values())) + write(args.out, record) + profiler = Profiler(sync) + if args.profile_reps: + if args.backend == 'gtls': + record['transformations'].append(rebuild(core, 'search_multi_periods', args.out, + instrument=True, kind='gtls', profiler=profiler)) + record['transformations'].append(rebuild(gtls, 'power', args.out, + instrument=True, kind='power', profiler=profiler)) + else: + record['transformations'].append(rebuild(tls, binned_name, args.out, + instrument=True, kind='v1', profiler=profiler)) + profiles = [] + for _ in range(args.profile_reps): + profiler.clear() + start = time.perf_counter() + with profiler.segment('API preparation/remaining host work'): + result = run() + elapsed = time.perf_counter() - start + profile_arrays, profile_scalars = output_arrays(result) + profiles.append(dict(total_s=elapsed, phases=profiler.rows, + output_comparison=compare(arrays, profile_arrays), results=profile_scalars)) + record.update(status='ok', profiles=profiles, output_file_sha256=sha(args.out.with_suffix('.npz'))) + except Exception: + record.update(status='error', error=traceback.format_exc()) + write(args.out, record) + print(json.dumps({k: record[k] for k in ['status', 'native_median_s', 'error'] if k in record}), flush=True) + if record['status'] != 'ok': + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/README.md b/benchmarks/tls_reference/README.md new file mode 100644 index 00000000..5577f3f7 --- /dev/null +++ b/benchmarks/tls_reference/README.md @@ -0,0 +1,134 @@ +# Observation-level TLS validation and timing + +These tools compare cuvarbase's standard TLS engine with the complete search +in [GTLS commit `74e449c`](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111). +The [results archive](../results/tls_reference_2026-09-10/README.md) supplies +the frozen inputs, source identities, outcomes and timing receipts behind the +[transit benchmark report](../../docs/TRANSIT_BENCHMARKS.md). + +## What is tested + +The primary comparison checks complete residual and power spectra, masks, +candidate and harmonic refinements, and final selections. It also checks the +actual cuvarbase public result contract. The independent full-grid population +contains ordinary, high-impact, eccentric and dense-M-dwarf transits on TESS +and ZTF cadences, plus noise-only controls. Selected-period stress fixtures +are separate numerical tests; they are not independent recovery trials. +Passband baselines are assumed already removed and the injected signals are +achromatic. Both search APIs receive only `t`, `y` and `dy`; the stored band +labels are provenance metadata, not a fitted multiband model. + +Untouched GTLS outcomes are retained. Exact differential checks additionally +use one disclosed host correction: exclude masked/nonfinite candidates before +ranking. `corrected_reference.py` applies that correction temporarily in +memory and records the original and corrected function hashes. Native CUDA +source and templates are unchanged. A native result is reused for the corrected +comparison only when its complete selection trace proves the correction is a +no-op. See the [defect explanation](../../docs/GTLS_COMPARISON.md#invalid-candidate-correction). + +| Tool | Purpose | +| --- | --- | +| `inputs.py` | Restore exact published observation, noise, signal and period arrays from the compact input bank | +| `cases.py` | Generate physical injections and nulls, or regenerate published numerical inputs with hash checks | +| `validate.py`, `comparison.py` | Instrument both searches, compare complete outputs and retain failures | +| `corrected_reference.py` | Auditable native host correction and sufficient checks for no-op reuse | +| `summarize.py` | Per-case and per-regime recovery, null and numerical-agreement summaries | +| `reproduce.py` | Rerun a published population without claiming new independent evidence | +| [timing/](timing/README.md) | Public API latency, 16-source throughput and component timings after numerical validation | +| `analyze_timing.py` | Validate timing provenance and produce the figure's normalized measurements | + +## Reproduce the numerical comparison + +Exact input restoration needs only NumPy and the Python standard library. +The compact bank stores the original numerical vectors and checks every +array's dtype, shape and bytes. It does not regenerate the signals or noise. +The original manifests and seals remain unchanged. + +From the repository root, use a new output directory: + +```sh +python benchmarks/tls_reference/inputs.py restore \ + --bank benchmarks/results/tls_reference_2026-09-10/inputs --study main \ + --manifest benchmarks/results/tls_reference_2026-09-10/validation/input_manifest.json \ + --out reproduced-inputs + +python benchmarks/tls_reference/reproduce.py --repo-root . \ + --manifest reproduced-inputs/manifest.json \ + --seal benchmarks/results/tls_reference_2026-09-10/validation/seal.json \ + --out reproduced-results +``` + +The search requires the recorded CUDA environment, cuvarbase's `tls` extra, +and the pinned GTLS package. These scripts do not provision a GPU or install +dependencies. The original seal remains unchanged; reproduction receipts +record the executing source separately. Running the same frozen inputs again +does not create a new independent study. + +The original Linux search environment used Python 3.11 and the dependencies in +[execution_environment.json](../../docs/BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/validation/execution_environment.json"). +In a fresh Python 3.11 environment with a working CUDA compiler and driver, +the core installation can be reproduced from the repository root: + +```sh +python -m pip install numpy==2.2.6 scipy==1.15.3 pycuda==2025.1.2 \ + cupy-cuda12x==13.6.0 batman-package==2.5.3 numba==0.67.0 \ + astropy==8.0.1 tqdm==4.70.0 pynvml==13.0.1 transitleastsquares==1.32 +git clone https://github.com/Farthing-0/GTLS.git GTLS +git -C GTLS checkout 74e449c325792a763dde4fbffab98039c5e8c111 +python -m pip install --no-deps -e . ./GTLS +python - <<'PY' +from importlib.util import find_spec +from pathlib import Path +import shutil + +destination = Path(find_spec("gputls").origin).parent +for source in Path("GTLS/src/gputls").glob("*.cu"): + shutil.copy2(source, destination / source.name) +PY +``` + +The final copy preserves the CUDA source files needed by this pinned native +package. Reproduction checks the installed GTLS source hashes against the +original seal. Put the CUDA compiler on `PATH` and set the loader's CUDA +library path as required by the local CUDA installation. Use the timing +runner's recorded thread settings for timing; the original main scientific +cohort used four Numba threads and one BLAS/OpenMP thread. + +The archive preserves the original generator/protocol source identities. +These maintained runners include packaging and usability changes and therefore +need not have the same file hashes as the original runners. Production source +hashes and the exact input arrays are checked separately. + +To audit physical input generation separately, use NumPy, SciPy and batman in +the [recorded generation environment](../results/tls_reference_2026-09-10/sources/README.md). +The original environment installs `batman-package==2.5.3`, whose module reports +version `2.5.1`. Regeneration checks every numerical hash and stops if values +differ; arbitrary platforms and dependency versions are not assumed to round +identically. + +```sh +python benchmarks/tls_reference/cases.py --repo-root . \ + --replay-manifest benchmarks/results/tls_reference_2026-09-10/validation/input_manifest.json \ + --out regenerated-inputs +``` + +## Timing and interpretation + +The [timing protocol](timing/README.md) specifies five single-source calls and +three 16-source batch repetitions per regime, using noise-only inputs to +represent searches in which transits are rare. It compares native worker +pools of 1, 2 and 4 with cuvarbase's complete public calls, and separately +measures search components and GTLS's extra output diagnostics. A configuration +must reproduce its frozen numerical outputs before its elapsed time can enter +a speed ratio. + +Numerical equality establishes the same search decisions on tested inputs. +Small recovery cohorts do not measure a universal one- or two-percentage-point +population margin. A fixed SDE threshold is descriptive here; it is not a +common calibrated false-positive rate across cadences. + +CPU tests for the comparison, timing accounting and publication checks: + +```sh +python -m pytest -q benchmarks/tls_reference +``` diff --git a/benchmarks/tls_reference/analyze_timing.py b/benchmarks/tls_reference/analyze_timing.py new file mode 100644 index 00000000..526b8186 --- /dev/null +++ b/benchmarks/tls_reference/analyze_timing.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Normalize audited TLS timings for the public comparison figure. + +Run the accuracy acceptance checks and timing receipt audit first. This step +requires both gates, preserves raw repetitions, and never uses failed calls +or the instrumented search boundary as public API timing denominators. +""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import statistics + + +PROFILES = {'tess_solar': 'tess_200s', 'tess_gap': 'tess_gap', 'ztf_solar': 'ztf'} + + +def read(path): + return json.loads(path.read_text()) + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def timing_row(profile, method, mode, measured, count, workers): + values = measured['raw_seconds'] + expected = 5 if mode == 'single' else 3 + if len(values) != expected or any(not math.isfinite(x) or x <= 0 for x in values): + raise ValueError('Incomplete or invalid public timing repetitions') + median = statistics.median(values) + if median != measured['median_seconds']: + raise ValueError('Timing median differs from its recorded repetitions') + return dict(profile=profile, method=method, mode=mode, + boundary='warm_public_api', n=count, workers=workers, + repetitions=len(values), total_seconds=values, + seconds_per_source=median/count, + min_seconds_per_source=min(values)/count, + max_seconds_per_source=max(values)/count) + + +def numerical_kind(manifest, acceptance, manifest_sha256): + if 'studies' in manifest: + merged = acceptance.get('merged_origin_checks', {}) + origins = merged.get('accepted_studies', {}) + if (merged.get('numerical_validation_passed') is not True or + merged.get('merged_manifest_sha256') != manifest_sha256 or + set(origins) != set(manifest['studies'])): + raise ValueError('Merged numerical studies have not passed their original gates') + for name, study in manifest['studies'].items(): + actual = origins[name] + if (actual.get('manifest_sha256') != study['manifest_sha256'] or + actual.get('seal_sha256') != study['seal_sha256'] or + actual.get('production_sources') != manifest['source_identity']['production_sources'] or + actual.get('numerical_validation_passed') is not True): + raise ValueError('Merged numerical origin differs from its accepted identity') + reproduced = any(value['evidence_kind'] == 'reproduction' for value in origins.values()) + if (merged.get('evidence_kind') != ('reproduction' if reproduced else 'independent') or + merged.get('publication_gate_passed') is not (not reproduced)): + raise ValueError('Merged evidence cannot relabel a reproduction independent') + return reproduced + if 'publication_gate' in acceptance and 'reproduction_gate' in acceptance: + raise ValueError('Independent and reproduced numerical evidence cannot be conflated') + reproduced = 'reproduction_gate' in acceptance + gate = 'reproduction_gate' if reproduced else 'publication_gate' + if acceptance.get(gate, {}).get('pass') is not True: + raise ValueError('The numerical-validation gate has not passed') + if not reproduced and manifest.get('suite') == 'reproduction': + raise ValueError('A reproduction cannot be relabeled independent') + if acceptance.get('inputs_manifest_sha256') != manifest_sha256: + raise ValueError('Numerical validation used a different input manifest') + if reproduced and (acceptance.get('original_source_identity') != manifest['source_identity'] or + acceptance.get('reproduction_sources', {}).get('production') != + manifest['source_identity']['production_sources']): + raise ValueError('Reproduced timing evidence lacks its actual production-source identity') + return reproduced + + +def analyze(checks, manifest, acceptance, manifest_sha256): + measurement_scope = checks.get('measurement_scope', 'full') + if measurement_scope not in ('full', 'single'): + raise ValueError('Unknown timing measurement scope') + reproduced = numerical_kind(manifest, acceptance, manifest_sha256) + entries = {case['file']: case for case in manifest['cases']} + rows, profiles, speedups, components = [], [], [], [] + for regime, profile in PROFILES.items(): + result = checks['regimes'][regime] + single, batch = result['public_single'], result['public_batch'] + if single['eligible'] is not True or (measurement_scope == 'full' and batch['eligible'] is not True): + raise ValueError('Public timing checks did not pass: ' + regime) + if measurement_scope == 'single' and (batch.get('eligible') is not False or + batch.get('status') != 'not_measured' or batch.get('source_count') != 0): + raise ValueError('Single-source evidence cannot imply measured batch throughput') + selection = checks['cohort_selection'][regime] + if 'studies' in manifest and selection.get('accepted_study') != acceptance['merged_origin_checks']: + raise ValueError('Timing selection and current accepted origins differ') + if not reproduced and selection.get('accepted_study', {}).get('evidence_kind') == 'reproduction': + raise ValueError('Reproduced timing selection cannot be relabeled independent') + if reproduced: + evidence = selection.get('accepted_study', {}) + if (evidence.get('evidence_kind') != 'reproduction' or + evidence.get('numerical_validation_passed') is not True or + evidence.get('publication_gate_passed') is not False): + raise ValueError('Timing selection did not retain its reproduced-evidence classification') + if selection['expected_candidate_sources'] != manifest['source_identity']['production_sources']: + raise ValueError('Timed candidate sources differ from the validated manifest') + examined = {case['case']: case for case in selection['examined']} + cohort_names = selection['selected_cases'] + count = len(cohort_names) + if (count < 1 or count > 16 or len(set(cohort_names)) != count or + (measurement_scope == 'full' and batch['source_count'] != count) or + selection['actual_batch_size'] != count): + raise ValueError('Batch size must equal its distinct successful sources') + if single['case'] != selection['single_case'] or single['case'] not in cohort_names: + raise ValueError('Single-source identity differs from the selected cohort') + names = [single['case']] if measurement_scope == 'single' else cohort_names + metadata = entries[single['case']]['metadata'] + for name in names: + entry = entries[name] + if examined[name]['input_sha256'] != entry['sha256']: + raise ValueError('Timing cohort used a different input array archive') + if entry['metadata']['regime'] != regime: + raise ValueError('Timing cohort contains another regime') + for key in ('ndata', 'baseline_days', 'period_count', 'search_kwargs'): + if entry['metadata'][key] != metadata[key]: + raise ValueError('Shared-grid timing cohort differs in ' + key) + if entry['arrays']['periods'] != entries[single['case']]['arrays']['periods']: + raise ValueError('Timing sources do not share an identical period array') + workers = None + if measurement_scope == 'full': + workers = batch['strongest_tested_native_workers'] + if workers not in (1, 2, 4): + raise ValueError('Native worker selection was not tested') + native_pool = batch['native_pool_configurations'][str(workers)] + if native_pool['eligible'] is not True: + raise ValueError('Selected native pool did not preserve its search results') + eligible = [(pool['elapsed']['median_seconds'], int(width)) + for width, pool in batch['native_pool_configurations'].items() + if pool['eligible']] + if min(eligible)[1] != workers: + raise ValueError('Selected native pool is not the fastest eligible configuration') + rows.extend([ + timing_row(profile, 'tls_v1', 'single', single['candidate'], 1, 1), + timing_row(profile, 'gtls', 'single', single['native'], 1, 1), + ]) + if measurement_scope == 'full': + rows.extend([ + timing_row(profile, 'tls_v1', 'batch', batch['candidate'], count, 1), + timing_row(profile, 'gtls', 'batch', batch['strongest_tested_native'], count, workers)]) + profiles.append(dict(profile=profile, regime=regime, n_samples=metadata['ndata'], + baseline_days=metadata['baseline_days'], n_periods=metadata['period_count'], + batch_size=count if measurement_scope == 'full' else None, + supporting_cohort_size=count, gtls_workers=1 if measurement_scope == 'single' else workers, + single_case=single['case'], + input_sha256=entries[single['case']]['sha256'])) + speedups.append(dict(profile=profile, single=single['speedup'], + batch=batch['speedup'] if measurement_scope == 'full' else None, + gtls_batch_workers=workers)) + component = result.get('common_search_components') + if component is None or component['eligible'] is not True: + raise ValueError('Separate search/component validation did not pass: ' + regime) + components.append(dict(profile=profile, **component)) + if selection.get('correction_timing', {}).get('required'): + corrected = result.get('corrected_native_crosscheck') or {} + kinds = ('single',) if measurement_scope == 'single' else ('single', 'batch') + if any(corrected.get(kind, {}).get('eligible') is not True for kind in kinds): + raise ValueError('Required corrected-native public timing did not pass: ' + regime) + if result.get('corrected_common_search_components', {}).get('eligible') is not True: + raise ValueError('Required corrected-native component timing did not pass: ' + regime) + return dict(measurement_scope='single' if measurement_scope == 'single' else 'single_and_batch', + verification=dict(complete=True, numerical_validation_complete=True, + numerical_evidence_kind='reproduction' if reproduced else 'independent', + exclusive_processes=True, + scope='Completed scientific acceptance and exclusive public-call receipt gates; ' + 'not a universal population recovery or false-positive guarantee.'), + profiles=profiles, timings=rows, speedups=speedups, components=components, + environment=checks['environment'], native_extras=checks['native_extras'], + source_scope=checks['scientific_scope'], + corrected_native_crosschecks={PROFILES[regime]: dict( + public=result.get('corrected_native_crosscheck'), + components=result.get('corrected_common_search_components')) + for regime, result in checks['regimes'].items() + if result.get('corrected_native_crosscheck') is not None}) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--checks', type=Path, required=True) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--acceptance', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + manifest, acceptance = read(args.manifest), read(args.acceptance) + if 'studies' in manifest: + if __package__: + from .timing.cohort import accepted_study + else: + from timing.cohort import accepted_study + merged = accepted_study(args.manifest, args.acceptance.parent) + if sha(args.acceptance) not in {value['receipt_sha256'] for value in merged['accepted_studies'].values()}: + raise ValueError('The supplied acceptance is not one of the merged source studies') + acceptance = dict(merged_origin_checks=merged) + result = analyze(read(args.checks), manifest, acceptance, sha(args.manifest)) + result['sources'] = {name: dict(file=path.name, sha256=sha(path)) + for name, path in (('timing_checks', args.checks), + ('inputs', args.manifest), + ('numerical_acceptance', args.acceptance))} + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2, allow_nan=False) + '\n') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/cases.py b/benchmarks/tls_reference/cases.py new file mode 100644 index 00000000..e6c02ca0 --- /dev/null +++ b/benchmarks/tls_reference/cases.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""CPU-only physical inputs for numerical parity and frozen recovery checks. + +Selected-period stress cases deliberately include truth and aliases. They are +mathematical differential fixtures, never recovery or performance evidence. +Fresh held-out generation requires a matching seal of sources and the plan. +""" +import argparse +from dataclasses import asdict +import hashlib +import importlib.util +import json +from pathlib import Path +import platform +import sys + +import numpy as np + +from validate import array_hash, production_sources, sha, write + + +REGIMES = { + 'tess_solar': dict(cadence='tess_200s', impact=(.2, .7)), + 'tess_highimpact': dict(cadence='tess_200s', impact=(.94, .96)), + 'tess_eccentric': dict(cadence='tess_200s', impact=(.2, .7), period=(6., 12.), eccentricity=(.7, .8)), + 'tess_mdwarf': dict(cadence='tess_200s', impact=(.2, .7), radius=.1, mass=.1), + 'ztf_solar': dict(cadence='ztf', impact=(.2, .7)), + 'ztf_highimpact': dict(cadence='ztf', impact=(.94, .96)), + 'ztf_mdwarf': dict(cadence='ztf', impact=(.2, .7), radius=.1, mass=.1), + 'tess_gap': dict(cadence='tess_gap', impact=(.2, .7)), +} +SNRS = (6., 8., 10., 12.) + +STRESS = [ + dict(name='solar_ordinary', regime='tess_solar'), + dict(name='solar_highimpact', regime='tess_highimpact'), + dict(name='solar_eccentric', regime='tess_eccentric'), + dict(name='dense_m_ordinary', regime='tess_mdwarf'), + dict(name='dense_m_highimpact', regime='tess_mdwarf', impact=.95), + dict(name='solar_grazing', regime='tess_solar', impact=1.), + dict(name='sparse_ordinary', regime='ztf_solar'), + dict(name='sparse_highimpact', regime='ztf_highimpact'), + dict(name='sparse_dense_m', regime='ztf_mdwarf'), + dict(name='gapped_ordinary', regime='tess_gap'), + dict(name='phase_wrap', regime='tess_solar', phase=1.-2**-23), + dict(name='phase_ties', regime='tess_solar', duplicate=True), + dict(name='heteroskedastic', regime='tess_solar', heteroskedastic=True), + dict(name='flat', regime='tess_solar', flat=True), + dict(name='noise_only', regime='tess_solar', null=True), + dict(name='large_absolute_epoch', regime='tess_highimpact', absolute_epoch=2457000.123456789), + dict(name='cleaning_edges', regime='tess_solar', cleaning_edges=True), + dict(name='dense_long_solar', regime='tess_solar', period=365.25, impact=.8, phase=5./365.25, repeated_campaigns=8, campaign_spacing_days=180.), + dict(name='dense_long_mdwarf', regime='tess_mdwarf', period=365.25, impact=.9, phase=5./365.25, repeated_campaigns=8, campaign_spacing_days=180.), + dict(name='long_solar', regime='ztf_solar', period=365.25, impact=.8), + dict(name='long_dense_m', regime='ztf_mdwarf', period=365.25, impact=.9), + dict(name='compact_star_native_extension', regime='ztf_mdwarf', period=10., radius=.012, mass=.6, + expected_native_cache_failure=True), + dict(name='sparse_long_native_extension', regime='ztf_solar', period=365.25, thin_points=200, + expected_native_cache_failure=True), +] + + +def module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + sys.modules[name] = result + spec.loader.exec_module(result) + return result + + +def deterministic_rng(stream, name): + digest = hashlib.sha256((stream+'\0'+name).encode()).digest() + return np.random.default_rng(np.frombuffer(digest, dtype='= 60 + return result.astype(np.float64) + + +def ou_noise(times, amplitude, tau, rng): + result = np.empty(len(times), dtype=np.float64) + result[0] = amplitude*rng.normal() + for i in range(1, len(times)): + rho = np.exp(-(times[i]-times[i-1])/tau) + result[i] = rho*result[i-1]+amplitude*np.sqrt(max(0., 1-rho*rho))*rng.normal() + return result + + +def weighted_signal_norm(signal, relative_error): + w = relative_error**-2 + center = np.dot(w, signal)/w.sum() + return float(np.sqrt(np.dot(w, (signal-center)**2))) + + +def source_identity(repo_root): + import batman + import scipy + cadence_root = repo_root/'benchmarks/results/tls_sensitivity_2026-09-09/cadences' + return dict(generator_sha256=sha(__file__), harness_sha256=sha(Path(__file__).with_name('validate.py')), + generation_environment=dict(python=platform.python_version(), platform=platform.platform(), + machine=platform.machine(), numpy=np.__version__, scipy=scipy.__version__, batman=batman.__version__), + validation_sources={p.name: sha(p) for p in sorted(Path(__file__).parent.iterdir()) + if p.suffix in ('.py', '.md') and p.is_file()}, + diagnostic_sha256=sha(repo_root/'benchmarks/tls_accuracy/diagnose.py'), + production_sources=production_sources(repo_root), + cadences={p.name: sha(p) for p in sorted(cadence_root.glob('*.npz'))}) + + +def build_case(name, regime_name, snr, null, stream, diag, ref, cadence_root, + full_grid, stress=None, exposure_nodes=64, conditional=True): + stress = stress or {} + settings = dict(REGIMES[regime_name]) + rng = deterministic_rng(stream, name) + if null and full_grid: + # I.i.d. nulls from the predeclared equal mixture, rather than pooling + # two fixed-size populations and silently treating them as binomial. + snr = float(rng.choice(SNRS)) + path = cadence_root/(settings['cadence']+'.npz') + with np.load(path, allow_pickle=False) as data: + times = data['t'].astype(np.float64) + errors = data['relative_error'].astype(np.float64) + exposures = data['exposure_days'].astype(np.float64) + band = data['band'].copy() + if stress.get('repeated_campaigns'): + count = int(stress['repeated_campaigns']) + spacing = float(stress['campaign_spacing_days']) + times = np.concatenate([times+i*spacing for i in range(count)]) + errors, exposures, band = (np.tile(v, count) for v in (errors, exposures, band)) + if 'thin_points' in stress: + index = np.unique(np.round(np.linspace(0, len(times)-1, stress['thin_points'])).astype(int)) + times, errors, exposures, band = (x[index] for x in (times, errors, exposures, band)) + if stress.get('duplicate'): + index = np.sort(np.r_[np.arange(len(times)), np.arange(0, len(times), 251)]) + times, errors, exposures, band = (x[index] for x in (times, errors, exposures, band)) + if stress.get('heteroskedastic'): + errors *= np.exp(rng.uniform(-2., 2., len(times))) + errors /= np.median(errors) + radius, mass = (stress.get(key, settings.get(key, 1.)) for key in ('radius', 'mass')) + for attempt in range(1, 10001): + period = stress.get('period', rng.uniform(*settings.get('period', (2., 6.)))) + impact = stress.get('impact', rng.uniform(*settings['impact'])) + eccentricity = rng.uniform(*settings.get('eccentricity', (0., 0.))) + physical = diag.Regime(name, period, radius=radius, mass=mass, rp=.00916/radius, + impact=impact, eccentricity=eccentricity) + duration, full_duration, semimajor = diag.durations(physical) + epoch = stress.get('phase', rng.uniform())*period + signal = diag.physical_signal(physical, times, exposures, epoch=epoch, + exposure_nodes=exposure_nodes) + in_transit = signal > np.max(signal)*1e-8 if np.max(signal) > 0 else np.zeros(len(times), bool) + events = np.unique(np.rint((times[in_transit]-epoch)/period).astype(int)).size + signal_norm = weighted_signal_norm(signal, errors) + observable = in_transit.sum() >= 5 and events >= 2 and signal_norm > 0 + if observable or not conditional: + break + else: + raise RuntimeError('Predeclared observability conditioning failed after 10000 proposals: '+name) + scale = signal_norm/snr if signal_norm > 0 else 1e-4 + dy = scale*errors + white = dy*rng.normal(size=len(times)) + ou_amplitude = .25*np.median(dy) + ou_tau = 1. if settings['cadence'] == 'ztf' else .15 + correlated = ou_noise(times, ou_amplitude, ou_tau, rng) + y = 1.+white+correlated-(0 if null or stress.get('null') else signal) + if stress.get('flat'): + y = np.ones_like(y) + # The public default uses a positive relative origin; supplying both + # methods this same input makes that dispatch idempotent. Development + # scouts preserve large epochs to stress literal native preprocessing. + absolute_epoch = stress.get('absolute_epoch', 1. if stream.startswith('heldout:') else 2457000.) + t = times+absolute_epoch + epoch += absolute_epoch + if stress.get('cleaning_edges'): + # Deliberate independently cleaned invalid rows; preserve valid inputs. + t = np.r_[0., np.nan, -1., t, t[-1]+1., t[-1]+2.] + y = np.r_[1., 1., 1., y, np.nan, 1.] + dy = np.r_[dy[0], dy[0], dy[0], dy, dy[0], 0.] + signal, exposures = (np.pad(x, (3, 2)) for x in (signal, exposures)) + band = np.pad(band, (3, 2)) + period_bounds = (.6, 12.878375495285127 if settings['cadence'] == 'tess_200s' else + 10. if settings['cadence'] == 'ztf' else 27.457888046800917) + if full_grid: + periods = np.sort(ref.period_grid(float(np.ptp(times)), R_star=radius, M_star=mass, + period_min=period_bounds[0], period_max=period_bounds[1])) + else: + periods = selected_periods(period, duration, float(np.ptp(times))) + if len(periods) < 60: + raise ValueError('Native GTLS period grid too short for valid reference grouping') + solar = diag.Regime('solar_comparison', period) + solar_duration = diag.durations(solar)[0] + metadata = dict(name=name, regime=regime_name, cohort='heldout' if stream.startswith('heldout:') else 'development', + purpose='recovery_full_grid' if full_grid else 'mathematical_differential', stream=stream, + null=bool(null or stress.get('null') or stress.get('flat')), physical=asdict(physical), + truth_period=period, truth_epoch=epoch, duration_days=duration, full_duration_days=full_duration, + semimajor_stellar_radii=semimajor, periastron_stellar_radii=semimajor*(1-eccentricity), + latent_white_oracle_snr=float(snr), realized_latent_white_oracle_snr=signal_norm/scale, + conditional_population=conditional, accepted_proposal=attempt, observable=bool(observable), + in_transit_observations=int(in_transit.sum()), observed_events=int(events), + noise=dict(white='independent Gaussian with supplied dy', ou_amplitude=float(ou_amplitude), + ou_tau_days=ou_tau, oracle_snr_excludes_ou=True), + duration_over_solar_central=duration/solar_duration, + below_approx_native_duration_envelope=duration/solar_duration < .135, + expected_native_cache_failure=stress.get('expected_native_cache_failure', False), + cadence=settings['cadence'], cadence_sha256=sha(path), ndata=len(t), baseline_days=float(np.ptp(times)), + cadence_construction='Synthetic repeated observed TESS campaign blocks' if stress.get('repeated_campaigns') else 'Observed cadence', + fractional_duration=duration/period, duration_times_ndata=duration/period*len(t), + input_time_offset_from_cadence_days=absolute_epoch, + input_time_origin_policy='Common positive relative origin for both APIs' if stream.startswith('heldout:') else + 'Large absolute epoch for literal native preprocessing development stress', + period_count=len(periods), period_bounds=period_bounds if full_grid else [float(periods[0]), float(periods[-1])], + truth_inserted_in_grid=not full_grid, + grid_execution='Explicit identical period array supplied to both engines; generated on CPU from pinned GTLS grid arithmetic.' if full_grid else + 'Explicit selected-period mathematical stress grid including truth and aliases.', + auto_grid_api_exercised=False, + exposure_quadrature_nodes=exposure_nodes, + search_kwargs=dict(R_star=radius, M_star=mass, oversampling_factor=3, + period_min=period_bounds[0], period_max=period_bounds[1]), + stress_settings=stress) + return dict(t=t, y=y, dy=dy, periods=periods, signal=signal, exposure_days=exposures, band=band), metadata + + +def case_specs(suite, plan): + if suite == 'stress': + return [(s['name'], s['regime'], 10., bool(s.get('null')), False, s) for s in STRESS] + specs = [] + for regime in REGIMES: + counts = plan.get(regime, {}) if suite == 'heldout' else {'8': 1, '10': 1, 'null': 1} + for snr in SNRS: + for i in range(int(counts.get(str(int(snr)), 0))): + specs.append(('%s_snr%d_%04d' % (regime, snr, i), regime, snr, False, True, {})) + for i in range(int(counts.get('null', 0))): + specs.append(('%s_null_%04d' % (regime, i), regime, SNRS[i % len(SNRS)], True, True, {})) + return specs + + +def verify_seal(seal, identity, plan, stream): + if not stream.startswith('heldout:'): + raise ValueError('Independent stream must explicitly start heldout:') + if seal.get('source_identity') != identity or seal.get('plan') != plan or seal.get('stream') != stream: + raise ValueError('Seal does not match current sources, frozen plan and independent stream') + required = ('gates', 'modes', 'thresholds', 'reference_commit', 'freeze_timestamp_utc', 'hardware', + 'engine_kind', 'chunk_policy', 'auto_grid', 'options', 'reference_package_sources') + if any(key not in seal for key in required): + raise ValueError('Seal lacks predeclared numeric gates, mode, thresholds, reference, date or hardware') + + +def replay_manifest(repo_root, manifest_path, output): + """Recreate existing numerical inputs, with no new independent-data claim.""" + manifest = json.loads(manifest_path.read_text()) + if output.exists(): + raise ValueError('Refuse to overwrite input directory') + diagnostic = module(repo_root/'benchmarks/tls_accuracy/diagnose.py', 'physical_diagnostic') + reference = module(repo_root/'cuvarbase/tls_reference_math.py', 'fixture_reference_math') + cadence_root = repo_root/'benchmarks/results/tls_sensitivity_2026-09-09/cadences' + frozen_sources = manifest.get('source_identity', {}) + for name, expected in frozen_sources.get('cadences', {}).items(): + if sha(cadence_root/name) != expected: + raise ValueError('Cadence source hash changed: '+name) + if frozen_sources.get('diagnostic_sha256') != sha(repo_root/'benchmarks/tls_accuracy/diagnose.py'): + raise ValueError('Physical signal generator differs from the published study') + output.mkdir(parents=True) + receipts = [] + for case in manifest['cases']: + metadata = case['metadata'] + arrays, generated = build_case(metadata['name'], metadata['regime'], metadata['latent_white_oracle_snr'], + metadata['null'], metadata['stream'], diagnostic, reference, cadence_root, + metadata['purpose'] == 'recovery_full_grid', stress=metadata.get('stress_settings'), + exposure_nodes=metadata['exposure_quadrature_nodes'], conditional=metadata['conditional_population']) + actual = {key: array_hash(value) for key, value in arrays.items()} + if actual != case['arrays']: + changed = [key for key in set(actual)|set(case['arrays']) if actual.get(key) != case['arrays'].get(key)] + raise ValueError('Numerical input differs for %s: %s. Use the published generation dependencies.' % (metadata['name'], changed)) + path = output/case['file'] + np.savez_compressed(path, **arrays, metadata=json.dumps(metadata, sort_keys=True)) + receipts.append(dict(name=metadata['name'], numerical_arrays_equal=True, + original_npz_sha256=case['sha256'], regenerated_npz_sha256=sha(path))) + write(output/'reproduction.json', dict(original_manifest_sha256=sha(manifest_path), + generator_sha256=sha(__file__), cases=receipts, + interpretation='Reproduced frozen numerical inputs, not newly independent observations')) + # Preserve the original seal/manifest; compression versions can change + # container bytes even when all numerical values match exactly. + (output/'original_manifest.json').write_bytes(manifest_path.read_bytes()) + reproduced = dict(manifest, suite='reproduction', original_manifest_sha256=sha(manifest_path)) + reproduced['cases'] = [dict(case, original_npz_sha256=case['sha256'], + sha256=sha(output/case['file'])) for case in manifest['cases']] + write(output/'manifest.json', reproduced) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repo-root', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--replay-manifest', type=Path, help='Regenerate published inputs using their frozen stream and metadata, verifying every numerical array hash.') + parser.add_argument('--suite', choices=('stress', 'full-dev', 'heldout'), default='stress') + parser.add_argument('--select', action='append', help='Optional explicit development case names; never filters heldout.') + parser.add_argument('--plan', type=Path) + parser.add_argument('--seal', type=Path) + parser.add_argument('--stream', default='development:20260910-v1') + args = parser.parse_args() + if args.replay_manifest: + replay_manifest(args.repo_root, args.replay_manifest, args.out) + return + identity = source_identity(args.repo_root) + plan = json.loads(args.plan.read_text()) if args.plan else {} + if args.suite == 'heldout': + if not args.seal or not args.plan or args.select: + parser.error('Heldout requires --seal, --plan, and no case filtering') + verify_seal(json.loads(args.seal.read_text()), identity, plan, args.stream) + elif args.stream.startswith('heldout:'): + parser.error('A heldout stream may only be used with the sealed heldout suite') + if args.out.exists(): + parser.error('Output already exists; generation is immutable') + specs = case_specs(args.suite, plan) + if args.select: + selected = set(args.select) + missing = selected-{s[0] for s in specs} + if missing: + parser.error('Unknown selected cases: '+str(sorted(missing))) + specs = [s for s in specs if s[0] in selected] + diag = module(args.repo_root/'benchmarks/tls_accuracy/diagnose.py', 'physical_diagnostic') + ref = module(args.repo_root/'cuvarbase/tls_reference_math.py', 'fixture_reference_math') + cadence_root = args.repo_root/'benchmarks/results/tls_sensitivity_2026-09-09/cadences' + args.out.mkdir(parents=True) + manifest = dict(suite=args.suite, stream=args.stream, source_identity=identity, + seal_sha256=sha(args.seal) if args.seal else None, cases=[]) + for name, regime, snr, null, full_grid, stress in specs: + arrays, metadata = build_case(name, regime, snr, null, args.stream, diag, ref, + cadence_root, full_grid, stress=stress, conditional=full_grid) + metadata['seal_sha256'] = sha(args.seal) if args.seal else None + path = args.out/(name+'.npz') + np.savez_compressed(path, **arrays, metadata=json.dumps(metadata, sort_keys=True)) + manifest['cases'].append(dict(file=path.name, sha256=sha(path), metadata=metadata, + arrays={key: array_hash(value) for key, value in arrays.items()})) + write(args.out/'manifest.json', manifest) + print(json.dumps(dict(case=name, ndata=metadata['ndata'], nperiods=metadata['period_count'], + observable=metadata['observable'], purpose=metadata['purpose'])), flush=True) + if identity != source_identity(args.repo_root): + manifest['status'] = 'source_changed_during_generation' + manifest['source_identity_after'] = source_identity(args.repo_root) + write(args.out/'manifest.json', manifest) + if args.suite == 'heldout': + raise RuntimeError('Source changed during heldout generation; seal invalid') + print('Development source tree changed during generation; recorded before/after hashes.', file=sys.stderr) + return + manifest['status'] = 'complete' + write(args.out/'manifest.json', manifest) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/comparison.py b/benchmarks/tls_reference/comparison.py new file mode 100644 index 00000000..9a6430da --- /dev/null +++ b/benchmarks/tls_reference/comparison.py @@ -0,0 +1,58 @@ +"""Serial reference-correction validation, without cloud or resource actions.""" +import json +from pathlib import Path +import shutil +from types import SimpleNamespace + +import numpy as np + +import corrected_reference +import validate as harness + + +def run_case(path, repo_root, case_root, *, seal=None, options=None, gates=None, thresholds=(8.,), replay=False): + """Run literal native, corrected native, then the actual public candidate. + + Corrected execution may reuse literal arrays only after a source-pinned + complete selection trace proves the host correction is a no-op. + """ + case_root.mkdir() + outcomes = {} + reuse = None + for backend in ('gtls', 'gtls_corrected', 'candidate'): + if backend == 'gtls_corrected' and outcomes.get('gtls') == 'ok': + native_path = case_root/'gtls/record.json' + literal = json.loads(native_path.read_text()) + with np.load(native_path.parent/literal['arrays_file'], allow_pickle=False) as data: + reuse = corrected_reference.no_op_receipt(data, literal['result']) + harness.write(case_root/'correction_trace.json', reuse) + if reuse['proved_no_op']: + from gputls import core + destination = case_root/backend + destination.mkdir() + shutil.copy2(native_path.parent/literal['arrays_file'], destination/literal['arrays_file']) + copied = dict(literal, backend=backend, elapsed_seconds=0., + reused_execution_elapsed_seconds=literal['elapsed_seconds'], + reference_reuse=dict(literal_record_sha256=harness.sha(native_path), **reuse), + result=dict(literal['result'], reference_correction=corrected_reference.identity(core))) + harness.write(destination/'record.json', copied) + outcomes[backend] = 'ok' + continue + try: + harness.run(SimpleNamespace(case=path, backend=backend, mode='full', + engine_root=repo_root, engine_kind='public', positive_origin=True, auto_grid=False, + reference_record=case_root/'gtls_corrected/record.json' if backend == 'candidate' else None, + options=options, work_chunk=256, chunk_policy='default', seal=seal, out=case_root/backend, replay=replay)) + outcomes[backend] = 'ok' + except Exception as error: + outcomes[backend] = str(error) + comparisons = {} + for backend, filename in (('gtls_corrected', 'compare.json'), ('gtls', 'literal_compare.json')): + if all((case_root/b/'record.json').exists() for b in (backend, 'candidate')): + harness.compare(SimpleNamespace(reference=case_root/backend/'record.json', candidate=case_root/'candidate/record.json', + gates=gates, threshold=list(thresholds), out=case_root/filename)) + comparisons[backend] = json.loads((case_root/filename).read_text()) + return dict(statuses=outcomes, corrected_reference_reused=bool(reuse and reuse['proved_no_op']), + passed=comparisons.get('gtls_corrected',{}).get('passed'), + comparison_status=comparisons.get('gtls_corrected',{}).get('status'), + literal_passed=comparisons.get('gtls',{}).get('passed')) diff --git a/benchmarks/tls_reference/corrected_reference.py b/benchmarks/tls_reference/corrected_reference.py new file mode 100644 index 00000000..43ce6369 --- /dev/null +++ b/benchmarks/tls_reference/corrected_reference.py @@ -0,0 +1,90 @@ +"""Auditable correction of pinned GTLS's full-mode masked-candidate defect. + +The installed reference is never modified. A temporary host function differs +only by filtering finite unmasked candidates before ranking. +Templates, CUDA source, duration unions, scoring and final fitting are native. +""" +from contextlib import contextmanager +import hashlib +import inspect +from pathlib import Path + +import numpy as np + + +CORRECTION_ID = 'finite_candidates_before_ranking_v1' +REFERENCE_COMMIT = '74e449c325792a763dde4fbffab98039c5e8c111' + + +def corrected_source(source): + old = ' combined = list(enumerate(zip(periods, -power)))' + new = old + '\n combined = [item for item in combined if not np.ma.is_masked(item[1][0]) and not np.ma.is_masked(item[1][1]) and np.isfinite(item[1][0]) and np.isfinite(item[1][1])]' + if source.count(old) != 1: + raise ValueError('Pinned native host function differs from the audited correction sites') + return source.replace(old, new) + + +def identity(core): + source = inspect.getsource(core.search_multi_periods) + changed = corrected_source(source) + return dict(correction=CORRECTION_ID, reference_commit=REFERENCE_COMMIT, + adapter_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + original_function_sha256=hashlib.sha256(source.encode()).hexdigest(), + corrected_function_sha256=hashlib.sha256(changed.encode()).hexdigest(), + changes=['Exclude masked/nonfinite periods and scores before the native stable sort'], + installed_source_unchanged=True, cuda_source_unchanged=True) + + +@contextmanager +def apply(core): + original = core.search_multi_periods + provenance = identity(core) + namespace = {} + replacement = corrected_source(inspect.getsource(original)) + # Retain the actual native filename so read-only return profiling also + # captures the corrected host's unchanged refinement/kernel outputs. + exec(compile(replacement, core.__file__, 'exec'), core.__dict__, namespace) + core.search_multi_periods = namespace['search_multi_periods'] + try: + yield provenance + finally: + core.search_multi_periods = original + + +def finite_candidate_indices(periods, power): + """Independent literal host selection, used only to prove no-op reuse.""" + combined = list(enumerate(zip(np.ma.asarray(periods), -np.ma.asarray(power)))) + combined = [item for item in combined if not np.ma.is_masked(item[1][0]) and + not np.ma.is_masked(item[1][1]) and np.isfinite(item[1][0]) and np.isfinite(item[1][1])] + ranked = sorted(combined, key=lambda item: item[1][1]) + first = [item[0] for item in ranked[:100]] + remaining = [item for item in ranked if item[0] not in first and item[1][0] > 1] + second = [item[0] for item in sorted(remaining, key=lambda item: item[1][1])[:100]] + return np.asarray(first+second, dtype=np.int64) + + +def no_op_receipt(arrays, result): + """Sufficient trace evidence that the correction cannot change this run. + + Source/inputs must also be the same. API errors never qualify for reuse. + The candidate ranking and all actually executed period inputs must remain + physical, and both chosen-period reads must already be unmasked/finite. + """ + periods = np.ma.array(arrays['periods'], mask=arrays['stage0_chi2_mask']) + power = np.ma.array(arrays['stage0_power'], mask=arrays['stage0_power_mask']) + expected = finite_candidate_indices(periods, power) + actual = arrays['refinement_indices'] + checks = dict(candidate_indices_equal=np.array_equal(expected, actual), + no_nonfinite_first_refinement=bool(np.all(np.isfinite(arrays['refinement0_periods']))), + no_nonfinite_harmonic_refinement=bool(np.all(np.isfinite(arrays['refinement1_periods']))), + first_selected_period_unmasked=result['stages'][2].get('preceding_selected_period_masked') is False, + first_selected_period_finite=result['stages'][2].get('preceding_selected_period_finite') is True, + final_selected_period_finite=result.get('period') is not None and np.isfinite(result['period'])) + if checks['candidate_indices_equal']: + checks['first_refinement_inputs_equal'] = np.array_equal( + np.asarray(periods)[expected], arrays['refinement0_periods']) + else: + checks['first_refinement_inputs_equal'] = False + return dict(correction=CORRECTION_ID, proved_no_op=all(checks.values()), checks=checks, + expected_candidates=len(expected), actual_candidates=len(actual), + scope='Exact source/inputs plus complete selection trace; no statistical outcome-based reuse') diff --git a/benchmarks/tls_reference/inputs.py b/benchmarks/tls_reference/inputs.py new file mode 100644 index 00000000..948467d4 --- /dev/null +++ b/benchmarks/tls_reference/inputs.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Export and restore exact frozen TLS inputs using a deduplicated array bank. + +Only NumPy and the standard library are needed. Array identity uses the frozen +validation convention: SHA-256 of JSON dtype, JSON shape, and contiguous bytes. +Restoration never regenerates a signal, cadence, noise realization, or grid. +""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import platform +import re +import shutil +import tempfile +import zipfile + +import numpy as np + + +ARRAY_KEYS = frozenset(('t', 'y', 'dy', 'periods', 'signal', 'exposure_days', 'band')) +HASH = re.compile(r'[0-9a-f]{64}') +LABEL = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]*') +CASE_FILE = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]*\.npz') + + +def sha(path): + result = hashlib.sha256() + with Path(path).open('rb') as source: + for block in iter(lambda: source.read(1024 * 1024), b''): + result.update(block) + return result.hexdigest() + + +def array_hash(value): + """The original validate.py identity, including dtype and shape.""" + value = np.ascontiguousarray(value) + result = hashlib.sha256() + result.update(json.dumps(value.dtype.descr if value.dtype.names else value.dtype.str).encode()) + result.update(json.dumps(value.shape).encode()) + result.update(value.tobytes()) + return result.hexdigest() + + +def write(path, value): + Path(path).write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + '\n') + + +def safe_relative(name): + if not isinstance(name, str) or '\\' in name: + raise ValueError('Invalid relative artifact path') + path = Path(name) + if path.is_absolute() or '..' in path.parts or str(path) != name or name in ('', '.'): + raise ValueError('Invalid relative artifact path') + return path + + +def artifact(root, name): + root = Path(root).resolve() + path = root / safe_relative(name) + if path.is_symlink() or root not in path.resolve().parents: + raise ValueError('Artifact escapes its directory or is a symlink') + return path + + +def check_manifest(manifest, *, allow_derived=False): + if not isinstance(manifest.get('cases'), list) or not manifest['cases']: + raise ValueError('A frozen manifest needs at least one case') + controlled = (allow_derived and manifest.get('suite') == 'controlled-development' and + 'rule' in manifest and 'endpoints' in manifest) + if not controlled and (manifest.get('status') != 'complete' or 'source_identity' not in manifest or 'seal_sha256' not in manifest): + raise ValueError('Use a complete frozen input manifest with its original identity and seal') + names = set() + for case in manifest['cases']: + filename = case['file'] + if not isinstance(filename, str) or not CASE_FILE.fullmatch(filename) or filename in names: + raise ValueError('Case filenames must be distinct safe plain NPZ filenames') + names.add(filename) + if case['metadata'].get('name') != filename[:-4]: + raise ValueError('Case metadata name differs from its filename') + if 'arrays' not in case and controlled and case['metadata'].get('purpose') == 'mathematical_differential': + pass # Only this explicitly opted-in control format lacks prior array hashes. + elif set(case.get('arrays', {})) != ARRAY_KEYS or not all(HASH.fullmatch(v) for v in case['arrays'].values()): + raise ValueError('Frozen TLS case must identify all seven numerical arrays') + if not HASH.fullmatch(case['sha256']): + raise ValueError('Invalid original NPZ SHA-256') + return manifest + + +def check_arrays(arrays, expected): + if set(arrays) != set(expected): + raise ValueError('Numerical array keys differ from the frozen manifest') + for name, value in arrays.items(): + if value.dtype.hasobject: + raise ValueError('Object arrays are not portable numerical input') + if array_hash(value) != expected[name]: + raise ValueError('Numerical array identity mismatch: ' + name) + + +def source_case(folder, case, *, derive_missing=False): + path = artifact(folder, case['file']) + if sha(path) != case['sha256']: + raise ValueError('Original NPZ bytes differ from the frozen manifest: ' + case['file']) + with np.load(path, allow_pickle=False) as data: + if set(data.files) != ARRAY_KEYS | {'metadata'}: + raise ValueError('Unexpected original NPZ fields: ' + case['file']) + arrays = {name: data[name] for name in sorted(ARRAY_KEYS)} + if json.loads(str(data['metadata'])) != case['metadata']: + raise ValueError('Original NPZ metadata differs from the frozen manifest: ' + case['file']) + if 'arrays' in case: + check_arrays(arrays, case['arrays']) + elif not derive_missing or any(value.dtype.hasobject for value in arrays.values()): + raise ValueError('Original case has no numerical identities; explicit controlled-development derivation is required') + return arrays + + +def new_staging(output): + output = Path(output).absolute() + if output.exists() or output.is_symlink(): + raise ValueError('Refuse to overwrite an existing output directory') + output.parent.mkdir(parents=True, exist_ok=True) + return output, Path(tempfile.mkdtemp(prefix='.' + output.name + '-', dir=output.parent)) + + +def publish(staging, output): + if output.exists() or output.is_symlink(): + raise ValueError('Output directory appeared during preparation') + staging.rename(output) + + +def export_bank(studies, output, *, derive_missing_array_hashes=False): + """Verify original cases and store each distinct numerical array once.""" + output, staging = new_staging(output) + try: + (staging / 'manifests').mkdir() + arrays, origins = {}, {} + original_bytes = array_uses = case_count = derived_uses = 0 + with zipfile.ZipFile(staging / 'arrays.npz', 'w', compression=zipfile.ZIP_DEFLATED, + compresslevel=6, allowZip64=True) as bank: + for label, path in studies: + if not LABEL.fullmatch(label) or label in origins: + raise ValueError('Study labels must be distinct safe names') + path = Path(path) + original = path.read_bytes() + manifest = check_manifest(json.loads(original), allow_derived=derive_missing_array_hashes) + stored = 'manifests/' + label + '.json' + (staging / stored).write_bytes(original) + origins[label] = dict(manifest=stored, + manifest_sha256=hashlib.sha256(original).hexdigest(), + cases=len(manifest['cases']), derived_array_hashes={}) + for case in manifest['cases']: + values = source_case(path.parent, case, derive_missing=derive_missing_array_hashes) + expected = case.get('arrays') + if expected is None: + expected = {name: array_hash(value) for name, value in values.items()} + origins[label]['derived_array_hashes'][case['file']] = expected + derived_uses += len(expected) + original_bytes += (path.parent / case['file']).stat().st_size + case_count += 1 + for name, value in values.items(): + identity = expected[name] + array_uses += 1 + if identity in arrays: + continue + value = np.ascontiguousarray(value) + data = io.BytesIO() + np.save(data, value, allow_pickle=False) + member = identity + '.npy' + # Fixed ZIP metadata makes the bank itself deterministic + # in one compression environment; values, not compressed + # container bytes, are the portable numerical contract. + info = zipfile.ZipInfo(member, (1980, 1, 1, 0, 0, 0)) + info.external_attr = 0o600 << 16 + bank.writestr(info, data.getvalue(), compress_type=zipfile.ZIP_DEFLATED, + compresslevel=6) + arrays[identity] = dict(member=member, dtype=value.dtype.str, + shape=list(value.shape), nbytes=value.nbytes) + if not origins: + raise ValueError('Provide at least one frozen study') + bank_path = staging / 'arrays.npz' + inventory = dict(schema_version=1, format='cuvarbase-frozen-tls-arrays', + array_hash_convention='sha256(JSON dtype + JSON shape + C-contiguous bytes)', + arrays_file='arrays.npz', arrays_file_sha256=sha(bank_path), + arrays_file_bytes=bank_path.stat().st_size, arrays=arrays, studies=origins) + write(staging / 'bank.json', inventory) + receipt = dict(schema_version=1, exporter_sha256=sha(__file__), + python=platform.python_version(), numpy=np.__version__, + original_studies={name: origin['manifest_sha256'] for name, origin in origins.items()}, + verified_original_cases=case_count, verified_original_array_uses=array_uses, + array_uses_with_original_digest=array_uses - derived_uses, + array_uses_derived_after_original_npz_hash_verification=derived_uses, + unique_arrays=len(arrays), unique_array_bytes=sum(row['nbytes'] for row in arrays.values()), + original_npz_bytes=original_bytes, bank_npz_bytes=bank_path.stat().st_size, + original_npz_metadata_equal=True, all_original_numerical_arrays_equal=True, + interpretation='Exact stored numerical inputs; no signal regeneration or new independent cases') + write(staging / 'export.json', receipt) + paths = sorted(p for p in staging.rglob('*') if p.is_file()) + (staging / 'SHA256SUMS').write_text(''.join( + sha(p) + ' ' + p.relative_to(staging).as_posix() + '\n' for p in paths)) + verify_bank(staging) + publish(staging, output) + return dict(receipt, output=str(output), total_bundle_bytes=sum( + p.stat().st_size for p in output.rglob('*') if p.is_file())) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def checksums(folder): + paths = set() + for line in artifact(folder, 'SHA256SUMS').read_text().splitlines(): + parts = line.split(' ', 1) + if len(parts) != 2 or not HASH.fullmatch(parts[0]) or parts[1] in paths: + raise ValueError('Malformed or duplicate checksum entry') + path = artifact(folder, parts[1]) + if sha(path) != parts[0]: + raise ValueError('Bundle file checksum mismatch: ' + parts[1]) + paths.add(parts[1]) + if not {'bank.json', 'arrays.npz', 'export.json'}.issubset(paths): + raise ValueError('Bundle checksums omit required artifacts') + return paths + + +def verify_bank(folder, *, return_arrays=False): + """Check bundle, original manifests, and every stored dtype/shape/value hash.""" + folder = Path(folder) + checked = checksums(folder) + inventory = json.loads(artifact(folder, 'bank.json').read_text()) + if inventory.get('schema_version') != 1 or inventory.get('format') != 'cuvarbase-frozen-tls-arrays': + raise ValueError('Unknown frozen-input bank format') + if inventory.get('arrays_file') != 'arrays.npz': + raise ValueError('Unexpected bank array filename') + path = artifact(folder, 'arrays.npz') + if path.stat().st_size != inventory['arrays_file_bytes'] or sha(path) != inventory['arrays_file_sha256']: + raise ValueError('Numerical bank container checksum mismatch') + needed, manifests = set(), {} + for label, origin in inventory['studies'].items(): + if not LABEL.fullmatch(label) or origin['manifest'] != 'manifests/' + label + '.json': + raise ValueError('Unsafe study manifest path') + if origin['manifest'] not in checked: + raise ValueError('Original manifest missing from bundle checksums') + manifest_path = artifact(folder, origin['manifest']) + if sha(manifest_path) != origin['manifest_sha256']: + raise ValueError('Original manifest checksum mismatch') + derived = origin.get('derived_array_hashes', {}) + manifest = check_manifest(json.loads(manifest_path.read_text()), allow_derived=bool(derived)) + if len(manifest['cases']) != origin['cases']: + raise ValueError('Original manifest case count mismatch') + missing = {case['file'] for case in manifest['cases'] if 'arrays' not in case} + if set(derived) != missing: + raise ValueError('Derived identities must describe only original controls lacking array hashes') + for expected in derived.values(): + if set(expected) != ARRAY_KEYS or not all(HASH.fullmatch(v) for v in expected.values()): + raise ValueError('Incomplete derived numerical identities') + needed.update(value for case in manifest['cases'] + for value in case.get('arrays', derived.get(case['file'], {})).values()) + manifests[label] = manifest + if not manifests or set(inventory['arrays']) != needed: + raise ValueError('Bank arrays do not match the complete original populations') + values = {} + with zipfile.ZipFile(path) as archive: + members = archive.namelist() + expected = {identity + '.npy' for identity in needed} + if len(members) != len(set(members)) or set(members) != expected: + raise ValueError('Unexpected or duplicate array archive members') + for identity, descriptor in inventory['arrays'].items(): + if not HASH.fullmatch(identity) or descriptor['member'] != identity + '.npy': + raise ValueError('Invalid array identity/member') + with archive.open(descriptor['member']) as source: + value = np.load(source, allow_pickle=False) + if (value.dtype.hasobject or value.dtype.str != descriptor['dtype'] or + list(value.shape) != descriptor['shape'] or value.nbytes != descriptor['nbytes']): + raise ValueError('Stored array dtype/shape differs from its descriptor') + if array_hash(value) != identity: + raise ValueError('Stored numerical array identity mismatch') + if return_arrays: + values[identity] = value + receipt = dict(studies=len(manifests), cases=sum(len(m['cases']) for m in manifests.values()), + array_uses=sum(len(ARRAY_KEYS) for m in manifests.values() for c in m['cases']), + unique_arrays=len(needed), all_numerical_identities_verified=True, + bank_manifest_sha256=sha(folder / 'bank.json'), arrays_file_sha256=sha(path)) + return (inventory, manifests, values, receipt) if return_arrays else receipt + + +def restore_bank(folder, study, manifest_path, output): + """Restore one complete study with cases.py-compatible provenance receipts.""" + inventory, manifests, values, verified = verify_bank(folder, return_arrays=True) + if study not in manifests: + raise ValueError('Unknown frozen study: ' + study) + manifest_path = Path(manifest_path) + original = manifest_path.read_bytes() + original_sha = hashlib.sha256(original).hexdigest() + if original_sha != inventory['studies'][study]['manifest_sha256']: + raise ValueError('Requested original manifest differs from the frozen bank study') + derived = inventory['studies'][study].get('derived_array_hashes', {}) + manifest = check_manifest(json.loads(original), allow_derived=bool(derived)) + output, staging = new_staging(output) + try: + receipts = [] + reproduced_cases = [] + for case in manifest['cases']: + expected = case.get('arrays', derived.get(case['file'], {})) + arrays = {name: values[identity] for name, identity in expected.items()} + check_arrays(arrays, expected) + target = staging / case['file'] + np.savez_compressed(target, **arrays, metadata=json.dumps(case['metadata'], sort_keys=True)) + restored_case = dict(case, arrays=expected, original_npz_sha256=case['sha256'], sha256=sha(target)) + if 'arrays' not in case: + restored_case['array_identity_basis'] = 'Derived from original NPZ after original container hash and metadata verification' + # Read each written container back and verify metadata plus every + # original numerical identity before publishing any usable manifest. + source_case(staging, restored_case) + reproduced_cases.append(restored_case) + receipts.append(dict(name=case['metadata']['name'], numerical_arrays_equal=True, + original_npz_sha256=case['sha256'], regenerated_npz_sha256=restored_case['sha256'])) + (staging / 'original_manifest.json').write_bytes(original) + reproduced = dict(manifest, suite='reproduction', original_suite=manifest['suite'], original_manifest_sha256=original_sha, + cases=reproduced_cases) + write(staging / 'manifest.json', reproduced) + write(staging / 'reproduction.json', dict( + original_manifest_sha256=original_sha, generator_sha256=sha(__file__), + restorer_sha256=sha(__file__), reproduction_method='exact-array-bank', + original_generator_sha256=manifest.get('source_identity', {}).get('generator_sha256'), + bank_manifest_sha256=verified['bank_manifest_sha256'], + bank_arrays_sha256=verified['arrays_file_sha256'], study=study, cases=receipts, + cases_with_derived_array_hashes=sorted(derived), + interpretation='Reproduced frozen numerical inputs from exact stored arrays, not newly independent observations')) + publish(staging, output) + return dict(study=study, cases=len(receipts), numerical_array_uses=len(receipts) * len(ARRAY_KEYS), + every_restored_array_verified=True, output=str(output), + manifest_sha256=sha(output / 'manifest.json'), original_manifest_sha256=original_sha) + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + export = commands.add_parser('export', help='Build an immutable bank from original NPZs and manifests') + export.add_argument('--study', nargs=2, action='append', required=True, metavar=('NAME', 'MANIFEST')) + export.add_argument('--out', type=Path, required=True) + export.add_argument('--derive-missing-array-hashes', action='store_true', + help='Explicitly derive absent array hashes for mathematical controlled-development cases only, after original NPZ hash verification') + verify = commands.add_parser('verify', help='Verify all bank files and every numerical array identity') + verify.add_argument('--bank', type=Path, required=True) + restore = commands.add_parser('restore', help='Restore one complete frozen study without signal regeneration') + restore.add_argument('--bank', type=Path, required=True) + restore.add_argument('--study', required=True) + restore.add_argument('--manifest', type=Path, required=True, help='Original published input manifest; bytes must match bank') + restore.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + if args.command == 'export': + result = export_bank(args.study, args.out, derive_missing_array_hashes=args.derive_missing_array_hashes) + elif args.command == 'verify': + result = verify_bank(args.bank) + else: + result = restore_bank(args.bank, args.study, args.manifest, args.out) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/profile_host_optimizations.py b/benchmarks/tls_reference/profile_host_optimizations.py new file mode 100644 index 00000000..ba997104 --- /dev/null +++ b/benchmarks/tls_reference/profile_host_optimizations.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Reproduce isolated exact host optimizations against a recorded git source. + +This is a CPU diagnostic, not a GPU or public-search speed denominator. +Run with numerical-library threads limited to one, for example: +OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 python -m \ + benchmarks.tls_reference.profile_host_optimizations --output profile.json +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import statistics +import subprocess +import time +import tracemalloc +import types + +import numpy as np + +from cuvarbase import tls_reference_math as candidate + + +BASELINE_REVISION = '6ced75d6d75bfaafa39b78c557fcba86f4651d92' + + +def digest(array): + return hashlib.sha256(np.ascontiguousarray(array).tobytes()).hexdigest() + + +def profile_pair(old, new, args, repetitions): + expected, actual = old(*args), new(*args) + np.testing.assert_array_equal(actual, expected) + elapsed = {'baseline': [], 'candidate': []} + for repetition in range(repetitions): + pairs = [('baseline', old), ('candidate', new)] + if repetition % 2: + pairs.reverse() + for label, function in pairs: + begin = time.perf_counter() + output = function(*args) + elapsed[label].append(time.perf_counter() - begin) + np.testing.assert_array_equal(output, expected) + memory = {} + for label, function in [('baseline', old), ('candidate', new)]: + tracemalloc.start() + output = function(*args) + memory[label] = tracemalloc.get_traced_memory()[1] + tracemalloc.stop() + np.testing.assert_array_equal(output, expected) + medians = {key: statistics.median(value) for key, value in elapsed.items()} + return dict(exact=True, output_sha256=digest(expected), + elapsed_seconds=elapsed, median_seconds=medians, + median_speedup=medians['baseline'] / medians['candidate'], + peak_tracemalloc_bytes=memory, + memory_scope='Separate untimed call; Python/NumPy tracked temporary allocations, excluding inputs') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-revision', default=BASELINE_REVISION) + parser.add_argument('--repetitions', type=int, default=5) + args = parser.parse_args() + if args.repetitions < 1: + parser.error('--repetitions must be positive') + root = Path(__file__).resolve().parents[2] + source = subprocess.check_output( + ['git', 'show', args.baseline_revision + ':cuvarbase/tls_reference_math.py'], cwd=root) + baseline = types.ModuleType('tls_reference_math_baseline') + exec(compile(source, '', 'exec'), baseline.__dict__) + record = dict(scope='Isolated CPU diagnostic; no end-to-end GPU speed claim', + baseline_revision=args.baseline_revision, + baseline_source_sha256=hashlib.sha256(source).hexdigest(), + candidate_source_sha256=hashlib.sha256(Path(candidate.__file__).read_bytes()).hexdigest(), + harness_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + environment=dict(python=platform.python_version(), numpy=np.__version__, + system=platform.platform(), machine=platform.machine(), + thread_settings={key: os.environ.get(key) for key in + ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS')}), + seed=912, repetitions=args.repetitions, candidate_ranking=[], duration_unions=[]) + for size in (2325, 74616, 235266, 1093617): + rng = np.random.default_rng(record['seed'] + size) + periods = np.linspace(.5, 50., size) + power = np.ma.array(rng.normal(size=size).astype(np.float32), + mask=rng.random(size) < .025) + result = profile_pair(baseline.refinement_candidate_indices, + candidate.refinement_candidate_indices, + (periods, power), args.repetitions) + record['candidate_ranking'].append(dict(nperiods=size, **result)) + widths = np.arange(2, 142, 2) + minima = rng.integers(0, 14, size) + maxima = rng.integers(14, 144, size) + result = profile_pair(baseline.chunk_width_masks, candidate.chunk_width_masks, + (widths, minima, maxima, max(1, size // 30)), args.repetitions) + record['duration_unions'].append(dict(nperiods=size, nwidths=len(widths), **result)) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(record, indent=2) + '\n') + print(json.dumps(dict(nperiods=size, + candidate_ranking=record['candidate_ranking'][-1]['median_speedup'], + duration_unions=result['median_speedup'])), flush=True) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/reproduce.py b/benchmarks/tls_reference/reproduce.py new file mode 100644 index 00000000..40a03bdb --- /dev/null +++ b/benchmarks/tls_reference/reproduce.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Reproduce every published TLS comparison with an elapsed-time ceiling.""" +import argparse +import json +from pathlib import Path +import signal +from types import SimpleNamespace +import time + +import validate as harness +import summarize +from comparison import run_case + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--seal', type=Path, required=True, help='Original published seal supplies options and gates; it is never rewritten.') + parser.add_argument('--repo-root', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--max-seconds', type=float, default=8400.) + args = parser.parse_args() + if args.out.exists() or args.max_seconds <= 0: + parser.error('Require a new output directory and a positive elapsed-time limit') + seal = json.loads(args.seal.read_text()) + manifest = json.loads(args.manifest.read_text()) + if manifest.get('suite') != 'reproduction' or manifest.get('seal_sha256') != harness.sha(args.seal): + parser.error('Use cases.py --replay-manifest to regenerate the published numerical inputs first') + if seal['modes'] != ['full'] or seal['engine_kind'] != 'public' or seal['chunk_policy'] != 'default': + parser.error('This frozen cohort requires the actual public full default') + args.out.mkdir(parents=True) + options_path, gates_path = args.out/'options.json', args.out/'gates.json' + harness.write(options_path, seal['options']) + harness.write(gates_path, seal['gates']) + started = time.perf_counter() + state = dict(seal_sha256=harness.sha(args.seal), manifest_sha256=harness.sha(args.manifest), + started_epoch=time.time(), elapsed_limit_seconds=args.max_seconds, planned=len(manifest['cases']), + cases=[], retention='Keep every record and both comparisons; retain full arrays for all nonpassing literal or corrected pairs ' + 'and the first passing injection/null in each regime. Matching discarded arrays retain original hashes.') + retained_signatures = set() + + def timeout(signum, frame): + raise TimeoutError('Frozen cohort elapsed-time ceiling reached') + signal.signal(signal.SIGALRM, timeout) + signal.setitimer(signal.ITIMER_REAL, args.max_seconds) + try: + for case in manifest['cases']: + if time.perf_counter()-started > args.max_seconds-180: + state['status'] = 'incomplete_elapsed_time_ceiling' + break + path = args.manifest.parent/case['file'] + if harness.sha(path) != case['sha256']: + raise ValueError('Frozen input bytes changed: '+str(path)) + name = case['metadata']['name'] + case_root = args.out/name + row = dict(name=name, statuses={}) + state['cases'].append(row) + harness.write(args.out/'progress.json', state) + outcome = run_case(path, args.repo_root, case_root, seal=args.seal, + options=options_path, gates=gates_path, thresholds=seal['thresholds'], replay=True) + row.update(outcome) + signature = (case['metadata']['regime'], case['metadata']['null']) + keep = not row.get('passed') or not row.get('literal_passed') or signature not in retained_signatures + if keep and row.get('passed') and row.get('literal_passed'): + retained_signatures.add(signature) + elif not keep: + removed = [] + for backend in ('gtls', 'gtls_corrected', 'candidate'): + record = json.loads((case_root/backend/'record.json').read_text()) + artifact = case_root/backend/record['arrays_file'] + if harness.sha(artifact) != record['arrays_sha256']: + raise ValueError('Array bytes changed before planned retention step') + removed.append(dict(file=str(artifact.relative_to(case_root)), sha256=record['arrays_sha256'])) + artifact.unlink() + harness.write(case_root/'retention.json', dict(all_checks_passed=True, arrays_removed=removed, + reason='Predeclared compact retention; complete output arrays compared before removal and hashes retained')) + row['full_arrays_retained'] = keep + row['elapsed_since_start_seconds'] = time.perf_counter()-started + harness.write(args.out/'progress.json', state) + print(json.dumps(row), flush=True) + if row.get('passed') is False and row.get('comparison_status') == 'compared': + state.update(status='stopped_numerical_or_public_contract_disagreement', first_failure=name) + break + else: + state['status'] = 'complete' + except TimeoutError as error: + state.update(status='incomplete_elapsed_time_ceiling', error=str(error)) + except Exception as error: + state.update(status='stopped_execution_or_protocol_error', error=repr(error)) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + state['elapsed_seconds'] = time.perf_counter()-started + harness.write(args.out/'progress.json', state) + rows, tables = [], [] + for reference_backend, comparison_file in (('gtls_corrected', 'compare.json'), ('gtls', 'literal_compare.json')): + for threshold in seal['thresholds']: + selected = [summarize.read_case(case, args.out, threshold, reference_backend, comparison_file) + for case in manifest['cases']] + rows.extend(selected) + tables.extend([dict(value, threshold=threshold) for value in + summarize.strata(selected, alpha=.05/(2*len(seal['thresholds'])))]) + harness.write(args.out/'summary.json', dict(state=state, strata=tables, cases=rows, + classification='Reproduction, not newly independent validation', primary_endpoint='Every complete spectrum, refinement and selected period against the corrected native host; ' + 'actual public API contract; literal native outcomes retained separately.', + warning='Small per-regime recovery samples are a cross-check, not a tight population-level sensitivity guarantee. ' + 'The masked-candidate host correction can change literal GTLS thresholds and outcomes.')) + accounted = len(state['cases']) == len(manifest['cases']) and all( + set(r['statuses']) == {'gtls', 'gtls_corrected', 'candidate'} for r in state['cases']) + unresolved = [r['name'] for r in state['cases'] if + r.get('comparison_status') == 'compared' and r.get('passed') is not True] + candidate_regressions = [r['name'] for r in state['cases'] if + r['statuses'].get('gtls_corrected') == 'ok' and r['statuses'].get('candidate') != 'ok'] + paired = sum(r.get('passed') is True for r in state['cases']) + failures = [{k:v for k,v in r.items() if k in ('name','statuses','comparison_status')} + for r in state['cases'] if any(value != 'ok' for value in r['statuses'].values())] + gate = (accounted and state.get('status') == 'complete' and not unresolved and + not candidate_regressions and paired == len(manifest['cases'])) + harness.write(args.out/'acceptance.json', dict(schema_version=1, + reproduction_gate=dict(pass_=gate), inputs_manifest_sha256=harness.sha(args.manifest), + seal_sha256=harness.sha(args.seal), original_source_identity=seal['source_identity'], + reproduction_sources=dict(production=harness.production_sources(args.repo_root), + tools={p.name:harness.sha(p) for p in Path(__file__).parent.glob('*.py')}), + reference_package_sources=seal['reference_package_sources'], + counts=dict(planned=len(manifest['cases']), accounted=len(state['cases']), numerical_pairs_passed=paired, + literal_exact_pairs=sum(r.get('literal_passed') is True for r in state['cases']), + corrected_reference_reused=sum(r.get('corrected_reference_reused') is True for r in state['cases'])), + all_planned_accounted=accounted, unresolved_numerical_cases=unresolved, + candidate_regressions=candidate_regressions, execution_failures=failures, + limits=['Finite tested input population, not all possible inputs', + 'Exact equality refers to the separately disclosed native host correction', + 'The small recovery/null sample does not establish a 2 percentage-point population margin', + 'SDE8 is descriptive, not a calibrated fixed false-positive rate', + 'Native unsupported cases are explicit and do not count as numerical parity successes'])) + acceptance = json.loads((args.out/'acceptance.json').read_text()) + acceptance['reproduction_gate']['pass'] = acceptance['reproduction_gate'].pop('pass_') + harness.write(args.out/'acceptance.json', acceptance) + + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/summarize.py b/benchmarks/tls_reference/summarize.py new file mode 100644 index 00000000..98dbe9f9 --- /dev/null +++ b/benchmarks/tls_reference/summarize.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Summarize every planned pair, retaining failures and per-regime uncertainty.""" +import argparse +from collections import defaultdict +import csv +import json +from pathlib import Path + +import numpy as np +from scipy.stats import beta + +from validate import sha, write + + +def binomial_interval(successes, count, alpha=.05): + if not 0 <= successes <= count: + raise ValueError('Require 0 <= successes <= count') + if count == 0: + return [None, None] + return [0. if successes == 0 else float(beta.ppf(alpha/2, successes, count-successes+1)), + 1. if successes == count else float(beta.ppf(1-alpha/2, successes+1, count-successes))] + + +def discordance_upper(discordances, count, alpha): + if not 0 <= discordances <= count or not 0 < alpha < 1: + raise ValueError('Invalid binomial count or tail probability') + if count == 0: + return None + return 1. if discordances == count else float(beta.ppf(1-alpha, discordances+1, count-discordances)) + + +def recovered(period, metadata, alias=1.): + if period is None or not np.isfinite(period): + return False + truth = metadata['truth_period']*alias + drift = abs(float(period)-truth)/truth*metadata['baseline_days'] + return drift <= .5*metadata['duration_days'] + + +def read_case(case, root, threshold, reference_backend='gtls', comparison_file='compare.json'): + metadata = case['metadata'] + name = metadata['name'] + row = dict(name=name, regime=metadata['regime'], cohort=metadata['cohort'], purpose=metadata['purpose'], + null=metadata['null'], snr=metadata['latent_white_oracle_snr'], + threshold=threshold, input_sha256=case['sha256'], observable=metadata['observable'], + accepted_proposal=metadata['accepted_proposal'], + below_approx_native_duration_envelope=metadata['below_approx_native_duration_envelope']) + row['reference_backend'] = reference_backend + for backend in ('gtls', 'candidate'): + source_backend = reference_backend if backend == 'gtls' else backend + path = root/name/source_backend/'record.json' + if not path.exists(): + row[backend+'_status'] = 'missing' + continue + record = json.loads(path.read_text()) + if record['input_sha256'] != case['sha256']: + raise ValueError('Recorded input hash differs from planned case: '+str(path)) + row[backend+'_record_sha256'] = sha(path) + row[backend+'_status'] = record['status'] + row[backend+'_seconds'] = record.get('elapsed_seconds') + if record['status'] != 'ok': + row[backend+'_error'] = record.get('error') + continue + result = record['result'] + score, period = result.get('score'), result.get('period') + detected = score is not None and score > threshold + row.update({backend+'_mode': record['mode'], backend+'_score': score, backend+'_period': period, + backend+'_detected': detected, + backend+'_primary_period_match': recovered(period, metadata), + backend+'_alias_match': any(recovered(period, metadata, a) for a in (.5, 2., 2/3, 1.5)), + backend+'_recovered': detected and recovered(period, metadata), + backend+'_native_snr': result.get('final_fit', {}).get('native_gtls_snr')}) + compare = root/name/comparison_file + if compare.exists(): + result = json.loads(compare.read_text()) + for backend, field in (('gtls', 'reference_record_sha256'), ('candidate', 'candidate_record_sha256')): + if field in result and result[field] != row.get(backend+'_record_sha256'): + raise ValueError('Comparison refers to different backend record: '+str(compare)) + row['comparison_passed'] = result['passed'] + row['comparison_sha256'] = sha(compare) + row['paired_success'] = row.get('gtls_status') == row.get('candidate_status') == 'ok' + row['reference_failure_extension'] = row.get('gtls_status') == 'error' and row.get('candidate_status') == 'ok' + if metadata['purpose'] != 'recovery_full_grid': + # No truth-inserted stress case contributes to a reported recovery rate. + for backend in ('gtls', 'candidate'): + row.pop(backend+'_recovered', None) + return row + + +def strata(rows, alpha=.05): + groups = defaultdict(list) + for row in rows: + if row['purpose'] == 'recovery_full_grid': + key = (row['regime'], 'null_mixture_6_8_10_12' if row['null'] else 'snr_%g' % row['snr']) + groups[key].append(row) + family = len(groups) + summaries = [] + for (regime, label), group in sorted(groups.items()): + paired = [r for r in group if r['paired_success']] + null = group[0]['null'] + outcome = 'detected' if null else 'recovered' + rcount = sum(r['gtls_'+outcome] for r in paired) + ccount = sum(r['candidate_'+outcome] for r in paired) + reference_evaluable = [r for r in group if r['gtls_status'] == 'ok'] + reference_all_count = sum(r['gtls_'+outcome] for r in reference_evaluable) + discord = sum((not r['gtls_detected'] and r['candidate_detected']) if null else + (r['gtls_recovered'] and not r['candidate_recovered']) for r in paired) + recovery = dict(reference_backend=group[0].get('reference_backend','gtls'), regime=regime, stratum=label, outcome='false_positive' if null else 'recovery', + planned=len(group), paired_success=len(paired), + reference_failures=sum(r['gtls_status'] == 'error' for r in group), + candidate_failures=sum(r['candidate_status'] == 'error' for r in group), + missing=sum('missing' in (r['gtls_status'], r['candidate_status']) for r in group), + reference_count=rcount, candidate_count=ccount, + reference_evaluable_count=len(reference_evaluable), + reference_all_successful_search_outcome_count=reference_all_count, + reference_all_successful_search_outcome_rate=reference_all_count/len(reference_evaluable) if reference_evaluable else None, + reference_rate=rcount/len(paired) if paired else None, + candidate_rate=ccount/len(paired) if paired else None, + reference_two_sided_95_interval=binomial_interval(rcount, len(paired)), + candidate_two_sided_95_interval=binomial_interval(ccount, len(paired)), + added_fp_or_lost_recovery=discord, + discordance_simultaneous_upper=discordance_upper(discord, len(paired), alpha/max(1, family)), + simultaneous_family=family, simultaneous_tail_alpha=alpha/max(1, family), + complete=len(paired) == len(group), + both_zero_observed_recovery=bool(not null and paired and rcount == 0 and ccount == 0), + comparison_failures=sum(r.get('comparison_passed') is False for r in group), + comparisons_missing=sum('comparison_passed' not in r for r in group)) + summaries.append(recovery) + return summaries + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--records-root', type=Path, required=True) + parser.add_argument('--threshold', type=float, action='append', required=True) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + if args.out.exists(): + parser.error('Output already exists; summaries are immutable') + manifest = json.loads(args.manifest.read_text()) + args.out.mkdir(parents=True) + rows, summaries = [], [] + for threshold in args.threshold: + selected = [read_case(c, args.records_root, threshold) for c in manifest['cases']] + rows.extend(selected) + # Multiple predeclared thresholds enlarge the simultaneous family. + table = strata(selected, alpha=.05/len(args.threshold)) + summaries.extend([dict(r, threshold=threshold) for r in table]) + with (args.out/'cases.csv').open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=sorted({k for r in rows for k in r})) + writer.writeheader() + writer.writerows(rows) + write(args.out/'summary.json', dict(manifest_sha256=sha(args.manifest), thresholds=args.threshold, + cohort=manifest['suite'], strata=summaries, + mathematical_stress_cases=sum(c['metadata']['purpose'] == 'mathematical_differential' for c in manifest['cases']), + warning='Rates use paired successful full-grid searches only; failures and missing planned cases remain explicit. ' + 'A small zero-discordance sample does not establish a tight recovery margin. ' + 'Descriptive thresholds are not a calibrated fixed false-positive rate.')) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/test_analyze_timing.py b/benchmarks/tls_reference/test_analyze_timing.py new file mode 100644 index 00000000..99241fab --- /dev/null +++ b/benchmarks/tls_reference/test_analyze_timing.py @@ -0,0 +1,191 @@ +"""Accounting gates for the public figure, using fictitious timing receipts.""" +import copy + +import pytest + +from analyze_timing import PROFILES, analyze, timing_row + + +def measured(values): + return dict(raw_seconds=values, median_seconds=sorted(values)[len(values)//2]) + + +def receipts(): + manifest = dict(source_identity=dict(production_sources={'engine': 'frozen'}), cases=[]) + checks = dict(regimes={}, cohort_selection={}, environment={}, native_extras=[], + scientific_scope='Synthetic unit-test receipt; no benchmark measurement.') + for regime in PROFILES: + names = [f'{regime}_{i}.npz' for i in range(16)] + for name in names: + manifest['cases'].append(dict(file=name, sha256=name+'hash', arrays={'periods': 'gridhash'}, + metadata=dict(regime=regime, ndata=100, baseline_days=20, period_count=200, + search_kwargs={'full': True}))) + checks['cohort_selection'][regime] = dict(selected_cases=names, single_case=names[0], + actual_batch_size=16, expected_candidate_sources={'engine': 'frozen'}, + examined=[dict(case=name, input_sha256=name+'hash') for name in names]) + checks['regimes'][regime] = dict( + public_single=dict(eligible=True, case=names[0], speedup=2, + native=measured([2]*5), candidate=measured([1]*5)), + public_batch=dict(eligible=True, source_count=16, speedup=2, + strongest_tested_native_workers=2, candidate=measured([8]*3), + strongest_tested_native=measured([16]*3), + native_pool_configurations={ + '1': dict(eligible=True, elapsed=measured([32]*3)), + '2': dict(eligible=True, elapsed=measured([16]*3)), + '4': dict(eligible=False, elapsed=measured([4]*3))}), + common_search_components=dict(eligible=True)) + acceptance = dict(publication_gate={'pass': True}, inputs_manifest_sha256='manifesthash') + return checks, manifest, acceptance + + +def test_figure_uses_all_sources_and_fastest_valid_pool(): + result = analyze(*receipts(), 'manifesthash') + row = next(row for row in result['timings'] + if row['method'] == 'gtls' and row['mode'] == 'batch') + assert row['workers'] == 2 # Four workers are faster but changed results. + assert row['seconds_per_source'] == 1 + assert row['n'] == 16 + + +def single_receipts(): + checks, manifest, acceptance = receipts() + checks['measurement_scope'] = 'single' + for result in checks['regimes'].values(): + result['public_batch'] = dict(eligible=False, status='not_measured', source_count=0) + return checks, manifest, acceptance + + +def test_single_normalization_emits_only_measured_latency(): + result = analyze(*single_receipts(), 'manifesthash') + assert result['measurement_scope'] == 'single' + assert len(result['timings']) == 6 + assert all(row['mode'] == 'single' and row['n'] == 1 and row['workers'] == 1 for row in result['timings']) + assert all(row['batch_size'] is None for row in result['profiles']) + assert all(row['batch'] is None and row['gtls_batch_workers'] is None for row in result['speedups']) + + +def merged_receipts(): + checks, manifest, acceptance = single_receipts() + manifest['studies'] = {'main': dict(manifest_sha256='original', seal_sha256='seal')} + origin = dict(manifest_sha256='original', seal_sha256='seal', numerical_validation_passed=True, + evidence_kind='independent', production_sources={'engine': 'frozen'}) + merged = dict(numerical_validation_passed=True, merged_manifest_sha256='manifesthash', + accepted_studies={'main': origin}, evidence_kind='independent', publication_gate_passed=True) + acceptance = dict(merged_origin_checks=merged) + for selection in checks['cohort_selection'].values(): + selection['accepted_study'] = copy.deepcopy(merged) + return checks, manifest, acceptance + + +def test_merged_single_evidence_retains_each_accepted_origin(): + result = analyze(*merged_receipts(), 'manifesthash') + assert result['verification']['numerical_evidence_kind'] == 'independent' + assert len(result['timings']) == 6 + + +@pytest.mark.parametrize('change', ('missing_origin', 'unaccepted', 'changed_source', 'stale_selection')) +def test_merged_single_evidence_cannot_bypass_source_gates(change): + checks, manifest, acceptance = merged_receipts() + merged = acceptance['merged_origin_checks'] + if change == 'missing_origin': + merged['accepted_studies'] = {} + elif change == 'unaccepted': + merged['numerical_validation_passed'] = False + elif change == 'changed_source': + merged['accepted_studies']['main']['production_sources'] = {'engine': 'changed'} + else: + checks['cohort_selection']['tess_solar']['accepted_study']['merged_manifest_sha256'] = 'other' + with pytest.raises(ValueError): + analyze(checks, manifest, acceptance, 'manifesthash') + + +@pytest.mark.parametrize('change', ('batch_claim', 'missing_single', 'wrong_source', 'missing_component', 'missing_correction')) +def test_single_normalization_rejects_missing_or_invented_measurements(change): + checks, manifest, acceptance = single_receipts() + result = checks['regimes']['tess_solar'] + if change == 'batch_claim': + result['public_batch']['eligible'] = True + elif change == 'missing_single': + result['public_single']['native']['raw_seconds'].pop() + elif change == 'wrong_source': + result['public_single']['case'] = 'another' + elif change == 'missing_component': + result['common_search_components']['eligible'] = False + else: + checks['cohort_selection']['tess_solar']['correction_timing'] = dict(required=True) + with pytest.raises(ValueError): + analyze(checks, manifest, acceptance, 'manifesthash') + + +@pytest.mark.parametrize('change', ['input', 'sources', 'count', 'invalid_pool', 'slower_pool', 'component']) +def test_incomplete_or_mismatched_receipts_cannot_publish(change): + checks, manifest, acceptance = receipts() + selection = checks['cohort_selection']['tess_solar'] + result = checks['regimes']['tess_solar'] + if change == 'input': + selection['examined'][0]['input_sha256'] = 'different' + elif change == 'sources': + selection['expected_candidate_sources']['engine'] = 'different' + elif change == 'count': + result['public_batch']['source_count'] = 15 + elif change == 'invalid_pool': + result['public_batch']['strongest_tested_native_workers'] = 4 + elif change == 'slower_pool': + result['public_batch']['strongest_tested_native_workers'] = 1 + else: + result['common_search_components']['eligible'] = False + with pytest.raises(ValueError): + analyze(checks, manifest, acceptance, 'manifesthash') + + +def test_scientific_acceptance_is_separate_from_timing_success(): + checks, manifest, acceptance = receipts() + rejected = copy.deepcopy(acceptance) + rejected['publication_gate']['pass'] = False + with pytest.raises(ValueError, match='validation gate'): + analyze(checks, manifest, rejected, 'manifesthash') + with pytest.raises(ValueError, match='different input manifest'): + analyze(checks, manifest, acceptance, 'anothermanifest') + + +def reproduced_receipts(): + checks, manifest, acceptance = receipts() + acceptance.pop('publication_gate') + acceptance.update(reproduction_gate={'pass': True}, + original_source_identity=copy.deepcopy(manifest['source_identity']), + reproduction_sources=dict(production=copy.deepcopy(manifest['source_identity']['production_sources']))) + for selection in checks['cohort_selection'].values(): + selection['accepted_study'] = dict(evidence_kind='reproduction', + numerical_validation_passed=True, publication_gate_passed=False) + return checks, manifest, acceptance + + +def test_reproduced_timings_keep_their_evidence_label(): + result = analyze(*reproduced_receipts(), 'manifesthash') + assert result['verification']['numerical_evidence_kind'] == 'reproduction' + assert result['verification']['complete'] + assert len(result['timings']) == 12 + assert analyze(*receipts(), 'manifesthash')['verification']['numerical_evidence_kind'] == 'independent' + + +@pytest.mark.parametrize('change', ('failed', 'both_gates', 'source', 'selection', 'promotion')) +def test_reproduced_evidence_cannot_bypass_or_relabel_gates(change): + checks, manifest, acceptance = reproduced_receipts() + if change == 'failed': + acceptance['reproduction_gate']['pass'] = False + elif change == 'both_gates': + acceptance['publication_gate'] = {'pass': True} + elif change == 'source': + acceptance['reproduction_sources']['production'] = {'engine': 'changed'} + elif change == 'selection': + checks['cohort_selection']['tess_solar']['accepted_study']['publication_gate_passed'] = True + else: + acceptance['publication_gate'] = acceptance.pop('reproduction_gate') + with pytest.raises(ValueError): + analyze(checks, manifest, acceptance, 'manifesthash') + + +@pytest.mark.parametrize('values', [[], [1]*4, [0]*5, [float('nan')]*5, [float('inf')]*5]) +def test_failed_or_incomplete_repetitions_are_not_speed_denominators(values): + with pytest.raises(ValueError): + timing_row('tess_200s', 'gtls', 'single', dict(raw_seconds=values), 1, 1) diff --git a/benchmarks/tls_reference/test_inputs.py b/benchmarks/tls_reference/test_inputs.py new file mode 100644 index 00000000..8835b25d --- /dev/null +++ b/benchmarks/tls_reference/test_inputs.py @@ -0,0 +1,290 @@ +"""CPU contracts for exact frozen-input restoration; no signal/GPU execution.""" +import copy +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import zipfile + +import numpy as np +import pytest + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(filename)) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +bank = load('frozen_input_bank', 'inputs.py') +reference = load('frozen_input_hash_reference', 'validate.py') + + +def make_study(root, name, count=2): + directory = root / name + directory.mkdir() + cases = [] + for i in range(count): + case_name = name + '_%d' % i + arrays = dict(t=np.array([1., 2., 3.]), y=np.array([1., .9 + .01 * i, 1.]), + dy=np.full(3, .01), periods=np.array([1.2, 2.4, 3.6]), + signal=np.array([0., .1, 0.]), exposure_days=np.full(3, .001), + band=np.array([0, 1, 0], dtype=np.int64)) + metadata = dict(name=case_name, regime='fixture', null=False, truth_period=2.4, + nested=dict(values=[1, 2., None, True])) + target = directory / (case_name + '.npz') + np.savez_compressed(target, **arrays, metadata=json.dumps(metadata)) + cases.append(dict(file=target.name, sha256=bank.sha(target), metadata=metadata, + arrays={key: reference.array_hash(value) for key, value in arrays.items()})) + manifest = dict(suite='heldout', stream='heldout:frozen-test', source_identity={'generator_sha256': 'fixed'}, + seal_sha256='a' * 64, status='complete', cases=cases) + target = directory / 'manifest.json' + bank.write(target, manifest) + return target + + +@pytest.fixture +def frozen(tmp_path): + main = make_study(tmp_path, 'main') + supplement = make_study(tmp_path, 'supplement', count=1) + folder = tmp_path / 'bank' + bank.export_bank([('main', main), ('supplement', supplement)], folder) + return folder, main, supplement + + +def rehash(folder): + """Simulate corruption with updated container hashes, but original identities.""" + inventory = json.loads((folder / 'bank.json').read_text()) + inventory['arrays_file_sha256'] = bank.sha(folder / 'arrays.npz') + inventory['arrays_file_bytes'] = (folder / 'arrays.npz').stat().st_size + bank.write(folder / 'bank.json', inventory) + paths = sorted(p for p in folder.rglob('*') if p.is_file() and p.name != 'SHA256SUMS') + (folder / 'SHA256SUMS').write_text(''.join(bank.sha(p) + ' ' + p.relative_to(folder).as_posix() + '\n' for p in paths)) + + +def rewrite_array_archive(folder, mutate): + target = folder / 'arrays.npz' + with zipfile.ZipFile(target) as original: + entries = {name: original.read(name) for name in original.namelist()} + mutate(entries) + with zipfile.ZipFile(target, 'w', compression=zipfile.ZIP_DEFLATED) as out: + for name, data in entries.items(): + out.writestr(name, data) + rehash(folder) + + +def test_identity_matches_frozen_validator_including_dtype_shape_and_layout(): + values = [np.array([0., -0., 1.], dtype=dtype) for dtype in ('f8')] + values.extend([np.arange(6).reshape(2, 3), np.asfortranarray(np.arange(6).reshape(2, 3))]) + for value in values: + assert bank.array_hash(value) == reference.array_hash(value) + assert bank.array_hash(values[0]) != bank.array_hash(values[1]) + assert bank.array_hash(np.arange(6)) != bank.array_hash(values[3]) + assert bank.array_hash(values[3]) == bank.array_hash(values[4]) + + +def test_dedup_preserves_complete_original_populations_and_metadata(frozen, tmp_path): + folder, main, supplement = frozen + checked = bank.verify_bank(folder) + assert checked['cases'] == 3 + assert checked['array_uses'] == 21 + assert checked['unique_arrays'] == 8 + for study, path in [('main', main), ('supplement', supplement)]: + output = tmp_path / (study + '-restored') + receipt = bank.restore_bank(folder, study, path, output) + assert receipt['every_restored_array_verified'] + assert (output / 'original_manifest.json').read_bytes() == path.read_bytes() + original = json.loads(path.read_text()) + restored = json.loads((output / 'manifest.json').read_text()) + assert restored['suite'] == 'reproduction' + assert restored['source_identity'] == original['source_identity'] + assert restored['seal_sha256'] == original['seal_sha256'] + assert restored['original_manifest_sha256'] == bank.sha(path) + for before, after in zip(original['cases'], restored['cases']): + assert after['original_npz_sha256'] == before['sha256'] + assert after['metadata'] == before['metadata'] + assert after['arrays'] == before['arrays'] + actual = bank.source_case(output, after) + expected = bank.source_case(path.parent, before) + for key in actual: + assert actual[key].dtype == expected[key].dtype + assert actual[key].shape == expected[key].shape + assert actual[key].tobytes() == expected[key].tobytes() + report = json.loads((output / 'reproduction.json').read_text()) + assert report['reproduction_method'] == 'exact-array-bank' + assert all(row['numerical_arrays_equal'] for row in report['cases']) + + +def test_existing_bank_and_restoration_are_never_overwritten(frozen, tmp_path): + folder, main, _ = frozen + before = bank.sha(folder / 'bank.json') + with pytest.raises(ValueError, match='overwrite'): + bank.export_bank([('main', main)], folder) + output = tmp_path / 'existing' + output.mkdir() + with pytest.raises(ValueError, match='overwrite'): + bank.restore_bank(folder, 'main', main, output) + assert bank.sha(folder / 'bank.json') == before + + +@pytest.mark.parametrize('corruption', ['container', 'arrays', 'metadata']) +def test_export_rejects_changed_original_and_leaves_no_published_bank(tmp_path, corruption): + manifest_path = make_study(tmp_path, 'main') + manifest = json.loads(manifest_path.read_text()) + case = manifest['cases'][0] + target = manifest_path.parent / case['file'] + if corruption == 'container': + target.write_bytes(target.read_bytes() + b'changed') + else: + with np.load(target, allow_pickle=False) as original: + values = {key: original[key] for key in original.files} + if corruption == 'arrays': + values['y'] = values['y'] + .1 + else: + values['metadata'] = json.dumps(dict(case['metadata'], truth_period=1.)) + np.savez_compressed(target, **values) + case['sha256'] = bank.sha(target) + bank.write(manifest_path, manifest) + output = tmp_path / 'bad-bank' + with pytest.raises(ValueError): + bank.export_bank([('main', manifest_path)], output) + assert not output.exists() + + +def test_restoration_requires_exact_original_manifest(frozen, tmp_path): + folder, main, _ = frozen + wrong = tmp_path / 'wrong.json' + wrong.write_bytes(main.read_bytes() + b'\n') + output = tmp_path / 'bad-restore' + with pytest.raises(ValueError, match='original manifest differs'): + bank.restore_bank(folder, 'main', wrong, output) + assert not output.exists() + + +def test_corrupt_bank_container_rejected(frozen): + folder, _, _ = frozen + path = folder / 'arrays.npz' + path.write_bytes(path.read_bytes() + b'corrupt') + with pytest.raises(ValueError, match='checksum'): + bank.verify_bank(folder) + + +def test_original_numerical_hashes_detect_changed_values_after_container_rehash(frozen): + folder, _, _ = frozen + def mutate(entries): + name = next(iter(entries)) + array = np.load(io.BytesIO(entries[name]), allow_pickle=False).copy() + array.flat[0] += 1 + out = io.BytesIO() + np.save(out, array, allow_pickle=False) + entries[name] = out.getvalue() + rewrite_array_archive(folder, mutate) + with pytest.raises(ValueError, match='numerical array identity'): + bank.verify_bank(folder) + + +@pytest.mark.parametrize('field,value', [('dtype', '= 96 and np.all(np.diff(periods) > 0) + assert period in periods and period/2 in periods + with pytest.raises(ValueError, match='fewer than 60'): + fixtures.selected_periods(10., .05, 1000., count=21) + + +def test_stream_separation_and_fixed_case_seed(): + x = fixtures.deterministic_rng('development:v1', 'case').normal(size=20) + np.testing.assert_array_equal(x, fixtures.deterministic_rng('development:v1', 'case').normal(size=20)) + assert not np.array_equal(x, fixtures.deterministic_rng('heldout:v1', 'case').normal(size=20)) + + +def test_white_snr_normalization_and_duplicate_time_ou(): + signal = np.array([0., .002, .004, .001, 0.]) + errors = np.array([1., 2., 3., 1., 1.]) + scale = fixtures.weighted_signal_norm(signal, errors)/8. + assert fixtures.weighted_signal_norm(signal, scale*errors) == pytest.approx(8., rel=1e-14) + times = np.array([0., 0., 1., 1., 1.1]) + noise = fixtures.ou_noise(times, 1., .15, fixtures.deterministic_rng('dev', 'ou')) + assert noise[0] == noise[1] and noise[2] == noise[3] + + +def test_heldout_requires_matching_complete_seal(): + identity, plan = {'generator_sha256': 'abc'}, {'tess_solar': {'8': 2}} + seal = dict(source_identity=identity, plan=plan, stream='heldout:frozen', + gates={}, modes=['full'], thresholds=[8.], reference_commit='pinned', + freeze_timestamp_utc='2026-09-10T00:00:00Z', hardware={'gpu': 'A40'}, + engine_kind='public', chunk_policy='default', auto_grid=False, options={}, reference_package_sources={}) + fixtures.verify_seal(seal, identity, plan, 'heldout:frozen') + for changed in ({'generator_sha256': 'new'}, {}): + with pytest.raises(ValueError, match='does not match'): + fixtures.verify_seal(seal, changed, plan, 'heldout:frozen') + with pytest.raises(ValueError, match='start heldout:'): + fixtures.verify_seal(seal, identity, plan, 'development:frozen') + del seal['gates'] + with pytest.raises(ValueError, match='lacks'): + fixtures.verify_seal(seal, identity, plan, 'heldout:frozen') + + +def test_full_plans_keep_every_predeclared_case(): + specs = fixtures.case_specs('heldout', {'tess_highimpact': {'8': 3, '10': 2, 'null': 3}}) + assert len(specs) == 8 + assert sum(s[3] for s in specs) == 3 + assert all(s[4] for s in specs) + + +def test_simultaneous_zero_discordance_bound_is_not_a_tight_margin(): + upper = summarize.discordance_upper(0, 64, .05/24) + assert upper == pytest.approx(1-(.05/24)**(1/64), abs=1e-14) + assert upper > .09 + assert summarize.discordance_upper(0, 0, .05) is None + assert summarize.binomial_interval(0, 10)[0] == 0 + assert summarize.binomial_interval(10, 10)[1] == 1 + + +def test_period_match_uses_baseline_drift_and_alias_separation(): + meta = {'truth_period': 10., 'baseline_days': 1000., 'duration_days': .1} + assert summarize.recovered(10.0004, meta) + assert not summarize.recovered(10.0006, meta) + assert not summarize.recovered(5., meta) + assert summarize.recovered(5., meta, alias=.5) + + +def test_public_chi2_must_be_in_original_error_units(tmp_path): + r = fast_record(tmp_path, 'r', [8., 2., 1.], 0) + c = fast_record(tmp_path, 'c', [8., 2., 1.], 0) + for path, public in ((r, False), (c, True)): + record = json.loads(path.read_text()) + record['result']['error_scale'] = 2. + with np.load(path.parent/'arrays.npz') as source: + values = {key: source[key] for key in source.files} + values['stage0_SR'], values['stage0_SR_mask'] = np.array([1., .5, 1/3]), np.zeros(3, bool) + if public: + record['engine_kind'] = 'public' + record['result']['public_contract'] = dict(period=1., SDE=8., search_configuration=dict( + method='reference', phase_binning=False, samples_used=3, input_count=3, time_origin=0.)) + for key in ('periods', 'chi2', 'power'): + values['public_'+key] = values[key].copy() + values['public_SR'] = values['stage0_SR'].copy() + values['public_valid_periods'] = np.ones(3, bool) + np.savez_compressed(path.parent/'arrays.npz', **values) + record['arrays_sha256'] = harness.sha(path.parent/'arrays.npz') + harness.write(path, record) + out = tmp_path/'compared.json' + harness.compare(SimpleNamespace(reference=r, candidate=c, gates=None, threshold=[], out=out)) + result = json.loads(out.read_text()) + assert not result['passed'] + assert not result['public_checks']['chi2']['passed'] + assert result['public_checks']['standard_engine']['passed'] + + +def test_native_correction_changes_only_filter_site(): + import corrected_reference + source = 'def search_multi_periods():\n combined = list(enumerate(zip(periods, -power)))\n period = periods[periodIndex]\n' + result = corrected_reference.corrected_source(source) + assert result.count('period = periods[periodIndex]') == 1 + assert result.replace(result.splitlines()[2]+'\n', '') == source + with pytest.raises(ValueError, match='audited correction sites'): + corrected_reference.corrected_source(source+source) + + +def test_noop_reuse_requires_complete_unchanged_selection_trace(): + import corrected_reference + periods = np.linspace(.6, 12., 301) + powers = np.arange(301, dtype=float)[::-1] + arrays = dict(periods=periods, stage0_chi2_mask=np.zeros(301,bool), + stage0_power=powers,stage0_power_mask=np.zeros(301,bool)) + indices=corrected_reference.finite_candidate_indices(periods,powers) + arrays.update(refinement_indices=indices,refinement0_periods=periods[indices], + refinement1_periods=np.array([1.,2.,4.])) + result=dict(period=2.,stages=[{}, {},dict(preceding_selected_period_masked=False, + preceding_selected_period_finite=True)]) + assert corrected_reference.no_op_receipt(arrays,result)['proved_no_op'] + arrays['stage0_chi2_mask'][0]=True + assert not corrected_reference.no_op_receipt(arrays,result)['proved_no_op'] + arrays['stage0_chi2_mask'][0]=False + arrays['refinement0_periods'][0]=np.nan + assert not corrected_reference.no_op_receipt(arrays,result)['proved_no_op'] + + +def test_finite_reference_selection_preserves_ties_and_period_cut(): + import corrected_reference + periods=np.linspace(.6,12.,400) + powers=np.zeros(400) + pm=np.zeros(400,bool);sm=np.zeros(400,bool) + pm[0:10]=True;sm[50:60]=True;powers[80]=np.nan + p=np.ma.array(periods,mask=pm);s=np.ma.array(powers,mask=sm) + result=corrected_reference.finite_candidate_indices(p,s) + valid=np.flatnonzero(~(pm|sm)&np.isfinite(powers)) + np.testing.assert_array_equal(result[:100],valid[:100]) + expected=[i for i in valid[100:] if periods[i]>1][:100] + np.testing.assert_array_equal(result[100:],expected) diff --git a/benchmarks/tls_reference/timing/README.md b/benchmarks/tls_reference/timing/README.md new file mode 100644 index 00000000..405e7c09 --- /dev/null +++ b/benchmarks/tls_reference/timing/README.md @@ -0,0 +1,173 @@ +# Full TLS search timing + +The declared timing scope covers **single-source latency and a batch of 16 +distinct noise-only light curves** for TESS, gapped TESS and ZTF. It compares +cuvarbase's standard TLS engine with pinned GTLS's full search: five single +calls, three batches, and five separate component calls per regime. Native +throughput is tested with persistent pools of 1, 2 and 4 workers. All calls +must pass the recorded output and process-ownership gates before a speed +ratio is eligible. The earlier single-only budget fallback stopped before +warmup; the restored full scope was declared before any valid measurement. + +These tools do not provision resources or change installed source files. +Numerical validation must pass before timing begins. Original independent +results and reproduced results retain separate labels; timing does not +establish detection sensitivity by itself. + +The published campaign retained a failed four-worker GTLS warmup on gapped +TESS. Its original all-configuration acceptance remains failed. The +[results report](../../results/tls_reference_2026-09-10/timing/README.md) +uses a separately labeled post hoc assessment of complete configurations, +with all attempts disclosed. `report_completed.py` enforces that reporting +scope without changing the original driver or numerical gates. Its +[CPU replay instructions](../../results/tls_reference_2026-09-10/sources/timing/README.md) +reproduce the published figure data from the retained evidence. + +## Inputs and measured calls + +The single source is the predeclared `null_0000` of each regime. If it fails +either public API, the established selection rule uses the earliest +manifest-order paired-success null in the supporting cohort. The receipt +records the failure and fallback. Selection never uses elapsed time, measured +SNR or signal recovery. A new timing failure invalidates its configuration; +the failed elapsed time is retained and never enters a speed ratio. + +The supporting input bank contains 16 distinct nulls per regime: eight from +the main 160-case confirmation and eight from the separately sealed 24-case +supplement. Their original manifests, seals, acceptance receipts and result +directories remain distinct. Production and native-reference sources and +per-regime search settings must agree. Only the chosen single source is +executed in `--measurement-scope single`. + +`benchmark.py` measures five complete single-source public calls and three +complete batches in full scope. Input arrays and period +grids are loaded before clocks start. Constructor work, input validation, +template-cache construction, the full search, candidate and harmonic +refinement, final fitting and returned arrays are included. Imports, CUDA +context setup and one complete warmup are reported separately. A persistent +worker uses one CPU numerical-library thread; the parent stops the clock +after the public return and CUDA synchronization. Output hashing follows the +timing barrier. + +Each implementation must reproduce its own validated complete period, power, +chi-squared, selected-period and SDE identities. This permits the separately +disclosed correction to an undefined native trial without silently claiming +literal cross-implementation equality. The native package remains unchanged; +`corrected_reference.py` temporarily applies the host correction in memory. +When the supporting cohort lacks a complete no-op proof, an additional +one-worker corrected-native measurement records its effect separately. + +Read-only GPU/process telemetry is sampled at 1 Hz. Each worker starts on an +empty GPU and retains a synchronized CUDA allocation before any search. When +the container hides its outer PIDs, exactly N newly visible NVML PIDs can be +bound as a set to N distinct live workers. The harness never invents an +individual mapping for hidden IDs. Every call requires that same complete +PID set and no other compute process; acceptance also requires all contexts +to disappear after clean worker exits. Component measurements use the same parent/child check, +outside their timing clocks. CPU quota, affinity, +thread settings, dependencies, input/source hashes, startup, warmups, all +repetitions, errors and validation time are retained. Run the campaign +exclusively; snapshots cannot exclude a job entirely between samples. + +## Search and reporting components + +`components.py` is separate from the uninstrumented public timings. It makes +a literal public warmup followed by five instrumented calls. Every returned +field must remain identical to the literal call, whose search arrays must +match the validated study. These component times never replace the public-call +denominator. + +The native common-search endpoint is immediately after the pinned final +single-period statement `bestLocation = lowestResidualsGPU.argmin().get()`. +The cuvarbase endpoint is immediately after `engine.search_full` returns. +Both include constructor, validation, cache construction, full search and +final window selection, and exclude subsequent physical-parameter reporting. +The boundaries represent the same search stage, with a small implementation +difference: cuvarbase transfers compact winner fields and returns its engine +result before stamping, while GTLS stamps after the final argmin transfer. + +GTLS additionally computes CPU per-transit SNR and pink-noise diagnostics. +Their inclusive durations and nested pink-noise durations remain separate; +nested component durations must not be added together. Full-public and +common-search ratios answer different questions and are reported separately. + +## Reproduce the published timing cohort + +From the repository root, first restore and validate the main population with +the [parent README](../README.md#reproduce-the-numerical-comparison). Use the +recorded CUDA environment, a pinned GTLS installation, and new output +directories. CUDA compilation requires `nvcc` on `PATH`. Then restore and +validate the supplementary null population and merge both accepted origins: + +```sh +python benchmarks/tls_reference/inputs.py restore \ + --bank benchmarks/results/tls_reference_2026-09-10/inputs --study supplement \ + --manifest benchmarks/results/tls_reference_2026-09-10/supplement/input_manifest.json \ + --out reproduced-supplement-inputs +python benchmarks/tls_reference/reproduce.py --repo-root . \ + --manifest reproduced-supplement-inputs/manifest.json \ + --seal benchmarks/results/tls_reference_2026-09-10/supplement/seal.json \ + --out reproduced-supplement-results +python benchmarks/tls_reference/timing/merge.py \ + --study main reproduced-inputs/manifest.json reproduced-results \ + --study supplement reproduced-supplement-inputs/manifest.json reproduced-supplement-results \ + --out reproduced-timing-inputs +python benchmarks/tls_reference/timing/benchmark.py \ + --manifest reproduced-timing-inputs/manifest.json --paired-results reproduced-results \ + --measurement-scope full \ + --correction-adapter benchmarks/tls_reference/corrected_reference.py \ + --output reproduced-public-timings +python benchmarks/tls_reference/timing/components.py \ + --manifest reproduced-timing-inputs/manifest.json --paired-results reproduced-results \ + --measurement-scope full --backend gtls --output reproduced-gtls-components +python benchmarks/tls_reference/timing/components.py \ + --manifest reproduced-timing-inputs/manifest.json --paired-results reproduced-results \ + --measurement-scope full --backend candidate --output reproduced-cuvarbase-components +python benchmarks/tls_reference/timing/components.py \ + --manifest reproduced-timing-inputs/manifest.json --paired-results reproduced-results \ + --measurement-scope full --backend gtls_corrected \ + --correction-adapter benchmarks/tls_reference/corrected_reference.py \ + --output reproduced-corrected-components +python benchmarks/tls_reference/timing/summarize.py reproduced-public-timings \ + --components-gtls reproduced-gtls-components \ + --components-candidate reproduced-cuvarbase-components \ + --components-corrected reproduced-corrected-components \ + --output reproduced-timing-checks.json +python benchmarks/tls_reference/analyze_timing.py \ + --checks reproduced-timing-checks.json --manifest reproduced-timing-inputs/manifest.json \ + --acceptance reproduced-results/acceptance.json --output reproduced-timing-summary.json +python -m pytest -q benchmarks/tls_reference +``` + +`merge.py` copies the 48 accepted null inputs and uses relative paths to both +original manifests and result trees. Keep those directories together when +moving the experiment. It verifies the original population, actual validator +and adapter, production hashes and passing comparisons. Its receipt uses +`reproduction_gate`; the timing summary reports +`numerical_evidence_kind: reproduction` and `measurement_scope: single_and_batch`. +Neither step changes an original seal or turns a rerun into independent +sensitivity evidence. The results archive separately preserves the exact +original scientific and timing source snapshots. + +Expected outputs use complete per-array hashes, dtypes, shapes and masks, +plus exact scalar period/SDE hashes. Removing a large NPZ after a successful +comparison does not remove these identities. `verify_study_hashes.py` can +check conversion of a retained study record against its array archive +without executing another search. + +## Batch scope and competitor selection + +`--measurement-scope full` runs three batch calls on the 16 distinct +nulls in each regime. Cuvarbase uses its public `tls_search_batch` API; native +GTLS uses persistent pools of 1, 2 and 4 workers with fixed round-robin source +assignment. The clock includes dispatch and completion of every source. + +A native pool is eligible only when every repetition preserves all complete +search-output identities against the literal one-worker run and each method +matches its own validated output. The fastest eligible median is selected; +all pool results and failures remain visible. These finite batches measure +throughput for the recorded workload and hardware. They do not by themselves +establish performance across an entire survey or all CPU configurations. +`--measurement-scope single` remains available for a smaller experiment with +no batch or strongest-pool claim. The optional `--row-ab` prefix-dispatch +attribution experiment is separate from the declared campaign. diff --git a/benchmarks/tls_reference/timing/__init__.py b/benchmarks/tls_reference/timing/__init__.py new file mode 100644 index 00000000..4ffaf66d --- /dev/null +++ b/benchmarks/tls_reference/timing/__init__.py @@ -0,0 +1 @@ +"""Reproducible TLS timing tools, isolated from validation helper modules.""" diff --git a/benchmarks/tls_reference/timing/benchmark.py b/benchmarks/tls_reference/timing/benchmark.py new file mode 100644 index 00000000..22f7c80f --- /dev/null +++ b/benchmarks/tls_reference/timing/benchmark.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +"""Uninstrumented complete-public-call TLS timings with deferred validation. + +Persistent workers retain their raw results until the parent has stopped the +clock. Only then are result arrays hashed and released. No numerical routines +are patched in headline graph mode. The optional row-prefix variant is an +explicit attribution experiment, kept separate from competitor selection. +""" +from __future__ import annotations + +import argparse +from collections import Counter +import multiprocessing as mp +import os +from pathlib import Path +import threading +import time +import traceback + +if __name__ == '__main__': + for variable in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[variable] = '1' + +if __package__: + from .common import (BATCH_REPETITIONS, NATIVE_BACKENDS, REGIMES, SINGLE_REPETITIONS, case_identity, + environment, fingerprint, initialize_backend, load_cases, + public_batch, public_single, sha, write) + from .cohort import frozen_outputs, select, verify_worker_sources +else: + from common import (BATCH_REPETITIONS, NATIVE_BACKENDS, REGIMES, SINGLE_REPETITIONS, case_identity, + environment, fingerprint, initialize_backend, load_cases, + public_batch, public_single, sha, write) + from cohort import frozen_outputs, select, verify_worker_sources + + +def process_ids(): + """Record any visible outer/inner namespace IDs for NVML process checks.""" + result = {os.getpid()} + status = Path('/proc/self/status') + if status.exists(): + for line in status.read_text().splitlines(): + if line.startswith('NSpid:'): + result.update(int(value) for value in line.split()[1:]) + return sorted(result) + + +def retain_cuda_context(): + """Make this worker visible to NVML before any search or timing clock.""" + import cupy as cp + allocation = cp.cuda.alloc(1) + cp.cuda.runtime.deviceSynchronize() + return allocation + + +def worker(connection, config): + """One serial public-API consumer, with no timed result serialization.""" + try: + cases = load_cases(config['manifest'], config['regime'], config.get('names')) + sources = initialize_backend(config['backend'], config['prefix'], config.get('correction_adapter')) + import cupy as cp + context_allocation = retain_cuda_context() # Kept alive until worker exit. + connection.send(dict(kind='ready', pid=os.getpid(), namespace_pids=process_ids(), sources=sources, + cuda_context_allocation_bytes=1, cuda_context_synchronized=True)) + retained = [] + while True: + command = connection.recv() + if command['kind'] == 'close': + break + if command['kind'] == 'validate': + records, errors = [], [] + for index, result in retained: + try: + records.append(fingerprint(config['backend'], cases[index], result)) + except Exception: + errors.append(dict(case=cases[index]['name'], traceback=traceback.format_exc())) + retained = [] + connection.send(dict(kind='validation', records=records, errors=errors)) + continue + if command['kind'] != 'run': + raise ValueError('Unknown worker command') + if retained: + raise RuntimeError('Previous results were not validated before reuse') + indices = command['indices'] + selected = [cases[index] for index in indices] + cp.cuda.runtime.deviceSynchronize() + started = time.perf_counter() + error = None + case_failures = [] + try: + if command['single']: + results = [public_single(config['backend'], selected[0])] + elif config['backend'] in NATIVE_BACKENDS: + # Preserve the identity and elapsed time of a failed + # public call, then let independent cases finish. A batch + # with any failure is never a timing denominator. + for index in indices: + call_started = time.perf_counter() + try: + result = public_single(config['backend'], cases[index]) + cp.cuda.runtime.deviceSynchronize() + retained.append((index, result)) + except Exception: + case_failures.append(dict(case=cases[index]['name'], + failure_seconds=time.perf_counter()-call_started, + traceback=traceback.format_exc())) + results = None + else: + results = public_batch(config['backend'], selected) + cp.cuda.runtime.deviceSynchronize() + # Keep the raw returned objects alive through the barrier. + if results is not None: + retained = list(zip(indices, results)) + except Exception: + error = traceback.format_exc() + try: + cp.cuda.runtime.deviceSynchronize() + except Exception: + pass + ended = time.perf_counter() + connection.send(dict(kind='complete', pid=os.getpid(), started=started, + ended=ended, api_seconds=ended-started, + completed_cases=len(retained), error=error, + case_failures=case_failures)) + except Exception: + try: + connection.send(dict(kind='fatal', pid=os.getpid(), traceback=traceback.format_exc())) + except Exception: + pass + finally: + connection.close() + + +def exclusive_gpu_processes(allowed, *, strict=True): + """Require exactly the bound host PIDs, including their presence.""" + import pynvml as nvml + nvml.nvmlInit() + try: + handle = nvml.nvmlDeviceGetHandleByIndex(0) + observed = sorted({int(process.pid) for process in nvml.nvmlDeviceGetComputeRunningProcesses(handle)}) + finally: + nvml.nvmlShutdown() + foreign = sorted(set(observed) - set(allowed)) + missing = sorted(set(allowed) - set(observed)) + if (foreign or missing) and strict: + raise RuntimeError('Foreign GPU compute processes or missing owned contexts prevent exclusive timings: ' + + repr(dict(foreign=foreign, missing=missing))) + return dict(allowed_pids=sorted(allowed), observed_pids=observed, + foreign_pids=foreign, missing_pids=missing, exclusive=not foreign and not missing, + monotonic=time.perf_counter(), utc=time.time()) + + +class GPUOwnership: + """Prove a worker's NVML host identity across its controlled lifetime. + + A container may expose only its inner PID in NSpid. On a previously empty + GPU, N distinct initialized children may then own exactly N new host PIDs + as a set. Hidden IDs are never assigned arbitrarily to individual children. + The set is provisional until all contexts disappear after clean pool exit. + """ + def __init__(self): + self.allowed_pids = [] + self.receipt = dict(version=1, status='starting', passed=False, + before_start=exclusive_gpu_processes([], strict=False)) + if not self.receipt['before_start']['exclusive']: + self.receipt['status'] = 'failed' + error = RuntimeError('GPU must be empty before any owned worker starts: ' + + repr(self.receipt['before_start'])) + error.gpu_ownership = self.receipt + raise error + + def bind(self, ready, processes, timeout=5.): + self.receipt.update(workers=ready, owned_worker_pids=[process.pid for process in processes]) + if (len(ready) != len(processes) or not ready or + len({process.pid for process in processes}) != len(processes)): + raise RuntimeError('Missing owned worker readiness records') + for value, process in zip(ready, processes): + if (not process.is_alive() or value.get('pid') != process.pid or + process.pid not in value.get('namespace_pids', []) or + value.get('cuda_context_allocation_bytes') != 1 or + value.get('cuda_context_synchronized') is not True): + raise RuntimeError('Owned worker did not prove a live synchronized CUDA context') + deadline = time.monotonic() + timeout + observations = [] + while True: + current = exclusive_gpu_processes([], strict=False) + observations.append(current) + observed = current['observed_pids'] + if len(observed) == len(ready): + break + if (len(observed) > len(ready) or time.monotonic() >= deadline or + any(not process.is_alive() for process in processes)): + self.receipt['startup_observations'] = observations + raise RuntimeError('Ambiguous or missing newly visible CUDA contexts: ' + repr(observed)) + time.sleep(.1) + bindings = [] + for value in ready: + matches = sorted(set(value['namespace_pids']) & set(observed)) + if len(matches) == 1: + host_pid, method = matches[0], 'visible_namespace_pid' + elif len(ready) == 1 and not matches: + host_pid, method = observed[0], 'single_worker_lifecycle' + elif not matches: + host_pid, method = None, 'pool_lifecycle_member' + else: + raise RuntimeError('Ambiguous directly visible namespace IDs') + bindings.append(dict(worker_pid=value['pid'], namespace_pids=value['namespace_pids'], + host_pid=host_pid, method=method)) + assigned = [value['host_pid'] for value in bindings if value['host_pid'] is not None] + if len(set(assigned)) != len(assigned): + raise RuntimeError('Multiple workers cannot share one claimed NVML identity') + self.allowed_pids = observed + self.receipt.update(status='bound', bindings=bindings, host_pids=observed, + identity_scope='individual' if len(assigned) == len(ready) else 'owned_pool_set', + live_workers_at_binding=[process.pid for process in processes if process.is_alive()], + startup_observations=observations, + after_start=exclusive_gpu_processes(self.allowed_pids)) + return self.allowed_pids + + def finish(self, processes, forced=(), timeout=5.): + """Call only after joining owned children; never terminate NVML PIDs.""" + exits = [dict(worker_pid=process.pid, exit_code=process.exitcode, + alive=process.is_alive(), forced=process.pid in forced) for process in processes] + deadline = time.monotonic() + timeout + observations = [] + while True: + current = exclusive_gpu_processes([], strict=False) + observations.append(current) + if current['exclusive'] or time.monotonic() >= deadline: + break + # Unexpected foreign contexts cannot be attributed to delayed cleanup. + if set(current['observed_pids']) - set(self.allowed_pids): + break + time.sleep(.1) + clean = bool(exits) and all(value['exit_code'] == 0 and not value['alive'] and + not value['forced'] for value in exits) + passed = self.receipt['status'] == 'bound' and clean and current['exclusive'] + self.receipt.update(status='complete' if passed else 'failed', passed=passed, + worker_exits=exits, exit_observations=observations, after_exit=current) + return self.receipt + + +def ownership_valid(record): + """Check the lifecycle receipt, not just an asserted success flag.""" + receipt = record.get('gpu_ownership', {}) + bindings = receipt.get('bindings', []) + hosts = receipt.get('host_pids', []) + workers = sorted(value.get('worker_pid', -1) for value in bindings) + exits = receipt.get('worker_exits', []) + ready = receipt.get('workers', []) + if (receipt.get('version') != 1 or receipt.get('status') != 'complete' or + receipt.get('passed') is not True or not hosts or len(hosts) != len(bindings) or + hosts != sorted(set(hosts)) or + len(set(workers)) != len(workers) or min(hosts + workers) <= 0 or + sorted(receipt.get('owned_worker_pids', [])) != workers or + sorted(receipt.get('live_workers_at_binding', [])) != workers or + sorted(value.get('pid', -1) for value in ready) != workers or + any(value.get('cuda_context_allocation_bytes') != 1 or + value.get('cuda_context_synchronized') is not True for value in ready) or + sorted(value.get('worker_pid', -1) for value in exits) != workers or + any(value.get('exit_code') != 0 or value.get('alive') is not False or + value.get('forced') is not False for value in exits)): + return False + if 'pool_width' in record and record['pool_width'] != len(workers): + return False + if any(value['namespace_pids'] != next(worker['namespace_pids'] for worker in ready + if worker['pid'] == value['worker_pid']) for value in bindings): + return False + for name, expected in (('before_start', []), ('after_start', hosts), ('after_exit', [])): + value = receipt.get(name, {}) + if (value.get('exclusive') is not True or value.get('allowed_pids') != expected or + value.get('observed_pids') != expected or value.get('foreign_pids') != [] or + value.get('missing_pids') != []): + return False + if not all(value.get('method') in ('visible_namespace_pid', 'single_worker_lifecycle', 'pool_lifecycle_member') and + value['worker_pid'] in value.get('namespace_pids', []) and + (value['host_pid'] in value['namespace_pids'] and value['host_pid'] in hosts + if value['method'] == 'visible_namespace_pid' else + len(bindings) == 1 and value['host_pid'] == hosts[0] + if value['method'] == 'single_worker_lifecycle' else + len(bindings) > 1 and value['host_pid'] is None) + for value in bindings): + return False + assigned = [value['host_pid'] for value in bindings if value['host_pid'] is not None] + if (len(set(assigned)) != len(assigned) or receipt.get('identity_scope') != + ('individual' if len(assigned) == len(bindings) else 'owned_pool_set')): + return False + calls = record.get('single', []) + record.get('batch', []) + record.get('repetitions', []) + if 'warmup' in record: + calls = [record['warmup']] + calls + if 'literal_exclusive_before' in record: + calls = [dict(exclusive_before=record['literal_exclusive_before'], + exclusive_after=record.get('literal_exclusive_after'))] + calls + for call in calls: + for name in ('exclusive_before', 'exclusive_after'): + value = call.get(name) or {} + if (value.get('exclusive') is not True or value.get('allowed_pids') != hosts or + value.get('observed_pids') != hosts or value.get('foreign_pids') != [] or + value.get('missing_pids') != []): + return False + return True + + +class WorkerPool: + def __init__(self, config, width, timeout=1800.): + self.timeout = timeout + self.names = config['names'] + self.connections, self.processes = [], [] + self.ownership = GPUOwnership() # Before spawning or initializing CUDA. + self.closed = False + begin = time.perf_counter() + context = mp.get_context('spawn') + try: + for _ in range(width): + parent, child = context.Pipe() + process = context.Process(target=worker, args=(child, config)) + process.start() + child.close() + self.connections.append(parent) + self.processes.append(process) + self.ready = [self._receive(connection, 'ready') for connection in self.connections] + self.ownership.bind(self.ready, self.processes) + except Exception as error: + self.close() + error.gpu_ownership = self.ownership.receipt + raise + self.startup_seconds = time.perf_counter() - begin + + def _receive(self, connection, expected): + if not connection.poll(self.timeout): + raise TimeoutError('Worker did not finish within the declared timeout') + result = connection.recv() + if result.get('kind') != expected: + raise RuntimeError('Unexpected worker response: ' + repr(result)) + return result + + def measure(self, indices, *, single=False, warmup=False): + if single and len(self.connections) != 1: + raise ValueError('Single-source latency uses one persistent worker') + # Round-robin assignment is fixed; completion order cannot alter inputs. + assignments = ([indices[:1]] * len(self.connections) if warmup else + [indices[i::len(self.connections)] for i in range(len(self.connections))]) + if any(not values for values in assignments): + raise ValueError('Every worker must receive at least one case') + if any(not process.is_alive() for process in self.processes): + raise RuntimeError('An owned GPU worker exited before measurement') + allowed_pids = self.ownership.allowed_pids + exclusive_before = exclusive_gpu_processes(allowed_pids) + before = time.perf_counter() + for connection, values in zip(self.connections, assignments): + connection.send(dict(kind='run', indices=values, single=single)) + completions = [self._receive(connection, 'complete') for connection in self.connections] + after = time.perf_counter() # All public returns + CUDA work completed. + exclusive_after = exclusive_gpu_processes(allowed_pids, strict=False) + # No hashing, NumPy comparisons or array IPC occurs before this point. + validation_begin = time.perf_counter() + for connection in self.connections: + connection.send(dict(kind='validate')) + validation = [self._receive(connection, 'validation') for connection in self.connections] + records = [record for value in validation for record in value['records']] + errors = ([value['error'] for value in completions if value['error']] + + [error for value in completions for error in value['case_failures']] + + [error for value in validation for error in value['errors']]) + if not exclusive_after['exclusive']: + errors.append(dict(reason='GPU ownership changed across the measurement', + snapshot=exclusive_after)) + if any(not process.is_alive() for process in self.processes): + errors.append(dict(reason='An owned worker exited across the measurement')) + expected = sum(len(values) for values in assignments) + expected_names = [self.names[index] for values in assignments for index in values] + observed_names = [value['case'] for value in records] + if Counter(expected_names) != Counter(observed_names): + errors.append(dict(reason='Post-barrier result membership differs from the assigned inputs', + expected=expected_names, observed=observed_names)) + complete = not errors and sum(value['completed_cases'] for value in completions) == expected + return dict(status='ok' if complete else 'error', elapsed_seconds=after-before, + denominator_seconds=after-before if complete else None, + validation_seconds=time.perf_counter()-validation_begin, + worker_public_calls=completions, errors=errors, outputs=records, + assignments=assignments, source_count=expected, warmup=warmup, + exclusive_before=exclusive_before, exclusive_after=exclusive_after) + + def close(self): + if self.closed: + return self.ownership.receipt + self.closed = True + forced = [] + for connection in self.connections: + try: + connection.send(dict(kind='close')) + except (BrokenPipeError, EOFError, OSError): + pass + for process in self.processes: + process.join(timeout=5.) + if process.is_alive(): + forced.append(process.pid) + process.terminate() + process.join(timeout=5.) + for connection in self.connections: + connection.close() + return self.ownership.finish(self.processes, forced=forced) + + +class Monitor: + """One-Hz read-only NVML/cgroup telemetry, outside worker API execution.""" + def __init__(self, path): + self.path = Path(path) + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._run, daemon=True) + + def _run(self): + import json + try: + import pynvml as nvml + nvml.nvmlInit() + handle = nvml.nvmlDeviceGetHandleByIndex(0) + except Exception as error: + handle, nvml = None, None + setup_error = repr(error) + with self.path.open('w') as output: + while not self.stop_event.is_set(): + row = dict(monotonic=time.perf_counter(), utc=time.time()) + try: + if handle is not None: + utilization = nvml.nvmlDeviceGetUtilizationRates(handle) + memory = nvml.nvmlDeviceGetMemoryInfo(handle) + row.update(gpu_percent=utilization.gpu, memory_percent=utilization.memory, + used_gpu_bytes=memory.used, + power_mw=nvml.nvmlDeviceGetPowerUsage(handle), + sm_clock_mhz=nvml.nvmlDeviceGetClockInfo(handle, nvml.NVML_CLOCK_SM), + temperature_c=nvml.nvmlDeviceGetTemperature(handle, nvml.NVML_TEMPERATURE_GPU)) + row['compute_processes'] = [dict(pid=int(process.pid), + used_gpu_bytes=int(process.usedGpuMemory)) + for process in nvml.nvmlDeviceGetComputeRunningProcesses(handle)] + else: + row['nvml_error'] = setup_error + for file in ('cpu.stat', 'memory.current'): + path = Path('/sys/fs/cgroup') / file + if path.exists(): + row[file] = path.read_text().strip() + except Exception as error: + row['error'] = repr(error) + output.write(json.dumps(row) + '\n') + output.flush() + self.stop_event.wait(1.) + if nvml is not None: + nvml.nvmlShutdown() + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *unused): + self.stop_event.set() + self.thread.join(timeout=5.) + + +def consistency(records, reference=None, expected_names=None, expected_repetitions=None, + field='strict'): + """All cases and repetitions must pass before a denominator is eligible.""" + baseline = {} if reference is None else dict(reference) + problems = [] + if not records or (expected_repetitions is not None and len(records) != expected_repetitions): + problems.append(dict(reason='Missing declared repetitions', actual=len(records), + expected=expected_repetitions)) + expected = (Counter(expected_names) if expected_names is not None else + Counter(reference.keys()) if reference is not None else None) + for repetition, record in enumerate(records): + if record['status'] != 'ok': + problems.append(dict(repetition=repetition, reason='incomplete public result')) + continue + observed = Counter(value['case'] for value in record['outputs']) + if expected is None: + expected = observed + if expected != observed or any(count != 1 for count in observed.values()): + problems.append(dict(repetition=repetition, reason='Result membership changed', + expected=dict(expected), observed=dict(observed))) + if record.get('source_count', sum(observed.values())) != sum(observed.values()): + problems.append(dict(repetition=repetition, reason='Source count differs from returned outputs')) + for output in record['outputs']: + name, current = output['case'], output[field] + if name not in baseline: + if reference is not None: + problems.append(dict(case=name, reason='absent from one-worker reference')) + else: + baseline[name] = current + elif baseline[name] != current: + problems.append(dict(case=name, repetition=repetition, + reason=field + ' output hashes changed')) + return dict(eligible=not problems, problems=problems, reference=baseline) + + +def run(args): + measurement_scope = getattr(args, 'measurement_scope', 'full') + if measurement_scope not in ('full', 'single'): + raise ValueError('Unknown measurement scope') + if measurement_scope == 'single' and args.pool_widths != [1]: + raise ValueError('Single-source latency uses exactly one worker per method') + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + # Apply one CPU math thread per worker before fresh spawned imports. This + # leaves the declared 1/2/4 worker count as the CPU parallelism control. + for variable in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[variable] = '1' + plan = dict(manifest=str(args.manifest.resolve()), manifest_sha256=sha(args.manifest), + regimes=args.regimes, measurement_scope=measurement_scope, + single_repetitions=SINGLE_REPETITIONS, + batch_repetitions=BATCH_REPETITIONS if measurement_scope == 'full' else 0, + native_pool_widths=args.pool_widths, + cpu_math_threads_per_worker=1, full=True, return_arrays=True, + instrumentation='None in public-call runner; only start/end clocks and completion synchronization', + scope='Public constructor/validation/template cache/full search/final fit; preloaded files and explicit period-grid generation excluded', + barrier='Every worker API return plus device synchronization before parent stops; result hashes afterward', + native_extras='Literal GTLS full calls include CPU per-transit SNR and pink-noise diagnostics absent from cuvarbase', + process_ownership='Empty device before workers; synchronized retained CUDA allocation; ' + 'bound NVML host PID(s) present and exclusive at every call boundary; clean exit and empty device afterward', + sources={path.name: sha(path) for path in Path(__file__).parent.glob('*.py')}, + environment=environment(), cases={}) + selections = {regime: select(args.manifest, args.paired_results, regime) for regime in args.regimes} + plan['cohort_selection'] = selections + plan['failure_policy'] = ('Preserve every failed original, use deterministic manifest-order paired-success ' + 'replacements before timing, report actual batch size if fewer than16. A new runtime failure ' + 'invalidates its run/configuration; rerun a complete paired cohort for any later replacement, ' + 'never mix different input cohorts or use failed elapsed times as speed denominators.') + all_cases = {regime: load_cases(args.manifest, regime, + [selections[regime]['single_case']] if measurement_scope == 'single' + else selections[regime]['selected_cases']) + for regime in args.regimes if selections[regime]['single_case'] is not None} + plan['cases'] = {regime: [case_identity(case) for case in cases] for regime, cases in all_cases.items()} + plan['frozen_outputs'] = {regime: frozen_outputs(args.paired_results, cases, + backends=('gtls', 'candidate', 'gtls_corrected') if selections[regime]['correction_timing']['required'] + else ('gtls', 'candidate'), manifest_path=args.manifest) for regime, cases in all_cases.items()} + correction_needed = any(selection['correction_timing']['required'] for selection in selections.values()) + if correction_needed and args.correction_adapter is None: + raise ValueError('An affected selected case requires --correction-adapter for the additional native cross-check') + plan['correction_adapter'] = (None if args.correction_adapter is None else + dict(path=str(args.correction_adapter.resolve()), sha256=sha(args.correction_adapter))) + write(output/'plan.json', plan) + summary = dict(status='running', regimes={}) + write(output/'summary.json', summary) + with Monitor(output/'monitor.jsonl'): + for regime, cases in all_cases.items(): + regimes = summary['regimes'][regime] = {} + configs = [('candidate', 1, 'graph')] + [('gtls', width, 'graph') + for width in args.pool_widths if width <= len(cases)] + if selections[regime]['correction_timing']['required']: + configs.append(('gtls_corrected', 1, 'graph')) + if args.row_ab: + configs.append(('candidate', 1, 'row')) + native_reference = None + for backend, width, prefix in configs: + label = f'{backend}_{prefix}_{width}worker' + target = output/regime/label + target.mkdir(parents=True) + config = dict(manifest=str(args.manifest.resolve()), regime=regime, + backend=backend, prefix=prefix, + correction_adapter=None if args.correction_adapter is None else str(args.correction_adapter.resolve()), + names=[case['name'] for case in cases], measurement_scope=measurement_scope) + record = dict(config=config, pool_width=width, single=[], batch=[]) + pool = None + try: + pool = WorkerPool(config, width, timeout=args.timeout) + for ready in pool.ready: + verify_worker_sources(backend, ready['sources'], selections[regime]) + record.update(pool_startup_seconds=pool.startup_seconds, workers=pool.ready) + record['warmup'] = pool.measure([0], warmup=True, + single=measurement_scope == 'single') + if record['warmup']['status'] != 'ok': + raise RuntimeError('Warmup did not complete correctly') + write(target/'record.json', record) + if width == 1 and selections[regime]['single_case'] is not None: + single_index = config['names'].index(selections[regime]['single_case']) + for _ in range(SINGLE_REPETITIONS): + record['single'].append(pool.measure([single_index], single=True)) + write(target/'record.json', record) + # Optional row-prefix A/B is deliberately single-source; + # it is not another entrant in strongest-pool selection. + if prefix == 'graph' and measurement_scope == 'full': + for _ in range(BATCH_REPETITIONS): + record['batch'].append(pool.measure(list(range(len(cases))))) + write(target/'record.json', record) + gate = consistency(record['batch'] or record['single'], + native_reference if backend == 'gtls' else None, + expected_names=(config['names'] if record['batch'] else + [selections[regime]['single_case']]), + expected_repetitions=(BATCH_REPETITIONS if record['batch'] else + SINGLE_REPETITIONS)) + if backend == 'gtls' and width == 1: + if gate['eligible'] and len(gate['reference']) == len(cases): + native_reference = gate['reference'] + else: + gate['eligible'] = False + gate['problems'].append(dict(reason='No stable complete one-worker reference')) + elif backend == 'gtls' and native_reference is None: + gate['eligible'] = False + gate['problems'].append(dict(reason='One-worker baseline missing or unstable')) + record['parity'] = gate + expected = {name: value['strict'] for name, value in + plan['frozen_outputs'][regime][backend].items()} + if prefix == 'row': + expected = {selections[regime]['single_case']: expected[selections[regime]['single_case']]} + record['frozen_output_gate'] = consistency(record['batch'] or record['single'], + reference=expected, expected_names=list(expected), + expected_repetitions=BATCH_REPETITIONS if record['batch'] else SINGLE_REPETITIONS) + if record['single']: + single_name = selections[regime]['single_case'] + reference = ({single_name: gate['reference'][single_name]} + if single_name in gate['reference'] else None) + record['single_parity'] = consistency(record['single'], reference=reference, + expected_names=[single_name], expected_repetitions=SINGLE_REPETITIONS) + record['full_repeatability_gate'] = consistency(record['single'], + expected_names=[single_name], expected_repetitions=SINGLE_REPETITIONS, + field='full_digest') + else: + record['single_parity'] = None + record['status'] = ('ok' if all(rep['status'] == 'ok' + for rep in record['single'] + record['batch']) else 'measurement_failure') + except Exception as error: + record.update(status='error', error=traceback.format_exc(), + parity=dict(eligible=False, problems=[dict(reason='runner failure')])) + if hasattr(error, 'gpu_ownership'): + record['gpu_ownership'] = error.gpu_ownership + finally: + if pool is not None: + try: + record['gpu_ownership'] = pool.close() + if not ownership_valid(record): + record.update(status='ownership_failure', + parity=dict(eligible=False, problems=[dict(reason='GPU ownership lifecycle failed')])) + except Exception: + record.update(status='ownership_failure', ownership_error=traceback.format_exc(), + parity=dict(eligible=False, problems=[dict(reason='GPU ownership cleanup failed')])) + write(target/'record.json', record) + regimes[label] = dict(status=record['status'], record=str(target.relative_to(output)/'record.json'), + parity=record['parity'], + single_seconds=[item['denominator_seconds'] for item in record['single']], + batch_seconds=[item['denominator_seconds'] for item in record['batch']]) + write(output/'summary.json', summary) + if (measurement_scope == 'single' or width == 1) and (record['status'] != 'ok' or + not record['parity']['eligible'] or + not record.get('frozen_output_gate', {}).get('eligible') or + not record.get('full_repeatability_gate', {}).get('eligible')): + summary['status'] = 'error' + write(output/'summary.json', summary) + raise RuntimeError('Required public timing failed its complete-output gate: ' + regime + '/' + label) + summary['cohort_selection'] = selections + summary['measurement_scope'] = measurement_scope + summary['status'] = ('complete' if all( + selection['single_case'] is not None if measurement_scope == 'single' + else selection['actual_batch_size'] == 16 + for selection in selections.values()) else 'insufficient_paired_cases') + write(output/'summary.json', summary) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--paired-results', type=Path, required=True, + help='Completed heldout per-case native/candidate records; selection uses API success only') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--regimes', nargs='+', choices=REGIMES, default=list(REGIMES)) + parser.add_argument('--measurement-scope', choices=('full', 'single'), default='full', + help='Single measures five single-source calls only; full also measures three batches and native pools') + parser.add_argument('--pool-widths', nargs='+', type=int, choices=(1, 2, 4)) + parser.add_argument('--timeout', type=float, default=1800.) + parser.add_argument('--row-ab', action='store_true', help='Separate five single-source original-row prefix calls') + parser.add_argument('--correction-adapter', type=Path, + help='Frozen corrected_reference.py from the independent study; used only for affected regimes') + args = parser.parse_args() + if args.pool_widths is None: + args.pool_widths = [1] if args.measurement_scope == 'single' else [1, 2, 4] + if args.measurement_scope == 'single' and args.pool_widths != [1]: + parser.error('Single-source latency uses exactly one worker per method') + if args.pool_widths != sorted(set(args.pool_widths)) or args.pool_widths[0] != 1: + parser.error('Pool widths must be unique, ascending and include the one-worker baseline first') + run(args) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/timing/cohort.py b/benchmarks/tls_reference/timing/cohort.py new file mode 100644 index 00000000..77c10023 --- /dev/null +++ b/benchmarks/tls_reference/timing/cohort.py @@ -0,0 +1,324 @@ +"""Select timing inputs using paired API success only, never timing/recovery. + +The first 16 cases are fixed in common.selected_names. Failed originals remain +in this receipt. Replacements are the next unused case in manifest order for +the same regime. Independent and reproduced evidence retain separate labels. +""" +import json +from pathlib import Path +import numpy as np + +if __package__: + from .common import NATIVE_BACKENDS, array_hash, selected_names, sha +else: + from common import NATIVE_BACKENDS, array_hash, selected_names, sha + + +def _reproduction_chain(manifest_path, acceptance_path, manifest, acceptance): + """Check original inputs and actual reproduced records without promotion.""" + if manifest.get('suite') != 'reproduction': + raise ValueError('A reproduction gate requires a reproduction manifest') + identity = manifest['source_identity'] + actual = acceptance.get('reproduction_sources', {}) + if (acceptance.get('original_source_identity') != identity or + actual.get('production') != identity['production_sources']): + raise ValueError('Reproduced production source differs from its original seal') + tools = actual.get('tools', {}) + if not tools.get('validate.py') or not tools.get('corrected_reference.py'): + raise ValueError('Reproduction lacks the executing validator/adapter identities') + original_path = Path(manifest_path).resolve().parent/'original_manifest.json' + if not original_path.is_file() or sha(original_path) != manifest.get('original_manifest_sha256'): + raise ValueError('Reproduction needs its unchanged original_manifest.json from cases.py') + original = json.loads(original_path.read_text()) + if (original.get('source_identity') != identity or + original.get('seal_sha256') != manifest['seal_sha256'] or + len(original['cases']) != len(manifest['cases'])): + raise ValueError('Reproduction differs from its original input population') + count = len(manifest['cases']) + counts = acceptance.get('counts', {}) + if (not count or any(counts.get(key) != count for key in + ('planned', 'accounted', 'numerical_pairs_passed')) or + acceptance.get('all_planned_accounted') is not True or + acceptance.get('unresolved_numerical_cases') or acceptance.get('candidate_regressions')): + raise ValueError('Reproduction did not account for every numerical pair') + result_root = Path(acceptance_path).resolve().parent + for source, case in zip(original['cases'], manifest['cases']): + if (case['file'] != source['file'] or case['metadata'] != source['metadata'] or + case.get('arrays') != source.get('arrays') or + case.get('original_npz_sha256') != source['sha256']): + raise ValueError('Reproduction changed an original numerical input or its metadata') + records = {} + folder = result_root/Path(case['file']).stem + for backend in ('gtls', 'gtls_corrected', 'candidate'): + path = folder/backend/'record.json' + record = json.loads(path.read_text()) + records[backend] = path + if (record.get('status') != 'ok' or record.get('input_sha256') != case['sha256'] or + record.get('seal_sha256') != manifest['seal_sha256'] or + record.get('harness_sha256') != tools['validate.py'] or + record.get('input_metadata', {}).get('cohort') != 'reproduction'): + raise ValueError('Reproduced record differs from its executing validator or input') + if backend == 'candidate': + if record.get('engine_sources') != actual['production']: + raise ValueError('Reproduced candidate used another production source') + elif record['result']['package_sources'] != acceptance['reference_package_sources']: + raise ValueError('Reproduced reference used another native package') + if backend == 'gtls_corrected' and record['result']['reference_correction'].get('adapter_sha256') != tools['corrected_reference.py']: + raise ValueError('Reproduced reference used another correction adapter') + comparison = json.loads((folder/'compare.json').read_text()) + if (comparison.get('passed') is not True or + comparison.get('reference_record_sha256') != sha(records['gtls_corrected']) or + comparison.get('candidate_record_sha256') != sha(records['candidate'])): + raise ValueError('Reproduction lacks a passing comparison for its actual records') + return actual + + +def _accepted_origin(manifest_path, acceptance_path): + path = Path(acceptance_path) + acceptance = json.loads(path.read_text()) + manifest = json.loads(Path(manifest_path).read_text()) + if 'publication_gate' in acceptance and 'reproduction_gate' in acceptance: + raise ValueError('Independent and reproduced acceptance cannot be conflated') + reproduced = 'reproduction_gate' in acceptance + gate = 'reproduction_gate' if reproduced else 'publication_gate' + if acceptance.get(gate, {}).get('pass') is not True: + raise ValueError('Numerical study has not passed its '+gate.replace('_', ' ')) + if (acceptance['inputs_manifest_sha256'] != sha(manifest_path) or + acceptance['seal_sha256'] != manifest['seal_sha256']): + raise ValueError('Accepted study and timing manifest differ') + if reproduced: + actual = _reproduction_chain(manifest_path, path, manifest, acceptance) + else: + if manifest.get('suite') == 'reproduction' or acceptance['source_identity'] != manifest['source_identity']: + raise ValueError('Independent acceptance cannot relabel a reproduced source') + actual = None + return dict(receipt_sha256=sha(path), counts=acceptance['counts'], + limits=acceptance.get('limits', []), publication_gate_passed=not reproduced, + reproduction_gate_passed=reproduced, numerical_validation_passed=True, + evidence_kind='reproduction' if reproduced else 'independent', + reproduction_sources=actual, + original_manifest_sha256=manifest.get('original_manifest_sha256') if reproduced else None, + manifest_sha256=sha(manifest_path), seal_sha256=manifest['seal_sha256'], + production_sources=manifest['source_identity']['production_sources'], + reference_package_sources=acceptance['reference_package_sources']) + + +def _resolve(manifest_path, value): + value = Path(value) + return value if value.is_absolute() else Path(manifest_path).resolve().parent/value + + +def accepted_study(manifest_path, results_root): + manifest = json.loads(Path(manifest_path).read_text()) + if 'studies' not in manifest: + return _accepted_origin(manifest_path, Path(results_root)/'acceptance.json') + origins, source_entries, reference = {}, {}, None + for name, study in manifest['studies'].items(): + origin = _resolve(manifest_path, study['manifest_path']) + receipt = _resolve(manifest_path, study['acceptance_path']) + validated = _accepted_origin(origin, receipt) + if (validated['manifest_sha256'] != study['manifest_sha256'] or + validated['seal_sha256'] != study['seal_sha256']): + raise ValueError('Merged manifest has another origin study identity') + if validated['production_sources'] != manifest['source_identity']['production_sources']: + raise ValueError('Merged timing studies used different production algorithms') + if reference is not None and reference != validated['reference_package_sources']: + raise ValueError('Merged timing studies used different native reference packages') + reference = validated['reference_package_sources'] + origins[name] = validated + source_entries[name] = {entry['file']: entry for entry in json.loads(origin.read_text())['cases']} + options = {} + for entry in manifest['cases']: + study = manifest['studies'][entry['study_id']] + original = source_entries[entry['study_id']].get(entry['file']) + if original is None or original['sha256'] != entry['sha256'] or original['metadata'] != entry['metadata']: + raise ValueError('Merged timing input differs from its accepted origin manifest') + expected_root = _resolve(manifest_path, study['results_root'])/Path(entry['file']).stem + if _resolve(manifest_path, entry['result_root']).resolve() != expected_root.resolve(): + raise ValueError('Merged case result root is outside its declared origin study') + regime, current = entry['metadata']['regime'], entry['metadata']['search_kwargs'] + if regime in options and options[regime] != current: + raise ValueError('Merged timing studies used different search options within a regime') + options[regime] = current + independent = {name:value for name,value in origins.items() if value['evidence_kind'] == 'independent'} + reproduced = {name:value for name,value in origins.items() if value['evidence_kind'] == 'reproduction'} + return dict(publication_gate_passed=not reproduced, reproduction_gate_passed=bool(reproduced), + numerical_validation_passed=True, + evidence_kind='reproduction' if reproduced else 'independent', + merged_manifest_sha256=sha(manifest_path), accepted_studies=origins, + independently_accepted_studies=independent, reproduced_studies=reproduced, + scope='Separate main and supplementary studies retain their own source/protocol/seal identities; production and native algorithms and per-regime search settings are shared') + + +def case_root(manifest_path, results_root, name, manifest=None): + manifest = json.loads(Path(manifest_path).read_text()) if manifest is None else manifest + entry = next(value for value in manifest['cases'] if value['file'] == name) + if 'result_root' in entry: + return _resolve(manifest_path, entry['result_root']) + return Path(results_root)/Path(name).stem + + +def case_seal(manifest, entry): + return (manifest['studies'][entry['study_id']]['seal_sha256'] + if 'studies' in manifest else manifest['seal_sha256']) + + +def select(manifest_path, results_root, regime): + manifest_path, results_root = Path(manifest_path), Path(results_root) + manifest = json.loads(manifest_path.read_text()) + entries = {entry['file']: entry for entry in manifest['cases']} + originals = selected_names(regime) + if any(name not in entries for name in originals): + raise ValueError('The predeclared 16-case timing cohort is absent from the manifest') + reserve = [entry['file'] for entry in manifest['cases'] + if entry['metadata']['regime'] == regime and entry['metadata'].get('null') is True + and entry['file'] not in originals] + examined, selected, replacements, excluded = [], [], [], [] + slots = [] + expected_candidate = manifest['source_identity']['production_sources'] + expected_native = None + acceptance = accepted_study(manifest_path, results_root) + + def check(name): + nonlocal expected_native + entry = entries[name] + if entry['metadata'].get('null') is not True: + raise ValueError('The primary survey timing cohort must contain only null light curves') + row = dict(case=name, input_sha256=entry['sha256'], backends={}) + for backend in ('gtls', 'candidate'): + path = case_root(manifest_path, results_root, name, manifest)/backend/'record.json' + if not path.exists(): + raise ValueError('Paired study result is not complete: ' + str(path)) + record = json.loads(path.read_text()) + if record.get('status') not in ('ok', 'error'): + raise ValueError('Paired study result is still running: ' + str(path)) + if record['input_sha256'] != entry['sha256']: + raise ValueError('Study input differs from timing manifest: ' + name) + if record.get('seal_sha256') != case_seal(manifest, entry): + raise ValueError('Study and manifest have different seals: ' + name) + if backend == 'candidate' and record['engine_sources'] != expected_candidate: + raise ValueError('Candidate study sources differ from the input seal') + if backend == 'gtls' and record['status'] == 'ok': + sources = record['result']['package_sources'] + if expected_native is not None and expected_native != sources: + raise ValueError('Native package changed within the independent study') + expected_native = sources + row['backends'][backend] = dict(status=record['status'], record_sha256=sha(path), + study_elapsed_seconds=record.get('elapsed_seconds'), + error=record.get('error') if record['status'] == 'error' else None) + row['paired_api_success'] = all(value['status'] == 'ok' for value in row['backends'].values()) + examined.append(row) + return row['paired_api_success'] + + for name in originals: + if check(name): + slots.append(name) + else: + excluded.append(name) + slots.append(None) + for name in reserve: + if all(name is not None for name in slots): + break + if check(name): + position = slots.index(None) + slots[position] = name + replacements.append(dict(original_case=originals[position], replacement_case=name)) + else: + excluded.append(name) + selected = [name for name in slots if name is not None] + single_requested = f'{regime}_null_0000.npz' + single = (single_requested if single_requested in selected else + next((entry['file'] for entry in manifest['cases'] if entry['file'] in selected), None)) + correction_traces, affected, correction_identity = [], [], None + for name in selected: + root = case_root(manifest_path, results_root, name, manifest) + trace_path = root/'correction_trace.json' + trace = json.loads(trace_path.read_text()) + if trace.get('correction') != 'finite_candidates_before_ranking_v1': + raise ValueError('Unknown corrected-native study trace') + if trace.get('proved_no_op') is not True: + affected.append(name) + corrected_path = root/'gtls_corrected/record.json' + corrected = json.loads(corrected_path.read_text()) + if corrected['input_sha256'] != entries[name]['sha256']: + raise ValueError('Corrected native used another timing input') + if corrected['status'] == 'ok': + identity = corrected['result']['reference_correction'] + if correction_identity is not None and correction_identity != identity: + raise ValueError('Native host correction changed within the study') + correction_identity = identity + correction_traces.append(dict(case=name, proved_no_op=trace.get('proved_no_op'), + corrected_status=corrected['status'], + trace_sha256=sha(trace_path), record_sha256=sha(corrected_path))) + return dict(regime=regime, requested_batch_size=16, actual_batch_size=len(selected), + status='complete' if len(selected) == 16 else 'insufficient_paired_successes', + original_cases=originals, selected_cases=selected, replacement_cases=replacements, + excluded_cases=excluded, examined=examined, + single_requested=single_requested, single_case=single, + single_replaced=single is not None and single != single_requested, + single_unavailable=single is None, + single_selection_rule='Requested null0000 when successful; otherwise earliest manifest-order paired-success null in the actual batch cohort', + selection_rule='Null-only paired API success; original null0000–0015 then next unused manifest-order null in regime. No injections, recovery, SNR output or elapsed time used.', + study_times_are_not_benchmark_denominators=True, + accepted_study=acceptance, + correction_timing=dict(required=bool(affected), affected_cases=affected, + traces=correction_traces, + rule='Additional one-worker full calls on the same entire selected cohort if any trace cannot prove the host correction is a no-op; never enters literal strongest-pool selection'), + expected_correction=correction_identity, + expected_candidate_sources=expected_candidate, + expected_native_sources=expected_native) + + +def verify_worker_sources(backend, actual, selection): + if backend in NATIVE_BACKENDS: + if actual['files'] != selection['expected_native_sources']: + raise ValueError('Timed native package differs from the independent study') + if backend == 'gtls_corrected' and actual.get('reference_correction') != selection['expected_correction']: + raise ValueError('Timed native correction differs from the independent study') + else: + expected = selection['expected_candidate_sources'] + for name, digest in actual['files'].items(): + if expected.get('cuvarbase/' + name) != digest: + raise ValueError('Timed candidate source differs from the study: ' + name) + + +def frozen_outputs(results_root, cases, backends=('gtls', 'candidate'), manifest_path=None): + """Read retained complete-output hashes before timed workers start. + + Pool-width selection still uses the literal one-worker native reference. + A candidate with a declared native-bug correction must reproduce its own + frozen public output; this function does not silently approve a scientific + difference between the two implementations. + """ + result = {backend: {} for backend in backends} + for case in cases: + for backend in result: + root = (case_root(manifest_path, results_root, case['name']) if manifest_path is not None else + Path(results_root)/Path(case['name']).stem)/backend + record_path = root/'record.json' + record = json.loads(record_path.read_text()) + if record['status'] != 'ok' or record['input_sha256'] != case['input_sha256']: + raise ValueError('Cannot freeze a failed or different timing-case output') + arrays = record['arrays'] + strict = {} + for key in ('periods', 'power', 'chi2'): + source = key if backend in NATIVE_BACKENDS else 'public_'+key + info = arrays[source] + if len(info['shape']) != 1: + raise ValueError('Frozen public spectrum is not one-dimensional') + mask = (arrays[key+'_mask']['sha256'] if backend in NATIVE_BACKENDS else + array_hash(np.zeros(info['shape'], dtype=bool))) + strict[key] = dict(data=info['sha256'], mask=mask) + if backend in NATIVE_BACKENDS: + period, sde = record['result']['period'], record['result']['score'] + else: + contract = record['result']['public_contract'] + period, sde = contract['period'], contract['SDE'] + if period is None or sde is None or not np.isfinite(period) or not np.isfinite(sde): + raise ValueError('Frozen public detection is not finite') + strict.update(period=array_hash(np.array(period, np.float64)), + SDE=array_hash(np.array(sde, np.float64))) + result[backend][case['name']] = dict(strict=strict, + record_sha256=sha(record_path), arrays_sha256=record['arrays_sha256'], + representation='Complete per-array dtype/shape/data hashes and masks retained by the independent study; large NPZ retention is not required') + return result diff --git a/benchmarks/tls_reference/timing/common.py b/benchmarks/tls_reference/timing/common.py new file mode 100644 index 00000000..659043bc --- /dev/null +++ b/benchmarks/tls_reference/timing/common.py @@ -0,0 +1,260 @@ +"""Shared inputs, literal public calls and post-measurement validation. + +This module performs no cloud/resource actions. Scientific source is imported +from the installed packages and never edited by the timing runner. +""" +from __future__ import annotations + +import hashlib +import importlib.metadata +import importlib.util +import json +import os +from pathlib import Path +import platform +import subprocess +import sys + +import numpy as np + + +REGIMES = ('tess_solar', 'tess_gap', 'ztf_solar') +SINGLE_REPETITIONS = 5 +BATCH_REPETITIONS = 3 +NATIVE_BACKENDS = ('gtls', 'gtls_corrected') +_CORRECTION_CONTEXTS = [] + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def plain(value): + if isinstance(value, np.generic): + return plain(value.item()) + if isinstance(value, np.ndarray): + return plain(value.tolist()) + if isinstance(value, float) and not np.isfinite(value): + return None + if isinstance(value, dict): + return {str(key): plain(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [plain(item) for item in value] + return value + + +def write(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + '.tmp') + temporary.write_text(json.dumps(plain(value), indent=2, allow_nan=False) + '\n') + temporary.replace(path) + + +def array_hash(value): + value = np.ascontiguousarray(value) + if value.dtype.hasobject: + raise TypeError('Object-array pointer bytes are not a numerical fingerprint') + digest = hashlib.sha256() + # Identical encoding to the independent study's parity_harness.array_hash. + digest.update(json.dumps(value.dtype.descr if value.dtype.names else value.dtype.str).encode()) + digest.update(json.dumps(value.shape).encode()) + digest.update(value.tobytes()) + return digest.hexdigest() + + +def masked_hash(value): + value = np.ma.asarray(value) + return dict(data=array_hash(value.data), mask=array_hash(np.ma.getmaskarray(value))) + + +def selected_names(regime): + if regime not in REGIMES: + raise ValueError('Unknown timing regime: ' + regime) + return [f'{regime}_null_{index:04d}.npz' for index in range(16)] + + +def load_cases(manifest_path, regime, names=None): + """Load and verify all bytes before clocks start; never generate a grid.""" + manifest_path = Path(manifest_path).resolve() + manifest = json.loads(manifest_path.read_text()) + entries = {entry['file']: entry for entry in manifest['cases']} + names = selected_names(regime) if names is None else names + cases = [] + for name in names: + entry = entries[name] + path = manifest_path.parent / name + if sha(path) != entry['sha256']: + raise ValueError('Fixture differs from its manifest: ' + name) + with np.load(path, allow_pickle=False) as source: + data = {key: np.array(source[key], copy=True) for key in ('t', 'y', 'dy', 'periods')} + metadata = json.loads(str(source['metadata'])) + if metadata['regime'] != regime: + raise ValueError('Fixture belongs to another regime: ' + name) + if np.any(data['t'] <= 0) or np.any(~np.isfinite(data['t'])): + raise ValueError('Timing requires the same finite positive-origin inputs for both APIs') + if np.any(np.diff(data['periods']) <= 0): + raise ValueError('The sealed timing grid must be strictly increasing, matching study public-output order') + options = dict(metadata['search_kwargs']) + cases.append(dict(name=name, data=data, options=options, metadata=metadata, + input_sha256=entry['sha256'], + arrays={key: array_hash(value) for key, value in data.items()}, + error_scale=float(np.mean(data['dy'])))) + if not cases: + raise ValueError('No timing cases selected') + first = cases[0] + for case in cases[1:]: + if (case['arrays']['periods'] != first['arrays']['periods'] or + case['options'] != first['options']): + raise ValueError('Public batch requires one identical period grid and search configuration') + return cases + + +def case_identity(case): + return {key: case[key] for key in ('name', 'input_sha256', 'arrays', 'options')} + + +def package_sources(backend): + if backend in NATIVE_BACKENDS: + import gputls + root = Path(gputls.__file__).parent + paths = sorted(path for path in root.rglob('*') + if path.is_file() and path.suffix in ('.py', '.cu', '.cuh') + and '__pycache__' not in path.parts) + else: + import cuvarbase + root = Path(cuvarbase.__file__).parent + paths = [root / name for name in ('tls.py', 'tls_reference.py', + 'tls_reference_math.py', 'tls_reference_frontend.py', + 'tls_reference_prefix.py', 'tls_grids.py', 'tls_stats.py', + 'tls_models.py', 'kernels/tls_reference.cu', + 'kernels/tls_reference_prepare.cu')] + return dict(root=str(root), files={str(path.relative_to(root)): sha(path) for path in paths}) + + +def initialize_backend(backend, prefix='graph', correction_adapter=None): + import cupy as cp + cp.cuda.Device(0).use() + if backend == 'candidate': + from cuvarbase import tls, tls_reference + if prefix == 'row': + tls_reference._native_flux_prefix = tls_reference._row_flux_prefix + elif prefix != 'graph': + raise ValueError('Unknown prefix implementation') + elif backend in NATIVE_BACKENDS: + from gputls import gtls, core + if backend == 'gtls_corrected': + if correction_adapter is None: + raise ValueError('Corrected-native timing requires the frozen validation adapter') + path = Path(correction_adapter).resolve() + spec = importlib.util.spec_from_file_location('_tls_timing_corrected_reference', path) + adapter = importlib.util.module_from_spec(spec) + spec.loader.exec_module(adapter) + context = adapter.apply(core) + provenance = context.__enter__() + # One dedicated process uses this explicitly labeled correction + # throughout its lifetime. Installed source files remain unchanged. + _CORRECTION_CONTEXTS.append(context) + else: + raise ValueError('Unknown backend') + cp.cuda.runtime.deviceSynchronize() + sources = package_sources(backend) + if backend == 'gtls_corrected': + sources['reference_correction'] = provenance + return sources + + +def public_single(backend, case): + data, options = case['data'], case['options'] + if backend in NATIVE_BACKENDS: + from gputls import gtls + model = gtls(data['t'], data['y'], data['dy'], verbose=False) + return model.power(periods=data['periods'], fast=False, + verbose=False, show_progress_bar=False, **options) + from cuvarbase.tls import tls_search_gpu + return tls_search_gpu(data['t'], data['y'], data['dy'], periods=data['periods'], + full=True, return_arrays=True, **options) + + +def public_batch(backend, cases): + if backend in NATIVE_BACKENDS: + return [public_single(backend, case) for case in cases] + from cuvarbase.tls import tls_search_batch + curves = [(case['data']['t'], case['data']['y'], case['data']['dy']) for case in cases] + return tls_search_batch(curves, periods=cases[0]['data']['periods'], + full=True, return_arrays=True, **cases[0]['options']) + + +def fingerprint(backend, case, result): + """Only call after the measurement barrier; hashes all returned arrays. + + The strict pool gate uses native period/chi2/power bytes including masks. + A separate common representation compares public output units and ignores + only data hidden by the scientific mask. It never replaces the strict gate. + """ + values = vars(result) if backend in NATIVE_BACKENDS else result + if backend == 'candidate' and 'error' in values: + raise ValueError('Candidate returned an error result: ' + str(values['error'])) + for key in ('periods', 'power', 'chi2', 'period', 'SDE'): + if key not in values: + raise ValueError('Public result lacks ' + key) + period, sde = float(values['period']), float(values['SDE']) + if not np.isfinite(period) or not np.isfinite(sde): + raise ValueError('Public result has no finite full-search detection') + fields = {} + for key, value in sorted(values.items()): + if isinstance(value, (np.ndarray, np.ma.MaskedArray)): + fields[key] = masked_hash(value) + elif isinstance(value, (list, tuple)) and value and isinstance(value[0], (int, float, np.number)): + fields[key] = masked_hash(np.asarray(value)) + else: + fields[key] = plain(value) + strict = {key: masked_hash(values[key]) for key in ('periods', 'power', 'chi2')} + strict['period'] = array_hash(np.array(period, dtype=np.float64)) + strict['SDE'] = array_hash(np.array(sde, dtype=np.float64)) + chi2 = np.ma.asarray(values['chi2']) + mask = np.ma.getmaskarray(chi2) | ~np.isfinite(np.asarray(chi2.data)) + if backend == 'candidate': + mask |= ~np.asarray(values['valid_periods']) + common = {} + for key in ('periods', 'power', 'chi2'): + array = np.asarray(np.ma.getdata(values[key]), dtype=np.float64).copy() + if key == 'chi2' and backend in NATIVE_BACKENDS: + array /= case['error_scale']**2 + array[mask] = np.nan + common[key] = array_hash(array) + common['mask'] = array_hash(mask) + common['period'], common['SDE'] = strict['period'], strict['SDE'] + return dict(case=case['name'], strict=strict, common=common, fields=fields, + full_digest=hashlib.sha256(json.dumps(fields, sort_keys=True, allow_nan=False).encode()).hexdigest(), + primary_period=period, SDE=sde, nperiods=len(chi2)) + + +def environment(): + versions = {} + for package in ('numpy', 'scipy', 'cupy-cuda12x', 'pycuda', 'batman-package', 'numba', 'pynvml'): + try: + versions[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + versions[package] = None + quota = {} + for name in ('/sys/fs/cgroup/cpu.max', '/sys/fs/cgroup/cpu.stat', + '/sys/fs/cgroup/memory.max', '/sys/fs/cgroup/memory.current'): + path = Path(name) + if path.exists(): + quota[name] = path.read_text().strip() + gpu = subprocess.run(['nvidia-smi', '--query-gpu=name,uuid,driver_version,memory.total,power.limit', + '--format=csv,noheader,nounits'], capture_output=True, text=True, check=False) + cpu_quota = None + if '/sys/fs/cgroup/cpu.max' in quota: + amount, interval = quota['/sys/fs/cgroup/cpu.max'].split() + if amount != 'max': + cpu_quota = int(amount) / int(interval) + threads = {name: os.environ.get(name) for name in ('OMP_NUM_THREADS', + 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'VECLIB_MAXIMUM_THREADS', + 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS')} + return dict(python=sys.version, platform=platform.platform(), packages=versions, + cgroup=quota, cpu_quota_cores=cpu_quota, + cpu_affinity=sorted(os.sched_getaffinity(0)) if hasattr(os, 'sched_getaffinity') else None, + cpu_math_thread_environment=threads, + nvidia_smi=gpu.stdout.strip(), nvidia_smi_error=gpu.stderr.strip()) diff --git a/benchmarks/tls_reference/timing/components.py b/benchmarks/tls_reference/timing/components.py new file mode 100644 index 00000000..238ccb34 --- /dev/null +++ b/benchmarks/tls_reference/timing/components.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Separate component measurements; never substitute them for public timings. + +The native common-search endpoint is stamped immediately after the pinned +final single-period argmin().get(), before physical/SNR postprocessing. The +candidate endpoint is immediately after engine.search_full returns. Added +wrappers and one in-memory timestamp statement do not alter numerical source +or returned values; every completed result must match a literal public call. +""" +from __future__ import annotations + +import argparse +from collections import defaultdict +import functools +import hashlib +import inspect +import json +import multiprocessing as mp +import os +from pathlib import Path +import time +import traceback + +if __name__ == '__main__': + for variable in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[variable] = '1' + +if __package__: + from .common import (NATIVE_BACKENDS, REGIMES, SINGLE_REPETITIONS, case_identity, environment, + fingerprint, initialize_backend, load_cases, public_single, + masked_hash, sha, write) + from .cohort import frozen_outputs, select, verify_worker_sources + from .benchmark import (GPUOwnership, Monitor, exclusive_gpu_processes, + ownership_valid, process_ids, retain_cuda_context) +else: + from common import (NATIVE_BACKENDS, REGIMES, SINGLE_REPETITIONS, case_identity, environment, + fingerprint, initialize_backend, load_cases, public_single, + masked_hash, sha, write) + from cohort import frozen_outputs, select, verify_worker_sources + from benchmark import (GPUOwnership, Monitor, exclusive_gpu_processes, + ownership_valid, process_ids, retain_cuda_context) + + +class Components: + def __init__(self, backend): + self.backend = backend + self.patches = [] + self.reset() + + def reset(self): + self.events = [] + self.search_end = None + self.search_result = None + self.boundary_count = 0 + + def patch(self, owner, name, replacement): + self.patches.append((owner, name, getattr(owner, name))) + setattr(owner, name, replacement) + + def timed(self, function, label, boundary=False): + @functools.wraps(function) + def wrapped(*args, **kwargs): + before = time.perf_counter() + try: + result = function(*args, **kwargs) + if boundary: + self.search_end = time.perf_counter() + self.boundary_count += 1 + self.search_result = result + return result + finally: + self.events.append(dict(stage=label, started=before, ended=time.perf_counter())) + return wrapped + + def mark_native_search_end(self): + self.search_end = time.perf_counter() + self.boundary_count += 1 + # Keep CPU spectrum references only after recording the timestamp. + # They can document a finished search if later native diagnostics fail. + frame = inspect.currentframe().f_back + while frame is not None and frame.f_code.co_name not in ( + 'search_multi_periods', 'search_multi_periods_multiGPU'): + frame = frame.f_back + if frame is None: + raise RuntimeError('Pinned native final search caller was not found') + values = frame.f_locals + self.search_result = {key: values[key] for key in ('periods', 'period', 'power', 'chi2', 'SDE')} + + def __enter__(self): + try: + return self.install() + except BaseException: + self.__exit__() + raise + + def install(self): + if self.backend in NATIVE_BACKENDS: + from gputls import core, stats + self.patch(core, 'spectra', self.timed(core.spectra, 'native_spectra')) + self.patch(core, 'search_multi_periods_again', + self.timed(core.search_multi_periods_again, 'native_candidate_or_harmonic_refinement')) + self.patch(core, 'snr_stats', self.timed(core.snr_stats, 'native_snr_stats_inclusive')) + self.patch(stats, 'pink_noise', self.timed(stats.pink_noise, 'native_pink_noise_nested')) + original = core.search_single_periods + source = inspect.getsource(original) + needle = ' bestLocation = lowestResidualsGPU.argmin().get()\n' + if source.count(needle) != 1: + raise ValueError('Pinned GTLS search-end statement was not found exactly once') + self.endpoint_source_sha256 = hashlib.sha256(source.encode()).hexdigest() + instrumented = source.replace(needle, needle + ' _benchmark_search_end()\n') + namespace = dict(core.__dict__, _benchmark_search_end=self.mark_native_search_end) + exec(compile(instrumented, str(core.__file__) + ':benchmark_timestamp', 'exec'), namespace) + self.patch(core, 'search_single_periods', + self.timed(namespace['search_single_periods'], 'native_final_window_and_diagnostics')) + else: + from cuvarbase import tls_reference as engine + self.endpoint_source_sha256 = sha(engine.__file__) + self.patch(engine, 'search_full', + self.timed(engine.search_full, 'candidate_search_full', boundary=True)) + original = engine.raw_search + @functools.wraps(original) + def raw(*args, **kwargs): + label = ('candidate_full_window_stage' if kwargs.get('full', False) + else 'candidate_coarse_stage') + return self.timed(original, label)(*args, **kwargs) + self.patch(engine, 'raw_search', raw) + for name, label in (('build_cache', 'candidate_template_cache'), + ('native_spectra', 'candidate_spectra'), + ('final_parameters', 'candidate_final_physical_parameters')): + self.patch(engine.reference, name, self.timed(getattr(engine.reference, name), label)) + return self + + def __exit__(self, *unused): + for owner, name, original in reversed(self.patches): + setattr(owner, name, original) + self.patches = [] + + def accounting(self, before, after): + durations = defaultdict(float) + calls = defaultdict(int) + for event in self.events: + durations[event['stage']] += event['ended'] - event['started'] + calls[event['stage']] += 1 + return dict(public_instrumented_seconds=after-before, + common_search_seconds=None if self.search_end is None else self.search_end-before, + after_common_search_seconds=None if self.search_end is None else after-self.search_end, + endpoint_count=self.boundary_count, + endpoint_valid=(self.boundary_count == 1 and self.search_end is not None and + before <= self.search_end <= after), + inclusive_stage_seconds=dict(durations), stage_call_counts=dict(calls), + events=[dict(stage=event['stage'], start_from_public=event['started']-before, + end_from_public=event['ended']-before) for event in self.events], + overlap_note='Stage times are inclusive and can overlap: pink_noise is nested in snr_stats, which is nested in final_window_and_diagnostics.') + + +def attempt(backend, case): + import cupy as cp + cp.cuda.runtime.deviceSynchronize() + before = time.perf_counter() + result, error = None, None + try: + result = public_single(backend, case) + cp.cuda.runtime.deviceSynchronize() + except Exception as failure: + error = dict(type=type(failure).__name__, message=str(failure), traceback=traceback.format_exc()) + after = time.perf_counter() + return before, after, result, error + + +def run_owned(args, sources, ownership): + """Numerical instrumentation stays in one child, after parent approval.""" + output = args.output.resolve() + allowed = sorted(value['host_pid'] for value in ownership['bindings']) + plan = dict(backend=args.backend, manifest_sha256=sha(args.manifest), + measurement_scope=getattr(args, 'measurement_scope', 'full'), + repetitions=SINGLE_REPETITIONS, full=True, + source_files=sources, environment=environment(), + harness_sources={path.name: sha(path) for path in Path(__file__).parent.glob('*.py')}, + common_endpoint=('Immediately after native final bestLocation = lowestResidualsGPU.argmin().get()' + if args.backend in NATIVE_BACKENDS else 'Immediately after production engine.search_full returns'), + timing_scope='From public-call entry including constructor/validation/cache through final window selection; candidate also transfers compact winner fields before its endpoint', + reporting='Separate component experiment; public headline timings must come from benchmark.py', + gpu_ownership=ownership, + cohorts={}) + write(output/'plan.json', plan) + records = {} + with Monitor(output/'monitor.jsonl'): + for regime in args.regimes: + selection = select(args.manifest, args.paired_results, regime) + verify_worker_sources(args.backend, sources, selection) + plan['cohorts'][regime] = selection + write(output/'plan.json', plan) + if selection['single_case'] is None: + records[regime] = dict(status='no_paired_single_case') + continue + if args.backend == 'gtls_corrected' and not selection['correction_timing']['required']: + records[regime] = dict(status='correction_proved_no_op_on_entire_timing_cohort') + continue + case = load_cases(args.manifest, regime, [selection['single_case']])[0] + expected = frozen_outputs(args.paired_results, [case], backends=(args.backend,), + manifest_path=args.manifest)[args.backend][case['name']] + literal_exclusive_before = exclusive_gpu_processes(allowed) + before, after, literal, error = attempt(args.backend, case) + literal_exclusive_after = exclusive_gpu_processes(allowed, strict=False) + if not literal_exclusive_after['exclusive']: + error = dict(type='ConcurrencyError', snapshot=literal_exclusive_after) + baseline = None + if literal is not None: + try: + baseline = fingerprint(args.backend, case, literal) + except Exception: + error = dict(type='FingerprintError', traceback=traceback.format_exc()) + record = dict(case=case_identity(case), literal_warmup_seconds=after-before, + literal_error=error, literal_outputs=baseline, repetitions=[], + gpu_ownership=ownership, + literal_exclusive_before=literal_exclusive_before, + literal_exclusive_after=literal_exclusive_after, + frozen_outputs=expected, + literal_matches_frozen=error is None and baseline is not None and baseline['strict'] == expected['strict']) + literal = None + with Components(args.backend) as instrument: + record['endpoint_source_sha256'] = instrument.endpoint_source_sha256 + for _ in range(SINGLE_REPETITIONS): + instrument.reset() + exclusive_before = exclusive_gpu_processes(allowed) + before, after, result, error = attempt(args.backend, case) + exclusive_after = exclusive_gpu_processes(allowed, strict=False) + if not exclusive_after['exclusive']: + error = dict(type='ConcurrencyError', snapshot=exclusive_after) + measured = instrument.accounting(before, after) + observed = None + if result is not None: + try: + observed = fingerprint(args.backend, case, result) + except Exception: + error = dict(type='FingerprintError', traceback=traceback.format_exc()) + exact = bool(baseline is not None and observed is not None and + observed['full_digest'] == baseline['full_digest']) + measured.update(error=error, outputs=observed, + exclusive_before=exclusive_before, exclusive_after=exclusive_after, + output_identical_to_literal=exact, + denominator_eligible=exact and error is None and measured['endpoint_valid'] and record['literal_matches_frozen'], + completed_search_before_api_failure=instrument.search_end is not None and error is not None) + if instrument.search_result is not None and args.backend in NATIVE_BACKENDS: + # Retained arrays are hashed after the measurement, + # including when optional native postprocessing failed. + measured['search_outputs'] = {key: masked_hash(value) + for key, value in instrument.search_result.items()} + record['repetitions'].append(measured) + write(output/(regime+'.json'), record) + result = None + record['status'] = ('ok' if all(rep['denominator_eligible'] for rep in record['repetitions']) + else 'failed_literal_output_gate') + records[regime] = dict(status=record['status'], file=regime+'.json') + write(output/(regime+'.json'), record) + # The supervisor alone can complete the lifecycle after this process exits. + write(output/'summary.json', dict(status='awaiting_worker_exit', backend=args.backend, regimes=records)) + + +def component_worker(connection, args): + try: + sources = initialize_backend(args.backend, correction_adapter=args.correction_adapter) + context_allocation = retain_cuda_context() + connection.send(dict(kind='ready', pid=os.getpid(), namespace_pids=process_ids(), sources=sources, + cuda_context_allocation_bytes=1, cuda_context_synchronized=True)) + command = connection.recv() + if command.get('kind') != 'bind': + raise RuntimeError('Component worker was not given its verified host PID binding') + run_owned(args, sources, command['ownership']) + connection.send(dict(kind='complete')) + except BaseException: + connection.send(dict(kind='fatal', traceback=traceback.format_exc())) + raise + finally: + connection.close() + + +def run(args): + """Supervise context birth and exit outside every component clock.""" + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + ownership, process, parent, child = None, None, None, None + forced, failure = [], None + try: + ownership = GPUOwnership() + context = mp.get_context('spawn') + parent, child = context.Pipe() + process = context.Process(target=component_worker, args=(child, args)) + process.start() + child.close() + if not parent.poll(180.): + raise TimeoutError('Component worker startup timed out') + ready = parent.recv() + if ready.get('kind') != 'ready': + raise RuntimeError('Component startup failed: ' + repr(ready)) + ownership.bind([ready], [process]) + write(output/'ownership.json', ownership.receipt) + parent.send(dict(kind='bind', ownership=ownership.receipt)) + if not parent.poll(600.): + raise TimeoutError('Component worker did not complete') + completed = parent.recv() + if completed.get('kind') != 'complete': + raise RuntimeError('Component worker failed: ' + repr(completed)) + except BaseException: + failure = traceback.format_exc() + finally: + receipt = (dict(status='failed', passed=False) if ownership is None else ownership.receipt) + if parent is not None: + parent.close() + if child is not None: + child.close() + if process is not None and process.pid is not None: + process.join(timeout=5.) + if process.is_alive(): + forced.append(process.pid) + process.terminate() # Only the Process object started here. + process.join(timeout=5.) + if ownership is not None: + try: + receipt = ownership.finish([] if process is None else [process], forced=forced) + write(output/'ownership.json', receipt) + if receipt['passed'] is not True: + failure = (failure or '') + '\nComponent GPU ownership lifecycle did not pass' + except Exception: + failure = (failure or '') + '\n' + traceback.format_exc() + summary_path = output/'summary.json' + summary = json.loads(summary_path.read_text()) if summary_path.exists() else dict( + backend=args.backend, regimes={}) + for regime in args.regimes: + path = output/(regime+'.json') + if path.exists(): + record = json.loads(path.read_text()) + record['gpu_ownership'] = receipt + if not ownership_valid(record): + record['status'] = 'ownership_failure' + failure = (failure or '') + '\nInvalid component ownership receipt: ' + regime + write(path, record) + summary['regimes'].setdefault(regime, dict(file=regime+'.json'))['status'] = record['status'] + summary.update(status='error' if failure else 'complete', gpu_ownership=receipt) + if failure: + summary['error'] = failure + write(summary_path, summary) + if failure: + raise RuntimeError('Component supervision failed: ' + failure) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--paired-results', type=Path, required=True) + parser.add_argument('--backend', choices=('gtls', 'gtls_corrected', 'candidate'), required=True) + parser.add_argument('--correction-adapter', type=Path) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--measurement-scope', choices=('full', 'single'), default='full', + help='Record the associated public timing scope; components always measure one source') + parser.add_argument('--regimes', nargs='+', choices=REGIMES, default=list(REGIMES)) + args = parser.parse_args() + for variable in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[variable] = '1' + run(args) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/timing/merge.py b/benchmarks/tls_reference/timing/merge.py new file mode 100644 index 00000000..853c49e8 --- /dev/null +++ b/benchmarks/tls_reference/timing/merge.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Prepare portable timing inputs from accepted original or reproduced studies.""" +import argparse +import json +import os +from pathlib import Path +import shutil + +if __package__: + from .cohort import _accepted_origin, accepted_study + from .common import selected_names, sha +else: + from cohort import _accepted_origin, accepted_study + from common import selected_names, sha + + +REGIMES = ('tess_solar', 'tess_gap', 'ztf_solar') + + +def merge(studies, output): + """Keep each source's seal and result directory; never reseal its outcomes.""" + output = Path(output).resolve() + if output.exists(): + raise ValueError('Use a new timing-input directory') + origins, cases, identity = {}, [], None + names = set() + for label, manifest_path, results_root in studies: + if label in origins: + raise ValueError('Study labels must be distinct') + manifest_path, results_root = Path(manifest_path).resolve(), Path(results_root).resolve() + manifest = json.loads(manifest_path.read_text()) + acceptance_path = results_root/'acceptance.json' + _accepted_origin(manifest_path, acceptance_path) + if identity is None: + identity = manifest['source_identity'] + elif manifest['source_identity']['production_sources'] != identity['production_sources']: + raise ValueError('Timing studies used different production sources') + origins[label] = dict(manifest_path=os.path.relpath(manifest_path, output), + acceptance_path=os.path.relpath(acceptance_path, output), + results_root=os.path.relpath(results_root, output), + manifest_sha256=sha(manifest_path), seal_sha256=manifest['seal_sha256']) + for case in manifest['cases']: + if case['metadata'].get('null') is not True or case['metadata']['regime'] not in REGIMES: + continue + name = case['file'] + if name in names or Path(name).name != name: + raise ValueError('Timing input filenames must be distinct plain filenames') + names.add(name) + source = manifest_path.parent/name + if sha(source) != case['sha256']: + raise ValueError('Timing input bytes differ from their accepted manifest') + cases.append((dict(case, study_id=label, + result_root=os.path.relpath(results_root/Path(name).stem, output)), source)) + if not origins: + raise ValueError('Provide at least one accepted study') + expected = {name for regime in REGIMES for name in selected_names(regime)} + if not expected.issubset(names): + raise ValueError('The fixed16-source null cohort is incomplete; reproduce both main and supplemental studies') + output.mkdir(parents=True) + for case, source in cases: + shutil.copyfile(source, output/case['file']) + if sha(output/case['file']) != case['sha256']: + raise ValueError('Copied timing input failed its hash check') + manifest = dict(schema_version=1, suite='validated_timing_merge', + source_identity=identity, studies=origins, cases=[case for case, _ in cases], + scope='Timing inputs retain each original or reproduced study identity; merging does not create independent evidence') + path = output/'manifest.json' + path.write_text(json.dumps(manifest, indent=2)+'\n') + validated = accepted_study(path, output) + reproduced = validated['evidence_kind'] == 'reproduction' + acceptance = dict(schema_version=1, + inputs_manifest_sha256=sha(path), classification=validated['evidence_kind'], + accepted_studies=validated['accepted_studies'], counts=dict(timing_inputs=len(cases)), + limits=['This merge creates no new independent evidence', + 'Every timing input retains its separate source manifest, seal and numerical-validation receipt']) + if reproduced: + acceptance.update(reproduction_gate={'pass': True}, original_source_identity=identity, + reproduction_sources=dict(production=identity['production_sources'], + studies={name: value['reproduction_sources'] for name, value in + validated['reproduced_studies'].items()})) + else: + acceptance.update(publication_gate={'pass': True}, source_identity=identity) + (output/'acceptance.json').write_text(json.dumps(acceptance, indent=2)+'\n') + return dict(manifest=str(path), acceptance=str(output/'acceptance.json'), + input_count=len(cases), evidence_kind=validated['evidence_kind']) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--study', nargs=3, action='append', required=True, + metavar=('NAME', 'MANIFEST', 'RESULTS')) + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + print(json.dumps(merge(args.study, args.out), indent=2)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/timing/report_completed.py b/benchmarks/tls_reference/timing/report_completed.py new file mode 100644 index 00000000..e71f9c16 --- /dev/null +++ b/benchmarks/tls_reference/timing/report_completed.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +"""Assess complete comparisons separately from a failed full timing campaign. + +This is an explicitly post hoc reporting assessment. It never edits or relabels +original campaign acceptance, records, or normalization. The only permitted +campaign incompleteness is a disclosed optional native pool's warmup OOM. +Every included configuration retains the original numerical, source, cohort, +repetition, and process-ownership requirements, with full-object repeatability +checked additionally. All medians and pool choices are recomputed from records. +No GPU, cloud, or benchmark execution is performed by this program. +""" +from __future__ import annotations + +import argparse +from collections import Counter +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +import math +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile + +REGIMES = ('tess_solar', 'tess_gap', 'ztf_solar') +CANDIDATE_FILES = {'tls.py', 'tls_reference.py', 'tls_reference_math.py', + 'tls_reference_frontend.py', 'tls_reference_prefix.py', 'tls_grids.py', + 'tls_stats.py', 'tls_models.py', 'kernels/tls_reference.cu', + 'kernels/tls_reference_prepare.cu'} +ALLOWED_OMISSION_REASONS = { + 'Configuration did not complete successfully', 'Warmup did not complete', + 'Batch repetition count is not three'} +REPORTING_SCOPE = 'post_hoc_complete_configurations_after_optional_native_warmup_oom' + + +def read(path): + return json.loads(Path(path).read_text()) + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def demand(condition, message): + if not condition: + raise ValueError(message) + + +def write(path, data): + Path(path).write_text(json.dumps(data, indent=2, allow_nan=False) + '\n') + + +def load_module(name, path, package=False): + spec = importlib.util.spec_from_file_location(name, path, + submodule_search_locations=[str(Path(path).parent)] if package else None) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@contextmanager +def frozen_modules(source_root): + """Import byte-verified source extracted from the executed archive.""" + source_root = Path(source_root) + manifest = read(source_root/'execution-sources.json') + archive = source_root/manifest['archive'] + demand(sha(archive) == manifest['archive_sha256'], 'Execution archive hash mismatch') + with tempfile.TemporaryDirectory(prefix='tls-frozen-report-') as tmp: + tmp = Path(tmp) + with tarfile.open(archive) as stream: + members = [member for member in stream if member.isfile()] + demand(set(member.name for member in members) == set(manifest['files']), + 'Execution archive file inventory differs') + for member in members: + path = Path(member.name) + demand(not path.is_absolute() and '..' not in path.parts, + 'Unsafe execution source member') + data = stream.extractfile(member).read() + demand(hashlib.sha256(data).hexdigest() == manifest['files'][member.name], + 'Execution source member hash differs: ' + member.name) + target = tmp/path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + tools = tmp/'latency/tools/timing' + package = '_tls_reporting_frozen' + for name in list(sys.modules): + if name == package or name.startswith(package+'.'): + del sys.modules[name] + load_module(package, tools/'__init__.py', package=True) + benchmark = load_module(package+'.benchmark', tools/'benchmark.py') + cohort = sys.modules[package+'.cohort'] + common = sys.modules[package+'.common'] + summarize = load_module(package+'.summarize', tools/'summarize.py') + analyze = load_module(package+'.analyzer', tools.parent/'analyze_timing.py') + try: + yield dict(root=tmp, benchmark=benchmark, cohort=cohort, common=common, + summarize=summarize, analyze=analyze, manifest=manifest) + finally: + for name in list(sys.modules): + if name == package or name.startswith(package+'.'): + del sys.modules[name] + + +def verify_collection(checkpoint): + """A failed pipeline may still have complete, verified collection.""" + checkpoint = Path(checkpoint) + outcome = read(checkpoint/'outcome.json') + demand(outcome.get('final_compact_verified') is True and + outcome.get('missing_final_paths') == [], + 'Final critical evidence was not completely collected') + termination = read(checkpoint/'termination.json') + demand(termination.get('verified') is True, 'Allocation termination is unverified') + index = read(checkpoint/'file-index.json') + prefix = 'timing-continuation/results/' + selected = {name: entry for name, entry in index.items() if name.startswith(prefix)} + demand(bool(selected), 'No collected timing campaign') + for name, entry in selected.items(): + path = checkpoint/'files'/name + demand(path.is_file() and path.stat().st_size == entry['size'] and + sha(path) == entry['sha256'], 'Collected file mismatch: ' + name) + root = checkpoint/'files'/prefix + disk = {str(path.relative_to(checkpoint/'files')) for path in root.rglob('*') if path.is_file()} + demand(disk == set(selected), 'Unindexed or missing collected campaign files') + return root, dict(outcome_sha256=sha(checkpoint/'outcome.json'), + termination_sha256=sha(checkpoint/'termination.json'), + file_index_sha256=sha(checkpoint/'file-index.json'), + verified_files={name: dict(sha256=item['sha256'], size=item['size']) + for name, item in sorted(selected.items())}, + collection_scope=outcome.get('completeness_scope'), + original_pipeline_exit_code=outcome.get('exit_code')) + + +def check_driver_terminal(timing, prepared): + terminal, status = read(timing/'terminal.json'), read(timing/'status.json') + expected = ['preflight', 'components_candidate', 'components_gtls'] + if any(item['correction_timing']['required'] for item in prepared['selections'].values()): + expected.append('components_gtls_corrected') + expected += ['public', 'summarize', 'normalize'] + stages = status.get('stages', []) + demand(terminal.get('status') == 'error' and type(terminal.get('exit_code')) is int and + terminal['exit_code'] == 2 and + 'ValueError: Final timing acceptance failed: ' in terminal.get('error', ''), + 'Original driver did not fail specifically at its final audit') + demand(status.get('status') == 'error' and [item.get('name') for item in stages] == expected and + all(item.get('status') == 'complete' and type(item.get('exit_code')) is int and + item['exit_code'] == 0 for item in stages), + 'An earlier original stage is missing or failed') + plan = read(timing/'pipeline-plan.json') + demand([item['name'] for item in plan['stages']] == expected and + all(actual['command'] == declared['command'] and + actual['timeout_seconds'] == declared['timeout_seconds'] + for actual, declared in zip(stages, plan['stages'])), + 'Executed stage inventory differs from its original pipeline plan') + return terminal + + +def replay_original_audit(timing, frozen): + """Run the exact original audit against read-only symlinks in a new folder.""" + with tempfile.TemporaryDirectory(prefix='tls-original-audit-') as folder: + folder = Path(folder) + for path in timing.iterdir(): + if path.name != 'acceptance.json': + (folder/path.name).symlink_to(path.resolve(), target_is_directory=path.is_dir()) + code = """import sys +from pathlib import Path +from types import SimpleNamespace +sys.path.insert(0, sys.argv[1]) +import run_timing +try: + run_timing.audit(SimpleNamespace(output=Path(sys.argv[2]))) +except ValueError as error: + if not str(error).startswith('Final timing acceptance failed: '): + raise + sys.exit(3) +sys.exit(4) +""" + run = subprocess.run([sys.executable, '-c', code, + str(frozen['root']/'latency'), str(folder)], capture_output=True, text=True, timeout=60) + demand(run.returncode == 3 and (folder/'acceptance.json').is_file(), + 'Exact original audit did not reproduce its rejection: ' + run.stderr) + replay, original = read(folder/'acceptance.json'), read(timing/'acceptance.json') + demand({k:v for k,v in replay.items() if k != 'created_utc'} == + {k:v for k,v in original.items() if k != 'created_utc'}, + 'Original audit reasons or recorded inputs could not be reproduced') + return replay + + +def verify_worker(backend, worker, selection, cohort): + actual = worker['sources'] + expected_names = (set(selection['expected_native_sources']) if backend != 'candidate' + else CANDIDATE_FILES) + demand(set(actual['files']) == expected_names, 'Worker source inventory is incomplete') + cohort.verify_worker_sources(backend, actual, selection) + + +def verify_output(output, case): + digest = hashlib.sha256(json.dumps(output['fields'], sort_keys=True, + allow_nan=False).encode()).hexdigest() + demand(output['full_digest'] == digest, 'Returned-object digest does not match retained fields') + demand(output['case'] == case['name'], 'Output names differ from the execution cohort') + # Full strict array/mask/primary/SDE hashes are checked against the frozen + # result by consistency(). Scalar metadata still must describe that spectrum. + demand(output['nperiods'] > 0 and + math.isfinite(output['primary_period']) and math.isfinite(output['SDE']), + 'Output has no finite complete search') + + +def measurement_problems(record, names, single_name, width, benchmark): + problems = [] + if record.get('status') != 'ok': problems.append('Configuration did not complete successfully') + if record.get('pool_width') != width: problems.append('Worker count differs from declaration') + if record.get('warmup', {}).get('status') != 'ok': problems.append('Warmup did not complete') + if len(record.get('batch', [])) != 3: problems.append('Batch repetition count is not three') + if len(record.get('single', [])) != (5 if width == 1 else 0): + problems.append('Single repetition count differs from declaration') + if not benchmark.ownership_valid(record): problems.append('GPU ownership lifecycle failed') + for kind in ('single', 'batch'): + expected = Counter([single_name] if kind == 'single' else names) + for rep in record.get(kind, []): + seconds = rep.get('denominator_seconds') + if (rep.get('status') != 'ok' or rep.get('errors') or + not isinstance(seconds, (int, float)) or not math.isfinite(seconds) or seconds <= 0 or + seconds != rep.get('elapsed_seconds') or rep.get('source_count') != sum(expected.values()) or + Counter(item['case'] for item in rep.get('outputs', [])) != expected): + problems.append('Incomplete or invalid public repetition') + return problems + + +def optional_warmup_oom(record, backend, width, problems): + """The post hoc scope permits this resource failure, never a partial time.""" + errors = record.get('warmup', {}).get('errors', []) + api_errors = [item for item in errors if 'traceback' in item] + membership = [item for item in errors if 'traceback' not in item] + warmup_outputs = record.get('warmup', {}).get('outputs', []) + names = record.get('config', {}).get('names', []) + expected = [names[0]] * width if names else [] + observed = [item['case'] for item in warmup_outputs] + accounting = (len(membership) == 1 and + membership[0].get('reason') == 'Post-barrier result membership differs from the assigned inputs' and + membership[0].get('expected') == expected and membership[0].get('observed') == observed and + len(observed) + len(api_errors) == width and + Counter(observed + [item.get('case') for item in api_errors]) == Counter(expected)) + return (backend == 'gtls' and width in (2, 4) and record.get('status') == 'error' and + set(problems) == ALLOWED_OMISSION_REASONS and + not record.get('single') and not record.get('batch') and + record.get('warmup', {}).get('status') == 'error' and + record['warmup'].get('denominator_seconds') is None and bool(api_errors) and accounting and + all('cupy.cuda.memory.OutOfMemoryError:' in item.get('traceback', '') for item in api_errors)) + + +def audit_config(record, backend, width, regime, selection, plan, frozen): + names = selection['selected_cases'] + config = record['config'] + demand(config.get('backend') == backend and config.get('prefix') == 'graph' and + config.get('regime') == regime and config.get('measurement_scope') == 'full' and + config.get('manifest') == plan['manifest'] and config.get('names') == names and + record.get('pool_width') == width, 'A configuration changed its cohort, backend, or width') + demand(record.get('status') in ('ok', 'error', 'measurement_failure', 'ownership_failure'), + 'A requested configuration is not terminal') + workers = record.get('workers', []) + demand(len(workers) == width and len({item['pid'] for item in workers}) == width, + 'A requested configuration lacks its complete worker/source inventory') + demand(workers == record['gpu_ownership']['workers'], 'Source worker and ownership inventory differ') + for worker in workers: + verify_worker(backend, worker, selection, frozen['cohort']) + # Ownership applies to failed configurations as well as selected timings. + demand(frozen['benchmark'].ownership_valid(record), 'Requested configuration ownership failed') + case_by_name = {item['name']: item for item in plan['cases'][regime]} + expected = plan['frozen_outputs'][regime][backend] + for rep in [record.get('warmup', {})] + record.get('single', []) + record.get('batch', []): + for output in rep.get('outputs', []): + demand(output['case'] in case_by_name, 'Unexpected source in a returned output') + verify_output(output, case_by_name[output['case']]) + problems = measurement_problems(record, names, selection['single_case'], width, frozen['benchmark']) + excluded_failure = bool(problems) + if excluded_failure: + demand(optional_warmup_oom(record, backend, width, problems), + 'Failure is outside the narrowly declared optional native warmup-OOM scope') + return dict(status=record['status'], complete=False, eligible=False, + exclusion='warmup_gpu_out_of_memory', problems=problems, + completed_single_repetitions=0, completed_batch_repetitions=0, + warmup_failure=record['warmup']['errors'], failed_times_are_speed_denominators=False) + gates = {} + for kind, selected in (('batch', names), ('single', [selection['single_case']])): + if kind == 'single' and width != 1: continue + ref = {name: expected[name]['strict'] for name in selected} + gates[kind+'_frozen'] = frozen['summarize'].gate(record, kind, selected, reference=ref) + gates[kind+'_full_repeatability'] = frozen['summarize'].gate(record, kind, selected, field='full_digest') + if width == 1: + ref = gates['batch_full_repeatability']['reference'] + gates['single_batch_full_identity'] = frozen['summarize'].gate(record, 'single', + [selection['single_case']], reference={selection['single_case']:ref[selection['single_case']]}, + field='full_digest') + eligible = all(item['eligible'] for item in gates.values()) + demand(eligible or (backend == 'gtls' and width in (2, 4)), + 'Mandatory candidate/native-one configuration failed a raw output gate') + return dict(status=record['status'], complete=True, eligible=eligible, + exclusion=None if eligible else 'complete_but_ineligible_output', + gates={name:dict(eligible=value['eligible'], problems=value['problems']) for name,value in gates.items()}, + completed_single_repetitions=len(record['single']), completed_batch_repetitions=len(record['batch']), + failed_times_are_speed_denominators=False) + + +def verify_audit_reasons(acceptance, configurations): + expected = [] + omissions = [] + for regime, records in configurations.items(): + for label, result in records.items(): + if result.get('exclusion') == 'warmup_gpu_out_of_memory': + omissions.append(regime+'/'+label) + expected.extend(regime+': '+label+': '+reason for reason in result['problems']) + actual = acceptance.get('publication_gate', {}).get('problems') + demand(acceptance.get('status') == 'rejected' and + acceptance.get('publication_gate', {}).get('pass') is False and bool(omissions) and + Counter(actual or []) == Counter(expected), + 'Original rejection includes failures beyond optional native warmup OOM completeness') + return omissions + + +def assess(checkpoint, source_root, manifest_path): + campaign, collection = verify_collection(checkpoint) + timing = campaign/'timing' + prepared, plan = read(timing/'prepared.json'), read(timing/'public/plan.json') + terminal = check_driver_terminal(timing, prepared) + manifest = read(manifest_path) + demand(sha(manifest_path) == prepared['manifest_sha256'] == plan['manifest_sha256'], + 'Timing input manifest changed') + with frozen_modules(source_root) as frozen: + source_inventory = frozen['manifest']['files'] + expected_tools = {Path(name).name:digest for name,digest in source_inventory.items() + if name.startswith('latency/tools/timing/') and name.endswith('.py')} + demand(prepared['timing_sources'] == expected_tools == plan['sources'], + 'Executed timing sources differ from the frozen archive') + demand(read(timing/'pipeline-plan.json')['driver_sha256'] == source_inventory['latency/run_timing.py'], + 'Executed driver differs from frozen source') + demand(plan['cohort_selection'] == prepared['selections'] and + plan['frozen_outputs'] == prepared['frozen_outputs'], 'Cohort/frozen outputs changed') + demand(plan['regimes'] == list(REGIMES) and plan['native_pool_widths'] == [1,2,4] and + plan['measurement_scope'] == 'full' and plan['single_repetitions'] == 5 and + plan['batch_repetitions'] == 3 and plan['cpu_math_threads_per_worker'] == 1, + 'Original timing declaration changed') + accepted = frozen['cohort'].accepted_study(manifest_path, Path(manifest_path).parent/'main') + configurations, expected_paths = {}, set() + public_summary = read(timing/'public/summary.json') + demand(public_summary.get('status') == 'complete' and + public_summary.get('cohort_selection') == prepared['selections'], + 'Original public runner did not account for the complete cohort') + for regime in REGIMES: + selection = prepared['selections'][regime] + demand(selection['accepted_study'] == accepted, 'Accepted numerical origins differ') + cases = frozen['common'].load_cases(manifest_path, regime, selection['selected_cases']) + recomputed_selection = frozen['cohort'].select(manifest_path, Path(manifest_path).parent/'main', regime) + demand(recomputed_selection == selection, 'Original deterministic source selection cannot be reproduced') + demand([frozen['common'].case_identity(case) for case in cases] == plan['cases'][regime], + 'Actual execution input arrays or options differ from the original plan') + backends = ('gtls', 'candidate', 'gtls_corrected') if selection['correction_timing']['required'] else ('gtls', 'candidate') + expected = frozen['cohort'].frozen_outputs(Path(manifest_path).parent/'main', cases, + backends=backends, manifest_path=manifest_path) + demand(expected == plan['frozen_outputs'][regime], 'Original frozen output bank differs') + required = [('candidate',1), ('gtls',1), ('gtls',2), ('gtls',4)] + if selection['correction_timing']['required']: required.append(('gtls_corrected',1)) + details = configurations[regime] = {} + demand(selection['actual_batch_size'] == len(cases) == 16 and + len({item['name'] for item in cases}) == 16 and selection['single_case'] in selection['selected_cases'], + 'The declared fixed 16-source cohort changed') + for backend,width in required: + label = f'{backend}_graph_{width}worker' + path = timing/'public'/regime/label/'record.json' + expected_paths.add(path.resolve()) + record = read(path) + detail = audit_config(record, backend, width, regime, selection, plan, frozen) + detail.update(record=str(path.relative_to(timing)), record_sha256=sha(path), + backend=backend, workers=width) + details[label] = detail + logged = public_summary['regimes'][regime][label] + demand(logged['status'] == record['status'] and logged['record'] == str(path.relative_to(timing/'public')) and + logged['single_seconds'] == [item['denominator_seconds'] for item in record['single']] and + logged['batch_seconds'] == [item['denominator_seconds'] for item in record['batch']], + 'Original public configuration accounting differs from its raw record') + demand(set(public_summary['regimes'][regime]) == set(details), 'Public configuration inventory differs') + actual_paths = {path.resolve() for path in (timing/'public').glob('*/*/record.json')} + demand(actual_paths == expected_paths and set(public_summary['regimes']) == set(REGIMES), + 'Missing or extra attempted configurations') + corrected = timing/'components/gtls_corrected' if any( + value['correction_timing']['required'] for value in prepared['selections'].values()) else None + component_backends = ['gtls','candidate'] + (['gtls_corrected'] if corrected else []) + for backend in component_backends: + component_summary = read(timing/'components'/backend/'summary.json') + demand(component_summary.get('status') == 'complete' and component_summary.get('backend') == backend, + 'Component stage is not complete') + for regime in REGIMES: + if backend == 'gtls_corrected' and not prepared['selections'][regime]['correction_timing']['required']: continue + record = read(timing/'components'/backend/(regime+'.json')) + selection = prepared['selections'][regime] + demand(record['gpu_ownership'] == component_summary['gpu_ownership'], 'Component ownership receipt differs') + demand(len(record['gpu_ownership']['workers']) == 1, 'Component worker cardinality changed') + verify_worker(backend, record['gpu_ownership']['workers'][0], selection, frozen['cohort']) + demand(record['frozen_outputs'] == plan['frozen_outputs'][regime][backend][selection['single_case']], + 'Component frozen expectation changed') + checks = frozen['summarize'].summarize(timing/'public', timing/'components/gtls', + timing/'components/candidate', corrected) + demand(checks == read(timing/'summary.json'), 'Raw summary cannot be recomputed from original records') + for regime in REGIMES: + for key in ('public_single','public_batch','common_search_components'): + demand(checks['regimes'][regime][key]['eligible'] is True, 'A mandatory original comparison gate failed') + # Supplement the original strict eligibility with full-object stability. + pools = checks['regimes'][regime]['public_batch']['native_pool_configurations'] + for width, pool in pools.items(): + detail = configurations[regime][f'gtls_graph_{width}worker'] + pool['eligible'] = pool['eligible'] and detail['eligible'] + eligible = [(item['elapsed']['median_seconds'],int(width)) + for width,item in pools.items() if item['eligible']] + demand(bool(eligible), 'No complete eligible native pool') + selected_width = min(eligible)[1] + batch = checks['regimes'][regime]['public_batch'] + batch['strongest_tested_native_workers'] = selected_width + batch['strongest_tested_native'] = pools[str(selected_width)]['elapsed'] + batch['speedup'] = frozen['summarize'].ratio(batch['strongest_tested_native'],batch['candidate']) + normalized = frozen['analyze'].analyze(checks, manifest, + dict(merged_origin_checks=accepted), sha(manifest_path)) + original_normalized = read(timing/'timing_analysis.json') + # Before supplementary full-object filtering, exact normalization must + # reproduce the frozen raw summary. A new selected pool is possible only + # when an original optional pool fails the added full-object gate. + original_replay = frozen['analyze'].analyze(read(timing/'summary.json'), manifest, + dict(merged_origin_checks=accepted), sha(manifest_path)) + demand(original_replay == {k:v for k,v in original_normalized.items() if k != 'sources'}, + 'Original normalized output cannot be reproduced') + demand(original_normalized['sources']['timing_checks']['sha256'] == sha(timing/'summary.json') and + original_normalized['sources']['inputs']['sha256'] == sha(manifest_path) and + original_normalized['sources']['numerical_acceptance']['sha256'] in + {item['receipt_sha256'] for item in accepted['accepted_studies'].values()}, + 'Original normalized source hashes differ') + rejection = replay_original_audit(timing, frozen) + omissions = verify_audit_reasons(rejection, configurations) + normalized.update(campaign_pass=False, reporting_scope=REPORTING_SCOPE, + reporting_scope_note='Post hoc assessment after an optional native pool warmup OOM; ' + 'the original all-configuration campaign acceptance remains failed. ' + 'Only complete configurations enter timing denominators, with all attempts disclosed.', + excluded_configurations=[regime+'/'+label for regime, rows in configurations.items() + for label, item in rows.items() if not item['eligible']], + configuration_outcomes=configurations, + sources=original_normalized['sources']) + normalized['verification'].update(campaign_pass=False, + reporting_scope=REPORTING_SCOPE, complete=True, + full_returned_object_repeatability=True, + scope='Separate post hoc complete-configuration reporting gate; original full campaign failed. ' + 'Original numerical/source/cohort/exclusivity/repetition gates are unchanged.') + receipt = dict(schema_version=1, created_utc=datetime.now(timezone.utc).isoformat(), + reporting_scope=REPORTING_SCOPE, campaign_pass=False, + original_campaign_acceptance=dict(file='timing/acceptance.json', + sha256=sha(timing/'acceptance.json'), passed=False, + publication_gate=rejection['publication_gate']), + original_terminal_sha256=sha(timing/'terminal.json'), + original_summary_sha256=sha(timing/'summary.json'), + original_normalization_sha256=sha(timing/'timing_analysis.json'), + exact_original_audit_replayed=True, all_attempts_accounted=True, + original_numerical_rules_changed=False, measurements_rerun=False, + failed_times_are_speed_denominators=False, + omitted_optional_configurations=omissions, configurations=configurations, + collection=collection, execution_source_manifest=frozen['manifest'], + assessment_source_sha256=sha(__file__), + scope_note='This gate authorizes only a report of complete comparisons, not the original campaign. ' + 'The report scope was chosen after observing the warmup OOM; pool ranking uses the original ' + 'fastest-eligible rule, additionally requiring stable complete returned-object digests.') + receipt['reporting_gate']={'pass':True,'problems':[]} + return normalized, receipt + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + for name in ('checkpoint','sources','manifest','output'): + parser.add_argument('--'+name,type=Path,required=True) + args=parser.parse_args() + demand(not args.output.exists(), 'Use a new post hoc reporting directory') + normalized, receipt=assess(args.checkpoint,args.sources,args.manifest) + args.output.mkdir(parents=True) + write(args.output/'timing_analysis.json',normalized) + receipt['timing_analysis_sha256']=sha(args.output/'timing_analysis.json') + write(args.output/'reporting_acceptance.json',receipt) + print(json.dumps(dict(reporting_gate=receipt['reporting_gate'],campaign_pass=False, + timing_analysis_sha256=receipt['timing_analysis_sha256'], + reporting_acceptance_sha256=sha(args.output/'reporting_acceptance.json')))) + + +if __name__=='__main__': + main() diff --git a/benchmarks/tls_reference/timing/summarize.py b/benchmarks/tls_reference/timing/summarize.py new file mode 100644 index 00000000..a5a77dd7 --- /dev/null +++ b/benchmarks/tls_reference/timing/summarize.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Audit complete timing receipts before calculating any speed ratio. + +The fastest tested native pool is eligible only when every declared repetition +returns the original one-worker period/power/chi2/primary/SDE hashes. Failed +or missing measurements remain visible and never become speed denominators. +""" +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +import statistics + +if __package__: + from .benchmark import consistency, ownership_valid + from .common import BATCH_REPETITIONS, SINGLE_REPETITIONS, write +else: + from benchmark import consistency, ownership_valid + from common import BATCH_REPETITIONS, SINGLE_REPETITIONS, write + + +def read(path): + return json.loads(Path(path).read_text()) + + +def distribution(values): + if not values or any(value is None or not math.isfinite(value) or value <= 0 for value in values): + return None + return dict(repetitions=len(values), raw_seconds=values, + median_seconds=statistics.median(values), + minimum_seconds=min(values), maximum_seconds=max(values)) + + +def gate(record, kind, names, reference=None, field='strict'): + if record is None: + return dict(eligible=False, problems=[dict(reason='Missing configuration')], reference={}) + result = consistency(record.get(kind, []), reference=reference, expected_names=names, + expected_repetitions=(SINGLE_REPETITIONS if kind == 'single' + else BATCH_REPETITIONS), field=field) + if record.get('status') != 'ok': + result['eligible'] = False + result['problems'].append(dict(reason='Configuration did not complete')) + if not ownership_valid(record): + result['eligible'] = False + result['problems'].append(dict(reason='GPU process birth, call ownership, or exit proof failed')) + return result + + +def public_times(record, kind): + return distribution([rep.get('denominator_seconds') for rep in record.get(kind, [])]) + + +def ratio(native, candidate): + return (None if native is None or candidate is None else + native['median_seconds'] / candidate['median_seconds']) + + +def compare_public(records, names, single_name, widths, frozen=None): + native_one = records.get('gtls_graph_1worker') + candidate = records.get('candidate_graph_1worker') + one_gate = gate(native_one, 'batch', names) + candidate_gate = gate(candidate, 'batch', names) + common_reference = gate(native_one, 'batch', names, field='common') + common_gate = gate(candidate, 'batch', names, + reference=common_reference['reference'], field='common') + candidate_times = public_times(candidate, 'batch') if candidate is not None else None + frozen_gates = {} + if frozen is not None: + for backend, record in (('gtls', native_one), ('candidate', candidate)): + reference = {name: value['strict'] for name, value in frozen.get(backend, {}).items()} + frozen_gates[backend] = gate(record, 'batch', names, reference=reference) + # Scientific equivalence is established by the separate validation study. + # A documented fix for undefined native trials can legitimately change + # native-vs-candidate hashes. Timing still requires each implementation's + # frozen expected outputs, and every pool must reproduce literal native1. + required = ((one_gate, candidate_gate, *frozen_gates.values()) if frozen is not None else + (one_gate, candidate_gate, common_reference, common_gate)) + base_eligible = all(item['eligible'] for item in required) + pools = {} + for width in widths: + record = records.get(f'gtls_graph_{width}worker') + pool_gate = gate(record, 'batch', names, reference=one_gate['reference']) + measured = public_times(record, 'batch') if record is not None else None + pools[str(width)] = dict(eligible=base_eligible and pool_gate['eligible'] and measured is not None, + output_gate=pool_gate, elapsed=measured, + startup_seconds=None if record is None else record.get('pool_startup_seconds')) + eligible = [(int(width), item) for width, item in pools.items() if item['eligible']] + fastest = min(eligible, key=lambda pair: (pair[1]['elapsed']['median_seconds'], pair[0])) if eligible else None + batch = dict(source_count=len(names), candidate_output_gate=candidate_gate, + common_output_gate=common_gate, native_one_worker_gate=one_gate, + frozen_output_gates=frozen_gates, + native_pool_configurations=pools, + candidate=candidate_times, + strongest_tested_native_workers=None if fastest is None else fastest[0], + strongest_tested_native=None if fastest is None else fastest[1]['elapsed'], + speedup=None if fastest is None else ratio(fastest[1]['elapsed'], candidate_times), + eligible=fastest is not None and candidate_times is not None) + + single = dict(eligible=False, speedup=None, case=single_name) + if single_name is not None: + native_single_gate = gate(native_one, 'single', [single_name]) + candidate_single_gate = gate(candidate, 'single', [single_name]) + native_common = gate(native_one, 'single', [single_name], field='common') + single_common = gate(candidate, 'single', [single_name], reference=native_common['reference'], field='common') + # The same source must retain its full search when dispatched in a batch. + native_cross = gate(native_one, 'single', [single_name], reference={single_name: one_gate['reference'].get(single_name)}) + candidate_cross = gate(candidate, 'single', [single_name], reference={single_name: candidate_gate['reference'].get(single_name)}) + native_time = public_times(native_one, 'single') if native_one is not None else None + candidate_time = public_times(candidate, 'single') if candidate is not None else None + single_frozen = {} + if frozen is not None: + for backend, record in (('gtls', native_one), ('candidate', candidate)): + reference = {single_name: frozen.get(backend, {}).get(single_name, {}).get('strict')} + single_frozen[backend] = gate(record, 'single', [single_name], reference=reference) + required = ((native_single_gate, candidate_single_gate, native_cross, candidate_cross, + *single_frozen.values()) if frozen is not None else + (native_single_gate, candidate_single_gate, native_common, single_common, + native_cross, candidate_cross)) + valid = all(item['eligible'] for item in required) + single.update(eligible=valid and native_time is not None and candidate_time is not None, + native=native_time, candidate=candidate_time, + native_output_gate=native_single_gate, candidate_output_gate=candidate_single_gate, + common_output_gate=single_common, + frozen_output_gates=single_frozen, + native_single_batch_gate=native_cross, candidate_single_batch_gate=candidate_cross, + speedup=ratio(native_time, candidate_time) if valid else None) + + row = records.get('candidate_row_1worker') + ab = None + if row is not None and single_name is not None: + graph_gate = gate(candidate, 'single', [single_name], field='full_digest') + row_gate = gate(row, 'single', [single_name], reference=graph_gate['reference'], field='full_digest') + row_time = public_times(row, 'single') + graph_time = public_times(candidate, 'single') if candidate is not None else None + valid = graph_gate['eligible'] and row_gate['eligible'] and row_time is not None and graph_time is not None + ab = dict(eligible=valid, output_gate=row_gate, graph=graph_time, original_row_prefix=row_time, + public_call_speedup=ratio(row_time, graph_time) if valid else None, + scope='Separate same-code full-public-call attribution; only prefix dispatch changed at runtime') + corrected = None + corrected_record = records.get('gtls_corrected_graph_1worker') + if corrected_record is not None or (frozen is not None and 'gtls_corrected' in frozen): + corrected = dict(scope='Conditional corrected-native cross-check on the same cohort; excluded from literal strongest-pool selection') + expected = {name: value['strict'] for name, value in (frozen or {}).get('gtls_corrected', {}).items()} + for kind, selected in (('batch', names), ('single', [single_name])): + if kind == 'single' and single_name is None: + corrected[kind] = dict(eligible=False, speedup=None, reason='No paired single case') + continue + expected_kind = {name: expected.get(name) for name in selected} + own = gate(corrected_record, kind, selected, reference=expected_kind) + candidate_own = gate(candidate, kind, selected, reference={ + name: (frozen or {}).get('candidate', {}).get(name, {}).get('strict') for name in selected}) + corrected_common = gate(corrected_record, kind, selected, field='common') + common = gate(candidate, kind, selected, reference=corrected_common['reference'], field='common') + elapsed = public_times(corrected_record, kind) if corrected_record is not None else None + other = public_times(candidate, kind) if candidate is not None else None + valid = all(value['eligible'] for value in (own, candidate_own, corrected_common, common)) + corrected[kind] = dict(eligible=valid and elapsed is not None and other is not None, + source_count=len(selected), own_frozen_output_gate=own, + candidate_frozen_output_gate=candidate_own, common_output_gate=common, + corrected_native=elapsed, candidate=other, + speedup=ratio(elapsed, other) if valid else None) + return dict(public_single=single, public_batch=batch, prefix_ab=ab, + corrected_native_crosscheck=corrected) + + +def compare_single_public(records, single_name, frozen): + """Five full public calls; no batch denominator or worker-pool selection.""" + if single_name is None: + raise ValueError('Single-source timing has no paired successful input') + names = [single_name] + native = records.get('gtls_graph_1worker') + candidate = records.get('candidate_graph_1worker') + checks, measured, full_gates = {}, {}, {} + for backend, record in (('gtls', native), ('candidate', candidate)): + expected = {single_name: frozen.get(backend, {}).get(single_name, {}).get('strict')} + checks[backend] = gate(record, 'single', names, reference=expected) + if record is not None and (record.get('batch') or record.get('pool_width') != 1): + checks[backend]['eligible'] = False + checks[backend]['problems'].append(dict(reason='Single scope cannot contain batch or multiworker measurements')) + measured[backend] = public_times(record, 'single') if record is not None else None + full_gates[backend] = gate(record, 'single', names, field='full_digest') + native_common = gate(native, 'single', names, field='common') + common = gate(candidate, 'single', names, reference=native_common['reference'], field='common') + valid = all(value['eligible'] for value in (*checks.values(), *full_gates.values())) and all(measured.values()) + single = dict(eligible=bool(valid), case=single_name, source_count=1, + native=measured['gtls'], candidate=measured['candidate'], + frozen_output_gates=checks, full_repeatability_gates=full_gates, common_output_gate=common, + speedup=ratio(measured['gtls'], measured['candidate']) if valid else None, + scope='Five full public calls in one persistent worker per method; no TLS batch throughput or strongest-pool comparison') + corrected = None + if 'gtls_corrected' in frozen or 'gtls_corrected_graph_1worker' in records: + record = records.get('gtls_corrected_graph_1worker') + own = gate(record, 'single', names, reference={single_name: + frozen.get('gtls_corrected', {}).get(single_name, {}).get('strict')}) + if record is not None and (record.get('batch') or record.get('pool_width') != 1): + own['eligible'] = False + own['problems'].append(dict(reason='Single corrected scope contains batch or multiworker measurements')) + corrected_common = gate(record, 'single', names, field='common') + paired = gate(candidate, 'single', names, reference=corrected_common['reference'], field='common') + elapsed = public_times(record, 'single') if record is not None else None + corrected_full = gate(record, 'single', names, field='full_digest') + ok = all(value['eligible'] for value in (own, checks['candidate'], full_gates['candidate'], + corrected_common, corrected_full, paired)) + corrected = dict(scope='Conditional corrected-native single-source cross-check; literal GTLS remains separately reported', + single=dict(eligible=bool(ok and elapsed and measured['candidate']), source_count=1, + corrected_native=elapsed, candidate=measured['candidate'], + own_frozen_output_gate=own, full_repeatability_gate=corrected_full, common_output_gate=paired, + speedup=ratio(elapsed, measured['candidate']) if ok else None), + batch=dict(eligible=False, status='not_measured')) + return dict(public_single=single, + public_batch=dict(eligible=False, status='not_measured', source_count=0, + scope='Batch protocol deferred before execution'), + prefix_ab=None, corrected_native_crosscheck=corrected) + + +def compare_components(native, candidate, single_name, frozen_required=False): + problems = [] + for backend, record in (('gtls', native), ('candidate', candidate)): + if record is None or record.get('case', {}).get('name') != single_name: + problems.append(dict(backend=backend, reason='Missing or different component input')) + continue + reps = record.get('repetitions', []) + if not ownership_valid(record): + problems.append(dict(backend=backend, reason='Component GPU ownership lifecycle failed')) + if (record.get('status') != 'ok' or len(reps) != SINGLE_REPETITIONS or + any(not rep.get('denominator_eligible') or not rep.get('endpoint_valid') or + not rep.get('output_identical_to_literal') or + (rep.get('outputs') or {}).get('full_digest') != (record.get('literal_outputs') or {}).get('full_digest') + for rep in reps)): + problems.append(dict(backend=backend, reason='Incomplete or non-identical component repetitions')) + if frozen_required and (not record.get('literal_matches_frozen') or + (record.get('literal_outputs') or {}).get('strict') != (record.get('frozen_outputs') or {}).get('strict')): + problems.append(dict(backend=backend, reason='Component literal output differs from its own frozen study result')) + if not problems: + if not frozen_required and native['literal_outputs']['common'] != candidate['literal_outputs']['common']: + problems.append(dict(reason='Component runs have different complete common search outputs')) + if native['case'] != candidate['case']: + problems.append(dict(reason='Component execution arrays or options differ')) + if problems: + return dict(eligible=False, speedup=None, problems=problems) + stats = {} + for backend, record in (('gtls', native), ('candidate', candidate)): + reps = record['repetitions'] + stages = sorted({name for rep in reps for name in rep['inclusive_stage_seconds']}) + stats[backend] = dict( + common_search=distribution([rep['common_search_seconds'] for rep in reps]), + public_instrumented=distribution([rep['public_instrumented_seconds'] for rep in reps]), + after_common_search=distribution([rep['after_common_search_seconds'] for rep in reps]), + inclusive_stages={name: distribution([rep['inclusive_stage_seconds'].get(name, 0.) for rep in reps]) + for name in stages}) + valid = all(stats[name]['common_search'] is not None for name in stats) + return dict(eligible=valid, measurements=stats, + common_outputs_identical=native['literal_outputs']['common'] == candidate['literal_outputs']['common'], + speedup=ratio(stats['gtls']['common_search'], stats['candidate']['common_search']) if valid else None, + scope='Separate instrumented single-source search through final window selection; candidate includes compact winner transfers, while native stops after final GPU argmin and excludes subsequent physical/SNR diagnostics', + overlap_note='Inclusive stage durations overlap and must not be added together.') + + +def summarize(root, native_components=None, candidate_components=None, corrected_components=None): + root = Path(root) + plan = read(root/'plan.json') + measurement_scope = plan.get('measurement_scope', 'full') + if measurement_scope not in ('full', 'single'): + raise ValueError('Unknown timing measurement scope') + if measurement_scope == 'single' and (plan['native_pool_widths'] != [1] or + plan['batch_repetitions'] != 0 or plan['single_repetitions'] != SINGLE_REPETITIONS): + raise ValueError('Single-source plan contains batch measurements or changed repetitions') + output = dict(status='audited', measurement_scope=measurement_scope, environment=plan['environment'], + scope=plan['scope'], native_extras=plan['native_extras'], + cohort_selection=plan['cohort_selection'], regimes={}, + scientific_scope='Timings require each method to reproduce its own frozen validation output. Numerical-search equivalence and any declared native bug correction must be assessed from the independent validation study; timing does not establish that scientific claim.') + for regime in plan['regimes']: + selection = plan['cohort_selection'][regime] + records = {path.parent.name: read(path) for path in (root/regime).glob('*/record.json')} + result = (compare_single_public(records, selection['single_case'], plan['frozen_outputs'][regime]) + if measurement_scope == 'single' else + compare_public(records, selection['selected_cases'], selection['single_case'], + plan['native_pool_widths'], frozen=plan['frozen_outputs'][regime])) + if native_components is not None and candidate_components is not None: + paths = [Path(folder)/(regime+'.json') for folder in (native_components, candidate_components)] + component_records = [read(path) if path.exists() else None for path in paths] + result['common_search_components'] = compare_components(*component_records, selection['single_case'], + frozen_required=True) + if corrected_components is not None and candidate_components is not None and selection['correction_timing']['required']: + paths = [Path(folder)/(regime+'.json') for folder in (corrected_components, candidate_components)] + component_records = [read(path) if path.exists() else None for path in paths] + result['corrected_common_search_components'] = compare_components(*component_records, + selection['single_case'], frozen_required=True) + result['corrected_common_search_components']['native_kind'] = 'gtls_corrected' + output['regimes'][regime] = result + return output + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('root', type=Path) + parser.add_argument('--components-gtls', type=Path) + parser.add_argument('--components-candidate', type=Path) + parser.add_argument('--components-corrected', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + if bool(args.components_gtls) != bool(args.components_candidate): + parser.error('Provide both component directories or neither') + if args.components_corrected is not None and args.components_candidate is None: + parser.error('Corrected component comparison requires candidate components') + write(args.output, summarize(args.root, args.components_gtls, args.components_candidate, args.components_corrected)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/timing/test_process_ownership.py b/benchmarks/tls_reference/timing/test_process_ownership.py new file mode 100644 index 00000000..085b984c --- /dev/null +++ b/benchmarks/tls_reference/timing/test_process_ownership.py @@ -0,0 +1,234 @@ +"""CPU tests for strict NVML host/container identity and lifetime gates.""" +import copy +import json +import sys +from types import SimpleNamespace + +import pytest + +from . import benchmark, components +from .test_timing import exclusive_snapshot, ownership_receipt, single_configuration + + +def nvml(monkeypatch, pids): + state = dict(pids=list(pids)) + fake = SimpleNamespace(nvmlInit=lambda: None, nvmlShutdown=lambda: None, + nvmlDeviceGetHandleByIndex=lambda index: index, + nvmlDeviceGetComputeRunningProcesses=lambda handle: + [SimpleNamespace(pid=pid) for pid in state['pids']]) + monkeypatch.setitem(sys.modules, 'pynvml', fake) + return state + + +class Process: + def __init__(self, pid=11): + self.pid, self.exitcode, self.alive = pid, None, True + def is_alive(self): + return self.alive + + +def ready(pid=11, namespace=None): + return dict(pid=pid, namespace_pids=[pid] if namespace is None else namespace, + cuda_context_allocation_bytes=1, cuda_context_synchronized=True) + + +def test_hidden_outer_pid_requires_empty_start_one_live_context_and_exit(monkeypatch): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + process = Process() + state['pids'] = [3212228] + assert ownership.bind([ready()], [process], timeout=0) == [3212228] + assert ownership.receipt['bindings'][0]['method'] == 'single_worker_lifecycle' + assert ownership.receipt['passed'] is False # Exit proof is still pending. + process.alive, process.exitcode = False, 0 + state['pids'] = [] + receipt = ownership.finish([process], timeout=0) + assert benchmark.ownership_valid(dict(gpu_ownership=receipt)) + assert receipt['before_start']['observed_pids'] == [] + assert receipt['after_start']['observed_pids'] == [3212228] + assert receipt['after_exit']['observed_pids'] == [] + + +def test_preexisting_gpu_context_rejected_before_spawning(monkeypatch): + nvml(monkeypatch, [500]) + with pytest.raises(RuntimeError, match='must be empty') as caught: + benchmark.GPUOwnership() + assert caught.value.gpu_ownership['before_start']['observed_pids'] == [500] + + +@pytest.mark.parametrize('observed', ([], [101, 202])) +def test_missing_or_ambiguous_new_context_is_rejected(monkeypatch, observed): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = observed + with pytest.raises(RuntimeError, match='Ambiguous or missing'): + ownership.bind([ready()], [Process()], timeout=0) + + +@pytest.mark.parametrize('mutation', ('wrong_pid', 'dead', 'no_allocation', 'not_synchronized')) +def test_bind_requires_the_actual_live_initialized_child(monkeypatch, mutation): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = [101] + process, value = Process(), ready() + if mutation == 'wrong_pid': + value['pid'] = 12 + elif mutation == 'dead': + process.alive = False + elif mutation == 'no_allocation': + value['cuda_context_allocation_bytes'] = 0 + else: + value['cuda_context_synchronized'] = False + with pytest.raises(RuntimeError, match='live synchronized'): + ownership.bind([value], [process], timeout=0) + + +def test_multiworker_hidden_ids_are_proved_as_a_set_without_invented_mapping(monkeypatch): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = [101, 102] + processes = [Process(11), Process(12)] + assert ownership.bind([ready(11), ready(12)], processes, timeout=0) == [101, 102] + assert ownership.receipt['identity_scope'] == 'owned_pool_set' + assert all(value['host_pid'] is None for value in ownership.receipt['bindings']) + for process in processes: + process.alive, process.exitcode = False, 0 + state['pids'] = [] + assert benchmark.ownership_valid(dict(gpu_ownership=ownership.finish(processes, timeout=0))) + + +@pytest.mark.parametrize('count', (2, 4)) +def test_multiworker_direct_ids_are_preserved(monkeypatch, count): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = list(range(101, 101+count)) + processes = [Process(11+i) for i in range(count)] + values = [ready(11+i, [101+i, 11+i]) for i in range(count)] + assert ownership.bind(values, processes, timeout=0) == state['pids'] + assert ownership.receipt['identity_scope'] == 'individual' + + +@pytest.mark.parametrize('observed', ([101], [101, 102, 103])) +def test_pool_requires_exactly_as_many_new_host_pids_as_live_children(monkeypatch, observed): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = observed + with pytest.raises(RuntimeError, match='Ambiguous or missing'): + ownership.bind([ready(11), ready(12)], [Process(11), Process(12)], timeout=0) + + +def test_pool_rejects_duplicate_or_ambiguous_direct_identity(monkeypatch): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + state['pids'] = [101, 102] + with pytest.raises(RuntimeError, match='share one'): + ownership.bind([ready(11, [11,101]), ready(12, [12,101])], [Process(11), Process(12)], timeout=0) + with pytest.raises(RuntimeError, match='Ambiguous directly'): + ownership.bind([ready(11, [11,101,102]), ready(12)], [Process(11), Process(12)], timeout=0) + + +@pytest.mark.parametrize('observed', ([], [102], [101, 102])) +def test_call_checks_reject_disappeared_replaced_or_extra_owner(monkeypatch, observed): + nvml(monkeypatch, observed) + with pytest.raises(RuntimeError, match='exclusive timings'): + benchmark.exclusive_gpu_processes([101]) + result = benchmark.exclusive_gpu_processes([101], strict=False) + assert result['exclusive'] is False + assert result['observed_pids'] == observed + + +@pytest.mark.parametrize('mode', ('stale_context', 'foreign_context', 'alive', 'forced', 'nonzero_exit')) +def test_exit_must_be_clean_and_context_must_disappear(monkeypatch, mode): + state = nvml(monkeypatch, []) + ownership = benchmark.GPUOwnership() + process = Process() + state['pids'] = [101] + ownership.bind([ready()], [process], timeout=0) + process.alive, process.exitcode = False, 0 + state['pids'] = [] + forced = [] + if mode == 'stale_context': + state['pids'] = [101] + elif mode == 'foreign_context': + state['pids'] = [202] + elif mode == 'alive': + process.alive = True + elif mode == 'forced': + forced = [11] + else: + process.exitcode = 1 + result = ownership.finish([process], forced=forced, timeout=0) + assert result['passed'] is False + assert not benchmark.ownership_valid(dict(gpu_ownership=result)) + + +@pytest.mark.parametrize('mode', ('missing_receipt', 'changed_owner', 'missing_exit', 'nonempty_baseline')) +def test_summary_rechecks_raw_identity_receipts(monkeypatch, mode): + from .summarize import gate + record = single_configuration() + if mode == 'missing_receipt': + del record['gpu_ownership'] + elif mode == 'changed_owner': + record['single'][0]['exclusive_after'] = exclusive_snapshot([202]) + elif mode == 'missing_exit': + del record['gpu_ownership']['after_exit'] + else: + record['gpu_ownership']['before_start'] = exclusive_snapshot([101]) + assert not gate(record, 'single', ['a'])['eligible'] + + +def test_cuda_allocation_is_retained_after_startup_synchronization(monkeypatch): + events, allocation = [], object() + def alloc(size): + assert size == 1 + events.append('allocate') + return allocation + monkeypatch.setitem(sys.modules, 'cupy', SimpleNamespace(cuda=SimpleNamespace( + alloc=alloc, runtime=SimpleNamespace(deviceSynchronize=lambda: events.append('sync'))))) + assert benchmark.retain_cuda_context() is allocation + assert events == ['allocate', 'sync'] + + +@pytest.mark.parametrize('exit_context', ([], [101])) +def test_component_supervisor_gates_start_run_and_exit(tmp_path, monkeypatch, exit_context): + state = nvml(monkeypatch, []) + events, process = [], Process() + args = SimpleNamespace(output=tmp_path/'output', regimes=['tess_solar'], backend='candidate') + class Connection: + def poll(self, timeout): + return True + def recv(self): + events.append('receive') + return dict(kind='ready', **ready()) if events.count('receive') == 1 else dict(kind='complete') + def send(self, command): + assert command['kind'] == 'bind' + assert command['ownership']['after_start']['observed_pids'] == [101] + events.append('run_after_binding') + record = dict(case={'name': 'a'}, status='ok', gpu_ownership=command['ownership'], + repetitions=[dict(exclusive_before=exclusive_snapshot(), exclusive_after=exclusive_snapshot())]) + benchmark.write(args.output/'tess_solar.json', record) + benchmark.write(args.output/'summary.json', dict(status='awaiting_worker_exit', regimes={})) + def close(self): + pass + def start(): + assert state['pids'] == [] + state['pids'] = [101] + events.append('start') + def join(timeout): + process.alive, process.exitcode = False, 0 + state['pids'] = exit_context + events.append('join') + process.start, process.join = start, join + context = SimpleNamespace(Pipe=lambda: (Connection(), Connection()), Process=lambda **kwargs: process) + monkeypatch.setattr(components.mp, 'get_context', lambda name: context) + original = benchmark.GPUOwnership.finish + monkeypatch.setattr(benchmark.GPUOwnership, 'finish', lambda self, processes, forced=(): + original(self, processes, forced=forced, timeout=0)) + if exit_context: + with pytest.raises(RuntimeError, match='ownership'): + components.run(args) + else: + components.run(args) + record = json.loads((args.output/'tess_solar.json').read_text()) + assert record['gpu_ownership']['passed'] is (not exit_context) + assert events.index('start') < events.index('run_after_binding') < events.index('join') diff --git a/benchmarks/tls_reference/timing/test_report_completed.py b/benchmarks/tls_reference/timing/test_report_completed.py new file mode 100644 index 00000000..4984398d --- /dev/null +++ b/benchmarks/tls_reference/timing/test_report_completed.py @@ -0,0 +1,183 @@ +"""CPU-only reporting safeguards; original measurement tools are not changed.""" +import copy +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +if __package__: + from . import report_completed as report +else: + import report_completed as report +from benchmarks.tls_reference.timing import benchmark, cohort, summarize +from benchmarks.tls_reference.timing.test_timing import ownership_receipt, exclusive_snapshot + + +def fixture(backend='candidate'): + names = ['a', 'b'] + sources = {name:'source' for name in report.CANDIDATE_FILES} + selection = dict(selected_cases=names, single_case='a', + expected_candidate_sources={'cuvarbase/'+name:value for name,value in sources.items()}, + expected_native_sources=sources) + def output(name): + fields = dict(value=name) + return dict(case=name, fields=fields, + full_digest=hashlib.sha256(json.dumps(fields,sort_keys=True).encode()).hexdigest(), + strict={'periods':name}, common={'periods':name}, + nperiods=1, primary_period=2., SDE=3.) + def rep(names): + return dict(status='ok', errors=[], outputs=[output(name) for name in names], + source_count=len(names), denominator_seconds=1.,elapsed_seconds=1., + exclusive_before=exclusive_snapshot(),exclusive_after=exclusive_snapshot()) + owner=ownership_receipt() + worker=copy.deepcopy(owner['workers'][0]);worker['sources']={'files':sources} + owner['workers']=[worker] + record=dict(status='ok', pool_width=1, workers=[worker], gpu_ownership=owner, + config=dict(backend=backend,prefix='graph',regime='tess_solar', + measurement_scope='full',manifest='manifest',names=names), + warmup=rep(['a']),single=[rep(['a']) for _ in range(5)],batch=[rep(names) for _ in range(3)]) + expected={name:{'strict':output(name)['strict']} for name in names} + plan=dict(manifest='manifest',cases={'tess_solar':[{'name':name} for name in names]}, + frozen_outputs={'tess_solar':{backend:expected}}) + modules=dict(benchmark=benchmark,cohort=cohort,summarize=summarize) + return record,selection,plan,modules + + +def test_complete_mandatory_config_keeps_all_original_gates_and_full_identity(): + r,s,p,f=fixture() + result=report.audit_config(r,'candidate',1,'tess_solar',s,p,f) + assert result['complete'] and result['eligible'] + assert result['gates']['single_batch_full_identity']['eligible'] + + +@pytest.mark.parametrize('mutation', ['missing_source','changed_source','case','missing_rep', + 'changed_strict','unstable_full','forged_full_digest','changed_owner','empty_owner', + 'partial_status','short_denominator','worker_count']) +def test_mandatory_configuration_cannot_be_salvaged(mutation): + r,s,p,f=fixture() + if mutation=='missing_source': r['workers'][0]['sources']['files'].pop('tls.py') + elif mutation=='changed_source': r['workers'][0]['sources']['files']['tls.py']='other' + elif mutation=='case': r['config']['names']=['a','other'] + elif mutation=='missing_rep': r['batch'].pop() + elif mutation=='changed_strict': r['batch'][0]['outputs'][0]['strict']['periods']='other' + elif mutation=='unstable_full': + value=r['batch'][0]['outputs'][0];value['fields']['extra']='changed' + value['full_digest']=hashlib.sha256(json.dumps(value['fields'],sort_keys=True).encode()).hexdigest() + elif mutation=='forged_full_digest': r['batch'][0]['outputs'][0]['fields']['extra']='unhashed' + elif mutation=='changed_owner': r['batch'][0]['exclusive_after']=exclusive_snapshot([202]) + elif mutation=='empty_owner': r['gpu_ownership']['after_start']=exclusive_snapshot([]) + elif mutation=='partial_status': r.pop('status') + elif mutation=='short_denominator': r['batch'][0]['denominator_seconds']=.5 + elif mutation=='worker_count': r['workers']=[] + with pytest.raises(ValueError): + report.audit_config(r,'candidate',1,'tess_solar',s,p,f) + + +def oom_record(): + record=dict(status='error',config={'names':['a']},single=[],batch=[], + warmup=dict(status='error',denominator_seconds=None,outputs=[{'case':'a'}]*3, + errors=[{'case':'a','traceback':'cupy.cuda.memory.OutOfMemoryError: allocation failed'}, + {'reason':'Post-barrier result membership differs from the assigned inputs', + 'expected':['a']*4,'observed':['a']*3}])) + return record + + +def test_only_warmup_oom_accounted_missing_output_is_a_permitted_optional_failure(): + assert report.optional_warmup_oom(oom_record(),'gtls',4,list(report.ALLOWED_OMISSION_REASONS)) + + +@pytest.mark.parametrize('mutation', ['native_one','candidate','partial_batch','other_error', + 'extra_error','wrong_membership','failure_denominator','owner_failure','missing_oom']) +def test_oom_scope_does_not_waive_other_failures(mutation): + r=oom_record();backend='gtls';width=4;problems=list(report.ALLOWED_OMISSION_REASONS) + if mutation=='native_one': width=1 + elif mutation=='candidate': backend='candidate' + elif mutation=='partial_batch': r['batch']=[{'status':'ok'}] + elif mutation=='other_error': r['warmup']['errors'][0]['traceback']='ValueError: wrong' + elif mutation=='extra_error': r['warmup']['errors'].append({'reason':'foreign context'}) + elif mutation=='wrong_membership': r['warmup']['errors'][1]['observed']=['a']*2 + elif mutation=='failure_denominator': r['warmup']['denominator_seconds']=1. + elif mutation=='owner_failure': problems.append('GPU ownership lifecycle failed') + elif mutation=='missing_oom': r['warmup']['errors']=r['warmup']['errors'][1:] + assert not report.optional_warmup_oom(r,backend,width,problems) + + +def rejection(): + details={'tess_gap':{'gtls_graph_4worker':dict(exclusion='warmup_gpu_out_of_memory', + problems=sorted(report.ALLOWED_OMISSION_REASONS))}} + reasons=['tess_gap: gtls_graph_4worker: '+reason for reason in sorted(report.ALLOWED_OMISSION_REASONS)] + acceptance=dict(status='rejected',publication_gate={'pass':False,'problems':reasons}) + return acceptance,details + + +def test_original_rejection_must_be_exactly_the_disclosed_optional_failure(): + a,d=rejection() + assert report.verify_audit_reasons(a,d)==['tess_gap/gtls_graph_4worker'] + + +@pytest.mark.parametrize('mutation',['source','normalization','component','missing_reason','accepted']) +def test_final_audit_traceback_alone_cannot_hide_other_failures(mutation): + a,d=rejection() + if mutation=='missing_reason':a['publication_gate']['problems'].pop() + elif mutation=='accepted':a['publication_gate']['pass']=True + else:a['publication_gate']['problems'].append('Other failed '+mutation+' gate') + with pytest.raises(ValueError,match='beyond'): + report.verify_audit_reasons(a,d) + + +def terminal_files(tmp_path): + names=['preflight','components_candidate','components_gtls','public','summarize','normalize'] + stages=[dict(name=name,command=[name],timeout_seconds=60,status='complete',exit_code=0) for name in names] + report.write(tmp_path/'status.json',dict(status='error',stages=stages)) + report.write(tmp_path/'pipeline-plan.json',dict(stages=stages)) + report.write(tmp_path/'terminal.json',dict(status='error',exit_code=2, + error='ValueError: Final timing acceptance failed: []')) + prepared={'selections':{'tess_solar':{'correction_timing':{'required':False}}}} + return prepared + + +def test_completed_stages_can_have_failed_final_all_configuration_audit(tmp_path): + p=terminal_files(tmp_path) + assert report.check_driver_terminal(tmp_path,p)['exit_code']==2 + + +@pytest.mark.parametrize('mutation',['earlier_failure','missing_stage','extra_stage','command','exit_bool','other_error']) +def test_partial_campaign_or_different_stage_is_rejected(tmp_path,mutation): + p=terminal_files(tmp_path);d=report.read(tmp_path/'status.json');t=report.read(tmp_path/'terminal.json') + if mutation=='earlier_failure':d['stages'][0]['exit_code']=1 + elif mutation=='missing_stage':d['stages'].pop() + elif mutation=='extra_stage':d['stages'].append(d['stages'][0]) + elif mutation=='command':d['stages'][0]['command']=['different'] + elif mutation=='exit_bool':d['stages'][0]['exit_code']=False + elif mutation=='other_error':t['error']='RuntimeError: public failed' + report.write(tmp_path/'status.json',d);report.write(tmp_path/'terminal.json',t) + with pytest.raises(ValueError):report.check_driver_terminal(tmp_path,p) + + +def collection_files(tmp_path): + name='timing-continuation/results/timing/acceptance.json' + path=tmp_path/'files'/name;path.parent.mkdir(parents=True);path.write_text('{}') + report.write(tmp_path/'file-index.json',{name:dict(size=path.stat().st_size,sha256=report.sha(path))}) + report.write(tmp_path/'outcome.json',dict(final_compact_verified=True,missing_final_paths=[],exit_code=2)) + report.write(tmp_path/'termination.json',dict(verified=True)) + return path + + +def test_failed_pipeline_can_be_fully_collected(tmp_path): + collection_files(tmp_path) + root,receipt=report.verify_collection(tmp_path) + assert receipt['original_pipeline_exit_code']==2 + assert len(receipt['verified_files'])==1 + + +@pytest.mark.parametrize('mutation',['changed','missing','extra','not_final','not_stopped']) +def test_collection_must_be_complete_and_hash_verified(tmp_path,mutation): + path=collection_files(tmp_path) + if mutation=='changed':path.write_text('{"changed":true}') + elif mutation=='missing':path.unlink() + elif mutation=='extra':(path.parent/'unindexed.json').write_text('{}') + elif mutation=='not_final':report.write(tmp_path/'outcome.json',dict(final_compact_verified=False,missing_final_paths=[])) + elif mutation=='not_stopped':report.write(tmp_path/'termination.json',dict(verified=False)) + with pytest.raises(ValueError):report.verify_collection(tmp_path) diff --git a/benchmarks/tls_reference/timing/test_reproduction.py b/benchmarks/tls_reference/timing/test_reproduction.py new file mode 100644 index 00000000..fa875753 --- /dev/null +++ b/benchmarks/tls_reference/timing/test_reproduction.py @@ -0,0 +1,141 @@ +"""CPU provenance checks for timing a reproduced population.""" +import copy +import json +from pathlib import Path +import shutil + +import pytest + +from .cohort import accepted_study, select +from .common import sha +from .merge import merge + + +def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value)) + + +def reproduced_study(root, regimes=('tess_solar',), indices=range(20), label='main'): + root.mkdir(parents=True) + identity = dict(production_sources={'cuvarbase/tls.py': 'production'}, + generator_sha256=label+'-original-generator') + native = {'core.py': 'native'} + tools = {'validate.py': 'public-validator', 'corrected_reference.py': 'public-adapter'} + original = dict(suite='heldout', source_identity=identity, seal_sha256=label+'-seal', cases=[]) + for regime in regimes: + for index in indices: + name = f'{regime}_null_{index:04d}.npz' + (root/name).write_bytes(('Fictitious CPU provenance fixture: '+name).encode()) + original['cases'].append(dict(file=name, sha256=sha(root/name), + metadata=dict(name=Path(name).stem, regime=regime, null=True, search_kwargs={}), + arrays={'periods': 'original-array-digest'})) + write(root/'original_manifest.json', original) + manifest = copy.deepcopy(original) + manifest.update(suite='reproduction', original_manifest_sha256=sha(root/'original_manifest.json')) + for case in manifest['cases']: + case['original_npz_sha256'] = case['sha256'] + write(root/'manifest.json', manifest) + results = root/'results' + for case in manifest['cases']: + folder = results/Path(case['file']).stem + for backend in ('gtls', 'gtls_corrected', 'candidate'): + write(folder/backend/'record.json', dict(status='ok', input_sha256=case['sha256'], + seal_sha256=manifest['seal_sha256'], harness_sha256=tools['validate.py'], + input_metadata=dict(cohort='reproduction'), engine_sources=identity['production_sources'], + result=dict(package_sources=native, reference_correction=dict( + correction='finite_candidates_before_ranking_v1', adapter_sha256=tools['corrected_reference.py'])))) + write(folder/'compare.json', dict(passed=True, + reference_record_sha256=sha(folder/'gtls_corrected/record.json'), + candidate_record_sha256=sha(folder/'candidate/record.json'))) + write(folder/'correction_trace.json', dict(correction='finite_candidates_before_ranking_v1', proved_no_op=True)) + count = len(manifest['cases']) + write(results/'acceptance.json', dict(reproduction_gate={'pass': True}, + inputs_manifest_sha256=sha(root/'manifest.json'), seal_sha256=manifest['seal_sha256'], + original_source_identity=identity, reproduction_sources=dict(production=identity['production_sources'], tools=tools), + reference_package_sources=native, all_planned_accounted=True, + unresolved_numerical_cases=[], candidate_regressions=[], + counts=dict(planned=count, accounted=count, numerical_pairs_passed=count))) + return root/'manifest.json', results + + +def test_reproduction_can_time_without_independent_promotion(tmp_path): + manifest, results = reproduced_study(tmp_path/'replay') + accepted = accepted_study(manifest, results) + assert accepted['reproduction_gate_passed'] + assert not accepted['publication_gate_passed'] + assert accepted['evidence_kind'] == 'reproduction' + assert accepted['reproduction_sources']['tools']['validate.py'] == 'public-validator' + selected = select(manifest, results, 'tess_solar') + assert selected['actual_batch_size'] == 16 + assert selected['accepted_study']['evidence_kind'] == 'reproduction' + + +@pytest.mark.parametrize('change', ('failed_gate', 'dual_gates', 'production', 'validator', 'adapter', + 'original_manifest', 'population', 'comparison', 'missing_case', 'count')) +def test_reproduction_requires_original_and_actual_execution_chains(tmp_path, change): + manifest_path, results = reproduced_study(tmp_path/'replay') + receipt_path = results/'acceptance.json' + receipt = json.loads(receipt_path.read_text()) + manifest = json.loads(manifest_path.read_text()) + folder = results/Path(manifest['cases'][0]['file']).stem + if change == 'failed_gate': + receipt['reproduction_gate']['pass'] = False + elif change == 'dual_gates': + receipt['publication_gate'] = {'pass': True} + elif change == 'production': + receipt['reproduction_sources']['production'] = {'engine': 'changed'} + elif change == 'validator': + receipt['reproduction_sources']['tools']['validate.py'] = 'changed' + elif change == 'adapter': + receipt['reproduction_sources']['tools']['corrected_reference.py'] = 'changed' + elif change == 'original_manifest': + (manifest_path.parent/'original_manifest.json').unlink() + elif change == 'population': + manifest['cases'][0]['arrays']['periods'] = 'changed' + write(manifest_path, manifest) + receipt['inputs_manifest_sha256'] = sha(manifest_path) + elif change == 'comparison': + comparison = json.loads((folder/'compare.json').read_text()) + comparison['candidate_record_sha256'] = 'changed' + write(folder/'compare.json', comparison) + elif change == 'missing_case': + (folder/'candidate/record.json').unlink() + elif change == 'count': + receipt['counts']['numerical_pairs_passed'] -= 1 + write(receipt_path, receipt) + with pytest.raises((ValueError, FileNotFoundError)): + accepted_study(manifest_path, results) + + +def test_reproduced_merge_is_portable_and_keeps_two_original_studies(tmp_path): + campaign = tmp_path/'campaign' + regimes = ('tess_solar', 'tess_gap', 'ztf_solar') + studies = [] + for label, indices in (('main', range(8)), ('supplement', range(8, 16))): + manifest, results = reproduced_study(campaign/label, regimes, indices, label) + studies.append((label, manifest, results)) + receipt = merge(studies, campaign/'timing-inputs') + assert receipt['input_count'] == 48 + assert receipt['evidence_kind'] == 'reproduction' + shutil.move(str(campaign), str(tmp_path/'moved')) + output = tmp_path/'moved/timing-inputs' + manifest = json.loads((output/'manifest.json').read_text()) + accepted = accepted_study(output/'manifest.json', output) + assert accepted['independently_accepted_studies'] == {} + assert set(accepted['reproduced_studies']) == {'main', 'supplement'} + for entry in manifest['cases']: + assert not Path(entry['result_root']).is_absolute() + assert sha(output/entry['file']) == entry['sha256'] + for regime in regimes: + assert select(output/'manifest.json', output, regime)['actual_batch_size'] == 16 + gate = json.loads((output/'acceptance.json').read_text()) + assert gate['reproduction_gate']['pass'] + assert 'publication_gate' not in gate + + +def test_merge_requires_the_original16_nulls_in_all_regimes(tmp_path): + manifest, results = reproduced_study(tmp_path/'one-regime') + with pytest.raises(ValueError, match='incomplete'): + merge([('main', manifest, results)], tmp_path/'timing-inputs') + assert not (tmp_path/'timing-inputs').exists() diff --git a/benchmarks/tls_reference/timing/test_timing.py b/benchmarks/tls_reference/timing/test_timing.py new file mode 100644 index 00000000..0508825e --- /dev/null +++ b/benchmarks/tls_reference/timing/test_timing.py @@ -0,0 +1,574 @@ +"""CPU checks for benchmark accounting and output gates; no CUDA imports.""" +import copy +import inspect +import json +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace + +import numpy as np +import pytest + +from . import benchmark +from .benchmark import consistency +from .cohort import accepted_study, frozen_outputs, select, verify_worker_sources +from .common import array_hash, fingerprint, masked_hash, selected_names, sha +from .components import Components +from .summarize import compare_components, compare_public, compare_single_public, distribution + + +def output(name='a', digest='same'): + return dict(case=name, strict={'periods': digest}, common={'periods': digest}, + full_digest=digest) + + +def exclusive_snapshot(pids=(101,)): + return dict(allowed_pids=list(pids), observed_pids=list(pids), + foreign_pids=[], missing_pids=[], exclusive=True) + + +def ownership_receipt(): + return dict(version=1, status='complete', passed=True, host_pids=[101], identity_scope='individual', + before_start=exclusive_snapshot(()), after_start=exclusive_snapshot(), + after_exit=exclusive_snapshot(()), + owned_worker_pids=[11], live_workers_at_binding=[11], + workers=[dict(pid=11, namespace_pids=[11], cuda_context_allocation_bytes=1, + cuda_context_synchronized=True)], + bindings=[dict(worker_pid=11, namespace_pids=[11], host_pid=101, + method='single_worker_lifecycle')], + worker_exits=[dict(worker_pid=11, exit_code=0, alive=False, forced=False)]) + + +def repetition(names=('a', 'b'), seconds=2.): + return dict(status='ok', outputs=[output(name) for name in names], + source_count=len(names), denominator_seconds=seconds, + exclusive_before=exclusive_snapshot(), exclusive_after=exclusive_snapshot()) + + +def configuration(names=('a', 'b'), seconds=2.): + return dict(status='ok', gpu_ownership=ownership_receipt(), batch=[repetition(names, seconds) for _ in range(3)], + single=[repetition(names[:1], seconds/2) for _ in range(5)]) + + +def single_configuration(seconds=1.): + return dict(status='ok', pool_width=1, batch=[], gpu_ownership=ownership_receipt(), + single=[repetition(('a',), seconds) for _ in range(5)]) + + +def test_single_scope_has_no_batch_or_strongest_pool_result(): + records = {'candidate_graph_1worker': single_configuration(), + 'gtls_graph_1worker': single_configuration(2.)} + frozen = {name: {'a': {'strict': {'periods': 'same'}}} for name in ('candidate', 'gtls')} + result = compare_single_public(records, 'a', frozen) + assert result['public_single']['eligible'] + assert result['public_single']['speedup'] == 2 + assert result['public_batch']['status'] == 'not_measured' + assert result['public_batch']['source_count'] == 0 + assert 'strongest_tested_native_workers' not in result['public_batch'] + + +@pytest.mark.parametrize('change', ('missing_rep', 'wrong_case', 'changed', 'full_output', 'failed', 'batch', 'pool')) +def test_single_scope_rejects_incomplete_or_different_results(change): + records = {'candidate_graph_1worker': single_configuration(), + 'gtls_graph_1worker': single_configuration(2.)} + record = records['candidate_graph_1worker'] + if change == 'missing_rep': + record['single'].pop() + elif change == 'wrong_case': + record['single'][0]['outputs'][0]['case'] = 'another' + elif change == 'changed': + record['single'][0]['outputs'][0]['strict'] = {'periods': 'changed'} + elif change == 'full_output': + record['single'][0]['outputs'][0]['full_digest'] = 'extra-field-changed' + elif change == 'failed': + record['single'][0].update(status='error', denominator_seconds=None) + elif change == 'batch': + record['batch'] = [repetition()] + else: + record['pool_width'] = 2 + frozen = {name: {'a': {'strict': {'periods': 'same'}}} for name in ('candidate', 'gtls')} + result = compare_single_public(records, 'a', frozen) + assert not result['public_single']['eligible'] + assert result['public_single']['speedup'] is None + + +def test_single_corrected_comparison_is_separate_and_requires_matching_outputs(): + records = {name+'_graph_1worker': single_configuration(seconds) for name, seconds in + (('candidate', 1), ('gtls', 2), ('gtls_corrected', 1.5))} + frozen = {name: {'a': {'strict': {'periods': 'same'}}} for name in ('candidate', 'gtls', 'gtls_corrected')} + result = compare_single_public(records, 'a', frozen) + assert result['public_single']['speedup'] == 2 + assert result['corrected_native_crosscheck']['single']['speedup'] == 1.5 + records['gtls_corrected_graph_1worker']['single'][0]['outputs'][0]['common'] = {'periods': 'changed'} + assert not compare_single_public(records, 'a', frozen)['corrected_native_crosscheck']['single']['eligible'] + + +@pytest.mark.parametrize('fail', (False, True)) +def test_single_runner_executes_only_five_public_single_calls_and_halts_on_mismatch(tmp_path, monkeypatch, fail): + import contextlib + calls = [] + selection = dict(single_case='a', selected_cases=['a', 'b'], actual_batch_size=2, + correction_timing=dict(required=False)) + monkeypatch.setattr(benchmark, 'select', lambda *args: copy.deepcopy(selection)) + monkeypatch.setattr(benchmark, 'load_cases', lambda manifest, regime, names: + [dict(name=name) for name in names]) + monkeypatch.setattr(benchmark, 'case_identity', lambda case: case) + monkeypatch.setattr(benchmark, 'frozen_outputs', lambda *args, **kwargs: + {name: {'a': {'strict': {'periods': 'same'}}} for name in ('candidate', 'gtls')}) + monkeypatch.setattr(benchmark, 'verify_worker_sources', lambda *args: None) + monkeypatch.setattr(benchmark, 'environment', lambda: {}) + monkeypatch.setattr(benchmark, 'Monitor', lambda *args: contextlib.nullcontext()) + class Pool: + def __init__(self, config, width, timeout): + assert config['names'] == ['a'] and width == 1 + self.backend = config['backend'] + self.ready = [dict(sources={})] + self.startup_seconds = .01 + def measure(self, indices, *, single=False, warmup=False): + assert single and indices == [0] + calls.append((self.backend, warmup)) + result = repetition(('a',)) + if fail and not warmup: + result['outputs'][0]['strict'] = {'periods': 'changed'} + return result + def close(self): + return ownership_receipt() + monkeypatch.setattr(benchmark, 'WorkerPool', Pool) + manifest = tmp_path/'manifest.json' + manifest.write_text('{}') + args = SimpleNamespace(measurement_scope='single', pool_widths=[1], output=tmp_path/'output', + manifest=manifest, paired_results=tmp_path, regimes=['tess_solar'], correction_adapter=None, + row_ab=False, timeout=10) + if fail: + with pytest.raises(RuntimeError, match='complete-output gate'): + benchmark.run(args) + assert len(calls) == 6 # Fail before starting the other implementation. + else: + benchmark.run(args) + assert len(calls) == 12 + plan = json.loads((args.output/'plan.json').read_text()) + assert plan['measurement_scope'] == 'single' + assert plan['batch_repetitions'] == 0 + + +def test_numerical_fingerprints_cover_shape_dtype_mask_and_objects(): + assert array_hash(np.ones(2, np.float32)) != array_hash(np.ones(2, np.float64)) + assert array_hash(np.ones(2)) != array_hash(np.ones((1, 2))) + assert masked_hash(np.ma.array([1, 2], mask=[0, 1])) != masked_hash([1, 2]) + with pytest.raises(TypeError, match='Object-array'): + array_hash(np.array([object()], object)) + + +def test_public_unit_conversion_preserves_strict_native_bits(): + case = dict(name='case', error_scale=2.) + native = SimpleNamespace(periods=np.array([1., 2.]), + power=np.ma.array([2., 999.], mask=[0, 1]), + chi2=np.ma.array([8., 123.], mask=[0, 1]), period=1., SDE=3., extra=np.array([1.])) + candidate = dict(periods=np.array([1., 2.]), power=np.array([2., np.nan]), + chi2=np.array([2., np.nan]), valid_periods=np.array([True, False]), period=1., SDE=3.) + old = fingerprint('gtls', case, native) + new = fingerprint('candidate', case, candidate) + assert old['common'] == new['common'] + assert old['strict'] != new['strict'] + native.extra[0] = 2. + changed = fingerprint('gtls', case, native) + assert old['strict'] == changed['strict'] + assert old['full_digest'] != changed['full_digest'] + + +@pytest.mark.parametrize('change', ('empty', 'missing_rep', 'missing_case', 'duplicate', 'changed', 'failure', 'count')) +def test_consistency_rejects_incomplete_or_changed_repetitions(change): + records = [repetition() for _ in range(3)] + if change == 'empty': + records = [] + elif change == 'missing_rep': + records.pop() + elif change == 'missing_case': + records[1]['outputs'].pop() + elif change == 'duplicate': + records[1]['outputs'][1] = output('a') + elif change == 'changed': + records[1]['outputs'][1] = output('b', 'changed') + elif change == 'failure': + records[1]['status'] = 'error' + else: + records[1]['source_count'] = 3 + assert not consistency(records, expected_names=['a', 'b'], expected_repetitions=3)['eligible'] + + +def test_fastest_native_pool_requires_original_complete_searches(): + records = {'candidate_graph_1worker': configuration(seconds=1.), + 'gtls_graph_1worker': configuration(seconds=8.), + 'gtls_graph_2worker': configuration(seconds=6.), + 'gtls_graph_4worker': configuration(seconds=2.)} + records['gtls_graph_4worker']['batch'][1]['outputs'][1] = output('b', 'changed') + summary = compare_public(records, ['a', 'b'], 'a', [1, 2, 4]) + assert summary['public_batch']['strongest_tested_native_workers'] == 2 + assert summary['public_batch']['speedup'] == 6. + assert not summary['public_batch']['native_pool_configurations']['4']['eligible'] + assert summary['public_single']['speedup'] == 8. + + +def test_cross_backend_difference_and_failures_never_become_denominators(): + records = {'candidate_graph_1worker': configuration(seconds=1.), + 'gtls_graph_1worker': configuration(seconds=8.)} + records['candidate_graph_1worker']['batch'][0]['outputs'][1]['common'] = {'periods': 'changed'} + result = compare_public(records, ['a', 'b'], 'a', [1]) + assert result['public_batch']['speedup'] is None + records['candidate_graph_1worker']['status'] = 'measurement_failure' + result = compare_public(records, ['a', 'b'], 'a', [1]) + assert result['public_single']['speedup'] is None + assert distribution([1., None, 0.01]) is None + assert distribution([1., float('inf')]) is None + + +def test_single_batch_and_full_output_prefix_ab_gates(): + records = {'candidate_graph_1worker': configuration(seconds=1.), + 'candidate_row_1worker': configuration(seconds=2.), + 'gtls_graph_1worker': configuration(seconds=8.)} + records['candidate_row_1worker']['single'][1]['outputs'][0]['full_digest'] = 'extra_array_changed' + records['candidate_graph_1worker']['single'][2]['outputs'][0]['strict'] = {'periods': 'changed'} + result = compare_public(records, ['a', 'b'], 'a', [1]) + assert not result['public_single']['eligible'] + assert not result['prefix_ab']['eligible'] + assert result['public_batch']['eligible'] + + +def test_declared_native_bug_difference_keeps_method_specific_frozen_gates(): + records = {'candidate_graph_1worker': configuration(seconds=1.), + 'gtls_graph_1worker': configuration(seconds=8.)} + for kind in ('single', 'batch'): + for rep in records['candidate_graph_1worker'][kind]: + for value in rep['outputs']: + value['common'] = {'periods': 'declared-native-bug-difference'} + frozen = {backend: {name: {'strict': {'periods': 'same'}} for name in ('a', 'b')} + for backend in ('gtls', 'candidate')} + result = compare_public(records, ['a', 'b'], 'a', [1], frozen=frozen) + assert result['public_batch']['eligible'] + assert not result['public_batch']['common_output_gate']['eligible'] + assert result['public_single']['eligible'] + frozen['candidate']['b']['strict'] = {'periods': 'unexpected'} + assert not compare_public(records, ['a', 'b'], 'a', [1], frozen=frozen)['public_batch']['eligible'] + + +def test_corrected_crosscheck_does_not_enter_literal_pool_selection(): + records = {'candidate_graph_1worker': configuration(seconds=1.), + 'gtls_graph_1worker': configuration(seconds=8.), + 'gtls_graph_2worker': configuration(seconds=6.), + 'gtls_corrected_graph_1worker': configuration(seconds=.5)} + frozen = {backend: {name: {'strict': {'periods': 'same'}} for name in ('a', 'b')} + for backend in ('gtls', 'gtls_corrected', 'candidate')} + result = compare_public(records, ['a', 'b'], 'a', [1, 2], frozen=frozen) + assert result['public_batch']['strongest_tested_native_workers'] == 2 + assert result['public_batch']['speedup'] == 6. + assert result['corrected_native_crosscheck']['batch']['speedup'] == .5 + assert result['corrected_native_crosscheck']['batch']['source_count'] == 2 + records['gtls_corrected_graph_1worker']['batch'][1]['outputs'][1] = output('b', 'changed') + bad = compare_public(records, ['a', 'b'], 'a', [1, 2], frozen=frozen) + assert not bad['corrected_native_crosscheck']['batch']['eligible'] + assert bad['public_batch']['eligible'] + + +def test_own_frozen_public_output_archives_are_verified_before_measurement(tmp_path): + case = dict(name='case.npz', input_sha256='input', error_scale=2.) + for backend in ('gtls', 'candidate'): + folder = tmp_path/'case'/backend + folder.mkdir(parents=True) + if backend == 'gtls': + arrays = dict(periods=np.array([1., 2.]), power=np.array([2., 3.]), chi2=np.array([8., 12.])) + arrays.update({key+'_mask': np.array([False, False]) for key in ('periods', 'power', 'chi2')}) + result = dict(period=1., score=3.) + else: + arrays = dict(public_periods=np.array([1., 2.]), public_power=np.array([2., 3.]), + public_chi2=np.array([2., 3.]), public_valid_periods=np.array([True, True])) + result = dict(public_contract=dict(period=1., SDE=3.)) + archive = folder/'arrays.npz' + np.savez_compressed(archive, **arrays) + record = dict(status='ok', input_sha256='input', result=result, arrays_file='arrays.npz', + arrays_sha256=sha(archive), + arrays={key: dict(sha256=array_hash(value), shape=list(value.shape), dtype=str(value.dtype)) + for key, value in arrays.items()}) + (folder/'record.json').write_text(json.dumps(record)) + result = frozen_outputs(tmp_path, [case]) + assert result['candidate']['case.npz']['strict']['power'] == result['gtls']['case.npz']['strict']['power'] + (tmp_path/'case/candidate/arrays.npz').unlink() + assert frozen_outputs(tmp_path, [case]) == result + + +def make_cohort(root, failing=(), failed_reserves=()): + originals = selected_names('tess_solar') + names = originals + [f'tess_solar_null_{index:04d}.npz' for index in range(16, 20)] + manifest = dict(cases=[dict(file=name, sha256=name, metadata=dict(regime='tess_solar', null=True, search_kwargs={})) for name in names], + seal_sha256='seal', source_identity=dict(production_sources={'cuvarbase/tls.py': 'source'})) + path = root/'manifest.json' + path.write_text(json.dumps(manifest)) + results = root/'results' + for name in names: + for backend in ('gtls', 'candidate', 'gtls_corrected'): + folder = results/Path(name).stem/backend + folder.mkdir(parents=True) + bad = backend == 'gtls' and name in set(failing) | set(failed_reserves) + record = dict(status='error' if bad else 'ok', input_sha256=name, seal_sha256='seal', + elapsed_seconds=0.001 if bad else 999999., + engine_sources=manifest['source_identity']['production_sources'], + result=dict(package_sources={'core.py': 'native'}, + reference_correction={'correction': 'finite_candidates_before_ranking_v1'})) + (folder/'record.json').write_text(json.dumps(record)) + trace = dict(correction='finite_candidates_before_ranking_v1', proved_no_op=True) + (results/Path(name).stem/'correction_trace.json').write_text(json.dumps(trace)) + acceptance = dict(publication_gate={'pass': True}, inputs_manifest_sha256=sha(path), + seal_sha256=manifest['seal_sha256'], source_identity=manifest['source_identity'], + reference_package_sources={'core.py': 'native'}, + counts={'planned': len(names), 'accounted': len(names)}) + (results/'acceptance.json').write_text(json.dumps(acceptance)) + return path, results + + +def test_replacements_are_manifest_order_success_only_and_keep_failures(tmp_path): + first = selected_names('tess_solar')[0] + manifest, results = make_cohort(tmp_path, [first], ['tess_solar_null_0016.npz']) + result = select(manifest, results, 'tess_solar') + assert result['actual_batch_size'] == 16 + assert result['single_case'] == 'tess_solar_null_0001.npz' + assert result['replacement_cases'] == [dict(original_case=first, replacement_case='tess_solar_null_0017.npz')] + assert result['single_replaced'] + assert result['excluded_cases'] == [first, 'tess_solar_null_0016.npz'] + assert result['examined'][0]['backends']['gtls']['study_elapsed_seconds'] == 0.001 + assert result['study_times_are_not_benchmark_denominators'] + verify_worker_sources('gtls', {'files': {'core.py': 'native'}}, result) + with pytest.raises(ValueError, match='native package'): + verify_worker_sources('gtls', {'files': {'core.py': 'changed'}}, result) + + +def test_insufficient_successes_and_missing_results_are_explicit(tmp_path): + originals = selected_names('tess_solar') + reserve = [f'tess_solar_null_{index:04d}.npz' for index in range(16, 20)] + manifest, results = make_cohort(tmp_path, originals[:2], reserve) + result = select(manifest, results, 'tess_solar') + assert result['actual_batch_size'] == 14 + assert result['single_case'] == 'tess_solar_null_0002.npz' + assert result['single_replaced'] + assert result['single_case'] in result['selected_cases'] + assert result['status'] == 'insufficient_paired_successes' + (results/Path(originals[0]).stem/'gtls/record.json').unlink() + with pytest.raises(ValueError, match='not complete'): + select(manifest, results, 'tess_solar') + + +def test_no_successful_null_does_not_invent_single_latency_input(tmp_path): + originals = selected_names('tess_solar') + reserve = [f'tess_solar_null_{index:04d}.npz' for index in range(16, 20)] + manifest, results = make_cohort(tmp_path, originals, reserve) + result = select(manifest, results, 'tess_solar') + assert result['actual_batch_size'] == 0 + assert result['single_case'] is None + assert result['single_unavailable'] + assert not result['single_replaced'] + + +def test_conditional_correction_timing_uses_trace_not_runtime_or_recovery(tmp_path): + manifest, results = make_cohort(tmp_path) + original = selected_names('tess_solar')[3] + before = select(manifest, results, 'tess_solar') + assert not before['correction_timing']['required'] + path = results/Path(original).stem/'correction_trace.json' + trace = json.loads(path.read_text()) + trace['proved_no_op'] = False + path.write_text(json.dumps(trace)) + after = select(manifest, results, 'tess_solar') + assert before['selected_cases'] == after['selected_cases'] + assert after['correction_timing']['required'] + assert after['correction_timing']['affected_cases'] == [original] + verify_worker_sources('gtls_corrected', dict(files={'core.py': 'native'}, + reference_correction=after['expected_correction']), after) + with pytest.raises(ValueError, match='correction differs'): + verify_worker_sources('gtls_corrected', dict(files={'core.py': 'native'}, + reference_correction={'correction': 'changed'}), after) + + +def test_unaccepted_independent_study_prevents_any_timing_selection(tmp_path): + manifest, results = make_cohort(tmp_path) + path = results/'acceptance.json' + receipt = json.loads(path.read_text()) + receipt['publication_gate']['pass'] = False + path.write_text(json.dumps(receipt)) + with pytest.raises(ValueError, match='publication gate'): + select(manifest, results, 'tess_solar') + + +def merged_cohort(root): + studies, entries, production = {}, [], None + for index, label in enumerate(('main', 'supplement')): + origin = root/label + origin.mkdir() + path, results = make_cohort(origin) + manifest = json.loads(path.read_text()) + manifest['seal_sha256'] = label+'-seal' + manifest['source_identity']['generator_sha256'] = label+'-generator' + path.write_text(json.dumps(manifest)) + for record_path in results.glob('*/*/record.json'): + record = json.loads(record_path.read_text()) + record['seal_sha256'] = manifest['seal_sha256'] + record_path.write_text(json.dumps(record)) + acceptance_path = results/'acceptance.json' + acceptance = json.loads(acceptance_path.read_text()) + acceptance.update(inputs_manifest_sha256=sha(path), seal_sha256=manifest['seal_sha256'], + source_identity=manifest['source_identity']) + acceptance_path.write_text(json.dumps(acceptance)) + studies[label] = dict(manifest_path=str(path), acceptance_path=str(acceptance_path), + results_root=str(results), manifest_sha256=sha(path), + seal_sha256=manifest['seal_sha256']) + production = manifest['source_identity']['production_sources'] + entries += [dict(value, study_id=label, result_root=str(results/Path(value['file']).stem)) + for value in manifest['cases'][index*8:(index+1)*8]] + merged = root/'timing_manifest.json' + merged.write_text(json.dumps(dict(source_identity={'production_sources': production}, + studies=studies, cases=entries))) + return merged + + +def test_two_study_null_manifest_preserves_separate_source_and_seal_chains(tmp_path): + manifest = merged_cohort(tmp_path) + accepted = accepted_study(manifest, tmp_path/'unused') + assert set(accepted['independently_accepted_studies']) == {'main', 'supplement'} + selected = select(manifest, tmp_path/'unused', 'tess_solar') + assert selected['actual_batch_size'] == 16 + assert selected['single_case'] == 'tess_solar_null_0000.npz' + assert selected['selected_cases'] == selected_names('tess_solar') + assert not selected['replacement_cases'] + + +@pytest.mark.parametrize('mutation', ('unaccepted', 'foreign_result_root', 'wrong_manifest_hash', 'changed_metadata')) +def test_merged_null_manifest_rejects_broken_origin_chain(tmp_path, mutation): + path = merged_cohort(tmp_path) + manifest = json.loads(path.read_text()) + if mutation == 'unaccepted': + receipt_path = Path(manifest['studies']['supplement']['acceptance_path']) + receipt = json.loads(receipt_path.read_text()) + receipt['publication_gate']['pass'] = False + receipt_path.write_text(json.dumps(receipt)) + elif mutation == 'foreign_result_root': + manifest['cases'][8]['result_root'] = str(tmp_path/'elsewhere') + elif mutation == 'wrong_manifest_hash': + manifest['studies']['supplement']['manifest_sha256'] = 'changed' + else: + manifest['cases'][8]['metadata']['search_kwargs']['oversampling_factor'] = 4 + path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError): + accepted_study(path, tmp_path/'unused') + + +def test_worker_retains_results_until_after_completion_ack(monkeypatch): + events = [] + cases = [dict(name='a'), dict(name='b')] + commands = iter([dict(kind='run', indices=[0, 1], single=False), + dict(kind='validate'), dict(kind='close')]) + class Connection: + def recv(self): + return next(commands) + def send(self, value): + events.append(value['kind']) + def close(self): + pass + monkeypatch.setattr(benchmark, 'load_cases', lambda *args: cases) + monkeypatch.setattr(benchmark, 'initialize_backend', lambda *args: {}) + monkeypatch.setattr(benchmark, 'retain_cuda_context', lambda: object()) + monkeypatch.setattr(benchmark, 'public_batch', lambda *args: [object(), object()]) + def deferred(*args): + events.append('hash') + return {'case': args[1]['name']} + monkeypatch.setattr(benchmark, 'fingerprint', deferred) + monkeypatch.setitem(sys.modules, 'cupy', SimpleNamespace(cuda=SimpleNamespace( + runtime=SimpleNamespace(deviceSynchronize=lambda: events.append('sync'))))) + benchmark.worker(Connection(), dict(manifest='unused', regime='unused', backend='candidate', prefix='graph')) + assert events.index('complete') < events.index('hash') < events.index('validation') + assert events[:events.index('complete')].count('sync') == 2 + + +def test_foreign_gpu_process_prevents_timing_and_is_retained_afterward(monkeypatch): + fake = SimpleNamespace(nvmlInit=lambda: None, nvmlShutdown=lambda: None, + nvmlDeviceGetHandleByIndex=lambda index: index, + nvmlDeviceGetComputeRunningProcesses=lambda handle: [SimpleNamespace(pid=10), SimpleNamespace(pid=20)]) + monkeypatch.setitem(sys.modules, 'pynvml', fake) + with pytest.raises(RuntimeError, match='Foreign GPU'): + benchmark.exclusive_gpu_processes([10]) + result = benchmark.exclusive_gpu_processes([10], strict=False) + assert result['foreign_pids'] == [20] + assert not result['exclusive'] + assert benchmark.exclusive_gpu_processes([10, 20])['exclusive'] + + +def install_fake_native(monkeypatch, source): + package = ModuleType('gputls') + core = ModuleType('gputls.core') + core.__file__ = 'fake_core.py' + stats = ModuleType('gputls.stats') + stats.pink_noise = lambda *args: 1. + core.spectra = lambda *args: None + core.search_multi_periods_again = lambda *args: None + core.snr_stats = lambda *args: stats.pink_noise() + class Lowest: + def argmin(self): + return self + def get(self): + return 7 + core.lowestResidualsGPU = Lowest() + exec(source, core.__dict__) + original = core.search_single_periods + monkeypatch.setattr(inspect, 'getsource', lambda function: source if function is original else '') + package.core, package.stats = core, stats + monkeypatch.setitem(sys.modules, 'gputls', package) + monkeypatch.setitem(sys.modules, 'gputls.core', core) + monkeypatch.setitem(sys.modules, 'gputls.stats', stats) + return core, stats, original + + +def test_component_injection_preserves_values_and_finds_real_caller(monkeypatch): + source = ('def search_single_periods():\n' + ' bestLocation = lowestResidualsGPU.argmin().get()\n' + ' diagnostic = snr_stats()\n' + ' return bestLocation, diagnostic\n') + core, stats, original = install_fake_native(monkeypatch, source) + literal = original() + # The real native caller remains below an instrumentation wrapper, so + # the callback must walk the frame stack rather than assume two frames. + exec('def search_multi_periods():\n' + ' periods, period, power, chi2, SDE = [1., 2.], 1., [2., 3.], [5., 6.], 4.\n' + ' return search_single_periods()\n', core.__dict__) + with Components('gtls') as instrument: + import time + before = time.perf_counter() + assert core.search_multi_periods() == literal + after = time.perf_counter() + result = instrument.accounting(before, after) + assert result['endpoint_valid'] + assert result['endpoint_count'] == 1 + assert instrument.search_result['period'] == 1. + assert result['stage_call_counts']['native_pink_noise_nested'] == 1 + assert result['stage_call_counts']['native_snr_stats_inclusive'] == 1 + assert result['inclusive_stage_seconds']['native_snr_stats_inclusive'] >= result['inclusive_stage_seconds']['native_pink_noise_nested'] + assert core.search_single_periods is original + + +def test_failed_component_install_restores_every_prior_patch(monkeypatch): + source = 'def search_single_periods():\n return 7\n' + core, stats, original = install_fake_native(monkeypatch, source) + snapshot = (core.spectra, core.search_multi_periods_again, core.snr_stats, stats.pink_noise) + with pytest.raises(ValueError, match='exactly once'): + with Components('gtls'): + pass + assert snapshot == (core.spectra, core.search_multi_periods_again, core.snr_stats, stats.pink_noise) + + +def test_component_summary_rechecks_literal_output_identity(): + one = dict(case={'name': 'a'}, status='ok', literal_outputs={'common': {'x': 'hash'}, 'full_digest': 'full'}, + gpu_ownership=ownership_receipt(), + repetitions=[dict(denominator_eligible=True, endpoint_valid=True, output_identical_to_literal=True, + exclusive_before=exclusive_snapshot(), exclusive_after=exclusive_snapshot(), + outputs={'full_digest': 'full'}, common_search_seconds=1., + public_instrumented_seconds=2., after_common_search_seconds=1., + inclusive_stage_seconds={'stage': 0.5}) for _ in range(5)]) + assert compare_components(one, copy.deepcopy(one), 'a')['eligible'] + changed = copy.deepcopy(one) + changed['repetitions'][2]['outputs']['full_digest'] = 'changed' + assert not compare_components(one, changed, 'a')['eligible'] diff --git a/benchmarks/tls_reference/timing/verify_study_hashes.py b/benchmarks/tls_reference/timing/verify_study_hashes.py new file mode 100644 index 00000000..ff58d0d0 --- /dev/null +++ b/benchmarks/tls_reference/timing/verify_study_hashes.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""CPU check of record-only expected hashes against retained study arrays. + +This verifies dtype/shape encoding, masked hidden storage and scalar hashes. +It does not execute a new public search or make a timing measurement. +""" +import argparse +import json +from pathlib import Path +from types import SimpleNamespace + +import numpy as np + +if __package__: + from .common import fingerprint, load_cases, sha, write + from .cohort import case_root, frozen_outputs +else: + from common import fingerprint, load_cases, sha, write + from cohort import case_root, frozen_outputs + + +def verify(manifest, results, name): + entries = json.loads(Path(manifest).read_text())['cases'] + entry = next(value for value in entries if value['file'] == name) + case = load_cases(manifest, entry['metadata']['regime'], [name])[0] + expected = frozen_outputs(results, [case], backends=('gtls', 'gtls_corrected', 'candidate'), + manifest_path=manifest) + checks = {} + for backend in expected: + root = case_root(manifest, results, name)/backend + record = json.loads((root/'record.json').read_text()) + archive = root/record['arrays_file'] + if sha(archive) != record['arrays_sha256']: + raise ValueError('Retained study array archive differs from its receipt') + with np.load(archive, allow_pickle=False) as arrays: + if backend != 'candidate': + values = {key: np.ma.array(arrays[key], mask=arrays[key+'_mask']) + for key in ('periods', 'power', 'chi2')} + public = SimpleNamespace(**values, period=record['result']['period'], + SDE=record['result']['score']) + masked_periods = int(np.sum(arrays['chi2_mask'])) + else: + public = {key: arrays['public_'+key] for key in ('periods', 'power', 'chi2', 'valid_periods')} + contract = record['result']['public_contract'] + public.update(period=contract['period'], SDE=contract['SDE']) + masked_periods = int(np.sum(~arrays['public_valid_periods'])) + actual = fingerprint(backend, case, public) + matches = actual['strict'] == expected[backend][name]['strict'] + checks[backend] = dict(passed=matches, nperiods=actual['nperiods'], masked_periods=masked_periods, + expected=expected[backend][name], observed_strict=actual['strict']) + return dict(passed=all(value['passed'] for value in checks.values()), case=name, checks=checks, + manifest_sha256=sha(manifest), + source_hashes={path.name: sha(path) for path in + (Path(__file__), Path(__file__).with_name('common.py'), + Path(__file__).with_name('cohort.py'))}, + scope='CPU reconstruction from retained study arrays/scalars compared with record-only expected timing fingerprints; no new public call or GPU execution') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--results', type=Path, required=True) + parser.add_argument('--case', required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + result = verify(args.manifest, args.results, args.case) + write(args.output, result) + if not result['passed']: + raise SystemExit('Study hash conversion failed') + print(json.dumps(dict(passed=True, case=args.case, + masked_periods={key: value['masked_periods'] for key, value in result['checks'].items()}))) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_reference/validate.py b/benchmarks/tls_reference/validate.py new file mode 100644 index 00000000..7ddec0ab --- /dev/null +++ b/benchmarks/tls_reference/validate.py @@ -0,0 +1,638 @@ +#!/usr/bin/env python3 +"""Complete TLS differential runner with explicit native-reference provenance. + +The reproduction runner invokes backends serially in one process and frees +unused CuPy pool blocks before each search. Native GTLS is instrumented only by +wrapping CPU postprocessing/profiling returns; the numerical source and CUDA +code are unchanged. Recorded wall time is diagnostic accounting, not a +headline performance benchmark. +""" +import argparse +import hashlib +import importlib +import inspect +import json +from pathlib import Path +import sys +import time +import traceback +import warnings + +import numpy as np + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def array_hash(value): + value = np.ascontiguousarray(value) + h = hashlib.sha256() + h.update(json.dumps(value.dtype.descr if value.dtype.names else value.dtype.str).encode()) + h.update(json.dumps(value.shape).encode()) + h.update(value.tobytes()) + return h.hexdigest() + + +def plain(value): + if isinstance(value, np.generic): + return plain(value.item()) + if isinstance(value, np.ndarray): + return [plain(v) for v in value.tolist()] + if isinstance(value, float) and not np.isfinite(value): + return None + if isinstance(value, dict): + return {str(k): plain(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [plain(v) for v in value] + return value + + +def write(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix+'.tmp') + temporary.write_text(json.dumps(plain(value), indent=2, allow_nan=False)+'\n') + temporary.replace(path) + + +def sources(root): + root = Path(root) + return {str(p.relative_to(root)): sha(p) for p in sorted(root.rglob('*')) + if p.is_file() and p.suffix in ('.py', '.cu', '.cuh') and '__pycache__' not in p.parts} + + +def production_sources(root): + root = Path(root) + paths = list((root/'cuvarbase').glob('*.py')) + paths += list((root/'cuvarbase/kernels').glob('*.cu')) + paths += list((root/'cuvarbase/kernels').glob('*.cuh')) + return {str(p.relative_to(root)): sha(p) for p in sorted(paths) if p.is_file()} + + +def put_masked(arrays, name, value): + value = np.ma.asarray(value) + arrays[name] = np.asarray(value.data).copy() + arrays[name+'_mask'] = np.ma.getmaskarray(value).copy() + + +def native_gpu_locals(local, arrays, prefix): + """Small logical-domain outputs retained from the actual native frame.""" + metadata = {} + for key in ('singleCalcPeriods', 'tSize', 'patchedDatasSize', 'maxDuration', 'TotalIter'): + if key in local: + metadata[key] = int(local[key]) + for source, target in [('durationsGridCollectionGPU', 'chunk_width_masks'), + ('fulldurationsMinGPU', 'minimum_width'), + ('fulldurationsMaxGPU', 'maximum_width'), + ('locationGPU', 'winning_local_flat_index')]: + if source in local: + arrays[prefix+target] = local[source].get() + if 'durations' in local: + arrays[prefix+'widths'] = np.asarray(local['durations']).copy() + if 'periods' in local: + arrays[prefix+'periods'] = np.asarray(local['periods']).copy() + return metadata + + +def normalize_native_winners(arrays, prefix, ndata, group_size, chi2): + """Decode a native chunk-local duration/start without reading undefined rows.""" + locations = arrays[prefix+'winning_local_flat_index'] + masks, widths = arrays[prefix+'chunk_width_masks'], arrays[prefix+'widths'] + start, width = np.full(len(chi2), -1, np.int32), np.full(len(chi2), -1, np.int32) + for i, value in enumerate(chi2): + eligible = widths[masks[i//group_size]] + if np.isfinite(value) and len(eligible): + local = int(locations[i]) + if not 0 <= local < len(eligible)*ndata: + raise ValueError('Native finite residual has invalid logical winner') + start[i], width[i] = local % ndata, eligible[local//ndata] + arrays[prefix+'winning_start'], arrays[prefix+'winning_width'] = start, width + + +def run_gtls(data, options, mode, auto_grid=False, case_search_kwargs=None, correction=False): + import cupy as cp + import gputls + from gputls import core, gtls, constants + + arrays, stages, refinements, final_fit = {}, [], [], {} + model = gtls(data['t'], data['y'], data['dy'], verbose=False) + for key in ('t', 'y', 'dy'): + arrays['prepared_'+key] = np.asarray(getattr(model, key)).copy() + original_spectra = core.spectra + previous_profile = sys.getprofile() + + def spectra_spy(chi2, oversampling_factor): + # Inspect actual locals at the point the first GPU residual vector + # has already reached the host, before full mode deletes its buffers. + caller = inspect.currentframe().f_back + stage = len(stages) + prefix = 'stage%d_' % stage + details = native_gpu_locals(caller.f_locals, arrays, prefix) if stage == 0 else {} + put_masked(arrays, prefix+'chi2', chi2) + result = original_spectra(chi2, oversampling_factor) + for name, value in zip(('SR', 'power_raw', 'power'), result[:3]): + put_masked(arrays, prefix+name, value) + if stage == 2 and 'period' in caller.f_locals: + prior = caller.f_locals['period'] + details['preceding_selected_period_masked'] = bool(np.ma.is_masked(prior)) + details['preceding_selected_period_finite'] = bool(not np.ma.is_masked(prior) and np.isfinite(prior)) + stages.append(dict(index=stage, SDE_raw=plain(result[3]), SDE=plain(result[4]), **details)) + for key in ('possiblePeriodsIndices', 'possiblePeriods', + 'possiblePeriodsIndices_multi', 'possiblePeriods_multi'): + if key in caller.f_locals: + arrays[prefix+key] = np.asarray(caller.f_locals[key]).copy() + return result + + def profile(frame, event, value): + if (event == 'return' and value is not None and frame.f_code.co_filename == core.__file__ and + frame.f_code.co_name == 'search_multi_periods_again'): + prefix = 'refinement%d_' % len(refinements) + details = native_gpu_locals(frame.f_locals, arrays, prefix) + if value is not None: + arrays[prefix+'chi2'] = np.asarray(value).copy() + refinements.append(details) + elif (event == 'return' and value is not None and frame.f_code.co_filename == core.__file__ and + frame.f_code.co_name == 'search_single_periods'): + local = frame.f_locals + location = int(local['bestLocation']) + arrays['final_chi2'] = local['lowestResidualsGPU'].ravel()[location:location+1].get() + arrays['final_winning_start'] = np.array([location % len(local['t'])], np.int32) + arrays['final_winning_width'] = np.array([local['durationPointsNum']], np.int32) + for key, item in zip(('fractional_duration', 'width_in_samples', 'duration', 'depth', 'T0', + 'transit_times', 'native_gtls_snr', 'native_gtls_snr_pink', + 'native_gtls_snrFit', 'native_gtls_snrFitPink'), value): + final_fit[key] = plain(item) + + core.spectra = spectra_spy + sys.setprofile(profile) + try: + kwargs = dict(case_search_kwargs or {}) + kwargs.update(options.get('gtls_kwargs', {})) + # These are the public reference's stellar-input validation bounds. + # Its duration cache and CUDA domain remain the pinned hardcoded + # defaults; accepting a supplied dense host does not widen those. + for parameter, default_min, default_max in ( + ('R_star', constants.R_STAR_MIN, constants.R_STAR_MAX), + ('M_star', constants.M_STAR_MIN, constants.M_STAR_MAX)): + value = kwargs.get(parameter, 1.) + kwargs.setdefault(parameter+'_min', min(default_min, value)) + kwargs.setdefault(parameter+'_max', max(default_max, value)) + kwargs.update(fast=mode == 'fast', verbose=False, show_progress_bar=False) + if correction: + import corrected_reference + with corrected_reference.apply(core) as correction_provenance: + result = model.power(periods=[] if auto_grid else data['periods'], **kwargs) + else: + correction_provenance = None + result = model.power(periods=[] if auto_grid else data['periods'], **kwargs) + finally: + core.spectra = original_spectra + sys.setprofile(previous_profile) + if not stages: + raise RuntimeError('GTLS did not produce a captured native spectrum') + if mode == 'fast': + periods, power = result + found = None + extra = {} + else: + periods, power, found = result.periods, result.power, result.period + extra = {key: plain(getattr(result, key)) for key in + ('duration', 'rawDuration', 'depth', 'T0', 'SDE', 'snr', 'snr_pink') + if hasattr(result, key)} + for key in ('possiblePeriodsIndices', 'possiblePeriods'): + if hasattr(result, key): + arrays[key] = np.asarray(getattr(result, key)).copy() + put_masked(arrays, 'periods', periods) + put_masked(arrays, 'power', power) + final = 'stage%d_' % (len(stages)-1) + arrays['chi2'], arrays['chi2_mask'] = arrays[final+'chi2'], arrays[final+'chi2_mask'] + arrays['coarse_chi2'], arrays['coarse_chi2_mask'] = arrays['stage0_chi2'], arrays['stage0_chi2_mask'] + normalize_native_winners(arrays, 'stage0_', len(model.t), stages[0]['singleCalcPeriods'], arrays['coarse_chi2']) + for i, details in enumerate(refinements): + normalize_native_winners(arrays, 'refinement%d_' % i, len(model.t), + details['singleCalcPeriods'], arrays['refinement%d_chi2' % i]) + if mode == 'full': + arrays['refinement_indices'] = arrays['stage1_possiblePeriodsIndices'].astype(np.int64) + arrays['harmonic_indices'] = arrays['stage2_possiblePeriodsIndices_multi'].astype(np.int64) + overview = np.asarray(model.lc_cache_overview) + widths, unique = np.unique(overview['width_in_samples'], return_index=True) + curves = [np.asarray(model.lc_arr[i]) for i in unique] + arrays['cache_overview'] = overview.copy() + arrays['cache_widths'] = widths.astype(np.int32) + arrays['cache_overshoot'] = np.asarray(overview['overshoot'][unique], dtype=np.float32) + arrays['cache_signal_lengths'] = np.array([len(c) for c in curves], dtype=np.int32) + arrays['cache_template_deficits'] = np.asarray( + 1-np.array([np.pad(c, (0, int(max(widths))-len(c)), 'constant') for c in curves]), + dtype=np.float32) + selected = np.where(~arrays['power_mask'], arrays['power'], np.nan) + global_index = int(np.nanargmax(selected)) if np.any(np.isfinite(selected)) else None + index = global_index if found is None else int(np.flatnonzero(arrays['periods'] == found)[0]) + if found is None and index is not None: + found = float(arrays['periods'][index]) + valid_input = np.ones(len(data['t']), bool) + for field in ('t', 'y', 'dy'): + valid_input &= np.isfinite(data[field]) & (data[field] > 0) + error_scale = float(np.mean(data['dy'][valid_input])) + return arrays, dict(primary_index=index, period=plain(found), + score=plain(stages[-1]['SDE']), global_power_primary_index=global_index, + error_scale=error_scale, + effective_search_kwargs=plain(kwargs), reference_correction=correction_provenance, + stages=stages, refinements=refinements, + group_size=stages[0].get('singleCalcPeriods'), extra=extra, final_fit=final_fit, + package_sources=sources(Path(gputls.__file__).parent), + reference_instrumentation='CPU spectra wrapper and refinement-return profile; CUDA source unchanged') + + +def run_candidate(data, options, mode, engine_root, reference_record, work_chunk, chunk_policy='replay', + engine_kind='public', auto_grid=False, case_search_kwargs=None): + sys.path.insert(0, str(engine_root)) + engine = importlib.import_module('cuvarbase.tls_reference') + function = getattr(engine, 'search_'+mode) + kwargs = dict(options.get('candidate_kwargs', {})) + kwargs.setdefault('work_chunk', work_chunk) + if reference_record is not None and chunk_policy == 'replay' and engine_kind != 'public': + kwargs['group_size'] = reference_record['result']['group_size'] + public = None + if engine_kind == 'public': + if chunk_policy != 'default': + raise ValueError('The public API must use its actual default logical-group policy') + public_api = importlib.import_module('cuvarbase.tls') + capture = [] + def runner_spy(*args, **options): + result = function(*args, **options) + capture.append(result) + return result + setattr(engine, 'search_'+mode, runner_spy) + try: + public_kwargs = dict(case_search_kwargs or {}) + public_kwargs.update(kwargs) + public = public_api.tls_search_gpu(data['t'], data['y'], data['dy'], + periods=None if auto_grid else data['periods'], full=mode == 'full', **public_kwargs) + finally: + setattr(engine, 'search_'+mode, function) + if len(capture) != 1: + raise RuntimeError('Public API did not invoke its default numerical engine exactly once') + result = capture[0] + else: + if auto_grid: + raise ValueError('Automatic-grid dispatch must be tested through the public API') + result = function(data['t'], data['y'], data['dy'], data['periods'], **kwargs) + arrays = {} + for key in ('t', 'y', 'dy'): + arrays['prepared_'+key] = np.asarray(result['prepared'][key]).copy() + for key in ('overview', 'widths', 'overshoot', 'signal_lengths', 'template_deficits'): + arrays['cache_'+key] = np.asarray(result['cache'][key]).copy() + put_masked(arrays, 'periods', np.ma.array(result['periods'], + mask=np.ma.getmaskarray(result['spectra']['chi2']))) + put_masked(arrays, 'power', result['spectra']['power']) + put_masked(arrays, 'chi2', result['spectra']['chi2']) + raw = result.get('coarse_raw', result['raw']) + arrays['coarse_chi2'] = np.asarray(raw['chi2']).copy() + arrays['coarse_chi2_mask'] = np.ma.getmaskarray(result.get('coarse_spectra', result['spectra'])['chi2']).copy() + for source, target in [('minima', 'minimum_width'), ('maxima', 'maximum_width'), + ('width_masks', 'chunk_width_masks'), ('start', 'winning_start'), + ('width', 'winning_width')]: + if source in raw: + arrays['stage0_'+target] = np.asarray(raw[source]).copy() + arrays['stage0_widths'] = arrays['cache_widths'].copy() + arrays['stage0_periods'] = arrays['periods'].copy() + for key, target in (('candidates', 'refinement_indices'), ('harmonics', 'harmonic_indices')): + if key in result: + arrays[target] = np.asarray(result[key], dtype=np.int64).copy() + for i, key in enumerate(('refined', 'harmonic_results')): + if key in result: + refined = result[key] + for source, target in (('chi2', 'chi2'), ('start', 'winning_start'), ('width', 'winning_width'), + ('minima', 'minimum_width'), ('maxima', 'maximum_width'), + ('width_masks', 'chunk_width_masks')): + arrays['refinement%d_%s' % (i, target)] = np.asarray(refined[source]).copy() + final_fit = {} + if 'final' in result: + final = result['final'] + arrays['final_chi2'] = np.asarray(final['chi2']).copy() + arrays['final_winning_start'] = np.asarray(final['start']).copy() + arrays['final_winning_width'] = np.asarray(final['width']).copy() + final_fit = engine.reference.final_parameters( + result['prepared']['t'], result['prepared']['y'], result['prepared']['dy'], + result['period'], result['cache'], int(final['width_index'][0]), int(final['start'][0]), + fit_chi2=float(final['chi2'][0]), error_scale=result['prepared']['error_scale']) + history = result.get('spectra_history', [result['spectra']]) + for i, stage in enumerate(history): + for key in ('chi2', 'SR', 'power_raw', 'power'): + if key in stage: + put_masked(arrays, 'stage%d_%s' % (i, key), stage[key]) + public_contract = None + if public is not None: + public_order = np.argsort(public['periods']) if 'periods' in public else None + for key in ('periods', 'chi2', 'power', 'SR', 'valid_periods'): + if key in public: + arrays['public_'+key] = np.asarray(public[key])[public_order].copy() + public_contract = {key: plain(public.get(key)) for key in + ('period', 'T0', 'duration', 'depth', 'fractional_duration', 'SDE', 'SDE_raw', 'SNR', + 'chi2_min', 'chi2_null', 'native_gtls_snr', 'search_configuration')} + index = result.get('primary_index', result['spectra']['primary_index']) + score = result['spectra']['SDE'] + return arrays, dict(primary_index=index, period=plain(result['period']), score=plain(score), + global_power_primary_index=result['spectra']['primary_index'], final_fit=plain(final_fit), + public_contract=public_contract, + error_scale=float(result['prepared']['error_scale']), + public_capture='Actual public API; selected numerical runner wrapped only to retain its unchanged return value' if public else None, + group_size=int(raw['group_size']), work_chunk=int(raw['work_chunk']), + stages=[dict(index=i, SDE=plain(s.get('SDE'))) for i, s in enumerate(history)]) + + +def run(args): + import cupy as cp + input_path = args.case.resolve() + with np.load(input_path, allow_pickle=False) as source: + data = {key: source[key] for key in source.files if key != 'metadata'} + metadata = json.loads(str(source['metadata'])) + positive_origin = getattr(args, 'positive_origin', False) + engine_kind = getattr(args, 'engine_kind', 'public') + auto_grid = getattr(args, 'auto_grid', False) + if positive_origin: + finite_times = data['t'][np.isfinite(data['t'])] + if not len(finite_times): + raise ValueError('Common positive-origin execution requires at least one finite time') + shift = float(np.floor(np.min(finite_times))-1.) + data['t'] = data['t']-shift + metadata = dict(metadata, execution_time_shift_days=shift, + truth_epoch=metadata['truth_epoch']-shift, + execution_preprocessing='Same positive-origin arrays supplied to native and candidate') + metadata = dict(metadata, auto_grid_api_exercised=auto_grid) + if getattr(args, 'replay', False): + metadata = dict(metadata, replayed_original_cohort=metadata.get('cohort'), cohort='reproduction', + interpretation='Reproduction of frozen inputs; never a new independent holdout') + options = json.loads(args.options.read_text()) if args.options else {} + seal = json.loads(args.seal.read_text()) if args.seal else None + if getattr(args, 'replay', False) and seal is not None: + if (options != seal['options'] or args.mode not in seal['modes'] or + engine_kind != seal['engine_kind'] or args.chunk_policy != seal['chunk_policy'] or + auto_grid != seal['auto_grid']): + raise ValueError('Reproduction options differ from the published experiment') + if production_sources(args.engine_root) != seal['source_identity']['production_sources']: + raise ValueError('Production source differs from the published seal; use the recorded source snapshot/revision') + if metadata.get('cohort') == 'heldout': + if seal is None or metadata.get('seal_sha256') != sha(args.seal): + raise ValueError('Heldout execution requires the exact seal used to generate inputs') + if args.mode not in seal['modes'] or options != seal['options']: + raise ValueError('Heldout mode/options differ from the frozen protocol') + if (engine_kind != seal.get('engine_kind') or args.chunk_policy != seal.get('chunk_policy') or + auto_grid != seal.get('auto_grid')): + raise ValueError('Heldout API/group/grid mode differs from the frozen protocol') + identity = seal['source_identity'] + if sha(__file__) != identity['harness_sha256']: + raise ValueError('Harness changed after holdout freeze') + for name, expected_hash in identity['validation_sources'].items(): + if sha(Path(__file__).parent/name) != expected_hash: + raise ValueError('Validation source changed after freeze: '+name) + if args.backend == 'gtls_corrected' and seal.get('reference_correction') != 'finite_candidates_before_ranking_v1': + raise ValueError('Corrected reference is not declared in this seal') + actual_sources = production_sources(args.engine_root) + frozen_sources = identity['production_sources'] + if actual_sources != frozen_sources: + raise ValueError('Candidate numerical source changed after holdout freeze') + reference = json.loads(args.reference_record.read_text()) if args.reference_record else None + if reference and reference['input_sha256'] != sha(input_path): + raise ValueError('Reference input differs from candidate input') + source_getter = production_sources + identity = dict(input_sha256=sha(input_path), input_metadata=metadata, + input_arrays={key: array_hash(value) for key, value in data.items()}, + backend=args.backend, mode=args.mode, options=options, chunk_policy=args.chunk_policy, + engine_kind=engine_kind, positive_origin=positive_origin, auto_grid=auto_grid, + seal_sha256=sha(args.seal) if args.seal else None, + harness_sha256=sha(__file__), reference_record_sha256=sha(args.reference_record) if reference else None, + engine_sources=source_getter(args.engine_root) if args.backend == 'candidate' else None) + if reference and reference.get('input_arrays') != identity['input_arrays']: + raise ValueError('Actual execution arrays differ after input preprocessing') + if args.out.exists(): + raise ValueError('Output exists; refuse to overwrite: '+str(args.out)) + args.out.mkdir(parents=True) + write(args.out/'record.json', dict(identity, status='running')) + # A fresh pool avoids a previous case's retained temporary buffers + # silently changing the reference's duration-union group size. + cp.get_default_memory_pool().free_all_blocks() + cp.cuda.runtime.deviceSynchronize() + free_before, total_memory = cp.cuda.runtime.memGetInfo() + start = time.perf_counter() + caught = [] + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + if args.backend in ('gtls', 'gtls_corrected'): + arrays, result = run_gtls(data, options, args.mode, auto_grid, + metadata.get('search_kwargs'), correction=args.backend == 'gtls_corrected') + if seal is not None and result['package_sources'] != seal['reference_package_sources']: + raise ValueError('Native GTLS source differs from the frozen reference package') + else: + arrays, result = run_candidate(data, options, args.mode, args.engine_root, + reference, args.work_chunk, args.chunk_policy, + engine_kind, auto_grid, metadata.get('search_kwargs')) + cp.cuda.runtime.deviceSynchronize() + elapsed = time.perf_counter()-start + path = args.out/'arrays.npz' + np.savez_compressed(path, **arrays) + record = dict(identity, status='ok', result=result, elapsed_seconds=elapsed, + free_memory_before=int(free_before), total_memory=int(total_memory), + arrays_file='arrays.npz', arrays_sha256=sha(path), + arrays={key: dict(sha256=array_hash(value), dtype=str(value.dtype), shape=value.shape) + for key, value in arrays.items()}, + warnings=sorted(set(str(v.message) for v in caught))) + except Exception: + record = dict(identity, status='error', elapsed_seconds=time.perf_counter()-start, + free_memory_before=int(free_before), total_memory=int(total_memory), + error=traceback.format_exc(), warnings=sorted(set(str(v.message) for v in caught))) + if args.backend == 'candidate' and record['engine_sources'] != source_getter(args.engine_root): + record.update(status='error', error='Candidate source files changed during execution') + write(args.out/'record.json', record) + print(json.dumps(dict(status=record['status'], output=str(args.out), elapsed_seconds=record['elapsed_seconds']))) + if record['status'] != 'ok': + raise RuntimeError(record['error']) + + +def compare_array(a, b, atol, rtol): + if a.shape != b.shape: + return dict(shape_equal=False, passed=False, shape_reference=a.shape, shape_candidate=b.shape) + if a.dtype.names or a.dtype.kind not in 'biufc' or b.dtype.kind not in 'biufc': + equal = bool(np.array_equal(a, b)) + return dict(shape_equal=True, dtype_equal=a.dtype == b.dtype, bitwise=equal, passed=equal) + finite_a, finite_b = np.isfinite(a), np.isfinite(b) + classifications = bool(np.array_equal(np.isnan(a), np.isnan(b)) and + np.array_equal(np.isposinf(a), np.isposinf(b)) and + np.array_equal(np.isneginf(a), np.isneginf(b))) + take = finite_a & finite_b + error = np.abs(a[take].astype(np.float64)-b[take].astype(np.float64)) + allowance = atol+rtol*np.abs(a[take].astype(np.float64)) + bitwise = a.dtype == b.dtype and a.tobytes() == b.tobytes() + return dict(shape_equal=True, dtype_equal=a.dtype == b.dtype, bitwise=bitwise, + same_nonfinite_classification=classifications, + max_abs_error=float(error.max()) if len(error) else 0., + changed_finite_cells=int(np.sum(error != 0)), compared_finite_cells=int(take.sum()), + outside_tolerance=int(np.sum(error > allowance)), + passed=classifications and bool(np.all(error <= allowance))) + + +def compare(args): + rpath, cpath = args.reference, args.candidate + reference, candidate = (json.loads(p.read_text()) for p in (rpath, cpath)) + if reference['input_sha256'] != candidate['input_sha256'] or reference['mode'] != candidate['mode']: + raise ValueError('Different inputs or GTLS modes cannot be parity-compared') + if reference.get('input_arrays') != candidate.get('input_arrays'): + raise ValueError('Different actual arrays cannot be parity-compared') + if reference['status'] != 'ok' or candidate['status'] != 'ok': + write(args.out, dict(status='execution_failure', reference_status=reference['status'], + candidate_status=candidate['status'], passed=False, + reference_failure_extension=reference['status'] == 'error' and candidate['status'] == 'ok', + warning='Reference failures and candidate extensions do not establish numerical parity.')) + return + for path, record in ((rpath, reference), (cpath, candidate)): + if sha(path.parent/record['arrays_file']) != record['arrays_sha256']: + raise ValueError('Result arrays hash mismatch') + gates = json.loads(args.gates.read_text()) if args.gates else {} + exact = ('periods', 'periods_mask', 'prepared_t', 'prepared_y', 'prepared_dy', + 'cache_widths', 'cache_template_deficits', 'cache_overshoot', 'cache_signal_lengths', + 'chi2_mask', 'power_mask', 'coarse_chi2_mask', 'stage0_minimum_width', + 'stage0_maximum_width', 'stage0_chunk_width_masks', 'stage0_winning_start', 'stage0_winning_width') + numeric = ('coarse_chi2', 'chi2', 'power') + if reference['mode'] == 'full': + exact += ('refinement_indices', 'harmonic_indices', 'final_winning_start', 'final_winning_width') + numeric += ('final_chi2',) + for i in range(2): + exact += tuple('refinement%d_%s' % (i, field) for field in + ('winning_start', 'winning_width', 'minimum_width', 'maximum_width', 'chunk_width_masks')) + numeric += ('refinement%d_chi2' % i,) + checks, public_checks = {}, {} + with np.load( + rpath.parent/reference['arrays_file'], allow_pickle=False) as r, np.load( + cpath.parent/candidate['arrays_file'], allow_pickle=False) as c: + for name in exact+numeric: + if name not in r or name not in c: + checks[name] = dict(passed=False, missing_reference=name not in r, missing_candidate=name not in c) + continue + gate = {} if name in exact else gates.get(name, gates.get('chi2', {}) if name.endswith('chi2') else {}) + checks[name] = compare_array(r[name], c[name], float(gate.get('atol', 0)), float(gate.get('rtol', 0))) + # Full-stage evidence must be explicitly present. Matching only the + # final maximum cannot substitute for matching candidate/refinement paths. + stage_counts = (len(reference['result']['stages']), len(candidate['result']['stages'])) + for i in range(stage_counts[0]): + for field in ('chi2', 'chi2_mask', 'power', 'power_mask'): + name = 'stage%d_%s' % (i, field) + if name in r and name in c: + gate = gates.get('chi2' if field.startswith('chi2') else 'power', {}) + checks[name] = compare_array(r[name], c[name], float(gate.get('atol', 0)), float(gate.get('rtol', 0))) + else: + checks[name] = dict(passed=False, missing_reference=name not in r, missing_candidate=name not in c) + rp = np.where(r['power_mask'], np.nan, r['power']).astype(float) + cp = np.where(c['power_mask'], np.nan, c['power']).astype(float) + finite = np.isfinite(rp) & np.isfinite(cp) + epsilon = float(np.max(np.abs(rp[finite]-cp[finite]))) if finite.any() else 0. + rid, cid = reference['result']['primary_index'], candidate['result']['primary_index'] + ordered = np.sort(rp[np.isfinite(rp)]) + margin = float(ordered[-1]-ordered[-2]) if len(ordered) >= 2 else None + ranking = dict(reference_index=rid, candidate_index=cid, same_primary=rid == cid, + reference_period=reference['result']['period'], candidate_period=candidate['result']['period'], + same_period=reference['result']['period'] == candidate['result']['period'], + reference_margin=margin, max_power_error=epsilon, + changed_within_error_band=rid != cid and margin is not None and margin <= 2*epsilon) + thresholds = [] + for threshold in args.threshold: + rs, cs = reference['result']['score'], candidate['result']['score'] + rd = rs is not None and rs > threshold + cd = cs is not None and cs > threshold + thresholds.append(dict(threshold=threshold, reference_above=rd, candidate_above=cd, + same_decision=rd == cd, + reference_margin=None if rs is None else abs(rs-threshold))) + if candidate.get('engine_kind') == 'public': + scale = reference['result']['error_scale'] + expected = dict(periods=r['periods'], + chi2=np.where(r['chi2_mask'], np.nan, r['chi2']).astype(np.float64)/scale**2, + power=np.where(r['power_mask'], np.nan, r['power']), + SR=np.where(r['stage%d_SR_mask' % (stage_counts[0]-1)], np.nan, + r['stage%d_SR' % (stage_counts[0]-1)]), + valid_periods=np.isfinite(r['chi2']) & ~r['chi2_mask']) + for field, value in expected.items(): + name = 'public_'+field + if name not in c: + public_checks[field] = dict(passed=False, missing_candidate=True) + continue + gate = gates.get(name, {}) + public_checks[field] = compare_array(np.asarray(value), c[name], + float(gate.get('atol', 0)), float(gate.get('rtol', 0))) + contract = candidate['result'].get('public_contract') or {} + final = reference['result'].get('final_fit', {}) + for key, value in dict(period=reference['result']['period'], SDE=reference['result']['score'], + **{k: v for k, v in final.items() if k in + ('T0', 'duration', 'depth', 'fractional_duration', 'native_gtls_snr')}).items(): + if key not in contract: + public_checks[key] = dict(passed=False, missing_candidate=True) + elif value is None or contract[key] is None: + public_checks[key] = dict(passed=value is None and contract[key] is None, + nonfinite_or_missing=True) + else: + gate = gates.get('public_fit', {}) + public_checks[key] = compare_array(np.atleast_1d(value), np.atleast_1d(contract[key]), + float(gate.get('atol', 0)), float(gate.get('rtol', 0))) + configuration = contract.get('search_configuration') or {} + public_checks['standard_engine'] = dict(passed=configuration.get('method') == 'reference' and + configuration.get('phase_binning') is False and configuration.get('samples_used') == len(r['prepared_t']) and + configuration.get('input_count') == len(r['prepared_t']) and configuration.get('time_origin') == 0.) + final_checks = {} + if reference['mode'] == 'full': + for key in ('fractional_duration', 'width_in_samples', 'duration', 'depth', 'T0', 'transit_times', 'native_gtls_snr'): + fits = [record['result'].get('final_fit', {}) for record in (reference, candidate)] + if any(key not in fit for fit in fits): + final_checks[key] = dict(passed=False, missing_reference=key not in fits[0], missing_candidate=key not in fits[1]) + continue + rvalue, cvalue = (fit[key] for fit in fits) + if rvalue is None or cvalue is None: + final_checks[key] = dict(passed=rvalue is None and cvalue is None, nonfinite_or_missing=True) + else: + gate = gates.get('final_fit', {}) + final_checks[key] = compare_array(np.atleast_1d(rvalue), np.atleast_1d(cvalue), + float(gate.get('atol', 0)), float(gate.get('rtol', 0))) + numeric_passed = (all(v['passed'] for v in checks.values()) and all(v['passed'] for v in final_checks.values()) and + all(v['passed'] for v in public_checks.values()) and stage_counts[0] == stage_counts[1]) + decisions_passed = ranking['same_primary'] and ranking['same_period'] and all(v['same_decision'] for v in thresholds) + passed = numeric_passed and decisions_passed + write(args.out, dict(status='compared', passed=passed, numeric_passed=numeric_passed, + decisions_passed=decisions_passed, checks=checks, final_checks=final_checks, + public_checks=public_checks, + stage_counts=stage_counts, ranking=ranking, thresholds=thresholds, + reference_record_sha256=sha(rpath), candidate_record_sha256=sha(cpath), + gates=gates, input_metadata=reference['input_metadata'], + warning='Numeric parity is distinct from a statistically established recovery margin. Near-tie outcome disagreements are retained.')) + print(json.dumps(dict(passed=passed, same_primary=ranking['same_primary'], output=str(args.out)))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + run_parser = sub.add_parser('run') + run_parser.add_argument('--case', type=Path, required=True) + run_parser.add_argument('--backend', choices=('gtls', 'gtls_corrected', 'candidate'), required=True) + run_parser.add_argument('--mode', choices=('fast', 'full'), default='full') + run_parser.add_argument('--engine-root', type=Path, required=True) + run_parser.add_argument('--engine-kind', choices=('production', 'public'), default='public') + run_parser.add_argument('--positive-origin', action='store_true', help='Apply the same positive-origin time shift before either backend.') + run_parser.add_argument('--auto-grid', action='store_true', help='Exercise both public APIs automatic period-grid generation.') + run_parser.add_argument('--reference-record', type=Path) + run_parser.add_argument('--options', type=Path) + run_parser.add_argument('--replay', action='store_true', help='Rerun published frozen inputs as reproduction, preserving original seed/source metadata.') + run_parser.add_argument('--seal', type=Path, help='Required for frozen heldout inputs.') + run_parser.add_argument('--work-chunk', type=int, default=256) + run_parser.add_argument('--chunk-policy', choices=('replay', 'default'), default='default', + help='Replay actual native group size, or independently test candidate default grouping.') + run_parser.add_argument('--out', type=Path, required=True) + compare_parser = sub.add_parser('compare') + compare_parser.add_argument('--reference', type=Path, required=True) + compare_parser.add_argument('--candidate', type=Path, required=True) + compare_parser.add_argument('--gates', type=Path, help='Pre-frozen per-array atol/rtol JSON; omitted means exact numerical equality.') + compare_parser.add_argument('--threshold', type=float, action='append', default=[]) + compare_parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + (run if args.command == 'run' else compare)(args) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/README.md b/benchmarks/tls_sensitivity/README.md new file mode 100644 index 00000000..9ecb1da3 --- /dev/null +++ b/benchmarks/tls_sensitivity/README.md @@ -0,0 +1,66 @@ +# Binned TLS sensitivity study (2026-09-09) + +These tools reproduce the dated comparison of three frozen **binned** cuvarbase TLS configurations with public GTLS `fast=True` on identical synthetic lightcurves and trial periods. Observing times, relative uncertainties and exposures come from the earlier TESS and ZTF cadence examples. The binned engine is now an explicit option, `method='binned'`; this experiment does not measure the standard observation-level TLS engine. See the [current benchmark and validation](../../docs/TRANSIT_BENCHMARKS.md). + +The experiment separates null calibration, independent recovery evaluation and exclusive GPU timing. Distributed recovery-run durations are diagnostic and never supply the published speed ratios. No tool here creates cloud resources or needs cloud credentials. + +| Tool | Purpose | +|---|---| +| `generate.py` | Generate a reproducible 128-case cohort shard from a frozen cadence archive | +| `run.py` | Search the shard through the shared [public-API adapter](../transit/worker.py), retaining all scalar outcomes and output hashes | +| `analyze.py` | Freeze null thresholds, then evaluate the nine predeclared recovery/false-positive comparisons | +| `timing.py`, `analyze_timings.py` | Measure and verify five repetitions of isolated single-source and 16-source searches | +| `bls_control.py` | Analyze the secondary box-search control on the same period-restricted inputs | +| `archive.py` | Verify input/sample-spectrum bytes and export lossless compact scalar evidence | +| `binning.py`, `plot_binning.py` | Isolate the SNR cost of phase compression at known ephemerides and illustrate the template | +| `hatpi_cost.py` | Historical binned-TLS cost pilot on a synthetic HATPI-like season and five-minute time averages; it establishes neither HATPI sensitivity nor the cost of the new default | + +Run commands from the repository root. Analysis of compact evidence needs NumPy and SciPy; plotting also needs Matplotlib. Cohort generation and the binning diagnostic need batman-package and Numba. GPU workers additionally need the measured CUDA/software environment and pinned numerical packages. The library's general Python support range is separate from the experiment's Python 3.11 environment. + +Given a result directory containing `evidence/`, `cadences/`, `configs/`, and the published analysis files, recompute into a scratch directory: + +```bash +python benchmarks/tls_sensitivity/analyze.py \ + --root RESULT/evidence --configs RESULT/configs \ + --thresholds /tmp/tls-thresholds.json --calibrate +python benchmarks/tls_sensitivity/analyze.py \ + --root RESULT/evidence --configs RESULT/configs \ + --thresholds /tmp/tls-thresholds.json --out /tmp/tls-recovery.json +``` + +Replace `RESULT` with the experiment directory. The analyzer refuses to overwrite a differing frozen threshold or analysis file. It validates case counts and IDs, paired input hashes, configuration identity and installed numerical-source identity. The same interface accepts full per-shard measurement directories instead of compact evidence. + +Generate and search a new shard in an environment with the pinned packages: + +```bash +python benchmarks/tls_sensitivity/generate.py \ + --cadence RESULT/cadences/tess_200s.npz --profile tess_200s \ + --split calibration --start 0 --count 128 --out /tmp/tess-input.npz +python benchmarks/tls_sensitivity/run.py \ + --input /tmp/tess-input.npz --config RESULT/configs/v1_original.json \ + --out /tmp/tess-search +``` + +Seeds include profile, split and global case index, so changing the shard size or machine assignment preserves the case definition. A fresh numerical environment can introduce floating-point differences; compare recorded per-array hashes before treating regenerated observations as byte-identical. `v1_resolved` is the intermediate configuration's file identifier, not a claim that its resolution is exact. + +Recreate the explanatory figure from the pinned template implementation: + +```bash +python benchmarks/tls_sensitivity/plot_binning.py \ + --template-source cuvarbase/tls_models.py --out /tmp/tls-phase-binning +``` + +Timing requires an otherwise idle GPU. `timing.py` uses the earlier experiment's `*_heldout.npz` files solely as fixed timing inputs, with eight injections and eight nulls. It isolates each configuration in a process, warms the workload, randomizes configuration order and records process intervals. Initial API calls are reported separately; caches may already be populated on disk. GTLS single-source latency uses one worker and is contextual when the sensitivity study uses a concurrent batch policy. + +Verify timing arithmetic, source identity and the actual committed input arrays: + +```bash +python benchmarks/tls_sensitivity/analyze_timings.py \ + --timing RESULT/timing \ + --inputs benchmarks/results/transit_2026-09-08/inputs \ + --cadences RESULT/cadences --configs RESULT/configs \ + --thresholds RESULT/thresholds.json \ + --recovery RESULT/recovery_analysis.json --out /tmp/tls-timing.json +``` + +`archive.py` preserves all scalar results, input/source/spectrum hashes and original summary hashes, while deduplicating common truth and source maps. It verifies all input arrays and the retained first-four-case spectra in each shard before export. The compact archive does not contain the full prepared observations or sampled periodograms: its receipt records the original verification, and its hashes allow later checks against the larger measurement archive. Re-running analysis from compact evidence verifies the summaries, not those omitted bytes. diff --git a/benchmarks/tls_sensitivity/analyze.py b/benchmarks/tls_sensitivity/analyze.py new file mode 100644 index 00000000..15cfc9a3 --- /dev/null +++ b/benchmarks/tls_sensitivity/analyze.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Calibrate, then analyze the independent TLS sensitivity experiment. + +The primary simultaneous family contains 27 one-sided bounds: nine recall +lower bounds and both false-positive bounds for nine comparisons. Marginal +Wilson intervals and diagnostic contrasts are labeled separately. +""" +import argparse +import hashlib +import json +from pathlib import Path + +import numpy as np +from scipy.stats import beta + +from archive import records as shard_records + + +PROFILES = ('tess_200s', 'tess_gap', 'ztf') +V1 = ('v1_original', 'v1_resolved', 'v1_fine') +COUNTS = dict(calibration=4096, injections=2048, nulls=4096) + + +def write(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + text = json.dumps(value, indent=2, allow_nan=False) + '\n' + if path.exists() and path.read_text() != text: + raise ValueError(f'Refusing to overwrite a different frozen analysis: {path}') + path.write_text(text) + + +def valid(c): + return c.get('api_result_valid', False) and not c.get('error') and c.get('finite_fraction', 0) > 0 + + +def score(c): + return float(c['score']) if valid(c) and c.get('score') is not None and np.isfinite(c['score']) else -np.inf + + +def wilson(k, n, z=1.959963984540054): + p = k / n + den = 1 + z*z/n + center = (p + z*z/(2*n)) / den + half = z * np.sqrt(p*(1-p)/n + z*z/(4*n*n)) / den + return [float(max(0, center-half)), float(min(1, center+half))] + + +def paired(a, b, alpha=.05): + """Each bound separately covers the difference with >=1-alpha probability. + + D = Pr(A only) - Pr(B only). Bound the two discordant multinomial cells + with one-sided Clopper-Pearson bounds at alpha/2, then subtract. Their + Bonferroni coverage does not assume independence between the cells. + """ + a, b = np.asarray(a, bool), np.asarray(b, bool) + if a.shape != b.shape or not len(a): + raise ValueError('Paired vectors must have equal nonzero length') + n = len(a) + wins, losses = int(np.sum(a & ~b)), int(np.sum(~a & b)) + def lo(k): + return float(beta.ppf(alpha/2, k, n-k+1)) if k else 0. + def hi(k): + return float(beta.ppf(1-alpha/2, k+1, n-k)) if k < n else 1. + return dict(n=n, a_only=wins, b_only=losses, difference=float(a.mean()-b.mean()), + lower=lo(wins)-hi(losses), upper=hi(wins)-lo(losses), alpha_per_bound=alpha) + + +def load(root, configs, splits, v1_methods=V1): + groups = {} + signatures = {} + for profile in PROFILES: + for method in (*v1_methods, f'gtls_{profile}'): + wanted = json.loads((configs / f'{method}.json').read_text()) + for split in splits: + records = [] + for path, record in shard_records(root, profile, split, method): + if record['config'] != wanted: + raise ValueError(f'Wrong execution configuration: {path}') + if record['status'] != 'ok' or record['count'] != 128 or len(record['cases']) != 128: + raise ValueError(f'Incomplete shard: {path}') + module = 'gputls' if method.startswith('gtls') else 'cuvarbase' + signature = json.dumps(record['installed_sources'][module], sort_keys=True) + if module in signatures and signatures[module] != signature: + raise ValueError(f'Installed {module} source bytes differ: {path}') + signatures[module] = signature + records.extend(record['cases']) + records.sort(key=lambda c: c['global_index']) + if [c['global_index'] for c in records] != list(range(COUNTS[split])): + raise ValueError(f'Missing or duplicated cases: {profile}/{method}/{split} ({len(records)})') + groups[profile, method, split] = records + # Every method must have received the same prepared observations. + for split in splits: + base = groups[profile, v1_methods[0], split] + for method in (*v1_methods[1:], f'gtls_{profile}'): + for a, b in zip(base, groups[profile, method, split]): + for key in ('seed', 'array_sha256', 'period', 'duration', 'injected'): + if a[key] != b[key]: + raise ValueError(f'Input mismatch: {profile}/{method}/{split}/{a["global_index"]}/{key}') + return groups, {k: hashlib.sha256(v.encode()).hexdigest() for k, v in signatures.items()} + + +def calibrate(groups): + thresholds = {} + for (profile, method, split), cases in groups.items(): + if split != 'calibration' or any(c['injected'] for c in cases): + raise ValueError('Calibration requires exclusively independent nulls') + threshold = float(np.quantile([score(c) for c in cases], .95, method='higher')) + if not np.isfinite(threshold): + raise ValueError('Nonfinite calibration threshold') + digest = hashlib.sha256(json.dumps([(c['global_index'], c.get('score'), valid(c), c['array_sha256']) + for c in cases], sort_keys=True).encode()).hexdigest() + thresholds[f'{profile}/{method}'] = dict(threshold=threshold, n=len(cases), + invalid=sum(not valid(c) for c in cases), + calibration_digest=digest) + return thresholds + + +def analyze(groups, thresholds): + methods, comparisons, diagnostics = [], [], [] + vectors = {} + for profile in PROFILES: + for method in (*V1, f'gtls_{profile}'): + threshold = thresholds[f'{profile}/{method}']['threshold'] + injections, nulls = (groups[profile, method, s] for s in ('injections', 'nulls')) + recovered = np.array([valid(c) and c['recovered'] for c in injections], bool) + detection = recovered & np.array([score(c) > threshold for c in injections]) + alias_detection = np.array([valid(c) and c['alias_recovered'] and score(c) > threshold + for c in injections], bool) + fp = np.array([score(c) > threshold for c in nulls]) + vectors[profile, method] = detection, fp + snr_rows = [] + for snr in (6., 8., 10., 14.): + take = np.array([c['target_white_oracle_snr'] == snr for c in injections]) + n, k = int(take.sum()), int(detection[take].sum()) + assert n == 512 + snr_rows.append(dict(snr=snr, n=n, detected=k, recall=k/n, marginal_wilson_95=wilson(k,n))) + methods.append(dict(profile=profile, method=method, threshold=threshold, + n_injections=len(injections), n_nulls=len(nulls), + detected=int(detection.sum()), period_recovered=int(recovered.sum()), + alias_detected=int(alias_detection.sum()), + false_positives=int(fp.sum()), recall=float(detection.mean()), fpr=float(fp.mean()), + recall_marginal_wilson_95=wilson(int(detection.sum()),len(injections)), + fpr_marginal_wilson_95=wilson(int(fp.sum()),len(nulls)), by_snr=snr_rows, + invalid_injections=sum(not valid(c) for c in injections), + invalid_nulls=sum(not valid(c) for c in nulls), + partial_injections=sum(valid(c) and c['finite_fraction'] < 1 for c in injections), + partial_nulls=sum(valid(c) and c['finite_fraction'] < 1 for c in nulls))) + gtls_d, gtls_fp = vectors[profile, f'gtls_{profile}'] + for method in V1: + detection, fp = vectors[profile, method] + recall_bound = paired(detection, gtls_d, .05/27) + fpr_bounds = paired(fp, gtls_fp, .05/27) + comparisons.append(dict(profile=profile, method=method, + recall_difference=recall_bound['difference'], + recall_lower_simultaneous_95=recall_bound['lower'], + fpr_difference=fpr_bounds['difference'], + fpr_lower_simultaneous_95=fpr_bounds['lower'], + fpr_upper_simultaneous_95=fpr_bounds['upper'], + recall_nominal_one_sided_bounds=paired(detection, gtls_d), + fpr_nominal_one_sided_bounds=paired(fp, gtls_fp), + supported=recall_bound['lower'] > -.05 and fpr_bounds['lower'] > -.02 and fpr_bounds['upper'] < .02)) + fine_d, fine_fp = vectors[profile, 'v1_fine'] + for method in V1[:2]: + detection, fp = vectors[profile, method] + diagnostics.append(dict(profile=profile, method=method, reference='v1_fine', + recall_nominal_one_sided_bounds=paired(detection, fine_d), + fpr_nominal_one_sided_bounds=paired(fp, fine_fp))) + return dict(methods=methods, comparisons=comparisons, resolution_diagnostics=diagnostics) + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--root', type=Path, required=True) + ap.add_argument('--configs', type=Path, required=True) + ap.add_argument('--thresholds', type=Path, required=True) + ap.add_argument('--calibrate', action='store_true') + ap.add_argument('--out', type=Path) + a = ap.parse_args() + if a.calibrate: + groups, signatures = load(a.root, a.configs, ('calibration',)) + write(a.thresholds, dict(thresholds=calibrate(groups), source_signatures=signatures, + quantile=.95, method='higher', decision='strict exceedance')) + else: + if a.out is None: + ap.error('--out is required for held-out analysis') + frozen = json.loads(a.thresholds.read_text()) + groups, signatures = load(a.root, a.configs, ('injections', 'nulls')) + if signatures != frozen['source_signatures']: + raise ValueError('Installed sources changed after calibration') + write(a.out, analyze(groups, frozen['thresholds'])) diff --git a/benchmarks/tls_sensitivity/analyze_timings.py b/benchmarks/tls_sensitivity/analyze_timings.py new file mode 100644 index 00000000..a006ebd1 --- /dev/null +++ b/benchmarks/tls_sensitivity/analyze_timings.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Verify exclusive TLS timings and select the fastest supported frozen setting.""" +import argparse +import csv +import hashlib +import json +from pathlib import Path + +import numpy as np + +from analyze import PROFILES, V1, write + + +def array_hash(value): + value = np.ascontiguousarray(value) + h = hashlib.sha256() + h.update(value.dtype.str.encode()) + h.update(json.dumps(value.shape).encode()) + h.update(value.tobytes()) + return h.hexdigest() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--timing', type=Path, required=True) + ap.add_argument('--inputs', type=Path, required=True, + help='Earlier *_heldout.npz files used for exclusive timing') + ap.add_argument('--cadences', type=Path, required=True) + ap.add_argument('--configs', type=Path, required=True) + ap.add_argument('--thresholds', type=Path, required=True) + ap.add_argument('--recovery', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + a = ap.parse_args() + signatures = json.loads(a.thresholds.read_text())['source_signatures'] + recovery = json.loads(a.recovery.read_text()) + criteria = {(r['profile'], r['method']): r for r in recovery['comparisons']} + plan = json.loads((a.timing / 'plan.json').read_text()) + statuses = json.loads((a.timing / 'status.json').read_text()) + if [s['job'] for s in statuses] != plan['order'] or any(s['exit_code'] for s in statuses): + raise ValueError('Incomplete or failed timing configuration') + for previous, following in zip(statuses[:-1], statuses[1:]): + if previous['finished_epoch'] > following['started_epoch']: + raise ValueError('Timing processes overlapped') + expected = {f'{p}/{m}/{s}' for p in PROFILES for m in (*V1, f'gtls_{p}') + for s in ('single', 'batch16')} + if set(plan['order']) != expected or len(plan['order']) != len(expected): + raise ValueError('Unexpected or duplicated timing jobs') + rows, references = [], {} + for job in plan['order']: + p, m, mode = job.split('/') + record = json.loads((a.timing / job / 'summary.json').read_text()) + if (record['status'] != 'ok' or record['n_repetitions'] != 5 or record['n_sources'] != 16 + or [r['rep'] for r in record['repetitions']] != list(range(5))): + raise ValueError('Timing was not five valid repetitions on sixteen sources') + wanted = json.loads((a.configs / f'{m}.json').read_text()) + if m.startswith('gtls') and mode == 'single': + wanted['workers'] = 1 + if record['config'] != wanted: + raise ValueError('Timed configuration differs from the declared execution policy') + module = 'gputls' if m.startswith('gtls') else 'cuvarbase' + signature = hashlib.sha256(json.dumps(record['installed_sources'][module], sort_keys=True).encode()).hexdigest() + if signature != signatures[module]: + raise ValueError('Timed numerical source differs from sensitivity experiment') + reference = {k: record[k] for k in ('indices', 'input_sha256', 'input_array_sha256', 'truth', 'grid_sha256')} + if p in references and reference != references[p]: + raise ValueError('Timing methods received different inputs') + references[p] = reference + input_path = a.inputs / f'{p}_heldout.npz' + if hashlib.sha256(input_path.read_bytes()).hexdigest() != record['input_sha256']: + raise ValueError('Timing input file differs from the retained original') + if record['indices'] != list(range(8)) + list(range(128, 136)): + raise ValueError('Timing did not use the declared injection/null subset') + with np.load(input_path) as original: + truth = json.loads(str(original['metadata']))['cases'] + if record['truth'] != [truth[i] for i in record['indices']]: + raise ValueError('Timing truth differs from the retained inputs') + hashes = [{k: array_hash(original[f'{k}_{i}']) for k in ('t', 'y', 'dy')} + for i in record['indices']] + if hashes != record['input_array_sha256']: + raise ValueError('Timed observation arrays differ from retained inputs') + if {k: array_hash(original[k]) for k in record['grid_sha256']} != record['grid_sha256']: + raise ValueError('Timed grid arrays differ from retained inputs') + with np.load(a.cadences / f'{p}.npz') as cadence: + grid = np.ascontiguousarray(cadence['tls_periods']) + h = hashlib.sha256() + h.update(grid.dtype.str.encode()) + h.update(json.dumps(grid.shape).encode()) + h.update(grid.tobytes()) + if h.hexdigest() != record['grid_sha256']['tls_periods']: + raise ValueError('Timed TLS period grid differs from sensitivity study') + baseline = {} + if mode == 'single': + for i, warm in zip(record['indices'], record['warmup']): + baseline[i] = warm['candidates'][0] + else: + baseline = dict(zip(record['indices'], record['warmup'][0]['candidates'])) + seconds, period_changes, score_delta = [], 0, 0. + for rep in record['repetitions']: + seen, elapsed = [], 0. + for call in rep['calls']: + ids = [call['index']] if mode == 'single' else call['indices'] + if len(ids) != len(call['candidates']): + raise ValueError('Mismatched timed outputs') + elapsed += call['elapsed_s'] + for index, candidate in zip(ids, call['candidates']): + seen.append(index) + if not candidate.get('api_result_valid', False) or candidate['finite_fraction'] != 1: + raise ValueError('Invalid timed candidate') + if not np.isfinite(candidate['score']) or not np.isfinite(candidate['period']) or candidate['period'] <= 0: + raise ValueError('Invalid timed score or period') + period_changes += candidate['period'] != baseline[index]['period'] + score_delta = max(score_delta, abs(candidate['score'] - baseline[index]['score'])) + if sorted(seen) != sorted(record['indices']): + raise ValueError('A repetition omitted or duplicated a source') + measured = elapsed / 16 + if not np.isclose(measured, rep['seconds_per_source'], rtol=1e-12, atol=0): + raise ValueError('Stored timing arithmetic is inconsistent') + seconds.append(measured) + median = float(np.median(seconds)) + if not np.isclose(median, record['seconds_per_source'], rtol=1e-12, atol=0): + raise ValueError('Stored timing median is inconsistent') + rows.append(dict(profile=p, method=m, mode=mode, seconds_per_source=median, + min_seconds_per_source=min(seconds), max_seconds_per_source=max(seconds), + initialization_s=record['initialization_s'], first_api_s=record['first_api_s'], + period_changes_from_workload_warmup=int(period_changes), + max_native_score_change_from_workload_warmup=score_delta, + source=str(Path(job) / 'summary.json'))) + by_key = {(r['profile'], r['method'], r['mode']): r for r in rows} + selected = [] + for p in PROFILES: + supported = [m for m in V1 if criteria[p, m]['supported']] + method = min(supported or ['v1_original'], key=lambda m: by_key[p, m, 'batch16']['seconds_per_source']) + v1, gtls = (by_key[p, m, 'batch16']['seconds_per_source'] for m in (method, f'gtls_{p}')) + selected.append(dict(profile=p, method=method, recovery_supported=bool(supported), + batch_speedup=gtls/v1, v1_batch_seconds=v1, gtls_batch_seconds=gtls, + v1_search_usd_per_million=v1*1e6*.49/3600, + gtls_search_usd_per_million=gtls*1e6*.49/3600, + criterion=criteria[p, method])) + write(a.out, dict(timings=rows, selected=selected, + verification=dict(complete=True, exclusive_processes=True, + identical_inputs=True, timing_input_arrays_verified=True, + sources_match_sensitivity=True, + full_timing_spectra_retained=False), + hardware=plan['hardware'], boundary=plan['boundary'], exclusions=plan['exclusions'], + cohort='Eight earlier injections (two per SNR) and eight earlier nulls; identical across algorithms.', + single_gtls_qualification='One-worker latency is contextual; sensitivity was calibrated for the stated batch execution mode.')) + csv_path = a.out.with_suffix('.csv') + with csv_path.open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/archive.py b/benchmarks/tls_sensitivity/archive.py new file mode 100644 index 00000000..70e84eaf --- /dev/null +++ b/benchmarks/tls_sensitivity/archive.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Export compact, lossless scalar evidence from complete recovery shards. + +Input truth and installed-source maps are stored once. Original summary hashes, +all scalar outcomes and every spectrum hash are retained. Prepared lightcurves +and sampled periodograms stay in the larger measurement archive. The exporter +verifies their bytes before writing a verification receipt. +""" +import argparse +import functools +import gzip +import hashlib +import json +from pathlib import Path + +import numpy as np + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, allow_nan=False).encode()).hexdigest() + + +def compressed(path, value): + with Path(path).open('wb') as raw: + with gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as stream: + stream.write(json.dumps(value, separators=(',', ':'), allow_nan=False).encode()) + + +@functools.lru_cache(maxsize=2) +def read(root): + root = Path(root) + provenance = json.loads((root / 'provenance.json').read_text()) + for name, key in [('inputs.json.gz', 'input_archive_sha256'), + ('searches.json.gz', 'search_archive_sha256')]: + actual = hashlib.sha256((root / name).read_bytes()).hexdigest() + if actual != provenance['verification'][key]: + raise ValueError(f'Compact evidence archive hash mismatch: {root / name}') + with gzip.open(root / 'inputs.json.gz', 'rt') as f: + inputs = json.load(f) + with gzip.open(root / 'searches.json.gz', 'rt') as f: + searches = json.load(f) + return inputs, searches, provenance + + +def reconstruct(inputs, sources, search): + record = dict(search['metadata']) + record['installed_sources'] = sources[search['sources_id']] + truth = inputs[search['input_id']]['metadata']['cases'] + record['cases'] = [dict(**t, **c) for t, c in zip(truth, search['outcomes'])] + if len(truth) != len(search['outcomes']): + raise ValueError('Compact evidence has mismatched case counts') + return record + + +def records(root, profile, split, method): + root = Path(root) + if (root / 'searches.json.gz').exists(): + inputs, searches, provenance = read(str(root.resolve())) + for name, search in searches.items(): + if name.startswith(f'{profile}_{split}_') and name.endswith('/' + method): + yield name, reconstruct(inputs, provenance['installed_sources'], search) + else: + for path in sorted(root.glob(f'{profile}_{split}_*/{method}/summary.json')): + yield str(path), json.loads(path.read_text()) + + +def main(): + from generate import array_hash, sha + + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--root', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + a = ap.parse_args() + if a.out.exists() and any(a.out.iterdir()): + ap.error('Export into an empty directory to preserve published receipts') + inputs, searches, sources = {}, {}, {} + n_arrays = n_spectra = 0 + for path in sorted(a.root.glob('*/input.npz')): + shard = path.parent.name + if not (path.parent / 'collection.json').exists(): + continue + with np.load(path) as data: + metadata = json.loads(str(data['metadata'])) + for i, truth in enumerate(metadata['cases']): + for key, expected in truth['array_sha256'].items(): + if array_hash(data[f'{key}_{i}']) != expected: + raise ValueError(f'Input array mismatch: {path}/{key}_{i}') + n_arrays += 1 + grids = {k: array_hash(data[k]) for k in ('freqs', 'q', 'tls_periods')} + inputs[shard] = dict(metadata=metadata, input_sha256=sha(path), grid_sha256=grids) + wanted = ['v1_original', 'v1_resolved', 'v1_fine', f'gtls_{metadata["profile"]}'] + if (path.parent / 'v1_bls_control/summary.json').exists(): + wanted.append('v1_bls_control') + for method in wanted: + summary = path.parent / method / 'summary.json' + record = json.loads(summary.read_text()) + if record['status'] != 'ok' or record['input_sha256'] != inputs[shard]['input_sha256']: + raise ValueError(f'Incomplete or mismatched search: {summary}') + source_id = digest(record['installed_sources']) + sources[source_id] = record['installed_sources'] + outcomes = [] + if len(record['cases']) != len(metadata['cases']): + raise ValueError(f'Incorrect case count: {summary}') + for truth, case in zip(metadata['cases'], record['cases']): + if any(case[k] != v for k, v in truth.items()): + raise ValueError(f'Search input truth changed: {summary}') + if case.get('output_file'): + spectrum = summary.parent / case['output_file'] + if sha(spectrum) != case['output_sha256']: + raise ValueError(f'Spectrum file hash mismatch: {spectrum}') + with np.load(spectrum) as data: + for key, expected in case['spectrum_sha256'].items(): + if array_hash(data[key]) != expected: + raise ValueError(f'Spectrum array hash mismatch: {spectrum}/{key}') + n_spectra += 1 + outcomes.append({k: v for k, v in case.items() if k not in truth}) + search = dict(input_id=shard, sources_id=source_id, + original_summary_sha256=sha(summary), + metadata={k: v for k, v in record.items() if k not in ('cases', 'installed_sources')}, + outcomes=outcomes) + if reconstruct(inputs, sources, search) != record: + raise ValueError(f'Lossy compact export: {summary}') + searches[shard + '/' + method] = search + print('Verified ' + shard, flush=True) + a.out.mkdir(parents=True, exist_ok=True) + compressed(a.out / 'inputs.json.gz', inputs) + compressed(a.out / 'searches.json.gz', searches) + provenance = dict(installed_sources=sources, + verification=dict(input_arrays_verified=n_arrays, + sampled_spectrum_files_verified=n_spectra, + search_summaries_roundtripped=len(searches), + omitted_spectra='All unretained spectra have hashes; their bytes cannot be re-verified from this compact archive.', + input_archive_sha256=sha(a.out / 'inputs.json.gz'), + search_archive_sha256=sha(a.out / 'searches.json.gz'))) + (a.out / 'provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/binning.py b/benchmarks/tls_sensitivity/binning.py new file mode 100644 index 00000000..89b3708d --- /dev/null +++ b/benchmarks/tls_sensitivity/binning.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Isolate phase-bin compression at known transit parameters on old inputs. + +This is a white-noise matched-filter diagnostic, NOT a search/recovery test. +It holds period, epoch, duration, and the cuvarbase template fixed. Expected +signal-to-noise is computed from each filter's actual weighted variance, not +from either package's SDE or the coarse kernel's bin-averaged T-squared norm. +""" +import argparse +import csv +import importlib.util +import json +from pathlib import Path + +import batman +import numpy as np + + +def load_tables(source): + spec = importlib.util.spec_from_file_location('frozen_tls_models', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + assert module.BATMAN_AVAILABLE + return module.generate_template_tables(n_table=1024, u=[.4804, .1867]) + + +def bin_filter(t, period, epoch, duration, nbins, integral): + # Same per-lightcurve phase origin as the public fast API; float64 here + # deliberately excludes folding roundoff from the compression diagnostic. + origin = np.floor(t.min()) + phase = ((t - origin) / period) % 1 + phase_center = ((epoch - origin) / period) % 1 + bins = np.floor(phase * nbins).astype(int) + mid = (((bins + .5) / nbins - phase_center + .5) % 1) - .5 + c0 = (mid - .5 / nbins) / (.5 * duration / period) + c1 = (mid + .5 / nbins) / (.5 * duration / period) + knots = np.linspace(-1, 1, len(integral)) + return (np.interp(c1, knots, integral) - np.interp(c0, knots, integral)) / (c1 - c0) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--old-inputs', type=Path, required=True) + ap.add_argument('--template-source', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + a = ap.parse_args() + template, integral, squared_integral = load_tables(a.template_source) + knots = np.linspace(-1, 1, len(template)) + rows = [] + for profile in ('tess_200s', 'tess_gap', 'ztf'): + with np.load(a.old_inputs / f'{profile}_heldout.npz') as d: + metadata = json.loads(str(d['metadata'])) + for truth in metadata['cases']: + if not truth['injected']: + continue + i = truth['index'] + t, dy, bands = (d[f'{k}_{i}'] for k in ('t', 'dy', 'band')) + p, epoch, duration = (truth[k] for k in ('period', 'epoch', 'duration')) + pm = batman.TransitParams() + pm.t0, pm.per, pm.rp = epoch, p, truth['rp'] + pm.a = (6.6743e-11 * 1.9884e30 * (p * 86400)**2 / (4 * np.pi**2))**(1/3) / 6.957e8 + pm.inc = float(np.degrees(np.arccos(truth['impact'] / pm.a))) + pm.ecc, pm.w, pm.u, pm.limb_dark = 0., 90., [.4804, .1867], 'quadratic' + signal = np.empty(len(t)) + for band in np.unique(bands): + take = bands == band + seconds = {1: 1800, 27: 600, 67: 200}[int(band)] if profile.startswith('tess') else 30 + signal[take] = 1 - batman.TransitModel(pm, t[take], supersample_factor=7, + exp_time=seconds / 86400).light_curve(pm) + # Match the fast kernel's weight regularizer, while isolating + # white noise. No OU recovery inference is made from this test. + w = 1 / (dy*dy + 1e-10) + def snr(filt): + return float(np.dot(w, signal * filt) / np.sqrt(np.dot(w*w*dy*dy, filt*filt))) + phase = ((t - epoch + .5 * p) % p) - .5 * p + direct = np.interp(phase / (.5 * duration), knots, template, left=0., right=0.) + direct_snr = snr(direct) + oracle_snr = float(np.sqrt(np.dot(1 / (dy*dy), signal * signal))) + # This box has oracle center and duration. It is only a shape + # control, not a timing or sensitivity comparison with BLS. + box_snr = snr((np.abs(phase) <= .5 * duration).astype(float)) + qmin = .5 * np.arcsin((1 / (p * 8.6307))**(2/3)) / np.pi + automatic = int(2**np.ceil(np.log2(max(256, 4 / qmin)))) + t23 = p / np.pi * np.arcsin(np.sqrt((1 - pm.rp)**2 - truth['impact']**2) / + np.sqrt(pm.a**2 - truth['impact']**2)) + ingress = .5 * (duration - t23) + for label, bins in [('original_auto', automatic), ('1024', 1024), ('4096', 4096), ('8192', 8192)]: + averaged = bin_filter(t, p, epoch, duration, bins, integral) + averaged_squared = bin_filter(t, p, epoch, duration, bins, squared_integral) + binned_snr = snr(averaged) + rows.append(dict(profile=profile, index=i, config=label, phase_bins=bins, + period_days=p, duration_days=duration, ingress_days=float(ingress), + bins_across_transit=duration / p * bins, bins_across_ingress=ingress / p * bins, + direct_snr=direct_snr, binned_snr=binned_snr, oracle_snr=oracle_snr, + box_snr=box_snr, binned_over_direct_snr=binned_snr / direct_snr, + coarse_norm_over_actual_noise=float(np.sqrt(np.dot(w, averaged_squared) / + np.dot(w*w*dy*dy, averaged*averaged))))) + a.out.parent.mkdir(parents=True, exist_ok=True) + with a.out.open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + for profile in ('tess_200s', 'tess_gap', 'ztf'): + for config in ('original_auto', '4096', '8192'): + subset = [r for r in rows if r['profile'] == profile and r['config'] == config] + loss = np.array([1 - r['binned_over_direct_snr'] for r in subset]) + print(profile, config, 'SNR loss quantiles 0/50/95/100 %:', + np.round(100 * np.quantile(loss, [0, .5, .95, 1]), 3).tolist()) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/bls_control.py b/benchmarks/tls_sensitivity/bls_control.py new file mode 100644 index 00000000..a9695362 --- /dev/null +++ b/benchmarks/tls_sensitivity/bls_control.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Secondary BLS comparison on the TLS study's identical, period-restricted inputs. + +These descriptive comparisons are not part of the nine primary TLS/GTLS +noninferiority decisions. The box search also uses a different score/ranker, +so the experiment compares complete searches rather than isolating shape. +""" +import argparse +import json +from pathlib import Path + +import numpy as np + +from analyze import PROFILES, V1, calibrate, load, paired, score, valid, wilson, write + + +CONTROL = 'v1_bls_control' + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--root', type=Path, required=True) + ap.add_argument('--configs', type=Path, required=True) + ap.add_argument('--thresholds', type=Path, required=True) + ap.add_argument('--primary-thresholds', type=Path, required=True) + ap.add_argument('--calibrate', action='store_true') + ap.add_argument('--out', type=Path) + a = ap.parse_args() + splits = ('calibration',) if a.calibrate else ('injections', 'nulls') + groups, signatures = load(a.root, a.configs, splits, (*V1, CONTROL)) + frozen = json.loads(a.primary_thresholds.read_text()) + if signatures != frozen['source_signatures']: + raise ValueError('Secondary control does not use the frozen numerical sources') + if a.calibrate: + only_control = {key: cases for key, cases in groups.items() if key[1] == CONTROL} + write(a.thresholds, dict(thresholds=calibrate(only_control), source_signatures=signatures, + quantile=.95, method='higher', decision='strict exceedance')) + return + if a.out is None: + ap.error('--out is required for analysis') + thresholds = {**frozen['thresholds'], **json.loads(a.thresholds.read_text())['thresholds']} + vectors, methods, contrasts = {}, [], [] + for profile in PROFILES: + for method in (*V1, CONTROL, f'gtls_{profile}'): + cut = thresholds[f'{profile}/{method}']['threshold'] + injections, nulls = (groups[profile, method, s] for s in ('injections', 'nulls')) + detected = np.array([valid(c) and c['recovered'] and score(c) > cut for c in injections], bool) + fp = np.array([score(c) > cut for c in nulls], bool) + vectors[profile, method] = detected, fp + by_snr = [] + for snr in (6., 8., 10., 14.): + take = np.array([c['target_white_oracle_snr'] == snr for c in injections]) + n, k = int(take.sum()), int(detected[take].sum()) + by_snr.append(dict(snr=snr, n=n, detected=k, recall=k/n, + marginal_wilson_95=wilson(k,n))) + methods.append(dict(profile=profile, method=method, threshold=cut, + detected=int(detected.sum()), n_injections=len(injections), + false_positives=int(fp.sum()), n_nulls=len(nulls), by_snr=by_snr, + invalid_injections=sum(not valid(c) for c in injections), + invalid_nulls=sum(not valid(c) for c in nulls))) + bd, bf = vectors[profile, CONTROL] + for method in (*V1, f'gtls_{profile}'): + td, tf = vectors[profile, method] + contrasts.append(dict(profile=profile, method=method, reference=CONTROL, + recall_nominal_one_sided_bounds=paired(td, bd), + fpr_nominal_one_sided_bounds=paired(tf, bf))) + write(a.out, dict(secondary=True, methods=methods, contrasts=contrasts, + limitation='Different native ranking statistics and numerical grids beyond the identical period grid; this does not isolate template shape. Intervals are nominal and not adjusted for multiple comparisons.')) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/generate.py b/benchmarks/tls_sensitivity/generate.py new file mode 100644 index 00000000..0d6b53d0 --- /dev/null +++ b/benchmarks/tls_sensitivity/generate.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Independent, reproducible transit cohorts on frozen observed cadences. + +Cadence archives contain t, relative_error, band, exposure_days, freqs, q, +tls_periods, and JSON metadata. Each case has an independent SeedSequence; +changing the shard size or running on another machine does not change it. +""" +import argparse +import hashlib +import json +from pathlib import Path + +import batman +import numpy as np +from numba import njit + + +PROFILES = ('tess_200s', 'tess_gap', 'ztf') +SPLITS = ('calibration', 'injections', 'nulls') +SEED = 2026090917 + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def array_hash(a): + a = np.ascontiguousarray(a) + h = hashlib.sha256() + h.update(a.dtype.str.encode()) + h.update(json.dumps(a.shape).encode()) + h.update(a.tobytes()) + return h.hexdigest() + + +@njit(cache=True) +def ou_noise(t, z, tau): + red = np.empty(len(t)) + red[0] = z[0] + for j in range(1, len(t)): + a = np.exp(-(t[j] - t[j - 1]) / tau) + red[j] = a * red[j - 1] + np.sqrt(1 - a * a) * z[j] + return red + + +def make_case(cadence, profile, split, index): + seed = [SEED, PROFILES.index(profile), SPLITS.index(split), index] + rng = np.random.default_rng(np.random.SeedSequence(seed)) + raw = [cadence[k] for k in ('t', 'relative_error', 'band', 'exposure_days')] + keep = rng.random(len(raw[0])) > rng.uniform(0, .03) + t, relative, band, exposure = [v[keep] for v in raw] + cm = json.loads(str(cadence['metadata'])) + target = (6., 8., 10., 14.)[index % 4] + injected = split == 'injections' + for attempt in range(1, 1001): + period = float(np.exp(rng.uniform(np.log(.8), np.log(min(12., .8 * cm['pmax']))))) + epoch = float(rng.uniform(0, period)) + rp = float(rng.choice([.025, .05, .10])) + impact = float(rng.uniform(0, .85)) + a = (6.6743e-11 * 1.9884e30 * (period * 86400)**2 / (4 * np.pi**2))**(1/3) / 6.957e8 + pm = batman.TransitParams() + pm.t0, pm.per, pm.rp, pm.a = epoch, period, rp, a + pm.inc, pm.ecc, pm.w = float(np.degrees(np.arccos(impact / a))), 0., 90. + pm.u, pm.limb_dark = [.4804, .1867], 'quadratic' + model = np.ones(len(t)) + for exp in np.unique(exposure): + take = exposure == exp + model[take] = batman.TransitModel(pm, t[take], supersample_factor=7, + exp_time=float(exp)).light_curve(pm) + inside = model < 1 - 1e-9 + events = np.unique(np.rint((t[inside] - epoch) / period).astype(int)) + if inside.sum() >= 5 and len(events) >= 2: + break + else: + raise RuntimeError('Observable-injection sampling exhausted') + w = relative**-2 + signal = model - np.dot(w, model) / w.sum() + white_scale = np.sqrt(np.dot(w, signal * signal)) / target + dy = white_scale * relative + red = ou_noise(t, rng.normal(size=len(t)), .15 if profile.startswith('tess') else 1.) + y = (model if injected else np.ones(len(t))) + rng.normal(size=len(t)) * dy + .25 * np.median(dy) * red + duration = period / np.pi * np.arcsin(np.sqrt((1 + rp)**2 - impact**2) / np.sqrt(a*a - impact**2)) + data = dict(t=t, y=y, dy=dy, band=band) + truth = dict(global_index=index, seed=seed, injected=injected, + target_white_oracle_snr=target if injected else None, + period=period, epoch=epoch, rp=rp, impact=impact, + duration=float(duration), ndata=len(t), n_in_transit=int(inside.sum()), + observed_transit_events=len(events), ephemeris_draws=attempt, + noise_scale=float(white_scale), + white_oracle_snr=float(np.sqrt(np.sum((signal / dy)**2))) if injected else None, + array_sha256={k: array_hash(v) for k, v in data.items()}) + return data, truth + + +def generate(cadence_path, profile, split, start, count, out): + with np.load(cadence_path) as cadence: + metadata = json.loads(str(cadence['metadata'])) + arrays = {k: cadence[k] for k in ('freqs', 'q', 'tls_periods')} + rows = [] + for local_index, index in enumerate(range(start, start + count)): + data, truth = make_case(cadence, profile, split, index) + truth['index'] = local_index + rows.append(truth) + arrays.update({f'{k}_{local_index}': v for k, v in data.items()}) + metadata.update(profile=profile, split=split, start=start, count=count, cases=rows, + generator_sha256=sha(__file__), cadence_sha256=sha(cadence_path)) + arrays['metadata'] = np.array(json.dumps(metadata, sort_keys=True)) + out = Path(out) + out.parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed(out, **arrays) + return metadata + + +if __name__ == '__main__': + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--cadence', type=Path, required=True) + ap.add_argument('--profile', choices=PROFILES, required=True) + ap.add_argument('--split', choices=SPLITS, required=True) + ap.add_argument('--start', type=int, required=True) + ap.add_argument('--count', type=int, default=128) + ap.add_argument('--out', type=Path, required=True) + a = ap.parse_args() + generate(a.cadence, a.profile, a.split, a.start, a.count, a.out) diff --git a/benchmarks/tls_sensitivity/hatpi_cost.py b/benchmarks/tls_sensitivity/hatpi_cost.py new file mode 100644 index 00000000..f0111c5c --- /dev/null +++ b/benchmarks/tls_sensitivity/hatpi_cost.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Price a synthetic HATPI-like season; this is not observed HATPI evidence. + +102 clear eight-hour nights are drawn within 196 days. Native 30-second +measurements and weighted five-minute averages come from the same four +synthetic lightcurves. Their common 0.6--12-day period grid is prepared before +timing. This pilot estimates search cost, not equivalent recovery. +""" +import argparse +import json +from pathlib import Path +import subprocess +import sys +import time + +import batman +import numpy as np + +from run import Backend, dump, sha + + +CONFIGS = { + 'v1_bls': dict(backend='v1_bls_batch', noverlap=4, qmin_fac=.5), + 'v1_tls_original': dict(backend='v1_tls', epoch_os=4, durations=16), + 'v1_tls_intermediate': dict(backend='v1_tls', epoch_os=8, durations=16, nbins=4096), + 'v1_tls_fine': dict(backend='v1_tls', epoch_os=16, durations=32, nbins=8192), + 'gtls': dict(backend='gtls', density=True, fast=True, workers=1, margin=.125, + release_cache=True), +} + + +def prepare(root): + from cuvarbase.tls_grids import period_grid_ofir + rng = np.random.default_rng(2026090931) + nights = np.sort(rng.choice(np.arange(196), size=102, replace=False)) + t = np.concatenate([n + .25 + np.arange(960) * 30 / 86400 for n in nights]) + periods = period_grid_ofir(t, period_min=.6, period_max=12., oversampling_factor=3) + q = np.arcsin((1 / (periods * 8.6307))**(2/3)) / np.pi + common = dict(tls_periods=periods, freqs=1/periods, q=q) + profiles = {mode: dict(common) for mode in ('native30s', 'binned300s')} + truth = [] + for i, period in enumerate((2.3, 5.7, 2.3, 5.7)): + pm = batman.TransitParams() + pm.t0, pm.per, pm.rp = .37 + i * .21, period, .05 + pm.a = (6.6743e-11 * 1.9884e30 * (period * 86400)**2 / (4*np.pi**2))**(1/3) / 6.957e8 + pm.inc = float(np.degrees(np.arccos(.5 / pm.a))) + pm.ecc, pm.w, pm.u, pm.limb_dark = 0., 90., [.4804, .1867], 'quadratic' + signal = 1 - batman.TransitModel(pm, t, supersample_factor=7, + exp_time=30/86400).light_curve(pm) + sigma = np.sqrt(np.sum((signal - signal.mean())**2)) / 10 + dy = sigma * (.8 + .4 * rng.random(len(t))) + y = 1 - (signal if i < 2 else 0) + rng.normal(size=len(t)) * dy + weights = dy.reshape(-1, 10)**-2 + total = weights.sum(axis=1) + averaged = ( + np.sum(weights * t.reshape(-1, 10), axis=1) / total, + np.sum(weights * y.reshape(-1, 10), axis=1) / total, + 1 / np.sqrt(total)) + for mode, lc in [('native30s', (t, y, dy)), ('binned300s', averaged)]: + profiles[mode].update({f'{k}_{i}': v for k, v in zip(('t', 'y', 'dy'), lc)}) + truth.append(dict(index=i, injected=i < 2, period=period, epoch=pm.t0)) + for mode, arrays in profiles.items(): + arrays['metadata'] = np.array(json.dumps(dict( + profile=mode, split='cost_pilot', pmin=.6, pmax=12., baseline=float(np.ptp(t)), + cases=truth, seed=2026090931, n_clear_nights=102, season_days=196, + provenance='Entirely synthetic HATPI-like observing pattern. No HATPI lightcurve was downloaded.', + time_binning='Ten consecutive original samples per average within each night, inverse-variance weighted.'))) + np.savez_compressed(root / f'{mode}.npz', **arrays) + + +def measure(args): + mode, method = args.job.split('/') + path = args.out / f'{mode}.npz' + data = np.load(path) + lcs = [tuple(data[f'{k}_{i}'] for k in ('t', 'y', 'dy')) for i in range(4)] + result = dict(status='running', mode=mode, method=method, config=CONFIGS[method], + input_sha256=sha(path), n_points=[len(lc[0]) for lc in lcs], + n_periods=len(data['tls_periods']), repetitions=[]) + output = args.out / mode / method / 'summary.json' + dump(output, result) + begin = time.perf_counter() + b = Backend(CONFIGS[method], data, max(len(lc[0]) for lc in lcs)) + b.sync() + result['initialization_s'] = time.perf_counter() - begin + # The serial GTLS API has no cross-source batch amortization. Use three + # distinct warm single-source calls to keep the pricing pilot bounded. + workload = ([('first_api', lcs[:1]), *[(f'rep{i}', lcs[i+1:i+2]) for i in range(3)]] + if method == 'gtls' else + [('first_api', lcs[:1]), ('batch_warmup', lcs), *[(f'rep{i}', lcs) for i in range(3)]]) + for label, selected in workload: + b.sync() + begin = time.perf_counter() + outputs = b.search(selected) + b.sync() + elapsed = time.perf_counter() - begin + candidates = [o['candidate'] for o in outputs] + valid = len(outputs) == len(selected) and all( + not o.get('error') and o['candidate'].get('api_result_valid', True) + and o['candidate']['finite_fraction'] == 1 for o in outputs) + result['repetitions'].append(dict(label=label, elapsed_s=elapsed, + n_sources=len(selected), valid=valid, candidates=candidates, + errors=[o.get('error') for o in outputs if o.get('error')])) + dump(output, result) + if not valid: + raise RuntimeError('Invalid cost-pilot API result; evidence retained') + result.update(status='ok', seconds_per_source=float(np.median( + [r['elapsed_s'] / r['n_sources'] for r in result['repetitions'] if r['label'].startswith('rep')]))) + dump(output, result) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--timeout', type=int, default=600, help='Maximum seconds per configuration process') + ap.add_argument('--job', help=argparse.SUPPRESS) + a = ap.parse_args() + if a.job: + measure(a) + return + a.out.mkdir(parents=True, exist_ok=True) + prepare(a.out) + jobs = [f'{p}/{m}' for p in ('native30s', 'binned300s') for m in CONFIGS] + np.random.default_rng(2026090932).shuffle(jobs) + statuses = [] + for job in jobs: + try: + subprocess.run([sys.executable, str(Path(__file__).resolve()), '--out', str(a.out), + '--job', job], timeout=a.timeout, check=True) + statuses.append(dict(job=job, status='ok')) + except (subprocess.TimeoutExpired, subprocess.CalledProcessError) as e: + statuses.append(dict(job=job, status=type(e).__name__)) + dump(a.out / 'status.json', statuses) + print(statuses[-1], flush=True) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/plot_binning.py b/benchmarks/tls_sensitivity/plot_binning.py new file mode 100644 index 00000000..eea41c05 --- /dev/null +++ b/benchmarks/tls_sensitivity/plot_binning.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Illustrate the actual TLS template and phase-bin approximations; no GPU needed.""" +import argparse +from pathlib import Path + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import numpy as np + +from binning import bin_filter, load_tables + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--template-source', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True, help='Output filename stem') + a = ap.parse_args() + template, integral, _ = load_tables(a.template_source) + period, epoch = 5., 2. + duration = period * np.arcsin((1 / (period * 8.6307))**(2/3)) / np.pi + x = np.linspace(-2.2, 2.2, 4001) + t = epoch + x / 24 + point = np.interp((t - epoch) / (.5 * duration), np.linspace(-1, 1, len(template)), + template, left=0, right=0) + fig, ax = plt.subplots(figsize=(8, 3.5), layout='constrained') + ax.plot(x, 1 - (np.abs(x) <= duration * 12).astype(float), color='#999999', + lw=1.4, ls='--', label='Box template') + ax.plot(x, 1 - point, color='#142b3b', lw=2.5, + label='Transit template on individual observations') + for bins, color in [(512, '#d47729'), (4096, '#367eaa')]: + filt = bin_filter(np.r_[0., t], period, epoch, duration, bins, integral)[1:] + ax.plot(x, 1 - filt, color=color, lw=1.3, alpha=.9, + label=f'{bins:,} phase bins ({period * 1440 / bins:.3g} min each)') + ax.set(xlabel='Hours from transit center', ylabel='Normalized brightness', + ylim=(-.07, 1.08), xlim=(-2.2, 2.2)) + ax.set_yticks([0, .5, 1], ['Transit minimum', '', 'Out of transit']) + ax.spines[['top', 'right']].set_visible(False) + ax.grid(axis='y', alpha=.14) + ax.legend(loc='lower left', fontsize=8, frameon=False, bbox_to_anchor=(0, 1.02), ncol=2) + a.out.parent.mkdir(parents=True, exist_ok=True) + for extension in ('png', 'svg', 'pdf'): + fig.savefig(a.out.with_suffix('.' + extension), dpi=180) + plt.close(fig) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/run.py b/benchmarks/tls_sensitivity/run.py new file mode 100644 index 00000000..9c88f23a --- /dev/null +++ b/benchmarks/tls_sensitivity/run.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Run one independent TLS recovery shard through pinned public APIs. + +All candidates and scores are retained. Full spectra are retained for the first +four cases of every shard; every spectrum is hashed before disposal. This +avoids committing large synthetic periodogram archives to a release repository. +""" +import argparse +import hashlib +import importlib.metadata +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import traceback + +import numpy as np + +from generate import array_hash + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'transit')) +from worker import Backend, dump, sha + + +def cpu_quota(): + paths = ['/sys/fs/cgroup/cpu.max', '/sys/fs/cgroup/cpu/cpu.cfs_quota_us', + '/sys/fs/cgroup/cpu/cpu.cfs_period_us', + '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us', + '/sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us'] + return {p: Path(p).read_text().strip() for p in paths if Path(p).exists()} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--input', type=Path, required=True) + ap.add_argument('--config', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + a = ap.parse_args() + cfg = json.loads(a.config.read_text()) + data = np.load(a.input) + metadata = json.loads(str(data['metadata'])) + lcs = [tuple(data[f'{k}_{i}'] for k in ('t', 'y', 'dy')) + for i in range(len(metadata['cases']))] + record = dict(status='running', profile=metadata['profile'], split=metadata['split'], + start=metadata.get('start', 0), count=len(lcs), config=cfg, + input_file=a.input.name, input_sha256=sha(a.input), + config_sha256=sha(a.config), adapter_sha256=sha(Path(__file__).resolve().parents[1] / 'transit/worker.py'), + runner_sha256=sha(__file__), cpu_quota=cpu_quota(), + threads={k: os.getenv(k) for k in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA_NUM_THREADS')}, + boundary='Prepared host arrays and explicit period grid to host periodograms and native candidate/score; GPU synchronized. Generation, imports, contexts, disk output excluded.', + packages={}, cases=[]) + for name in ('numpy', 'scipy', 'batman-package', 'pycuda', 'cupy-cuda12x', 'gputls', 'cuvarbase'): + try: + record['packages'][name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + pass + try: + record['gpu'] = subprocess.check_output(['nvidia-smi', '--query-gpu=name,uuid,memory.total,memory.free,driver_version', '--format=csv,noheader'], text=True).strip() + except (OSError, subprocess.CalledProcessError): + record['gpu'] = None + a.out.mkdir(parents=True, exist_ok=True) + dump(a.out / 'summary.json', record) + try: + begin = time.perf_counter() + backend = Backend(cfg, data, max(len(lc[0]) for lc in lcs)) + backend.sync() + record['initialization_s'] = time.perf_counter() - begin + begin = time.perf_counter() + warm = backend.search(lcs[:1]) + backend.sync() + record['first_api_s'] = time.perf_counter() - begin + record['warmup_candidates'] = [o['candidate'] for o in warm] + record['installed_sources'] = {} + for name in ('cuvarbase', 'gputls'): + if name in sys.modules: + package = Path(sys.modules[name].__file__).parent + record['installed_sources'][name] = {str(p.relative_to(package)): sha(p) + for p in sorted(package.rglob('*')) if p.is_file() and p.suffix in ('.py', '.cu', '.cuh', '.so')} + for start in range(0, len(lcs), cfg.get('eval_chunk', 16)): + stop = min(start + cfg.get('eval_chunk', 16), len(lcs)) + backend.sync() + begin = time.perf_counter() + try: + outputs = backend.search(lcs[start:stop]) + backend.sync() + except Exception: + error = traceback.format_exc() + elapsed = time.perf_counter() - begin + for i in range(start, stop): + record['cases'].append(dict(**metadata['cases'][i], api_result_valid=False, + period_found=None, score=None, error=error, + recovered=False, alias_recovered=False, + search_s=elapsed / (stop - start))) + dump(a.out / 'summary.json', record) + continue + elapsed = time.perf_counter() - begin + if len(outputs) != stop - start: + raise RuntimeError('Public API returned wrong number of outputs') + for i, output in zip(range(start, stop), outputs): + truth = metadata['cases'][i] + candidate = dict(output['candidate']) + candidate.setdefault('api_result_valid', True) + found = candidate.pop('period') + epoch_found = candidate.pop('epoch', None) + drift = abs(found / truth['period'] - 1) * metadata['baseline'] if found is not None else None + recovered = bool(drift is not None and drift <= .5 * truth['duration']) if truth['injected'] else None + alias = bool(found is not None and any(abs(found / (truth['period'] * k) - 1) * metadata['baseline'] <= .5 * truth['duration'] + for k in (.5, 1., 2., 1/3, 3.))) if truth['injected'] else None + row = dict(**truth, **candidate, period_found=found, epoch_found=epoch_found, phase_drift_days=drift, + recovered=recovered, alias_recovered=alias, search_s=elapsed / (stop - start), + evaluation_chunk=stop - start, n_periods=len(output['periods']), + spectrum_sha256={k: array_hash(output[k]) for k in ('periods', 'power')}) + if output.get('error'): + row['error'] = output['error'] + if i < 4: + path = a.out / f'case_{i:04}.npz' + np.savez_compressed(path, periods=output['periods'], power=output['power']) + row.update(output_file=path.name, output_sha256=sha(path)) + record['cases'].append(row) + dump(a.out / 'summary.json', record) + # Progress deliberately omits held-out outcomes until analysis. + print(json.dumps(dict(completed=stop, count=len(lcs), elapsed_s=elapsed)), flush=True) + record['status'] = 'ok' + except Exception: + record.update(status='error', error=traceback.format_exc()) + dump(a.out / 'summary.json', record) + if record['status'] != 'ok': + raise RuntimeError(record['error']) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_sensitivity/timing.py b/benchmarks/tls_sensitivity/timing.py new file mode 100644 index 00000000..b16f8dc8 --- /dev/null +++ b/benchmarks/tls_sensitivity/timing.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Measure TLS latency and throughput with one GPU and isolated method processes. + +The parent randomizes configuration order. Each child warms its exact workload +and measures five synchronized repetitions. Inputs must be the earlier tuning +archives, not the independent sensitivity cohorts. No cloud resources are +created by this script. +""" +import argparse +import json +from pathlib import Path +import subprocess +import sys +import time + +import numpy as np + +from run import Backend, array_hash, cpu_quota, dump, sha + + +PROFILES = ('tess_200s', 'tess_gap', 'ztf') +METHODS = ('v1_original', 'v1_resolved', 'v1_fine') +INDICES = list(range(8)) + list(range(128, 136)) + + +def observe(backend, lightcurves): + backend.sync() + start = time.perf_counter() + outputs = backend.search(lightcurves) + backend.sync() + elapsed = time.perf_counter() - start + if len(outputs) != len(lightcurves): + raise RuntimeError('Wrong number of timed outputs') + candidates = [] + for output in outputs: + c = output['candidate'] + if output.get('error') or not c.get('api_result_valid', True) or c['finite_fraction'] != 1.: + raise RuntimeError('Invalid timed result: ' + str(output.get('error', c))) + candidates.append(dict(**c, spectrum_sha256={ + k: array_hash(output[k]) for k in ('periods', 'power')})) + return elapsed, candidates + + +def child(args): + profile, method, mode = args.job.split('/') + path = args.inputs / f'{profile}_heldout.npz' + data = np.load(path) + config = json.loads((args.configs / f'{method}.json').read_text()) + if method.startswith('gtls') and mode == 'single': + config['workers'] = 1 + lcs = [tuple(data[f'{key}_{i}'] for key in ('t', 'y', 'dy')) for i in INDICES] + truth = json.loads(str(data['metadata'])) + result = dict(status='running', profile=profile, method=method, mode=mode, + config=config, indices=INDICES, input_sha256=sha(path), + grid_sha256={k: array_hash(data[k]) for k in ('freqs', 'q', 'tls_periods')}, + input_array_sha256=[{k: array_hash(v) for k, v in zip(('t', 'y', 'dy'), lc)} for lc in lcs], + truth=[truth['cases'][i] for i in INDICES], + runner_sha256=sha(__file__), + adapter_sha256=sha(Path(__file__).resolve().parents[1] / 'transit/worker.py'), + cpu_quota=cpu_quota(), repetitions=[]) + output = args.out / profile / method / mode / 'summary.json' + dump(output, result) + try: + start = time.perf_counter() + backend = Backend(config, data, max(len(lc[0]) for lc in lcs)) + backend.sync() + result['initialization_s'] = time.perf_counter() - start + elapsed, candidates = observe(backend, lcs[:1]) + result['first_api_s'] = elapsed + result['first_api_candidates'] = candidates + # Warm the same workload that will be timed, including all pool workers. + warm = [observe(backend, [lc]) for lc in lcs] if mode == 'single' else [observe(backend, lcs)] + result['warmup'] = [dict(elapsed_s=t, candidates=c) for t, c in warm] + for rep in range(args.reps): + # Rotating source order prevents one particular source always being first. + order = np.roll(np.arange(len(lcs)), rep).tolist() + if mode == 'single': + calls = [dict(index=INDICES[i], elapsed_s=t, candidates=c) + for i in order for t, c in [observe(backend, [lcs[i]])]] + seconds = float(np.mean([c['elapsed_s'] for c in calls])) + else: + elapsed, candidates = observe(backend, [lcs[i] for i in order]) + calls = [dict(indices=[INDICES[i] for i in order], elapsed_s=elapsed, + candidates=candidates)] + seconds = elapsed / len(lcs) + result['repetitions'].append(dict(rep=rep, seconds_per_source=seconds, calls=calls)) + dump(output, result) + values = [r['seconds_per_source'] for r in result['repetitions']] + result.update(status='ok', seconds_per_source=float(np.median(values)), + min_seconds_per_source=min(values), max_seconds_per_source=max(values), + n_sources=len(lcs), n_repetitions=args.reps, + single_statistic='Median across repetitions of mean latency over 16 distinct sources', + batch_statistic='Median 16-source API call duration divided by 16') + result['installed_sources'] = {} + for name in ('cuvarbase', 'gputls'): + if name in sys.modules: + root = Path(sys.modules[name].__file__).parent + result['installed_sources'][name] = { + str(p.relative_to(root)): sha(p) for p in sorted(root.rglob('*')) + if p.is_file() and p.suffix in ('.py', '.cu', '.cuh', '.so')} + dump(output, result) + except Exception: + import traceback + result.update(status='error', error=traceback.format_exc()) + dump(output, result) + raise + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument('--inputs', type=Path, required=True) + ap.add_argument('--configs', type=Path, required=True) + ap.add_argument('--out', type=Path, required=True) + ap.add_argument('--reps', type=int, default=5) + ap.add_argument('--seed', type=int, default=2026090927) + ap.add_argument('--job', help=argparse.SUPPRESS) + a = ap.parse_args() + if a.job: + child(a) + return + jobs = [f'{p}/{m}/{mode}' for p in PROFILES for m in (*METHODS, f'gtls_{p}') + for mode in ('single', 'batch16')] + np.random.default_rng(a.seed).shuffle(jobs) + a.out.mkdir(parents=True, exist_ok=True) + plan = dict(order=jobs, seed=a.seed, indices=INDICES, repetitions=a.reps, + boundary='Prepared host arrays and explicit period grid through host periodograms, native candidate and score, including transfers and synchronization.', + exclusions='Imports, context initialization, grid creation, synthetic data generation, disk I/O and survey preprocessing.', + hardware={ + 'gpu': subprocess.check_output(['nvidia-smi', '-q', '-x'], text=True), + 'cpu': subprocess.check_output(['lscpu'], text=True), + 'cpu_quota': cpu_quota(), + 'packages': subprocess.check_output([sys.executable, '-m', 'pip', 'freeze'], text=True)}) + dump(a.out / 'plan.json', plan) + statuses = [] + for job in jobs: + print('Timing ' + job, flush=True) + started = time.time() + completed = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--inputs', str(a.inputs), + '--configs', str(a.configs), '--out', str(a.out), '--reps', str(a.reps), + '--job', job]) + statuses.append(dict(job=job, exit_code=completed.returncode, + started_epoch=started, finished_epoch=time.time())) + dump(a.out / 'status.json', statuses) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/BLS_EXECUTION_PROTOCOL.md b/benchmarks/tls_survey/BLS_EXECUTION_PROTOCOL.md new file mode 100644 index 00000000..9cfff091 --- /dev/null +++ b/benchmarks/tls_survey/BLS_EXECUTION_PROTOCOL.md @@ -0,0 +1,213 @@ +# Prospective native-BLS execution-throughput supplement + +This is a separate timing study. It does not amend the original +[throughput protocol](THROUGHPUT_PROTOCOL.md), its exact selected-score gate, +its failed results, its frozen selections, or its missing qualified BLS bars. +The original BLS one-worker pilot changed the selected likelihood score on +gapped-TESS development case 0006, as recorded in the +[retained exclusion audit](../results/tls_survey_2026-09-10/throughput-tuning-exclusions-audit/AUDIT.md). +The original BLS exact-repeatability qualification remains **failed**, even if +every subsequent supplemental output happens to match. + +The supplement measures how many unchanged native BLS searches execute per +second and records their numerical variability. It introduces no numerical +passing tolerance. A changed score or period is recorded as a discrepancy; +it is never relabeled as passing. Neither a small discrepancy nor an unchanged +period establishes unchanged calibrated detection. TLS's scientific settings, +zero approximation allowances, calibration, and held-out analysis are unchanged. + +## Freeze and execution boundary + +Approve this protocol and the supplemental implementation, renderer, and +sidecar sources now, while the primary pipeline runs. Create an immutable +supplemental seal at `evidence/bls-execution-supplement/seal.json` at that review, +before any supplemental search. +Record its SHA256 in every supplemental campaign and report. The seal binds: + +- This protocol and every executed supplemental source and imported scientific + or timing dependency by SHA256. +- The unchanged scientific seal, original development-tuning receipt, and + exact original development cohort by SHA256. +- A mechanical post-primary binding rule for the completed primary measurement, + its verified archive/inventory, resource receipts, and every fixed measurement + cohort, including original input and varied-cohort retained-index hashes. +- The tuning, measurement, accounting, discrepancy, allocation, and budget + rules below, including the permanently failed original qualification. + +The completed primary measurement hash cannot be known beforehand. After +verified primary completion, create a separate immutable binding receipt +containing its actual SHA256 and the cohort/resource identities required by +the sealed rule. Bind that receipt's SHA256 in every supplemental campaign and +report. This step copies and verifies identities mechanically; it cannot change +algorithms, gates, settings, sources, cohort definitions, or case selection in +response to primary outcomes. Root approval of the prospective seal authorizes +this later mechanical binding. Do not overwrite either receipt. Any inconsistency +requires a preserved failed preparation receipt and root review before work. + +The supplement starts only after the primary pipeline has completed +successfully and its verified archive is available. A rescue archive or a +terminal parent process alone does not establish successful completion. +Verify that primary workers and their descendant GPU contexts have exited. +The original collector must not terminate the rental during this separate +authorized study. The sidecar retains supplementary artifacts on success, +failure, or timeout, then returns control to the original collection and +termination procedure. This protocol grants no provisioning authority. + +## Fixed searches, cohorts, and allocation + +Call the existing native BLS search and the science seal's selected ranker for +each regime. Preserve the complete supplied period grids, duration/phase +settings, errors, source arrays, and normal compact timed output policy. +Do not change numerical kernels, determinize or round outputs, replace the +selected ranker, shorten grids, screen cases, or alter a scientific source. +Full power/period arrays, masks, and all ranker diagnostics are collected +outside measured queues, using the existing full diagnostic call. + +Use exactly the original 24 development inputs for tuning: the first eight +manifest-order inputs from TESS solar, gapped TESS, and ZTF solar. Measurement +uses exactly the original independent timing populations: the first 16 nulls +per cadence for three panels, plus the existing 96-input varied-size cohort +derived from the first 32 nulls per cadence by the original deterministic +retained-fraction/index rule. Bind the primary measurement's actual manifests +and retained indices. Do not replace a failed case or select a subset according +to timing, numerical stability, detection, or API success. + +Run only the supplemental BLS processes on the same single GPU and container +CPU/memory allocation used by the primary comparison. Preserve one numerical +library thread per worker. Record GPU UUID, CPU quota, memory limit, source +and input hashes, process identities, and context ownership. Input generation +and other scientific work on the rental must have finished. An ownership or +allocation violation invalidates the affected measurement; it cannot become +a fast execution result. + +## Independent development tuning + +Use the original staged five-configuration policy: one, two, and four persistent +workers at batch size one; then batch sizes four and eight at the selected +worker count. Task batches group serial calls within a worker and never mix +different grids or search options. Each pilot attempts at least 24 sources and +runs for at least 30 seconds, completing whole fixed-cohort cycles, once. + +Among operationally valid, complete pilots with positive successful-completion +rates, select the worker count with the highest successful lightcurves/second. +Then choose among its batch sizes one, four, and eight by the same metric. +Exact speed ties prefer fewer workers, then smaller batches. API failures +consume time and reduce the success numerator. Always display failure counts +and completion fractions beside rates; a selected setting with API failures +does not establish successful processing of the entire workload. + +A numerical mismatch never removes a timing result or chooses an alternative +reference. There are no retries until a numerical gate passes. If no worker +setting has an operationally valid positive rate, report that no execution +configuration was selected and leave subsequent panels unavailable. Freeze the +winner and the original campaign's unchanged hourly price before supplemental +measurement. Record the separate development tuning seal's path and SHA256; +measurement and rendering must verify its campaign, selection, and artifacts. +No measurement input or held-out +detection result may influence that selection. + +## Sustained queues and accounting + +For each of the four fixed panels, execute three queues with the frozen winner. +Each queue attempts at least 96 lightcurves and lasts at least 120 seconds, +finishing whole cohort cycles with at most one task per worker in flight. +The minimum is **attempted inputs**, not successful outputs: API failures must +not cause retries until 96 successes occur. Report the actual number of +successes, which can be below 96. Cycle scheduling and stopping depend only on +the fixed input order, attempted count, and wall time, never on numerical +agreement, recovery, or successful output count. Repetitions reuse inputs and +are not additional independent astrophysical samples. + +Every assigned case receives one attempt during its scheduled cycle. Catch +ordinary native API errors per case, retain the error, and attempt the remaining +scheduled members once. Do not skip the rest of a batch because one case failed. +A successful completion must return the selected period and score required by +the unchanged adapter in a valid finite form. A backend exception or invalid +selected output is an API/output failure. A changed finite value is a completed +search and a numerical discrepancy, not an API failure. + +For every complete operationally valid queue, require: + +- `attempts = successful_completions + API_or_output_failures`, with one retained + record per attempted case and exact scheduled cohort membership. +- Elapsed time covers all attempts, dispatch, required input validation, + preparation, transfers, full search, selected ranking, output handling, + error handling, and worker completion. Failed attempts consume that time. + The supplement also flushes per-attempt worker journals and a parent task + journal, and compares selected endpoints inside this clock. This additional + instrumentation cost is retained and disclosed beside cross-method rates. +- Report attempted and successful lightcurves/second, completion fraction, + error counts and case identities, plus costs per attempt and per successful + lightcurve. No-success cost per successful lightcurve is unavailable. +- Keep startup, imports/context creation, grid construction, first-call latency, + complete diagnostic warmup, and teardown separately. Match the original + cold-amortization convention: charge input/setup time, worker startup, and + complete diagnostic warmup in addition to the queues. Separately report total + configuration time and cost, including post-queue diagnostics and teardown; + do not label either convention as including costs it omits. Sample device + memory, worker RSS, and container memory with the original accounting and + limitations. + +Missing, duplicated, foreign, or misidentified results; corrupted source/input +or reference hashes; worker loss; execution timeout; failed instrumentation; +or a resource-ownership/allocation violation invalidate the setting or panel. +Retain all partial records and elapsed time, but do not select it or publish a +complete sustained rate. An incomplete queue is never represented as meeting +the planned duration or attempted-input requirement. + +## Numerical discrepancies remain visible + +The first scheduled one-worker full outputs on the development cohort are +fixed comparison anchors for all tuning configurations. Do not replace an +unavailable anchor with the first later success. On each independent panel, +take one scheduled full one-worker reference before the selected pool runs; +an unavailable reference stays unavailable. An anchor is a comparison point, +not an accepted numerical truth or passing criterion. + +Collect complete outputs before and after queues on every worker, and compare +every measured selected period/score with its assigned anchor. Retain both +values and exact differences for each changed selected endpoint, together with +input, worker, configuration, repetition, and attempt identities. Preserve +full diagnostic arrays and mask/period fingerprints and describe nonwinning +power and other-ranker differences separately. Unavailable comparisons and +API failures must be explicit. Do not suppress zero-difference repeats or +replace an original disagreement with a matching repeat. + +Every supplemental record and renderer must carry +`original_qualification_passed = false`. No numerical discrepancy threshold, +new numerical passing label, or inferred SNR/recovery tolerance is introduced. +The supplement does not re-evaluate calibrated false-positive or recovery +performance, and does not imply independently recalibrated baseline cuts. + +## Time limit, preservation, and reporting + +The sidecar enforces one absolute **3,600-second supplemental execution window** +starting before worker startup. At $0.49/hour this allows approximately $0.49 +of additional rental compute, within the already authorized cumulative budget; +the live ledger and remaining guard must be checked before release. No new +allowance or provisioning is authorized. Reserve the final 120 seconds for +worker shutdown and ownership verification. Do not begin another required +queue or stage if its known minimum duration cannot fit before that reserve. +Never shorten a planned queue, reduce repetitions, alter settings, or omit cases +to fit the deadline. Stop and retain incomplete work when the budget is reached. +All GPU workers must exit by the absolute deadline; failure to quiesce is a +resource-safety failure, not a completed benchmark. Archive/transfer costs are +recorded separately and remain subject to the existing rental guard. + +Preserve the original qualified figure unchanged. A separate combined figure +may show supplemental native-BLS execution rates as hatched bars, with a +permanent **failed exact-repeatability qualification** label and separate +source/provenance binding. Show medians and observed ranges from the three +complete repetitions, completion fractions, and all unavailable panels. +These bars are not qualified results under the original numerical protocol. +Do not silently merge them into the original qualified CSV or label their +execution rates as proof of equivalent scientific outputs. Use matched cohort +and resource receipts for any cross-method comparison, and keep numerical +qualification, execution completion, and calibrated sensitivity distinct. + +Every campaign and rendered artifact binds the supplemental seal, post-primary +binding receipt, scientific seal, primary tuning, primary measurement, exact +cohort identities, and actual resource receipts. Preserve all supplementary +attempts, discrepancies, errors, +partial runs, manifests, arrays, source snapshots, and budget/ownership records +before the original collector resumes and the rental terminates. diff --git a/benchmarks/tls_survey/FOLLOWUP_20260924.md b/benchmarks/tls_survey/FOLLOWUP_20260924.md new file mode 100644 index 00000000..d010772f --- /dev/null +++ b/benchmarks/tls_survey/FOLLOWUP_20260924.md @@ -0,0 +1,51 @@ +# September 24 throughput follow-up + +This work continues the unfinished BLS and varied-size throughput comparison. +It is a new experiment on a new rental. The September 10–12 source snapshots, +science seal, input files, failed receipts and figures remain immutable. +The user authorized proceeding on September 24 after an estimate of $5–15 +additional cloud spend, within the existing $100 cumulative authorization. +The previous conservative ledger is $73.75634266798957. This follow-up has a +$15 ceiling including storage, and a maximum 20-hour rental at no more than +$0.70/hour. Collection and teardown are included in that window. + +## Order of work + +1. Validate the launcher with all six numerical thread limits set before imports. +2. Preserve exact copies of the old source/input subset and record the new GPU, + driver, CPU/RAM allocation and software versions. +3. Run bounded repeatability diagnostics on development inputs. Compare saved + scalar outputs, spectra, masks and numerical differences. Diagnose the known + timing failures separately; any replay of original timing inputs is explicitly + diagnostic and cannot become a new independent development or science sample. +4. Freeze a concrete follow-up timing plan before new measurement. BLS execution + rates retain all finite numerical discrepancies and native API failures under + the existing supplemental accounting definitions. These rates do not acquire + the original failed exact-repeatability qualification. +5. Run competitors on the same new allocation for new comparisons. Preserve + all full searches, original science-selected BLS configurations and rankers, + and complete period grids. Record each configuration before execution. +6. Verify collected outputs, update the report/figure with the actual outcomes, + reconcile costs, and verify provider termination. + +The three original timing populations and deterministic 96-source varied-size +population are repeated workloads, not newly blinded science data. If execution +settings change, selection uses development inputs only; measurement outcomes +cannot select a faster or more numerically convenient configuration. Missing +measurements and failed scientific qualification remain explicit. No output +rounding, hidden tolerance, case filtering, or retry-until-passing is allowed. + +The final timing plan must retain three repetitions per panel, each at least +96 attempted inputs and 120 seconds, completing whole cohort cycles. Failed API +calls consume elapsed time. The scalar and full-spectrum diagnostics remain +separate from successful-completion throughput. Cold preparation, memory, +failed comparisons and instrumentation costs are reported explicitly. + +The default release implementation and experimental execution mode are frozen +while diagnosing these failures. Any proposed numerical implementation change +requires a separate source snapshot and validation before it can support a new +scientific claim. The original 5,111/5,120 exactness outcome is never relabeled. + +Final README/release claims, the complete release gate, release tag and package +publication follow the benchmark results. Independent documentation and +packaging cleanup can proceed while GPU queues run. diff --git a/benchmarks/tls_survey/README.md b/benchmarks/tls_survey/README.md new file mode 100644 index 00000000..27ac03a0 --- /dev/null +++ b/benchmarks/tls_survey/README.md @@ -0,0 +1,249 @@ +# Survey TLS throughput and detection study + +This campaign builds on the September 10 observation-level GTLS compatibility +study. The production deployment criterion is retention of its complete trials, +valid masks, candidate/refinement policy and numerical objective. It does not +introduce a lossy screening path. Approximate experiments remain opt-in. + +The earlier box diagnostic compared idealized known-period filters and the +secondary BLS search had one fixed setting. Neither established TLS's practical +advantage over a strong tuned BLS search. This campaign measures that advantage +before assigning any approximation allowance. + +## Stages and independence + +1. **Development**: eight physical draws per regime (two at each white-noise + oracle SNR 6, 8, 10 and 12), plus 64 separate development nulls per regime. + These inputs can expose design defects. Failed development designs remain + recorded and are not held-out evidence. +2. **Development diagnostics**: enumerate all GTLS sample-index cache templates + at every observation start at the true period and compare with the optimal + arbitrary-width box. Both fit a weighted constant and use the same expected + linear-filter SNR. Select both filters with the diagonal-error objective, + then report their response using the actual OU covariance variance; the OU + values are not independently optimized over the template families. The native + family optimum is an optimistic ceiling: native depth estimation and score + ranking need not choose that filter. `bls_response.py` measures actual + noise-free BLS filter response with one further resolution increase. +3. **BLS tuning**: compare overlap 4/8/16/32, duration step .1/.05/.025/.0125, and minimum + duration factors 1/.5/.25/.125 times the broad native stellar envelope. Reducing + the minimum width also reduces the fundamental phase-bin width quantum. + Test normalized BLS power, unnormalized delta-chi-squared likelihood power (using the supplied errors), and median-trend/MAD ranking. Choose maximum + development recovery at its own calibrated 5% FPR; ties favor finest + resolution, then delta-chi-squared likelihood power. This is the strongest *tested* setting, not a + claim that every conceivable BLS implementation has been optimized. The + 32-overlap setting was added before the final freeze after the response + diagnostic found a 1.37% loss in the previous finest eccentric-TESS case. + It is explicitly inapplicable to the long gapped-TESS grid because its + minimum widths exceed available shared memory; the prior finest setting + already attains the ideal-box response on all gapped development examples. + The stronger tested setting retains a bounded residual ideal-box gap + (up to about 0.24% in these development examples). +4. **Freeze** `analyze.py freeze` before generating any final calibration or test + data. Record all science-source hashes, production-source hashes, settings, + populations, statistics, execution policy and accuracy tolerances. +5. **Independent calibration**: 512 new nulls per regime and detector. Use the + strict upper order statistic with rank `ceil((n+1)*(1-alpha))` for primary + 5% and secondary 1% FPR. For n=512 their no-tie marginal rates are + 25/513 and 5/513; ties can make the realized operating point more conservative. This exchangeability guarantee is marginal over calibration + sets; it does not certify the conditional rate of one realized threshold. +6. **Held-out evaluation**: 256 injections and 256 further nulls per regime (64 injections per SNR). + Keep every planned injection, including unsampled signals, one-event cases + and API failures. Detection requires threshold exceedance and period drift + over the full baseline at most half the physical first-to-fourth-contact + duration. Report aliases separately. No injected truth is inserted into + blind grids. Results retain per-SNR, few-point, few-event and grid-unreachable + subgroups. A small cohort cannot establish 0.1 percentage-point equivalence. + +Both detectors receive the same paired null lightcurves, independently drawn +from development and test cohorts, and use separate method-specific thresholds. +Null latent SNR/noise scales are IID draws from the equal four-level mixture; +the injected populations are deliberately balanced over those four levels. +The two detector scores are calibrated separately; SDE, native SNR, BLS power +and oracle SNR are never equated. Report exact marginal binomial intervals and +paired TLS-minus-BLS discordance bounds. Simultaneous paired bounds cover all +regimes, both recovery/FPR outcomes and both operating points using Bonferroni. +No pooled success overrides a weak subgroup. The exact-only production policy +allows zero FPR increase; its separate 0.1 percentage-point absolute cap is +recorded for context and does not grant an operative allowance. + +Balanced injection strata need not have equal success probabilities. The +two-sided Clopper–Pearson construction remains valid for their average at the +confidence levels used here; see [Mattner and Tasto, Theorem 1.12](https://arxiv.org/pdf/1403.0229). +The paired construction combines those two-sided discordance bounds by +Bonferroni; it does not require an IID pooled-injection assumption. + +## Frozen tolerance rule + +An approximation may consume at most **5% of a demonstrated positive TLS +advantage**, with additional absolute ceilings of **0.1% fractional expected +SNR loss** and **0.1 percentage point recovery loss or FPR increase**. A +nonpositive or uncertain subgroup advantage gives zero allowance. This protects +at least 95% of an established advantage and avoids treating even a one-percent +SNR loss as harmless when the entire advantage is around one percent. + +The development expected-SNR guard requires positive benefit in every sampled +case under both white and OU variance before considering a one-sided bootstrap +lower mean bound. The recovery guard uses the lower paired development bound. +These are conservative engineering guards, not claims of universal population +coverage. If the development family diagnostic itself cannot establish a +benefit, it cannot justify an approximation budget. The anticipated production +path is exact optimization: no discarded observations, durations, epochs or +candidates and no changed decisions on qualification inputs. Shared native +float32-prefix variability remains explicitly recorded; failed bitwise checks +are not silently assigned a wider tolerance. + +## Physical coverage + +| Regime | Stellar/geometry sampling | Cadence / period domain | +| --- | --- | --- | +| TESS solar | Solar mass/radius; impact .2–.7 | Observed 200 s sector; injections 2–6 d, blind .6–12.878 d | +| TESS high impact | Solar; impact .94–.96 | Same cadence; 2–6 d | +| TESS eccentric | Solar; e=.7–.8, omega=90°, impact .2–.7 | Same cadence; 6–12 d | +| TESS small M dwarf | .1 solar mass/radius, density 100×solar; impact .2–.7 | Same cadence; 2–6 d | +| ZTF solar | Solar; impact .2–.7 | Observed g/r timestamps over 2,744 d; 2–6 d, blind .6–10 d | +| ZTF high impact | Solar; impact .94–.96 | Same ZTF cadence/domain | +| ZTF small M dwarf | .1 solar mass/radius | Same ZTF cadence/domain | +| Separated TESS, long | Solar; impact .2–.7 | Observed separated sectors over 735 d; 15–25 d, blind .6–27.458 d | +| TESS grazing/smeared | Solar; impact .999–1.003 | Every ninth archived 200 s sample; 1,800 s exposures; 2–6 d | +| HATpi-like short | Solar; impact .2–.96 | Synthetic 30 s cadence, eight-hour nights, absent nights/nightly gaps; .65–2 d, blind .6–5 d | + +All use Earth-size planets, fixed quadratic limb darkening [.4804,.1867], +achromatic transit depth, heterogeneous supplied errors and an OU component +with amplitude .25 times median error. The OU time scale is one day for ZTF +and .15 day otherwise. Oracle SNR 6/8/10/12 specifies the preassigned target for +the centered physical signal in *white* noise; correlated noise is extra. +Unsampled signals can realize SNR zero and remain in their assigned target +groups. The expected-SNR diagnostic computes the realized centered signal norm. +Null noise scale is drawn +from the same latent physical mixture independently. There is no observability +rejection, so sparse signals do not disappear before evaluation. TESS/ZTF +cadences are observed timestamps with synthetic flux; HATpi is entirely +synthetic. Errors are rescaled toward the target oracle SNR, so these controlled +ZTF sampling experiments do not forecast Earth/Sun transit yields at actual +ZTF photometric precision. Band offsets are assumed removed. + +Period grids use fixed **regime-level** oversampling: 9 for high-impact TESS, +high-impact ZTF, eccentric TESS and HATpi; 24 for grazing/smeared TESS; 3 for the +others. This policy was chosen using physical boundary durations in development, +not the realized injection truth. Both detectors receive the same grid. The +native SDE normalization keeps oversampling setting 3. These duration-informed +benchmark strata do not imply that an operational survey knows impact or + eccentricity; an unknown-regime survey needs a correspondingly conservative +common grid. Every case records nearest-grid drift relative to the recovery +criterion. The original coarse-grid grazing development failure is retained. + +`boundaries.py` checks 32/64/128-node exposure quadrature on observed development +inputs and joint M-dwarf/high-impact/grazing/eccentric boundaries at .65, 10 and +365.25 days with 30/200/1800 s exposures. These annual cases are known-transit +physical diagnostics, not blind annual-period recovery or throughput evidence. +The existing numerical stress archive supplies separate annual-period tests. + +Unsupported claims include universal sensitivity, arbitrary stellar/planetary +populations, omega outside the sampled 90°, limb-darkening mismatch, +chromatic/multiband fitting, real survey-flux systematics, transit-timing +variations, eclipsing-binary rejection, starspot distributions, annual blind +search completeness and real-HATpi recovery. The declared two stellar-density +points and narrow planet-radius population are deliberate finite coverage, +not a physical continuum. + +## Reproduction + +Use Python with numpy, scipy and batman-package for generation/analysis; +current cuvarbase, CuPy and PyCUDA are additionally required on the single GPU. +Generate final development after preserving any rejected development design: + +```sh +python benchmarks/tls_survey/generate.py --split development --count 8 --out STUDY/development +python benchmarks/tls_survey/generate.py --split development_nulls --count 64 --out STUDY/development-nulls +python benchmarks/tls_survey/development.py --manifest STUDY/development/manifest.json --out STUDY/development-snr.json +python benchmarks/tls_survey/boundaries.py --manifest STUDY/development/manifest.json --out STUDY/boundaries.json +python benchmarks/tls_survey/bls_response.py --manifest STUDY/development/manifest.json --out STUDY/bls-response.json +python benchmarks/tls_survey/run.py --manifest STUDY/development/manifest.json --methods tls bls_medium bls_fine bls_finest bls_strong --shard-count 4 --shard-index 0 --out STUDY/development-search-0.json +``` + +Run shards 0–3 in separate processes on the same GPU, each with its own receipt; +repeat for development nulls. The runner enforces one numerical-library thread +per process. Four processes share the same CPU/memory allocation and GPU. +Execution timings here are scientific-run receipts, not sustained-throughput +measurements. `throughput.py` provides the latter with separately tuned batch +and worker settings. + +```sh +python benchmarks/tls_survey/analyze.py freeze --development STUDY/development-search-*.json --development-nulls STUDY/development-null-search-*.json --snr STUDY/development-snr.json --out STUDY/seal.json +python benchmarks/tls_survey/generate.py --split calibration --count 512 --seal STUDY/seal.json --out STUDY/calibration +python benchmarks/tls_survey/run.py --manifest STUDY/calibration/manifest.json --methods tls bls_medium bls_fine bls_finest bls_strong --seal STUDY/seal.json --shard-count 4 --shard-index 0 --out STUDY/calibration-search-0.json +python benchmarks/tls_survey/analyze.py calibrate --seal STUDY/seal.json --results STUDY/calibration-search-*.json --out STUDY/thresholds.json +``` + +After all calibration shards finish and thresholds are frozen, generate +`--split injections --count 256` and `--split nulls --count 256`, run all four +shards with the original seal, then: + +```sh +python benchmarks/tls_survey/analyze.py analyze --seal STUDY/seal.json --thresholds STUDY/thresholds.json --injections STUDY/injection-search-*.json --nulls STUDY/null-search-*.json --out STUDY/recovery.json +``` + +Final-data execution runs only the selected BLS configuration per regime. +A successful degenerate/all-masked TLS result with no candidate and SDE=0 is a valid nondetection with score zero; actual API errors or unavailable scores fail calibration rather than silently lowering a threshold. +An unused diagnostic ranker's failure does not invalidate the selected detector. +Atomic resumable receipts retain every input identity, planned population, +source version, candidate, spectrum/validity hashes and API failure. Analysis +rejects missing counts, duplicate cases, unpaired inputs and source drift. + +After reviewing the concrete seal, `campaign.py` can run the remaining stages +as a detached process. Supply the reviewed SHA literally; it verifies the seal +and sources before each stage, preserves interrupted input generation, resumes +search receipts, and exports an exact deduplicated array bank when requested: + +```sh +python benchmarks/tls_survey/campaign.py --seal STUDY/seal.json --seal-sha256 REVIEWED_SHA256 --work STUDY/heldout --export-bank +``` + +Restart the same command after an interruption. An exclusive local lock prevents +two controllers sharing one work directory. `campaign.json` records workers, +commands, logs, heartbeats and failures. This controller never provisions or +terminates a rented resource; its owner must separately enforce the authorized +cost limit and download artifacts before termination. + +`exactness.py` supplies a separate implementation qualification on every final +injection and independent test null (5,120 paired inputs for this design). +Freeze its auxiliary plan before held-out generation and review its SHA beside +the science seal. After the science campaign completes, one worker runs the +immutable pre-optimization TLS checkout on those exact inputs and compares to +the **original** candidate receipts: complete available period/chi2 spectra and +validity hashes, chosen period/SDE, recovery and both frozen-threshold decisions. + +```sh +python benchmarks/tls_survey/exactness.py freeze --seal STUDY/seal.json --baseline-root BASELINE --campaign STUDY/heldout --out STUDY/exactness-plan.json +python benchmarks/tls_survey/exactness.py run --seal STUDY/seal.json --plan STUDY/exactness-plan.json --plan-sha256 REVIEWED_PLAN_SHA256 --campaign STUDY/heldout --out STUDY/exactness-results.json +``` + +Any mismatch withholds aggregate exactness qualification. The first ten +mismatching inputs receive two further native-baseline diagnostic runs; each +original outcome is persisted before those runs and cannot be replaced by a +later matching repeat. The plan records the development-based extra cost +estimate (about 4.84 GPU hours / $2.37 at $0.49 per hour), separately from measured +sustained throughput. Finite paired checks do not establish universal physical +or numerical equivalence. + +The retained [development implementation comparison](../../docs/BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/development-promoted-baseline-parity.json") +has 79 exact results out of 80. One HATpi-like case changed its chi2 hash and SDE +by about 3.34e-6 while retaining its period, valid mask and recovery/alias +decisions; all 32 cases using the new short-row path matched. This is a recorded +failure of aggregate bitwise equality, with no numerical tolerance relaxed and +no cause assigned from the regime alone. + +The same auxiliary plan pins `heldout_snr.py`. This CPU-only wrapper applies +the unchanged development filter definitions to all 2,560 held-out injections, +without relabeling their split or tuning any setting. Its scientific fields +matched the original diagnostic exactly on all 80 development inputs. Run it +after the science campaign and before sustained throughput measurement: + +```sh +python benchmarks/tls_survey/heldout_snr.py run --seal STUDY/seal.json --plan STUDY/exactness-plan.json --plan-sha256 REVIEWED_PLAN_SHA256 --manifest STUDY/heldout/inputs-injections/manifest.json --out STUDY/heldout-snr.json +``` + +Join descriptive filter ceilings to original detections by input name/hash and +regime. They explain physical/sampling losses and remain distinct from package +SDE/SNR, actual native depth/ranking, and the predeclared blind recovery endpoint. diff --git a/benchmarks/tls_survey/RECOVERY_RUNBOOK.md b/benchmarks/tls_survey/RECOVERY_RUNBOOK.md new file mode 100644 index 00000000..8285bbaa --- /dev/null +++ b/benchmarks/tls_survey/RECOVERY_RUNBOOK.md @@ -0,0 +1,52 @@ +# Rendering the sealed recovery report + +Run this CPU-only formatting step after the original scientific analysis, +complete immutable-baseline qualification, and held-out SNR diagnostic finish: + +```sh +/workspace/tls-survey/modern/bin/python \ + /workspace/tls-survey/candidate/benchmarks/tls_survey/report_recovery.py \ + --recovery /workspace/tls-survey/final-campaign/detection-results.json \ + --seal /workspace/tls-survey/evidence/seal-final.json \ + --exactness /workspace/tls-survey/evidence/exactness-final.json \ + --snr /workspace/tls-survey/evidence/heldout-snr-final.json \ + --output /workspace/tls-survey/final-campaign/report +``` + +The renderer imports only the Python standard library. It checks the original +seal and threshold identities, planned regime/method/FPR rows and denominators, +paired-contrast counts, subgroup membership, original execution receipt hashes, +and every planned baseline comparison. It reports scientific execution failures +and numerical mismatches; they are not removed from denominators or replaced +by diagnostic reruns. A complete exactness execution can validly produce a +report that withholds aggregate exactness. An incomplete or inconsistent input +fails before rendering. + +Outputs are `RECOVERY.md`, `recovery_fpr.csv`, `paired_contrasts.csv`, +`thresholds.csv`, `subgroups.csv`, `exactness.csv`, `exactness_mismatches.csv`, +`snr_descriptive.csv`, `snr_cases.csv`, and `provenance.json`. The provenance +retains source JSON hashes, the renderer hash, validated counts, and output +hashes. Existing inference intervals are copied unchanged into CSVs. Markdown +percentages are rounded only for readability. Native TLS/BLS detection evidence +and baseline/optimized TLS exactness have separate sections. + +SNR subgroup levels 6/8/10/12 are preassigned latent targets. An unsampled +injection can realize SNR zero and remains in its target group. The +`subgroups.csv` rows with `kind=snr` preserve those assignments; the separate +held-out diagnostic computes the realized centered signal norm. + +`--snr` is optional. When supplied, it must be the complete held-out diagnostic +for the original injection manifest and case identities. Medians and observed +ranges describe its native-family/ideal-box white/OU SNR values by regime and +original TLS detected/missed groups. White responses are the enumerated +known-period family ceilings; OU values evaluate those same white-selected +filters using the OU covariance, rather than independently optimizing its +objective. These diagnostics are not package SNR, actual blind-search gain, new inference, or new +approximation allowances. Undefined ratios and empty groups remain visible. + +Rerendering the same inputs is allowed. A previous report directory cannot be +reused for different inputs or a changed fixture mode. Synthetic smoke fixtures +must carry `synthetic_fixture: true` in every input and use `--synthetic`, which +places a prominent non-scientific watermark on the report. The external smoke +fixture is `/tmp/cuvarbase-recovery-report-SYNTHETIC-v2/`; unit tests construct +their own explicitly synthetic temporary inputs. diff --git a/benchmarks/tls_survey/THROUGHPUT_PROTOCOL.md b/benchmarks/tls_survey/THROUGHPUT_PROTOCOL.md new file mode 100644 index 00000000..fb36cfd0 --- /dev/null +++ b/benchmarks/tls_survey/THROUGHPUT_PROTOCOL.md @@ -0,0 +1,193 @@ +# Sustained TLS throughput protocol + +This protocol is declared before the first tuning pilot. The scientific +calibration and recovery study has its own frozen protocol. Timing cannot +establish detection sensitivity. + +## Workload and competitors + +Use the first eight manifest-order development inputs in each of `tess_solar`, +`tess_gap_long`, and `ztf_solar`: 24 distinct sources with dense, separated-sector, +and sparse sampling and different observation/grid sizes. Run complete blind +searches on the supplied period arrays. Do not insert truth periods, shorten +grids, or use approximate screening. The executed campaign compares branch +baseline, optimized default TLS, public pinned GTLS, and selected BLS. Corrected +GTLS remains supported by the reproducer but is not included in this timing +campaign; its numerical role is covered by the archived corrected-reference +study. That adapter changes only the documented invalid-candidate host mask. +Include GPU BLS using the science seal's chosen method and ranker for each +regime; require unchanged scientific and production source hashes. Run the same +GPU BLS search and chosen ranking operations. Only qualifying calls compute +complete power/period arrays, mask hashes and all development rankers; timed calls +compute the selected ranker alone. CPU parity checks compare this wrapper's full +and compact results with the frozen science runner for all three rankers on the +same supplied powers. GPU checks require exact selected-period/score repeatability. +Tune BLS's pool and task batch size independently under the same +five-configuration rule. A BLS task calls its single-lightcurve API serially; +batching bundles calls for dispatch. BLS throughput does not imply that its +separately calibrated recovery equals TLS recovery. + +Each method runs by itself on the same single GPU and container CPU/memory +allocation. Numerical libraries use one CPU thread per worker. Record GPU UUID, +CPU quota, memory limit, source/input hashes and worker-context ownership. +Concurrent CPU calibration/generation on the rental must finish before timing; +input generation on another machine may overlap. + +## Predeclared tuning rule + +Tune each backend independently. First compare persistent pools of **one, two +and four workers at batch size one**. Select the fastest numerically eligible +worker count. At that count, compare **batch sizes four and eight**, retaining +the batch-one result. This tests five configurations per backend. It explores a +conditional parameter space and does not establish a global optimum. The optional +`--exhaustive` reproduction flag tests all nine combinations; it is not part of +the declared executed campaign. + +Each pilot completes at least 24 sources and at least 30 seconds, in complete +cohort cycles, with one repetition. Select the highest completed-source rate +among eligible tested configurations; exact ties choose fewer workers and then +smaller batches. Record actual API batch sizes. Batches never mix different +period arrays or search options. Both current observation-level APIs process +the sources within a worker's task serially; this batch control bundles public +calls and changes dispatch/load balance, rather than introducing an approximate +multi-source kernel. + +All failures and attempted settings remain in `campaign.json`. A failure is +never a successful timing denominator. If the one-worker reference fails, that +backend has no qualifying pool. The selected operating configuration is frozen +before independent timing outcomes are opened. Per-cadence final panels use +that configuration; they are not separately tuned cadence-specific optima. + +## Numerical qualification + +For TLS and GTLS, first freeze each backend's successful one-worker complete +period, chi-squared and mask arrays and selected period/SDE. Before and after each queue, +every distinct input is searched with full arrays on every worker; all those +outputs must equal the backend's one-worker reference exactly. This prevents a +native memory-dependent duration-group change from qualifying itself. +The optimized +implementation must also match the branch baseline's complete search-output +fingerprints on the actual timing cohort. + +**BLS amendment, accepted before scientific freeze or any tuning pilot +(2026-09-11 UTC).** BLS requires exact period arrays and finite masks, and exact +period and score of the science-selected ranker, against its own one-worker +reference before/after queues and against the compact output during every queue. +The complete BLS power arrays and all ranker outputs are retained outside timed +queues; report nonwinning power changes, maximum absolute/relative differences, +and each ranker's selected-period/score variation separately. Nonwinning power +or unused-ranker variation alone does not reject BLS. Any actual selected-period +or selected-score mismatch still disqualifies that pool or panel. Preserve the +numeric values and all failures; do not relax this endpoint gate after tuning or +held-out outcomes. This is an operational BLS repeatability gate, not a claim of +full BLS spectrum equivalence or equal BLS/TLS sensitivity. + +The amendment follows the retained development-only `smoke-harness-v3` failure +and a six-repeat diagnostic on each combination of ordinary TESS/ZTF inputs and +`bls_finest`/`bls_strong` (24 calls, 66.78 seconds). Full science calls changed +nonwinning powers by at most 9.31e-9 (maximum 10 ULP), with identical finite +masks. Within each combination the raw/likelihood selected periods and scores +were exact; unused detrended scores varied by up to 1.37e-5. This is consistent +with the existing [BLS reproducibility documentation](../../docs/source/bls.rst) +and unordered float32 atomic accumulation in both fused and multipass kernels. +No supported deterministic setting exists for this fast backend. The bounded +probe does not guarantee selected endpoint stability on the final population. +Old smoke protocols, failed receipts, complete repeat arrays and the diagnostic +script remain archived; the TLS numerical gates are unchanged. + +Measured tasks check selected period/SDE and source membership while keeping +only compact results. Complete-array validation surrounds the queue; it does +not establish the identity of every unreturned intermediate array in every +repeated call. Long shared float32 scans can be nondeterministic; a failed strict +TLS/GTLS gate remains a failure and requires separate diagnosis. No numerical +tolerance is expanded after a tuning or held-out failure. + +## Independent sustained measurement + +Use the first **16 manifest-order independent nulls per cadence**, selected by +identity and successful input generation, never by timing or detection outcome. +These are distinct from development inputs. Search each cadence separately. +Replace the final balanced-mixture panel with **96 distinct derived nulls**: +the first 32 independent nulls per cadence, with deterministic retained fractions +`0.8 + 0.2*i/31`, for manifest positions `i=0..31`. Round retained counts with +NumPy `rint`. Always retain the first/last observation and draw the remaining +interior indices without replacement, using `default_rng` seeded from SHA256 +of `tls-survey-throughput-varied-v1`, a NUL byte, and the original filename. +Slice times, flux and errors together and preserve the original period grid. +Keep original input hashes and retained indices in the derived-input manifest. +These modified nulls are for throughput only and must not enter recovery or +false-positive inference. They exercise many observation-array lengths and +repeated cache-plan construction throughout the queue. For each backend's frozen setting, run **three +queues**, each completing **at least 96 light curves and at least 120 seconds**. +Finish whole cohort cycles and keep at most one task per worker in flight. +Repeated cycles measure execution on fixed inputs, not additional independent +astrophysical trials. The varied queue has 96 distinct inputs before any repeat; +the per-cadence panels reuse their 16 distinct original nulls in complete cycles. + +The elapsed queue clock includes dispatch, input validation, template preparation, +transfers, full coarse search, candidate/harmonic refinement, output construction, +scalar checking and completion. cuvarbase uses its normal compact survey output +(`return_arrays=False`); public GTLS always returns spectra and additional noise +diagnostics. The public-call comparison includes that output-policy difference. +BLS's public GPU API downloads its power array, which is required by the chosen +ranker; timed calls omit unused rankers and complete-spectrum hashing. +Profiles are separate, instrumented diagnostics and never timing denominators. + +Cold records retain worker import/context and input-loading time, first-public- +batch latency, and the first complete qualifying cohort. Recreate one reusable +period grid per shared configuration/worker from sealed `grid_kwargs` and demand +byte equality with the supplied array. Its measured construction time is +included in startup amortization. Historical inputs lacking a recipe are marked +array-only and cannot support an all-preparation-included claim. Cold-amortized +rates conservatively charge the complete validation warmup, including hashes +and each worker's duplicate qualifying inputs; those costs are identified +separately from first-API latency. +Worker processes are fresh, while existing filesystem compiler/kernel caches +remain available. Report these as process-cold latencies, including any actual +first-use compilation or guard canary cost, without claiming an empty disk +cache or a first-ever installation measurement. +The guarded short-row CUB module is a specific exception to filesystem reuse: +direct NVCC compilation explicitly disables flush-to-zero to match the native +CUB wheel; only the resulting process/context module is cached in memory. +Each fresh supported worker/context therefore pays that compilation and startup +canary, even when unrelated CuPy filesystem kernel caches are warm. +Record the guarded short-prefix status after warmup, after the measured queues, +and before worker teardown: actual dispatch/fallback counts, guard or compiler +failure reason, context/device, and compilation/canary time. Reading the status +must not compile or activate the optimization. The baseline's missing helper +is recorded explicitly; native GTLS and BLS mark it not applicable. + +Sample device memory, worker RSS and container memory every 0.1 seconds. GPU +and container sampling spans startup, qualification, queues, and teardown; +worker RSS sampling begins when the worker pool reports ready, supplemented +by lifetime RSS high-water marks. Reject observed foreign GPU processes during +that interval, including the explicit checks immediately before/after queues. +Sampled +peaks are lower bounds; also retain worker lifetime RSS high-water marks and +allocator reservation sizes. A container's cumulative memory high-water mark +can include earlier configurations and is labeled accordingly. Record the +bundle's actual hourly price and compute measured-queue and cold-amortized cost +projections. Million-source costs are projections, excluding data acquisition, +survey preprocessing and vetting. + +## Reporting + +`measurements.csv` contains every attempted configuration, rates, cold timing, +projected costs and memory. The one performance figure uses qualified independent +measurements only: median completed light curves per second with the observed +range across three repetitions. Its four panels show dense TESS, separated TESS, +ZTF and the varied-size queue, with separate y scales explicitly labeled. Display +baseline, optimized TLS, public GTLS, and the science-selected BLS. Use logarithmic y scales to accommodate +different method costs and identify these scales on the figure. + +Predeclared failure handling: retain a visibly missing bar labeled **no qualifying +result** and its reason if a competitor fails qualification or has no eligible +development setting. Continue independent remaining backends and panels; do not +abort the entire measurement merely because one competitor fails its fresh +single-worker reference. Every planned panel remains visible, with no failed +result entering a rate or speedup denominator, no relaxed gate, no post-hoc +case subset, and no held-out retuning. A failed baseline/optimized paired gate +suppresses the optimized bar and ratio in that panel. If either result is +unavailable, no baseline/optimized ratio is reported. All qualified displayed +results must have identical GPU/CPU/memory receipts. These finite three-cadence timing workloads do +not establish universal survey throughput or recovery equivalence. diff --git a/benchmarks/tls_survey/THROUGHPUT_RUNBOOK.md b/benchmarks/tls_survey/THROUGHPUT_RUNBOOK.md new file mode 100644 index 00000000..3ff1212b --- /dev/null +++ b/benchmarks/tls_survey/THROUGHPUT_RUNBOOK.md @@ -0,0 +1,89 @@ +# Running the final single-GPU timing campaign + +The science controller must finish all GPU work and remote input generation +first. The following paths describe the recorded survey allocation; adjust +paths when reproducing elsewhere while preserving the source and input hashes. +The baseline checkout is revision `6ced75d6d75bfaafa39b78c557fcba86f4651d92`. +The final candidate must match the scientific seal's production sources. +No command here provisions or terminates a resource. + +```sh +export PATH="/usr/local/cuda/bin:$PATH" + +/workspace/tls-survey/modern/bin/python \ + /workspace/tls-survey/candidate/benchmarks/tls_survey/throughput_campaign.py \ + --stage tune \ + --manifest /workspace/tls-survey/dev-final/manifest.json \ + --output /workspace/tls-survey/evidence/throughput-tune-final \ + --baseline-root /workspace/tls-survey/baseline \ + --candidate-root /workspace/tls-survey/candidate \ + --science-seal /workspace/tls-survey/evidence/seal-final.json \ + --backends baseline candidate gtls bls \ + --hourly-usd 0.49 --max-hours 4 +``` + +This declares the timing plan before the first pilot, runs the separately +tuned five-setting search for each of the four competitors, and freezes one +eligible operating setting per backend. Corrected GTLS is supported for an +explicit separate reproduction, but is not part of this executed campaign. +The CUDA compiler directory must remain on PATH for every competitor and stage: +PyCUDA BLS invokes `nvcc` by name, while CuPy may discover the compiler through +an absolute CUDA toolkit path. Do not set the `NVCC` variable; the guarded +short-prefix path conservatively rejects custom compiler commands. +The first one-worker tuning warmup also serves as the later integration check +of the BLS qualification amendment; no extra held-out smoke population is opened. +Every BLS configuration retains complete pre/post-queue spectra and native +repeat diagnostics, while exact selected endpoints remain required. Preserve +the earlier `smoke-harness-v2`, `smoke-harness-v3` and `bls-native-repeat-v1` +development-only failures/diagnostics alongside the amended timing protocol. + +```sh +/workspace/tls-survey/modern/bin/python \ + /workspace/tls-survey/candidate/benchmarks/tls_survey/throughput_campaign.py \ + --stage measure \ + --manifest /workspace/tls-survey/final-campaign/inputs-nulls/manifest.json \ + --output /workspace/tls-survey/evidence/throughput-final \ + --tuning /workspace/tls-survey/evidence/throughput-tune-final/campaign.json \ + --baseline-root /workspace/tls-survey/baseline \ + --candidate-root /workspace/tls-survey/candidate \ + --science-seal /workspace/tls-survey/evidence/seal-final.json \ + --backends baseline candidate gtls bls \ + --hourly-usd 0.49 --max-hours 10 + +/workspace/tls-survey/modern/bin/python \ + /workspace/tls-survey/candidate/benchmarks/tls_survey/plot_throughput.py \ + /workspace/tls-survey/evidence/throughput-final/campaign.json \ + --exactness /workspace/tls-survey/evidence/exactness-final.json \ + --science-seal /workspace/tls-survey/evidence/seal-final.json \ + --output /workspace/tls-survey/evidence/throughput-final/performance +``` + +Add `--resume` to an existing tuning or measurement campaign only with unchanged +sources, protocol, seal, and inputs. Completed configurations are reused by +hash; failed configurations remain failed. A failed competitor's fresh +qualification does not prevent the remaining planned panels from running. +The time cap is checked between configurations and does not kill an active GPU +call. It resets per invocation, so the resource owner's outer lifecycle/budget +guard must account for all attempts, preparation, and scientific work. + +Before launch, allow roughly **1.5–3 hours for tuning and 4–8 hours for final +measurement/qualification**, approximately **$2.70–5.40 combined at $0.49/hour**. +These are conservative planning estimates, not measured results. In particular, +four-worker strong-BLS development calls for ordinary ZTF took roughly 43 seconds +each under contention; archived public-GTLS single-source ZTF timings were +roughly 15 seconds on another GPU. Every distinct qualifying input runs on every +worker, before and after the sustained queues, so qualification is a material +part of study cost. Actual eligible settings and hardware determine runtime. + +Archive both timing directories, their logs, the derived varied-input manifest +and arrays, the original null manifest/arrays, the scientific seal, all source +identities, the figure and its data receipt, and any failure records. The outer +resource owner must archive and terminate the rental on success **or failure**; +these benchmark scripts intentionally do not own resource lifecycle operations. +See [the frozen timing policy](THROUGHPUT_PROTOCOL.md) for numerical gates, +timing boundaries, process-cold accounting, and the missing-competitor policy. +The figure requires the complete held-out exactness receipt and original science +seal, validates all planned regime/split counts, and prominently reports X/N +exact cases. A failed aggregate qualification remains withheld even when a +separate timing cohort supports its own speed ratio. The accompanying figure +CSV and JSON retain that status and both scientific and auxiliary-plan identities. diff --git a/benchmarks/tls_survey/analyze.py b/benchmarks/tls_survey/analyze.py new file mode 100644 index 00000000..9c520b14 --- /dev/null +++ b/benchmarks/tls_survey/analyze.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Select BLS on development, freeze tolerances, calibrate, then analyze holdout.""" +import argparse +import json +from pathlib import Path +import numpy as np +from scipy.stats import beta +from common import BLS_CONFIGS,REGIMES,SNRS,now,sha,source_identity,write,method_applicable + + +def binomial_interval(k,n,alpha=.05): + if n<1: + return [0.,1.] + return [float(beta.ppf(alpha/2,k,n-k+1)) if k else 0., + float(beta.ppf(1-alpha/2,k+1,n-k)) if klen(scores): + raise ValueError('Insufficient nulls for this finite threshold/FPR') + value=float(np.sort(scores)[rank-1]) + above=int(np.sum(scores>value)) + tied=int(np.sum(scores==value)) + return dict(value=value,n=len(scores),rank_1based=rank, + calibration_scores_above=above,calibration_scores_at_threshold=tied, + calibration_zero_scores=int(np.sum(scores==0.)), + calibration_strict_exceedance_fraction=above/len(scores), + extra_conservatism_from_ties=abovecut and + (null or r['candidates'][ranker]['recovered']) for r in rows],bool) + + +def require_count(rows,expected,where): + if len(rows)!=expected: + raise ValueError('%s: expected %d cases, got %d'%(where,expected,len(rows))) + + +def require_paired(a,b): + if [r['input_sha256'] for r in a]!=[r['input_sha256'] for r in b]: + raise ValueError('Paired methods did not receive identical ordered lightcurves') + + +def freeze(args): + dev,dev_receipts=records(args.development,'development') + nulls,null_receipts=records(args.development_nulls,'development_nulls') + diag=json.loads(args.snr.read_text()) + if any(r['manifest_sha256']!=diag['manifest_sha256'] for r in dev_receipts): + raise ValueError('Expected-SNR and blind development manifests differ') + regimes=args.regimes.split(',') + selected={};tol={};development=[] + identities=[r['production_sources'] for r in dev_receipts+null_receipts] + if any(v!=identities[0] for v in identities): + raise ValueError('Development numerical sources differ across receipts') + for regime in regimes: + candidates=[] + tdev=by_method(dev,regime,'tls');tnull=by_method(nulls,regime,'tls') + if not tdev or not tnull: + raise ValueError('TLS missing development regime: '+regime) + if not all(r['valid'] for r in tnull): + raise ValueError('Invalid TLS development nulls prevent calibration') + tcut=threshold([score(r,'native') for r in tnull],args.fpr) + td=detections(tdev,'native',tcut['value']) + for method in BLS_CONFIGS: + if not method_applicable(method,regime): + continue + bdev=by_method(dev,regime,method);bnull=by_method(nulls,regime,method) + require_paired(tdev,bdev);require_paired(tnull,bnull) + if not all(r['valid'] for r in bnull): + continue + for ranker in ('raw','likelihood','detrended'): + if not all(np.isfinite(score(r,ranker)) for r in bnull): + continue + cut=threshold([score(r,ranker) for r in bnull],args.fpr) + detected=detections(bdev,ranker,cut['value']) + # Fidelity tie-break favors finest duration/epoch resolution, + # then delta-chi2 likelihood ranking; speed never weakens the control. + candidates.append(dict(method=method,ranker=ranker,detected=int(detected.sum()), + n=len(detected),threshold=cut,paired_tls_minus_bls=paired_interval(td,detected), + median_search_s=float(np.median([r['elapsed_s'] for r in bdev])))) + if not candidates: + raise ValueError('No complete BLS development comparison: '+regime) + winner=max(candidates,key=lambda c:(c['detected'],BLS_CONFIGS[c['method']]['noverlap'],{'detrended':0,'raw':1,'likelihood':2}[c['ranker']])) + selected[regime]={k:winner[k] for k in ('method','ranker')} + sr=[r for r in diag['rows'] if r['regime']==regime] + if len(sr)!=len(tdev): + raise ValueError('Expected-SNR and blind development populations differ') + snr_vectors={k:np.array([r[k] for r in sr if r[k] is not None],float) + for k in ('native_white_advantage','native_ou_advantage')} + # Protect every development example and both covariance diagnostics; + # an uncertain/nonpositive advantage gives no approximation allowance. + rng=np.random.default_rng(142091) + lowers={} + for key,values in snr_vectors.items(): + if len(values)!=len(sr) or np.min(values)<=0: + lowers[key]=0. + else: + means=np.mean(rng.choice(values,size=(10000,len(values)),replace=True),axis=1) + lowers[key]=max(0.,float(np.quantile(means,.05))) + snr_advantage=min(lowers.values()) + recovery_advantage=max(0.,winner['paired_tls_minus_bls']['interval'][0]) + tol[regime]=dict(expected_snr_fractional_loss=min(.001,.05*snr_advantage), + recovery_absolute_probability_loss=min(.001,.05*recovery_advantage), + snr_demonstrated_advantage_lower=snr_advantage, + recovery_demonstrated_advantage_lower=recovery_advantage, + observed_development_white_median=float(np.median(snr_vectors['native_white_advantage'])), + observed_development_ou_median=float(np.median(snr_vectors['native_ou_advantage'])), + fpr_absolute_increase_max=0.,fpr_absolute_cap=.001) + development.append(dict(regime=regime,tls=dict(detected=int(td.sum()),n=len(td),threshold=tcut), + bls_candidates=candidates,selected=winner)) + value=dict(schema_version=1,created_utc=now(),source_identity=source_identity(),regimes=regimes, + counts=dict(calibration=args.calibration_count,injections=args.injection_count,nulls=args.null_count), + exposure_nodes=args.exposure_nodes,target_fpr=args.fpr,secondary_target_fpr=.01,bls_selected=selected, + primary_advantage='Blind calibrated recovery; expected-SNR family ceilings are explanatory, not actual native-search sensitivity.', + tolerance_rule='At most 5% of a demonstrated positive TLS advantage, capped at 0.1% fractional expected SNR and 0.1 percentage point recovery/FPR. Any nonpositive/uncertain subgroup advantage gives zero allowance.', + tolerances=tol,production_sources=dev_receipts[0]['production_sources'],execution_shards=args.execution_shards, + production_acceptance=dict(policy='exact only',removed_trials_allowed=0, + changed_valid_masks_allowed=0,changed_candidate_or_detection_decisions_allowed=0, + approximate_screening='No screening before an unconditional full observation-level fallback', + numerical='Full spectra and fits must match reference on qualification inputs. Shared float32 prefix variability is recorded and never silently widened.'), + calibration_policy='Separate independently generated nulls per regime and per method. Strict order-statistic thresholds. Independent test nulls report realized FPR uncertainty.', + recovery_policy='Primary period drift over baseline <= half physical contact duration; aliases separately descriptive; unsampled/one-event cases and failures remain denominator.', + heldout_analysis_policy='No changes to settings, seeds, thresholds, counts, endpoints, or tolerances after heldout results. Marginal exact intervals plus simultaneous regime paired bounds; pilot may be inconclusive.', + development_receipts=dev_receipts+null_receipts,development_snr_sha256=sha(args.snr),development=development) + if args.out.exists(): + raise ValueError('Refuse to overwrite a frozen seal') + write(args.out,value) + + +def calibrate(args): + seal=json.loads(args.seal.read_text()) + rows,receipts=records(args.results,'calibration') + if any(r['production_sources']!=seal['production_sources'] for r in receipts): + raise ValueError('Calibration numerical sources differ from frozen design') + thresholds={};secondary={} + for regime in seal['regimes']: + for label,method,ranker in [('tls','tls','native'),('bls',seal['bls_selected'][regime]['method'],seal['bls_selected'][regime]['ranker'])]: + population=by_method(rows,regime,method) + require_count(population,seal['counts']['calibration'],regime+'/'+label) + if not all(r['valid'] for r in population): + raise ValueError('Failed calibration nulls: '+regime+'/'+label) + thresholds[regime+'/'+label]=threshold([score(r,ranker) for r in population],seal['target_fpr']) + secondary[regime+'/'+label]=threshold([score(r,ranker) for r in population],seal['secondary_target_fpr']) + if args.out.exists(): + raise ValueError('Refuse to overwrite independently frozen thresholds') + write(args.out,dict(created_utc=now(),seal_sha256=sha(args.seal),thresholds=thresholds,secondary_thresholds=secondary,receipts=receipts)) + + +def analyze(args): + seal=json.loads(args.seal.read_text());cuts=json.loads(args.thresholds.read_text()) + if cuts['seal_sha256']!=sha(args.seal): + raise ValueError('Thresholds belong to another design') + inj,ireceipts=records(args.injections,'injections');null,nreceipts=records(args.nulls,'nulls') + for receipt in cuts['receipts']+ireceipts+nreceipts: + if receipt['production_sources']!=seal['production_sources']: + raise ValueError('Execution numerical sources differ from frozen design') + tables=[];contrasts=[] + for target_fpr,point_cuts in ((seal['target_fpr'],cuts['thresholds']), + (seal['secondary_target_fpr'],cuts['secondary_thresholds'])): + for regime in seal['regimes']: + vectors={} + populations={} + for label,method,ranker in [('tls','tls','native'),('bls',seal['bls_selected'][regime]['method'],seal['bls_selected'][regime]['ranker'])]: + ir=by_method(inj,regime,method);nr=by_method(null,regime,method) + require_count(ir,seal['counts']['injections'],regime+'/'+label+'/injections') + require_count(nr,seal['counts']['nulls'],regime+'/'+label+'/nulls') + cut=point_cuts[regime+'/'+label]['value'] + d=detections(ir,ranker,cut);fp=detections(nr,ranker,cut,null=True) + vectors[label]=(d,fp);populations[label]=(ir,nr) + strata=[] + for kind in ('snr','sampling'): + levels=SNRS if kind=='snr' else ('unsampled','one_event','two_events','three_plus_events','one_to_four_points','grid_unreachable') + for level in levels: + if kind=='snr': + take=np.array([r['white_oracle_snr']==level for r in ir]) + else: + take=np.array([not r.get('grid_reachable',True) if level=='grid_unreachable' else r['in_transit_observations']==0 if level=='unsampled' else + r['observed_events']==1 if level=='one_event' else r['observed_events']==2 if level=='two_events' else + r['observed_events']>=3 if level=='three_plus_events' else 0cut and r['candidates'][ranker]['alias_recovered'] for r in ir) + tables.append(dict(regime=regime,target_fpr=target_fpr,method=label,configuration=method,ranker=ranker,threshold=cut, + calibration=point_cuts[regime+'/'+label], + detected=int(d.sum()),n_injections=len(d),recovery=float(d.mean()),recovery_interval95=binomial_interval(int(d.sum()),len(d)), + aliases_including_fundamental=int(alias),false_positives=int(fp.sum()),n_nulls=len(fp),fpr=float(fp.mean()), + fpr_interval95=binomial_interval(int(fp.sum()),len(fp)),failed_injections=sum(not r['valid'] for r in ir), + failed_nulls=sum(not r['valid'] for r in nr),strata=strata)) + require_paired(populations['tls'][0],populations['bls'][0]);require_paired(populations['tls'][1],populations['bls'][1]) + contrasts.append(dict(regime=regime,target_fpr=target_fpr,tls_minus_bls_recovery=paired_interval(vectors['tls'][0],vectors['bls'][0]), + tls_minus_bls_recovery_simultaneous=paired_interval(vectors['tls'][0],vectors['bls'][0],.05/(4*len(seal['regimes']))), + tls_minus_bls_fpr=paired_interval(vectors['tls'][1],vectors['bls'][1]), + tls_minus_bls_fpr_simultaneous=paired_interval(vectors['tls'][1],vectors['bls'][1],.05/(4*len(seal['regimes']))))) + write(args.out,dict(created_utc=now(),seal_sha256=sha(args.seal),thresholds_sha256=sha(args.thresholds), + methods=tables,contrasts=contrasts,receipts=ireceipts+nreceipts, + limitation='Finite synthetic-flux population on fixed observed or synthetic cadences. Exact implementation qualification is separate. No universal completeness or sub-percentage noninferiority established. Marginal calibrated target FPR is not certainty about realized conditional FPR.')) + + +def main(): + p=argparse.ArgumentParser(description=__doc__);sub=p.add_subparsers(dest='command',required=True) + f=sub.add_parser('freeze');f.add_argument('--development',nargs='+',type=Path,required=True) + f.add_argument('--development-nulls',nargs='+',type=Path,required=True);f.add_argument('--snr',type=Path,required=True) + f.add_argument('--regimes',default=','.join(REGIMES));f.add_argument('--calibration-count',type=int,default=512) + f.add_argument('--injection-count',type=int,default=256);f.add_argument('--null-count',type=int,default=256) + f.add_argument('--execution-shards',type=int,default=4) + f.add_argument('--exposure-nodes',type=int,default=64);f.add_argument('--fpr',type=float,default=.05) + c=sub.add_parser('calibrate');c.add_argument('--seal',type=Path,required=True);c.add_argument('--results',nargs='+',type=Path,required=True) + a=sub.add_parser('analyze');a.add_argument('--seal',type=Path,required=True);a.add_argument('--thresholds',type=Path,required=True) + a.add_argument('--injections',nargs='+',type=Path,required=True);a.add_argument('--nulls',nargs='+',type=Path,required=True) + for cmd in (f,c,a):cmd.add_argument('--out',type=Path,required=True) + args=p.parse_args();globals()[args.command](args) + + +if __name__=='__main__': + main() diff --git a/benchmarks/tls_survey/bls_execution_throughput.py b/benchmarks/tls_survey/bls_execution_throughput.py new file mode 100644 index 00000000..1a3a1a1d --- /dev/null +++ b/benchmarks/tls_survey/bls_execution_throughput.py @@ -0,0 +1,776 @@ +#!/usr/bin/env python3 +"""Separate native-BLS execution timing; original numerical exclusion is retained. + +This supplement never grants numerical qualification. It uses the unchanged +compact BLS API wrapper, input recipes, resource monitor and ownership machinery. +Only native API/invalid-output errors are counted as failed completions. Broken +instrumentation, membership, source identity or resource ownership invalidates +the entire configuration's rates. No CUDA imports occur in the parent process. +""" +from __future__ import annotations + +import argparse +from collections import Counter +import hashlib +import json +import multiprocessing as mp +from multiprocessing.connection import wait +import os +from pathlib import Path +import signal +import sys +import time +import traceback + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from benchmarks.tls_survey import throughput as native +from benchmarks.tls_survey import throughput_campaign as primary + +SCOPES = (*primary.REGIMES, 'varied') +CLEANUP_RESERVE_SECONDS = 120 + + +class Interrupted(RuntimeError): + pass + + +def deadline_check(deadline): + if time.time() >= deadline - CLEANUP_RESERVE_SECONDS: + raise Interrupted('Shared supplemental deadline reached; reserving cleanup time') + + +def process_start_ticks(): + path = Path('/proc/self/stat') + return int(path.read_text().rsplit(')',1)[1].split()[19]) if path.exists() else None + + +def source_identity(protocol): + paths = [Path(__file__), Path(protocol), Path(native.__file__), Path(primary.__file__), + ROOT/'benchmarks/tls_reference/timing/common.py', + ROOT/'benchmarks/tls_reference/timing/benchmark.py'] + return {str(p.relative_to(ROOT)): native.sha(p) for p in paths} + + +def allocation(env): + values = (env.get('nvidia_smi'), env.get('cpu_quota_cores'), env.get('host_memory_limit_bytes')) + if any(v is None for v in values): + raise ValueError('Incomplete GPU/CPU/memory allocation identity') + if any(value != '1' for value in env['cpu_math_thread_environment'].values()): + raise ValueError('Numerical CPU threads must remain one') + return values + + +def cohort_identity(cases): + return [dict(name=c['name'], regime=c['metadata']['regime'], nobs=len(c['data']['t']), + nperiods=len(c['data']['periods']), input_sha256=c['input_sha256']) for c in cases] + + +def first_anchors(rows): + """Only the first scheduled worker-0 observation can anchor each case.""" + anchors, seen = {}, set() + for row in rows: + if row['worker'] != 0: + continue + for observation in row['observations']: + name = observation['case'] + if name in seen: + continue + seen.add(name) + if observation['status'] == 'success': + anchors[name] = observation + return anchors + + +def compare_observation(observation, anchor): + """Diagnostic exact comparisons without any passing tolerance or gate.""" + result = dict(case=observation['case'], status=observation['status']) + if observation['status'] != 'success': + return dict(result, comparison='API failure; no output comparison') + if anchor is None: + return dict(result, comparison='fixed first-observation reference unavailable') + changed = [k for k in ('period', 'score') if + observation['scalar']['fields'][k] != anchor['scalar']['fields'][k]] + result.update(comparison='recorded', changed_selected_fields=changed) + if changed: + result.update(expected=anchor['scalar']['values'], actual=observation['scalar']['values'], + differences={k:observation['scalar']['values'][k]-anchor['scalar']['values'][k] + for k in changed}) + if 'full' in observation and 'full' in anchor: + old, new = anchor['full'], observation['full'] + result['changed_complete_fields'] = [k for k in sorted(set(old['fields']) | set(new['fields'])) + if old['fields'].get(k) != new['fields'].get(k)] + arrays = [] + for full in (old, new): + artifact = full['spectrum_artifact'] + if native.sha(artifact['path']) != artifact['sha256']: + raise ValueError('Archived complete BLS spectrum changed') + with np.load(artifact['path'], allow_pickle=False) as data: + arrays.append(np.array(data['power'], copy=True)) + if arrays[0].shape != arrays[1].shape: + raise ValueError('BLS power shape changed within a fixed input') + finite = np.isfinite(arrays[0]) & np.isfinite(arrays[1]) + delta = np.abs(arrays[1][finite].astype(float)-arrays[0][finite].astype(float)) + result.update(changed_finite_power_values=int(np.count_nonzero(delta)), + maximum_absolute_power_difference=float(delta.max()) if delta.size else None, + changed_finite_mask_values=int(np.count_nonzero( + np.isfinite(arrays[0]) != np.isfinite(arrays[1])))) + return result + + +def account_task(row, expected_indices, cases, anchors): + """Fail closed on instrumentation, while retaining native failures and drift.""" + if row['indices'] != expected_indices: + raise ValueError('Returned indices differ from assigned task') + expected = [cases[i]['name'] for i in expected_indices] + actual = [v['case'] for v in row['observations']] + if actual != expected: + raise ValueError('Attempted-case membership/order differs from assigned task') + statuses = [v['status'] for v in row['observations']] + if any(s not in ('success', 'api_error') for s in statuses): + raise ValueError('Unknown or instrumental observation status') + for value in row['observations']: + if value['status'] == 'success' and not all(np.isfinite(value['scalar']['values'][k]) + for k in ('period', 'score')): + raise ValueError('Worker labeled nonfinite output successful') + return dict(attempted_count=len(expected), successful_count=statuses.count('success'), + failed_count=statuses.count('api_error'), + comparisons=[compare_observation(v, anchors.get(v['case'])) for v in row['observations']]) + + +def queue_summary(rows, elapsed): + if elapsed <= 0: + raise ValueError('Elapsed queue time must be positive') + attempted = sum(r['accounting']['attempted_count'] for r in rows) + success = sum(r['accounting']['successful_count'] for r in rows) + failed = sum(r['accounting']['failed_count'] for r in rows) + if attempted != success+failed or not attempted: + raise ValueError('Incomplete attempt accounting') + return dict(attempted_count=attempted, successful_count=success, failed_count=failed, + elapsed_seconds=elapsed, successful_lightcurves_per_second=success/elapsed, + attempted_lightcurves_per_second=attempted/elapsed, + failure_fraction=failed/attempted, completion_fraction=success/attempted, + sum_worker_api_seconds=sum(v.get('api_seconds',0.) for row in rows + for v in row['observations']), + api_seconds_note='Diagnostic sum across possibly overlapping workers; ' + 'excludes per-case journals and is never the throughput denominator.') + + +def execution_winner(records): + valid = [r for r in records if r.get('execution_rates_valid') and + r['summary']['successful_lightcurves_per_second'] > 0] + return min(valid, key=lambda r: (-r['summary']['median_repetition_successful_lightcurves_per_second'], + r['workers'], r['batch_size'])) if valid else None + + +def validate_diagnostic_coverage(rows, cohort, workers, cases): + expected = Counter((w,tuple(indices)) for indices in cohort for w in range(workers)) + observed = Counter((row['worker'],tuple(row['indices'])) for row in rows) + if observed != expected: + raise ValueError('Diagnostic worker/batch coverage differs from assigned cohort') + for row in rows: + account_task(row,row['indices'],cases,{}) + + +def worker(connection, config): + journal = None + try: + started = time.perf_counter() + sys.path.insert(0, config['source_root']) + cases = native.load_manifest(config['manifest'], config['names']) + seal = native.configure_bls(cases, config['science_seal']) + load_seconds = time.perf_counter()-started + grids = native.prepare_grids(cases) + from cuvarbase.base import ensure_context + ensure_context() + import cuvarbase + import cupy as cp + science = native.science_bls_module() + if science.production_identity() != seal['production_sources']: + raise ValueError('Production sources changed from science seal') + package = Path(cuvarbase.__file__).resolve().parent + if package.parent != Path(config['source_root']).resolve(): + raise ValueError('BLS import escaped requested checkout') + sources = {str(p.relative_to(package)): native.sha(p) for p in sorted(package.rglob('*')) + if p.is_file() and p.suffix in ('.py', '.cu', '.cuh')} + owned_context = native.retain_cuda_context() + connection.send(dict(kind='ready', pid=os.getpid(), namespace_pids=native.process_ids(), + process_start_ticks=process_start_ticks(), + supplement_owner_token=os.environ.get('CUVARBASE_SURVEY_BLS_SUPPLEMENT_OWNER'), + cuda_context_allocation_bytes=1, cuda_context_synchronized=True, + source_files=dict(root=str(package), files=sources), + input_load_seconds=load_seconds, grid_preparation=grids, + ready_seconds=time.perf_counter()-started)) + counter = 0 + journal = (Path(config['output'])/f'worker-{os.getpid()}-attempts.jsonl').open('x') + + def event(value): + journal.write(json.dumps(dict(pid=os.getpid(),monotonic=time.perf_counter(),**value))+'\n') + journal.flush() + + while True: + command = connection.recv() + if command['kind'] == 'close': + break + if command['kind'] == 'memory': + connection.send(dict(kind='memory', pid=os.getpid(), host_peak_rss_bytes=native.rss_peak_bytes(), + cupy_pool_reserved_bytes=cp.get_default_memory_pool().total_bytes(), + cupy_pool_used_bytes=cp.get_default_memory_pool().used_bytes())) + continue + before = time.perf_counter() + observations = [] + for index in command['indices']: + deadline_check(config['deadline_epoch']) + case = cases[index] + observation = dict(case=case['name'], index=index) + event(dict(event='attempt_started',sequence=counter,case=case['name'], + input_sha256=case['input_sha256'],kind=command['kind'],task=command.get('task'), + repetition=command.get('repetition'))) + api_started = time.perf_counter() + try: + cp.cuda.runtime.deviceSynchronize() + result = native.compact_bls(case, science, arrays=command['kind'] != 'run') + cp.cuda.runtime.deviceSynchronize() + # Invalid selected native outputs are failed executions, not fast successes. + if not all(np.isfinite(result[k]) for k in ('period', 'score')): + raise ValueError('Native BLS returned a nonfinite selected endpoint') + except Exception: + observation.update(status='api_error', error=traceback.format_exc(), + api_seconds=time.perf_counter()-api_started) + else: + observation['api_seconds'] = time.perf_counter()-api_started + event(dict(event='api_returned',sequence=counter,case=case['name'], + period=result['period'],score=result['score'])) + # Any failure in diagnostic construction/archive is instrumental and fatal. + observation.update(status='success', scalar=dict( + fields=native.scalar_fingerprint('bls', result), + values={k:result[k] for k in ('period', 'score')})) + if command['kind'] != 'run': + native.archive_bls_spectra(result, Path(config['output'])/'spectra'/str(os.getpid()), + f'{counter:06d}-{case["name"]}') + observation['full'] = native.complete_fingerprint('bls', case, result) + del result + event(dict(event='attempt_completed',sequence=counter,kind=command['kind'], + task=command.get('task'),repetition=command.get('repetition'),observation=observation)) + counter += 1 + observations.append(observation) + connection.send(dict(kind='complete', pid=os.getpid(), task=command.get('task'), + repetition=command.get('repetition'), + indices=command['indices'], observations=observations, + api_and_diagnostic_seconds=time.perf_counter()-before, + host_peak_rss_bytes=native.rss_peak_bytes())) + except BaseException: + error = traceback.format_exc() + if journal is not None: + journal.write(json.dumps(dict(event='worker_interrupted',pid=os.getpid(),error=error))+'\n') + journal.flush() + try: + connection.send(dict(kind='fatal', traceback=error)) + except Exception: + pass + finally: + if journal is not None: + journal.close() + connection.close() + + +class Pool(native.Pool): + def __init__(self, config, width, deadline): + self.deadline = deadline + self.timeout, self.connections, self.processes = 1800, [], [] + self.ownership, self.closed = native.GPUOwnership(), False + started = time.perf_counter() + try: + context = mp.get_context('spawn') + for unused in range(width): + parent, child = context.Pipe() + process = context.Process(target=worker, args=(child, config)) + process.start() + child.close() + self.connections.append(parent) + self.processes.append(process) + self.ready = [self.receive(c, 'ready') for c in self.connections] + self.ownership.bind(self.ready, self.processes) + except BaseException as error: + error.gpu_ownership = self.close() + raise + self.startup_seconds = time.perf_counter()-started + + def receive(self, connection, kind='complete'): + deadline_check(self.deadline) + timeout = min(self.timeout, self.deadline-CLEANUP_RESERVE_SECONDS-time.time()) + if not connection.poll(max(0, timeout)): + raise Interrupted('Worker timeout/deadline; incomplete configuration invalid') + value = connection.recv() + if value['kind'] != kind: + raise RuntimeError('Worker instrumentation failed: '+repr(value)) + return value + + def diagnostic(self, cohort, output): + rows = [] + with Path(output).open('x') as stream: + for indices in cohort: + for connection in self.connections: + connection.send(dict(kind='qualify', indices=indices)) + for index, connection in enumerate(self.connections): + row = dict(worker=index, **self.receive(connection)) + if row['indices'] != indices or row['task'] is not None: + raise ValueError('Diagnostic worker returned a different assigned task') + rows.append(row) + stream.write(json.dumps(row)+'\n') + stream.flush() + return rows + + def queue(self, cohort, cases, min_attempts, min_seconds, anchors, journal, repetition=0): + ownership = native.exclusive_gpu_processes(self.ownership.allowed_pids) + if not ownership['exclusive']: + raise ValueError('GPU ownership failed before queue') + started = time.perf_counter() + cycles = max(1, int(np.ceil(min_attempts/len(cases))), + int(np.ceil(len(self.connections)/len(cohort)))) + jobs = cohort*cycles + submitted, pending, rows = 0, {}, [] + + def submit(connection): + nonlocal submitted + connection.send(dict(kind='run', indices=jobs[submitted], task=submitted,repetition=repetition)) + pending[connection] = submitted + submitted += 1 + + with Path(journal).open('x') as stream: + for connection in self.connections[:len(jobs)]: + submit(connection) + while pending: + deadline_check(self.deadline) + ready = wait(list(pending), timeout=min(self.timeout, max(0, + self.deadline-CLEANUP_RESERVE_SECONDS-time.time()))) + if not ready: + raise Interrupted('Queue timeout/deadline; journal retains completed attempts') + for connection in ready: + row = self.receive(connection) + task = pending.pop(connection) + if row['task'] != task or row['repetition'] != repetition: + raise ValueError('Worker task identity changed') + row['accounting'] = account_task(row, jobs[task], cases, anchors) + rows.append(row) + stream.write(json.dumps(row)+'\n') + stream.flush() + if submitted == len(jobs) and time.perf_counter()-started < min_seconds: + jobs.extend(cohort) + if submitted < len(jobs): + submit(connection) + elapsed = time.perf_counter()-started + final_owner = native.exclusive_gpu_processes(self.ownership.allowed_pids) + if not final_owner['exclusive']: + raise ValueError('GPU ownership failed after queue') + summary = queue_summary(rows, elapsed) + if summary['attempted_count'] < min_attempts or elapsed < min_seconds: + raise ValueError('Completed queue does not meet predeclared minima') + return dict(status='completed_queue', **summary, completed_input_cycles=len(jobs)//len(cohort), + tasks=rows, exclusive_before=ownership, exclusive_after=final_owner, + journal=dict(path=str(Path(journal).resolve()), sha256=native.sha(journal))) + + +def numerical_summary(diagnostics, repetitions): + comparisons = [value for phase in diagnostics for row in phase['rows'] + for value in row['accounting']['comparisons']] + comparisons += [value for rep in repetitions for row in rep['tasks'] + for value in row['accounting']['comparisons']] + return dict(original_qualification_passed=False, + interpretation='Original exact repeatability qualification remains failed. ' + 'This supplement supplies execution rates, no new numerical acceptance threshold.', + selected_mismatch_count=sum(bool(v.get('changed_selected_fields')) for v in comparisons), + complete_output_mismatch_count=sum(bool(v.get('changed_complete_fields')) for v in comparisons), + reference_missing_count=sum(v['comparison']=='fixed first-observation reference unavailable' + for v in comparisons), + comparison_count=len(comparisons)) + + +def run_configuration(args, manifest, names, scope, workers, batch_size, output, + expected_allocation, expected_cohort, anchors=None, repetitions=1, + min_attempts=24, min_seconds=30): + deadline_check(args.deadline_epoch) + if repetitions*min_seconds > args.deadline_epoch-time.time()-CLEANUP_RESERVE_SECONDS: + raise Interrupted('Known minimum configuration duration cannot fit shared deadline') + output.mkdir(parents=True, exist_ok=False) + start = time.perf_counter() + record = dict(schema_version=1, status='running', scope=scope, backend='native_bls_execution', + workers=workers, batch_size=batch_size, execution_rates_valid=False, + original_qualification_passed=False, + original_numerical_qualification_passed=False, repetitions=[], diagnostics=[], + source_identity=source_identity(args.protocol), science_seal_sha256=native.sha(args.science_seal), + manifest_sha256=native.sha(manifest), config=dict(manifest=str(manifest), names=names, + hourly_usd=args.hourly_usd), + timing_boundary='Same preparation/full-grid/transfer/CPU/GPU boundaries as primary timing. ' + 'Instrumentation overhead differs: the supplement flushes three per-case worker journal events ' + 'for successful calls (start, API return, completion), two for failures, and one parent task ' + 'record, in addition to exact scalar comparisons. All are inside elapsed queue time. ' + 'Diagnostic summed worker API seconds exclude these journals and never supply a rate denominator. ' + 'Failed API calls consume elapsed time and ' + 'count as attempts, never successful completions. Full-array diagnostics run outside queues.', + cold_cache_policy='Fresh processes; existing filesystem compiler caches retained. ' + 'First-cohort complete-array diagnostic cost is conservatively charged to cold amortization.') + record.update(args.authorization_identity) + pool, telemetry = None, native.Telemetry(output/'telemetry.jsonl', []) + try: + cases = native.load_manifest(manifest, names) + native.configure_bls(cases, args.science_seal) + record.update(cohort=cohort_identity(cases), environment=native.resource_environment()) + if record['cohort'] != expected_cohort: + raise ValueError('Supplement cohort differs from original timing inputs') + if allocation(record['environment']) != tuple(expected_allocation): + raise ValueError('GPU/CPU/RAM allocation differs from primary campaign') + cohort = native.batches(cases, batch_size) + record['actual_api_batch_sizes'] = [len(x) for x in cohort] + native.write(output/'result.json', record) + telemetry.__enter__() + pool = Pool(dict(manifest=str(manifest), names=names, science_seal=str(args.science_seal), + source_root=str(args.source_root), output=str(output), + deadline_epoch=args.deadline_epoch), workers, args.deadline_epoch) + telemetry.pids[:] = [p.pid for p in pool.processes] + record.update(worker_ready=pool.ready, startup_seconds=pool.startup_seconds, + parent_input_load_and_setup_seconds=time.perf_counter()-start-pool.startup_seconds) + cold_start = time.perf_counter() + before = pool.diagnostic(cohort, output/'diagnostic-before.jsonl') + validate_diagnostic_coverage(before,cohort,workers,cases) + record['first_full_cohort_seconds'] = time.perf_counter()-cold_start + record['cold_first_public_call_seconds_by_worker'] = [ + row['observations'][0]['api_seconds'] for row in before[:workers] if row['observations']] + if anchors is None: + anchors = first_anchors(before) + for row in before: + row['accounting'] = account_task(row, row['indices'], cases, anchors) + record['fixed_reference_anchors'] = anchors + record['diagnostics'].append(dict(phase='before', rows=before)) + record['worker_after_warmup'] = pool.memory() + native.write(output/'result.json', record) + for i in range(repetitions): + if min_seconds > args.deadline_epoch-time.time()-CLEANUP_RESERVE_SECONDS: + raise Interrupted('Known minimum queue duration cannot fit shared deadline') + result = pool.queue(cohort, cases, min_attempts, min_seconds, anchors, + output/f'queue-{i}.jsonl',repetition=i) + record['repetitions'].append(dict(repetition=i, **result)) + native.write(output/'result.json', record) + record['worker_after_queues'] = pool.memory() + after = pool.diagnostic(cohort, output/'diagnostic-after.jsonl') + validate_diagnostic_coverage(after,cohort,workers,cases) + for row in after: + row['accounting'] = account_task(row, row['indices'], cases, anchors) + record['diagnostics'].append(dict(phase='after', rows=after)) + record['worker_memory_before_teardown'] = pool.memory() + record['status'] = 'complete' + except BaseException as error: + record.update(status='partial' if isinstance(error, Interrupted) else 'error', + error=traceback.format_exc()) + if hasattr(error, 'gpu_ownership'): + record['gpu_ownership'] = error.gpu_ownership + finally: + if pool is not None: + telemetry.pids[:] = [] + teardown_start = time.perf_counter() + try: + record['gpu_ownership'] = pool.close() + except BaseException: + record.update(status='error',cleanup_error=traceback.format_exc()) + record['gpu_ownership'] = dict(passed=False,status='cleanup_instrumentation_failure', + receipt=pool.ownership.receipt) + record['teardown_seconds'] = time.perf_counter()-teardown_start + if telemetry.thread.ident is not None: + telemetry.__exit__() + record['memory'] = telemetry.summarize(pool.ownership.allowed_pids if pool else []) + record['telemetry_errors'] = [r for r in telemetry.rows if r.get('error')] + record['total_campaign_seconds'] = time.perf_counter()-start + record['numerical'] = numerical_summary(record['diagnostics'], record['repetitions']) + instrument_ok = (record.get('gpu_ownership', {}).get('passed') is True and + record['memory']['ownership_passed'] and not record['telemetry_errors'] and + record['memory']['gpu_used_bytes'] is not None and + record['memory']['host_pool_rss_bytes'] is not None) + record['execution_rates_valid'] = record['status']=='complete' and instrument_ok + if not instrument_ok: + record['status'] = 'error' + record.setdefault('error', 'Ownership or memory instrumentation failed') + if record['execution_rates_valid']: + elapsed = sum(v['elapsed_seconds'] for v in record['repetitions']) + tasks = [t for v in record['repetitions'] for t in v['tasks']] + summary = queue_summary(tasks, elapsed) + preparation = (record['startup_seconds']+record['first_full_cohort_seconds']+ + record['parent_input_load_and_setup_seconds']) + rates = [r['successful_lightcurves_per_second'] for r in record['repetitions']] + success = summary['successful_count'] + record['summary'] = dict(summary, + median_repetition_successful_lightcurves_per_second=float(np.median(rates)), + observed_rate_min=min(rates), observed_rate_max=max(rates), + cold_first_cohort_including_startup_seconds=preparation, + cold_amortized_successful_lightcurves_per_second=success/(elapsed+preparation), + total_measured_compute_usd=args.hourly_usd*elapsed/3600, + cold_preparation_compute_usd=args.hourly_usd*preparation/3600, + estimated_run_compute_usd=args.hourly_usd*record['total_campaign_seconds']/3600, + cost_per_attempt_usd=args.hourly_usd*elapsed/(3600*summary['attempted_count']), + cost_per_successful_lightcurve_usd=None if not success else args.hourly_usd*elapsed/(3600*success), + whole_configuration_successful_lightcurves_per_second=success/record['total_campaign_seconds'], + whole_configuration_cost_per_successful_lightcurve_usd=None if not success else + args.hourly_usd*record['total_campaign_seconds']/(3600*success), + usd_per_million_successful=None if not success else args.hourly_usd*elapsed*1e6/(3600*success)) + record['artifact_sha256'] = {str(p.relative_to(output)):native.sha(p) for p in sorted(output.rglob('*')) + if p.is_file() and p.name != 'result.json'} + native.write(output/'result.json', record) + return record + + +def checked_json(path, expected_sha=None): + if expected_sha is not None and native.sha(path) != expected_sha: + raise ValueError('Receipt changed: '+str(path)) + return json.loads(Path(path).read_text()) + + +def canonical_sha(value): + return hashlib.sha256(json.dumps(value,sort_keys=True,separators=(',',':'), + allow_nan=False).encode()).hexdigest() + + +def verify_authorization(args): + """Validate the prospective source seal and mechanical post-primary binding.""" + seal = checked_json(args.supplement_seal,args.supplement_seal_sha256) + binding = checked_json(args.supplement_binding,args.supplement_binding_sha256) + if seal['kind'] != 'native_bls_execution_supplement' or seal['schema'] != 1: + raise ValueError('Unexpected supplement authorization schema') + if seal['budget'] != dict(gpu_cap_seconds=3600,cleanup_reserve_seconds=CLEANUP_RESERVE_SECONDS): + raise ValueError('Supplement budget/cleanup policy changed') + if binding['schema'] != 1 or binding['supplement_seal_sha256'] != args.supplement_seal_sha256: + raise ValueError('Mechanical binding uses another supplement seal') + for key in ('science_seal_sha256','auxiliary_plan_sha256'): + if binding[key] != seal[key]: + raise ValueError('Mechanical binding changes reviewed identity: '+key) + if native.sha(args.science_seal) != seal['science_seal_sha256']: + raise ValueError('Scientific seal changed') + required = [str(ROOT/name) for name in source_identity(args.protocol)] + if any(path not in seal['remote_files'] for path in required): + raise ValueError('A supplement runner/timing dependency was not source sealed') + for path,digest in seal['remote_files'].items(): + if native.sha(path) != digest: + raise ValueError('Supplement source changed: '+path) + rule = seal['binding_rule'] + if rule['primary_tuning_path'] not in seal['remote_files']: + raise ValueError('Original development tuning was not prospectively sealed') + if (str(args.primary_tuning) != rule['primary_tuning_path'] or + str(args.primary_measurement) != rule['primary_measurement_path']): + raise ValueError('Primary paths differ from reviewed mechanical binding rule') + checked_json(args.primary_tuning,binding['primary_tuning_sha256']) + measurement = checked_json(args.primary_measurement,binding['primary_measurement_sha256']) + if measurement['status'] != 'complete' or measurement['stage'] != 'measure': + raise ValueError('Mechanical binding requires completed primary measurement') + if measurement['science_seal_sha256'] != seal['science_seal_sha256']: + raise ValueError('Primary measurement science seal differs') + if native.sha(rule['primary_state_path']) != binding['primary_state_sha256']: + raise ValueError('Bound primary completion state changed') + if native.sha(rule['primary_bundle_receipt_path']) != binding['primary_bundle_receipt_sha256']: + raise ValueError('Bound primary archive receipt changed') + if (measurement['manifest_sha256'] != binding['primary_measurement_manifest_sha256'] or + measurement['varied_manifest_sha256'] != binding['primary_measurement_varied_manifest_sha256']): + raise ValueError('Bound primary manifest identity changed') + if native.sha(measurement['varied_manifest']) != measurement['varied_manifest_sha256']: + raise ValueError('Bound varied-cohort manifest changed') + expected = [] + for row in measurement['configs']: + result = checked_json(args.primary_measurement.parent/row['result'],row['result_sha256']) + expected.append(dict(scope=row['scope'],result=row['result'],result_sha256=row['result_sha256'], + cohort_sha256=canonical_sha(result['cohort']), + environment_sha256=canonical_sha(result['environment']))) + if binding['primary_configs'] != expected: + raise ValueError('Mechanical binding changes original cohort/resource receipts') + return dict(supplement_seal_sha256=args.supplement_seal_sha256, + supplement_binding_sha256=args.supplement_binding_sha256, + science_seal_sha256=seal['science_seal_sha256'], + auxiliary_plan_sha256=seal['auxiliary_plan_sha256'], + primary_tuning_sha256=binding['primary_tuning_sha256'], + primary_measurement_sha256=binding['primary_measurement_sha256']) + + +def primary_panel(measurement_path, campaign, scope): + for row in campaign['configs']: + if row['scope'] == scope: + result_path = Path(measurement_path).parent/row['result'] + result = checked_json(result_path, row['result_sha256']) + if 'cohort' in result and 'environment' in result: + return result + raise ValueError('No original timing cohort identity for '+scope) + + +def main(argv=None): + stage_started = time.perf_counter() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--stage', required=True, choices=['tune','measure']) + for name in ('manifest','output','science-seal','primary-tuning','primary-measurement', + 'source-root','supplement-seal','supplement-binding'): + parser.add_argument('--'+name, type=Path, required=True) + parser.add_argument('--supplement-seal-sha256', required=True) + parser.add_argument('--supplement-binding-sha256', required=True) + parser.add_argument('--tuning', type=Path, help='Separate tuning-seal.json for measure') + parser.add_argument('--protocol', type=Path, default=Path(__file__).with_name('BLS_EXECUTION_PROTOCOL.md')) + parser.add_argument('--hourly-usd', type=float, required=True) + parser.add_argument('--deadline-epoch', type=float, required=True) + args = parser.parse_args(argv) + if not 0 < args.deadline_epoch-time.time() <= 3600: + parser.error('Shared absolute deadline must be within one hour') + if args.hourly_usd < 0 or not np.isfinite(args.hourly_usd): + parser.error('Hourly cost must be finite and nonnegative') + if args.stage == 'measure' and (not args.tuning or not args.primary_measurement): + parser.error('measure requires --tuning and --primary-measurement') + for key,value in vars(args).items(): + if isinstance(value, Path): + setattr(args,key,value.resolve()) + if args.output.exists(): + parser.error('Fresh output directory required; no resume or retry of existing stages') + args.authorization_identity = verify_authorization(args) + original = checked_json(args.primary_tuning) + if original['status'] != 'complete' or original['stage'] != 'tune': + raise ValueError('Completed original tuning required') + science_sha = native.sha(args.science_seal) + if original['science_seal_sha256'] != science_sha: + raise ValueError('Original tuning science identity changed') + failure = next(r for r in original['configs'] if r['id']=='bls-mixed-w1-b1') + if failure['eligible']: + raise ValueError('Supplement requires the retained original BLS exclusion') + checked_json(args.primary_tuning.parent/failure['result'], failure['result_sha256']) + identity = source_identity(args.protocol) + reference = primary_panel(args.primary_tuning, original, 'mixed') + if args.hourly_usd != reference['config']['hourly_usd']: + raise ValueError('Rental price differs from frozen primary timing receipt') + expected_allocation = allocation(reference['environment']) + campaign = dict(schema_version=1, stage=args.stage, status='running', source_identity=identity, + science_seal_sha256=science_sha, primary_tuning_sha256=native.sha(args.primary_tuning), + original_bls_exclusion_sha256=failure['result_sha256'], + original_qualification_passed=False, + original_numerical_qualification_passed=False, manifest_sha256=native.sha(args.manifest), + deadline_epoch=args.deadline_epoch, hourly_usd=args.hourly_usd, + allocation=list(expected_allocation), configs=[], unavailable=[], selected=None) + campaign.update(args.authorization_identity) + args.output.mkdir(parents=True) + campaign['stage_preparation_seconds'] = time.perf_counter()-stage_started + + def interrupted(signum, frame): + raise Interrupted('Signal '+str(signum)+'; preserve partial evidence and clean owned workers') + + signal.signal(signal.SIGTERM, interrupted) + signal.signal(signal.SIGINT, interrupted) + signal.signal(signal.SIGALRM, interrupted) + signal.setitimer(signal.ITIMER_REAL, max(.01,args.deadline_epoch-time.time()-CLEANUP_RESERVE_SECONDS)) + + def run(scope, manifest, names, expected_cohort, workers, batch, anchors=None, + reps=1, min_attempts=24, seconds=30, prefix=''): + identifier = f'{prefix}bls-execution-{scope}-w{workers}-b{batch}' + directory = args.output/identifier + result = run_configuration(args, manifest, names, scope, workers, batch, directory, + expected_allocation, expected_cohort, anchors, reps,min_attempts,seconds) + campaign['configs'].append(dict(id=identifier, scope=scope, workers=workers, batch_size=batch, + reference_only=bool(prefix), result=str(directory.relative_to(args.output)/'result.json'), + result_sha256=native.sha(directory/'result.json'), + execution_rates_valid=result['execution_rates_valid'], failure_reason=result.get('error'))) + native.write(args.output/'campaign.json', campaign) + if result['status'] == 'partial': + raise Interrupted('Configuration interrupted; do not select partial-stage results') + return result + + try: + native.write(args.output/'campaign.json', campaign) + if args.stage == 'tune': + if campaign['manifest_sha256'] != original['manifest_sha256']: + raise ValueError('Development manifest differs from original tuning') + names = original['names'] + expected_cohort = reference['cohort'] + records = [run('mixed',args.manifest,names,expected_cohort,1,1)] + anchors = records[0].get('fixed_reference_anchors', {}) + for workers in (2,4): + records.append(run('mixed',args.manifest,names,expected_cohort,workers,1,anchors)) + chosen = execution_winner(records) + if chosen is not None: + campaign['worker_stage_selection'] = chosen['workers'] + native.write(args.output/'campaign.json', campaign) + for batch in (4,8): + records.append(run('mixed',args.manifest,names,expected_cohort,chosen['workers'],batch,anchors)) + chosen = execution_winner([r for r in records if r['workers']==campaign['worker_stage_selection']]) + campaign['selected'] = dict(workers=chosen['workers'],batch_size=chosen['batch_size']) + else: + campaign['unavailable'].append(dict(scope='mixed',reason='No positive valid execution rate')) + else: + sealed = checked_json(args.tuning) + tuning = checked_json(Path(sealed['campaign_path']),sealed['campaign_sha256']) + for key in (*args.authorization_identity,'source_identity','allocation','deadline_epoch','hourly_usd'): + if sealed[key] != campaign[key] or sealed[key] != tuning[key]: + raise ValueError('Supplement tuning identity changed: '+key) + for name,digest in tuning['artifact_sha256'].items(): + if native.sha(Path(sealed['campaign_path']).parent/name) != digest: + raise ValueError('Supplement tuning output changed: '+name) + if tuning['status'] != 'complete' or tuning['selected'] != sealed['selected']: + raise ValueError('Incomplete or changed separate tuning selection') + campaign.update(tuning_seal_sha256=native.sha(args.tuning),tuning_seal_path=str(args.tuning), + selected=sealed['selected']) + measurement = checked_json(args.primary_measurement) + if measurement['status'] != 'complete' or measurement['stage'] != 'measure': + raise ValueError('Completed original measurement required') + if measurement['science_seal_sha256'] != science_sha or measurement['manifest_sha256'] != campaign['manifest_sha256']: + raise ValueError('Original measurement science or input identity changed') + campaign['primary_measurement_sha256'] = native.sha(args.primary_measurement) + for scope in SCOPES: + if campaign['selected'] is None: + campaign['unavailable'].append(dict(scope=scope,reason='No valid execution tuning selection')) + continue + prior = primary_panel(args.primary_measurement,measurement,scope) + if allocation(prior['environment']) != expected_allocation: + raise ValueError('Original panel allocation differs from tuning') + if prior['config']['hourly_usd'] != args.hourly_usd: + raise ValueError('Original panel rental price differs from tuning') + manifest = Path(measurement['varied_manifest']) if scope=='varied' else args.manifest + if scope=='varied' and native.sha(manifest) != measurement['varied_manifest_sha256']: + raise ValueError('Original varied manifest changed') + names = [v['name'] for v in prior['cohort']] + chosen = campaign['selected'] + anchors = None + if chosen['workers'] > 1: + ref = run(scope,manifest,names,prior['cohort'],1,1,reps=1,min_attempts=1,seconds=0,prefix='reference-') + if ref['status'] != 'complete': + campaign['unavailable'].append(dict(scope=scope,reason='Reference instrumentation failed')) + continue + anchors = ref['fixed_reference_anchors'] + measured = run(scope,manifest,names,prior['cohort'],chosen['workers'],chosen['batch_size'],anchors, + reps=3,min_attempts=96,seconds=120) + if not measured['execution_rates_valid']: + campaign['unavailable'].append(dict(scope=scope,reason=measured.get('error', + 'Execution ownership/accounting invalid'))) + campaign['status'] = 'complete' + except BaseException as error: + campaign.update(status='partial' if isinstance(error, Interrupted) else 'error',error=traceback.format_exc()) + if campaign['status'] != 'complete': + campaign['selected'] = None + finally: + signal.setitimer(signal.ITIMER_REAL,0) + campaign['total_stage_seconds'] = time.perf_counter()-stage_started + campaign['estimated_stage_compute_usd'] = args.hourly_usd*campaign['total_stage_seconds']/3600 + try: + campaign['gpu_empty_after'] = native.exclusive_gpu_processes([]) + except BaseException: + campaign['gpu_empty_after'] = dict(exclusive=False,error=traceback.format_exc()) + if not campaign['gpu_empty_after']['exclusive']: + campaign['status']='error' + campaign['selected']=None + campaign['artifact_sha256'] = {str(p.relative_to(args.output)):native.sha(p) + for p in sorted(args.output.rglob('*')) if p.is_file() and p.name not in ('campaign.json','tuning-seal.json')} + native.write(args.output/'campaign.json',campaign) + if args.stage=='tune' and campaign['status']=='complete': + native.write(args.output/'tuning-seal.json',dict(schema_version=1, + original_qualification_passed=False, + campaign_path=str(args.output/'campaign.json'),campaign_sha256=native.sha(args.output/'campaign.json'), + **{k:campaign[k] for k in ('science_seal_sha256','source_identity','primary_tuning_sha256', + 'primary_measurement_sha256','supplement_seal_sha256', + 'supplement_binding_sha256','auxiliary_plan_sha256','deadline_epoch', + 'hourly_usd','allocation','selected','manifest_sha256','original_bls_exclusion_sha256')})) + print(json.dumps(dict(status=campaign['status'], selected=campaign['selected'], + configurations=len(campaign['configs']))),flush=True) + return 0 if campaign['status']=='complete' else 1 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/benchmarks/tls_survey/bls_response.py b/benchmarks/tls_survey/bls_response.py new file mode 100644 index 00000000..9178c1d7 --- /dev/null +++ b/benchmarks/tls_survey/bls_response.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Known-period noise-free BLS convergence; never a blind recovery result.""" +import argparse +import json +from pathlib import Path +import traceback +import numpy as np +from common import BLS_CONFIGS,load_case,now,sha,write +from development import optimal_box,ou_filter_snr,weighted_snr +from run import bls_bounds,production_identity + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--manifest',type=Path,required=True);p.add_argument('--out',type=Path,required=True) + a=p.parse_args();manifest=json.loads(a.manifest.read_text()) + if manifest['split']!='development':p.error('Development data only') + from cuvarbase.bls import eebls_gpu_fast,_fast_bls_solutions,subtract_epoch,_fast_path_nbins + from cuvarbase.base import ensure_context + ensure_context() + rows=[] + # One extra4xphase/2xduration/2xminwidth control checks convergence of finest. + configs={name:settings for name,settings in BLS_CONFIGS.items() if name!='bls_strong'} + configs['bls_convergence']=dict(noverlap=32,dlogq=.0125,qmin_factor=.125) + for entry in manifest['cases']: + arrays,m=load_case(a.manifest.parent,entry) + t,s,dy=(arrays[k] for k in ('t','signal','dy')) + period=np.array([m['truth_period']]);freq=1/period + phase=(t-m['truth_epoch']+.5*period[0])%period[0]-.5*period[0] + ideal,_=optimal_box(phase,s,dy) + for method,cfg in configs.items(): + config=dict(cfg);qmin,qmax=bls_bounds(period);qmin*=config.pop('qmin_factor') + try: + power=eebls_gpu_fast(t,1-s,dy,freq,qmin=qmin,qmax=qmax,ignore_negative_delta_sols=True,**config) + solution=_fast_bls_solutions(t,1-s,dy,freq,power,qmin,qmax,1,ignore_negative_delta_sols=True,**config)[0] + oracle=weighted_snr(s,s,dy) + power_snr=float(np.sqrt(max(0.,power[0]))*oracle) + if solution is None: + snr=red=0.;q=phi=None + else: + q,phi=solution + relative_t,epoch=subtract_epoch(t) + local_phi=(phi-epoch*freq[0])%1. + nbf=int(_fast_path_nbins(freq.astype(np.float32),qmin,qmax)[1][0]) + # Recover the discrete histogram offset and box exactly; + # floating boundaries use the same float32 operations. + grid_start=local_phi*nbf + shifted_index=int(round(grid_start*config['noverlap'])) + start_bin=(shifted_index//config['noverlap'])%nbf + pass_index=shifted_index%config['noverlap'] + offset=np.float32(pass_index/config['noverlap']) + phases=relative_t.astype(np.float32)*np.float32(freq[0]) + phases-=np.floor(phases) + bins=np.floor(np.float32(nbf)*phases-offset).astype(np.int64)%nbf + model=((bins-start_bin)%nbf0 else None, + q=q,phi=phi,error=None)) + except Exception: + rows.append(dict(name=m['name'],regime=m['regime'],method=method,valid=False, + error=traceback.format_exc())) + write(a.out,dict(created_utc=now(),manifest_sha256=sha(a.manifest),source_sha256=sha(__file__), + production_sources=production_identity(),rows=rows, + interpretation='Known true-period noiseless actual BLS GPU power and its CPU-reconstructed box; common fitted-constant white/OU expected SNR. Independent diagnostic, never truth inserted in blind grids.')) + print(json.dumps(dict(completed=len(rows)//len(configs))),flush=True) + + +if __name__=='__main__':main() diff --git a/benchmarks/tls_survey/boundaries.py b/benchmarks/tls_survey/boundaries.py new file mode 100644 index 00000000..a508b8d2 --- /dev/null +++ b/benchmarks/tls_survey/boundaries.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Physical grid and exposure-quadrature development checks, including joint edges.""" +import argparse +from dataclasses import asdict +import json +from pathlib import Path +import sys +import numpy as np +from common import ROOT,REGIMES,load_case,module,now,sha,write +from development import weighted_snr + + +def main(): + p=argparse.ArgumentParser(description=__doc__) + p.add_argument('--manifest',type=Path,required=True);p.add_argument('--out',type=Path,required=True) + a=p.parse_args();manifest=json.loads(a.manifest.read_text()) + if manifest['split']!='development':p.error('Development only') + diag=module(ROOT/'benchmarks/tls_accuracy/diagnose.py','survey_boundary_physics') + rows=[] + for entry in manifest['cases']: + arr,m=load_case(a.manifest.parent,entry) + physical=diag.Regime(**m['physical']) + signals={n:diag.physical_signal(physical,arr['t'],arr['exposure_days'],epoch=m['truth_epoch'],exposure_nodes=n) + for n in (32,64,128)} + oracle=weighted_snr(signals[128],signals[128],arr['dy']) + rows.append(dict(kind='observed_exposure_convergence',name=m['name'],regime=m['regime'], + grid_recovery_ratio=m['nearest_grid_drift_over_half_duration'], + max_relative_flux_error_32=float(np.max(np.abs(signals[32]-signals[128]))/max(signals[128].max(),1e-30)), + max_relative_flux_error_64=float(np.max(np.abs(signals[64]-signals[128]))/max(signals[128].max(),1e-30)), + filter_snr_loss_32=1-weighted_snr(signals[128],signals[32],arr['dy'])/oracle if oracle>0 else None, + filter_snr_loss_64=1-weighted_snr(signals[128],signals[64],arr['dy'])/oracle if oracle>0 else None)) + for period in (.65,10.,365.25): + for impact in (.95,1.02): + for eccentricity in (0.,.8): + physical=diag.Regime('joint_mdwarf_boundary',period,radius=.1,mass=.1,rp=.00916/.1, + impact=impact,eccentricity=eccentricity) + duration,full,semimajor=diag.durations(physical) + for exposure in (30.,200.,1800.): + span=2*max(duration,exposure/86400) + t=np.linspace(-span,span,2049) + signals={n:diag.physical_signal(physical,t,exposure/86400,exposure_nodes=n) for n in (64,128,256)} + dy=np.ones(len(t)) + oracle=weighted_snr(signals[256],signals[256],dy) + rows.append(dict(kind='joint_physics_boundary',physical=asdict(physical),exposure_seconds=exposure, + duration_days=duration,ingress_days=(duration-full)/2,fractional_duration=duration/period, + periastron_stellar_radii=semimajor*(1-eccentricity), + filter_snr_loss_64=1-weighted_snr(signals[256],signals[64],dy)/oracle if oracle>0 else None, + filter_snr_loss_128=1-weighted_snr(signals[256],signals[128],dy)/oracle if oracle>0 else None, + interpretation='Contact/exposure diagnostic at known transit; no blind recovery or annual throughput claim')) + write(a.out,dict(created_utc=now(),manifest_sha256=sha(a.manifest),source_sha256=sha(__file__),rows=rows, + limitations='Only solar/0.1solar stellar populations; Earth-size planets; fixed quadratic limb darkening; eccentric omega=90degrees; achromatic depth. Joint Mdwarf/eccentric/grazing annual cases are physical development diagnostics only.')) + + +if __name__=='__main__':main() diff --git a/benchmarks/tls_survey/campaign.py b/benchmarks/tls_survey/campaign.py new file mode 100644 index 00000000..402b3701 --- /dev/null +++ b/benchmarks/tls_survey/campaign.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Run a reviewed frozen accuracy campaign; no cloud lifecycle operations. + +This operational controller is excluded from the scientific source seal. Every +scientific child checks the sealed sources, and this controller checks them again +at stage boundaries. Restart the same command to resume completed search rows. +""" +import argparse +from datetime import datetime, timezone +import fcntl +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time +import traceback + +for _name in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[_name] = '1' + +from common import ROOT, source_identity, sha, write +sys.path.insert(0, str(ROOT)) +from run import production_identity + + +def utc(): + return datetime.now(timezone.utc).isoformat() + + +def check_manifest(path, split, seal, seal_sha): + value = json.loads(path.read_text()) + if (value['status'] != 'complete' or value['split'] != split or + value['seal_sha256'] != seal_sha or value['source_identity'] != seal['source_identity'] or + value['regimes'] != seal['regimes'] or value['count_per_regime'] != seal['counts'][split]): + raise ValueError('Existing manifest differs from reviewed design: ' + str(path)) + names = [row['metadata']['name'] for row in value['cases']] + if len(names) != len(set(names)): + raise ValueError('Duplicate generated inputs') + for regime in seal['regimes']: + rows = [row for row in value['cases'] if row['metadata']['regime'] == regime] + if len(rows) != seal['counts'][split]: + raise ValueError('Incomplete generated regime: ' + regime) + return value + + +def check_result(path, manifest_path, manifest, seal, shard): + value = json.loads(path.read_text()) + entries = manifest['cases'][shard::seal['execution_shards']] + expected = {(entry['metadata']['name'], method): entry['sha256'] for entry in entries + for method in ('tls', seal['bls_selected'][entry['metadata']['regime']]['method'])} + actual = {(row['name'], row['method']): row['input_sha256'] for row in value['cases']} + if (value['status'] != 'complete' or value['split'] != manifest['split'] or + value['manifest_sha256'] != sha(manifest_path) or + value['production_sources'] != seal['production_sources'] or + value['runner_sha256'] != sha(ROOT / 'benchmarks/tls_survey/run.py') or + value['shard_index'] != shard or value['shard_count'] != seal['execution_shards'] or + len(actual) != len(value['cases']) or actual != expected): + raise ValueError('Incomplete or mismatched search receipt: ' + str(path)) + return value + + +class Campaign: + def __init__(self, args): + self.args = args + self.folder = args.work.resolve() + self.folder.mkdir(parents=True, exist_ok=True) + self.seal = json.loads(args.seal.read_text()) + if sha(args.seal) != args.seal_sha256: + raise ValueError('Seal SHA differs from explicitly reviewed SHA') + self.seal_sha = args.seal_sha256 + self.env = dict(os.environ, LANG='C.UTF-8', LC_ALL='C.UTF-8', PYTHONUTF8='1', + PYTHONPATH=str(ROOT), PATH='/usr/local/cuda/bin:' + os.environ['PATH']) + self.state_path = self.folder / 'campaign.json' + self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else dict( + created_utc=utc(), seal_sha256=self.seal_sha, stages=[], attempts=[]) + if self.state['seal_sha256'] != self.seal_sha: + raise ValueError('Cannot reuse work directory for another seal') + self.children = [] + + def save(self): + write(self.state_path, self.state) + + def guard(self): + if (sha(self.args.seal) != self.seal_sha or source_identity() != self.seal['source_identity'] or + production_identity() != self.seal['production_sources']): + raise ValueError('Scientific or production sources changed after review') + + def commands(self, label, commands): + self.guard() + stage = dict(name=label, started_utc=utc(), status='running', workers=[]) + self.state['stages'].append(stage) + logs = [] + try: + for index, command in enumerate(commands): + log_path = self.folder / (label + '-' + str(index) + '.log') + log = log_path.open('a'); logs.append(log) + process = subprocess.Popen(command, cwd=ROOT, env=self.env, stdout=log, + stderr=subprocess.STDOUT, start_new_session=True) + self.children.append(process) + stage['workers'].append(dict(pid=process.pid, command=command, log=str(log_path))) + self.save() + while any(process.poll() is None for process in self.children): + self.state['heartbeat_utc'] = utc() + self.save() + time.sleep(20) + for process, worker in zip(self.children, stage['workers']): + worker['exit_code'] = process.wait() + if any(worker['exit_code'] != 0 for worker in stage['workers']): + raise RuntimeError('Child failed; preserved all receipts/logs: ' + label) + stage['status'] = 'complete' + except BaseException: + for process in self.children: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + for process in self.children: + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + stage['status'] = 'failed' + raise + finally: + for log in logs: + log.close() + self.children = [] + stage['completed_utc'] = utc() + self.save() + + def python(self, file, *args): + return [sys.executable, str(ROOT / file), *map(str, args)] + + def generate(self, split): + self.guard() + folder = self.folder / ('inputs-' + split) + manifest_path = folder / 'manifest.json' + if folder.exists(): + manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {} + if manifest.get('status') != 'complete': + # The generator refuses overwrites. Preserve interruption evidence + # and repeat its identical predeclared deterministic streams. + preserved = folder.with_name(folder.name + '.interrupted-' + str(time.time_ns())) + folder.rename(preserved) + self.state.setdefault('preserved_interrupted_inputs', []).append(str(preserved)) + self.save() + if not folder.exists(): + self.commands('generate-' + split, [self.python('benchmarks/tls_survey/generate.py', + '--split', split, '--count', self.seal['counts'][split], '--regimes', + ','.join(self.seal['regimes']), '--exposure-nodes', self.seal['exposure_nodes'], + '--seal', self.args.seal, '--out', folder)]) + check_manifest(manifest_path, split, self.seal, self.seal_sha) + return manifest_path + + def search(self, split, manifest_path): + self.guard() + manifest = check_manifest(manifest_path, split, self.seal, self.seal_sha) + methods = sorted({'tls'} | {v['method'] for v in self.seal['bls_selected'].values()}) + outputs = [self.folder / (split + '-search-' + str(i) + '.json') + for i in range(self.seal['execution_shards'])] + commands = [] + for shard, output in enumerate(outputs): + if output.exists() and json.loads(output.read_text()).get('status') == 'complete': + check_result(output, manifest_path, manifest, self.seal, shard) + continue + commands.append(self.python('benchmarks/tls_survey/run.py', '--manifest', manifest_path, + '--methods', *methods, '--seal', self.args.seal, '--shard-index', shard, + '--shard-count', self.seal['execution_shards'], '--out', output)) + if commands: + self.commands('search-' + split, commands) + for shard, output in enumerate(outputs): + check_result(output, manifest_path, manifest, self.seal, shard) + return outputs + + def execute(self): + self.guard() + self.state.update(status='running', pid=os.getpid()) + self.state['attempts'].append(dict(started_utc=utc(), controller_sha256=sha(__file__))) + self.save() + calibration = self.generate('calibration') + calibration_results = self.search('calibration', calibration) + thresholds = self.folder / 'thresholds.json' + if thresholds.exists(): + value = json.loads(thresholds.read_text()) + if value['seal_sha256'] != self.seal_sha or { + row['sha256'] for row in value['receipts']} != {sha(p) for p in calibration_results}: + raise ValueError('Existing thresholds differ from completed calibration receipts') + else: + self.commands('calibrate', [self.python('benchmarks/tls_survey/analyze.py', 'calibrate', + '--seal', self.args.seal, '--results', *calibration_results, '--out', thresholds)]) + threshold_sha = sha(thresholds) + if self.state.get('thresholds_sha256', threshold_sha) != threshold_sha: + raise ValueError('Independently frozen thresholds changed during interruption') + self.state['thresholds_sha256'] = threshold_sha + self.save() + # No test input is generated until independent thresholds have been fixed. + injections = self.generate('injections') + nulls = self.generate('nulls') + injection_results = self.search('injections', injections) + null_results = self.search('nulls', nulls) + result = self.folder / 'detection-results.json' + self.commands('analyze', [self.python('benchmarks/tls_survey/analyze.py', 'analyze', + '--seal', self.args.seal, '--thresholds', thresholds, '--injections', *injection_results, + '--nulls', *null_results, '--out', result)]) + if self.args.export_bank: + bank = self.folder / 'input-bank' + if not bank.exists(): + self.commands('export-bank', [self.python('benchmarks/tls_reference/inputs.py', 'export', + '--study', 'calibration', calibration, '--study', 'injections', injections, + '--study', 'nulls', nulls, '--out', bank)]) + self.commands('verify-bank', [self.python('benchmarks/tls_reference/inputs.py', 'verify', '--bank', bank)]) + self.state.update(status='complete', completed_utc=utc(), result_sha256=sha(result)) + self.save() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--seal', type=lambda p: Path(p).resolve(), required=True) + parser.add_argument('--seal-sha256', required=True, help='Explicit SHA reviewed before held-out execution') + parser.add_argument('--work', type=Path, required=True) + parser.add_argument('--export-bank', action='store_true') + args = parser.parse_args() + args.work.mkdir(parents=True, exist_ok=True) + with (args.work / 'campaign.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + campaign = Campaign(args) + def interrupted(signum, frame): + raise KeyboardInterrupt('Controller received signal ' + str(signum)) + signal.signal(signal.SIGTERM, interrupted) + try: + campaign.execute() + except BaseException: + campaign.state.update(status='failed', completed_utc=utc(), error=traceback.format_exc()) + campaign.save() + raise + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/common.py b/benchmarks/tls_survey/common.py new file mode 100644 index 00000000..8a979f65 --- /dev/null +++ b/benchmarks/tls_survey/common.py @@ -0,0 +1,96 @@ +"""Shared immutable identities and predeclared survey populations.""" +from datetime import datetime, timezone +import hashlib +import importlib.util +import json +from pathlib import Path +import sys +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +CADENCES = ROOT / 'benchmarks/results/tls_sensitivity_2026-09-09/cadences' +SNRS = (6., 8., 10., 12.) +REGIMES = { + 'tess_solar': dict(cadence='tess_200s', impact=[.2,.7]), + 'tess_highimpact': dict(cadence='tess_200s', impact=[.94,.96], grid_oversampling=9), + 'tess_eccentric': dict(cadence='tess_200s', impact=[.2,.7], period=[6.,12.], eccentricity=[.7,.8], grid_oversampling=9), + 'tess_mdwarf': dict(cadence='tess_200s', impact=[.2,.7], radius=.1, mass=.1), + 'ztf_solar': dict(cadence='ztf', impact=[.2,.7]), + 'ztf_highimpact': dict(cadence='ztf', impact=[.94,.96], grid_oversampling=9), + 'ztf_mdwarf': dict(cadence='ztf', impact=[.2,.7], radius=.1, mass=.1), + 'tess_gap_long': dict(cadence='tess_gap', impact=[.2,.7], period=[15.,25.]), + 'tess_grazing_smeared': dict(cadence='tess_200s', impact=[.999,1.003], exposure_seconds=1800., stride=9, grid_oversampling=24), + 'hatpi_short': dict(cadence='hatpi', impact=[.2,.96], period=[.65,2.], grid_oversampling=9), +} +BLS_CONFIGS = { + 'bls_medium': dict(noverlap=4, dlogq=.1, qmin_factor=1.), + 'bls_fine': dict(noverlap=8, dlogq=.05, qmin_factor=.5), + 'bls_finest': dict(noverlap=16, dlogq=.025, qmin_factor=.25), + 'bls_strong': dict(noverlap=32, dlogq=.0125, qmin_factor=.125), +} + + +def method_applicable(method,regime): + return not (method=='bls_strong' and regime=='tess_gap_long') + + +def module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + sys.modules[name] = result + spec.loader.exec_module(result) + return result + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def array_hash(value): + v = np.ascontiguousarray(value) + h = hashlib.sha256(str(v.dtype).encode()+str(v.shape).encode()+v.tobytes()) + return h.hexdigest() + + +def write(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix+'.tmp') + temporary.write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False)+'\n') + temporary.replace(path) + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def source_identity(): + relatives = ['benchmarks/tls_accuracy/diagnose.py', 'cuvarbase/tls_reference_math.py', + 'benchmarks/transit/worker.py','benchmarks/tls_reference/cases.py', + 'benchmarks/tls_reference/validate.py'] + relatives += ['benchmarks/tls_survey/'+name for name in + ('common.py','generate.py','development.py','run.py','analyze.py', + 'bls_response.py','boundaries.py')] + relatives += [str(p.relative_to(ROOT)) for p in sorted(CADENCES.glob('*.npz'))] + return {p: sha(ROOT/p) for p in relatives} + + +def load_case(folder, entry): + path = Path(folder)/entry['file'] + if sha(path) != entry['sha256']: + raise ValueError('Case bytes changed: '+str(path)) + with np.load(path, allow_pickle=False) as data: + metadata = json.loads(str(data['metadata'])) + arrays = {k:data[k] for k in data.files if k != 'metadata'} + if metadata != entry['metadata']: + raise ValueError('Case metadata differs from manifest') + return arrays, metadata + + +def recovered(found, metadata, aliases=False): + if found is None or not np.isfinite(found): + return False + factors = (.5,1.,2.,1/3,3.) if aliases else (1.,) + return any(abs(found/(metadata['truth_period']*factor)-1)*metadata['baseline_days'] + <= .5*metadata['duration_days'] for factor in factors) diff --git a/benchmarks/tls_survey/development.py b/benchmarks/tls_survey/development.py new file mode 100644 index 00000000..b0e47f4b --- /dev/null +++ b/benchmarks/tls_survey/development.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Comparable expected filter SNR of native index-template and ideal box families. + +This uses actual GTLS cache deficits (including literal padding) at the known +period, every sample start, and a fitted weighted constant. It is an optimistic +filter-family diagnostic: native GTLS's depth estimator/ranker need not select +its matched-filter maximum. It never equates package SNR or SDE fields. +""" +import argparse +import json +from pathlib import Path +import numpy as np +from scipy.fft import rfft,irfft +from common import ROOT,load_case,module,now,sha,write + + +def weighted_snr(signal, template, errors): + w=errors**-2 + h=template-np.dot(w,template)/w.sum() + norm=np.sqrt(np.dot(w,h*h)) + return max(0.,float(np.dot(w*signal,h)/norm)) if norm>0 else 0. + + +def ou_filter_snr(times,signal,template,errors,amplitude,tau): + w=errors**-2 + h=template-np.dot(w,template)/w.sum() + coefficients=w*h + order=np.argsort(times) + a=coefficients[order] + t=times[order] + # a^T K a = amp^2 (sum a_i^2 + 2 sum_{j0 else 0. + + +def optimal_box(phase,signal,errors): + """All contiguous positive-endpoint intervals, with a fitted constant. + + Phases are centered on truth, so the physical transit support does not + wrap. Boxes with < half the total weight can only improve when a zero- + signal outer sample is removed. This unhandicapped control exhausts the + relevant intervals rather than using contact duration as the box width. + """ + order=np.argsort(phase) + s,w=signal[order],errors[order]**-2 + total=w.sum() + if w[s>0].sum() >= .5*total: + raise ValueError("Physical signal support exceeds half the weight; box certificate unsupported") + centered=s-np.dot(w,s)/total + cw=np.r_[0.,np.cumsum(w)] + cy=np.r_[0.,np.cumsum(w*centered)] + positive=np.flatnonzero(s>0) + best=0. + endpoints=None + for j,start in enumerate(positive): + ends=positive[j:]+1 + weight=cw[ends]-cw[start] + numerator=cy[ends]-cy[start] + good=(weight>0)&(weight<.5*total)&(numerator>0) + score=np.zeros(len(ends)) + score[good]=numerator[good]/np.sqrt(weight[good]*(1-weight[good]/total)) + k=int(score.argmax()) + if score[k]>best: + best=float(score[k]);endpoints=(int(start),int(ends[k])) + template=np.zeros(len(s)) + if endpoints: + template[order[endpoints[0]:endpoints[1]]]=1. + return best,template + + +def optimal_native_family(times,period,signal,errors,cache): + """FFT correlations enumerate every start of every native cache row.""" + order=np.argsort((times%period)/period) + s,w=signal[order],errors[order]**-2 + total=w.sum() + centered=s-np.dot(w,s)/total + fw,fs=rfft(w),rfft(w*centered) + best=0.;winner=None + n=len(s) + for index,width in enumerate(cache['widths']): + width=int(width) + if width>n: + continue + g=np.zeros(n) + g[:width]=cache['template_deficits'][index,:width] + fg=rfft(g) + numer=irfft(fs*np.conjugate(fg),n) + wg=irfft(fw*np.conjugate(fg),n) + wg2=irfft(fw*np.conjugate(rfft(g*g)),n) + variance=wg2-wg*wg/total + good=variance>max(float(wg2.max())*1e-12,0.) + scores=np.zeros(n) + scores[good]=np.maximum(0.,numer[good])/np.sqrt(variance[good]) + start=int(scores.argmax()) + if scores[start]>best: + best=float(scores[start]);winner=(index,width,start) + template=np.zeros(n) + if winner: + index,width,start=winner + template[order[(start+np.arange(width))%n]]=cache['template_deficits'][index,:width] + return best,template,winner + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest',type=Path,required=True) + parser.add_argument('--out',type=Path,required=True) + args=parser.parse_args() + manifest=json.loads(args.manifest.read_text()) + if manifest['split']!='development': + parser.error('Expected-SNR development may only consume the declared development split') + ref=module(ROOT/'cuvarbase/tls_reference_math.py','survey_diag_reference') + rows=[] + caches={} + for entry in manifest['cases']: + a,m=load_case(args.manifest.parent,entry) + key=(len(a['t']),tuple(a['periods'][[0,-1]])) + if key not in caches: + caches[key]=ref.build_cache(a['periods'],len(a['t'])) + phase=(a['t']-m['truth_epoch']+.5*m['truth_period'])%m['truth_period']-.5*m['truth_period'] + bs,bg=optimal_box(phase,a['signal'],a['dy']) + ts,tg,winner=optimal_native_family(a['t'],m['truth_period'],a['signal'],a['dy'],caches[key]) + oracle=weighted_snr(a['signal'],a['signal'],a['dy']) + red_kwargs=dict(amplitude=m['noise']['ou_amplitude'],tau=m['noise']['ou_tau_days']) + br=ou_filter_snr(a['t'],a['signal'],bg,a['dy'],**red_kwargs) + tr=ou_filter_snr(a['t'],a['signal'],tg,a['dy'],**red_kwargs) + rows.append(dict(name=m['name'],regime=m['regime'],ndata=m['ndata'],period=m['truth_period'], + q=m['fractional_duration'],impact=m['physical']['impact'],eccentricity=m['physical']['eccentricity'], + stellar_density_solar=m['stellar_density_solar'],observed_events=m['observed_events'], + in_transit_observations=m['in_transit_observations'],white_oracle_snr=oracle, + native_family_white_snr=ts,ideal_box_white_snr=bs, + native_family_ou_snr=tr,ideal_box_ou_snr=br, + native_white_advantage=ts/bs-1 if bs>0 else None, + native_ou_advantage=tr/br-1 if br>0 else None, + native_cache_winner=[int(v) for v in winner] if winner else None, + template_filter_white_check=weighted_snr(a['signal'],tg,a['dy']), + box_filter_white_check=weighted_snr(a['signal'],bg,a['dy']))) + write(args.out,dict(created_utc=now(),manifest_sha256=sha(args.manifest),diagnostic_sha256=sha(__file__),rows=rows, + interpretation='Known-period matched-filter ceilings for GTLS actual sample-index cache and unhandicapped boxes. Fitted weighted constant. Native depth/ranking may perform worse. White and OU variance are common definitions, never package-reported SNR/SDE.')) + print(json.dumps(dict(completed=len(rows),count=len(manifest['cases']))),flush=True) + + +if __name__=='__main__': + main() diff --git a/benchmarks/tls_survey/diagnose_repeatability.py b/benchmarks/tls_survey/diagnose_repeatability.py new file mode 100644 index 00000000..ad91d451 --- /dev/null +++ b/benchmarks/tls_survey/diagnose_repeatability.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Finite repeatability diagnosis; never a throughput or sensitivity result.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +import time +import traceback + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def differences(before, after): + import numpy as np + if before.dtype != after.dtype or before.shape != after.shape: + return dict(same_shape_dtype=False, exact=False) + finite = np.isfinite(before) & np.isfinite(after) + delta = after[finite].astype(float)-before[finite].astype(float) + return dict(same_shape_dtype=True, exact=before.tobytes()==after.tobytes(), + changed_finite_values=int(np.count_nonzero(delta)), + changed_finite_masks=int(np.count_nonzero(np.isfinite(before)!=np.isfinite(after))), + max_absolute_difference=float(np.abs(delta).max()) if delta.size else None) + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--candidate-root',type=Path,required=True) + parser.add_argument('--source-root',type=Path,required=True) + parser.add_argument('--manifest',type=Path,required=True) + parser.add_argument('--science-seal',type=Path,required=True) + parser.add_argument('--backend',choices=('baseline','candidate','gtls','bls'),required=True) + parser.add_argument('--output',type=Path,required=True) + parser.add_argument('--repetitions',type=int,default=3) + parser.add_argument('--names',nargs='*',default=[]) + args=parser.parse_args() + args.output.mkdir(parents=True,exist_ok=False) + sys.path.insert(0,str(args.candidate_root.resolve())) + from benchmarks.tls_survey import throughput as native + from benchmarks.tls_survey import common + import numpy as np + import cupy as cp + sys.path.insert(0,str(args.source_root.resolve())) + cases=native.load_manifest(args.manifest,args.names) + if args.backend=='bls': + seal=native.configure_bls(cases,args.science_seal) + from cuvarbase.base import ensure_context + ensure_context() + science=native.science_bls_module() + if science.production_identity()!=seal['production_sources']: + raise ValueError('BLS source tree differs from frozen science') + else: + native.initialize_backend('candidate' if args.backend=='baseline' else args.backend) + native.prepare_grids(cases) + started=time.perf_counter() + record=dict(backend=args.backend,purpose='repeatability_diagnostic_not_timing_or_requalification', + environment=native.resource_environment(),source_root=str(args.source_root), + source_sha256=sha(__file__),manifest_sha256=sha(args.manifest), + names=[c['name'] for c in cases],repetitions=args.repetitions,observations=[],status='running') + anchors={} + native.write(args.output/'result.json',record) + for repetition in range(args.repetitions): + for case in cases: + beginning=time.perf_counter() + row=dict(case=case['name'],repetition=repetition,input_sha256=case['input_sha256']) + try: + result=native.public_call('candidate' if args.backend=='baseline' else args.backend, + [case],arrays=True)[0] + cp.cuda.runtime.deviceSynchronize() + fp=native.complete_fingerprint('candidate' if args.backend=='baseline' else args.backend,case,result) + values=vars(result) if args.backend=='gtls' else result + arrays=result['_arrays'] if args.backend=='bls' else { + key:np.asarray(np.ma.filled(values[key],np.nan)) for key in ('periods','power','chi2')} + path=args.output/f'{repetition}-{case["name"]}' + np.savez_compressed(path,**arrays) + scalar_keys=('period','score') if args.backend=='bls' else ('period','SDE') + row.update(status='success',strict=fp['strict'], + scalar={k:float(values[k]) for k in scalar_keys}, + artifact=dict(file=path.name,sha256=sha(path))) + if case['name'] not in anchors: + if repetition==0: + anchors[case['name']]=dict(path=path,strict=fp['strict'],scalar=row['scalar']) + else: + row['comparison']='original reference unavailable' + else: + anchor=anchors[case['name']] + with np.load(anchor['path'],allow_pickle=False) as original: + row['array_differences']={key:differences(original[key],value) for key,value in arrays.items()} + row['changed_strict_fields']=[key for key in fp['strict'] if fp['strict'][key]!=anchor['strict'][key]] + row['scalar_differences']={key:row['scalar'][key]-anchor['scalar'][key] for key in scalar_keys} + except Exception: + row.update(status='error',error=traceback.format_exc()) + row['elapsed_seconds_including_diagnostics']=time.perf_counter()-beginning + record['observations'].append(row) + native.write(args.output/'result.json',record) + print(json.dumps({k:v for k,v in row.items() if k not in ('strict','artifact','error')}),flush=True) + record.update(status='complete',elapsed_seconds=time.perf_counter()-started, + failed_calls=sum(r['status']=='error' for r in record['observations']), + changed_repeats=sum(bool(r.get('changed_strict_fields')) for r in record['observations'])) + native.write(args.output/'result.json',record) + + +if __name__=='__main__': + main() diff --git a/benchmarks/tls_survey/exactness.py b/benchmarks/tls_survey/exactness.py new file mode 100644 index 00000000..b9c96bca --- /dev/null +++ b/benchmarks/tls_survey/exactness.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Qualify every held-out TLS result against an immutable baseline, separately. + +Freeze this operational plan alongside the science seal before held-out inputs +exist. The original candidate receipt is always the primary comparison; repeat +diagnostics never replace either original outcome. +""" +import argparse +import fcntl +import json +import os +from pathlib import Path +import sys +import time +import traceback + +for _name in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[_name] = '1' + +import numpy as np +from common import ROOT, array_hash, load_case, now, recovered, sha, source_identity, write +from campaign import check_manifest, check_result + + +def package_identity(root): + package = Path(root) / 'cuvarbase' + return {str(path.relative_to(package)): sha(path) for path in sorted(package.rglob('*')) + if path.is_file() and path.suffix in ('.py', '.cu', '.cuh')} + + +def candidate_decisions(row, metadata, thresholds): + candidate = row.get('candidates', {}).get('native', {}) + value = candidate.get('score') + decisions = {} + for key in ('thresholds', 'secondary_thresholds'): + cut = thresholds[key][metadata['regime'] + '/tls'] + above = bool(row['valid'] and value is not None and value > cut['value']) + decisions[key] = dict(target_fpr=cut['target_fpr'], threshold=cut['value'], above=above, + detected=above and (metadata['null'] or candidate.get('recovered', False))) + return decisions + + +def compare(original, baseline, metadata, thresholds): + differences = [] + if not original['valid'] or not baseline['valid']: + differences.append('unavailable_valid_execution') + if original['valid'] != baseline['valid']: + differences.append('validity') + for field in sorted(set(original['spectra']) | set(baseline['spectra'])): + if original['spectra'].get(field) != baseline['spectra'].get(field): + differences.append('spectrum/' + field) + if original.get('candidates', {}).get('native') != baseline.get('candidates', {}).get('native'): + differences.append('candidate_period_score_recovery') + first = candidate_decisions(original, metadata, thresholds) + second = candidate_decisions(baseline, metadata, thresholds) + if first != second: + differences.append('frozen_threshold_decisions') + return dict(exact=not differences, differences=differences, + original_candidate_decisions=first, baseline_decisions=second) + + +def baseline_search(arrays, metadata): + """Capture the original full available periodogram before computing hashes.""" + from cuvarbase.tls import tls_search_gpu + import pycuda.driver as driver + result = tls_search_gpu(arrays['t'], arrays['y'], arrays['dy'], periods=arrays['periods'], + return_arrays=True, **metadata['search_kwargs']) + driver.Context.synchronize() + def finite(value): + return float(value) if value is not None and np.isfinite(value) else None + native = dict(period=finite(result['period']), score=finite(result['SDE'])) + native['successful_no_candidate'] = native['period'] is None and native['score'] == 0. + native['no_candidate_reason'] = result.get('error') if native['successful_no_candidate'] else None + native['recovered'] = recovered(native['period'], metadata) + native['alias_recovered'] = recovered(native['period'], metadata, aliases=True) + arrays_out = {key: np.asarray(np.ma.filled(result[key], np.nan)) for key in ('periods', 'chi2')} + arrays_out['valid_mask'] = np.isfinite(np.ma.filled(result['chi2'], np.nan)) + valid = native['score'] is not None and (native['period'] is not None or native['successful_no_candidate']) + return dict(valid=valid, candidates={'native': native}, spectra={ + key: array_hash(value) for key, value in arrays_out.items()}, error=None), arrays_out + + +def measured_search(arrays, metadata): + begin = time.perf_counter() + try: + row, spectra = baseline_search(arrays, metadata) + except Exception: + row = dict(valid=False, candidates={}, spectra={}, error=traceback.format_exc()) + spectra = {} + row['elapsed_s'] = time.perf_counter() - begin + return row, spectra + + +def freeze(args): + if any((args.campaign / ('inputs-' + split)).exists() + for split in ('calibration', 'injections', 'nulls')): + raise ValueError('Freeze the implementation plan before any final campaign inputs exist') + seal = json.loads(args.seal.read_text()) + if source_identity() != seal['source_identity'] or package_identity(ROOT) != seal['production_sources']: + raise ValueError('Candidate sources differ from the proposed science seal') + baseline = package_identity(args.baseline_root) + if not baseline or args.baseline_root.resolve() == ROOT.resolve(): + raise ValueError('Provide a separate immutable baseline checkout') + count = sum(seal['counts'][split] for split in ('injections', 'nulls')) * len(seal['regimes']) + if args.out.exists(): + raise ValueError('Refuse to overwrite the frozen implementation-qualification plan') + write(args.out, dict(schema_version=1, created_utc=now(), seal_sha256=sha(args.seal), + protocol_sha256=sha(__file__), baseline_root=str(args.baseline_root.resolve()), + heldout_snr_protocol_sha256=sha(ROOT / 'benchmarks/tls_survey/heldout_snr.py'), + planned_campaign_root=str(args.campaign.resolve()), + baseline_sources=baseline, expected_cases=count, splits=['injections', 'nulls'], workers=1, + primary_reference='Original candidate scientific receipts; never replace with reruns', + acceptance='Every valid execution, full available period/chi2/valid-mask hash, selected period/SDE, recovery, and both frozen-threshold decisions identical. Any mismatch withholds aggregate exactness.', + repeat_diagnostics=dict(first_mismatching_cases=10, additional_baseline_runs=2, + policy='Retain every repeat separately; never overwrite original outcomes'), + estimated_extra_hours=4.837752061155108 * count / 5120, + estimated_extra_gpu_usd=.49 * 4.837752061155108 * count / 5120, + estimate_basis='80 immutable-baseline development calls, one worker; planning estimate, not sustained throughput.')) + + +def run(args): + plan = json.loads(args.plan.read_text()); seal = json.loads(args.seal.read_text()) + if sha(args.plan) != args.plan_sha256 or sha(args.seal) != plan['seal_sha256']: + raise ValueError('Plan or science seal differs from explicitly reviewed identity') + if sha(__file__) != plan['protocol_sha256']: + raise ValueError('Implementation-qualification protocol changed after freeze') + if args.campaign.resolve() != Path(plan['planned_campaign_root']).resolve(): + raise ValueError('Campaign directory differs from the pre-input frozen plan') + baseline_root = Path(getattr(args, 'baseline_root', None) or plan['baseline_root']).resolve() + def guard(): + if (source_identity() != seal['source_identity'] or + package_identity(ROOT) != seal['production_sources'] or + package_identity(baseline_root) != plan['baseline_sources']): + raise ValueError('Candidate or immutable baseline sources changed') + guard() + thresholds_path = args.campaign / 'thresholds.json' + thresholds = json.loads(thresholds_path.read_text()) + if thresholds['seal_sha256'] != sha(args.seal): + raise ValueError('Thresholds belong to another scientific design') + entries = []; originals = {}; receipt_identities = [] + for split in plan['splits']: + manifest_path = args.campaign / ('inputs-' + split) / 'manifest.json' + manifest = check_manifest(manifest_path, split, seal, sha(args.seal)) + entries.extend((split, manifest_path.parent, entry) for entry in manifest['cases']) + for shard in range(seal['execution_shards']): + path = args.campaign / (split + '-search-' + str(shard) + '.json') + receipt = check_result(path, manifest_path, manifest, seal, shard) + receipt_identities.append(dict(path=str(path), sha256=sha(path))) + for row in receipt['cases']: + if row['method'] == 'tls': + key = (split, row['name']) + if key in originals: + raise ValueError('Duplicate original candidate outcome') + originals[key] = row + if len(entries) != plan['expected_cases'] or len(originals) != len(entries): + raise ValueError('Missing planned held-out implementation comparisons') + identity = dict(plan_sha256=sha(args.plan), seal_sha256=sha(args.seal), + thresholds_sha256=sha(thresholds_path), candidate_receipts=receipt_identities) + state = json.loads(args.out.read_text()) if args.out.exists() else dict( + started_utc=now(), identity=identity, protocol_sha256=sha(__file__), cases=[]) + if state['identity'] != identity: + raise ValueError('Cannot resume qualification with altered original receipts or thresholds') + done = {(row['split'], row['name']) for row in state['cases']} + if len(done) != len(state['cases']) or not done.issubset(originals): + raise ValueError('Duplicate or foreign qualification rows') + # One isolated process imports only the immutable baseline numerical package. + sys.path.insert(0, str(baseline_root)) + import cuvarbase + if Path(cuvarbase.__file__).resolve().parent != (baseline_root / 'cuvarbase').resolve(): + raise ValueError('Numerical import escaped the immutable baseline checkout') + from cuvarbase.base import ensure_context + ensure_context() + import pycuda.driver as driver + state.update(status='running', gpu=str(driver.Context.get_device().name()), workers=1, + baseline_sources=plan['baseline_sources']) + write(args.out, state) + for split, folder, entry in entries: + name = entry['metadata']['name']; key = (split, name) + if key in done: + continue + arrays, metadata = load_case(folder, entry) + original = originals[key] + if original['input_sha256'] != entry['sha256']: + raise ValueError('Original candidate searched another input') + baseline, spectra = measured_search(arrays, metadata) + primary = compare(original, baseline, metadata, thresholds) + row = dict(split=split, name=name, regime=metadata['regime'], input_sha256=entry['sha256'], + original_candidate=original, baseline=baseline, comparison=primary, repeats=[], + repeat_status='pending' if not primary['exact'] else 'not_required') + # Publish the original outcome before any optional diagnostic. A stopped + # diagnostic must never cause its primary mismatch to be replaced. + state['cases'].append(row) + state.update(completed_cases=len(state['cases']), mismatches=sum( + not item['comparison']['exact'] for item in state['cases']), heartbeat_utc=now()) + write(args.out, state) + if not primary['exact']: + assets = args.out.parent / (args.out.stem + '-mismatches'); assets.mkdir(exist_ok=True) + artifact = assets / (name + '-original-baseline.npz') + np.savez_compressed(artifact, **spectra) + row['original_baseline_arrays'] = dict(path=str(artifact), sha256=sha(artifact)) + write(args.out, state) + earlier = state['mismatches'] - 1 + if earlier < plan['repeat_diagnostics']['first_mismatching_cases']: + for index in range(plan['repeat_diagnostics']['additional_baseline_runs']): + repeated, _ = measured_search(arrays, metadata) + row['repeats'].append(dict(index=index, baseline=repeated, + versus_original_candidate=compare(original, repeated, metadata, thresholds), + versus_original_baseline=compare(baseline, repeated, metadata, thresholds))) + write(args.out, state) + row['repeat_status'] = 'complete' + else: + row['repeat_omission'] = 'Predeclared first-mismatch diagnostic cap reached' + row['repeat_status'] = 'capped' + write(args.out, state) + print(json.dumps(dict(completed=len(state['cases']), planned=len(entries), mismatches=state['mismatches'])), flush=True) + guard() + state.update(status='complete', completed_utc=now(), exactness_qualified=state['mismatches'] == 0, + incomplete_repeat_diagnostics=sum(row['repeat_status'] == 'pending' for row in state['cases']), + interpretation='Finite held-out implementation qualification; no universal numerical or physical equivalence claim. Original failures remain failures regardless of repeat outcomes.') + write(args.out, state) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + frozen = sub.add_parser('freeze'); frozen.add_argument('--baseline-root', type=Path, required=True) + frozen.add_argument('--campaign', type=Path, required=True, + help='Planned final campaign directory, before any final inputs exist') + executed = sub.add_parser('run'); executed.add_argument('--plan', type=Path, required=True) + executed.add_argument('--plan-sha256', required=True) + executed.add_argument('--campaign', type=Path, required=True) + executed.add_argument('--baseline-root', type=Path, + help='Optional relocated checkout; every frozen source hash must still match') + for command in (frozen, executed): + command.add_argument('--seal', type=Path, required=True) + command.add_argument('--out', type=Path, required=True) + args = parser.parse_args(); args.out.parent.mkdir(parents=True, exist_ok=True) + if args.command == 'run': + with args.out.with_suffix('.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + run(args) + else: + freeze(args) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/followup.py b/benchmarks/tls_survey/followup.py new file mode 100644 index 00000000..45f36c3d --- /dev/null +++ b/benchmarks/tls_survey/followup.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Launch a separately recorded follow-up using the preserved survey sources. + +Set numerical-library limits before Python imports NumPy or starts any worker. +The historical experiment, source trees and output directories remain immutable. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +THREAD_VARIABLES = ( + 'OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS', +) + + +def execution_environment(parent=None): + environment = dict(os.environ if parent is None else parent) + environment.update({name: '1' for name in THREAD_VARIABLES}) + environment.update(PYTHONNOUSERSITE='1', PYTHONUTF8='1', LC_ALL='C.UTF-8') + # Both PyCUDA and the short-prefix compiler invoke nvcc by name. + environment['PATH'] = '/usr/local/cuda/bin:' + environment.get('PATH', '') + return environment + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def verify_plan(path, expected): + path = Path(path).resolve() + if sha(path) != expected: + raise ValueError('Follow-up plan changed') + plan = json.loads(path.read_text()) + root = path.parent + for relative, digest in plan['files'].items(): + source = root / relative + if source.is_symlink() or not source.is_file() or sha(source) != digest: + raise ValueError('Follow-up source/input changed: ' + relative) + return plan + + +def launch(plan_path, plan_sha, command, output): + """Record one attempt, including a failed subprocess; never replace evidence.""" + plan = verify_plan(plan_path, plan_sha) + output = Path(output) + output.mkdir(parents=True, exist_ok=False) + environment = execution_environment() + receipt = dict(plan_sha256=plan_sha, command=command, started_epoch=time.time(), + launcher_sha256=sha(__file__), + cpu_math_thread_environment={key: environment[key] for key in THREAD_VARIABLES}, + purpose=plan['purpose'], status='running') + record = output / 'launch.json' + record.write_text(json.dumps(receipt, indent=2) + '\n') + process = None + try: + with (output / 'stdout.log').open('xb') as stdout, (output / 'stderr.log').open('xb') as stderr: + process = subprocess.Popen(command, env=environment, stdout=stdout, stderr=stderr, + start_new_session=True) + result = process.wait(timeout=plan['attempt_timeout_seconds']) + receipt.update(exit_code=result, status='complete' if result == 0 else 'failed') + except BaseException as error: + receipt.update(status='failed', error=type(error).__name__ + ': ' + str(error)) + if process is not None: + for sig in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + continue + # The leader may exit before its worker descendants. + if sig == signal.SIGKILL: + break + raise + finally: + receipt['ended_epoch'] = time.time() + receipt['elapsed_seconds'] = receipt['ended_epoch'] - receipt['started_epoch'] + record.write_text(json.dumps(receipt, indent=2) + '\n') + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--plan', type=Path, required=True) + parser.add_argument('--plan-sha256', required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('command', nargs=argparse.REMAINDER) + args = parser.parse_args() + command = args.command[1:] if args.command[:1] == ['--'] else args.command + if not command: + parser.error('A child command is required') + return launch(args.plan, args.plan_sha256, command, args.output) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/benchmarks/tls_survey/generate.py b/benchmarks/tls_survey/generate.py new file mode 100644 index 00000000..21088586 --- /dev/null +++ b/benchmarks/tls_survey/generate.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Generate development, calibration, or sealed independent recovery populations.""" +import argparse +from dataclasses import asdict +import json +import platform +from pathlib import Path +import numpy as np +from common import (ROOT, CADENCES, REGIMES, SNRS, module, now, sha, source_identity, write) + + +def physical_modules(): + return (module(ROOT/'benchmarks/tls_accuracy/diagnose.py', 'survey_physics'), + module(ROOT/'cuvarbase/tls_reference_math.py', 'survey_reference'), + module(ROOT/'benchmarks/tls_reference/cases.py', 'survey_cases')) + + +def hatpi_cadence(): + """Synthetic 30-s samples, eight-hour nights; missing nights and nightly gaps.""" + times = np.concatenate([night+np.arange(0.,8/24,30/86400) for night in range(30) + if night % 7 not in (3,4)]) + times = times[(times % 1 < .12) | (times % 1 > .15)] + errors = 1.+.5*np.sin(np.pi*(times%1)/(8/24))**2 + return times, errors, np.full(len(times),30/86400), np.zeros(len(times),int) + + +def make_case(regime, split, index, diag, ref, shared, exposure_nodes=64): + name = '%s_%s_%04d' % (regime,split,index) + settings = REGIMES[regime] + rng = shared.deterministic_rng('survey-v1-20260910:'+split,name) + if settings['cadence'] == 'hatpi': + t, errors, exposure, band = hatpi_cadence() + provenance = 'Synthetic HATpi-like 30-second nightly sampling; no real HATpi data' + else: + with np.load(CADENCES/(settings['cadence']+'.npz')) as d: + t,errors,exposure,band = [np.asarray(d[k]).copy() for k in + ('t','relative_error','exposure_days','band')] + provenance = 'Observed archived survey timestamps; synthetic flux and noise' + stride = settings.get('stride',1) + t,errors,exposure,band = (v[::stride] for v in (t,errors,exposure,band)) + if 'exposure_seconds' in settings: + exposure[:] = settings['exposure_seconds']/86400 + errors = errors/np.median(errors) + # Independent occasional poor measurements, including ~10x error range. + errors *= np.exp(rng.uniform(-.5,.5,len(t))) + radius,mass = (settings.get(k,1.) for k in ('radius','mass')) + period = rng.uniform(*settings.get('period',(2.,6.))) + impact = rng.uniform(*settings['impact']) + eccentricity = rng.uniform(*settings.get('eccentricity',(0.,0.))) + physical = diag.Regime(name,period,radius=radius,mass=mass,rp=.00916/radius, + impact=impact,eccentricity=eccentricity) + duration,full,a = diag.durations(physical) + epoch = rng.random()*period + signal = diag.physical_signal(physical,t,exposure,epoch=epoch,exposure_nodes=exposure_nodes) + inside = signal > signal.max()*1e-8 if signal.max() > 0 else np.zeros(len(t),bool) + events = np.unique(np.rint((t[inside]-epoch)/period)).size + snr = float(SNRS[index%len(SNRS)] if split in ('development','injections') else rng.choice(SNRS)) + norm = shared.weighted_signal_norm(signal,errors) + # No observability rejection: unsampled and one-event cases remain planned outcomes. + scale = norm/snr if norm > 0 else 1e-4 + dy = scale*errors + tau = 1. if settings['cadence']=='ztf' else .15 + amplitude = .25*np.median(dy) + noise = dy*rng.normal(size=len(t))+shared.ou_noise(t,amplitude,tau,rng) + injected = split in ('development','injections') + y = 1.+noise-(signal if injected else 0.) + bounds = (.6, 12.878375495285127 if settings['cadence']=='tess_200s' else + 10. if settings['cadence']=='ztf' else 5. if settings['cadence']=='hatpi' else 27.457888046800917) + periods = np.sort(ref.period_grid(float(np.ptp(t)),R_star=radius,M_star=mass, + period_min=bounds[0],period_max=bounds[1], + oversampling_factor=settings.get("grid_oversampling",3))) + metadata = dict(name=name,regime=regime,split=split,cohort=split,null=not injected, + purpose='recovery_full_grid',physical=asdict(physical),truth_period=period,truth_epoch=epoch+1., + duration_days=duration,full_duration_days=full,ingress_days=.5*(duration-full), + stellar_density_solar=mass/radius**3,semimajor_stellar_radii=a, + periastron_stellar_radii=a*(1-eccentricity),baseline_days=float(np.ptp(t)),ndata=len(t), + cadence=settings['cadence'],cadence_construction=provenance, + fractional_duration=duration/period,in_transit_observations=int(inside.sum()),observed_events=int(events), + conditional_population=False,observable=bool(inside.sum()>=5 and events>=2), + latent_white_oracle_snr=snr,realized_latent_white_oracle_snr=norm/scale, + exposure_quadrature_nodes=exposure_nodes,truth_inserted_in_grid=False,period_count=len(periods), + noise=dict(ou_amplitude=float(amplitude),ou_tau_days=tau,oracle_snr_excludes_ou=True), + period_bounds=list(bounds), + grid_kwargs=dict(R_star=radius,M_star=mass,period_min=bounds[0],period_max=bounds[1], + oversampling_factor=settings.get('grid_oversampling',3),n_transits_min=2), + nearest_grid_drift_over_half_duration=float(np.min(np.abs(periods/period-1))*np.ptp(t)/(.5*duration)), + search_kwargs=dict(R_star=radius,M_star=mass,oversampling_factor=3)) + return dict(t=t+1.,y=y,dy=dy,periods=periods,signal=signal,exposure_days=exposure,band=band), metadata + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out',type=Path,required=True) + parser.add_argument('--split',choices=('development','development_nulls','calibration','injections','nulls'),required=True) + parser.add_argument('--count',type=int,required=True,help='Per regime; injections balanced over SNR 6/8/10/12') + parser.add_argument('--regimes',default=','.join(REGIMES)) + parser.add_argument('--seal',type=Path) + parser.add_argument('--exposure-nodes',type=int,default=64) + args=parser.parse_args() + if args.out.exists(): + parser.error('Refuse to overwrite an input directory') + if args.count<1 or args.exposure_nodes<16: + parser.error('Positive count and at least 16 exposure quadrature nodes required') + identity=source_identity() + regimes=args.regimes.split(',') + if args.split in ('calibration','injections','nulls'): + if args.seal is None: + parser.error('Independent generation requires a frozen development seal') + seal=json.loads(args.seal.read_text()) + if seal['source_identity'] != identity: + parser.error('Generator/analysis sources changed after development freeze') + if regimes != seal['regimes'] or args.count != seal['counts'][args.split]: + parser.error('Requested population differs from frozen plan') + if args.exposure_nodes != seal['exposure_nodes']: + parser.error('Exposure quadrature differs from frozen plan') + # cases.py imports validate from its own directory. + import sys + sys.path.insert(0,str(ROOT/'benchmarks/tls_reference')) + diag,ref,shared=physical_modules() + args.out.mkdir(parents=True) + manifest=dict(status='running',suite='survey-'+args.split,created_utc=now(),split=args.split,source_identity=identity, + seal_sha256=sha(args.seal) if args.seal else None, + count_per_regime=args.count,regimes=regimes,environment=dict(python=platform.python_version(),numpy=np.__version__),cases=[]) + write(args.out/'manifest.json',manifest) + for regime in regimes: + for index in range(args.count): + arrays,metadata=make_case(regime,args.split,index,diag,ref,shared,args.exposure_nodes) + filename=metadata['name']+'.npz' + np.savez_compressed(args.out/filename,**arrays,metadata=json.dumps(metadata,sort_keys=True)) + manifest['cases'].append(dict(file=filename,sha256=sha(args.out/filename),metadata=metadata, + arrays={key:shared.array_hash(value) for key,value in arrays.items()})) + write(args.out/'manifest.json',manifest) + print(json.dumps(dict(regime=regime,completed=len(manifest['cases']))),flush=True) + manifest['status']='complete' + write(args.out/'manifest.json',manifest) + + +if __name__=='__main__': + main() diff --git a/benchmarks/tls_survey/heldout_snr.py b/benchmarks/tls_survey/heldout_snr.py new file mode 100644 index 00000000..4ec69535 --- /dev/null +++ b/benchmarks/tls_survey/heldout_snr.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Apply the frozen development filter diagnostic to every held-out injection. + +This operational wrapper preserves the development-only guard in development.py. +It imports that module's existing numerical definitions without modifying them. +""" +import argparse +import json +import os +from pathlib import Path +import time + +for _name in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[_name] = '1' + +from common import ROOT, load_case, module, now, sha, source_identity, write +from development import optimal_box, optimal_native_family, weighted_snr, ou_filter_snr + + +def diagnostic_row(a, m, cache): + """Identical row definitions to the frozen development.py main loop.""" + phase = (a['t'] - m['truth_epoch'] + .5 * m['truth_period']) % m['truth_period'] - .5 * m['truth_period'] + bs, bg = optimal_box(phase, a['signal'], a['dy']) + ts, tg, winner = optimal_native_family(a['t'], m['truth_period'], a['signal'], a['dy'], cache) + oracle = weighted_snr(a['signal'], a['signal'], a['dy']) + red_kwargs = dict(amplitude=m['noise']['ou_amplitude'], tau=m['noise']['ou_tau_days']) + br = ou_filter_snr(a['t'], a['signal'], bg, a['dy'], **red_kwargs) + tr = ou_filter_snr(a['t'], a['signal'], tg, a['dy'], **red_kwargs) + return dict(name=m['name'], regime=m['regime'], ndata=m['ndata'], period=m['truth_period'], + q=m['fractional_duration'], impact=m['physical']['impact'], eccentricity=m['physical']['eccentricity'], + stellar_density_solar=m['stellar_density_solar'], observed_events=m['observed_events'], + in_transit_observations=m['in_transit_observations'], white_oracle_snr=oracle, + native_family_white_snr=ts, ideal_box_white_snr=bs, + native_family_ou_snr=tr, ideal_box_ou_snr=br, + native_white_advantage=ts/bs-1 if bs > 0 else None, + native_ou_advantage=tr/br-1 if br > 0 else None, + native_cache_winner=[int(v) for v in winner] if winner else None, + template_filter_white_check=weighted_snr(a['signal'], tg, a['dy']), + box_filter_white_check=weighted_snr(a['signal'], bg, a['dy'])) + + +def execute(args): + begin = time.perf_counter() + manifest = json.loads(args.manifest.read_text()) + if manifest['status'] != 'complete': + raise ValueError('Require a complete original input manifest') + def guard(): + if args.command == 'run': + seal = json.loads(args.seal.read_text()); plan = json.loads(args.plan.read_text()) + if (sha(args.plan) != args.plan_sha256 or plan['seal_sha256'] != sha(args.seal) or + manifest['seal_sha256'] != sha(args.seal) or manifest['split'] != 'injections' or + source_identity() != seal['source_identity'] or + manifest['source_identity'] != seal['source_identity'] or + sha(__file__) != plan['heldout_snr_protocol_sha256']): + raise ValueError('Held-out diagnostic differs from the reviewed sources/plan/inputs') + if args.manifest.resolve() != (Path(plan['planned_campaign_root']) / 'inputs-injections/manifest.json').resolve(): + raise ValueError('Diagnostic inputs differ from the pre-input planned campaign') + if manifest['regimes'] != seal['regimes'] or manifest['count_per_regime'] != seal['counts']['injections']: + raise ValueError('Diagnostic population differs from the science seal') + for regime in seal['regimes']: + if sum(entry['metadata']['regime'] == regime for entry in manifest['cases']) != seal['counts']['injections']: + raise ValueError('Missing planned diagnostic regime') + elif manifest['split'] != 'development': + raise ValueError('Wrapper validation may only consume development inputs') + guard() + identity = dict(manifest_sha256=sha(args.manifest), wrapper_sha256=sha(__file__), + scientific_diagnostic_sha256=sha(ROOT / 'benchmarks/tls_survey/development.py'), + cache_math_sha256=sha(ROOT / 'cuvarbase/tls_reference_math.py')) + if args.command == 'run': + identity.update(seal_sha256=sha(args.seal), auxiliary_plan_sha256=sha(args.plan)) + state = json.loads(args.out.read_text()) if args.out.exists() else dict( + identity=identity, manifest_sha256=identity['manifest_sha256'], split=manifest['split'], + started_utc=now(), rows=[], attempts=[], interpretation='Frozen known-period GTLS sample-index family and ideal-box filter ceilings, with common fitted-constant white/OU expected SNR; no package-SNR/SDE equivalence, no endpoint or setting selection.') + if state['identity'] != identity: + raise ValueError('Cannot resume with changed diagnostic definitions or input identity') + if args.command == 'run': + state.update(seal_sha256=identity['seal_sha256'], auxiliary_plan_sha256=identity['auxiliary_plan_sha256']) + planned = {entry['metadata']['name']: entry['sha256'] for entry in manifest['cases']} + done = {row['name']: row['input_sha256'] for row in state['rows']} + if len(planned) != len(manifest['cases']) or len(done) != len(state['rows']) or any(planned.get(k) != v for k, v in done.items()): + raise ValueError('Duplicate or foreign diagnostic input/output') + ref = module(ROOT / 'cuvarbase/tls_reference_math.py', 'heldout_snr_cache_math') + caches = {} + state.update(status='running', planned_cases=len(planned)) + state['attempts'].append(dict(started_utc=now(), numerical_threads=1)) + write(args.out, state) + for entry in manifest['cases']: + if entry['metadata']['name'] in done: + continue + started = time.perf_counter() + a, m = load_case(args.manifest.parent, entry) + key = (len(a['t']), tuple(a['periods'][[0, -1]])) + if key not in caches: + caches[key] = ref.build_cache(a['periods'], len(a['t'])) + row = diagnostic_row(a, m, caches[key]) + row.update(input_sha256=entry['sha256'], elapsed_s=time.perf_counter()-started) + state['rows'].append(row) + state['completed_cases'] = len(state['rows']) + write(args.out, state) + print(json.dumps(dict(completed=len(state['rows']), count=len(planned))), flush=True) + if sha(args.manifest) != identity['manifest_sha256']: + raise ValueError('Input manifest changed during the diagnostic') + guard() + state['attempts'][-1].update(completed_utc=now(), elapsed_s=time.perf_counter()-begin) + state.update(status='complete', completed_utc=now(), total_attempt_seconds=sum( + attempt.get('elapsed_s', 0.) for attempt in state['attempts'])) + write(args.out, state) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + validation = sub.add_parser('validate') + run = sub.add_parser('run'); run.add_argument('--seal', type=Path, required=True) + run.add_argument('--plan', type=Path, required=True); run.add_argument('--plan-sha256', required=True) + for command in (validation, run): + command.add_argument('--manifest', type=Path, required=True) + command.add_argument('--out', type=Path, required=True) + execute(parser.parse_args()) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/plot_native_bls_comparison.py b/benchmarks/tls_survey/plot_native_bls_comparison.py new file mode 100644 index 00000000..b5bab539 --- /dev/null +++ b/benchmarks/tls_survey/plot_native_bls_comparison.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Present strict TLS timings beside separately measured native BLS execution. + +The original qualified figure is left intact. Native BLS execution measurements +never acquire exact-repeatability qualification in this presentation. +""" +import argparse +import csv +import hashlib +import json +import math +from pathlib import Path +import statistics +import sys + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from benchmarks.tls_survey.plot_throughput import ( + BACKENDS, SCOPES, heldout_qualification, read_campaign, +) + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def canonical_sha(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + allow_nan=False).encode()).hexdigest() + + +def allocation(record): + environment = record['environment'] + values = tuple(environment.get(key) for key in ( + 'nvidia_smi', 'cpu_quota_cores', 'host_memory_limit_bytes')) + if any(value is None for value in values): + raise ValueError('Unverified resource allocation') + return values + + +def cohort(record): + rows = record['cohort'] + identities = [tuple(row[key] for key in + ('name', 'regime', 'nobs', 'nperiods', 'input_sha256')) for row in rows] + if not identities or len({row[0] for row in identities}) != len(identities): + raise ValueError('Empty or duplicated timing cohort') + return sorted(identities) + + +def execution_rates(record): + """Validate accounting, without introducing a numerical passing tolerance.""" + if (record.get('status') != 'complete' or record.get('execution_rates_valid') is not True or + record.get('gpu_ownership', {}).get('passed') is not True): + raise ValueError('Execution or ownership validity failed') + if record['numerical'].get('original_qualification_passed') is not False: + raise ValueError('Native BLS must retain its failed original qualification') + repetitions = record['repetitions'] + if len(repetitions) != 3: + raise ValueError('Exactly three planned sustained repetitions required') + rates = [] + for row in repetitions: + if row.get('status') != 'completed_queue': + raise ValueError('Interrupted or instrument-invalid queue') + counts = [row[key] for key in ('attempted_count', 'successful_count', 'failed_count')] + if any(type(value) is not int or value < 0 for value in counts): + raise ValueError('Invalid execution counts') + attempted, successful, failed = counts + seconds = row['elapsed_seconds'] + if (attempted != successful + failed or attempted < 96 or + not math.isfinite(seconds) or seconds < 120): + raise ValueError('Incomplete sustained queue or inconsistent accounting') + rate = successful / seconds + reported = row['successful_lightcurves_per_second'] + if not math.isfinite(reported) or not math.isclose(rate, reported, rel_tol=1e-12, abs_tol=0): + raise ValueError('Reported execution rate omits failed work or elapsed time') + rates.append(rate) + return rates + + +IDENTITY_KEYS = ('science_seal_sha256', 'auxiliary_plan_sha256', 'supplement_seal_sha256', + 'supplement_binding_sha256', 'primary_tuning_sha256', 'primary_measurement_sha256') + + +def read_native(path, primary_path, primary_campaign, expected_allocation, supplement_seal, supplement_binding, + native_tuning): + path, primary_path, supplement_seal, supplement_binding, native_tuning = map( + Path, (path, primary_path, supplement_seal, supplement_binding, native_tuning)) + campaign = json.loads(path.read_text()) + seal = json.loads(supplement_seal.read_text()) + binding = json.loads(supplement_binding.read_text()) + if (seal.get('schema') != 1 or seal.get('kind') != 'native_bls_execution_supplement' or + binding.get('schema') != 1): + raise ValueError('Unexpected supplementary design or binding schema') + if campaign.get('stage') != 'measure' or campaign.get('status') != 'complete': + raise ValueError('Native comparison requires completed planned measurements') + if (campaign.get('original_numerical_qualification_passed') is not False or + campaign.get('science_seal_sha256') != primary_campaign['science_seal_sha256'] or + campaign.get('primary_measurement_sha256') != sha(primary_path) or + campaign.get('supplement_seal_sha256') != sha(supplement_seal) or + campaign.get('supplement_binding_sha256') != sha(supplement_binding) or + binding.get('supplement_seal_sha256') != sha(supplement_seal) or + seal.get('science_seal_sha256') != primary_campaign['science_seal_sha256']): + raise ValueError('Native execution study has changed identity or qualification') + for key in ('science_seal_sha256', 'auxiliary_plan_sha256'): + if campaign[key] != seal[key] or binding[key] != seal[key]: + raise ValueError('Supplement changes the reviewed scientific or auxiliary identity') + for key in ('primary_tuning_sha256', 'primary_measurement_sha256'): + if campaign[key] != binding[key]: + raise ValueError('Supplement changes the mechanically bound primary artifacts') + tuning_path = seal['binding_rule']['primary_tuning_path'] + if seal['remote_files'].get(tuning_path) != binding['primary_tuning_sha256']: + raise ValueError('Primary tuning was not frozen in the prospective supplement seal') + for key in ('manifest_sha256', 'varied_manifest_sha256'): + if primary_campaign[key] != binding['primary_measurement_'+key]: + raise ValueError('Supplement changes the mechanically bound timing manifests') + originals = {} + expected_configs = [] + for row in primary_campaign['configs']: + source = primary_path.parent / row['result'] + if sha(source) != row['result_sha256']: + raise ValueError('Primary timing receipt changed') + record = json.loads(source.read_text()) + expected_configs.append(dict(scope=row['scope'], result=row['result'], result_sha256=row['result_sha256'], + cohort_sha256=canonical_sha(record['cohort']), environment_sha256=canonical_sha(record['environment']))) + if row['scope'] not in SCOPES: + continue + if record.get('cohort'): + value = cohort(record) + if row['scope'] in originals and originals[row['scope']] != value: + raise ValueError('Primary competitors used different timing cohorts') + originals[row['scope']] = value + if binding['primary_configs'] != expected_configs: + raise ValueError('Supplement changes the mechanically bound primary cohorts or resources') + if sha(native_tuning) != campaign['tuning_seal_sha256']: + raise ValueError('Separate development tuning seal changed') + tuning_seal = json.loads(native_tuning.read_text()) + if Path(tuning_seal['campaign_path']).name != 'campaign.json': + raise ValueError('Unexpected development tuning campaign name') + tuning_path = native_tuning.parent/'campaign.json' + if sha(tuning_path) != tuning_seal['campaign_sha256']: + raise ValueError('Separate development tuning campaign changed') + tuning = json.loads(tuning_path.read_text()) + if (tuning.get('status') != 'complete' or tuning.get('stage') != 'tune' or + tuning_seal.get('original_qualification_passed') is not False): + raise ValueError('Separate development tuning is incomplete or requalified') + for key in (*IDENTITY_KEYS, 'source_identity', 'allocation', 'deadline_epoch', 'selected', + 'original_bls_exclusion_sha256', 'hourly_usd'): + if tuning_seal[key] != tuning[key] or tuning_seal[key] != campaign[key]: + raise ValueError('Native measurement differs from its development selection: '+key) + if tuning_seal['manifest_sha256'] != tuning['manifest_sha256']: + raise ValueError('Separate development tuning manifest changed') + for name, digest in tuning['artifact_sha256'].items(): + if sha(tuning_path.parent/name) != digest: + raise ValueError('Separate development tuning artifact changed') + records, missing, seen = {}, {}, set() + for row in campaign.get('unavailable', []): + missing[row['scope']] = row['reason'] + for row in campaign['configs']: + scope = row['scope'] + if scope not in SCOPES: + raise ValueError('Unplanned native timing scope') + source = path.parent / row['result'] + if sha(source) != row['result_sha256']: + raise ValueError('Native timing receipt changed') + record = json.loads(source.read_text()) + if record.get('scope') != scope or record.get('backend') != 'native_bls_execution': + raise ValueError('Native result scope or backend changed') + for key in (*IDENTITY_KEYS, 'source_identity'): + if record[key] != campaign[key]: + raise ValueError('Native result belongs to another sealed execution: '+key) + if row.get('reference_only'): + if row['workers'] != 1 or row['batch_size'] != 1: + raise ValueError('Invalid native reference diagnostic setting') + continue + if scope in seen: + raise ValueError('Repeated native timing scope') + seen.add(scope) + if not row['execution_rates_valid']: + missing[scope] = row.get('failure_reason') or 'Execution accounting or resource check failed' + continue + if scope in missing: + raise ValueError('Contradictory native availability') + for key in ('workers', 'batch_size'): + if (row[key] != campaign['selected'][key] or + record[key] != campaign['selected'][key]): + raise ValueError('Native setting changed after development selection') + execution_rates(record) + if allocation(record) != expected_allocation: + raise ValueError('Native BLS did not use the same GPU/CPU/memory allocation') + if scope not in originals or cohort(record) != originals[scope]: + raise ValueError('Native BLS did not use the same timing light curves and grids') + records[scope] = record + for scope in SCOPES: + if scope not in records: + missing.setdefault(scope, 'No valid native execution measurement') + return campaign, records, missing + + +def table_rows(primary, native, missing, native_missing, paired, heldout): + rows = [] + for scope in SCOPES: + for backend in BACKENDS: + is_native = backend == 'bls' + record = native.get(scope) if is_native else primary.get((backend, scope)) + rates = (execution_rates(record) if is_native else + [r['lightcurves_per_second'] for r in record['repetitions']]) if record else [] + repetitions = record['repetitions'] if record else [] + numerical = record.get('numerical', {}) if record else {} + attempts = sum(r['attempted_count'] for r in repetitions) if is_native and record else None + successes = sum(r['successful_count'] for r in repetitions) if is_native and record else None + summary = record['summary'] if record else {} + rows.append(dict(scope=scope, backend=backend, + workers=record.get('workers') if record else None, + batch_size=record.get('batch_size') if record else None, + rate_contract='native_execution_only' if is_native else 'original_qualified_timing', + original_numerical_qualification_passed=False if is_native else bool(record), + rate_available=bool(record), + missing_reason=(native_missing.get(scope) if is_native else missing.get((backend, scope))) + if not record else None, + median_lightcurves_per_second=statistics.median(rates) if rates else None, + minimum_lightcurves_per_second=min(rates) if rates else None, + maximum_lightcurves_per_second=max(rates) if rates else None, + attempted_count=attempts, successful_count=successes, + failed_count=sum(r['failed_count'] for r in repetitions) if is_native and record else None, + completion_fraction=successes/attempts if attempts else None, + selected_mismatch_count=numerical.get('selected_mismatch_count'), + complete_output_mismatch_count=numerical.get('complete_output_mismatch_count'), + cold_first_cohort_including_startup_seconds=record['summary'].get( + 'cold_first_cohort_including_startup_seconds') if record else None, + sampled_gpu_peak_bytes=record.get('memory', {}).get('gpu_used_bytes') if record else None, + sampled_worker_rss_peak_bytes=record.get('memory', {}).get('host_pool_rss_bytes') if record else None, + total_measured_compute_usd=record['summary'].get('total_measured_compute_usd') if record else None, + usd_per_million_successful_steady=summary.get('usd_per_million_successful' if is_native else + 'usd_per_million_steady'), + usd_per_million_successful_cold_amortized=( + (summary['total_measured_compute_usd']+summary['cold_preparation_compute_usd'])*1e6/successes + if is_native and successes and 'cold_preparation_compute_usd' in summary else + summary.get('usd_per_million_cold_amortized') if not is_native else None), + cold_amortized_successful_lightcurves_per_second=summary.get( + 'cold_amortized_successful_lightcurves_per_second' if is_native else + 'cold_amortized_lightcurves_per_second'), + heldout_exact_cases=heldout['exact_cases'], heldout_planned_cases=heldout['planned_cases'], + heldout_aggregate_exactness_qualified=heldout['aggregate_exactness_qualified'], + timing_cohort_paired_tls_qualification=bool(paired.get(scope)), + science_seal_sha256=heldout['science_seal_sha256'])) + return rows + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--primary', type=Path, required=True) + parser.add_argument('--native-bls', type=Path, required=True) + parser.add_argument('--supplement-seal', type=Path, required=True) + parser.add_argument('--supplement-binding', type=Path, required=True) + parser.add_argument('--native-tuning', type=Path, required=True, help='Separate development tuning-seal.json') + parser.add_argument('--science-seal', type=Path, required=True) + parser.add_argument('--exactness', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + primary_campaign, data, missing, paired, resources = read_campaign(args.primary) + heldout = heldout_qualification(primary_campaign, args.exactness, args.science_seal) + native_campaign, native, native_missing = read_native( + args.native_bls, args.primary, primary_campaign, resources, args.supplement_seal, args.supplement_binding, + args.native_tuning) + if native_campaign['auxiliary_plan_sha256'] != heldout['auxiliary_plan_sha256']: + raise ValueError('Native supplement and held-out exactness use different auxiliary plans') + rows = table_rows(data, native, missing, native_missing, paired, heldout) + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + colors = {'baseline': '#8898a6', 'candidate': '#087d92', 'gtls': '#cb7950', 'bls': '#7759a0'} + titles = ('TESS dense sector', 'TESS separated sectors', 'ZTF g/r', 'Varied sizes: 96 sources') + fig, axes = plt.subplots(1, 4, figsize=(13, 6.2), layout='constrained') + for ax, scope, title in zip(axes, SCOPES, titles): + positive = [] + for index, backend in enumerate(BACKENDS): + row = next(r for r in rows if r['scope'] == scope and r['backend'] == backend) + value = row['median_lightcurves_per_second'] + if value is None or value == 0: + label = 'No valid\nmeasurement' if value is None else 'Zero successful\ncompletions' + ax.text(index, .03, label, transform=ax.get_xaxis_transform(), rotation=90, + ha='center', va='bottom', fontsize=7, color='#884343') + continue + low, high = row['minimum_lightcurves_per_second'], row['maximum_lightcurves_per_second'] + positive.extend(v for v in (low, value, high) if v > 0) + native_bar = backend == 'bls' + ax.bar(index, value, .7, facecolor='white' if native_bar else colors[backend], + edgecolor=colors[backend], hatch='///' if native_bar else None, + yerr=[[value-low], [high-value]], capsize=3, + error_kw=dict(elinewidth=1, ecolor='#303c46')) + label = f'{value:.2f}' + if native_bar: + fraction = row['completion_fraction'] + percent = f'{fraction:.3%}' + if fraction < 1 and percent == '100.000%': + percent = '<100%' + label += f"\n{percent} completed\n{row['failed_count']} API errors" + ax.text(index, high*1.13, label, ha='center', fontsize=7 if native_bar else 8) + ax.set_title(title, fontsize=11) + ax.set_xticks(range(4), ['Baseline', 'Optimized', 'GTLS', 'Native BLS'], rotation=35) + ax.set_yscale('log') + ax.set_ylim(min(positive)/2 if positive else .01, max(positive)*4 if positive else 1) + ax.set_xlim(-.6, 3.6) + ax.spines[['top', 'right']].set_visible(False) + ax.grid(axis='y', which='major', alpha=.15) + ax.set_axisbelow(True) + axes[0].set_ylabel('Successful light curves per second · log scale') + status = 'finite tested scope passed' if heldout['aggregate_exactness_qualified'] else 'AGGREGATE EXACTNESS WITHHELD' + fixture = 'SYNTHETIC FIXTURE · ' if primary_campaign.get('synthetic_fixture') else '' + fig.suptitle(fixture+'Full transit-search throughput · '+resources[0].split(',')[0]+'\n' + f"Held-out TLS exactness: {heldout['exact_cases']}/{heldout['planned_cases']} · {status}", + fontsize=13, weight='bold', color='#253b46' if heldout['aggregate_exactness_qualified'] else '#9b2424') + fig.supxlabel('Hatched BLS bars: native execution only; original exact-repeatability qualification failed.\n' + 'Median of 3 queues; whiskers: observed repeat range. Each queue: ≥96 attempts AND ≥120 s.\n' + 'Same light curves, grids and GPU/CPU/memory allocation; separate development tuning. Full search and transfers included.\n' + 'Native BLS API failures consume elapsed time and reduce successful throughput; score discrepancies remain recorded.\n' + 'Native BLS also includes per-attempt journaling and comparison overhead; that extra cost is retained.\n' + 'Separate logarithmic y scales. Throughput does not establish equal detection sensitivity or global TLS equivalence.', fontsize=8) + args.output.parent.mkdir(parents=True, exist_ok=True) + for extension in ('png', 'pdf', 'svg'): + fig.savefig(args.output.with_suffix('.'+extension), dpi=180) + plt.close(fig) + with args.output.with_suffix('.csv').open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(rows[0])) + writer.writeheader(); writer.writerows(rows) + provenance = dict(primary_campaign_sha256=sha(args.primary), native_campaign_sha256=sha(args.native_bls), + science_seal_sha256=sha(args.science_seal), supplement_seal_sha256=sha(args.supplement_seal), + supplement_binding_sha256=sha(args.supplement_binding), + native_tuning_seal_sha256=sha(args.native_tuning), + renderer_sha256=sha(__file__), original_renderer_sha256=sha(Path(__file__).with_name('plot_throughput.py')), + heldout_exactness=heldout, allocation=resources, original_bls_numerical_qualification_passed=False, + native_instrumentation_note='Per-attempt journaling and exact comparison overhead is included beyond primary instrumentation; no subtraction.', + primary_missing=primary_campaign.get('unavailable'), native_missing=native_missing, + native_selected=native_campaign.get('selected'), outputs={ + args.output.with_suffix('.'+extension).name: sha(args.output.with_suffix('.'+extension)) + for extension in ('png', 'pdf', 'svg', 'csv')}) + args.output.with_suffix('.data.json').write_text(json.dumps(provenance, indent=2)+'\n') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/plot_throughput.py b/benchmarks/tls_survey/plot_throughput.py new file mode 100644 index 00000000..0b01ba26 --- /dev/null +++ b/benchmarks/tls_survey/plot_throughput.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Render all predeclared panels, including visibly unavailable competitors.""" +import argparse +from collections import Counter +import csv +import hashlib +import json +from pathlib import Path + +import numpy as np + +SCOPES = ('tess_solar', 'tess_gap_long', 'ztf_solar', 'varied') +BACKENDS = ('baseline', 'candidate', 'gtls', 'bls') + + +def heldout_qualification(campaign, exactness_path, seal_path): + """Bind the finite held-out qualification independently of timing eligibility.""" + seal_bytes = Path(seal_path).read_bytes() + seal = json.loads(seal_bytes) + seal_hash = hashlib.sha256(seal_bytes).hexdigest() + exactness_bytes = Path(exactness_path).read_bytes() + exactness = json.loads(exactness_bytes) + if (campaign.get('science_seal_sha256') != seal_hash or + exactness['identity']['seal_sha256'] != seal_hash): + raise ValueError('Timing/held-out qualification belongs to a different science seal') + regimes = seal['regimes'] + if not regimes or len(regimes) != len(set(regimes)): + raise ValueError('Invalid planned held-out regimes') + planned = {(regime, split): seal['counts'][split] for regime in regimes for split in ('injections','nulls')} + if any(type(value) is not int or value <= 0 for value in planned.values()): + raise ValueError('Invalid planned held-out counts') + rows = exactness['cases'] + expected = sum(planned.values()) + if (exactness['status'] != 'complete' or exactness['completed_cases'] != expected or + len(rows) != expected or Counter((row['regime'],row['split']) for row in rows) != Counter(planned) or + len({(row['split'],row['name']) for row in rows}) != expected): + raise ValueError('Held-out qualification does not cover every planned regime/input') + for row in rows: + comparison = row['comparison'] + if (type(comparison['exact']) is not bool or comparison['exact'] != (not comparison['differences']) or + (comparison['exact'] and (not row['original_candidate']['valid'] or not row['baseline']['valid']))): + raise ValueError('Held-out exactness flag contradicts the original outcome') + mismatches = sum(not row['comparison']['exact'] for row in rows) + if (exactness['mismatches'] != mismatches or + exactness['exactness_qualified'] != (mismatches == 0)): + raise ValueError('Held-out exactness summary contradicts the planned case outcomes') + return dict(path=str(Path(exactness_path).resolve()), + sha256=hashlib.sha256(exactness_bytes).hexdigest(), + science_seal_path=str(Path(seal_path).resolve()), science_seal_sha256=seal_hash, + auxiliary_plan_sha256=exactness['identity']['plan_sha256'], + planned_cases=expected, compared_cases=len(rows), exact_cases=expected-mismatches, + mismatches=mismatches, aggregate_exactness_qualified=bool(exactness['exactness_qualified']), + interpretation='Original finite held-out qualification. Timing-cohort ratios do not establish global sensitivity preservation.') + + +def figure_csv(path, data, missing, paired, heldout): + rows = [] + for scope in SCOPES: + for backend in BACKENDS: + record = data.get((backend,scope)) + rates = [row['lightcurves_per_second'] for row in record['repetitions']] if record else [] + ratio = None + if backend == 'candidate' and record and paired.get(scope) and ('baseline',scope) in data: + baseline = [row['lightcurves_per_second'] for row in data['baseline',scope]['repetitions']] + ratio = float(np.median(rates)/np.median(baseline)) + rows.append(dict(scope=scope, backend=backend, timing_eligible=record is not None, + failure_reason=missing.get((backend,scope)), + median_lightcurves_per_second=float(np.median(rates)) if rates else None, + minimum_lightcurves_per_second=min(rates) if rates else None, + maximum_lightcurves_per_second=max(rates) if rates else None, + timing_cohort_optimized_vs_baseline=ratio, + heldout_exact_cases=heldout['exact_cases'], heldout_planned_cases=heldout['planned_cases'], + heldout_aggregate_exactness_qualified=heldout['aggregate_exactness_qualified'], + heldout_exactness_sha256=heldout['sha256'], + science_seal_sha256=heldout['science_seal_sha256'], + auxiliary_plan_sha256=heldout['auxiliary_plan_sha256'])) + with path.open('w',newline='') as stream: + writer = csv.DictWriter(stream,fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + +def read_campaign(path): + campaign = json.loads(path.read_text()) + if campaign['stage'] != 'measure' or campaign.get('status') != 'complete': + raise ValueError('Only completed independent sustained measurements supply the figure') + data, missing = {}, {} + for row in campaign.get('unavailable', []): + missing[(row['backend'], row['scope'])] = row['reason'] + for row in campaign['configs']: + key = (row['backend'], row['scope']) + if row['scope'] not in SCOPES or row['backend'] not in BACKENDS: + continue + record_path = path.parent/row['result'] + if not row['eligible']: + missing[key] = row.get('failure_reason', 'Required-output or ownership qualification failed') + continue + if hashlib.sha256(record_path.read_bytes()).hexdigest() != row['result_sha256']: + raise ValueError('Measurement record differs from campaign identity') + record = json.loads(record_path.read_text()) + if (record['status'] != 'ok' or not record['gpu_ownership']['passed'] or + len(record['qualification']) != 2 or + not all(item['gate']['passed'] for item in record['qualification'])): + raise ValueError('Campaign eligibility disagrees with numerical/ownership receipts') + if len(record['repetitions']) < 3 or not all(item['status'] == 'ok' for item in record['repetitions']): + raise ValueError('Three successful sustained repetitions required') + data[key] = record + paired = {} + for check in campaign.get('baseline_candidate_spectra', {}).get('checks', []): + paired[check['scope']] = paired.get(check['scope'], True) and check['exact'] + for scope, passed in paired.items(): + if not passed: + data.pop(('candidate', scope), None) + missing[('candidate', scope)] = 'Paired baseline/optimized complete-spectrum qualification failed' + for backend in BACKENDS: + for scope in SCOPES: + if (backend, scope) not in data: + missing.setdefault((backend, scope), 'No qualifying result in the predeclared campaign') + if not data: + raise ValueError('No qualified timing exists; retain the failure report without a performance figure') + allocations = {(r['environment']['nvidia_smi'], r['environment']['cpu_quota_cores'], + r['environment']['host_memory_limit_bytes']) for r in data.values()} + if len(allocations) != 1 or any(value is None for value in next(iter(allocations))): + raise ValueError('The figure requires one verified GPU/CPU/memory allocation') + return campaign, data, missing, paired, next(iter(allocations)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('campaign', type=Path) + parser.add_argument('--exactness', type=Path, required=True, help='Complete original held-out exactness receipt') + parser.add_argument('--science-seal', type=Path, required=True, help='Original science seal with planned population counts') + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + campaign, data, missing, paired, allocation = read_campaign(args.campaign) + heldout = heldout_qualification(campaign,args.exactness,args.science_seal) + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + labels = ('TESS\ndense sector', 'TESS\nseparated sectors', 'ZTF\ng/r', 'Varied sizes\n96-source queue') + display = {'baseline': 'Baseline TLS', 'candidate': 'Optimized TLS', + 'gtls': 'Public GTLS', 'bls': 'Selected BLS'} + colors = {'baseline': '#8b98a7', 'candidate': '#147d92', 'gtls': '#cb7950', 'bls': '#7759a0'} + fig, axes = plt.subplots(1, 4, figsize=(13, 5.8), layout='constrained') + for ax, scope, title in zip(axes, SCOPES, labels): + medians, observed = {}, [] + for offset, backend in enumerate(BACKENDS): + record = data.get((backend, scope)) + if record is None: + ax.text(offset, .03, 'No qualifying\nresult', transform=ax.get_xaxis_transform(), + ha='center', va='bottom', fontsize=7, rotation=90, color='#754646') + continue + rates = np.asarray([row['lightcurves_per_second'] for row in record['repetitions']]) + if np.any(rates <= 0) or not np.all(np.isfinite(rates)): + raise ValueError('Nonpositive/nonfinite eligible throughput') + median = float(np.median(rates)) + observed.extend(rates.tolist()) + medians[backend] = median + ax.bar(offset, median, .7, color=colors[backend], + yerr=[[median-float(rates.min())], [float(rates.max())-median]], capsize=3, + error_kw=dict(elinewidth=1, ecolor='#303c46')) + ax.text(offset, float(rates.max())*1.13, f'{median:.2f}', ha='center', fontsize=8) + ax.set_xticks(range(4), ['Baseline', 'Optimized', 'GTLS', 'BLS'], rotation=35) + ax.set_title(title, fontsize=12) + ax.set_yscale('log') + ax.set_ylim(min(observed)/2 if observed else .01, max(observed)*3 if observed else 1) + ax.set_xlim(-.6, 3.6) + ax.spines[['right', 'top']].set_visible(False) + ax.grid(axis='y', which='major', alpha=.15) + ax.set_axisbelow(True) + if paired.get(scope) and all(b in medians for b in ('baseline', 'candidate')): + message = f"Optimized / baseline: {medians['candidate']/medians['baseline']:.2f}×" + else: + message = 'No qualified baseline / optimized ratio' + ax.text(.5, .98, message, transform=ax.transAxes, ha='center', va='top', fontsize=8, + color=colors['candidate']) + axes[0].set_ylabel('Completed light curves per second · log scale') + status = ('finite tested scope passed' if heldout['aggregate_exactness_qualified'] else 'AGGREGATE EXACTNESS WITHHELD') + fixture = 'SYNTHETIC FIXTURE · ' if campaign.get('synthetic_fixture') else '' + fig.suptitle(fixture+'Sustained full transit search · '+allocation[0].split(',')[0]+'\n' + f"Full held-out TLS exactness: {heldout['exact_cases']}/{heldout['planned_cases']} · {status}", + fontsize=14, weight='bold', color='#253b46' if heldout['aggregate_exactness_qualified'] else '#9b2424') + settings = [] + for backend in BACKENDS: + chosen = campaign['selected'].get(backend) + settings.append(f"{display[backend]}: {chosen['workers']} workers, batch {chosen['batch_size']}" + if chosen else f'{display[backend]}: no qualifying development setting') + fig.supxlabel('Median of 3 queues; whiskers: observed repeat range. Each queue: ≥96 curves AND ≥120 s.\n' + 'Logarithmic, separate y scales. Independently selected pool/batch settings; full search and transfers included.\n' + 'BLS uses science-selected settings/ranking; throughput does not imply equal detection sensitivity.\n' + 'Ratios qualify their timing cohorts; they do not establish global sensitivity preservation.\n' + + '; '.join(settings[:2])+'\n'+'; '.join(settings[2:]), fontsize=8) + args.output.parent.mkdir(parents=True, exist_ok=True) + for extension in ('png', 'pdf', 'svg'): + fig.savefig(args.output.with_suffix('.'+extension), dpi=180) + figure_csv(args.output.with_suffix('.csv'),data,missing,paired,heldout) + args.output.with_suffix('.data.json').write_text(json.dumps(dict( + campaign=str(args.campaign), campaign_sha256=hashlib.sha256(args.campaign.read_bytes()).hexdigest(), + renderer_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + heldout_exactness=heldout, + csv_sha256=hashlib.sha256(args.output.with_suffix('.csv').read_bytes()).hexdigest(), + outputs={args.output.with_suffix('.'+extension).name: + hashlib.sha256(args.output.with_suffix('.'+extension).read_bytes()).hexdigest() + for extension in ('png','pdf','svg','csv')}, + measurement_records=[dict(path=str((args.campaign.parent/row['result']).resolve()),sha256=row['result_sha256']) + for row in campaign['configs'] if (row['backend'],row['scope']) in data], + allocation=allocation, source_records={f'{a}/{b}': r['summary'] for (a,b),r in data.items()}, + numerical_contracts=dict(tls='Exact complete spectra, masks and selected endpoints; paired baseline/optimized gate', + bls='Exact period arrays, finite masks and selected endpoints; nonwinning powers and unused rankers diagnostic'), + bls_native_repeat_diagnostics={scope: [dict(phase=item['phase'], + **{key: item['native_repeat_diagnostics'][key] for key in ( + 'changed_power_comparisons', 'changed_selected_endpoints', 'max_absolute_power_difference')}) + for item in record['qualification'] if 'native_repeat_diagnostics' in item] + for (backend, scope), record in data.items() if backend == 'bls'}, + missing={f'{a}/{b}': reason for (a,b),reason in missing.items()}, paired_qualification=paired), + indent=2)+'\n') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/report_followup.py b/benchmarks/tls_survey/report_followup.py new file mode 100644 index 00000000..0525963c --- /dev/null +++ b/benchmarks/tls_survey/report_followup.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Report verified new-allocation timings without changing original qualifications.""" +import argparse +from collections import Counter +import csv +import hashlib +import json +import math +from pathlib import Path +import statistics +import sys + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from benchmarks.tls_survey.plot_throughput import SCOPES, heldout_qualification +from benchmarks.tls_survey.plot_native_bls_comparison import allocation, cohort, execution_rates + +BACKENDS = ('baseline', 'candidate', 'gtls', 'bls') +LABELS = dict(baseline='TLS baseline', candidate='TLS experimental', gtls='GTLS', bls='BLS execution') + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def validate_comparison(data, expected_allocation, seal_sha): + thread_names = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS') + for (backend, scope), record in data.items(): + if allocation(record) != tuple(expected_allocation) or record['science_seal_sha256'] != seal_sha: + raise ValueError('A comparison changes GPU/CPU/RAM allocation or scientific identity') + threads = record['environment'].get('cpu_math_thread_environment', {}) + if any(threads.get(name) != '1' for name in thread_names): + raise ValueError('The six numerical thread limits were not preserved') + expected = {name: 32 for name in SCOPES[:-1]} if scope == 'varied' else {scope: 16} + if Counter(row['regime'] for row in record['cohort']) != Counter(expected): + raise ValueError('Timing panel changed its predeclared population') + for scope in SCOPES: + identities = [cohort(record) for (backend, panel), record in data.items() if panel == scope] + if any(value != identities[0] for value in identities[1:]): + raise ValueError('Competitors used different input bytes: '+scope) + + +def read_results(work): + evidence = work/'collected' + verification = json.loads((work/'collection-verification.json').read_text()) + if verification['status'] != 'archive_and_all_members_verified': + raise ValueError('Verified collection required') + inventory = json.loads((evidence/'completion/inventory.json').read_text()) + + def checked(relative, expected=None): + path = evidence/relative + recorded = inventory[relative]['sha256'] + if sha(path) != recorded or (expected is not None and expected != recorded): + raise ValueError('Collected receipt identity changed: '+relative) + return json.loads(path.read_text()) + + state = checked('campaign-state.json') + design = checked('campaign-design.json', state['design_sha256']) + checked('evidence/science-seal.json') + seal_sha = sha(evidence/'evidence/science-seal.json') + exactness = heldout_qualification(dict(science_seal_sha256=seal_sha), + ROOT/'benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json', + evidence/'evidence/science-seal.json') + data, missing, paired = {}, {}, {} + strict_name = 'strict-measure/campaign.json' + if strict_name in inventory: + strict = checked(strict_name) + if strict.get('science_seal_sha256') != seal_sha: + raise ValueError('Strict timing science seal changed') + for entry in strict.get('unavailable', []): + missing[entry['backend'], entry['scope']] = entry['reason'] + for entry in strict.get('configs', []): + if entry['scope'] not in SCOPES: + continue + key = entry['backend'], entry['scope'] + if not entry['result_sha256']: + missing[key] = entry.get('failure_reason', 'No timing receipt') + continue + record = checked('strict-measure/'+entry['result'], entry['result_sha256']) + if not entry['eligible']: + missing[key] = entry.get('failure_reason', 'Strict qualification failed') + continue + if (record['status'] != 'ok' or record['gpu_ownership']['passed'] is not True or + len(record['qualification']) != 2 or + not all(q['gate']['passed'] for q in record['qualification']) or + len(record['repetitions']) != 3): + raise ValueError('Strict eligibility contradicts its evidence') + for row in record['repetitions']: + if (row['status'] != 'ok' or row['source_count'] < 96 or row['elapsed_seconds'] < 120 or + not math.isclose(row['lightcurves_per_second'], + row['source_count']/row['elapsed_seconds'], rel_tol=1e-12)): + raise ValueError('Incomplete or misreported sustained queue') + data[key] = record + for check in strict.get('baseline_candidate_spectra', {}).get('checks', []): + paired[check['scope']] = paired.get(check['scope'], True) and check['exact'] + for scope, passed in paired.items(): + if not passed: + data.pop(('candidate', scope), None) + missing['candidate', scope] = 'Paired baseline/experimental complete-spectrum gate failed' + bls_name = 'bls-execution/campaign.json' + if bls_name in inventory: + bls = checked(bls_name) + plan = checked('bls-execution/plan.json', bls['plan_sha256']) + if plan['science_seal_sha256'] != seal_sha or plan['original_numerical_qualification_passed'] is not False: + raise ValueError('BLS supplement identity or original failure changed') + for entry in bls.get('configs', []): + if not entry['label'].startswith('measure-'): + continue + scope = entry['label'][len('measure-'):] + if scope not in SCOPES: + raise ValueError('Undeclared BLS measurement cohort') + record = checked('bls-execution/'+entry['label']+'/result.json', entry['result_sha256']) + if not entry['execution_rates_valid']: + missing['bls', scope] = record.get('error', record['status']) + continue + execution_rates(record) + data['bls', scope] = record + expected_allocation = tuple(state['environment'][key] for key in ( + 'nvidia_smi', 'cpu_quota_cores', 'host_memory_limit_bytes')) + validate_comparison(data, expected_allocation, seal_sha) + rows = [] + for scope in SCOPES: + for backend in BACKENDS: + record = data.get((backend, scope)) + row = dict(scope=scope, backend=backend, available=record is not None, + qualification='execution only; original exact gate failed' if backend == 'bls' else 'strict timing gates', + workers=None, batch_size=None, + median_lightcurves_per_second=None, minimum_rate=None, maximum_rate=None, + attempted=None, successful=None, api_failures=None, selected_discrepancies=None, + selected_comparisons=None, complete_diagnostic_discrepancies=None, + cold_preparation_seconds=None, sampled_gpu_peak_bytes=None, + usd_per_million_successful=None, paired_experimental_speed_ratio=None, + failure_reason=None if record else missing.get((backend, scope), 'No completed qualifying panel')) + if record: + native = backend == 'bls' + rates = execution_rates(record) if native else [r['lightcurves_per_second'] for r in record['repetitions']] + attempted = sum(r['attempted_count' if native else 'source_count'] for r in record['repetitions']) + success = sum(r['successful_count' if native else 'source_count'] for r in record['repetitions']) + median = statistics.median(rates) + row.update(workers=record['workers'], batch_size=record['batch_size'], + median_lightcurves_per_second=median, minimum_rate=min(rates), maximum_rate=max(rates), + attempted=attempted, successful=success, api_failures=attempted-success, + selected_discrepancies=record['numerical']['selected_mismatch_count'] if native else 0, + selected_comparisons=record['numerical']['comparison_count'] if native else None, + complete_diagnostic_discrepancies=record['numerical']['complete_output_mismatch_count'] if native else 0, + cold_preparation_seconds=record['summary']['cold_first_cohort_including_startup_seconds'], + sampled_gpu_peak_bytes=record['memory']['gpu_used_bytes'], + usd_per_million_successful=design['hourly_usd']*1e6/(3600*median) if median else None) + if backend == 'candidate' and paired.get(scope) and ('baseline', scope) in data: + baseline = statistics.median(r['lightcurves_per_second'] for r in data['baseline', scope]['repetitions']) + row['paired_experimental_speed_ratio'] = median/baseline + rows.append(row) + return dict(rows=rows, original_exactness=exactness, allocation=list(expected_allocation), + campaign_status=state['status'], design_sha256=state['design_sha256'], + verified_archive_sha256=verification['archive_sha256'], hourly_usd=design['hourly_usd'], + reporter_sha256=sha(__file__), validator_sha256={name: sha(Path(__file__).with_name(name)) + for name in ('plot_throughput.py', 'plot_native_bls_comparison.py')}) + + +def render(result, output): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + + output.mkdir(parents=True, exist_ok=True) + (output/'summary.json').write_text(json.dumps(result, indent=2)+'\n') + with (output/'measurements.csv').open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(result['rows'][0])) + writer.writeheader() + writer.writerows(result['rows']) + fig, axes = plt.subplots(1, 4, figsize=(14, 5), layout='constrained') + colors = ['#4477aa', '#228833', '#aa3377', '#ccbb44'] + available = [r['median_lightcurves_per_second'] for r in result['rows'] if r['available'] and r['median_lightcurves_per_second']] + floor = min(available)/3 if available else .01 + ceiling = max(available)*3 if available else 1 + for axis, scope in zip(axes, SCOPES): + rows = [r for r in result['rows'] if r['scope'] == scope] + for index, row in enumerate(rows): + if row['available'] and row['median_lightcurves_per_second']: + median = row['median_lightcurves_per_second'] + axis.bar(index, median, color=colors[index], hatch='///' if row['backend'] == 'bls' else None, + edgecolor='#333333', linewidth=.6) + axis.errorbar(index, median, yerr=[[median-row['minimum_rate']], [row['maximum_rate']-median]], + color='#222222', capsize=3) + else: + label = '0 completions' if row['available'] else 'unavailable' + axis.text(index, floor*1.1, label, rotation=90, ha='center', va='bottom', fontsize=8) + axis.set(title=scope.replace('_', ' '), yscale='log', ylim=(floor, ceiling), xlim=(-.6, 3.6)) + axis.set_xticks(range(4), [LABELS[b] for b in BACKENDS], rotation=40, ha='right') + axis.grid(axis='y', alpha=.2) + axis.set_axisbelow(True) + axes[0].set_ylabel('Successful lightcurves / second') + fig.suptitle('Same-allocation sustained throughput: median and observed range\n' + 'Hatched BLS = execution only; original exact-repeatability qualification failed', fontsize=12) + exact = result['original_exactness'] + qualification = 'passed' if exact['aggregate_exactness_qualified'] else 'failed' + fig.supxlabel(f"Experimental study exactness: {exact['exact_cases']:,}/{exact['planned_cases']:,}; " + f"aggregate gate {qualification}. Timing results do not requalify sensitivity.", fontsize=9) + fig.savefig(output/'throughput.png', dpi=180) + fig.savefig(output/'throughput.svg') + plt.close(fig) + lines = ['# September 24 throughput follow-up', '', + 'These are repeated original timing workloads on one new allocation, with unchanged numerical ' + 'sources and full grids. Each available panel has three complete queues of at least 96 attempts ' + 'and 120 seconds, in whole cohort cycles.', '', + f"The original experimental exactness outcome remains {exact['exact_cases']:,}/{exact['planned_cases']:,}; " + f"its {exact['mismatches']} mismatches still fail the aggregate gate. These timing repetitions do not requalify sensitivity.", '', + '![Sustained throughput](throughput.png)', '', + '| Workload | Method | Median / second | Observed range | API failures / attempts | Selected discrepancies |', + '| --- | --- | ---: | ---: | ---: | ---: |'] + for row in result['rows']: + if row['available']: + values = (f"{row['median_lightcurves_per_second']:.5g}", + f"{row['minimum_rate']:.5g}–{row['maximum_rate']:.5g}", + f"{row['api_failures']}/{row['attempted']}", str(row['selected_discrepancies'])) + else: + values = ('unavailable', '—', '—', '—') + lines.append('| '+' | '.join([row['scope'], LABELS[row['backend']], *values])+' |') + lines.extend(['', 'BLS rates count successful native completions and include failed-call elapsed time and ' + 'per-attempt journal overhead. BLS selected discrepancies include the pre/post diagnostic comparisons ' + 'and measured queues; they introduce no tolerance or numerical passing label. Its original exact ' + 'qualification remains failed. TLS/GTLS rates require the unchanged strict timing gates.', '', + 'The CSV retains the selected worker/batch settings, comparison counts, cold preparation, sampled GPU memory, ' + 'unavailable reasons and cost projections. BLS batches group serial native calls within a worker. ' + 'Projected costs use the median successful rate at the recorded hourly price; they exclude acquisition, ' + 'preprocessing and vetting and do not describe an actual million-source run.', '', + f"Verified evidence archive SHA256: `{result['verified_archive_sha256']}`.", + f"Frozen follow-up design SHA256: `{result['design_sha256']}`.", '']) + (output/'REPORT.md').write_text('\n'.join(lines)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--work', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + render(read_results(args.work.resolve()), args.output.resolve()) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/report_recovery.py b/benchmarks/tls_survey/report_recovery.py new file mode 100644 index 00000000..a14fbf64 --- /dev/null +++ b/benchmarks/tls_survey/report_recovery.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Validate and format existing survey inference and implementation receipts. + +This renderer imports no scientific or GPU code and calculates no new intervals, +tests, thresholds, or pooled detection rates. Counts and hashes validate the +reported design; all inferential values are copied from the analysis JSON. +""" +from __future__ import annotations + +import argparse +from collections import Counter +import csv +import hashlib +import json +import math +from pathlib import Path +from statistics import median + +SNRS = (6., 8., 10., 12.) +SAMPLING = ('unsampled', 'one_event', 'two_events', 'three_plus_events', + 'one_to_four_points', 'grid_unreachable') +SNR_METRICS = ('native_family_white_snr', 'ideal_box_white_snr', 'native_family_ou_snr', + 'ideal_box_ou_snr', 'native_white_advantage', 'native_ou_advantage') + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def interval(value, lower=0., upper=1.): + require(len(value) == 2 and all(math.isfinite(v) for v in value) and + lower <= value[0] <= value[1] <= upper, 'Invalid stored interval') + + +def count(value, maximum, label): + require(type(value) is int and 0 <= value <= maximum, 'Invalid count: ' + label) + + +def unique(rows, fields): + values = {tuple(row[key] for key in fields): row for row in rows} + require(len(values) == len(rows), 'Duplicate report rows: ' + '/'.join(fields)) + return values + + +def validate(recovery, seal, exactness, seal_hash, synthetic=False): + require(all(bool(value.get('synthetic_fixture')) == synthetic + for value in (recovery, seal, exactness)), 'Synthetic fixture requires explicit --synthetic mode') + require(recovery['seal_sha256'] == seal_hash == exactness['identity']['seal_sha256'], + 'Recovery/exactness scientific seal identities differ') + require(recovery['thresholds_sha256'] == exactness['identity']['thresholds_sha256'], + 'Recovery/exactness threshold identities differ') + require(exactness['status'] == 'complete', 'Exactness execution is incomplete') + regimes = seal['regimes'] + require(regimes and len(set(regimes)) == len(regimes), 'Missing/duplicate planned regimes') + targets = (seal['target_fpr'], seal['secondary_target_fpr']) + require(len(set(targets)) == 2 and all(0 < value < 1 for value in targets), 'Invalid planned FPRs') + require(set(seal['bls_selected']) == set(regimes), 'Missing/extra frozen BLS selections') + counts = seal['counts'] + require(all(type(counts[key]) is int and counts[key] > 0 for key in ('calibration', 'injections', 'nulls')), + 'Invalid planned population sizes') + require(counts['injections'] % len(SNRS) == 0, 'Report schema requires the sealed balanced four-SNR design') + methods = unique(recovery['methods'], ('regime', 'target_fpr', 'method')) + required = {(regime, target, method) for regime in regimes for target in targets for method in ('tls', 'bls')} + require(set(methods) == required, 'Missing/extra planned regime/method/FPR rows') + strata = {} + for (regime, target, label), row in methods.items(): + selection = dict(method='tls', ranker='native') if label == 'tls' else seal['bls_selected'][regime] + require((row['configuration'], row['ranker']) == (selection['method'], selection['ranker']), + 'Reported method differs from frozen science selection') + for denominator, numerator, rate, bounds, failures, planned in ( + ('n_injections', 'detected', 'recovery', 'recovery_interval95', 'failed_injections', 'injections'), + ('n_nulls', 'false_positives', 'fpr', 'fpr_interval95', 'failed_nulls', 'nulls')): + require(row[denominator] == counts[planned], 'Main report denominator differs from planned count') + count(row[numerator], row[denominator], numerator) + count(row[failures], row[denominator], failures) + require(row[numerator] + row[failures] <= row[denominator], 'Failed execution counted as detection') + require(math.isclose(row[rate], row[numerator]/row[denominator], abs_tol=1e-12), + 'Stored rate disagrees with its counts') + interval(row[bounds]) + count(row['aliases_including_fundamental'], row['n_injections'], 'aliases') + calibration = row['calibration'] + require(calibration['n'] == counts['calibration'] and calibration['target_fpr'] == target and + calibration['value'] == row['threshold'] and math.isfinite(row['threshold']), + 'Calibration identity/count/threshold differs from planned row') + require(calibration['decision'] == 'strict exceedance', 'Unexpected threshold decision policy') + require(1 <= calibration['rank_1based'] <= counts['calibration'], 'Invalid stored threshold rank') + for key in ('calibration_scores_above', 'calibration_scores_at_threshold', 'calibration_zero_scores'): + count(calibration[key], counts['calibration'], key) + require(0 <= calibration['attainable_marginal_fpr'] <= target and + calibration['attainable_marginal_fpr'] == calibration['marginal_fpr_upper_bound'], + 'Invalid stored attainable FPR bound') + levels = unique(row['strata'], ('kind', 'level')) + require(set(levels) <= {('snr', value) for value in SNRS} | {('sampling', value) for value in SAMPLING}, + 'Unexpected subgroup level') + require({key for key in levels if key[0] == 'snr'} == {('snr', value) for value in SNRS}, + 'Missing planned SNR subgroup') + for (kind, level), subgroup in levels.items(): + require(0 < subgroup['n'] <= counts['injections'], 'Invalid nonempty subgroup size') + count(subgroup['detected'], subgroup['n'], 'subgroup detected') + interval(subgroup['interval95']) + if kind == 'snr': + require(subgroup['n'] == counts['injections']//len(SNRS), 'SNR subgroup is not the planned balanced count') + require(sum(levels['snr', value]['detected'] for value in SNRS) == row['detected'], + 'SNR subgroup detection counts do not match main row') + strata[regime, target, label] = levels + for regime in regimes: + for target in targets: + require({key: value['n'] for key, value in strata[regime, target, 'tls'].items()} == + {key: value['n'] for key, value in strata[regime, target, 'bls'].items()}, + 'TLS/BLS subgroup input counts differ') + contrasts = unique(recovery['contrasts'], ('regime', 'target_fpr')) + require(set(contrasts) == {(r, f) for r in regimes for f in targets}, 'Missing/extra planned paired contrasts') + for (regime, target), row in contrasts.items(): + for outcome, planned, endpoint in (('recovery', 'injections', 'detected'), ('fpr', 'nulls', 'false_positives')): + for suffix in ('', '_simultaneous'): + value = row['tls_minus_bls_' + outcome + suffix] + require(value['n'] == counts[planned], 'Paired contrast denominator differs from plan') + for key in ('first_only', 'second_only'): + count(value[key], value['n'], key) + require(value['first_only'] + value['second_only'] <= value['n'], 'Invalid paired discordant counts') + difference_count = methods[regime, target, 'tls'][endpoint] - methods[regime, target, 'bls'][endpoint] + require(value['first_only'] - value['second_only'] == difference_count and + math.isclose(value['difference'], difference_count/value['n'], abs_tol=1e-12), + 'Paired contrast disagrees with main counts') + interval(value['interval'], -1., 1.) + require(0 < value['confidence'] < 1, 'Invalid stored contrast confidence') + receipts = unique(recovery['receipts'], ('path',)) + require(len(receipts) == 2*seal['execution_shards'], 'Missing/extra scientific execution receipts') + require(Counter(row['split'] for row in receipts.values()) == + Counter({key: seal['execution_shards'] for key in ('injections', 'nulls')}), 'Wrong scientific receipt splits') + require(all(row['production_sources'] == seal['production_sources'] for row in receipts.values()), + 'Scientific numerical source identity differs from seal') + original_receipts = unique(exactness['identity']['candidate_receipts'], ('path',)) + require({key: value['sha256'] for key, value in receipts.items()} == + {key: value['sha256'] for key, value in original_receipts.items()}, + 'Exactness did not compare the original scientific receipts') + cases = unique(exactness['cases'], ('split', 'name')) + planned_cases = {(regime, split): counts[split] for regime in regimes for split in ('injections', 'nulls')} + require(Counter((row['regime'], row['split']) for row in cases.values()) == Counter(planned_cases), + 'Missing/extra planned baseline exactness cases') + for row in cases.values(): + original = row['original_candidate'] + require((original['regime'], original['method'], original['name'], original['input_sha256']) == + (row['regime'], 'tls', row['name'], row['input_sha256']), 'Exactness original candidate input identity differs') + comparison = row['comparison'] + require(comparison['exact'] == (not comparison['differences']), 'Exactness flag disagrees with recorded differences') + require(not comparison['exact'] or (original['valid'] and row['baseline']['valid']), + 'Invalid execution cannot establish exactness') + for side in ('original_candidate_decisions', 'baseline_decisions'): + require(set(comparison[side]) == {'thresholds', 'secondary_thresholds'}, 'Missing frozen-threshold decisions') + for key, target in zip(('thresholds', 'secondary_thresholds'), targets): + decision = comparison[side][key] + require(decision['target_fpr'] == target and decision['threshold'] == methods[row['regime'], target, 'tls']['threshold'], + 'Exactness used another TLS operating point') + mismatch_count = sum(not row['comparison']['exact'] for row in cases.values()) + require(exactness['completed_cases'] == len(cases) and exactness['mismatches'] == mismatch_count and + exactness['exactness_qualified'] == (mismatch_count == 0), 'Exactness summary disagrees with original outcomes') + require(exactness['incomplete_repeat_diagnostics'] == sum(row['repeat_status'] == 'pending' for row in cases.values()), + 'Exactness repeat-diagnostic summary differs') + for regime in regimes: + for split, endpoint in (('injections', 'detected'), ('nulls', 'false_positives')): + for key, target in zip(('thresholds', 'secondary_thresholds'), targets): + failures = sum(not row['original_candidate']['valid'] for row in cases.values() + if (row['regime'], row['split']) == (regime, split)) + require(failures == methods[regime, target, 'tls']['failed_'+split], + 'Original exactness candidate failures disagree with the recovery report') + detected = sum(row['comparison']['original_candidate_decisions'][key]['detected'] + for row in cases.values() if (row['regime'], row['split']) == (regime, split)) + require(detected == methods[regime, target, 'tls'][endpoint], + 'Original exactness candidate decisions disagree with the recovery report') + injections = [row for row in cases.values() if (row['regime'], row['split']) == (regime, 'injections')] + for key, target in zip(('thresholds', 'secondary_thresholds'), targets): + for kind, levels in (('snr', SNRS), ('sampling', SAMPLING)): + for level in levels: + selected = [] + for row in injections: + original = row['original_candidate'] + include = (original['white_oracle_snr'] == level if kind == 'snr' else + not original.get('grid_reachable', True) if level == 'grid_unreachable' else + original['in_transit_observations'] == 0 if level == 'unsampled' else + original['observed_events'] == 1 if level == 'one_event' else + original['observed_events'] == 2 if level == 'two_events' else + original['observed_events'] >= 3 if level == 'three_plus_events' else + 0 < original['in_transit_observations'] < 5) + if include: + selected.append(row) + subgroup = strata[regime, target, 'tls'].get((kind, level)) + require((subgroup['n'] if subgroup else 0) == len(selected), + 'Reported subgroup size differs from original input membership') + require((subgroup['detected'] if subgroup else 0) == sum( + row['comparison']['original_candidate_decisions'][key]['detected'] for row in selected), + 'Reported TLS subgroup detections differ from original outcomes') + return regimes, targets, methods, strata, contrasts + + +def snr_distributions(snr, recovery, seal, exactness, seal_hash, synthetic): + """Descriptive observed distributions, with groups from original TLS decisions.""" + require(bool(snr.get('synthetic_fixture')) == synthetic, 'SNR fixture marker differs') + require(snr['status'] == 'complete' and snr['split'] == 'injections' and snr['seal_sha256'] == seal_hash, + 'Incomplete or foreign held-out SNR diagnostics') + manifests = {row['manifest_sha256'] for row in recovery['receipts'] if row['split'] == 'injections'} + require(manifests == {snr['manifest_sha256']}, 'SNR and recovery injection manifest identities differ') + cases = {row['name']: row for row in exactness['cases'] if row['split'] == 'injections'} + rows = {key[0]: value for key, value in unique(snr['rows'], ('name',)).items()} + require(set(rows) == set(cases), 'Missing/extra held-out SNR case membership') + for name, row in rows.items(): + require((row['regime'], row['input_sha256']) == (cases[name]['regime'], cases[name]['input_sha256']), + 'SNR case input identity differs from original search') + for key in SNR_METRICS: + require(row[key] is None or math.isfinite(row[key]), 'Nonfinite measured SNR diagnostic') + distributions, joined = [], [] + for name, row in rows.items(): + decisions = cases[name]['comparison']['original_candidate_decisions'] + joined.append(dict(name=name, regime=row['regime'], input_sha256=row['input_sha256'], + original_tls_valid=cases[name]['original_candidate']['valid'], + primary_tls_detected=decisions['thresholds']['detected'], + secondary_tls_detected=decisions['secondary_thresholds']['detected'], + **{key: row[key] for key in SNR_METRICS})) + for regime in seal['regimes']: + population = [row for row in joined if row['regime'] == regime] + groups = [('all', None, population)] + for column, target in (('primary_tls_detected', seal['target_fpr']), + ('secondary_tls_detected', seal['secondary_target_fpr'])): + for detected in (True, False): + groups.append(('tls_detected' if detected else 'tls_missed_including_failures', target, + [row for row in population if row[column] == detected])) + for group, target, selected in groups: + for metric in SNR_METRICS: + values = [row[metric] for row in selected if row[metric] is not None] + distributions.append(dict(regime=regime, target_fpr=target, group=group, metric=metric, + group_n=len(selected), finite_n=len(values), + observed_median=median(values) if values else None, + observed_minimum=min(values) if values else None, + observed_maximum=max(values) if values else None)) + return distributions, joined + + +def csv_file(path, rows, fieldnames=None): + fieldnames = list(rows[0]) if rows else fieldnames + require(bool(fieldnames), 'Empty CSV needs its declared schema') + with path.open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + +def table(headers, rows): + return '\n'.join(['| ' + ' | '.join(headers) + ' |', '| ' + ' | '.join('---' for _ in headers) + ' |'] + + ['| ' + ' | '.join(str(value).replace('|', '\\|') for value in row) + ' |' for row in rows]) + + +def rate_cell(k, n, bounds): + return f'{k}/{n} ({100*k/n:.2f}%; {100*bounds[0]:.2f}–{100*bounds[1]:.2f}%)' if n else '0/0 — unrepresented' + + +def contrast_cell(value): + return f"{100*value['difference']:+.2f} [{100*value['interval'][0]:+.2f}, {100*value['interval'][1]:+.2f}]" + + +def render(args): + inputs = {key: Path(getattr(args, key)) for key in ('recovery', 'seal', 'exactness')} + recovery, seal, exactness = (json.loads(inputs[key].read_text()) for key in inputs) + regimes, targets, methods, strata, contrasts = validate(recovery, seal, exactness, sha(inputs['seal']), args.synthetic) + snr_rows = None + if getattr(args, 'snr', None): + inputs['snr'] = Path(args.snr) + snr = json.loads(inputs['snr'].read_text()) + snr_rows, snr_cases = snr_distributions(snr, recovery, seal, exactness, sha(inputs['seal']), args.synthetic) + output = Path(args.output) + source_records = {key: dict(path=str(path.resolve()), sha256=sha(path)) for key,path in inputs.items()} + if (output/'provenance.json').exists(): + previous = json.loads((output/'provenance.json').read_text()) + require(previous['sources'] == source_records and previous['synthetic_fixture'] == args.synthetic, + 'Output directory already belongs to different source inputs or fixture mode') + output.mkdir(parents=True, exist_ok=True) + main, thresholds, subgroups, paired = [], [], [], [] + for regime in regimes: + for target in targets: + for label in ('tls', 'bls'): + row = methods[regime, target, label] + main.append({key: row[key] for key in ('regime', 'target_fpr', 'method', 'configuration', 'ranker', + 'detected', 'n_injections', 'recovery', 'false_positives', 'n_nulls', 'fpr', + 'failed_injections', 'failed_nulls', 'aliases_including_fundamental')}) + for endpoint in ('recovery', 'fpr'): + main[-1].update({endpoint+'_interval95_lower': row[endpoint+'_interval95'][0], + endpoint+'_interval95_upper': row[endpoint+'_interval95'][1]}) + thresholds.append(dict(regime=regime, method=label, configuration=row['configuration'], + ranker=row['ranker'], **row['calibration'])) + for kind, levels in (('snr', SNRS), ('sampling', SAMPLING)): + for level in levels: + value = strata[regime, target, label].get((kind, level)) + subgroups.append(dict(regime=regime, target_fpr=target, method=label, kind=kind, level=level, + status='reported' if value else 'unrepresented', n=value['n'] if value else 0, + detected=value['detected'] if value else 0, + interval95_lower=value['interval95'][0] if value else '', + interval95_upper=value['interval95'][1] if value else '')) + for endpoint in ('recovery', 'fpr'): + for bound, suffix in (('marginal', ''), ('simultaneous_family', '_simultaneous')): + value = contrasts[regime, target]['tls_minus_bls_'+endpoint+suffix] + paired.append(dict(regime=regime, target_fpr=target, endpoint=endpoint, bound=bound, + n=value['n'], tls_only=value['first_only'], bls_only=value['second_only'], + difference=value['difference'], interval_lower=value['interval'][0], interval_upper=value['interval'][1], + stored_individual_confidence=value['confidence'], construction=value['construction'])) + exact_counts, mismatches = [], [] + for regime in regimes: + for split in ('injections', 'nulls'): + rows = [row for row in exactness['cases'] if row['regime'] == regime and row['split'] == split] + exact_counts.append(dict(regime=regime, split=split, planned=seal['counts'][split], compared=len(rows), + exact=sum(row['comparison']['exact'] for row in rows), + mismatches=sum(not row['comparison']['exact'] for row in rows), + candidate_invalid=sum(not row['original_candidate']['valid'] for row in rows), + baseline_invalid=sum(not row['baseline']['valid'] for row in rows), + pending_repeat_diagnostics=sum(row['repeat_status'] == 'pending' for row in rows))) + for row in rows: + if not row['comparison']['exact']: + mismatches.append(dict(regime=regime, split=split, name=row['name'], input_sha256=row['input_sha256'], + differences='; '.join(row['comparison']['differences']), + candidate_valid=row['original_candidate']['valid'], baseline_valid=row['baseline']['valid'], + candidate_error=row['original_candidate'].get('error'), baseline_error=row['baseline'].get('error'), + repeat_status=row['repeat_status'])) + for name, rows in (('recovery_fpr.csv', main), ('thresholds.csv', thresholds), ('subgroups.csv', subgroups), + ('paired_contrasts.csv', paired), ('exactness.csv', exact_counts), ('exactness_mismatches.csv', mismatches)): + csv_file(output/name, rows, fieldnames=('regime','split','name','input_sha256','differences', + 'candidate_valid','baseline_valid','candidate_error','baseline_error','repeat_status')) + if snr_rows is not None: + csv_file(output/'snr_descriptive.csv', snr_rows) + csv_file(output/'snr_cases.csv', snr_cases) + title = 'SYNTHETIC FIXTURE — NOT A SCIENTIFIC RESULT' if args.synthetic else 'Survey recovery and implementation qualification' + text = [f'# {title}', + 'These tables format the existing sealed analysis. Rates remain separate by regime; interval bounds are copied from the source JSON. ' + 'No new inferential statistics or pooled detection rates are calculated.', + 'Native TLS versus the selected native GPU BLS measures blind detection at separately calibrated operating points. ' + 'Baseline versus optimized TLS exactness is a separate comparison of the original held-out executions. ' + 'Package SDE, BLS power and expected matched-filter SNR are not interchangeable.', + recovery['limitation'], + '## Frozen BLS control', + table(['Regime', 'Selected configuration', 'Ranker'], + [(r, seal['bls_selected'][r]['method'], seal['bls_selected'][r]['ranker']) for r in regimes])] + for target in targets: + title = 'Primary' if target == targets[0] else 'Secondary' + text += [f'## {title} operating point: {100*target:g}% target FPR', + 'Recovery and observed FPR cells show successes/denominator, rate, and the existing 95% marginal interval. ' + 'Failed executions remain in each planned denominator; a failure is not a detection.', + table(['Regime', 'TLS recovery', 'BLS recovery', 'TLS observed FPR', 'BLS observed FPR'], + [[regime] + [rate_cell(methods[regime,target,label][k], methods[regime,target,label][n], + methods[regime,target,label][ci]) + for k,n,ci in (('detected','n_injections','recovery_interval95'),('false_positives','n_nulls','fpr_interval95')) + for label in ('tls','bls')] for regime in regimes]), + 'Paired differences below are TLS minus BLS in percentage points. Both marginal and the existing simultaneous-family bounds are shown; ' + 'an interval crossing zero does not establish an advantage. These intervals do not establish sub-percentage equivalence.', + table(['Regime', 'Recovery: marginal', 'Recovery: simultaneous', 'FPR: marginal', 'FPR: simultaneous'], + [[r] + [contrast_cell(contrasts[r,target]['tls_minus_bls_'+key]) for key in + ('recovery','recovery_simultaneous','fpr','fpr_simultaneous')] for r in regimes])] + calibration = [methods[r,target,m]['calibration'] for r in regimes for m in ('tls','bls')] + bounds = ', '.join(f'{100*v:.4f}%' for v in sorted({c['attainable_marginal_fpr'] for c in calibration})) + text += [f"Independent calibration used {seal['counts']['calibration']} nulls per method and regime, with strict threshold exceedance. " + f'Stored attainable marginal FPR bound(s): {bounds}. This discrete bound is marginal over calibration sets, not certainty about ' + 'the conditional FPR of the realized threshold. Ties can make the operating point more conservative. ' + 'All scores, ranks, exceedance counts, tie counts and zero-score counts are in [thresholds.csv](thresholds.csv).', + table(['Regime', 'Method', 'Failed injections', 'Failed nulls', 'Ties at threshold', 'Extra tie conservatism'], + [[r, m, methods[r,target,m]['failed_injections'], methods[r,target,m]['failed_nulls'], + methods[r,target,m]['calibration']['calibration_scores_at_threshold'], + methods[r,target,m]['calibration']['extra_conservatism_from_ties']] for r in regimes for m in ('tls','bls')])] + for kind, levels in (('snr', SNRS), ('sampling', SAMPLING)): + text += [f'### {title} {"target white-noise oracle SNR" if kind == "snr" else "sampling"} subgroups', + 'Sampling groups overlap; their counts must not be added. An unrepresented group has no estimated recovery interval.' + if kind == 'sampling' else 'These are preassigned latent target SNR levels. Unsampled signals can have realized SNR zero and remain in their assigned groups. The held-out diagnostic computes the realized centered signal norm; none of these quantities is a package-reported detection score.', + table(['Regime', 'Level', 'TLS recovery', 'BLS recovery'], + [[r, level] + [rate_cell(value['detected'], value['n'], value['interval95']) if value else '0/0 — unrepresented' + for value in (strata[r,target,m].get((kind,level)) for m in ('tls','bls'))] + for r in regimes for level in levels])] + text += ['## Comparable expected-SNR diagnostics'] + if snr_rows is None: + text.append('No held-out expected-SNR artifact was supplied for this rendering.') + else: + text += ['The native family and ideal box are evaluated at the known period with the same sampled signal, weights, ' + 'and fitted constant. Templates are selected by the white diagonal-error matched-filter objective; ' + 'their white responses are the enumerated family ceilings. OU values evaluate those same white-selected ' + 'filters using the actual OU covariance variance, not an independently OU-optimized family maximum. ' + 'The white native-family optimum is an optimistic ceiling: the actual blind search and native depth/ranking need not attain it. ' + 'These are descriptive diagnostics, not package SNR/SDE values or a measured blind-search advantage.', + 'Cells show the observed median relative native-family/ideal-box advantage and observed minimum–maximum, in percent; ' + 'these ranges are not confidence intervals. Finite/total counts expose undefined ratios, including zero-signal cases. ' + 'Detected/missed groups use original TLS decisions; misses include invalid executions and do not isolate a causal effect. ' + 'No new tests, approximation allowances, or inferential intervals are calculated.'] + lookup = {(row['regime'],row['target_fpr'],row['group'],row['metric']): row for row in snr_rows} + def snr_cell(row): + if not row['finite_n']: + return f"0/{row['group_n']} finite — unavailable" + return (f"{row['finite_n']}/{row['group_n']} finite; {100*row['observed_median']:+.3f}% " + f"[{100*row['observed_minimum']:+.3f}, {100*row['observed_maximum']:+.3f}]") + for target, groups in ((None, ('all',)), (targets[0], ('tls_detected','tls_missed_including_failures')), + (targets[1], ('tls_detected','tls_missed_including_failures'))): + text += [('All held-out injections.' if target is None else f'Original TLS decisions at {100*target:g}% target FPR.'), + table(['Regime', 'Group', 'White-noise family/box advantage', 'OU-noise family/box advantage'], + [[regime, group] + [snr_cell(lookup[regime,target,group,metric]) for metric in + ('native_white_advantage','native_ou_advantage')] for regime in regimes for group in groups])] + text.append('Full native/box SNR distributions are in [snr_descriptive.csv](snr_descriptive.csv); ' + 'the measured case values and original decision join are in [snr_cases.csv](snr_cases.csv).') + text += ['## Baseline versus optimized TLS: finite implementation qualification', + ('Every planned original held-out comparison met the exactness gate.' if exactness['exactness_qualified'] else + '**Aggregate exactness is withheld. Original mismatches or unavailable valid executions remain failures, regardless of diagnostic repeats.**'), + 'This checks the full available period/chi-squared/mask hashes, selected period/SDE, recovery and both frozen-threshold decisions. ' + 'It does not establish universal numerical or physical equivalence. BLS is absent from this comparison.', + table(['Regime', 'Split', 'Planned', 'Compared', 'Exact', 'Mismatches', 'Candidate invalid', 'Baseline invalid', 'Pending repeats'], + [list(row.values()) for row in exact_counts]), + 'Individual implementation failures are retained in [exactness_mismatches.csv](exactness_mismatches.csv); the original source JSON ' + 'retains every diagnostic repeat and any full-array mismatch artifacts.', + '## Machine-readable tables and provenance', + '[Recovery/FPR](recovery_fpr.csv), [paired contrasts](paired_contrasts.csv), [all subgroups](subgroups.csv), ' + '[thresholds](thresholds.csv), [per-regime exactness](exactness.csv), [provenance](provenance.json).', + 'All interval bounds in the CSVs preserve the original JSON values. Displayed percentages are rounded only for readability.', + table(['Source', 'SHA256'], [(key, sha(path)) for key,path in inputs.items()] + [('renderer', sha(__file__))])] + (output/'RECOVERY.md').write_text('\n\n'.join(text)+'\n') + provenance = dict(purpose='Format-only rendering of existing sealed inference and exactness receipts', + synthetic_fixture=args.synthetic, renderer_sha256=sha(__file__), + sources=source_records, + validated=dict(regimes=regimes, target_fprs=targets, method_rows=len(methods), contrast_rows=len(contrasts), + exactness_cases=len(exactness['cases']), counts_per_regime=seal['counts']), + outputs={name:sha(output/name) for name in + ('RECOVERY.md','recovery_fpr.csv','thresholds.csv','subgroups.csv','paired_contrasts.csv','exactness.csv', + 'exactness_mismatches.csv') + (('snr_descriptive.csv','snr_cases.csv') if snr_rows is not None else ())}) + (output/'provenance.json').write_text(json.dumps(provenance, indent=2, sort_keys=True)+'\n') + print(json.dumps(dict(status='rendered', output=str(output.resolve()), **provenance['validated']))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('recovery', 'seal', 'exactness', 'output'): + parser.add_argument('--'+name, type=Path, required=True) + parser.add_argument('--snr', type=Path, help='Optional complete held-out white/OU expected-SNR diagnostic JSON') + parser.add_argument('--synthetic', action='store_true', help='Require fixture markers and visibly watermark every Markdown report') + render(parser.parse_args()) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/run.py b/benchmarks/tls_survey/run.py new file mode 100644 index 00000000..3c0875fc --- /dev/null +++ b/benchmarks/tls_survey/run.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Run full blind standard TLS or development-selected GPU BLS; resumable receipts.""" +import argparse +import importlib.metadata +import json +import os +from pathlib import Path +import sys +import time +import traceback +for _thread_variable in ('OMP_NUM_THREADS','OPENBLAS_NUM_THREADS','MKL_NUM_THREADS','NUMBA_NUM_THREADS'): + os.environ[_thread_variable]='1' +import numpy as np +from common import ROOT,BLS_CONFIGS,array_hash,load_case,module,now,recovered,sha,write,source_identity,method_applicable + + +def finite(v): + value=float(v) + return value if np.isfinite(value) else None + + +def production_identity(): + import cuvarbase + package=Path(cuvarbase.__file__).parent + return {str(p.relative_to(package)):sha(p) for p in sorted(package.rglob('*')) + if p.is_file() and p.suffix in ('.py','.cu','.cuh')} + + +def bls_bounds(periods): + """Broad stellar envelope: no use of injection duration, impact, or epoch.""" + seconds=np.asarray(periods)*86400 + qmin=np.minimum(.15,695508000*.05*(4*seconds/(20848*1e15))**(1/3)/seconds) + qmax=np.minimum(.15,(695508000*4+2*69911000)*(4*seconds/(416970*1e15))**(1/3)/seconds) + return qmin,qmax + + +def bls_candidates(periods,power,chi2_null=None): + worker=module(ROOT/'benchmarks/transit/worker.py','survey_bls_ranking') + good=np.isfinite(power) + if not good.any(): + raise ValueError('No finite BLS powers') + index=int(np.argmax(np.where(good,power,-np.inf))) + candidates={'raw':dict(period=float(periods[index]),score=float(power[index]))} + if chi2_null is not None: + candidates['likelihood']=dict(period=float(periods[index]),score=float(power[index]*chi2_null), + chi2_null=float(chi2_null)) + try: + candidates['detrended']=worker.spectral_candidate(periods,power) + except ValueError as exc: + candidates['detrended']=dict(period=None,score=None,error=str(exc)) + return candidates + + +def search(arrays,metadata,method): + t,y,dy,periods=(arrays[k] for k in ('t','y','dy','periods')) + if method=='tls': + from cuvarbase.tls import tls_search_gpu + r=tls_search_gpu(t,y,dy,periods=periods,return_arrays=True,**metadata['search_kwargs']) + candidate=dict(period=finite(r['period']),score=finite(r['SDE'])) + candidate['successful_no_candidate']=candidate['period'] is None and candidate['score']==0. + candidate['no_candidate_reason']=r.get('error') if candidate['successful_no_candidate'] else None + candidates={'native':candidate} + spectra={key:array_hash(np.asarray(np.ma.filled(r[key],np.nan))) for key in ('periods','chi2')} + spectra['valid_mask']=array_hash(np.isfinite(np.ma.filled(r['chi2'],np.nan))) + else: + from cuvarbase.bls import eebls_gpu_fast + qmin,qmax=bls_bounds(periods) + config=dict(BLS_CONFIGS[method]);qmin*=config.pop('qmin_factor') + p=np.asarray(eebls_gpu_fast(t,y,dy,1/periods,qmin=qmin,qmax=qmax, + ignore_negative_delta_sols=True,**config)) + weight=dy**-2 + weighted_mean=np.dot(weight,y)/weight.sum() + chi2_null=float(np.dot(weight,(y-weighted_mean)**2)) + candidates=bls_candidates(periods,p,chi2_null) + spectra={'periods':array_hash(periods),'power':array_hash(p),'valid_mask':array_hash(np.isfinite(p))} + return candidates,spectra + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest',type=Path,required=True) + parser.add_argument('--methods',nargs='+',choices=('tls',*BLS_CONFIGS),required=True) + parser.add_argument('--out',type=Path,required=True) + parser.add_argument('--regimes',help='Optional comma-separated shard; all cases in these regimes required') + parser.add_argument('--seal',type=Path) + parser.add_argument('--shard-index',type=int,default=0) + parser.add_argument('--shard-count',type=int,default=1) + args=parser.parse_args() + if args.shard_count<1 or not 0<=args.shard_index0 + Path(old['full']['spectrum_artifact']['path']).write_bytes(b'changed') + with pytest.raises(ValueError,match='spectrum changed'):m.compare_observation(new,old) + + +def test_deadline_reserves_cleanup_and_never_shrinks_queue(): + with pytest.raises(m.Interrupted):m.deadline_check(time.time()+119) + m.deadline_check(time.time()+121) + + +def authorization_fixture(monkeypatch,tmp_path): + def save(name,value): + path=tmp_path/name + path.write_text(json.dumps(value)) + return path + runner=save('runner.py',{'source':'unchanged'}) + monkeypatch.setattr(m,'ROOT',tmp_path) + monkeypatch.setattr(m,'source_identity',lambda protocol:{'runner.py':m.native.sha(runner)}) + science=save('science.json',{'scientific':'seal'}) + tuning=save('primary-tuning.json',{'original':'tuning'}) + state=save('state.json',{'status':'complete'}) + bundle=save('bundle.json',{'archive':'verified'}) + varied=save('varied.json',{'cases':[]}) + result=save('result.json',{'cohort':[dict(name='a',input_sha256='input')], + 'environment':dict(cpu_quota_cores=7.65)}) + measurement=save('primary-measurement.json',dict(status='complete',stage='measure', + science_seal_sha256=m.native.sha(science),manifest_sha256='original-null-manifest', + varied_manifest=str(varied),varied_manifest_sha256=m.native.sha(varied), + configs=[dict(scope='varied',result='result.json',result_sha256=m.native.sha(result))])) + seal=save('supplement-seal.json',dict(schema=1,kind='native_bls_execution_supplement', + science_seal_sha256=m.native.sha(science),auxiliary_plan_sha256='reviewed-aux', + remote_files={str(runner):m.native.sha(runner),str(tuning):m.native.sha(tuning)}, + budget=dict(gpu_cap_seconds=3600,cleanup_reserve_seconds=120), + binding_rule=dict(primary_tuning_path=str(tuning),primary_measurement_path=str(measurement), + primary_state_path=str(state),primary_bundle_receipt_path=str(bundle)))) + contents=json.loads(result.read_text()) + binding=save('binding.json',dict(schema=1,supplement_seal_sha256=m.native.sha(seal), + science_seal_sha256=m.native.sha(science),auxiliary_plan_sha256='reviewed-aux', + primary_tuning_sha256=m.native.sha(tuning),primary_measurement_sha256=m.native.sha(measurement), + primary_state_sha256=m.native.sha(state),primary_bundle_receipt_sha256=m.native.sha(bundle), + primary_measurement_manifest_sha256='original-null-manifest', + primary_measurement_varied_manifest_sha256=m.native.sha(varied), + primary_configs=[dict(scope='varied',result='result.json',result_sha256=m.native.sha(result), + cohort_sha256=m.canonical_sha(contents['cohort']),environment_sha256=m.canonical_sha(contents['environment']))])) + args=SimpleNamespace(supplement_seal=seal,supplement_seal_sha256=m.native.sha(seal), + supplement_binding=binding,supplement_binding_sha256=m.native.sha(binding),science_seal=science, + protocol=tmp_path/'protocol.md',primary_tuning=tuning,primary_measurement=measurement) + return args + + +def test_authorization_copies_both_seal_layers_and_primary_identities(monkeypatch,tmp_path): + args=authorization_fixture(monkeypatch,tmp_path) + identity=m.verify_authorization(args) + assert identity['supplement_binding_sha256']==args.supplement_binding_sha256 + assert identity['primary_measurement_sha256']==m.native.sha(args.primary_measurement) + assert identity['auxiliary_plan_sha256']=='reviewed-aux' + + +@pytest.mark.parametrize('changed',['runner.py','primary-tuning.json','primary-measurement.json', + 'state.json','bundle.json','varied.json','result.json']) +def test_authorization_rejects_changed_source_or_primary_evidence(monkeypatch,tmp_path,changed): + args=authorization_fixture(monkeypatch,tmp_path) + (tmp_path/changed).write_text('{}') + with pytest.raises(ValueError):m.verify_authorization(args) + + +def test_binding_cannot_replace_original_cohort_even_with_updated_own_hash(monkeypatch,tmp_path): + args=authorization_fixture(monkeypatch,tmp_path) + altered=json.loads(args.supplement_binding.read_text()) + altered['primary_configs'][0]['cohort_sha256']='different-cohort' + args.supplement_binding.write_text(json.dumps(altered)) + args.supplement_binding_sha256=m.native.sha(args.supplement_binding) + with pytest.raises(ValueError,match='cohort/resource'):m.verify_authorization(args) + + +def test_complete_tune_freezes_five_configs_and_refuses_rerun(monkeypatch,tmp_path): + science=tmp_path/'science.json';science.write_text('{}') + manifest=tmp_path/'manifest.json';manifest.write_text('{}') + old=tmp_path/'original.json';old.write_text('{}') + primary=tmp_path/'primary.json' + primary.write_text(json.dumps(dict(status='complete',stage='tune', + science_seal_sha256=m.native.sha(science),manifest_sha256=m.native.sha(manifest),names=['a'], + configs=[dict(id='bls-mixed-w1-b1',eligible=False,result='original.json',result_sha256=m.native.sha(old))]))) + identity=dict(supplement_seal_sha256='seal',supplement_binding_sha256='binding', + science_seal_sha256=m.native.sha(science),auxiliary_plan_sha256='aux', + primary_tuning_sha256=m.native.sha(primary),primary_measurement_sha256='measure') + monkeypatch.setattr(m,'verify_authorization',lambda args:identity) + monkeypatch.setattr(m,'source_identity',lambda protocol:{'new-runner':'reviewed'}) + monkeypatch.setattr(m,'primary_panel',lambda *unused:dict(cohort=[dict(name='a')],config=dict(hourly_usd=.49),environment=dict( + nvidia_smi='samegpu',cpu_quota_cores=7.65,host_memory_limit_bytes=50000000000, + cpu_math_thread_environment=dict(OMP_NUM_THREADS='1')))) + monkeypatch.setattr(m.native,'exclusive_gpu_processes',lambda pids:dict(exclusive=True)) + calls=[] + def configuration(args,manifest,names,scope,workers,batch,output,*unused): + calls.append((workers,batch)) + output.mkdir() + speed={(1,1):1,(2,1):2,(4,1):3,(4,4):4,(4,8):2}[(workers,batch)] + result=dict(status='complete',execution_rates_valid=True,workers=workers,batch_size=batch, + fixed_reference_anchors={},summary=dict(successful_lightcurves_per_second=speed, + median_repetition_successful_lightcurves_per_second=speed)) + m.native.write(output/'result.json',result) + return result + monkeypatch.setattr(m,'run_configuration',configuration) + output=tmp_path/'supplement-tune' + argv=['--stage','tune','--manifest',str(manifest),'--output',str(output), + '--science-seal',str(science),'--primary-tuning',str(primary), + '--primary-measurement',str(tmp_path/'measurement.json'),'--source-root',str(tmp_path), + '--supplement-seal',str(tmp_path/'seal.json'),'--supplement-binding',str(tmp_path/'binding.json'), + '--supplement-seal-sha256','seal','--supplement-binding-sha256','binding', + '--hourly-usd','.49','--deadline-epoch',str(time.time()+1000)] + assert m.main(argv)==0 + assert calls==[(1,1),(2,1),(4,1),(4,4),(4,8)] + seal=json.loads((output/'tuning-seal.json').read_text()) + assert seal['selected']==dict(workers=4,batch_size=4) + assert seal['supplement_binding_sha256']=='binding' + assert seal['hourly_usd']==.49 + assert seal['campaign_sha256']==m.native.sha(output/'campaign.json') + with pytest.raises(SystemExit):m.main(argv) + assert len(calls)==5 + changed_price=list(argv) + changed_price[changed_price.index('--output')+1]=str(tmp_path/'changed-price') + changed_price[changed_price.index('--hourly-usd')+1]='0.01' + with pytest.raises(ValueError,match='Rental price'):m.main(changed_price) + assert len(calls)==5 diff --git a/benchmarks/tls_survey/test_campaign.py b/benchmarks/tls_survey/test_campaign.py new file mode 100644 index 00000000..88f2ab26 --- /dev/null +++ b/benchmarks/tls_survey/test_campaign.py @@ -0,0 +1,78 @@ +"""Resume guards must reject incomplete or foreign campaign artifacts.""" +import copy +import json +from pathlib import Path + +import pytest + +import campaign + + +@pytest.fixture +def artifacts(tmp_path): + seal = dict(source_identity={'physics': 'fixed'}, production_sources={'kernel': 'fixed'}, + regimes=['tess_solar'], counts={'injections': 2}, execution_shards=2, + bls_selected={'tess_solar': {'method': 'bls_strong'}}) + entries = [dict(metadata={'name': 'case' + str(i), 'regime': 'tess_solar'}, sha256=str(i)) + for i in range(2)] + manifest = dict(status='complete', split='injections', seal_sha256='reviewed', + source_identity=seal['source_identity'], regimes=seal['regimes'], + count_per_regime=2, cases=entries) + manifest_path = tmp_path / 'manifest.json' + manifest_path.write_text(json.dumps(manifest)) + receipt = dict(status='complete', split='injections', + manifest_sha256=campaign.sha(manifest_path), + production_sources=seal['production_sources'], + runner_sha256=campaign.sha(campaign.ROOT / 'benchmarks/tls_survey/run.py'), + shard_index=0, shard_count=2, + cases=[dict(name='case0', method=m, input_sha256='0') + for m in ('tls', 'bls_strong')]) + return seal, manifest, manifest_path, receipt + + +def test_complete_resume_artifacts(artifacts, tmp_path): + seal, manifest, path, receipt = artifacts + assert campaign.check_manifest(path, 'injections', seal, 'reviewed') == manifest + output = tmp_path / 'results.json'; output.write_text(json.dumps(receipt)) + assert campaign.check_result(output, path, manifest, seal, 0) == receipt + + +@pytest.mark.parametrize('change', ['missing', 'duplicate', 'wrong_method', 'wrong_input', + 'wrong_shard', 'wrong_source', 'incomplete']) +def test_resume_rejects_bad_search_receipts(artifacts, tmp_path, change): + seal, manifest, path, receipt = artifacts + receipt = copy.deepcopy(receipt) + if change == 'missing': + receipt['cases'].pop() + elif change == 'duplicate': + receipt['cases'].append(receipt['cases'][0]) + elif change == 'wrong_method': + receipt['cases'][1]['method'] = 'bls_finest' + elif change == 'wrong_input': + receipt['cases'][0]['input_sha256'] = 'other' + elif change == 'wrong_shard': + receipt['shard_index'] = 1 + elif change == 'wrong_source': + receipt['production_sources'] = {'kernel': 'changed'} + elif change == 'incomplete': + receipt['status'] = 'running' + output = tmp_path / 'results.json'; output.write_text(json.dumps(receipt)) + with pytest.raises(ValueError): + campaign.check_result(output, path, manifest, seal, 0) + + +@pytest.mark.parametrize('change', ['missing', 'duplicate', 'wrong_seal', 'wrong_source']) +def test_resume_rejects_bad_input_manifests(artifacts, change): + seal, manifest, path, _ = artifacts + manifest = copy.deepcopy(manifest) + if change == 'missing': + manifest['cases'].pop() + elif change == 'duplicate': + manifest['cases'][1] = manifest['cases'][0] + elif change == 'wrong_seal': + manifest['seal_sha256'] = 'unreviewed' + elif change == 'wrong_source': + manifest['source_identity'] = {'physics': 'changed'} + path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError): + campaign.check_manifest(path, 'injections', seal, 'reviewed') diff --git a/benchmarks/tls_survey/test_exactness.py b/benchmarks/tls_survey/test_exactness.py new file mode 100644 index 00000000..8311714d --- /dev/null +++ b/benchmarks/tls_survey/test_exactness.py @@ -0,0 +1,148 @@ +"""The original held-out implementation outcome must survive every diagnostic.""" +import copy +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +import exactness + + +@pytest.fixture +def matching(): + metadata = dict(regime='tess_solar', null=False, name='test') + candidate = dict(period=3., score=7., successful_no_candidate=False, + no_candidate_reason=None, recovered=True, alias_recovered=True) + row = dict(valid=True, candidates={'native': candidate}, + spectra={'periods': 'grid', 'chi2': 'all-values', 'valid_mask': 'all-mask'}, + name='test', method='tls', input_sha256='input') + thresholds = {key: {'tess_solar/tls': dict(value=cut, target_fpr=fpr)} + for key, cut, fpr in [('thresholds', 6., .05), ('secondary_thresholds', 8., .01)]} + return row, metadata, thresholds + + +def test_complete_originals_match_both_operating_points(matching): + row, metadata, thresholds = matching + result = exactness.compare(row, copy.deepcopy(row), metadata, thresholds) + assert result['exact'] + assert result['baseline_decisions']['thresholds']['detected'] + assert not result['baseline_decisions']['secondary_thresholds']['detected'] + + +@pytest.mark.parametrize('change', ['chi2', 'valid_mask', 'periods', 'period', 'score', 'recovered', 'valid']) +def test_every_primary_numerical_difference_is_retained(matching, change): + row, metadata, thresholds = matching + baseline = copy.deepcopy(row) + if change in baseline['spectra']: + baseline['spectra'][change] = 'different' + elif change == 'valid': + baseline['valid'] = False + else: + baseline['candidates']['native'][change] = False if change == 'recovered' else 9. + assert not exactness.compare(row, baseline, metadata, thresholds)['exact'] + + +def test_both_api_failures_do_not_establish_equivalence(matching): + row, metadata, thresholds = matching + row.update(valid=False, candidates={}, spectra={}) + assert not exactness.compare(row, row, metadata, thresholds)['exact'] + + +def test_no_candidate_is_valid_equal_nondetection(matching): + row, metadata, thresholds = matching + row['candidates']['native'].update(period=None, score=0., successful_no_candidate=True, + recovered=False, alias_recovered=False) + assert exactness.compare(row, row, metadata, thresholds)['exact'] + + +def test_plan_is_frozen_before_execution(tmp_path, monkeypatch): + seal = tmp_path / 'seal.json' + seal.write_text(json.dumps(dict(source_identity={'science': 'fixed'}, + production_sources={'candidate': 'fixed'}, regimes=['tess_solar'] * 10, + counts={'injections': 256, 'nulls': 256}))) + monkeypatch.setattr(exactness, 'source_identity', lambda: {'science': 'fixed'}) + monkeypatch.setattr(exactness, 'package_identity', lambda root: { + 'candidate' if Path(root) == exactness.ROOT else 'baseline': 'fixed'}) + args = SimpleNamespace(seal=seal, baseline_root=tmp_path / 'baseline', out=tmp_path / 'plan.json', + campaign=tmp_path / 'final-campaign') + exactness.freeze(args) + plan = json.loads(args.out.read_text()) + assert plan['expected_cases'] == 5120 + assert plan['workers'] == 1 + assert plan['baseline_sources'] == {'baseline': 'fixed'} + assert plan['estimated_extra_hours'] == pytest.approx(4.837752061155108) + with pytest.raises(ValueError, match='overwrite'): + exactness.freeze(args) + args.out = tmp_path / 'second-plan.json' + data = args.campaign / 'inputs-calibration'; data.mkdir(parents=True) + (data / 'manifest.json').write_text('{}') + with pytest.raises(ValueError, match='before any final'): + exactness.freeze(args) + + +@pytest.mark.parametrize('split', ['calibration', 'injections', 'nulls']) +@pytest.mark.parametrize('partial_file', [False, True]) +def test_plan_rejects_partial_final_input_directory(tmp_path, split, partial_file): + args = SimpleNamespace(campaign=tmp_path / 'final-campaign') + data = args.campaign / ('inputs-' + split) + data.mkdir(parents=True) + if partial_file: + (data / 'case.npz').write_bytes(b'partially generated input') + assert not (data / 'manifest.json').exists() + with pytest.raises(ValueError, match='before any final'): + exactness.freeze(args) + + +def test_interrupted_repeat_cannot_replace_primary_mismatch(tmp_path, monkeypatch, matching): + original, metadata, thresholds = matching + baseline_root = tmp_path / 'baseline'; (baseline_root / 'cuvarbase').mkdir(parents=True) + source = {'source': 'fixed'} + seal = tmp_path / 'seal.json'; seal.write_text(json.dumps(dict(source_identity=source, + production_sources=source, execution_shards=1))) + plan = tmp_path / 'plan.json'; plan.write_text(json.dumps(dict(seal_sha256=exactness.sha(seal), + protocol_sha256=exactness.sha(exactness.__file__), baseline_root=str(baseline_root), + baseline_sources=source, expected_cases=1, splits=['injections'], planned_campaign_root=str(tmp_path), + repeat_diagnostics={'first_mismatching_cases': 10, 'additional_baseline_runs': 2}))) + thresholds['seal_sha256'] = exactness.sha(seal) + (tmp_path / 'thresholds.json').write_text(json.dumps(thresholds)) + (tmp_path / 'injections-search-0.json').write_text('original immutable receipt') + entry = {'metadata': metadata, 'sha256': 'input', 'file': 'test.npz'} + monkeypatch.setattr(exactness, 'source_identity', lambda: source) + monkeypatch.setattr(exactness, 'package_identity', lambda root: source) + monkeypatch.setattr(exactness, 'check_manifest', lambda *args: {'cases': [entry]}) + monkeypatch.setattr(exactness, 'check_result', lambda *args: {'cases': [original]}) + monkeypatch.setattr(exactness, 'load_case', lambda *args: ({}, metadata)) + monkeypatch.setitem(sys.modules, 'cuvarbase', SimpleNamespace( + __file__=str(baseline_root / 'cuvarbase/__init__.py'))) + monkeypatch.setitem(sys.modules, 'cuvarbase.base', SimpleNamespace(ensure_context=lambda: None)) + driver = SimpleNamespace(Context=SimpleNamespace(get_device=lambda: SimpleNamespace(name=lambda: 'test'))) + monkeypatch.setitem(sys.modules, 'pycuda', SimpleNamespace(driver=driver)) + monkeypatch.setitem(sys.modules, 'pycuda.driver', driver) + baseline = copy.deepcopy(original); baseline['spectra']['chi2'] = 'primary mismatch' + calls = [] + def measure(*args): + calls.append(1) + if len(calls) > 1: + raise KeyboardInterrupt('interrupted diagnostic') + return baseline, {} + monkeypatch.setattr(exactness, 'measured_search', measure) + args = SimpleNamespace(plan=plan, plan_sha256=exactness.sha(plan), seal=seal, + campaign=tmp_path, out=tmp_path / 'exactness.json') + with pytest.raises(KeyboardInterrupt): + exactness.run(args) + persisted = json.loads(args.out.read_text()) + assert persisted['mismatches'] == 1 + assert persisted['cases'][0]['baseline']['spectra']['chi2'] == 'primary mismatch' + assert persisted['cases'][0]['original_candidate'] == original + dumped = persisted['cases'][0]['original_baseline_arrays'] + assert exactness.sha(dumped['path']) == dumped['sha256'] + # Resume never executes the original input a second time or erases failure. + exactness.run(args) + final = json.loads(args.out.read_text()) + assert len(calls) == 2 + assert final['status'] == 'complete' and not final['exactness_qualified'] + args.campaign = tmp_path / 'other-existing-campaign' + with pytest.raises(ValueError, match='pre-input frozen plan'): + exactness.run(args) diff --git a/benchmarks/tls_survey/test_followup.py b/benchmarks/tls_survey/test_followup.py new file mode 100644 index 00000000..028be54d --- /dev/null +++ b/benchmarks/tls_survey/test_followup.py @@ -0,0 +1,83 @@ +"""The September follow-up must fix the actual child-process environment failure.""" +import json +import sys + +import pytest + +from benchmarks.tls_survey import followup + + +def plan(tmp_path): + path = tmp_path / 'plan.json' + path.write_text(json.dumps(dict(purpose='test', files={}, attempt_timeout_seconds=10))) + return path, followup.sha(path) + + +def test_child_receives_all_six_limits_before_imports(monkeypatch, tmp_path): + for key in followup.THREAD_VARIABLES: + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv('OPENBLAS_NUM_THREADS', '32') + source, digest = plan(tmp_path) + command = [sys.executable, '-c', + 'import json,os; print(json.dumps({k:os.environ.get(k) for k in ' + + repr(followup.THREAD_VARIABLES) + '}))'] + output = tmp_path / 'attempt' + assert followup.launch(source, digest, command, output) == 0 + actual = json.loads((output / 'stdout.log').read_text()) + assert set(actual.values()) == {'1'} + assert actual['VECLIB_MAXIMUM_THREADS'] == actual['NUMEXPR_NUM_THREADS'] == '1' + + +def test_failed_attempt_is_retained_and_cannot_be_overwritten(tmp_path): + source, digest = plan(tmp_path) + output = tmp_path / 'attempt' + assert followup.launch(source, digest, [sys.executable, '-c', 'raise SystemExit(7)'], output) == 7 + receipt = json.loads((output / 'launch.json').read_text()) + assert receipt['status'] == 'failed' and receipt['exit_code'] == 7 + with pytest.raises(FileExistsError): + followup.launch(source, digest, [sys.executable, '-c', 'pass'], output) + + +def test_source_drift_stops_before_launch(tmp_path): + source, digest = plan(tmp_path) + source.write_text('{}') + output = tmp_path / 'attempt' + with pytest.raises(ValueError, match='plan changed'): + followup.launch(source, digest, [sys.executable, '-c', 'pass'], output) + assert not output.exists() + + +@pytest.mark.parametrize('changed_array', [False, True]) +def test_preserved_varied_inputs_require_exact_restored_parent_arrays(tmp_path, changed_array): + import hashlib + import numpy as np + from benchmarks.tls_survey import run_strict_followup as strict + + nulls = tmp_path/'inputs/nulls' + varied = tmp_path/'inputs/varied' + nulls.mkdir(parents=True) + varied.mkdir() + arrays = dict(t=np.arange(1., 7.), y=np.arange(6.), dy=np.ones(6), periods=np.array([1., 2.])) + np.savez_compressed(nulls/'a.npz', **arrays) + original_zip_sha = 'a'*64 + original_manifest_sha = 'b'*64 + (nulls/'manifest.json').write_text(json.dumps(dict( + original_manifest_sha256=original_manifest_sha, + cases=[dict(file='a.npz', sha256=strict.sha(nulls/'a.npz'), original_npz_sha256=original_zip_sha)]))) + indices = np.array([0, 2, 5]) + derived = {key: value if key == 'periods' else value[indices] for key, value in arrays.items()} + if changed_array: + derived['y'][1] += 1. + np.savez_compressed(varied/'v.npz', **derived, retained_original_indices=indices) + (varied/'manifest.json').write_text(json.dumps(dict( + source_manifest_sha256=original_manifest_sha, + cases=[dict(file='v.npz', sha256=strict.sha(varied/'v.npz'), metadata=dict( + original_file='a.npz', original_sha256=original_zip_sha, + index_sha256=hashlib.sha256(indices.tobytes()).hexdigest()))]))) + if changed_array: + with pytest.raises(ValueError, match='differ from their restored parent'): + strict.prepare_preserved_varied(tmp_path, tmp_path/'bound') + else: + result = strict.prepare_preserved_varied(tmp_path, tmp_path/'bound') + assert (result.parent/'v.npz').read_bytes() == (varied/'v.npz').read_bytes() + assert json.loads(result.read_text())['source_manifest_sha256'] == strict.sha(nulls/'manifest.json') diff --git a/benchmarks/tls_survey/test_heldout_snr.py b/benchmarks/tls_survey/test_heldout_snr.py new file mode 100644 index 00000000..24412f65 --- /dev/null +++ b/benchmarks/tls_survey/test_heldout_snr.py @@ -0,0 +1,63 @@ +"""The descriptive wrapper must keep frozen definitions and input identities.""" +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +import heldout_snr + + +@pytest.fixture +def campaign(tmp_path, monkeypatch): + source = {'scientific': 'fixed'} + seal = tmp_path / 'seal.json'; seal.write_text(json.dumps(dict(source_identity=source, + regimes=['tess_solar'], counts={'injections': 1}))) + plan = tmp_path / 'plan.json'; plan.write_text(json.dumps(dict( + seal_sha256=heldout_snr.sha(seal), planned_campaign_root=str(tmp_path), + heldout_snr_protocol_sha256=heldout_snr.sha(heldout_snr.__file__)))) + folder = tmp_path / 'inputs-injections'; folder.mkdir() + manifest = folder / 'manifest.json'; manifest.write_text(json.dumps(dict(status='complete', + split='injections', seal_sha256=heldout_snr.sha(seal), source_identity=source, + regimes=['tess_solar'], count_per_regime=1, + cases=[dict(metadata={'name': 'test', 'regime': 'tess_solar'}, sha256='input')]))) + monkeypatch.setattr(heldout_snr, 'source_identity', lambda: source) + monkeypatch.setattr(heldout_snr, 'load_case', lambda path, entry: ( + {'t': np.arange(8), 'periods': np.array([1., 2.])}, entry['metadata'])) + monkeypatch.setattr(heldout_snr, 'module', lambda *args: SimpleNamespace(build_cache=lambda *args: {})) + calls = [] + def row(arrays, metadata, cache): + calls.append(metadata['name']) + return dict(name=metadata['name'], regime=metadata['regime'], native_family_white_snr=1.) + monkeypatch.setattr(heldout_snr, 'diagnostic_row', row) + return SimpleNamespace(command='run', seal=seal, plan=plan, plan_sha256=heldout_snr.sha(plan), + manifest=manifest, out=tmp_path / 'heldout-snr.json'), calls + + +def test_guarded_descriptive_run_and_resume(campaign): + args, calls = campaign + heldout_snr.execute(args); heldout_snr.execute(args) + value = json.loads(args.out.read_text()) + assert calls == ['test'] + assert value['status'] == 'complete' and value['split'] == 'injections' + assert value['seal_sha256'] == heldout_snr.sha(args.seal) + assert value['manifest_sha256'] == heldout_snr.sha(args.manifest) + assert value['rows'][0]['input_sha256'] == 'input' + + +@pytest.mark.parametrize('field,value', [('split', 'nulls'), ('seal_sha256', 'different'), + ('source_identity', {}), ('count_per_regime', 2)]) +def test_foreign_heldout_populations_rejected(campaign, field, value): + args, calls = campaign + manifest = json.loads(args.manifest.read_text()); manifest[field] = value + args.manifest.write_text(json.dumps(manifest)) + with pytest.raises(ValueError): + heldout_snr.execute(args) + assert not calls + + +def test_validation_cannot_relabel_heldout_data(campaign): + args, calls = campaign; args.command = 'validate' + with pytest.raises(ValueError, match='development inputs'): + heldout_snr.execute(args) + assert not calls diff --git a/benchmarks/tls_survey/test_plot_native_bls_comparison.py b/benchmarks/tls_survey/test_plot_native_bls_comparison.py new file mode 100644 index 00000000..c28f5ea0 --- /dev/null +++ b/benchmarks/tls_survey/test_plot_native_bls_comparison.py @@ -0,0 +1,228 @@ +"""Execution throughput must never erase native BLS's failed qualification.""" +import copy +import json + +import pytest + +from benchmarks.tls_survey.plot_native_bls_comparison import ( + IDENTITY_KEYS, allocation, canonical_sha, execution_rates, read_native, sha, table_rows, +) + + +def record(): + return dict(status='complete', scope='tess_solar', backend='native_bls_execution', workers=2, batch_size=4, + execution_rates_valid=True, gpu_ownership=dict(passed=True), + numerical=dict(original_qualification_passed=False, + selected_mismatch_count=6, complete_output_mismatch_count=9), + environment=dict(nvidia_smi='A40, same-uuid', cpu_quota_cores=7.65, + host_memory_limit_bytes=49999998976), + cohort=[dict(name='case.npz', regime='tess_solar', nobs=1000, nperiods=2000, + input_sha256='same-input')], + repetitions=[dict(status='completed_queue', attempted_count=100, successful_count=90, failed_count=10, + elapsed_seconds=120., successful_lightcurves_per_second=.75) for _ in range(3)], + summary=dict(cold_first_cohort_including_startup_seconds=8., + total_measured_compute_usd=.049), memory={}) + + +def fixture(tmp_path): + primary_record = tmp_path/'primary-result.json' + primary_record.write_text(json.dumps(record())) + primary = dict(science_seal_sha256='science', manifest_sha256='manifest', varied_manifest_sha256='varied', + configs=[dict(scope='tess_solar', + result=primary_record.name, result_sha256=sha(primary_record))]) + primary_path = tmp_path/'primary.json' + primary_path.write_text(json.dumps(primary)) + seal = tmp_path/'seal.json'; seal.write_text(json.dumps(dict(schema=1, kind='native_bls_execution_supplement', + science_seal_sha256='science', auxiliary_plan_sha256='auxiliary', + binding_rule=dict(primary_tuning_path='/original/tuning.json'), + remote_files={'/original/tuning.json':'tuning'}))) + binding = tmp_path/'binding.json'; binding.write_text(json.dumps(dict(schema=1, + supplement_seal_sha256=sha(seal), science_seal_sha256='science', auxiliary_plan_sha256='auxiliary', + primary_tuning_sha256='tuning', primary_measurement_sha256=sha(primary_path), + primary_measurement_manifest_sha256='manifest', primary_measurement_varied_manifest_sha256='varied', + primary_configs=[dict(primary['configs'][0], cohort_sha256=canonical_sha(record()['cohort']), + environment_sha256=canonical_sha(record()['environment']))]))) + native_record = tmp_path/'native-result.json'; native_record.write_text(json.dumps(record())) + campaign = dict(stage='measure', status='complete', original_numerical_qualification_passed=False, + selected=dict(workers=2, batch_size=4), + auxiliary_plan_sha256='auxiliary', primary_tuning_sha256='tuning', supplement_binding_sha256=sha(binding), + science_seal_sha256='science', primary_measurement_sha256=sha(primary_path), + supplement_seal_sha256=sha(seal), configs=[dict(scope='tess_solar', workers=2, batch_size=4, + result=native_record.name, result_sha256=sha(native_record), execution_rates_valid=True)]) + campaign.update(source_identity={'source':'source-hash'}, allocation=list(allocation(record())), + deadline_epoch=123456., original_bls_exclusion_sha256='original-failure', hourly_usd=.49) + value = record(); value.update({key:campaign[key] for key in (*IDENTITY_KEYS, 'source_identity')}) + native_record.write_text(json.dumps(value)); campaign['configs'][0]['result_sha256'] = sha(native_record) + folder = tmp_path/'tune'; folder.mkdir() + tuning = dict(campaign, stage='tune', manifest_sha256='development-manifest', configs=[], artifact_sha256={}) + (folder/'campaign.json').write_text(json.dumps(tuning)) + tuning_seal = dict(schema_version=1, original_qualification_passed=False, + campaign_path='/original/tune/campaign.json', campaign_sha256=sha(folder/'campaign.json'), + manifest_sha256=tuning['manifest_sha256'], **{key:tuning[key] for key in + (*IDENTITY_KEYS, 'source_identity', 'allocation', 'deadline_epoch', 'selected', + 'original_bls_exclusion_sha256', 'hourly_usd')}) + tuning_path = folder/'tuning-seal.json'; tuning_path.write_text(json.dumps(tuning_seal)) + campaign['tuning_seal_sha256'] = sha(tuning_path) + native = tmp_path/'native.json'; native.write_text(json.dumps(campaign)) + return native, primary_path, primary, allocation(record()), seal, binding, tuning_path + + +def test_api_failures_reduce_rate_and_mismatches_do_not_become_qualified(tmp_path): + assert execution_rates(record()) == [.75]*3 + _, native, missing = read_native(*fixture(tmp_path)) + heldout = dict(exact_cases=5119, planned_cases=5120, + aggregate_exactness_qualified=False, science_seal_sha256='science') + rows = table_rows({}, native, {}, missing, {}, heldout) + row = next(r for r in rows if r['backend'] == 'bls' and r['scope'] == 'tess_solar') + assert row['rate_available'] + assert row['median_lightcurves_per_second'] == .75 + assert row['attempted_count'] == 300 and row['successful_count'] == 270 and row['failed_count'] == 30 + assert row['completion_fraction'] == .9 + assert row['selected_mismatch_count'] == 6 and row['complete_output_mismatch_count'] == 9 + assert not row['original_numerical_qualification_passed'] + assert not row['heldout_aggregate_exactness_qualified'] + assert row['heldout_exact_cases'] == 5119 + + +@pytest.mark.parametrize('mutation', [ + lambda r:r['repetitions'][0].update(successful_lightcurves_per_second=100/120), + lambda r:r['repetitions'][0].update(failed_count=0), + lambda r:r['repetitions'][0].update(elapsed_seconds=119.9), + lambda r:r['repetitions'][0].update(attempted_count=90, successful_count=80), + lambda r:r['repetitions'].pop(), + lambda r:r['repetitions'][0].update(status='interrupted'), + lambda r:r['gpu_ownership'].update(passed=False), + lambda r:r['numerical'].update(original_qualification_passed=True), +]) +def test_rejects_incomplete_or_misrepresented_execution(mutation): + value = record(); mutation(value) + with pytest.raises(ValueError): + execution_rates(value) + + +@pytest.mark.parametrize('mutation', [ + lambda r:r['environment'].update(cpu_quota_cores=8.), + lambda r:r['environment'].update(nvidia_smi='A40, different-uuid'), + lambda r:r['cohort'][0].update(input_sha256='different-input'), + lambda r:r['cohort'][0].update(nperiods=1999), + lambda r:r.update(workers=4), +]) +def test_rejects_resource_or_input_mismatch(tmp_path, mutation): + args = fixture(tmp_path) + campaign = json.loads(args[0].read_text()) + path = tmp_path/campaign['configs'][0]['result'] + value = json.loads(path.read_text()); mutation(value); path.write_text(json.dumps(value)) + campaign['configs'][0]['result_sha256'] = sha(path) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError): + read_native(*args) + + +def test_changed_original_receipt_is_rejected(tmp_path): + args = fixture(tmp_path) + path = tmp_path/args[2]['configs'][0]['result'] + value = copy.deepcopy(record()); value['cohort'][0]['input_sha256'] = 'rewritten' + path.write_text(json.dumps(value)) + with pytest.raises(ValueError, match='Primary timing receipt changed'): + read_native(*args) + + +def test_zero_successful_work_remains_zero(): + value = record() + for row in value['repetitions']: + row.update(successful_count=0, failed_count=100, successful_lightcurves_per_second=0.) + assert execution_rates(value) == [0.]*3 + + +def test_no_heldout_retry_can_replace_a_failed_panel(tmp_path): + args = fixture(tmp_path) + campaign = json.loads(args[0].read_text()) + failed = copy.deepcopy(campaign['configs'][0]); failed['execution_rates_valid'] = False + campaign['configs'].insert(0, failed) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError, match='Repeated native timing scope'): + read_native(*args) + + +def test_short_reference_diagnostic_is_verified_but_never_displayed_as_measurement(tmp_path): + args = fixture(tmp_path) + campaign = json.loads(args[0].read_text()) + value = record(); value.update(workers=1, batch_size=1, + **{key:campaign[key] for key in (*IDENTITY_KEYS, 'source_identity')}) + value['repetitions'] = [dict(attempted_count=1, successful_count=1, failed_count=0, + elapsed_seconds=.1, successful_lightcurves_per_second=10.)] + path = tmp_path/'reference.json'; path.write_text(json.dumps(value)) + reference = dict(scope='tess_solar', workers=1, batch_size=1, reference_only=True, + result=path.name, result_sha256=sha(path), execution_rates_valid=True) + campaign['configs'].insert(0, reference); args[0].write_text(json.dumps(campaign)) + _, native, _ = read_native(*args) + assert execution_rates(native['tess_solar']) == [.75]*3 + path.write_text(json.dumps(dict(value, workers=2))) + with pytest.raises(ValueError, match='Native timing receipt changed'): + read_native(*args) + + +@pytest.mark.parametrize('mutation', [ + lambda b:b.update(auxiliary_plan_sha256='foreign-plan'), + lambda b:b.update(primary_measurement_manifest_sha256='foreign-manifest'), + lambda b:b.update(primary_measurement_varied_manifest_sha256='foreign-varied'), + lambda b:b['primary_configs'][0].update(cohort_sha256='foreign-cohort'), + lambda b:b['primary_configs'][0].update(environment_sha256='foreign-allocation'), +]) +def test_mechanical_binding_cannot_change_reviewed_identities(tmp_path, mutation): + args = fixture(tmp_path) + binding = json.loads(args[5].read_text()); mutation(binding) + args[5].write_text(json.dumps(binding)) + campaign = json.loads(args[0].read_text()); campaign['supplement_binding_sha256'] = sha(args[5]) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError): + read_native(*args) + + +def test_later_binding_cannot_replace_prospectively_pinned_primary_tuning(tmp_path): + args = fixture(tmp_path) + binding = json.loads(args[5].read_text()); binding['primary_tuning_sha256'] = 'replacement' + args[5].write_text(json.dumps(binding)) + campaign = json.loads(args[0].read_text()); campaign.update( + primary_tuning_sha256='replacement', supplement_binding_sha256=sha(args[5])) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError, match='Primary tuning was not frozen'): + read_native(*args) + + +def test_measurement_cannot_change_the_sealed_development_winner(tmp_path): + args = fixture(tmp_path) + campaign = json.loads(args[0].read_text()); campaign['selected']['workers'] = 4 + row = campaign['configs'][0]; row['workers'] = 4 + path = tmp_path/row['result']; value = json.loads(path.read_text()); value['workers'] = 4 + path.write_text(json.dumps(value)); row['result_sha256'] = sha(path) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError, match='development selection: selected'): + read_native(*args) + + +@pytest.mark.parametrize('key,value', [('scope','varied'), ('backend','other'), + ('auxiliary_plan_sha256','foreign')]) +def test_individual_native_result_must_match_its_campaign(tmp_path, key, value): + args = fixture(tmp_path) + campaign = json.loads(args[0].read_text()); row = campaign['configs'][0] + path = tmp_path/row['result']; record_value = json.loads(path.read_text()); record_value[key] = value + path.write_text(json.dumps(record_value)); row['result_sha256'] = sha(path) + args[0].write_text(json.dumps(campaign)) + with pytest.raises(ValueError): + read_native(*args) + + +def test_development_artifacts_are_verified_before_rate_display(tmp_path): + args = fixture(tmp_path) + tuning_path = args[6].parent/'campaign.json'; artifact = args[6].parent/'diagnostic.json' + artifact.write_text('{"original":true}') + tuning = json.loads(tuning_path.read_text()); tuning['artifact_sha256'] = {artifact.name:sha(artifact)} + tuning_path.write_text(json.dumps(tuning)) + seal = json.loads(args[6].read_text()); seal['campaign_sha256'] = sha(tuning_path) + args[6].write_text(json.dumps(seal)) + campaign = json.loads(args[0].read_text()); campaign['tuning_seal_sha256'] = sha(args[6]) + args[0].write_text(json.dumps(campaign)); read_native(*args) + artifact.write_text('{"replaced":true}') + with pytest.raises(ValueError, match='development tuning artifact changed'): + read_native(*args) diff --git a/benchmarks/tls_survey/test_plot_qualification.py b/benchmarks/tls_survey/test_plot_qualification.py new file mode 100644 index 00000000..68e403f9 --- /dev/null +++ b/benchmarks/tls_survey/test_plot_qualification.py @@ -0,0 +1,57 @@ +"""Scientific qualification labels must remain independent of timing ratios.""" +import csv +import json + +import pytest + +from benchmarks.tls_survey.test_report_recovery import synthetic_fixture +from benchmarks.tls_survey.plot_throughput import heldout_qualification, figure_csv +from benchmarks.tls_survey.report_recovery import sha + + +def test_failed_heldout_qualification_stays_visible_beside_valid_timing_ratio(tmp_path): + args = synthetic_fixture(tmp_path) + campaign = dict(science_seal_sha256=sha(args.seal)) + heldout = heldout_qualification(campaign,args.exactness,args.seal) + assert heldout['planned_cases'] == 16 and heldout['exact_cases'] == 15 + assert not heldout['aggregate_exactness_qualified'] + data = {('baseline','tess_solar'):dict(repetitions=[dict(lightcurves_per_second=1.)]*3), + ('candidate','tess_solar'):dict(repetitions=[dict(lightcurves_per_second=2.)]*3)} + path = tmp_path/'figure.csv' + figure_csv(path,data,{},dict(tess_solar=True),heldout) + with path.open() as stream: + rows = list(csv.DictReader(stream)) + selected = next(row for row in rows if row['backend'] == 'candidate' and row['scope'] == 'tess_solar') + assert selected['timing_cohort_optimized_vs_baseline'] == '2.0' + assert selected['heldout_aggregate_exactness_qualified'] == 'False' + assert selected['heldout_exact_cases'] == '15' and selected['heldout_planned_cases'] == '16' + assert selected['heldout_exactness_sha256'] == sha(args.exactness) + + +@pytest.mark.parametrize('mutation', [ + lambda value:value.update(status='running'), + lambda value:value['cases'].pop(), + lambda value:value['cases'][0].update(regime='foreign'), + lambda value:value.update(exactness_qualified=True), + lambda value:value['identity'].update(seal_sha256='wrong'), +]) +def test_figure_rejects_incomplete_or_foreign_heldout_qualification(tmp_path, mutation): + args = synthetic_fixture(tmp_path) + campaign = dict(science_seal_sha256=sha(args.seal)) + value = json.loads(args.exactness.read_text()) + mutation(value) + args.exactness.write_text(json.dumps(value)) + with pytest.raises(ValueError): + heldout_qualification(campaign,args.exactness,args.seal) + + +def test_planned_population_is_read_from_seal_not_reported_length(tmp_path): + args = synthetic_fixture(tmp_path) + seal = json.loads(args.seal.read_text()) + seal['counts']['injections'] = 8 + args.seal.write_text(json.dumps(seal)) + value = json.loads(args.exactness.read_text()) + value['identity']['seal_sha256'] = sha(args.seal) + args.exactness.write_text(json.dumps(value)) + with pytest.raises(ValueError,match='every planned regime/input'): + heldout_qualification(dict(science_seal_sha256=sha(args.seal)),args.exactness,args.seal) diff --git a/benchmarks/tls_survey/test_protocol.py b/benchmarks/tls_survey/test_protocol.py new file mode 100644 index 00000000..65caf160 --- /dev/null +++ b/benchmarks/tls_survey/test_protocol.py @@ -0,0 +1,144 @@ +"""Exercise the freeze/calibrate/analyze boundary without running detectors.""" +import argparse +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + + +HERE = Path(__file__).resolve().parent + + +def _module(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +common = _module('survey_protocol_common', HERE / 'common.py') +with patch.dict(sys.modules, {'common': common}): + analysis = _module('survey_protocol_analysis', HERE / 'analyze.py') + + +def _receipt(tmp_path, split, count, methods): + rows, planned = [], [] + for index in range(count): + name = '%s_%04d' % (split, index) + identity = hashlib.sha256(name.encode()).hexdigest() + planned.append(dict(name=name, sha256=identity)) + for method in methods: + injected = split in ('development', 'injections') + score = 1000. if injected else float(index) + candidate = dict(score=score, period=2., recovered=injected, + alias_recovered=injected) + rankers = ('native',) if method == 'tls' else ('raw', 'likelihood', 'detrended') + rows.append(dict(name=name, input_sha256=identity, method=method, + regime='tess_solar', valid=True, elapsed_s=1., + candidates={ranker: dict(candidate) for ranker in rankers}, + white_oracle_snr=common.SNRS[index % 4], observed_events=3, + in_transit_observations=20, grid_reachable=True)) + path = tmp_path / (split + '.json') + common.write(path, dict(status='complete', split=split, cases=rows, + planned_inputs=planned, planned_cases=count, methods=methods, + manifest_sha256='manifest-' + split, production_sources={'engine.py': 'frozen'}, + runner_sha256='runner', shard_count=1)) + return path + + +@pytest.fixture +def protocol(tmp_path, monkeypatch): + monkeypatch.setattr(analysis, 'source_identity', lambda: {'science.py': 'frozen'}) + methods = ['tls', *common.BLS_CONFIGS] + dev = _receipt(tmp_path, 'development', 8, methods) + devnull = _receipt(tmp_path, 'development_nulls', 64, methods) + snr = tmp_path / 'snr.json' + common.write(snr, dict(manifest_sha256='manifest-development', rows=[ + dict(regime='tess_solar', native_white_advantage=.01, native_ou_advantage=-.002) + for unused in range(8)])) + seal = tmp_path / 'seal.json' + freeze = argparse.Namespace(development=[dev], development_nulls=[devnull], + snr=snr, regimes='tess_solar', fpr=.05, calibration_count=128, + injection_count=8, null_count=64, exposure_nodes=64, + execution_shards=1, out=seal) + analysis.freeze(freeze) + selected = ['tls', 'bls_strong'] + calibration = _receipt(tmp_path, 'calibration', 128, selected) + thresholds = tmp_path / 'thresholds.json' + calibrate = argparse.Namespace(seal=seal, results=[calibration], out=thresholds) + injections = _receipt(tmp_path, 'injections', 8, selected) + nulls = _receipt(tmp_path, 'nulls', 64, selected) + analyze = argparse.Namespace(seal=seal, thresholds=thresholds, + injections=[injections], nulls=[nulls], out=tmp_path / 'recovery.json') + return dict(freeze=freeze, calibrate=calibrate, analyze=analyze) + + +def _change(path, function): + value = json.loads(path.read_text()) + function(value) + common.write(path, value) + + +def test_full_protocol_keeps_tight_tolerances_and_separate_operating_points(protocol): + sealed = json.loads(protocol['freeze'].out.read_text()) + assert sealed['bls_selected']['tess_solar'] == dict(method='bls_strong', ranker='likelihood') + tolerance = sealed['tolerances']['tess_solar'] + assert tolerance['expected_snr_fractional_loss'] == 0. + assert tolerance['recovery_absolute_probability_loss'] == 0. + assert tolerance['fpr_absolute_increase_max'] == 0. + analysis.calibrate(protocol['calibrate']) + analysis.analyze(protocol['analyze']) + result = json.loads(protocol['analyze'].out.read_text()) + assert len(result['methods']) == 4 + assert {row['target_fpr'] for row in result['methods']} == {.05, .01} + assert all(row['detected'] == 8 for row in result['methods']) + assert all(row['n_nulls'] == 64 for row in result['methods']) + assert all(row['tls_minus_bls_recovery']['difference'] == 0 for row in result['contrasts']) + # Exact agreement on eight cases must still retain finite-sample uncertainty. + assert all(row['tls_minus_bls_recovery']['interval'][0] < 0 for row in result['contrasts']) + + +def test_seal_and_thresholds_cannot_be_overwritten(protocol): + with pytest.raises(ValueError, match='overwrite a frozen seal'): + analysis.freeze(protocol['freeze']) + analysis.calibrate(protocol['calibrate']) + with pytest.raises(ValueError, match='overwrite independently frozen thresholds'): + analysis.calibrate(protocol['calibrate']) + + +@pytest.mark.parametrize('mutation,match', [ + (lambda r: r['production_sources'].update({'engine.py': 'changed'}), 'numerical sources'), + (lambda r: r['cases'][0].update(valid=False), 'Failed calibration nulls'), + (lambda r: r['cases'].pop(), 'expected 128 cases'), + (lambda r: r['cases'].append(copy.deepcopy(r['cases'][0])), 'Duplicate measured case'), + (lambda r: r['cases'][0].update(input_sha256='changed'), 'planned identity'), +]) +def test_calibration_rejects_incomplete_or_changed_evidence(protocol, mutation, match): + _change(protocol['calibrate'].results[0], mutation) + with pytest.raises(ValueError, match=match): + analysis.calibrate(protocol['calibrate']) + assert not protocol['calibrate'].out.exists() + + +def test_analysis_requires_thresholds_from_the_original_seal(protocol): + analysis.calibrate(protocol['calibrate']) + _change(protocol['analyze'].thresholds, lambda r: r.update(seal_sha256='another-seal')) + with pytest.raises(ValueError, match='another design'): + analysis.analyze(protocol['analyze']) + assert not protocol['analyze'].out.exists() + + +def test_failed_heldout_injections_remain_in_the_denominator(protocol): + analysis.calibrate(protocol['calibrate']) + _change(protocol['analyze'].injections[0], lambda r: r['cases'][0].update(valid=False)) + analysis.analyze(protocol['analyze']) + result = json.loads(protocol['analyze'].out.read_text()) + tls = [row for row in result['methods'] if row['method'] == 'tls'] + assert all(row['n_injections'] == 8 and row['detected'] == 7 for row in tls) + assert all(row['failed_injections'] == 1 for row in tls) diff --git a/benchmarks/tls_survey/test_report_followup.py b/benchmarks/tls_survey/test_report_followup.py new file mode 100644 index 00000000..548030ff --- /dev/null +++ b/benchmarks/tls_survey/test_report_followup.py @@ -0,0 +1,35 @@ +"""New-allocation figures cannot silently compare different resources or inputs.""" +import copy + +import pytest + +from benchmarks.tls_survey.report_followup import validate_comparison + + +@pytest.mark.parametrize('changed', [None, 'gpu', 'input', 'science', 'threads', 'population']) +def test_comparison_requires_identical_allocation_and_input_bytes(changed): + reference = dict(environment=dict(nvidia_smi='gpu-1', cpu_quota_cores=8, + host_memory_limit_bytes=1024, + cpu_math_thread_environment={key: '1' for key in ( + 'OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS')}), + science_seal_sha256='frozen-science', + cohort=[dict(name=f'a-{index}.npz', regime='tess_solar', nobs=6, + nperiods=10, input_sha256='original-bytes') for index in range(16)]) + candidate = copy.deepcopy(reference) + if changed == 'gpu': + candidate['environment']['nvidia_smi'] = 'gpu-2' + elif changed == 'input': + candidate['cohort'][0]['input_sha256'] = 'different-bytes' + elif changed == 'science': + candidate['science_seal_sha256'] = 'different-science' + elif changed == 'threads': + candidate['environment']['cpu_math_thread_environment']['NUMEXPR_NUM_THREADS'] = '4' + elif changed == 'population': + candidate['cohort'][0]['regime'] = 'ztf_solar' + data = {('baseline', 'tess_solar'): reference, ('bls', 'tess_solar'): candidate} + if changed: + with pytest.raises(ValueError): + validate_comparison(data, ('gpu-1', 8, 1024), 'frozen-science') + else: + validate_comparison(data, ('gpu-1', 8, 1024), 'frozen-science') diff --git a/benchmarks/tls_survey/test_report_recovery.py b/benchmarks/tls_survey/test_report_recovery.py new file mode 100644 index 00000000..272f4107 --- /dev/null +++ b/benchmarks/tls_survey/test_report_recovery.py @@ -0,0 +1,196 @@ +"""Format-only report guards, using explicitly artificial source JSON.""" +import copy +import csv +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from benchmarks.tls_survey.report_recovery import render, sha + + +def synthetic_fixture(folder): + """Small, visibly synthetic population; literal bounds are not estimates.""" + folder = Path(folder) + folder.mkdir(parents=True, exist_ok=True) + regimes = ['SYNTHETIC_dense', 'SYNTHETIC_sparse'] + targets = (.05, .01) + seal = dict(synthetic_fixture=True, regimes=regimes, target_fpr=targets[0], secondary_target_fpr=targets[1], + counts=dict(calibration=512, injections=4, nulls=4), execution_shards=1, + bls_selected={regime:dict(method='bls_strong', ranker='likelihood') for regime in regimes}, + production_sources={'SYNTHETIC_kernel.py':'fixture-only'}) + seal_path = folder/'SYNTHETIC-seal.json' + seal_path.write_text(json.dumps(seal)) + receipts = [dict(path='/SYNTHETIC/'+split+'.json', sha256='fixture-'+split, + split=split, manifest_sha256='fixture-manifest-'+split, + production_sources=seal['production_sources']) for split in ('injections','nulls')] + recovery = dict(synthetic_fixture=True, seal_sha256=sha(seal_path), thresholds_sha256='fixture-thresholds', + receipts=receipts, methods=[], contrasts=[], limitation='SYNTHETIC FIXTURE. No scientific conclusion.') + exactness = dict(synthetic_fixture=True, status='complete', identity=dict(seal_sha256=sha(seal_path), + plan_sha256='e'*64, + thresholds_sha256='fixture-thresholds', candidate_receipts=[{key:r[key] for key in ('path','sha256')} for r in receipts]), + cases=[], incomplete_repeat_diagnostics=0) + snr = dict(synthetic_fixture=True, status='complete', split='injections', seal_sha256=sha(seal_path), + manifest_sha256='fixture-manifest-injections', rows=[]) + def detected(label, split, index, target): + limit = (2 if label == 'tls' else 1) if split == 'injections' else (1 if label == 'tls' else 0) + return index < max(0, limit-(target == .01)) + for regime in regimes: + for split in ('injections','nulls'): + for index in range(4): + name = regime+'-'+split+'-'+str(index) + identity = hashlib.sha256(name.encode()).hexdigest() + valid = not (regime == regimes[0] and split == 'injections' and index == 3) + decisions = {key:dict(target_fpr=target, threshold=10. if target == .05 else 20., + above=detected('tls',split,index,target), detected=detected('tls',split,index,target)) + for key,target in zip(('thresholds','secondary_thresholds'), targets)} + exactness['cases'].append(dict(regime=regime, split=split, name=name, input_sha256=identity, + original_candidate=dict(regime=regime, method='tls', name=name, input_sha256=identity, + white_oracle_snr=(6.,8.,10.,12.)[index], observed_events=3, + in_transit_observations=20, grid_reachable=index != 3, + valid=valid, error=None if valid else 'SYNTHETIC execution failure'), + baseline=dict(valid=True, error=None), + comparison=dict(exact=valid, differences=[] if valid else ['unavailable_valid_execution'], + original_candidate_decisions=decisions, baseline_decisions=copy.deepcopy(decisions)), + repeat_status='not_required' if valid else 'complete')) + if split == 'injections': + snr['rows'].append(dict(name=name, regime=regime, input_sha256=identity, + native_family_white_snr=0. if index == 3 else 9.5, + ideal_box_white_snr=0. if index == 3 else 9., + native_family_ou_snr=0. if index == 3 else 8.5, + ideal_box_ou_snr=0. if index == 3 else 8., + native_white_advantage=None if index == 3 else 9.5/9.-1, + native_ou_advantage=None if index == 3 else 8.5/8.-1)) + for target in targets: + for label in ('tls','bls'): + d = [detected(label,'injections',index,target) for index in range(4)] + f = [detected(label,'nulls',index,target) for index in range(4)] + rank = 488 if target == .05 else 508 + cut = (10. if target == .05 else 20.) + (1 if label == 'bls' else 0) + calibration = dict(value=cut, n=512, target_fpr=target, rank_1based=rank, + decision='strict exceedance', calibration='SYNTHETIC stored order statistic', + calibration_scores_above=512-rank, calibration_scores_at_threshold=1, + calibration_zero_scores=0, calibration_strict_exceedance_fraction=(512-rank)/512, + extra_conservatism_from_ties=False, + attainable_marginal_fpr=(513-rank)/513, marginal_fpr_upper_bound=(513-rank)/513) + strata = [dict(kind='snr', level=level, n=1, detected=int(d[index]), interval95=[0.,1.]) + for index,level in enumerate((6.,8.,10.,12.))] + strata += [dict(kind='sampling', level='three_plus_events', n=4, detected=sum(d), interval95=[0.,1.]), + dict(kind='sampling', level='grid_unreachable', n=1, detected=0, interval95=[0.,1.])] + recovery['methods'].append(dict(regime=regime, target_fpr=target, method=label, + configuration='tls' if label == 'tls' else 'bls_strong', ranker='native' if label == 'tls' else 'likelihood', + threshold=cut, calibration=calibration, detected=sum(d), n_injections=4, recovery=sum(d)/4, + recovery_interval95=[0.,1.], false_positives=sum(f), n_nulls=4, fpr=sum(f)/4, fpr_interval95=[0.,1.], + failed_injections=int(regime == regimes[0] and label == 'tls'), failed_nulls=0, + aliases_including_fundamental=sum(d), strata=strata)) + contrast = dict(regime=regime, target_fpr=target) + for endpoint,split in (('recovery','injections'), ('fpr','nulls')): + wins = sum(detected('tls',split,index,target) and not detected('bls',split,index,target) for index in range(4)) + for suffix in ('','_simultaneous'): + contrast['tls_minus_bls_'+endpoint+suffix] = dict(n=4, first_only=wins, second_only=0, + difference=wins/4, interval=[-1.,1.], confidence=.95 if not suffix else 1-.05/8, + construction='SYNTHETIC literal bounds; no inference') + recovery['contrasts'].append(contrast) + exactness.update(completed_cases=len(exactness['cases']), mismatches=1, exactness_qualified=False) + args = SimpleNamespace(seal=seal_path, output=folder/'rendered', synthetic=True) + for key,value in (('recovery',recovery), ('exactness',exactness), ('snr',snr)): + path = folder/('SYNTHETIC-'+key+'.json') + path.write_text(json.dumps(value)) + setattr(args,key,path) + return args + + +def read_csv(path): + with path.open() as stream: + return list(csv.DictReader(stream)) + + +def test_complete_synthetic_render_keeps_counts_intervals_failures_and_identity(tmp_path): + args = synthetic_fixture(tmp_path) + render(args) + text = (args.output/'RECOVERY.md').read_text() + assert 'SYNTHETIC FIXTURE — NOT A SCIENTIFIC RESULT' in text + assert 'Aggregate exactness is withheld' in text + assert 'Sampling groups overlap' in text and 'unrepresented' in text + assert 'optimistic ceiling' in text and 'not confidence intervals' in text + assert len(read_csv(args.output/'recovery_fpr.csv')) == 8 + assert len(read_csv(args.output/'paired_contrasts.csv')) == 16 + assert len(read_csv(args.output/'subgroups.csv')) == 80 + assert len(read_csv(args.output/'exactness.csv')) == 4 + assert len(read_csv(args.output/'exactness_mismatches.csv')) == 1 + assert len(read_csv(args.output/'snr_descriptive.csv')) == 60 + values = read_csv(args.output/'recovery_fpr.csv') + assert all(row['recovery_interval95_lower'] == '0.0' and row['recovery_interval95_upper'] == '1.0' for row in values) + provenance = json.loads((args.output/'provenance.json').read_text()) + assert provenance['sources']['snr']['sha256'] == sha(args.snr) + assert provenance['validated']['exactness_cases'] == 16 + + +@pytest.mark.parametrize('file,mutation,match', [ + ('recovery', lambda value:value['methods'].pop(), 'planned regime/method/FPR'), + ('recovery', lambda value:value['methods'].append(copy.deepcopy(value['methods'][0])), 'Duplicate'), + ('recovery', lambda value:value['methods'][0].update(configuration='different'), 'frozen science'), + ('recovery', lambda value:value['contrasts'].pop(), 'planned paired'), + ('recovery', lambda value:value['methods'][0].update(n_injections=3), 'denominator'), + ('recovery', lambda value:value['methods'][0]['strata'].pop(0), 'planned SNR'), + ('recovery', lambda value:value['methods'][0]['strata'].pop(), 'subgroup input counts'), + ('exactness', lambda value:value['identity'].update(seal_sha256='different'), 'seal identities'), + ('exactness', lambda value:value.update(status='running'), 'incomplete'), + ('exactness', lambda value:value['cases'].pop(), 'planned baseline'), + ('exactness', lambda value:value.update(exactness_qualified=True), 'summary'), + ('exactness', lambda value:value['identity']['candidate_receipts'][0].update(sha256='different'), 'original scientific receipts'), + ('snr', lambda value:value.update(manifest_sha256='different'), 'manifest identities'), + ('snr', lambda value:value['rows'].pop(), 'SNR case membership'), + ('snr', lambda value:value['rows'][0].update(input_sha256='different'), 'case input identity'), +]) +def test_rejects_foreign_incomplete_or_inconsistent_reports(tmp_path, file, mutation, match): + args = synthetic_fixture(tmp_path) + path = getattr(args,file) + value = json.loads(path.read_text()) + mutation(value) + path.write_text(json.dumps(value)) + with pytest.raises(ValueError, match=match): + render(args) + assert not args.output.exists() + + +def test_fixture_cannot_silently_render_as_science_and_snr_is_optional(tmp_path): + args = synthetic_fixture(tmp_path) + args.synthetic = False + with pytest.raises(ValueError, match='Synthetic fixture'): + render(args) + args.synthetic = True + args.snr = None + render(args) + assert 'No held-out expected-SNR artifact' in (args.output/'RECOVERY.md').read_text() + assert not (args.output/'snr_cases.csv').exists() + + +def test_all_exact_case_still_writes_empty_failure_csv_schema(tmp_path): + args = synthetic_fixture(tmp_path) + exactness = json.loads(args.exactness.read_text()) + for row in exactness['cases']: + row['original_candidate'].update(valid=True, error=None) + row['comparison'].update(exact=True, differences=[]) + exactness.update(mismatches=0, exactness_qualified=True) + args.exactness.write_text(json.dumps(exactness)) + recovery = json.loads(args.recovery.read_text()) + for row in recovery['methods']: + row['failed_injections'] = 0 + args.recovery.write_text(json.dumps(recovery)) + render(args) + assert read_csv(args.output/'exactness_mismatches.csv') == [] + assert (args.output/'exactness_mismatches.csv').read_text().startswith('regime,split,name,input_sha256') + assert 'Every planned original held-out comparison met' in (args.output/'RECOVERY.md').read_text() + + +def test_output_cannot_mix_previous_optional_snr_or_source_inputs(tmp_path): + args = synthetic_fixture(tmp_path) + render(args) + previous = sha(args.output/'RECOVERY.md') + args.snr = None + with pytest.raises(ValueError, match='different source inputs'): + render(args) + assert sha(args.output/'RECOVERY.md') == previous diff --git a/benchmarks/tls_survey/test_science.py b/benchmarks/tls_survey/test_science.py new file mode 100644 index 00000000..cc72d343 --- /dev/null +++ b/benchmarks/tls_survey/test_science.py @@ -0,0 +1,280 @@ +"""Independent CPU checks of the survey's statistical and filter diagnostics. + +Small exhaustive searches and dense covariance calculations are deliberately +independent of the optimized production diagnostic algorithms. +""" +import importlib.util +import math +from pathlib import Path +import sys +from unittest.mock import patch + +import numpy as np +import pytest +from scipy.stats import binom, multinomial + + +HERE = Path(__file__).resolve().parent + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + sys.modules[name] = result + spec.loader.exec_module(result) + return result + + +common = _load('survey_science_common', HERE / 'common.py') +with patch.dict(sys.modules, {'common': common}): + analysis = _load('survey_science_analysis', HERE / 'analyze.py') + development = _load('survey_science_development', HERE / 'development.py') + + +def _projected_filter(signal, template, errors): + """Whiten, project out the constant using least squares, then correlate.""" + constant = 1 / np.asarray(errors) + whitened = np.asarray(template) / errors + coefficient = np.linalg.lstsq(constant[:, None], whitened, rcond=None)[0] + direction = whitened - constant * coefficient[0] + norm = np.linalg.norm(direction) + if norm < 1e-12 * max(1., np.linalg.norm(whitened)): + return 0. + return max(0., float(np.dot(signal / errors, direction) / norm)) + + +@pytest.mark.parametrize('n', [1, 19, 64, 256]) +def test_binomial_intervals_include_exact_zero_and_all_success_limits(n): + tail = .05 / 2 + np.testing.assert_allclose(analysis.binomial_interval(0, n), + [0., 1 - tail ** (1 / n)], rtol=2e-14) + np.testing.assert_allclose(analysis.binomial_interval(n, n), + [tail ** (1 / n), 1.], rtol=2e-14) + assert analysis.binomial_interval(0, 0) == [0., 1.] + + +@pytest.mark.parametrize('k,n', [(1, 19), (3, 10), (32, 64), (120, 128)]) +def test_clopper_pearson_endpoints_invert_binomial_tail_probabilities(k, n): + lower, upper = analysis.binomial_interval(k, n) + assert binom.sf(k - 1, n, lower) == pytest.approx(.025, abs=2e-14) + assert binom.cdf(k, n, upper) == pytest.approx(.025, abs=2e-14) + + +def test_binomial_intervals_have_nominal_or_greater_finite_sample_coverage(): + n = 20 + intervals = np.array([analysis.binomial_interval(k, n) for k in range(n + 1)]) + for probability in np.linspace(0., 1., 101): + included = (intervals[:, 0] <= probability) & (probability <= intervals[:, 1]) + coverage = np.sum(binom.pmf(np.arange(n + 1), n, probability)[included]) + assert coverage >= .95 - 1e-13 + + +def test_paired_intervals_use_discordance_and_keep_finite_sample_uncertainty(): + first = np.r_[np.ones(12, bool), np.zeros(8, bool)] + second = np.r_[np.ones(9, bool), np.zeros(11, bool)] + forward = analysis.paired_interval(first, second) + reverse = analysis.paired_interval(second, first) + assert (forward['first_only'], forward['second_only']) == (3, 0) + assert forward['difference'] == .15 + np.testing.assert_allclose(reverse['interval'], -np.array(forward['interval'])[::-1]) + same = analysis.paired_interval(first, first) + assert same['difference'] == 0 + assert same['interval'][0] < 0 < same['interval'][1] + # Equal marginal recoveries do not imply paired equivalence: disjoint + # discoveries have a wider interval than exact paired agreement. + disjoint = analysis.paired_interval(first, first[::-1]) + assert np.ptp(disjoint['interval']) > np.ptp(same['interval']) + + +@pytest.mark.parametrize('probabilities', [(0.1, 0.2, 0.3, 0.4), + (0.7, 0.01, 0.04, 0.25)]) +def test_paired_interval_coverage_by_exhaustive_multinomial_outcomes(probabilities): + # Categories: both, first only, second only, neither. Enumerate every + # possible table rather than treating the two recovery rates as unpaired. + n = 6 + truth = probabilities[1] - probabilities[2] + covered = 0. + for both in range(n + 1): + for first_only in range(n - both + 1): + for second_only in range(n - both - first_only + 1): + neither = n - both - first_only - second_only + first = [True] * (both + first_only) + [False] * (second_only + neither) + second = ([True] * both + [False] * first_only + + [True] * second_only + [False] * neither) + bounds = analysis.paired_interval(first, second)['interval'] + if bounds[0] <= truth <= bounds[1]: + covered += multinomial.pmf( + [both, first_only, second_only, neither], n, probabilities) + assert covered >= .95 - 1e-13 + + +@pytest.mark.parametrize('first,second', [([], []), ([True], []), ([[True]], [[True]])]) +def test_paired_intervals_reject_empty_or_misaligned_populations(first, second): + with pytest.raises(ValueError, match='Paired nonempty aligned'): + analysis.paired_interval(first, second) + + +@pytest.mark.parametrize('alpha,n', [(.05, 19), (.05, 256), (.01, 99), (.01, 256)]) +def test_conformal_threshold_has_exchangeable_rank_control_and_strict_ties(alpha, n): + values = np.arange(n + 1, dtype=float) + # Hold every possible observation out once. Every ordering is represented + # because the statistic depends only on ranks, so this is exact coverage. + for population in (values, np.floor(values / 3)): + exceedances = 0 + for index in range(n + 1): + cutoff = analysis.threshold(np.delete(population, index), alpha) + exceedances += population[index] > cutoff['value'] + assert exceedances / (n + 1) <= alpha + result = analysis.threshold(np.ones(n), alpha) + assert result['value'] == 1. + assert result['decision'] == 'strict exceedance' + assert result['attainable_marginal_fpr'] <= alpha + + +@pytest.mark.parametrize('values,alpha', [([], .05), ([np.nan] * 20, .05), + ([np.inf] * 20, .05), (range(18), .05), + (range(98), .01)]) +def test_calibration_cannot_drop_invalid_nulls_or_invent_finer_resolution(values, alpha): + with pytest.raises(ValueError): + analysis.threshold(values, alpha) + + +def test_detection_threshold_ties_and_failures_remain_nondetections(): + def row(value, recovered=True, valid=True): + return dict(valid=valid, candidates={'native': dict(score=value, recovered=recovered)}) + rows = [row(8.), row(np.nextafter(8., np.inf)), row(10., recovered=False), + row(20., valid=False), row(None, valid=False)] + np.testing.assert_array_equal(analysis.detections(rows, 'native', 8.), + [False, True, False, False, False]) + np.testing.assert_array_equal(analysis.detections(rows, 'native', 8., null=True), + [False, True, True, False, False]) + + +@pytest.mark.parametrize('tau', [.0001, .3, 200.]) +@pytest.mark.parametrize('amplitude', [0., .05, 2.]) +def test_ou_recursion_matches_dense_covariance_with_gaps_ties_and_heterogeneous_errors(tau, amplitude): + rng = np.random.default_rng(1691) + times = np.array([4., 0., .02, 4., 500., .4, 1., .02, 3., 70., .03]) + errors = rng.uniform(.1, 1., len(times)) + signal = np.array([.2, 0., .8, .1, 0., .3, 1., .4, .8, 0., .2]) + template = signal + rng.uniform(0, .5, len(times)) + weights = errors ** -2 + coefficients = weights * (template - np.average(template, weights=weights)) + covariance = np.diag(errors ** 2) + amplitude ** 2 * np.exp( + -np.abs(times[:, None] - times[None, :]) / tau) + expected = max(0., np.dot(coefficients, signal) / + np.sqrt(coefficients @ covariance @ coefficients)) + actual = development.ou_filter_snr(times, signal, template, errors, amplitude, tau) + assert actual == pytest.approx(expected, rel=2e-13) + if amplitude == 0: + assert actual == pytest.approx(_projected_filter(signal, template, errors), rel=2e-13) + + +@pytest.mark.parametrize('seed', [11, 42, 918]) +def test_centered_optimal_box_matches_every_admissible_interval(seed): + rng = np.random.default_rng(seed) + count = 21 + phase = np.linspace(-.5, .5, count) + signal = np.zeros(count) + signal[8:13] = rng.uniform(.05, 1., 5) + signal[10] = 0. # Sampling/exposure differences need not give a smooth row. + errors = rng.uniform(.8, 1.2, count) + permutation = rng.permutation(count) + phase, signal, errors = (array[permutation] for array in (phase, signal, errors)) + order = np.argsort(phase) + best = 0. + weights = errors ** -2 + for start in range(count): + for end in range(start + 1, count + 1): + template = np.zeros(count) + template[order[start:end]] = 1. + if np.dot(weights, template) < .5 * np.sum(weights): + best = max(best, _projected_filter(signal, template, errors)) + score, template = development.optimal_box(phase, signal, errors) + assert score == pytest.approx(best, rel=2e-13) + assert score == pytest.approx(_projected_filter(signal, template, errors), rel=2e-13) + + +def test_box_certificate_rejects_weight_dominated_support_and_handles_no_signal(): + phase = np.arange(9.) + score, template = development.optimal_box(phase, np.zeros(9), np.ones(9)) + assert score == 0. + assert not template.any() + with pytest.raises(ValueError, match='support exceeds half the weight'): + development.optimal_box(phase, np.r_[1., np.zeros(8)], np.r_[.01, np.ones(8)]) + + +@pytest.mark.parametrize('count', [15, 16, 23]) +def test_native_fft_family_matches_direct_cyclic_template_enumeration(count): + rng = np.random.default_rng(910 + count) + times = rng.uniform(.1, 20., count) + period = 2.37 + errors = rng.uniform(.4, 1.7, count) + order = np.argsort((times % period) / period) + widths = [1, 2, 4, 7, count, count + 2] + deficits = np.zeros((len(widths), count + 2), dtype=np.float32) + for row, width in enumerate(widths): + deficits[row, :width] = rng.uniform(.1, 1., width) + deficits[2, :4] = [.2, .7, .6, 1.] # Include literal native padding deficit. + deficits[-2, :count] = 1. # Pure constant has zero identifiable signal. + signal = np.zeros(count) + signal[order[(count - 2 + np.arange(4)) % count]] = [.2, .7, .6, 1.] + best = 0. + for row, width in enumerate(widths): + if width > count: + continue + for start in range(count): + template = np.zeros(count) + template[order[(start + np.arange(width)) % count]] = deficits[row, :width] + best = max(best, _projected_filter(signal, template, errors)) + actual, template, winner = development.optimal_native_family( + times, period, signal, errors, dict(widths=widths, template_deficits=deficits)) + assert winner is not None + assert actual == pytest.approx(best, rel=5e-13) + assert actual == pytest.approx(_projected_filter(signal, template, errors), rel=5e-13) + + +def test_recovery_boundary_is_closed_and_harmonics_are_separate(): + metadata = dict(truth_period=2., baseline_days=16., duration_days=.5) + boundary = 2.03125 # Exactly representable half-duration drift. + assert common.recovered(boundary, metadata) + assert not common.recovered(np.nextafter(boundary, np.inf), metadata) + assert not common.recovered(1., metadata) + assert common.recovered(1., metadata, aliases=True) + assert not common.recovered(None, metadata) + assert not common.recovered(np.nan, metadata) + + +def test_fixed_grid_policy_protects_declared_thin_regimes_without_truth_adaptation(): + expected = {'tess_highimpact': 9, 'ztf_highimpact': 9, + 'tess_eccentric': 9, 'hatpi_short': 9, 'tess_grazing_smeared': 24} + for name, settings in common.REGIMES.items(): + assert settings.get('grid_oversampling', 3) == expected.get(name, 3) + assert [common.BLS_CONFIGS[name]['qmin_factor'] for name in + ('bls_medium', 'bls_fine', 'bls_finest')] == [1., .5, .25] + + +def test_fixed_grids_resolve_maximum_impact_and_eccentricity_design_boundaries(): + pytest.importorskip('batman') + from cuvarbase import tls_reference_math as reference + physics = _load('survey_science_physics', common.ROOT / 'benchmarks/tls_accuracy/diagnose.py') + baseline = 90. + for name, settings in common.REGIMES.items(): + radius, mass = settings.get('radius', 1.), settings.get('mass', 1.) + lower, upper = settings.get('period', (2., 6.)) + grid = np.sort(reference.period_grid( + baseline, R_star=radius, M_star=mass, period_min=.5 * lower, + period_max=1.2 * upper, oversampling_factor=settings.get('grid_oversampling', 3))) + for target in (lower, math.sqrt(lower * upper), upper): + index = np.searchsorted(grid, target) + before, after = grid[index - 1:index + 1] + midpoint = (before + after) / 2 + model = physics.Regime(name, midpoint, radius=radius, mass=mass, + rp=.00916 / radius, impact=max(settings['impact']), + eccentricity=max(settings.get('eccentricity', (0., 0.)))) + duration, _, _ = physics.durations(model) + # Every period in this grid interval is at most half a grid step + # from a trial; this samples the declared physical boundary, not + # the favorable realized injection coordinates. + worst_nearest_drift = (after - before) / (2 * midpoint) * baseline + assert worst_nearest_drift < .25 * duration, name diff --git a/benchmarks/tls_survey/test_throughput.py b/benchmarks/tls_survey/test_throughput.py new file mode 100644 index 00000000..dccf6ef0 --- /dev/null +++ b/benchmarks/tls_survey/test_throughput.py @@ -0,0 +1,321 @@ +"""CPU checks of queue membership and exact numerical qualification gates.""" +from collections import Counter +import hashlib +import json +from types import SimpleNamespace + +import numpy as np +import pytest + +from benchmarks.tls_survey.throughput import (Pool, batches, prepare_grids, qualify_rows, + configure_bls, public_call, scalar_fingerprint, complete_fingerprint, Telemetry, + archive_bls_spectra, bls_repeat_diagnostics) +from benchmarks.tls_survey.throughput_campaign import (prepare_varied_manifest, winner, select_names, + validate_tuning_identity) + + +def row(worker=0, name='a', digest='same'): + return dict(worker=worker, error=None, + outputs=[dict(case=name, strict=dict(power=digest))], + scalars=[dict(case=name, fields=dict(period='one', SDE='two'))]) + + +def test_complete_spectrum_gate_rejects_changed_nonwinning_power(): + assert qualify_rows([row()], {'a': {'power': 'same'}}, ['a'], 1)['passed'] + assert not qualify_rows([row(digest='changed')], {'a': {'power': 'same'}}, ['a'], 1)['passed'] + + +def test_membership_gate_requires_every_case_once_on_every_worker(): + valid = [row(0), row(1)] + assert qualify_rows(valid, expected_names=['a'], workers=2)['passed'] + assert not qualify_rows(valid[:1], expected_names=['a'], workers=2)['passed'] + assert not qualify_rows(valid + [row(1)], expected_names=['a'], workers=2)['passed'] + assert not qualify_rows(valid, expected_names=['a', 'b'], workers=2)['passed'] + + +def test_batches_never_mix_grid_options_groups_or_lose_inputs(): + cases = [dict(group=value) for value in ('x', 'y', 'x', 'x', 'y')] + jobs = batches(cases, 2) + assert Counter(index for job in jobs for index in job) == Counter(range(5)) + assert all(len(job) <= 2 and len({cases[i]['group'] for i in job}) == 1 for job in jobs) + assert jobs == [[0, 2], [3], [1, 4]] + + +def test_bounded_queue_completes_whole_cycles_and_preserves_population(monkeypatch): + import benchmarks.tls_survey.throughput as module + cases = [dict(name=name, metadata=dict(regime=regime)) + for name, regime in (('a', 'tess'), ('b', 'ztf'), ('c', 'tess'))] + scalars = {case['name']: dict(period='one', SDE='two') for case in cases} + class Connection: + def __init__(self): + self.command = None + self.maximum_pending = 0 + def send(self, command): + assert self.command is None + self.command = command + self.maximum_pending = 1 + pool = object.__new__(Pool) + pool.connections = [Connection(), Connection()] + pool.ownership = SimpleNamespace(allowed_pids=[10, 20]) + pool.timeout = 1 + def receive(connection): + command, connection.command = connection.command, None + return dict(task=command['task'], error=None, + scalars=[dict(case=cases[i]['name'], fields=scalars[cases[i]['name']]) + for i in command['indices']]) + pool.receive = receive + monkeypatch.setattr(module, 'wait', lambda values, timeout: values[:1]) + monkeypatch.setattr(module, 'exclusive_gpu_processes', lambda values: dict(exclusive=True)) + measured = pool.run_queue([[0, 2], [1]], cases, 7, 0, scalars) + assert measured['source_count'] == 9 + assert measured['completed_input_cycles'] == 3 + assert measured['regime_counts'] == dict(tess=6, ztf=3) + assert all(connection.command is None for connection in pool.connections) + + +def test_fastest_failure_cannot_win_and_ties_prefer_smaller_pools(): + def result(speed, workers=1, batch=1, valid=True): + return dict(status='ok', workers=workers, batch_size=batch, + gpu_ownership=dict(passed=True), + qualification=[dict(gate=dict(passed=valid))]*2, + repetitions=[dict(status='ok')], + summary=dict(median_repetition_lightcurves_per_second=speed)) + selected = winner([result(100, valid=False), result(2, workers=4), + result(2, workers=2, batch=4), result(2, workers=2, batch=1)]) + assert selected['workers'] == 2 and selected['batch_size'] == 1 + + +def test_measurement_cannot_change_frozen_timing_runner_or_protocol(): + tuning = dict(status='complete', driver_sha256='driver', runner_sha256='runner', + protocol_sha256='protocol', harness_dependency_sha256={'common.py':'dependency'}) + validate_tuning_identity(tuning,dict(tuning)) + for key in ('driver_sha256','runner_sha256','protocol_sha256','harness_dependency_sha256'): + changed = dict(tuning) + changed[key] = 'modified' + with pytest.raises(ValueError,match='Timing definitions changed'): + validate_tuning_identity(tuning,changed) + with pytest.raises(ValueError,match='completed tuning'): + validate_tuning_identity(dict(tuning,status='running'),tuning) + + +def test_common_preparation_reproduces_sealed_grid_or_fails(): + from cuvarbase.tls_reference_math import period_grid + options = dict(R_star=1., M_star=1., period_min=.6, period_max=12., + oversampling_factor=3, n_transits_min=2) + periods = np.sort(period_grid(27., **options)) + def case(): + return dict(group='shared', data=dict(periods=periods.copy()), + metadata=dict(baseline_days=27., grid_kwargs=options)) + cohort = [case(), case()] + records = prepare_grids(cohort) + assert len(records) == 1 and records[0]['status'] == 'exact_regeneration' + assert cohort[0]['data']['periods'] is cohort[1]['data']['periods'] + changed = case() + changed['data']['periods'][0] = np.nextafter(periods[0], np.inf) + with pytest.raises(ValueError, match='differs from sealed input'): + prepare_grids([changed]) + + +def test_varied_nulls_preserve_grid_endpoints_and_aligned_samples(tmp_path): + source = tmp_path/'source' + source.mkdir() + manifest = dict(cases=[]) + for regime in ('tess_solar', 'tess_gap_long', 'ztf_solar'): + for index in range(2): + name = f'{regime}_nulls_{index:04d}.npz' + metadata = dict(regime=regime, null=True, search_kwargs=dict(R_star=1., M_star=1.), + grid_kwargs={}, baseline_days=19.) + path = source/name + np.savez_compressed(path, t=np.arange(1,21.), y=np.arange(20.)+100, + dy=np.arange(20.)+1, periods=np.array([2.,3.]), + metadata=json.dumps(metadata)) + manifest['cases'].append(dict(file=name, metadata=metadata, + sha256=hashlib.sha256(path.read_bytes()).hexdigest())) + (source/'manifest.json').write_text(json.dumps(manifest)) + result = prepare_varied_manifest(source/'manifest.json', tmp_path/'derived', count=2) + entries = json.loads(result.read_text())['cases'] + assert len(entries) == 6 + assert sorted({entry['metadata']['ndata'] for entry in entries}) == [16,20] + for entry in entries: + with np.load(result.parent/entry['file']) as data: + assert data['t'][0] == 1 and data['t'][-1] == 20 + np.testing.assert_array_equal(data['y'], data['t']+99) + np.testing.assert_array_equal(data['dy'], data['t']) + np.testing.assert_array_equal(data['periods'], [2.,3.]) + assert entry['metadata']['do_not_use_for_recovery_or_false_alarm'] + # Resume verifies bytes, preserving the original derivation rather than + # regenerating a silently different population. + assert prepare_varied_manifest(source/'manifest.json', tmp_path/'derived', count=2) == result + + +def test_science_bls_selection_and_exact_selected_candidate_gate(tmp_path, monkeypatch): + import benchmarks.tls_survey.throughput as module + from benchmarks.tls_survey import common + monkeypatch.setattr(common, 'source_identity', lambda: {'science.py': 'frozen'}) + selection = dict(method='bls_finest', ranker='likelihood') + seal = dict(source_identity={'science.py': 'frozen'}, bls_selected={'tess_solar': selection}) + seal_path = tmp_path/'seal.json' + seal_path.write_text(json.dumps(seal)) + cases = [dict(name='source', metadata=dict(regime='tess_solar'), group='original', + data={'periods':np.array([2.,3.])})] + configure_bls(cases, seal_path) + calls = [] + def compact(case, science, *, arrays=False): + calls.append((case['bls_selection']['method'], arrays)) + return dict(period=2., score=9., + candidates={'raw': {'period':2.,'score':.2}, 'likelihood':{'period':2.,'score':9.}}, + spectra={'power':'complete-spectrum','valid_mask':'mask','periods':'periods'}) + monkeypatch.setattr(module, 'science_bls_module', lambda: SimpleNamespace()) + monkeypatch.setattr(module, 'compact_bls', compact) + result = public_call('bls', cases, arrays=True)[0] + assert calls == [('bls_finest', True)] and result['score'] == 9. + assert 'score' in scalar_fingerprint('bls', result) and 'SDE' not in scalar_fingerprint('bls', result) + before = complete_fingerprint('bls', cases[0], result) + result['spectra']['power'] = 'changed-nonwinning-bin' + after = complete_fingerprint('bls', cases[0], result) + assert before['strict'] == after['strict'] + assert before['full_digest'] != after['full_digest'] + result['score'] = np.nextafter(result['score'], np.inf) + assert before['strict'] != complete_fingerprint('bls', cases[0], result)['strict'] + seal['source_identity']['science.py'] = 'edited' + seal_path.write_text(json.dumps(seal)) + with pytest.raises(ValueError, match='science sources'): + configure_bls(cases, seal_path) + + +def test_final_science_regime_names_select_all_timing_groups(tmp_path): + from benchmarks.tls_survey.common import REGIMES as scientific_regimes + from benchmarks.tls_survey.throughput_campaign import REGIMES as timing_regimes + assert set(timing_regimes) <= set(scientific_regimes) + manifest = dict(cases=[dict(file=r+'_development_0000.npz', metadata=dict(regime=r, null=False)) + for r in scientific_regimes]) + path = tmp_path/'manifest.json' + path.write_text(json.dumps(manifest)) + assert len(select_names(path, 1)) == 3 + assert any(name.startswith('tess_gap_long_') for name in select_names(path, 1)) + + +def test_compact_bls_all_selected_rankers_equal_full_science_path(monkeypatch): + import sys + from benchmarks.tls_survey.throughput import compact_bls, science_bls_module + rng = np.random.default_rng(183) + periods = np.linspace(.6, 12., 103) + power = rng.uniform(.01, .1, len(periods)) + power[17] = np.nan + calls = [] + def fake_gpu(t, y, dy, frequencies, **kwargs): + calls.append((frequencies.copy(), kwargs)) + return power.copy() + monkeypatch.setitem(sys.modules, 'cuvarbase.bls', SimpleNamespace(eebls_gpu_fast=fake_gpu)) + science = science_bls_module() + data = dict(t=np.arange(1., 51.), y=1+rng.normal(0,.01,50), + dy=rng.uniform(.01,.04,50), periods=periods) + candidates, _ = science.search(data, {}, 'bls_finest') + for ranker in ('raw', 'likelihood', 'detrended'): + case = dict(data=data, bls_selection=dict(method='bls_finest', ranker=ranker)) + compact = compact_bls(case, science) + assert compact == {key:candidates[ranker][key] for key in ('period','score')} + full = compact_bls(case, science, arrays=True) + assert full['candidates'] == candidates + assert compact == {key:full[key] for key in ('period','score')} + np.testing.assert_array_equal(full['_arrays']['power'], power) + np.testing.assert_array_equal(full['_arrays']['periods'], periods) + np.testing.assert_array_equal(calls[-1][0], calls[0][0]) + for key in ('qmin','qmax'): + np.testing.assert_array_equal(calls[-1][1][key], calls[0][1][key]) + assert calls[-1][1]['noverlap'] == calls[0][1]['noverlap'] + + +def test_bls_spectra_retained_and_variation_reported_without_changing_tls_gate(tmp_path): + period = np.array([2., 3.]) + power = np.array([.5, .25], dtype=np.float32) + case = dict(name='a', data=dict(periods=period)) + def make(value, filename, score=.5): + result = dict(period=2., score=score, + candidates={'raw': dict(period=2., score=score)}, + spectra={'periods':'periods', 'valid_mask':'mask', + 'power':hashlib.sha256(value.tobytes()).hexdigest()}, + _arrays=dict(periods=period, power=value)) + archive_bls_spectra(result, tmp_path, filename) + fingerprint = complete_fingerprint('bls', case, result) + return dict(worker=0, error=None, outputs=[fingerprint], + scalars=[dict(case='a', fields=scalar_fingerprint('bls', result))]) + before = make(power, 'before.npz') + changed = power.copy() + changed[1] = np.nextafter(changed[1], np.float32(np.inf)) + after = make(changed, 'after.npz') + expected = qualify_rows([before])['strict'] + assert qualify_rows([after], expected, ['a'], 1)['passed'] + diagnostic = bls_repeat_diagnostics([after], [before]) + assert diagnostic['changed_power_comparisons'] == 1 + assert diagnostic['changed_selected_endpoints'] == 0 + assert diagnostic['max_absolute_power_difference'] == float(changed[1]-power[1]) + assert diagnostic['comparisons'][0]['changed_finite_power_values'] == 1 + mismatch = make(changed, 'selected-changed.npz', score=np.nextafter(.5, np.inf)) + assert not qualify_rows([mismatch], expected, ['a'], 1)['passed'] + assert bls_repeat_diagnostics([mismatch], [before])['changed_selected_endpoints'] == 1 + # TLS and native-GTLS retain their existing complete-spectrum equality. + assert not qualify_rows([row(digest='different')], {'a': {'power':'same'}}, ['a'], 1)['passed'] + (tmp_path/'after.npz').write_bytes(b'corrupted archive') + with pytest.raises(ValueError, match='spectrum changed'): + bls_repeat_diagnostics([after], [before]) + + +def test_selected_endpoint_queue_failure_retains_actual_values(monkeypatch): + import benchmarks.tls_survey.throughput as module + connection = SimpleNamespace(send=lambda command: None) + pool = object.__new__(Pool) + pool.connections = [connection] + pool.ownership = SimpleNamespace(allowed_pids=[10]) + pool.timeout = 1 + # Dictionary keys need a hashable connection, as multiprocessing pipes are. + class Connection: + def send(self, command): + pass + pool.connections = [Connection()] + pool.receive = lambda unused: dict(task=0, error=None, + scalars=[dict(case='a', fields={'period':'same', 'score':'changed'}, + values=dict(period=2., score=.50000001))]) + monkeypatch.setattr(module, 'wait', lambda values, timeout: values) + monkeypatch.setattr(module, 'exclusive_gpu_processes', lambda values: dict(exclusive=True)) + with pytest.raises(RuntimeError) as captured: + pool.run_queue([[0]], [dict(name='a', metadata=dict(regime='tess'))], 1, 0, + {'a':{'period':'same', 'score':'previous'}}) + failure = captured.value.failed_queue + assert failure['status'] == 'error' + assert failure['tasks'][0]['scalars'][0]['values']['score'] == .50000001 + assert not failure['tasks'][0]['scalar_match'] + + +def test_figure_retains_missing_competitors_and_suppresses_failed_paired_ratio(tmp_path): + from benchmarks.tls_survey.plot_throughput import read_campaign, SCOPES, BACKENDS + configurations = [] + for backend in ('baseline', 'candidate'): + record = dict(status='ok', gpu_ownership=dict(passed=True), + qualification=[dict(gate=dict(passed=True))]*2, + repetitions=[dict(status='ok', lightcurves_per_second=2.)]*3, + summary={}, environment=dict(nvidia_smi='GPU, UUID, 1', cpu_quota_cores=7.65, + host_memory_limit_bytes=50_000_000_000)) + filename = backend+'.json' + (tmp_path/filename).write_text(json.dumps(record)) + configurations.append(dict(backend=backend, scope='tess_solar', eligible=True, result=filename, + result_sha256=hashlib.sha256((tmp_path/filename).read_bytes()).hexdigest())) + campaign = dict(stage='measure', status='complete', configs=configurations, + baseline_candidate_spectra=dict(checks=[dict(scope='tess_solar', exact=False)])) + path = tmp_path/'campaign.json' + path.write_text(json.dumps(campaign)) + _, data, missing, paired, _ = read_campaign(path) + assert ('baseline','tess_solar') in data and ('candidate','tess_solar') not in data + assert not paired['tess_solar'] + assert len(data)+len(missing) == len(SCOPES)*len(BACKENDS) + assert 'Paired' in missing[('candidate','tess_solar')] + + +def test_telemetry_rejects_foreign_gpu_context_even_if_it_exits_before_teardown(tmp_path): + telemetry = Telemetry(tmp_path/'telemetry.jsonl', [1]) + telemetry.rows = [dict(gpu_processes=[10], gpu_used_bytes=5), + dict(gpu_processes=[10,99], gpu_used_bytes=15), + dict(gpu_processes=[], gpu_used_bytes=0)] + result = telemetry.summarize([10]) + assert not result['ownership_passed'] and result['foreign_gpu_pids'] == [99] + assert result['gpu_used_bytes'] == 15 diff --git a/benchmarks/tls_survey/throughput.py b/benchmarks/tls_survey/throughput.py new file mode 100644 index 00000000..51be9ba1 --- /dev/null +++ b/benchmarks/tls_survey/throughput.py @@ -0,0 +1,858 @@ +#!/usr/bin/env python3 +"""Bounded, sustained single-GPU queues with independently tuned process pools. + +Run one configuration at a time; compare configurations only after the numerical +and ownership gates pass. No cloud actions occur here. All imports of CUDA or a +scientific backend happen in spawned workers, so a baseline checkout can be +measured alongside the edited tree without monkey-patching either implementation. +""" +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +import cProfile +import hashlib +import io +import json +import multiprocessing as mp +from multiprocessing.connection import wait +import os +from pathlib import Path +import pstats +import resource +import shutil +import sys +import threading +import time +import traceback + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from benchmarks.tls_reference.timing.common import (array_hash, environment, + fingerprint, initialize_backend, sha, write) +from benchmarks.tls_reference.timing.benchmark import (GPUOwnership, + exclusive_gpu_processes, process_ids, retain_cuda_context) + + +def science_bls_module(): + """Load the sealed science runner without ambiguous top-level common imports.""" + name = '_tls_survey_throughput_science' + if name in sys.modules: + return sys.modules[name] + from benchmarks.tls_survey import common as science_common + previous = sys.modules.get('common') + sys.modules['common'] = science_common + try: + return science_common.module(Path(__file__).with_name('run.py'), name) + finally: + if previous is None: + sys.modules.pop('common', None) + else: + sys.modules['common'] = previous + + +def configure_bls(cases, seal_path): + """Use only the per-regime method/ranker frozen by scientific development.""" + from benchmarks.tls_survey.common import BLS_CONFIGS, source_identity, method_applicable + seal = json.loads(Path(seal_path).read_text()) + if seal['source_identity'] != source_identity(): + raise ValueError('BLS science sources differ from the frozen seal') + for case in cases: + selection = seal['bls_selected'][case['metadata']['regime']] + if selection['method'] not in BLS_CONFIGS or not method_applicable( + selection['method'], case['metadata']['regime']): + raise ValueError('Selected BLS configuration is not applicable') + if selection['ranker'] not in ('raw', 'likelihood', 'detrended'): + raise ValueError('Unknown frozen BLS ranker') + case['bls_selection'] = selection + case['group'] = hashlib.sha256((case['group'] + + json.dumps(selection, sort_keys=True)).encode()).hexdigest() + return seal + + +def load_manifest(path, names=(), regimes=()): + """Accept reference and survey manifests; every array file is byte checked.""" + path = Path(path).resolve() + manifest = json.loads(path.read_text()) + selected = set(names) + cases = [] + for entry in manifest['cases']: + if selected and entry['file'] not in selected: + continue + filename = path.parent / entry['file'] + if sha(filename) != entry['sha256']: + raise ValueError('Input hash mismatch: ' + str(filename)) + with np.load(filename, allow_pickle=False) as source: + metadata = json.loads(str(source['metadata'])) + if regimes and metadata['regime'] not in regimes: + continue + data = {key: np.array(source[key], copy=True) + for key in ('t', 'y', 'dy', 'periods')} + if np.any(data['t'] <= 0): + raise ValueError('Competitors require identical positive-origin timestamps') + if 'metadata' in entry and metadata != entry['metadata']: + raise ValueError('Input metadata differs from its manifest entry') + options = dict(metadata['search_kwargs']) + case = dict(name=entry['file'], data=data, options=options, + metadata=metadata, input_sha256=entry['sha256'], + error_scale=float(np.mean(data['dy']))) + case['group'] = hashlib.sha256((array_hash(data['periods']) + + json.dumps(options, sort_keys=True)).encode()).hexdigest() + cases.append(case) + if not cases or selected - {case['name'] for case in cases}: + raise ValueError('Empty cohort or missing requested input files') + return cases + + +def batches(cases, batch_size): + """One immutable grid/options group per API batch, in manifest order.""" + grouped = defaultdict(list) + for index, case in enumerate(cases): + grouped[case['group']].append(index) + return [indices[start:start + batch_size] for indices in grouped.values() + for start in range(0, len(indices), batch_size)] + + +def public_call(backend, cases, *, arrays): + if backend == 'bls': + science = science_bls_module() + return [compact_bls(case, science, arrays=arrays) for case in cases] + if backend in ('gtls', 'gtls_corrected'): + from gputls import gtls + return [gtls(case['data']['t'], case['data']['y'], case['data']['dy'], + verbose=False).power(periods=case['data']['periods'], + fast=False, verbose=False, show_progress_bar=False, + **case['options']) for case in cases] + from cuvarbase.tls import tls_search_batch + return tls_search_batch([(case['data']['t'], case['data']['y'], case['data']['dy']) + for case in cases], periods=cases[0]['data']['periods'], + full=True, return_arrays=arrays, **cases[0]['options']) + + +def compact_bls(case, science, *, arrays=False): + """Same sealed GPU search; additional full diagnostics only on qualification.""" + from cuvarbase.bls import eebls_gpu_fast + from benchmarks.tls_survey.common import BLS_CONFIGS + selection = case['bls_selection'] + t, y, dy, periods = (case['data'][key] for key in ('t', 'y', 'dy', 'periods')) + qmin, qmax = science.bls_bounds(periods) + config = dict(BLS_CONFIGS[selection['method']]) + qmin *= config.pop('qmin_factor') + power = np.asarray(eebls_gpu_fast(t, y, dy, 1/periods, qmin=qmin, qmax=qmax, + ignore_negative_delta_sols=True, **config)) + if arrays: + weight = dy**-2 + weighted_mean = np.dot(weight, y)/weight.sum() + chi2_null = float(np.dot(weight, (y-weighted_mean)**2)) + candidates = science.bls_candidates(periods, power, chi2_null) + chosen = candidates[selection['ranker']] + return dict(period=chosen['period'], score=chosen['score'], candidates=candidates, + spectra=dict(periods=array_hash(periods), power=array_hash(power), + valid_mask=array_hash(np.isfinite(power))), + _arrays=dict(periods=periods, power=power)) + if selection['ranker'] == 'detrended': + from benchmarks.transit.worker import spectral_candidate + chosen = spectral_candidate(periods, power) + else: + good = np.isfinite(power) + if not good.any(): + raise ValueError('No finite BLS powers') + index = int(np.argmax(np.where(good, power, -np.inf))) + value = float(power[index]) + if selection['ranker'] == 'likelihood': + weight = dy**-2 + weighted_mean = np.dot(weight, y)/weight.sum() + chi2_null = float(np.dot(weight, (y-weighted_mean)**2)) + value = float(power[index]*chi2_null) + chosen = dict(period=float(periods[index]), score=value) + return dict(period=chosen['period'], score=chosen['score']) + + +def scalar_fingerprint(backend, result): + """Shared selected-detection fields, excluding package-specific SNR units.""" + values = vars(result) if backend in ('gtls', 'gtls_corrected') else result + if 'error' in values: + raise RuntimeError('API returned error: ' + str(values['error'])) + fields = {name: array_hash(np.asarray(float(values[name]), dtype=np.float64)) + for name in (('period', 'score') if backend == 'bls' else ('period', 'SDE'))} + if any(not np.isfinite(float(values[name])) for name in fields): + raise ValueError('Nonfinite selected detection') + return fields + + +def complete_fingerprint(backend, case, result): + if backend != 'bls': + return fingerprint(backend, case, result) + strict = {key: result['spectra'][key] for key in ('periods', 'valid_mask')} + strict.update(scalar_fingerprint(backend, result)) + # Native BLS float32 atomic accumulation varies even within one worker. + # Its selected endpoint remains exact; retain nonwinning powers and unused + # rankers as diagnostics rather than falsely calling this full equivalence. + fields = {key: value for key, value in result.items() + if key not in ('_arrays', 'spectrum_artifact')} + return dict(case=case['name'], strict=strict, common=strict, fields=fields, + spectrum_artifact=result.get('spectrum_artifact'), + qualification_contract='BLS exact periods/mask and selected period/score; full powers diagnostic', + full_digest=hashlib.sha256(json.dumps(fields, sort_keys=True, + allow_nan=False).encode()).hexdigest(), + primary_period=result['period'], selected_score=result['score'], + nperiods=len(case['data']['periods'])) + + +def archive_bls_spectra(result, directory, name): + """Persist full qualification arrays outside the API and sustained clocks.""" + path = Path(directory)/name + path.parent.mkdir(parents=True, exist_ok=True) + np.savez_compressed(path, **result['_arrays']) + result['spectrum_artifact'] = dict(path=str(path.resolve()), sha256=sha(path)) + + +def bls_repeat_diagnostics(rows, reference_rows=None): + """Report native repeat variation; this function never grants eligibility.""" + expected = {} + for row in rows if reference_rows is None else reference_rows: + for value in row['outputs']: + expected.setdefault(value['case'], value) + records = [] + for row in rows: + for actual in row['outputs']: + name = actual['case'] + reference = expected[name] + arrays = [] + for output in (reference, actual): + artifact = output['spectrum_artifact'] + path = Path(artifact['path']) + if sha(path) != artifact['sha256']: + raise ValueError('Archived BLS qualification spectrum changed') + with np.load(path, allow_pickle=False) as source: + arrays.append(np.array(source['power'], copy=True)) + first, current = arrays + common_finite = np.isfinite(first) & np.isfinite(current) + delta = np.abs(current[common_finite].astype(np.float64) - + first[common_finite].astype(np.float64)) + relative = delta/np.maximum(np.abs(first[common_finite].astype(np.float64)), 1e-30) + candidates = {} + for ranker, value in actual['fields']['candidates'].items(): + previous = reference['fields']['candidates'][ranker] + score_delta = (None if value.get('score') is None or previous.get('score') is None else + float(value['score'] - previous['score'])) + candidates[ranker] = dict(exact=value == previous, + period_changed=value.get('period') != previous.get('period'), + score_difference=score_delta, + reference=previous, actual=value) + records.append(dict(case=name, worker=row['worker'], + reference_artifact=reference['spectrum_artifact'], + actual_artifact=actual['spectrum_artifact'], + full_power_hash_equal=reference['fields']['spectra']['power'] == actual['fields']['spectra']['power'], + finite_masks_equal=bool(np.array_equal(np.isfinite(first), np.isfinite(current))), + changed_finite_power_values=int(np.count_nonzero(delta)), + max_absolute_power_difference=float(delta.max(initial=0.)), + max_relative_power_difference=float(relative.max(initial=0.)), + selected_endpoint_exact=all(reference['strict'][key] == actual['strict'][key] + for key in ('period', 'score')), + candidates=candidates)) + return dict(contract='Descriptive native BLS variation, not a TLS-equivalence or tolerance gate', + comparisons=records, + changed_power_comparisons=sum(not value['full_power_hash_equal'] for value in records), + changed_selected_endpoints=sum(not value['selected_endpoint_exact'] for value in records), + max_absolute_power_difference=max((value['max_absolute_power_difference'] for value in records), default=0.)) + + +def prefix_dispatch_status(backend): + if backend not in ('candidate', 'baseline'): + return dict(status='not_applicable', backend=backend) + from cuvarbase import tls_reference + helper = getattr(tls_reference, '_native_short_prefix_status', None) + if helper is None: + return dict(status='helper_absent_in_this_source', backend=backend) + return dict(status='recorded', backend=backend, dispatch=helper()) + + +def rss_peak_bytes(): + value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return int(value if sys.platform == 'darwin' else value * 1024) + + +def resource_environment(): + """Record both cgroup versions; host logical CPU count is not a quota.""" + result = environment() + result['nvcc_command_path'] = shutil.which('nvcc') + paths = ('cpu/cpu.cfs_quota_us', 'cpu/cpu.cfs_period_us', 'cpu/cpu.stat', + 'memory/memory.limit_in_bytes', 'memory/memory.usage_in_bytes', + 'memory/memory.max_usage_in_bytes') + for name in paths: + path = Path('/sys/fs/cgroup') / name + if path.exists(): + result['cgroup'][str(path)] = path.read_text().strip() + quota = result['cgroup'].get('/sys/fs/cgroup/cpu/cpu.cfs_quota_us') + period = result['cgroup'].get('/sys/fs/cgroup/cpu/cpu.cfs_period_us') + if quota is not None and period is not None and int(quota) > 0: + result['cpu_quota_cores'] = int(quota)/int(period) + memory = result['cgroup'].get('/sys/fs/cgroup/memory/memory.limit_in_bytes', + result['cgroup'].get('/sys/fs/cgroup/memory.max')) + result['host_memory_limit_bytes'] = int(memory) if memory and memory != 'max' else None + return result + + +def prepare_grids(cases): + """Measure one reusable explicit grid per shared survey configuration.""" + from cuvarbase.tls_reference_math import period_grid + groups, records = {}, [] + for case in cases: + if case['group'] in groups: + case['data']['periods'] = groups[case['group']] + continue + metadata = case['metadata'] + if 'grid_kwargs' not in metadata: + records.append(dict(group=case['group'], status='sealed_array_only_no_recipe', seconds=None)) + groups[case['group']] = case['data']['periods'] + continue + before = time.perf_counter() + periods = np.sort(period_grid(metadata['baseline_days'], **metadata['grid_kwargs'])) + elapsed = time.perf_counter()-before + if not np.array_equal(periods, case['data']['periods']): + raise ValueError('Regenerated common grid differs from sealed input') + groups[case['group']] = case['data']['periods'] = periods + records.append(dict(group=case['group'], status='exact_regeneration', seconds=elapsed, + nperiods=len(periods), sha256=array_hash(periods), kwargs=metadata['grid_kwargs'])) + return records + + +def worker(connection, config): + try: + started = time.perf_counter() + if config['source_root']: + sys.path.insert(0, str(Path(config['source_root']).resolve())) + cases = load_manifest(config['manifest'], config['names'], config['regimes']) + seal = configure_bls(cases, config['science_seal']) if config['backend'] == 'bls' else None + load_seconds = time.perf_counter() - started + grid_preparation = prepare_grids(cases) + internal_backend = 'candidate' if config['backend'] == 'baseline' else config['backend'] + if internal_backend == 'bls': + from cuvarbase.base import ensure_context + ensure_context() + import cuvarbase + science = science_bls_module() + production = science.production_identity() + if production != seal['production_sources']: + raise ValueError('BLS production sources differ from the scientific seal') + sources = dict(root=str(Path(cuvarbase.__file__).parent), files=production, + science_seal_sha256=sha(config['science_seal']), + bls_selected=seal['bls_selected']) + else: + sources = initialize_backend(internal_backend, correction_adapter=config['correction_adapter']) + if internal_backend in ('candidate', 'bls'): + package = Path(sources['root']) + sources['files'] = {str(path.relative_to(package)): sha(path) + for path in sorted(package.rglob('*')) if path.is_file() + and path.suffix in ('.py', '.cu', '.cuh')} + if config['source_root'] and config['backend'] in ('baseline', 'candidate', 'bls'): + if Path(sources['root']).resolve().parent != Path(config['source_root']).resolve(): + raise RuntimeError('Scientific source import escaped the requested isolated checkout') + import cupy as cp + from cupy._core import _accelerator + owned_allocation = retain_cuda_context() + connection.send(dict(kind='ready', pid=os.getpid(), namespace_pids=process_ids(), + cuda_context_allocation_bytes=1, cuda_context_synchronized=True, + source_files=sources, input_load_seconds=load_seconds, + cuda_environment=dict(runtime_version=cp.cuda.runtime.runtimeGetVersion(), + driver_version=cp.cuda.runtime.driverGetVersion(), + compute_capability=cp.cuda.Device().compute_capability, + routine_accelerators=_accelerator.get_routine_accelerators(), + cupy_accelerators_environment=os.environ.get('CUPY_ACCELERATORS'), + nvcc_environment=os.environ.get('NVCC')), + grid_preparation=grid_preparation, + ready_seconds=time.perf_counter()-started)) + qualification_index = 0 + while True: + command = connection.recv() + if command['kind'] == 'close': + break + if command['kind'] == 'memory': + connection.send(dict(kind='memory', pid=os.getpid(), host_peak_rss_bytes=rss_peak_bytes(), + cupy_pool_reserved_bytes=cp.get_default_memory_pool().total_bytes(), + cupy_pool_used_bytes=cp.get_default_memory_pool().used_bytes(), + prefix_dispatch=prefix_dispatch_status(config['backend']))) + continue + selected = [cases[index] for index in command['indices']] + profile = cProfile.Profile() if command['kind'] == 'profile' else None + cp.cuda.runtime.deviceSynchronize() + before = time.perf_counter() + error, results, fingerprints, scalars = None, None, [], [] + try: + if profile: + profile.enable() + results = public_call(internal_backend, selected, + arrays=command['kind'] != 'run') + cp.cuda.runtime.deviceSynchronize() + except Exception: + error = traceback.format_exc() + finally: + if profile: + profile.disable() + after = time.perf_counter() + # Qualifying spectra are hashed after the individual API clock. + # Scalar checks on measured tasks remain inside sustained wall time. + if results is not None: + try: + scalars = [dict(case=case['name'], fields=scalar_fingerprint(internal_backend, result)) + for case, result in zip(selected, results)] + if internal_backend == 'bls': + for scalar, result in zip(scalars, results): + scalar['values'] = {key: result[key] for key in ('period', 'score')} + if command['kind'] != 'run': + if internal_backend == 'bls': + for case, result in zip(selected, results): + filename = f"{qualification_index:05d}-{command['kind']}-{case['name']}" + archive_bls_spectra(result, Path(config['output'])/'spectra'/str(os.getpid()), filename) + qualification_index += 1 + fingerprints = [complete_fingerprint(internal_backend, case, result) + for case, result in zip(selected, results)] + if len(results) != len(selected): + raise ValueError('API output count differs from assigned count') + except Exception: + error = traceback.format_exc() + profile_text = None + if profile: + stream = io.StringIO() + pstats.Stats(profile, stream=stream).strip_dirs().sort_stats('cumulative').print_stats(45) + profile_text = stream.getvalue() + del results + connection.send(dict(kind='complete', pid=os.getpid(), task=command.get('task'), + indices=command['indices'], started=before, ended=after, + api_seconds=after-before, error=error, outputs=fingerprints, + scalars=scalars, profile=profile_text, host_peak_rss_bytes=rss_peak_bytes())) + except BaseException: + try: + connection.send(dict(kind='fatal', traceback=traceback.format_exc())) + except Exception: + pass + finally: + connection.close() + + +class Telemetry: + """Sample the whole device and owned pool; peaks are sampled lower bounds.""" + def __init__(self, path, pids, interval=.1): + self.path, self.pids, self.interval = Path(path), pids, interval + self.stop = threading.Event() + self.rows = [] + self.thread = threading.Thread(target=self.sample, daemon=True) + + def sample(self): + import pynvml as nvml + nvml.nvmlInit() + try: + handle = nvml.nvmlDeviceGetHandleByIndex(0) + with self.path.open('w') as output: + while not self.stop.is_set(): + row = dict(monotonic=time.perf_counter()) + try: + row['gpu_used_bytes'] = int(nvml.nvmlDeviceGetMemoryInfo(handle).used) + row['gpu_utilization_percent'] = int(nvml.nvmlDeviceGetUtilizationRates(handle).gpu) + row['gpu_power_mw'] = int(nvml.nvmlDeviceGetPowerUsage(handle)) + row['gpu_sm_clock_mhz'] = int(nvml.nvmlDeviceGetClockInfo(handle, nvml.NVML_CLOCK_SM)) + row['gpu_temperature_c'] = int(nvml.nvmlDeviceGetTemperature(handle, nvml.NVML_TEMPERATURE_GPU)) + row['gpu_processes'] = [int(p.pid) for p in nvml.nvmlDeviceGetComputeRunningProcesses(handle)] + row['host_pool_rss_bytes'] = 0 + for pid in self.pids: + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + if line.startswith('VmRSS:'): + row['host_pool_rss_bytes'] += int(line.split()[1])*1024 + for name in ('memory.current', 'memory.peak'): + file = Path('/sys/fs/cgroup') / name + if file.exists(): + row['cgroup_' + name] = int(file.read_text()) + for name, key in (('memory/memory.usage_in_bytes', 'cgroup_memory.current'), + ('memory/memory.max_usage_in_bytes', 'cgroup_memory.peak')): + file = Path('/sys/fs/cgroup') / name + if file.exists(): + row[key] = int(file.read_text()) + except Exception: + row['error'] = traceback.format_exc() + self.rows.append(row) + output.write(json.dumps(row) + '\n') + output.flush() + self.stop.wait(self.interval) + finally: + nvml.nvmlShutdown() + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *unused): + self.stop.set() + self.thread.join(timeout=5) + + def summarize(self, allowed_pids): + foreign = sorted({pid for row in self.rows for pid in row.get('gpu_processes', []) + if pid not in allowed_pids}) + return dict(sample_interval_seconds=self.interval, samples=len(self.rows), + scope='Worker startup, full qualification, measured queues, post-qualification and teardown. ' + 'Worker RSS sampling begins after worker readiness; lifetime RSS high-water marks ' + 'also cover imports/startup. GPU and container sampling include startup.', + foreign_gpu_pids=foreign, + ownership_passed=bool(self.rows) and not foreign, + peaks_are_sampled_lower_bounds=True, + cgroup_peak_scope='Container lifetime high-water mark; may include earlier configurations. ' + 'Per-configuration host/GPU memory uses sampled current memory plus worker lifetime RSS.', + operating_ranges={name: [min((row[name] for row in self.rows if name in row), default=None), + max((row[name] for row in self.rows if name in row), default=None)] + for name in ('gpu_power_mw', 'gpu_sm_clock_mhz', 'gpu_temperature_c', + 'gpu_utilization_percent')}, + **{name: max((row[name] for row in self.rows if name in row), default=None) + for name in ('gpu_used_bytes', 'host_pool_rss_bytes', + 'cgroup_memory.current', 'cgroup_memory.peak')}) + + +class Pool: + def __init__(self, config, width, timeout): + self.timeout, self.connections, self.processes = timeout, [], [] + self.ownership = GPUOwnership() + self.closed = False + started = time.perf_counter() + try: + context = mp.get_context('spawn') + for unused in range(width): + parent, child = context.Pipe() + process = context.Process(target=worker, args=(child, config)) + process.start() + child.close() + self.connections.append(parent) + self.processes.append(process) + self.ready = [self.receive(connection, 'ready') for connection in self.connections] + self.ownership.bind(self.ready, self.processes) + except BaseException as error: + error.gpu_ownership = self.close() + raise + self.startup_seconds = time.perf_counter() - started + + def receive(self, connection, kind='complete'): + if not connection.poll(self.timeout): + raise TimeoutError('Worker exceeded declared per-task timeout') + message = connection.recv() + if message['kind'] != kind: + raise RuntimeError('Unexpected worker response: ' + repr(message)) + return message + + def qualify(self, cohort, kind='qualify'): + """Every worker sees every batch, concurrently, before and after queues.""" + rows = [] + for indices in cohort: + for connection in self.connections: + connection.send(dict(kind=kind, indices=indices)) + rows.extend(dict(worker=index, **self.receive(connection)) + for index, connection in enumerate(self.connections)) + return rows + + def run_queue(self, cohort, cases, min_sources, min_seconds, scalars): + """Keep at most one task per worker in flight; stop at whole input cycles.""" + before_owner = exclusive_gpu_processes(self.ownership.allowed_pids) + if not before_owner['exclusive']: + raise RuntimeError('GPU ownership check failed before the measured queue') + started = time.perf_counter() + jobs = [] + cycles = max(1, int(np.ceil(min_sources / len(cases))), + int(np.ceil(len(self.connections) / len(cohort)))) + for cycle in range(cycles): + jobs.extend(cohort) + pending, records, submitted = {}, [], 0 + + def submit(connection): + nonlocal submitted + indices = jobs[submitted] + connection.send(dict(kind='run', indices=indices, task=submitted)) + pending[connection] = submitted + submitted += 1 + + for connection in self.connections[:len(jobs)]: + submit(connection) + while pending: + ready = wait(list(pending), timeout=self.timeout) + if not ready: + raise TimeoutError('Sustained queue worker timeout') + for connection in ready: + row = self.receive(connection) + expected_task = pending.pop(connection) + if row['task'] != expected_task: + raise ValueError('Worker returned a different assigned task') + row['scalar_match'] = all(value['fields'] == scalars.get(value['case']) + for value in row['scalars']) + expected_names = Counter(cases[index]['name'] for index in jobs[expected_task]) + row['membership_match'] = Counter(value['case'] for value in row['scalars']) == expected_names + records.append(row) + if row['error'] or not row['scalar_match'] or not row['membership_match']: + error = RuntimeError('Measured task failed numerical/membership gate: ' + repr(row)) + error.failed_queue = dict(status='error', elapsed_seconds=time.perf_counter()-started, + tasks=records, failed_task=expected_task, + expected_scalars=scalars) + raise error + if submitted == len(jobs) and time.perf_counter()-started < min_seconds: + jobs.extend(cohort) + if submitted < len(jobs): + submit(connection) + ended = time.perf_counter() + after_owner = exclusive_gpu_processes(self.ownership.allowed_pids) + if not after_owner['exclusive']: + raise RuntimeError('GPU ownership check failed after the measured queue') + counts = Counter(cases[index]['metadata']['regime'] for job in jobs for index in job) + return dict(status='ok', source_count=sum(counts.values()), regime_counts=dict(counts), + elapsed_seconds=ended-started, lightcurves_per_second=sum(counts.values())/(ended-started), + completed_input_cycles=len(jobs)//len(cohort), tasks=records, + exclusive_before=before_owner, exclusive_after=after_owner) + + def memory(self): + for connection in self.connections: + connection.send(dict(kind='memory')) + return [self.receive(connection, 'memory') for connection in self.connections] + + def close(self): + if self.closed: + return self.ownership.receipt + self.closed = True + forced = [] + for connection in self.connections: + try: + connection.send(dict(kind='close')) + except (EOFError, BrokenPipeError, OSError): + pass + for process in self.processes: + process.join(timeout=5) + if process.is_alive(): + forced.append(process.pid) + process.terminate() + process.join(timeout=5) + for connection in self.connections: + connection.close() + return self.ownership.finish(self.processes, forced=forced) + + +def qualify_rows(rows, reference=None, expected_names=None, workers=None): + """Exact predeclared backend fields; BLS nonwinning powers are diagnostic.""" + expected = {} if reference is None else dict(reference) + problems = [] + scalars = {} + by_worker = defaultdict(Counter) + for row in rows: + if row['error']: + problems.append(dict(reason='API failure', error=row['error'])) + for value in row['outputs']: + name = value['case'] + by_worker[row['worker']][name] += 1 + identity = value['strict'] + if name not in expected: + if reference is not None: + problems.append(dict(reason='Unfrozen case', case=name)) + else: + expected[name] = identity + elif expected[name] != identity: + problems.append(dict(reason='Changed required search output', case=name, + worker=row['worker'])) + for value in row['scalars']: + if value['case'] in scalars and scalars[value['case']] != value['fields']: + problems.append(dict(reason='Changed selected detection', case=value['case'])) + scalars[value['case']] = value['fields'] + if not rows or not expected: + problems.append(dict(reason='No qualifying outputs')) + if expected_names is not None: + target = Counter(expected_names) + if workers is not None and set(by_worker) != set(range(workers)): + problems.append(dict(reason='Missing or unexpected qualifying workers')) + for index, observed in by_worker.items(): + if observed != target: + problems.append(dict(reason='Qualifying case membership differs', worker=index, + expected=dict(target), observed=dict(observed))) + return dict(passed=not problems, problems=problems, strict=expected, scalars=scalars) + + +def run(args): + args.output.mkdir(parents=True, exist_ok=True) + started = time.perf_counter() + cases = load_manifest(args.manifest, args.names, args.regimes) + if args.backend == 'bls': + configure_bls(cases, args.science_seal) + cohort = batches(cases, args.batch_size) + record = dict(status='running', schema_version=1, backend=args.backend, + workers=args.workers, batch_size=args.batch_size, + actual_api_batch_sizes=[len(indices) for indices in cohort], + manifest_sha256=sha(args.manifest), harness_sha256=sha(__file__), + harness_dependency_sha256={name: sha(ROOT/'benchmarks/tls_reference/timing'/name) + for name in ('common.py', 'benchmark.py')}, + science_seal_sha256=sha(args.science_seal) if args.science_seal else None, + environment=resource_environment(), config={key: str(value) if isinstance(value, Path) else value + for key, value in vars(args).items()}, + cohort=[dict(name=case['name'], regime=case['metadata']['regime'], + nobs=len(case['data']['t']), nperiods=len(case['data']['periods']), + input_sha256=case['input_sha256']) for case in cases], + timing_boundary='Persistent bounded queue: dispatch, complete public API including validation, ' + 'template preparation, GPU transfers, search/refinement, requested result construction, ' + 'scalar verification and task completion. Input-file loading, imports/context setup and ' + 'first calls recorded separately and amortized in end-to-end rate. Explicit grids are regenerated ' + 'once per shared configuration/worker from sealed metadata, byte checked and amortized in cold ' + 'preparation. Historical inputs without a grid recipe are explicitly marked array-only.', + output_policy='cuvarbase TLS return_arrays=False; native public API always returns arrays and extra diagnostics. ' + 'BLS uses the sealed GPU search and only its selected ranker during measured calls; ' + 'complete power/period arrays, masks, hashes and all rankers are retained only for qualification. ' + 'BLS eligibility requires exact period arrays, finite masks, selected period and selected score; ' + 'native atomic variation in nonwinning powers and unused rankers is reported separately. ' + 'TLS complete-spectrum eligibility is unchanged. ' + 'All distinct case spectra checked on every worker before and after queues.', + cold_cache_policy='Fresh worker processes; existing filesystem compiler/kernel caches are retained. ' + 'Cold-start values include actual first-use setup/compilation/canary costs incurred in this state, ' + 'but are not empty-disk-cache installation or first-ever compilation measurements. ' + 'The guarded short-row CUB kernel uses direct NVCC compilation with explicit FTZ disabled; ' + 'it has only a bounded process/context memory cache and pays its compilation/canary on each ' + 'fresh supported worker/context, even if other CuPy filesystem caches are warm.', + queue_population_note='Fixed distinct source cohort repeated in whole cycles; repetitions measure ' + 'steady-state execution, not independent astrophysical population draws.', + qualification=[], repetitions=[]) + write(args.output/'result.json', record) + pool = None + telemetry = Telemetry(args.output/'telemetry.jsonl', []) + try: + telemetry.__enter__() + config = dict(manifest=str(args.manifest.resolve()), names=args.names, regimes=args.regimes, + backend=args.backend, source_root=args.source_root, + output=str(args.output.resolve()), + science_seal=str(args.science_seal.resolve()) if args.science_seal else None, + correction_adapter=str(args.correction_adapter.resolve())) + pool = Pool(config, args.workers, args.timeout) + telemetry.pids[:] = [process.pid for process in pool.processes] + record.update(worker_ready=pool.ready, startup_seconds=pool.startup_seconds, + parent_input_load_and_setup_seconds=time.perf_counter()-started-pool.startup_seconds) + reference, reference_rows = None, None + if args.reference: + frozen = json.loads(args.reference.read_text()) + if frozen['status'] != 'ok' or frozen['workers'] != 1: + raise ValueError('Pool reference must be a successful one-worker run') + if frozen['backend'] != args.backend: + raise ValueError('Pool qualification reference must use the same backend') + if frozen.get('science_seal_sha256') != record['science_seal_sha256']: + raise ValueError('Pool reference uses another scientific BLS selection') + if frozen['manifest_sha256'] != record['manifest_sha256']: + raise ValueError('Pool reference belongs to another input manifest') + reference = frozen['qualification'][0]['gate']['strict'] + reference_rows = frozen['qualification'][0]['rows'] + cold_started = time.perf_counter() + cold = pool.qualify(cohort) + record['first_full_cohort_seconds'] = time.perf_counter()-cold_started + record['cold_first_public_batch_api_seconds_by_worker'] = [row['api_seconds'] for row in cold[:args.workers]] + record['cold_accounting_note'] = ('First-cohort time includes qualification hashing and runs each ' + 'distinct input on every worker. Cold-amortized throughput therefore conservatively charges ' + 'validation warmup overhead; first-public-batch API latency is separately retained.') + expected_names = [case['name'] for case in cases] + gate = qualify_rows(cold, reference, expected_names, args.workers) + record['qualification'].append(dict(phase='before', rows=cold, gate=gate)) + if args.backend == 'bls': + record['qualification'][-1]['native_repeat_diagnostics'] = bls_repeat_diagnostics(cold, reference_rows) + if not gate['passed'] or set(gate['strict']) != {case['name'] for case in cases}: + raise RuntimeError('Pre-queue required-output qualification failed') + record['worker_after_warmup'] = pool.memory() + if args.profile: + record['profiles'] = pool.qualify(cohort, kind='profile') + if not qualify_rows(record['profiles'], gate['strict'], expected_names, args.workers)['passed']: + raise RuntimeError('Profile instrumentation changed search outputs') + write(args.output/'result.json', record) + for repetition in range(args.repetitions): + measured = pool.run_queue(cohort, cases, args.min_sources, args.min_seconds, gate['scalars']) + measured['repetition'] = repetition + measured['estimated_workload_compute_usd'] = args.hourly_usd*measured['elapsed_seconds']/3600 + record['repetitions'].append(measured) + write(args.output/'result.json', record) + record['worker_after_queues'] = pool.memory() + after = pool.qualify(cohort) + final_gate = qualify_rows(after, gate['strict'], expected_names, args.workers) + record['qualification'].append(dict(phase='after', rows=after, gate=final_gate)) + if args.backend == 'bls': + record['qualification'][-1]['native_repeat_diagnostics'] = bls_repeat_diagnostics(after, reference_rows or cold) + if not final_gate['passed']: + raise RuntimeError('Post-queue required-output qualification failed') + record['worker_memory_before_teardown'] = pool.memory() + record['status'] = 'ok' + except BaseException as error: + record.update(status='error', error=traceback.format_exc()) + if hasattr(error, 'failed_queue'): + record['failed_queue'] = error.failed_queue + if hasattr(error, 'gpu_ownership'): + record['gpu_ownership'] = error.gpu_ownership + finally: + if pool is not None: + record['gpu_ownership'] = pool.close() + if not record['gpu_ownership']['passed']: + record['status'] = 'error' + telemetry.__exit__() + record['memory'] = telemetry.summarize(pool.ownership.allowed_pids if pool is not None else []) + if (not record['memory']['ownership_passed'] or + record['memory']['gpu_used_bytes'] is None or + record['memory']['host_pool_rss_bytes'] is None): + record['status'] = 'error' + record.setdefault('error', 'Missing memory telemetry or unexpected GPU process during the run') + record['total_campaign_seconds'] = time.perf_counter()-started + if record['status'] == 'ok': + elapsed = sum(row['elapsed_seconds'] for row in record['repetitions']) + count = sum(row['source_count'] for row in record['repetitions']) + preparation = record['startup_seconds'] + record['first_full_cohort_seconds'] + record['parent_input_load_and_setup_seconds'] + median_rate = float(np.median([row['lightcurves_per_second'] for row in record['repetitions']])) + record['summary'] = dict(steady_state_lightcurves_per_second=count/elapsed, + median_repetition_lightcurves_per_second=median_rate, + total_measured_sources=count, total_measured_seconds=elapsed, + total_measured_compute_usd=args.hourly_usd*elapsed/3600, + observed_rate_min=min(row['lightcurves_per_second'] for row in record['repetitions']), + observed_rate_max=max(row['lightcurves_per_second'] for row in record['repetitions']), + cold_first_cohort_including_startup_seconds=preparation, + cold_preparation_compute_usd=args.hourly_usd*preparation/3600, + cold_amortized_lightcurves_per_second=count/(elapsed+preparation), + usd_per_million_steady=args.hourly_usd*1e6/(3600*median_rate), + usd_per_million_cold_amortized=args.hourly_usd*(elapsed+preparation)*1e6/(3600*count), + estimated_run_compute_usd=args.hourly_usd*record['total_campaign_seconds']/3600, + timing_scope_note='Cost projection includes measured reusable grid preparation in cold amortization; ' + 'excludes survey data acquisition/preprocessing and vetting; no million-source execution claim.') + write(args.output/'result.json', record) + print(json.dumps({key: record[key] for key in ('status', 'summary', 'error') if key in record}), flush=True) + return 0 if record['status'] == 'ok' else 1 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--backend', choices=('baseline', 'candidate', 'gtls', 'gtls_corrected', 'bls'), required=True) + parser.add_argument('--science-seal', type=Path, help='Frozen scientific BLS method/ranker choices') + parser.add_argument('--source-root', help='Isolated checkout/package root for this worker backend') + parser.add_argument('--correction-adapter', type=Path, + default=ROOT/'benchmarks/tls_reference/corrected_reference.py') + parser.add_argument('--reference', type=Path, help='Same-backend one-worker result.json frozen before tuning') + parser.add_argument('--names', nargs='*', default=[]) + parser.add_argument('--regimes', nargs='*', default=[]) + parser.add_argument('--workers', type=int, default=1) + parser.add_argument('--batch-size', type=int, default=1) + parser.add_argument('--repetitions', type=int, default=3) + parser.add_argument('--min-sources', type=int, default=96) + parser.add_argument('--min-seconds', type=float, default=60.) + parser.add_argument('--timeout', type=float, default=1800.) + parser.add_argument('--hourly-usd', type=float, required=True) + parser.add_argument('--profile', action='store_true', help='Extra qualified cProfile run, excluded from headline timings') + args = parser.parse_args() + for name in ('workers', 'batch_size', 'repetitions', 'min_sources'): + if getattr(args, name) < 1: + parser.error(name + ' must be positive') + if args.min_seconds < 0 or args.hourly_usd < 0: + parser.error('Time/cost controls must be nonnegative') + if args.backend == 'baseline' and not args.source_root: + parser.error('baseline requires --source-root') + if args.backend == 'bls' and not args.science_seal: + parser.error('BLS requires --science-seal from independent scientific development') + if args.workers > 1 and not args.reference: + parser.error('concurrent pools require --reference from the same-backend one-worker run') + for variable in ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', + 'VECLIB_MAXIMUM_THREADS', 'NUMEXPR_NUM_THREADS', 'NUMBA_NUM_THREADS'): + os.environ[variable] = '1' + raise SystemExit(run(args)) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/tls_survey/throughput_campaign.py b/benchmarks/tls_survey/throughput_campaign.py new file mode 100644 index 00000000..7d575b42 --- /dev/null +++ b/benchmarks/tls_survey/throughput_campaign.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Predeclared sequential tuning and independent sustained-throughput measurement. + +Select one operational batch/pool setting per backend on the balanced development +queue. Freeze it before opening timing outcomes from an independent null cohort. +Per-cadence panels characterize that setting; they do not claim separate tuning +of every cadence. Every attempted configuration and failed numerical gate remains +in the campaign receipt. This driver never rents or terminates cloud resources. +""" +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +import numpy as np + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) +from benchmarks.tls_reference.timing.common import sha, write + +REGIMES = ('tess_solar', 'tess_gap_long', 'ztf_solar') +BACKENDS = ('baseline', 'candidate', 'gtls', 'gtls_corrected', 'bls') +EXECUTED_BACKENDS = ('baseline', 'candidate', 'gtls', 'bls') +VARIED_POLICY = ('First 32 independent manifest-order nulls per regime; retain rounded fractions ' + '0.8 + 0.2*i/31 for positions i=0..31. Retain first/last observations, select remaining indices ' + 'without replacement with NumPy default_rng seeded by SHA256 of ' + 'tls-survey-throughput-varied-v1 + NUL + original filename. Align time/flux/error slicing, ' + 'keep the original period grid, and never use these modified nulls for recovery/FAP inference. ' + 'The resulting 96 distinct sources replace the final balanced-mixed workload, exercising ' + 'cache preparation across many observation-array lengths throughout each queue.') + + +def validate_tuning_identity(tuning, measurement): + """A frozen operating choice must use the same timing definitions at measure.""" + if tuning.get('status') != 'complete': + raise ValueError('Measurement requires completed tuning') + for key in ('driver_sha256', 'runner_sha256', 'protocol_sha256', 'harness_dependency_sha256'): + if key not in tuning or tuning[key] != measurement.get(key): + raise ValueError('Timing definitions changed after frozen tuning: ' + key) + + +def select_names(manifest_path, count, *, nulls_only=False): + manifest = json.loads(Path(manifest_path).read_text()) + names = [] + for regime in REGIMES: + entries = [entry for entry in manifest['cases'] + if entry['metadata']['regime'] == regime and + (not nulls_only or entry['metadata']['null'])] + if len(entries) < count: + raise ValueError(f'{regime} has only {len(entries)} eligible cases; {count} required') + names.extend(entry['file'] for entry in entries[:count]) + return names + + +def prepare_varied_manifest(source_manifest, output, count=32): + """Derive timing-only nulls without reading scientific detection outcomes.""" + source_manifest, output = Path(source_manifest), Path(output) + if count < 2: + raise ValueError('Varied timing requires at least two retention levels') + origin = json.loads(source_manifest.read_text()) + receipt_path = output/'manifest.json' + if receipt_path.exists(): + existing = json.loads(receipt_path.read_text()) + if existing['source_manifest_sha256'] != sha(source_manifest): + raise ValueError('Varied timing input origin changed') + if existing['count_per_regime'] != count: + raise ValueError('Varied timing case count changed') + if any(sha(output/entry['file']) != entry['sha256'] for entry in existing['cases']): + raise ValueError('Varied timing input arrays changed') + return receipt_path + output.mkdir(parents=True, exist_ok=True) + receipt = dict(purpose='throughput_only_derived_nulls', policy=VARIED_POLICY, + source_manifest_sha256=sha(source_manifest), numpy_version=np.__version__, + count_per_regime=count, cases=[]) + for regime in REGIMES: + entries = [entry for entry in origin['cases'] if entry['metadata']['regime'] == regime + and entry['metadata']['null']] + if len(entries) < count: + raise ValueError(f'Varied timing needs {count} independent nulls in {regime}') + for index, entry in enumerate(entries[:count]): + source_path = source_manifest.parent/entry['file'] + if sha(source_path) != entry['sha256']: + raise ValueError('Original timing-null input hash changed') + with np.load(source_path, allow_pickle=False) as data: + original = json.loads(str(data['metadata'])) + nobs = len(data['t']) + fraction = .8 + .2*index/(count-1) + retained = min(nobs, max(3, int(np.rint(fraction*nobs)))) + digest = hashlib.sha256(('tls-survey-throughput-varied-v1\0'+entry['file']).encode()).digest() + rng = np.random.default_rng(np.frombuffer(digest, dtype='=24 sources and >=30 seconds. Failed configurations excluded but retained. ' + 'Exact same-backend one-worker complete spectra qualify TLS/GTLS before/after queues; ' + 'BLS requires exact period arrays/masks and selected period/score, with retained full powers ' + 'and unused-ranker variation reported separately under its predeclared atomic-repeat amendment. ' + 'Default staged search first compares workers 1/2/4 at batch 1, freezes the fastest eligible ' + 'worker count, then tests batches 4/8 only at that count (five configurations per backend). ' + 'This is a conditional explored space, not an exhaustive global-optimum claim. Optional ' + '--exhaustive evaluates the full declared Cartesian product instead. Ties choose fewer ' + 'workers then smaller batches. One operational setting per backend is frozen for all final ' + 'cadence and mixed panels.', + measurement_policy='First 16 manifest-order independent nulls per cadence (or explicit count), ' + 'with no selection by outcomes. Frozen setting; per-regime queues and a derived 96-distinct-source ' + 'varied-size queue per varied_final_policy; three repetitions each at >=96 sources AND >=120 seconds.', + driver_sha256=sha(__file__), runner_sha256=sha(Path(__file__).with_name('throughput.py')), + harness_dependency_sha256={name: sha(ROOT/'benchmarks/tls_reference/timing'/name) + for name in ('common.py', 'benchmark.py')}, + protocol_sha256=sha(Path(__file__).with_name('THROUGHPUT_PROTOCOL.md')), + failure_policy='Retain every failed configuration and missing competitor panel with its reason; ' + 'continue independent competitors. No failed result contributes a performance denominator. ' + 'Show every predeclared scope, with missing bars marked no qualifying result. ' + 'Compute a baseline/optimized ratio only where the paired complete-spectrum gate passes. ' + 'Do not widen gates, substitute post-hoc case subsets, or retune held-out failures.', + configs=[], unavailable=[], selected={}, status='running') + if receipt_path.exists(): + prior = json.loads(receipt_path.read_text()) + for key in ('stage', 'manifest_sha256', 'names', 'backends', 'worker_options', + 'batch_options', 'exhaustive', 'science_seal_sha256', 'driver_sha256', + 'runner_sha256', 'protocol_sha256', 'harness_dependency_sha256'): + if prior[key] != plan[key]: + raise ValueError('Resume changes the frozen campaign: ' + key) + plan = prior + plan['status'] = 'running' + tuning = None + if args.tuning: + tuning = json.loads(args.tuning.read_text()) + if tuning['stage'] != 'tune' or not tuning.get('selected'): + raise ValueError('Tuning receipt does not contain frozen selections') + validate_tuning_identity(tuning, plan) + if set(names) & set(tuning['names']): + raise ValueError('Final timing cohort reuses development case identities') + if tuning.get('science_seal_sha256') != plan['science_seal_sha256']: + raise ValueError('Final timing changes the frozen BLS selection') + plan['tuning_sha256'] = sha(args.tuning) + plan['selected'] = tuning['selected'] + varied_manifest = None + varied_names = [] + if args.stage == 'measure': + varied_manifest = prepare_varied_manifest(args.manifest, args.output/'varied-inputs') + varied_names = [entry['file'] for entry in json.loads(varied_manifest.read_text())['cases']] + plan.update(varied_manifest=str(varied_manifest.resolve()), varied_names=varied_names, + varied_manifest_sha256=sha(varied_manifest)) + declaration = args.output/'declaration.json' + if not declaration.exists(): + write(declaration, {key: value for key, value in plan.items() + if key not in ('configs', 'selected', 'status')}) + elif plan.get('declaration_sha256') not in (None, sha(declaration)): + raise ValueError('Frozen timing declaration changed') + plan['declaration_sha256'] = sha(declaration) + write(receipt_path, plan) + started = time.perf_counter() + completed = {row['id']: row for row in plan['configs']} + + def unavailable(backend, scope, reason, reference=None): + entry = dict(backend=backend, scope=scope, reason=reason) + if reference is not None: + entry.update(reference=str(reference.resolve()), + reference_sha256=sha(reference) if reference.exists() else None) + if entry not in plan['unavailable']: + plan['unavailable'].append(entry) + write(receipt_path, plan) + + def run(backend, workers, batch, scope, reference=None): + identifier = f'{backend}-{scope}-w{workers}-b{batch}' + output = args.output/identifier + if identifier in completed: + actual = sha(output/'result.json') if (output/'result.json').exists() else None + if actual != completed[identifier]['result_sha256']: + raise ValueError('Saved configuration changed before resume: '+identifier) + return json.loads((output/'result.json').read_text()) if actual else dict(status='runner_failed_before_receipt') + if time.perf_counter()-started > args.max_hours*3600: + raise TimeoutError('Campaign time cap reached before the next configuration') + selected_names = (varied_names if scope == 'varied' else names if scope == 'mixed' else + [name for name in names if name.startswith(scope + '_')]) + input_manifest = varied_manifest if scope == 'varied' else args.manifest + command = [sys.executable, str(Path(__file__).with_name('throughput.py')), + '--manifest', str(input_manifest.resolve()), '--output', str(output.resolve()), + '--backend', backend, '--workers', str(workers), '--batch-size', str(batch), + '--hourly-usd', str(args.hourly_usd), '--names', *selected_names, + '--repetitions', '1' if args.stage == 'tune' or scope.endswith('_reference') else '3', + '--min-sources', '24' if args.stage == 'tune' else '96', + '--min-seconds', '30' if args.stage == 'tune' else '120'] + if backend in ('candidate', 'baseline', 'bls'): + source = args.baseline_root if backend == 'baseline' else args.candidate_root + command += ['--source-root', str(source.resolve())] + if args.science_seal: + command += ['--science-seal', str(args.science_seal.resolve())] + if reference: + command += ['--reference', str(reference.resolve())] + log = args.output/(identifier+'.log') + print(json.dumps(dict(action='start', id=identifier, utc=time.time())), flush=True) + with log.open('w') as stream: + result = subprocess.run(command, stdout=stream, stderr=subprocess.STDOUT, check=False) + result_path = output/'result.json' + record = json.loads(result_path.read_text()) if result_path.exists() else dict(status='runner_failed_before_receipt') + row = dict(id=identifier, backend=backend, workers=workers, batch_size=batch, + scope=scope, returncode=result.returncode, result=str(result_path.relative_to(args.output)), + result_sha256=sha(result_path) if result_path.exists() else None, + eligible=eligible(record), summary=record.get('summary'), + failure_reason=None if eligible(record) else record.get('error', record['status'])) + plan['configs'].append(row) + completed[identifier] = row + write(receipt_path, plan) + print(json.dumps(dict(action='finished', **row)), flush=True) + return record + + try: + if args.stage == 'tune': + for backend in args.backends: + records = [] + initial = run(backend, 1, 1, 'mixed') + records.append(initial) + if not eligible(initial): + unavailable(backend, 'mixed', 'Development one-worker qualification failed; no eligible tuning setting') + continue + reference = args.output/f'{backend}-mixed-w1-b1/result.json' + for workers in args.workers: + if workers != 1: + records.append(run(backend, workers, 1, 'mixed', reference)) + worker_choice = winner(records) + worker_counts = args.workers if args.exhaustive else [worker_choice['workers']] + plan.setdefault('worker_stage_selection', {})[backend] = dict( + workers=worker_choice['workers'], batch_size=1, + pilot_lightcurves_per_second=worker_choice['summary']['median_repetition_lightcurves_per_second']) + write(receipt_path, plan) + for workers in worker_counts: + for batch in args.batches: + if batch != 1: + records.append(run(backend, workers, batch, 'mixed', reference)) + chosen = winner(records) + if chosen is not None: + plan['selected'][backend] = dict(workers=chosen['workers'], batch_size=chosen['batch_size'], + pilot_lightcurves_per_second=chosen['summary']['median_repetition_lightcurves_per_second'], + qualification_reference=str(reference.resolve())) + write(receipt_path, plan) + else: + # A fresh independent cohort requires its own literal single-worker + # spectra before the selected concurrent configuration can qualify. + for backend in args.backends: + chosen = plan['selected'].get(backend) + if chosen is None: + for scope in (*REGIMES, 'varied'): + unavailable(backend, scope, 'No qualifying setting in the frozen development tuning') + continue + for scope in (*REGIMES, 'varied'): + # Reference generation uses the regular runner, retaining a + # complete successful scalar queue; these are labeled and + # never included in the selected-setting figure. + reference = None + if chosen['workers'] > 1: + reference_dir = args.output/'qualification'/scope/backend + reference_dir.mkdir(parents=True, exist_ok=True) + input_manifest = varied_manifest if scope == 'varied' else args.manifest + input_names = varied_names if scope == 'varied' else names + ref_command = [sys.executable, str(Path(__file__).with_name('throughput.py')), + '--manifest', str(input_manifest.resolve()), '--output', str(reference_dir.resolve()), + '--backend', backend, '--workers', '1', '--batch-size', '1', + '--hourly-usd', str(args.hourly_usd), '--repetitions', '1', '--min-sources', '1', + '--min-seconds', '0', '--names', + *[name for name in input_names if scope == 'varied' or name.startswith(scope+'_')]] + if backend in ('candidate', 'baseline', 'bls'): + source = args.baseline_root if backend == 'baseline' else args.candidate_root + ref_command += ['--source-root', str(source.resolve())] + if args.science_seal: + ref_command += ['--science-seal', str(args.science_seal.resolve())] + reference = reference_dir/'result.json' + if not (args.resume and reference.exists()): + with (reference_dir/'run.log').open('w') as stream: + subprocess.run(ref_command, stdout=stream, stderr=subprocess.STDOUT, check=False) + if not reference.exists() or not eligible(json.loads(reference.read_text())): + unavailable(backend, scope, 'Fresh one-worker required-output qualification failed', reference) + continue + run(backend, chosen['workers'], chosen['batch_size'], scope, reference) + if 'baseline' in args.backends and 'candidate' in args.backends: + plan['baseline_candidate_spectra'] = cross_baseline_qualification(args.output, plan['configs']) + for check in plan['baseline_candidate_spectra']['checks']: + if not check['exact']: + unavailable('candidate', check['scope'], 'Paired baseline/optimized complete spectra differ') + if args.stage == 'tune' and not plan['baseline_candidate_spectra']['passed']: + plan['selected'].pop('candidate', None) + plan['status'] = 'complete' + except BaseException as error: + plan.update(status='interrupted', error=repr(error)) + raise + finally: + plan['elapsed_this_invocation_seconds'] = time.perf_counter()-started + write(receipt_path, plan) + summary_table(args.output, plan['configs'], plan['unavailable']) + + +if __name__ == '__main__': + main() diff --git a/benchmarks/transit/README.md b/benchmarks/transit/README.md new file mode 100644 index 00000000..84f53a6e --- /dev/null +++ b/benchmarks/transit/README.md @@ -0,0 +1,30 @@ +# Transit benchmark tools + +These tools analyze and reproduce parts of the [2026-09-08 transit experiment](../results/transit_2026-09-08/README.md). Its TLS arm uses the earlier **binned** engine, now retained as `method='binned'`. The [current report](../../docs/TRANSIT_BENCHMARKS.md) distinguishes those historical TLS results from the standard observation-level search and identifies the BLS measurements used for release claims. Run commands from the repository root. Plotting and analysis require Python, NumPy, SciPy and Matplotlib; backend searches additionally require the pinned scientific packages and a CUDA device for GPU methods. + +Regenerate the current timing figure without a GPU: + +```bash +python benchmarks/transit/plot_main.py \ + --root benchmarks/results/transit_2026-09-08 \ + --tls-reference benchmarks/results/tls_reference_2026-09-10 \ + --output-dir /tmp/cuvarbase-figure +``` + +| Tool | Purpose | +|---|---| +| `plot_main.py` | Six-panel timing figure: BLS and TLS across three cadences; accepts current or historical TLS evidence | +| `analyze.py`, `recovery_statistics.py` | Independent null calibration, injection recovery, false positives and paired confidence bounds | +| `analyze_timings.py`, `analyze_runtime_cohorts.py` | Timing medians, repetition ranges, cost projections and cohort checks | +| `analyze_components.py` | Component tables and ablations | +| `worker.py`, `cpu_batch.py`, `grid_and_search.py` | Backend searches and timing jobs; configuration is supplied explicitly | +| `components.py`, `components_tls.py` | Diagnostic BLS and TLS component measurements | +| `generate.py` | Construct seeded synthetic flux/noise on the retained observed cadences | + +The committed [inputs](../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived directory: benchmarks/results/transit_2026-09-08/inputs") and [selection record](../../docs/BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/selection.json") define the measured experiment. Use each worker's `--help` for arguments; `worker.py --config` takes a JSON configuration from the selected method records. Install the selected backend in its own environment, including fBLS on the import path when selecting that backend. The original cloud controller and environment setup are retained in the pinned Git archive described below; no cloud resources are started by the analysis or plotting tools. + +The command above combines this experiment's BLS measurements with the current observation-level TLS study. For the historical binned comparison, replace `--tls-reference` with `--tls-study benchmarks/results/tls_sensitivity_2026-09-09`; omit both options to recreate the initial September 8 figure. Those older TLS figures do not describe the new default engine. + +Analysis scripts write into `--root`. Use a scratch copy to recompute tables. Without `--verify-arrays`, recovery and timing analysis checks committed per-job summaries and inputs; it does not re-verify the omitted periodograms, and records that distinction in its output. `analyze_components.py` and full-array recovery/timing validation require restoring the periodogram archive. Do not overwrite the published verification receipts with a summary-only rerun. + +The [archive notes](../results/transit_2026-09-08/ARCHIVE.md) explain exactly which evidence is present, how to retrieve the frozen original harness, and which files require the larger local archive. The moved workers retain the numerical search implementation; module lookup paths have been made independent of the original pod. A fresh run measures its own hardware and environment and must record new provenance. diff --git a/benchmarks/transit/analyze.py b/benchmarks/transit/analyze.py new file mode 100644 index 00000000..50fbe6d0 --- /dev/null +++ b/benchmarks/transit/analyze.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Verify held-out bookkeeping and derive recovery, FPR and paired comparisons.""" +import argparse,csv,hashlib,json +from pathlib import Path +import numpy as np +from recovery_statistics import paired,summarize + + +def sha(p):return hashlib.sha256(Path(p).read_bytes()).hexdigest() +def table(p,rows): + keys=list(dict.fromkeys(k for r in rows for k in r)) + with p.open('w',newline='') as f: + w=csv.DictWriter(f,fieldnames=keys);w.writeheader();w.writerows(rows) + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--root',type=Path,required=True);ap.add_argument('--verify-arrays',action='store_true') + a=ap.parse_args();r=a.root + declared=json.loads((r/'validation-methods.json').read_text()) + assert declared['selection_sha256']==sha(r/'selection.json') + assert declared['operational_selection_sha256']==sha(r/'operational-selection.json') + records={};summaries=[];errors=[];checked=0;snrrows=[] + for m in declared['methods']: + profile,tag=m['profile'],m['tag'];parts={} + for split in ['calibration','heldout']: + folder=r/'results'/f'{split}_{profile}_{tag}';p=folder/'summary.json' + if not p.exists():errors.append('Missing job '+str(folder));continue + d=json.loads(p.read_text());e=json.loads((folder/'execution.json').read_text()) + if d['status']!='ok' or e['exit_code']!=0:errors.append('Incomplete job '+str(folder));continue + inp=r/'inputs'/d['input_file'] + if sha(inp)!=d['input_sha256']:errors.append('Input hash '+str(inp)) + with np.load(inp) as data:meta=json.loads(str(data['metadata'])) + cases=sorted(d['cases'],key=lambda c:c['index']) + if [c['index'] for c in cases]!=list(range(len(meta['cases']))):errors.append('Incomplete case indices '+str(folder));continue + for c in cases: + truth=meta['cases'][c['index']] + if c['injected']!=truth['injected'] or c['snr']!=truth['target_white_oracle_snr']:errors.append('Truth metadata mismatch '+str(folder)) + expected=bool(c['period'] is not None and abs(c['period']/truth['period']-1)*meta['baseline']<=.5*truth['duration']) if truth['injected'] else None + if expected!=c['recovered']:errors.append('Recovery classification '+str(folder)+str(c['index'])) + if c.get('output_file'): + out=folder/c['output_file'] + if a.verify_arrays: + if sha(out)!=c['output_sha256']:errors.append('Output hash '+str(out)) + with np.load(out) as v: + if len(v['periods'])!=c['n_periods'] or v['periods'].shape!=v['power'].shape:errors.append('Output shape '+str(out)) + finite=float(np.isfinite(v['power']).mean()) if len(v['power']) else 0. + if abs(finite-c['finite_fraction'])>1e-12:errors.append('Finite coverage '+str(out)) + checked+=1 + parts[split]=cases + if len(parts)!=2:continue + summary=dict(profile=profile,method=tag,family=m['family'],config=m['config'],**summarize(parts['calibration'],parts['heldout'])) + records[(profile,tag)]=summary;summaries.append(summary) + for row in summary['by_snr']:snrrows.append(dict(profile=profile,method=tag,**row)) + comparisons=[] + for profile in ['ztf','tess_200s','tess_gap']: + for v1,competitor in [('bls_v1','bls_pypi'),('bls_v1','bls_cpu'),('bls_v1','bls_gpu'),('bls_v1_batch','bls_pypi'), + ('bls_v1_batch','bls_cpu'),('bls_v1_batch','bls_gpu'),('tls_v1','gtls'),('tls_v1','gtls_batch')]: + if (profile,v1) not in records or (profile,competitor) not in records:continue + av,bv=records[(profile,v1)],records[(profile,competitor)] + period=paired(av['period_recovered_vector'],bv['period_recovered_vector']) + detection=paired(av['detected_vector'],bv['detected_vector']) + falsepositive=paired(av['false_positive_vector'],bv['false_positive_vector']) + valid=all(x['invalid_calibration']==x['invalid_heldout']==0 for x in [av,bv]) + comparisons.append(dict(profile=profile,v1=v1,comparator=competitor,all_api_results_valid=valid, + period_noninferior=valid and period['noninferior_5pp'],detection_noninferior=valid and detection['noninferior_5pp'], + fpr_increase_upper_95=falsepositive['upper_95_one_sided'], + comparable_detection=valid and detection['noninferior_5pp'] and falsepositive['upper_95_one_sided']<.05, + period_comparison=period,detection_comparison=detection,false_positive_comparison=falsepositive)) + compact=[] + for s in summaries: + compact.append({k:v for k,v in s.items() if k not in ['config','by_snr'] and not k.endswith('_vector')}) + table(r/'recovery_summary.csv',compact);table(r/'recovery_by_snr.csv',snrrows) + table(r/'paired_comparisons.csv',comparisons) + result=dict(methods=summaries,comparisons=comparisons,verification=dict(errors=errors,output_arrays_checked=checked,arrays_verified=a.verify_arrays,complete=not errors)) + (r/'recovery_analysis.json').write_text(json.dumps(result,indent=2,allow_nan=False)+'\n') + print(json.dumps(dict(methods=len(summaries),comparisons=len(comparisons),errors=errors,output_arrays_checked=checked),indent=2)) + if errors:raise SystemExit(1) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/analyze_components.py b/benchmarks/transit/analyze_components.py new file mode 100644 index 00000000..7e90efa4 --- /dev/null +++ b/benchmarks/transit/analyze_components.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Summarize synchronized wall phases and numerical effects of diagnostic ablations.""" +import argparse,json +from pathlib import Path +import numpy as np +from analyze import sha,table + + +def candidate(d):return d['native_candidate'] + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--root',type=Path,required=True);a=ap.parse_args();r=a.root + declared=json.loads((r/'components.json').read_text());records={};phases=[];summaries=[];errors=[] + for job in declared: + folder=r/'results'/job['name'];d=json.loads((folder/'summary.json').read_text());e=json.loads((folder/'execution.json').read_text()) + if d['status']!='ok' or e['exit_code']!=0:errors.append('Failed component job '+job['name']);continue + if sha(r/'inputs'/job['input'])!=d['input_sha256']:errors.append('Component input hash '+job['name']) + if sha(folder/'outputs.npz')!=d['output_sha256']:errors.append('Component output hash '+job['name']) + records[job['name']]=d;profiles=[v.get('phases',v) for v in d['profiles']] + totals=[sum(p['exclusive_s'] for p in profile.values()) for profile in profiles] + names=sorted(set(k for profile in profiles for k in profile)) + for name in names: + values=[profile.get(name,{}).get('exclusive_s',0.) for profile in profiles] + phases.append(dict(job=job['name'],profile=d['profile'],phase=name,exclusive_mean_s=float(np.mean(values)), + calls_mean=float(np.mean([profile.get(name,{}).get('calls',0) for profile in profiles])), + fraction_of_profile=float(np.mean(values))/float(np.mean(totals)))) + with np.load(folder/'outputs.npz') as v: + instrumentation_delta=float(np.max(np.abs(v['native']-v['profiled']))) + summaries.append(dict(job=job['name'],profile=d['profile'],native_median_s=d['native_median_s'], + synchronized_profile_mean_s=float(np.mean(totals)),profile_over_native=float(np.mean(totals))/d['native_median_s'], + instrumentation_max_abs_power_difference=instrumentation_delta, + grid_median_s=d.get('grid_median_s'),grid_float32_equal=d.get('grid_float32_equal'))) + ablations=[] + for profile in ['tess_200s','tess_gap','ztf']: + for base,variant,meaning in [(f'component_{profile}_bls_v1',f'component_{profile}_bls_v1_unfused','Public unfused phase passes versus fused histogram'), + (f'component_{profile}_bls_v1',f'component_{profile}_bls_v1_no_scatter','Diagnostic chronological input versus conflict-scattered observation order'), + (f'component_{profile}_gtls_native',f'component_{profile}_gtls_both','Diagnostic batching of two GTLS host loops')]: + aa,bb=records[base],records[variant] + with np.load(r/'results'/base/'outputs.npz') as av,np.load(r/'results'/variant/'outputs.npz') as bv: + equal=bool(np.array_equal(av['periods'],bv['periods']));assert equal + delta=float(np.max(np.abs(av['native']-bv['native'])));exact=bool(np.array_equal(av['native'],bv['native'])) + ca,cb=candidate(aa),candidate(bb) + ablations.append(dict(profile=profile,baseline=base,variant=variant,meaning=meaning, + baseline_s=aa['native_median_s'],variant_s=bb['native_median_s'],variant_over_baseline=bb['native_median_s']/aa['native_median_s'], + periods_equal=equal,powers_equal=exact,max_abs_power_difference=delta,candidate_period_equal=ca['period']==cb['period'], + candidate_score_abs_difference=abs(ca['score']-cb['score']),power_units='native SDE' if aa['config'].get('fast') else 'BLS chi2 ratio')) + table(r/'component_summary.csv',summaries);table(r/'component_phases.csv',phases);table(r/'component_ablations.csv',ablations) + result=dict(components=summaries,phases=phases,ablations=ablations,verification=dict(complete=not errors,errors=errors), + note='Synchronized wall regions perturb execution; attribution uses profile fractions alongside ordinary uninstrumented API timings. Ablations are diagnostic, not released competitors, and their gains cannot simply be multiplied.') + (r/'component_analysis.json').write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(dict(jobs=len(records),ablations=len(ablations),errors=errors),indent=2)) + if errors:raise SystemExit(1) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/analyze_runtime_cohorts.py b/benchmarks/transit/analyze_runtime_cohorts.py new file mode 100644 index 00000000..70af6f05 --- /dev/null +++ b/benchmarks/transit/analyze_runtime_cohorts.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Check flux-dependent runtime on all held-out cases measured on the original pod.""" +import argparse,json +from pathlib import Path +import numpy as np +from analyze import table + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--root',type=Path,required=True);a=ap.parse_args();r=a.root + rows=[];ratios=[] + for m in json.loads((r/'validation-methods.json').read_text())['methods']: + # The serial GTLS cases were run on other machines and cannot supply same-host ratios. + if m['tag']=='gtls':continue + d=json.loads((r/'results'/f"heldout_{m['profile']}_{m['tag']}"/'summary.json').read_text()) + assert d['status']=='ok' and not d.get('partitioned_recovery') + chunk=m['config'].get('eval_chunk',1);cases=sorted(d['cases'],key=lambda c:c['index']);stats={} + for injected,label in [(True,'injected'),(False,'null')]: + chosen=[c for c in cases if c['injected']==injected];assert len(chosen)==128 + assert all(c.get('api_result_valid',True) and c.get('finite_fraction',0.)>0. for c in chosen) + times=np.array([c['search_s'] for c in chosen]);assert np.all(np.isfinite(times)&(times>0)) + # Each eval_chunk source group has one shared wall measurement; don't count + # its repeated per-source value as independent timing repetitions. + for j in range(0,len(chosen),chunk): + assert len({c['search_s'] for c in chosen[j:j+chunk]})==1 + row=dict(profile=m['profile'],method=m['tag'],cohort=label,sources=128,independent_search_calls=128//chunk, + sources_per_call=chunk,mean_seconds_per_source=float(times.mean()),median_seconds_per_source=float(np.median(times)), + partial_spectra=sum(c.get('finite_fraction',0.)<1. for c in chosen), + p10_seconds_per_source=float(np.quantile(times,.1)),p90_seconds_per_source=float(np.quantile(times,.9))) + rows.append(row);stats[label]=row + ratios.append(dict(profile=m['profile'],method=m['tag'],sources_per_call=chunk, + injection_over_null_mean=stats['injected']['mean_seconds_per_source']/stats['null']['mean_seconds_per_source'])) + table(r/'runtime_by_cohort.csv',rows);table(r/'runtime_cohort_ratios.csv',ratios) + result=dict(complete=True,cohorts=rows,ratios=ratios, + note='Secondary runtime check from original-pod validation calls, after API warmup and excluding result compression/I/O. Independent distinct sources; one call per source or source group, not repeated timing trials. Serial GTLS recovery from auxiliary nodes is excluded. Main speed/cost ratios use the separate exclusive timing manifest.') + (r/'runtime_cohort_analysis.json').write_text(json.dumps(result,indent=2)+'\n') + print(json.dumps(ratios,indent=2)) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/analyze_timings.py b/benchmarks/transit/analyze_timings.py new file mode 100644 index 00000000..4d5e3de4 --- /dev/null +++ b/benchmarks/transit/analyze_timings.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Validate timing outputs against recovery runs; compute directly traceable ratios.""" +import argparse,hashlib,json +from pathlib import Path +import numpy as np +from analyze import table,sha + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--root',type=Path,required=True);ap.add_argument('--verify-arrays',action='store_true') + a=ap.parse_args();r=a.root;declared=json.loads((r/'timing-methods.json').read_text()) + assert sha(r/'validation-methods.json')==declared['validation_manifest_sha256'] + rows=[];errors=[];agreements=[];repetitions=[] + recovery=json.loads((r/'recovery_analysis.json').read_text()) + thresholds={(v['profile'],v['method']):v['threshold'] for v in recovery['methods']} + jobs={j['name']:j for j in json.loads((r/'timings.json').read_text())} + if (r/'cpu_operations.json').exists():jobs.update({j['name']:j for j in json.loads((r/'cpu_operations.json').read_text())}) + for m in declared['methods']: + folder=r/'results'/m['job'];d=json.loads((folder/'summary.json').read_text());e=json.loads((folder/'execution.json').read_text()) + if d['status']!='ok' or e['exit_code']!=0:errors.append('Unsuccessful timing '+m['job']);continue + if d['config']!=m['config']:errors.append('Timing config mismatch '+m['job']) + if sha(r/'inputs'/d['input_file'])!=d['input_sha256']:errors.append('Timing input mismatch '+m['job']) + ref_tag=m['tag']+'_batch' if m['tag'] in ['bls_v1','gtls'] and m['mode']=='batch16' else m['tag'] + ref_folder=r/'results'/f"heldout_{m['profile']}_{ref_tag}" + reference={c['index']:c for c in json.loads((ref_folder/'summary.json').read_text())['cases']} + threshold=thresholds[m['profile'],ref_tag] + if [c['index'] for c in d['cases']]!=list(range(128,128+m['n'])):errors.append('Timing source indices '+m['job']) + expected_reps=jobs[m['job']]['reps'] + if len(d['times_s'])!=expected_reps or len(d['timed_candidates'])!=expected_reps:errors.append('Timing repetition count '+m['job']) + for rep,values in enumerate(d['timed_candidates']): + values=[values] if isinstance(values,dict) else values + if len(values)!=m['n']:errors.append('Timed candidate count '+m['job']);continue + for offset,c in enumerate(values): + rc=reference[128+offset] + valid=c.get('api_result_valid',True) and c.get('finite_fraction')==1. and c.get('period') is not None and c.get('score') is not None + valid=valid and bool(np.isfinite(c['period']) and np.isfinite(c['score'])) + if not valid:errors.append('Invalid repetition '+m['job']+':'+str(rep)) + repetitions.append(dict(job=m['job'],rep=rep,index=128+offset,valid=valid, + candidate_numerically_equal=bool(valid and np.isclose(c['period'],rc['period'],rtol=1e-12,atol=0.)), + score_abs_difference=abs(c['score']-rc['score']) if valid else None, + calibrated_null_decision_equal=bool(valid and (c['score']>threshold)==(rc['score']>threshold)))) + for c in d['cases']: + rc=reference[c['index']];valid=c.get('api_result_valid',True) + agree=dict(job=m['job'],index=c['index'],valid=valid, + candidate_equal=c['period']==rc['period'],candidate_numerically_equal=bool(np.isclose(c['period'],rc['period'],rtol=1e-12,atol=0.)) if valid else False,score_abs_difference=abs(c['score']-rc['score']) if valid else None) + agree['calibrated_null_decision_equal']=bool(valid and (c['score']>threshold)==(rc['score']>threshold)) + if not valid:errors.append('Invalid timed output '+m['job']+str(c['index'])) + if a.verify_arrays and c.get('output_file') and rc.get('output_file'): + p=folder/c['output_file'];rp=ref_folder/rc['output_file'] + if sha(p)!=c['output_sha256'] or sha(rp)!=rc['output_sha256']:errors.append('Timing array hash '+str(p)) + with np.load(p) as v,np.load(rp) as rv: + agree['period_arrays_equal']=bool(np.array_equal(v['periods'],rv['periods'])) + agree['power_arrays_equal']=bool(np.array_equal(v['power'],rv['power'])) + agree['power_max_abs_difference']=float(np.max(np.abs(v['power']-rv['power']))) + agreements.append(agree) + t=np.array(d['times_s']);per=float(np.median(t))/m['n'] + if not np.all(np.isfinite(t)&(t>0)):errors.append('Invalid elapsed time '+m['job']) + if not np.isclose(d['seconds_per_source'],per,rtol=1e-14):errors.append('Timing arithmetic '+m['job']) + rows.append(dict(profile=m['profile'],method=m['tag'],family=m['family'],mode=m['mode'],n=m['n'],job=m['job'], + median_total_s=float(np.median(t)),seconds_per_source=per,min_total_s=float(t.min()),max_total_s=float(t.max()),reps=len(t), + initialization_s=d['initialization_s'],first_api_s=d['first_api_s'], + projected_gpu_usd_per_million=per*1e6*.49/3600 if m['tag']!='bls_cpu' else None)) + by={(v['profile'],v['method'],v['mode']):v for v in rows};ratios=[] + for profile in ['tess_200s','tess_gap','ztf']: + for mode in ['single','batch16']: + for v1,other in [('bls_v1','bls_pypi'),('bls_v1','bls_cpu'),('bls_v1','bls_gpu'),('tls_v1','gtls')]: + aa=by[(profile,v1,mode)];bb=by[(profile,other,mode)];speed=bb['seconds_per_source']/aa['seconds_per_source'] + ratios.append(dict(profile=profile,mode=mode,v1=v1,comparator=other,speedup=speed, + cpu_break_even_hourly_usd=.49/speed if other=='bls_cpu' else None)) + aa=by[profile,'bls_v1','fresh_grid'];bb=by[profile,'bls_pypi','fresh_grid'] + ratios.append(dict(profile=profile,mode='fresh_grid',v1='bls_v1',comparator='bls_pypi', + speedup=bb['seconds_per_source']/aa['seconds_per_source'],cpu_break_even_hourly_usd=None)) + result=dict(timings=rows,ratios=ratios,agreements=agreements,repetition_agreements=repetitions,verification=dict(errors=errors,arrays_verified=a.verify_arrays,complete=not errors), + cost_note='GPU bundle rental-equivalent linear search-only projections at $0.49/hour. CPU break-even price is an estimate, not a measured standalone CPU rental.') + table(r/'timing_summary.csv',rows);table(r/'speedups.csv',ratios) + (r/'timing_analysis.json').write_text(json.dumps(result,indent=2,allow_nan=False)+'\n') + print(json.dumps(dict(timing_jobs=len(rows),errors=errors,candidate_disagreements=sum(not x['candidate_equal'] for x in agreements), + repetition_candidate_disagreements=sum(not x['candidate_numerically_equal'] for x in repetitions), + repetition_null_decision_disagreements=sum(not x['calibrated_null_decision_equal'] for x in repetitions)),indent=2)) + if errors:raise SystemExit(1) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/components.py b/benchmarks/transit/components.py new file mode 100644 index 00000000..49bb149e --- /dev/null +++ b/benchmarks/transit/components.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""BLS wall-phase profiles and public-API fusion ablation; no installed source edits.""" +import argparse,ast,hashlib,inspect,json,sys,textwrap,time +from contextlib import contextmanager +from pathlib import Path +import numpy as np +from worker import Backend,sha,dump +import worker + + +class Profiler: + def __init__(self,sync):self.sync=sync;self.rows={};self.stack=[] + @contextmanager + def part(self,name): + self.sync();state=[time.perf_counter(),0.];self.stack.append(state) + try:yield + finally: + self.sync();elapsed=time.perf_counter()-state[0];self.stack.pop() + if self.stack:self.stack[-1][1]+=elapsed + row=self.rows.setdefault(name,dict(calls=0,inclusive_s=0.,exclusive_s=0.)) + row['calls']+=1;row['inclusive_s']+=elapsed;row['exclusive_s']+=elapsed-state[1] + def wrap(self,name,fn): + def wrapped(*a,**kw): + with self.part(name):return fn(*a,**kw) + return wrapped + + +class Kernel: + def __init__(self,fn,profile,name):self.fn=fn;self.profile=profile;self.name=name + def __getattr__(self,name): + value=getattr(self.fn,name) + return self.profile.wrap('GPU kernel launches (synchronized)',value) if name in ['prepared_call','prepared_async_call'] else value + + +class Scans(ast.NodeTransformer): + def visit_Assign(self,node): + if ast.unparse(node.targets[0]) in ['max_nbins','global_max_nbins']: + return ast.copy_location(ast.With(items=[ast.withitem(context_expr=ast.Call( + func=ast.Attribute(value=ast.Name(id='_BLS_PROFILE',ctx=ast.Load()),attr='part',ctx=ast.Load()), + args=[ast.Constant('Host maximum-bin scan')],keywords=[]))],body=[node]),node) + return node + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--input',required=True);ap.add_argument('--config',required=True) + ap.add_argument('--out',type=Path,required=True);a=ap.parse_args();a.out.mkdir(parents=True,exist_ok=True) + d=np.load(a.input);cfg=json.loads(a.config);meta=json.loads(str(d['metadata']));i=2 + lc=tuple(np.array(d[f'{k}_{i}']) for k in ['t','y','dy']);b=Backend(cfg,d,len(lc[0])) + if cfg.get('no_scatter'): + b.bls._cached_conflict_scatter_perm=lambda n:None + b.search([lc]) + times=[] + for rep in range(3): + b.sync();start=time.perf_counter();native=b.search([lc])[0];b.sync();times.append(time.perf_counter()-start) + m=b.bls;p=Profiler(b.sync) + # Rebuild exactly one function with two max-bin scans wrapped in timers. + target='_eebls_gpu_fast_impl' if cfg['backend'].startswith('v1') else 'eebls_gpu_fast' + old=getattr(m,target);source=inspect.getsource(old);tree=Scans().visit(ast.parse(textwrap.dedent(source)));ast.fix_missing_locations(tree) + transformed=ast.unparse(tree)+'\n';path=a.out/'instrumented_bls.py';path.write_text(transformed) + # Keep the original globals live, so wrapped lookup/memory methods below are resolved normally. + m.__dict__['_BLS_PROFILE']=p;exec(compile(tree,str(path),'exec'),m.__dict__) + for name,label in [('setdata','Host preparation and H2D'),('transfer_data_to_cpu','Spectrum D2H')]: + setattr(m.BLSMemory,name,p.wrap(label,getattr(m.BLSMemory,name))) + if hasattr(m,'_pooled_bls_memory'): + m._pooled_bls_memory=p.wrap('Memory pool / allocation',m._pooled_bls_memory) + if hasattr(m,'_get_cached_kernels'): + original=m._get_cached_kernels + def cached(*args,**kwargs): + with p.part('Kernel cache lookup'): + return {k:Kernel(fn,p,k) for k,fn in original(*args,**kwargs).items()} + m._get_cached_kernels=cached + if b.functions is not None:b.functions={k:Kernel(fn,p,k) for k,fn in b.functions.items()} + worker.spectral_candidate=p.wrap('Common candidate ranking',worker.spectral_candidate) + profiles=[] + for rep in range(2): + p.rows={} + with p.part('Remaining public API work'): + profiled=b.search([lc])[0] + profiles.append(p.rows) + gridtimes=[] + grid_index=next(j for j in range(len(meta['cases'])) if np.ptp(d[f't_{j}'])==meta['baseline']) + grid_t=np.array(d[f't_{grid_index}']) + for rep in range(3): + start=time.perf_counter() + f,q=m.transit_autofreq(grid_t,rho=1.,samples_per_peak=2,qmin_fac=.5, + fmin=1/meta['pmax'],fmax=1/meta['pmin']) + take=f<=1/meta['pmin'];f=f[take];q=q[take] + gridtimes.append(time.perf_counter()-start) + # Actual source baseline can differ slightly after independently missing endpoint samples. + fullbaseline=float(np.ptp(grid_t)) + np.savez_compressed(a.out/'outputs.npz',native=native['power'],profiled=profiled['power'],periods=native['periods'],auto_freqs=f,auto_q=q) + dump(a.out/'summary.json',dict(status='ok',profile=meta['profile'],config=cfg,input_sha256=sha(a.input), + worker_sha256=sha(__file__),instrumented_sha256=sha(path),original_function_sha256=hashlib.sha256(source.encode()).hexdigest(), + native_times_s=times,native_median_s=float(np.median(times)),profiles=profiles, + native_candidate=native['candidate'],profiled_candidate=profiled['candidate'], + max_abs_power_difference=float(np.max(np.abs(native['power']-profiled['power']))), + grid_times_s=gridtimes,grid_median_s=float(np.median(gridtimes)),grid_baseline=fullbaseline,grid_observed_time_index=grid_index,n_auto_freqs=len(f),grid_float32_equal=bool(np.array_equal(f.astype(np.float32),d['freqs'].astype(np.float32))), + output_sha256=sha(a.out/'outputs.npz'),meaning='Synchronized wall regions, not kernel-busy traces. Grid construction is measured separately and excluded from explicit-grid API timings.')) + print('BLS_COMPONENTS_COMPLETE',flush=True) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/components_tls.py b/benchmarks/transit/components_tls.py new file mode 100644 index 00000000..ad594f0b --- /dev/null +++ b/benchmarks/transit/components_tls.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Profile the selected TLS API configuration on the new observed cadences.""" +import argparse,importlib,json,sys,time +from pathlib import Path +import numpy as np +from worker import Backend,sha,dump +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'tls_profile')) +from profile_tls import Profiler,rebuild,compare + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--input',required=True);ap.add_argument('--config',required=True) + ap.add_argument('--out',type=Path,required=True);ap.add_argument('--variant',default='native') + a=ap.parse_args();a.out.mkdir(parents=True,exist_ok=True);cfg=json.loads(a.config);cfg['workers']=1 + d=np.load(a.input);meta=json.loads(str(d['metadata']));lc=tuple(np.array(d[f'{k}_2']) for k in ['t','y','dy']) + b=Backend(cfg,d,len(lc[0]));transforms=[];path=a.out/'profile.json' + if cfg['backend']=='gtls': + core=importlib.import_module('gputls.core') + if a.variant!='native':transforms.append(rebuild(core,'search_multi_periods',path,variant=a.variant)) + b.search([lc]);times=[] + for rep in range(3): + b.sync();start=time.perf_counter();native=b.search([lc])[0];b.sync();times.append(time.perf_counter()-start) + profiler=Profiler(b.sync) + if cfg['backend']=='gtls': + from gputls import gtls + transforms.append(rebuild(core,'search_multi_periods',path,instrument=True,kind='gtls',profiler=profiler)) + transforms.append(rebuild(gtls,'power',path,instrument=True,kind='power',profiler=profiler)) + else: + from cuvarbase import tls + name = '_tls_search_batch_binned' if hasattr(tls, '_tls_search_batch_binned') else 'tls_search_batch' + transforms.append(rebuild(tls,name,path,instrument=True,kind='v1',profiler=profiler));b.tls=getattr(tls,name) + profiles=[] + for rep in range(2): + profiler.clear() + with profiler.segment('API remainder'): + out=b.search([lc])[0] + profiles.append(dict(phases=profiler.rows,total_s=sum(v['exclusive_s'] for v in profiler.rows.values()))) + np.savez_compressed(a.out/'outputs.npz',periods=native['periods'],native=native['power'],profiled=out['power']) + dump(a.out/'summary.json',dict(status='ok',profile=meta['profile'],config=cfg,variant=a.variant, + input_sha256=sha(a.input),worker_sha256=sha(__file__),instrumentation_sha256=sha('/tmp/cuvarbase-tls-profile/profile_tls.py'), + native_times_s=times,native_median_s=float(np.median(times)),profiles=profiles,transformations=transforms, + native_candidate=native['candidate'],profiled_candidate=out['candidate'], + output_agreement=compare({'periods':native['periods'],'power':native['power']},{'periods':out['periods'],'power':out['power']}), + output_sha256=sha(a.out/'outputs.npz'),meaning='Synchronized wall phases in one worker. Diagnostic Python ablations are not released GTLS.')) + print('TLS_COMPONENTS_COMPLETE',flush=True) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/cpu_batch.py b/benchmarks/transit/cpu_batch.py new file mode 100644 index 00000000..a6c04a90 --- /dev/null +++ b/benchmarks/transit/cpu_batch.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Astropy operational alternative: parallelize distinct sources for batch throughput.""" +import sys +from pathlib import Path +import numpy as np +import worker +from worker import astropy_piece,qtransit,sha + + +def astropy_source(job): + lc,frequencies,chunks,oversample=job + power=np.empty(len(frequencies)) + for ids,lo,hi in chunks: + dmin=.5*qtransit(lo)*lo;dmax=min(2*qtransit(hi)*hi,lo*.95) + durations=np.geomspace(dmin,dmax,int(np.ceil(np.log(dmax/dmin)/np.log(1.1)))+1) + power[ids]=astropy_piece((lc,1/frequencies[ids],durations,oversample)) + return power + + +class AcrossSources(worker.Backend): + def search(self,lcs): + assert self.kind=='astropy' + if len(lcs)==1:return super().search(lcs) + jobs=[(lc,self.f,self.chunks,self.cfg.get('epoch_os',10)) for lc in lcs] + powers=list(self.pool.map(astropy_source,jobs) if self.pool else map(astropy_source,jobs)) + return [dict(periods=1/self.f,power=p,candidate=worker.spectral_candidate(1/self.f,p)) for p in powers] + + +if __name__=='__main__': + # The frozen controller passes --indices only to its original worker.py. + # This bounded adapter explicitly supplies the two declared timing subsets. + if '--timing' in sys.argv and '--indices' not in sys.argv: + filename=Path(sys.argv[sys.argv.index('--input')+1]).name + assert filename in ['tess_200s_tune.npz','tess_200s_heldout.npz'] + first=0 if filename.endswith('_tune.npz') else 128 + sys.argv+=['--indices',','.join(map(str,range(first,first+16)))] + original_dump=worker.dump + def wrapped_dump(path,record): + record['wrapper_sha256']=sha(__file__) + record['operational_boundary']='Same Astropy period chunks and duration/epoch grids; parallelize across distinct sources when batch size exceeds one.' + return original_dump(path,record) + worker.dump=wrapped_dump;worker.Backend=AcrossSources;worker.main() diff --git a/benchmarks/transit/generate.py b/benchmarks/transit/generate.py new file mode 100644 index 00000000..751378cf --- /dev/null +++ b/benchmarks/transit/generate.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Freeze shared transit injections on observed ZTF and QLP cadences.""" +import argparse, hashlib, importlib.util, json +from pathlib import Path +import numpy as np +import batman +from astropy.io import fits + + +def sha(p): return hashlib.sha256(Path(p).read_bytes()).hexdigest() + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--root',type=Path,required=True);ap.add_argument('--calibration',action='store_true') + args=ap.parse_args();root=args.root;src=root/'sources';out=root/'inputs';out.mkdir(exist_ok=True) + module=Path(__file__).resolve().parents[2]/'cuvarbase/bls_frequencies.py' + spec=importlib.util.spec_from_file_location('grids',module);grid=importlib.util.module_from_spec(spec);spec.loader.exec_module(grid) + cadences={};source_files={} + p=Path(__file__).resolve().parents[1]/'results/transit_2026-09-08/inputs/cadence_sources/real_ztf_heldout.npz' + with np.load(p) as d: + t=np.concatenate([d[f't_0_{b}'] for b in range(2)]) + band=np.concatenate([np.full(len(d[f't_0_{b}']),b) for b in range(2)]) + err=np.concatenate([d[f'dy_0_{b}'] for b in range(2)]) + err=np.clip(err,*np.quantile(err,[.1,.9]));err/=np.median(err) + cadences['ztf']=(t,err,band,np.full(len(t),30/86400)) + source_files['ztf']=[dict(file=str(p),sha256=sha(p),use='Observed times and clipped relative uncertainty pattern, no observed flux')] + tess={} + for s in [1,27,67]: + p=src/f'qlp-tic261136679-s{s:04}.fits' + with fits.open(p) as f: + d=f[1].data;good=np.isfinite(d['TIME'])&(d['QUALITY']==0) + t=np.asarray(d['TIME'][good],float) + dt={1:1800,27:600,67:200}[s]/86400 + tess[s]=(t,np.full(len(t),np.sqrt(1800/(dt*86400))),np.full(len(t),s),np.full(len(t),dt)) + cadences['tess_200s']=tess[67] + cadences['tess_gap']=tuple(np.concatenate([tess[s][j] for s in [1,27]]) for j in range(4)) + for name,sectors in [('tess_200s',[67]),('tess_gap',[1,27])]: + source_files[name]=[dict(file=f'qlp-tic261136679-s{s:04}.fits',sha256=sha(src/f'qlp-tic261136679-s{s:04}.fits'), + use='Observed TIME and QUALITY only; not observed flux') for s in sectors] + manifests=[] + for pi,(name,raw) in enumerate(cadences.items()): + order=np.argsort(raw[0]);t,relative,band,exposure=[v[order] for v in raw];t=t-t.min() + baseline=float(np.ptp(t));pmax={'ztf':10.,'tess_200s':baseline/2,'tess_gap':27.457888046800917}[name] + pmin=2**1.5/8.6307 + # QLP's published samples_per_peak=2, qmin_fac=.5 recursion, evaluated in float64. + frequencies=grid._euler_transit_grid(1/pmax,1/pmin,.5,2*baseline,8.6307) + frequencies=frequencies[frequencies<=1/pmin] + q=grid._q_transit(frequencies) + tls_periods=np.sort(1/frequencies[frequencies<=1/.6]) + for split,ninj,nnull in ([('calibration',0,128)] if args.calibration else [('tune',32,32),('heldout',128,128)]): + arrays=dict(freqs=frequencies,q=q,tls_periods=tls_periods) + rows=[] + for i in range(ninj+nnull): + seed=820260908+100000*pi+({'tune':0,'heldout':10000,'calibration':20000}[split])+i + rng=np.random.default_rng(seed) + # Independent 0--3% losses make the input arrays distinct; retain real gaps/cadence. + keep=rng.random(len(t))>rng.uniform(0,.03) + tt,rr,bb,ee=[v[keep] for v in [t,relative,band,exposure]] + injected=i=5 and len(events)>=2: break + if attempts>1000: raise RuntimeError('Observable-injection sampling exhausted') + w=rr**-2;signal=model-np.dot(w,model)/w.sum() + white_scale=np.sqrt(np.dot(w,signal*signal))/target + dy=white_scale*rr + # 25%-amplitude OU residual: 5.9% of total noise variance at equal errors. + tau=.15 if name.startswith('tess') else 1. + z=rng.normal(size=len(tt));red=np.empty(len(tt));red[0]=z[0] + for j in range(1,len(tt)): + aou=np.exp(-(tt[j]-tt[j-1])/tau) + red[j]=aou*red[j-1]+np.sqrt(1-aou*aou)*z[j] + y=(model if injected else np.ones(len(tt)))+rng.normal(size=len(tt))*dy+.25*np.median(dy)*red + duration=period/np.pi*np.arcsin(np.sqrt((1+rp)**2-impact**2)/np.sqrt(a*a-impact**2)) + arrays.update({f't_{i}':tt,f'y_{i}':y,f'dy_{i}':dy,f'band_{i}':bb}) + rows.append(dict(index=i,seed=seed,injected=injected,target_white_oracle_snr=target if injected else None, + period=period,epoch=epoch,rp=rp,impact=impact,duration=duration,ndata=len(tt), + n_in_transit=int(inside.sum()),observed_transit_events=len(events),ephemeris_draws=attempts, + noise_scale=white_scale,white_oracle_snr=float(np.sqrt(np.sum((signal/dy)**2))) if injected else None)) + metadata=dict(profile=name,split=split,baseline=baseline,pmin=pmin,pmax=pmax, + n_injections=ninj,n_nulls=nnull,source_files=source_files[name],cases=rows, + period_grid=dict(bls=len(frequencies),tls=len(tls_periods),tls_pmin=float(tls_periods.min())), + scope='Controlled recovery conditional on >=5 in-transit observations and >=2 observed transit events; observed cadences, synthetic flux and noise; solar density, circular orbits, known normalized band baselines.', + noise='Independent Gaussian errors plus OU residual of 0.25 median error amplitude; tau=0.15 days TESS / 1 day ZTF. Quoted SNR is white-noise oracle, not native SDE or pink SNR.', + generator_sha256=sha(__file__),grid_source_sha256=sha(module)) + arrays['metadata']=np.array(json.dumps(metadata)) + p=out/f'{name}_{split}.npz';np.savez_compressed(p,**arrays) + manifests.append(dict(file=p.name,sha256=sha(p),**{k:v for k,v in metadata.items() if k!='cases'})) + print(name,split,'N',min(r['ndata'] for r in rows),max(r['ndata'] for r in rows),'periods',len(frequencies),len(tls_periods),flush=True) + (out/('calibration-manifest.json' if args.calibration else 'manifest.json')).write_text(json.dumps(manifests,indent=2)+'\n') + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/grid_and_search.py b/benchmarks/transit/grid_and_search.py new file mode 100644 index 00000000..bf90b524 --- /dev/null +++ b/benchmarks/transit/grid_and_search.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Time a fresh native Keplerian grid plus one BLS search, retaining grid agreement.""" +import argparse,importlib.metadata,json,os,sys,time +from pathlib import Path +import numpy as np +import worker +from worker import Backend,dump,sha + + +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--input',required=True);ap.add_argument('--config',required=True) + ap.add_argument('--out',type=Path,required=True);ap.add_argument('--timing',action='store_true');ap.add_argument('--reps',type=int,default=5) + a=ap.parse_args();a.out.mkdir(parents=True,exist_ok=True);d=np.load(a.input);cfg=json.loads(a.config);meta=json.loads(str(d['metadata']));i=128 + lc=tuple(np.array(d[f'{k}_{i}']) for k in ['t','y','dy']);expected=np.asarray(d['freqs']);start=time.perf_counter() + b=Backend(cfg,d,len(lc[0]));b.sync();initialization=time.perf_counter()-start + # Select a retained observed time vector with the declared full baseline, so the + # new grid is scientifically the same as the supplied grid. Its scan is timed. + grid_index=next(j for j in range(len(meta['cases'])) if np.ptp(d[f't_{j}'])==meta['baseline']) + grid_t=np.array(d[f't_{grid_index}']) + def call(): + f,q=b.bls.transit_autofreq(grid_t,rho=1.,samples_per_peak=2,qmin_fac=.5,fmin=1/meta['pmax'],fmax=1/meta['pmin']) + keep=f<=1/meta['pmin'];b.f=f[keep];b.q=q[keep] + return b.search([lc])[0] + b.sync();start=time.perf_counter();call();b.sync();first=time.perf_counter()-start + call();times=[];candidates=[] + for rep in range(a.reps): + b.sync();start=time.perf_counter();out=call();b.sync();times.append(time.perf_counter()-start);candidates.append(out['candidate']) + assert b.f.shape==expected.shape + float32_equal=bool(np.array_equal(b.f.astype(np.float32),expected.astype(np.float32))) + assert float32_equal,'Fresh grid changed a GPU trial frequency' + q32_equal=bool(np.array_equal(b.q.astype(np.float32),d['q'].astype(np.float32))) + assert q32_equal,'Fresh grid changed a GPU duration prior' + p=a.out/f'case_{i:04}.npz';np.savez_compressed(p,periods=out['periods'],power=out['power']) + case=dict(index=i,injected=False,snr=None,**out['candidate'],recovered=None,alias_recovered=None, + n_periods=len(out['periods']),search_s=None,evaluation_chunk=1,output_file=p.name,output_sha256=sha(p)) + installed=Path(b.bls.__file__).parent + dump(a.out/'summary.json',dict(status='ok',profile=meta['profile'],split=meta['split'],config=cfg,indices=[i],cases=[case], + input_file=Path(a.input).name,input_sha256=sha(a.input),worker_sha256=sha(worker.__file__),wrapper_sha256=sha(__file__), + installed_sources={'cuvarbase':{str(f.relative_to(installed)):sha(f) for f in installed.rglob('*') if f.is_file() and f.suffix in ['.py','.cu','.cuh','.so']}}, + boundary='Fresh native transit_autofreq grid, trimming the upper endpoint, then prepared-array BLS and common candidate ranking. Imports/context and disk I/O excluded.', + grid_observed_time_index=grid_index,grid_n_observations=len(grid_t),grid_baseline=float(np.ptp(grid_t)),grid_float32_equal=float32_equal,grid_q_float32_equal=q32_equal, + grid_max_relative_difference=float(np.max(np.abs(b.f/expected-1))), + initialization_s=initialization,first_api_s=first,times_s=times,median_s=float(np.median(times)),seconds_per_source=float(np.median(times)),timed_candidates=candidates)) + print('GRID_AND_SEARCH_COMPLETE',flush=True) + + +if __name__=='__main__':main() diff --git a/benchmarks/transit/plot_main.py b/benchmarks/transit/plot_main.py new file mode 100644 index 00000000..250a9ce6 --- /dev/null +++ b/benchmarks/transit/plot_main.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""Render the public timing figure from verified benchmark analysis records.""" +import argparse +import hashlib +import json +from pathlib import Path + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.lines import Line2D +from matplotlib.ticker import FuncFormatter, LogLocator, NullLocator + +PROFILES = ['tess_200s', 'tess_gap', 'ztf'] +TITLES = { + 'tess_200s': 'TESS: one dense sector', + 'tess_gap': 'TESS: two separated sectors', + 'ztf': 'ZTF: sparse g/r', +} +SUBTITLES = { + 'tess_200s': '200 s cadence · up to 9,736 samples · 26 days', + 'tess_gap': '30 / 10 min cadence · up to 4,295 samples · 735 days', + 'ztf': 'Up to 1,317 samples · 2,744 days', +} +COLORS = { + 'bls_v1': '#008566', 'tls_v1': '#008566', 'bls_pypi': '#2466aa', + 'bls_cpu': '#b55b12', 'bls_gpu': '#8957a5', 'gtls': '#8957a5', +} + + +def time_label(seconds): + if seconds < .1: + return f'{float(f"{seconds * 1000:.2g}"):g} ms' + return f'{float(f"{seconds:.2g}"):g} s' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', type=Path, required=True) + parser.add_argument('--output-dir', type=Path, + help='Defaults to the benchmark result directory.') + parser.add_argument('--tls-study', type=Path, + help='Use the independent follow-up TLS timing_analysis.json and supported settings.') + parser.add_argument('--tls-reference', type=Path, + help='Use validated observation-level TLS timing_analysis.json.') + args = parser.parse_args() + if args.tls_study and args.tls_reference: + parser.error('Choose one TLS measurement campaign.') + recovery = json.loads((args.root / 'recovery_analysis.json').read_text()) + timing = json.loads((args.root / 'timing_analysis.json').read_text()) + for record in (recovery, timing): + assert record['verification']['complete'] + assert record['verification']['arrays_verified'] + methods = {(r['profile'], r['method']): r for r in recovery['methods']} + times = {(r['profile'], r['method'], r['mode']): r for r in timing['timings']} + tls_selection = {} + reference = None + reference_profiles = {} + partial_campaign = False + tls_modes = ('single', 'batch16') + if args.tls_reference: + timing_path = args.tls_reference / 'timing_analysis.json' + reference = json.loads(timing_path.read_text()) + partial_campaign = reference.get('campaign_pass') is False + if partial_campaign: + assessment = json.loads( + (args.tls_reference / 'reporting_acceptance.json').read_text()) + scope = 'post_hoc_complete_configurations_after_optional_native_warmup_oom' + original_path = args.tls_reference / 'timing' / 'acceptance.json' + original = json.loads(original_path.read_text()) + if (assessment.get('reporting_gate', {}).get('pass') is not True or + assessment.get('campaign_pass') is not False or + reference.get('reporting_scope') != scope or + assessment.get('timing_analysis_sha256') != + hashlib.sha256(timing_path.read_bytes()).hexdigest() or + assessment.get('original_campaign_acceptance', {}).get('passed') is not False or + assessment.get('original_campaign_acceptance', {}).get('sha256') != + hashlib.sha256(original_path.read_bytes()).hexdigest() or + original.get('publication_gate', {}).get('pass') is not False): + raise ValueError('Partial campaign requires its separate hash-bound reporting assessment.') + for gate in ('complete', 'numerical_validation_complete', 'exclusive_processes'): + if reference['verification'].get(gate) is not True: + raise ValueError(f'TLS measurement gate did not pass: {gate}') + reference_profiles = {r['profile']: r for r in reference['profiles']} + scope = reference.get('measurement_scope', 'single_and_batch') + if scope not in ('single', 'single_and_batch'): + raise ValueError('Unknown TLS timing scope.') + if scope == 'single': + tls_modes = ('single',) + new_times = {(r['profile'], r['method'], r['mode']): r + for r in reference['timings']} + for profile in PROFILES: + for method in ('tls_v1', 'gtls'): + for mode in tls_modes: + record = new_times[profile, method, 'single' if mode == 'single' else 'batch'] + if record['boundary'] != 'warm_public_api': + raise ValueError('The topline figure requires public-call timings.') + expected_n = 1 if mode == 'single' else reference_profiles[profile]['batch_size'] + if record['n'] != expected_n: + raise ValueError('The timing count differs from the displayed workload.') + times[profile, method, mode] = dict( + seconds_per_source=record['seconds_per_source'], n=1, + min_total_s=record['min_seconds_per_source'], + max_total_s=record['max_seconds_per_source'], workers=record['workers']) + if args.tls_study: + followup = json.loads((args.tls_study / 'timing_analysis.json').read_text()) + assert followup['verification']['complete'] + assert followup['verification']['exclusive_processes'] + tls_selection = {r['profile']: r for r in followup['selected']} + followup_times = {(r['profile'], r['method'], r['mode']): r for r in followup['timings']} + for profile in PROFILES: + for displayed, measured in [('tls_v1', tls_selection[profile]['method']), ('gtls', f'gtls_{profile}')]: + for mode in ('single', 'batch16'): + row = followup_times[profile, measured, mode] + # The plot consumes per-source values; n=1 keeps its range + # conversion consistent without pretending these are raw calls. + times[profile, displayed, mode] = dict( + seconds_per_source=row['seconds_per_source'], n=1, + min_total_s=row['min_seconds_per_source'], + max_total_s=row['max_seconds_per_source']) + plt.rcParams.update({ + 'font.family': 'DejaVu Sans', 'font.size': 12, 'svg.fonttype': 'none', + 'axes.spines.top': False, 'axes.spines.right': False, + 'axes.spines.left': False, 'axes.edgecolor': '#c7cfd5', + 'xtick.color': '#526270', 'ytick.color': '#263c4c', + }) + fig = plt.figure(figsize=(15, 10.5), facecolor='white') + grid = fig.add_gridspec(3, 2, left=.155, right=.925, top=.815, bottom=.13, + hspace=.88, wspace=.79) + fig.text(.035, .96, 'Faster transit searches across TESS and ZTF cadences', + fontsize=23, weight='bold', color='#172a3a') + fig.text(.035, .925, 'Search time per lightcurve · lower is faster', + fontsize=15, color='#526270') + batch_sizes = {p.get('batch_size') for p in reference_profiles.values()} + if tls_modes == ('single',): + batch_label = 'BLS batch of 16: time per lightcurve' + else: + batch_label = ('Batch: time per lightcurve' if reference and batch_sizes != {16} + else 'Batch of 16: time per lightcurve') + fig.legend(handles=[ + Line2D([], [], marker='o', color='#334a5e', markerfacecolor='white', + linestyle='none', markersize=8, label='One lightcurve'), + Line2D([], [], marker='o', color='#334a5e', linestyle='none', + markersize=8, label=batch_label), + ], loc='upper left', bbox_to_anchor=(.028, .904), ncol=2, + frameon=False, fontsize=12) + + for row, profile in enumerate(PROFILES): + for col, (family, entries, v1) in enumerate([ + ('BLS', ['bls_v1', 'bls_pypi', 'bls_cpu', 'bls_gpu'], 'bls_v1'), + ('TLS', ['tls_v1', 'gtls'], 'tls_v1'), + ]): + ax = fig.add_subplot(grid[row, col]) + values_on_axis, labels = [], [] + modes = tls_modes if family == 'TLS' else ('single', 'batch16') + baseline = times[profile, v1, modes[-1]]['seconds_per_source'] + for index, method in enumerate(entries): + values = [times[profile, method, mode]['seconds_per_source'] + for mode in modes] + values_on_axis.extend(values) + color = COLORS[method] + if len(values) > 1: + ax.plot(values, [index, index], color=color, lw=2, alpha=.6) + upper = [] + for mode, value in zip(modes, values): + record = times[profile, method, mode] + low = record['min_total_s'] / record['n'] + high = record['max_total_s'] / record['n'] + upper.append(high) + values_on_axis.extend((low, high)) + ax.errorbar(value, index, + xerr=[[max(0, value-low)], [max(0, high-value)]], + fmt='none', ecolor=color, capsize=2, alpha=.6) + ax.scatter(values[0], index, s=65, edgecolors=color, + facecolors='white', linewidths=1.8, zorder=4) + if len(values) > 1: + ax.scatter(values[1], index, s=52, color=color, zorder=5) + if method == v1: + label = 'cuvarbase v1' + if family == 'TLS' and reference: + label += '\n1 worker' + if family == 'TLS' and tls_selection: + choice = tls_selection[profile] + if choice['method'] == 'v1_fine': + label += '\nfine grid' + elif choice['method'] == 'v1_resolved': + label += '\nintermediate grid' + if not choice['recovery_supported']: + label += ' *' + elif method == 'bls_pypi': + label = 'cuvarbase 0.2.5' + elif method == 'bls_cpu': + backend = methods[profile, method]['config']['backend'] + label = 'CPU: ' + ('Astropy' if backend == 'astropy' else 'periodfind') + elif method == 'bls_gpu': + label = 'GPU: periodfind' + else: + label = 'GPU: GTLS' + if reference and len(modes) > 1: + workers = times[profile, method, modes[-1]]['workers'] + label += f'\nbatch: {workers} worker' + ('s' if workers != 1 else '') + labels.append(label) + annotation = time_label(values[-1]) + if method != v1: + annotation += f' · {values[-1]/baseline:.1f}×' + ax.annotate(annotation, (max(upper), index), xytext=(8, 0), + textcoords='offset points', va='center', fontsize=12, + color=color, weight='bold' if method == v1 else 'normal', + annotation_clip=False) + ax.set_xscale('log') + ax.set_xlim(min(values_on_axis)/1.7, max(values_on_axis)*25) + ax.set_ylim(len(entries)-.5, -.6) + ax.set_yticks(range(len(entries)), labels) + ax.tick_params(axis='y', length=0, pad=10) + ax.xaxis.set_major_locator(LogLocator(base=10, numticks=4)) + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: time_label(x))) + ax.xaxis.set_minor_locator(NullLocator()) + ax.grid(axis='x', alpha=.18) + ax.set_axisbelow(True) + family_label = 'TLS (one source)' if family == 'TLS' and len(modes) == 1 else family + ax.set_title(f'{family_label} / {TITLES[profile]}', loc='left', + fontsize=14, weight='bold', color='#172a3a', pad=33) + subtitle = SUBTITLES[profile] + if family == 'TLS' and reference: + metadata = reference_profiles[profile] + subtitle = (f"{metadata['n_samples']:,} samples · " + f"{metadata['baseline_days']:,.0f} days · " + f"{metadata['n_periods']:,} trial periods") + ax.text(0, 1.10, subtitle, transform=ax.transAxes, + fontsize=10.5, color='#526270') + repetitions = ('BLS: 5 single / 3 batch repetitions; TLS: 5 per mode.' if tls_selection + else 'Medians of 5 single / 3 batch calls;') + labels_note = 'Labels give batch time and the ratio to v1.' + if tls_modes == ('single',): + labels_note = 'Labels: BLS batch time; TLS single-source time. Ratios are relative to v1.' + repetitions = 'Medians; whiskers span repetitions;' + fig.text(.035, .090 if partial_campaign else .069, + (f'{labels_note} {repetitions} logarithmic axes.' if tls_modes == ('single',) + else f'{labels_note} {repetitions} Whiskers span repetitions; logarithmic axes.'), + fontsize=11, color='#394d5d') + environment = 'A40 + 7.65 CPU-equivalent allocation. Warm searches from prepared arrays; grid construction and preprocessing excluded.' + if reference: + gpu_models = {line.split(',')[0].strip() for line in + reference['environment']['nvidia_smi'].splitlines() if line.strip()} + if len(gpu_models) != 1: + raise ValueError('TLS figure requires one recorded GPU model.') + tls_gpu = gpu_models.pop().removeprefix('NVIDIA ') + environment = (f'BLS: A40; TLS: {tls_gpu}. Warm APIs; input loading and grid construction excluded. ' + 'CPU allocations: see report.') + fig.text(.035, .068 if partial_campaign else .047, environment, + fontsize=11, color='#526270') + qualification = 'Recovery qualifications are in the benchmark report. Equivalent TLS detection sensitivity is not established.' + if tls_selection: + passed = sum(s['recovery_supported'] for s in tls_selection.values()) + qualification = (f'TLS: {passed}/3 cadences meet the recovery / false-positive matching criterion. ' + + ('* Matching inconclusive. ' if passed < 3 else '') + + 'BLS qualifications: see report.') + if reference: + qualification = ('GTLS batch: fastest eligible 1/2/4-worker pool. ' + 'Recovery qualifications and search/diagnostic timings: see report.') + if tls_modes == ('single',): + qualification = ('TLS: five calls per method on one noise-only curve per cadence; batch throughput unmeasured. ' + 'Numerical/recovery checks: see report.') + fig.text(.035, .046 if partial_campaign else .025, qualification, + fontsize=11, color='#394d5d') + if partial_campaign: + failed_labels = {'tess_solar': 'dense TESS', 'tess_gap': 'separated TESS', + 'ztf_solar': 'ZTF'} + excluded = reference.get('excluded_configurations', []) + if not excluded or any(not value.endswith('/gtls_graph_4worker') for value in excluded): + raise ValueError('Figure failure note does not cover these excluded configurations.') + failures = ', '.join(failed_labels[value.split('/')[0]] for value in excluded) + fig.text(.035, .024, + 'Post hoc report of complete configurations: original campaign gate failed ' + f'after 4-worker GTLS ran out of memory on {failures}.', + fontsize=11, color='#394d5d') + output = args.output_dir or args.root + output.mkdir(parents=True, exist_ok=True) + for extension in ('png', 'pdf', 'svg'): + fig.savefig(output / f'benchmark_story.{extension}', dpi=160, + facecolor='white', metadata={'Creator': 'cuvarbase benchmark tools'}) + plt.close(fig) + print('Wrote benchmark_story.png / .pdf / .svg') + + +if __name__ == '__main__': + main() diff --git a/benchmarks/transit/recovery_statistics.py b/benchmarks/transit/recovery_statistics.py new file mode 100644 index 00000000..c5f60414 --- /dev/null +++ b/benchmarks/transit/recovery_statistics.py @@ -0,0 +1,71 @@ +"""Finite-sample summaries for the paired injection experiment.""" +import numpy as np +from scipy.stats import beta + + +def wilson(k,n,z=1.959963984540054): + if not n:return [None,None] + p=k/n;den=1+z*z/n;center=(p+z*z/(2*n))/den + half=z*np.sqrt(p*(1-p)/n+z*z/(4*n*n))/den + return [float(max(0,center-half)),float(min(1,center+half))] + + +def paired(a,b): + """Conservative one-sided 95% bounds via two 97.5% binomial bounds. + + D = Pr(A only) - Pr(B only). Bonferroni requires no independence + between these two multinomial cells. Each returned bound separately + has >=95% coverage; do not call their pair a two-sided 95% interval. + """ + a=np.asarray(a,bool);b=np.asarray(b,bool);assert a.shape==b.shape + n=len(a);wins=int(np.sum(a&~b));losses=int(np.sum(~a&b)) + def lo(k):return float(beta.ppf(.025,k,n-k+1)) if k else 0. + def hi(k):return float(beta.ppf(.975,k+1,n-k)) if k-.05)) + + +def valid(case): + # TLS explicitly represents trial periods with no admissible fit as NaN. + # A finite native candidate from the remaining searched periods is still a + # valid API result. Report partial spectra separately; never conceal them. + return case.get('api_result_valid',True) and case.get('finite_fraction',0.)>0. and not case.get('error') + + +def score(case): + v=case.get('score') + return float(v) if v is not None and np.isfinite(v) and valid(case) else -np.inf + + +def summarize(calibration,heldout): + assert len(calibration)==128 and all(not c['injected'] for c in calibration) + nullscores=np.array([score(c) for c in calibration]) + threshold=float(np.quantile(nullscores,.95,method='higher')) + if not np.isfinite(threshold):raise ValueError('Calibration did not produce a finite detection threshold') + injected=[c for c in heldout if c['injected']];nulls=[c for c in heldout if not c['injected']] + assert len(injected)==128 and len(nulls)==128 + period=np.array([c['recovered'] and valid(c) for c in injected],bool) + detected_score=np.array([score(c)>threshold for c in injected]) + detection=period&detected_score + alias=np.array([c.get('alias_recovered',False) and valid(c) for c in injected],bool) + alias_detection=alias&detected_score + fp=np.array([score(c)>threshold for c in nulls]) + bins=[] + for snr in [6.,8.,10.,14.]: + take=np.array([c['snr']==snr for c in injected]);n=int(take.sum());assert n==32 + k=int(detection[take].sum());kp=int(period[take].sum()) + bins.append(dict(snr=snr,n=n,period_recovered=kp,detected=k,alias_recovered=int(alias[take].sum()),alias_detected=int(alias_detection[take].sum()),recall=k/n,interval=wilson(k,n), + period_recall=kp/n,period_interval=wilson(kp,n))) + return dict(threshold=threshold,n_injections=len(injected),n_calibration_nulls=len(calibration),n_heldout_nulls=len(nulls), + period_recovered=int(period.sum()),detected=int(detection.sum()),alias_recovered=int(alias.sum()),alias_detected=int(alias_detection.sum()),false_positives=int(fp.sum()), + period_recall=float(period.mean()),detection_recall=float(detection.mean()),false_positive_rate=float(fp.mean()), + period_interval=wilson(int(period.sum()),len(period)),detection_interval=wilson(int(detection.sum()),len(detection)), + false_positive_interval=wilson(int(fp.sum()),len(fp)),by_snr=bins, + invalid_calibration=sum(not valid(c) for c in calibration), + invalid_heldout=sum(not valid(c) for c in heldout), + partial_spectra_calibration=sum(0.0) + if not good.any(): raise ValueError('No finite periodogram values') + f=1/periods[good];sr=np.sqrt(np.maximum(0.,power[good])) + delta=np.diff(f) + if np.all(delta>0): + pass + elif np.all(delta<0): + f=f[::-1];sr=sr[::-1] + else: + order=np.argsort(f);f=f[order];sr=sr[order] + bins=np.array_split(np.arange(len(f)),max(2,int(np.ceil(1+np.log2(len(f)))))) + centers=np.array([np.median(f[b]) for b in bins]);trend=np.array([np.median(sr[b]) for b in bins]) + resid=sr-np.interp(f,centers,trend) + scale=1.4826*np.median(np.abs(resid-np.median(resid))) + if not scale>0:raise ValueError('Degenerate periodogram scale') + index=int(np.argmax(resid));z=(resid[index]-np.median(resid))/scale + return dict(period=float(1/f[index]),score=float(z),finite_fraction=float(good.mean())) + + +class Backend: + def __init__(self,cfg,d,capacity): + self.cfg=cfg;self.kind=cfg['backend'];self.f=np.array(d['freqs']);self.q=np.array(d['q']);self.tp=np.array(d['tls_periods']) + self.meta=json.loads(str(d['metadata']));self.sync=lambda:None;self.memory=None;self.functions=None;self.capacity=capacity + k=self.kind + if k.startswith('v1_bls') or k.startswith('pypi_bls'): + if k.startswith('pypi'): + import pycuda.autoinit + else: + from cuvarbase.base import ensure_context + ensure_context() + import pycuda.driver as drv + import cuvarbase.bls as bls + self.bls=bls;self.sync=drv.Context.synchronize + if k.startswith('pypi'): + self.functions=bls.compile_bls(function_names=['full_bls_no_sol'],block_size=cfg.get('block_size',256)) + self.memory=bls.BLSMemory(capacity,len(self.f)) + elif cfg.get('unfused'): + fn='full_bls_no_sol_optimized' if cfg.get('optimized') else 'full_bls_no_sol' + self.functions=bls.compile_bls(function_names=[fn],use_optimized=cfg.get('optimized',False),block_size=cfg.get('block_size',256)) + if k=='v1_bls_batch' and cfg.get('reuse_batch'): + from cuvarbase.memory.bls_memory import BLSBatchMemory + self.memory=BLSBatchMemory(capacity,cfg.get('batch_capacity',16),len(self.f),stream=drv.Stream()) + elif k=='v1_tls': + from cuvarbase.base import ensure_context + from cuvarbase import tls as tls_module + # This dated benchmark targets the original binned engine. + tls_search_batch = getattr(tls_module, '_tls_search_batch_binned', tls_module.tls_search_batch) + import pycuda.driver as drv + ensure_context();self.tls=tls_search_batch;self.sync=drv.Context.synchronize + elif k=='gtls': + import cupy as cp + from gputls import gtls + self.gtls=gtls;self.sync=cp.cuda.runtime.deviceSynchronize + self.pool=ProcessPoolExecutor(max_workers=cfg['workers'],mp_context=mp.get_context('spawn')) if cfg.get('workers',1)>1 else None + elif k=='astropy': + from astropy.timeseries import BoxLeastSquares + self.astropy=BoxLeastSquares + self.pool=ProcessPoolExecutor(max_workers=cfg['workers'],mp_context=mp.get_context('spawn')) if cfg.get('workers',1)>1 else None + elif k.startswith('periodfind'): + self.pf=importlib.import_module('periodfind.'+('gpu' if k.endswith('gpu') else 'cpu')) + elif k=='fbls': + from fBLS import fBLS + self.fbls=fBLS + else:raise ValueError(k) + # Chunks make each competitor's scalar duration API approximate the same Keplerian prior. + # This is 16 API calls for an entire grid, never one Python call per trial period. + self.chunks=[] + edges=np.geomspace(self.meta['pmin'],self.meta['pmax'],17) + periods=1/self.f + for lo,hi in zip(edges[:-1],edges[1:]): + ids=np.flatnonzero((periods>=lo)&(periods<(hi if hi`` except the submodules in +# ``_SUBMODULES``. Everything else lives in its module +# (``cuvarbase.bls.eebls_gpu``, ``cuvarbase.tls.tls_search_gpu``, ...). +_LAZY_ATTRS = { + 'GPUAsyncProcess': '.base', + 'NFFTMemory': '.memory', + 'ConditionalEntropyMemory': '.memory', + 'LombScargleMemory': '.memory', + 'BLSMemory': '.bls', + 'BLSBatchMemory': '.memory', + 'NFFTAsyncProcess': '.cunfft', + 'nfft_adjoint_async': '.cunfft', + 'ConditionalEntropyAsyncProcess': '.ce', + 'conditional_entropy': '.ce', + 'conditional_entropy_fast': '.ce', + 'LombScargleAsyncProcess': '.lombscargle', + 'lomb_scargle_async': '.lombscargle', + 'PDMAsyncProcess': '.pdm', +} + +# Submodules reachable as attributes (``cuvarbase.bls``) without an +# explicit ``import cuvarbase.bls``. ``nufft_lrt`` is deliberately here +# and NOT in ``_LAZY_ATTRS``: the NUFFT likelihood-ratio test is +# quarantined as EXPERIMENTAL for 1.0 (importable as +# ``cuvarbase.nufft_lrt``, outside the 1.x API-stability promise, warns +# at construction): its Sep-2026 re-validation passed the correctness +# gate but showed that its defaults and return conventions should +# still change before the API is frozen (decision D1, 2026-09-06). +_SUBMODULES = { + 'base', 'memory', 'core', 'utils', + 'bls', 'bls_frequencies', 'ce', 'cunfft', 'lombscargle', 'pdm', + 'cufinufft_backend', 'nufft_lrt', + 'tls', 'tls_grids', 'tls_models', 'tls_stats', +} + +__all__ = list(_LAZY_ATTRS) + + +def __getattr__(name): + import importlib + + if name in _LAZY_ATTRS: + module = importlib.import_module(_LAZY_ATTRS[name], __name__) + return getattr(module, name) + + if name in _SUBMODULES: + return importlib.import_module('.' + name, __name__) + + raise AttributeError("module %r has no attribute %r" % (__name__, name)) + + +def __dir__(): + return sorted(set(list(globals()) + list(_LAZY_ATTRS) + + list(_SUBMODULES))) diff --git a/cuvarbase/_cufft.py b/cuvarbase/_cufft.py new file mode 100644 index 00000000..2cc7d29a --- /dev/null +++ b/cuvarbase/_cufft.py @@ -0,0 +1,265 @@ +"""Minimal in-house cuFFT binding (replaces the abandoned scikit-cuda). + +The NFFT / Lomb-Scargle path needs only batched 1D complex-to-complex +transforms: create a plan, run an inverse FFT, and estimate the cuFFT +work-area size. This module binds just those entry points of ``libcufft`` +via ``ctypes`` and exposes a small scikit-cuda-compatible surface so the +existing call sites are unchanged: + + Plan(shape, in_dtype, out_dtype, stream=None, batch=1) + fft(x_gpu, y_gpu, plan) # forward + ifft(x_gpu, y_gpu, plan) # inverse + cufft.cufftEstimate1d(nx, cufft.CUFFT_C2C) # work-area bytes + +``libcufft`` is loaded lazily on first use, so importing this module (and +hence ``cuvarbase.cunfft`` / ``cuvarbase.lombscargle``) does NOT require +CUDA -- only calling into it does. This drops the unmaintained +``scikit-cuda`` 0.5.3 dependency (issue #63), whose numpy>=1.24 +incompatibility previously required a runtime monkeypatch. + +Motivation for a direct binding over ``cupy``: cupy is a large, +CUDA-version-specific dependency, whereas the cuFFT surface cuvarbase +uses is three functions; scikit-cuda itself was just a ctypes binding +of the same calls. +""" +import atexit +import ctypes +import ctypes.util +import glob +import os +import sys + +import numpy as np + +# --- cufft.h constants ----------------------------------------------------- +# cufftType +CUFFT_R2C = 0x2a +CUFFT_C2R = 0x2c +CUFFT_C2C = 0x29 +CUFFT_D2Z = 0x6a +CUFFT_Z2D = 0x6c +CUFFT_Z2Z = 0x69 + +# transform direction (cufftExec*) +CUFFT_FORWARD = -1 +CUFFT_INVERSE = 1 + +# cufftResult names for error messages +_RESULT = { + 0: 'CUFFT_SUCCESS', 1: 'CUFFT_INVALID_PLAN', 2: 'CUFFT_ALLOC_FAILED', + 3: 'CUFFT_INVALID_TYPE', 4: 'CUFFT_INVALID_VALUE', + 5: 'CUFFT_INTERNAL_ERROR', 6: 'CUFFT_EXEC_FAILED', + 7: 'CUFFT_SETUP_FAILED', 8: 'CUFFT_INVALID_SIZE', + 9: 'CUFFT_UNALIGNED_DATA', 10: 'CUFFT_INCOMPLETE_PARAMETER_LIST', + 11: 'CUFFT_INVALID_DEVICE', 12: 'CUFFT_PARSE_ERROR', + 13: 'CUFFT_NO_WORKSPACE', 14: 'CUFFT_NOT_IMPLEMENTED', + 15: 'CUFFT_LICENSE_ERROR', 16: 'CUFFT_NOT_SUPPORTED', +} + +_lib = None + +# Set once the interpreter starts shutting down. Plan.__del__ must not call +# into libcufft after this, because the CUDA primary context may already be +# torn down -- a C-level fault that try/except cannot catch. The OS reclaims +# the plans at process exit anyway. +_shutting_down = False + + +@atexit.register +def _mark_shutting_down(): + global _shutting_down + _shutting_down = True + + +class CufftError(RuntimeError): + """A cufft* call returned a non-success cufftResult.""" + + +def _check(status): + if status != 0: + raise CufftError("cuFFT call failed: %s (%d)" + % (_RESULT.get(status, 'UNKNOWN'), status)) + + +def _candidate_libs(): + """Ordered libcufft candidates: explicit paths first, SONAMEs last. + + Covers (a) the loader's own resolution, (b) the pip wheel layout + ``/nvidia/cufft/lib/libcufft.so.*`` (nvidia-cufft-cuXX), + (c) CUDA-toolkit ``lib64`` dirs, and (d) bare SONAMEs found via + ``LD_LIBRARY_PATH``/ldconfig. Absolute paths are tried before bare + names so a runtime-only install works without ``LD_LIBRARY_PATH``. + """ + cands = [] + found = ctypes.util.find_library('cufft') + if found: + cands.append(found) + + patterns = [] + # pip wheel: site-packages/nvidia/cufft/lib/libcufft.so* + for p in sys.path: + if p and os.path.isdir(p): + patterns.append(os.path.join(p, 'nvidia', 'cufft', 'lib', + 'libcufft.so*')) + # CUDA toolkit install dirs + patterns += ['/usr/local/cuda*/lib64/libcufft.so*', + '/usr/local/cuda/lib64/libcufft.so*', + '/opt/cuda*/lib64/libcufft.so*'] + for pat in patterns: + # reverse-sort so a higher SONAME version (.so.11) precedes .so + cands.extend(sorted(glob.glob(pat), reverse=True)) + + # bare SONAMEs (resolved via the dynamic loader / LD_LIBRARY_PATH) + cands += ['libcufft.so', 'libcufft.so.12', 'libcufft.so.11', + 'libcufft.so.10', 'libcufft.dylib', + 'cufft64_12.dll', 'cufft64_11.dll', 'cufft64_10.dll'] + + seen, ordered = set(), [] + for c in cands: + if c not in seen: + seen.add(c) + ordered.append(c) + return ordered + + +def _load(): + """Lazily load libcufft and declare the prototypes we use.""" + global _lib + if _lib is not None: + return _lib + + last_err = None + lib = None + for name in _candidate_libs(): + try: + # RTLD_GLOBAL so libcufft's own deps (libcudart, cublas, ...) + # and symbols are visible to the rest of the process. + lib = ctypes.CDLL(name, mode=ctypes.RTLD_GLOBAL) + break + except OSError as exc: + last_err = exc + if lib is None: + raise ImportError( + "could not load libcufft (required for the NFFT / Lomb-Scargle " + "GPU path). Install the CUDA cuFFT runtime and ensure it is on " + "the loader path (e.g. LD_LIBRARY_PATH must include the CUDA " + "lib64 directory, or `pip install nvidia-cufft-cu12`). If cuFFT " + "is present, a missing sibling runtime (libcudart/libcublas) can " + "also cause this. Last loader error: %s" % last_err) + + # cufftHandle is a plain int; cudaStream_t is an opaque pointer. + lib.cufftPlan1d.restype = ctypes.c_int + lib.cufftPlan1d.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, + ctypes.c_int, ctypes.c_int] + lib.cufftDestroy.restype = ctypes.c_int + lib.cufftDestroy.argtypes = [ctypes.c_int] + lib.cufftSetStream.restype = ctypes.c_int + lib.cufftSetStream.argtypes = [ctypes.c_int, ctypes.c_void_p] + lib.cufftExecC2C.restype = ctypes.c_int + lib.cufftExecC2C.argtypes = [ctypes.c_int, ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_int] + lib.cufftExecZ2Z.restype = ctypes.c_int + lib.cufftExecZ2Z.argtypes = [ctypes.c_int, ctypes.c_void_p, + ctypes.c_void_p, ctypes.c_int] + lib.cufftEstimate1d.restype = ctypes.c_int + lib.cufftEstimate1d.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, + ctypes.POINTER(ctypes.c_size_t)] + _lib = lib + return _lib + + +def _fft_type(in_dtype, out_dtype): + cin, cout = np.dtype(in_dtype), np.dtype(out_dtype) + if cin == np.complex64 and cout == np.complex64: + return CUFFT_C2C + if cin == np.complex128 and cout == np.complex128: + return CUFFT_Z2Z + raise ValueError( + "cuvarbase._cufft supports only complex64->complex64 (C2C) and " + "complex128->complex128 (Z2Z); got %s -> %s" % (cin, cout)) + + +def _devptr(x_gpu): + """Device pointer (as an int) for a pycuda GPUArray or DeviceAllocation.""" + if hasattr(x_gpu, 'gpudata'): + return int(x_gpu.gpudata) + if hasattr(x_gpu, 'ptr'): + return int(x_gpu.ptr) + return int(x_gpu) + + +class Plan(object): + """A batched 1D complex-to-complex cuFFT plan (scikit-cuda-compatible).""" + + def __init__(self, shape, in_dtype, out_dtype, batch=1, stream=None): + lib = _load() + if np.isscalar(shape): + n = int(shape) + else: + n = int(np.prod(shape)) + self.n = n + self.batch = int(batch) + self.fft_type = _fft_type(in_dtype, out_dtype) + self._exec = (lib.cufftExecC2C if self.fft_type == CUFFT_C2C + else lib.cufftExecZ2Z) + + self.handle = ctypes.c_int() + _check(lib.cufftPlan1d(ctypes.byref(self.handle), n, + self.fft_type, self.batch)) + + if stream is not None: + handle = getattr(stream, 'handle', stream) + _check(lib.cufftSetStream(self.handle, ctypes.c_void_p(int(handle)))) + + def __del__(self): + # Never raise from __del__. Skip the destroy during interpreter + # shutdown: the CUDA context may already be gone, and calling into + # libcufft then can fault below the Python level (the OS reclaims + # the plan at exit regardless). + try: + if (not _shutting_down + and getattr(self, 'handle', None) is not None + and _lib is not None): + _lib.cufftDestroy(self.handle) + self.handle = None + except Exception: + pass + + +def _exec(plan, x_gpu, y_gpu, direction): + _check(plan._exec(plan.handle, ctypes.c_void_p(_devptr(x_gpu)), + ctypes.c_void_p(_devptr(y_gpu)), direction)) + + +def fft(x_gpu, y_gpu, plan): + """Forward FFT of ``x_gpu`` into ``y_gpu`` using ``plan`` (in place ok).""" + _exec(plan, x_gpu, y_gpu, CUFFT_FORWARD) + + +def ifft(x_gpu, y_gpu, plan): + """Inverse (unnormalized) FFT of ``x_gpu`` into ``y_gpu`` using ``plan``.""" + _exec(plan, x_gpu, y_gpu, CUFFT_INVERSE) + + +def cufftEstimate1d(nx, fft_type, batch=1): + """cuFFT work-area size in bytes for a 1D plan of size ``nx``.""" + lib = _load() + work = ctypes.c_size_t(0) + _check(lib.cufftEstimate1d(int(nx), int(fft_type), int(batch), + ctypes.byref(work))) + return work.value + + +# scikit-cuda exposed the low-level entry points under ``skcuda.fft.cufft`` +# (e.g. ``cufft.cufft.cufftEstimate1d``, ``cufft.cufft.CUFFT_C2C``). Mirror +# that nested attribute so call sites importing this module as ``cufft`` +# keep working unchanged. +import types as _types # noqa: E402 + +cufft = _types.SimpleNamespace( + cufftEstimate1d=cufftEstimate1d, + CUFFT_C2C=CUFFT_C2C, + CUFFT_Z2Z=CUFFT_Z2Z, + CUFFT_FORWARD=CUFFT_FORWARD, + CUFFT_INVERSE=CUFFT_INVERSE, +) diff --git a/cuvarbase/base/__init__.py b/cuvarbase/base/__init__.py new file mode 100644 index 00000000..323521ac --- /dev/null +++ b/cuvarbase/base/__init__.py @@ -0,0 +1,11 @@ +""" +Base classes and abstractions for cuvarbase. + +This module contains the core abstractions used across different +periodogram implementations. +""" + +from .async_process import GPUAsyncProcess +from .context import ensure_context + +__all__ = ['GPUAsyncProcess', 'ensure_context'] diff --git a/cuvarbase/base/async_process.py b/cuvarbase/base/async_process.py new file mode 100644 index 00000000..175d0ce4 --- /dev/null +++ b/cuvarbase/base/async_process.py @@ -0,0 +1,70 @@ +import warnings + +from .context import ensure_context +import pycuda.driver as cuda + + +class GPUAsyncProcess: + """Base class of every GPU periodogram process. + + ``reader``, ``function_kwargs`` and ``device`` have been accepted + since 0.2.5 but are not read by any process; they are kept for 1.x + and will be removed in 2.0. The device is selected by the + ``CUDA_DEVICE`` environment variable (via ``pycuda.autoprimaryctx``, + see :func:`cuvarbase.base.ensure_context`), so a ``device`` other + than 0 is ignored with a ``UserWarning``. + """ + + def __init__(self, *args, **kwargs): + # Constructing any GPU process is a "first GPU use" -- retain the + # CUDA primary context now (no longer done eagerly at import). + ensure_context() + self.reader = kwargs.get('reader', None) + self.nstreams = kwargs.get('nstreams', None) + self.function_kwargs = kwargs.get('function_kwargs', {}) + self.device = kwargs.get('device', 0) + if self.device is not None and self.device != 0: + warnings.warn("GPUAsyncProcess(device=%r) is ignored: the " + "device is selected by the CUDA_DEVICE " + "environment variable (pycuda.autoprimaryctx). " + "The device= keyword is deprecated and will be " + "removed in 2.0" % (self.device,), + UserWarning, stacklevel=2) + self.streams = [] + self.gpu_data = [] + self.results = [] + self._adjust_nstreams = self.nstreams is None + if self.nstreams is not None: + self._create_streams(self.nstreams) + self.prepared_functions = {} + + def _create_streams(self, n): + for i in range(n): + self.streams.append(cuda.Stream()) + + def _compile_and_prepare_functions(self): + raise NotImplementedError() + + def run(self, *args, **kwargs): + raise NotImplementedError() + + def finish(self): + """ synchronize all active streams """ + for i, stream in enumerate(self.streams): + stream.synchronize() + + def batched_run(self, data, batch_size=10, **kwargs): + """ Run your data in batches (avoids memory problems) """ + nsubmit = 0 + results = [] + while nsubmit < len(data): + batch = [] + while len(batch) < batch_size and nsubmit < len(data): + batch.append(data[nsubmit]) + nsubmit += 1 + + res = self.run(batch, **kwargs) + self.finish() + results.extend(res) + + return results diff --git a/cuvarbase/base/context.py b/cuvarbase/base/context.py new file mode 100644 index 00000000..9386506d --- /dev/null +++ b/cuvarbase/base/context.py @@ -0,0 +1,40 @@ +"""Lazy CUDA primary-context management. + +Historically cuvarbase created (retained and pushed) the CUDA primary +context eagerly via ``import pycuda.autoprimaryctx`` at package import +time, so merely ``import cuvarbase`` required a working GPU. The context +is now created on first GPU use through :func:`ensure_context`, which +defers to the same ``pycuda.autoprimaryctx`` machinery: device selection +honors the ``CUDA_DEVICE`` environment variable (via pycuda's +``make_default_context``) and an ``atexit`` handler pops the context on +interpreter shutdown. + +As a result ``import cuvarbase`` and the CPU-only helpers (e.g. +``sparse_bls_cpu``, ``single_bls``, ``fap_baluev``) no longer touch the +GPU. The ``pycuda`` *package* remains an import dependency of the GPU +modules -- they ``import pycuda.driver`` at module top -- but importing +them no longer allocates a CUDA context; that happens only when a kernel +is compiled or a periodogram process is constructed. +""" + +_autoctx = None + + +def ensure_context(): + """Retain and activate the CUDA primary context, once, on first use. + + Returns the ``pycuda.autoprimaryctx`` module, which exposes the + active ``context`` and the selected ``device``. The underlying + context setup (``cuda.init()`` + ``retain_primary_context`` + push + + ``atexit`` cleanup) runs only on the first call; subsequent calls + return the cached module, so this is safe to call at the top of every + GPU entry point. + + Device selection follows the ``CUDA_DEVICE`` environment variable + (read by pycuda's ``make_default_context`` the first time this runs). + """ + global _autoctx + if _autoctx is None: + import pycuda.autoprimaryctx as autoctx + _autoctx = autoctx + return _autoctx diff --git a/cuvarbase/bls.py b/cuvarbase/bls.py index b9c0b84a..dd632320 100644 --- a/cuvarbase/bls.py +++ b/cuvarbase/bls.py @@ -2,29 +2,80 @@ Implementation of the box-least squares periodogram [K2002]_ and variants. -.. [K2002] `Kovacs et al. 2002 `_ +The Keplerian transit-search helpers (:func:`q_transit`, +:func:`freq_transit`, :func:`transit_autofreq`, :func:`eebls_transit`) +assume the transiting body orbits at the host star's mean density. That +assumption fixes the transit-duration/period relation [SM03]_ and, with +it, the optimal frequency-grid spacing for a transit search [O2014]_. -""" -from __future__ import print_function, division +.. [K2002] `Kovacs et al. 2002, A&A 391, 369 `_ -from builtins import zip -from builtins import range -import sys +""" +import functools +import threading +import warnings +from collections import OrderedDict -#import pycuda.autoinit -import pycuda.autoprimaryctx import pycuda.driver as cuda import pycuda.gpuarray as gpuarray from pycuda.compiler import SourceModule -from .core import GPUAsyncProcess -from .utils import find_kernel, _module_reader +from .base import ensure_context +from .utils import (find_kernel, _module_reader, subtract_epoch, + conflict_scatter_perm, check_lightcurve, check_freqs) +from .bls_frequencies import (_euler_transit_grid, + _recursion_transit_grid, + _validate_grid_method) +from .memory.bls_memory import BLSBatchMemory +from .memory._host import host_array -import resource import numpy as np + +__all__ = [ + 'fmin_transit', + 'fmax_transit0', + 'q_transit', + 'freq_transit', + 'fmax_transit', + 'transit_autofreq', + 'compile_bls', + 'BLSMemory', + 'eebls_gpu_fast', + 'eebls_gpu_fast_optimized', + 'eebls_gpu_fast_adaptive', + 'eebls_gpu_custom', + 'dnbins', + 'nbins_iter', + 'count_tot_nbins', + 'eebls_gpu', + 'single_bls', + 'convert_bls_power', + 'sparse_bls_cpu', + 'compile_sparse_bls', + 'sparse_bls_gpu', + 'eebls_transit', + 'compile_bls_batch', + 'eebls_gpu_batch', + 'hone_solution', + 'eebls_transit_gpu', +] + + _default_block_size = 256 + +# Minimum number of observations any BLS path accepts. Every BLS +# statistic is normalized by the weighted variance of y, which is +# identically zero for a single point (the periodogram came back as +# 0/0 = NaN); two points is the smallest input for which the null +# model is defined. The Keplerian entry points need more than this -- +# ``fmin_transit`` needs ``min_obs_per_transit`` (default 5) or the +# duty cycle q = min_obs_per_transit / N exceeds 1 and the grid comes +# back all-NaN -- and raise from there. +_BLS_MIN_NDATA = 2 _all_function_names = ['full_bls_no_sol', + 'full_bls_no_sol_optimized', + 'full_bls_no_sol_fused', 'bin_and_phase_fold_custom', 'reduction_max', 'store_best_sols', @@ -32,6 +83,183 @@ 'bin_and_phase_fold_bst_multifreq', 'binned_bls_bst'] +# Kernel cache: (block_size, use_optimized, function_names) -> compiled functions +# LRU cache with max 20 entries to prevent unbounded memory growth +# Each entry is ~1-5 MB (compiled CUDA kernels) +# Expected max memory: ~100 MB for full cache +_KERNEL_CACHE_MAX_SIZE = 20 +_kernel_cache = OrderedDict() +_kernel_cache_lock = threading.Lock() + + +def _choose_block_size(ndata): + """ + Choose a CUDA block size based on data size. + + Parameters + ---------- + ndata : int + Number of data points + + Returns + ------- + block_size : int + CUDA block size (32, 64, 128, or 256) + + Notes + ----- + The heuristic considers only ``ndata``; occupancy effects driven + by the number of phase bins (i.e. small ``qmin``) are ignored, so + the choice may be suboptimal for unusual ``ndata``/``nbins`` + combinations. The v1.0 re-benchmark (warm kernel cache) measures ~1.0-1.3x over + fixed blocks on Keplerian-style grids; benchmark ``block_size`` + yourself if it matters for your workload. + """ + if ndata <= 32: + return 32 # Single warp + elif ndata <= 64: + return 64 # Two warps + elif ndata <= 128: + return 128 # Four warps + else: + return 256 # Default (8 warps) + + +# Frequency-chunk size for occupancy-aware launches (see +# _shmem_limits_occupancy): 8192 measured best on an RTX A5000 Kepler +# grid (131K freqs; 8192 -> 114.8 ms vs 151.2 ms unchunked, 16384 +# within 2%), and small enough that launch overhead stays negligible +# for any grid where chunking triggers at all. +_OCCUPANCY_FREQ_CHUNK = 8192 + + +def _shmem_limits_occupancy(mem_req, block_size): + """True when a launch needing ``mem_req`` bytes of shared memory + per block caps resident blocks/SM below the thread-count limit -- + i.e. shared memory, not threads, is the occupancy limiter and + frequency-chunked launches (which size shared memory per chunk) + can win occupancy back.""" + dev = ensure_context().device + try: + smem_sm = dev.get_attribute( + cuda.device_attribute.MAX_SHARED_MEMORY_PER_MULTIPROCESSOR) + thr_sm = dev.get_attribute( + cuda.device_attribute.MAX_THREADS_PER_MULTIPROCESSOR) + except Exception: + return False + blocks_by_threads = max(1, thr_sm // block_size) + blocks_by_shmem = max(1, smem_sm // max(1, int(mem_req))) + return blocks_by_shmem < blocks_by_threads + + +def _get_cached_kernels(block_size, use_optimized=False, function_names=None): + """ + Get compiled kernels from cache, or compile and cache if not present. + + Thread-safe LRU cache implementation. When cache exceeds max size, + least recently used entries are evicted. + + Parameters + ---------- + block_size : int + CUDA block size + use_optimized : bool + Use optimized kernel + function_names : list, optional + Function names to compile + + Returns + ------- + functions : dict + Compiled kernel functions + + Notes + ----- + Cache size is limited to _KERNEL_CACHE_MAX_SIZE entries (~100 MB max). + Each compiled kernel is approximately 1-5 MB in memory. + Thread-safe for concurrent access from multiple threads. + """ + if function_names is None: + function_names = _all_function_names + + # Ensure a CUDA context exists before returning kernels, even on a + # cache hit (compile_bls only runs on a miss): callers go straight on + # to launches/memory allocation, so kernel acquisition must + # self-guarantee the context rather than rely on a warm-cache having + # been compiled in this process. Idempotent/cached after first call. + ensure_context() + + # Create cache key from block size, optimization flag, and function names + key = (block_size, use_optimized, tuple(sorted(function_names))) + + with _kernel_cache_lock: + # Check if key exists and move to end (most recently used) + if key in _kernel_cache: + _kernel_cache.move_to_end(key) + return _kernel_cache[key] + + # Compile kernel (done inside lock to prevent duplicate compilation) + compiled_functions = compile_bls(block_size=block_size, + use_optimized=use_optimized, + function_names=function_names) + + # Add to cache + _kernel_cache[key] = compiled_functions + _kernel_cache.move_to_end(key) + + # Evict oldest entry if cache is full + if len(_kernel_cache) > _KERNEL_CACHE_MAX_SIZE: + _kernel_cache.popitem(last=False) # Remove oldest (FIFO = LRU) + + return compiled_functions + + +def _cached_compile_bls(**kwargs): + """``compile_bls(**kwargs)`` through the thread-safe LRU cache. + + The generated CUDA source depends only on ``block_size`` and + ``use_optimized`` (the kernel file), and the returned dict on + ``function_names`` and ``prepare`` -- exactly the cache key + :func:`_get_cached_kernels` uses -- so the default entry points can + share compilations instead of running ``nvcc`` again per call. + ``prepare=False`` (which returns unprepared functions) is the one + option the cache does not model, so it falls through to a direct + compile. + + pycuda re-runs an ``nvcc --preprocess`` subprocess on every + ``SourceModule`` even when its own disk cache holds the cubin, so an + uncached ``compile_bls`` costs ~0.4-0.5 s per call (Sep 2026 audit, + ids 3, 7, 43, 60, 126). Bit-identical: the same compiled kernels. + """ + if not kwargs.get('prepare', True): + return compile_bls(**kwargs) + return _get_cached_kernels( + kwargs.get('block_size', _default_block_size), + kwargs.get('use_optimized', False), + list(kwargs.get('function_names', _all_function_names))) + + +def _get_cached_sparse_kernel(block_size): + """``compile_sparse_bls`` through the same LRU cache. + + ``sparse_bls.cu`` is templated on ``BLOCK_SIZE`` alone, so that is + the whole key. Without this every ``sparse_bls_gpu`` / + ``eebls_transit(ndata < sparse_threshold)`` call recompiled the + kernel (~0.4-1.6 s) around ~2-20 ms of kernel work (Sep 2026 audit, + ids 7, 43, 60, 126).""" + ensure_context() + key = (block_size, 'sparse') + with _kernel_cache_lock: + if key in _kernel_cache: + _kernel_cache.move_to_end(key) + return _kernel_cache[key] + compiled = compile_sparse_bls(block_size=block_size) + _kernel_cache[key] = compiled + _kernel_cache.move_to_end(key) + if len(_kernel_cache) > _KERNEL_CACHE_MAX_SIZE: + _kernel_cache.popitem(last=False) + return compiled + _function_signatures = { 'full_bls_no_sol': [np.intp, np.intp, np.intp, @@ -39,22 +267,37 @@ np.intp, np.uint32, np.uint32, np.uint32, np.uint32, np.uint32, np.float32, np.float32, np.uint32], + 'full_bls_no_sol_optimized': [np.intp, np.intp, np.intp, + np.intp, np.intp, np.intp, + np.intp, np.uint32, np.uint32, + np.uint32, np.uint32, np.uint32, + np.float32, np.float32, np.uint32], + # fused-noverlap variant (bls_common.cuh, present in both modules); + # identical argument list, hist_size = noverlap * max_nbins + 'full_bls_no_sol_fused': [np.intp, np.intp, np.intp, + np.intp, np.intp, np.intp, + np.intp, np.uint32, np.uint32, + np.uint32, np.uint32, np.uint32, + np.float32, np.float32, np.uint32], 'bin_and_phase_fold_custom': [np.intp, np.intp, np.intp, np.intp, np.intp, np.intp, - np.intp, np.intp, np.int32, + np.intp, np.intp, np.float64, np.uint32, np.uint32, np.uint32, - np.uint32], + np.uint32, np.uint32], 'reduction_max': [np.intp, np.intp, np.uint32, np.uint32, np.uint32, np.intp, np.intp, np.uint32, np.uint32], - 'store_best_sols': [np.intp, np.intp, np.intp, np.uint32, - np.uint32, np.uint32, np.float32, np.uint32, - np.uint32], + # (argmaxes, best_phi, best_q, nbins0_arr, nbinsf_arr, noverlap, + # dlogq, nfreq, freq_offset): per-frequency bin-count arrays + 'store_best_sols': [np.intp, np.intp, np.intp, np.intp, np.intp, + np.uint32, np.float32, np.uint32, np.uint32], 'store_best_sols_custom': [np.intp, np.intp, np.intp, np.intp, np.intp, np.uint32, np.uint32, np.uint32, np.uint32], + # (t, yw, w, yw_bin, w_bin, freqs, nbins0_arr, nbinsf_arr, ndata, + # nfreq, freq_offset, noverlap, dlogq, nbins_tot) 'bin_and_phase_fold_bst_multifreq': [np.intp, np.intp, np.intp, np.intp, - np.intp, np.intp, np.uint32, np.uint32, + np.intp, np.intp, np.intp, np.intp, np.uint32, np.uint32, np.uint32, np.uint32, np.float32, np.uint32], 'binned_bls_bst': [np.intp, np.intp, np.intp, np.uint32, np.uint32] @@ -64,8 +307,11 @@ def _reduction_max(max_func, arr, arr_args, nfreq, nbins, stream, final_arr, final_argmax_arr, final_index, block_size): - # assert power of 2 - assert(block_size - 2 * (block_size / 2) == 0) + # The reduction kernels require the compiled power-of-two block + # size; a mismatched block_size silently corrupts the tree + # reduction. (The old `assert(block_size - 2*(block_size/2) == 0)` + # was always true under Python 3 division.) + _validate_block_size(block_size) block = (block_size, 1, 1) grid_size = int(np.ceil(float(nbins) / block_size)) * nfreq @@ -82,7 +328,7 @@ def _reduction_max(max_func, arr, arr_args, nfreq, nbins, arr.ptr, arr_args.ptr, np.uint32(0), init) init = np.uint32(0) - nbins0 = grid_size / nfreq + nbins0 = grid_size // nfreq grid_size = int(np.ceil(float(nbins0) / block_size)) * nfreq grid = (grid_size, 1) @@ -95,19 +341,61 @@ def _reduction_max(max_func, arr, arr_args, nfreq, nbins, def fmin_transit(t, rho=1., min_obs_per_transit=5, **kwargs): - T = max(t) - min(t) + """Minimum search frequency for a Keplerian transit grid. + + The larger of (a) the frequency whose Keplerian duration holds at + least ``min_obs_per_transit`` samples and (b) ``2 / T`` (two cycles + over the baseline ``T``), the latter being the long-period limit of + Ofir (2014), Sect. 3.1 [O2014]_. + """ + t = np.asarray(t) + if t.size == 0 or not np.all(np.isfinite(t)): + raise ValueError("fmin_transit: t must be a non-empty array of " + "finite observation times") + if t.size < int(min_obs_per_transit): + # q = min_obs_per_transit / N > 1 below this, and + # freq_transit(q) = fmax0 * sin(pi q)**1.5 is NaN for q > 1: + # transit_autofreq used to return freqs = [nan], q = [nan], + # which reached the kernels as a NaN uint32 bin count and + # crashed the device (Sep 2026 audit, defect 23). + raise ValueError( + "fmin_transit: %d observations cannot hold %d samples in a " + "single transit (the Keplerian duty cycle would exceed 1); " + "pass an explicit fmin/freqs, or lower " + "min_obs_per_transit" % (t.size, int(min_obs_per_transit))) qmin = float(min_obs_per_transit) / len(t) fmin1 = freq_transit(qmin, rho=rho) - fmin2 = 2./(max(t) - min(t)) + fmin2 = 2./(np.max(t) - np.min(t)) return max([fmin1, fmin2]) def fmax_transit0(rho=1., **kwargs): + """Orbital frequency of a body grazing the stellar surface. + + This is the natural high-frequency cutoff for a transit search: the + Keplerian frequency of a circular orbit at the stellar radius, + :math:`f_{\\max,0} = \\sqrt{G \\rho_\\star / 3\\pi}` (period = + free-fall/orbit time at the surface). For ``rho = 1`` (solar mean + density) this evaluates to ``8.6307`` cycles/day -- a *derived* + constant, not a literature value. (Ofir 2014 [O2014]_ instead caps + at the Roche-limit frequency ``fmax0 / 3**1.5``.) + """ return 8.6307 * np.sqrt(rho) def q_transit(freq, rho=1., **kwargs): + """Keplerian transit-duration fraction ``q`` at a given frequency. + + Assuming a central transit (inclination 90 deg, impact parameter 0) + of a body orbiting at the host's mean density, the fractional transit + duration is :math:`q = \\arcsin[(f / f_{\\max,0})^{2/3}] / \\pi`. + This is Seager & Mallen-Ornelas (2003) eq. (3) reduced to ``b = 0`` + [SM03]_, with ``fmax0`` from :func:`fmax_transit0`. + + Not to be confused with :func:`cuvarbase.tls_grids.q_transit`, the + TLS helper that takes a *period* and stellar/planet parameters. + """ fmax0 = fmax_transit0(rho=rho) f23 = np.power(freq / fmax0, 2./3.) @@ -116,17 +404,28 @@ def q_transit(freq, rho=1., **kwargs): def freq_transit(q, rho=1., **kwargs): + """Frequency at which the Keplerian transit fraction equals ``q``. + + Inverse of :func:`q_transit`: + :math:`f = f_{\\max,0}\\,\\sin(\\pi q)^{3/2}` [SM03]_. + """ fmax0 = fmax_transit0(rho=rho) return fmax0 * (np.sin(np.pi * q) ** 1.5) def fmax_transit(rho=1., qmax=0.5, **kwargs): + """Maximum search frequency, capped by the surface-orbit cutoff. + + The smaller of :func:`fmax_transit0` and the frequency whose + Keplerian duration reaches ``qmax`` [SM03]_. + """ fmax0 = fmax_transit0(rho=rho) return min([fmax0, freq_transit(qmax, rho=rho, **kwargs)]) def transit_autofreq(t, fmin=None, fmax=None, samples_per_peak=2, - rho=1., qmin_fac=0.2, qmax_fac=None, **kwargs): + rho=1., qmin_fac=0.2, qmax_fac=None, + method='vectorized', **kwargs): """ Produce list of frequencies for a given frequency range suitable for performing Keplerian BLS. @@ -153,6 +452,24 @@ def transit_autofreq(t, fmin=None, fmax=None, samples_per_peak=2, qmax_fac: float, optional (default: None) The maximum :math:`q` value to search in units of the Keplerian :math:`q` value. If ``None``, this defaults to ``1/qmin_fac``. + method: str, optional (default: ``'vectorized'``) + How to evaluate the spacing recursion + ``f_{n+1} = f_n + qmin_fac q(f_n) / (samples_per_peak T)``. + ``'vectorized'`` solves it with numpy + (:func:`cuvarbase.bls_frequencies._euler_transit_grid`): + 9.8-14.9x faster on the audit host (a shared NVIDIA A40 + machine; see the CHANGELOG), and it converges to a fixed point + of the same recursion rather than approximating it -- the grid + length is identical and every frequency agrees to ~1e-15 + relative (one to two float64 ulps of the accumulated sum). + ``'recursion'`` + runs the original scalar Python loop, one ``q`` evaluation per + frequency; use it if you need grids bit-identical to + cuvarbase < 1.0. + + .. versionadded:: 1.0 + **kwargs: + passed to `fmin_transit` Returns ------- @@ -161,29 +478,60 @@ def transit_autofreq(t, fmin=None, fmax=None, samples_per_peak=2, q0vals: array_like The list of Keplerian :math:`q` values. + Notes + ----- + The grid is spaced by :math:`\\Delta f = q(f) / (\\mathrm{OS}\\,T)`, + Ofir (2014) eq. (4) [O2014]_ (with ``OS = samples_per_peak``): the + local frequency resolution is set by the transit duty cycle ``q(f)``, + so the grid is denser at high frequencies. This is far coarser than a + uniform grid while still Nyquist-sampling every trial transit. + """ if qmax_fac is None: qmax_fac = 1./qmin_fac + t = np.asarray(t) + if t.size == 0 or not np.all(np.isfinite(t)): + raise ValueError("transit_autofreq: t must be a non-empty array " + "of finite observation times") + if fmin is None: - fmin = fmin_transit(t, rho=rho, samples_per_peak=samples_per_peak, - **kwargs) + fmin = fmin_transit(t, rho=rho, **kwargs) if fmax is None: - fmax = fmax_transit(rho=rho, **kwargs) - - T = max(t) - min(t) - freqs = [fmin] - while freqs[-1] < fmax: - df = qmin_fac * q_transit(freqs[-1], rho=rho) / (samples_per_peak * T) - freqs.append(freqs[-1] + df) - freqs = np.array(freqs) + fmax = fmax_transit(rho=rho, qmax=0.5 / qmax_fac, **kwargs) + + T = np.max(t) - np.min(t) + _validate_grid_method(method) + if method == 'recursion': + freqs = _recursion_transit_grid(fmin, fmax, qmin_fac, + samples_per_peak * T, rho=rho) + else: + freqs = _euler_transit_grid(fmin, fmax, qmin_fac, + samples_per_peak * T, + fmax_transit0(rho=rho), rho=rho) q0vals = q_transit(freqs, rho=rho) return freqs, q0vals +def _validate_block_size(block_size): + """Validate CUDA block size for the BLS kernels. + + The tree reductions and warp-shuffle stages assume a power-of-two + block of at least one full warp; anything else silently produces + wrong results or undefined behavior, so fail loudly here instead. + """ + if not isinstance(block_size, (int, np.integer)): + raise ValueError("block_size must be an integer, got %r" + % (block_size,)) + if block_size < 32 or (block_size & (block_size - 1)) != 0: + raise ValueError("block_size must be a power of 2 and >= 32 " + "(one warp); got %d" % block_size) + + def compile_bls(block_size=_default_block_size, function_names=_all_function_names, prepare=True, + use_optimized=False, **kwargs): """ Compile BLS kernel @@ -197,6 +545,8 @@ def compile_bls(block_size=_default_block_size, prepare: bool, optional (default: True) Whether or not to prepare functions (for slightly faster kernel launching) + use_optimized: bool, optional (default: False) + Use optimized kernel with bank conflict fixes and warp shuffles Returns ------- @@ -204,11 +554,37 @@ def compile_bls(block_size=_default_block_size, Dictionary of (function name, PyCUDA function object) pairs """ + _validate_block_size(block_size) + + # Compiling a kernel needs an active CUDA context (lazily created). + ensure_context() + # Read kernel cppd = dict(BLOCK_SIZE=block_size) - kernel_txt = _module_reader(find_kernel('bls'), + kernel_name = 'bls_optimized' if use_optimized else 'bls' + kernel_txt = _module_reader(find_kernel(kernel_name), cpp_defs=cppd) + # Filter function names based on kernel variant: + # bls_optimized.cu has full_bls_no_sol_optimized but not full_bls_no_sol + # bls.cu has full_bls_no_sol but not full_bls_no_sol_optimized + requested = list(function_names) + if use_optimized: + function_names = [n for n in function_names + if n != 'full_bls_no_sol'] + else: + function_names = [n for n in function_names + if n != 'full_bls_no_sol_optimized'] + + if len(function_names) == 0: + raise ValueError( + "compile_bls: no loadable functions remain from %r with " + "use_optimized=%r (the %s kernel provides %r)" + % (requested, use_optimized, + 'optimized' if use_optimized else 'standard', + 'full_bls_no_sol_optimized' if use_optimized + else 'full_bls_no_sol')) + # compile kernel module = SourceModule(kernel_txt, options=['--use_fast_math']) @@ -223,8 +599,67 @@ def compile_bls(block_size=_default_block_size, return functions -class BLSMemory(object): +def _rephase_solutions(best_q, best_phi, epoch, freqs): + """``(q, phi)`` pairs with the transit phases moved back to the + caller's original timescale. + + The kernels report ``phi`` relative to the subtracted epoch; the + public convention is ``(t * f) mod 1`` on the input times, so + ``phi -> (phi + epoch * f) mod 1``. Vectorized: the per-frequency + Python comprehension this replaces cost 39 ms at 60,121 frequencies + and 74 ms at 117,403 -- more than the GPU work it followed (Sep 2026 + audit, id 136). + + The arithmetic is float64 throughout and bit-identical to the + comprehension it replaces: ``epoch`` comes from + :func:`~cuvarbase.utils.subtract_epoch` as a ``np.float64``, so + ``epoch * f`` was already promoted to float64 even for the float32 + grids :func:`~cuvarbase.bls_frequencies.keplerian_freq_grid` + returns. The explicit cast keeps it that way if a caller ever + supplies a plain Python ``epoch`` (NumPy 2's weak-scalar promotion + would evaluate the whole expression in float32, which destroys the + phase at BJD-scale epochs). + """ + phi = (np.asarray(best_phi, dtype=np.float64) + + epoch * np.asarray(freqs, dtype=np.float64)) % 1.0 + return list(zip(best_q, phi)) + + +# conflict_scatter_perm(n) is a pure function of n (a golden-ratio +# stride), and setdata used to rebuild it on every call: 0.09 ms of a +# 1.15 ms TESS-scale setdata, more at Kepler lengths. Small: one int64 +# array per distinct ndata, evicted oldest-first. +_SCATTER_PERM_CACHE_MAX_SIZE = 8 +_scatter_perm_cache = OrderedDict() +_scatter_perm_lock = threading.Lock() + + +def _cached_conflict_scatter_perm(n): + """:func:`cuvarbase.utils.conflict_scatter_perm` memoized on ``n``. + + The returned array is shared between callers and must be treated as + read-only (it is only ever used as a fancy index).""" + n = int(n) + with _scatter_perm_lock: + if n in _scatter_perm_cache: + _scatter_perm_cache.move_to_end(n) + return _scatter_perm_cache[n] + perm = conflict_scatter_perm(n) + if perm is not None: + perm.flags.writeable = False + with _scatter_perm_lock: + _scatter_perm_cache[n] = perm + _scatter_perm_cache.move_to_end(n) + if len(_scatter_perm_cache) > _SCATTER_PERM_CACHE_MAX_SIZE: + _scatter_perm_cache.popitem(last=False) + return perm + + +class BLSMemory: def __init__(self, max_ndata, max_nfreqs, stream=None, **kwargs): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). + ensure_context() self.max_ndata = max_ndata self.max_nfreqs = max_nfreqs self.t = None @@ -243,44 +678,64 @@ def __init__(self, max_ndata, max_nfreqs, stream=None, **kwargs): self.qmax = None self.nbinsf_g = None + self.chi2_0 = None + self.bls = None self.bls_g = None self.rtype = np.float32 + # floor(min(t)) subtracted from the times before the float32 cast + # (phases are measured relative to it) + self.epoch = None + self.stream = stream - self.allocate_pinned_arrays(nfreqs=max_nfreqs, ndata=max_ndata) + # Pinned (page-locked) host buffers by default for true async + # transfer overlap; falls back to page-aligned if pinning fails. + self.pinned = kwargs.get('pinned', True) + + self.allocate_host_arrays(nfreqs=max_nfreqs, ndata=max_ndata) def allocate_pinned_arrays(self, nfreqs=None, ndata=None): + """Deprecated alias for :meth:`allocate_host_arrays` (shipped in + 0.2.5; kept for 1.x, removed in 2.0).""" + warnings.warn("BLSMemory.allocate_pinned_arrays is deprecated; use " + "allocate_host_arrays. It will be removed in 2.0", + DeprecationWarning, stacklevel=2) + return self.allocate_host_arrays(nfreqs=nfreqs, ndata=ndata) + + def allocate_host_arrays(self, nfreqs=None, ndata=None): + """Allocate host arrays for transfers. + + The buffers that are actually the source or destination of an + asynchronous copy are page-locked when ``pinned=True`` (the + default), so ``set_async``/``get_async`` overlap with + computation; if pinning fails they fall back to page-aligned + memory (see :func:`cuvarbase.memory._host.host_array`). + + The rest are page-aligned: ``nbins0``/``nbinsf`` are replaced by + fresh (pageable) arrays in :meth:`setdata` before any transfer + can read them, and ``bls`` is only an async *destination* when a + stream is attached -- with ``stream=None``, + :meth:`transfer_data_to_cpu` builds a new array from + ``bls_g.get()`` and never writes into this one. Page-locking + costs ~1.5-6.5 ms per buffer regardless of its size, which + dominated single ``eebls_gpu_fast(memory=None)`` calls at + survey grid sizes (Sep 2026 audit, id 41). + """ if nfreqs is None: nfreqs = int(self.max_nfreqs) if ndata is None: ndata = int(self.max_ndata) - self.bls = cuda.aligned_zeros(shape=(nfreqs,), - dtype=self.rtype, - alignment=resource.getpagesize()) - - self.nbins0 = cuda.aligned_zeros(shape=(nfreqs,), - dtype=np.int32, - alignment=resource.getpagesize()) - - self.nbinsf = cuda.aligned_zeros(shape=(nfreqs,), - dtype=np.int32, - alignment=resource.getpagesize()) - - self.t = cuda.aligned_zeros(shape=(ndata,), - dtype=self.rtype, - alignment=resource.getpagesize()) - - self.yw = cuda.aligned_zeros(shape=(ndata,), - dtype=self.rtype, - alignment=resource.getpagesize()) - - self.w = cuda.aligned_zeros(shape=(ndata,), - dtype=self.rtype, - alignment=resource.getpagesize()) + pin_result = self.pinned and self.stream is not None + self.bls = host_array((nfreqs,), self.rtype, pinned=pin_result) + self.nbins0 = host_array((nfreqs,), np.int32, pinned=False) + self.nbinsf = host_array((nfreqs,), np.int32, pinned=False) + self.t = host_array((ndata,), self.rtype, pinned=self.pinned) + self.yw = host_array((ndata,), self.rtype, pinned=self.pinned) + self.w = host_array((ndata,), self.rtype, pinned=self.pinned) def allocate_freqs(self, nfreqs=None): if nfreqs is None: @@ -315,6 +770,10 @@ def transfer_data_to_cpu(self): else: self.bls_g.get_async(ary=self.bls, stream=self.stream) + # self.bls is page-locked, so the copy above is genuinely + # asynchronous: sync before the host-side normalization or + # the divide races the DMA and gets overwritten by it. + self.stream.synchronize() self.bls /= self.yy # return self.bls @@ -323,22 +782,62 @@ def setdata(self, t, y, dy, qmin=None, qmax=None, freqs=None, nf=None, transfer=True, **kwargs): + # The weights below are dy**-2 and the periodogram is divided + # by the weighted variance of y: a non-finite sample or + # dy = 0 used to travel to the device unnoticed. + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, + name='BLSMemory.setdata') + if freqs is not None: self.freqs = np.asarray(freqs).astype(self.rtype) - self.nbinsf = (np.ones_like(self.freqs)/qmin).astype(np.uint32) - self.nbins0 = (np.ones_like(self.freqs)/qmax).astype(np.uint32) + self.nbins0, self.nbinsf = _fast_path_nbins(self.freqs, + qmin, qmax) - self.t[:len(t)] = np.asarray(t).astype(self.rtype)[:] + # Epoch-subtract in float64 before the float32 cast: absolute + # timestamps (e.g. BJD) would otherwise destroy the phase fold. + t, self.epoch = subtract_epoch(t) w = np.power(dy, -2) - w /= sum(w) - self.w[:len(t)] = np.asarray(w).astype(self.rtype)[:] - - self.ybar = sum(y * w) - self.yy = np.dot(w, np.power(y - self.ybar, 2)) + # kept before the in-place normalization: chi2_0 below is the + # un-normalized weighted sum of squares, i.e. yy * sum(dy**-2) + wsum = np.sum(w) + w /= wsum + + self.ybar = np.sum(y * w) + # einsum, not np.dot: BLAS ddot spawns a full threadpool for + # large vectors, and on CPU-quota-limited containers (RunPod, + # K8s) the burst trips CFS throttling and freezes the process + # ~90 ms per 100 ms period (measured 8x end-to-end slowdown at + # TESS scale). einsum stays in numpy core, single-threaded. + self.yy = float(np.einsum('i,i->', w, + np.power(y - self.ybar, 2))) + # chi2 of the constant model for the data actually loaded here; + # convert_bls_power scalings must use this rather than whatever + # y/dy a later (memory-reuse) call happens to pass. Derived + # from yy instead of a second pass over the light curve + # (_chi2_null): chi2_0 = sum_i w_i (y_i - ybar)^2 with the raw + # weights, and yy is the same sum with the normalized ones, so + # chi2_0 = yy * sum(dy**-2) to float64 rounding (Sep 2026 + # audit, id 137). + self.chi2_0 = float(self.yy * np.float64(wsum)) u = (y - self.ybar) * w - self.yw[:len(t)] = np.asarray(u).astype(self.rtype)[:] + + # Store in conflict-scattered order: time-sorted input puts + # warp-adjacent samples into the same phase bin at nearly every + # trial frequency, serializing the kernels' shared-memory + # atomics (3.1x on a TESS-like cadence). Binning is a sum, so + # the order is semantically free. See + # utils.conflict_scatter_perm. + perm = _cached_conflict_scatter_perm(len(t)) + if perm is None: + self.t[:len(t)] = t.astype(self.rtype)[:] + self.w[:len(t)] = np.asarray(w).astype(self.rtype)[:] + self.yw[:len(t)] = np.asarray(u).astype(self.rtype)[:] + else: + self.t[:len(t)] = t.astype(self.rtype)[perm] + self.w[:len(t)] = np.asarray(w).astype(self.rtype)[perm] + self.yw[:len(t)] = np.asarray(u).astype(self.rtype)[perm] if any([x is None for x in [self.t_g, self.yw_g, self.w_g]]): self.allocate_data() @@ -347,6 +846,16 @@ def setdata(self, t, y, dy, qmin=None, qmax=None, if nf is None: nf = len(freqs) self.allocate_freqs(nfreqs=nf) + elif freqs is not None and len(self.freqs) != len(self.freqs_g): + # the device grid arrays keep their first size; a silent + # pycuda "ary and self must be the same size" used to + # surface from set_async + raise ValueError( + "BLSMemory: this memory's device frequency arrays hold " + "%d frequencies (sized by the first setdata call) but " + "%d were given; reuse a BLSMemory with the same " + "len(freqs) or construct a new one" + % (len(self.freqs_g), len(self.freqs))) if transfer: self.transfer_data_to_gpu(transfer_freqs=(freqs is not None)) @@ -357,9 +866,16 @@ def setdata(self, t, y, dy, qmin=None, qmax=None, def fromdata(cls, t, y, dy, qmin=None, qmax=None, freqs=None, nf=None, transfer=True, **kwargs): - - max_ndata = kwargs.get('max_ndata', len(t)) - max_nfreqs = kwargs.get('max_nfreqs', nf if freqs is None + """Construct a :class:`BLSMemory` sized for ``t``/``freqs`` and + load the data. ``max_ndata`` / ``max_nfreqs`` may be given as + keywords to over-allocate the host arrays (they used to be + passed on to ``__init__`` a second time and raise ``TypeError``; + Sep 2026 audit, id 67). Note the device frequency arrays are + sized by the first ``setdata`` call: reuse requires the same + ``len(freqs)``.""" + # pop, not get: __init__ takes them positionally + max_ndata = kwargs.pop('max_ndata', len(t)) + max_nfreqs = kwargs.pop('max_nfreqs', nf if freqs is None else len(freqs)) c = cls(max_ndata, max_nfreqs, **kwargs) @@ -368,6 +884,333 @@ def fromdata(cls, t, y, dy, qmin=None, qmax=None, **kwargs) +def _fast_path_nbins(freqs32, qmin, qmax): + """Per-frequency bin counts of the fast (shared-memory) kernels: + ``nbinsf = floor(1/qmin)`` fine bins and ``nbins0 = floor(1/qmax)`` + (box widths ``m / nbinsf`` for ``m`` up to and including + ``floor(nbinsf / nbins0)`` -- see :func:`_fast_box_widths`), + exactly as :meth:`BLSMemory.setdata` uploads them. + ``freqs32`` is the float32 frequency array (only its length and + dtype matter); ``qmin``/``qmax`` scalar or per-frequency. + + The bounds are validated here because this is where they become + ``uint32``: ``(1 / np.array([nan, 0.01, 5, inf])).astype(uint32)`` + is ``[0, 100, 0, 0]``, and a zero bin count makes the kernels + divide by zero and ``atomicAdd`` outside the histogram -- an + illegal memory access that kills the process's CUDA context (Sep + 2026 audit, defect 23). + + The division is deliberately left in the input dtype: float32 and + float64 truncate to different bin counts for some bounds (e.g. + ``qmin = 1/7`` gives 6 in float32 and 7 in float64), so promoting + it here would change every existing periodogram. + """ + _validate_fast_q_bounds(len(freqs32), qmin, qmax) + nbinsf = (np.ones_like(freqs32) / qmin).astype(np.uint32) + nbins0 = (np.ones_like(freqs32) / qmax).astype(np.uint32) + return nbins0, nbinsf + + +def _validate_fast_q_bounds(nfreqs, qmin, qmax): + """Validate transit-duration bounds for the binned (fast) kernels. + + ``_validate_q_bounds`` (finite, qmin >= 0, qmax > 0, qmin <= qmax) + plus the two conditions the *binned* kernels add: ``qmin > 0`` and + ``qmax <= 1`` (see :func:`_check_q_bounds_for_bins`). ``None`` + bounds fall through to the caller's default so this never changes + which exception an unsupported call raises. + """ + if qmin is None or qmax is None: + return + qmins = _broadcast_q_bound(qmin, nfreqs, 1e-2, 'qmin') + qmaxes = _broadcast_q_bound(qmax, nfreqs, 0.5, 'qmax') + _validate_q_bounds(qmins, qmaxes) + _check_q_bounds_for_bins(qmins, qmaxes) + + +def _validate_noverlap(noverlap): + """noverlap must be a positive integer (number of phase-shifted + passes on the fast BLS paths).""" + if not isinstance(noverlap, (int, np.integer)) or noverlap < 1: + raise ValueError("noverlap must be a positive integer, got %r" + % (noverlap,)) + + +# Small per-thread pool of BLSMemory objects for the "no memory given" +# fast path. Reusing one costs a setdata (a few host passes) instead of +# six host allocations plus four device allocations per call, which is +# most of a short call's wall time at survey grid sizes (Sep 2026 audit, +# id 41). Set to 0 to disable the pool (every call then allocates its +# own memory, as before 1.0). +# +# Thread-local rather than locked-and-shared: a BLSMemory is stateful +# (it holds one light curve's t/yw/w and one frequency grid), so two +# threads must never be handed the same one. +_MEMORY_POOL_MAX_SIZE = 2 +_memory_pool_tls = threading.local() + + +def _pooled_bls_memory(t, y, dy, qmin, qmax, freqs, kwargs): + """A :class:`BLSMemory` sized for ``(len(t), len(freqs))``, loaded + with this call's data, reused from the per-thread pool when one of + the right shape is there. + + The key pins ``max_ndata`` and ``max_nfreqs``, so ``setdata`` + overwrites every element of ``t``/``yw``/``w`` and every frequency + of the device grid: nothing of the previous light curve survives, + and the reuse guard in :meth:`BLSMemory.setdata` (device frequency + arrays keep their first size) can never trip. Returns ``None`` when + pooling is disabled or the shape is unusable. + """ + if _MEMORY_POOL_MAX_SIZE <= 0: + return None + pool = getattr(_memory_pool_tls, 'pool', None) + if pool is None: + pool = _memory_pool_tls.pool = OrderedDict() + + key = (int(len(t)), int(len(freqs)), bool(kwargs.get('pinned', True))) + mem = pool.pop(key, None) + if mem is None: + mem = BLSMemory.fromdata(t, y, dy, qmin=qmin, qmax=qmax, + freqs=freqs, stream=None, transfer=True, + **kwargs) + else: + mem.setdata(t, y, dy, qmin=qmin, qmax=qmax, freqs=freqs, + transfer=True, **kwargs) + pool[key] = mem + while len(pool) > _MEMORY_POOL_MAX_SIZE: + pool.popitem(last=False) + return mem + + +def _eebls_gpu_fast_impl(t, y, dy, freqs, fname, use_optimized, + qmin=1e-2, qmax=0.5, + ignore_negative_delta_sols=False, + functions=None, stream=None, dlogq=0.3, + memory=None, noverlap=2, max_nblocks=5000, + force_nblocks=None, dphi=0.0, + shmem_lim=None, freq_batch_size=None, + transfer_to_device=True, + transfer_to_host=True, + convention='chi2ratio', **kwargs): + """Shared implementation behind :func:`eebls_gpu_fast` and + :func:`eebls_gpu_fast_optimized`; see their docstrings for the + parameter descriptions.""" + # Validate before ANY device work (kernel compile included): a NaN + # in t used to give a finite periodogram with a wrong argmax, and a + # NaN or out-of-range q bound crashed the kernel and killed the + # process's CUDA context (Sep 2026 audit, defect 23). + _name = ('eebls_gpu_fast_optimized' if use_optimized + else 'eebls_gpu_fast') + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name=_name) + check_freqs(freqs, name=_name) + _validate_fast_q_bounds(len(freqs), qmin, qmax) + _validate_noverlap(noverlap) + _validate_convention(convention) + if convention != 'chi2ratio' and not transfer_to_host: + raise ValueError("convention=%r requires transfer_to_host=True " + "(the device-side periodogram is always " + "'chi2ratio')" % (convention,)) + + if functions is None: + # Use the thread-safe LRU kernel cache (compilation costs ~150 ms + # per call otherwise). Fall back to a direct compile only for + # non-default compile options that aren't part of the cache key. + # The fused-noverlap kernel ships in the same module, so request + # it alongside (same single compilation). + if kwargs.get('prepare', True): + functions = _get_cached_kernels( + kwargs.get('block_size', _default_block_size), + use_optimized, [fname, 'full_bls_no_sol_fused']) + else: + ckw = dict(kwargs) + ckw.setdefault('use_optimized', use_optimized) + functions = compile_bls( + function_names=[fname, 'full_bls_no_sol_fused'], **ckw) + + func = functions[fname] + + # Fused-noverlap fast path: for power-of-two noverlap with no base + # phase offset, one launch histograms at noverlap-times finer phase + # resolution and derives every pass's box sums from it -- the + # noverlap-x fold + histogram (and per-frequency fixed costs) are + # paid once. Bin assignment is bit-identical to the multi-pass loop + # there (see full_bls_no_sol_fused in bls_common.cuh); any other + # (noverlap, dphi) combination keeps the host-side loop, as do + # caller-provided ``functions`` dicts without the fused kernel. + fused_func = None + try: + fused_func = functions.get('full_bls_no_sol_fused') + except AttributeError: + fused_func = None + noverlap_int = int(noverlap) + use_fused = (fused_func is not None + and noverlap_int >= 2 + and float(dphi) == 0.0 + and (noverlap_int & (noverlap_int - 1)) == 0) + + if shmem_lim is None: + att = cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK + shmem_lim = ensure_context().device.get_attribute(att) + + if memory is None: + # Reuse a pooled memory where that is invisible to the caller: + # only on the default stream (with a stream attached, + # transfer_data_to_cpu writes into -- and hands back -- the + # pinned ``bls`` buffer, which a pooled memory would overwrite + # on the next call) and only when the result is transferred + # back (otherwise ``memory.bls`` is returned as-is and would + # carry the previous call's periodogram instead of zeros), and + # never when the caller sized the buffers by hand. + memory = None + if (stream is None and transfer_to_host + and 'max_ndata' not in kwargs + and 'max_nfreqs' not in kwargs): + memory = _pooled_bls_memory(t, y, dy, qmin, qmax, freqs, + kwargs) + if memory is None: + memory = BLSMemory.fromdata(t, y, dy, qmin=qmin, qmax=qmax, + freqs=freqs, stream=stream, + transfer=True, + **kwargs) + elif transfer_to_device: + memory.setdata(t, y, dy, qmin=qmin, qmax=qmax, + freqs=freqs, transfer=True, + **kwargs) + + float_size = np.float32(1).nbytes + block_size = kwargs.get('block_size', _default_block_size) + + auto_freq_batch = freq_batch_size is None + if freq_batch_size is None: + freq_batch_size = len(freqs) + + block = (block_size, 1, 1) + + # minimum q value that we can handle with the shared memory limit + qmin_min = 2 * float_size / (shmem_lim - float_size * block_size) + + # The fused kernel needs (block_size + 2*noverlap*max_nbins) floats + # of shared memory; fall back to the multi-pass loop when that + # exceeds the device limit (the loop only needs the 1x histogram). + if use_fused: + global_max_nbins = int(np.max(memory.nbinsf[:len(freqs)])) + fused_req = (block_size + + 2 * noverlap_int * global_max_nbins) * float_size + if fused_req > shmem_lim: + use_fused = False + elif auto_freq_batch and _shmem_limits_occupancy(fused_req, + block_size): + # Occupancy-aware chunking: launches size shared memory by + # the max bin count of the frequencies they cover, and + # ascending grids have monotonically decreasing bin counts + # -- chunked launches let everything past the first chunks + # run at full occupancy (measured +32% on the Kepler + # config; only triggers when shared memory is the + # occupancy limiter). + freq_batch_size = _OCCUPANCY_FREQ_CHUNK + + # Phase oversampling: the kernel's box start positions step one + # fine phase bin, so a single pass undersamples boxes whose width + # is near the finest bin. Fused path: one launch builds the + # noverlap-times finer histogram and evaluates all shifted grids. + # Fallback: run ``noverlap`` passes with the bin grid shifted by + # 1/noverlap of a bin each time and keep the elementwise max -- + # equivalent to the manual dphi re-run procedure this replaces. + best_bls_g = None + n_passes = 1 if use_fused else noverlap + for i_pass in range(n_passes): + dphi_pass = dphi + float(i_pass) / noverlap + + i_freq = 0 + while (i_freq < len(freqs)): + j_freq = min([i_freq + freq_batch_size, len(freqs)]) + nfreqs = j_freq - i_freq + + # np.max, not builtin max(): iterating a 300K-element numpy + # array through Python scalars cost 10+ ms per call at + # HAT-Net/Kepler grid sizes. + max_nbins = int(np.max(memory.nbinsf[i_freq:j_freq])) + + if use_fused: + hist_size = noverlap_int * int(max_nbins) + else: + hist_size = int(max_nbins) + mem_req = (block_size + 2 * hist_size) * float_size + + if mem_req > shmem_lim: + s = "qmin = %.2e requires too much shared memory." \ + % (1. / max_nbins) + s += " Either try a larger value of qmin (> %e)" % (qmin_min) + s += " or avoid using %s." % ( + 'eebls_gpu_fast_optimized' if use_optimized + else 'eebls_gpu_fast') + raise ValueError(s) + nblocks = min([nfreqs, max_nblocks]) + if force_nblocks is not None: + nblocks = force_nblocks + + grid = (nblocks, 1) + args = (grid, block) + if stream is not None: + args += (stream,) + args += (memory.t_g.ptr, memory.yw_g.ptr, memory.w_g.ptr) + args += (memory.bls_g.ptr, memory.freqs_g.ptr) + args += (memory.nbins0_g.ptr, memory.nbinsf_g.ptr) + args += (np.uint32(len(t)), np.uint32(nfreqs), + np.uint32(i_freq)) + if use_fused: + # hist_size is the fine histogram size; the fused + # kernel consumes the real noverlap and the base dphi. + args += (np.uint32(hist_size), np.uint32(noverlap_int)) + args += (np.float32(dlogq), np.float32(dphi)) + else: + # The kernel's own noverlap argument is a no-op in the + # compiled (linear bin spacing) branch; phase + # oversampling is implemented by the dphi-shifted + # passes above. + args += (np.uint32(max_nbins), np.uint32(1)) + args += (np.float32(dlogq), np.float32(dphi_pass)) + args += (np.uint32(ignore_negative_delta_sols),) + + launch_func = fused_func if use_fused else func + if stream is not None: + launch_func.prepared_async_call(*args, + shared_size=int(mem_req)) + else: + launch_func.prepared_call(*args, shared_size=int(mem_req)) + + i_freq = j_freq + + if not use_fused and noverlap > 1: + if best_bls_g is None: + best_bls_g = memory.bls_g.copy() + else: + gpuarray.maximum(memory.bls_g, best_bls_g, + out=best_bls_g, stream=stream) + + if best_bls_g is not None: + cuda.memcpy_dtod(memory.bls_g.gpudata, best_bls_g.gpudata, + best_bls_g.nbytes) + + if transfer_to_host: + memory.transfer_data_to_cpu() + if stream is not None: + stream.synchronize() + # Use the chi2_0 of the data actually loaded in the memory: on + # the memory-reuse path (memory= given, transfer_to_device=False) + # the y/dy arguments may not be the data that produced this + # periodogram, and 'snr'/'loglik' would be scaled by the wrong + # null model. + chi2_0 = getattr(memory, 'chi2_0', None) + if chi2_0 is None: + chi2_0 = _chi2_null(y, dy) + return _convert_bls_power_from_chi2_0(memory.bls, chi2_0, + convention) + + return memory.bls + + def eebls_gpu_fast(t, y, dy, freqs, qmin=1e-2, qmax=0.5, ignore_negative_delta_sols=False, functions=None, stream=None, dlogq=0.3, @@ -399,13 +1242,22 @@ def eebls_gpu_fast(t, y, dy, freqs, qmin=1e-2, qmax=0.5, No extra global memory is needed, meaning you likely do *not* need to use ``large_run`` with this function. - .. note:: + .. warning:: + + BLS weights each observation by ``1/dy**2`` (normalized). A + point with a near-zero reported uncertainty concentrates + essentially all of the statistical weight in one phase bin and + deterministically produces spurious power of ~0.99 in pure + noise, at nearly every trial frequency. Symptoms: + ``max(dy**-2) / sum(dy**-2)`` close to 1, and suspiciously + high, nearly flat power on noise-like data. Guard with a + percentile-based error floor before calling:: - There is no ``noverlap`` parameter here yet. This is only a problem - if the optimal ``q`` value is close to ``qmin``. To alleviate this, - you can run this function ``noverlap`` times with - ``dphi = i/noverlap`` for the ``i``-th run. Then take the best solution - of all runs. + dy_floor = np.percentile(dy, 10) + dy = np.clip(dy, dy_floor, None) + + See the "Data hygiene: near-zero uncertainties" section of the + BLS documentation for details. Parameters ---------- @@ -418,16 +1270,52 @@ def eebls_gpu_fast(t, y, dy, freqs, qmin=1e-2, qmax=0.5, freqs: array_like, float Frequencies qmin: float or array_like, optional (default: 1e-2) - minimum q values to search at each frequency + minimum q values to search at each frequency; scalar or one + value per frequency qmax: float or array_like (default: 0.5) - maximum q values to search at each frequency + maximum q values to search at each frequency; scalar or one + value per frequency. + + .. note:: + + The shared-memory kernels do not search a continuum of + ``q``. Phase is binned into ``nbinsf = floor(1/qmin)`` + bins and a box is ``m`` of those bins, so the searched + widths are ``q = m / nbinsf`` for ``m = 1, 1 + dnbins(1), + ...`` up to ``floor(nbinsf / floor(1/qmax))`` -- the + widest box with ``q <= 1/floor(1/qmax)``. The widest box + is included (before 1.0 the loop stopped one level short + and never tested ``qmax`` itself), but the geometric + ``dlogq`` step can still skip it: with the defaults + (``qmin=0.01``, ``qmax=0.5``, ``dlogq=0.3``) the widest + tested width is ``q = 0.48``. Box start phases step one + fine bin divided by ``noverlap``, so a box of ``m`` bins + can be misaligned by up to ``1 / (2 m noverlap)`` of its + width, which costs power: the Sep 2026 audit measured + 49-90 % of the exact float64 box power for boxes at or + near ``qmin`` (``m`` of order 1). Raise ``noverlap`` + (nearly free on the fused path) or lower ``qmin`` if you + need to compare fast-path power with an exact (e.g. + astropy) box fit at face value; :func:`eebls_gpu` uses a + finer q ladder. ignore_negative_delta_sols: bool Whether or not to ignore solutions with a negative delta (i.e. an inverted dip) + noverlap: int, optional (default: 2) + Phase-offset oversampling: the periodogram is the elementwise + maximum over ``noverlap`` passes, with the phase-bin grid + shifted by ``1/noverlap`` of the finest bin width between + passes. This recovers box solutions whose phase offset falls + between bin boundaries (important when the best ``q`` is close + to ``qmin``); runtime scales linearly with ``noverlap``. + ``noverlap=1`` is a single unshifted pass. + convention: str, optional (default: 'chi2ratio') + Power-spectrum convention for the returned periodogram + ('chi2ratio', 'snr' or 'loglik'); see + :func:`convert_bls_power`. Requires ``transfer_to_host=True`` + for non-default values. dphi: float, optional (default: 0.) - Phase offset (in units of the finest grid spacing). If you - want ``noverlap`` bins at the smallest ``q`` value, run this - function ``noverlap`` times, with ``dphi = i / noverlap`` - for the ``i``-th run and take the best solution for all the runs. + Base phase-bin offset in units of the finest grid spacing; + pass ``i_pass`` adds ``i_pass / noverlap`` to it. dlogq: float The logarithmic spacing of the q values to use. If negative, the q values increase by ``dq = qmin``. @@ -441,7 +1329,128 @@ def eebls_gpu_fast(t, y, dy, freqs, qmin=1e-2, qmax=0.5, Maximum amount of shared memory to use per block in bytes. This is GPU-dependent but usually around 48KB. If ``None``, uses device information provided by PyCUDA (recommended). - max_nblocks: int, optional (default: 200) + max_nblocks: int, optional (default: 5000) + Maximum grid size to use + force_nblocks: int, optional (default: None) + If this is set the gridsize is forced to be this value + memory: :class:`BLSMemory` instance, optional (default: None) + See :class:`BLSMemory`. + transfer_to_host: bool, optional (default: True) + Transfer BLS back to CPU. + transfer_to_device: bool, optional (default: True) + Transfer data to GPU + **kwargs: + passed to `compile_bls` + + Returns + ------- + bls: array_like, float + BLS periodogram, normalized to + :math:`1 - \\chi_2(\\omega) / \\chi_2(constant)` + + Notes + ----- + The phase fold is float32, so it resolves ``ulp(T * max(freqs))``: + keep ``qmin / noverlap`` well above it or narrow boxes lose power + (``q = 0.01`` boxes recover 3-15 % less than the exact float64 box + at ``T * f > 7000``, 22 % less over a 10-year baseline at 20 c/d), + and binned power moves by up to ~10 % with the fractional part of + ``min(t)``. Because the kernels accumulate through float32 atomics, + two identical calls differ by ~1e-8 to 1e-7. See "Precision and + reproducibility" in the BLS documentation. + + """ + return _eebls_gpu_fast_impl( + t, y, dy, freqs, 'full_bls_no_sol', + kwargs.pop('use_optimized', False), + qmin=qmin, qmax=qmax, + ignore_negative_delta_sols=ignore_negative_delta_sols, + functions=functions, stream=stream, dlogq=dlogq, + memory=memory, noverlap=noverlap, max_nblocks=max_nblocks, + force_nblocks=force_nblocks, dphi=dphi, + shmem_lim=shmem_lim, freq_batch_size=freq_batch_size, + transfer_to_device=transfer_to_device, + transfer_to_host=transfer_to_host, **kwargs) + + +def eebls_gpu_fast_optimized(t, y, dy, freqs, qmin=1e-2, qmax=0.5, + ignore_negative_delta_sols=False, + functions=None, stream=None, dlogq=0.3, + memory=None, noverlap=2, max_nblocks=5000, + force_nblocks=None, dphi=0.0, + shmem_lim=None, freq_batch_size=None, + transfer_to_device=True, + transfer_to_host=True, **kwargs): + """ + Variant of eebls_gpu_fast built from the bls_optimized.cu module. + + Its multi-pass kernel (``full_bls_no_sol_optimized``) uses separate + yw/w shared arrays (no bank conflicts) and a warp-shuffle finish + for the block reduction. At the default power-of-two ``noverlap`` + with ``dphi=0`` both entry points launch the SAME fused kernel + (``full_bls_no_sol_fused``, shared through bls_common.cuh), so they + perform identically; only the multi-pass fallback (other + ``noverlap`` values, ``dphi != 0``) differs, where the v1.0 + re-benchmark measured parity (~1.0x) rather than the 20-30 % once + claimed here. + + All parameters are identical to eebls_gpu_fast. + + Parameters + ---------- + t: array_like, float + Observation times + y: array_like, float + Observations + dy: array_like, float + Observation uncertainties + freqs: array_like, float + Frequencies + qmin: float or array_like, optional (default: 1e-2) + minimum q values to search at each frequency; scalar or one + value per frequency + qmax: float or array_like (default: 0.5) + maximum q values to search at each frequency; scalar or one + value per frequency. + + .. note:: + + The shared-memory kernels do not search a continuum of + ``q``. Phase is binned into ``nbinsf = floor(1/qmin)`` + bins and a box is ``m`` of those bins, so the searched + widths are ``q = m / nbinsf`` for ``m = 1, 1 + dnbins(1), + ...`` up to ``floor(nbinsf / floor(1/qmax))`` -- the + widest box with ``q <= 1/floor(1/qmax)``. The widest box + is included (before 1.0 the loop stopped one level short + and never tested ``qmax`` itself), but the geometric + ``dlogq`` step can still skip it: with the defaults + (``qmin=0.01``, ``qmax=0.5``, ``dlogq=0.3``) the widest + tested width is ``q = 0.48``. Box start phases step one + fine bin divided by ``noverlap``, so a box of ``m`` bins + can be misaligned by up to ``1 / (2 m noverlap)`` of its + width, which costs power: the Sep 2026 audit measured + 49-90 % of the exact float64 box power for boxes at or + near ``qmin`` (``m`` of order 1). Raise ``noverlap`` + (nearly free on the fused path) or lower ``qmin`` if you + need to compare fast-path power with an exact (e.g. + astropy) box fit at face value; :func:`eebls_gpu` uses a + finer q ladder. + ignore_negative_delta_sols: bool + Whether or not to ignore solutions with a negative delta (i.e. an inverted dip) + noverlap: int, optional (default: 2) + Phase-offset oversampling (elementwise max over ``noverlap`` + bin-grid-shifted passes); see :func:`eebls_gpu_fast`. + dphi: float, optional (default: 0.) + Base phase-bin offset (in units of the finest grid spacing) + dlogq: float + The logarithmic spacing of the q values to use + functions: dict + Dictionary of compiled functions (see :func:`compile_bls`) + freq_batch_size: int, optional (default: None) + Number of frequencies to compute in a single batch + shmem_lim: int, optional (default: None) + Maximum amount of shared memory to use per block in bytes + max_nblocks: int, optional (default: 5000) Maximum grid size to use force_nblocks: int, optional (default: None) If this is set the gridsize is forced to be this value @@ -458,93 +1467,163 @@ def eebls_gpu_fast(t, y, dy, freqs, qmin=1e-2, qmax=0.5, ------- bls: array_like, float BLS periodogram, normalized to - :math:`1 - \chi_2(\omega) / \chi_2(constant)` + :math:`1 - \\chi_2(\\omega) / \\chi_2(constant)` """ - fname = 'full_bls_no_sol' + kwargs.pop('use_optimized', None) + return _eebls_gpu_fast_impl( + t, y, dy, freqs, 'full_bls_no_sol_optimized', True, + qmin=qmin, qmax=qmax, + ignore_negative_delta_sols=ignore_negative_delta_sols, + functions=functions, stream=stream, dlogq=dlogq, + memory=memory, noverlap=noverlap, max_nblocks=max_nblocks, + force_nblocks=force_nblocks, dphi=dphi, + shmem_lim=shmem_lim, freq_batch_size=freq_batch_size, + transfer_to_device=transfer_to_device, + transfer_to_host=transfer_to_host, **kwargs) + + +def eebls_gpu_fast_adaptive(t, y, dy, freqs, qmin=1e-2, qmax=0.5, + ignore_negative_delta_sols=False, + functions=None, stream=None, dlogq=0.3, + memory=None, noverlap=2, max_nblocks=5000, + force_nblocks=None, dphi=0.0, + shmem_lim=None, freq_batch_size=None, + transfer_to_device=True, + transfer_to_host=True, + use_optimized=True, + **kwargs): + """ + Adaptive BLS with dynamic block sizing for optimal performance. - if functions is None: - functions = compile_bls(function_names=[fname], **kwargs) + Automatically selects optimal block size based on ndata: + - ndata <= 32: 32 threads (single warp) + - ndata <= 64: 64 threads (two warps) + - ndata <= 128: 128 threads (four warps) + - ndata > 128: 256 threads (eight warps) - func = functions[fname] + Smaller blocks reduce idle-thread overhead for small datasets. + Measured benefit is modest: the v1.0 re-benchmark (warm kernel + cache) puts the block-size effect at ~1.0-1.3x vs the fixed + 256-thread default (earlier 1.4-5.3x figures were dominated by + per-call kernel handling that the kernel cache now amortizes; see + ``benchmarks/results/block_size_a5000.json``). - if shmem_lim is None: - dev = pycuda.autoprimaryctx.device - att = cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK - shmem_lim = pycuda.autoprimaryctx.device.get_attribute(att) + All other parameters identical to eebls_gpu_fast. - if memory is None: - memory = BLSMemory.fromdata(t, y, dy, qmin=qmin, qmax=qmax, - freqs=freqs, stream=stream, - transfer=True, - **kwargs) - elif transfer_to_device: - memory.setdata(t, y, dy, qmin=qmin, qmax=qmax, - freqs=freqs, transfer=True, - **kwargs) + Parameters + ---------- + t: array_like, float + Observation times + y: array_like, float + Observations + dy: array_like, float + Observation uncertainties + freqs: array_like, float + Frequencies + qmin: float or array_like, optional (default: 1e-2) + minimum q values to search at each frequency; scalar or one + value per frequency + qmax: float or array_like (default: 0.5) + maximum q values to search at each frequency; scalar or one + value per frequency. - float_size = np.float32(1).nbytes - block_size = kwargs.get('block_size', _default_block_size) + .. note:: - if freq_batch_size is None: - freq_batch_size = len(freqs) + The shared-memory kernels do not search a continuum of + ``q``. Phase is binned into ``nbinsf = floor(1/qmin)`` + bins and a box is ``m`` of those bins, so the searched + widths are ``q = m / nbinsf`` for ``m = 1, 1 + dnbins(1), + ...`` up to ``floor(nbinsf / floor(1/qmax))`` -- the + widest box with ``q <= 1/floor(1/qmax)``. The widest box + is included (before 1.0 the loop stopped one level short + and never tested ``qmax`` itself), but the geometric + ``dlogq`` step can still skip it: with the defaults + (``qmin=0.01``, ``qmax=0.5``, ``dlogq=0.3``) the widest + tested width is ``q = 0.48``. Box start phases step one + fine bin divided by ``noverlap``, so a box of ``m`` bins + can be misaligned by up to ``1 / (2 m noverlap)`` of its + width, which costs power: the Sep 2026 audit measured + 49-90 % of the exact float64 box power for boxes at or + near ``qmin`` (``m`` of order 1). Raise ``noverlap`` + (nearly free on the fused path) or lower ``qmin`` if you + need to compare fast-path power with an exact (e.g. + astropy) box fit at face value; :func:`eebls_gpu` uses a + finer q ladder. + ignore_negative_delta_sols: bool + Whether or not to ignore solutions with a negative delta + use_optimized: bool, optional (default: True) + Use optimized kernel with bank conflict fixes and warp shuffles + **kwargs: + All other parameters passed to underlying implementation - nbatches = int(np.ceil(len(freqs) / freq_batch_size)) - block = (block_size, 1, 1) + Returns + ------- + bls: array_like, float + BLS periodogram - # minimum q value that we can handle with the shared memory limit - qmin_min = 2 * float_size / (shmem_lim - float_size * block_size) - i_freq = 0 - while(i_freq < len(freqs)): - j_freq = min([i_freq + freq_batch_size, len(freqs)]) - nfreqs = j_freq - i_freq - - max_nbins = max(memory.nbinsf[i_freq:j_freq]) - - mem_req = (block_size + 2 * max_nbins) * float_size - - if mem_req > shmem_lim: - s = "qmin = %.2e requires too much shared memory." % (1./max_nbins) - s += " Either try a larger value of qmin (> %e)" % (qmin_min) - s += " or avoid using eebls_gpu_fast." - raise Exception(s) - # nblocks = int((2 * max_shmem / (mem_req + 4 * float_size))) - nblocks = min([nfreqs, max_nblocks]) - if force_nblocks is not None: - nblocks = force_nblocks - - grid = (nblocks, 1) - args = (grid, block) - if stream is not None: - args += (stream,) - args += (memory.t_g.ptr, memory.yw_g.ptr, memory.w_g.ptr) - args += (memory.bls_g.ptr, memory.freqs_g.ptr) - args += (memory.nbins0_g.ptr, memory.nbinsf_g.ptr) - args += (np.uint32(len(t)), np.uint32(nfreqs), - np.uint32(i_freq)) - args += (np.uint32(max_nbins), np.uint32(noverlap)) - args += (np.float32(dlogq), np.float32(dphi)) - args += (np.uint32(ignore_negative_delta_sols),) + See Also + -------- + eebls_gpu_fast : Standard implementation with fixed block size + eebls_gpu_fast_optimized : Optimized implementation + """ + # Validated here as well as in the shared implementation: this + # wrapper compiles a kernel (GPU work) before it delegates. + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, + name='eebls_gpu_fast_adaptive') + check_freqs(freqs, name='eebls_gpu_fast_adaptive') + _validate_fast_q_bounds(len(freqs), qmin, qmax) - if stream is not None: - func.prepared_async_call(*args, shared_size=int(mem_req)) - else: - func.prepared_call(*args, shared_size=int(mem_req)) + ndata = len(t) - i_freq = j_freq + # Choose optimal block size + block_size = _choose_block_size(ndata) - if transfer_to_host: - memory.transfer_data_to_cpu() - if stream is not None: - stream.synchronize() + # Override any user-provided block_size + kwargs['block_size'] = block_size - return memory.bls + # Get cached kernels for this block size. The fused-noverlap kernel + # ships in the same module and costs nothing extra to load, so ask + # for it too: without it in the dict the shared implementation falls + # back to the ``noverlap``-pass loop, which was 1.7-2.3x the GPU + # time of eebls_gpu_fast on identical inputs (Sep 2026 audit, ids + # 40, 63). ``_eebls_gpu_fast_impl`` still picks the multi-pass loop + # whenever the fused kernel is not valid (non-power-of-two + # ``noverlap``, ``dphi != 0``, or not enough shared memory). + if functions is None: + fname = 'full_bls_no_sol_optimized' if use_optimized else 'full_bls_no_sol' + functions = _get_cached_kernels(block_size, use_optimized, + [fname, 'full_bls_no_sol_fused']) + + # Use optimized implementation + if use_optimized: + return eebls_gpu_fast_optimized( + t, y, dy, freqs, qmin=qmin, qmax=qmax, + ignore_negative_delta_sols=ignore_negative_delta_sols, + functions=functions, stream=stream, dlogq=dlogq, + memory=memory, noverlap=noverlap, max_nblocks=max_nblocks, + force_nblocks=force_nblocks, dphi=dphi, + shmem_lim=shmem_lim, freq_batch_size=freq_batch_size, + transfer_to_device=transfer_to_device, + transfer_to_host=transfer_to_host, + **kwargs) + else: + return eebls_gpu_fast( + t, y, dy, freqs, qmin=qmin, qmax=qmax, + ignore_negative_delta_sols=ignore_negative_delta_sols, + functions=functions, stream=stream, dlogq=dlogq, + memory=memory, noverlap=noverlap, max_nblocks=max_nblocks, + force_nblocks=force_nblocks, dphi=dphi, + shmem_lim=shmem_lim, freq_batch_size=freq_batch_size, + transfer_to_device=transfer_to_device, + transfer_to_host=transfer_to_host, + **kwargs) def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, ignore_negative_delta_sols=False, freq_batch_size=None, nstreams=5, max_memory=None, - functions=None, **kwargs): + functions=None, convention='chi2ratio', **kwargs): """ Box-Least Squares, with custom q and phi values. Useful if you're honing the initial solution or testing between @@ -563,18 +1642,27 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, q_values: array_like Set of q values to search at each trial frequency phi_values: float or array_like - Set of phi values to search at each trial frequency + Set of transit start phases to search at each trial frequency. + These are ABSOLUTE phases, ``(t * f) mod 1`` in the original + input timescale (the same convention as the ``phi`` returned + by :func:`eebls_gpu` and accepted by :func:`single_bls`); they + are re-referenced internally to the subtracted epoch in float64. + Consequently the same coarse ``phi_values`` grid samples + different absolute phases for ``t`` and ``t + 2457000.5``, and + low-power frequencies can differ between the two (a fine grid, + or :func:`hone_solution`, makes this negligible). ignore_negative_delta_sols: bool Whether or not to ignore solutions with a negative delta (i.e. an inverted dip) nstreams: int, optional (default: 5) Number of CUDA streams to utilize. freq_batch_size: int, optional (default: None) - Number of frequencies to compute in a single batch; determines - this automatically by default based on ``max_memory`` + Number of frequencies to compute in a single batch; determined + automatically from ``max_memory`` when ``None``; capped at + ``len(freqs)`` and at ``(2**31 - 1) // len(t)`` either way. max_memory: float, optional (default: None) - Maximum memory to use in bytes. Will ignore this if - ``freq_batch_size`` is specified. If ``None``, will use the - free memory given by ``pycuda.driver.mem_get_info()`` + Memory budget in bytes for the device scratch buffers. Ignored + if ``freq_batch_size`` is specified; ``None`` budgets half of + the free memory reported by ``pycuda.driver.mem_get_info()``. functions: tuple of CUDA functions Dictionary of prepared functions from :func:`compile_bls`. **kwargs: @@ -588,17 +1676,26 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, Best (q, phi) solution at each frequency """ + # Validate before any GPU work: an unknown convention would + # otherwise only raise at the return statement, after the whole + # multi-stream grid search has run. + _validate_convention(convention) + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, + name='eebls_gpu_custom') + check_freqs(freqs, name='eebls_gpu_custom') functions = functions if functions is not None \ - else compile_bls(**kwargs) + else _cached_compile_bls(**kwargs) block_size = kwargs.get('block_size', _default_block_size) ndata = len(t) + nfreq = len(freqs) - # read max_memory as total free memory available from driver + # default budget: half of the free device memory, bounded below by + # what the grid needs (see _DEFAULT_MEMORY_FRACTION) if max_memory is None: free, total = cuda.mem_get_info() - max_memory = int(0.9 * free) + max_memory = int(_DEFAULT_MEMORY_FRACTION * free) if freq_batch_size is None: # compute memory @@ -610,61 +1707,74 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, nq = len(q_values) nphi = len(phi_values) - # q_values and phi_values - mem0 += nq + nphi + # q_values (float32) and phi_values (float64) + mem0 += nq * real_type_size + nphi * 2 * real_type_size # freqs + bls + best_phi + best_q + best_sol (int32) - mem0 += len(freqs) * 5 * real_type_size + mem0 += nfreq * 5 * real_type_size # yw_g_bins, w_g_bins, bls_tmp_gs, bls_tmp_sol_gs (int32) mem_per_f = 4 * nstreams * nq * nphi * real_type_size freq_batch_size = int(float(max_memory - mem0) / (mem_per_f)) - if freq_batch_size == 0: - raise Exception("Not enough memory (freq_batch_size = 0)") + if freq_batch_size <= 0: + raise RuntimeError("Not enough memory (freq_batch_size = 0)") - nbtot = len(q_values) * len(phi_values) * freq_batch_size + # cap at len(freqs) and at (2^31 - 1) // ndata (fold launch geometry; + # see _cap_freq_batch_size) + freq_batch_size = _cap_freq_batch_size(freq_batch_size, ndata, nfreq) - grid_size = int(np.ceil(float(nbtot) / block_size)) + nbtot = len(q_values) * len(phi_values) * freq_batch_size # move data to GPU w = np.power(dy, -2) - w /= sum(w) - ybar = np.dot(w, y) - YY = np.dot(w, np.power(np.array(y) - ybar, 2)) + w /= np.sum(w) + # einsum, not np.dot: BLAS ddot spawns a full threadpool for large + # vectors, and on CPU-quota-limited containers the burst trips CFS + # throttling (measured on the pod at ndata = 20000: median 0.60 ms + # with a 98 ms tail and 12 throttle events per 50 calls, vs 0.17 ms + # and none for einsum). Same operation, last-ulp float64 summation + # order. See BLSMemory.setdata (Sep 2026 audit, id 45). + ybar = float(np.einsum('i,i->', w, y)) + YY = float(np.einsum('i,i->', w, np.power(np.array(y) - ybar, 2))) yw = (np.array(y) - ybar) * np.array(w) - t_g = gpuarray.to_gpu(np.array(t).astype(np.float32)) + t, epoch = subtract_epoch(t) + t_g = gpuarray.to_gpu(t.astype(np.float32)) yw_g = gpuarray.to_gpu(yw.astype(np.float32)) w_g = gpuarray.to_gpu(np.array(w).astype(np.float32)) - freqs_g = gpuarray.to_gpu(np.array(freqs).astype(np.float32)) + freqs_g = gpuarray.to_gpu(np.array(freqs).astype(np.float64)) + + nbatches = int(np.ceil(float(nfreq) / freq_batch_size)) + # One scratch set per stream, but never more streams than batches + # (a single-batch grid does not need nstreams x 4 zero-filled + # buffers). + nsets = max(1, min(int(nstreams), nbatches)) yw_g_bins, w_g_bins, bls_tmp_gs, bls_tmp_sol_gs, streams \ = [], [], [], [], [] - for i in range(nstreams): + for i in range(nsets): streams.append(cuda.Stream()) yw_g_bins.append(gpuarray.zeros(nbtot, dtype=np.float32)) w_g_bins.append(gpuarray.zeros(nbtot, dtype=np.float32)) bls_tmp_gs.append(gpuarray.zeros(nbtot, dtype=np.float32)) bls_tmp_sol_gs.append(gpuarray.zeros(nbtot, dtype=np.uint32)) - bls_g = gpuarray.zeros(len(freqs), dtype=np.float32) - bls_sol_g = gpuarray.zeros(len(freqs), dtype=np.uint32) + bls_g = gpuarray.zeros(nfreq, dtype=np.float32) + bls_sol_g = gpuarray.zeros(nfreq, dtype=np.uint32) - bls_best_phi = gpuarray.zeros(len(freqs), dtype=np.float32) - bls_best_q = gpuarray.zeros(len(freqs), dtype=np.float32) + bls_best_phi = gpuarray.zeros(nfreq, dtype=np.float32) + bls_best_q = gpuarray.zeros(nfreq, dtype=np.float32) q_values_g = gpuarray.to_gpu(np.asarray(q_values).astype(np.float32)) - phi_values_g = gpuarray.to_gpu(np.asarray(phi_values).astype(np.float32)) + # phi values stay float64: the kernel re-references them to the + # subtracted epoch as (phi - epoch*freq) % 1 in double precision + # (epoch*freq can be ~1e6 cycles), matching single_bls bit for bit + phi_values_g = gpuarray.to_gpu(np.asarray(phi_values).astype(np.float64)) block = (block_size, 1, 1) - grid = (grid_size, 1) - - nbatches = int(np.ceil(float(len(freqs)) / freq_batch_size)) - - bls = np.zeros(len(freqs)) bin_func = functions['bin_and_phase_fold_custom'] bls_func = functions['binned_bls_bst'] max_func = functions['reduction_max'] @@ -672,10 +1782,10 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, for batch in range(nbatches): imin = freq_batch_size * batch - imax = min([len(freqs), freq_batch_size * (batch + 1)]) + imax = min([nfreq, freq_batch_size * (batch + 1)]) nf = imax - imin - j = batch % nstreams + j = batch % nsets yw_g_bin = yw_g_bins[j] w_g_bin = w_g_bins[j] bls_tmp_g = bls_tmp_gs[j] @@ -693,7 +1803,7 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, args = (bin_grid, block, stream) args += (t_g.ptr, yw_g.ptr, w_g.ptr) args += (yw_g_bin.ptr, w_g_bin.ptr, freqs_g.ptr) - args += (q_values_g.ptr, phi_values_g.ptr) + args += (q_values_g.ptr, phi_values_g.ptr, np.float64(epoch)) args += (np.uint32(len(q_values)), np.uint32(len(phi_values))) args += (np.uint32(len(t)), np.uint32(nf)) args += (np.uint32(freq_batch_size * batch),) @@ -726,14 +1836,30 @@ def eebls_gpu_custom(t, y, dy, freqs, q_values, phi_values, qphi_sols = list(zip(best_q, best_phi)) - return bls_g.get()/YY, qphi_sols + return (convert_bls_power(bls_g.get() / YY, y, dy, + convention=convention), + qphi_sols) def dnbins(nbins, dlogq): + """Host mirror of the device ``dnbins`` in ``bls_common.cuh``: the + number of bins the q ladder grows by at ``nbins``. + + The kernels take ``dlogq`` as a ``float`` argument and form + ``floorf(dlogq * nbins)`` in float32, and for some ``(dlogq, + nbins)`` pairs that product lands on the other side of an integer + than the float64 one (``0.65 * 180`` is 117.0 in float64 but + 116.99999 in float32). The host ladder sizes ``eebls_gpu``'s device + bin rows (:func:`count_tot_nbins`) and replicates the fast kernels' + box grid (:func:`_fast_box_widths`), so it has to agree with the + device rung for rung: the product is formed in float32 here too. + Bit-identical to the old float64 arithmetic at the default + ``dlogq`` values (0.2, 0.3) for every ``nbins <= 200000``. + """ if (dlogq < 0): return 1 - n = int(np.floor(dlogq * nbins)) + n = int(np.floor(np.float32(dlogq) * np.float32(nbins))) return n if n > 0 else 1 @@ -756,14 +1882,141 @@ def count_tot_nbins(nbins0, nbinsf, dlogq): return ntot +@functools.lru_cache(maxsize=65536) +def _count_tot_nbins_cached(nbins0, nbinsf, dlogq): + """``count_tot_nbins`` memoized on its (small) set of distinct + arguments: the batch table below evaluates it once per batch (or + once per frequency on the per-frequency path), and Keplerian grids + have only a few hundred distinct ``(nbins0, nbinsf)`` pairs.""" + return count_tot_nbins(int(nbins0), int(nbinsf), float(dlogq)) + + +# Fraction of the free device memory that eebls_gpu / eebls_gpu_custom +# budget by default. The allocation is further bounded by what the +# frequency grid actually needs (freq_batch_size is capped at +# len(freqs)), so small grids allocate only a few MB; large grids +# leave half the device to other processes instead of taking ~90% of +# it for a transient zero-filled scratch buffer (Sep 2026 audit, +# finding 135 / plan item BLS-2). +_DEFAULT_MEMORY_FRACTION = 0.5 + +# Largest ndata * (frequencies per batch) product one fold launch may +# cover: bin_and_phase_fold_bst_multifreq / bin_and_phase_fold_custom +# run one thread per (observation, frequency) pair and the grid size +# must stay a sane 32-bit block count. The kernels index in 64 bits, so +# this cap is a launch-geometry bound, not a correctness requirement. +_MAX_FOLD_THREADS = 2 ** 31 - 1 + + +def _cap_freq_batch_size(freq_batch_size, ndata, nfreq): + """Bound a (user-supplied or auto-sized) ``freq_batch_size``. + + The batch never exceeds the frequency grid (a 300-frequency grid + used to allocate scratch space for the ~100K-frequency batch the + memory budget allowed) and ``ndata * freq_batch_size`` never + exceeds ``_MAX_FOLD_THREADS`` (the fold kernels used to be launched + with a 32-bit ``ndata * nfreq`` bound that wrapped at 2^32 -- + defect 1 of the Sep 2026 audit). Always >= 1. + """ + cap = max(1, _MAX_FOLD_THREADS // max(1, int(ndata))) + return int(max(1, min(int(freq_batch_size), cap, int(nfreq)))) + + +def _q_bounds_to_nbins(qmins, qmaxes): + """Per-frequency bin counts for the binned (eebls_gpu) kernels: + ``nbins0 = floor(1/qmax)`` (coarsest) and ``nbinsf = ceil(1/qmin)`` + (finest), as int64 arrays. The bounds must already have passed + ``_validate_q_bounds``; the binned kernels additionally need + ``qmin > 0`` (a finite finest bin count) and ``qmax <= 1`` + (``nbins0 >= 1``; ``nbins0 = 0`` divides by zero on the device).""" + qmins = np.asarray(qmins, dtype=np.float64) + qmaxes = np.asarray(qmaxes, dtype=np.float64) + _check_q_bounds_for_bins(qmins, qmaxes) + nbins0 = np.floor(1. / qmaxes).astype(np.int64) + nbinsf = np.ceil(1. / qmins).astype(np.int64) + return nbins0, nbinsf + + +def _per_freq_nbins_tot(nbins0, nbinsf, dlogq): + """``count_tot_nbins(nbins0[i], nbinsf[i], dlogq)`` for every + frequency, evaluated once per distinct ``(nbins0, nbinsf)`` pair (a + Keplerian grid of 10^5 frequencies has only a few hundred).""" + nbins0 = np.asarray(nbins0, dtype=np.int64) + nbinsf = np.asarray(nbinsf, dtype=np.int64) + pairs = np.stack([nbins0, nbinsf], axis=1) + uniq, inv = np.unique(pairs, axis=0, return_inverse=True) + counts = np.array([_count_tot_nbins_cached(int(a), int(b), dlogq) + for a, b in uniq], dtype=np.int64) + return counts[np.asarray(inv).ravel()] + + +def _max_nbins_tot(nbins0, nbinsf, dlogq): + """Largest number of (phase bin, q level) cells any single frequency + needs -- the bound used to budget memory before batching. + + Note ``count_tot_nbins(nb0, nbf, dlogq)`` is non-decreasing in + ``nbf`` but NOT monotone in ``nb0``: at ``nbf = 359`` and + ``dlogq = 0.2`` it is 1875, 1939 and 1704 for ``nb0`` = 28, 29, 30. + The pre-1.0 sizing used the value at the grid-wide ``(min nb0, max + nbf)`` collapse, which a batch starting at a larger ``nb0`` could + exceed (the 44 MB overrun of the audit's 70,000-point ``fmin=0.02, + fmax=0.5`` case). The kernels now work per frequency, so the exact + per-frequency maximum is the bound. + """ + return int(np.max(_per_freq_nbins_tot(nbins0, nbinsf, dlogq))) + + +def _bls_batch_table(nbins0, nbinsf, freq_batch_size, dlogq): + """Batch table for :func:`eebls_gpu`, built BEFORE the device + scratch buffers are allocated so they can be sized from the actual + maximum over batches. + + Returns a list of ``(imin, imax, nbins_tot_b)`` per batch of + ``freq_batch_size`` frequencies, where ``nbins_tot_b`` is the + batch's row stride: the largest per-frequency + ``count_tot_nbins(nbins0[i], nbinsf[i], dlogq)`` among its + frequencies (each frequency writes only its own cells; the rest of + its row stays zero). Batch boundaries never change which boxes a + frequency searches. + """ + nbins_tot_f = _per_freq_nbins_tot(nbins0, nbinsf, dlogq) + nfreq = len(nbins_tot_f) + freq_batch_size = int(freq_batch_size) + if freq_batch_size < 1: + raise ValueError("freq_batch_size must be >= 1") + table = [] + for imin in range(0, nfreq, freq_batch_size): + imax = min(nfreq, imin + freq_batch_size) + table.append((imin, imax, int(np.max(nbins_tot_f[imin:imax])))) + return table + + def eebls_gpu(t, y, dy, freqs, qmin=1e-2, qmax=0.5, ignore_negative_delta_sols=False, nstreams=5, noverlap=3, dlogq=0.2, max_memory=None, - freq_batch_size=None, functions=None, **kwargs): + freq_batch_size=None, functions=None, + convention='chi2ratio', **kwargs): """ Box-Least Squares, accelerated with PyCUDA + .. warning:: + + BLS weights each observation by ``1/dy**2`` (normalized). A + point with a near-zero reported uncertainty concentrates + essentially all of the statistical weight in one phase bin and + deterministically produces spurious power of ~0.99 in pure + noise, at nearly every trial frequency. Symptoms: + ``max(dy**-2) / sum(dy**-2)`` close to 1, and suspiciously + high, nearly flat power on noise-like data. Guard with a + percentile-based error floor before calling:: + + dy_floor = np.percentile(dy, 10) + dy = np.clip(dy, dy_floor, None) + + See the "Data hygiene: near-zero uncertainties" section of the + BLS documentation for details. + Parameters ---------- t: array_like, float @@ -775,146 +2028,188 @@ def eebls_gpu(t, y, dy, freqs, qmin=1e-2, qmax=0.5, freqs: array_like, float Frequencies qmin: float or array_like - Minimum q value(s) to test for each frequency + Minimum q value(s) to test for each frequency. A scalar applies + to every frequency; an array (same length as ``freqs``) gives a + per-frequency bound. The finest phase bin at frequency ``i`` is + ``1 / ceil(1 / qmin[i])``. qmax: float or array_like - Maximum q value(s) to test for each frequency + Maximum q value(s) to test for each frequency (scalar or + per-frequency array). The coarsest bin is + ``1 / floor(1 / qmax[i])``. Per-frequency bounds are honoured + exactly per frequency (each frequency searches only its own + q levels), so results do not depend on ``freq_batch_size`` or on + the free device memory. ignore_negative_delta_sols: bool Whether or not to ignore solutions with a negative delta (i.e. an inverted dip) nstreams: int, optional (default: 5) Number of CUDA streams to utilize. noverlap: int, optional (default: 3) - Number of overlapping q bins to use - dlogq: float, optional, (default: 0.5) - logarithmic spacing of :math:`q` values, where :math:`d\log q = dq / q` + Phase-offset oversampling: each q level is evaluated on + ``noverlap`` phase-bin grids shifted by ``1/noverlap`` of a bin + (``phi = q * (j + s / noverlap)``), not extra q levels. + dlogq: float, optional, (default: 0.2) + logarithmic spacing of :math:`q` values, where :math:`d\\log q = dq / q` freq_batch_size: int, optional (default: None) - Number of frequencies to compute in a single batch; determines - this automatically based on ``max_memory`` + Number of frequencies to compute in a single batch; determined + automatically from ``max_memory`` when ``None``. Whether given + or automatic, it is capped at ``len(freqs)`` and at + ``(2**31 - 1) // len(t)`` (one fold thread per (observation, + frequency) pair per launch). max_memory: float, optional (default: None) - Maximum memory to use in bytes. Will ignore this if - ``freq_batch_size`` is specified, and will use the total free memory - as returned by ``pycuda.driver.mem_get_info`` if this is ``None``. + Memory budget in bytes for the device scratch buffers (four + arrays per stream, sized by the frequency batch). Ignored if + ``freq_batch_size`` is specified. ``None`` budgets half of the + free memory reported by ``pycuda.driver.mem_get_info``; the + allocation never exceeds what ``len(freqs)`` frequencies need. functions: tuple of CUDA functions returned by ``compile_bls`` + convention: str, optional (default: 'chi2ratio') + Power-spectrum convention for the returned periodogram + ('chi2ratio', 'snr' or 'loglik'); see + :func:`convert_bls_power`. Returns ------- bls: array_like, float - BLS periodogram, normalized to :math:`1 - \chi^2(f) / \chi^2_0` + BLS periodogram; in the default convention, normalized to + :math:`1 - \\chi^2(f) / \\chi^2_0` qphi_sols: list of ``(q, phi)`` tuples Best ``(q, phi)`` solution at each frequency """ - def locext(ext, arr, imin=None, imax=None): - if isinstance(arr, float) or isinstance(arr, int): - return arr - return ext(arr[slice(imin, imax)]) - - functions = functions if functions is not None \ - else compile_bls(**kwargs) - - if max_memory is None: - free, total = cuda.mem_get_info() - max_memory = int(0.9 * free) + _validate_convention(convention) + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name='eebls_gpu') + check_freqs(freqs, name='eebls_gpu') - # smallest and largest number of bins - nbins0_max = 1 - nbinsf_max = 1 block_size = kwargs.get('block_size', _default_block_size) + ndata = len(t) + nfreq = len(freqs) - max_q_vals = locext(max, qmax) - min_q_vals = locext(min, qmin) + # Per-frequency bin counts (scalar bounds broadcast). Validated + # before any device work (including the kernel compile): qmin > + # qmax used to surface as a ZeroDivisionError from count_tot_nbins, + # qmax > 1 as a device divide-by-zero. + qmins = _broadcast_q_bound(qmin, nfreq, 1e-2, 'qmin') + qmaxes = _broadcast_q_bound(qmax, nfreq, 0.5, 'qmax') + _validate_q_bounds(qmins, qmaxes) + nbins0_f, nbinsf_f = _q_bounds_to_nbins(qmins, qmaxes) - nbins0_max = int(np.floor(1./max_q_vals)) - nbinsf_max = int(np.ceil(1./min_q_vals)) + functions = functions if functions is not None \ + else _cached_compile_bls(**kwargs) - ndata = len(t) + if max_memory is None: + free, total = cuda.mem_get_info() + max_memory = int(_DEFAULT_MEMORY_FRACTION * free) - nbins_tot_max = count_tot_nbins(nbins0_max, nbinsf_max, dlogq) + real_type_size = np.float32(1).nbytes if freq_batch_size is None: - # compute memory - real_type_size = np.float32(1).nbytes - # data mem0 = ndata * 3 * real_type_size # freqs + bls + best_phi + best_q + best_sol (int32) - mem0 += len(freqs) * 5 * real_type_size + mem0 += nfreq * 5 * real_type_size - # yw_g_bins, w_g_bins, bls_tmp_gs, bls_tmp_sol_gs (int32) - mem_per_f = 4 * nstreams * nbins_tot_max * noverlap * real_type_size + # yw_g_bins, w_g_bins, bls_tmp_gs, bls_tmp_sol_gs (int32), sized + # by the largest per-frequency cell count (the batch stride can + # never exceed it; see _max_nbins_tot) + nbins_tot_bound = _max_nbins_tot(nbins0_f, nbinsf_f, dlogq) + mem_per_f = 4 * nstreams * nbins_tot_bound * noverlap * real_type_size freq_batch_size = int(float(max_memory - mem0) / (mem_per_f)) - if freq_batch_size == 0: - raise Exception("Not enough memory (freq_batch_size = 0)") - - gs = freq_batch_size * nbins_tot_max * noverlap - - grid_size = int(np.ceil(float(gs) / block_size)) + if freq_batch_size <= 0: + raise RuntimeError("Not enough memory (freq_batch_size = 0)") + + # Cap user-supplied and automatic batch sizes alike: at len(freqs) + # (allocate only what the grid needs) and at (2^31 - 1) // ndata. + freq_batch_size = _cap_freq_batch_size(freq_batch_size, ndata, nfreq) + + # The batch table is built BEFORE allocating so the scratch buffers + # are sized from the actual maximum over batches. The old code + # sized them from count_tot_nbins(grid-wide min nbins0, grid-wide + # max nbinsf), which is not an upper bound (non-monotone in + # nbins0) -- a batch could need more cells than were allocated and + # the fold kernel's atomics ran off the end of the buffer (illegal + # memory access on the default eebls_transit path; audit defect 1). + batches = _bls_batch_table(nbins0_f, nbinsf_f, freq_batch_size, dlogq) + nbatches = len(batches) + gs = max((imax - imin) * nbins_tot for + (imin, imax, nbins_tot) in batches) * noverlap # move data to GPU w = np.power(dy, -2) - w /= sum(w) - ybar = np.dot(w, y) - YY = np.dot(w, np.power(np.array(y) - ybar, 2)) + w /= np.sum(w) + # einsum, not np.dot: BLAS ddot spawns a full threadpool for large + # vectors, and on CPU-quota-limited containers the burst trips CFS + # throttling (measured on the pod at ndata = 20000: median 0.60 ms + # with a 98 ms tail and 12 throttle events per 50 calls, vs 0.17 ms + # and none for einsum). Same operation, last-ulp float64 summation + # order. See BLSMemory.setdata (Sep 2026 audit, id 45). + ybar = float(np.einsum('i,i->', w, y)) + YY = float(np.einsum('i,i->', w, np.power(np.array(y) - ybar, 2))) yw = (np.array(y) - ybar) * np.array(w) - t_g = gpuarray.to_gpu(np.array(t).astype(np.float32)) + t, epoch = subtract_epoch(t) + t_g = gpuarray.to_gpu(t.astype(np.float32)) yw_g = gpuarray.to_gpu(yw.astype(np.float32)) w_g = gpuarray.to_gpu(np.array(w).astype(np.float32)) freqs_g = gpuarray.to_gpu(np.array(freqs).astype(np.float32)) + # Per-frequency bin counts, read by the fold and store kernels at + # index (i_freq + freq_offset): every frequency searches exactly its + # own q levels. Before 1.0 the kernels took one scalar pair per + # launch, so array bounds collapsed to the batch-wide (min nbins0, + # max nbinsf) window and the result depended on freq_batch_size / + # free memory (Sep 2026 audit defect 7). + nbins0_g = gpuarray.to_gpu(nbins0_f.astype(np.uint32)) + nbinsf_g = gpuarray.to_gpu(nbinsf_f.astype(np.uint32)) + + # One scratch set per stream, but never more streams than batches + # (a 3-batch grid does not need 5 x 4 zero-filled buffers). + nsets = max(1, min(int(nstreams), nbatches)) yw_g_bins, w_g_bins, bls_tmp_gs, bls_tmp_sol_gs, streams \ = [], [], [], [], [] - for i in range(nstreams): + for i in range(nsets): streams.append(cuda.Stream()) yw_g_bins.append(gpuarray.zeros(gs, dtype=np.float32)) w_g_bins.append(gpuarray.zeros(gs, dtype=np.float32)) bls_tmp_gs.append(gpuarray.zeros(gs, dtype=np.float32)) bls_tmp_sol_gs.append(gpuarray.zeros(gs, dtype=np.int32)) - bls_g = gpuarray.zeros(len(freqs), dtype=np.float32) - bls_sol_g = gpuarray.zeros(len(freqs), dtype=np.int32) + bls_g = gpuarray.zeros(nfreq, dtype=np.float32) + bls_sol_g = gpuarray.zeros(nfreq, dtype=np.int32) - bls_best_phi = gpuarray.zeros(len(freqs), dtype=np.float32) - bls_best_q = gpuarray.zeros(len(freqs), dtype=np.float32) + bls_best_phi = gpuarray.zeros(nfreq, dtype=np.float32) + bls_best_q = gpuarray.zeros(nfreq, dtype=np.float32) block = (block_size, 1, 1) - grid = (grid_size, 1) - - nbatches = int(np.ceil(float(len(freqs)) / freq_batch_size)) - - bls = np.zeros(len(freqs)) bin_func = functions['bin_and_phase_fold_bst_multifreq'] bls_func = functions['binned_bls_bst'] max_func = functions['reduction_max'] store_func = functions['store_best_sols'] - for batch in range(nbatches): - - imin = freq_batch_size * batch - imax = min([len(freqs), freq_batch_size * (batch + 1)]) - - minq = locext(min, qmin, imin, imax) - maxq = locext(max, qmax, imin, imax) - - nbins0 = int(np.floor(1./maxq)) - nbinsf = int(np.ceil(1./minq)) - - nbins_tot = count_tot_nbins(nbins0, nbinsf, dlogq) + for batch, (imin, imax, nbins_tot) in enumerate(batches): nf = imax - imin - j = batch % nstreams + all_bins = nf * nbins_tot * noverlap + if all_bins > gs: + # cannot happen with the table-derived gs above; guard the + # device against ever overrunning its buffers again + raise ValueError( + "eebls_gpu: batch %d needs %d bin cells but only %d were " + "allocated (nbins_tot=%d, noverlap=%d)" + % (batch, all_bins, gs, nbins_tot, noverlap)) + + j = batch % nsets yw_g_bin = yw_g_bins[j] w_g_bin = w_g_bins[j] bls_tmp_g = bls_tmp_gs[j] bls_tmp_sol_g = bls_tmp_sol_gs[j] stream = streams[j] - # stream.synchronize() yw_g_bin.fill(np.float32(0), stream=stream) w_g_bin.fill(np.float32(0), stream=stream) @@ -926,14 +2221,12 @@ def locext(ext, arr, imin=None, imax=None): args = (bin_grid, block, stream) args += (t_g.ptr, yw_g.ptr, w_g.ptr) args += (yw_g_bin.ptr, w_g_bin.ptr, freqs_g.ptr) - args += (np.int32(ndata), np.int32(nf)) - args += (np.int32(nbins0), np.int32(nbinsf)) - args += (np.int32(freq_batch_size * batch), np.int32(noverlap)) - args += (np.float32(dlogq), np.int32(nbins_tot)) + args += (nbins0_g.ptr, nbinsf_g.ptr) + args += (np.uint32(ndata), np.uint32(nf)) + args += (np.uint32(imin), np.uint32(noverlap)) + args += (np.float32(dlogq), np.uint32(nbins_tot)) bin_func.prepared_async_call(*args) - all_bins = nf * nbins_tot * noverlap - bls_grid = (int(np.ceil(float(all_bins) / block_size)), 1) args = (bls_grid, block, stream) args += (yw_g_bin.ptr, w_g_bin.ptr) @@ -943,23 +2236,27 @@ def locext(ext, arr, imin=None, imax=None): args = (max_func, bls_tmp_g, bls_tmp_sol_g) args += (nf, nbins_tot * noverlap, stream, bls_g, bls_sol_g) - args += (batch * freq_batch_size, block_size) + args += (imin, block_size) _reduction_max(*args) store_grid = (int(np.ceil(float(nf) / block_size)), 1) args = (store_grid, block, stream) args += (bls_sol_g.ptr, bls_best_phi.ptr, bls_best_q.ptr) - args += (np.uint32(nbins0), np.uint32(nbinsf), np.uint32(noverlap)) + args += (nbins0_g.ptr, nbinsf_g.ptr, np.uint32(noverlap)) args += (np.float32(dlogq), np.uint32(nf)) - args += (np.uint32(batch * freq_batch_size),) + args += (np.uint32(imin),) store_func.prepared_async_call(*args) best_q = bls_best_q.get() best_phi = bls_best_phi.get() - qphi_sols = list(zip(best_q, best_phi)) + # Adjust phases to original timescale (vectorized; see + # _rephase_solutions) + qphi_sols = _rephase_solutions(best_q, best_phi, epoch, freqs) - return bls_g.get()/YY, qphi_sols + return (convert_bls_power(bls_g.get() / YY, y, dy, + convention=convention), + qphi_sols) def single_bls(t, y, dy, freq, q, phi0, ignore_negative_delta_sols=False): @@ -978,9 +2275,12 @@ def single_bls(t, y, dy, freq, q, phi0, ignore_negative_delta_sols=False): freq: float Frequency of the signal q: float - Transit duration in phase + Transit duration in phase, in ``[0, 1]`` (``q = 0``, the + sparse paths' no-solution sentinel, evaluates to a power of 0) phi0: float - Phase offset of transit + Phase offset of transit, in the ORIGINAL input timescale + (internally re-referenced to the subtracted epoch, consistent + with the phases reported by the GPU functions in this module) ignore_negative_delta_sols: Whether or not to ignore solutions with negative delta (inverted dips) @@ -989,8 +2289,40 @@ def single_bls(t, y, dy, freq, q, phi0, ignore_negative_delta_sols=False): bls: float BLS power for this set of parameters """ - - phi = np.asarray(t).astype(np.float32) * np.float32(freq) + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name='single_bls') + if not (np.isfinite(freq) and np.isfinite(q) and np.isfinite(phi0)): + raise ValueError("single_bls: freq, q and phi0 must be finite; " + "got freq=%r, q=%r, phi0=%r" % (freq, q, phi0)) + if freq <= 0: + raise ValueError("single_bls: freq must be > 0; got %r" % (freq,)) + # q is a fractional transit duration. A negative q, or one wider + # than a full phase cycle, used to return a silent power of 0 (an + # empty box / an all-weight box). q = 0 is the sparse paths' + # "no valid box" sentinel and still evaluates to 0; phi0 is any + # finite phase (negative values wrap, like the reported solutions). + if q < 0 or q > 1: + raise ValueError("single_bls: q must be in [0, 1] (a fractional " + "transit duration); got %r" % (q,)) + + # Epoch-subtract before the float32 cast + t, epoch = subtract_epoch(t) + + # Adjust phase offset to subtracted timescale + phi0 = (phi0 - (epoch * freq)) % 1.0 + + phi = t.astype(np.float32) * np.float32(freq) + # Wrap into [0, 1) BEFORE subtracting the phase offset, exactly like + # the GPU kernels' mod1(t * f) (verified bit-identical to the + # compiled kernels' fold on hardware; nvcc does not FMA-contract the + # mod1 expression). Subtracting phi0 first -- the old order -- + # happens at magnitude ~t*f, where float32 resolution is only + # ulp(t*f)/2 ~ 1.5e-5 phase for a 1-year baseline (2.4e-4 for 10 + # years), so points within that fuzz of a box edge acquired the + # wrong membership relative to the kernels' full-resolution [0, 1) + # fold. Wrapping first shrinks the CPU-vs-GPU edge-disagreement + # window by ~2 orders of magnitude, to the float32 rounding of the + # kernels' bin-index arithmetic (~1e-7). + phi -= np.floor(phi) phi -= np.float32(phi0) phi -= np.floor(phi) @@ -999,15 +2331,1406 @@ def single_bls(t, y, dy, freq, q, phi0, ignore_negative_delta_sols=False): w = np.power(dy, -2) w /= np.sum(w.astype(np.float32)) - ybar = np.dot(w, np.asarray(y).astype(np.float32)) - YY = np.dot(w, np.power(np.asarray(y).astype(np.float32) - ybar, 2)) + # Centre in float64 before the float32 cast (defect 8 of the Sep + # 2026 audit: float32 sums of raw mag-12 fluxes minus ybar * W lost + # 1e-3..1e-2 of the power). ybar of the centred float32 flux is + # residual roundoff (~1e-8), kept for parity with the kernels. + yc, _ = _center_flux_float64(y, dy) + # einsum, not np.dot (see eebls_gpu): single_bls runs once per + # reported solution, so the BLAS threadpool cliff was paid + # n_solutions times per eebls_transit call (measured median 93.9 ms + # per call at ndata = 20000, min 0.79 ms). + ybar = float(np.einsum('i,i->', w, yc)) + YY = float(np.einsum('i,i->', w, np.power(yc - ybar, 2))) W = np.sum(w[mask]) - YW = np.dot(w[mask], np.asarray(y).astype(np.float32)[mask]) - ybar * W + YW = float(np.einsum('i,i->', w[mask], yc[mask])) - ybar * W if YW > 0 and ignore_negative_delta_sols: return 0 - return 0 if W < 1e-9 else (YW ** 2) / (W * (1 - W)) / YY + # Upper bound mirrors the GPU kernels' bls_value: a box holding + # (nearly) all the statistical weight has no out-of-transit baseline + # and its power is roundoff-divided-by-roundoff (this function sums + # float32-cast quantities like the kernels do). + if W < 1e-9 or W > 1 - 1e-4: + return 0 + return (YW ** 2) / (W * (1 - W)) / YY + + +_BLS_POWER_CONVENTIONS = ('chi2ratio', 'snr', 'loglik') + + +def _chi2_null(y, dy): + """Weighted chi-squared of the constant (weighted-mean) model, + computed in float64; the normalization connecting the BLS power + conventions.""" + y = np.asarray(y, dtype=np.float64) + w = np.power(np.asarray(dy, dtype=np.float64), -2) + # einsum, not np.dot: keep the per-LC path off BLAS threadpools + # (CFS-throttling cliff on CPU-quota-limited hosts; see + # BLSMemory.setdata). + ybar = float(np.einsum('i,i->', w, y)) / np.sum(w) + return float(np.einsum('i,i->', w, np.power(y - ybar, 2))) + + +def _validate_convention(convention): + if convention not in _BLS_POWER_CONVENTIONS: + raise ValueError("convention must be one of %s, got %r" + % (_BLS_POWER_CONVENTIONS, convention)) + + +def convert_bls_power(power, y, dy, *, convention='chi2ratio'): + """ + Convert the native BLS power to another power-spectrum convention. + + The native convention ('chi2ratio') is + + .. math:: + + P = 1 - \\chi^2 / \\chi^2_0 + + where :math:`\\chi^2` is the weighted sum of squared residuals of + the best-fit box and :math:`\\chi^2_0` that of the constant + (weighted-mean) model. + + Parameters + ---------- + power: array_like or float + BLS power(s) in the native 'chi2ratio' convention. + y: array_like, float + Observations (used to compute :math:`\\chi^2_0`). + dy: array_like, float + Observation uncertainties. + convention: str, optional (default: 'chi2ratio') + One of: + + * ``'chi2ratio'``: the native power, returned unchanged. + * ``'snr'``: :math:`\\sqrt{\\chi^2_0 P}` -- the unsigned + signal-to-noise of the best-fit transit depth, + :math:`|\\hat{\\delta}| / \\sigma_{\\hat{\\delta}}`. Equals + ``astropy.timeseries.BoxLeastSquares`` power with + ``objective='snr'`` at the same (period, duration, phase) + (astropy reports it signed and keeps dips only). + * ``'loglik'``: :math:`\\chi^2_0 P / 2` -- the improvement in + Gaussian log-likelihood of the best two-level (in/out of + transit) model over the constant weighted-mean model. + Note: astropy's ``objective='likelihood'`` power uses the + out-of-transit level as its reference instead, so it equals + this value divided by :math:`(1 - r)`, with :math:`r` the + in-transit fraction of the total statistical weight; the + two agree in the transit limit :math:`q \\ll 1`. + + Returns + ------- + power: array_like or float + Power(s) in the requested convention. + """ + _validate_convention(convention) + return _convert_bls_power_from_chi2_0(power, None, convention, + y=y, dy=dy) + + +def _convert_bls_power_from_chi2_0(power, chi2_0, convention, + y=None, dy=None): + """``convert_bls_power`` with a precomputed :math:`\\chi^2_0` + (falls back to computing it from ``y``/``dy`` when ``None``).""" + _validate_convention(convention) + if convention == 'chi2ratio': + return power + if chi2_0 is None: + chi2_0 = _chi2_null(y, dy) + if convention == 'snr': + return np.sqrt(chi2_0 * np.asarray(power)) + return 0.5 * chi2_0 * np.asarray(power) # 'loglik' + + +def _broadcast_q_bound(value, nfreqs, default, name): + """Broadcast a transit-duration bound (scalar or per-frequency + array; ``None`` means ``default``) to a float array of length + ``nfreqs``.""" + if value is None: + value = default + arr = np.atleast_1d(np.asarray(value, dtype=np.float64)) + if len(arr) == 1: + arr = np.full(nfreqs, arr[0]) + elif len(arr) != nfreqs: + raise ValueError("%s must be a scalar or have the same length " + "as freqs (%d); got length %d" + % (name, nfreqs, len(arr))) + return arr + + +def _center_flux_float64(y, dy): + """Weighted-mean-subtract ``y`` in float64 and return the centred + flux as float32 (plus the float64 normalized weights). + + The sparse kernels and :func:`single_bls` accumulate float32 sums + of ``w * y``; with raw fluxes of magnitude ~12 (or normalized flux + ~1) those partial sums carry the mean, and subtracting + ``ybar * W`` afterwards cancels catastrophically: 1e-3..1e-2 + relative power errors on mag-12 data and powers > 1 when one point + is ~1e3x more precise than the rest (Sep 2026 audit, defect 8). + Centring in float64 BEFORE the float32 cast (as the binned path + always did) leaves ~1e-6. + """ + y64 = np.asarray(y, dtype=np.float64) + w64 = np.power(np.asarray(dy, dtype=np.float64), -2) + w64 /= np.sum(w64) + ybar = float(np.einsum('i,i->', w64, y64)) + return (y64 - ybar).astype(np.float32), w64 + + +def _validate_q_bounds(qmins, qmaxes): + """Reject transit-duration bounds that would silently produce an + all-zero periodogram (every candidate box rejected).""" + if not (np.all(np.isfinite(qmins)) and np.all(np.isfinite(qmaxes))): + raise ValueError("qmin/qmax must be finite") + if np.any(qmins < 0): + raise ValueError("qmin must be >= 0 (0 disables the lower bound)") + if np.any(qmaxes <= 0): + raise ValueError("qmax must be > 0") + if np.any(qmins > qmaxes): + raise ValueError("qmin > qmax for %d frequencies; every candidate " + "transit would be rejected" + % int(np.sum(qmins > qmaxes))) + + +def _check_q_bounds_for_bins(qmins, qmaxes): + """The two extra conditions the *binned* BLS kernels impose on top + of :func:`_validate_q_bounds`. + + ``qmin > 0``: the finest phase bin is ``1/qmin`` wide, so ``qmin = + 0`` asks for infinitely many bins (and casts to a bin count of 0). + ``qmax <= 1``: the coarsest bin count is ``1/qmax``, and ``nbins0 = + 0`` divides by zero inside the kernel and lets its ``atomicAdd`` + run outside the shared-memory histogram. + """ + qmins = np.asarray(qmins, dtype=np.float64) + qmaxes = np.asarray(qmaxes, dtype=np.float64) + if np.any(qmins <= 0): + raise ValueError("qmin must be > 0 for the binned BLS kernels " + "(the finest phase bin is 1/qmin wide); got " + "min(qmin) = %g" % float(np.min(qmins))) + if np.any(qmaxes > 1): + raise ValueError("qmax must be <= 1; got max(qmax) = %g" + % float(np.max(qmaxes))) + + +def sparse_bls_cpu(t, y, dy, freqs, *, qmin=None, qmax=None, + ignore_negative_delta_sols=False, + convention='chi2ratio'): + """ + Sparse BLS implementation for CPU (no binning, tests all pairs of observations). + + This is more efficient than traditional BLS when the number of observations + is small, as it avoids redundant grid searching over finely-grained parameter + grids. Based on https://arxiv.org/abs/2103.06193 + + Parameters + ---------- + t: array_like, float + Observation times + y: array_like, float + Observations + dy: array_like, float + Observation uncertainties + freqs: array_like, float + Frequencies to test + qmin: float or array_like, optional (default: None) + Minimum transit duration (in phase) to consider. A scalar + applies to all frequencies; an array gives a per-frequency + bound (same length as ``freqs``, e.g. Keplerian + ``q_transit(freqs) * qmin_fac``). ``None`` means no lower + bound (all ``q > 0``). + qmax: float or array_like, optional (default: None) + Maximum transit duration (in phase), scalar or per-frequency. + ``None`` means the algorithm's standard upper cutoff of 0.5. + ignore_negative_delta_sols: bool, optional (default: False) + Whether or not to ignore solutions with negative delta (inverted dips) + convention: str, optional (default: 'chi2ratio') + Power-spectrum convention for the returned powers ('chi2ratio', + 'snr' or 'loglik'); see :func:`convert_bls_power`. + + Returns + ------- + bls: array_like, float + BLS power at each frequency + solutions: list of (q, phi0) tuples + Best (q, phi0) solution at each frequency + """ + _validate_convention(convention) + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name='sparse_bls_cpu') + check_freqs(freqs, name='sparse_bls_cpu') + + # Original flux kept for convert_bls_power's chi2_0 + y_orig, dy_orig = y, dy + + t, epoch = subtract_epoch(t) + t = t.astype(np.float32) + # Centre in float64 BEFORE the float32 cast (see + # _center_flux_float64): the float32 pair scan below otherwise + # loses 1e-3..1e-2 of the power on mag-scale fluxes. + y, _ = _center_flux_float64(y, dy) + dy = np.asarray(dy).astype(np.float32) + # Keep a float64 copy for the phase re-referencing below: the + # original-timescale conversion (phi + epoch*freq) % 1 must use the + # same float64 frequency the caller will use to convert back (e.g. + # in single_bls); with the float32-cast frequency the phases would + # be off by epoch * |f64 - f32|, which reaches ~0.07 cycles for + # BJD-scale epochs (~2.45e6 days). + freqs64 = np.asarray(freqs, dtype=np.float64) + freqs = freqs64.astype(np.float32) + + ndata = len(t) + nfreqs = len(freqs) + + qmins = _broadcast_q_bound(qmin, nfreqs, 0.0, 'qmin') + qmaxes = _broadcast_q_bound(qmax, nfreqs, 0.5, 'qmax') + _validate_q_bounds(qmins, qmaxes) + + # Precompute weights (constant across all frequencies) + w = np.power(dy, -2).astype(np.float32) + w /= np.sum(w) + + bls_powers = np.zeros(nfreqs, dtype=np.float32) + best_q = np.zeros(nfreqs, dtype=np.float32) + best_phi = np.zeros(nfreqs, dtype=np.float32) + + # residual float32 mean of the centred flux (~1e-8); kept so the + # scan is exactly the kernel's arithmetic. einsum, not np.dot: + # BLAS sdot/ddot spawns a full threadpool for large vectors and on + # CPU-quota-limited containers the burst trips CFS throttling (see + # eebls_gpu; Sep 2026 audit, id 45). Same operation, different + # summation order. + ybar = float(np.einsum('i,i->', w, y)) + YY = float(np.einsum('i,i->', w, np.power(y - ybar, 2))) + + # Vectorized pair scan. Transit candidates are exactly the + # contiguous runs of phase-sorted observations (plus wrap-around + # runs); prefix sums turn each candidate's (W, YW) into two array + # lookups, so the scan is O(N^2) numpy work with O(N^2) + # temporaries. The previous pure-Python loop recomputed each + # slice sum, costing O(N^3) time (minutes per frequency at the + # ndata=500 sparse threshold). + i_idx = np.arange(ndata) + + for i_freq, freq in enumerate(freqs): + qmin_f = qmins[i_freq] + qmax_f = qmaxes[i_freq] + + # Compute phases and sort + phi = (t * freq) % 1.0 + order = np.argsort(phi) + phi_s = phi[order].astype(np.float64) + y_s = y[order].astype(np.float64) + w_s = w[order].astype(np.float64) + + # Prefix sums: cw[k] = sum(w_s[:k]), cyw[k] = sum((w*y)_s[:k]) + cw = np.concatenate(([0.0], np.cumsum(w_s))) + cyw = np.concatenate(([0.0], np.cumsum(w_s * y_s))) + + # mid[j]: upper transit boundary when the last in-transit + # observation is j-1 (midpoint to the first excluded + # observation; epsilon past the final phase when nothing + # is excluded) + mid = np.empty(ndata + 1) + mid[0] = 0.0 # unused + mid[1:ndata] = 0.5 * (phi_s[1:] + phi_s[:-1]) + mid[ndata] = phi_s[ndata - 1] + 1e-7 + + # ---- Non-wrapped transits: obs i..j-1, 0 <= i < j <= ndata. + # Matrices indexed [i, j-1]. + W_nw = cw[None, 1:] - cw[i_idx, None] + YW_nw = cyw[None, 1:] - cyw[i_idx, None] - ybar * W_nw + q_nw = mid[None, 1:] - phi_s[:, None] + valid_nw = np.triu(np.ones((ndata, ndata), dtype=bool)) + + # ---- Wrapped transits: obs i..end plus 0..k-1, 0 <= k < i. + # Matrices indexed [i, k]; the head boundary for k=0 is an + # epsilon past phase 1 (only the tail is in transit). + head_q = np.empty(ndata) + head_q[0] = 1e-7 + head_q[1:] = mid[1:ndata] + W_w = (cw[ndata] - cw[:ndata, None]) + cw[None, :ndata] + YW_w = ((cyw[ndata] - cyw[:ndata, None]) + cyw[None, :ndata] + - ybar * W_w) + q_w = (1.0 - phi_s[:, None]) + head_q[None, :] + valid_w = i_idx[None, :] < i_idx[:, None] + + powers = [] + for W, YW, q, valid in ((W_nw, YW_nw, q_nw, valid_nw), + (W_w, YW_w, q_w, valid_w)): + # W bounds mirror sparse_bls.cu's MIN_W/MAX_W_COMPLEMENT: + # the complement must exceed float32 roundoff so the GPU + # kernel and this reference exclude the same degenerate + # all-weight boxes (parity tests compare them directly) + valid = (valid & (q > 0) & (q >= qmin_f) & (q <= qmax_f) + & (W > 1e-9) & (W < 1.0 - 1e-4)) + if ignore_negative_delta_sols: + valid &= (YW <= 0) + with np.errstate(divide='ignore', invalid='ignore'): + p = np.where(valid, + (YW * YW) / (W * (1.0 - W)) / YY, 0.0) + powers.append(p) + + all_powers = np.concatenate([p.ravel() for p in powers]) + imax = int(np.argmax(all_powers)) + if all_powers[imax] > 0: + n_nw = ndata * ndata + if imax < n_nw: + ii, jj = divmod(imax, ndata) + q_best = q_nw[ii, jj] + else: + ii, kk = divmod(imax - n_nw, ndata) + q_best = q_w[ii, kk] + bls_powers[i_freq] = all_powers[imax] + best_q[i_freq] = q_best + best_phi[i_freq] = phi_s[ii] + + # Adjust phases to original timescale (float64 frequencies: the + # inverse conversion in single_bls uses the caller's float64 freq). + # Vectorized; see _rephase_solutions. + solutions = _rephase_solutions(best_q, best_phi, epoch, freqs64) + + return (convert_bls_power(bls_powers, y_orig, dy_orig, + convention=convention), + solutions) + + +def _sparse_shared_mem_bytes(ndata, block_size): + """Dynamic shared memory ``sparse_bls_kernel`` needs per block for + ``ndata`` points: three arrays padded to the next power of two (for + the bitonic sort), two prefix-sum arrays and three per-thread + scratch values, all float32.""" + n_pow2 = 1 + while n_pow2 < ndata: + n_pow2 *= 2 + return (3 * n_pow2 + 2 * int(ndata) + 3 * int(block_size)) * 4 + + +def _sparse_max_ndata(shmem_lim, block_size): + """Largest ``ndata`` whose :func:`_sparse_shared_mem_bytes` fits in + ``shmem_lim`` bytes.""" + best = 0 + n_pow2 = 1 + while (3 * n_pow2 + 3 * block_size) * 4 <= shmem_lim: + n = min(n_pow2, (shmem_lim // 4 - 3 * n_pow2 - 3 * block_size) // 2) + best = max(best, int(n)) + n_pow2 *= 2 + return best + + +def _reject_use_simple(kwargs, where): + """The bubble-sort ``sparse_bls_simple.cu`` kernel was removed in + 1.0 (it still carried the pre-PR#65 ``MAX_W_COMPLEMENT 1E-9`` bound + and returned powers up to 4.6 in pure noise on single-site data; + Sep 2026 audit, defect 20). Refuse the old switch loudly instead + of silently running the full kernel.""" + if 'use_simple' in kwargs: + raise TypeError("%s: the 'use_simple' sparse kernel was removed in " + "cuvarbase 1.0; drop the argument (the bitonic " + "sort + prefix-sum kernel is the only sparse " + "kernel)" % where) + + +def compile_sparse_bls(block_size=_default_block_size, **kwargs): + """ + Compile sparse BLS GPU kernel (bitonic sort + prefix sums for O(1) + range queries). + + Parameters + ---------- + block_size: int, optional (default: _default_block_size) + CUDA threads per CUDA block. + + Returns + ------- + kernel: PyCUDA function + The compiled sparse_bls_kernel function + """ + _reject_use_simple(kwargs, 'compile_sparse_bls') + + # Compiling a kernel needs an active CUDA context (lazily created). + ensure_context() + + cppd = dict(BLOCK_SIZE=block_size) + kernel_txt = _module_reader(find_kernel('sparse_bls'), + cpp_defs=cppd) + + # compile kernel + module = SourceModule(kernel_txt, options=['--use_fast_math']) + + kernel = module.get_function('sparse_bls_kernel') + + # Don't use prepare() - it causes issues with large shared memory + return kernel + + +def sparse_bls_gpu(t, y, dy, freqs, *, qmin=None, qmax=None, + ignore_negative_delta_sols=False, + block_size=64, max_ndata=None, + stream=None, kernel=None, + convention='chi2ratio'): + """ + GPU-accelerated sparse BLS implementation. + + Uses a CUDA kernel to test all pairs of observations as potential + transit boundaries. More efficient than CPU implementation for datasets + with ~100-1000 observations. + + Based on https://arxiv.org/abs/2103.06193 + + Parameters + ---------- + t: array_like, float + Observation times + y: array_like, float + Observations + dy: array_like, float + Observation uncertainties + freqs: array_like, float + Frequencies to test + qmin: float or array_like, optional (default: None) + Minimum transit duration (in phase) to consider; scalar or + per-frequency array (same length as ``freqs``). ``None`` means + no lower bound (all ``q > 0``). + qmax: float or array_like, optional (default: None) + Maximum transit duration (in phase), scalar or per-frequency. + ``None`` means the algorithm's standard upper cutoff of 0.5. + ignore_negative_delta_sols: bool, optional (default: False) + Whether or not to ignore solutions with negative delta (inverted dips) + block_size: int, optional (default: 64) + CUDA threads per CUDA block (use 32-128 for best performance) + max_ndata: int, optional (default: None) + Maximum number of data points (for shared memory allocation). + If None, uses len(t) + stream: pycuda.driver.Stream, optional (default: None) + CUDA stream for async execution + kernel: PyCUDA function, optional (default: None) + Pre-compiled kernel. If None, compiles kernel automatically. + convention: str, optional (default: 'chi2ratio') + Power-spectrum convention for the returned powers ('chi2ratio', + 'snr' or 'loglik'); see :func:`convert_bls_power`. + + Returns + ------- + bls_powers: array_like, float + BLS power at each frequency + solutions: list of (q, phi0) tuples + Best (q, phi0) solution at each frequency + """ + _validate_convention(convention) + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name='sparse_bls_gpu') + check_freqs(freqs, name='sparse_bls_gpu') + + # Original flux kept for convert_bls_power's chi2_0 + y_orig, dy_orig = y, dy + + # Convert to numpy arrays (epoch-subtract before the float32 cast) + t, epoch = subtract_epoch(t) + t = t.astype(np.float32) + # Centre in float64 BEFORE the float32 cast: the kernel's float32 + # prefix sums of w*y otherwise carry the mean flux and the + # `YW -= ybar * W` correction cancels catastrophically (Sep 2026 + # audit, defect 8; the in-kernel ybar is now ~1e-8 and harmless). + y, _ = _center_flux_float64(y, dy) + dy = np.asarray(dy).astype(np.float32) + # float64 copy for the phase re-referencing below (see + # sparse_bls_cpu: the float32-cast frequency would put the + # original-timescale phases off by epoch * |f64 - f32|) + freqs64 = np.asarray(freqs, dtype=np.float64) + freqs = freqs64.astype(np.float32) + + ndata = len(t) + nfreqs = len(freqs) + + qmins = _broadcast_q_bound(qmin, nfreqs, 0.0, + 'qmin').astype(np.float32) + qmaxes = _broadcast_q_bound(qmax, nfreqs, 0.5, + 'qmax').astype(np.float32) + _validate_q_bounds(qmins, qmaxes) + + if max_ndata is None: + max_ndata = ndata + + # Block size must be a power of 2 for tree reductions + if block_size & (block_size - 1) != 0: + raise ValueError(f"block_size must be a power of 2, got {block_size}") + + # Compile kernel if not provided (through the LRU cache: pycuda + # runs nvcc --preprocess on every SourceModule, so an uncached + # compile costs ~0.4-1.6 s around a ~2-20 ms kernel) + if kernel is None: + kernel = _get_cached_sparse_kernel(block_size) + + # Shared memory per block: + # sh_phi[n_pow2] + sh_y[n_pow2] + sh_w[n_pow2] + # + sh_cumsum_w[N] + sh_cumsum_yw[N] + 3*blockDim.x + shared_mem_size = _sparse_shared_mem_bytes(max_ndata, block_size) + + # The kernel keeps the whole light curve in shared memory, so it is + # limited to ~2000 points on a 48 KB device; the launch used to fail + # with a bare "cuLaunchKernel failed: invalid argument" (Sep 2026 + # audit, ids 77/126). Check before any allocation or launch. + att = cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK + shmem_lim = int(ensure_context().device.get_attribute(att)) + if shared_mem_size > shmem_lim: + raise ValueError( + "sparse_bls_gpu: %d points need %d bytes of shared memory " + "per block, above this device's %d-byte limit (the sparse " + "kernel handles at most %d points here with block_size=%d). " + "Use the binned kernels for larger light curves: " + "eebls_transit(use_sparse=False) / eebls_gpu_fast / eebls_gpu." + % (max_ndata, shared_mem_size, shmem_lim, + _sparse_max_ndata(shmem_lim, block_size), block_size)) + + # Allocate GPU memory + t_g = gpuarray.to_gpu(t) + y_g = gpuarray.to_gpu(y) + dy_g = gpuarray.to_gpu(dy) + freqs_g = gpuarray.to_gpu(freqs) + qmin_g = gpuarray.to_gpu(qmins) + qmax_g = gpuarray.to_gpu(qmaxes) + + bls_powers_g = gpuarray.zeros(nfreqs, dtype=np.float32) + best_q_g = gpuarray.zeros(nfreqs, dtype=np.float32) + best_phi_g = gpuarray.zeros(nfreqs, dtype=np.float32) + + # Launch kernel + # Grid: one block per frequency (or fewer if limited by hardware) + max_blocks = 65535 # CUDA maximum + grid = (min(nfreqs, max_blocks), 1) + block = (block_size, 1, 1) + + if stream is None: + stream = cuda.Stream() + + # Call kernel without prepare() to avoid resource issues + kernel( + t_g, y_g, dy_g, freqs_g, qmin_g, qmax_g, + np.uint32(ndata), np.uint32(nfreqs), + np.uint32(ignore_negative_delta_sols), + bls_powers_g, best_q_g, best_phi_g, + block=block, grid=grid, stream=stream, + shared=shared_mem_size + ) + + # Copy results back + stream.synchronize() + bls_powers = bls_powers_g.get() + best_q = best_q_g.get() + best_phi = best_phi_g.get() + + # Adjust phases to original timescale (float64 frequencies: the + # inverse conversion in single_bls uses the caller's float64 freq). + # Vectorized; see _rephase_solutions. + solutions = _rephase_solutions(best_q, best_phi, epoch, freqs64) + + return (convert_bls_power(bls_powers, y_orig, dy_orig, + convention=convention), + solutions) + + +def _fast_box_widths(nbinsf, nbins0, dlogq): + """Box widths, in fine phase bins, that the fast (shared-memory) + kernels iterate at one frequency: ``m = 1, 1 + dnbins(1, dlogq), + ...`` up to and including ``max_bin_width = nbinsf // nbins0``. + + The searched durations are ``q = m / nbinsf``, so the widest one is + ``(nbinsf // nbins0) / nbinsf <= 1 / nbins0``, i.e. the discretized + ``qmax`` (``nbins0 = floor(1/qmax)``). Before 1.0 the kernels wrote + ``max_bin_width = divrndup(nbinsf, nbins0)`` and looped ``m < + max_bin_width``: the same set whenever ``nbins0`` does not divide + ``nbinsf``, but one level short when it does -- ``qmin=0.025, + qmax=0.1`` searched only ``q <= 0.075`` (Sep 2026 audit, id 64). + + Note the geometric step can still overshoot the last level: with + ``qmin=0.01, qmax=0.5, dlogq=0.3`` the ladder is ``..., 37, 48`` + and 62 > 50, so ``q = 0.48`` remains the widest box tested. + """ + nbf = int(nbinsf) + nb0 = max(1, int(nbins0)) + max_bin_width = nbf // nb0 + widths = [] + m = 1 + while m <= max_bin_width: + widths.append(m) + m += dnbins(m, dlogq) + return widths + + +def _fast_bls_box_scan(t32, yw32, w32, freq, nbins0, nbinsf, dlogq, + noverlap, dphi=0.0, + ignore_negative_delta_sols=False): + """CPU replica of the box grid the fast kernels + (``full_bls_no_sol`` / ``_optimized`` / ``_fused``) search at ONE + frequency: fold in float32, histogram into ``nbinsf`` phase bins on + ``noverlap`` grids shifted by ``1/noverlap`` of a bin (plus the base + offset ``dphi``), and scan every box of ``m`` bins for ``m = 1, + 1 + dnbins(1), ...`` up to and including ``nbinsf // nbins0`` (the + widest box with ``q = m / nbinsf <= 1 / nbins0``). + + ``t32`` are the epoch-subtracted float32 times, ``yw32 = w * (y - + ybar)`` and ``w32`` the normalized weights, all as + :meth:`BLSMemory.setdata` uploads them (order is irrelevant). + + Returns ``(value, q, phi0)`` with ``value = YW^2 / (W (1 - W))`` + (divide by ``YY`` for the 'chi2ratio' power), ``q = m / nbinsf`` and + the box start phase ``phi0 = (n + dphi_pass) / nbinsf`` (mod 1) + relative to the epoch of ``t32``; ``(0, 0, 0)`` when no box passes + the weight guards. + """ + nbf = int(nbinsf) + # q levels, exactly as the kernels iterate them + ms = _fast_box_widths(nbf, nbins0, dlogq) + + phi = np.asarray(t32, dtype=np.float32) * np.float32(freq) + phi = phi - np.floor(phi) + w64 = np.asarray(w32, dtype=np.float64) + yw64 = np.asarray(yw32, dtype=np.float64) + n = np.arange(nbf) + + best_val, best_q, best_phi = 0.0, 0.0, 0.0 + for s_pass in range(int(noverlap)): + dphi_pass = np.float32(float(dphi) + float(s_pass) / noverlap) + b = np.floor(np.float32(nbf) * phi - dphi_pass) + b = b.astype(np.int64) % nbf + hw = np.bincount(b, weights=w64, minlength=nbf) + hyw = np.bincount(b, weights=yw64, minlength=nbf) + # circular prefix sums: box (n, m) = bins n .. n + m - 1 mod nbf + cw = np.concatenate(([0.0], np.cumsum(np.concatenate([hw, hw])))) + cyw = np.concatenate(([0.0], + np.cumsum(np.concatenate([hyw, hyw])))) + for m in ms: + W = cw[n + m] - cw[n] + YW = cyw[n + m] - cyw[n] + # same guards as bls_value in bls_common.cuh + ok = (W > 1e-10) & (W < 1.0 - 1e-4) + if ignore_negative_delta_sols: + ok &= (YW <= 0) + with np.errstate(divide='ignore', invalid='ignore'): + val = np.where(ok, YW * YW / (W * (1.0 - W)), 0.0) + k = int(np.argmax(val)) + if val[k] > best_val: + best_val = float(val[k]) + best_q = m / float(nbf) + best_phi = ((n[k] + float(dphi_pass)) / float(nbf)) % 1.0 + return best_val, best_q, best_phi + + +def _fast_bls_solutions(t, y, dy, freqs, powers, qmin, qmax, n_solutions, + dlogq=0.3, noverlap=2, dphi=0.0, + ignore_negative_delta_sols=False): + """Best-fit ``(q, phi)`` at the ``n_solutions`` highest-power + frequencies of a fast-kernel periodogram (``eebls_gpu_fast`` and + friends do not track solutions). + + Each selected frequency's box grid is re-scanned on the CPU with + :func:`_fast_bls_box_scan` -- the same q levels, phase-bin grids + and float32 fold the kernel used -- so the returned ``(q, phi)`` is + the box that produced ``powers[k]`` (up to float32 accumulation + order). ``phi`` is the transit start phase in the ORIGINAL input + timescale (the convention of :func:`eebls_gpu` / + :func:`single_bls`). + + Returns a list of length ``len(freqs)``: ``(q, phi)`` tuples at the + selected frequencies (skipping those with zero power) and ``None`` + elsewhere. + """ + nfreq = len(freqs) + sols = [None] * nfreq + n_sel = int(min(max(0, int(n_solutions)), nfreq)) + if n_sel == 0: + return sols + + powers = np.asarray(powers, dtype=np.float64) + order = np.argsort(-powers, kind='stable')[:n_sel] + + t64, epoch = subtract_epoch(np.asarray(t, dtype=np.float64)) + y64 = np.asarray(y, dtype=np.float64) + w = np.power(np.asarray(dy, dtype=np.float64), -2) + w /= np.sum(w) + ybar = float(np.einsum('i,i->', w, y64)) + t32 = t64.astype(np.float32) + w32 = w.astype(np.float32) + yw32 = ((y64 - ybar) * w).astype(np.float32) + + freqs64 = np.asarray(freqs, dtype=np.float64) + freqs32 = freqs64.astype(np.float32) + # The SAME ladder BLSMemory.setdata uploads: the bounds go to + # _fast_path_nbins as the caller passed them (their own dtype, not + # promoted to float64 -- float32 bounds truncate to different bin + # counts at some values: 1/float32(0.025) is 40 in float32 but + # 1/float64(float32(0.025)) = 39.9999994 -> 39), with the fast + # paths' defaults for None. Promoting them first, as + # _broadcast_q_bound does, re-scanned a ladder one bin off the + # kernel's for float32 ``qvals`` and returned a (q, phi) the kernel + # never evaluated (Sep 2026 fresh-eyes review, finding 18). + nbins0, nbinsf = _fast_path_nbins(freqs32, + 1e-2 if qmin is None else qmin, + 0.5 if qmax is None else qmax) + + for k in order: + k = int(k) + if not powers[k] > 0: + continue + val, q, phi = _fast_bls_box_scan( + t32, yw32, w32, freqs32[k], nbins0[k], nbinsf[k], dlogq, + noverlap, dphi=dphi, + ignore_negative_delta_sols=ignore_negative_delta_sols) + if val <= 0: + continue + # back to the original timescale (float64 frequency, as eebls_gpu) + sols[k] = (float(q), float((phi + epoch * freqs64[k]) % 1.0)) + return sols + + +def eebls_transit(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, + qmin_fac=0.5, qmax_fac=2.0, fmin=None, + fmax=None, freqs=None, qvals=None, + use_fast=False, use_optimized=False, + use_sparse=None, sparse_threshold=500, + use_gpu=True, + ignore_negative_delta_sols=False, + n_solutions=10, + **kwargs): + """ + Keplerian BLS transit search, automatically selecting the + implementation from the dataset size. + + For small datasets (``ndata < sparse_threshold``) the sparse BLS + algorithm (Panahi & Zucker 2021) tests every pair of observations + as transit boundaries (no binning; chosen for its detection + properties on sparse data, not for speed). For larger datasets the + periodogram is computed by the fast shared-memory GPU kernel + (:func:`eebls_gpu_fast`, fused phase-oversampling) and the best-fit + ``(q, phi)`` is recovered at the ``n_solutions`` highest peaks. + Both paths honour the same per-frequency Keplerian duration bounds + ``[qmin_fac, qmax_fac] * q_transit(f)``. + + Parameters + ---------- + t: array_like, float + Observation times + y: array_like, float + Observations + dy: array_like, float + Observation uncertainties + fmax_frac: float, optional (default: 1.0) + Maximum frequency is `fmax_frac * fmax`, where + `fmax` is automatically selected by `fmax_transit`. + fmin_frac: float, optional (default: 1.0) + Minimum frequency is `fmin_frac * fmin`, where + `fmin` is automatically selected by `fmin_transit`. + fmin: float, optional (default: None) + Overrides automatic frequency minimum with this value + fmax: float, optional (default: None) + Overrides automatic frequency maximum with this value + qmin_fac: float, optional (default: 0.5) + Fraction of the fiducial q value to search + at each frequency (minimum) + qmax_fac: float, optional (default: 2.0) + Fraction of the fiducial q value to search + at each frequency (maximum) + freqs: array_like, optional (default: None) + Overrides the auto-generated frequency grid + qvals: array_like, optional (default: None) + Overrides the keplerian q values + use_fast: bool, optional (default: False) + Periodogram only: skip the ``(q, phi)`` recovery pass and return + ``solutions=None``. The periodogram itself is the same + :func:`eebls_gpu_fast` result the default path returns (kept + for backward compatibility; before 1.0 the default path ran the + slower binned :func:`eebls_gpu` search). + use_optimized: bool, optional (default: False) + Use the optimized GPU kernel (:func:`eebls_gpu_fast_optimized`; + periodogram only, ``solutions=None``). + + Unless an explicit ``block_size`` is passed (which is always + respected), this automatically selects a block size based on + ndata: + + - ndata <= 32: 32 threads (single warp) + - ndata <= 64: 64 threads (two warps) + - ndata <= 128: 128 threads (four warps) + - ndata > 128: 256 threads (eight warps) + + Smaller blocks reduce idle-thread overhead for small datasets + (measured effect with a warm kernel cache is ~1.0-1.3x; see + eebls_gpu_fast_adaptive). + use_sparse: bool, optional (default: None) + If True, use sparse BLS. If False, use standard BLS. If None (default), + automatically select based on dataset size (sparse_threshold). + sparse_threshold: int, optional (default: 500) + Threshold for automatically selecting sparse BLS. If ndata < threshold + and use_sparse is None, sparse BLS is used. + use_gpu: bool, optional (default: True) + Use GPU implementation. If True, uses GPU for both sparse and standard BLS. + If False, uses CPU for sparse BLS. The use_gpu parameter only affects sparse BLS; standard BLS always uses GPU. + ignore_negative_delta_sols: bool, optional (default: False) + Whether or not to ignore inverted dips + n_solutions: int, optional (default: 10) + Standard (non-sparse) path: number of highest-power frequencies + at which the best-fit ``(q, phi)`` is recovered (a CPU re-scan + of the kernel's box grid at those frequencies; see + :func:`_fast_bls_solutions`). The remaining entries of + ``solutions`` are ``None``. ``0`` returns a list of ``None``. + For a solution at every frequency use :func:`eebls_transit_gpu` + or :func:`eebls_gpu` (the full binned search; much slower). + **kwargs: + passed to `eebls_gpu_fast` (``dlogq``, ``noverlap``, ``dphi``, + ``freq_batch_size``, ``functions``, ``block_size``, ...; the + fast-kernel defaults ``dlogq=0.3``, ``noverlap=2`` apply), + `compile_bls`, `fmax_transit`, `fmin_transit`, and + `transit_autofreq`. The :func:`eebls_gpu`-only kwargs + ``nstreams`` and ``max_memory`` are ignored (with a + ``UserWarning``; use :func:`eebls_transit_gpu` or + :func:`eebls_gpu` if you need them). On the sparse + path, only the kwargs that `sparse_bls_gpu` accepts + (``block_size``, ``max_ndata``, ``stream``, ``kernel``, + ``convention``) are forwarded to it. A ``convention=`` kwarg + ('chi2ratio', 'snr' or 'loglik'; see + :func:`convert_bls_power`) selects the power-spectrum + convention on every path. + + .. note:: + + Both paths honour the per-frequency Keplerian + ``qmin_fac``/``qmax_fac`` duration bounds exactly per + frequency (the pre-1.0 standard path collapsed them to one + batch-wide window, so its results depended on + ``freq_batch_size`` and on the free device memory), so + results are comparable across the ``sparse_threshold`` + boundary up to the two algorithms' different candidate + sets (binned box grid vs observation pairs). Pass + ``use_sparse=False`` to force the standard path. + + Returns + ------- + freqs: array_like, float + Frequencies where BLS is evaluated + bls: array_like, float + BLS periodogram, normalized to :math:`1 - \\chi^2(f) / \\chi^2_0` + solutions: list of ``(q, phi)`` tuples, or None + Best ``(q, phi)`` solution per frequency; ``phi`` is the transit + start phase in the original input timescale. Sparse path: a + solution at every frequency. Standard path: solutions at the + ``n_solutions`` highest peaks (always including the argmax), + ``None`` elsewhere. ``None`` altogether when ``use_fast=True`` + or ``use_optimized=True``. + + """ + # Validate before anything else -- including the Keplerian grid + # builder, which turns a NaN timestamp into a NaN frequency grid + # and (with use_fast=True) a device crash that kills the CUDA + # context (Sep 2026 audit, defect 23). The Keplerian grid needs + # min_obs_per_transit (default 5) points; fmin_transit raises for + # shorter light curves, so only the universal floor is applied + # here (an explicit ``freqs=`` grid does not need the extra + # points). + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, name='eebls_transit') + if freqs is not None: + check_freqs(freqs, name='eebls_transit') + + ndata = len(t) + _reject_use_simple(kwargs, 'eebls_transit') + + # Determine whether to use sparse BLS + if use_sparse is None: + use_sparse = ndata < sparse_threshold + + # Generate frequency grid if not provided + if freqs is None: + if qvals is not None: + raise ValueError("qvals must be None if freqs is None") + if fmin is None: + fmin = fmin_transit(t, **kwargs) * fmin_frac + if fmax is None: + fmax = fmax_transit(qmax=0.5 / qmax_fac, **kwargs) * fmax_frac + freqs, qvals = transit_autofreq(t, fmin=fmin, fmax=fmax, + qmin_fac=qmin_fac, **kwargs) + if qvals is None: + qvals = q_transit(freqs, **kwargs) + + qmins = np.asarray(qvals) * qmin_fac + qmaxes = np.asarray(qvals) * qmax_fac + + # Neither the sparse path nor the fused fast kernel takes these + # (they are eebls_gpu's); warn rather than dropping them silently -- + # max_memory in particular exists to bound device allocation, and + # ndata < sparse_threshold is exactly the ZTF-scale regime. + for key in ('nstreams', 'max_memory'): # eebls_gpu-only + if kwargs.pop(key, None) is not None: + warnings.warn( + "eebls_transit ignores %s: the default path runs " + "eebls_gpu_fast, which uses one stream and sizes its " + "own shared-memory batches. Call eebls_transit_gpu " + "(Keplerian bounds, solution at every frequency) or " + "eebls_gpu directly if you need %s." % (key, key), + UserWarning, stacklevel=2) + + # Use sparse BLS for small datasets + if use_sparse: + # The sparse path honors the same per-frequency Keplerian + # q bounds as the standard path; use_fast only selects + # between the standard implementations. + if use_gpu: + # Forward only the kwargs sparse_bls_gpu accepts; the rest + # (rho, samples_per_peak, dlogq, ...) belong to the frequency + # grid helpers or standard-BLS layers above. + sparse_keys = ('block_size', 'max_ndata', 'stream', 'kernel', + 'convention') + sparse_kwargs = {k: v for k, v in kwargs.items() + if k in sparse_keys} + powers, sols = sparse_bls_gpu(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes, + ignore_negative_delta_sols=ignore_negative_delta_sols, + **sparse_kwargs) + else: + # Use CPU sparse BLS (fallback) + powers, sols = sparse_bls_cpu( + t, y, dy, freqs, qmin=qmins, qmax=qmaxes, + ignore_negative_delta_sols=ignore_negative_delta_sols, + convention=kwargs.get('convention', 'chi2ratio')) + return freqs, powers, sols + + # Standard (binned) GPU path for larger datasets: the periodogram + # comes from the fast shared-memory kernel, which honours the + # per-frequency Keplerian bounds (before 1.0 this path ran + # eebls_gpu, whose kernels collapsed array bounds to one batch-wide + # window -- Sep 2026 audit defect 7); the best (q, phi) is recovered + # at the top n_solutions peaks afterwards. + dlogq = kwargs.setdefault('dlogq', 0.3) + noverlap = kwargs.setdefault('noverlap', 2) + dphi = kwargs.get('dphi', 0.0) + + if use_optimized: + # Choose a block size from ndata unless the caller asked for a + # specific one (an explicit block_size must never be silently + # overridden -- the compiled BLOCK_SIZE and the launch + # configuration have to agree with what the caller expects) + block_size = kwargs.get('block_size') + if block_size is None: + block_size = _choose_block_size(ndata) + kwargs['block_size'] = block_size + + # Get cached kernels for this block size (fused-noverlap kernel + # included -- see eebls_gpu_fast_adaptive; ids 40, 63) + fname = 'full_bls_no_sol_optimized' + functions = _get_cached_kernels(block_size, use_optimized, + [fname, 'full_bls_no_sol_fused']) + + powers = eebls_gpu_fast_optimized(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes, + ignore_negative_delta_sols=ignore_negative_delta_sols, + functions=functions, + **kwargs) + return freqs, powers, None + + powers = eebls_gpu_fast(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes, + ignore_negative_delta_sols=ignore_negative_delta_sols, + **kwargs) + if use_fast: + return freqs, powers, None + + sols = _fast_bls_solutions( + t, y, dy, freqs, powers, qmins, qmaxes, n_solutions, + dlogq=dlogq, noverlap=noverlap, dphi=dphi, + ignore_negative_delta_sols=ignore_negative_delta_sols) + return freqs, powers, sols + + +_batch_function_signature = { + 'full_bls_batch': [ + np.intp, np.intp, np.intp, # t_all, yw_all, w_all + np.intp, np.intp, # bls_all, freqs + np.intp, np.intp, # nbins0, nbinsf + np.intp, # ndata_per_lc + np.uint32, np.uint32, np.uint32, # max_ndata, nfreq, freq_offset + np.uint32, np.uint32, # hist_size, noverlap + np.float32, np.float32, # dlogq, dphi + np.uint32, np.uint32, # ignore_neg, n_lcs + np.uint32, # bls_stride (output row pitch) + ], + # fused-noverlap variant: identical argument list; hist_size is the + # FINE histogram size (noverlap * max_nbins) + 'full_bls_batch_fused': [ + np.intp, np.intp, np.intp, + np.intp, np.intp, + np.intp, np.intp, + np.intp, + np.uint32, np.uint32, np.uint32, + np.uint32, np.uint32, + np.float32, np.float32, + np.uint32, np.uint32, + np.uint32, + ], +} + + +def compile_bls_batch(block_size=_default_block_size, **kwargs): + """ + Compile the multi-LC batch BLS kernel. + + Parameters + ---------- + block_size : int, optional (default: _default_block_size) + CUDA threads per block. + + Returns + ------- + functions : dict + Dictionary of compiled kernel functions. + """ + _validate_block_size(block_size) + + # Compiling a kernel needs an active CUDA context (lazily created). + ensure_context() + + cppd = dict(BLOCK_SIZE=block_size) + kernel_txt = _module_reader(find_kernel('bls_batch'), cpp_defs=cppd) + module = SourceModule(kernel_txt, options=['--use_fast_math']) + + functions = {} + for name, sig in _batch_function_signature.items(): + func = module.get_function(name) + functions[name] = func.prepare(sig) + + return functions + + +def _get_cached_batch_kernels(block_size): + """``compile_bls_batch`` through the same thread-safe LRU cache the + single-LC paths use. Without this every ``eebls_gpu_batch`` call + recompiled the kernel (~0.6-0.9 s on an A5000) -- which dwarfed the + 2-10 ms of actual kernel work and was the entire "batch is ~12x + slower at TESS scale" regression (E1).""" + ensure_context() + key = (block_size, 'batch') + with _kernel_cache_lock: + if key in _kernel_cache: + _kernel_cache.move_to_end(key) + return _kernel_cache[key] + compiled = compile_bls_batch(block_size=block_size) + _kernel_cache[key] = compiled + _kernel_cache.move_to_end(key) + if len(_kernel_cache) > _KERNEL_CACHE_MAX_SIZE: + _kernel_cache.popitem(last=False) + return compiled + + +def eebls_gpu_batch(lightcurves, freqs, *, qmin=1e-2, qmax=0.5, + noverlap=2, dlogq=0.3, dphi=0.0, + ignore_negative_delta_sols=False, + max_batch_lcs=256, block_size=None, + functions=None, convention='chi2ratio', + memory=None, freq_batch_size=None, **kwargs): + """ + Process multiple lightcurves in batched GPU operations. + + Launches a single kernel with grid=(nfreq_blocks, n_lcs), where each + CUDA block handles one (frequency, lightcurve) pair. This eliminates + per-lightcurve Python loop overhead and kernel launch costs. + + Parameters + ---------- + lightcurves : list of (t, y, dy) tuples + List of lightcurves to process. + freqs : array_like + Frequency grid (shared across all lightcurves). + qmin : float or array_like, optional (default: 1e-2) + Minimum fractional transit duration. An array gives a + per-frequency bound (e.g. from + ``bls_frequencies.keplerian_freq_grid(..., return_qvals=True)`` + scaled by a qmin factor); must have the same length as + ``freqs``. + qmax : float or array_like, optional (default: 0.5) + Maximum fractional transit duration (scalar or per-frequency, + as for ``qmin``). + noverlap : int, optional (default: 2) + Phase-bin oversampling: the periodogram is the elementwise max + over ``noverlap`` kernel passes with the phase-bin grid shifted + by ``1/noverlap`` of the finest bin between passes (same + semantics as ``eebls_gpu_fast``). Runtime scales linearly; + ``noverlap=1`` gives a single unshifted pass. Must be a + positive integer (``noverlap=0`` used to return an all-zero + periodogram instead of raising). + dlogq : float, optional (default: 0.3) + Logarithmic spacing of q values. + dphi : float, optional (default: 0.0) + Phase offset (in units of the finest phase bin). + ignore_negative_delta_sols : bool, optional (default: False) + Ignore solutions with positive residuals (inverted dips). + max_batch_lcs : int, optional (default: 256) + Maximum lightcurves per kernel launch. + block_size : int, optional + CUDA threads per block. If None, auto-selects based on max ndata. + functions : dict, optional + Pre-compiled batch kernel functions. + memory : :class:`cuvarbase.memory.bls_memory.BLSBatchMemory`, optional + Reusable staging/device memory. Streaming many chunks of + lightcurves through repeated ``eebls_gpu_batch`` calls pays + several ms of pinned-host + device allocation per call + otherwise; construct one ``BLSBatchMemory(max_ndata, + min(max_batch_lcs, n_lcs), nfreq, stream=Stream())`` sized for + the largest chunk and pass it to every call. Must satisfy + ``max_ndata >= max(len(t))``, ``n_lcs >= min(max_batch_lcs, + len(lightcurves))`` and ``nfreqs >= len(freqs)``. + freq_batch_size : int, optional + Frequencies per kernel launch. ``None`` (default) launches the + whole grid at once unless shared memory would limit occupancy + (large bin counts from small Keplerian ``qmin``), in which + case an occupancy-aware chunk size is used automatically. + + Returns + ------- + bls_results : list of ndarray + BLS power array for each lightcurve, each shape (nfreq,). + + Notes + ----- + What batching buys is the removal of per-call host overhead + (pinned-host and device allocation, transfers, launches): the + kernel throughput per light curve is the same as the single-LC + fused kernel once one light curve fills the GPU (Sep 2026 audit, + id 139: 0.16-0.20 ms/LC batched vs 0.20 ms single at ZTF/TESS + scale, 8.5-9.3 vs 8.3-8.9 ms/LC at HAT scale). The ~5-10x measured + against a naive per-call ``eebls_gpu_fast`` loop (RTX A5000, Jul + 2026; fresh ``BLSMemory`` per call) is that overhead; against a + single-LC loop that reuses its ``BLSMemory`` the whole-call cost + per light curve is about the same (~0.4 ms/LC either way at ZTF + scale). Pass ``memory=`` to keep the batch path itself from + re-allocating per chunk. The earlier "~12x slower at TESS scale" + regression was per-call kernel compilation (now LRU-cached like + the single-LC paths); see + `archived numerical diagnosis `_. + """ + _validate_convention(convention) + # Validate every light curve, the shared grid and the q bounds + # before any device work: one NaN sample used to give a finite + # periodogram with a wrong argmax, and a NaN or out-of-range q + # bound crashed the kernel and killed the process's CUDA context + # (Sep 2026 audit, defect 23). + check_freqs(freqs, name='eebls_gpu_batch') + for i, lc in enumerate(lightcurves): + if len(lc) != 3: + raise ValueError("eebls_gpu_batch: lightcurve %d must be a " + "(t, y, dy) tuple; got %d elements" + % (i, len(lc))) + check_lightcurve(lc[0], lc[1], lc[2], min_n=_BLS_MIN_NDATA, + name='eebls_gpu_batch lightcurve %d' % i) + _validate_fast_q_bounds(len(freqs), qmin, qmax) + + freqs = np.asarray(freqs).astype(np.float32) + nfreq = len(freqs) + n_total = len(lightcurves) + # noverlap=0 used to launch nothing and return the untouched (zero, + # or stale on memory reuse) periodogram (Sep 2026 audit, id 75) + _validate_noverlap(noverlap) + + # Group LCs by similar ndata to minimize padding + lc_indices = list(range(n_total)) + lc_ndatas = [len(lc[0]) for lc in lightcurves] + + # Sort by ndata for efficient grouping + sorted_indices = sorted(lc_indices, key=lambda i: lc_ndatas[i]) + + # Auto-select block size + max_ndata_all = max(lc_ndatas) + if block_size is None: + block_size = _choose_block_size(max_ndata_all) + + # Compile kernel if needed (LRU-cached; per-call compilation was + # the dominant cost of this function -- see _get_cached_batch_kernels) + if functions is None: + functions = _get_cached_batch_kernels(block_size) + + func = functions['full_bls_batch'] + + # Fused-noverlap path (mirrors _eebls_gpu_fast_impl): one launch + # with a noverlap-times finer histogram replaces the dphi-shifted + # multi-pass loop for power-of-two noverlap with dphi == 0. + fused_func = functions.get('full_bls_batch_fused') + noverlap_int = int(noverlap) + use_fused = (fused_func is not None + and noverlap_int >= 2 + and float(dphi) == 0.0 + and (noverlap_int & (noverlap_int - 1)) == 0) + + # Process in batches + all_results = [None] * n_total # indexed by original order + + shmem_lim = kwargs.get('shmem_lim', None) + if shmem_lim is None: + dev = ensure_context().device + att = cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK + shmem_lim = dev.get_attribute(att) + + float_size = np.float32(1).nbytes + + # One BLSBatchMemory serves every chunk (and, via ``memory=``, + # every future call with compatible sizes): allocating the pinned + # staging buffers + device arrays per chunk cost multiple ms per + # call (cuMemHostAlloc dominates at survey nfreq). + batch_cap = min(max_batch_lcs, n_total) + if memory is not None: + mem = memory + if (mem.max_ndata < max_ndata_all or mem.n_lcs < batch_cap + or mem.nfreqs < nfreq): + raise ValueError( + "eebls_gpu_batch: provided memory is too small " + f"(max_ndata {mem.max_ndata} < {max_ndata_all}, " + f"n_lcs {mem.n_lcs} < {batch_cap}, or nfreqs " + f"{mem.nfreqs} < {nfreq})") + stream = mem.stream + else: + stream = cuda.Stream() + mem = BLSBatchMemory(max_ndata_all, batch_cap, nfreq, + stream=stream) + + # Set frequency grid once for all chunks + max_nbins = mem.set_freqs(freqs, qmin=qmin, qmax=qmax) + + # Check shared memory (qmin may be a per-frequency array) + mem_req = (block_size + 2 * max_nbins) * float_size + if mem_req > shmem_lim: + qmin_min = 2 * float_size / (shmem_lim - float_size * block_size) + raise ValueError( + f"qmin={float(np.min(qmin)):.2e} requires too much " + f"shared memory ({mem_req} > {shmem_lim}). " + f"Try qmin > {qmin_min:.2e}." + ) + + # Fused path needs the noverlap-times finer histogram to fit; + # otherwise fall back to the multi-pass loop. + batch_use_fused = use_fused + if batch_use_fused: + fused_req = (block_size + + 2 * noverlap_int * max_nbins) * float_size + if fused_req > shmem_lim: + batch_use_fused = False + else: + mem_req = fused_req + + # Occupancy-aware frequency chunking: each launch sizes its shared + # memory by the max bin count of the frequencies it covers, and + # ascending frequency grids have monotonically decreasing bin + # counts -- so when the global max would cap resident blocks below + # the thread limit (Kepler-scale qmin), chunked launches let all + # but the first chunks run at full occupancy (measured +32% on + # Kepler, neutral elsewhere; only triggers when shared memory is + # the occupancy limiter). + if freq_batch_size is None: + freq_batch_size = nfreq + if _shmem_limits_occupancy(mem_req, block_size): + freq_batch_size = _OCCUPANCY_FREQ_CHUNK + + freqs_uploaded = False + i = 0 + while i < len(sorted_indices): + # Take up to max_batch_lcs from sorted order + batch_end = min(i + max_batch_lcs, len(sorted_indices)) + batch_indices = sorted_indices[i:batch_end] + batch_n = len(batch_indices) + + # Set lightcurve data + for j, orig_idx in enumerate(batch_indices): + t, y, dy = lightcurves[orig_idx] + mem.set_lightcurve(j, t, y, dy) + + # Transfer to GPU (frequency grid only once; only the + # populated LC slots) + mem.transfer_to_gpu(n_lcs_active=batch_n, + transfer_freqs=not freqs_uploaded) + freqs_uploaded = True + + # Launch kernel(s) + block = (block_size, 1, 1) + + # Phase oversampling, mirroring _eebls_gpu_fast_impl (A2). + # Fused path: a single launch of full_bls_batch_fused evaluates + # all noverlap bin grids from one finer histogram. Fallback + # (non-power-of-two noverlap, dphi != 0, or fused histogram over + # the shared-memory limit): run ``noverlap`` passes with the bin + # grid shifted by 1/noverlap of a fine bin and keep the + # elementwise max. Without multi-passing the batch path was + # single-pass while the fast/adaptive reference multi-passes -- + # the small-ndata periodogram divergence flagged in the Jun GPU + # batch (E1). Frequency chunks size their shared memory by the + # chunk's own max bin count (occupancy; see freq_batch_size + # above). + best_bls_g = None + n_passes = 1 if batch_use_fused else noverlap + for i_pass in range(n_passes): + dphi_pass = dphi + float(i_pass) / noverlap + + i_freq = 0 + while i_freq < nfreq: + j_freq = min(i_freq + freq_batch_size, nfreq) + nf_chunk = j_freq - i_freq + chunk_nbins = int(np.max(mem.nbinsf[i_freq:j_freq])) + + if batch_use_fused: + hist_size = noverlap_int * chunk_nbins + else: + hist_size = chunk_nbins + chunk_req = (block_size + 2 * hist_size) * float_size + + grid = (min(nf_chunk, 5000), batch_n) + args = (grid, block, stream) + args += (mem.t_g.ptr, mem.yw_g.ptr, mem.w_g.ptr) + args += (mem.bls_g.ptr, mem.freqs_g.ptr) + args += (mem.nbins0_g.ptr, mem.nbinsf_g.ptr) + args += (mem.ndata_per_lc_g.ptr,) + # per-LC stride of the padded data layout = the + # memory's allocation stride (constant across chunks + # on reuse) + args += (np.uint32(mem.max_ndata),) + args += (np.uint32(nf_chunk), np.uint32(i_freq)) + if batch_use_fused: + args += (np.uint32(hist_size), + np.uint32(noverlap_int)) + args += (np.float32(dlogq), np.float32(dphi)) + else: + args += (np.uint32(hist_size), np.uint32(1)) + args += (np.float32(dlogq), np.float32(dphi_pass)) + args += (np.uint32(int(ignore_negative_delta_sols)),) + args += (np.uint32(batch_n),) + # output row pitch = the memory's frequency allocation + # (may exceed len(freqs) on reuse) + args += (np.uint32(mem.nfreqs),) + + launch_func = fused_func if batch_use_fused else func + launch_func.prepared_async_call(*args, + shared_size=int(chunk_req)) + i_freq = j_freq + + if not batch_use_fused and noverlap > 1: + if best_bls_g is None: + best_bls_g = mem.bls_g.copy() + else: + gpuarray.maximum(mem.bls_g, best_bls_g, + out=best_bls_g, stream=stream) + + if best_bls_g is not None: + cuda.memcpy_dtod(mem.bls_g.gpudata, best_bls_g.gpudata, + best_bls_g.nbytes) + + # Transfer results back (only the populated rows) + mem.transfer_to_cpu(n_lcs_active=batch_n) + batch_results = mem.get_results(n_lcs_active=batch_n, + nfreq_active=nfreq) + + # Store results in original order + for j, orig_idx in enumerate(batch_indices): + _, y_j, dy_j = lightcurves[orig_idx] + all_results[orig_idx] = convert_bls_power( + batch_results[j], y_j, dy_j, convention=convention) + + i = batch_end + + return all_results def hone_solution(t, y, dy, f0, df0, q0, dlogq0, phi0, stop=1e-5, @@ -1023,11 +3746,10 @@ def hone_solution(t, y, dy, f0, df0, q0, dlogq0, phi0, stop=1e-5, q = q0 phi = phi0 f = f0 - nol = noverlap - baseline = max(t) - min(t) + baseline = np.max(t) - np.min(t) - functions = compile_bls(**kwargs) + functions = _cached_compile_bls(**kwargs) i = 0 while pn is None or i < 5 or ((pn - p0) / p0 > stop and i < max_iter): @@ -1069,7 +3791,8 @@ def hone_solution(t, y, dy, f0, df0, q0, dlogq0, phi0, stop=1e-5, def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, qmin_fac=0.5, qmax_fac=2.0, fmin=None, - fmax=None, freqs=None, qvals=None, use_fast=False, + fmax=None, freqs=None, qvals=None, + use_fast=False, use_optimized=False, ignore_negative_delta_sols=False, **kwargs): """ @@ -1077,6 +3800,23 @@ def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, orbit of a planet with Mp/Ms << 1, Rp/Rs < 1, Lp/Ls << 1 and negligible eccentricity. + .. warning:: + + BLS weights each observation by ``1/dy**2`` (normalized). A + point with a near-zero reported uncertainty concentrates + essentially all of the statistical weight in one phase bin and + deterministically produces spurious power of ~0.99 in pure + noise, at nearly every trial frequency. Symptoms: + ``max(dy**-2) / sum(dy**-2)`` close to 1, and suspiciously + high, nearly flat power on noise-like data. Guard with a + percentile-based error floor before calling:: + + dy_floor = np.percentile(dy, 10) + dy = np.clip(dy, dy_floor, None) + + See the "Data hygiene: near-zero uncertainties" section of the + BLS documentation for details. + Parameters ---------- t: array_like, float @@ -1088,7 +3828,7 @@ def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, fmax_frac: float, optional (default: 1.0) Maximum frequency is `fmax_frac * fmax`, where `fmax` is automatically selected by `fmax_transit`. - fmin_frac: float, optional (default: 1.5) + fmin_frac: float, optional (default: 1.0) Minimum frequency is `fmin_frac * fmin`, where `fmin` is automatically selected by `fmin_transit`. fmin: float, optional (default: None) @@ -1108,6 +3848,9 @@ def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, functions: tuple, optional (default=None) result of ``compile_bls(**kwargs)``. use_fast: bool, optional (default: False) + Use fast GPU implementation. + use_optimized: bool, optional (default: False) + Use optimized GPU implementation (if not using fast). ignore_negative_delta_sols: bool Whether or not to ignore inverted dips @@ -1121,19 +3864,23 @@ def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, freqs: array_like, float Frequencies where BLS is evaluated bls: array_like, float - BLS periodogram, normalized to :math:`1 - \chi^2(f) / \chi^2_0` - solutions: list of ``(q, phi)`` tuples - Best ``(q, phi)`` solution at each frequency - - .. note:: - - Only returned when ``use_fast=False``. + BLS periodogram, normalized to :math:`1 - \\chi^2(f) / \\chi^2_0` + solutions: list of ``(q, phi)`` tuples, or None + Best ``(q, phi)`` solution at each frequency; ``phi`` is in the + original input timescale. ``None`` when ``use_fast=True`` or + ``use_optimized=True`` (those kernels do not track solutions). + The return is always a 3-tuple, matching :func:`eebls_transit`. """ + # See eebls_transit: validate before the Keplerian grid builder. + check_lightcurve(t, y, dy, min_n=_BLS_MIN_NDATA, + name='eebls_transit_gpu') + if freqs is not None: + check_freqs(freqs, name='eebls_transit_gpu') if freqs is None: if qvals is not None: - raise Exception("qvals must be None if freqs is None") + raise ValueError("qvals must be None if freqs is None") if fmin is None: fmin = fmin_transit(t, **kwargs) * fmin_frac if fmax is None: @@ -1152,7 +3899,14 @@ def eebls_transit_gpu(t, y, dy, fmax_frac=1.0, fmin_frac=1.0, ignore_negative_delta_sols=ignore_negative_delta_sols, **kwargs) - return freqs, powers + return freqs, powers, None + elif use_optimized: + powers = eebls_gpu_fast_optimized(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes, + ignore_negative_delta_sols=ignore_negative_delta_sols, + **kwargs) + + return freqs, powers, None powers, sols = eebls_gpu(t, y, dy, freqs, qmin=qmins, qmax=qmaxes, diff --git a/cuvarbase/bls_frequencies.py b/cuvarbase/bls_frequencies.py new file mode 100644 index 00000000..169afe4b --- /dev/null +++ b/cuvarbase/bls_frequencies.py @@ -0,0 +1,354 @@ +""" +Frequency grid utilities for BLS transit searches. + +Provides Keplerian-aware frequency grids that exploit the physical +relationship between orbital period and transit duration to minimize the +number of trial frequencies while maintaining sensitivity. + +The transit-duration/period relation is Seager & Mallen-Ornelas (2003), +ApJ 585, 1038, "A Unique Solution of Planet and Star Parameters from an +Extrasolar Planet Transit Light Curve" (eq. 3). The duty-cycle-based +frequency spacing is Ofir (2014), A&A 561, A138, "Optimizing the search +for transiting planets in long time series" (eq. 4; arXiv:1307.7330). +Consistent with :func:`cuvarbase.bls.transit_autofreq`. +""" +import numpy as np + + +__all__ = [ + 'keplerian_freq_grid', + 'uniform_freq_grid', + 'freq_grid_stats', +] + + +def _q_transit(freq, rho=1.0): + """ + Keplerian transit duration fraction q = T_dur / P. + + For a central transit (impact parameter 0) of a planet on a circular + orbit, Seager & Mallen-Ornelas (2003) eq. (3) reduces to:: + + q = arcsin((f / f_max0)^(2/3)) / pi + + where ``f_max0 = sqrt(G rho_star / 3pi)`` is the surface-orbit + frequency (``8.6307 * sqrt(rho)`` cycles/day, a derived constant -- + see :func:`cuvarbase.bls.fmax_transit0`). + + Parameters + ---------- + freq : float or array_like + Orbital frequency (1/days). + rho : float + Mean stellar density in solar units. + + Returns + ------- + q : float or array_like + Transit duration fraction. + """ + fmax0 = 8.6307 * np.sqrt(rho) + f23 = np.minimum(1.0, np.power(freq / fmax0, 2.0 / 3.0)) + return np.arcsin(f23) / np.pi + + +_GRID_METHODS = ('vectorized', 'recursion') + + +def _validate_grid_method(method): + if method not in _GRID_METHODS: + raise ValueError("grid method must be one of %r, got %r" + % (list(_GRID_METHODS), method)) + + +def _dq_transit(freq, fmax0): + r"""``d q / d f`` for :func:`_q_transit`, zero where ``q`` saturates. + + With :math:`x = (f/f_0)^{2/3}` and :math:`q = \arcsin(x)/\pi`, + :math:`dq/df = 2x / (3 \pi f \sqrt{1 - x^2})`. + """ + freq = np.asarray(freq, dtype=np.float64) + x = np.power(freq / fmax0, 2.0 / 3.0) + capped = ~(x < 1.0) + x = np.minimum(1.0, x) + with np.errstate(divide='ignore', invalid='ignore'): + dq = (2.0 / (3.0 * np.pi * freq)) * x / np.sqrt(1.0 - x * x) + return np.where(capped | ~np.isfinite(dq), 0.0, dq) + + +def _euler_transit_grid(fmin, fmax, num_fac, denom, fmax0, rho=1.0, + q_floor=0.0, tol=1e-15, max_iter=8, + seed_points=4096): + r"""The Ofir (2014) duty-cycle frequency recursion, vectorized. + + Returns the frequencies of + + .. math:: f_{n+1} = f_n + a\,\max(q(f_n),\,q_{\rm floor}) / b + + (``a = num_fac``, ``b = denom``) from ``f_0 = fmin`` up to and + including the first point at or above ``fmax`` -- exactly what the + scalar ``while`` loop this replaces builds, but without a Python + iteration per frequency (0.3-1.0 s at survey grid sizes, dwarfing + the GPU search itself; Sep 2026 audit, ids 4 and 44). + + Method: seed with the continuum solution of ``df/dn = a q(f)/b`` + (cumulative trapezoid of its reciprocal in the variable + :math:`u = f^{1/3}`, where the integrand is smooth at both ends, + inverted with ``np.interp``), then apply defect correction. Writing + the residual of the recursion as + ``d_n = f_n + a q(f_n)/b - f_{n+1}``, the error obeys the linear + recursion ``e_{n+1} = (1 + a q'(f_n)/b) e_n + d_n``, whose solution + is a ``cumprod``/``cumsum`` pair -- so each correction pass is O(N) + numpy work, and two to four passes drive the residual to float64 + rounding. + + This converges to a fixed point of the same recursion, not to an + approximation of it: measured against the scalar loop over + ZTF/HAT/TESS/Kepler baselines and ``rho`` in [0.05, 5], it + reproduces the grid length exactly and every frequency to ~1e-15 + relative (one to two float64 ulps) -- and bitwise once cast to the float32 + :func:`keplerian_freq_grid` returns. + """ + fmin = float(fmin) + fmax = float(fmax) + num_fac = float(num_fac) + denom = float(denom) + if not np.isfinite(fmin) or not np.isfinite(fmax) or fmin <= 0: + raise ValueError("frequency grid needs finite bounds with " + "fmin > 0; got fmin=%r fmax=%r" % (fmin, fmax)) + if not np.isfinite(denom) or denom <= 0 or num_fac <= 0: + raise ValueError("frequency grid step must be positive; got " + "num_fac=%r denom=%r" % (num_fac, denom)) + if fmin >= fmax: + return np.array([fmin], dtype=np.float64) + + step_scale = num_fac / denom + + def _q(f): + q = _q_transit(f, rho=rho) + return np.maximum(q, q_floor) if q_floor > 0 else q + + def _step(f): + # exactly the scalar loop's expression, elementwise + return (num_fac * _q(f)) / denom + + def _dstep(f): + dq = _dq_transit(f, fmax0) + if q_floor > 0: + dq = np.where(_q_transit(f, rho=rho) < q_floor, 0.0, dq) + return step_scale * dq + + # --- seed: invert n(f) = int df / step(f) --- + top = fmax + for _ in range(64): + u = np.linspace(fmin ** (1. / 3.), top ** (1. / 3.), + int(seed_points)) + fa = u ** 3 + g = 3.0 * u * u / np.maximum(_step(fa), 1e-300) + nn = np.concatenate(([0.0], np.cumsum(0.5 * (g[1:] + g[:-1]) + * np.diff(u)))) + ntot = int(np.floor(np.interp(fmax, fa, nn))) + 8 + f = np.interp(np.arange(ntot + 1, dtype=np.float64), nn, fa) + f[0] = fmin + if f[-1] > fmax: + break + top = top + max(top - fmin, 1e-12) + else: # pragma: no cover - unreachable for finite, positive bounds + raise RuntimeError("could not bracket the frequency grid " + "(fmin=%r fmax=%r)" % (fmin, fmax)) + + # --- defect correction --- + scale = max(abs(fmax), abs(fmin)) + for _ in range(int(max_iter)): + d = f[:-1] + _step(f[:-1]) - f[1:] + logp = np.concatenate(([0.0], np.cumsum(np.log1p(_dstep(f[:-1]))))) + p = np.exp(logp) + e = np.concatenate(([0.0], p[1:] * np.cumsum(d / p[1:]))) + f = f + e + f[0] = fmin + if np.max(np.abs(e)) <= tol * scale: + break + + idx = int(np.searchsorted(f, fmax, side='left')) + return f[:idx + 1] + + +def _recursion_transit_grid(fmin, fmax, num_fac, denom, rho=1.0, + q_floor=0.0): + """The same recursion as :func:`_euler_transit_grid`, run as the + scalar Python loop: the reference implementation, and what + ``method='recursion'`` selects.""" + freqs = [float(fmin)] + while freqs[-1] < fmax: + q = float(_q_transit(freqs[-1], rho=rho)) + if q_floor > 0: + q = max(q, q_floor) + freqs.append(freqs[-1] + (num_fac * q) / denom) + return np.array(freqs, dtype=np.float64) + + +def keplerian_freq_grid(period_min, period_max, baseline, *, + R_star=1.0, M_star=1.0, oversampling=2, + return_qvals=False, method='vectorized'): + """ + Generate a non-uniform frequency grid optimized for transit detection. + + Transit duration scales as T_dur ~ P^(1/3) (Kepler's third law), + so the required frequency resolution scales as df ~ q(f) / (T * oversampling) + where q(f) is the transit duration fraction at frequency f. This gives + fewer frequencies at low frequencies (long periods) where transits are + longer and the resolution requirement is coarser. + + This is the duty-cycle-based spacing of Ofir (2014), A&A 561, A138, + eq. (4) (``df = q(f) / (oversampling * T)``), consistent with + :func:`cuvarbase.bls.transit_autofreq`. + + Parameters + ---------- + period_min : float + Minimum period to search (days). + period_max : float + Maximum period to search (days). + baseline : float + Total observation baseline (days). + R_star : float, optional (default: 1.0) + Stellar radius in solar radii. Used to compute stellar density. + M_star : float, optional (default: 1.0) + Stellar mass in solar masses. Used to compute stellar density. + oversampling : float, optional (default: 2) + Oversampling factor. Higher values give denser grids. + return_qvals : bool, optional (default: False) + Also return the Keplerian transit duration fraction q at each + frequency. Pass e.g. ``qmin=0.5 * qvals, qmax=2.0 * qvals`` to + :func:`cuvarbase.bls.eebls_gpu_batch` for a duration- + constrained search (the batch kernel supports per-frequency + q bounds). + method : str, optional (default: ``'vectorized'``) + How to evaluate the spacing recursion. ``'vectorized'`` solves + it with numpy (5.2-13.6x faster on the audit host, a shared + NVIDIA A40 machine -- see the CHANGELOG; agrees with the loop + to ~1e-15 relative, one to two float64 ulps, in float64 and + bitwise in the float32 returned here). + ``'recursion'`` runs the original scalar Python loop, one + ``q`` evaluation per frequency. + + .. versionadded:: 1.0 + + Returns + ------- + freqs : ndarray, float32 + Non-uniform frequency array (1/days), sorted ascending. + qvals : ndarray, float32 + Keplerian q at each frequency (only if ``return_qvals=True``). + """ + # Mean stellar density in solar units + rho = M_star / (R_star ** 3) + + f_min = 1.0 / period_max + f_max = 1.0 / period_min + + T = baseline + + _validate_grid_method(method) + # ``q`` is floored at 1e-6 to avoid a zero step at f -> 0. + if method == 'recursion': + freqs = _recursion_transit_grid(f_min, f_max, 1.0, + oversampling * T, rho=rho, + q_floor=1e-6) + else: + freqs = _euler_transit_grid(f_min, f_max, 1.0, oversampling * T, + 8.6307 * np.sqrt(rho), rho=rho, + q_floor=1e-6) + + freqs = np.array(freqs, dtype=np.float32) + + # Trim to exact range + freqs = freqs[freqs <= f_max * 1.001] + + if return_qvals: + qvals = _q_transit(freqs.astype(np.float64), + rho=rho).astype(np.float32) + return freqs, qvals + + return freqs + + +def uniform_freq_grid(period_min, period_max, baseline, *, oversampling=2, + R_star=1.0, M_star=1.0): + """ + Generate a uniform frequency grid matched to Keplerian sensitivity. + + Uses the finest resolution needed by the Keplerian grid (at the lowest + frequency / longest period) as the uniform spacing. This gives a fair + comparison: both grids detect the same transits, but the uniform grid + wastes resolution at high frequencies where coarser spacing would suffice. + + Parameters + ---------- + period_min : float + Minimum period (days). + period_max : float + Maximum period (days). + baseline : float + Total observation baseline (days). + oversampling : float, optional (default: 2) + Oversampling factor. + R_star : float, optional (default: 1.0) + Stellar radius in solar radii. + M_star : float, optional (default: 1.0) + Stellar mass in solar masses. + + Returns + ------- + freqs : ndarray, float32 + Uniform frequency array (1/days). + """ + rho = M_star / (R_star ** 3) + f_min = 1.0 / period_max + f_max = 1.0 / period_min + + # Use the finest resolution needed (at lowest frequency) + q_min_freq = float(_q_transit(f_min, rho=rho)) + q_min_freq = max(q_min_freq, 1e-6) + df = q_min_freq / (oversampling * baseline) + + nf = int(np.ceil((f_max - f_min) / df)) + return np.linspace(f_min, f_max, max(nf, 1)).astype(np.float32) + + +def freq_grid_stats(freqs, baseline): + """ + Compute summary statistics for a frequency grid. + + Parameters + ---------- + freqs : ndarray + Frequency array. + baseline : float + Observation baseline (days). + + Returns + ------- + stats : dict + Dictionary with grid statistics. + """ + nf = len(freqs) + df = np.diff(freqs) + periods = 1.0 / freqs + + # Sensitivity-matched uniform grid: use finest df in this grid + df_min = float(df.min()) + uniform_nf = int(np.ceil((freqs[-1] - freqs[0]) / df_min)) + + return { + 'nfreq': nf, + 'f_min': float(freqs[0]), + 'f_max': float(freqs[-1]), + 'period_min': float(periods[-1]), + 'period_max': float(periods[0]), + 'df_min': df_min, + 'df_max': float(df.max()), + 'df_ratio': float(df.max() / df.min()), + 'uniform_nfreq': uniform_nf, + 'reduction_factor': uniform_nf / nf if nf > 0 else 0, + } diff --git a/cuvarbase/ce.py b/cuvarbase/ce.py index eed4f8d7..02a0784a 100644 --- a/cuvarbase/ce.py +++ b/cuvarbase/ce.py @@ -1,295 +1,178 @@ """ Implementation of Graham et al. 2013's Conditional Entropy period finding algorithm -""" -from __future__ import print_function, division - -from builtins import zip -from builtins import range -from builtins import object +.. note:: **Maintenance status.** cuvarbase's conditional entropy + implementation is in maintenance mode: it works and will keep + working, but no further performance or feature development is + planned. For new projects that need a fast GPU conditional-entropy + (or AOV) search, consider `periodfind + `_ (also on PyPI as + ``periodfind``), an actively maintained GPU period-finding + package developed for ZTF/SCoPe. +""" import numpy as np import pycuda.driver as cuda -import pycuda.gpuarray as gpuarray -#import pycuda.autoinit -import pycuda.autoprimaryctx from pycuda.compiler import SourceModule -from .core import GPUAsyncProcess -from .utils import _module_reader, find_kernel +from .base import GPUAsyncProcess, ensure_context +from .utils import _module_reader, find_kernel, normalize_light_curves +from .utils import check_lightcurve, check_freqs from .utils import autofrequency as utils_autofreq +from .memory import ConditionalEntropyMemory + + +__all__ = [ + 'conditional_entropy', + 'conditional_entropy_fast', + 'ConditionalEntropyAsyncProcess', +] + + +# Every kernel the CE module compiles, in the (sorted) order in which +# ``ConditionalEntropyAsyncProcess.function_tuple`` is unpacked by +# :func:`conditional_entropy` / :func:`conditional_entropy_fast`. +_CE_KERNELS = ('ce_classical_fast', 'ce_classical_faster', 'constdpdm_ce', + 'histogram_data_count', 'histogram_data_weighted', + 'log_prob', 'standard_ce', 'weighted_ce') + +# The ``ConditionalEntropyMemory`` options a ``run`` call may pass per +# call. When the call runs on an existing memory object the kernels +# dispatch on THAT object's settings, so a per-call value that disagrees +# with it is rejected rather than silently ignored (``use_fast`` is not +# overridable per call at all: ``call_func`` is fixed in the constructor). +_CE_MEMORY_OPTIONS = ('phase_bins', 'mag_bins', 'mag_overlap', + 'phase_overlap', 'max_phi', 'weighted', 'use_double', + 'compute_log_prob', 'balanced_magbins', + 'widen_mag_range') + + +# Minimum number of observations the conditional-entropy entry points +# accept. CE rescales y to [0, 1] with (y - min) / (max - min), which +# is 0/0 for a single point (the whole spectrum came back NaN). +_CE_MIN_NDATA = 2 + + +def _check_ce_data(data, where): + """Validate a CE ``[(t, y, dy), ...]`` batch before any GPU work. + + ``dy = 0`` or a NaN in ``y`` used to give a finite but wrong + spectrum (the NaN point was counted in magnitude bin 0; 3% relative + error with a different argmax), and a NaN in ``t`` moved the argmax + without any warning (Sep 2026 audit, defect 23). A constant ``y`` + (audit id 115) made ``setdata``'s ``(y - min) / (max - min)`` 0/0 + for every point: the NaN bin indices were cast to uint32 (a + platform-defined value) and the spectrum was flat garbage. + """ + for i, lc in enumerate(data): + # exactly (t, y, dy): normalize_light_curves unpacks three + # values one line downstream, so a 2-tuple died there with + # a raw "not enough values to unpack" instead of this message + if len(lc) != 3: + raise ValueError("%s: lightcurve %d must be a (t, y, dy) " + "tuple; got %d elements" + % (where, i, len(lc))) + dy = lc[2] + name = '%s lightcurve %d' % (where, i) + _t, y, _dy = check_lightcurve(lc[0], lc[1], dy, + min_n=_CE_MIN_NDATA, name=name) + if np.all(y == y[0]): + raise ValueError( + "%s: y is constant (all %d values equal %r); the " + "conditional entropy bins y over its range max - min, " + "which is zero, so there are no magnitude bins to build. " + "Remove constant lightcurves before searching" + % (name, y.size, y[0])) + + +def _needs_compile(prepared_functions): + """True unless every CE kernel has already been compiled and prepared. + + (The previous gate looked for a key ``'ce_wt'`` that no compile ever + produced, so the module was rebuilt with nvcc on every call.) + """ + if not prepared_functions: + return True + return not all(name in prepared_functions for name in _CE_KERNELS) -import resource -import warnings - - -class ConditionalEntropyMemory(object): - def __init__(self, **kwargs): - self.phase_bins = kwargs.get('phase_bins', 10) - self.mag_bins = kwargs.get('mag_bins', 5) - self.phase_overlap = kwargs.get('phase_overlap', 0) - self.mag_overlap = kwargs.get('mag_overlap', 0) - - self.max_phi = kwargs.get('max_phi', 3.) - self.stream = kwargs.get('stream', None) - self.weighted = kwargs.get('weighted', False) - self.widen_mag_range = kwargs.get('widen_mag_range', False) - self.n0 = kwargs.get('n0', None) - self.nf = kwargs.get('nf', None) - - self.compute_log_prob = kwargs.get('compute_log_prob', False) - - self.balanced_magbins = kwargs.get('balanced_magbins', False) - - if self.weighted and self.balanced_magbins: - raise Exception("simultaneous balanced_magbins and weighted" - " options is not currently supported") - - if self.weighted and self.compute_log_prob: - raise Exception("simultaneous compute_log_prob and weighted" - " options is not currently supported") - self.n0_buffer = kwargs.get('n0_buffer', None) - self.buffered_transfer = kwargs.get('buffered_transfer', False) - self.t = None - self.y = None - self.dy = None - - self.t_g = None - self.y_g = None - self.dy_g = None - - self.bins_g = None - self.ce_c = None - self.ce_g = None - self.mag_bwf = None - self.mag_bwf_g = None - self.real_type = np.float32 - if kwargs.get('use_double', False): - self.real_type = np.float64 - - self.freqs = kwargs.get('freqs', None) - self.freqs_g = None - - self.mag_bin_fracs = None - self.mag_bin_fracs_g = None - - self.ytype = np.uint32 if not self.weighted else self.real_type - - def allocate_buffered_data_arrays(self, **kwargs): - n0 = kwargs.get('n0', self.n0) - if self.buffered_transfer: - n0 = kwargs.get('n0_buffer', self.n0_buffer) - assert(n0 is not None) - - kw = dict(dtype=self.real_type, - alignment=resource.getpagesize()) - - self.t = cuda.aligned_zeros(shape=(n0,), **kw) - - self.y = cuda.aligned_zeros(shape=(n0,), - dtype=self.ytype, - alignment=resource.getpagesize()) - - if self.weighted: - self.dy = cuda.aligned_zeros(shape=(n0,), **kw) - - if self.balanced_magbins: - self.mag_bwf = cuda.aligned_zeros(shape=(self.mag_bins,), **kw) - - if self.compute_log_prob: - self.mag_bin_fracs = cuda.aligned_zeros(shape=(self.mag_bins,), - **kw) - return self - - def allocate_pinned_cpu(self, **kwargs): - nf = kwargs.get('nf', self.nf) - assert(nf is not None) - - self.ce_c = cuda.aligned_zeros(shape=(nf,), dtype=self.real_type, - alignment=resource.getpagesize()) - - return self - - def allocate_data(self, **kwargs): - n0 = kwargs.get('n0', self.n0) - if self.buffered_transfer: - n0 = kwargs.get('n0_buffer', self.n0_buffer) - - assert(n0 is not None) - self.t_g = gpuarray.zeros(n0, dtype=self.real_type) - self.y_g = gpuarray.zeros(n0, dtype=self.ytype) - if self.weighted: - self.dy_g = gpuarray.zeros(n0, dtype=self.real_type) - - def allocate_bins(self, **kwargs): - nf = kwargs.get('nf', self.nf) - assert(nf is not None) - - self.nbins = nf * self.phase_bins * self.mag_bins - - if self.weighted: - self.bins_g = gpuarray.zeros(self.nbins, dtype=self.real_type) - else: - self.bins_g = gpuarray.zeros(self.nbins, dtype=np.uint32) - - if self.balanced_magbins: - self.mag_bwf_g = gpuarray.zeros(self.mag_bins, - dtype=self.real_type) - if self.compute_log_prob: - self.mag_bin_fracs_g = gpuarray.zeros(self.mag_bins, - dtype=self.real_type) - - def allocate_freqs(self, **kwargs): - nf = kwargs.get('nf', self.nf) - assert(nf is not None) - self.freqs_g = gpuarray.zeros(nf, dtype=self.real_type) - if self.ce_g is None: - self.ce_g = gpuarray.zeros(nf, dtype=self.real_type) - - def allocate(self, **kwargs): - self.freqs = kwargs.get('freqs', self.freqs) - self.nf = kwargs.get('nf', len(self.freqs)) - - if self.freqs is not None: - self.freqs = np.asarray(self.freqs).astype(self.real_type) - - assert(self.nf is not None) - - self.allocate_data(**kwargs) - self.allocate_bins(**kwargs) - self.allocate_freqs(**kwargs) - self.allocate_pinned_cpu(**kwargs) - - if self.buffered_transfer: - self.allocate_buffered_data_arrays(**kwargs) - - return self - - def transfer_data_to_gpu(self, **kwargs): - assert(not any([x is None for x in [self.t, self.y]])) - - self.t_g.set_async(self.t, stream=self.stream) - self.y_g.set_async(self.y, stream=self.stream) - - if self.weighted: - assert(self.dy is not None) - self.dy_g.set_async(self.dy, stream=self.stream) - - if self.balanced_magbins: - self.mag_bwf_g.set_async(self.mag_bwf, stream=self.stream) - - if self.compute_log_prob: - self.mag_bin_fracs_g.set_async(self.mag_bin_fracs, - stream=self.stream) - - def transfer_freqs_to_gpu(self, **kwargs): - freqs = kwargs.get('freqs', self.freqs) - assert(freqs is not None) - - self.freqs_g.set_async(freqs, stream=self.stream) - - def transfer_ce_to_cpu(self, **kwargs): - self.ce_g.get_async(stream=self.stream, ary=self.ce_c) - - def compute_mag_bin_fracs(self, y, **kwargs): - N = float(len(y)) - mbf = np.array([np.sum(y == i)/N for i in range(self.mag_bins)]) - - if self.mag_bin_fracs is None: - self.mag_bin_fracs = np.zeros(self.mag_bins, dtype=self.real_type) - self.mag_bin_fracs[:self.mag_bins] = mbf[:] - - def balance_magbins(self, y, **kwargs): - yinds = np.argsort(y) - ybins = np.zeros(len(y)) - - assert len(y) >= self.mag_bins - - di = len(y) / self.mag_bins - mag_bwf = np.zeros(self.mag_bins) - for i in range(self.mag_bins): - imin = max([0, int(i * di)]) - imax = min([len(y), int((i + 1) * di)]) - - inds = yinds[imin:imax] - ybins[inds] = i - - mag_bwf[i] = y[inds[-1]] - y[inds[0]] - - mag_bwf /= (max(y) - min(y)) - - return ybins, mag_bwf.astype(self.real_type) - - def setdata(self, t, y, **kwargs): - dy = kwargs.get('dy', self.dy) - - self.n0 = kwargs.get('n0', len(t)) - - t = np.asarray(t).astype(self.real_type) - y = np.asarray(y).astype(self.real_type) - - yscale = max(y[:self.n0]) - min(y[:self.n0]) - y0 = min(y[:self.n0]) - if self.weighted: - dy = np.asarray(dy).astype(self.real_type) - if self.widen_mag_range: - med_sigma = np.median(dy[:self.n0]) - yscale += 2 * self.max_phi * med_sigma - y0 -= self.max_phi * med_sigma - - dy /= yscale - y = (y - y0) / yscale - if not self.weighted: - if self.balanced_magbins: - y, self.mag_bwf = self.balance_magbins(y) - y = y.astype(self.ytype) - - else: - y = np.floor(y * self.mag_bins).astype(self.ytype) - - if self.compute_log_prob: - self.compute_mag_bin_fracs(y) - - if self.buffered_transfer: - arrs = [self.t, self.y] - if self.weighted: - arrs.append(self.dy) - - if any([arr is None for arr in arrs]): - if self.buffered_transfer: - self.allocate_buffered_data_arrays(**kwargs) - - assert(self.n0 <= len(self.t)) - - self.t[:self.n0] = t[:self.n0] - self.y[:self.n0] = y[:self.n0] - - if self.weighted: - self.dy[:self.n0] = dy[:self.n0] - else: - self.t = t - self.y = y - if self.weighted: - self.dy = dy - return self - - def set_gpu_arrays_to_zero(self, **kwargs): - self.t_g.fill(self.real_type(0), stream=self.stream) - self.y_g.fill(self.ytype(0), stream=self.stream) - if self.weighted: - self.bins_g.fill(self.real_type(0), stream=self.stream) - self.dy_g.fill(self.real_type(0), stream=self.stream) - else: - self.bins_g.fill(np.uint32(0), stream=self.stream) - - def fromdata(self, t, y, **kwargs): - self.setdata(t, y, **kwargs) - if kwargs.get('allocate', True): - self.allocate(**kwargs) +def _is_single_freq_grid(freqs): + """True if ``freqs`` is one 1-D grid (to be shared by every lightcurve) + rather than a sequence of per-lightcurve grids. - return self + Accepts any 1-D numeric array or list (float32, float64, integers, + Python floats); previously only Python/np.float64 scalars were + recognized, so a float32 grid was mistaken for a list of grids. + """ + if isinstance(freqs, np.ndarray): + return freqs.ndim == 1 + if len(freqs) == 0: + return True + return isinstance(freqs[0], (float, int, np.floating, np.integer)) + + +def _freq_grids(freqs, nlcs): + """Expand ``freqs`` into a list of ``nlcs`` per-lightcurve grids.""" + if _is_single_freq_grid(freqs): + return [freqs] * nlcs + return list(freqs) + + +# --------------------------------------------------------------------------- +# Grid sizing for the block-per-frequency fast kernels +# --------------------------------------------------------------------------- +# Hardware limit on resident thread blocks per SM: 16 on sm_5x/6x/7.5/8.6, +# 32 on sm_70/8.0. 16 is the safe value -- a grid-stride kernel loses +# nothing by launching fewer blocks than could be resident. +_MAX_BLOCKS_PER_SM = 16 + + +def _device_occupancy_limits(): + """``(num_SMs, shared_memory_per_SM, max_threads_per_SM)`` of the + active device, with conservative fallbacks for drivers that do not + report the per-SM attributes.""" + dev = ensure_context().device + att = cuda.device_attribute + nsm = int(dev.get_attribute(att.MULTIPROCESSOR_COUNT)) + try: + shmem_sm = int(dev.get_attribute( + att.MAX_SHARED_MEMORY_PER_MULTIPROCESSOR)) + except Exception: + shmem_sm = int(dev.get_attribute(att.MAX_SHARED_MEMORY_PER_BLOCK)) + try: + thr_sm = int(dev.get_attribute(att.MAX_THREADS_PER_MULTIPROCESSOR)) + except Exception: + thr_sm = 1024 + return nsm, shmem_sm, thr_sm + + +def _fast_grid_size(shmem, block_size, nfreq): + """Number of thread blocks to launch for ``ce_classical_fast`` / + ``ce_classical_faster``. + + Both kernels give one trial frequency to each *block* and stride by + ``gridDim.x``, so every ``ce[i]`` is computed by exactly one block + from the same data in the same order: the result does not depend on + the grid size at all, and the only question is how many blocks keep + the device busy. Fill the device -- ``num_SMs`` times the number of + blocks that can be resident on an SM (shared memory, threads and the + hardware block limit) -- capped at the number of frequencies in the + launch. + + The heuristic this replaced, ``floor(2 * shmem_lim / shmem)``, is a + per-block shared-memory ratio rather than a grid size: it launched + 34 blocks at ``ndata = 300`` and 5 blocks at ``ndata = 2000`` no + matter how large the device or the frequency grid was, leaving an + 84-SM A40 (or a 128-SM 4090) almost entirely idle (Sep 2026 audit, + ids 61 and 107). + """ + nsm, shmem_sm, thr_sm = _device_occupancy_limits() + by_shmem = (shmem_sm // shmem) if shmem > 0 else _MAX_BLOCKS_PER_SM + by_threads = (thr_sm // block_size) if block_size > 0 else 1 + blocks_per_sm = max(1, min(int(by_shmem), int(by_threads), + _MAX_BLOCKS_PER_SM)) + return max(1, min(int(nfreq), nsm * blocks_per_sm)) def conditional_entropy(memory, functions, block_size=256, @@ -304,6 +187,31 @@ def conditional_entropy(memory, functions, block_size=256, if transfer_to_device: memory.transfer_data_to_gpu() + if memory.bins_g is None: + # bins_g is None both when the fast path deliberately skipped + # the histogram and when the memory was simply never allocated + # (__init__ leaves it None until allocate_bins runs); saying + # "use_fast=True" for the second case is a confident wrong + # explanation, so distinguish them. + if getattr(memory, 'use_fast', False): + raise ValueError( + "the standard conditional-entropy kernels accumulate " + "into a global histogram, but this memory was allocated " + "with use_fast=True, which skips it; allocate the " + "memory from a process with use_fast=False (or pass " + "use_fast=False to ConditionalEntropyMemory)") + raise ValueError( + "the standard conditional-entropy kernels accumulate into a " + "global histogram, but this memory has none: it was never " + "allocated. Call ConditionalEntropyMemory.fromdata(..., " + "allocate=True), or allocate_bins() on it, before running.") + + # The histogram kernels accumulate into ``bins_g``: it must start from + # zero on EVERY call, not only when ``run(set_data=True)`` zeroed it + # (``run(memory=..., set_data=False)`` used to accumulate counts + # across calls). + memory.bins_g.fill(memory.bins_g.dtype.type(0), stream=memory.stream) + if memory.weighted: args = (grid, block, memory.stream) args += (memory.t_g.ptr, memory.y_g.ptr, memory.dy_g.ptr) @@ -353,7 +261,7 @@ def conditional_entropy_fast(memory, functions, block_size=256, freq_batch_size=None, shmem_lc=True, shmem_lim=None, - max_nblocks=200, + max_nblocks=None, force_nblocks=None, stream=None, **kwargs): @@ -361,9 +269,15 @@ def conditional_entropy_fast(memory, functions, block_size=256, ce_logp, ce_std, ce_wt = functions if shmem_lim is None: - dev = pycuda.autoprimaryctx.device + dev = ensure_context().device att = cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK - shmem_lim = pycuda.autoprimaryctx.device.get_attribute(att) + shmem_lim = dev.get_attribute(att) + + if stream is None: + # launch on the memory's own stream so the data upload, the + # kernel and the result download are ordered and ``finish()`` + # (which synchronizes the process streams) covers all of them + stream = memory.stream if transfer_to_device: memory.transfer_data_to_gpu() @@ -373,11 +287,19 @@ def conditional_entropy_fast(memory, functions, block_size=256, block = (block_size, 1, 1) - # Get the shared memory requirement + # Shared memory layout (must match ce_classical_fast/faster): + # block_bin[nmag * nphase] (uint32) | block_bin_phi[nphase] (uint32) + # | pad to sizeof(FLT) | Hc[nmag * nphase] (FLT) + # | t_sh[ndata] (FLT) | y_sh[ndata] (uint32) (faster only) r = memory.real_type(1).nbytes u = np.uint32(1).nbytes shmem = (r + u) * memory.phase_bins * memory.mag_bins shmem += u * memory.phase_bins + # The alignment pad sits between the uint32 histograms and Hc, so it + # has to be added BEFORE the (optional) lightcurve block: computing + # it after adding ``data_mem`` made it depend on the parity of ndata + # and under-allocated by 4 bytes for odd ndata in double precision. + shmem += (-shmem) % r data_mem = (r + u) * len(memory.t) func = fast_ce @@ -393,21 +315,36 @@ def conditional_entropy_fast(memory, functions, block_size=256, shmem += data_mem func = faster_ce - # Make sure we have extra memory for alignment - shmem += shmem % r + if shmem > shmem_lim: + # Without this the launch fails deep inside pycuda with + # "cuLaunchKernel failed: invalid argument", which names + # neither the histogram nor the limit. + raise ValueError( + "use_fast=True needs %d bytes of shared memory per block for " + "the %d x %d (phase_bins x mag_bins) histogram, but this " + "device allows %d bytes per block. Reduce phase_bins * " + "mag_bins to at most about %d, or use use_fast=False (the " + "standard kernels keep the histogram in global memory)" + % (shmem, memory.phase_bins, memory.mag_bins, shmem_lim, + max(1, int((shmem_lim - u * memory.phase_bins) // (r + u))))) i_freq = 0 while (i_freq < memory.nf): j_freq = min([i_freq + freq_batch_size, memory.nf]) - grid = (min([int(np.ceil((j_freq - i_freq) / block_size)), - max_nblocks]), 1) - if data_in_shared_mem: - grid = (int(np.floor(2 * float(shmem_lim) / shmem)), 1) + # One block per trial frequency, grid-stride: size the grid from + # the device, not from the shared-memory footprint (ids 61/107). + nblocks = _fast_grid_size(shmem, block_size, j_freq - i_freq) + if max_nblocks is not None: + nblocks = min(nblocks, int(max_nblocks)) if force_nblocks is not None: - grid = (force_nblocks, 1) + nblocks = int(force_nblocks) + grid = (nblocks, 1) - assert(grid[0] > 0) + if not grid[0] > 0: + raise RuntimeError( + "computed CUDA grid size is 0: the shared-memory limit is " + "too small for this configuration") args = (grid, block, stream) args += (memory.t_g.ptr, memory.y_g.ptr) @@ -436,11 +373,12 @@ class ConditionalEntropyAsyncProcess(GPUAsyncProcess): ---------- phase_bins: int, optional (default: 10) Number of phase bins to use. - mag_bins: int, optional (default: 10) + mag_bins: int, optional (default: 5) Number of mag bins to use. max_phi: float, optional (default: 3.) - For weighted CE; skips contibutions to bins that are more than - ``max_phi`` sigma away. + For weighted CE; a magnitude bin only receives probability mass + from a datum if some part of the bin lies within ``max_phi`` + sigma of it (the datum's own bin always does). weighted: bool, optional (default: False) If true, uses the weighted version of the CE periodogram. Slower, but accounts for data uncertainties. @@ -451,16 +389,72 @@ class ConditionalEntropyAsyncProcess(GPUAsyncProcess): mag_overlap: int, optional (default: 0) If > 0, the mag bins are overlapped with each other use_fast: bool, optional (default: False) - Use a somewhat experimental function to speed up - computations. This is perfect for large Nfreqs and nobs <~ 2000. - If True, use :func:`run` and not :func:`large_run` and set - ``nstreams = 1``. + Use the shared-memory kernels (one thread block per trial + frequency, histogram kept in shared memory). Results match the + standard kernels to floating-point precision. Since the grid is + sized from the device (Sep 2026; it used to be a few blocks + whatever the GPU) the fast kernels are, IN SINGLE PRECISION, + the quicker of the two for all but the smallest problems -- on + one NVIDIA A40, shared with other jobs, so read the ratios as + indicative only: 1.3x at (ndata, nfreq) = (1000, 1e5), 1.9x at + (2000, 1e5) and 8x at (1e4, 1e5), break-even below that. With + ``use_double=True`` occupancy is shared-memory bound and the + fast kernels are roughly break-even, up to ~1.2x SLOWER around + ndata 1000-2000. They also need no global + histogram, saving ``nfreq * phase_bins * mag_bins`` uint32 of + device memory (20 MB for a 100k-frequency 10 x 5 search). + Incompatible with ``weighted=True``, ``balanced_magbins=True`` + and ``compute_log_prob=True`` (the fast kernels compute only the + conditional entropy). Works with ``run``, ``large_run`` + and the batched entry points, in single or double precision. + use_double: bool, optional (default: False) + Use double precision on the GPU. + balanced_magbins: bool, optional (default: False) + Use magnitude bins that each hold the same number of points to + within one (edges at the midpoints between adjacent sorted + groups; see + :meth:`cuvarbase.memory.ConditionalEntropyMemory.balance_magbins`) + instead of uniform bins. Incompatible with ``weighted``, + ``use_fast``, ``compute_log_prob`` and ``mag_overlap > 0``. + widen_mag_range: bool, optional (default: False) + Weighted CE only: widen the normalized magnitude range by + ``max_phi`` times the median uncertainty on each side, so that + the probability mass of the faintest/brightest points is not + truncated by the range edges. + compute_log_prob: bool, optional (default: False) + Instead of the conditional entropy, return the Poisson + log-likelihood of the phase-folded histogram under the + phase-independent null model (``sum_{phi, m} [N log Nexp - Nexp + - lgamma(N + 1)]`` with ``Nexp = N_phi * p(m)``). Like the CE it + is *minimized* at the true frequency. Incompatible with + ``weighted``, ``balanced_magbins`` and ``use_fast`` (there is + no shared-memory log-probability kernel). + + Notes + ----- + The returned periodogram is Graham et al. (2013)'s conditional + entropy ``H(m|phi)`` plus a constant: the histogram is converted to + a *density* in magnitude, which adds ``sum_m p(m) log(dm_m)``, the + mass-weighted mean of the log bin widths ``dm_m`` (in units of the + normalized magnitude range). With ``mag_overlap=0`` every bin has + ``dm_m = 1 / mag_bins``, so the offset is ``log(1 / mag_bins)`` + (``-1.609`` for the default ``mag_bins=5``). With ``mag_overlap > 0`` + the unweighted kernels use ``dm_m = min(mag_overlap + 1, mag_bins - + m) / mag_bins`` (the top bins are truncated at the brightest + magnitude cell), whereas the weighted kernel integrates every bin + over the full window and uses the constant ``(mag_overlap + 1) / + mag_bins``; ``weighted=True`` and ``weighted=False`` spectra then + differ by a constant. With ``balanced_magbins=True`` each bin uses + its own width. In every case the offset is the same at every + frequency (the per-magnitude-bin totals do not depend on the trial + frequency), so the location of the minimum is unaffected; subtract + it if you need the entropy in Graham's normalization. Example ------- >>> proc = ConditionalEntropyAsyncProcess() >>> Ndata = 1000 - >>> t = np.sort(365 * np.random.rand(N)) + >>> t = np.sort(365 * np.random.rand(Ndata)) >>> y = 12 + 0.01 * np.cos(2 * np.pi * t / 5.0) >>> y += 0.01 * np.random.randn(len(t)) >>> dy = 0.01 * np.ones_like(y) @@ -470,34 +464,157 @@ class ConditionalEntropyAsyncProcess(GPUAsyncProcess): """ def __init__(self, *args, **kwargs): - super(ConditionalEntropyAsyncProcess, self).__init__(*args, **kwargs) self.phase_bins = kwargs.get('phase_bins', 10) self.mag_bins = kwargs.get('mag_bins', 5) self.max_phi = kwargs.get('max_phi', 3.) self.weighted = kwargs.get('weighted', False) self.block_size = kwargs.get('block_size', 256) + self.compute_log_prob = kwargs.get('compute_log_prob', False) self.phase_overlap = kwargs.get('phase_overlap', 0) self.mag_overlap = kwargs.get('mag_overlap', 0) - if self.mag_overlap > 0: - if kwargs.get('balanced_magbins', False): - raise Exception("mag_overlap must be zero " - "if balanced_magbins is True") - + self.balanced_magbins = kwargs.get('balanced_magbins', False) + self.widen_mag_range = kwargs.get('widen_mag_range', False) + self.use_fast = kwargs.get('use_fast', False) self.use_double = kwargs.get('use_double', False) + # Reject unsupported option combinations before touching the GPU + self._check_options(dict(weighted=self.weighted, + balanced_magbins=self.balanced_magbins, + compute_log_prob=self.compute_log_prob, + mag_overlap=self.mag_overlap), + use_fast=self.use_fast) + + super(ConditionalEntropyAsyncProcess, self).__init__(*args, **kwargs) + self.real_type = np.float32 if self.use_double: self.real_type = np.float64 self.call_func = conditional_entropy - if kwargs.get('use_fast', False): + if self.use_fast: self.call_func = conditional_entropy_fast self.memory = kwargs.get('memory', None) self.shmem_lc = kwargs.get('shmem_lc', True) + @staticmethod + def _check_options(opts, use_fast=False): + """ + Raise ``ValueError`` for option combinations that have no + implementation (see ``docs/source/ce.rst``). + + Parameters + ---------- + opts: dict + Memory options (``weighted``, ``balanced_magbins``, + ``compute_log_prob``, ``mag_overlap``); missing keys are + treated as their defaults. + use_fast: bool + Whether the shared-memory kernels are in use. + """ + weighted = opts.get('weighted', False) + balanced = opts.get('balanced_magbins', False) + log_prob = opts.get('compute_log_prob', False) + mag_overlap = opts.get('mag_overlap', 0) + + if weighted and use_fast: + raise ValueError("use_fast must be False if weighted is True") + if log_prob and use_fast: + # conditional_entropy_fast only launches the shared-memory + # CE kernels: this combination used to return the plain + # conditional entropy instead of the log-probability + raise ValueError("use_fast must be False if compute_log_prob " + "is True (the fast kernels compute only the " + "conditional entropy; there is no " + "shared-memory log-probability kernel)") + if weighted and balanced: + raise ValueError("simultaneous balanced_magbins and weighted" + " options is not currently supported") + if weighted and log_prob: + raise ValueError("simultaneous compute_log_prob and weighted" + " options is not currently supported") + if balanced and use_fast: + raise ValueError("use_fast must be False if balanced_magbins " + "is True (the fast kernels only implement " + "uniform magnitude bins)") + if balanced and log_prob: + raise ValueError("simultaneous balanced_magbins and " + "compute_log_prob options is not currently " + "supported") + if balanced and mag_overlap > 0: + raise ValueError("mag_overlap must be zero " + "if balanced_magbins is True") + + def _memory_kwargs(self, **overrides): + """ + Build the keyword arguments for ``ConditionalEntropyMemory`` from + the process settings, apply ``overrides`` (per-call kwargs) and + validate the resulting option combination. + """ + kw = dict(phase_bins=self.phase_bins, + mag_bins=self.mag_bins, + mag_overlap=self.mag_overlap, + phase_overlap=self.phase_overlap, + max_phi=self.max_phi, + weighted=self.weighted, + use_double=self.use_double, + compute_log_prob=self.compute_log_prob, + balanced_magbins=self.balanced_magbins, + widen_mag_range=self.widen_mag_range) + kw.update(overrides) + # Not overridable per call: the memory layout has to match the + # kernels this process will actually launch (``call_func`` is + # chosen in the constructor), and the fast kernels skip the + # global histogram. + kw['use_fast'] = self.use_fast + self._check_options(kw, use_fast=self.use_fast) + return kw + + def _check_memory_options(self, mem, kwargs): + """ + Check the per-call option kwargs of a ``run`` that uses an + existing memory object (``memory=...`` or the memory from + :meth:`preallocate`). + + The kernels dispatch on the *memory's* settings (its ``weighted`` + / ``compute_log_prob`` / ``balanced_magbins`` flags pick the + kernel, ``phase_bins`` / ``mag_bins`` size its histogram), so a + per-call option that disagrees with the memory used to be + silently ignored. Raise ``ValueError`` instead, and re-check the + memory's own option combination against this process's + ``use_fast`` (a weighted memory run through the fast kernels, + for instance, read its float magnitudes as bin indices). + """ + opts = dict(phase_bins=mem.phase_bins, + mag_bins=mem.mag_bins, + mag_overlap=mem.mag_overlap, + phase_overlap=mem.phase_overlap, + max_phi=mem.max_phi, + weighted=mem.weighted, + use_double=(mem.real_type is np.float64), + compute_log_prob=mem.compute_log_prob, + balanced_magbins=mem.balanced_magbins, + widen_mag_range=mem.widen_mag_range) + bad = [k for k in _CE_MEMORY_OPTIONS + if k in kwargs and kwargs[k] != opts[k]] + if bad: + raise ValueError( + "per-call option(s) %s do not match the memory this call " + "runs on (%s): the kernels dispatch on the memory's " + "settings, so the per-call value would be ignored. " + "Allocate (or preallocate) the memory with these options, " + "or leave the memory argument out" + % (', '.join('%s=%r' % (k, kwargs[k]) for k in bad), + ', '.join('%s=%r' % (k, opts[k]) for k in bad))) + self._check_options(opts, use_fast=self.use_fast) + + def _ensure_compiled(self, **kwargs): + """Compile and prepare the kernels once per process object.""" + if _needs_compile(getattr(self, 'prepared_functions', None)): + self._compile_and_prepare_functions(**kwargs) + def _compile_and_prepare_functions(self, **kwargs): cpp_defs = dict(NPHASE=self.phase_bins, @@ -534,18 +651,51 @@ def _compile_and_prepare_functions(self, **kwargs): np.uint32, np.uint32, np.uint32, np.uint32, np.uint32, np.uint32] ) + if tuple(sorted(self.dtypes.keys())) != _CE_KERNELS: + raise RuntimeError("CE kernel table does not match _CE_KERNELS") for fname, dtype in self.dtypes.items(): func = self.module.get_function(fname) self.prepared_functions[fname] = func.prepare(dtype) self.function_tuple = tuple(self.prepared_functions[fname] - for fname in sorted(self.dtypes.keys())) + for fname in _CE_KERNELS) - def memory_requirement(self, data, **kwargs): + def memory_requirement(self, n0, nf, **kwargs): """ - Return an approximate GPU memory requirement in bytes. - Will throw a ``NotImplementedError`` if called, so ... don't call it. + Return an approximate GPU memory requirement in bytes for one + lightcurve with ``n0`` observations and ``nf`` trial + frequencies. + + The histogram dominates: ``nf * phase_bins * mag_bins`` + entries (uint32, or ``real_type`` when ``weighted=True``). + With ``use_fast=True`` there is no global histogram (it lives in + shared memory), so only the data, the grid and the result count. + + Parameters + ---------- + n0: int + Number of observations. + nf: int + Number of trial frequencies. + + Returns + ------- + mem: int + Approximate bytes of GPU memory required. """ - raise NotImplementedError() + rsize = np.dtype(self.real_type).itemsize + bin_size = rsize if self.weighted else np.dtype(np.uint32).itemsize + + # histogram bins (the ``use_fast`` kernels keep the histogram in + # shared memory and allocate none) + mem = 0 + if not getattr(self, 'use_fast', False): + mem = nf * self.phase_bins * self.mag_bins * bin_size + # observation data: t, y (+ dy when weighted) + mem += (3 if self.weighted else 2) * n0 * rsize + # frequencies + CE result + mem += 2 * nf * rsize + + return int(mem) def allocate_for_single_lc(self, t, y, freqs, dy=None, stream=None, **kwargs): @@ -568,20 +718,12 @@ def allocate_for_single_lc(self, t, y, freqs, dy=None, Returns ------- - mem: ConditionalEntropyMemory + mem: ~cuvarbase.memory.ce_memory.ConditionalEntropyMemory Memory object. """ - kw = dict(phase_bins=self.phase_bins, - mag_bins=self.mag_bins, - mag_overlap=self.mag_overlap, - phase_overlap=self.phase_overlap, - max_phi=self.max_phi, - stream=stream, - weighted=self.weighted, - use_double=self.use_double) - - kw.update(kwargs) + kw = self._memory_kwargs(**kwargs) + kw['stream'] = stream mem = ConditionalEntropyMemory(**kw) mem.fromdata(t, y, dy=dy, freqs=freqs, allocate=True, **kwargs) @@ -606,10 +748,11 @@ def allocate(self, data, freqs=None, **kwargs): * ``t``: Observation times * ``y``: Observations * ``dy``: Observation uncertainties - freqs: list, optional - Either a list of floats (same frequencies for all data), - or a list of length ``n=len(data)``, with element ``i`` of the - list being a list of frequencies for the ``i``-th lightcurve. + freqs: array_like, optional + Either a single 1-D array of frequencies (same grid for all + lightcurves), or a list of length ``n=len(data)`` with + element ``i`` being the frequency grid for the ``i``-th + lightcurve. **kwargs Returns @@ -627,9 +770,8 @@ def allocate(self, data, freqs=None, **kwargs): frqs = freqs if frqs is None: frqs = [self.autofrequency(t, **kwargs) for (t, y, dy) in data] - - elif isinstance(freqs[0], float): - frqs = [freqs] * len(data) + else: + frqs = _freq_grids(freqs, len(data)) for i, ((t, y, dy), f) in enumerate(zip(data, frqs)): mem = self.allocate_for_single_lc(t, y, dy=dy, freqs=f, @@ -644,6 +786,11 @@ def preallocate(self, max_nobs, freqs, """ Preallocate memory for future runs. + The frequency grid is uploaded to the GPU here, and each memory + object is bound to one of the process streams (or to + ``streams[i]`` if given), so that :meth:`finish` synchronizes + the result transfers of later :meth:`run` calls. + Parameters ---------- max_nobs: int @@ -653,37 +800,85 @@ def preallocate(self, max_nobs, freqs, nlcs: int, optional (default: 1) Maximum batch size for ``run`` calls streams: list of ``pycuda.driver.Stream`` - Length of list must be ``>= nlcs`` + Length of list must be ``>= nlcs``; defaults to the process + streams (created as needed) Returns ------- self.memory: list List of ``ConditionalEntropyMemory`` objects """ - kw = dict(phase_bins=self.phase_bins, - mag_bins=self.mag_bins, - mag_overlap=self.mag_overlap, - phase_overlap=self.phase_overlap, - max_phi=self.max_phi, - weighted=self.weighted, - use_double=self.use_double, - n0_buffer=max_nobs, - buffered_transfer=True, - allocate=True, - freqs=freqs) - - kw.update(kwargs) + overrides = dict(n0_buffer=max_nobs, + buffered_transfer=True, + allocate=True, + freqs=freqs) + overrides.update(kwargs) + kw = self._memory_kwargs(**overrides) + + if streams is None: + if len(self.streams) < nlcs: + self._create_streams(nlcs - len(self.streams)) + streams = self.streams + elif len(streams) < nlcs: + raise ValueError("preallocate: %d streams given for nlcs=%d" + % (len(streams), nlcs)) self.memory = [] for i in range(nlcs): - stream = None if streams is None else streams[i] - kw.update(dict(stream=stream)) + kw.update(dict(stream=streams[i])) mem = ConditionalEntropyMemory(**kw) mem.allocate(**kwargs) + mem.transfer_freqs_to_gpu() self.memory.append(mem) return self.memory + @staticmethod + def _memory_freq_grids(memory, nlcs): + """The frequency grids already bound to ``memory``, or ``None``. + + ``run(freqs=None)`` used to build a fresh ``autofrequency`` grid + even when :meth:`preallocate` (or :meth:`allocate`) had already + uploaded one; since the grid length is then almost never + ``mem.nf``, that combination raised + ``"memory was allocated for N frequencies ..."`` instead of + doing the work. When every memory object that will be used + carries a grid, that grid is the one the user asked to + preallocate, so use it. + """ + if memory is None or len(memory) < nlcs: + return None + grids = [] + for mem in memory[:nlcs]: + f = getattr(mem, 'freqs', None) + if f is None or mem.nf is None or len(f) != mem.nf: + return None + # float64: the memory holds the grid in the device's + # real_type (float32 by default), but this grid is echoed + # back as the result's frequency labels, which were + # float64 before this path existed. + grids.append(np.asarray(f, dtype=np.float64)) + return grids + + @staticmethod + def _sync_memory_freqs(mem, freqs): + """ + Make sure the frequency grid held by (and uploaded to) ``mem`` + is ``freqs``: upload when the memory's grid was never transferred + (``allocate()`` only creates a zero-filled ``freqs_g``) and + re-upload when a ``run`` call passes a grid that differs from the + one the memory was allocated with. + """ + f = np.asarray(freqs, dtype=mem.real_type) + if mem.nf is not None and len(f) != mem.nf: + raise ValueError( + "memory was allocated for %d frequencies but the call " + "passes %d; allocate (or preallocate) the memory for the " + "new grid" % (mem.nf, len(f))) + if (not getattr(mem, '_freqs_on_device', False) + or mem.freqs is None or not np.array_equal(mem.freqs, f)): + mem.transfer_freqs_to_gpu(freqs=f) + def run(self, data, memory=None, freqs=None, @@ -700,11 +895,17 @@ def run(self, data, * ``t``: observation times * ``y``: observations * ``dy``: observation uncertainties - freqs: optional, list of ``np.ndarray`` frequencies - List of custom frequencies. If not specified, calls - ``autofrequency`` with default arguments + freqs: optional, array_like + A single 1-D frequency grid (shared by all lightcurves) or a + list of per-lightcurve grids. If not specified, the grid + already bound to ``memory`` (or to :meth:`preallocate`'s + ``self.memory``) is used, and failing that + ``autofrequency`` is called with default arguments. memory: optional, list of ``ConditionalEntropyMemory`` objects - List of memory objects, length of list must be ``>= len(data)`` + List of memory objects, length of list must be ``>= len(data)``. + Defaults to the memory :meth:`preallocate` created. A grid + whose length differs from the one the memory was allocated + for raises ``ValueError``. set_data: boolean, optional (default: True) Transfers data to gpu if memory is provided **kwargs @@ -713,36 +914,78 @@ def run(self, data, ------- results: list of lists list of (freqs, ce) corresponding to CE for each element of - the ``data`` array + the ``data`` array; the ce arrays are page-locked host + buffers filled asynchronously — call :meth:`finish` before + reading them (the batched entry points synchronize for you) """ + _check_ce_data(data, 'ConditionalEntropyAsyncProcess.run') + if freqs is not None: + for frq in _freq_grids(freqs, len(data)): + check_freqs(frq, + name='ConditionalEntropyAsyncProcess.run') + + memory = memory if memory is not None else self.memory + if memory is None: + # per-call option kwargs: reject an unsupported combination + # on the host, before the kernels are compiled + self._memory_kwargs(**kwargs) + else: + # ... and, on an existing memory, a per-call option that + # disagrees with the memory (it would be silently ignored) + for mem in memory[:len(data)]: + self._check_memory_options(mem, kwargs) + # compile module if not compiled already - if not hasattr(self, 'prepared_functions') or \ - not all([func in self.prepared_functions for func in - ['ce_wt']]): - self._compile_and_prepare_functions(**kwargs) + self._ensure_compiled(**kwargs) + + # Prepare data + data = normalize_light_curves(data) # create and/or check frequencies frqs = freqs + if frqs is None: + frqs = self._memory_freq_grids(memory, len(data)) if frqs is None: frqs = [self.autofrequency(d[0], **kwargs) for d in data] + else: + frqs = _freq_grids(frqs, len(data)) - elif isinstance(frqs[0], float): - frqs = [frqs] * len(data) - - assert(len(frqs) == len(data)) + if len(frqs) != len(data): + raise ValueError( + "number of frequency grids (%d) does not match number of " + "lightcurves (%d)" % (len(frqs), len(data))) - memory = memory if memory is not None else self.memory + if freqs is None: + # grids that did not come through the check above: the + # autofrequency default, or the grid a preallocated memory + # was built with + for frq in frqs: + check_freqs(frq, + name='ConditionalEntropyAsyncProcess.run') + + if not self.use_fast: + for f, d in zip(frqs, data): + if len(f) * len(d[0]) > 2**32-1: + raise OverflowError( + "Number of streams is too large - overflowing 32 bit integers\n" + "Decrease frequency range or use :func:`large_run` instead") if memory is None: memory = self.allocate(data, freqs=frqs, **kwargs) for mem in memory: mem.transfer_freqs_to_gpu() - elif set_data: + else: + if len(memory) < len(data): + raise ValueError( + "%d memory objects for %d lightcurves; preallocate " + "with nlcs >= the batch size" % (len(memory), len(data))) for i, (t, y, dy) in enumerate(data): - memory[i].set_gpu_arrays_to_zero(**kwargs) - memory[i].setdata(t, y, dy=dy, **kwargs) + self._sync_memory_freqs(memory[i], frqs[i]) + if set_data: + memory[i].set_gpu_arrays_to_zero(**kwargs) + memory[i].setdata(t, y, dy=dy, **kwargs) kw = dict(block_size=self.block_size, shmem_lc=self.shmem_lc) @@ -767,8 +1010,9 @@ def large_run(self, data, * ``t``: observation times * ``y``: observations * ``dy``: observation uncertainties - freqs: optional, list of ``np.ndarray`` frequencies - List of custom frequencies. If not specified, calls + freqs: optional, array_like + A single 1-D frequency grid (shared by all lightcurves) or a + list of per-lightcurve grids. If not specified, calls ``autofrequency`` with default arguments max_memory: float, optional (default: None) Maximum memory per batch in bytes. If ``None``, it @@ -782,13 +1026,23 @@ def large_run(self, data, list of (freqs, ce) corresponding to CE for each element of the ``data`` array + Notes + ----- + Each batch gets its own memory, so ``large_run`` is unaffected by + (and does not disturb) memory created by :meth:`preallocate`. + """ + _check_ce_data(data, 'ConditionalEntropyAsyncProcess.large_run') + if freqs is not None: + for frq in _freq_grids(freqs, len(data)): + check_freqs( + frq, name='ConditionalEntropyAsyncProcess.large_run') + # per-call option kwargs: validated before any device work + self._memory_kwargs(**kwargs) + # compile module if not compiled already - if not hasattr(self, 'prepared_functions') or \ - not all([func in self.prepared_functions for func in - ['ce_wt']]): - self._compile_and_prepare_functions(**kwargs) + self._ensure_compiled(**kwargs) if max_memory is None: free, total = cuda.mem_get_info() @@ -798,14 +1052,27 @@ def large_run(self, data, frqs = freqs if frqs is None: frqs = [self.autofrequency(d[0], **kwargs) for d in data] + else: + frqs = _freq_grids(freqs, len(data)) - elif isinstance(frqs[0], float): - frqs = [frqs] * len(data) + if len(frqs) != len(data): + raise ValueError( + "number of frequency grids (%d) does not match number of " + "lightcurves (%d)" % (len(frqs), len(data))) - assert(len(frqs) == len(data)) + if freqs is None: + for frq in frqs: + check_freqs( + frq, name='ConditionalEntropyAsyncProcess.large_run') cpers = [] for d, f in zip(data, frqs): + # Limit frequencies to ensure that + # thread numbers are within the limits of single-precision + max_threads_per_launch = 2**32 - 1 + total_threads = len(d[0]) * len(f) + thread_nbatches = int(np.ceil(total_threads/max_threads_per_launch)) + size_of_real = self.real_type(1).nbytes # subtract of lc memory @@ -815,12 +1082,28 @@ def large_run(self, data, batch_size = int(np.floor(fmem / (size_of_real * (tot_bins + 2)))) nbatches = int(np.ceil(len(f) / float(batch_size))) + if thread_nbatches > nbatches: + # Cap the batch size by the thread limit directly: + # ceil(len(f) / thread_nbatches) can overshoot + # max_threads_per_launch by up to len(d[0]) - 1 threads, + # which would trip the overflow guard in run(). + batch_size = max(1, max_threads_per_launch // len(d[0])) + nbatches = int(np.ceil(len(f) / float(batch_size))) + cper = np.zeros(len(f)) for i in range(nbatches): imin = i * batch_size imax = min([len(f), (i + 1) * batch_size]) - r = self.run([d], freqs=f[slice(imin, imax)], **kwargs) + fbatch = np.asarray(f)[imin:imax] + # Allocate for this batch explicitly: the batches are + # slices of the grid, so a preallocated ``self.memory`` + # (whose nf is the *full* grid) can never serve them and + # run() would raise. (Before the frequency-upload fix + # this path silently ran on the preallocated memory's + # zero-filled grid.) + mem = self.allocate([d], freqs=[fbatch], **kwargs) + r = self.run([d], freqs=[fbatch], memory=mem, **kwargs) self.finish() cper[imin:imax] = r[0][1][:] @@ -844,6 +1127,12 @@ def batched_run_const_nfreq(self, data, batch_size=10, of observations. """ + _check_ce_data(data, 'batched_run_const_nfreq') + if freqs is not None: + check_freqs(freqs, name='batched_run_const_nfreq') + # per-call option kwargs: validated before any device work + self._memory_kwargs(**kwargs) + # create streams if needed bsize = min([len(data), batch_size]) if len(self.streams) < bsize: @@ -854,12 +1143,9 @@ def batched_run_const_nfreq(self, data, batch_size=10, if freqs is None: data_with_max_baseline = max(data, - key=lambda d: max(d[0]) - min(d[0])) + key=lambda d: np.max(d[0]) - np.min(d[0])) freqs = self.autofrequency(data_with_max_baseline[0], **kwargs) - df = freqs[1] - freqs[0] - nf = len(freqs) - ces = [] # make data batches @@ -870,16 +1156,10 @@ def batched_run_const_nfreq(self, data, batch_size=10, batches.append([data[i] for i in range(start, finish)]) # set up memory containers for gpu and cpu (pinned) memory - kwargs_mem = dict(buffered_transfer=True, - n0_buffer=max_ndata, - mag_overlap=self.mag_overlap, - phase_overlap=self.phase_overlap, - phase_bins=self.phase_bins, - mag_bins=self.mag_bins, - weighted=self.weighted, - max_phi=self.max_phi, - use_double=self.use_double) - kwargs_mem.update(kwargs) + overrides = dict(buffered_transfer=True, + n0_buffer=max_ndata) + overrides.update(kwargs) + kwargs_mem = self._memory_kwargs(**overrides) memory = [ConditionalEntropyMemory(stream=stream, **kwargs_mem) for stream in streams] diff --git a/cuvarbase/core.py b/cuvarbase/core.py index cc7b55ee..f97b25f7 100644 --- a/cuvarbase/core.py +++ b/cuvarbase/core.py @@ -1,56 +1,17 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +""" +Deprecated alias of :mod:`cuvarbase.base`. -from builtins import range -from builtins import object -import numpy as np -from .utils import gaussian_window, tophat_window, get_autofreqs -import pycuda.driver as cuda -from pycuda.compiler import SourceModule +``cuvarbase.core`` shipped in 0.2.5 and is kept for the 1.x series so +old imports keep working; importing it emits a ``DeprecationWarning``. +It will be removed in 2.0. Import ``GPUAsyncProcess`` and +``ensure_context`` from :mod:`cuvarbase.base` instead. +""" +import warnings +from .base import GPUAsyncProcess, ensure_context -class GPUAsyncProcess(object): - def __init__(self, *args, **kwargs): - self.reader = kwargs.get('reader', None) - self.nstreams = kwargs.get('nstreams', None) - self.function_kwargs = kwargs.get('function_kwargs', {}) - self.device = kwargs.get('device', 0) - self.streams = [] - self.gpu_data = [] - self.results = [] - self._adjust_nstreams = self.nstreams is None - if self.nstreams is not None: - self._create_streams(self.nstreams) - self.prepared_functions = {} +warnings.warn("cuvarbase.core is deprecated; import from cuvarbase.base. " + "It will be removed in 2.0", DeprecationWarning, + stacklevel=2) - def _create_streams(self, n): - for i in range(n): - self.streams.append(cuda.Stream()) - - def _compile_and_prepare_functions(self): - raise NotImplementedError() - - def run(self, *args, **kwargs): - raise NotImplementedError() - - def finish(self): - """ synchronize all active streams """ - for i, stream in enumerate(self.streams): - stream.synchronize() - - def batched_run(self, data, batch_size=10, **kwargs): - """ Run your data in batches (avoids memory problems) """ - nsubmit = 0 - results = [] - while nsubmit < len(data): - batch = [] - while len(batch) < batch_size and nsubmit < len(data): - batch.append(data[nsubmit]) - nsubmit += 1 - - res = self.run(batch, **kwargs) - self.finish() - results.extend(res) - - return results +__all__ = ['GPUAsyncProcess', 'ensure_context'] diff --git a/cuvarbase/cufinufft_backend.py b/cuvarbase/cufinufft_backend.py new file mode 100644 index 00000000..c40ebcbe --- /dev/null +++ b/cuvarbase/cufinufft_backend.py @@ -0,0 +1,227 @@ +""" +cuFINUFFT backend for the NFFT in the Lomb-Scargle periodogram. + +Optional cross-check backend (``use_cufinufft=True``) replacing the +custom Gaussian-spreading NFFT with cuFINUFFT's type-1 (nonuniform to +uniform) transform. + +.. note:: + + The custom NFFT kernel remains the default and, in cuvarbase's + benchmarks (Feb 2026, RTX A5000), was faster end-to-end: the + cuFINUFFT path ran at 0.63-0.84x the custom kernel's speed because + plan creation dominated each call. Plans are now cached (LRU, + keyed on problem shape) to amortize that cost; treat this backend + as a numerical cross-check unless you benchmark it on your own + workload. + +The key integration point is ``cufinufft_nfft_adjoint()``, which is a +drop-in replacement for ``cunfft.nfft_adjoint_async()`` in the +Lomb-Scargle pipeline. + +Requires: pip install cufinufft>=2.2 +""" +import threading +from collections import OrderedDict + +import numpy as np + +try: + import cufinufft + HAS_CUFINUFFT = True +except ImportError: + HAS_CUFINUFFT = False + +import pycuda.gpuarray as gpuarray + +from .base import ensure_context + + +__all__ = [ + 'HAS_CUFINUFFT', + 'check_cufinufft', + 'free_plan_cache', + 'cufinufft_nfft_adjoint', +] + + +# LRU cache of cufinufft Plans keyed on (nf_total, eps, n_pts, +# gpu_method, dtype). Plan creation (cuFFT plan + GPU workspace allocation) +# dominated the per-call cost of this backend; reuse amortizes it. +# Cached plans hold GPU memory: the cache is small and evicted plans +# free their resources on garbage collection; call free_plan_cache() +# to drop them eagerly (e.g. before tearing down the CUDA context). +_PLAN_CACHE_MAX_SIZE = 8 +_plan_cache = OrderedDict() +_plan_cache_lock = threading.Lock() + + +def check_cufinufft(): + """Raise ImportError if cufinufft is not available.""" + if not HAS_CUFINUFFT: + raise ImportError( + "cufinufft is required for the cuFINUFFT LS backend. " + "Install with: pip install cufinufft>=2.2" + ) + + +def _get_plan(nf_total, eps, n_pts, gpu_method=1, dtype='complex64'): + """Return a cached cufinufft Plan for this problem shape and + precision (``dtype``: 'complex64' or 'complex128').""" + key = (int(nf_total), float(eps), int(n_pts), int(gpu_method), + str(dtype)) + with _plan_cache_lock: + if key in _plan_cache: + _plan_cache.move_to_end(key) + return _plan_cache[key] + + plan = cufinufft.Plan( + nufft_type=1, + n_modes=(int(nf_total),), + n_trans=1, + eps=eps, + dtype=str(dtype), + gpu_method=gpu_method, + ) + + with _plan_cache_lock: + _plan_cache[key] = plan + _plan_cache.move_to_end(key) + while len(_plan_cache) > _PLAN_CACHE_MAX_SIZE: + _plan_cache.popitem(last=False) + + return plan + + +def free_plan_cache(): + """Drop all cached cufinufft plans, releasing their GPU resources + (via the plans' finalizers once unreferenced).""" + with _plan_cache_lock: + _plan_cache.clear() + + +def cufinufft_nfft_adjoint(memory, minimum_frequency=0.0, + samples_per_peak=1.0, eps=None, + gpu_method=1, + transfer_to_device=True, + transfer_to_host=True, **kwargs): + """ + Compute NFFT adjoint (type-1) using cufinufft. + + Drop-in replacement for ``cunfft.nfft_adjoint_async()``. Uses the same + ``NFFTMemory`` object and produces output in the same ``ghat_g``/``ghat_c`` + arrays with the same indexing convention. + + Output convention + ----------------- + After this function, ``memory.ghat_g[k]`` contains the Fourier coefficient + at mode ``k0 + k``, where ``k0 = round(minimum_frequency / df)`` and + ``df = 1 / (samples_per_peak * baseline)``. This matches the output of + the custom NFFT pipeline's normalize kernel. + + Time scaling + ------------ + cufinufft type-1 computes: ``F[m] = sum_j c_j * exp(i * m * x_j)`` + with ``x_j`` in ``[-pi, pi]`` and output modes ``m = -N/2, ..., N/2-1``. + + To match our frequency grid, we scale times: + ``x = 2*pi * (t - tmin) / (spp * dt) - pi`` + + This makes mode m correspond to frequency ``m * df``. + + Parameters + ---------- + memory : NFFTMemory + Memory object with t_g, y_g, ghat_g arrays and metadata (tmin, tmax, + n0, nf). The ghat_g array must be pre-allocated with size >= nf. + minimum_frequency : float, optional (default: 0) + First frequency f0 = k0 * df. + samples_per_peak : float, optional (default: 1) + Oversampling factor. + eps : float, optional + Requested precision for cufinufft. Default: 1e-6 for a float32 + memory, 1e-12 for a double one (``memory.use_double`` / + ``memory.real_type == np.float64``; the transform then runs in + complex128 -- before 1.0 the backend was complex64 only and + ``use_double=True`` raised ``TypeError``). + gpu_method : int, optional (default: 1) + cufinufft spreading method (1 = shared-memory subproblem, + 2 = global-memory; see the cufinufft documentation). + transfer_to_device : bool, optional (default: True) + Transfer input data to GPU before computation. + transfer_to_host : bool, optional (default: True) + Transfer result to CPU after computation. + + Returns + ------- + ghat_c : ndarray, complex + The NFFT result on CPU (only if transfer_to_host=True). + """ + check_cufinufft() + + # Creating cufinufft Plans and touching GPU arrays needs an active + # CUDA context (lazily created; idempotent after first call). + ensure_context() + + if transfer_to_device: + memory.transfer_data_to_gpu() + + nf = memory.nf + tmin = float(memory.tmin) + tmax = float(memory.tmax) + dt = tmax - tmin + spp = float(samples_per_peak) + + # Frequency spacing and starting mode + df = 1.0 / (spp * dt) + k0 = max(0, int(round(float(minimum_frequency) / df))) + + # Maximum mode needed: k0 + nf - 1 + max_mode = k0 + nf - 1 + + # cufinufft with default modeord=0 outputs modes -N/2 .. N/2-1 + # For mode M to be available, need N/2 - 1 >= M, so N >= 2*(M+1) + nf_total = 2 * (max_mode + 1) + + # precision follows the memory (float32 -> complex64, float64 -> + # complex128); cufinufft requires x, c and f to share it + real_type = np.dtype(getattr(memory, 'real_type', np.float32)) + use_double = real_type == np.dtype(np.float64) + complex_type = np.complex128 if use_double else np.complex64 + dtype_name = 'complex128' if use_double else 'complex64' + if eps is None: + eps = 1e-12 if use_double else 1e-6 + + # Scale times to [-pi, pi] + # x = 2*pi * (t - tmin) / (spp * dt) - pi + # = scale * t + shift + scale = real_type.type(2.0 * np.pi / (spp * dt)) + shift = real_type.type(-scale * tmin - np.pi) + + x_cu = memory.t_g * scale + shift + + # strengths in the matching complex precision + c = memory.y_g.astype(complex_type) + + # Output buffer for full transform + f_out = gpuarray.zeros(nf_total, dtype=complex_type) + + # Execute with a cached plan (creation dominates the per-call + # cost); setpts re-bins the points for this call's data + plan = _get_plan(nf_total, eps, len(x_cu), gpu_method=gpu_method, + dtype=dtype_name) + plan.setpts(x_cu) + plan.execute(c, f_out) + + # Extract modes k0 .. k0+nf-1 + # In default ordering, mode m is at index m + N/2 + offset = nf_total // 2 + k0 + + # Write into memory.ghat_g with same indexing as custom NFFT: + # ghat_g[k] = Fourier coefficient at mode k0 + k + memory.ghat_g[:nf] = f_out[offset:offset + nf] + + if transfer_to_host: + memory.transfer_nfft_to_cpu() + + return memory.ghat_c diff --git a/cuvarbase/cunfft.py b/cuvarbase/cunfft.py old mode 100755 new mode 100644 index b9f32904..ada7f753 --- a/cuvarbase/cunfft.py +++ b/cuvarbase/cunfft.py @@ -1,163 +1,99 @@ -#!/usr/bin/env python -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +""" +NFFT (Non-equispaced Fast Fourier Transform) implementation. -from builtins import object - -import sys -import resource +This module provides GPU-accelerated NFFT functionality for periodogram computation. +""" import numpy as np import pycuda.driver as cuda -import pycuda.gpuarray as gpuarray from pycuda.compiler import SourceModule -# import pycuda.autoinit - -import skcuda.fft as cufft - -from .core import GPUAsyncProcess -from .utils import find_kernel, _module_reader - - -class NFFTMemory(object): - def __init__(self, sigma, stream, m, use_double=False, - precomp_psi=True, **kwargs): - - self.sigma = sigma - self.stream = stream - self.m = m - self.use_double = use_double - self.precomp_psi = precomp_psi - - # set datatypes - self.real_type = np.float32 if not self.use_double \ - else np.float64 - self.complex_type = np.complex64 if not self.use_double \ - else np.complex128 - - self.other_settings = {} - self.other_settings.update(kwargs) - - self.t = kwargs.get('t', None) - self.y = kwargs.get('y', None) - self.f0 = kwargs.get('f0', 0.) - self.n0 = kwargs.get('n0', None) - self.nf = kwargs.get('nf', None) - self.t_g = kwargs.get('t_g', None) - self.y_g = kwargs.get('y_g', None) - self.ghat_g = kwargs.get('ghat_g', None) - self.ghat_c = kwargs.get('ghat_c', None) - self.q1 = kwargs.get('q1', None) - self.q2 = kwargs.get('q2', None) - self.q3 = kwargs.get('q3', None) - self.cu_plan = kwargs.get('cu_plan', None) - - D = (2 * self.sigma - 1) * np.pi - self.b = float(2 * self.sigma * self.m) / D - - def allocate_data(self, **kwargs): - self.n0 = kwargs.get('n0', self.n0) - self.nf = kwargs.get('nf', self.nf) - - assert(self.n0 is not None) - assert(self.nf is not None) - - self.t_g = gpuarray.zeros(self.n0, dtype=self.real_type) - self.y_g = gpuarray.zeros(self.n0, dtype=self.real_type) - - return self - - def allocate_precomp_psi(self, **kwargs): - self.n0 = kwargs.get('n0', self.n0) - - assert(self.n0 is not None) - - self.q1 = gpuarray.zeros(self.n0, dtype=self.real_type) - self.q2 = gpuarray.zeros(self.n0, dtype=self.real_type) - self.q3 = gpuarray.zeros(2 * self.m + 1, dtype=self.real_type) - - return self - - def allocate_grid(self, **kwargs): - self.nf = kwargs.get('nf', self.nf) - - assert(self.nf is not None) - - self.n = int(self.sigma * self.nf) - self.ghat_g = gpuarray.zeros(self.n, - dtype=self.complex_type) - self.cu_plan = cufft.Plan(self.n, self.complex_type, self.complex_type, - stream=self.stream) - return self - - def allocate_pinned_cpu(self, **kwargs): - self.nf = kwargs.get('nf', self.nf) - - assert(self.nf is not None) - self.ghat_c = cuda.aligned_zeros(shape=(self.nf,), - dtype=self.complex_type, - alignment=resource.getpagesize()) - - return self - - def is_ready(self): - assert(self.n0 == len(self.t_g)) - assert(self.n0 == len(self.y_g)) - assert(self.n == len(self.ghat_g)) - - if self.ghat_c is not None: - assert(self.nf == len(self.ghat_c)) - if self.precomp_psi: - assert(self.n0 == len(self.q1)) - assert(self.n0 == len(self.q2)) - assert(2 * self.m + 1 == len(self.q3)) +from . import _cufft as cufft - def allocate(self, **kwargs): - self.n0 = kwargs.get('n0', self.n0) - self.nf = kwargs.get('nf', self.nf) +from .base import GPUAsyncProcess +from .utils import find_kernel, _module_reader, check_lightcurve +from .memory import NFFTMemory - assert(self.n0 is not None) - assert(self.nf is not None) - self.n = int(self.sigma * self.nf) - self.allocate_data(**kwargs) - self.allocate_grid(**kwargs) - self.allocate_pinned_cpu(**kwargs) - if self.precomp_psi: - self.allocate_precomp_psi(**kwargs) +__all__ = [ + 'nfft_adjoint_async', + 'NFFTAsyncProcess', +] - return self - def transfer_data_to_gpu(self, **kwargs): - t = kwargs.get('t', self.t) - y = kwargs.get('y', self.y) +def _first_mode(minimum_frequency, samples_per_peak, tmin, tmax): + """The integer first mode ``k0 = round(f0 * spp * (tmax - tmin))`` + of an adjoint NFFT starting at ``minimum_frequency``, computed in + float64 on the host. - assert(t is not None) - assert(y is not None) - - self.t_g.set_async(t, stream=self.stream) - self.y_g.set_async(y, stream=self.stream) - - def transfer_nfft_to_cpu(self, **kwargs): - cuda.memcpy_dtoh_async(self.ghat_c, self.ghat_g.ptr, - stream=self.stream) - - def fromdata(self, t, y, allocate=True, **kwargs): - self.tmin = min(t) - self.tmax = max(t) - - self.t = np.asarray(t).astype(self.real_type) - self.y = np.asarray(y).astype(self.real_type) - - self.n0 = kwargs.get('n0', len(t)) - self.nf = kwargs.get('nf', self.nf) - - if self.nf is not None and allocate: - self.allocate(**kwargs) - - return self + The periodic grid only has integer modes, so the kernels + (``nfft_shift``/``normalize``) need ``k0`` as an integer. They used + to re-derive it from the float32 product of their ``f0``, ``spp`` + and ``xf - x0`` arguments, whose rounding reaches half a mode from + ``k0 ~ 2e6`` upward (about 0.5% of grids in [2e6, 3e6), ~18% in + [4e6, 5e6)); the two kernels could even round to *different* + integers (Sep-2026 readiness review, idx 24). The float64 product + here is exact to well beyond ``1e9``. + """ + k0 = np.rint(float(minimum_frequency) * float(samples_per_peak) + * (float(tmax) - float(tmin))) + if not np.isfinite(k0) or abs(k0) >= 2 ** 31: + raise ValueError( + "nfft_adjoint_async: the first mode " + "minimum_frequency * samples_per_peak * (tmax - tmin) = %r " + "does not fit the kernels' int32 mode index" % (k0,)) + return int(k0) + + +def _reject_precision_override(process, kwargs, name): + """Return ``kwargs`` without a ``use_double`` key, raising + ``ValueError`` when that key disagrees with ``process.use_double``. + + Precision is a property of the process object: the kernels are + compiled and prepared once, at construction, in ``process.real_type``. + The memory classes take ``use_double`` too, so a per-call + ``use_double=True`` on a single-precision process used to build + float64/complex128 device buffers that the float32 kernels then read + as float32 -- a wrong periodogram with a plausible float64 dtype + (Sep-2026 readiness review). Every entry point that forwards its + keywords to a memory constructor runs this first, before any device + work. A value equal to the process precision is accepted (and + dropped, so it can neither reach a constructor that also receives + the process value positionally nor perturb a memory cache key). + """ + if 'use_double' not in kwargs: + return kwargs + kwargs = dict(kwargs) + requested = bool(kwargs.pop('use_double')) + have = bool(process.use_double) + if requested != have: + raise ValueError( + "%s: use_double=%r does not match the precision this process " + "was built with (use_double=%r). The kernels are compiled at " + "construction, so construct %s(use_double=%r) instead." + % (name, requested, have, type(process).__name__, requested)) + return kwargs + + +def _check_memory_precision(process, memories, name): + """Raise ``ValueError`` if any memory object in ``memories`` was + allocated at a precision other than ``process.use_double`` (see + :func:`_reject_precision_override`: the prepared kernels read the + buffers in the process precision whatever they were allocated as). + Objects without a ``use_double`` attribute are not checked.""" + have = bool(process.use_double) + for i, mem in enumerate(memories): + mem_double = getattr(mem, 'use_double', None) + if mem_double is None: + continue + if bool(mem_double) != have: + raise ValueError( + "%s: memory %d was allocated with use_double=%r but this " + "process runs its kernels with use_double=%r. Allocate the " + "memory from this process (allocate/preallocate), or " + "construct %s(use_double=%r)." + % (name, i, bool(mem_double), have, + type(process).__name__, bool(mem_double))) def nfft_adjoint_async(memory, functions, @@ -175,12 +111,18 @@ def nfft_adjoint_async(memory, functions, ---------- memory: ``NFFTMemory`` Allocated memory, must have data already set (see, e.g., - ``NFFTAsyncProcess.allocate()``) + ``NFFTAsyncProcess.allocate()``, which validates the light + curve with :func:`cuvarbase.utils.check_lightcurve`; this + low-level entry point cannot re-check data it does not see) functions: tuple, length 5 Tuple of compiled functions from `SourceModule`. Must be prepared with their appropriate dtype. minimum_frequency: float, optional (default: 0) - First frequency of transform + First frequency of transform. The transform starts at the + integer mode ``k0 = round(minimum_frequency * samples_per_peak + * (tmax - tmin))`` (rounded in float64 on the host; see + :func:`_first_mode`), so a fractional first mode gives the + nearest integer mode's transform. block_size: int, optional Number of CUDA threads per block just_return_gridded_data: bool, optional @@ -193,10 +135,23 @@ def nfft_adjoint_async(memory, functions, transfer_to_device: bool, optional, (default: True) If the data is already on the gpu, set as False transfer_to_host: bool, optional, (default: True) - If False, will not transfer the resulting nfft to CPU memory + If False, will not transfer the resulting nfft to CPU memory. + If True, the stream is synchronized before returning, so the + returned host buffer is complete (before Sep 2026 the pinned + buffer was returned while the device-to-host copy was still in + flight: immediate reads were stale on reused memory). precomp_psi: bool, optional, (default: True) - Only relevant if ``fast`` is True. Will precompute values for the - fast gridding procedure. + Only relevant if ``fast_grid`` is True. When True *and* the + memory was built with ``precomp_psi=True`` (so it carries the + psi tables ``q1``/``q2``/``q3``), the tables are filled by + ``precompute_psi`` and the data is spread with + ``fast_gaussian_grid``; otherwise (``False`` here, or a memory + without tables) the inline-psi ``slow_gaussian_grid`` kernel is + used, which needs no tables. A memory flagged + ``precomp_psi=True`` whose tables are not allocated raises + ``ValueError``. Before 1.0 the ``fast_grid`` branch + dereferenced the tables unconditionally, so ``precomp_psi=False`` + raised ``AttributeError``. samples_per_peak: float, optional (default: 1) Frequency spacing is reduced by this factor, but number of frequencies is kept the same @@ -204,9 +159,34 @@ def nfft_adjoint_async(memory, functions, Returns ------- ghat_cpu: ``np.array`` - The resulting NFFT + The resulting NFFT (``memory.ghat_c``, the memory's pinned host + buffer -- copy it out before reusing the memory) + + Notes + ----- + The gridding kernels accumulate with atomic adds, so ``memory.ghat_g`` + is zeroed here on every call; a memory object can be reused across + calls (before Sep 2026 a second call on the same memory summed onto + the previous grid). With ``transfer_to_host=False`` nothing is + synchronized: call ``memory.stream.synchronize()`` (or + ``NFFTAsyncProcess.finish()``) before reading ``ghat_g``. """ + # The light curve behind ``memory`` was validated where it was + # loaded (NFFTAsyncProcess.allocate / LombScargleMemory.setdata); + # only the transform's own scalars can be checked here. A + # non-finite minimum_frequency poisons every mode's phase factor + # and a non-positive samples_per_peak collapses the grid. + # ``minimum_frequency`` may be negative: the adjoint transform is + # defined over modes -nf/2 .. nf/2 and the tests exercise + # ``minimum_frequency = -nf // 2``. + if not np.isfinite(minimum_frequency): + raise ValueError("nfft_adjoint_async: minimum_frequency must be " + "finite; got %r" % (minimum_frequency,)) + if not (np.isfinite(samples_per_peak) and samples_per_peak > 0): + raise ValueError("nfft_adjoint_async: samples_per_peak must be " + "finite and > 0; got %r" % (samples_per_peak,)) + precompute_psi, fast_gaussian_grid, slow_gaussian_grid, \ nfft_shift, normalize = functions @@ -219,25 +199,50 @@ def nfft_adjoint_async(memory, functions, def grid_size(nthreads): return int(np.ceil(float(nthreads) / block_size)) - minimum_frequency = memory.real_type(minimum_frequency) + # integer first mode, exact on the host (the kernels used to + # recompute it from float32 arguments; see _first_mode) + k0 = _first_mode(minimum_frequency, samples_per_peak, + memory.tmin, memory.tmax) # transfer data -> gpu if transfer_to_device: memory.transfer_data_to_gpu() - # smooth data onto uniform grid - if fast_grid: - if memory.precomp_psi: - grid = (grid_size(memory.n0 + 2 * memory.m + 1), 1) - args = (grid, block, stream) - args += (memory.t_g.ptr,) - args += (memory.q1.ptr, memory.q2.ptr, memory.q3.ptr) - args += (np.int32(memory.n0), np.int32(memory.n), - np.int32(memory.m), memory.real_type(memory.b)) - args += (memory.real_type(memory.tmin), - memory.real_type(memory.tmax), - memory.real_type(samples_per_peak)) - precompute_psi.prepared_async_call(*args) + # The gridding kernels accumulate into ghat_g with atomic adds: zero + # it on every call so reused memory does not sum onto the previous + # transform (only fresh gpuarray.zeros buffers were ever clean). + if use_grid is None: + memory.ghat_g.fill(memory.complex_type(0), stream=stream) + + # smooth data onto uniform grid. + # ``fast_gaussian_grid`` reads the psi tables q1/q2/q3, which + # NFFTMemory allocates only when it was built with precomp_psi=True. + # Before 1.0 this branch dispatched on ``fast_grid`` alone and then + # dereferenced ``memory.q1.ptr`` unconditionally, so precomp_psi=False + # (through NFFTAsyncProcess.run/allocate or LombScargleAsyncProcess) + # raised AttributeError on every release (Sep-2026 readiness audit, + # Phase 2 verification carry-over). The inline-psi kernel + # ``slow_gaussian_grid`` needs no tables, so a call without them is + # routed there; the default (tables allocated and requested) is + # unchanged. + use_precomp_psi = bool(fast_grid) and bool(precomp_psi) \ + and bool(memory.precomp_psi) + if use_precomp_psi: + if memory.q1 is None or memory.q2 is None or memory.q3 is None: + raise ValueError( + "nfft_adjoint_async: memory.precomp_psi is True but the " + "psi tables q1/q2/q3 are not allocated; call " + "memory.allocate_precomp_psi() (or memory.allocate())") + grid = (grid_size(memory.n0 + 2 * memory.m + 1), 1) + args = (grid, block, stream) + args += (memory.t_g.ptr,) + args += (memory.q1.ptr, memory.q2.ptr, memory.q3.ptr) + args += (np.int32(memory.n0), np.int32(memory.n), + np.int32(memory.m), memory.real_type(memory.b)) + args += (memory.real_type(memory.tmin), + memory.real_type(memory.tmax), + memory.real_type(samples_per_peak)) + precompute_psi.prepared_async_call(*args) grid = (grid_size(memory.n0), 1) args = (grid, block, stream) @@ -272,8 +277,8 @@ def grid_size(nthreads): if use_grid is not None: memory.ghat_g.set(use_grid) - # for a non-zero minimum frequency, do a shift - if abs(minimum_frequency) > 1E-9: + # for a non-zero first mode, do a shift (k0 = 0 is the identity) + if k0 != 0: grid = (grid_size(memory.n), 1) args = (grid, block, stream) args += (memory.ghat_g.ptr, memory.ghat_g.ptr) @@ -281,7 +286,7 @@ def grid_size(nthreads): args += (memory.real_type(memory.tmin), memory.real_type(memory.tmax), memory.real_type(samples_per_peak), - memory.real_type(minimum_frequency)) + np.int32(k0)) nfft_shift.prepared_async_call(*args) # Run IFFT on grid @@ -298,12 +303,17 @@ def grid_size(nthreads): args += (memory.real_type(memory.tmin), memory.real_type(memory.tmax), memory.real_type(samples_per_peak), - memory.real_type(minimum_frequency)) + np.int32(k0)) normalize.prepared_async_call(*args) - # Transfer result! + # Transfer result and wait for it: the caller gets the pinned host + # buffer, which is only valid once the async D2H copy has landed. if transfer_to_host: memory.transfer_nfft_to_cpu() + if stream is not None: + stream.synchronize() + else: + cuda.Context.synchronize() return memory.ghat_c @@ -314,15 +324,20 @@ class NFFTAsyncProcess(GPUAsyncProcess): Parameters ---------- - sigma: float, optional (default: 2) - Size of NFFT grid will be NFFT_SIZE * sigma + sigma: float, optional (default: 4) + Size of NFFT grid will be NFFT_SIZE * sigma. The transform + returns the one-sided modes ``k = 0..nf-1`` on a grid of + ``sigma * nf`` points, so the effective oversampling at the top + of the band is ``sigma / 2``: ``sigma >= 4`` is required for + full-band accuracy in this layout (with ``sigma = 2`` the modes + ``k >= nf/2`` are aliased at O(1), in double precision too). m: int, optional (default: 8) - Maximum radius for grid contributions (by default, - this value will automatically be set based on a specified - error tolerance) - autoset_m: bool, optional (default: True) + Maximum radius for grid contributions, used when + ``autoset_m`` is False. + autoset_m: bool, optional (default: False) Automatically set the ``m`` parameter based on the - error tolerance given by the ``m_tol`` parameter + error tolerance given by the ``tol`` parameter (see + :meth:`estimate_m`) tol: float, optional (default: 1E-8) Error tolerance for the NFFT (used to auto set ``m``) block_size: int, optional (default: 256) @@ -385,14 +400,20 @@ def m_from_C(self, C, sigma): D = (np.pi * (1. - 1. / (2. * sigma - 1.))) return int(np.ceil(-np.log(0.25 * C) / D)) - def estimate_m(self, N): + def estimate_m(self, N=None, y=None): """ - Estimate ``m`` based on an error tolerance of ``self.tol``. + Choose the filter radius ``m`` to meet the error tolerance + ``self.m_tol``. Parameters ---------- - N: int - size of NFFT + N: int, optional + Size of the NFFT. Required when ``y`` is not given + (heuristic fallback below). + y: array_like, optional + The input coefficients of the adjoint NFFT (the + observations). When given, ``m`` is chosen from the + rigorous L1-norm error bound below. Returns ------- @@ -401,24 +422,78 @@ def estimate_m(self, N): Notes ----- - Pulled from _. - + The approximation error of the (adjoint) NFFT with a Gaussian + window satisfies (NFFT3 guide, p. 11, eq. (5.9); Steidl 1998) + + .. math:: + + \\max_k |E_k| \\le 4 e^{-m \\pi (1 - 1/(2\\sigma - 1))} + \\, \\|y\\|_1 + + so given the data ``y``, ``m`` is set to the smallest integer + with :math:`4 e^{-m \\pi (1 - 1/(2\\sigma-1))} \\|y\\|_1 \\le` + ``tol``. + + In double precision (``use_double=True``) the realized error + tracks this bound down to the ``~1e-10`` absolute level + (A5000-validated, Jul 2026: max error is *below* the bound for + every ``m <= 14`` on the reference configuration, bottoming out + near ``1e-11`` from FFT roundoff amplified by the Gaussian + deconvolution). That figure assumes the gridding kernel rounds + the grid coordinate in double: while ``cunfft.cu`` used + ``floorf()`` on that coordinate (the case before the Sep-2026 + NFFT fixes) the double-precision error floor was ~1e-2 for + times far from the origin, and the ``~1e-10`` level was reached + only when the coordinates were exactly representable in + float32. An earlier revision of this docstring described + a ``~1e-3``, m-independent error floor as inherent; that floor + was a kernel defect -- a float32 ``PI`` literal in the phase + factors of ``nfft_shift``/``normalize`` (error + ``~2.8e-8 * 2*pi*|k0|* ||y||_1``, amplified with ``m`` by the + deconvolution) -- fixed in the same pass. In single precision a + genuine floor of roughly ``1e-3`` absolute (``1e-5`` relative) + remains: it comes from float32 trig on large un-reduced phase + arguments and float32 grid/FFT roundoff, and very large ``m`` + *increases* it (the wider Gaussian amplifies grid noise). + Requesting ``tol`` below that floor at single precision will not + be honored -- use ``use_double=True`` for tolerances below + ``~1e-2``. + + When ``y`` is unavailable, this falls back to the historical + heuristic (from `jakevdp/nfft + `_) that substitutes ``N`` + for :math:`\\|y\\|_1`, which guarantees the tolerance only + when ``max|y| <= 1``. + """ + if y is not None: + l1 = float(np.sum(np.absolute(y))) + if l1 <= 0: + # zero input: the transform is exactly zero for any m + return 1 + return max(1, self.m_from_C(self.m_tol / l1, self.sigma)) + + if N is None: + raise ValueError("estimate_m requires N when y is not given") + # Clamp like the y-path above: pathological tolerances + # (m_tol > 4N) would give m <= 0, i.e. a negative Gaussian + # shape parameter b and garbage gridding. + return max(1, self.m_from_C(self.m_tol / N, self.sigma)) + + def get_m(self, N=None, y=None): """ - - # TODO: this should be computed in terms of the L1-norm of the true - # Fourier coefficients... see p. 11 of - # https://www-user.tu-chemnitz.de/~potts/nfft/guide/nfft3.pdf - # Need to think about how to estimate the value of m more accurately - return self.m_from_C(self.m_tol / N, self.sigma) - - def get_m(self, N=None): - """ Returns the ``m`` value for ``N`` frequencies. Parameters ---------- N: int - Number of frequencies, only needed if ``autoset_m`` is ``False``. + Number of frequencies, only needed if ``autoset_m`` is ``True`` + and ``y`` is not given. + y: array_like, optional + Adjoint-NFFT input coefficients; when given (and + ``autoset_m`` is ``True``), ``m`` comes from the rigorous + L1-norm bound in :func:`estimate_m`. Callers that size + shared buffers before seeing the data (e.g. the + Lomb-Scargle memory layouts) use the ``N`` fallback. Returns ------- @@ -426,7 +501,7 @@ def get_m(self, N=None): The filter radius (in grid points) """ if self.autoset_m: - return self.estimate_m(N) + return self.estimate_m(N=N, y=y) else: return self.m @@ -450,12 +525,14 @@ def _compile_and_prepare_functions(self, **kwargs): self.real_type, self.real_type, self.real_type], + # the last argument of normalize/nfft_shift is the integer + # first mode k0 (host-computed; see _first_mode) normalize=[np.intp, np.intp, np.int32, np.int32, np.int32, self.real_type, self.real_type, self.real_type, - self.real_type, self.real_type], + self.real_type, np.int32], nfft_shift=[np.intp, np.intp, np.int32, np.int32, self.real_type, - self.real_type, self.real_type, self.real_type] + self.real_type, self.real_type, np.int32] ) for function, dtype in self.dtypes.items(): @@ -488,12 +565,27 @@ def allocate(self, data, **kwargs): # Purge any previously allocated memory allocated_memory = [] + # Precision is fixed at construction (the kernels are compiled + # in self.real_type); a per-call use_double that disagrees + # raises here, before any device work, and an equal one is + # dropped (NFFTMemory below also gets it positionally). + kwargs = _reject_precision_override(self, kwargs, + 'NFFTAsyncProcess.allocate') + + for i, d in enumerate(data): + if len(d) != 3: + raise ValueError( + "NFFTAsyncProcess.allocate: dataset %d must be a " + "(t, y, nf) tuple; got %d elements" % (i, len(d))) + check_lightcurve(d[0], d[1], min_n=2, + name='NFFTAsyncProcess.allocate dataset %d' % i) + if len(data) > len(self.streams): self._create_streams(len(data) - len(self.streams)) for i, (t, y, nf) in enumerate(data): - m = self.get_m(nf) + m = self.get_m(nf, y=y) mem = NFFTMemory(self.sigma, self.streams[i], m, use_double=self.use_double, **kwargs) @@ -515,15 +607,59 @@ def run(self, data, memory=None, **kwargs): * ``t``: observation times * ``y``: observations * ``nf``: int, size of NFFT - memory: + memory: list of ``NFFTMemory``, optional + Preallocated memory (from :meth:`allocate`), one per + dataset; ``data`` is ignored when given. The memory may be + reused across calls: the grid is zeroed on every transform. + It must have been allocated at the process precision + (``ValueError`` otherwise). **kwargs + Passed to :func:`nfft_adjoint_async` (``transfer_to_host``, + ``transfer_to_device``, ``fast_grid``, ...). ``use_double`` + is **not** a per-call option: the kernels are compiled at + construction in the process precision, so a ``use_double`` + that differs from ``NFFTAsyncProcess(use_double=...)`` + raises ``ValueError`` before any device work (an equal value + is accepted and ignored). Before 1.0 the keyword reached the + memory constructor and, with a user-supplied ``memory``, + silently paired float64 buffers with float32 kernels. Returns ------- powers: list of np.ndarrays - List of adjoint NFFTs + List of adjoint NFFTs. Each is the memory's pinned host + buffer ``ghat_c``; with the default ``transfer_to_host=True`` + the stream has been synchronized and the buffer is complete + on return (copy it before reusing the memory). With + ``transfer_to_host=False`` call :meth:`finish` (or + ``memory.stream.synchronize()``) before reading ``ghat_g``. """ + # Validate before any device work (kernel compile included). + # ``data`` is ignored when ``memory`` is supplied, and the + # light curve behind a memory object was validated when it was + # allocated. min_n = 2: NFFTMemory rescales the times to + # [-1/2, 1/2) by the baseline max(t) - min(t), which is zero + # for a single sample -- the transform came back all-NaN. + kwargs = _reject_precision_override(self, kwargs, + 'NFFTAsyncProcess.run') + if memory is not None: + _check_memory_precision(self, memory, 'NFFTAsyncProcess.run') + if memory is None: + for i, d in enumerate(data): + if len(d) != 3: + raise ValueError( + "NFFTAsyncProcess.run: dataset %d must be a " + "(t, y, nf) tuple; got %d elements" % (i, len(d))) + check_lightcurve(d[0], d[1], min_n=2, + name='NFFTAsyncProcess.run dataset %d' % i) + nf = d[2] + if not (np.isscalar(nf) and np.isfinite(nf) + and nf > 0 and int(nf) == nf): + raise ValueError( + "NFFTAsyncProcess.run: dataset %d: nf must be a " + "positive integer; got %r" % (i, nf)) + if not hasattr(self, 'prepared_functions') or \ not all([func in self.prepared_functions for func in self.function_names]): diff --git a/cuvarbase/kernels/bls.cu b/cuvarbase/kernels/bls.cu index eb6b8d20..dabe927c 100644 --- a/cuvarbase/kernels/bls.cu +++ b/cuvarbase/kernels/bls.cu @@ -1,183 +1,26 @@ #include #define RESTRICT __restrict__ #define CONSTANT const -#define MIN_W 1E-3 //{CPP_DEFS} -__device__ unsigned int get_id(){ - return blockIdx.x * blockDim.x + threadIdx.x; -} - -__device__ int mod(int a, int b){ - int r = a % b; - return (r < 0) ? r + b : r; -} - -__device__ float mod1(float a){ - return a - floorf(a); -} - -__device__ float bls_value(float ybar, float w, unsigned int ignore_negative_delta_sols){ - // if ignore negative delta sols is turned on, that means only solutions where - // the mean amplitude within the transit is _lower_ than the mean amplitude of the source - // are considered: it will ignore "inverted dips" - float bls = (w > 1e-10 && w < 1.f - 1e-10) ? ybar * ybar / (w * (1.f - w)) : 0.f; - return ((ignore_negative_delta_sols == 1) & (ybar > 0)) ? 0.f : bls; -} - -__global__ void binned_bls_bst(float *yw, float *w, float *bls, unsigned int n, unsigned int ignore_negative_delta_sols){ - unsigned int i = get_id(); - - if (i < n){ - bls[i] = bls_value(yw[i], w[i], ignore_negative_delta_sols); - } -} - - -__device__ unsigned int dnbins(unsigned int nbins, float dlogq){ - - if (dlogq < 0) - return 1; - - unsigned int n = (unsigned int) floorf(dlogq * nbins); - - return (n == 0) ? 1 : n; -} - -__device__ unsigned int nbins_iter(unsigned int i, unsigned int nb0, float dlogq){ - - - if (i == 0) - return nb0; - - unsigned int nb = nb0; - for(int j = 0; j < i; j++) - nb += dnbins(nb, dlogq); - - return nb; -} - -__device__ unsigned int count_tot_nbins(unsigned int nbins0, unsigned int nbinsf, float dlogq){ - unsigned int ntot = 0; - - for(int i = 0; nbins_iter(i, nbins0, dlogq) <= nbinsf; i++) - ntot += nbins_iter(i, nbins0, dlogq); - return ntot; -} - - - -__global__ void store_best_sols_custom(unsigned int *argmaxes, float *best_phi, - float *best_q, float *q_values, - float *phi_values, unsigned int nq, unsigned int nphi, - unsigned int nfreq, unsigned int freq_offset){ - - unsigned int i = get_id(); - - if (i < nfreq){ - unsigned int imax = argmaxes[i + freq_offset]; - - best_phi[i + freq_offset] = phi_values[imax / nq]; - best_q[i + freq_offset] = q_values[imax % nq]; - } -} - - -__device__ int divrndup(int a, int b){ - return (a % b > 0) ? a/b + 1 : a/b; -} - - - - -__global__ void store_best_sols(unsigned int *argmaxes, float *best_phi, - float *best_q, - unsigned int nbins0, unsigned int nbinsf, - unsigned int noverlap, - float dlogq, unsigned int nfreq, unsigned int freq_offset){ - - unsigned int i = get_id(); - - if (i < nfreq){ - unsigned int imax = argmaxes[i + freq_offset]; - float dphi = 1. / noverlap; - - unsigned int nb = nbins0; - unsigned int bin_offset = 0; - unsigned int i_iter = 0; - while ((bin_offset + nb) * noverlap <= imax){ - bin_offset += nb; - nb = nbins_iter(++i_iter, nbins0, dlogq); - } - - float q = 1. / nb; - int s = (((int) imax) - ((int) (bin_offset * noverlap))) / nb; - int jphi = (((int) imax) - ((int) (bin_offset * noverlap))) % nb; - - float phi = mod1((float) (((double) q) * (((double) jphi) + ((double) s) * ((double) dphi)))); - - best_phi[i + freq_offset] = phi; - best_q[i + freq_offset] = q; - } -} - -// needs ndata * nfreq threads -// noverlap -- number of overlapped bins (noverlap * (1 / q) total bins) -// Note: this thread heavily utilizes global atomic operations, and could -// likely be improved by 1-2 orders of magnitude for large Ndata (10^4) -// if shared memory atomics were utilized. -__global__ void bin_and_phase_fold_bst_multifreq( - float *t, float *yw, float *w, - float *yw_bin, float *w_bin, float *freqs, - unsigned int ndata, unsigned int nfreq, unsigned int nbins0, unsigned int nbinsf, - unsigned int freq_offset, unsigned int noverlap, float dlogq, - unsigned int nbins_tot){ - unsigned int i = get_id(); - - if (i < ndata * nfreq){ - unsigned int i_data = i % ndata; - unsigned int i_freq = i / ndata; - - unsigned int offset = i_freq * nbins_tot * noverlap; - - float W = w[i_data]; - float YW = yw[i_data]; - - // get phase [0, 1) - float phi = mod1(t[i_data] * freqs[i_freq + freq_offset]); - - float dphi = 1.f / noverlap; - unsigned int nbtot = 0; - unsigned int nb, b; - - // iterate through bins (logarithmically spaced) - for(int j = 0; nbins_iter(j, nbins0, dlogq) <= nbinsf; j++){ - nb = nbins_iter(j, nbins0, dlogq); - - // iterate through offsets [ 0, 1./sigma, ..., - // (sigma - 1) / sigma ] - for (int s = 0; s < noverlap; s++){ - b = (unsigned int) mod((int) floorf(nb * phi - s * dphi), nb); - b += offset + s * nb + noverlap * nbtot; - - atomicAdd(&(yw_bin[b]), YW); - atomicAdd(&(w_bin[b]), W); - } - nbtot += nb; - } - } -} - +// Device/global functions shared with bls_optimized.cu live in a single +// source file to prevent the two kernels from drifting apart (see +// bls_common.cuh and test_kernel_drift.py). Only the functions that +// differ on purpose stay below: full_bls_no_sol (this file uses the +// interleaved [yw, w] shared layout and a full tree reduction) versus +// full_bls_no_sol_optimized in bls_optimized.cu, and reduction_max (full +// tree reduction here vs warp-shuffle finish there). +//{INCLUDE bls_common.cuh} __global__ void full_bls_no_sol( - const float* __restrict__ t, - const float* __restrict__ yw, + const float* __restrict__ t, + const float* __restrict__ yw, const float* __restrict__ w, - float* __restrict__ bls, + float* __restrict__ bls, const float* __restrict__ freqs, - const unsigned int * __restrict__ nbins0, - const unsigned int * __restrict__ nbinsf, - unsigned int ndata, + const unsigned int * __restrict__ nbins0, + const unsigned int * __restrict__ nbinsf, + unsigned int ndata, unsigned int nfreq, unsigned int freq_offset, unsigned int hist_size, @@ -217,7 +60,14 @@ __global__ void full_bls_no_sol( nb0 = nbins0[i_freq + freq_offset]; nbf = nbinsf[i_freq + freq_offset]; - max_bin_width = divrndup(nbf, nb0); + // Widest box: floor(nbf / nb0), i.e. the largest m whose + // q = m/nbf still satisfies q <= 1/nb0 (= the discretized + // qmax). This used to be divrndup(nbf, nb0) with a strict + // `m < max_bin_width` loop, which is the same bound whenever + // nb0 does not divide nbf but drops the qmax box itself when + // it does (Sep 2026 audit, id 64: qmin=0.025/qmax=0.1 tested + // only q <= 0.075). + max_bin_width = nbf / nb0; #ifdef USE_LOG_BIN_SPACING tot_nbins = count_tot_nbins(nb0, nbf, dlogq); @@ -249,7 +99,7 @@ __global__ void full_bls_no_sol( // wait for everyone to finish adding data to the histogram __syncthreads(); - + // get max bls for this THREAD #ifdef USE_LOG_BIN_SPACING for (unsigned int n = threadIdx.x; n < tot_nbins; n += blockDim.x){ @@ -260,7 +110,7 @@ __global__ void full_bls_no_sol( bin_offset += nb; nb += dnbins(nb, dlogq); } - + b = (((int) n) - ((int) (bin_offset * noverlap))) % nb; s = (((int) n) - ((int) (bin_offset * noverlap))) / nb; @@ -280,12 +130,12 @@ __global__ void full_bls_no_sol( #else for (unsigned int n = threadIdx.x; n < nbf; n += blockDim.x){ - + thread_yw = 0.f; thread_w = 0.f; unsigned int m0 = 0; - for (unsigned int m = 1; m < max_bin_width; m += dnbins(m, dlogq)){ + for (unsigned int m = 1; m <= max_bin_width; m += dnbins(m, dlogq)){ for (s = m0; s < m; s++){ thread_yw += block_bins[2 * ((n + s) % nbf)]; thread_w += block_bins[2 * ((n + s) % nbf) + 1]; @@ -309,7 +159,7 @@ __global__ void full_bls_no_sol( if(threadIdx.x < k){ bls1 = best_bls[threadIdx.x]; bls2 = best_bls[threadIdx.x + k]; - + best_bls[threadIdx.x] = (bls1 > bls2) ? bls1 : bls2; } __syncthreads(); @@ -325,48 +175,9 @@ __global__ void full_bls_no_sol( } -// needs ndata * nfreq threads -// noverlap -- number of overlapped bins (noverlap * (1 / q) total bins) -__global__ void bin_and_phase_fold_custom( - float *t, float *yw, float *w, - float *yw_bin, float *w_bin, float *freqs, - float *q_values, float *phi_values, - unsigned int nq, unsigned int nphi, unsigned int ndata, - unsigned int nfreq, unsigned int freq_offset){ - unsigned int i = get_id(); - - if (i < ndata * nfreq){ - unsigned int i_data = i % ndata; - unsigned int i_freq = i / ndata; - - unsigned int offset = i_freq * nq * nphi; - - float W = w[i_data]; - float YW = yw[i_data]; - - // get phase [0, 1) - float phi = mod1(t[i_data] * freqs[i_freq + freq_offset]); - - for(int pb = 0; pb < nphi; pb++){ - float dphi = phi - phi_values[pb]; - dphi -= floorf(dphi); - - for(int qb = 0; qb < nq; qb++){ - if (dphi < q_values[qb]){ - atomicAdd(&(yw_bin[pb * nq + qb + offset]), YW); - atomicAdd(&(w_bin[pb * nq + qb + offset]), W); - } - } - } - } -} - - - - -__global__ void reduction_max(float *arr, unsigned int *arr_args, unsigned int nfreq, +__global__ void reduction_max(float *arr, unsigned int *arr_args, unsigned int nfreq, unsigned int nbins, unsigned int stride, - float *block_max, unsigned int *block_arg_max, + float *block_max, unsigned int *block_arg_max, unsigned int offset, unsigned int init){ __shared__ float partial_max[BLOCK_SIZE]; diff --git a/cuvarbase/kernels/bls_batch.cu b/cuvarbase/kernels/bls_batch.cu new file mode 100644 index 00000000..6148d3c4 --- /dev/null +++ b/cuvarbase/kernels/bls_batch.cu @@ -0,0 +1,336 @@ +#include +//{CPP_DEFS} + +// Multi-lightcurve BLS kernel for batch processing. +// +// Grid: (nfreqs, n_lcs) +// blockIdx.x indexes over frequencies +// blockIdx.y indexes over lightcurves +// +// Shared memory layout per block: +// block_bins_yw[hist_size] - binned weighted observations +// block_bins_w[hist_size] - binned weights +// best_bls[blockDim.x] - per-thread BLS maxima for reduction +// +// Data layout: all LC arrays padded to max_ndata and concatenated. +// t_all[lc_idx * max_ndata + i] for i < ndata_per_lc[lc_idx] +// yw_all[lc_idx * max_ndata + i] +// w_all[lc_idx * max_ndata + i] + +__device__ unsigned int batch_get_id(){ + return blockIdx.x * blockDim.x + threadIdx.x; +} + +__device__ float batch_mod1_fast(float a){ + return a - floorf(a); +} + +__device__ int batch_mod(int a, int b){ + int r = a % b; + return (r < 0) ? r + b : r; +} + +__device__ float batch_bls_value(float ybar, float w, unsigned int ignore_neg){ + // Upper w bound must be float32-meaningful (see bls_value in + // bls_common.cuh: the old 1e-10 complement underflowed to `w < 1.f`, + // letting all-weight boxes divide roundoff by roundoff). + float bls = (w > 1e-10f && w < 1.f - 1e-4f) ? ybar * ybar / (w * (1.f - w)) : 0.f; + return ((ignore_neg == 1) & (ybar > 0.f)) ? 0.f : bls; +} + +__device__ unsigned int batch_dnbins(unsigned int nbins, float dlogq){ + if (dlogq < 0.f) + return 1; + unsigned int n = (unsigned int) floorf(dlogq * nbins); + return (n == 0) ? 1 : n; +} + + +// Fused-noverlap batch kernel: same derivation as full_bls_no_sol_fused +// in bls_common.cuh (fine histogram at noverlap-times finer phase +// resolution; every pass's box = contiguous run of fine bins). Host +// routes here only for power-of-two noverlap with dphi == 0, where the +// fine-bin assignment is bit-identical to the multi-pass launches. +// hist_size is the FINE histogram size: noverlap * max(nbinsf). +__global__ void full_bls_batch_fused( + const float* __restrict__ t_all, + const float* __restrict__ yw_all, + const float* __restrict__ w_all, + float* __restrict__ bls_all, + const float* __restrict__ freqs, + const unsigned int* __restrict__ nbins0, + const unsigned int* __restrict__ nbinsf, + const unsigned int* __restrict__ ndata_per_lc, + unsigned int max_ndata, + unsigned int nfreq, + unsigned int freq_offset, + unsigned int hist_size, + unsigned int noverlap, + float dlogq, + float dphi, + unsigned int ignore_negative_delta_sols, + unsigned int n_lcs, + unsigned int bls_stride){ + + extern __shared__ float sh[]; + + float *fine_yw = sh; + float *fine_w = (float *)&sh[hist_size]; + float *best_bls = (float *)&sh[2 * hist_size]; + + __shared__ float f0; + __shared__ int nb0, nbf, max_bin_width, nfine; + __shared__ unsigned int ndata_lc; + + unsigned int lc_idx = blockIdx.y; + if (lc_idx >= n_lcs) + return; + + unsigned int data_offset = lc_idx * max_ndata; + const float *t = t_all + data_offset; + const float *yw = yw_all + data_offset; + const float *w = w_all + data_offset; + + // bls_stride, not nfreq: freq-chunked launches pass nfreq = the + // chunk's frequency count while rows of bls_all stay one full + // grid apart. + float *bls_out = bls_all + lc_idx * bls_stride; + + float phi, bls1, bls2, thread_max_bls, thread_yw, thread_w; + + unsigned int i_freq = blockIdx.x; + while (i_freq < nfreq){ + + thread_max_bls = 0.f; + + if (threadIdx.x == 0){ + f0 = freqs[i_freq + freq_offset]; + nb0 = nbins0[i_freq + freq_offset]; + nbf = nbinsf[i_freq + freq_offset]; + // Widest box: floor(nbf / nb0) -- the largest m whose + // q = m/nbf satisfies q <= 1/nb0 (the discretized qmax). + // Kept identical to the single-LC fast kernels (Sep 2026 + // audit, id 64). + max_bin_width = nbf / nb0; + nfine = nbf * ((int) noverlap); + ndata_lc = ndata_per_lc[lc_idx]; + } + + __syncthreads(); + + for(unsigned int k = threadIdx.x; k < nfine; k += blockDim.x){ + fine_yw[k] = 0.f; + fine_w[k] = 0.f; + } + + __syncthreads(); + + for (unsigned int k = threadIdx.x; k < ndata_lc; k += blockDim.x){ + phi = batch_mod1_fast(t[k] * f0); + float u = ((float) nbf) * phi - dphi; + int j = batch_mod((int) floorf(((float) noverlap) * u), nfine); + + atomicAdd(&(fine_yw[j]), yw[k]); + atomicAdd(&(fine_w[j]), w[k]); + } + + __syncthreads(); + + for (unsigned int jj = threadIdx.x; jj < nfine; jj += blockDim.x){ + + thread_yw = 0.f; + thread_w = 0.f; + unsigned int f_m0 = 0; + + for (unsigned int m = 1; m <= max_bin_width; m += batch_dnbins(m, dlogq)){ + unsigned int f_m = m * noverlap; + for (unsigned int u = f_m0; u < f_m; u++){ + unsigned int idx = jj + u; + if (idx >= (unsigned int) nfine) + idx -= nfine; + thread_yw += fine_yw[idx]; + thread_w += fine_w[idx]; + } + f_m0 = f_m; + + bls1 = batch_bls_value(thread_yw, thread_w, ignore_negative_delta_sols); + if (bls1 > thread_max_bls) + thread_max_bls = bls1; + } + } + + best_bls[threadIdx.x] = thread_max_bls; + + __syncthreads(); + + for(unsigned int k = (blockDim.x / 2); k >= 32; k /= 2){ + if(threadIdx.x < k){ + bls1 = best_bls[threadIdx.x]; + bls2 = best_bls[threadIdx.x + k]; + best_bls[threadIdx.x] = (bls1 > bls2) ? bls1 : bls2; + } + __syncthreads(); + } + + if (threadIdx.x < 32){ + float val = best_bls[threadIdx.x]; + + for(int offset = 16; offset > 0; offset /= 2){ + float other = __shfl_down_sync(0xffffffff, val, offset); + val = (val > other) ? val : other; + } + + if (threadIdx.x == 0) + best_bls[0] = val; + } + + if (threadIdx.x == 0) + bls_out[i_freq + freq_offset] = best_bls[0]; + + i_freq += gridDim.x; + } +} + +__global__ void full_bls_batch( + const float* __restrict__ t_all, + const float* __restrict__ yw_all, + const float* __restrict__ w_all, + float* __restrict__ bls_all, + const float* __restrict__ freqs, + const unsigned int* __restrict__ nbins0, + const unsigned int* __restrict__ nbinsf, + const unsigned int* __restrict__ ndata_per_lc, + unsigned int max_ndata, + unsigned int nfreq, + unsigned int freq_offset, + unsigned int hist_size, + unsigned int noverlap, + float dlogq, + float dphi, + unsigned int ignore_negative_delta_sols, + unsigned int n_lcs, + unsigned int bls_stride){ + + extern __shared__ float sh[]; + + // Separate yw/w arrays in shared memory (avoid bank conflicts) + float *block_bins_yw = sh; + float *block_bins_w = (float *)&sh[hist_size]; + float *best_bls = (float *)&sh[2 * hist_size]; + + __shared__ float f0; + __shared__ int nb0, nbf, max_bin_width; + __shared__ unsigned int ndata_lc; + + unsigned int lc_idx = blockIdx.y; + if (lc_idx >= n_lcs) + return; + + // Pointer offsets for this lightcurve + unsigned int data_offset = lc_idx * max_ndata; + const float *t = t_all + data_offset; + const float *yw = yw_all + data_offset; + const float *w = w_all + data_offset; + + // Output offset: bls_all[lc_idx * nfreq + freq_idx] + // bls_stride, not nfreq: freq-chunked launches pass nfreq = the + // chunk's frequency count while rows of bls_all stay one full + // grid apart. + float *bls_out = bls_all + lc_idx * bls_stride; + + unsigned int s; + int b; + float phi, bls1, bls2, thread_max_bls, thread_yw, thread_w; + + unsigned int i_freq = blockIdx.x; + while (i_freq < nfreq){ + + thread_max_bls = 0.f; + + if (threadIdx.x == 0){ + f0 = freqs[i_freq + freq_offset]; + nb0 = nbins0[i_freq + freq_offset]; + nbf = nbinsf[i_freq + freq_offset]; + // Widest box: floor(nbf / nb0) -- the largest m whose + // q = m/nbf satisfies q <= 1/nb0 (the discretized qmax). + // Kept identical to the single-LC fast kernels (Sep 2026 + // audit, id 64). + max_bin_width = nbf / nb0; + ndata_lc = ndata_per_lc[lc_idx]; + } + + __syncthreads(); + + // Initialize bins to 0 + for(unsigned int k = threadIdx.x; k < nbf; k += blockDim.x){ + block_bins_yw[k] = 0.f; + block_bins_w[k] = 0.f; + } + + __syncthreads(); + + // Histogram the data for this LC + for (unsigned int k = threadIdx.x; k < ndata_lc; k += blockDim.x){ + phi = batch_mod1_fast(t[k] * f0); + b = batch_mod((int) floorf(((float) nbf) * phi - dphi), (int) nbf); + + atomicAdd(&(block_bins_yw[b]), yw[k]); + atomicAdd(&(block_bins_w[b]), w[k]); + } + + __syncthreads(); + + // Scan q values and find best BLS + for (unsigned int n = threadIdx.x; n < nbf; n += blockDim.x){ + + thread_yw = 0.f; + thread_w = 0.f; + unsigned int m0 = 0; + + for (unsigned int m = 1; m <= max_bin_width; m += batch_dnbins(m, dlogq)){ + for (s = m0; s < m; s++){ + thread_yw += block_bins_yw[(n + s) % nbf]; + thread_w += block_bins_w[(n + s) % nbf]; + } + m0 = m; + + bls1 = batch_bls_value(thread_yw, thread_w, ignore_negative_delta_sols); + if (bls1 > thread_max_bls) + thread_max_bls = bls1; + } + } + + best_bls[threadIdx.x] = thread_max_bls; + + __syncthreads(); + + // Standard tree reduction down to single warp + for(unsigned int k = (blockDim.x / 2); k >= 32; k /= 2){ + if(threadIdx.x < k){ + bls1 = best_bls[threadIdx.x]; + bls2 = best_bls[threadIdx.x + k]; + best_bls[threadIdx.x] = (bls1 > bls2) ? bls1 : bls2; + } + __syncthreads(); + } + + // Final warp reduction using shuffle + if (threadIdx.x < 32){ + float val = best_bls[threadIdx.x]; + + for(int offset = 16; offset > 0; offset /= 2){ + float other = __shfl_down_sync(0xffffffff, val, offset); + val = (val > other) ? val : other; + } + + if (threadIdx.x == 0) + best_bls[0] = val; + } + + // Store result + if (threadIdx.x == 0) + bls_out[i_freq + freq_offset] = best_bls[0]; + + i_freq += gridDim.x; + } +} diff --git a/cuvarbase/kernels/bls_common.cuh b/cuvarbase/kernels/bls_common.cuh new file mode 100644 index 00000000..b861926a --- /dev/null +++ b/cuvarbase/kernels/bls_common.cuh @@ -0,0 +1,432 @@ +// Shared device/global functions for the BLS kernels. +// +// bls.cu and bls_optimized.cu both inline this file via the +// //{INCLUDE bls_common.cuh} directive (expanded by utils._module_reader +// at load time). Single-sourcing these functions removes the historical +// drift hazard between the two kernel files: the reduction_max s>32 +// candidate-drop bug (commit 77b4333) was originally fixed in only one +// copy because the same function lived in two places. Keep functions that +// differ on purpose -- reduction_max (full tree vs warp shuffle) and +// full_bls_no_sol / full_bls_no_sol_optimized -- in their own files. + +__device__ unsigned int get_id(){ + return blockIdx.x * blockDim.x + threadIdx.x; +} + +__device__ int mod(int a, int b){ + int r = a % b; + return (r < 0) ? r + b : r; +} + +__device__ float mod1(float a){ + return a - floorf(a); +} + +__device__ double mod1d(double a){ + return a - floor(a); +} + +__device__ float bls_value(float ybar, float w, unsigned int ignore_negative_delta_sols){ + // if ignore negative delta sols is turned on, that means only solutions where + // the mean amplitude within the transit is _lower_ than the mean amplitude of + // the source are considered: it will ignore "inverted dips" + // + // The upper w bound must be a float32-meaningful complement: the old + // `w < 1.f - 1e-10f` compiled to `w < 1.f` (1e-10 < ulp(1)/2), so a + // box capturing ALL the statistical weight passed the guard with + // (1.f - w) equal to pure atomic-roundoff noise (~1e-5 for n~1e4 + // points) and ybar likewise roundoff around 0 -- a 0/0 that showed + // up as nondeterministic bogus peaks on single-site data at alias + // frequencies (PR #65 reproducer, HATPI). 1e-4 exceeds worst-case + // accumulation error with margin; no legitimate transit solution + // holds >99.99% of the total weight (there would be no + // out-of-transit baseline). The lower bound is unchanged: small-w + // sums of positive weights carry no cancellation. + float bls = (w > 1e-10f && w < 1.f - 1e-4f) ? ybar * ybar / (w * (1.f - w)) : 0.f; + return ((ignore_negative_delta_sols == 1) & (ybar > 0.f)) ? 0.f : bls; +} + +__global__ void binned_bls_bst(float *yw, float *w, float *bls, unsigned int n, unsigned int ignore_negative_delta_sols){ + unsigned int i = get_id(); + + if (i < n){ + bls[i] = bls_value(yw[i], w[i], ignore_negative_delta_sols); + } +} + +__device__ unsigned int dnbins(unsigned int nbins, float dlogq){ + if (dlogq < 0.f) + return 1; + + unsigned int n = (unsigned int) floorf(dlogq * nbins); + + return (n == 0) ? 1 : n; +} + +__device__ unsigned int nbins_iter(unsigned int i, unsigned int nb0, float dlogq){ + if (i == 0) + return nb0; + + unsigned int nb = nb0; + for(int j = 0; j < i; j++) + nb += dnbins(nb, dlogq); + + return nb; +} + +__device__ unsigned int count_tot_nbins(unsigned int nbins0, unsigned int nbinsf, float dlogq){ + unsigned int ntot = 0; + + for(int i = 0; nbins_iter(i, nbins0, dlogq) <= nbinsf; i++) + ntot += nbins_iter(i, nbins0, dlogq); + return ntot; +} + +__global__ void store_best_sols_custom(unsigned int *argmaxes, float *best_phi, + float *best_q, float *q_values, + double *phi_values, unsigned int nq, unsigned int nphi, + unsigned int nfreq, unsigned int freq_offset){ + + unsigned int i = get_id(); + + if (i < nfreq){ + unsigned int imax = argmaxes[i + freq_offset]; + + best_phi[i + freq_offset] = (float) phi_values[imax / nq]; + best_q[i + freq_offset] = q_values[imax % nq]; + } +} + +// Per-frequency bin counts: nbins0 / nbinsf are read from the arrays +// uploaded by eebls_gpu (index i + freq_offset), so every frequency +// decodes its argmax against its OWN q window. They used to be scalar +// launch arguments collapsed to the batch-wide (min nbins0, max nbinsf) +// -- Sep 2026 audit defect 7 (bls-q-collapse). +__global__ void store_best_sols(unsigned int *argmaxes, float *best_phi, + float *best_q, + const unsigned int * __restrict__ nbins0_arr, + const unsigned int * __restrict__ nbinsf_arr, + unsigned int noverlap, + float dlogq, unsigned int nfreq, unsigned int freq_offset){ + + unsigned int i = get_id(); + + if (i < nfreq){ + unsigned int nbins0 = nbins0_arr[i + freq_offset]; + unsigned int nbinsf = nbinsf_arr[i + freq_offset]; + unsigned int imax = argmaxes[i + freq_offset]; + float dphi = 1.f / noverlap; + + // The batch stride is the largest per-frequency cell count in + // the batch; a frequency whose every candidate box scored 0 + // (all-zero row, e.g. ignore_negative_delta_sols with only + // inverted dips) can argmax into the zero-filled tail beyond + // its own cells. Clamp so the decoded (q, phi) stays inside + // this frequency's window (its power is 0 either way). + if (imax >= count_tot_nbins(nbins0, nbinsf, dlogq) * noverlap) + imax = 0; + + unsigned int nb = nbins0; + unsigned int bin_offset = 0; + unsigned int i_iter = 0; + while ((bin_offset + nb) * noverlap <= imax){ + bin_offset += nb; + nb = nbins_iter(++i_iter, nbins0, dlogq); + } + + float q = 1.f / nb; + int s = (((int) imax) - ((int) (bin_offset * noverlap))) / nb; + int jphi = (((int) imax) - ((int) (bin_offset * noverlap))) % nb; + + float phi = mod1((float) (((double) q) * (((double) jphi) + ((double) s) * ((double) dphi)))); + + best_phi[i + freq_offset] = phi; + best_q[i + freq_offset] = q; + } +} + +// Fused-noverlap fast BLS kernel (one block per frequency, grid-stride). +// +// The multi-pass host loop launches the full fold+histogram+scan kernel +// ``noverlap`` times with the phase-bin grid shifted by 1/noverlap of a +// bin between passes and takes the elementwise max. This kernel fuses +// all passes into ONE launch: it histograms the data once at +// ``noverlap``-times finer phase resolution and derives every pass's +// box sums from runs of fine bins. +// +// Derivation. Pass s assigns a point with phase phi to coarse bin +// b_s = floor(nbf*phi - s/noverlap) mod nbf. +// With u = nbf*phi and fine bin j = floor(noverlap*u) mod (noverlap*nbf): +// b_s = floor((j - s)/noverlap) mod nbf (integer identity) +// so the box of pass s starting at coarse bin n with width m covers +// exactly the fine bins [noverlap*n + s, noverlap*(n+m) + s): every +// (n, s) box is a contiguous run of noverlap*m fine bins whose fine +// start jj = noverlap*n + s enumerates [0, noverlap*nbf) bijectively. +// +// Float32 caveat: the host only routes here for power-of-two noverlap +// with base dphi == 0, where fl(noverlap*u) == noverlap*u and +// u - s/noverlap are exact, so bin assignment is bit-identical to the +// multi-pass kernels; other noverlap values fall back to the host +// loop. (Box SUMS still differ from the multi-pass path at float32 +// rounding level: fine-bin partials accumulate in a different order, +// on top of the run-to-run atomic nondeterminism both paths share.) +// +// Cost vs the host loop: shared-memory atomics and folds drop by +// noverlap-x (histogram built once), per-frequency fixed costs (bin +// init, syncthreads, block reduction) are paid once instead of +// noverlap times; the box scan reads noverlap-x more (cheap, +// conflict-free) fine-bin partials. Shared memory grows to +// 2 * noverlap * max_nbins + blockDim floats; the host checks the +// limit and falls back to the multi-pass loop when it doesn't fit. +// +// hist_size here is the FINE histogram size: noverlap * max(nbinsf). +__global__ void full_bls_no_sol_fused( + const float* __restrict__ t, + const float* __restrict__ yw, + const float* __restrict__ w, + float* __restrict__ bls, + const float* __restrict__ freqs, + const unsigned int * __restrict__ nbins0, + const unsigned int * __restrict__ nbinsf, + unsigned int ndata, + unsigned int nfreq, + unsigned int freq_offset, + unsigned int hist_size, + unsigned int noverlap, + float dlogq, + float dphi, + unsigned int ignore_negative_delta_sols){ + extern __shared__ float sh[]; + + // separate yw/w arrays (bank-conflict-free layout) + float *fine_yw = sh; + float *fine_w = (float *)&sh[hist_size]; + float *best_bls = (float *)&sh[2 * hist_size]; + + __shared__ float f0; + __shared__ int nb0, nbf, max_bin_width, nfine; + + float phi, bls1, bls2, thread_max_bls, thread_yw, thread_w; + + unsigned int i_freq = blockIdx.x; + while (i_freq < nfreq){ + + thread_max_bls = 0.f; + + if (threadIdx.x == 0){ + f0 = freqs[i_freq + freq_offset]; + nb0 = nbins0[i_freq + freq_offset]; + nbf = nbinsf[i_freq + freq_offset]; + // Widest box: floor(nbf / nb0), i.e. the largest m whose + // q = m/nbf still satisfies q <= 1/nb0 (= the discretized + // qmax). This used to be divrndup(nbf, nb0) with a strict + // `m < max_bin_width` loop, which is the same bound whenever + // nb0 does not divide nbf but drops the qmax box itself when + // it does (Sep 2026 audit, id 64: qmin=0.025/qmax=0.1 tested + // only q <= 0.075). + max_bin_width = nbf / nb0; + nfine = nbf * ((int) noverlap); + } + + __syncthreads(); + + for(unsigned int k = threadIdx.x; k < nfine; k += blockDim.x){ + fine_yw[k] = 0.f; + fine_w[k] = 0.f; + } + + __syncthreads(); + + // fold + fine histogram: ndata (not noverlap*ndata) atomics + for (unsigned int k = threadIdx.x; k < ndata; k += blockDim.x){ + phi = mod1(t[k] * f0); + + // u reproduces the multi-pass pass-0 expression exactly; + // dphi is 0 on this path (host guarantees it). + float u = ((float) nbf) * phi - dphi; + int j = mod((int) floorf(((float) noverlap) * u), nfine); + + atomicAdd(&(fine_yw[j]), yw[k]); + atomicAdd(&(fine_w[j]), w[k]); + } + + __syncthreads(); + + // scan: fine start jj <-> (coarse start n = jj/noverlap, + // pass s = jj%noverlap); box width m coarse = noverlap*m fine + for (unsigned int jj = threadIdx.x; jj < nfine; jj += blockDim.x){ + + thread_yw = 0.f; + thread_w = 0.f; + unsigned int f_m0 = 0; + + for (unsigned int m = 1; m <= max_bin_width; m += dnbins(m, dlogq)){ + unsigned int f_m = m * noverlap; + for (unsigned int u = f_m0; u < f_m; u++){ + unsigned int idx = jj + u; + if (idx >= (unsigned int) nfine) + idx -= nfine; + thread_yw += fine_yw[idx]; + thread_w += fine_w[idx]; + } + f_m0 = f_m; + + bls1 = bls_value(thread_yw, thread_w, ignore_negative_delta_sols); + if (bls1 > thread_max_bls) + thread_max_bls = bls1; + } + } + + best_bls[threadIdx.x] = thread_max_bls; + + __syncthreads(); + + // tree reduction to one warp, then warp shuffle + for(unsigned int k = (blockDim.x / 2); k >= 32; k /= 2){ + if(threadIdx.x < k){ + bls1 = best_bls[threadIdx.x]; + bls2 = best_bls[threadIdx.x + k]; + best_bls[threadIdx.x] = (bls1 > bls2) ? bls1 : bls2; + } + __syncthreads(); + } + + if (threadIdx.x < 32){ + float val = best_bls[threadIdx.x]; + for(int offset = 16; offset > 0; offset /= 2){ + float other = __shfl_down_sync(0xffffffff, val, offset); + val = (val > other) ? val : other; + } + if (threadIdx.x == 0) + best_bls[0] = val; + } + + if (threadIdx.x == 0) + bls[i_freq + freq_offset] = best_bls[0]; + + i_freq += gridDim.x; + } +} + +// needs ndata * nfreq threads +// noverlap -- number of overlapped bins (noverlap * (1 / q) total bins) +// Note: this thread heavily utilizes global atomic operations, and could +// likely be improved by 1-2 orders of magnitude for large Ndata (10^4) +// if shared memory atomics were utilized. +// +// The thread index and the ndata * nfreq bound are 64-bit: the host +// launches exactly ceil(ndata * nfreq / blockDim) blocks, and with a +// 32-bit product (ndata = 66K points x a 66K-frequency batch is 4.4e9 +// > 2^32) the bound wrapped, so most threads exited and the rest +// binned the wrong (data, frequency) pair -- silent zeros/garbage on +// the default eebls_transit path for TESS 2-min / Kepler short-cadence +// light curves (Sep 2026 audit, defect 1). The host additionally caps +// freq_batch_size at (2^31 - 1) // ndata. +// +// nbins0_arr / nbinsf_arr give the per-frequency coarsest/finest bin +// counts (index i_freq + freq_offset); nbins_tot is the batch STRIDE +// (the largest count_tot_nbins over the batch's frequencies), so a +// frequency with fewer levels leaves the tail of its row untouched +// (zero, hence power 0 in binned_bls_bst). Scalar per-launch counts +// collapsed every frequency to the batch-wide (min nbins0, max nbinsf) +// window (Sep 2026 audit defect 7, bls-q-collapse). +__global__ void bin_and_phase_fold_bst_multifreq( + float *t, float *yw, float *w, + float *yw_bin, float *w_bin, float *freqs, + const unsigned int * __restrict__ nbins0_arr, + const unsigned int * __restrict__ nbinsf_arr, + unsigned int ndata, unsigned int nfreq, + unsigned int freq_offset, unsigned int noverlap, float dlogq, + unsigned int nbins_tot){ + size_t i = ((size_t) blockIdx.x) * blockDim.x + threadIdx.x; + + if (i < ((size_t) ndata) * nfreq){ + unsigned int i_data = (unsigned int) (i % ndata); + unsigned int i_freq = (unsigned int) (i / ndata); + + unsigned int nbins0 = nbins0_arr[i_freq + freq_offset]; + unsigned int nbinsf = nbinsf_arr[i_freq + freq_offset]; + + unsigned int offset = i_freq * nbins_tot * noverlap; + + float W = w[i_data]; + float YW = yw[i_data]; + + // get phase [0, 1) + float phi = mod1(t[i_data] * freqs[i_freq + freq_offset]); + + float dphi = 1.f / noverlap; + unsigned int nbtot = 0; + unsigned int nb, b; + + // iterate through bins (logarithmically spaced) + for(int j = 0; nbins_iter(j, nbins0, dlogq) <= nbinsf; j++){ + nb = nbins_iter(j, nbins0, dlogq); + + // iterate through offsets [ 0, 1./sigma, ..., + // (sigma - 1) / sigma ] + for (int s = 0; s < noverlap; s++){ + b = (unsigned int) mod((int) floorf(nb * phi - s * dphi), nb); + b += offset + s * nb + noverlap * nbtot; + + atomicAdd(&(yw_bin[b]), YW); + atomicAdd(&(w_bin[b]), W); + } + nbtot += nb; + } + } +} + +// needs ndata * nfreq threads (64-bit index and bound, see +// bin_and_phase_fold_bst_multifreq) +// noverlap -- number of overlapped bins (noverlap * (1 / q) total bins) +__global__ void bin_and_phase_fold_custom( + float *t, float *yw, float *w, + float *yw_bin, float *w_bin, double *freqs, + float *q_values, double *phi_values, + double epoch, + unsigned int nq, unsigned int nphi, unsigned int ndata, + unsigned int nfreq, unsigned int freq_offset){ + size_t i = ((size_t) blockIdx.x) * blockDim.x + threadIdx.x; + + if (i < ((size_t) ndata) * nfreq){ + unsigned int i_data = (unsigned int) (i % ndata); + unsigned int i_freq = (unsigned int) (i / ndata); + + unsigned int offset = i_freq * nq * nphi; + + float W = w[i_data]; + float YW = yw[i_data]; + + // Fold in single precision with the float32-cast frequency, + // exactly like bin_and_phase_fold_bst_multifreq and the CPU + // reference single_bls (which folds with float32(t) * + // float32(freq)). freqs stay double ONLY for the epoch + // re-referencing below -- folding with the double frequency + // would shift each phase by up to ~|f64 - f32|* t relative to + // the reference and flip bin membership of edge points. + float f0 = (float) freqs[i_freq + freq_offset]; + + // get phase [0, 1) + float phi = mod1(t[i_data] * f0); + + for(int pb = 0; pb < nphi; pb++){ + // Re-reference the trial phase (given in the original input + // timescale) to the subtracted epoch, in double precision: + // epoch * freq can be ~1e6 cycles for BJD-scale epochs. + // phi_values are double so this matches the float64 + // conversion (phi0 - epoch*freq) % 1 in single_bls bit for + // bit before the float32 cast. + float phi0 = (float)mod1d(phi_values[pb] - (epoch * freqs[i_freq + freq_offset])); + float dphi = phi - phi0; + dphi -= floorf(dphi); + + for(int qb = 0; qb < nq; qb++){ + if (dphi < q_values[qb]){ + atomicAdd(&(yw_bin[pb * nq + qb + offset]), YW); + atomicAdd(&(w_bin[pb * nq + qb + offset]), W); + } + } + } + } +} diff --git a/cuvarbase/kernels/bls_optimized.cu b/cuvarbase/kernels/bls_optimized.cu new file mode 100644 index 00000000..92984c43 --- /dev/null +++ b/cuvarbase/kernels/bls_optimized.cu @@ -0,0 +1,276 @@ +#include +#define RESTRICT __restrict__ +#define CONSTANT const +//{CPP_DEFS} + +// Optimized version of BLS kernel with following improvements: +// 1. Fixed bank conflicts (separate yw/w arrays) +// 2. Explicit use of fast math intrinsics +// 3. Better memory access patterns +// 4. Warp-level reduction in final stages +// +// Device/global functions shared with bls.cu live in bls_common.cuh +// (inlined below) so the two kernels cannot drift apart. Only the +// functions that differ on purpose stay in this file: the bank-conflict +// -free full_bls_no_sol_optimized and the warp-shuffle reduction_max. +//{INCLUDE bls_common.cuh} + +// OPTIMIZED VERSION of full_bls_no_sol +// Key improvements: +// 1. Separate yw/w arrays to avoid bank conflicts +// 2. Explicit fast math intrinsics +// 3. Warp-level reduction for final max finding +__global__ void full_bls_no_sol_optimized( + const float* __restrict__ t, + const float* __restrict__ yw, + const float* __restrict__ w, + float* __restrict__ bls, + const float* __restrict__ freqs, + const unsigned int * __restrict__ nbins0, + const unsigned int * __restrict__ nbinsf, + unsigned int ndata, + unsigned int nfreq, + unsigned int freq_offset, + unsigned int hist_size, + unsigned int noverlap, + float dlogq, + float dphi, + unsigned int ignore_negative_delta_sols){ + unsigned int i = get_id(); + + extern __shared__ float sh[]; + + // OPTIMIZATION: Separate yw/w arrays to avoid bank conflicts + // Old layout: [yw0, w0, yw1, w1, ...] + // New layout: [yw0, yw1, ..., ywN, w0, w1, ..., wN] + float *block_bins_yw = sh; + float *block_bins_w = (float *)&sh[hist_size]; + float *best_bls = (float *)&sh[2 * hist_size]; + + __shared__ float f0; + __shared__ int nb0, nbf, max_bin_width; + +#ifdef USE_LOG_BIN_SPACING + __shared__ int tot_nbins; +#endif + + unsigned int s; + int b; + float phi, bls1, bls2, thread_max_bls, thread_yw, thread_w; + + unsigned int i_freq = blockIdx.x; + while (i_freq < nfreq){ + + thread_max_bls = 0.f; + + if (threadIdx.x == 0){ + f0 = freqs[i_freq + freq_offset]; + nb0 = nbins0[i_freq + freq_offset]; + nbf = nbinsf[i_freq + freq_offset]; + // Widest box: floor(nbf / nb0), i.e. the largest m whose + // q = m/nbf still satisfies q <= 1/nb0 (= the discretized + // qmax). This used to be divrndup(nbf, nb0) with a strict + // `m < max_bin_width` loop, which is the same bound whenever + // nb0 does not divide nbf but drops the qmax box itself when + // it does (Sep 2026 audit, id 64: qmin=0.025/qmax=0.1 tested + // only q <= 0.075). + max_bin_width = nbf / nb0; + +#ifdef USE_LOG_BIN_SPACING + tot_nbins = count_tot_nbins(nb0, nbf, dlogq); +#endif + } + + __syncthreads(); + + // Initialize bins to 0 - now separate arrays + for(unsigned int k = threadIdx.x; k < nbf; k += blockDim.x){ + block_bins_yw[k] = 0.f; + block_bins_w[k] = 0.f; + } + + __syncthreads(); + + // Histogram the data - OPTIMIZATION: use fast math + for (unsigned int k = threadIdx.x; k < ndata; k += blockDim.x){ + phi = mod1(t[k] * f0); + + b = mod((int) floorf(((float) nbf) * phi - dphi), (int) nbf); + + // OPTIMIZATION: Atomic adds on separate arrays (no bank conflicts) + atomicAdd(&(block_bins_yw[b]), yw[k]); + atomicAdd(&(block_bins_w[b]), w[k]); + } + + __syncthreads(); + + // Get max bls for this thread +#ifdef USE_LOG_BIN_SPACING + for (unsigned int n = threadIdx.x; n < tot_nbins; n += blockDim.x){ + + unsigned int bin_offset = 0; + unsigned int nb = nb0; + while ((bin_offset + nb) * noverlap < n){ + bin_offset += nb; + nb += dnbins(nb, dlogq); + } + + b = (((int) n) - ((int) (bin_offset * noverlap))) % nb; + s = (((int) n) - ((int) (bin_offset * noverlap))) / nb; + + thread_yw = 0.f; + thread_w = 0.f; + + for (unsigned int m = b; m < b + nb; m ++){ + thread_yw += block_bins_yw[m % nbf]; + thread_w += block_bins_w[m % nbf]; + } + + bls1 = bls_value(thread_yw, thread_w, ignore_negative_delta_sols); + if (bls1 > thread_max_bls) + thread_max_bls = bls1; + } + +#else + for (unsigned int n = threadIdx.x; n < nbf; n += blockDim.x){ + + thread_yw = 0.f; + thread_w = 0.f; + unsigned int m0 = 0; + + for (unsigned int m = 1; m <= max_bin_width; m += dnbins(m, dlogq)){ + for (s = m0; s < m; s++){ + thread_yw += block_bins_yw[(n + s) % nbf]; + thread_w += block_bins_w[(n + s) % nbf]; + } + m0 = m; + + bls1 = bls_value(thread_yw, thread_w, ignore_negative_delta_sols); + if (bls1 > thread_max_bls) + thread_max_bls = bls1; + } + } +#endif + + best_bls[threadIdx.x] = thread_max_bls; + + __syncthreads(); + + // Standard tree reduction down to single warp (32 threads) + for(unsigned int k = (blockDim.x / 2); k >= 32; k /= 2){ + if(threadIdx.x < k){ + bls1 = best_bls[threadIdx.x]; + bls2 = best_bls[threadIdx.x + k]; + + best_bls[threadIdx.x] = (bls1 > bls2) ? bls1 : bls2; + } + __syncthreads(); + } + + // Final warp reduction using shuffle (no sync needed) + // After the loop above, best_bls[0...31] contains the values to reduce + if (threadIdx.x < 32){ + float val = best_bls[threadIdx.x]; + + // Warp shuffle reduction (no __syncthreads needed within a warp) + for(int offset = 16; offset > 0; offset /= 2){ + float other = __shfl_down_sync(0xffffffff, val, offset); + val = (val > other) ? val : other; + } + + if (threadIdx.x == 0) + best_bls[0] = val; + } + + // Store result + if (threadIdx.x == 0) + bls[i_freq + freq_offset] = best_bls[0]; + + i_freq += gridDim.x; + } +} + + +__global__ void reduction_max(float *arr, unsigned int *arr_args, unsigned int nfreq, + unsigned int nbins, unsigned int stride, + float *block_max, unsigned int *block_arg_max, + unsigned int offset, unsigned int init){ + + __shared__ float partial_max[BLOCK_SIZE]; + __shared__ unsigned int partial_arg_max[BLOCK_SIZE]; + + unsigned int id = blockIdx.x * blockDim.x + threadIdx.x; + + unsigned int nblocks_per_freq = gridDim.x / nfreq; + unsigned int nthreads_per_freq = blockDim.x * nblocks_per_freq; + + unsigned int fno = id / nthreads_per_freq; + unsigned int b = id % nthreads_per_freq; + + partial_max[threadIdx.x] = (fno < nfreq && b < nbins) ? + arr[fno * stride + b] : -1.f; + + partial_arg_max[threadIdx.x] = (fno < nfreq && b < nbins) ? + ( + (init == 1) ? + b : arr_args[fno * stride + b] + ) : 0; + + __syncthreads(); + + float m1, m2; + + // Reduce to find max - standard reduction down to warp level. + // NOTE: must be s >= 32 (not s > 32) so the s=32 fold runs and only + // 32 candidates survive for the warp-shuffle stage below; with s > 32 + // elements 32..63 were silently dropped (same bug fixed in + // full_bls_no_sol_optimized by commit 72ae029). + for(int s = blockDim.x / 2; s >= 32; s /= 2){ + if(threadIdx.x < s){ + m1 = partial_max[threadIdx.x]; + m2 = partial_max[threadIdx.x + s]; + + partial_max[threadIdx.x] = (m1 > m2) ? m1 : m2; + + partial_arg_max[threadIdx.x] = (m1 > m2) ? + partial_arg_max[threadIdx.x] : + partial_arg_max[threadIdx.x + s]; + } + + __syncthreads(); + } + + // OPTIMIZATION: Final warp reduction with shuffle + if (threadIdx.x < 32){ + float val = partial_max[threadIdx.x]; + unsigned int arg = partial_arg_max[threadIdx.x]; + + for(int offset = 16; offset > 0; offset /= 2){ + float other_val = __shfl_down_sync(0xffffffff, val, offset); + unsigned int other_arg = __shfl_down_sync(0xffffffff, arg, offset); + + if (other_val > val){ + val = other_val; + arg = other_arg; + } + } + + if (threadIdx.x == 0){ + partial_max[0] = val; + partial_arg_max[0] = arg; + } + } + + __syncthreads(); + + // Store result + if (threadIdx.x == 0 && fno < nfreq){ + unsigned int i = (gridDim.x == nfreq) ? 0 : + fno * stride - fno * nblocks_per_freq; + + i += blockIdx.x + offset; + + block_max[i] = partial_max[0]; + block_arg_max[i] = partial_arg_max[0]; + } +} diff --git a/cuvarbase/kernels/ce.cu b/cuvarbase/kernels/ce.cu index a53ce4de..58462aef 100644 --- a/cuvarbase/kernels/ce.cu +++ b/cuvarbase/kernels/ce.cu @@ -14,6 +14,13 @@ #define FLT float #endif +// Weighted-CE bins with less probability mass than this are ignored +// (see weighted_ce); it is far below any mass that could change the +// entropy but keeps denormal / underflowing bins from producing inf. +#ifndef CE_WEIGHT_FLOOR + #define CE_WEIGHT_FLOOR 1E-20 +#endif + __device__ double atomicAddDouble(double* address, double val) { @@ -62,13 +69,25 @@ __global__ void histogram_data_weighted(FLT *t, FLT *y, FLT *dy, int n0 = phase_ind(freqs[i_freq] * t[j_data]); unsigned int offset = i_freq * (NMAG * NPHASE); + // bin index of the datum itself; Y == 1 (the brightest point) + // would otherwise give NMAG, one past the last bin int m0 = (int) (Y * NMAG); + if (m0 >= NMAG) m0 = NMAG - 1; for(int m = 0; m < NMAG; m++){ + // signed distances from the datum to the bin's lower and + // upper edges (in units of the normalized magnitude range) FLT z = (((FLT) m) / NMAG - Y); - if (abs(z) > max_phi * DY && m != m0) - continue; FLT zmax = z + (1 + MAG_OVERLAP) / ((FLT) NMAG); + + // skip bin m only when the WHOLE bin lies more than max_phi + // sigma away from the datum (lower edge above Y + max_phi*DY + // or upper edge below Y - max_phi*DY); the datum's own bin is + // always kept. Testing only the lower edge (as before) threw + // away the mass of every bin below the datum whose lower edge + // was > max_phi*DY away, biasing the histogram upward. + if ((z > max_phi * DY || zmax < -max_phi * DY) && m != m0) + continue; FLT wtot = normcdf(zmax / DY) - normcdf(z / DY); for(int n = n0; n >= n0 - PHASE_OVERLAP; n--) @@ -91,6 +110,10 @@ __global__ void histogram_data_count(FLT *t, unsigned int *y, if (i_freq < nfreq){ unsigned int offset = i_freq * (NMAG * NPHASE); unsigned int m0 = y[j_data]; + // defensive: setdata clamps the bin index, but an index of NMAG + // would spill into the next phase bin / next frequency / past + // the end of `bin` + if (m0 >= NMAG) m0 = NMAG - 1; int n0 = phase_ind(freqs[i_freq] * t[j_data]); for (int n = (int) n0; n >= (((int) n0) - PHASE_OVERLAP); n--){ @@ -140,8 +163,12 @@ __global__ void ce_classical_fast(const FLT * __restrict__ t, unsigned int * block_bin = (unsigned int *)sh; unsigned int * block_bin_phi = (unsigned int *)&block_bin[nmag * nphase]; - // align! - unsigned int r = ((nmag * nphase + nphase) * sizeof(unsigned int)) % sizeof(FLT); + // align Hc to sizeof(FLT): `r` is the number of PADDING ELEMENTS + // (unsigned ints) needed after block_bin_phi, i.e. the byte remainder + // divided by sizeof(unsigned int). Using the byte remainder directly + // as an element offset (as before) misaligned Hc by 4 bytes whenever + // (nmag + 1) * nphase was odd in double precision. + unsigned int r = (((nmag * nphase + nphase) * sizeof(unsigned int)) % sizeof(FLT)) / sizeof(unsigned int); FLT * Hc = (FLT *)&block_bin_phi[nphase + r]; __shared__ FLT f0; @@ -176,6 +203,7 @@ __global__ void ce_classical_fast(const FLT * __restrict__ t, // make 2d histogram for(i = threadIdx.x; i < ndata; i += blockDim.x){ m0 = (int) (y[i]); + if (m0 >= (int) nmag) m0 = ((int) nmag) - 1; n0 = ((int) floor(nphase * mod1(t[i] * f0))) % nphase; for (n = n0; n >= (((int) n0) - ((int) phase_overlap)); n--){ @@ -256,8 +284,12 @@ __global__ void ce_classical_faster(const FLT * __restrict__ t, unsigned int * block_bin = (unsigned int *)sh; unsigned int * block_bin_phi = (unsigned int *)&block_bin[nmag * nphase]; - // align! - unsigned int r = ((nmag * nphase + nphase) * sizeof(unsigned int)) % sizeof(FLT); + // align Hc to sizeof(FLT): `r` is the number of PADDING ELEMENTS + // (unsigned ints) needed after block_bin_phi, i.e. the byte remainder + // divided by sizeof(unsigned int). Using the byte remainder directly + // as an element offset (as before) misaligned Hc by 4 bytes whenever + // (nmag + 1) * nphase was odd in double precision. + unsigned int r = (((nmag * nphase + nphase) * sizeof(unsigned int)) % sizeof(FLT)) / sizeof(unsigned int); FLT * Hc = (FLT *)&block_bin_phi[nphase + r]; FLT * t_sh = (FLT *)&Hc[nmag * nphase]; unsigned int * y_sh = (unsigned int *)&t_sh[ndata]; @@ -301,7 +333,8 @@ __global__ void ce_classical_faster(const FLT * __restrict__ t, // make 2d histogram for(i = threadIdx.x; i < ndata; i += blockDim.x){ - m0 = (int) (y[i]); + m0 = (int) (y_sh[i]); + if (m0 >= (int) nmag) m0 = ((int) nmag) - 1; n0 = ((int) floor(nphase * mod1(t_sh[i] * f0))) % nphase; for (n = n0; n >= (((int) n0) - ((int) phase_overlap)); n--){ @@ -381,8 +414,12 @@ __global__ void weighted_ce(FLT *bins, unsigned int nfreq, FLT *ce){ FLT pmn = bins[offset + m]; bin_tot += pmn; - if (pmn > 0.f && p_phi_n > 1E-10) - Hc += pmn * log((dm * p_phi_n) / pmn); + // Skip (numerically) empty bins: a tiny mass makes + // (dm * p_phi_n) / pmn overflow to inf in float32, and + // its contribution pmn * log(...) is negligible anyway. + // The log is split so the ratio is never formed. + if (pmn > CE_WEIGHT_FLOOR && p_phi_n > 1E-10) + Hc += pmn * (log(dm * p_phi_n) - log(pmn)); } } ce[i] = Hc / bin_tot; diff --git a/cuvarbase/kernels/cunfft.cu b/cuvarbase/kernels/cunfft.cu index 5c33d807..6b981068 100644 --- a/cuvarbase/kernels/cunfft.cu +++ b/cuvarbase/kernels/cunfft.cu @@ -3,17 +3,22 @@ #define RESTRICT __restrict__ #define CONSTANT const -#define PI 3.14159265358979323846264338327950288f #define FILTER gauss_filter //{CPP_DEFS} #ifdef DOUBLE_PRECISION #define ATOMIC_ADD atomicAddDouble #define FLT double - + // PI must be a double literal here: the float32 literal's relative + // error (2.8e-8) times the un-reduced phase arguments in nfft_shift/ + // normalize (up to 2*pi*|k0|) produced an m-independent absolute + // error floor ~1e-3 that swamped the truncation bound (A3 diagnosis, + // Jul 2026 batch 3). + #define PI 3.14159265358979323846264338327950288 #else #define ATOMIC_ADD atomicAdd #define FLT float + #define PI 3.14159265358979323846264338327950288f #endif #define CMPLX pycuda::complex @@ -42,13 +47,13 @@ __device__ int mod(CONSTANT int a, CONSTANT int b) { return (ret < 0) ? ret + b : ret; } -__device__ float modflt(CONSTANT FLT a, CONSTANT FLT b){ +__device__ FLT modflt(CONSTANT FLT a, CONSTANT FLT b){ return a - floor(a / b) * b; } __device__ FLT diffmod(CONSTANT FLT a, CONSTANT FLT b, CONSTANT FLT M) { FLT ret = a - b; - if (fabsf(ret) > M/2){ + if (fabs(ret) > M/2){ if (ret > 0) return ret - M; return M + ret; @@ -61,19 +66,32 @@ __global__ void nfft_shift( CMPLX *out, CONSTANT int ng, CONSTANT int nbatch, - CONSTANT FLT x0, - CONSTANT FLT xf, + CONSTANT FLT x0, // unused since the host passes k0 (kept for + CONSTANT FLT xf, // a stable prepared signature) CONSTANT FLT spp, - CONSTANT FLT f0){ + CONSTANT int k0){ // first mode (integer, computed on the host) int i = blockIdx.x *blockDim.x + threadIdx.x; int batch = i / ng; if (batch < nbatch) { - FLT k0 = f0 * spp * (xf - x0); - - FLT phi = (2.f * PI * (i % ng) * k0) / ng; + // The first mode k0 = f0 / df = f0 * spp * (xf - x0) is an + // INTEGER by construction (the periodic grid only has integer + // modes; a fractional k0 would give a Dirichlet-leakage mixture, + // not the transform). It is rounded on the host in float64 and + // passed in: re-deriving it here from the FLT product misrounded + // by one mode from k0 ~ 2e6 upward in the float32 build, and + // this kernel and normalize could round to different integers + // (ids 104 and 24 of the Sep-2026 readiness review). + + // phi = 2 pi (i mod ng) k0 / ng, reduced modulo one cycle in exact + // integer arithmetic. The un-reduced float32 product + // (i mod ng) * k0 reached ~1e12 at survey scale (ulp ~ 1e5 -> + // 0.1-0.4 rad phase errors at the top of the grid; ids 98/160). + long long r = (((long long) (i % ng)) * k0) % ((long long) ng); + if (r < 0) r += ng; + FLT phi = (2.f * PI * ((FLT) r)) / ng; CMPLX shift = CMPLX(cos(phi), sin(phi)); @@ -142,8 +160,15 @@ __global__ void fast_gaussian_grid( // observation FLT yi = y[i]; - // nearest gridpoint (rounding down) - int u = (int) floorf(ng * xval - m); + // nearest gridpoint (rounding down). Must be the FLT-typed + // floor(): under DOUBLE_PRECISION floorf() rounded the double + // coordinate to float32 first, so points within a float32 ulp + // below an integer were deposited one cell to the right of + // where precompute_psi (which uses the exact fraction) placed + // the window -- ~n0*ng/2^24 misplaced points, making + // use_double=True LESS accurate than float32 at survey scale + // (nfft-floorf-double, Sep 2026). For float, floor() is floorf(). + int u = (int) floor(ng * xval - m); // precomputed filter values FLT Q = q1[di]; @@ -221,7 +246,8 @@ __global__ void normalize( CONSTANT FLT x0, // min(x) CONSTANT FLT xf, // max(x) CONSTANT FLT spp, // samples per peak - CONSTANT FLT f0) // first frequency + CONSTANT int k0) // first mode (integer, computed on the host; + // see nfft_shift) { int i = blockIdx.x *blockDim.x + threadIdx.x; @@ -231,17 +257,23 @@ __global__ void normalize( int k = i % nf; FLT sT = spp * (xf - x0); - FLT n0 = (x0 / sT) * ng; - FLT k0 = f0 * sT; + // mode index of this entry, in 64-bit integer arithmetic + long long kk = ((long long) k0) + k; CMPLX G = gin[batch * ng + k]; - // *= exp(2pi i (k0 + k) * n0 / n) - FLT theta_k = (2.f * PI * n0 * (k0 + k)) / ng; + // *= exp(2 pi i f_k x0) with f_k = (k0 + k) / sT: the phase of the + // time origin x0 the gridding subtracted. The argument is + // 2 pi f |tmin| (1e4-1e6 rad at survey scale), so reduce it modulo + // one cycle in double BEFORE the FLT trig -- evaluated as the + // float32 2 pi n0 (k0 + k) / ng it lost ~0.05-0.1 rad (ids 98/160). + double cyc = ((double) kk) * ((double) x0) / ((double) sT); + cyc -= floor(cyc); + FLT theta_k = (FLT) (2.0 * 3.14159265358979323846264338327950288 * cyc); G *= CMPLX(cos(theta_k), sin(theta_k)); // normalization factor from gridding kernel (gaussian) - FLT khat = PI * (k0 + k) / ng; + FLT khat = PI * ((FLT) kk) / ng; gout[i] = G * exp(b * khat * khat); } diff --git a/cuvarbase/kernels/lomb.cu b/cuvarbase/kernels/lomb.cu index f8016841..4ee8f1ce 100644 --- a/cuvarbase/kernels/lomb.cu +++ b/cuvarbase/kernels/lomb.cu @@ -3,11 +3,18 @@ //{CPP_DEFS} #define EPSILON 1E-8 -#define PI 3.141592653589793238462643383279502884f #ifdef DOUBLE_PRECISION #define FLT double + // PI must be a double literal here: the float32 literal's relative + // error (2.8e-8) rescales the un-reduced phase arguments in cossum/ + // sinsum (2*pi*f*(t + 0.5), with t on the caller's original time + // scale) so the direct-sums kernels evaluate the periodogram on a + // frequency axis stretched by 1 + 2.8e-8 even in double-precision + // mode (same defect class as the cunfft.cu A3 fix, Jul 2026). + #define PI 3.14159265358979323846264338327950288 #else #define FLT float + #define PI 3.14159265358979323846264338327950288f #endif #define STANDARD 0 @@ -220,36 +227,7 @@ __global__ void lomb(pycuda::complex *sw, } -__global__ void lomb_mh(pycuda::complex *sw, - pycuda::complex *syw, - FLT *lsp, - FLT *reg, - int nfreq, - int nharmonics, - FLT YY, - FLT Y, - int k0, - int mode){ - - // least squares (lomb scargle with FLTing mean) - - unsigned int i = blockIdx.x * blockDim.x + threadIdx.x; - // reg = (lambda_a, lambda_b, lambda_c) - if (i < nfreq){ - pycuda::complex SW, SW2, SYW; - SW = sw[i]; - SW2 = sw[2 * i + k0]; - SYW = syw[i]; - - FLT C = SW.real(); - FLT S = SW.imag(); - - FLT C2 = SW2.real(); - FLT S2 = SW2.imag(); - - FLT YCh = SYW.real(); - FLT YSh = SYW.imag(); - - lsp[i] = lspow(C, S, C2, S2, YCh, YSh, YY, Y, reg, mode); - } -} \ No newline at end of file +// Multiharmonic (H>1) GLS is handled on the host in +// cuvarbase.lombscargle._mh_power_from_spectra (the small per-frequency +// 2H x 2H solve runs in float64), reusing the GPU NFFT spectra. There is +// deliberately no in-kernel multiharmonic solver. \ No newline at end of file diff --git a/cuvarbase/kernels/nufft_lrt.cu b/cuvarbase/kernels/nufft_lrt.cu new file mode 100644 index 00000000..dd72ec71 --- /dev/null +++ b/cuvarbase/kernels/nufft_lrt.cu @@ -0,0 +1,204 @@ +#include +#include + +#define RESTRICT __restrict__ +#define CONSTANT const +//{CPP_DEFS} + +#ifdef DOUBLE_PRECISION + #define FLT double + // PI must be a double literal in double-precision mode (same defect + // class as the cunfft.cu A3 fix, Jul 2026). PI is currently + // unreferenced in this file; the guard keeps any future phase + // computation from inheriting the float32 literal. + #define PI 3.14159265358979323846264338327950288 +#else + #define FLT float + #define PI 3.14159265358979323846264338327950288f +#endif + +#define CMPLX pycuda::complex + +// Compute matched filter statistic for NUFFT LRT +// Implements: sum(Y * conj(T) / P_s) / sqrt(sum(|T|^2 / P_s)) +__global__ void nufft_matched_filter( + CMPLX *RESTRICT Y, // NUFFT of lightcurve, length nf + CMPLX *RESTRICT T, // NUFFT of template, length nf + FLT *RESTRICT P_s, // Power spectrum estimate, length nf + FLT *RESTRICT weights, // Frequency weights (for one-sided spectrum), length nf + FLT *RESTRICT results, // Output results [numerator, denominator], length 2 + CONSTANT int nf, // Number of frequency samples + CONSTANT FLT eps_floor) // Floor for power spectrum to avoid division by zero +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + // Shared memory for reduction + extern __shared__ FLT sdata[]; + FLT *s_num = sdata; + FLT *s_den = &sdata[blockDim.x]; + + FLT num_sum = 0.0f; + FLT den_sum = 0.0f; + + // Each thread processes one or more frequency bins + if (i < nf) { + FLT P_inv = 1.0f / fmax(P_s[i], eps_floor); + FLT w = weights[i]; + + // Numerator: real(Y * conj(T) * w / P_s) + CMPLX YT_conj = Y[i] * conj(T[i]); + num_sum = YT_conj.real() * w * P_inv; + + // Denominator: |T|^2 * w / P_s + FLT T_mag_sq = (T[i].real() * T[i].real() + T[i].imag() * T[i].imag()); + den_sum = T_mag_sq * w * P_inv; + } + + // Store partial sums in shared memory + s_num[threadIdx.x] = num_sum; + s_den[threadIdx.x] = den_sum; + __syncthreads(); + + // Reduction in shared memory + for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) { + if (threadIdx.x < s) { + s_num[threadIdx.x] += s_num[threadIdx.x + s]; + s_den[threadIdx.x] += s_den[threadIdx.x + s]; + } + __syncthreads(); + } + + // Write result for this block to global memory + if (threadIdx.x == 0) { + atomicAdd(&results[0], s_num[0]); + atomicAdd(&results[1], s_den[0]); + } +} + +// Compute power spectrum estimate from NUFFT +// Simple smoothed periodogram approach +__global__ void estimate_power_spectrum( + CMPLX *RESTRICT Y, // NUFFT of data, length nf + FLT *RESTRICT P_s, // Output power spectrum, length nf + CONSTANT int nf, // Number of frequency samples + CONSTANT int smooth_window,// Smoothing window size + CONSTANT FLT eps_floor) // Floor value as fraction of median +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < nf) { + // Compute periodogram value: |Y[i]|^2 + FLT power = Y[i].real() * Y[i].real() + Y[i].imag() * Y[i].imag(); + + // Simple boxcar smoothing + FLT smoothed = 0.0f; + int count = 0; + int half_window = smooth_window / 2; + + for (int j = -half_window; j <= half_window; j++) { + int idx = i + j; + if (idx >= 0 && idx < nf) { + FLT val = Y[idx].real() * Y[idx].real() + Y[idx].imag() * Y[idx].imag(); + smoothed += val; + count++; + } + } + + P_s[i] = smoothed / count; + } +} + +// Apply frequency weights for one-sided spectrum conversion +__global__ void compute_frequency_weights( + FLT *RESTRICT weights, // Output weights, length nf + CONSTANT int nf, // Number of frequency samples + CONSTANT int n_data) // Original data length (for determining Nyquist) +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < nf) { + // Weights for converting two-sided to one-sided spectrum + if (i == 0) { + weights[i] = 1.0f; + } else if (i < nf - 1) { + weights[i] = 2.0f; + } else { + // Last frequency (Nyquist for even n_data) + weights[i] = (n_data % 2 == 0) ? 1.0f : 2.0f; + } + } +} + +// Demean data on GPU +__global__ void demean_data( + FLT *RESTRICT data, // Data to demean (in-place), length n + CONSTANT int n, // Length of data + CONSTANT FLT mean) // Mean to subtract +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < n) { + data[i] -= mean; + } +} + +// Compute mean of data (reduction kernel) +__global__ void compute_mean( + FLT *RESTRICT data, // Input data, length n + FLT *RESTRICT result, // Output mean + CONSTANT int n) // Length of data +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + extern __shared__ FLT sdata[]; + + FLT sum = 0.0f; + if (i < n) { + sum = data[i]; + } + + sdata[threadIdx.x] = sum; + __syncthreads(); + + // Reduction + for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) { + if (threadIdx.x < s) { + sdata[threadIdx.x] += sdata[threadIdx.x + s]; + } + __syncthreads(); + } + + if (threadIdx.x == 0) { + atomicAdd(result, sdata[0] / n); + } +} + +// Generate transit template (simple box model) +__global__ void generate_transit_template( + FLT *RESTRICT t, // Time values, length n + FLT *RESTRICT template_out,// Output template, length n + CONSTANT int n, // Length of data + CONSTANT FLT period, // Orbital period + CONSTANT FLT epoch, // Transit epoch + CONSTANT FLT duration, // Transit duration + CONSTANT FLT depth) // Transit depth +{ + int i = blockIdx.x * blockDim.x + threadIdx.x; + + if (i < n) { + // Phase fold + FLT phase = fmod(t[i] - epoch, period) / period; + if (phase < 0) phase += 1.0f; + + // Center phase around 0.5 + if (phase > 0.5f) phase -= 1.0f; + + // Check if in transit + FLT phase_width = duration / (2.0f * period); + if (fabs(phase) <= phase_width) { + template_out[i] = -depth; + } else { + template_out[i] = 0.0f; + } + } +} diff --git a/cuvarbase/kernels/pdm.cu b/cuvarbase/kernels/pdm.cu index d2ca98c1..d567bbb3 100644 --- a/cuvarbase/kernels/pdm.cu +++ b/cuvarbase/kernels/pdm.cu @@ -1,14 +1,15 @@ #include -#define WEIGHT(k) (w==NULL ? 1.0f : w[k]) #define GAUSSIAN(x) expf(-0.5f *x*x) -#define WEIGHTED_LININTERP true -#define SKIP_BIN(i) (bin_wtots[i] * NBINS < 0.01f) //INSERT_NBINS_HERE +// Fractional part of x*f in float32. For x*f in (-2^-25, 0) this rounds to +// exactly 1.0f, so every (int)(PHASE * NBINS) below must be wrapped with +// `% NBINS` before it indexes a bin array. #define PHASE(x,f) (x * f - floorf(x * f)) #define RESTRICT __restrict__ #define CONSTANT const +#define MAX_BLOCK_SIZE 256 __device__ float phase_diff( CONSTANT float dt, @@ -46,6 +47,7 @@ __device__ float var_step_function( for(int i = 0; i < ndata; i++){ bin = (int) (PHASE(t[i], freq) * NBINS); + bin = bin % NBINS; var_tot += w[i] * (y[i] - bin_means[bin]) * (y[i] - bin_means[bin]); } @@ -217,3 +219,290 @@ __global__ void pdm_binned_step( power[i] = 1.f - var_step_function(t, y, w, freqs[i], ndata) / var; } } + + +__global__ void pdm_binned_step_fast( + const float *RESTRICT t, + const float *RESTRICT y, + const float *RESTRICT w, + const float *RESTRICT freqs, + float *power, + CONSTANT int ndata, + CONSTANT int nfreqs, + CONSTANT float dphi, + CONSTANT float var_tot_val){ + + __shared__ float s_t[MAX_BLOCK_SIZE]; + __shared__ float s_y[MAX_BLOCK_SIZE]; + __shared__ float s_w[MAX_BLOCK_SIZE]; + + int tid = threadIdx.x; + int i = blockIdx.x * blockDim.x + tid; + + float freq = (i < nfreqs) ? freqs[i] : 0.0f; + + float bin_wtots[NBINS]; + float bin_sums[NBINS]; + + for (int b = 0; b < NBINS; b++){ + bin_wtots[b] = 0.f; + bin_sums[b] = 0.f; + } + + for (int j = 0; j < ndata; j += blockDim.x) { + int load_idx = j + tid; + if (load_idx < ndata) { + s_t[tid] = t[load_idx]; + s_y[tid] = y[load_idx]; + s_w[tid] = w[load_idx]; + } + __syncthreads(); + + if (i < nfreqs) { + int n_in_tile = (ndata - j < blockDim.x) ? (ndata - j) : blockDim.x; + for (int k = 0; k < n_in_tile; k++) { + float phase = PHASE(s_t[k], freq); + int bin = (int)(phase * NBINS); + bin = bin % NBINS; + bin_wtots[bin] += s_w[k]; + bin_sums[bin] += s_y[k] * s_w[k]; + } + } + __syncthreads(); + } + + if (i < nfreqs) { + float ss_bin = 0.f; + for (int b = 0; b < NBINS; b++) { + if (bin_wtots[b] > 1e-10f) { + ss_bin += (bin_sums[b] * bin_sums[b]) / bin_wtots[b]; + } + } + // Assumes y is zero-meaned (weighted) + power[i] = ss_bin / var_tot_val; + } +} + +__global__ void pdm_binned_linterp_fast( + const float *RESTRICT t, + const float *RESTRICT y, + const float *RESTRICT w, + const float *RESTRICT freqs, + float *power, + CONSTANT int ndata, + CONSTANT int nfreqs, + CONSTANT float dphi, + CONSTANT float var_tot_val){ + + __shared__ float s_t[MAX_BLOCK_SIZE]; + __shared__ float s_y[MAX_BLOCK_SIZE]; + __shared__ float s_w[MAX_BLOCK_SIZE]; + + int tid = threadIdx.x; + int i = blockIdx.x * blockDim.x + tid; + + float freq = (i < nfreqs) ? freqs[i] : 0.0f; + + float bin_wtots[NBINS]; + float bin_means[NBINS]; + + for (int b = 0; b < NBINS; b++){ + bin_wtots[b] = 0.f; + bin_means[b] = 0.f; + } + + // Pass 1: Accumulate bins + for (int j = 0; j < ndata; j += blockDim.x) { + int load_idx = j + tid; + if (load_idx < ndata) { + s_t[tid] = t[load_idx]; + s_y[tid] = y[load_idx]; + s_w[tid] = w[load_idx]; + } + __syncthreads(); + + if (i < nfreqs) { + int n_in_tile = (ndata - j < blockDim.x) ? (ndata - j) : blockDim.x; + for (int k = 0; k < n_in_tile; k++) { + float phase = PHASE(s_t[k], freq); + int bin = (int)(phase * NBINS); + bin = bin % NBINS; + bin_wtots[bin] += s_w[k]; + bin_means[bin] += s_y[k] * s_w[k]; + } + } + __syncthreads(); + } + + if (i < nfreqs) { + for (int b = 0; b < NBINS; b++) { + if (bin_wtots[b] > 1e-10f) { + bin_means[b] /= bin_wtots[b]; + } + } + } + + float var_pdm = 0.f; + // Pass 2: Calculate variance + for (int j = 0; j < ndata; j += blockDim.x) { + int load_idx = j + tid; + if (load_idx < ndata) { + s_t[tid] = t[load_idx]; + s_y[tid] = y[load_idx]; + s_w[tid] = w[load_idx]; + } + __syncthreads(); + + if (i < nfreqs) { + int n_in_tile = (ndata - j < blockDim.x) ? (ndata - j) : blockDim.x; + for (int k = 0; k < n_in_tile; k++) { + float phase = PHASE(s_t[k], freq); + float p_nbins = phase * NBINS; + int bin = (int)(p_nbins); + bin = bin % NBINS; + + float alpha = p_nbins - floorf(p_nbins) - 0.5f; + int bin0 = (alpha < 0) ? bin - 1 : bin; + int bin1 = (alpha < 0) ? bin : bin + 1; + + if (bin0 < 0) bin0 += NBINS; + if (bin1 >= NBINS) bin1 -= NBINS; + + alpha += (alpha < 0) ? 1.f : 0.f; + float y0 = (1.f - alpha) * bin_means[bin0] + alpha * bin_means[bin1]; + float dy = s_y[k] - y0; + var_pdm += s_w[k] * dy * dy; + } + } + __syncthreads(); + } + + if (i < nfreqs) { + power[i] = 1.f - var_pdm / var_tot_val; + } +} + +__global__ void pdm_binless_tophat_fast( + const float *RESTRICT t, + const float *RESTRICT y, + const float *RESTRICT w, + const float *RESTRICT freqs, + float *power, + CONSTANT int ndata, + CONSTANT int nfreqs, + CONSTANT float dphi, + CONSTANT float var_tot_val){ + + __shared__ float s_t[MAX_BLOCK_SIZE]; + __shared__ float s_y[MAX_BLOCK_SIZE]; + __shared__ float s_w[MAX_BLOCK_SIZE]; + + int tid = threadIdx.x; + int i = blockIdx.x * blockDim.x + tid; + float freq = (i < nfreqs) ? freqs[i] : 0.0f; + + float total_var_pdm = 0.f; + + for (int j = 0; j < ndata; j++) { + float tj = t[j]; + float yj = y[j]; + float wj = w[j]; + + float mbar = 0.f; + float wtot = 0.f; + + for (int ks = 0; ks < ndata; ks += blockDim.x) { + int load_idx = ks + tid; + if (load_idx < ndata) { + s_t[tid] = t[load_idx]; + s_y[tid] = y[load_idx]; + s_w[tid] = w[load_idx]; + } + __syncthreads(); + + if (i < nfreqs) { + int n_in_tile = (ndata - ks < blockDim.x) ? (ndata - ks) : blockDim.x; + for (int k = 0; k < n_in_tile; k++) { + float dph = phase_diff(fabsf(s_t[k] - tj), freq); + if (dph < dphi) { + mbar += s_w[k] * s_y[k]; + wtot += s_w[k]; + } + } + } + __syncthreads(); + } + + if (i < nfreqs && wtot > 1e-10f) { + float diff = yj - (mbar / wtot); + total_var_pdm += wj * diff * diff; + } + } + + if (i < nfreqs) { + power[i] = 1.f - total_var_pdm / var_tot_val; + } +} + +__global__ void pdm_binless_gauss_fast( + const float *RESTRICT t, + const float *RESTRICT y, + const float *RESTRICT w, + const float *RESTRICT freqs, + float *power, + CONSTANT int ndata, + CONSTANT int nfreqs, + CONSTANT float dphi, + CONSTANT float var_tot_val){ + + __shared__ float s_t[MAX_BLOCK_SIZE]; + __shared__ float s_y[MAX_BLOCK_SIZE]; + __shared__ float s_w[MAX_BLOCK_SIZE]; + + int tid = threadIdx.x; + int i = blockIdx.x * blockDim.x + tid; + float freq = (i < nfreqs) ? freqs[i] : 0.0f; + float inv_dphi = 1.0f / dphi; + + float total_var_pdm = 0.f; + + for (int j = 0; j < ndata; j++) { + float tj = t[j]; + float yj = y[j]; + float wj = w[j]; + + float mbar = 0.f; + float wtot = 0.f; + + for (int ks = 0; ks < ndata; ks += blockDim.x) { + int load_idx = ks + tid; + if (load_idx < ndata) { + s_t[tid] = t[load_idx]; + s_y[tid] = y[load_idx]; + s_w[tid] = w[load_idx]; + } + __syncthreads(); + + if (i < nfreqs) { + int n_in_tile = (ndata - ks < blockDim.x) ? (ndata - ks) : blockDim.x; + for (int k = 0; k < n_in_tile; k++) { + float dph = phase_diff(fabsf(s_t[k] - tj), freq); + float x = dph * inv_dphi; + float wgt = s_w[k] * expf(-0.5f * x * x); + mbar += wgt * s_y[k]; + wtot += wgt; + } + } + __syncthreads(); + } + + if (i < nfreqs && wtot > 1e-10f) { + float diff = yj - (mbar / wtot); + total_var_pdm += wj * diff * diff; + } + } + + if (i < nfreqs) { + power[i] = 1.f - total_var_pdm / var_tot_val; + } +} diff --git a/cuvarbase/kernels/sparse_bls.cu b/cuvarbase/kernels/sparse_bls.cu new file mode 100644 index 00000000..16098b15 --- /dev/null +++ b/cuvarbase/kernels/sparse_bls.cu @@ -0,0 +1,327 @@ +#include +#define RESTRICT __restrict__ +#define CONSTANT const +#define MIN_W 1E-9 +// Must be float32-meaningful: 1e-9 underflowed against 1.0f (the bound +// compiled to `W > 1.f`, i.e. no upper guard), so an all-weight box +// divided roundoff by roundoff. Matches bls_common.cuh's bls_value +// bound; sparse_bls_cpu uses the same complement for parity. +#define MAX_W_COMPLEMENT 1E-4 +//{CPP_DEFS} + +/** + * Sparse BLS CUDA Kernel (full version) + * + * Uses bitonic sort (parallel) and prefix sums for O(1) range queries. + * Based on https://arxiv.org/abs/2103.06193 + */ + +__device__ unsigned int get_id(){ + return blockIdx.x * blockDim.x + threadIdx.x; +} + +__device__ float mod1(float a){ + return a - floorf(a); +} + +__device__ float bls_power(float YW, float W, float YY, + unsigned int ignore_negative_delta_sols){ + if (ignore_negative_delta_sols && YW > 0.f) + return 0.f; + + if (W < MIN_W || W > 1.f - MAX_W_COMPLEMENT) + return 0.f; + + float bls = (YW * YW) / (W * (1.f - W) * YY); + return bls; +} + +/** + * Bitonic sort with striding for ndata > blockDim.x + * + * Sorts sh_phi, sh_y, sh_w in parallel using bitonic merge network. + * n_pow2 must be the next power of 2 >= ndata. + * Elements beyond ndata are padded with large values (2.0f). + */ +__device__ void bitonic_sort_by_phase(float* sh_phi, float* sh_y, float* sh_w, + unsigned int ndata, unsigned int n_pow2){ + unsigned int tid = threadIdx.x; + + for (unsigned int k = 2; k <= n_pow2; k *= 2) { + for (unsigned int j = k / 2; j > 0; j /= 2) { + // Each thread handles multiple elements with striding + for (unsigned int idx = tid; idx < n_pow2; idx += blockDim.x) { + unsigned int ixj = idx ^ j; + + if (ixj > idx) { + // Determine sort direction + bool ascending = ((idx & k) == 0); + + // Bounds check: only compare valid elements + float phi_a = sh_phi[idx]; + float phi_b = sh_phi[ixj]; + + bool swap = (phi_a > phi_b) == ascending; + + if (swap) { + sh_phi[idx] = phi_b; + sh_phi[ixj] = phi_a; + + float tmp; + tmp = sh_y[idx]; sh_y[idx] = sh_y[ixj]; sh_y[ixj] = tmp; + tmp = sh_w[idx]; sh_w[idx] = sh_w[ixj]; sh_w[ixj] = tmp; + } + } + } + __syncthreads(); + } + } +} + +/** + * Main sparse BLS kernel + * + * Each thread block handles one frequency. Within each block: + * 1. Compute phases and weights for all observations + * 2. Sort observations by phase using bitonic sort + * 3. Build prefix sums for O(1) range queries + * 4. Test all pairs of observations as transit boundaries (parallel) + * 5. Tree reduce to find maximum BLS + * + * Shared memory layout: + * sh_phi[n_pow2] - phases (padded to power of 2 for bitonic sort) + * sh_y[n_pow2] - y values (padded) + * sh_w[n_pow2] - weights (padded) + * sh_cumsum_w[ndata] - prefix sum of weights + * sh_cumsum_yw[ndata] - prefix sum of w*y + * thread_results[3*blockDim.x] - per-thread (bls, q, phi) for reduction + * + * Total: 3*n_pow2 + 2*ndata + 3*blockDim.x floats + */ +__global__ void sparse_bls_kernel( + const float* __restrict__ t, + const float* __restrict__ y, + const float* __restrict__ dy, + const float* __restrict__ freqs, + const float* __restrict__ qmin_arr, + const float* __restrict__ qmax_arr, + unsigned int ndata, + unsigned int nfreqs, + unsigned int ignore_negative_delta_sols, + float* __restrict__ bls_powers, + float* __restrict__ best_q, + float* __restrict__ best_phi) +{ + extern __shared__ float shared_mem[]; + + // Compute n_pow2 (next power of 2 >= ndata) + unsigned int n_pow2 = 1; + while (n_pow2 < ndata) n_pow2 *= 2; + + float* sh_phi = shared_mem; // n_pow2 floats + float* sh_y = &shared_mem[n_pow2]; // n_pow2 floats + float* sh_w = &shared_mem[2 * n_pow2]; // n_pow2 floats + float* sh_cumsum_w = &shared_mem[3 * n_pow2]; // ndata floats + float* sh_cumsum_yw = &shared_mem[3 * n_pow2 + ndata]; // ndata floats + float* thread_results = &shared_mem[3 * n_pow2 + 2 * ndata]; // 3*blockDim.x + + unsigned int freq_idx = blockIdx.x; + unsigned int tid = threadIdx.x; + + while (freq_idx < nfreqs) { + float freq = freqs[freq_idx]; + float qmin_f = qmin_arr[freq_idx]; + float qmax_f = qmax_arr[freq_idx]; + + // Step 1: Load data and compute phases + for (unsigned int i = tid; i < ndata; i += blockDim.x) { + float phi = mod1(t[i] * freq); + float weight = 1.f / (dy[i] * dy[i]); + + sh_phi[i] = phi; + sh_y[i] = y[i]; + sh_w[i] = weight; + } + + // Pad arrays to n_pow2 for bitonic sort + for (unsigned int i = ndata + tid; i < n_pow2; i += blockDim.x) { + sh_phi[i] = 2.f; // Larger than any valid phase + sh_y[i] = 0.f; + sh_w[i] = 0.f; + } + __syncthreads(); + + // Step 2: Normalize weights + float local_sum = 0.f; + for (unsigned int i = tid; i < ndata; i += blockDim.x) { + local_sum += sh_w[i]; + } + + // Use thread_results[0..blockDim-1] as scratch for reduction + thread_results[tid] = local_sum; + __syncthreads(); + for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) { + if (tid < s && tid + s < blockDim.x) + thread_results[tid] += thread_results[tid + s]; + __syncthreads(); + } + float sum_w = thread_results[0]; + __syncthreads(); + + for (unsigned int i = tid; i < ndata; i += blockDim.x) { + sh_w[i] /= sum_w; + } + __syncthreads(); + + // Step 3: Compute ybar + local_sum = 0.f; + for (unsigned int i = tid; i < ndata; i += blockDim.x) { + local_sum += sh_w[i] * sh_y[i]; + } + thread_results[tid] = local_sum; + __syncthreads(); + for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) { + if (tid < s && tid + s < blockDim.x) + thread_results[tid] += thread_results[tid + s]; + __syncthreads(); + } + float ybar = thread_results[0]; + __syncthreads(); + + // Step 4: Compute YY + local_sum = 0.f; + for (unsigned int i = tid; i < ndata; i += blockDim.x) { + float diff = sh_y[i] - ybar; + local_sum += sh_w[i] * diff * diff; + } + thread_results[tid] = local_sum; + __syncthreads(); + for (unsigned int s = blockDim.x / 2; s > 0; s >>= 1) { + if (tid < s && tid + s < blockDim.x) + thread_results[tid] += thread_results[tid + s]; + __syncthreads(); + } + float YY = thread_results[0]; + __syncthreads(); + + // Step 5: Sort by phase using bitonic sort (parallel, with striding) + bitonic_sort_by_phase(sh_phi, sh_y, sh_w, ndata, n_pow2); + + // Step 6: Compute prefix sums using serial scan on thread 0 + // This is O(N) which is fine for N <= 500 (sparse threshold) + if (tid == 0) { + sh_cumsum_w[0] = sh_w[0]; + sh_cumsum_yw[0] = sh_w[0] * sh_y[0]; + for (unsigned int i = 1; i < ndata; i++) { + sh_cumsum_w[i] = sh_cumsum_w[i-1] + sh_w[i]; + sh_cumsum_yw[i] = sh_cumsum_yw[i-1] + sh_w[i] * sh_y[i]; + } + } + __syncthreads(); + + // Step 7: Parallel pair testing with O(1) range queries + float thread_max_bls = 0.f; + float thread_q = 0.f; + float thread_phi0 = 0.f; + + unsigned int N = ndata; + unsigned int total_nonwrap = N * (N + 1) / 2; + unsigned int total_wrap = N * (N - 1) / 2; + unsigned int total_pairs = total_nonwrap + total_wrap; + + for (unsigned int p = tid; p < total_pairs; p += blockDim.x) { + float phi0, q, W, YW; + + if (p < total_nonwrap) { + // Decode non-wrapped pair (i, j) from flat index + unsigned int idx = p; + unsigned int i = 0; + while (idx >= (N - i)) { + idx -= (N - i); + i++; + } + unsigned int j = i + 1 + idx; // j in [i+1, N] + + phi0 = sh_phi[i]; + + if (j < N) { + q = 0.5f * (sh_phi[j] + sh_phi[j-1]) - phi0; + } else { + q = sh_phi[N - 1] - phi0 + 1e-7f; + } + + if (q <= 0.f || q < qmin_f || q > qmax_f) continue; + + // Use prefix sums for O(1) range query: sum of w[i..j-1] + unsigned int last = (j < N) ? j - 1 : N - 1; + W = (i == 0) ? sh_cumsum_w[last] : sh_cumsum_w[last] - sh_cumsum_w[i - 1]; + YW = (i == 0) ? sh_cumsum_yw[last] : sh_cumsum_yw[last] - sh_cumsum_yw[i - 1]; + YW -= ybar * W; + + } else { + // Decode wrapped pair (i, k) from flat index + unsigned int idx = p - total_nonwrap; + unsigned int i = 1; + while (idx >= i) { + idx -= i; + i++; + } + unsigned int k = idx; // k in [0, i) + + phi0 = sh_phi[i]; + + if (k > 0) { + q = (1.f - phi0) + 0.5f * (sh_phi[k-1] + sh_phi[k]); + } else { + q = 1.f - phi0 + 1e-7f; + } + + if (q <= 0.f || q < qmin_f || q > qmax_f) continue; + + // W = sum(w[i..N-1]) + sum(w[0..k-1]) + W = sh_cumsum_w[N - 1] - (i > 0 ? sh_cumsum_w[i - 1] : 0.f); + YW = sh_cumsum_yw[N - 1] - (i > 0 ? sh_cumsum_yw[i - 1] : 0.f); + + if (k > 0) { + W += sh_cumsum_w[k - 1]; + YW += sh_cumsum_yw[k - 1]; + } + YW -= ybar * W; + } + + float bls = bls_power(YW, W, YY, ignore_negative_delta_sols); + + if (bls > thread_max_bls) { + thread_max_bls = bls; + thread_q = q; + thread_phi0 = phi0; + } + } + + // Step 8: Store thread results and reduce + thread_results[tid] = thread_max_bls; + thread_results[blockDim.x + tid] = thread_q; + thread_results[2 * blockDim.x + tid] = thread_phi0; + __syncthreads(); + + for (unsigned int stride = blockDim.x / 2; stride > 0; stride /= 2) { + if (tid < stride && tid + stride < blockDim.x) { + if (thread_results[tid + stride] > thread_results[tid]) { + thread_results[tid] = thread_results[tid + stride]; + thread_results[blockDim.x + tid] = thread_results[blockDim.x + tid + stride]; + thread_results[2 * blockDim.x + tid] = thread_results[2 * blockDim.x + tid + stride]; + } + } + __syncthreads(); + } + + // Step 9: Write results + if (tid == 0) { + bls_powers[freq_idx] = thread_results[0]; + best_q[freq_idx] = thread_results[blockDim.x]; + best_phi[freq_idx] = thread_results[2 * blockDim.x]; + } + + freq_idx += gridDim.x; + } +} diff --git a/cuvarbase/kernels/tls.cu b/cuvarbase/kernels/tls.cu new file mode 100644 index 00000000..910ea035 --- /dev/null +++ b/cuvarbase/kernels/tls.cu @@ -0,0 +1,487 @@ +/* + * Transit Least Squares (TLS) GPU kernel + * + * Kernel using a limb-darkened transit template for physically + * realistic fitting. Data are staged in shared memory; the depth and + * chi2 accumulations are order-independent, so no phase sort is + * needed (an earlier bitonic sort was pure wasted work and was also + * incomplete for non-power-of-2 sizes). + * + * The transit template is a 1D array mapping transit_coord in [-1, 1] + * to normalized depth in [0, 1], precomputed on the CPU using batman + * (or a trapezoidal fallback) and loaded into shared memory. + * + * References: + * [1] Hippke & Heller (2019), A&A 623, A39 + * [2] Kovacs et al. (2002), A&A 391, 369 + */ + +#include + +//{CPP_DEFS} + +#ifndef BLOCK_SIZE +#define BLOCK_SIZE 128 +#endif + +// No PI macro here: an earlier float32 PI literal was dead code (never +// referenced) and was removed in the Jul 2026 kernel-hygiene pass (same +// audit class as the cunfft.cu A3 fix). This kernel is float32-only by +// design; if pi is ever needed, add it under a DOUBLE_PRECISION guard +// as in cunfft.cu/lomb.cu. +#define WARP_SIZE 32 + +/* + * Epoch (t0) grid: the stride scales with the transit duration + * (stride = duration_phase / T0_OVERSAMPLE) so that narrow transits + * always overlap a tested epoch. The previous fixed 30-point grid + * missed transits narrower than ~1/30 of the period entirely. + * Mirrors cuvarbase.tls_grids.t0_grid_size(). + */ +#ifndef T0_OVERSAMPLE +#define T0_OVERSAMPLE 3.0f +#endif +#ifndef MIN_N_T0 +#define MIN_N_T0 30 +#endif +#ifndef MAX_N_T0 +#define MAX_N_T0 20000 +#endif + +__device__ inline int t0_grid_size(float duration_phase) { + int n_t0 = (int)ceilf(T0_OVERSAMPLE / duration_phase); + if (n_t0 < MIN_N_T0) n_t0 = MIN_N_T0; + if (n_t0 > MAX_N_T0) n_t0 = MAX_N_T0; + return n_t0; +} + +// Device utility functions +__device__ inline float mod1(float x) { + return x - floorf(x); +} + +/** + * Look up transit template value with linear interpolation. + * + * Maps transit_coord in [-1, 1] to template index, does linear + * interpolation between adjacent samples. Returns 0 outside [-1, 1]. + * + * s_template: shared memory pointer to template array + * n_template: number of template samples + * transit_coord: position within transit, [-1, 1] + */ +__device__ float lookup_template(const float* s_template, int n_template, + float transit_coord) +{ + if (transit_coord < -1.0f || transit_coord > 1.0f) + return 0.0f; + + // Map [-1, 1] to [0, n_template - 1] + float idx_f = (transit_coord + 1.0f) * 0.5f * (float)(n_template - 1); + + int idx0 = (int)floorf(idx_f); + int idx1 = idx0 + 1; + + // Clamp + if (idx0 < 0) idx0 = 0; + if (idx1 >= n_template) idx1 = n_template - 1; + if (idx0 >= n_template) idx0 = n_template - 1; + + float frac = idx_f - floorf(idx_f); + + return s_template[idx0] * (1.0f - frac) + s_template[idx1] * frac; +} + +/** + * Calculate optimal transit depth using weighted least squares + * with limb-darkened transit template. + */ +__device__ float calculate_optimal_depth( + const float* y_sh, + const float* dy_sh, + const float* phases_sh, + const float* s_template, + int n_template, + float duration_phase, + float t0_phase, + int ndata) +{ + float numerator = 0.0f; + float denominator = 0.0f; + + float half_dur = duration_phase * 0.5f; + + for (int i = 0; i < ndata; i++) { + float phase_rel = mod1(phases_sh[i] - t0_phase + 0.5f) - 0.5f; + + if (fabsf(phase_rel) < half_dur) { + float transit_coord = phase_rel / half_dur; + float template_val = lookup_template(s_template, n_template, transit_coord); + float sigma2 = dy_sh[i] * dy_sh[i] + 1e-10f; + float y_residual = 1.0f - y_sh[i]; + numerator += y_residual * template_val / sigma2; + denominator += template_val * template_val / sigma2; + } + } + + if (denominator < 1e-10f) return 0.0f; + + float depth = numerator / denominator; + if (depth < 0.0f) depth = 0.0f; + if (depth > 1.0f) depth = 1.0f; + + return depth; +} + +/** + * Calculate chi-squared for a given transit model fit + * using limb-darkened transit template. + */ +__device__ float calculate_chi2( + const float* y_sh, + const float* dy_sh, + const float* phases_sh, + const float* s_template, + int n_template, + float duration_phase, + float t0_phase, + float depth, + int ndata) +{ + float chi2 = 0.0f; + float half_dur = duration_phase * 0.5f; + + for (int i = 0; i < ndata; i++) { + float phase_rel = mod1(phases_sh[i] - t0_phase + 0.5f) - 0.5f; + float model_val; + if (fabsf(phase_rel) < half_dur) { + float transit_coord = phase_rel / half_dur; + float template_val = lookup_template(s_template, n_template, transit_coord); + model_val = 1.0f - depth * template_val; + } else { + model_val = 1.0f; + } + float residual = y_sh[i] - model_val; + float sigma2 = dy_sh[i] * dy_sh[i] + 1e-10f; + chi2 += (residual * residual) / sigma2; + } + + return chi2; +} + +/** + * TLS search kernel with Keplerian duration constraints + * Grid: (nperiods, 1, 1), Block: (BLOCK_SIZE, 1, 1) + * + * Shared memory layout: + * phases[ndata] | y_sh[ndata] | dy_sh[ndata] | + * template[n_template] | thread_chi2[blockDim] | thread_t0[blockDim] | + * thread_dur[blockDim] | thread_depth[blockDim] + */ +extern "C" __global__ void tls_search_kernel_keplerian( + const float* __restrict__ t, + const float* __restrict__ y, + const float* __restrict__ dy, + const float* __restrict__ periods, + const float* __restrict__ qmin, + const float* __restrict__ qmax, + const float* __restrict__ transit_template, + const int ndata, + const int nperiods, + const int n_durations, + const int n_template, + float* __restrict__ chi2_out, + float* __restrict__ best_t0_out, + float* __restrict__ best_duration_out, + float* __restrict__ best_depth_out) +{ + extern __shared__ float shared_mem[]; + float* phases = shared_mem; + float* y_sh = &shared_mem[ndata]; + float* dy_sh = &shared_mem[2 * ndata]; + float* s_template = &shared_mem[3 * ndata]; + float* thread_chi2 = &s_template[n_template]; + float* thread_t0 = &thread_chi2[blockDim.x]; + float* thread_duration = &thread_t0[blockDim.x]; + float* thread_depth = &thread_duration[blockDim.x]; + + int period_idx = blockIdx.x; + if (period_idx >= nperiods) return; + + // Load template from global to shared memory (once per block) + for (int i = threadIdx.x; i < n_template; i += blockDim.x) { + s_template[i] = transit_template[i]; + } + __syncthreads(); + + float period = periods[period_idx]; + float duration_phase_min = qmin[period_idx]; + float duration_phase_max = qmax[period_idx]; + + // Phase fold + for (int i = threadIdx.x; i < ndata; i += blockDim.x) { + phases[i] = mod1(t[i] / period); + } + __syncthreads(); + + // Stage y and dy in shared memory + for (int i = threadIdx.x; i < ndata; i += blockDim.x) { + y_sh[i] = y[i]; + dy_sh[i] = dy[i]; + } + __syncthreads(); + + // Search over durations and T0 using Keplerian constraints + float thread_min_chi2 = 1e30f; + float thread_best_t0 = 0.0f; + float thread_best_duration = 0.0f; + float thread_best_depth = 0.0f; + + for (int d_idx = 0; d_idx < n_durations; d_idx++) { + float log_dur_min = logf(duration_phase_min); + float log_dur_max = logf(duration_phase_max); + float log_duration = log_dur_min + (log_dur_max - log_dur_min) * d_idx / (n_durations - 1); + float duration_phase = expf(log_duration); + float duration = duration_phase * period; + + // Duration-scaled epoch grid (see t0_grid_size above) + int n_t0 = t0_grid_size(duration_phase); + for (int t0_idx = threadIdx.x; t0_idx < n_t0; t0_idx += blockDim.x) { + float t0_phase = (float)t0_idx / n_t0; + float depth = calculate_optimal_depth(y_sh, dy_sh, phases, + s_template, n_template, + duration_phase, t0_phase, ndata); + + if (depth > 0.0f && depth < 0.5f) { + float chi2 = calculate_chi2(y_sh, dy_sh, phases, + s_template, n_template, + duration_phase, t0_phase, depth, ndata); + if (chi2 < thread_min_chi2) { + thread_min_chi2 = chi2; + thread_best_t0 = t0_phase; + thread_best_duration = duration; + thread_best_depth = depth; + } + } + } + } + + // Store per-thread results to shared memory + thread_chi2[threadIdx.x] = thread_min_chi2; + thread_t0[threadIdx.x] = thread_best_t0; + thread_duration[threadIdx.x] = thread_best_duration; + thread_depth[threadIdx.x] = thread_best_depth; + __syncthreads(); + + // Block reduction down to warp size + for (int stride = blockDim.x / 2; stride >= WARP_SIZE; stride /= 2) { + if (threadIdx.x < stride) { + if (thread_chi2[threadIdx.x + stride] < thread_chi2[threadIdx.x]) { + thread_chi2[threadIdx.x] = thread_chi2[threadIdx.x + stride]; + thread_t0[threadIdx.x] = thread_t0[threadIdx.x + stride]; + thread_duration[threadIdx.x] = thread_duration[threadIdx.x + stride]; + thread_depth[threadIdx.x] = thread_depth[threadIdx.x + stride]; + } + } + __syncthreads(); + } + + // Final warp reduction using shuffle (no sync needed) + if (threadIdx.x < WARP_SIZE) { + float val_chi2 = thread_chi2[threadIdx.x]; + float val_t0 = thread_t0[threadIdx.x]; + float val_dur = thread_duration[threadIdx.x]; + float val_dep = thread_depth[threadIdx.x]; + + for (int offset = WARP_SIZE / 2; offset > 0; offset /= 2) { + float other_chi2 = __shfl_down_sync(0xffffffff, val_chi2, offset); + float other_t0 = __shfl_down_sync(0xffffffff, val_t0, offset); + float other_dur = __shfl_down_sync(0xffffffff, val_dur, offset); + float other_dep = __shfl_down_sync(0xffffffff, val_dep, offset); + + if (other_chi2 < val_chi2) { + val_chi2 = other_chi2; + val_t0 = other_t0; + val_dur = other_dur; + val_dep = other_dep; + } + } + + if (threadIdx.x == 0) { + thread_chi2[0] = val_chi2; + thread_t0[0] = val_t0; + thread_duration[0] = val_dur; + thread_depth[0] = val_dep; + } + } + + // Write final result + if (threadIdx.x == 0) { + chi2_out[period_idx] = thread_chi2[0]; + best_t0_out[period_idx] = thread_t0[0]; + best_duration_out[period_idx] = thread_duration[0]; + best_depth_out[period_idx] = thread_depth[0]; + } +} + +/** + * TLS search kernel (standard, fixed duration range) + * Grid: (nperiods, 1, 1), Block: (BLOCK_SIZE, 1, 1) + * + * RETAINED FOR API COMPATIBILITY ONLY (compile_tls()['standard']): no + * Python wrapper launches it since 1.0. Its hard-coded duration window + * [0.005, 0.15] is unphysical beyond P ~ 60 d for a Sun-like star + * (audit defect 2, tls-duration-window); every legacy-path search now + * runs tls_search_kernel_keplerian with per-period bounds (the + * Keplerian default, or the fixed window passed as constant arrays, + * which is bit-identical to this kernel's trial grid). + * + * Shared memory layout: + * phases[ndata] | y_sh[ndata] | dy_sh[ndata] | + * template[n_template] | thread_chi2[blockDim] | thread_t0[blockDim] | + * thread_dur[blockDim] | thread_depth[blockDim] + */ +extern "C" __global__ void tls_search_kernel( + const float* __restrict__ t, + const float* __restrict__ y, + const float* __restrict__ dy, + const float* __restrict__ periods, + const float* __restrict__ transit_template, + const int ndata, + const int nperiods, + const int n_template, + float* __restrict__ chi2_out, + float* __restrict__ best_t0_out, + float* __restrict__ best_duration_out, + float* __restrict__ best_depth_out) +{ + extern __shared__ float shared_mem[]; + float* phases = shared_mem; + float* y_sh = &shared_mem[ndata]; + float* dy_sh = &shared_mem[2 * ndata]; + float* s_template = &shared_mem[3 * ndata]; + float* thread_chi2 = &s_template[n_template]; + float* thread_t0 = &thread_chi2[blockDim.x]; + float* thread_duration = &thread_t0[blockDim.x]; + float* thread_depth = &thread_duration[blockDim.x]; + + int period_idx = blockIdx.x; + if (period_idx >= nperiods) return; + + // Load template from global to shared memory (once per block) + for (int i = threadIdx.x; i < n_template; i += blockDim.x) { + s_template[i] = transit_template[i]; + } + __syncthreads(); + + float period = periods[period_idx]; + + // Phase fold + for (int i = threadIdx.x; i < ndata; i += blockDim.x) { + phases[i] = mod1(t[i] / period); + } + __syncthreads(); + + // Stage y and dy in shared memory + for (int i = threadIdx.x; i < ndata; i += blockDim.x) { + y_sh[i] = y[i]; + dy_sh[i] = dy[i]; + } + __syncthreads(); + + // Search over durations and T0 + float thread_min_chi2 = 1e30f; + float thread_best_t0 = 0.0f; + float thread_best_duration = 0.0f; + float thread_best_depth = 0.0f; + + int n_durations = 15; + float duration_phase_min = 0.005f; + float duration_phase_max = 0.15f; + + for (int d_idx = 0; d_idx < n_durations; d_idx++) { + float log_dur_min = logf(duration_phase_min); + float log_dur_max = logf(duration_phase_max); + float log_duration = log_dur_min + (log_dur_max - log_dur_min) * d_idx / (n_durations - 1); + float duration_phase = expf(log_duration); + float duration = duration_phase * period; + + // Duration-scaled epoch grid (see t0_grid_size above) + int n_t0 = t0_grid_size(duration_phase); + for (int t0_idx = threadIdx.x; t0_idx < n_t0; t0_idx += blockDim.x) { + float t0_phase = (float)t0_idx / n_t0; + float depth = calculate_optimal_depth(y_sh, dy_sh, phases, + s_template, n_template, + duration_phase, t0_phase, ndata); + + if (depth > 0.0f && depth < 0.5f) { + float chi2 = calculate_chi2(y_sh, dy_sh, phases, + s_template, n_template, + duration_phase, t0_phase, depth, ndata); + if (chi2 < thread_min_chi2) { + thread_min_chi2 = chi2; + thread_best_t0 = t0_phase; + thread_best_duration = duration; + thread_best_depth = depth; + } + } + } + } + + // Store per-thread results to shared memory + thread_chi2[threadIdx.x] = thread_min_chi2; + thread_t0[threadIdx.x] = thread_best_t0; + thread_duration[threadIdx.x] = thread_best_duration; + thread_depth[threadIdx.x] = thread_best_depth; + __syncthreads(); + + // Block reduction down to warp size + for (int stride = blockDim.x / 2; stride >= WARP_SIZE; stride /= 2) { + if (threadIdx.x < stride) { + if (thread_chi2[threadIdx.x + stride] < thread_chi2[threadIdx.x]) { + thread_chi2[threadIdx.x] = thread_chi2[threadIdx.x + stride]; + thread_t0[threadIdx.x] = thread_t0[threadIdx.x + stride]; + thread_duration[threadIdx.x] = thread_duration[threadIdx.x + stride]; + thread_depth[threadIdx.x] = thread_depth[threadIdx.x + stride]; + } + } + __syncthreads(); + } + + // Final warp reduction using shuffle (no sync needed) + if (threadIdx.x < WARP_SIZE) { + float val_chi2 = thread_chi2[threadIdx.x]; + float val_t0 = thread_t0[threadIdx.x]; + float val_dur = thread_duration[threadIdx.x]; + float val_dep = thread_depth[threadIdx.x]; + + for (int offset = WARP_SIZE / 2; offset > 0; offset /= 2) { + float other_chi2 = __shfl_down_sync(0xffffffff, val_chi2, offset); + float other_t0 = __shfl_down_sync(0xffffffff, val_t0, offset); + float other_dur = __shfl_down_sync(0xffffffff, val_dur, offset); + float other_dep = __shfl_down_sync(0xffffffff, val_dep, offset); + + if (other_chi2 < val_chi2) { + val_chi2 = other_chi2; + val_t0 = other_t0; + val_dur = other_dur; + val_dep = other_dep; + } + } + + if (threadIdx.x == 0) { + thread_chi2[0] = val_chi2; + thread_t0[0] = val_t0; + thread_duration[0] = val_dur; + thread_depth[0] = val_dep; + } + } + + // Write final result + if (threadIdx.x == 0) { + chi2_out[period_idx] = thread_chi2[0]; + best_t0_out[period_idx] = thread_t0[0]; + best_duration_out[period_idx] = thread_duration[0]; + best_depth_out[period_idx] = thread_depth[0]; + } +} diff --git a/cuvarbase/kernels/tls_fast.cu b/cuvarbase/kernels/tls_fast.cu new file mode 100644 index 00000000..fcdd2551 --- /dev/null +++ b/cuvarbase/kernels/tls_fast.cu @@ -0,0 +1,707 @@ +/* + * Fast Transit Least Squares (TLS) GPU kernel — batch-native. + * + * Algorithmic differences from tls.cu (the reference kernel): + * + * 1. Closed-form chi2. For the weighted least-squares transit fit with + * template T and depth d, chi2(d) = chi2_0 - 2 d num + d^2 den with + * num = sum_i (1 - y_i) T_i / sigma_i^2 + * den = sum_i T_i^2 / sigma_i^2 + * chi2_0 = sum_i (y_i - 1)^2 / sigma_i^2 (per-lightcurve constant) + * At the optimal depth d* = num/den, chi2 = chi2_0 - num^2/den, so a + * single accumulation pass yields both the depth and the chi2 — the + * reference kernel's second full-data chi2 pass is redundant. + * Minimizing chi2 over trials is exactly maximizing num^2/den, so the + * per-period argmin never suffers cancellation against chi2_0. + * + * 2. Phase-binned evaluation. Each block folds its lightcurve at its + * period ONCE into NBINS phase bins (A_k = sum (1-y)/sigma^2, + * B_k = sum 1/sigma^2), then every (duration, t0) trial integrates + * only the ~q*NBINS bins inside the transit window instead of + * scanning all ndata points. This removes both the O(ndata) factor + * from the trial loop and the shared-memory cap on ndata (raw data + * stay in global memory and are read exactly once per period). + * + * 3. Integrated template tables. S1(x) = int_{-1}^{x} T dx and + * S2(x) = int_{-1}^{x} T^2 dx are precomputed on the CPU + * (tls_models.generate_template_integrals). The bin-averaged + * template over a bin's transit-coordinate span [c0, c1] is + * (S1(c1)-S1(c0))/(c1-c0): area sampling rather than point + * sampling. This reduces quadrature error; phase compression still + * loses within-bin information and can reduce sensitivity. + * + * 4. Batch-native. Grid is (nperiods, nlc); per-lightcurve data are + * concatenated with offset/length arrays. A whole survey chunk is a + * single kernel launch sharing one period grid and one template. + * + * The (duration, t0) trial grid is IDENTICAL to tls.cu: n_durations + * log-spaced durations in [qmin, qmax], t0 = j/n_t0 with + * n_t0 = clamp(ceil(T0_OVERSAMPLE/q), MIN_N_T0, MAX_N_T0), and the + * same validity gate 0 < depth < 0.5. + * + * References: + * [1] Hippke & Heller (2019), A&A 623, A39 + * [2] Kovacs et al. (2002), A&A 391, 369 + */ + +#include + +//{CPP_DEFS} + +#ifndef BLOCK_SIZE +#define BLOCK_SIZE 128 +#endif + +/* Number of phase bins; must be a power of two (wrap uses a mask). */ +#ifndef NBINS +#define NBINS 2048 +#endif + +/* Number of intervals in the integrated template tables (tables have + * NTEMPLATE+1 entries). Must match the Python-side table length. */ +#ifndef NTEMPLATE +#define NTEMPLATE 1024 +#endif + +/* Maximum n_durations supported by the per-duration shared staging. */ +#ifndef MAX_DURATIONS +#define MAX_DURATIONS 64 +#endif + +/* Number of local durations scanned by the refinement kernel (odd). */ +#ifndef REFINE_ND +#define REFINE_ND 3 +#endif + +#ifndef T0_OVERSAMPLE +#define T0_OVERSAMPLE 3.0f +#endif +#ifndef MIN_N_T0 +#define MIN_N_T0 30 +#endif +#ifndef MAX_N_T0 +#define MAX_N_T0 20000 +#endif + +#define WARP_SIZE 32 + +/* Keep the original dense loop available for numerical/performance + * comparisons. This changes only how zero-weight bins are visited, + * never the template, histogram resolution, or trial grid. */ +#ifndef TLS_SKIP_EMPTY_BINS +#define TLS_SKIP_EMPTY_BINS 1 +#endif + +/* The reduction workspace is unused until the trial scan finishes. + * Reuse it for an occupancy bitmap, next-nonempty-word links, and the + * population count. Small block-size overrides that cannot hold this + * workspace simply retain the dense loop. */ +#if TLS_SKIP_EMPTY_BINS && NBINS >= 1024 && \ + (2 * NBINS / WARP_SIZE + 1 <= 4 * BLOCK_SIZE) +#define TLS_SPARSE_SCAN_AVAILABLE 1 +#else +#define TLS_SPARSE_SCAN_AVAILABLE 0 +#endif + +__device__ inline float mod1f(float x) { + return x - floorf(x); +} + +__device__ inline int t0_grid_size(float duration_phase) { + int n_t0 = (int)ceilf(T0_OVERSAMPLE / duration_phase); + if (n_t0 < MIN_N_T0) n_t0 = MIN_N_T0; + if (n_t0 > MAX_N_T0) n_t0 = MAX_N_T0; + return n_t0; +} + +/* + * Evaluate an integrated table S (NTEMPLATE+1 entries spanning + * x in [-1, 1]) at x, with linear interpolation. Outside [-1, 1] the + * template is zero, so S saturates at its endpoint values. + */ +__device__ inline float lookup_integral(const float* __restrict__ S, float x) +{ + float idx_f = (x + 1.0f) * (0.5f * (float)NTEMPLATE); + idx_f = fminf(fmaxf(idx_f, 0.0f), (float)NTEMPLATE); + int i0 = (int)idx_f; + if (i0 >= NTEMPLATE) i0 = NTEMPLATE - 1; + float frac = idx_f - (float)i0; + return S[i0] + (S[i0 + 1] - S[i0]) * frac; +} + +/* + * Fast TLS search kernel (batch-native, Keplerian duration constraints). + * + * Grid: (nperiods, nlc, 1); Block: (BLOCK_SIZE, 1, 1) + * + * Inputs (global memory): + * t_hi_all, t_lo_all, a_all, b_all : concatenated per-point arrays; + * t is stored as an epoch-subtracted float-float pair + * (t = t_hi + t_lo to float64 precision), and for point i, + * a = (1 - y)/sigma^2 and b = 1/sigma^2 + * (sigma^2 includes the +1e-10 regularizer, matching tls.cu) + * lc_off, lc_len : per-lightcurve offset/length into the above + * periods[nperiods_band], qmin[...], qmax[...] : the trial grid FOR + * THIS LAUNCH. The host may split the full grid into bands that + * compile with different NBINS (narrow durations need finer + * bins; the scan cost is proportional to NBINS, so coarse bands + * should not pay the finest band's price). + * period_map[nperiods_band] : global period index of each band entry + * (identity when the grid is not banded) + * S1, S2 : integrated template tables (NTEMPLATE+1 entries each) + * + * Outputs, laid out as [lc * nperiods_total + period_map[band idx]]: + * score_out (num^2/den = chi2_0 - chi2; <= 0 marks a failed period; + * the host reconstructs chi2 in float64), best_t0_out, + * best_duration_out, best_depth_out + * + * Shared memory layout (floats): + * A[NBINS] | B[NBINS] | S1[NTEMPLATE+1] | S2[NTEMPLATE+1] | + * red_score[BLOCK_SIZE] | red_t0[BLOCK_SIZE] | red_dur[BLOCK_SIZE] | + * red_depth[BLOCK_SIZE] | dur_q[MAX_DURATIONS] | dur_cum[MAX_DURATIONS+1] + */ +extern "C" __global__ void tls_fast_search_kernel( + const float* __restrict__ t_hi_all, + const float* __restrict__ t_lo_all, + const float* __restrict__ a_all, + const float* __restrict__ b_all, + const int* __restrict__ lc_off, + const int* __restrict__ lc_len, + const float* __restrict__ periods, + const float* __restrict__ qmin, + const float* __restrict__ qmax, + const int* __restrict__ period_map, + const float* __restrict__ S1_g, + const float* __restrict__ S2_g, + const int nperiods_band, + const int nperiods_total, + const int n_durations, + float* __restrict__ score_out, + float* __restrict__ best_t0_out, + float* __restrict__ best_duration_out, + float* __restrict__ best_depth_out) +{ + extern __shared__ float shared_mem[]; + float* A = shared_mem; + float* B = &A[NBINS]; + float* S1 = &B[NBINS]; + float* S2 = &S1[NTEMPLATE + 1]; + float* red_score = &S2[NTEMPLATE + 1]; + float* red_t0 = &red_score[BLOCK_SIZE]; + float* red_dur = &red_t0[BLOCK_SIZE]; + float* red_depth = &red_dur[BLOCK_SIZE]; + float* dur_q = &red_depth[BLOCK_SIZE]; + /* trial-index prefix sums per duration (stored as float-cast ints + * would lose precision above 2^24; keep a separate int view) */ + int* dur_cum = (int*)&dur_q[MAX_DURATIONS]; + + const int period_idx = blockIdx.x; + const int lc_idx = blockIdx.y; + if (period_idx >= nperiods_band) return; + + const int off = lc_off[lc_idx]; + const int nd = lc_len[lc_idx]; + const float period = periods[period_idx]; + + /* --- Stage integrated template tables and zero the bins --- */ + for (int i = threadIdx.x; i < NTEMPLATE + 1; i += blockDim.x) { + S1[i] = S1_g[i]; + S2[i] = S2_g[i]; + } + for (int i = threadIdx.x; i < NBINS; i += blockDim.x) { + A[i] = 0.0f; + B[i] = 0.0f; + } + + /* --- Per-duration trial bookkeeping (one thread; tiny) --- */ + if (threadIdx.x == 0) { + float lqmin = logf(qmin[period_idx]); + float lqmax = logf(qmax[period_idx]); + int cum = 0; + for (int d = 0; d < n_durations; d++) { + float lq = (n_durations > 1) + ? lqmin + (lqmax - lqmin) * d / (n_durations - 1) + : lqmin; + float q = expf(lq); + dur_q[d] = q; + dur_cum[d] = cum; + cum += t0_grid_size(q); + } + dur_cum[n_durations] = cum; + } + __syncthreads(); + + /* --- Fold and bin the lightcurve at this period --- + * Float-float ("double-single") fold: at plain float32, t/P for a + * 1,400-day baseline and a short period carries a phase error of + * ~1e-4 — the size of a whole bin. Times are stored as a hi/lo + * float32 pair (t = t_hi + t_lo exactly to float64 precision) and + * the period reciprocal is split the same way, so the fractional + * phase is recovered to ~1e-7 with pure FP32 FMAs. This avoids + * double-precision math, which runs at 1/64 rate on consumer GPUs + * and would otherwise dominate the whole kernel at large ndata. */ + const double inv_period_d = 1.0 / (double)period; + const float inv_hi = (float)inv_period_d; + const float inv_lo = (float)(inv_period_d - (double)inv_hi); + for (int i = threadIdx.x; i < nd; i += blockDim.x) { + const float th = t_hi_all[off + i]; + const float tl = t_lo_all[off + i]; + float u = th * inv_hi; + float e = fmaf(th, inv_hi, -u); /* exact product residual */ + float c = fmaf(th, inv_lo, fmaf(tl, inv_hi, e)); + float phi = (u - floorf(u)) + c; + phi -= floorf(phi); + int k = (int)(phi * (float)NBINS); + k &= (NBINS - 1); + atomicAdd(&A[k], a_all[off + i]); + atomicAdd(&B[k], b_all[off + i]); + } + __syncthreads(); + +#if TLS_SPARSE_SCAN_AVAILABLE + /* At fine resolutions sparse lightcurves leave most phase bins + * empty. Build a forward link across each empty run, using the + * empty B entries themselves; all nonzero A/B entries retain + * their original values. This has no extra shared-memory cost. + * A negative B value is metadata, never a statistical weight. + * + * Restrict this path to lightcurves with fewer than NBINS/4 + * observations, guaranteeing that at least 75% of bins are empty. + * At intermediate occupancy, preserving the dense coordinate + * arithmetic across every gap can cost more than the saved + * integral lookups. Dense data avoid the preparation altogether. + */ + bool sparse_scan = false; + if (nd < NBINS / 4) { + const int n_words = NBINS / WARP_SIZE; + unsigned int* occupied = (unsigned int*)red_score; + unsigned int* next_word = &occupied[n_words + 1]; + const int lane = threadIdx.x & (WARP_SIZE - 1); + for (int word = threadIdx.x / WARP_SIZE; word < n_words; + word += blockDim.x / WARP_SIZE) { + const int k = word * WARP_SIZE + lane; + const unsigned int mask = __ballot_sync( + 0xffffffff, A[k] != 0.0f || B[k] != 0.0f); + if (lane == 0) occupied[word] = mask; + } + __syncthreads(); + if (threadIdx.x == 0) { + unsigned int count = 0; + for (int word = 0; word < n_words; word++) + count += __popc(occupied[word]); + occupied[n_words] = count; + } + __syncthreads(); + const unsigned int count = occupied[n_words]; + sparse_scan = count > 0 && count < NBINS / 2; + /* All threads must read the decision before any thread can + * reuse this workspace for the final block reduction. */ + __syncthreads(); + if (sparse_scan) { + /* Search empty runs at the word level once, not once + * for every bin in the run. This also bounds setup work + * for pathological lightcurves concentrated in a few + * phase bins. */ + for (int word = threadIdx.x; word < n_words; + word += blockDim.x) { + int next = word; + do { + next = (next + 1) & (n_words - 1); + } while (!occupied[next]); + next_word[word] = next; + } + __syncthreads(); + for (int k = threadIdx.x; k < NBINS; k += blockDim.x) { + if (A[k] == 0.0f && B[k] == 0.0f) { + const int lane_k = k & (WARP_SIZE - 1); + int word = k / WARP_SIZE; + /* Strictly later bits; the expression is also + * well defined for lane_k == 31 (result zero). */ + unsigned int mask = occupied[word] + & (0xfffffffeu << lane_k); + if (!mask) { + word = next_word[word]; + mask = occupied[word]; + } + const int next = word * WARP_SIZE + __ffs(mask) - 1; + const int jump = (next - k) & (NBINS - 1); + B[k] = -(float)jump; + } + } + __syncthreads(); + } + } +#endif + + const int total_trials = dur_cum[n_durations]; + + /* --- Scan all (duration, t0) trials, flattened across threads --- */ + float best_score = -1.0f; /* score = num^2/den = chi2_0 - chi2 */ + float best_t0 = 0.0f; + float best_dur = 0.0f; + float best_depth = 0.0f; + + int d_idx = 0; + for (int trial = threadIdx.x; trial < total_trials; trial += blockDim.x) { + /* locate the duration bucket (monotonically increasing) */ + while (dur_cum[d_idx + 1] <= trial) d_idx++; + + const float q = dur_q[d_idx]; + const float hd = 0.5f * q; + const float inv_hd = 1.0f / hd; + const int n_t0 = dur_cum[d_idx + 1] - dur_cum[d_idx]; + const int t0_idx = trial - dur_cum[d_idx]; + const float t0 = (float)t0_idx / (float)n_t0; + + /* bins overlapping the window [t0 - hd, t0 + hd] */ + const float invNB = 1.0f / (float)NBINS; + int k0 = (int)floorf((t0 - hd) * (float)NBINS); + int k1 = (int)ceilf((t0 + hd) * (float)NBINS) - 1; + /* q >= 1 is rejected host-side; belt-and-braces so a rogue + * window can never visit a bin twice */ + if (k1 - k0 >= NBINS) k1 = k0 + NBINS - 1; + + /* transit coordinate of bin kk's left edge, and per-bin span */ + const float dc = invNB * inv_hd; + + float num = 0.0f; + float den = 0.0f; + float c0 = ((float)k0 * invNB - t0) * inv_hd; +#if TLS_SPARSE_SCAN_AVAILABLE + if (sparse_scan) { + float s1_prev = lookup_integral(S1, c0); + float s2_prev = lookup_integral(S2, c0); + int kk = k0; + while (kk <= k1) { + int k = kk & (NBINS - 1); + if (B[k] < 0.0f) { + const int jump = (int)-B[k]; + kk += jump; + if (kk > k1) break; + /* Preserve the dense loop's float32 coordinates. + * Replacing these additions with dc*jump changes + * rounding; subtracting nearly equal cumulative + * template integrals at a transit edge can amplify + * that tiny shift into a material score change. + * Empty bins still need no table or weight loads. */ + for (int skipped = 0; skipped < jump; skipped++) + c0 += dc; + k = kk & (NBINS - 1); + s1_prev = lookup_integral(S1, c0); + s2_prev = lookup_integral(S2, c0); + } + const float c1 = c0 + dc; + const float s1_next = lookup_integral(S1, c1); + const float s2_next = lookup_integral(S2, c1); + num += A[k] * (s1_next - s1_prev); + den += B[k] * (s2_next - s2_prev); + s1_prev = s1_next; + s2_prev = s2_next; + c0 = c1; + kk++; + } + } else +#endif + { + float s1_prev = lookup_integral(S1, c0); + float s2_prev = lookup_integral(S2, c0); + for (int kk = k0; kk <= k1; kk++) { + int k = kk & (NBINS - 1); + float c1 = c0 + dc; + float s1_next = lookup_integral(S1, c1); + float s2_next = lookup_integral(S2, c1); + num += A[k] * (s1_next - s1_prev); + den += B[k] * (s2_next - s2_prev); + s1_prev = s1_next; + s2_prev = s2_next; + c0 = c1; + } + } + /* bin-average scale: 1/(c1-c0) = hd*NBINS applied once */ + const float scale = hd * (float)NBINS; + num *= scale; + den *= scale; + + if (den > 1e-10f && num > 0.0f) { + float depth = num / den; + if (depth < 0.5f) { + float score = num * depth; /* num^2/den */ + if (score > best_score) { + best_score = score; + best_t0 = t0; + best_dur = q * period; + best_depth = depth; + } + } + } + } + + /* --- Block reduction (max score) --- */ + red_score[threadIdx.x] = best_score; + red_t0[threadIdx.x] = best_t0; + red_dur[threadIdx.x] = best_dur; + red_depth[threadIdx.x] = best_depth; + __syncthreads(); + + for (int stride = blockDim.x / 2; stride >= WARP_SIZE; stride /= 2) { + if (threadIdx.x < stride) { + if (red_score[threadIdx.x + stride] > red_score[threadIdx.x]) { + red_score[threadIdx.x] = red_score[threadIdx.x + stride]; + red_t0[threadIdx.x] = red_t0[threadIdx.x + stride]; + red_dur[threadIdx.x] = red_dur[threadIdx.x + stride]; + red_depth[threadIdx.x] = red_depth[threadIdx.x + stride]; + } + } + __syncthreads(); + } + + if (threadIdx.x < WARP_SIZE) { + float v_score = red_score[threadIdx.x]; + float v_t0 = red_t0[threadIdx.x]; + float v_dur = red_dur[threadIdx.x]; + float v_dep = red_depth[threadIdx.x]; + + for (int offset = WARP_SIZE / 2; offset > 0; offset /= 2) { + float o_score = __shfl_down_sync(0xffffffff, v_score, offset); + float o_t0 = __shfl_down_sync(0xffffffff, v_t0, offset); + float o_dur = __shfl_down_sync(0xffffffff, v_dur, offset); + float o_dep = __shfl_down_sync(0xffffffff, v_dep, offset); + if (o_score > v_score) { + v_score = o_score; + v_t0 = o_t0; + v_dur = o_dur; + v_dep = o_dep; + } + } + + if (threadIdx.x == 0) { + const size_t out_idx = (size_t)lc_idx * nperiods_total + + period_map[period_idx]; + /* Write the SCORE (delta-chi2 = num^2/den = chi2_0 - chi2), + * not chi2 itself: subtracting from the large per-LC + * constant in float32 would quantize the spectrum by + * ulp(chi2_0) ~ 6e-8 * ndata. The host reconstructs + * chi2 = chi2_0 - score in float64. score <= 0 marks a + * period with no valid trial. */ + if (v_score > 0.0f) { + score_out[out_idx] = v_score; + best_t0_out[out_idx] = v_t0; + best_duration_out[out_idx] = v_dur; + best_depth_out[out_idx] = v_dep; + } else { + score_out[out_idx] = -1.0f; + best_t0_out[out_idx] = 0.0f; + best_duration_out[out_idx] = 0.0f; + best_depth_out[out_idx] = 0.0f; + } + } + } +} + +/* + * Exact refinement kernel. + * + * The binned scan uses a coarse epoch grid and smears each point's + * template weight over its bin. This kernel re-evaluates the best + * candidate periods per lightcurve EXACTLY (per-point template lookup, + * no binning) on a fine local (duration, t0) grid centered on the + * coarse solution. + * + * Results go to separate compact per-candidate outputs — the coarse + * per-period spectrum is left untouched. Detection statistics (SDE) + * must be computed from a UNIFORM-fidelity spectrum: a finer trial + * grid digs deeper chi2 minima everywhere (noise included), so mixing + * refined values into the coarse spectrum — or refining everything — + * shifts the SR distribution and deflates the SDE scale that the + * legacy kernel and its calibrated thresholds established. Refinement + * therefore only sharpens the best-fit parameters (period choice among + * the candidates, t0, duration, depth, chi2_min). + * + * Grid: (n_candidates, nlc, 1); Block: (BLOCK_SIZE, 1, 1) + * cand_period_idx[lc * n_candidates + c] gives the period index to + * refine (a value < 0 disables that slot). + * + * Trial layout per candidate: REFINE_ND durations log-spaced within + * [q0/dur_span, q0*dur_span] (bracketing one coarse duration-grid + * step), each with n_t0_local epochs spanning +/- t0_halfwidth around + * the coarse t0 at stride q/refine_oversample. + * + * Each trial is owned by one warp-group slice of the block: trials are + * distributed round-robin over (blockDim/WARP_SIZE) warps; a warp + * accumulates num/den over all points with lane-strided reads and + * reduces with shuffles. Points stream from global memory (coalesced); + * the point template T is staged in shared memory. + * + * Shared memory layout (floats): + * T[NTEMPLATE + 1] | warp_best[4 * (BLOCK_SIZE/WARP_SIZE)] + */ +extern "C" __global__ void tls_refine_kernel( + const float* __restrict__ t_hi_all, + const float* __restrict__ t_lo_all, + const float* __restrict__ a_all, + const float* __restrict__ b_all, + const int* __restrict__ lc_off, + const int* __restrict__ lc_len, + const float* __restrict__ periods, + const int* __restrict__ cand_period_idx, + const float* __restrict__ T_g, + const int nperiods, + const int n_candidates, + const float dur_span, /* e.g. one coarse log-step, ~1.10 */ + const float t0_halfwidth_frac, /* halfwidth in units of duration */ + const float refine_oversample, /* t0 stride = q / refine_oversample */ + const float* __restrict__ coarse_t0_in, + const float* __restrict__ coarse_duration_in, + float* __restrict__ refined_score_out, /* [lc * n_candidates + c] */ + float* __restrict__ refined_t0_out, + float* __restrict__ refined_duration_out, + float* __restrict__ refined_depth_out) +{ + extern __shared__ float shared_mem[]; + float* T_sh = shared_mem; + float* warp_best = &T_sh[NTEMPLATE + 1]; /* 4 floats per warp */ + + const int cand_idx = blockIdx.x; + const int lc_idx = blockIdx.y; + if (cand_idx >= n_candidates) return; + + const size_t slot = (size_t)lc_idx * n_candidates + cand_idx; + const int period_idx = cand_period_idx[slot]; + + /* disabled or sentinel candidates still need a sentinel output */ + float coarse_dur = 0.0f, coarse_t0 = 0.0f, period = 1.0f; + if (period_idx >= 0) { + const size_t in_idx = (size_t)lc_idx * nperiods + period_idx; + coarse_dur = coarse_duration_in[in_idx]; + coarse_t0 = coarse_t0_in[in_idx]; + period = periods[period_idx]; + } + if (period_idx < 0 || coarse_dur <= 0.0f) { + if (threadIdx.x == 0) { + refined_score_out[slot] = -1.0f; + refined_t0_out[slot] = 0.0f; + refined_duration_out[slot] = 0.0f; + refined_depth_out[slot] = 0.0f; + } + return; + } + + const int off = lc_off[lc_idx]; + const int nd = lc_len[lc_idx]; + const double inv_period_d = 1.0 / (double)period; + const float inv_hi = (float)inv_period_d; + const float inv_lo = (float)(inv_period_d - (double)inv_hi); + const float q0 = coarse_dur / period; + + for (int i = threadIdx.x; i < NTEMPLATE + 1; i += blockDim.x) { + T_sh[i] = T_g[i]; + } + __syncthreads(); + + const int warp_id = threadIdx.x / WARP_SIZE; + const int lane = threadIdx.x % WARP_SIZE; + const int n_warps = blockDim.x / WARP_SIZE; + + /* local trial grid */ + const float lq0 = logf(q0); + const float ldspan = logf(dur_span); + const int n_dur_local = REFINE_ND; /* compile-time, odd, e.g. 5 */ + + float w_best_score = -1.0f; + float w_best_t0 = 0.0f, w_best_dur = 0.0f, w_best_depth = 0.0f; + + /* count t0 trials for the central duration to fix the grid size + * (same count reused for all durations so trial indexing is flat) */ + const float t0_hw = t0_halfwidth_frac * q0; + const float dt0 = q0 / refine_oversample; + int n_t0_local = 2 * (int)ceilf(t0_hw / dt0) + 1; + + const int total_trials = n_dur_local * n_t0_local; + + for (int trial = warp_id; trial < total_trials; trial += n_warps) { + const int d_i = trial / n_t0_local; + const int t0_i = trial % n_t0_local; + + const float lq = lq0 + ldspan * (2.0f * d_i / (n_dur_local - 1) - 1.0f); + const float q = expf(lq); + const float hd = 0.5f * q; + const float inv_hd = 1.0f / hd; + float t0 = coarse_t0 + dt0 * (float)(t0_i - n_t0_local / 2); + t0 = t0 - floorf(t0); /* wrap to [0, 1) */ + + float num = 0.0f; + float den = 0.0f; + for (int i = lane; i < nd; i += WARP_SIZE) { + const float th = t_hi_all[off + i]; + const float tl = t_lo_all[off + i]; + float u = th * inv_hi; + float e = fmaf(th, inv_hi, -u); + float cc = fmaf(th, inv_lo, fmaf(tl, inv_hi, e)); + float phi = (u - floorf(u)) + cc; + phi -= floorf(phi); + float rel = phi - t0; + rel -= rintf(rel); /* wrap to [-0.5, 0.5] */ + float c = rel * inv_hd; + if (fabsf(c) < 1.0f) { + /* point template lookup (linear interpolation) */ + float idx_f = (c + 1.0f) * (0.5f * (float)NTEMPLATE); + int i0 = (int)idx_f; + if (i0 >= NTEMPLATE) i0 = NTEMPLATE - 1; + float frac = idx_f - (float)i0; + float Tv = T_sh[i0] + (T_sh[i0 + 1] - T_sh[i0]) * frac; + num += a_all[off + i] * Tv; + den += b_all[off + i] * Tv * Tv; + } + } + /* warp reduction of the two partial sums */ + for (int offset = WARP_SIZE / 2; offset > 0; offset /= 2) { + num += __shfl_down_sync(0xffffffff, num, offset); + den += __shfl_down_sync(0xffffffff, den, offset); + } + + if (lane == 0 && den > 1e-10f && num > 0.0f) { + float depth = num / den; + if (depth < 0.5f) { + float score = num * depth; + if (score > w_best_score) { + w_best_score = score; + w_best_t0 = t0; + w_best_dur = q * period; + w_best_depth = depth; + } + } + } + } + + /* combine warp winners via shared memory (few warps; lane 0 only) */ + if (lane == 0) { + warp_best[4 * warp_id + 0] = w_best_score; + warp_best[4 * warp_id + 1] = w_best_t0; + warp_best[4 * warp_id + 2] = w_best_dur; + warp_best[4 * warp_id + 3] = w_best_depth; + } + __syncthreads(); + + if (threadIdx.x == 0) { + float b_score = -1.0f, b_t0 = 0.0f, b_dur = 0.0f, b_dep = 0.0f; + for (int w = 0; w < n_warps; w++) { + if (warp_best[4 * w] > b_score) { + b_score = warp_best[4 * w]; + b_t0 = warp_best[4 * w + 1]; + b_dur = warp_best[4 * w + 2]; + b_dep = warp_best[4 * w + 3]; + } + } + if (b_score > 0.0f) { + refined_score_out[slot] = b_score; + refined_t0_out[slot] = b_t0; + refined_duration_out[slot] = b_dur; + refined_depth_out[slot] = b_dep; + } else { + refined_score_out[slot] = -1.0f; + refined_t0_out[slot] = 0.0f; + refined_duration_out[slot] = 0.0f; + refined_depth_out[slot] = 0.0f; + } + } +} diff --git a/cuvarbase/kernels/tls_reference.cu b/cuvarbase/kernels/tls_reference.cu new file mode 100644 index 00000000..93224a47 --- /dev/null +++ b/cuvarbase/kernels/tls_reference.cu @@ -0,0 +1,505 @@ +/* + * Fused observation-rank TLS search, preserving public GTLS search math. + * + * Adapted from GTLS src/gputls/GPUFun.py:getGPUCode(), specifically + * calcAverageFromCumsum, calcAllFullSum_v2, calculate_final_ootr_v3, + * calcAllLowestResidualsGPUB_SignalTiled_v2, and edge-effect correction. + * Source snapshot: benchmarks/results/tls_profile_2026-09-08/sources/gtls-head.tar + * SHA256 of the returned CUDA string: + * 25570532816bd94b390c10cd6a1b0477de7873c52715191de7c422c5b8d3eb9c + * + * MIT License + * Copyright (c) 2018 Michael Hippke 2023 Quanquan Hu + * + * 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. + * + * This implementation retains the supplied GTLS templates, observation-rank + * windows, unweighted window-mean depth, epoch skip schedule, chi2 sentinel, + * and float32 residual accumulation. It does not replace them with an + * analytic-depth or physical-phase objective. In particular, template rows + * must contain 1 minus the original zero-padded flux cache; a padded entry + * therefore has deficit 1, not 0, exactly as in the pinned host code. + * + * Required inputs are the SAME patched sorted arrays, prefix sums and width + * union as the reference. The host controls width coverage and sort/prefix + * semantics. Native GTLS unions width masks over its memory-dependent period + * chunk, so changing chunks can change coverage unless the host fixes it. + * + * All observations remain in global memory. Only block reductions use shared + * memory. Work tiles contain only the native evaluated start positions; the + * first omitted position is inserted as a sentinel candidate when needed. + * Fusing out-of-transit residuals and reducing each tile removes the native + * [period, duration, observation] residual and OOTR tensors. + * The full refinement stage instead retains its native OOTR scan order in a + * bounded selected-period tensor, while still reducing residuals in tiles. + */ + +#ifndef TLS_REFERENCE_BLOCK_SIZE +#define TLS_REFERENCE_BLOCK_SIZE 256 +#endif + +typedef unsigned long long tls_ref_key; + +__device__ __forceinline__ tls_ref_key tls_ref_empty_key() { + return ~((tls_ref_key)0); +} + +/* Match first-index argmin, including NaN propagation. Finite cleaned inputs + * should not produce NaN; retaining its ordering also makes debug comparisons + * explicit rather than silently replacing a malformed score by a finite one. */ +__device__ __forceinline__ bool tls_ref_better( + float candidate, tls_ref_key candidate_key, + float incumbent, tls_ref_key incumbent_key) +{ + const bool candidate_nan = isnan(candidate); + const bool incumbent_nan = isnan(incumbent); + if (candidate_nan != incumbent_nan) return candidate_nan; + if (candidate_nan) return candidate_key < incumbent_key; + return candidate < incumbent || + (candidate == incumbent && candidate_key < incumbent_key); +} + +__device__ __forceinline__ float tls_ref_mean_depth( + const float* flux_prefix, int width, int start) +{ + if (start == 0) { + return 1.0f - flux_prefix[width - 1] / width; + } else { + const float end_val = flux_prefix[start + width - 1]; + const float start_val = flux_prefix[start - 1]; + return 1.0f - (end_val - start_val) / width; + } +} + +/* Arithmetic order follows the three reference kernels. Explicit rounded + * additions/subtractions retain the original float32 intermediate writes + * even though those intermediates now stay in registers. */ +__device__ __forceinline__ float tls_ref_ootr( + const float* error_prefix, int stride, int width, int start) +{ + const float window_prefix = error_prefix[width - 1]; + const float fullsum = __fsub_rn(error_prefix[stride - 1], window_prefix); + if (start == 0) return fullsum; + const int p = start - 1; + const float p_e_p = error_prefix[p]; + const float p_e_p_plus_window = + p + width < stride ? error_prefix[p + width] : 0.0f; + const float cumsum_weight = __fsub_rn( + p_e_p, __fsub_rn(p_e_p_plus_window, window_prefix)); + return __fadd_rn(fullsum, cumsum_weight); +} + +__device__ __forceinline__ float tls_ref_window( + const float* data, const float* invvar, + const float* flux_prefix, const float* error_prefix, + const float* signal, float overshoot, float edge_correction, + int ndata, int stride, int width, int start, int skip_factor, + float transit_depth_min, float* fitted_depth) +{ + const int skip = width > skip_factor ? width / skip_factor : 1; + const float calc_mean = tls_ref_mean_depth(flux_prefix, width, start); + float current_stat = (float)ndata; + *fitted_depth = 0.0f; + if (calc_mean > transit_depth_min && start % skip == 0) { + const float ootr = tls_ref_ootr(error_prefix, stride, width, start); + const float reverse_scale = calc_mean * overshoot * 2.0f; + float intransit_residual = 0.0f; + for (int i = 0; i < width; i++) { + const float sigi = signal[i] * reverse_scale; + const float loss = data[start + i] - (1.0f - sigi); + intransit_residual += loss * loss * invvar[start + i]; + } + const int skip_search_point = 1; + const float actual_loss_fraction = (float)width / + (((width - 1) / skip_search_point) + 1); + current_stat = intransit_residual * actual_loss_fraction + ootr + - edge_correction; + *fitted_depth = calc_mean * overshoot; + } + return current_stat; +} + +/* Search one packed tile of one cached width for each period row. + * + * All matrix inputs are row-major [nrows, stride], where + * stride = ndata + largest cached width, rounded as in native GTLS. + * Widths and templates have already been selected by the host's width union. + * template_deficits is [nwidths, template_stride]. + * + * Host tiles, for each width d: + * skip = max(width[d] // skip_factor, 1) + * evaluated_count = ceil(ndata / skip) + * for first in range(0, evaluated_count, TLS_REFERENCE_BLOCK_SIZE): + * tile_duration.append(d); tile_first_trial.append(first) + * + * Grid=(ntiles,nrows,1), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). + * Partial arrays are [nrows,ntiles]. No phase or ndata accuracy cap. + */ +extern "C" __global__ void tls_reference_search( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ error_prefix, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + const int* __restrict__ tile_duration, + const int* __restrict__ tile_first_trial, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int ntiles, int skip_factor, float transit_depth_min, + float* __restrict__ partial_chi2, + tls_ref_key* __restrict__ partial_key, + float* __restrict__ partial_depth) +{ + const int tile = blockIdx.x; + const int row = blockIdx.y; + if (tile >= ntiles || row >= nrows) return; + const int d = tile_duration[tile]; + const int width = widths[d]; + const int skip = width > skip_factor ? width / skip_factor : 1; + const long long trial = (long long)tile_first_trial[tile] + threadIdx.x; + const long long start_long = trial * skip; + const long long row_offset = (long long)row * stride; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + if (start_long < ndata) { + const int start = (int)start_long; + key = (tls_ref_key)d * ndata + start; + value = tls_ref_window( + patched_flux + row_offset, inverse_variance + row_offset, + flux_prefix + row_offset, error_prefix + row_offset, + template_deficits + (long long)d * template_stride, + overshoot[d], edge_correction[row], ndata, stride, width, + start, skip_factor, transit_depth_min, &depth); + } + /* All skipped starts have the same residual ndata. Retain their first + * logical index, rather than clamping all results to ndata: if no starts + * are skipped and every fitted residual exceeds ndata, native argmin + * must still return that larger residual. */ + if (threadIdx.x == 0 && tile_first_trial[tile] == 0 && skip > 1 && ndata > 1) { + const tls_ref_key skipped_key = (tls_ref_key)d * ndata + 1; + if (tls_ref_better((float)ndata, skipped_key, value, key)) { + value = (float)ndata; + key = skipped_key; + depth = 0.0f; + } + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const long long output = (long long)row * ntiles + tile; + partial_chi2[output] = values[0]; + partial_key[output] = keys[0]; + partial_depth[output] = depths[0]; + } +} + +/* Grid=(nrows,1,1), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). */ +extern "C" __global__ void tls_reference_reduce( + const float* __restrict__ partial_chi2, + const tls_ref_key* __restrict__ partial_key, + const float* __restrict__ partial_depth, + const int* __restrict__ widths, + int nrows, int ndata, int ntiles, + float* __restrict__ minimum_chi2, + int* __restrict__ best_start, + int* __restrict__ best_width_index, + int* __restrict__ best_width, + float* __restrict__ best_depth) +{ + const int row = blockIdx.x; + if (row >= nrows) return; + const long long row_offset = (long long)row * ntiles; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + for (int tile = threadIdx.x; tile < ntiles; tile += blockDim.x) { + const long long k = row_offset + tile; + if (tls_ref_better(partial_chi2[k], partial_key[k], value, key)) { + value = partial_chi2[k]; + key = partial_key[k]; + depth = partial_depth[k]; + } + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + minimum_chi2[row] = values[0]; + const bool has_key = keys[0] != tls_ref_empty_key(); + const int d = has_key ? (int)(keys[0] / ndata) : -1; + best_start[row] = has_key ? (int)(keys[0] % ndata) : -1; + best_width_index[row] = d; + best_width[row] = has_key ? widths[d] : 0; + best_depth[row] = depths[0]; + } +} + +/* Diagnostic only: materialize the native logical tensor on small fixtures. + * Grid=(ceil(ndata/blocksize),nwidths,nrows). The production search uses the + * packed tiles above and never allocates this tensor. */ +extern "C" __global__ void tls_reference_window_values( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ error_prefix, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int skip_factor, float transit_depth_min, + float* __restrict__ values) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + float depth; + values[((long long)row * nwidths + d) * ndata + start] = tls_ref_window( + patched_flux + offset, inverse_variance + offset, + flux_prefix + offset, error_prefix + offset, + template_deficits + (long long)d * template_stride, overshoot[d], + edge_correction[row], ndata, stride, widths[d], start, skip_factor, + transit_depth_min, &depth); +} + +/* Legacy full-mode sum. Native calcAllFullSum repeats the same sequential + * full-data sum for every width; reuse that sum and its sequential window + * prefixes. Widths must be positive and strictly increasing. Both sequences + * retain the native float32 multiply/add order, with no parallel reduction. */ +extern "C" __global__ void tls_reference_fullsum_legacy( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const int* __restrict__ widths, + int nrows, int stride, int nwidths, + float* __restrict__ fullsum_out) +{ + const int row = blockIdx.x * blockDim.x + threadIdx.x; + if (row >= nrows) return; + const long long offset = (long long)row * stride; + float fullsum = 0.0f; + for (int i = 0; i < stride; i++) { + const float diff = 1.0f - patched_flux[offset + i]; + fullsum += diff * diff * inverse_variance[offset + i]; + } + float window_sum = 0.0f; + int i = 0; + for (int d = 0; d < nwidths; d++) { + const int width = widths[d]; + while (i < width) { + const float diff = 1.0f - patched_flux[offset + i]; + window_sum += diff * diff * inverse_variance[offset + i]; + i++; + } + fullsum_out[(long long)row * nwidths + d] = fullsum - window_sum; + } +} + +/* Native full-stage OOTR preparation, before the host applies + * cp.cumsum(delta, axis=-1) with the same shape as the reference. + * Grid=(ceil(ndata/blocksize),nwidths,nrows). */ +extern "C" __global__ void tls_reference_ootr_delta( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const int* __restrict__ widths, + int nrows, int ndata, int stride, int nwidths, + float* __restrict__ delta) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + const int width = widths[d]; + const float visible = 1.0f - patched_flux[offset + start]; + const float invisible = 1.0f - patched_flux[offset + start + width]; + const float add_visible = visible * visible * inverse_variance[offset + start]; + const float remove_invisible = invisible * invisible * + inverse_variance[offset + start + width]; + delta[((long long)row * nwidths + d) * ndata + start] = + add_visible - remove_invisible; +} + +/* Complete native full-stage OOTR after the host's prefix scan. */ +extern "C" __global__ void tls_reference_ootr_add( + float* __restrict__ ootr, + const float* __restrict__ fullsum, + int nrows, int ndata, int nwidths) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long k = ((long long)row * nwidths + d) * ndata + start; + ootr[k] = fullsum[(long long)row * nwidths + d] + ootr[k]; +} + +/* Full refinement, corresponding to both native NoSkipTemp (many rows) and + * NoSkip (the final single row). Unlike the fast stage, fullsum and OOTR + * arrive from the native sequential/full-difference-scan preparation above. + * The host supplies every start position: tile_first_trial=0,256,512,... for + * each width. Search and reduction outputs share the fast-stage layout. + * Grid=(ntiles,nrows), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). + */ +__device__ __forceinline__ float tls_ref_full_window( + const float* data, const float* invvar, const float* flux_prefix, + const float* signal, float fullsum, const float* ootr, + float overshoot, float edge_correction, int ndata, int width, int start, + float transit_depth_min, float* fitted_depth) +{ + const float mean = tls_ref_mean_depth(flux_prefix, width, start); + *fitted_depth = 0.0f; + if (!(mean > transit_depth_min)) return (float)ndata; + const float outside = start == 0 ? fullsum : ootr[start - 1]; + const float reverse_scale = mean * overshoot * 2.0f; + float residual = 0.0f; + for (int i = 0; i < width; i++) { + const float sigi = signal[i] * reverse_scale; + const float loss = data[start + i] - (1.0f - sigi); + residual += loss * loss * invvar[start + i]; + } + *fitted_depth = mean * overshoot; + return residual + outside - edge_correction; +} + +extern "C" __global__ void tls_reference_full_search( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ fullsum, + const float* __restrict__ ootr, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + const int* __restrict__ tile_duration, + const int* __restrict__ tile_first_trial, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int ntiles, float transit_depth_min, + float* __restrict__ partial_chi2, + tls_ref_key* __restrict__ partial_key, + float* __restrict__ partial_depth) +{ + const int tile = blockIdx.x; + const int row = blockIdx.y; + if (tile >= ntiles || row >= nrows) return; + const int d = tile_duration[tile]; + const int width = widths[d]; + const long long start_long = (long long)tile_first_trial[tile] + threadIdx.x; + const long long offset = (long long)row * stride; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + if (start_long < ndata) { + const int start = (int)start_long; + key = (tls_ref_key)d * ndata + start; + value = tls_ref_full_window(patched_flux + offset, + inverse_variance + offset, flux_prefix + offset, + template_deficits + (long long)d * template_stride, + fullsum[(long long)row * nwidths + d], + ootr + ((long long)row * nwidths + d) * ndata, + overshoot[d], edge_correction[row], ndata, width, start, + transit_depth_min, &depth); + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const long long output = (long long)row * ntiles + tile; + partial_chi2[output] = values[0]; + partial_key[output] = keys[0]; + partial_depth[output] = depths[0]; + } +} + +/* Diagnostic only, full-stage logical window residuals. */ +extern "C" __global__ void tls_reference_full_window_values( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ fullsum, + const float* __restrict__ ootr, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + int nrows, int ndata, int stride, int nwidths, int template_stride, + float transit_depth_min, float* __restrict__ values) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + float depth; + values[((long long)row * nwidths + d) * ndata + start] = tls_ref_full_window( + patched_flux + offset, inverse_variance + offset, flux_prefix + offset, + template_deficits + (long long)d * template_stride, + fullsum[(long long)row * nwidths + d], + ootr + ((long long)row * nwidths + d) * ndata, + overshoot[d], edge_correction[row], ndata, widths[d], start, + transit_depth_min, &depth); +} diff --git a/cuvarbase/kernels/tls_reference_experimental.cu b/cuvarbase/kernels/tls_reference_experimental.cu new file mode 100644 index 00000000..87a99f78 --- /dev/null +++ b/cuvarbase/kernels/tls_reference_experimental.cu @@ -0,0 +1,539 @@ +/* + * Fused observation-rank TLS search, preserving public GTLS search math. + * + * Adapted from GTLS src/gputls/GPUFun.py:getGPUCode(), specifically + * calcAverageFromCumsum, calcAllFullSum_v2, calculate_final_ootr_v3, + * calcAllLowestResidualsGPUB_SignalTiled_v2, and edge-effect correction. + * Source snapshot: benchmarks/results/tls_profile_2026-09-08/sources/gtls-head.tar + * SHA256 of the returned CUDA string: + * 25570532816bd94b390c10cd6a1b0477de7873c52715191de7c422c5b8d3eb9c + * + * MIT License + * Copyright (c) 2018 Michael Hippke 2023 Quanquan Hu + * + * 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. + * + * This implementation retains the supplied GTLS templates, observation-rank + * windows, unweighted window-mean depth, epoch skip schedule, chi2 sentinel, + * and float32 residual accumulation. It does not replace them with an + * analytic-depth or physical-phase objective. In particular, template rows + * must contain 1 minus the original zero-padded flux cache; a padded entry + * therefore has deficit 1, not 0, exactly as in the pinned host code. + * + * Required inputs are the SAME patched sorted arrays, prefix sums and width + * union as the reference. The host controls width coverage and sort/prefix + * semantics. Native GTLS unions width masks over its memory-dependent period + * chunk, so changing chunks can change coverage unless the host fixes it. + * + * All observations remain in global memory. Only block reductions use shared + * memory. Work tiles contain only the native evaluated start positions; the + * first omitted position is inserted as a sentinel candidate when needed. + * Fusing out-of-transit residuals and reducing each tile removes the native + * [period, duration, observation] residual and OOTR tensors. + * The full refinement stage instead retains its native OOTR scan order in a + * bounded selected-period tensor, while still reducing residuals in tiles. + */ + +#ifndef TLS_REFERENCE_BLOCK_SIZE +#define TLS_REFERENCE_BLOCK_SIZE 256 +#endif + +typedef unsigned long long tls_ref_key; + +/* Compact winner records are 32 bytes: f32 residual, i32 start, f64 epoch, + * i32 local width index, i32 width, f32 depth, and one zero padding word. + * Preserve float bits by writing words, rather than casting to a shared + * floating dtype. Only the epoch gather and copies happen in this kernel. */ +extern "C" __global__ void tls_reference_pack_winners( + const double* __restrict__ time, + const int* __restrict__ order, + const float* __restrict__ chi2, + const int* __restrict__ starts, + const int* __restrict__ indices, + const int* __restrict__ widths, + const float* __restrict__ depths, + int nrows, int ndata, + unsigned int* __restrict__ packed) +{ + const int row = blockIdx.x * blockDim.x + threadIdx.x; + if (row >= nrows) return; + const int start = starts[row]; + // Match the original clip followed by where(start >= 0, epoch, nan). + const int safe_start = max(0, min(start, ndata - 1)); + const long long epoch = start >= 0 ? + __double_as_longlong(time[order[(long long)row * ndata + safe_start]]) : + (long long)0x7ff8000000000000ULL; + const long long offset = (long long)row * 8; + packed[offset] = __float_as_uint(chi2[row]); + packed[offset + 1] = (unsigned int)start; + packed[offset + 2] = (unsigned int)epoch; + packed[offset + 3] = (unsigned int)((unsigned long long)epoch >> 32); + packed[offset + 4] = (unsigned int)indices[row]; + packed[offset + 5] = (unsigned int)widths[row]; + packed[offset + 6] = __float_as_uint(depths[row]); + packed[offset + 7] = 0; +} + +__device__ __forceinline__ tls_ref_key tls_ref_empty_key() { + return ~((tls_ref_key)0); +} + +/* Match first-index argmin, including NaN propagation. Finite cleaned inputs + * should not produce NaN; retaining its ordering also makes debug comparisons + * explicit rather than silently replacing a malformed score by a finite one. */ +__device__ __forceinline__ bool tls_ref_better( + float candidate, tls_ref_key candidate_key, + float incumbent, tls_ref_key incumbent_key) +{ + const bool candidate_nan = isnan(candidate); + const bool incumbent_nan = isnan(incumbent); + if (candidate_nan != incumbent_nan) return candidate_nan; + if (candidate_nan) return candidate_key < incumbent_key; + return candidate < incumbent || + (candidate == incumbent && candidate_key < incumbent_key); +} + +__device__ __forceinline__ float tls_ref_mean_depth( + const float* flux_prefix, int width, int start) +{ + if (start == 0) { + return 1.0f - flux_prefix[width - 1] / width; + } else { + const float end_val = flux_prefix[start + width - 1]; + const float start_val = flux_prefix[start - 1]; + return 1.0f - (end_val - start_val) / width; + } +} + +/* Arithmetic order follows the three reference kernels. Explicit rounded + * additions/subtractions retain the original float32 intermediate writes + * even though those intermediates now stay in registers. */ +__device__ __forceinline__ float tls_ref_ootr( + const float* error_prefix, int stride, int width, int start) +{ + const float window_prefix = error_prefix[width - 1]; + const float fullsum = __fsub_rn(error_prefix[stride - 1], window_prefix); + if (start == 0) return fullsum; + const int p = start - 1; + const float p_e_p = error_prefix[p]; + const float p_e_p_plus_window = + p + width < stride ? error_prefix[p + width] : 0.0f; + const float cumsum_weight = __fsub_rn( + p_e_p, __fsub_rn(p_e_p_plus_window, window_prefix)); + return __fadd_rn(fullsum, cumsum_weight); +} + +__device__ __forceinline__ float tls_ref_window( + const float* data, const float* invvar, + const float* flux_prefix, const float* error_prefix, + const float* signal, float overshoot, float edge_correction, + int ndata, int stride, int width, int start, int skip_factor, + float transit_depth_min, float* fitted_depth) +{ + const int skip = width > skip_factor ? width / skip_factor : 1; + const float calc_mean = tls_ref_mean_depth(flux_prefix, width, start); + float current_stat = (float)ndata; + *fitted_depth = 0.0f; + if (calc_mean > transit_depth_min && start % skip == 0) { + const float ootr = tls_ref_ootr(error_prefix, stride, width, start); + const float reverse_scale = calc_mean * overshoot * 2.0f; + float intransit_residual = 0.0f; + for (int i = 0; i < width; i++) { + const float sigi = signal[i] * reverse_scale; + const float loss = data[start + i] - (1.0f - sigi); + intransit_residual += loss * loss * invvar[start + i]; + } + const int skip_search_point = 1; + const float actual_loss_fraction = (float)width / + (((width - 1) / skip_search_point) + 1); + current_stat = intransit_residual * actual_loss_fraction + ootr + - edge_correction; + *fitted_depth = calc_mean * overshoot; + } + return current_stat; +} + +/* Search one packed tile of one cached width for each period row. + * + * All matrix inputs are row-major [nrows, stride], where + * stride = ndata + largest cached width, rounded as in native GTLS. + * Widths and templates have already been selected by the host's width union. + * template_deficits is [nwidths, template_stride]. + * + * Host tiles, for each width d: + * skip = max(width[d] // skip_factor, 1) + * evaluated_count = ceil(ndata / skip) + * for first in range(0, evaluated_count, TLS_REFERENCE_BLOCK_SIZE): + * tile_duration.append(d); tile_first_trial.append(first) + * + * Grid=(ntiles,nrows,1), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). + * Partial arrays are [nrows,ntiles]. No phase or ndata accuracy cap. + */ +extern "C" __global__ void tls_reference_search( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ error_prefix, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + const int* __restrict__ tile_duration, + const int* __restrict__ tile_first_trial, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int ntiles, int skip_factor, float transit_depth_min, + float* __restrict__ partial_chi2, + tls_ref_key* __restrict__ partial_key, + float* __restrict__ partial_depth) +{ + const int tile = blockIdx.x; + const int row = blockIdx.y; + if (tile >= ntiles || row >= nrows) return; + const int d = tile_duration[tile]; + const int width = widths[d]; + const int skip = width > skip_factor ? width / skip_factor : 1; + const long long trial = (long long)tile_first_trial[tile] + threadIdx.x; + const long long start_long = trial * skip; + const long long row_offset = (long long)row * stride; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + if (start_long < ndata) { + const int start = (int)start_long; + key = (tls_ref_key)d * ndata + start; + value = tls_ref_window( + patched_flux + row_offset, inverse_variance + row_offset, + flux_prefix + row_offset, error_prefix + row_offset, + template_deficits + (long long)d * template_stride, + overshoot[d], edge_correction[row], ndata, stride, width, + start, skip_factor, transit_depth_min, &depth); + } + /* All skipped starts have the same residual ndata. Retain their first + * logical index, rather than clamping all results to ndata: if no starts + * are skipped and every fitted residual exceeds ndata, native argmin + * must still return that larger residual. */ + if (threadIdx.x == 0 && tile_first_trial[tile] == 0 && skip > 1 && ndata > 1) { + const tls_ref_key skipped_key = (tls_ref_key)d * ndata + 1; + if (tls_ref_better((float)ndata, skipped_key, value, key)) { + value = (float)ndata; + key = skipped_key; + depth = 0.0f; + } + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const long long output = (long long)row * ntiles + tile; + partial_chi2[output] = values[0]; + partial_key[output] = keys[0]; + partial_depth[output] = depths[0]; + } +} + +/* Grid=(nrows,1,1), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). */ +extern "C" __global__ void tls_reference_reduce( + const float* __restrict__ partial_chi2, + const tls_ref_key* __restrict__ partial_key, + const float* __restrict__ partial_depth, + const int* __restrict__ widths, + int nrows, int ndata, int ntiles, + float* __restrict__ minimum_chi2, + int* __restrict__ best_start, + int* __restrict__ best_width_index, + int* __restrict__ best_width, + float* __restrict__ best_depth) +{ + const int row = blockIdx.x; + if (row >= nrows) return; + const long long row_offset = (long long)row * ntiles; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + for (int tile = threadIdx.x; tile < ntiles; tile += blockDim.x) { + const long long k = row_offset + tile; + if (tls_ref_better(partial_chi2[k], partial_key[k], value, key)) { + value = partial_chi2[k]; + key = partial_key[k]; + depth = partial_depth[k]; + } + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + minimum_chi2[row] = values[0]; + const bool has_key = keys[0] != tls_ref_empty_key(); + const int d = has_key ? (int)(keys[0] / ndata) : -1; + best_start[row] = has_key ? (int)(keys[0] % ndata) : -1; + best_width_index[row] = d; + best_width[row] = has_key ? widths[d] : 0; + best_depth[row] = depths[0]; + } +} + +/* Diagnostic only: materialize the native logical tensor on small fixtures. + * Grid=(ceil(ndata/blocksize),nwidths,nrows). The production search uses the + * packed tiles above and never allocates this tensor. */ +extern "C" __global__ void tls_reference_window_values( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ error_prefix, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int skip_factor, float transit_depth_min, + float* __restrict__ values) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + float depth; + values[((long long)row * nwidths + d) * ndata + start] = tls_ref_window( + patched_flux + offset, inverse_variance + offset, + flux_prefix + offset, error_prefix + offset, + template_deficits + (long long)d * template_stride, overshoot[d], + edge_correction[row], ndata, stride, widths[d], start, skip_factor, + transit_depth_min, &depth); +} + +/* Legacy full-mode sum. Native calcAllFullSum repeats the same sequential + * full-data sum for every width; reuse that sum and its sequential window + * prefixes. Widths must be positive and strictly increasing. Both sequences + * retain the native float32 multiply/add order, with no parallel reduction. */ +extern "C" __global__ void tls_reference_fullsum_legacy( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const int* __restrict__ widths, + int nrows, int stride, int nwidths, + float* __restrict__ fullsum_out) +{ + const int row = blockIdx.x * blockDim.x + threadIdx.x; + if (row >= nrows) return; + const long long offset = (long long)row * stride; + float fullsum = 0.0f; + for (int i = 0; i < stride; i++) { + const float diff = 1.0f - patched_flux[offset + i]; + fullsum += diff * diff * inverse_variance[offset + i]; + } + float window_sum = 0.0f; + int i = 0; + for (int d = 0; d < nwidths; d++) { + const int width = widths[d]; + while (i < width) { + const float diff = 1.0f - patched_flux[offset + i]; + window_sum += diff * diff * inverse_variance[offset + i]; + i++; + } + fullsum_out[(long long)row * nwidths + d] = fullsum - window_sum; + } +} + +/* Native full-stage OOTR preparation, before the host applies + * cp.cumsum(delta, axis=-1) with the same shape as the reference. + * Grid=(ceil(ndata/blocksize),nwidths,nrows). */ +extern "C" __global__ void tls_reference_ootr_delta( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const int* __restrict__ widths, + int nrows, int ndata, int stride, int nwidths, + float* __restrict__ delta) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + const int width = widths[d]; + const float visible = 1.0f - patched_flux[offset + start]; + const float invisible = 1.0f - patched_flux[offset + start + width]; + const float add_visible = visible * visible * inverse_variance[offset + start]; + const float remove_invisible = invisible * invisible * + inverse_variance[offset + start + width]; + delta[((long long)row * nwidths + d) * ndata + start] = + add_visible - remove_invisible; +} + +/* Complete native full-stage OOTR after the host's prefix scan. */ +extern "C" __global__ void tls_reference_ootr_add( + float* __restrict__ ootr, + const float* __restrict__ fullsum, + int nrows, int ndata, int nwidths) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long k = ((long long)row * nwidths + d) * ndata + start; + ootr[k] = fullsum[(long long)row * nwidths + d] + ootr[k]; +} + +/* Full refinement, corresponding to both native NoSkipTemp (many rows) and + * NoSkip (the final single row). Unlike the fast stage, fullsum and OOTR + * arrive from the native sequential/full-difference-scan preparation above. + * The host supplies every start position: tile_first_trial=0,256,512,... for + * each width. Search and reduction outputs share the fast-stage layout. + * Grid=(ntiles,nrows), block=(TLS_REFERENCE_BLOCK_SIZE,1,1). + */ +__device__ __forceinline__ float tls_ref_full_window( + const float* data, const float* invvar, const float* flux_prefix, + const float* signal, float fullsum, const float* ootr, + float overshoot, float edge_correction, int ndata, int width, int start, + float transit_depth_min, float* fitted_depth) +{ + const float mean = tls_ref_mean_depth(flux_prefix, width, start); + *fitted_depth = 0.0f; + if (!(mean > transit_depth_min)) return (float)ndata; + const float outside = start == 0 ? fullsum : ootr[start - 1]; + const float reverse_scale = mean * overshoot * 2.0f; + float residual = 0.0f; + for (int i = 0; i < width; i++) { + const float sigi = signal[i] * reverse_scale; + const float loss = data[start + i] - (1.0f - sigi); + residual += loss * loss * invvar[start + i]; + } + *fitted_depth = mean * overshoot; + return residual + outside - edge_correction; +} + +extern "C" __global__ void tls_reference_full_search( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ fullsum, + const float* __restrict__ ootr, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + const int* __restrict__ tile_duration, + const int* __restrict__ tile_first_trial, + int nrows, int ndata, int stride, int nwidths, int template_stride, + int ntiles, float transit_depth_min, + float* __restrict__ partial_chi2, + tls_ref_key* __restrict__ partial_key, + float* __restrict__ partial_depth) +{ + const int tile = blockIdx.x; + const int row = blockIdx.y; + if (tile >= ntiles || row >= nrows) return; + const int d = tile_duration[tile]; + const int width = widths[d]; + const long long start_long = (long long)tile_first_trial[tile] + threadIdx.x; + const long long offset = (long long)row * stride; + float value = __int_as_float(0x7f800000); + tls_ref_key key = tls_ref_empty_key(); + float depth = 0.0f; + if (start_long < ndata) { + const int start = (int)start_long; + key = (tls_ref_key)d * ndata + start; + value = tls_ref_full_window(patched_flux + offset, + inverse_variance + offset, flux_prefix + offset, + template_deficits + (long long)d * template_stride, + fullsum[(long long)row * nwidths + d], + ootr + ((long long)row * nwidths + d) * ndata, + overshoot[d], edge_correction[row], ndata, width, start, + transit_depth_min, &depth); + } + __shared__ float values[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ tls_ref_key keys[TLS_REFERENCE_BLOCK_SIZE]; + __shared__ float depths[TLS_REFERENCE_BLOCK_SIZE]; + values[threadIdx.x] = value; + keys[threadIdx.x] = key; + depths[threadIdx.x] = depth; + __syncthreads(); + for (int step = TLS_REFERENCE_BLOCK_SIZE / 2; step > 0; step /= 2) { + if (threadIdx.x < step && tls_ref_better( + values[threadIdx.x + step], keys[threadIdx.x + step], + values[threadIdx.x], keys[threadIdx.x])) { + values[threadIdx.x] = values[threadIdx.x + step]; + keys[threadIdx.x] = keys[threadIdx.x + step]; + depths[threadIdx.x] = depths[threadIdx.x + step]; + } + __syncthreads(); + } + if (threadIdx.x == 0) { + const long long output = (long long)row * ntiles + tile; + partial_chi2[output] = values[0]; + partial_key[output] = keys[0]; + partial_depth[output] = depths[0]; + } +} + +/* Diagnostic only, full-stage logical window residuals. */ +extern "C" __global__ void tls_reference_full_window_values( + const float* __restrict__ patched_flux, + const float* __restrict__ inverse_variance, + const float* __restrict__ flux_prefix, + const float* __restrict__ fullsum, + const float* __restrict__ ootr, + const float* __restrict__ edge_correction, + const int* __restrict__ widths, + const float* __restrict__ template_deficits, + const float* __restrict__ overshoot, + int nrows, int ndata, int stride, int nwidths, int template_stride, + float transit_depth_min, float* __restrict__ values) +{ + const int start = blockIdx.x * blockDim.x + threadIdx.x; + const int d = blockIdx.y; + const int row = blockIdx.z; + if (start >= ndata || d >= nwidths || row >= nrows) return; + const long long offset = (long long)row * stride; + float depth; + values[((long long)row * nwidths + d) * ndata + start] = tls_ref_full_window( + patched_flux + offset, inverse_variance + offset, flux_prefix + offset, + template_deficits + (long long)d * template_stride, + fullsum[(long long)row * nwidths + d], + ootr + ((long long)row * nwidths + d) * ndata, + overshoot[d], edge_correction[row], ndata, widths[d], start, + transit_depth_min, &depth); +} diff --git a/cuvarbase/kernels/tls_reference_prepare.cu b/cuvarbase/kernels/tls_reference_prepare.cu new file mode 100644 index 00000000..28e3865a --- /dev/null +++ b/cuvarbase/kernels/tls_reference_prepare.cu @@ -0,0 +1,176 @@ +/* +Adapted from GTLS 74e449c325792a763dde4fbffab98039c5e8c111 GPUFun.cu. +MIT License + +Copyright (c) 2018 Michael Hippke 2023 Quanquan Hu + +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. +*/ +extern "C" { +// Configuration constants + #define SKIP_POINT 8 + + // Physical constants - optimized with appropriate values + #define R_STAR_MIN 0.05 // Minimum stellar radius (solar radii) - updated boundary + #define R_STAR_MAX 4.0 // Maximum stellar radius (solar radii) - updated boundary + #define SECONDS_PER_DAY 86400 // Seconds in a day + #define R_SUN 695508000 // Radius of the Sun [m] + #define R_JUP 69911000 // Radius of Jupiter [m] + #define FRACTIONAL_TRANSIT_DURATION_MAX 0.15 // Maximum fractional transit duration - updated value + + // Derived constants for duration calculations - optimized values + #define PI_GM_MAX 416970 // Simplified pi*G*M_max for duration calc + #define PI_GM_MIN 20848 // Simplified pi*G*M_min for duration calc - updated boundary + #define RS_MIN (R_SUN * R_STAR_MIN) // Minimum stellar radius in meters + #define RS_MAX (R_SUN * R_STAR_MAX) // Maximum stellar radius in meters + + // Transit fitting constants + #define SIGNAL_DEPTH 0.5 // Standard signal depth for fitting + #define FLOAT_INFINITY 0x7f800000 // IEEE-754 float infinity + #define SCALE_FACTOR 1000000000000000.0 // Scale factor for duration calculations + +__global__ void foldFast(const double* time, const double* periods, double* phase, + int* periodSize, int* timeSize) { + int tid = blockDim.x * blockIdx.x + threadIdx.x; + int y = blockDim.y * blockIdx.y + threadIdx.y; + + if (tid < (*timeSize)) { + double time_val = time[tid]; + double period = periods[y]; + double phase_raw = time_val / period; + phase[tid + y * (*timeSize)] = phase_raw - (int)(phase_raw); + } + } + +__global__ void durationsGrid(const double* periods, int* durationsMax, int* durationsMin, + const float* tLength, const int* tSize, const int* periodSize) { + int tid = blockDim.x * blockIdx.x + threadIdx.x; + + if (tid < (*periodSize)) { + float length = *tLength; + int size = *tSize; + + // Calculate transit statistics with optimized operations + double period_days = periods[tid]; + double no_of_transits_naive = length / period_days; + double correction_factor = (no_of_transits_naive + 1.0) / no_of_transits_naive; + + double period_seconds = period_days * SECONDS_PER_DAY; + + // Pre-calculate common factors for efficiency + double period_factor_min = (4.0 * period_seconds) / (PI_GM_MIN * SCALE_FACTOR); + double period_factor_max = (4.0 * period_seconds) / (PI_GM_MAX * SCALE_FACTOR); + + // Calculate minimum and maximum transit durations + double T14Min = RS_MIN * pow(period_factor_min, 1.0 / 3.0); + double T14Max = (RS_MAX + R_JUP * 2.0) * pow(period_factor_max, 1.0 / 3.0); + + double durationMin = T14Min / period_seconds; + double durationMax = T14Max / period_seconds; + + // Apply maximum duration constraints efficiently + durationMin = (durationMin > FRACTIONAL_TRANSIT_DURATION_MAX) ? + FRACTIONAL_TRANSIT_DURATION_MAX : durationMin; + durationMax = (durationMax > FRACTIONAL_TRANSIT_DURATION_MAX) ? + FRACTIONAL_TRANSIT_DURATION_MAX : durationMax; + + // Convert to sample indices with optimized rounding + int duration_min_in_samples = floor(durationMin * size); + int duration_max_in_samples = ceil(durationMax * size * correction_factor); + + durationsMin[tid] = duration_min_in_samples; + durationsMax[tid] = duration_max_in_samples; + } + } + +__global__ void patchData(float *in_patchedData, float *in_patchedDys, + int *patchedDataSize, int *in_sortIndex, int *maxDuration, + float *flux, float *dy, int *tSize) { + int tid = blockIdx.x * blockDim.x + threadIdx.x; // patchedData index + int y = blockIdx.y * blockDim.y + threadIdx.y; // period index + + float *patchedData = in_patchedData + y * (*patchedDataSize); + float *patchedDys = in_patchedDys + y * (*patchedDataSize); + int *sortIndex = in_sortIndex + y * (*tSize); + + if (tid < (*tSize)) { + int src_idx = sortIndex[tid]; + patchedData[tid] = flux[src_idx]; + patchedDys[tid] = dy[src_idx]; + } else if (tid < (*tSize + *maxDuration)) { + int src_idx = sortIndex[tid - (*tSize)]; + patchedData[tid] = flux[src_idx]; + patchedDys[tid] = dy[src_idx]; + } + } + +__global__ void calcInverseSquaredPatchedDy(float *out, float *patched_dys, int *patched_data_size) { + int tid = blockIdx.x * blockDim.x + threadIdx.x; + int y = blockIdx.y * blockDim.y + threadIdx.y; + + if (tid < *patched_data_size) { + float dy_val = patched_dys[tid + y * (*patched_data_size)]; + out[tid + y * (*patched_data_size)] = 1.0f / (dy_val * dy_val); + } + } + +__global__ void calcEdgeEffectCorrections(float *out, float *patch_data, + float* inverse_squared_patched_dys, int *patched_data_size, + int* maxDuration, int* period_size) { + int tid = blockIdx.x * blockDim.x + threadIdx.x; + + if (tid >= *period_size) { + return; + } + + float* patched_data = patch_data + tid * (*patched_data_size); + float* inverse_squared_patched_dy = inverse_squared_patched_dys + tid * (*patched_data_size); + + double edgeEffect = 0.0; + int start_idx = (*patched_data_size) - (*maxDuration); + + for (int j = start_idx; j < (*patched_data_size); j++) { + double patchDataJ = (double)(patched_data[j]); + double patchDataDyJ = (double)(inverse_squared_patched_dy[j]); + edgeEffect += (1.0 + patchDataJ * patchDataJ - 2.0 * patchDataJ) * patchDataDyJ; + } + out[tid] = edgeEffect; + } + +__global__ void calculate_base_error( + float* out_base_error, // Shape: (num_periods, patched_data_size) + const float* in_patched_data, + const float* in_inverse_squared_patched_dy, + int patched_data_size, + int num_periods + ) { + int tid = blockIdx.x * blockDim.x + threadIdx.x; // point index + int z = blockIdx.y; // period index + + if (z >= num_periods || tid >= patched_data_size) { + return; + } + + const float* patched_data_period = in_patched_data + z * patched_data_size; + const float* inverse_squared_patched_period = in_inverse_squared_patched_dy + z * patched_data_size; + + float diff = 1.0f - patched_data_period[tid]; + out_base_error[tid + z * patched_data_size] = diff * diff * inverse_squared_patched_period[tid]; + } +} diff --git a/cuvarbase/kernels/tls_reference_short_prefix.cu b/cuvarbase/kernels/tls_reference_short_prefix.cu new file mode 100644 index 00000000..f5683813 --- /dev/null +++ b/cuvarbase/kernels/tls_reference_short_prefix.cu @@ -0,0 +1,44 @@ +// Reuse the installed native CUB agent and its floating-point association. +// Runtime guards and a startup canary live in tls_reference_short_prefix.py. +#include +#include +#include +#include + +#if CUB_VERSION != 200800 +#error "This native scan wrapper requires CUB 2.8.0" +#endif +#if __CUDACC_VER_MAJOR__ != 12 || __CUDACC_VER_MINOR__ != 4 || __CUDACC_VER_BUILD__ != 131 +#error "Only nvcc 12.4.131 has been validated for this native scan wrapper" +#endif +#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ != 860 +#error "Only SM86 policy parity has been source-audited" +#endif + +using Op = cuda::std::plus<>; +using Policy = cub::detail::scan::policy_hub::Policy860::ScanPolicyT; +using Agent = cub::detail::scan::AgentScan< + Policy, float*, float*, Op, cub::NullType, unsigned int, float, false>; +static_assert(Policy::BLOCK_THREADS == 128, "native thread count changed"); +static_assert(Policy::ITEMS_PER_THREAD == 15, "native tile mapping changed"); +static_assert(Policy::SCAN_ALGORITHM == cub::BLOCK_SCAN_WARP_SCANS, + "native scan association changed"); +static_assert(Policy::LOAD_ALGORITHM == cub::BLOCK_LOAD_WARP_TRANSPOSE, + "native load policy changed"); +static_assert(Policy::STORE_ALGORITHM == cub::BLOCK_STORE_WARP_TRANSPOSE, + "native store policy changed"); + +extern "C" __global__ __launch_bounds__(128) +void native_cub_short_rows(float* input, float* output, int rows, int columns) +{ + const int row = blockIdx.x; + if (row >= rows || columns < 1 || columns > Agent::TILE_ITEMS) return; + __shared__ typename Agent::TempStorage temporary; + typename Agent::ScanTileStateT unused_state; + // This is exactly the dynamic device scan's first AND last tile branch. + // No lookback/state access occurs for tile_idx=0, IS_LAST_TILE=true. + // CUB also preserves its native first-element fill of unused suffix slots. + const long long offset = (long long)row * columns; + Agent agent(temporary, input + offset, output + offset, Op{}, cub::NullType{}); + agent.template ConsumeTile((unsigned int)columns, 0, 0, unused_state); +} diff --git a/cuvarbase/kernels/wavelet.cu b/cuvarbase/kernels/wavelet.cu deleted file mode 100644 index 2404a8b0..00000000 --- a/cuvarbase/kernels/wavelet.cu +++ /dev/null @@ -1,147 +0,0 @@ -#include -#define WEIGHT(k) (w==NULL ? 1.0f : w[k]) -#define GAUSSIAN(x) expf(-0.5f *x*x) -#define WEIGHTED_LININTERP true -#define SKIP_BIN(i) (bin_wtots[i] * NBINS < 0.01f) -//INSERT_NBINS_HERE -#define PHASE(x,f) (x * f - floorf(x * f)) -#define TWOPI 6.28318530718f -#define RESTRICT __restrict__ -#define CONSTANT const -#define MIN_NOBS 10 -#define wavelet full_wavelet - - -__device__ float fast_wavelet(float dt, float sigma, float freq){ - float a = fabs(TWOPI * sigma * freq * dt); - - return a < 1.f ? 1.f - 3.f * a * a + 2.f * a * a * a : 0.f; -} - -__device__ float full_wavelet(float dt, float sigma, float freq){ - float a = fabs(TWOPI * sigma * freq * dt); - - return expf(-a*a); -} - -__device__ float cosine_wtransform(float *t, float *y, float *w, float freq, float tau, float sigma, - int imin, int imax){ - float pow = 0.f; - float weight = 0.f; - float tot_weight = 0.f; - for(int i = imin; i <= imax; i++){ - weight = wavelet(t[i] - tau, sigma, freq) * (w == NULL ? 1.f : w[i]); - tot_weight += weight; - pow += y[i] * weight * cos(TWOPI * freq * t[i]); - } - return pow / tot_weight; -} - -__device__ float sine_wtransform(float *t, float *y, float *w, float freq, float tau, float sigma, - int imin, int imax){ - float pow = 0.f; - float weight = 0.f; - float tot_weight = 0.f; - for(int i = imin; i <= imax; i++){ - weight = wavelet(t[i] - tau, sigma, freq) * (w == NULL ? 1.f : w[i]); - tot_weight += weight; - pow += y[i] * weight * cos(TWOPI * freq * t[i]); - } - return pow / tot_weight; -} - -__device__ float weighted_mean(float *t, float *y, float *w, float freq, float tau, - float sigma, int imin, int imax){ - float s = 0.f; - float weight = 0.f; - float total_weight = 0.f; - for(int i = imin; i <= imax; i++){ - weight = wavelet(t[i] - tau, sigma, freq) * (w == NULL ? 1.f : w[i]); - s += y[i] * weight; - total_weight += weight; - } - return s / total_weight; -} - -__device__ float weighted_var(float *t, float *y, float *w, float freq, float tau, - float sigma, int imin, int imax){ - float s = 0.f; - float weight = 0.f; - float total_weight = 0.f; - for(int i = imin; i <= imax; i++){ - weight = wavelet(t[i] - tau, sigma, freq) * (w == NULL ? 1.f : w[i]); - s += y[i] * y[i] * weight; - total_weight += weight; - } - return s / total_weight; -} - -__device__ float power(float *t, float *y, float *w, float freq, float tau, - float prec, float sigma, int nobs){ - - // least squares (lomb scargle with floating mean) - - int imin = 0; - int imax = nobs - 1; - - float wmin = pow(10.f, -prec); - - while( imin < nobs && wavelet(t[imin] - tau, sigma, freq) < wmin) imin ++; - while( imax > 0 && wavelet(t[imax] - tau, sigma, freq) < wmin) imax --; - - if (imax - imin < MIN_NOBS) return 0.f; - - float Y = weighted_mean(t, y, w, freq, tau, sigma, imin, imax); - float YY = weighted_var(t, y, w, freq, tau, sigma, imin, imax) - Y*Y; - - float C = cosine_wtransform(t, w, NULL, freq, tau, sigma, imin, imax); - float S = sine_wtransform(t, w, NULL, freq, tau, sigma, imin, imax); - - float C2 = cosine_wtransform(t, w, NULL, 2 * freq, tau, sigma, imin, imax); - float S2 = sine_wtransform(t, w, NULL, 2 * freq, tau, sigma, imin, imax); - - float YC = cosine_wtransform(t, y, w, freq, tau, sigma, imin, imax) - Y * C; - float YS = sine_wtransform(t, y, w, freq, tau, sigma, imin, imax) - Y * S; - - float CC = 0.5f * ( 1.f + C2 ) - C * C; - float CS = 0.5f * S2 - C * S; - float SS = 0.5f * ( 1.f - C2 ) - S * S; - - float D = CC * SS - CS * CS; - - float p = (SS * YC * YC + CC * YS * YS - 2 * CS * YC * YS) / (YY * D); - - // force 0 < p < 1 - return p < 0.f ? 0.f : (p > 1.f ? 0.f : p); -} - - -__device__ int sumint(int *arr, int len){ - int s = 0.f; - for(int i = 0; i < len; i++) - s += arr[i]; - return s; -} - - -__global__ void wavelet_spectrogram(float *t, float *y, float *w, float *spectrogram, - float *freqs, float *taus, int *ntaus, int nfreqs, - int nobs, float sigma, float prec){ - - int i = blockIdx.x * blockDim.x + threadIdx.x; - - int tot_ntaus = sumint(ntaus, nfreqs); - if (i < tot_ntaus){ - int fno = 0; - int s = 0; - while(s < i){ - fno ++; - s += ntaus[fno]; - } - - float tau = taus[i]; - float freq = freqs[fno]; - - spectrogram[i] = power(t, y, w, freq, tau, prec, sigma, nobs); - } -} \ No newline at end of file diff --git a/cuvarbase/lombscargle.py b/cuvarbase/lombscargle.py index e875d44a..82a28954 100644 --- a/cuvarbase/lombscargle.py +++ b/cuvarbase/lombscargle.py @@ -1,337 +1,185 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from builtins import zip -from builtins import map -from builtins import range -from builtins import object -import resource +""" +Lomb-Scargle periodogram implementation. +GPU-accelerated implementation of the generalized Lomb-Scargle periodogram. +""" import numpy as np -from scipy.special import gamma, gammaln +from scipy.special import gammaln -import pycuda.driver as cuda -import pycuda.gpuarray as gpuarray from pycuda.compiler import SourceModule -# import pycuda.autoinit -from .core import GPUAsyncProcess -from .utils import weights, find_kernel, _module_reader, normalize_light_curves +from . import _cufft as cufft + +from .base import GPUAsyncProcess +from .utils import find_kernel, _module_reader, normalize_light_curves +from .utils import check_lightcurve, check_freqs from .utils import autofrequency as utils_autofreq -from .cunfft import NFFTAsyncProcess, nfft_adjoint_async, NFFTMemory +from .memory import LombScargleMemory +from .memory.lombscargle_memory import nfft_grid_sizes +from .cunfft import NFFTAsyncProcess, nfft_adjoint_async +from .cunfft import _reject_precision_override, _check_memory_precision + + +__all__ = [ + 'get_k0', + 'check_k0', + 'mhdirect_sums', + 'add_regularization', + 'mhgls_params_from_sums', + 'mhgls_from_sums', + 'lomb_scargle_direct_sums', + 'lomb_scargle_async', + 'LombScargleAsyncProcess', + 'fap_baluev', + 'lomb_scargle_simple', +] + + +try: + from .cufinufft_backend import cufinufft_nfft_adjoint, HAS_CUFINUFFT +except ImportError: + HAS_CUFINUFFT = False + + +# Minimum number of observations the Lomb-Scargle entry points accept. +# The generalized (floating-mean) periodogram fits three free +# parameters -- offset, cosine and sine amplitude -- so fewer than four +# points leave no residual degrees of freedom: the audit measured +# powers of 9.9e9 at N = 2 and 1.6e4 at N = 3 (a normalized power +# cannot exceed 1). +_LS_MIN_NDATA = 4 + + +# Frequencies per stacked multiharmonic solve in +# :func:`_mh_power_from_spectra` (bounds the (chunk, 2H, 2H) +# temporary; results do not depend on it). +_MH_SOLVE_CHUNK = 1 << 16 + + +def _grid_spacing(freqs): + """``(f, df)``: the frequency grid as a 1-d float64 array and its + spacing estimated from the full span, ``(f[-1] - f[0]) / (nf - 1)``. + + The full-span estimate is used everywhere (:func:`get_k0`, + :func:`check_k0`, the ``df`` handed to the kernels) because + ``f[1] - f[0]`` carries the rounding of two nearly equal numbers: + for ``freqs = df * (k0 + arange(nf))`` its relative error is + ``~k0 * eps``, which ``k0 * df`` then amplifies to ``k0**2 * eps`` + (3e-5 modes at k0 = 365,000 in float64, and far worse for float32 + grids). + + Raises ``ValueError`` for fewer than two frequencies or a + non-increasing / non-finite grid. + """ + f = np.asarray(freqs, dtype=np.float64).ravel() + nf = len(f) + if nf < 2: + raise ValueError( + "at least two frequencies are needed (got %d): the GPU " + "Lomb-Scargle evaluates a uniform grid df * (k0 + arange(nf))" + % nf) + df = (f[-1] - f[0]) / (nf - 1) + if not (np.isfinite(df) and df > 0): + raise ValueError( + "freqs must be finite and strictly increasing (got freqs[0]=%r, " + "freqs[-1]=%r): the GPU Lomb-Scargle evaluates a uniform grid " + "df * (k0 + arange(nf)) with df > 0" % (f[0], f[-1])) + return f, df def get_k0(freqs): - return max([1, int(round(freqs[0] / (freqs[1] - freqs[0])))]) - - -def check_k0(freqs, k0=None, rtol=1E-2, atol=1E-7): - k0 = k0 if k0 is not None else get_k0(freqs) - df = freqs[1] - freqs[0] - f0 = k0 * df - assert(abs(f0 - freqs[0]) < rtol * df + atol) - - -class LombScargleMemory(object): - """ - Container class for allocating memory and transferring - data between the GPU and CPU for Lomb-Scargle computations + """Index of the first mode, ``round(freqs[0] / df)`` (at least 1), + of a uniform grid ``freqs = df * (k0 + arange(nf))``.""" + f, df = _grid_spacing(freqs) + return max([1, int(round(f[0] / df))]) + + +def check_k0(freqs, k0=None, rtol=1E-6, atol=0.): + """Validate that ``freqs`` is the uniform grid ``df * (k0 + arange(nf))`` + the GPU kernels evaluate. + + Every kernel (NFFT and direct sums) evaluates ``fmin + i * df``; the + user's array only labels the output. A grid that is not uniform -- + two concatenated ``arange`` segments, a uniform grid with points + deleted, ``geomspace`` -- was silently evaluated on the implied + uniform grid and returned under the wrong labels before 1.0, when + only ``freqs[0:2]`` were inspected (defect 15, + ``ls-nonuniform-grid``). ``freqs[0]`` must also be an integer + multiple of ``df``: the NFFT can only produce integer modes + (:func:`~cuvarbase.cunfft.nfft_adjoint_async` rounds + ``minimum_frequency`` to the nearest one, in float64 on the host). Parameters ---------- - sigma: int - The ``sigma`` parameter for the NFFT - stream: :class:`pycuda.driver.Stream` instance - The CUDA stream used for calculations/data transfer - m: int - The ``m`` parameter for the NFFT + freqs : array_like + Candidate grid (any float dtype; compared in float64). + k0 : int, optional + Expected first mode; :func:`get_k0` of the grid if omitted. + rtol : float, optional (default: 1e-6) + Tolerance on every spacing and on ``freqs[0] - k0 * df``, as a + fraction of ``df``. A dtype-aware allowance for the rounding of + the grid's own construction is added on top, term by term: + ``4 eps(dtype) max|f|`` for the spacings, and + ``4 eps |freqs[0]| + 4 eps max|f| k0 / (nf - 1)`` for the first + mode. float64 ``autofrequency``/``arange``/``linspace`` grids of + any size and the float32 casts of the same grids pass, while a + first mode offset by a hundredth of a bin is rejected -- for a + float32 survey-scale grid too, as long as ``k0`` is not a large + fraction of ``nf``. (When it is, ``df`` itself is only known to + ``eps max|f| / (nf - 1)``, so offsets below + ``4 eps max|f| k0 / ((nf - 1) df)`` bins are genuinely + indistinguishable in that dtype; pass float64 frequencies, or + ``use_double=True``, for narrow high-frequency bands.) + atol : float, optional (default: 0) + Absolute tolerance (frequency units) added to both tests. + + Raises + ------ + ValueError + Naming the first non-uniform spacing, or the fractional + ``freqs[0] / df``. """ - def __init__(self, sigma, stream, m, **kwargs): - - self.sigma = sigma - self.stream = stream - self.m = m - self.k0 = kwargs.get('k0', 0) - self.precomp_psi = kwargs.get('precomp_psi', True) - self.amplitude_prior = kwargs.get('amplitude_prior', None) - self.window = kwargs.get('window', False) - self.nharmonics = kwargs.get('nharmonics', 1) - self.use_fft = kwargs.get('use_fft', True) - - self.other_settings = {} - self.other_settings.update(kwargs) - - self.floating_mean = kwargs.get('floating_mean', True) - self.use_double = kwargs.get('use_double', False) - - self.mode = 1 if self.floating_mean else 0 - if self.window: - self.mode = 2 - - self.n0 = kwargs.get('n0', None) - self.nf = kwargs.get('nf', None) - - self.t_g = kwargs.get('t_g', None) - self.yw_g = kwargs.get('yw_g', None) - self.w_g = kwargs.get('w_g', None) - self.lsp_g = kwargs.get('lsp_g', None) - - if self.use_fft: - self.nfft_mem_yw = kwargs.get('nfft_mem_yw', None) - self.nfft_mem_w = kwargs.get('nfft_mem_w', None) - - if self.nfft_mem_yw is None: - self.nfft_mem_yw = NFFTMemory(self.sigma, self.stream, - self.m, **kwargs) - - if self.nfft_mem_w is None: - self.nfft_mem_w = NFFTMemory(self.sigma, self.stream, - self.m, **kwargs) - - self.real_type = self.nfft_mem_yw.real_type - self.complex_type = self.nfft_mem_yw.complex_type - - else: - self.real_type = np.float32 - self.complex_type = np.complex64 - - if self.use_double: - self.real_type = np.float64 - self.complex_type = np.complex128 - - # Set up regularization - self.reg_g = gpuarray.zeros(2 * self.nharmonics + 1, - dtype=self.real_type) - self.reg = np.zeros(2 * self.nharmonics + 1, - dtype=self.real_type) - - if self.amplitude_prior is not None: - lmbda = np.power(self.amplitude_prior, -2) - if isinstance(lmbda, float): - lmbda = lmbda * np.ones(self.nharmonics) - - for i, l in enumerate(lmbda): - self.reg[2 * i] = self.real_type(l) - self.reg[1 + 2 * i] = self.real_type(l) - - self.reg_g.set_async(self.reg, stream=self.stream) - - self.buffered_transfer = kwargs.get('buffered_transfer', False) - self.n0_buffer = kwargs.get('n0_buffer', None) - - self.lsp_c = kwargs.get('lsp_c', None) - - self.t = kwargs.get('t', None) - self.yw = kwargs.get('yw', None) - self.w = kwargs.get('w', None) - - def allocate_data(self, **kwargs): - """ Allocates memory for lightcurve """ - n0 = kwargs.get('n0', self.n0) - if self.buffered_transfer: - n0 = kwargs.get('n0_buffer', self.n0_buffer) - - assert(n0 is not None) - self.t_g = gpuarray.zeros(n0, dtype=self.real_type) - self.yw_g = gpuarray.zeros(n0, dtype=self.real_type) - self.w_g = gpuarray.zeros(n0, dtype=self.real_type) - - if self.use_fft: - self.nfft_mem_w.t_g = self.t_g - self.nfft_mem_w.y_g = self.w_g - - self.nfft_mem_yw.t_g = self.t_g - self.nfft_mem_yw.y_g = self.yw_g - - self.nfft_mem_yw.n0 = n0 - self.nfft_mem_w.n0 = n0 - - return self - - def allocate_grids(self, **kwargs): - """ - Allocates memory for NFFT grids, NFFT precomputation vectors, - and the GPU vector for the Lomb-Scargle power - """ - k0 = kwargs.get('k0', self.k0) - n0 = kwargs.get('n0', self.n0) - if self.buffered_transfer: - n0 = kwargs.get('n0_buffer', self.n0_buffer) - assert(n0 is not None) - - self.nf = kwargs.get('nf', self.nf) - assert(self.nf is not None) - - if self.use_fft: - if self.nfft_mem_yw.precomp_psi: - self.nfft_mem_yw.allocate_precomp_psi(n0=n0) - - # Only one precomp psi needed - self.nfft_mem_w.precomp_psi = False - self.nfft_mem_w.q1 = self.nfft_mem_yw.q1 - self.nfft_mem_w.q2 = self.nfft_mem_yw.q2 - self.nfft_mem_w.q3 = self.nfft_mem_yw.q3 - - fft_size = self.nharmonics * (self.nf + k0) - self.nfft_mem_yw.allocate_grid(nf=fft_size - k0) - self.nfft_mem_w.allocate_grid(nf=2 * fft_size - k0) - - self.lsp_g = gpuarray.zeros(self.nf, dtype=self.real_type) - return self - - def allocate_pinned_cpu(self, **kwargs): - """ Allocates pinned CPU memory for asynchronous transfer of result """ - nf = kwargs.get('nf', self.nf) - assert(nf is not None) - - self.lsp_c = cuda.aligned_zeros(shape=(nf,), dtype=self.real_type, - alignment=resource.getpagesize()) - - return self - - def is_ready(self): - """ don't use this. """ - raise NotImplementedError() - - def allocate_buffered_data_arrays(self, **kwargs): - """ - Allocates pinned memory for lightcurves if we're reusing - this container - """ - n0 = kwargs.get('n0', self.n0) - if self.buffered_transfer: - n0 = kwargs.get('n0_buffer', self.n0_buffer) - assert(n0 is not None) - - self.t = cuda.aligned_zeros(shape=(n0,), - dtype=self.real_type, - alignment=resource.getpagesize()) - - self.yw = cuda.aligned_zeros(shape=(n0,), - dtype=self.real_type, - alignment=resource.getpagesize()) - - self.w = cuda.aligned_zeros(shape=(n0,), - dtype=self.real_type, - alignment=resource.getpagesize()) - - return self - - def allocate(self, **kwargs): - """ Allocate all memory necessary """ - self.nf = kwargs.get('nf', self.nf) - assert(self.nf is not None) - - self.allocate_data(**kwargs) - self.allocate_grids(**kwargs) - self.allocate_pinned_cpu(**kwargs) - - if self.buffered_transfer: - self.allocate_buffered_data_arrays(**kwargs) - - return self - - def setdata(self, **kwargs): - """ Sets the value of the data arrays. """ - t = kwargs.get('t', self.t) - yw = kwargs.get('yw', self.yw) - w = kwargs.get('w', self.w) - - y = kwargs.get('y', None) - dy = kwargs.get('dy', None) - self.ybar = 0. - self.yy = kwargs.get('yy', 1.) - - self.n0 = kwargs.get('n0', len(t)) - if dy is not None: - assert('w' not in kwargs) - w = weights(dy) - - if y is not None: - assert('yw' not in kwargs) - - self.ybar = np.dot(y, w) - yw = np.multiply(w, y - self.ybar) - y2 = np.power(y - self.ybar, 2) - self.yy = np.dot(w, y2) - - t = np.asarray(t).astype(self.real_type) - yw = np.asarray(yw).astype(self.real_type) - w = np.asarray(w).astype(self.real_type) - - if self.buffered_transfer: - if any([arr is None for arr in [self.t, self.yw, self.w]]): - if self.buffered_transfer: - self.allocate_buffered_data_arrays(**kwargs) - - assert(self.n0 <= len(self.t)) - - self.t[:self.n0] = t[:self.n0] - self.yw[:self.n0] = yw[:self.n0] - self.w[:self.n0] = w[:self.n0] - else: - self.t = np.asarray(t).astype(self.real_type) - self.yw = np.asarray(yw).astype(self.real_type) - self.w = np.asarray(w).astype(self.real_type) - - # Set minimum and maximum t values (needed to scale things - # for the NFFT) - self.tmin = min(t) - self.tmax = max(t) - - if self.use_fft: - self.nfft_mem_yw.tmin = self.tmin - self.nfft_mem_w.tmin = self.tmin - - self.nfft_mem_yw.tmax = self.tmax - self.nfft_mem_w.tmax = self.tmax - - self.nfft_mem_w.n0 = len(t) - self.nfft_mem_yw.n0 = len(t) - - return self - - def transfer_data_to_gpu(self, **kwargs): - """ Transfers the lightcurve to the GPU """ - t, yw, w = self.t, self.yw, self.w - - assert(not any([arr is None for arr in [t, yw, w]])) - - # Do asynchronous data transfer - self.t_g.set_async(t, stream=self.stream) - self.yw_g.set_async(yw, stream=self.stream) - self.w_g.set_async(w, stream=self.stream) - - def transfer_lsp_to_cpu(self, **kwargs): - """ Asynchronous transfer of LSP result to CPU """ - self.lsp_g.get_async(ary=self.lsp_c, stream=self.stream) - - def fromdata(self, **kwargs): - """ Sets and (optionally) allocates memory for data """ - self.setdata(**kwargs) - - if kwargs.get('allocate', True): - self.allocate(**kwargs) - - return self - - def set_gpu_arrays_to_zero(self, **kwargs): - """ Sets all gpu arrays to zero """ - for x in [self.t_g, self.yw_g, self.w_g]: - if x is not None: - x.fill(self.real_type(0), stream=self.stream) - - for x in [self.t, self.yw, self.w]: - if x is not None: - x[:] = 0. - - if hasattr(self, 'nfft_mem_yw'): - self.nfft_mem_yw.ghat_g.fill(self.complex_type(0), - stream=self.stream) - if hasattr(self, 'nfft_mem_w'): - self.nfft_mem_w.ghat_g.fill(self.complex_type(0), - stream=self.stream) + f, df = _grid_spacing(freqs) + nf = len(f) + k0 = get_k0(f) if k0 is None else int(k0) + + dtype = np.asarray(freqs).dtype + eps = np.finfo(dtype).eps if np.issubdtype(dtype, np.floating) \ + else np.finfo(np.float64).eps + round_tol = 4.0 * float(eps) * float(np.max(np.abs(f))) + + # uniformity: every spacing against the median spacing (robust to a + # single gap, so the message names the gap and not the first point) + diffs = np.diff(f) + df_med = float(np.median(diffs)) + bad = np.flatnonzero(np.abs(diffs - df_med) + > rtol * df_med + round_tol + atol) + if len(bad): + i = int(bad[0]) + raise ValueError( + "freqs is not uniformly spaced: freqs[%d] - freqs[%d] = %.10g " + "but the grid spacing is %.10g (%d of %d spacings deviate by " + "more than %g df). The GPU Lomb-Scargle evaluates exactly " + "freqs = df * (k0 + arange(nf)) and cannot use a non-uniform " + "grid; build one uniform grid per band instead" + % (i + 1, i, diffs[i], df_med, len(bad), nf, rtol)) + + # first mode: the two terms of |f[0] - k0 * df| round differently. + # f[0] itself only carries its own storage error, eps * |f[0]|; the + # k0 * df product amplifies the spacing's rounding (two endpoint + # roundings, spread over nf - 1 spacings) by k0 / (nf - 1). Using + # round_tol = 4 eps max|f| for *both* opens a hole of + # 4 eps fmax / df modes -- 0.43 df on a float32 survey grid -- which + # is exactly the off-by-a-fraction-of-a-mode band shift defect 15 + # closes (0.52 relative power error at a 0.1-mode offset). + k0_tol = (rtol * df + 4.0 * float(eps) * abs(float(f[0])) + + round_tol * float(k0) / (nf - 1) + atol) + if not (abs(f[0] - k0 * df) <= k0_tol): + raise ValueError( + "freqs[0]=%.10g is not an integer multiple of the grid spacing " + "df=%.10g (freqs[0] / df = %.8f, nearest integer k0 = %d): the " + "GPU Lomb-Scargle requires freqs = df * (k0 + arange(nf))" + % (f[0], df, f[0] / df, k0)) def mhdirect_sums(t, yw, w, freq, YY, nharms=1): @@ -371,33 +219,64 @@ def mhdirect_sums(t, yw, w, freq, YY, nharms=1): ns = np.arange(2 * nharms + 1) - def sgn(n): - return 1 if n == 0 else np.sign(n) - c = [np.dot(w, np.cos(n * phase)) for n in ns] s = [np.dot(w, np.sin(n * phase)) for n in ns] - yc = [np.dot(yw, np.cos(n * phase)) for n in ns[1:nharms+1]] - ys = [np.dot(yw, np.sin(n * phase)) for n in ns[1:nharms+1]] + yc = np.asarray([np.dot(yw, np.cos(n * phase)) + for n in ns[1:nharms+1]]) + ys = np.asarray([np.dot(yw, np.sin(n * phase)) + for n in ns[1:nharms+1]]) - cc = [[0.5 * (c[n+m] + c[abs(n-m)]) for m in ns[1:nharms+1]] for n in ns[1:nharms+1]] + ybar = np.sum(yw) + C = np.asarray(c)[1:nharms+1] + S = np.asarray(s)[1:nharms+1] + YC = yc - ybar * C + YS = ys - ybar * S - cs = [[0.5 * (s[n+m] - sgn(n-m) * s[abs(n-m)]) for m in ns[1:nharms+1]] for n in ns[1:nharms+1]] + return _mh_assemble_from_centered(c, s, YC, YS, nharms) - ss = [[0.5 * (c[abs(n-m)] - c[n+m]) for m in ns[1:nharms+1]] for n in ns[1:nharms+1]] - C = np.asarray(c)[1:nharms+1] - S = np.asarray(s)[1:nharms+1] +def _mh_assemble_from_centered(c, s, YC, YS, nharms): + """Assemble the (C, S, CC, CS, SS, YC, YS) multiharmonic GLS sums from + raw weight moments and already-mean-subtracted YC/YS. + + Shared by :func:`mhdirect_sums` (which gets the moments from direct + trig sums) and the GPU multiharmonic path (which reads them off the + NFFT spectra of ``w`` and ``w*(y-ybar)``). - ybar = sum(yw) - YC = np.asarray(yc) - ybar * C - YS = np.asarray(ys) - ybar * S + Parameters + ---------- + c, s : array_like + Weight moments, length ``2*nharms+1``: + ``c[m] = sum w cos(2 pi m f t)``, ``s[m] = sum w sin(2 pi m f t)`` + (so ``c[0]=sum w=1``, ``s[0]=0``). Indices up to ``2*nharms`` are + needed for the cross-term matrices. + YC, YS : array_like + Already mean-subtracted ``sum w (y-ybar) cos/sin(2 pi h f t)`` for + ``h = 1..nharms``. + nharms : int + Number of harmonics. + """ + c = np.asarray(c, dtype=np.float64) + s = np.asarray(s, dtype=np.float64) + H = nharms + + def sgn(n): + return 1 if n == 0 else np.sign(n) + + hs = range(1, H + 1) + cc = [[0.5 * (c[n+m] + c[abs(n-m)]) for m in hs] for n in hs] + cs = [[0.5 * (s[n+m] - sgn(n-m) * s[abs(n-m)]) for m in hs] for n in hs] + ss = [[0.5 * (c[abs(n-m)] - c[n+m]) for m in hs] for n in hs] + + C = c[1:H+1] + S = s[1:H+1] CC = np.asarray(cc) - np.outer(C, C) CS = np.asarray(cs) - np.outer(C, S) SS = np.asarray(ss) - np.outer(S, S) - return C, S, CC, CS, SS, YC, YS + return C, S, CC, CS, SS, np.asarray(YC), np.asarray(YS) def add_regularization(sums, amplitude_priors=None, cn0=None, sn0=None): @@ -536,6 +415,261 @@ def mhgls_from_sums(sums, YY, ybar): return P +def _mh_power_from_spectra(sw, syw, k0, nharms, nf, YY, reg_kwargs=None): + """Multiharmonic GLS power from the GPU NFFT spectra. + + ``sw`` is the adjoint NFFT of the weights ``w`` and ``syw`` of + ``w*(y-ybar)``, both laid out so that entry ``j`` holds the spectrum + at frequency index ``(k0 + j)`` (i.e. frequency ``(k0+j)*df``). The + value at the ``m``-th harmonic of the ``i``-th output frequency + ``m*f_i`` is therefore at array index ``(m-1)*k0 + m*i``. + + For each output frequency the weight moments ``c[0..2H], s[0..2H]`` + are read from ``sw`` (``c[0]=1``, ``s[0]=0``) and the mean-subtracted + ``YC, YS`` (h=1..H) from ``syw``; these are fed through the existing, + tested :func:`_mh_assemble_from_centered` + :func:`mhgls_from_sums` + (the small 2H x 2H solve runs in float64 on the host -- cheap, and + numerically safer than a float32 in-kernel solve). + + The systems for all ``nf`` frequencies are assembled and solved as + one stacked ``(nf, 2H, 2H)`` problem (in chunks of + ``_MH_SOLVE_CHUNK``) rather than one at a time in Python; the + arithmetic per frequency is the same as + ``mhgls_from_sums(add_regularization(_mh_assemble_from_centered(...)))`` + and agrees with it to ~1e-16 relative. + """ + H = int(nharms) + i = np.arange(nf) + + # weight moments c[m], s[m] for m = 1..2H from the w-spectrum + cm = np.empty((2 * H + 1, nf), dtype=np.float64) + sm = np.empty((2 * H + 1, nf), dtype=np.float64) + cm[0] = 1.0 + sm[0] = 0.0 + for m in range(1, 2 * H + 1): + idx = (m - 1) * k0 + m * i + vals = sw[idx] + cm[m] = vals.real + sm[m] = vals.imag + + # mean-subtracted YC[h], YS[h] for h = 1..H from the w*(y-ybar) spectrum + YC = np.empty((H, nf), dtype=np.float64) + YS = np.empty((H, nf), dtype=np.float64) + for h in range(1, H + 1): + idx = (h - 1) * k0 + h * i + vals = syw[idx] + YC[h - 1] = vals.real + YS[h - 1] = vals.imag + + # The 2H x 2H systems are assembled and solved for all frequencies + # at once (np.linalg.solve broadcasts over a leading axis, calling + # the same LAPACK dgesv per matrix): the per-frequency Python loop + # this replaces cost 60-90 us per frequency (1.6 s at nf = 20,000, + # H = 2). Chunked so the (nf, 2H, 2H) stack stays small. + hs = np.arange(1, H + 1) + n_idx = hs[:, np.newaxis] + m_idx = hs[np.newaxis, :] + isum = n_idx + m_idx + idiff = np.abs(n_idx - m_idx) + # sgn(0) = 1 (see _mh_assemble_from_centered) + sgn = np.where(n_idx == m_idx, 1.0, + np.sign(n_idx - m_idx)).astype(np.float64) + + # regularization: a ridge 1/prior**2 on the diagonal of CC and SS + # and a shift of YC/YS toward the prior centroid (add_regularization) + D = cn0 = sn0 = None + if reg_kwargs: + priors = reg_kwargs.get('amplitude_priors', None) + if priors is not None: + D = np.ones(H, dtype=np.float64) * np.power(priors, -2) + c0 = reg_kwargs.get('cn0', None) + s0 = reg_kwargs.get('sn0', None) + cn0 = np.zeros(H) if c0 is None else np.asarray(c0, np.float64) + sn0 = np.zeros(H) if s0 is None else np.asarray(s0, np.float64) + + power = np.empty(nf, dtype=np.float64) + chunk = max(1, int(_MH_SOLVE_CHUNK)) + for start in range(0, nf, chunk): + sl = slice(start, min(start + chunk, nf)) + c = cm[:, sl] + s = sm[:, sl] + nc = c.shape[1] + + C = c[1:H + 1] + S = s[1:H + 1] + + cc = 0.5 * (c[isum] + c[idiff]) + cs = 0.5 * (s[isum] - sgn[:, :, np.newaxis] * s[idiff]) + ss = 0.5 * (c[idiff] - c[isum]) + + CC = cc - C[:, np.newaxis, :] * C[np.newaxis, :, :] + CS = cs - C[:, np.newaxis, :] * S[np.newaxis, :, :] + SS = ss - S[:, np.newaxis, :] * S[np.newaxis, :, :] + + YCc = YC[:, sl] + YSc = YS[:, sl] + if D is not None: + dg = np.diag(D)[:, :, np.newaxis] + CC = CC + dg + SS = SS + dg + YCc = YCc + (D * cn0)[:, np.newaxis] + YSc = YSc + (D * sn0)[:, np.newaxis] + + A = np.empty((nc, 2 * H, 2 * H), dtype=np.float64) + A[:, :H, :H] = CC.transpose(2, 0, 1) + A[:, :H, H:] = CS.transpose(2, 0, 1) + A[:, H:, :H] = CS.transpose(2, 1, 0) + A[:, H:, H:] = SS.transpose(2, 0, 1) + + b = np.empty((nc, 2 * H, 1), dtype=np.float64) + b[:, :H, 0] = YCc.T + b[:, H:, 0] = YSc.T + + theta = np.linalg.solve(A, b)[:, :, 0] + cn = theta[:, :H] + sn = theta[:, H:] + + XX = (cn[:, :, np.newaxis] * cn[:, np.newaxis, :]) * A[:, :H, :H] + XX += 2 * (cn[:, :, np.newaxis] * sn[:, np.newaxis, :]) \ + * A[:, :H, H:] + XX += (sn[:, :, np.newaxis] * sn[:, np.newaxis, :]) * A[:, H:, H:] + + YX = 2 * ((cn * YCc.T).sum(axis=1) + (sn * YSc.T).sum(axis=1)) + power[sl] = (YX - XX.sum(axis=(1, 2))) / YY + return power + + +# Keys that hand :class:`LombScargleMemory` (or the two +# :class:`~cuvarbase.memory.nfft_memory.NFFTMemory` sets it builds) a +# pre-built buffer, or that override the sizes it allocates for: a +# memory object built with any of them cannot be matched against a +# later request by settings alone, so the batched entry point neither +# reuses nor caches memory when one is given and every such call +# allocates its own set exactly as it did before 1.0. +_LS_MEMORY_OVERRIDE_KWARGS = frozenset(( + 't_g', 'yw_g', 'w_g', 'lsp_g', 'lsp_c', 't', 'yw', 'w', + 'nfft_mem_yw', 'nfft_mem_w', 'n0', 'nf', 'k0', + 'buffered_transfer', 'n0_buffer', + 'y_g', 'ghat_g', 'ghat_c', 'q1', 'q2', 'q3', 'cu_plan', + # LombScargleMemory takes these POSITIONALLY, so passing them as + # keywords has always raised TypeError("got multiple values for + # argument ..."). They must opt out of the cache too: on a cache + # hit the constructor is never called, so the call would silently + # succeed and silently ignore the keyword, returning the + # process-default result. Loud error beats wrong configuration. + 'sigma', 'm', 'stream')) + + +def _amplitude_prior_key(prior): + """Hashable, exact key for an ``amplitude_prior`` (scalar, sequence + or array); ``None`` for no prior.""" + if prior is None: + return None + arr = np.asarray(prior, dtype=np.float64) + return (arr.shape, arr.tobytes()) + + +def _ls_memory_settings(nf, k0, m, sigma, use_double, nharmonics, use_fft, + kwargs): + """The settings that make two :class:`LombScargleMemory` objects + interchangeable for a run: grid, NFFT parameters, precision, model + mode and prior. Mirrors ``LombScargleMemory.__init__``'s defaults. + + ``kwargs`` is the dict the memory constructor will actually be + handed, so EVERY setting is read from it (with the constructor's + own default) and the ``use_double``/``nharmonics`` arguments are + only the fallback for a dict that does not carry them. Reading + ``nharmonics`` from the process instead would make a per-call + ``nharmonics=`` (a legitimate override: the harmonic count is read + off the memory object) silently reuse a set built for the + process-level value. ``use_double`` is keyed the same way for + consistency, but it is not a per-call option -- the entry points + reject a value that differs from the process precision before any + device work (:func:`~cuvarbase.cunfft._reject_precision_override`), + so the dict always carries the process value here. + + ``precomp_psi=False`` is keyed because it selects a different + gridding kernel: with tables (the default) the data is spread by + ``fast_gaussian_grid`` from the ``q1``/``q2``/``q3`` tables that + ``precompute_psi`` fills; without them + :func:`~cuvarbase.cunfft.nfft_adjoint_async` routes to the + inline-psi ``slow_gaussian_grid`` kernel (before 1.0 that path + raised ``AttributeError``). A set built without tables cannot + serve a request for them, and vice versa. + """ + return dict(nf=int(nf), k0=int(k0), m=int(m), sigma=float(sigma), + use_double=bool(kwargs.get('use_double', use_double)), + nharmonics=int(kwargs.get('nharmonics', nharmonics)), + use_fft=bool(kwargs.get('use_fft', use_fft)), + mode=(2 if kwargs.get('window', False) + else (1 if kwargs.get('floating_mean', True) else 0)), + precomp_psi=bool(kwargs.get('precomp_psi', True)), + pinned=bool(kwargs.get('pinned', True)), + prior=_amplitude_prior_key(kwargs.get('amplitude_prior', + None))) + + +def _ls_memory_matches(mem, settings, max_ndata): + """True if ``mem`` is already allocated for ``settings`` and can hold + a lightcurve of ``max_ndata`` points (see + :func:`_ls_memory_settings`).""" + try: + if mem.lsp_c is None or mem.lsp_g is None or mem.t_g is None: + return False + if not mem.buffered_transfer: + return False + if mem.n0_buffer is None or int(mem.n0_buffer) < int(max_ndata): + return False + if (int(mem.nf) != settings['nf'] or int(mem.k0) != settings['k0'] + or int(mem.m) != settings['m'] + or float(mem.sigma) != settings['sigma']): + return False + if (bool(mem.use_double) != settings['use_double'] + or int(mem.nharmonics) != settings['nharmonics'] + or bool(mem.use_fft) != settings['use_fft'] + or int(mem.mode) != settings['mode'] + or bool(mem.precomp_psi) != settings['precomp_psi'] + or bool(mem.pinned) != settings['pinned']): + return False + if _amplitude_prior_key(mem.amplitude_prior) != settings['prior']: + return False + except AttributeError: + return False + return True + + +def _check_nfft_grids(memory, nf, k0, nharms): + """Hard check that the NFFT memories can serve ``nf`` frequencies + starting at mode ``k0`` with ``nharms`` harmonics: the highest + spectrum entry read must exist, and the grid must be long enough + for that mode to sit in the Gaussian window's alias-free band + (``sigma * (k0 + count) <= n``, see + :func:`~cuvarbase.memory.lombscargle_memory.nfft_grid_sizes`). + """ + H = int(nharms) + top_yw = (H - 1) * k0 + H * (nf - 1) + top_w = (2 * H - 1) * k0 + 2 * H * (nf - 1) + for name, nm, top in (('yw', memory.nfft_mem_yw, top_yw), + ('w', memory.nfft_mem_w, top_w)): + if nm.nf is None or nm.n is None or nm.ghat_g is None: + raise RuntimeError( + "LombScargleMemory: NFFT grid '%s' is not allocated " + "(call allocate first)" % name) + if top >= nm.nf: + raise ValueError( + "NFFT grid '%s' holds %d modes but mode index %d is " + "needed for nf=%d, k0=%d, nharmonics=%d: the memory was " + "allocated for a different frequency grid" % + (name, nm.nf, top, nf, k0, H)) + if nm.sigma * (k0 + nm.nf) > nm.n + 1e-9: + raise ValueError( + "NFFT grid '%s' (n=%d) is too short for modes up to " + "k0 + nf = %d at sigma=%r: need n >= sigma * (k0 + nf) " + "= %d, otherwise the top of the band is aliased" % + (name, nm.n, k0 + nm.nf, nm.sigma, + int(np.ceil(nm.sigma * (k0 + nm.nf))))) + + def lomb_scargle_direct_sums(t, yw, w, freqs, YY, nharms=1, **kwargs): """ Compute Lomb-Scargle periodogram using direct summations. This @@ -572,12 +706,13 @@ def sfunc(f): return mhdirect_sums(t, yw, w, f, YY, nharms=nharms) sums = [add_regularization(s, **kwargs) for s in list(map(sfunc, freqs))] - ybar = sum(yw) + ybar = np.sum(yw) return np.array([mhgls_from_sums(s, YY, ybar) for s in sums]) def lomb_scargle_async(memory, functions, freqs, block_size=256, use_fft=True, + use_cufinufft=False, python_dir_sums=False, transfer_to_device=True, transfer_to_host=True, @@ -605,6 +740,10 @@ def lomb_scargle_async(memory, functions, freqs, If False, uses direct sums. python_dir_sums: bool, optional (default: False) If True, performs direct sums with Python on the CPU + (``lomb_scargle_direct_sums``, computed in float64, all + harmonics; slow). Like every other path the result is written + into ``memory.lsp_c`` (the memory's precision and length, + ``memory.nf``) and that pinned buffer is returned. transfer_to_device: bool, optional, (default: True) If the data is already on the gpu, set as False transfer_to_host: bool, optional, (default: True) @@ -617,12 +756,54 @@ def lomb_scargle_async(memory, functions, freqs, ------- lsp_c: ``np.array`` The resulting periodgram (``memory.lsp_c``) + + Notes + ----- + The light curve itself is validated by the entry point that filled + ``memory`` (:meth:`LombScargleAsyncProcess.run` and friends call + :func:`cuvarbase.utils.check_lightcurve`); only the frequency grid + can be checked here. + + ``memory.nharmonics > 1`` is honoured on every path. The NFFT path + reads the two spectra back and solves the small per-frequency + system on the host (:func:`_mh_power_from_spectra`); the direct-sum + kernel only forms the H = 1 moments, so ``use_fft=False`` with + ``nharmonics > 1`` (like ``python_dir_sums=True``) runs + :func:`lomb_scargle_direct_sums` on the host in float64 -- correct + but O(N nf H) on the CPU. Before 1.0 both silently returned the H = 1 + periodogram (defect 13, ``ls-nharmonics-nofft``). Multiharmonic + power is always the floating-mean GLS: ``floating_mean=False`` and + ``window=True`` raise for ``nharmonics > 1``. """ + if use_cufinufft and not HAS_CUFINUFFT: + raise ImportError( + "use_cufinufft=True but cufinufft is not installed. " + "Install with: pip install cufinufft>=2.2") + (lomb, lomb_dirsum), nfft_funcs = functions - df = freqs[1] - freqs[0] + check_freqs(freqs, name='lomb_scargle_async') + freqs, df = _grid_spacing(freqs) + nf = len(freqs) samples_per_peak = 1./((memory.tmax - memory.tmin) * df) - assert(get_k0(freqs) == memory.k0) + if not (get_k0(freqs) == memory.k0): + raise ValueError( + "freqs does not match the grid this memory was set up for " + "(k0 mismatch: %d != %d)" % (get_k0(freqs), memory.k0)) + if nf > memory.nf: + raise ValueError( + "memory was allocated for nf=%d frequencies but %d were given" + % (memory.nf, nf)) + + nharm = int(getattr(memory, 'nharmonics', 1)) + if nharm > 1 and memory.mode != 1: + raise ValueError( + "nharmonics=%d is only implemented for the floating-mean " + "generalized Lomb-Scargle (floating_mean=True, window=False)" + % nharm) + reg_kwargs = None + if getattr(memory, 'amplitude_prior', None) is not None: + reg_kwargs = dict(amplitude_priors=memory.amplitude_prior) stream = memory.stream @@ -633,12 +814,21 @@ def lomb_scargle_async(memory, functions, freqs, if transfer_to_device: memory.transfer_data_to_gpu() - # do direct summations with python on the CPU (for debugging) - if python_dir_sums: - t = memory.t_g.get() - yw = memory.yw_g.get() - w = memory.w_g.get() - return lomb_scargle_direct_sums(t, yw, w, freqs, memory.yy) + # Host direct sums (float64, any number of harmonics): requested + # explicitly (python_dir_sums), or use_fft=False with nharmonics > 1 + # (the direct-sum kernel is H = 1 only). + if python_dir_sums or (not use_fft and nharm > 1): + if stream is not None: + stream.synchronize() + n0 = int(memory.n0) + t = memory.t_g.get()[:n0].astype(np.float64) + yw = memory.yw_g.get()[:n0].astype(np.float64) + w = memory.w_g.get()[:n0].astype(np.float64) + power = lomb_scargle_direct_sums(t, yw, w, freqs, memory.yy, + nharms=nharm, + **(reg_kwargs or {})) + memory.lsp_c[:nf] = power.astype(memory.real_type) + return memory.lsp_c # Use direct sums (on GPU) if not use_fft: @@ -650,11 +840,11 @@ def lomb_scargle_async(memory, functions, freqs, memory.real_type(memory.yy), memory.real_type(memory.ybar), memory.real_type(df), - memory.real_type(min(freqs)), + memory.real_type(np.min(freqs)), memory.mode) lomb_dirsum.prepared_async_call(*args) - if transfer_to_device: + if transfer_to_host: memory.transfer_lsp_to_cpu() return memory.lsp_c else: @@ -664,15 +854,47 @@ def lomb_scargle_async(memory, functions, freqs, nfft_kwargs.update(kwargs) - nfft_kwargs['minimum_frequency'] = freqs[0] + # k0 * df rather than freqs[0]: with samples_per_peak = + # 1 / (T df) the first mode the NFFT derives is then k0 exactly + # in float64, whatever dtype the user's grid came in (a float32 + # freqs[0] is only good to ~k0 * 6e-8 modes; the kernels used to + # round that product themselves in float32, see + # cunfft._first_mode) + nfft_kwargs['minimum_frequency'] = float(memory.k0) * df nfft_kwargs['samples_per_peak'] = samples_per_peak - # if not memory.window: - # NFFT(w * (y - ybar)) - nfft_adjoint_async(memory.nfft_mem_yw, nfft_funcs, **nfft_kwargs) + _check_nfft_grids(memory, int(memory.nf), int(memory.k0), + getattr(memory, 'nharmonics', 1)) - # NFFT(w) - nfft_adjoint_async(memory.nfft_mem_w, nfft_funcs, **nfft_kwargs) + if use_cufinufft: + # cuFINUFFT path: replace custom NFFT with cufinufft type-1 + cufinufft_nfft_adjoint(memory.nfft_mem_yw, **nfft_kwargs) + cufinufft_nfft_adjoint(memory.nfft_mem_w, **nfft_kwargs) + else: + # Custom NFFT path (Gaussian spreading + FFT) + # NFFT(w * (y - ybar)) + nfft_adjoint_async(memory.nfft_mem_yw, nfft_funcs, + **nfft_kwargs) + + # NFFT(w) + nfft_adjoint_async(memory.nfft_mem_w, nfft_funcs, + **nfft_kwargs) + + if nharm > 1: + # Multiharmonic GLS: the GPU NFFT already produced the w-spectrum + # (to 2H harmonics) and the w*(y-ybar)-spectrum (to H); read them + # back and do the small per-frequency 2H x 2H solve on the host + # (see _mh_power_from_spectra). Sync the stream first so the async + # NFFT has completed before the device->host copy. + if stream is not None: + stream.synchronize() + sw = memory.nfft_mem_w.ghat_g.get() + syw = memory.nfft_mem_yw.ghat_g.get() + power = _mh_power_from_spectra(sw, syw, int(memory.k0), nharm, + int(memory.nf), memory.yy, + reg_kwargs=reg_kwargs) + memory.lsp_c[:memory.nf] = power.astype(memory.real_type) + return memory.lsp_c args = (grid, block, stream) args += (memory.nfft_mem_w.ghat_g.ptr, memory.nfft_mem_yw.ghat_g.ptr) @@ -695,24 +917,33 @@ class LombScargleAsyncProcess(GPUAsyncProcess): Parameters ---------- + use_cufinufft: bool, optional (default: False) + Use the cuFINUFFT library for the NFFT instead of the custom + Gaussian-spreading kernel. Requires ``pip install + cufinufft>=2.2`` (raises ImportError otherwise). Provided as a + numerical cross-check backend: in cuvarbase's benchmarks the + custom kernel was faster end-to-end (see + ``cuvarbase.cufinufft_backend``). **kwargs: passed to ``NFFTAsyncProcess`` Example ------- >>> proc = LombScargleAsyncProcess() >>> Ndata = 1000 - >>> t = np.sort(365 * np.random.rand(N)) + >>> t = np.sort(365 * np.random.rand(Ndata)) >>> y = 12 + 0.01 * np.cos(2 * np.pi * t / 5.0) >>> y += 0.01 * np.random.randn(len(t)) >>> dy = 0.01 * np.ones_like(y) - >>> freqs, powers = proc.run([(t, y, dy)]) + >>> results = proc.run([(t, y, dy)]) >>> proc.finish() - >>> ls_freqs, ls_powers = freqs[0], powers[0] + >>> ls_freqs, ls_powers = results[0] """ def __init__(self, *args, **kwargs): super(LombScargleAsyncProcess, self).__init__(*args, **kwargs) + self.use_cufinufft = kwargs.pop('use_cufinufft', False) + self.nfft_proc = NFFTAsyncProcess(*args, **kwargs) self._cpp_defs = self.nfft_proc._cpp_defs @@ -723,11 +954,22 @@ def __init__(self, *args, **kwargs): self.module_options = self.nfft_proc.module_options self.use_double = self.nfft_proc.use_double self.memory = None + # Memory set (pinned host buffers, device arrays and cuFFT + # plans) reused by batched_run_const_nfreq across calls of + # the same shape; see that method's Notes. Set to None to + # release it. + self._batch_memory = None self.nharmonics = kwargs.get('nharmonics', 1) - if self.nharmonics > 1: - raise Exception("Only 1 harmonic is supported right now") + if self.nharmonics < 1: + raise ValueError("nharmonics must be >= 1, got %r" + % (self.nharmonics,)) + + if self.use_cufinufft and not HAS_CUFINUFFT: + raise ImportError( + "cufinufft not found. Install with: pip install cufinufft>=2.2" + ) def _compile_and_prepare_functions(self, **kwargs): @@ -751,15 +993,26 @@ def _compile_and_prepare_functions(self, **kwargs): def memory_requirement(self, n0, nf, k0, nbatch=1, autoadjust_sigma=False, **kwargs): - """ return an approximate GPU memory requirement in bytes """ + """Approximate GPU memory requirement in bytes for ``nbatch`` + lightcurves of ``n0`` points on a grid of ``nf`` frequencies + starting at mode ``k0``. + + The NFFT grids are sized exactly as ``LombScargleMemory`` + allocates them (from the top mode, padded to a 7-smooth + length; see + :func:`~cuvarbase.memory.lombscargle_memory.nfft_grid_sizes`). + ``autoadjust_sigma`` is accepted for backward compatibility + and ignored: it used to emulate that sizing when the + allocation itself did not do it. + """ H = self.nharmonics sigma = self.nfft_proc.sigma m = self.nfft_proc.get_m(nf) - if autoadjust_sigma: - sigma = int(np.round(float(sigma * (nf + k0)) / nf)) + nf_yw, n_yw, nf_w, n_w = nfft_grid_sizes(nf, k0, nharmonics=H, + sigma=sigma) - fft_size = H * (nf + k0) + mem = 0 # data mem += 3 * n0 @@ -767,28 +1020,38 @@ def memory_requirement(self, n0, nf, k0, nbatch=1, # final result mem += nf + # regularization + mem += 2 * H + 1 + rsize = self.real_type(1).nbytes csize = self.complex_type(1).nbytes c = int(np.ceil(float(csize) / rsize)) if kwargs.get('use_fft', True): - # yw grid / fft (doubled because complex) - mem = c * sigma * (fft_size - k0) - - # w grid / fft (doubled because complex) - mem += c * sigma * (2 * fft_size - k0) - - # precomputation (q1 = n0, q2 = n0, q3 = 2m + 1) - mem += 2 * n0 + 2 * m + 1 + for nx in (n_yw, n_w): + # grid (complex) + mem += c * nx + # work area for cufft.Plan (x2: a safety margin -- the + # padded lengths are 7-smooth, so Bluestein's much + # larger work area is no longer triggered, but the + # estimate is per-plan and cheap) + mem += 1 / rsize * 2 * cufft.cufft.cufftEstimate1d( + nx, cufft.cufft.CUFFT_C2C) + + # precomputation (q1 = n0, q2 = n0, q3 = 2m + 1), one set + # per NFFT grid + mem += 2 * (2 * n0 + 2 * m + 1) # inverse of design matrix if H > 1: # sparse matrix A (block-diagonal) - mem += (2 * H) ** 2 * nbatch + mem += (2 * H) ** 2 # vector b (Ax = b) - mem += nbatch + mem += 1 + + mem *= nbatch # size of float mem *= rsize @@ -820,9 +1083,15 @@ def allocate_for_single_lc(self, t, y, dy, nf, k0=0, Returns ------- - mem: LombScargleMemory + mem: ~cuvarbase.memory.lombscargle_memory.LombScargleMemory Memory object. """ + # a per-call use_double is not an option (the kernels are + # compiled in the process precision): reject a disagreeing + # value before any device work, drop an equal one + kwargs = _reject_precision_override( + self, kwargs, 'LombScargleAsyncProcess.allocate_for_single_lc') + m = self.nfft_proc.get_m(nf) sigma = self.nfft_proc.sigma @@ -841,20 +1110,67 @@ def allocate_for_single_lc(self, t, y, dy, nf, k0=0, def preallocate(self, max_nobs, nlcs=1, nf=None, k0=None, freqs=None, streams=None, **kwargs): + """Allocate ``nlcs`` reusable :class:`LombScargleMemory` objects + (stored in ``self.memory`` and used by :meth:`run` when no + ``memory`` is passed) for lightcurves of up to ``max_nobs`` + points on the grid ``df * (k0 + arange(nf))``. + Parameters + ---------- + max_nobs : int + Largest number of observations any later ``run`` will pass. + nlcs : int, optional (default: 1) + Number of memory objects (lightcurves per ``run`` call). + nf, k0 : int, optional + Grid size and first mode; alternatively give ``freqs``. + freqs : array_like, optional + The uniform grid (validated with :func:`check_k0`). + streams : list of ``pycuda.driver.Stream``, optional + One stream per memory object. Defaults to ``self.streams`` + (created as needed) -- the streams :meth:`finish` + synchronizes. Before 1.0 the default was ``None`` (the null + stream), so ``finish()`` did not wait for the result copy + and ``run()`` after ``preallocate()`` returned stale + powers. Streams given here that are not already in + ``self.streams`` are appended to it so ``finish()`` covers + them. + **kwargs + Passed to :class:`LombScargleMemory` (``use_double`` is not + accepted per call: the process precision is used, and a + different value raises ``ValueError``). + """ + kwargs = _reject_precision_override( + self, kwargs, 'LombScargleAsyncProcess.preallocate') if freqs is not None: + check_k0(freqs) k0 = get_k0(freqs) nf = len(freqs) - if nf is not None: - assert k0 is not None + if nf is None: + raise ValueError("preallocate needs nf (with k0) or freqs") + if k0 is None: + raise ValueError("k0 must be given when nf is specified " + "without freqs") m = self.nfft_proc.get_m(nf) sigma = self.nfft_proc.sigma + if streams is None: + if len(self.streams) < nlcs: + self._create_streams(nlcs - len(self.streams)) + streams = self.streams[:nlcs] + else: + streams = list(streams) + if len(streams) < nlcs: + raise ValueError("preallocate: %d streams given for nlcs=%d" + % (len(streams), nlcs)) + for s in streams: + if not any(s is s0 for s0 in self.streams): + self.streams.append(s) + self.memory = [] for i in range(nlcs): - stream = None if streams is None else streams[i] + stream = streams[i] mem = LombScargleMemory(sigma, stream, m, k0=k0, buffered_transfer=True, @@ -895,6 +1211,8 @@ def allocate(self, data, nfreqs=None, k0s=None, **kwargs): list of allocated memory objects for each lightcurve """ + kwargs = _reject_precision_override( + self, kwargs, 'LombScargleAsyncProcess.allocate') if len(data) > len(self.streams): self._create_streams(len(data) - len(self.streams)) @@ -932,34 +1250,137 @@ def run(self, data, ---------- data: list of tuples list of [(t, y, dy), ...] containing + * ``t``: observation times * ``y``: observations - * ``dy``: observation uncertainties + * ``dy``: observation uncertainties, or ``None`` for unit + weights (an unweighted periodogram) + freqs: optional, list of ``np.ndarray`` frequencies - List of custom frequencies. Right now, this has to be linearly - spaced with ``freqs[0] / (freqs[1] - freqs[0])`` being an integer. + List of custom frequency grids (one per lightcurve; a single + array is used for all). Each grid **must** be uniform, + ``freqs = df * (k0 + np.arange(nf))`` with integer ``k0 >= 1`` + and ``nf >= 2`` -- the kernels evaluate exactly that grid and + the array only labels the output. Grids are validated with + :func:`check_k0` and a ``ValueError`` names the first + offending point (concatenated or thinned grids, ``geomspace``, + ``linspace`` whose start is not a multiple of its step). + Use one uniform grid per band instead. Default: ``autofrequency``. memory: optional, list of ``LombScargleMemory`` objects List of memory objects, length of list must be ``>= len(data)`` use_fft: optional, bool (default: True) - Uses the NFFT, otherwise just does direct summations (which - are quite slow...) + Uses the NFFT, otherwise direct summations (O(N nf); slow). + ``nharmonics > 1`` is supported on both paths -- with + ``use_fft=False`` the multiharmonic sums run on the host. floating_mean: optional, bool (default: True) Add a floating mean to the model (see Zechmeister & Kurster 2009) window: optional, bool (default: False) If true, computes the window function for the data instead of Lomb-Scargle - amplitude_prior: optional, float (default: None) - If not None, sets the variance of a Gaussian prior on - the amplitude (sometimes useful for suppressing aliases) + amplitude_prior: optional, float or array_like (default: None) + If not None, the *standard deviation* of a zero-centred + Gaussian prior on the amplitude of every harmonic (or one + per harmonic); a ridge term ``1 / amplitude_prior**2`` is + added to the amplitude normal equations (see + :func:`add_regularization`; sometimes useful for suppressing + aliases). Honoured on every path, including + ``nharmonics > 1`` (silently ignored there before 1.0). **kwargs Returns ------- results: list of lists - list of (freqs, pows) for each LS periodogram + list of (freqs, pows) for each LS periodogram; the power + arrays are page-locked host buffers filled asynchronously — + call :meth:`finish` before reading them (the batched entry + points synchronize for you) + + Notes + ----- + * ``floating_mean=True`` (default) is the generalized + Lomb-Scargle of Zechmeister & Kurster (2009), astropy's + ``fit_mean=True``. ``floating_mean=False`` is the classic + periodogram of the data centred on the **unweighted** mean + (``normalize_light_curves`` subtracts ``nanmean(y)``), which + differs from astropy's ``fit_mean=False, center_data=True`` + for heteroscedastic errors. ``window=True`` returns the + spectral window as the periodogram of ``y = 1`` with the + ``STANDARD`` normalization, which is **4x** astropy's + ``LombScargle(t, ones, fit_mean=False, center_data=False)``. + Neither is defined for ``nharmonics > 1`` (``ValueError``). + * A power of exactly ``-1`` is the kernels' sentinel for a + non-finite or negative value at that frequency + (``kernels/lomb.cu``). Since 1.0 every entry point validates + the light curve first + (:func:`cuvarbase.utils.check_lightcurve`), so the inputs + that used to produce ``-1`` everywhere -- non-finite + ``y``/``dy``, ``dy = 0``, mismatched lengths -- raise + ``ValueError`` instead. Two degenerate cases the validator + deliberately still accepts DO return ``-1`` at every + frequency: a constant (zero-variance) ``y``, and + all-identical ``t``. Apart from those, a ``-1`` in a + returned periodogram is a bug report, not a valid power. + * Precision: the default float32 pipeline agrees with the exact + (float64) GLS to ~1e-4 in power for ``f * T`` up to ~1e4 and + ~1e-3 at survey scale (``f * T ~ 1e5-1e6``). Because the Baluev + false-alarm probability is exponentially sensitive to the peak + power (``d ln FAP / dP ~ -N / 2``), use ``use_double=True`` for + FAP-grade work on large ``f * T`` grids; it reaches ~1e-7. + * ``use_double`` is a property of the process object + (``LombScargleAsyncProcess(use_double=True)``): the kernels + are compiled once, at construction, in that precision. It is + **not** a per-call keyword -- a ``use_double=`` in ``**kwargs`` + that differs from the process precision raises ``ValueError`` + before any device work (an equal value is accepted and + ignored), and a ``memory`` allocated at the other precision + raises too. Before 1.0 the keyword silently reached the + memory constructor, so ``run(..., use_double=True)`` on a + default process paired float64 buffers with float32 kernels + and returned a wrong periodogram with a float64 dtype. + ``nharmonics=`` **is** a legitimate per-call override. """ + # Private: set by batched_run_const_nfreq, which has already + # run check_freqs/check_k0 on the single shared grid it shares + # across every light curve. Popped here so it never reaches the + # memory constructors below. It suppresses only the O(nf) + # uniformity/first-mode check: check_freqs itself always runs, + # so the Phase 1 validation (defect 23) cannot be switched off + # from a public entry point, however this keyword is reached. + grid_prechecked = kwargs.pop('_grid_prechecked', False) + + # Precision is fixed at construction: a per-call use_double that + # disagrees with it, or a memory allocated at the other + # precision, is rejected here, before any device work (an equal + # use_double is dropped from kwargs). + kwargs = _reject_precision_override(self, kwargs, + 'LombScargleAsyncProcess.run') + if memory is not None: + _check_memory_precision(self, memory, + 'LombScargleAsyncProcess.run') + + # Validate before any device work (kernel compile included): + # dy = 0 or a non-finite y used to come back as an + # undocumented power of -1 at every frequency, and + # normalize_light_curves' nanmean silently absorbs NaNs (Sep + # 2026 audit, defect 23). + for i, lc in enumerate(data): + if len(lc) != 3: + raise ValueError( + "LombScargleAsyncProcess.run: lightcurve %d must be " + "a (t, y, dy) tuple; got %d elements" % (i, len(lc))) + check_lightcurve(lc[0], lc[1], lc[2], min_n=_LS_MIN_NDATA, + name='LombScargleAsyncProcess.run ' + 'lightcurve %d' % i) + + # check_freqs is O(nf) but cheap and is the Phase 1 guard + # against non-finite / non-positive grids: run it ALWAYS, so + # no keyword can turn defect 23's validation off. + if freqs is not None: + for frq in (freqs if isinstance(freqs, list) else [freqs]): + check_freqs(frq, name='LombScargleAsyncProcess.run') + # compile module if not compiled already if not hasattr(self, 'prepared_functions') or \ not all([func in self.prepared_functions for func in @@ -974,17 +1395,33 @@ def run(self, data, if frqs is None: frqs = [self.autofrequency(d[0], **kwargs) for d in data] - elif isinstance(frqs[0], float): + elif not isinstance(frqs, list): frqs = [frqs] * len(data) - assert(len(frqs) == len(data)) - - dfs = [frq[1] - frq[0] for frq in frqs] + if len(frqs) != len(data): + raise ValueError( + "number of frequency grids (%d) does not match number of " + "lightcurves (%d)" % (len(frqs), len(data))) + + # the kernels evaluate df * (k0 + arange(nf)) and the user's + # array only labels the output: validate every grid (uniform + # spacing, integer first mode, >= 2 points) before any GPU work + if not grid_prechecked: + # validate each *distinct* grid object once: batched callers + # pass the same array for every lightcurve and check_k0 is + # O(nf) (a median over the spacings) + checked = [] + for frq in frqs: + if any(frq is done for done in checked): + continue + if freqs is None: + # the autofrequency default did not go through the + # check at the top of this method + check_freqs(frq, name='LombScargleAsyncProcess.run') + check_k0(frq) + checked.append(frq) k0s = [get_k0(frq) for frq in frqs] - # make sure k0 * df is the minimum frequency - [check_k0(frq, k0=k0) for frq, k0 in zip(frqs, k0s)] - if memory is None: memory = self.memory @@ -998,7 +1435,8 @@ def run(self, data, memory[i].setdata(t=t, y=y, dy=dy, **kwargs) ls_kwargs = dict(block_size=self.block_size, - use_fft=use_fft) + use_fft=use_fft, + use_cufinufft=self.use_cufinufft) ls_kwargs.update(kwargs) funcs = (self.function_tuple, self.nfft_proc.function_tuple) @@ -1009,7 +1447,7 @@ def run(self, data, results = [(f, r) for f, r in zip(frqs, results)] return results - def batched_run_const_nfreq(self, data, batch_size=10, + def batched_run_const_nfreq(self, data, batch_size=1, use_fft=True, freqs=None, only_return_best_freqs=False, ignore_freq_mask=None, @@ -1018,21 +1456,127 @@ def batched_run_const_nfreq(self, data, batch_size=10, Same as ``batched_run`` but is more efficient when the frequencies are the same for each lightcurve. Doesn't reallocate memory for each batch. + Parameters + ---------- + data: list of ``(t, y, dy)`` tuples + Lightcurves (``dy=None`` gives unit weights). + freqs: array_like, optional + The one uniform grid ``df * (k0 + np.arange(nf))`` shared by + all lightcurves (validated with :func:`check_k0`; a + non-uniform grid raises ``ValueError``). Default: the + ``autofrequency`` grid of the lightcurve with the longest + baseline (all of its points -- before 1.0 the last one was + dropped). + only_return_best_freqs: bool, optional (default: False) + Return ``(best_freqs, best_freq_faps)`` instead of the + periodograms: for each lightcurve the frequency of the highest + power (within ``ignore_freq_mask``) and the Baluev (2008) + false-alarm probability of that peak, :func:`fap_baluev` + with ``d_K = 2 * nharmonics + 1`` (the ``nharmonics`` in + effect for this call: a per-call ``nharmonics=`` keyword + overrides the process attribute, as it does for the + periodogram itself) and ``fmax = max(freqs)``. + **Changed in 1.0:** the second element is the FAP itself + (small is significant; it can underflow to exactly 0 for + overwhelming peaks). Before 1.0 it was ``1 - FAP``, which + rounds to exactly 1.0 for every FAP below 1e-16 and used the + single-harmonic degrees of freedom for multiharmonic runs. + ignore_freq_mask: array_like of bool, optional + Frequencies to exclude from the peak search (same length as + ``freqs``). + batch_size: int, optional (default: 1) + Lightcurves processed per multi-stream batch. The default + of 1 is the safe choice — all published survey-throughput + numbers (e.g. 4.4 ms/LC for ZTF-scale grids) were measured + at ``batch_size=1``. The "multi-stream overhead" that made + larger values slower is per-call setup, diagnosed Jul 2026 + (A5000): this method needs ``batch_size`` separate + ``LombScargleMemory`` sets — pinned host buffers, device + arrays, and a cuFFT plan each — a cost that scales with + ``batch_size``, while the GPU compute stages barely benefit + because a single survey-scale Lomb-Scargle already + saturates the device. Since 1.0 that setup is paid once and + reused (see Notes), so the remaining cost of a larger + ``batch_size`` is device memory. When one call processes + many lightcurves (hundreds+) the setup amortizes: + ``batch_size=4`` measured ~10% faster per LC than 1 at 256 + LCs/call, while 8 was net slower. Only increase this if you + benchmark it on your own workload; see + `archived numerical diagnosis `_. + Notes ----- To get best efficiency, make sure the maximum number of observations - is not much larger than the typical number of observations + is not much larger than the typical number of observations. + + **Memory reuse (new in 1.0).** Building a memory set (pinned + host buffers, device arrays and two cuFFT plans) costs tens of + milliseconds at survey ``nf``, which used to be paid on *every* + call. This method now reuses an already-allocated set when one + fits the problem — grid (``nf``, ``k0``), NFFT parameters, + precision, harmonics, model mode and ``amplitude_prior`` all + equal and its host buffers long enough for the longest + lightcurve in the call. It prefers the set + :meth:`preallocate` built (``self.memory``), and otherwise + keeps the one it built last (``self._batch_memory``), so a loop + of one-lightcurve calls allocates once. The reused device + memory is held until the process object is dropped; set + ``proc._batch_memory = None`` to release it early. Results are + unchanged: the reused buffers are zeroed and overwritten before + every run, exactly as on the ``preallocate`` path. A per-call + ``nharmonics=`` (which overrides the process attribute for one + call) is part of the key, so such a call allocates and caches + its own set rather than matching one built for the process + default. ``use_double`` is not a per-call option: the kernels + are compiled at construction in the process precision, so a + ``use_double=`` that differs from it raises ``ValueError`` + before any device work (see :meth:`run`). Passing a + ``LombScargleMemory`` buffer directly, or fixing its size + (``t_g=``, ``lsp_c=``, ``nfft_mem_yw=``, ``n0_buffer=``, + ``nf=``, ``k0=``, ...), opts the call out of both reuse and + caching. """ + # Validate before any device work (see run()). A per-call + # use_double that disagrees with the process precision is + # rejected first (an equal one is dropped). + kwargs = _reject_precision_override(self, kwargs, + 'batched_run_const_nfreq') + for i, lc in enumerate(data): + if len(lc) != 3: + raise ValueError( + "batched_run_const_nfreq: lightcurve %d must be a " + "(t, y, dy) tuple; got %d elements" % (i, len(lc))) + check_lightcurve(lc[0], lc[1], lc[2], min_n=_LS_MIN_NDATA, + name='batched_run_const_nfreq ' + 'lightcurve %d' % i) + + if freqs is None: + data_with_max_baseline = max(data, + key=lambda d: np.max(d[0]) - np.min(d[0])) + # autofrequency already returns df * (k0 + arange(nf)); the + # old "correction" nf = round(max / df) - k0 dropped its last + # point (id 147) + freqs = self.autofrequency(data_with_max_baseline[0], **kwargs) + + freqs = np.asarray(freqs) + # one grid shared by every lightcurve: validate it once here and + # tell run() not to repeat the O(nf) checks per lightcurve. This + # runs BEFORE the kernel compile and the stream creation below, + # so a rejected grid, like a rejected light curve, leaves the + # device untouched (the "before any device work" promise of the + # Sep-2026 validation; until Sep 2026 the compile came first). + check_freqs(freqs, name='batched_run_const_nfreq') + check_k0(freqs) + k0 = get_k0(freqs) + nf = len(freqs) + # compile and prepare module functions if not already done if not hasattr(self, 'prepared_functions') or \ not all([func in self.prepared_functions for func in ['lomb', 'lomb_dirsum']]): self._compile_and_prepare_functions(**kwargs) - # Prepare data - data = normalize_light_curves(data) - # create streams if needed bsize = min([len(data), batch_size]) if len(self.streams) < bsize: @@ -1041,23 +1585,6 @@ def batched_run_const_nfreq(self, data, batch_size=10, streams = [self.streams[i] for i in range(bsize)] max_ndata = max([len(t) for t, y, dy in data]) - if freqs is None: - data_with_max_baseline = max(data, - key=lambda d: max(d[0]) - min(d[0])) - freqs = self.autofrequency(data_with_max_baseline[0], **kwargs) - - # now correct frequencies - df = freqs[1] - freqs[0] - k0 = get_k0(freqs) - # nf = len(freqs) - nf = int(round(max(freqs) / df)) - k0 - freqs = df * (k0 + np.arange(nf)) - - df = freqs[1] - freqs[0] - k0 = get_k0(freqs) - nf = len(freqs) - check_k0(freqs, k0=k0) - lsps = [] # make data batches @@ -1077,41 +1604,111 @@ def batched_run_const_nfreq(self, data, batch_size=10, nharmonics=self.nharmonics, use_fft=use_fft) kwargs_lsmem.update(kwargs) - memory = [LombScargleMemory(sigma, stream, m, k0=k0, - **kwargs_lsmem) - for stream in streams] - # allocate memory - [mem.allocate(nf=nf, **kwargs) for mem in memory] + # The harmonic count the powers are computed with is the one the + # memory constructor is handed (kwargs_lsmem: a per-call + # nharmonics= written over the process attribute). The Baluev + # d_K below must use the same value -- until Sep 2026 it read + # self.nharmonics, so batched_run_const_nfreq(nharmonics=2, + # only_return_best_freqs=True) on a default process returned a + # FAP with d_K=3 for a 2-harmonic peak (Phase 2 verification + # carry-over, readiness audit). + nharmonics_eff = int(kwargs_lsmem['nharmonics']) + + # Reuse an already-allocated memory set when one fits this + # problem: the one preallocate() built, else the one the last + # call to this method built (pinned host buffers, device arrays + # and two cuFFT plans -- tens of ms per call at survey nf). + # kwargs that hand LombScargleMemory its own buffers opt out. + # The key is built from kwargs_lsmem -- the dict the constructor + # below is actually handed -- so a per-call nharmonics= (which + # kwargs_lsmem.update(kwargs) has already written over the + # process-level value) keys and builds its own set instead of + # matching one built for the process default. (use_double + # cannot differ from the process value here: it was rejected or + # dropped at the top of this method.) + memory = None + cacheable = not (_LS_MEMORY_OVERRIDE_KWARGS & set(kwargs)) + if cacheable: + settings = _ls_memory_settings(nf, k0, m, sigma, + self.use_double, + self.nharmonics, use_fft, + kwargs_lsmem) + + def _usable(mems): + return (mems is not None and len(mems) >= bsize + and all(_ls_memory_matches(mem, settings, max_ndata) + and any(mem.stream is st + for st in self.streams) + for mem in mems[:bsize])) + + for candidate in (self.memory, self._batch_memory): + if _usable(candidate): + memory = candidate[:bsize] + break - funcs = (self.function_tuple, self.nfft_proc.function_tuple) - best_freqs, best_freq_significances = [], [] - - default_mask = np.array([True] * len(freqs)) - mask = default_mask if ignore_freq_mask is None else ~np.asarray(ignore_freq_mask) + if memory is None: + memory = [LombScargleMemory(sigma, stream, m, k0=k0, + **kwargs_lsmem) + for stream in streams] + + # allocate memory + [mem.allocate(nf=nf, **kwargs) for mem in memory] + + if cacheable: + self._batch_memory = memory + + best_freqs, best_freq_faps = [], [] + + # ``None`` means "every frequency": an all-True mask would only + # buy three nf-sized copies per lightcurve (16 ms at nf = 365k + # for np.array([True] * nf) alone) + mask = None if ignore_freq_mask is None \ + else ~np.asarray(ignore_freq_mask) for b, batch in enumerate(batches): - + results = self.run(batch, memory=memory, freqs=freqs, - use_fft=use_fft, + use_fft=use_fft, _grid_prechecked=True, **kwargs) self.finish() - + for i, (f, p) in enumerate(results): if only_return_best_freqs: - best_index = np.argmax(p[mask]) - fap = fap_baluev(batch[i][0], batch[i][2], p[mask], np.max(freqs[mask])) - significance = 1. - fap[best_index] - best_freqs.append(freqs[mask][best_index]) - best_freq_significances.append(significance) + pm = np.asarray(p[:nf], dtype=np.float64) + fm = freqs + if mask is not None: + pm = pm[mask] + fm = freqs[mask] + best_index = int(np.argmax(pm)) + # FAP of the best peak only (identical value, and + # the log-space fap_baluev is the CPU-bound part of + # this option); d_K = 2H + 1 for the effective H + fap = fap_baluev(batch[i][0], batch[i][2], + pm[best_index], np.max(fm), + d_K=_baluev_d_K(nharmonics_eff)) + best_freqs.append(fm[best_index]) + best_freq_faps.append(float(fap)) else: lsps.append(np.copy(p)) if only_return_best_freqs: - return best_freqs, best_freq_significances + return best_freqs, best_freq_faps else: return [(freqs, lsp) for lsp in lsps] +def _baluev_d_K(nharmonics): + """Baluev (2008) ``d_K`` for an ``nharmonics``-harmonic floating-mean + model: ``2 * nharmonics + 1`` (a sine/cosine pair per harmonic plus + the mean). ``nharmonics`` must be the harmonic count the periodogram + was actually computed with -- see + :meth:`LombScargleAsyncProcess.batched_run_const_nfreq`.""" + H = int(nharmonics) + if H < 1: + raise ValueError("nharmonics must be >= 1, got %r" % (nharmonics,)) + return 2 * H + 1 + + def fap_baluev(t, dy, z, fmax, d_K=3, d_H=1, use_gamma=True): """ False alarm probability for periodogram peak @@ -1121,21 +1718,23 @@ def fap_baluev(t, dy, z, fmax, d_K=3, d_H=1, use_gamma=True): ---------- t: array_like Observation times. - dy: array_like - Observation uncertainties. + dy: array_like or None + Observation uncertainties (``None``: unit weights). z: array_like or float Periodogram value(s) fmax: float Maximum frequency searched d_K: int, optional (default: 3) - Number of degrees of fredom for periodgram model. - 2H - 1 where H is the number of harmonics + Number of degrees of freedom of the periodogram model: + ``2H + 1`` (offset plus a cosine and sine amplitude per + harmonic) for ``H`` harmonics, so 3 for the standard + floating-mean Lomb-Scargle d_H: int, optional (default: 1) Number of degrees of freedom for default model. use_gamma: bool, optional (default: True) Use gamma function for computation of numerical - coefficient; replaced with scipy.special.gammaln - and should be stable now + coefficient; computed with scipy.special.gammaln + to avoid overflow at large N Returns ------- fap: float @@ -1164,24 +1763,43 @@ def fap_baluev(t, dy, z, fmax, d_K=3, d_H=1, use_gamma=True): if use_gamma: g = np.exp(gammaln(0.5 * N_H) - gammaln(0.5 * (N_K + 1))) - w = np.power(dy, -2) + w = np.ones(N) if dy is None else np.power(dy, -2) - tbar = np.dot(w, t) / sum(w) - Dt = np.dot(w, np.power(t - tbar, 2)) / sum(w) + # np.sum, not the builtin: sum() over a numpy array iterates it in + # Python (6 ms per call at N = 65,000) + wsum = np.sum(w) + tbar = np.dot(w, t) / wsum + Dt = np.dot(w, np.power(t - tbar, 2)) / wsum Teff = np.sqrt(4 * np.pi * Dt) W = fmax * Teff A = (2 * np.pi ** 1.5) * W + # Evaluate in log space (issue #14): for z near 1 the naive + # FAP = 1 - (1 - (1-z)**(0.5*N_K)) * exp(-tau) + # underflows -- both factors round to 1.0 and the subtraction + # cancels to exactly 0.0 for significant peaks. Rewriting as + # FAP = -expm1(-tau) + exp(log(1 - Psing) - tau) + # keeps the result positive down to the float64 limit (~1e-308). + z = np.asarray(z, dtype=np.float64) + eZ1 = (z / np.pi) ** 0.5 * (d - 1) - eZ2 = (1 - z) ** (0.5 * (N_K - 1)) - tau = (g * A / (2 * np.pi)) * eZ1 * eZ2 + with np.errstate(divide='ignore'): + # log(1 - z); -inf at z == 1 (exp() of it is 0, as intended) + log1mz = np.log1p(-np.minimum(z, 1.0)) + log_eZ1 = np.log(eZ1) + + log_tau = (np.log(g * A / (2 * np.pi)) + + log_eZ1 + + 0.5 * (N_K - 1) * log1mz) + tau = np.exp(log_tau) - Psing = 1 - (1 - z) ** (0.5 * N_K) + # log(1 - Psing) = log((1 - z)**(0.5 * N_K)) + log_Psing_c = 0.5 * N_K * log1mz - return 1 - Psing * np.exp(-tau) + return -np.expm1(-tau) + np.exp(log_Psing_c - tau) def lomb_scargle_simple(t, y, dy, **kwargs): @@ -1190,12 +1808,27 @@ def lomb_scargle_simple(t, y, dy, **kwargs): things work on the GPU. Note: This will be substantially slower than working with the ``LombScargleAsyncProcess`` interface. - """ - w = np.power(dy, -2) - w /= sum(w) - proc = LombScargleAsyncProcess() - results = proc.run([(t, y, w)], **kwargs) + ``use_double=True`` builds the process in double precision; the + remaining keywords are passed to + :meth:`LombScargleAsyncProcess.run` (``freqs=``, ``nharmonics=``, + ``floating_mean=``, ...). Before 1.0 ``use_double`` reached the + memory constructor of a single-precision process instead and the + result was wrong (float64 buffers read by float32 kernels). + """ + # Validated here as well as in run(): this wrapper constructs a + # process (and so a CUDA context) before it forwards the data. + check_lightcurve(t, y, dy, min_n=_LS_MIN_NDATA, + name='lomb_scargle_simple') + use_double = bool(kwargs.pop('use_double', False)) + + # Pass dy straight through: LombScargleMemory.setdata converts + # uncertainties to normalized inverse-variance weights itself. + # (Pre-normalizing here double-applied the conversion, effectively + # weighting by dy^4 and giving the *largest*-error points the most + # weight.) + proc = LombScargleAsyncProcess(use_double=use_double) + results = proc.run([(t, y, dy)], **kwargs) freqs, powers = results[0] diff --git a/cuvarbase/memory/__init__.py b/cuvarbase/memory/__init__.py new file mode 100644 index 00000000..6d7a9ee5 --- /dev/null +++ b/cuvarbase/memory/__init__.py @@ -0,0 +1,39 @@ +""" +Memory management classes for GPU operations. + +This module contains classes for managing memory allocation and transfer +between CPU and GPU for various periodogram computations. + +Attributes are resolved lazily (PEP 562) so that importing one memory +class does not import the others' modules: ``nfft_memory`` and +``lombscargle_memory`` bind libcufft through :mod:`cuvarbase._cufft`, +which BLS/CE users never need to load. +""" + +_LAZY_ATTRS = { + 'NFFTMemory': '.nfft_memory', + 'ConditionalEntropyMemory': '.ce_memory', + 'LombScargleMemory': '.lombscargle_memory', + 'weights': '.lombscargle_memory', + 'BLSBatchMemory': '.bls_memory', +} + +__all__ = [ + 'NFFTMemory', + 'ConditionalEntropyMemory', + 'LombScargleMemory', + 'weights', + 'BLSBatchMemory', +] + + +def __getattr__(name): + if name in _LAZY_ATTRS: + import importlib + module = importlib.import_module(_LAZY_ATTRS[name], __name__) + return getattr(module, name) + raise AttributeError("module %r has no attribute %r" % (__name__, name)) + + +def __dir__(): + return sorted(set(list(globals()) + __all__)) diff --git a/cuvarbase/memory/_host.py b/cuvarbase/memory/_host.py new file mode 100644 index 00000000..f9d73033 --- /dev/null +++ b/cuvarbase/memory/_host.py @@ -0,0 +1,64 @@ +"""Host-array allocation for host<->device transfers. + +cuvarbase's ``*Memory`` classes stage data in host arrays that are copied +to/from the GPU. For ``memcpy_*_async`` to actually run asynchronously +(overlapping transfers with compute), the host buffer must be *page-locked* +(pinned). Plain ``cuda.aligned_zeros`` is only page-aligned, so the driver +silently stages async copies through a synchronous bounce buffer. + +:func:`host_array` allocates a pinned (``cuda.pagelocked_zeros``) buffer by +default and falls back to page-aligned memory if pinning fails -- e.g. when +the OS/driver page-locked-memory limit is exhausted -- so a pinning failure +degrades performance rather than crashing. +""" +import warnings + +import pycuda.driver as cuda + +_warned_fallback = False + + +def _warn_fallback(exc): + global _warned_fallback + if not _warned_fallback: + _warned_fallback = True + warnings.warn( + "could not allocate page-locked (pinned) host memory (%s: %s); " + "falling back to page-aligned host arrays. Async host<->device " + "transfers will stage synchronously, reducing overlap. Pass " + "pinned=False to silence this, or raise the system's locked-" + "memory limit." % (type(exc).__name__, exc), + RuntimeWarning) + + +def host_array(shape, dtype, pinned=True): + """Allocate a zeroed host array for GPU transfers. + + Parameters + ---------- + shape : int or tuple + Array shape. + dtype : numpy dtype + Array dtype. + pinned : bool, optional (default: True) + If True, allocate page-locked (pinned) memory for true async + transfer overlap, falling back to page-aligned memory if pinning + fails. If False, allocate page-aligned memory directly. + + Returns + ------- + numpy.ndarray + A zeroed host array (pinned when possible). + """ + if pinned: + try: + return cuda.pagelocked_zeros(shape, dtype=dtype) + except Exception as exc: + # Pinning failed. Try the page-aligned fallback; if THAT also + # raises (e.g. the GPU is stubbed out in a CPU-only test run), + # let it propagate rather than warn about a fallback that did + # not actually happen. + arr = cuda.aligned_zeros(shape, dtype=dtype) + _warn_fallback(exc) + return arr + return cuda.aligned_zeros(shape, dtype=dtype) diff --git a/cuvarbase/memory/bls_memory.py b/cuvarbase/memory/bls_memory.py new file mode 100644 index 00000000..91832e3c --- /dev/null +++ b/cuvarbase/memory/bls_memory.py @@ -0,0 +1,308 @@ +""" +Memory management for batch BLS GPU operations. + +Handles padded multi-lightcurve data layout with page-aligned CPU +arrays (NOT page-locked/pinned: async transfers fall back to +synchronous staged copies) and GPU arrays for batch processing. +""" +import numpy as np + +import pycuda.driver as cuda # noqa: F401 (kept for transfer methods / API) +import pycuda.gpuarray as gpuarray + +from ..base import ensure_context +from ._host import host_array +from ..utils import (subtract_epoch, conflict_scatter_perm, + check_lightcurve, check_freqs) + + +class BLSBatchMemory: + """ + Memory manager for multi-lightcurve batch BLS. + + Data layout: all LC arrays padded to max_ndata and concatenated. + t_all[lc_idx * max_ndata + i] for i < ndata_per_lc[lc_idx] + yw_all[lc_idx * max_ndata + i] + w_all[lc_idx * max_ndata + i] + + Output layout: + bls_all[lc_idx * nfreqs + freq_idx] + + Parameters + ---------- + max_ndata : int + Maximum observations per lightcurve (arrays padded to this). + n_lcs : int + Number of lightcurves in this batch. + nfreqs : int + Number of trial frequencies. + stream : pycuda.driver.Stream, optional + CUDA stream for async transfers. + """ + + def __init__(self, max_ndata, n_lcs, nfreqs, stream=None, pinned=True): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). + ensure_context() + self.max_ndata = int(max_ndata) + self.n_lcs = int(n_lcs) + self.nfreqs = int(nfreqs) + self.stream = stream + self.rtype = np.float32 + # Pinned (page-locked) host buffers by default for async overlap; + # graceful fallback to page-aligned if pinning fails. + self.pinned = pinned + + # Per-LC normalization factors + self.yy = np.zeros(n_lcs, dtype=np.float64) + + # Per-LC epochs: floor(min(t)) subtracted from each lightcurve's times + # before the float32 cast (phases are relative to it) + self.epochs = np.zeros(n_lcs, dtype=np.float64) + + # Pinned (or page-aligned fallback) host arrays + p = self.pinned + total_data = self.max_ndata * self.n_lcs + total_bls = self.nfreqs * self.n_lcs + + self.t = host_array((total_data,), self.rtype, pinned=p) + self.yw = host_array((total_data,), self.rtype, pinned=p) + self.w = host_array((total_data,), self.rtype, pinned=p) + self.ndata_per_lc = host_array((self.n_lcs,), np.uint32, pinned=p) + + self.freqs = host_array((self.nfreqs,), self.rtype, pinned=p) + self.nbins0 = host_array((self.nfreqs,), np.uint32, pinned=p) + self.nbinsf = host_array((self.nfreqs,), np.uint32, pinned=p) + + self.bls = host_array((total_bls,), self.rtype, pinned=p) + + # GPU arrays (allocated on first transfer) + self.t_g = None + self.yw_g = None + self.w_g = None + self.ndata_per_lc_g = None + self.freqs_g = None + self.nbins0_g = None + self.nbinsf_g = None + self.bls_g = None + + def set_freqs(self, freqs, qmin=1e-2, qmax=0.5): + """ + Set frequency grid and compute bin counts. + + Parameters + ---------- + freqs : array_like + Frequency array (1/days). + qmin : float or array_like + Minimum fractional transit duration. + qmax : float or array_like + Maximum fractional transit duration. + + Returns + ------- + max_nbins : int + Maximum number of fine bins (for shared memory sizing). + """ + check_freqs(freqs, name='BLSBatchMemory.set_freqs') + freqs = np.asarray(freqs, dtype=self.rtype) + nf = len(freqs) + if nf > self.nfreqs: + raise ValueError( + f"Got {nf} freqs but allocated for {self.nfreqs}") + + self.freqs[:nf] = freqs + + # Validate before the uint32 cast below: a NaN, a zero qmin or + # a qmax >= 1 becomes a bin count of 0, which divides by zero + # in the kernel and atomicAdds outside the shared-memory + # histogram -- an illegal memory access that kills the CUDA + # context (Sep 2026 audit, defect 23). Imported lazily to + # avoid a circular import with cuvarbase.bls. + from ..bls import _validate_fast_q_bounds + _validate_fast_q_bounds(nf, qmin, qmax) + + qmin_arr = np.broadcast_to(np.asarray(qmin, dtype=self.rtype), (nf,)) + qmax_arr = np.broadcast_to(np.asarray(qmax, dtype=self.rtype), (nf,)) + + self.nbinsf[:nf] = (1.0 / qmin_arr).astype(np.uint32) + self.nbins0[:nf] = (1.0 / qmax_arr).astype(np.uint32) + + max_nbins = int(self.nbinsf[:nf].max()) + return max_nbins + + def set_lightcurve(self, idx, t, y, dy): + """ + Set data for one lightcurve in the batch. + + Computes weights, weighted-mean-subtracted observations, and + stores the yy normalization factor. + + Parameters + ---------- + idx : int + Index of this lightcurve within the batch (0-based). + t : array_like + Observation times. + y : array_like + Observations. + dy : array_like + Observation uncertainties. + """ + check_lightcurve(t, y, dy, min_n=2, + name='BLSBatchMemory.set_lightcurve %d' % idx) + # Epoch-subtract in float64 before the float32 cast: absolute + # timestamps (e.g. BJD) would otherwise destroy the phase fold. + t, epoch = subtract_epoch(t) + y = np.asarray(y, dtype=np.float64) + dy = np.asarray(dy, dtype=np.float64) + ndata = len(t) + + if idx >= self.n_lcs: + raise ValueError(f"idx={idx} >= n_lcs={self.n_lcs}") + if ndata > self.max_ndata: + raise ValueError( + f"ndata={ndata} > max_ndata={self.max_ndata}") + + self.ndata_per_lc[idx] = np.uint32(ndata) + self.epochs[idx] = epoch + + offset = idx * self.max_ndata + + # Compute weights + w = np.power(dy, -2) + w /= w.sum() + + # Weighted mean and normalization. einsum, not np.dot: BLAS + # ddot spawns a threadpool for large vectors and trips CFS + # throttling on CPU-quota-limited hosts (see BLSMemory.setdata). + ybar = float(np.einsum('i,i->', y, w)) + self.yy[idx] = float(np.einsum('i,i->', w, (y - ybar) ** 2)) + + # Store (use float64 for computation, cast to float32 for GPU) + # in conflict-scattered order: time-sorted input serializes the + # batch kernel's shared-memory atomics (warp-adjacent samples + # fold into the same phase bin; 3.1x measured on TESS-like + # cadence). Binning is a sum, so order is semantically free. + perm = conflict_scatter_perm(ndata) + if perm is None: + self.t[offset:offset + ndata] = t.astype(self.rtype) + self.yw[offset:offset + ndata] = \ + ((y - ybar) * w).astype(self.rtype) + self.w[offset:offset + ndata] = w.astype(self.rtype) + else: + self.t[offset:offset + ndata] = t.astype(self.rtype)[perm] + self.yw[offset:offset + ndata] = \ + ((y - ybar) * w).astype(self.rtype)[perm] + self.w[offset:offset + ndata] = w.astype(self.rtype)[perm] + + # Zero-pad remainder (should already be zero from aligned_zeros, + # but be explicit in case of reuse) + self.t[offset + ndata:offset + self.max_ndata] = 0.0 + self.yw[offset + ndata:offset + self.max_ndata] = 0.0 + self.w[offset + ndata:offset + self.max_ndata] = 0.0 + + def transfer_to_gpu(self, n_lcs_active=None, transfer_freqs=True): + """Transfer host arrays to GPU asynchronously. + + Parameters + ---------- + n_lcs_active : int, optional + Transfer only the first ``n_lcs_active`` lightcurve slots + (chunked reuse: a batch call processing fewer LCs than the + allocation avoids re-uploading the padded tail). Default: + all slots. + transfer_freqs : bool, optional (default: True) + Upload the frequency grid + bin-count arrays. Chunk loops + reusing the same grid only need this once. + """ + n_act = self.n_lcs if n_lcs_active is None else int(n_lcs_active) + n_act = min(n_act, self.n_lcs) + total_data = self.max_ndata * self.n_lcs + total_bls = self.nfreqs * self.n_lcs + + if self.t_g is None: + self.t_g = gpuarray.zeros(total_data, dtype=self.rtype) + self.yw_g = gpuarray.zeros(total_data, dtype=self.rtype) + self.w_g = gpuarray.zeros(total_data, dtype=self.rtype) + self.ndata_per_lc_g = gpuarray.zeros( + self.n_lcs, dtype=np.uint32) + self.freqs_g = gpuarray.zeros(self.nfreqs, dtype=self.rtype) + self.nbins0_g = gpuarray.zeros(self.nfreqs, dtype=np.uint32) + self.nbinsf_g = gpuarray.zeros(self.nfreqs, dtype=np.uint32) + self.bls_g = gpuarray.zeros(total_bls, dtype=self.rtype) + + nd = self.max_ndata * n_act + # driver-level prefix copies (contiguous views of the pinned + # buffers stay page-locked, so these are genuinely async) + if self.stream is not None: + cuda.memcpy_htod_async(self.t_g.gpudata, self.t[:nd], + self.stream) + cuda.memcpy_htod_async(self.yw_g.gpudata, self.yw[:nd], + self.stream) + cuda.memcpy_htod_async(self.w_g.gpudata, self.w[:nd], + self.stream) + cuda.memcpy_htod_async(self.ndata_per_lc_g.gpudata, + self.ndata_per_lc[:n_act], self.stream) + else: + cuda.memcpy_htod(self.t_g.gpudata, self.t[:nd]) + cuda.memcpy_htod(self.yw_g.gpudata, self.yw[:nd]) + cuda.memcpy_htod(self.w_g.gpudata, self.w[:nd]) + cuda.memcpy_htod(self.ndata_per_lc_g.gpudata, + self.ndata_per_lc[:n_act]) + + if transfer_freqs: + self.freqs_g.set_async(self.freqs, stream=self.stream) + self.nbins0_g.set_async(self.nbins0, stream=self.stream) + self.nbinsf_g.set_async(self.nbinsf, stream=self.stream) + + def transfer_to_cpu(self, n_lcs_active=None): + """Transfer BLS results from GPU to host. + + Parameters + ---------- + n_lcs_active : int, optional + Read back only the first ``n_lcs_active`` result rows. + """ + n_act = self.n_lcs if n_lcs_active is None else int(n_lcs_active) + n_act = min(n_act, self.n_lcs) + nb = self.nfreqs * n_act + if self.stream is not None: + cuda.memcpy_dtoh_async(self.bls[:nb], self.bls_g.gpudata, + self.stream) + self.stream.synchronize() + else: + cuda.memcpy_dtoh(self.bls[:nb], self.bls_g.gpudata) + + def get_results(self, n_lcs_active=None, nfreq_active=None): + """ + Return normalized BLS results per lightcurve. + + Parameters + ---------- + n_lcs_active : int, optional + Number of populated lightcurve slots to return (chunked + reuse). Default: all slots. + nfreq_active : int, optional + Number of valid frequencies per row (a memory allocated + for more frequencies than the current call uses -- the + ``memory=`` reuse path -- keeps its allocation pitch, and + the row tails are stale). Default: the full allocation. + + Returns + ------- + results : list of ndarray + BLS power for each lightcurve, normalized by yy. + """ + n_act = self.n_lcs if n_lcs_active is None else int(n_lcs_active) + n_act = min(n_act, self.n_lcs) + nf = self.nfreqs if nfreq_active is None else int(nfreq_active) + nf = min(nf, self.nfreqs) + results = [] + for i in range(n_act): + offset = i * self.nfreqs + raw = self.bls[offset:offset + nf].copy() + if self.yy[i] > 0: + raw /= self.yy[i] + results.append(raw) + return results diff --git a/cuvarbase/memory/ce_memory.py b/cuvarbase/memory/ce_memory.py new file mode 100644 index 00000000..97ad8b7f --- /dev/null +++ b/cuvarbase/memory/ce_memory.py @@ -0,0 +1,484 @@ +""" +Memory management for Conditional Entropy period-finding operations. +""" +import numpy as np + +import pycuda.driver as cuda # noqa: F401 (used by transfer methods) +import pycuda.gpuarray as gpuarray + +from ..base import ensure_context +from ._host import host_array + + +class ConditionalEntropyMemory: + """ + Container class for managing memory allocation and data transfer + for Conditional Entropy computations on GPU. + + Parameters + ---------- + phase_bins : int, optional (default: 10) + Number of phase bins for conditional entropy calculation + mag_bins : int, optional (default: 5) + Number of magnitude bins + phase_overlap : int, optional (default: 0) + Overlap between phase bins + mag_overlap : int, optional (default: 0) + Overlap between magnitude bins + max_phi : float, optional (default: 3.0) + Maximum phase value + stream : pycuda.driver.Stream, optional + CUDA stream for asynchronous operations + weighted : bool, optional (default: False) + Use weighted binning + use_fast : bool, optional (default: False) + The memory will only ever be used by the shared-memory + (``use_fast=True``) kernels, which keep their histogram in + shared memory: skip the ``nf * phase_bins * mag_bins`` global + histogram (``bins_g``) they never read. That array is 20 MB + for a 100k-frequency 10 x 5 search, and it was allocated -- and + zero-filled on every ``run`` -- for nothing. The standard + kernels need it, so they refuse a memory allocated this way. + **kwargs : dict + Additional parameters + """ + + def __init__(self, **kwargs): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). + ensure_context() + self.phase_bins = kwargs.get('phase_bins', 10) + self.mag_bins = kwargs.get('mag_bins', 5) + self.phase_overlap = kwargs.get('phase_overlap', 0) + self.mag_overlap = kwargs.get('mag_overlap', 0) + + self.max_phi = kwargs.get('max_phi', 3.) + self.stream = kwargs.get('stream', None) + self.weighted = kwargs.get('weighted', False) + self.use_fast = kwargs.get('use_fast', False) + self.widen_mag_range = kwargs.get('widen_mag_range', False) + self.n0 = kwargs.get('n0', None) + self.nf = kwargs.get('nf', None) + + self.compute_log_prob = kwargs.get('compute_log_prob', False) + + self.balanced_magbins = kwargs.get('balanced_magbins', False) + + # Pinned (page-locked) host buffers by default for async overlap; + # graceful fallback to page-aligned if pinning fails. + self.pinned = kwargs.get('pinned', True) + + if self.weighted and self.balanced_magbins: + raise ValueError("simultaneous balanced_magbins and weighted" + " options is not currently supported") + + if self.weighted and self.compute_log_prob: + raise ValueError("simultaneous compute_log_prob and weighted" + " options is not currently supported") + + if self.use_fast and self.compute_log_prob: + # the fast kernels compute only the conditional entropy; a + # memory built this way silently returned the CE instead of + # the log-probability + raise ValueError("use_fast must be False if compute_log_prob" + " is True (there is no shared-memory" + " log-probability kernel)") + self.n0_buffer = kwargs.get('n0_buffer', None) + self.buffered_transfer = kwargs.get('buffered_transfer', False) + self.t = None + self.y = None + self.dy = None + + self.t_g = None + self.y_g = None + self.dy_g = None + + self.bins_g = None + self.ce_c = None + self.ce_g = None + self.mag_bwf = None + self.mag_bwf_g = None + self.real_type = np.float32 + if kwargs.get('use_double', False): + self.real_type = np.float64 + + self.freqs = kwargs.get('freqs', None) + self.freqs_g = None + # True once ``freqs`` has been uploaded into ``freqs_g``; + # ``allocate_freqs`` creates a zero-filled array, so a run on a + # memory whose grid was never transferred would evaluate every + # frequency at f = 0 (``run(memory=...)`` checks this flag) + self._freqs_on_device = False + + self.mag_bin_fracs = None + self.mag_bin_fracs_g = None + + self.ytype = np.uint32 if not self.weighted else self.real_type + + def allocate_buffered_data_arrays(self, **kwargs): + """Allocate buffered CPU arrays for data transfer.""" + n0 = kwargs.get('n0', self.n0) + if self.buffered_transfer: + n0 = kwargs.get('n0_buffer', self.n0_buffer) + if not (n0 is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`n0 is not None` not satisfied") + + p = self.pinned + self.t = host_array((n0,), self.real_type, pinned=p) + self.y = host_array((n0,), self.ytype, pinned=p) + + if self.weighted: + self.dy = host_array((n0,), self.real_type, pinned=p) + + if self.balanced_magbins: + self.mag_bwf = host_array((self.mag_bins,), self.real_type, + pinned=p) + + if self.compute_log_prob: + self.mag_bin_fracs = host_array((self.mag_bins,), self.real_type, + pinned=p) + return self + + def allocate_pinned_cpu(self, **kwargs): + """Allocate the host result buffer (page-locked by default; + falls back to page-aligned if pinning fails).""" + nf = kwargs.get('nf', self.nf) + if not (nf is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`nf is not None` not satisfied") + + self.ce_c = host_array((nf,), self.real_type, pinned=self.pinned) + + return self + + def allocate_data(self, **kwargs): + """Allocate GPU memory for input data.""" + n0 = kwargs.get('n0', self.n0) + if self.buffered_transfer: + n0 = kwargs.get('n0_buffer', self.n0_buffer) + + if not (n0 is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`n0 is not None` not satisfied") + self.t_g = gpuarray.zeros(n0, dtype=self.real_type) + self.y_g = gpuarray.zeros(n0, dtype=self.ytype) + if self.weighted: + self.dy_g = gpuarray.zeros(n0, dtype=self.real_type) + + def allocate_bins(self, **kwargs): + """Allocate GPU memory for histogram bins. + + The global ``bins_g`` histogram belongs to the standard kernels; + ``ce_classical_fast``/``_faster`` build theirs in shared memory + and never touch it, so ``use_fast=True`` skips it (``bins_g`` + stays ``None``). The per-magnitude-bin side arrays are small + and are still allocated when the corresponding option is on. + """ + nf = kwargs.get('nf', self.nf) + if not (nf is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`nf is not None` not satisfied") + + self.nbins = nf * self.phase_bins * self.mag_bins + + if self.use_fast: + self.bins_g = None + elif self.weighted: + self.bins_g = gpuarray.zeros(self.nbins, dtype=self.real_type) + else: + self.bins_g = gpuarray.zeros(self.nbins, dtype=np.uint32) + + if self.balanced_magbins: + self.mag_bwf_g = gpuarray.zeros(self.mag_bins, + dtype=self.real_type) + if self.compute_log_prob: + self.mag_bin_fracs_g = gpuarray.zeros(self.mag_bins, + dtype=self.real_type) + + def allocate_freqs(self, **kwargs): + """Allocate GPU memory for frequency array.""" + nf = kwargs.get('nf', self.nf) + if not (nf is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`nf is not None` not satisfied") + self.freqs_g = gpuarray.zeros(nf, dtype=self.real_type) + self._freqs_on_device = False + if self.ce_g is None or self.ce_g.size != nf: + self.ce_g = gpuarray.zeros(nf, dtype=self.real_type) + + def allocate(self, **kwargs): + """Allocate all required GPU memory.""" + self.freqs = kwargs.get('freqs', self.freqs) + self.nf = kwargs.get('nf', len(self.freqs)) + + if self.freqs is not None: + self.freqs = np.asarray(self.freqs).astype(self.real_type) + + if not (self.nf is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`self.nf is not None` not satisfied") + + self.allocate_data(**kwargs) + self.allocate_bins(**kwargs) + self.allocate_freqs(**kwargs) + self.allocate_pinned_cpu(**kwargs) + + if self.buffered_transfer: + self.allocate_buffered_data_arrays(**kwargs) + + return self + + def transfer_data_to_gpu(self, **kwargs): + """Transfer data from CPU to GPU asynchronously.""" + if not (not any([x is None for x in [self.t, self.y]])): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`not any([x is None for x in [self.t, self.y]])` not satisfied") + + self.t_g.set_async(self.t, stream=self.stream) + self.y_g.set_async(self.y, stream=self.stream) + + if self.weighted: + if not (self.dy is not None): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`self.dy is not None` not satisfied") + self.dy_g.set_async(self.dy, stream=self.stream) + + if self.balanced_magbins: + self.mag_bwf_g.set_async(self.mag_bwf, stream=self.stream) + + if self.compute_log_prob: + self.mag_bin_fracs_g.set_async(self.mag_bin_fracs, + stream=self.stream) + + def transfer_freqs_to_gpu(self, **kwargs): + """Transfer frequency array to GPU. + + Uses ``freqs`` if given (it then becomes the memory's grid), + otherwise ``self.freqs``; the grid is cast to ``real_type``. + ``self.freqs`` is a private copy: ``run(memory=...)`` compares + it with the grid of the next call to decide whether to upload + again, and for a caller's float32 grid ``np.ascontiguousarray`` + returned the caller's own array, so a grid modified in place + between two calls compared equal to itself and stayed stale on + the device. + """ + freqs = kwargs.get('freqs', self.freqs) + if not (freqs is not None): + raise ValueError( + "ConditionalEntropyMemory: requirement " + "`freqs is not None` not satisfied") + freqs = np.array(freqs, dtype=self.real_type, copy=True) + if self.freqs_g is None or self.freqs_g.size != len(freqs): + raise ValueError( + "ConditionalEntropyMemory: freqs_g holds %s frequencies " + "but %d were given; call allocate(freqs=...) first" + % (None if self.freqs_g is None else self.freqs_g.size, + len(freqs))) + self.freqs = freqs + self.freqs_g.set_async(freqs, stream=self.stream) + self._freqs_on_device = True + + def transfer_ce_to_cpu(self, **kwargs): + """Transfer conditional entropy results from GPU to CPU.""" + self.ce_g.get_async(stream=self.stream, ary=self.ce_c) + + def compute_mag_bin_fracs(self, y, **kwargs): + """Compute magnitude bin fractions for probability calculations. + + ``y`` holds integer magnitude-bin indices; the fractions sum to 1. + """ + N = float(len(y)) + yb = np.minimum(np.asarray(y).astype(np.int64), self.mag_bins - 1) + mbf = np.bincount(yb, minlength=self.mag_bins)[:self.mag_bins] / N + + if self.mag_bin_fracs is None: + self.mag_bin_fracs = np.zeros(self.mag_bins, dtype=self.real_type) + self.mag_bin_fracs[:self.mag_bins] = mbf[:] + + # Lower limit on a balanced bin's width, as a fraction of the + # (already normalized) magnitude range. Only reached when a whole + # bin (and the neighbouring edges) sit on one quantized magnitude + # value; it keeps ``log(width)`` finite. + balanced_min_width = 1e-6 + + def balance_magbins(self, y, **kwargs): + """Create balanced magnitude bins with equal number of observations. + + The ``mag_bins`` bins each hold (as nearly as possible) the same + number of points. Bin edges are placed at the midpoints between + the largest value of one group and the smallest value of the next, + so the widths ``mag_bwf`` tile the normalized magnitude range + ``[0, 1]`` (they sum to 1). Widths are floored at + ``balanced_min_width`` so that quantized magnitudes (fewer distinct + values than points) cannot produce a zero-width bin, which would + make the conditional entropy ``-inf``. + + Parameters + ---------- + y : array-like + Magnitudes, normalized to ``[0, 1]``. + + Returns + ------- + ybins : array + Balanced bin index of each point. + mag_bwf : array, ``real_type`` + Width of each bin (fraction of the magnitude range). + """ + y = np.asarray(y) + yinds = np.argsort(y, kind='stable') + ybins = np.zeros(len(y)) + + if len(y) < self.mag_bins: + raise ValueError( + "balanced_magbins requires at least mag_bins=%d " + "observations; got %d" % (self.mag_bins, len(y))) + + # integer group boundaries: bounds[-1] == len(y) exactly, so every + # sorted point belongs to a group (``int(i * (len(y) / mag_bins))`` + # could fall one short of len(y) through float rounding and leave + # the brightest point(s) in bin 0) + bounds = (np.arange(self.mag_bins + 1) * len(y)) // self.mag_bins + edges = np.zeros(self.mag_bins + 1, dtype=np.float64) + edges[0] = np.min(y) + edges[-1] = np.max(y) + for i in range(self.mag_bins): + imin, imax = int(bounds[i]), int(bounds[i + 1]) + + inds = yinds[imin:imax] + ybins[inds] = i + + if i > 0: + # midpoint between the previous group's largest value + # and this group's smallest value + edges[i] = 0.5 * (float(y[yinds[imin - 1]]) + + float(y[yinds[imin]])) + + yrange = float(edges[-1] - edges[0]) + if yrange > 0: + mag_bwf = np.diff(edges) / yrange + else: + mag_bwf = np.full(self.mag_bins, 1.0 / self.mag_bins) + mag_bwf = np.maximum(mag_bwf, self.balanced_min_width) + + return ybins, mag_bwf.astype(self.real_type) + + def setdata(self, t, y, **kwargs): + """ + Set data for conditional entropy computation. + + Parameters + ---------- + t : array-like + Time values + y : array-like + Observation values + dy : array-like, optional + Observation uncertainties (required if weighted=True) + **kwargs : dict + Additional parameters + """ + dy = kwargs.get('dy', self.dy) + + self.n0 = kwargs.get('n0', len(t)) + + t = np.asarray(t).astype(self.real_type) + y = np.asarray(y).astype(self.real_type) + + yscale = max(y[:self.n0]) - min(y[:self.n0]) + y0 = min(y[:self.n0]) + if self.weighted: + dy = np.asarray(dy).astype(self.real_type) + if self.widen_mag_range: + med_sigma = np.median(dy[:self.n0]) + yscale += 2 * self.max_phi * med_sigma + y0 -= self.max_phi * med_sigma + + dy /= yscale + y = (y - y0) / yscale + if not self.weighted: + if self.balanced_magbins: + y, self.mag_bwf = self.balance_magbins(y) + y = y.astype(self.ytype) + + else: + # y is normalized to [0, 1] with the brightest point at + # exactly 1.0, so floor(y * mag_bins) would give the + # out-of-range index mag_bins for it: clamp into the + # last bin. + y = np.minimum(np.floor(y * self.mag_bins), + self.mag_bins - 1).astype(self.ytype) + + if self.compute_log_prob: + self.compute_mag_bin_fracs(y[:self.n0]) + + if self.buffered_transfer: + arrs = [self.t, self.y] + if self.weighted: + arrs.append(self.dy) + + if any([arr is None for arr in arrs]): + if self.buffered_transfer: + self.allocate_buffered_data_arrays(**kwargs) + + if not (self.n0 <= len(self.t)): + raise RuntimeError( + "ConditionalEntropyMemory: requirement " + "`self.n0 <= len(self.t)` not satisfied") + + self.t[:self.n0] = t[:self.n0] + self.y[:self.n0] = y[:self.n0] + + if self.weighted: + self.dy[:self.n0] = dy[:self.n0] + else: + self.t = t + self.y = y + if self.weighted: + self.dy = dy + return self + + def set_gpu_arrays_to_zero(self, **kwargs): + """Zero out GPU arrays (``bins_g`` only when it exists: the + fast kernels do not allocate it).""" + self.t_g.fill(self.real_type(0), stream=self.stream) + self.y_g.fill(self.ytype(0), stream=self.stream) + if self.weighted: + self.dy_g.fill(self.real_type(0), stream=self.stream) + if self.bins_g is not None: + self.bins_g.fill(self.bins_g.dtype.type(0), stream=self.stream) + + def fromdata(self, t, y, **kwargs): + """ + Initialize memory from data arrays. + + Parameters + ---------- + t : array-like + Time values + y : array-like + Observation values + allocate : bool, optional (default: True) + Whether to allocate GPU memory + **kwargs : dict + Additional parameters + + Returns + ------- + self : ConditionalEntropyMemory + """ + self.setdata(t, y, **kwargs) + + if kwargs.get('allocate', True): + self.allocate(**kwargs) + + return self diff --git a/cuvarbase/memory/lombscargle_memory.py b/cuvarbase/memory/lombscargle_memory.py new file mode 100644 index 00000000..0d18ce6b --- /dev/null +++ b/cuvarbase/memory/lombscargle_memory.py @@ -0,0 +1,424 @@ +""" +Memory management for Lomb-Scargle periodogram computations. +""" +import numpy as np + +import pycuda.driver as cuda # noqa: F401 (used by transfer methods) +import pycuda.gpuarray as gpuarray + +from ..base import ensure_context +from ..utils import weights +from ._host import host_array +from .nfft_memory import NFFTMemory, next_fast_len + +# The Lomb-Scargle NFFTs read one-sided modes k0 .. k0 + nf - 1 (after +# nfft_shift), and the Gaussian window is only alias-free for modes below +# n / sigma of the grid. sigma = 2 leaves the top of EVERY band aliased +# even with the grids sized from the top mode (measured maxabs up to 4.0 +# vs astropy); sigma >= 3 is required on the NFFT path. +MIN_NFFT_SIGMA = 3 + + +def nfft_grid_sizes(nf, k0, nharmonics=1, sigma=4): + """Mode counts and (padded) grid lengths of the two Lomb-Scargle + NFFTs for a frequency grid ``df * (k0 + arange(nf))``. + + The ``lomb`` kernel / ``_mh_power_from_spectra`` read entry + ``(h - 1) k0 + h i`` of the yw-spectrum for harmonics ``h = 1..H`` + and entry ``(m - 1) k0 + m i`` of the w-spectrum for ``m = 1..2H`` + (entry ``j`` holds mode ``k0 + j``), so the yw transform needs + ``H (nf + k0) - k0`` modes and the w transform twice that. Each + grid is sized from its TOP MODE, ``sigma * (k0 + count)``, not from + the count: before 1.0 the grids were ``sigma * count`` and any band + with ``fmin >= ~fmax / 2`` read aliased modes (powers 1e4..1e36, + defect 4, ``nfft-k0-size``). Lengths are padded to + :func:`~cuvarbase.memory.nfft_memory.next_fast_len`. + + Returns + ------- + (nf_yw, n_yw, nf_w, n_w) : ints + Mode count and grid length of the yw and w transforms. + """ + H = int(nharmonics) + fft_size = H * (int(nf) + int(k0)) + nf_yw = fft_size - int(k0) + nf_w = 2 * fft_size - int(k0) + n_yw = next_fast_len(int(np.ceil(sigma * (int(k0) + nf_yw) - 1e-9))) + n_w = next_fast_len(int(np.ceil(sigma * (int(k0) + nf_w) - 1e-9))) + return nf_yw, n_yw, nf_w, n_w + + +# ``weights`` is re-exported here (and as ``cuvarbase.memory.weights``) +# for backward compatibility; :func:`cuvarbase.utils.weights` is canonical. + + +class LombScargleMemory: + """ + Container class for allocating memory and transferring + data between the GPU and CPU for Lomb-Scargle computations. + + Parameters + ---------- + sigma : float + The sigma parameter for the NFFT + stream : pycuda.driver.Stream + The CUDA stream used for calculations/data transfer + m : int + The m parameter for the NFFT + **kwargs : dict + Additional parameters + """ + def __init__(self, sigma, stream, m, **kwargs): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). This + # __init__ allocates reg_g immediately, so the context must exist. + ensure_context() + + self.sigma = sigma + self.stream = stream + self.m = m + self.k0 = kwargs.get('k0', 0) + self.precomp_psi = kwargs.get('precomp_psi', True) + self.amplitude_prior = kwargs.get('amplitude_prior', None) + self.window = kwargs.get('window', False) + self.nharmonics = kwargs.get('nharmonics', 1) + self.use_fft = kwargs.get('use_fft', True) + # Pinned (page-locked) host buffers by default for async overlap; + # graceful fallback to page-aligned if pinning fails. + self.pinned = kwargs.get('pinned', True) + + self.other_settings = {} + self.other_settings.update(kwargs) + + self.floating_mean = kwargs.get('floating_mean', True) + self.use_double = kwargs.get('use_double', False) + + self.mode = 1 if self.floating_mean else 0 + if self.window: + self.mode = 2 + + self.n0 = kwargs.get('n0', None) + self.nf = kwargs.get('nf', None) + + self.t_g = kwargs.get('t_g', None) + self.yw_g = kwargs.get('yw_g', None) + self.w_g = kwargs.get('w_g', None) + self.lsp_g = kwargs.get('lsp_g', None) + + if self.use_fft: + self.nfft_mem_yw = kwargs.get('nfft_mem_yw', None) + self.nfft_mem_w = kwargs.get('nfft_mem_w', None) + + if self.nfft_mem_yw is None: + self.nfft_mem_yw = NFFTMemory(self.sigma, self.stream, + self.m, **kwargs) + + if self.nfft_mem_w is None: + self.nfft_mem_w = NFFTMemory(self.sigma, self.stream, + self.m, **kwargs) + + self.real_type = self.nfft_mem_yw.real_type + self.complex_type = self.nfft_mem_yw.complex_type + + else: + self.real_type = np.float32 + self.complex_type = np.complex64 + + if self.use_double: + self.real_type = np.float64 + self.complex_type = np.complex128 + + # Set up regularization + self.reg_g = gpuarray.zeros(2 * self.nharmonics + 1, + dtype=self.real_type) + self.reg = np.zeros(2 * self.nharmonics + 1, + dtype=self.real_type) + + if self.amplitude_prior is not None: + lmbda = np.power(self.amplitude_prior, -2) + if isinstance(lmbda, float): + lmbda = lmbda * np.ones(self.nharmonics) + + for i, l in enumerate(lmbda): + self.reg[2 * i] = self.real_type(l) + self.reg[1 + 2 * i] = self.real_type(l) + + self.reg_g.set_async(self.reg, stream=self.stream) + + self.buffered_transfer = kwargs.get('buffered_transfer', False) + self.n0_buffer = kwargs.get('n0_buffer', None) + + self.lsp_c = kwargs.get('lsp_c', None) + + self.t = kwargs.get('t', None) + self.yw = kwargs.get('yw', None) + self.w = kwargs.get('w', None) + + def allocate_data(self, **kwargs): + """Allocates memory for lightcurve.""" + n0 = kwargs.get('n0', self.n0) + if self.buffered_transfer: + n0 = kwargs.get('n0_buffer', self.n0_buffer) + + if not (n0 is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`n0 is not None` not satisfied") + self.t_g = gpuarray.zeros(n0, dtype=self.real_type) + self.yw_g = gpuarray.zeros(n0, dtype=self.real_type) + self.w_g = gpuarray.zeros(n0, dtype=self.real_type) + + if self.use_fft: + self.nfft_mem_w.t_g = self.t_g + self.nfft_mem_w.y_g = self.w_g + + self.nfft_mem_yw.t_g = self.t_g + self.nfft_mem_yw.y_g = self.yw_g + + self.nfft_mem_yw.n0 = n0 + self.nfft_mem_w.n0 = n0 + + return self + + def allocate_grids(self, **kwargs): + """ + Allocates memory for NFFT grids, NFFT precomputation vectors, + and the GPU vector for the Lomb-Scargle power. + """ + k0 = kwargs.get('k0', self.k0) + n0 = kwargs.get('n0', self.n0) + if self.buffered_transfer: + n0 = kwargs.get('n0_buffer', self.n0_buffer) + if not (n0 is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`n0 is not None` not satisfied") + + self.nf = kwargs.get('nf', self.nf) + if not (self.nf is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`self.nf is not None` not satisfied") + + if self.use_fft: + # Each NFFT grid needs its OWN psi tables. ``precompute_psi`` + # (cunfft.cu) stores frac(ng * x) for the grid length ng it + # was run with, and the w grid is ~2x the yw grid (2H vs H + # harmonics). Sharing the yw tables with the w grid, as this + # code did before 1.0, displaced every point's Gaussian on + # the w grid by frac(ng_yw x) - frac(ng_w x) cells and biased + # every default-path Lomb-Scargle power by 3e-3..2.4e-2 + # (defect 3, nfft-psi-table). q3 is grid-independent but + # tiny (2m+1 entries), so each grid simply owns all three. + self.nfft_mem_w.precomp_psi = self.nfft_mem_yw.precomp_psi + for nfft_mem in (self.nfft_mem_yw, self.nfft_mem_w): + if nfft_mem.precomp_psi: + nfft_mem.allocate_precomp_psi(n0=n0) + + if self.sigma < MIN_NFFT_SIGMA: + raise ValueError( + "LombScargleMemory: sigma=%r is too small for the " + "NFFT Lomb-Scargle (the top of every frequency band " + "would be aliased); use sigma >= %d, or the direct " + "sums (use_fft=False)" % (self.sigma, MIN_NFFT_SIGMA)) + nf_yw, n_yw, nf_w, n_w = nfft_grid_sizes( + self.nf, k0, nharmonics=self.nharmonics, sigma=self.sigma) + self.nfft_mem_yw.allocate_grid(nf=nf_yw, n=n_yw) + self.nfft_mem_w.allocate_grid(nf=nf_w, n=n_w) + + self.lsp_g = gpuarray.zeros(self.nf, dtype=self.real_type) + return self + + def allocate_pinned_cpu(self, **kwargs): + """Allocate the host result buffer (page-locked by default; + falls back to page-aligned if pinning fails).""" + nf = kwargs.get('nf', self.nf) + if not (nf is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`nf is not None` not satisfied") + + self.lsp_c = host_array((nf,), self.real_type, pinned=self.pinned) + + return self + + def is_ready(self): + """Verify all required memory is allocated for a run. + + Raises RuntimeError if frequencies or device arrays are + missing or inconsistently sized (mirrors + ``NFFTMemory.is_ready``). + """ + if self.nf is None: + raise RuntimeError( + "LombScargleMemory: nf is not set (call allocate " + "first)") + if self.lsp_g is None or len(self.lsp_g) < self.nf: + raise RuntimeError( + "LombScargleMemory: lsp_g is not allocated for " + "nf=%d" % self.nf) + if any(arr is None for arr in (self.t_g, self.yw_g, self.w_g)): + raise RuntimeError( + "LombScargleMemory: data arrays (t_g, yw_g, w_g) are " + "not allocated (call allocate_data first)") + if self.use_fft: + self.nfft_mem_yw.is_ready() + self.nfft_mem_w.is_ready() + + def allocate_buffered_data_arrays(self, **kwargs): + """ + Allocate host memory for lightcurves if we're reusing this + container (page-locked by default; page-aligned fallback). + """ + n0 = kwargs.get('n0', self.n0) + if self.buffered_transfer: + n0 = kwargs.get('n0_buffer', self.n0_buffer) + if not (n0 is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`n0 is not None` not satisfied") + + self.t = host_array((n0,), self.real_type, pinned=self.pinned) + self.yw = host_array((n0,), self.real_type, pinned=self.pinned) + self.w = host_array((n0,), self.real_type, pinned=self.pinned) + + return self + + def allocate(self, **kwargs): + """Allocate all memory necessary.""" + self.nf = kwargs.get('nf', self.nf) + if not (self.nf is not None): + raise RuntimeError( + "LombScargleMemory: requirement " + "`self.nf is not None` not satisfied") + + self.allocate_data(**kwargs) + self.allocate_grids(**kwargs) + self.allocate_pinned_cpu(**kwargs) + + if self.buffered_transfer: + self.allocate_buffered_data_arrays(**kwargs) + + return self + + def setdata(self, **kwargs): + """Sets the value of the data arrays.""" + t = kwargs.get('t', self.t) + yw = kwargs.get('yw', self.yw) + w = kwargs.get('w', self.w) + + y = kwargs.get('y', None) + dy = kwargs.get('dy', None) + self.ybar = 0. + self.yy = kwargs.get('yy', 1.) + + self.n0 = kwargs.get('n0', len(t)) + if dy is not None: + if not ('w' not in kwargs): + raise ValueError( + "LombScargleMemory: requirement " + "`'w' not in kwargs` not satisfied") + w = weights(dy) + elif y is not None and 'w' not in kwargs: + # dy=None means unit weights (an unweighted periodogram, as + # run() documents). Never fall back to self.w here: on a + # reused (buffered) memory that would silently be the + # previous lightcurve's weights; before 1.0 it was None and + # raised TypeError. + w = np.full(len(y), 1.0 / len(y)) + + if y is not None: + if not ('yw' not in kwargs): + raise ValueError( + "LombScargleMemory: requirement " + "`'yw' not in kwargs` not satisfied") + + self.ybar = np.dot(y, w) + yw = np.multiply(w, y - self.ybar) + y2 = np.power(y - self.ybar, 2) + self.yy = np.dot(w, y2) + + t = np.asarray(t).astype(self.real_type) + yw = np.asarray(yw).astype(self.real_type) + w = np.asarray(w).astype(self.real_type) + + if self.buffered_transfer: + if any([arr is None for arr in [self.t, self.yw, self.w]]): + if self.buffered_transfer: + self.allocate_buffered_data_arrays(**kwargs) + + if not (self.n0 <= len(self.t)): + raise RuntimeError( + "LombScargleMemory: requirement " + "`self.n0 <= len(self.t)` not satisfied") + + self.t[:self.n0] = t[:self.n0] + self.yw[:self.n0] = yw[:self.n0] + self.w[:self.n0] = w[:self.n0] + else: + self.t = np.asarray(t).astype(self.real_type) + self.yw = np.asarray(yw).astype(self.real_type) + self.w = np.asarray(w).astype(self.real_type) + + # Set minimum and maximum t values (needed to scale things + # for the NFFT). np.min/np.max, not the Python builtins: the + # builtins iterate the array in Python (5.4 ms per lightcurve + # at N = 65,000, 84 ms at N = 1e6) for the same value. + self.tmin = np.min(t) + self.tmax = np.max(t) + + if self.use_fft: + self.nfft_mem_yw.tmin = self.tmin + self.nfft_mem_w.tmin = self.tmin + + self.nfft_mem_yw.tmax = self.tmax + self.nfft_mem_w.tmax = self.tmax + + self.nfft_mem_w.n0 = len(t) + self.nfft_mem_yw.n0 = len(t) + + return self + + def transfer_data_to_gpu(self, **kwargs): + """Transfers the lightcurve to the GPU.""" + t, yw, w = self.t, self.yw, self.w + + if not (not any([arr is None for arr in [t, yw, w]])): + raise RuntimeError( + "LombScargleMemory: requirement " + "`not any([arr is None for arr in [t, yw, w]])` not satisfied") + + # Do asynchronous data transfer + self.t_g.set_async(t, stream=self.stream) + self.yw_g.set_async(yw, stream=self.stream) + self.w_g.set_async(w, stream=self.stream) + + def transfer_lsp_to_cpu(self, **kwargs): + """Asynchronous transfer of LSP result to CPU.""" + self.lsp_g.get_async(ary=self.lsp_c, stream=self.stream) + + def fromdata(self, **kwargs): + """Sets and (optionally) allocates memory for data.""" + self.setdata(**kwargs) + + if kwargs.get('allocate', True): + self.allocate(**kwargs) + + return self + + def set_gpu_arrays_to_zero(self, **kwargs): + """Sets all gpu arrays to zero.""" + for x in [self.t_g, self.yw_g, self.w_g]: + if x is not None: + x.fill(self.real_type(0), stream=self.stream) + + for x in [self.t, self.yw, self.w]: + if x is not None: + x[:] = 0. + + if hasattr(self, 'nfft_mem_yw'): + self.nfft_mem_yw.ghat_g.fill(self.complex_type(0), + stream=self.stream) + if hasattr(self, 'nfft_mem_w'): + self.nfft_mem_w.ghat_g.fill(self.complex_type(0), + stream=self.stream) diff --git a/cuvarbase/memory/nfft_memory.py b/cuvarbase/memory/nfft_memory.py new file mode 100644 index 00000000..c65cf12f --- /dev/null +++ b/cuvarbase/memory/nfft_memory.py @@ -0,0 +1,351 @@ +""" +Memory management for NFFT (Non-equispaced Fast Fourier Transform) operations. +""" +import numpy as np + +import pycuda.driver as cuda # noqa: F401 (used by transfer methods) +import pycuda.gpuarray as gpuarray + +from ..base import ensure_context +from ..utils import subtract_epoch +from ._host import host_array +from .. import _cufft as cufft + + +def next_fast_len(n): + """Smallest integer ``>= n`` whose prime factors are all in + {2, 3, 5, 7} -- the radices cuFFT has dedicated fast kernels for. + + Other lengths fall back to Bluestein's algorithm, which is several + times slower and needs a much larger work area (the Lomb-Scargle + grids sized by ``sigma * (nf + k0)`` are essentially never smooth + by accident: an audit measured cuFFT 0.83 -> 0.08 ms at n ~ 2.9e6 + from padding alone). Padding a gridded NFFT to a longer grid is + harmless -- the transform is evaluated at the same modes, on a + finer grid, so the result moves slightly *toward* the exact DFT. + + Parameters + ---------- + n : int + Minimum length. + + Returns + ------- + int + The smallest 7-smooth number ``>= max(n, 1)``. + """ + n = int(n) + if n <= 1: + return 1 + best = 1 << (n - 1).bit_length() # power of two >= n + p7 = 1 + while p7 < best: + p5 = p7 + while p5 < best: + p3 = p5 + while p3 < best: + # smallest power of two that lifts p3 to >= n + q = -(-n // p3) + cand = p3 << max(0, (q - 1).bit_length()) + if cand < best: + best = cand + p3 *= 3 + p5 *= 5 + p7 *= 7 + return best + + +class NFFTMemory: + r""" + Container class for managing memory allocation and data transfer + for NFFT computations on GPU. + + Parameters + ---------- + sigma : float + Oversampling factor for NFFT + stream : pycuda.driver.Stream + CUDA stream for asynchronous operations + m : int + NFFT truncation parameter + use_double : bool, optional (default: False) + Use double precision floating point + precomp_psi : bool, optional (default: True) + Precompute psi values for faster gridding + **kwargs : dict + Additional parameters + + Notes + ----- + **Time origin / phase convention.** :meth:`fromdata` subtracts + ``epoch = floor(min(t))`` from the times in float64 *before* they + are cast to the device precision (``utils.subtract_epoch``, the + same convention as BLS), and stores it as ``self.epoch``. The + transform the kernels then compute is + + .. math:: + + \hat g_k = \sum_j y_j \exp\left(2\pi i f_k (t_j - \mathrm{epoch})\right) + + i.e. the magnitudes are those of the transform of the input and + the phases are relative to ``epoch``. Multiply by + ``exp(2j * pi * f_k * epoch)`` (in float64, on the host) if phases + relative to ``t = 0`` are needed. For data with ``min(t)`` in + ``[0, 1)`` the epoch is 0 and nothing changes. Before 1.0 the + absolute times were cast to float32 as given, so BJD-scale input + (~2.457e6 d, float32 spacing 0.25 d) produced wrong *magnitudes* + (rel. error 0.94; defect 12, ``nfft-absolute-time``). + """ + + def __init__(self, sigma, stream, m, use_double=False, + precomp_psi=True, **kwargs): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). + ensure_context() + + self.sigma = sigma + self.stream = stream + self.m = m + self.use_double = use_double + self.precomp_psi = precomp_psi + # Time origin subtracted by fromdata (see the class docstring); + # 0 unless fromdata was used with min(t) outside [0, 1). + self.epoch = kwargs.get('epoch', 0.0) + # Pinned (page-locked) host buffer by default; falls back to + # page-aligned if pinning fails. + self.pinned = kwargs.get('pinned', True) + + # set datatypes + self.real_type = np.float32 if not self.use_double \ + else np.float64 + self.complex_type = np.complex64 if not self.use_double \ + else np.complex128 + + self.other_settings = {} + self.other_settings.update(kwargs) + + self.t = kwargs.get('t', None) + self.y = kwargs.get('y', None) + self.f0 = kwargs.get('f0', 0.) + self.n0 = kwargs.get('n0', None) + self.nf = kwargs.get('nf', None) + self.t_g = kwargs.get('t_g', None) + self.y_g = kwargs.get('y_g', None) + self.ghat_g = kwargs.get('ghat_g', None) + self.ghat_c = kwargs.get('ghat_c', None) + self.q1 = kwargs.get('q1', None) + self.q2 = kwargs.get('q2', None) + self.q3 = kwargs.get('q3', None) + self.cu_plan = kwargs.get('cu_plan', None) + + D = (2 * self.sigma - 1) * np.pi + self.b = float(2 * self.sigma * self.m) / D + + def allocate_data(self, **kwargs): + """Allocate GPU memory for input data (times and values).""" + self.n0 = kwargs.get('n0', self.n0) + self.nf = kwargs.get('nf', self.nf) + + if not (self.n0 is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 is not None` not satisfied") + if not (self.nf is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.nf is not None` not satisfied") + + self.t_g = gpuarray.zeros(self.n0, dtype=self.real_type) + self.y_g = gpuarray.zeros(self.n0, dtype=self.real_type) + + return self + + def allocate_precomp_psi(self, **kwargs): + """Allocate memory for precomputed psi values.""" + self.n0 = kwargs.get('n0', self.n0) + + if not (self.n0 is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 is not None` not satisfied") + + self.q1 = gpuarray.zeros(self.n0, dtype=self.real_type) + self.q2 = gpuarray.zeros(self.n0, dtype=self.real_type) + self.q3 = gpuarray.zeros(2 * self.m + 1, dtype=self.real_type) + + return self + + def allocate_grid(self, **kwargs): + """Allocate the oversampled grid ``ghat_g`` and its cuFFT plan. + + Parameters + ---------- + nf : int, optional + Number of modes the transform is evaluated at (entries + ``ghat_g[0:nf]`` after ``normalize``). Defaults to + ``self.nf``. + n : int, optional + Grid (FFT) length. Defaults to ``int(sigma * nf)``, which + is right for the *centred* convention (modes + ``-nf/2 .. nf/2 - 1``). Callers that read one-sided modes + ``k0 .. k0 + nf - 1`` (the Lomb-Scargle memory) must size + the grid from the top mode instead, ``>= sigma * (k0 + nf)``, + and may pad to :func:`next_fast_len`. + """ + self.nf = kwargs.get('nf', self.nf) + + if not (self.nf is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.nf is not None` not satisfied") + + n = kwargs.get('n', None) + self.n = int(self.sigma * self.nf) if n is None else int(n) + if self.n < self.nf: + raise ValueError( + "NFFTMemory: grid length n=%d is smaller than the number " + "of requested modes nf=%d" % (self.n, self.nf)) + self.ghat_g = gpuarray.zeros(self.n, + dtype=self.complex_type) + self.cu_plan = cufft.Plan(self.n, self.complex_type, self.complex_type, + stream=self.stream) + return self + + def allocate_pinned_cpu(self, **kwargs): + """Allocate the host result buffer (page-locked by default). + + With ``pinned=True`` (default) the array is page-locked so + ``get_async`` overlaps with computation; falls back to + page-aligned memory if pinning fails. + """ + self.nf = kwargs.get('nf', self.nf) + + if not (self.nf is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.nf is not None` not satisfied") + self.ghat_c = host_array((self.nf,), self.complex_type, + pinned=self.pinned) + + return self + + def is_ready(self): + """Verify all required memory is allocated.""" + if not (self.n0 == len(self.t_g)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 == len(self.t_g)` not satisfied") + if not (self.n0 == len(self.y_g)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 == len(self.y_g)` not satisfied") + if not (self.n == len(self.ghat_g)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n == len(self.ghat_g)` not satisfied") + + if self.ghat_c is not None: + if not (self.nf == len(self.ghat_c)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.nf == len(self.ghat_c)` not satisfied") + + if self.precomp_psi: + if not (self.n0 == len(self.q1)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 == len(self.q1)` not satisfied") + if not (self.n0 == len(self.q2)): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 == len(self.q2)` not satisfied") + if not (2 * self.m + 1 == len(self.q3)): + raise RuntimeError( + "NFFTMemory: requirement " + "`2 * self.m + 1 == len(self.q3)` not satisfied") + + def allocate(self, **kwargs): + """Allocate all required memory for NFFT computation.""" + self.n0 = kwargs.get('n0', self.n0) + self.nf = kwargs.get('nf', self.nf) + + if not (self.n0 is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.n0 is not None` not satisfied") + if not (self.nf is not None): + raise RuntimeError( + "NFFTMemory: requirement " + "`self.nf is not None` not satisfied") + self.n = int(self.sigma * self.nf) + + self.allocate_data(**kwargs) + self.allocate_grid(**kwargs) + self.allocate_pinned_cpu(**kwargs) + if self.precomp_psi: + self.allocate_precomp_psi(**kwargs) + + return self + + def transfer_data_to_gpu(self, **kwargs): + """Transfer data from CPU to GPU asynchronously.""" + t = kwargs.get('t', self.t) + y = kwargs.get('y', self.y) + + if not (t is not None): + raise ValueError( + "NFFTMemory: requirement " + "`t is not None` not satisfied") + if not (y is not None): + raise ValueError( + "NFFTMemory: requirement " + "`y is not None` not satisfied") + + self.t_g.set_async(t, stream=self.stream) + self.y_g.set_async(y, stream=self.stream) + + def transfer_nfft_to_cpu(self, **kwargs): + """Transfer NFFT result from GPU to CPU asynchronously.""" + cuda.memcpy_dtoh_async(self.ghat_c, self.ghat_g.ptr, + stream=self.stream) + + def fromdata(self, t, y, allocate=True, **kwargs): + """ + Initialize memory from data arrays. + + Parameters + ---------- + t : array-like + Time values + y : array-like + Observation values + allocate : bool, optional (default: True) + Whether to allocate GPU memory + **kwargs : dict + Additional parameters + + Returns + ------- + self : NFFTMemory + + Notes + ----- + Times are shifted by ``epoch = floor(min(t))`` in float64 + before the cast to the device precision and ``self.epoch`` is + set; the transform's phases are relative to that epoch (see + the class notes). + """ + t64, self.epoch = subtract_epoch(t) + self.tmin = float(np.min(t64)) + self.tmax = float(np.max(t64)) + + self.t = t64.astype(self.real_type) + self.y = np.asarray(y).astype(self.real_type) + + self.n0 = kwargs.get('n0', len(t)) + self.nf = kwargs.get('nf', self.nf) + + if self.nf is not None and allocate: + self.allocate(**kwargs) + + return self diff --git a/cuvarbase/nufft_lrt.py b/cuvarbase/nufft_lrt.py new file mode 100644 index 00000000..d7cdb9ee --- /dev/null +++ b/cuvarbase/nufft_lrt.py @@ -0,0 +1,1040 @@ +""" +NUFFT-based Likelihood Ratio Test for transit detection. + +Contributed by Jamila Taaki (`@xiaziyna `_). +This module implements a frequency-domain matched-filter / likelihood- +ratio test for box transits in correlated noise, with the noise spectrum +estimated adaptively from the data. + +The data and each transit template are transformed with the GPU adjoint +NFFT (:class:`cuvarbase.cunfft.NFFTAsyncProcess`), which handles the +non-uniform (gappy / multi-season) sampling directly over the full +observational baseline. The per-template matched-filter combination +(``SNR = sum_k Y_k T_k* w_k / P_s(k) / sqrt(sum_k |T_k|^2 w_k / P_s(k))``) +runs on the host -- it is an O(nf) reduction, negligible next to the NFFT. + +Conventions +----------- +* **Times.** :meth:`NUFFTLRTAsyncProcess.run` subtracts + ``floor(min(t))`` in float64 (:func:`cuvarbase.utils.subtract_epoch`) + before anything is cast to the device precision, so absolute BJD-scale + timestamps are safe (float32 spacing at 2.457e6 is 0.25 d, wider than + a transit). ``epochs`` passed in and the best epochs returned are in + the caller's original time scale. +* **PSD.** ``psd[k]`` is the expected squared modulus of the noise's + *unnormalized* adjoint NFFT at mode ``k``: + ``P(k) = E |sum_j s_j exp(2 pi i f_k t_j)|^2`` with + ``f_k = k / (max(t) - min(t))``, ``k = 0..nf-1`` (one-sided, all nf + modes are physical positive-frequency coefficients). White noise of + variance ``sigma^2`` per point has ``P(k) = n sigma^2`` at every k. + ``psd=np.ones(nf)`` therefore gives a statistic in *data units*, not + an SNR. +* **The statistic is not N(0, 1).** Under irregular sampling the NFFT + modes are not orthogonal, so the frequency-diagonal whitened + correlation is over-dispersed even with the TRUE noise PSD (null + standard deviation 1.8-2.7 for ground-based sampling at ``nf = 2n``, + growing with ``nf``). Detection thresholds must be calibrated + empirically per (sampling, ``nf``, PSD estimator) configuration, e.g. + from the null-percentile of signal-free or scrambled light curves as + ``benchmarks/nufft_lrt/validate.py`` does. Raising ``nf`` inflates the + raw value without adding information. +* **Detectors** (:meth:`NUFFTLRTAsyncProcess.run`, ``detector=``): + ``'matched'`` (default) is the stationary whitened filter above; + ``'marginal'`` is Detector A of Taaki, Kamalabadi & Kemball (2020), + which marginalizes systematics coefficients under a Gaussian prior; + ``'sequential'`` least-squares cotrends against the same basis and + then runs the matched filter on the residual. The latter two need + ``systematics_basis``, Detector A also ``coeff_prior_cov``. +* **Detector A's prior is effectively wider than specified.** Its + Gram matrix is accumulated over ``nf`` (by default ``2n``) + non-orthogonal NFFT modes, which overcounts the corresponding + time-domain inner products by ~2.2-2.4x for the samplings measured + in the Sep-2026 audit, so ``coeff_prior_cov`` acts as though it were + about that much wider. The effect on the statistic is small, but + calibrate the prior and the threshold together. +* ``dy`` is not used by any detector (a ``UserWarning`` is emitted if it + is passed); the noise model is the PSD. +""" +import warnings + +import numpy as np + +import pycuda.driver as cuda +import pycuda.gpuarray as gpuarray +from pycuda.compiler import SourceModule + +from .base import GPUAsyncProcess, ensure_context +from .cunfft import NFFTAsyncProcess +from .memory import NFFTMemory +from .utils import (find_kernel, _module_reader, + subtract_epoch, check_lightcurve) + + +__all__ = [ + 'epoch_grid', + 'NUFFTLRTMemory', + 'NUFFTLRTAsyncProcess', +] + + +# Emitted once per NUFFTLRTAsyncProcess construction (not at import, so +# ``from cuvarbase import *`` and the BLS/LS/PDM users never see it). +# Keep the "cuvarbase.nufft_lrt is EXPERIMENTAL" prefix: filterwarnings +# entries match on it. +_EXPERIMENTAL_MSG = ( + "cuvarbase.nufft_lrt is EXPERIMENTAL and outside the 1.x API-stability " + "promise. Its Sep-2026 correctness fixes were re-validated by " + "injection-recovery (the default path is correct on BJD-scale times " + "and recovers random-epoch transits), but that campaign also showed " + "that its defaults and return conventions should still change " + "(automatic epoch grid resolution, PSD whitening without measurable " + "gain, tuple-or-array return), so run() may change incompatibly in a " + "1.x release; the statistic is not N(0, 1) and thresholds must be " + "calibrated empirically (see " + "https://johnh2o2.github.io/cuvarbase/nufft_lrt.html).") + + +def _whitened_inner(A, B, psd, weights): + """Whitened frequency-domain inner product Re sum_k A_k B_k* w_k / P_k + -- the metric of the stationary matched filter. + + Reference helper, not on the :meth:`NUFFTLRTAsyncProcess.run` path + (which precomputes ``Y w / P`` once per run and evaluates + :func:`_matched_filter_statistic` per template); the CPU tests use + it as the independent algebra for the K = 0 limit of Detector A.""" + return float(np.real(np.sum(A * np.conj(B) * weights / psd))) + + +def _matched_filter_statistic(Yw, wp, T): + """The stationary whitened matched filter of the module docstring, + ``Re _W / sqrt(_W)``, with the data side precomputed: + ``Yw = Y w / P`` and ``wp = w / P`` (float64), ``T`` the template + transform. This is the per-template reduction + :meth:`NUFFTLRTAsyncProcess.run` evaluates for ``detector='matched'`` + and ``'sequential'``; Detector A's K = 0 limit and the reference + wrapper :meth:`NUFFTLRTAsyncProcess._compute_matched_filter_snr` + call it too, so the tests of the latter cover the shipped + arithmetic. Returns 0.0 for a template with no whitened power.""" + T = np.asarray(T) + num = float(np.real(np.sum(Yw * np.conj(T)))) + den = float(np.sum((np.abs(T) ** 2) * wp)) + return num / np.sqrt(den) if den > 0 else 0.0 + + +def _prior_response_matrix(G, prior_cov): + """Return ``M = (Cov_c^{-1} + G)^{-1}`` for the Detector A Woodbury + term without ever inverting the prior covariance: + + (C^{-1} + G)^{-1} = C (I + G C)^{-1} + + (push-through identity), so a zero prior variance along a mode + correctly gives the "prior pinned to its mean" limit (no + marginalization along that mode). A ``pinv`` of the prior would turn + that same zero into an *improper flat* prior -- the opposite limit. + ``I + G C`` has eigenvalues >= 1 for positive semidefinite ``G`` and + ``C``, so the solve is always well posed. + + Raises ``ValueError`` if ``prior_cov`` is not a symmetric positive + semidefinite ``(K, K)`` matrix. + """ + G = np.asarray(G, dtype=np.float64) + K = G.shape[0] + C = np.atleast_2d(np.asarray(prior_cov, dtype=np.float64)) + if C.shape != (K, K): + raise ValueError("coeff_prior_cov must be (K, K) with K = %d " + "basis vectors (got shape %r)" % (K, C.shape)) + if not np.all(np.isfinite(C)): + raise ValueError("coeff_prior_cov must be finite") + scale = max(float(np.max(np.abs(C))), 1.0) + if not np.allclose(C, C.T, rtol=1e-8, atol=1e-12 * scale): + raise ValueError("coeff_prior_cov must be symmetric") + ev = np.linalg.eigvalsh(C) + if ev.min() < -1e-10 * scale: + raise ValueError("coeff_prior_cov must be positive semidefinite " + "(smallest eigenvalue %g)" % ev.min()) + A = np.eye(K) + G @ C + M = np.linalg.solve(A.T, C.T).T # C A^{-1} + return 0.5 * (M + M.T) + + +def _marginal_precompute(Y, V_ks, psd, weights, prior_cov): + """Template-independent part of Detector A (hoisted out of the + template loop). Returns ``(Vw, M, w_y)`` with ``Vw = V_k w / P`` + (K, nf), ``M`` the (K, K) response matrix of + :func:`_prior_response_matrix` and ``w_y[j] = _W``.""" + K = len(V_ks) + if K == 0: + raise ValueError("Detector A needs at least one basis vector " + "(K >= 1); use the plain matched filter for " + "K = 0") + Vk = np.asarray(V_ks).reshape(K, -1) + wp = np.asarray(weights, dtype=np.float64) / np.asarray(psd, np.float64) + Vw = Vk * wp + G = np.real(Vw @ np.conj(Vk).T) + G = 0.5 * (G + G.T) + M = _prior_response_matrix(G, prior_cov) + w_y = np.real(Vw @ np.conj(np.asarray(Y))) + return Vw, M, w_y + + +def _marginal_evaluate(Yw, wp, T, Vw, M, w_y, eps_floor=1e-12): + """Per-template part of Detector A: ``Yw = Y w / P`` and ``wp = w / P`` + are precomputed; ``T`` is the template transform.""" + T = np.asarray(T) + w_t = np.real(Vw @ np.conj(T)) + num = float(np.real(np.sum(Yw * np.conj(T)))) - float(w_y @ M @ w_t) + den = float(np.sum((np.abs(T) ** 2) * wp)) - float(w_t @ M @ w_t) + if den <= eps_floor: + return 0.0 + return float(num / np.sqrt(den)) + + +def _marginal_statistic(Y, T, V_ks, psd, weights, prior_cov, + eps_floor=1e-12): + """Taaki et al. (2020) Detector A (marginalized joint detector) in + the whitened frequency domain, via the Woodbury identity. + + The joint model is y = t + V c + s with c ~ N(mu_c, Cov_c) and s + stationary with PSD P(k); marginalizing c gives a matched filter + under the combined covariance Cov_z = Cov_s + V Cov_c V^T. With + W = Cov_s^{-1} applied diagonally in the frequency domain, + + _z = _W - w_a^T (Cov_c^{-1} + G)^{-1} w_b, + + where G_ij = _W and (w_a)_j = _W. The statistic is + T_A = _z / sqrt(_z) with y_hat = y - V mu_c + (the mean-systematics subtraction happens in the time domain before + the transform). The K basis transforms V_ks are computed once per + lightcurve; per template this adds only K-dimensional algebra. + + ``(Cov_c^{-1} + G)^{-1}`` is formed as ``Cov_c (I + G Cov_c)^{-1}`` + (see :func:`_prior_response_matrix`), so singular priors are handled + in the correct limit and non-PSD priors raise ``ValueError``. + + Parameters: Y, T = NFFTs of the (mean-subtracted) data and template; + V_ks = list/array of K basis NFFTs; prior_cov = Cov_c (K x K). + Returns the marginalized SNR (float). + """ + K = len(V_ks) + wp = np.asarray(weights, dtype=np.float64) / np.asarray(psd, np.float64) + Y = np.asarray(Y) + if K == 0: + return _matched_filter_statistic(Y * wp, wp, T) + Vw, M, w_y = _marginal_precompute(Y, V_ks, psd, weights, prior_cov) + return _marginal_evaluate(Y * wp, wp, T, Vw, M, w_y, eps_floor) + + +def _sequential_detrend(t, y, basis): + """The papers' "standard" baseline: ordinary least-squares cotrend + against the systematics basis (time domain, unwhitened -- as a + pipeline would), returning the residual for the stationary matched + filter. + + The fit includes an intercept: the basis columns and ``y`` are + centred before the least-squares solve and the centred basis is + subtracted, so the residual keeps the mean of ``y`` (removed later + by the demean in :meth:`NUFFTLRTAsyncProcess.run`) and a basis + column with a non-zero mean cannot absorb the mean flux. Without the + intercept a column with mean ``m_v`` and std ``s_v`` biases its + coefficient by ``ybar m_v / (m_v^2 + s_v^2)`` and leaves a + residual systematic of amplitude ``ybar m_v / s_v`` (a 1% column + mean on relative flux left 10x the noise; audit Sep 2026). + """ + V = np.asarray(basis, dtype=np.float64) + if V.ndim == 1: + V = V[:, None] + y = np.asarray(y, dtype=np.float64) + Vc = V - V.mean(axis=0) + coeff, *_ = np.linalg.lstsq(Vc, y - y.mean(), rcond=None) + return y - Vc @ coeff + + +def _smoothed_periodogram(power, window): + """Boxcar-smooth a periodogram with edge correction. + + Each output bin is the mean of the *available* neighbors inside the + window, so the first/last ``window//2`` bins are not biased low by + the implicit zero-padding of a plain ``np.convolve(..., 'same')`` + (which would overweight those bins by up to ~2x after the 1/P(k) + whitening). + + The window is clamped to ``len(power)``: ``np.convolve(..., 'same')`` + returns ``max(len(power), window)`` samples, so a window wider than + the spectrum used to lengthen the PSD and fail later with a raw + numpy broadcast error (``nf < smooth_window``, e.g. nf = 4 with the + default ``smooth_window=5``). + """ + k = min(int(window), len(power)) + if k <= 1: + return power + kernel = np.ones(k, dtype=power.dtype) + num = np.convolve(power, kernel, mode='same') + den = np.convolve(np.ones_like(power), kernel, mode='same') + return (num / den).astype(power.dtype, copy=False) + + +def _floor_psd(psd, eps_floor, real_type): + """Floor a PSD at ``eps_floor`` times its positive median (once, for + every detector path). This caps any single bin's whitening weight at + ``1/eps_floor`` times the typical weight: a zero bin in a user PSD + otherwise gives a statistic of ~1e6 (matched) or nan (marginal).""" + psd = np.asarray(psd, dtype=real_type) + pos = psd[psd > 0] + median_ps = np.median(pos) if pos.size else real_type(1.0) + return np.maximum(psd, real_type(eps_floor) * real_type(median_ps) + ).astype(real_type, copy=False) + + +def epoch_grid(period, duration, oversample=2.0, min_epochs=8, + max_epochs=96): + """Epoch grid used by :meth:`NUFFTLRTAsyncProcess.run` when + ``epochs=None``: ``n = clip(ceil(oversample * period / duration), + min_epochs, max_epochs)`` epochs at ``arange(n) * period / n`` + (relative to the epoch-subtracted time origin), so consecutive + templates are misaligned by at most ``duration / oversample`` until + the ``max_epochs`` cap is reached. + """ + n = int(np.ceil(float(oversample) * float(period) / float(duration))) + n = int(min(max(n, int(min_epochs)), int(max_epochs))) + return np.arange(n, dtype=np.float64) * float(period) / n + + +class NUFFTLRTMemory: + """ + Memory management for NUFFT LRT computations. + + Parameters + ---------- + nfft_memory : NFFTMemory + Memory for NUFFT computation + stream : pycuda.driver.Stream + CUDA stream for operations + use_double : bool, optional (default: False) + Use double precision + """ + + def __init__(self, nfft_memory, stream, use_double=False, **kwargs): + # Direct construction is a supported entry point (exported in + # __all__): retain the CUDA context before any GPU allocation, + # like every other *Memory class. + ensure_context() + self.nfft_memory = nfft_memory + self.stream = stream + self.use_double = use_double + + self.real_type = np.float64 if use_double else np.float32 + self.complex_type = np.complex128 if use_double else np.complex64 + + # Memory for LRT computation + self.template_g = None + self.power_spectrum_g = None + self.weights_g = None + self.results_g = None + self.results_c = None + + def allocate(self, nf, **kwargs): + """Allocate GPU memory for LRT computation.""" + self.nf = nf + + # Template NUFFT result + self.template_nufft_g = gpuarray.zeros(nf, dtype=self.complex_type) + + # Power spectrum estimate + self.power_spectrum_g = gpuarray.zeros(nf, dtype=self.real_type) + + # Per-mode weights (all ones: every mode k = 0..nf-1 is a distinct + # positive-frequency coefficient; see NUFFTLRTAsyncProcess.run) + self.weights_g = gpuarray.zeros(nf, dtype=self.real_type) + + # Results: [numerator, denominator] + self.results_g = gpuarray.zeros(2, dtype=self.real_type) + self.results_c = cuda.aligned_zeros(shape=(2,), + dtype=self.real_type, + alignment=4096) + + return self + + def transfer_results_to_cpu(self): + """Transfer LRT results from GPU to CPU.""" + cuda.memcpy_dtoh_async(self.results_c, self.results_g.ptr, + stream=self.stream) + + +class NUFFTLRTAsyncProcess(GPUAsyncProcess): + """ + GPU implementation of the NUFFT likelihood-ratio transit search. + + This implements a matched filter in the frequency domain: + + .. math:: + \\text{SNR} = \\frac{\\sum_k Y_k T_k^* w_k / P_s(k)} + {\\sqrt{\\sum_k |T_k|^2 w_k / P_s(k)}} + + where: + + - Y_k is the NUFFT of the lightcurve + - T_k is the NUFFT of the transit template + - P_s(k) is the power spectrum (adaptively estimated or provided) + - w_k are per-mode weights; they are all 1 (every returned mode + ``k = 0..nf-1`` is a distinct positive-frequency coefficient, so + the 1/2/1 weighting of a packed one-sided RFFT does not apply) + + .. warning:: **Experimental.** This module and the :meth:`run` + signature are outside the 1.x API-stability promise. The + Sep-2026 correctness fixes were re-validated by the + injection-recovery campaign of 2026-09-06 (the default path is + correct on BJD-scale times and recovers random-epoch transits; + ``benchmarks/results/nufft_lrt_validation_2026-09-06/``), but + that campaign also showed that the defaults a 1.x freeze would + lock in should still change -- the automatic epoch grid costs + 4-9 % of completeness against a finer one, PSD whitening gave + no gain over a flat PSD, and :meth:`run` returns a tuple or an + array depending on ``epochs`` -- so the API may change without + a deprecation cycle. Constructing this class emits a + ``UserWarning`` saying so. The class is importable as + ``cuvarbase.nufft_lrt.NUFFTLRTAsyncProcess`` only; it is not in + the top-level ``cuvarbase`` namespace. + + The value is a whitened correlation, not an N(0, 1) SNR: see the + module docstring for the PSD convention and the calibration caveat. + :meth:`run` selects between three detectors with ``detector=``: + ``'matched'`` (default, the formula above), ``'marginal'`` (Taaki + et al. Detector A, systematics marginalized under a Gaussian prior) + and ``'sequential'`` (least-squares cotrend, then the filter). + + Parameters + ---------- + sigma : float, optional (default: 4.0) + Oversampling factor of the NFFT grid (``sigma * nf`` grid + points). The transform returns the one-sided modes + ``k = 0..nf-1``, so the effective oversampling at the top of the + band is ``sigma / 2``; ``sigma = 4`` keeps every returned mode + inside the Gaussian window's accuracy band (full-band error + ~4e-4 in float32, ~1e-6 in float64 vs the exact adjoint DFT). + With ``sigma = 2`` (the pre-Sep-2026 default) the modes + ``k >= nf/2`` carried O(1) aliasing error. + m : int, optional (default: None) + NFFT truncation parameter. ``None`` means 8 when + ``autoset_m=False``; ignored when ``autoset_m=True``. + use_double : bool, optional (default: False) + Use double precision + use_fast_math : bool, optional (default: True) + Use fast math in CUDA kernels + block_size : int, optional (default: 256) + CUDA block size + autoset_m : bool, optional (default: True) + Choose ``m`` from the NFFT truncation-error bound (see + :meth:`cuvarbase.cunfft.NFFTAsyncProcess.estimate_m`); one + ``m`` per :meth:`run` sized for the largest transformed vector. + **kwargs : dict + Additional parameters passed to :class:`NFFTAsyncProcess`. + + Example + ------- + >>> import numpy as np + >>> from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess + >>> + >>> rng = np.random.RandomState(0) + >>> t = np.sort(rng.uniform(0, 60, 600)) # any time origin + >>> P, dur, t0 = 5.3, 0.22, 1.7 # injected transit + >>> phase = ((t - t0) / P) % 1.0 + >>> y = 1.0 - 0.01 * (np.minimum(phase, 1 - phase) < 0.5 * dur / P) + >>> y += 0.003 * rng.randn(len(t)) + >>> + >>> proc = NUFFTLRTAsyncProcess() + >>> # focused search around a candidate: the period step must keep + >>> # the box aligned over the baseline T, dP <~ dur * P / (2 T) + >>> periods = np.arange(4.8, 5.8, 0.22 * 4.8 / (2 * 60)) + >>> # periods x durations is a full outer product, so always pass a + >>> # short explicit duration array (the ``durations=None`` default + >>> # is 0.1 * periods, i.e. len(periods)**2 cells) + >>> durations = np.array([0.12, 0.25]) + >>> # epochs=None scans an automatic epoch grid per (period, + >>> # duration) and returns the max over epochs plus the best epoch + >>> snr, best_epoch = proc.run(t, y, periods, durations=durations) + >>> i, j = np.unravel_index(np.argmax(snr), snr.shape) + >>> periods[i], durations[j], best_epoch[i, j] # ~5.3, 0.25, ~1.7 (mod P) + """ + + def __init__(self, sigma=4.0, m=None, use_double=False, + use_fast_math=True, block_size=256, autoset_m=True, + **kwargs): + warnings.warn(_EXPERIMENTAL_MSG, UserWarning, stacklevel=2) + super(NUFFTLRTAsyncProcess, self).__init__(**kwargs) + + self.sigma = sigma + self.m = m + self.use_double = use_double + self.use_fast_math = use_fast_math + self.block_size = block_size + self.autoset_m = autoset_m + + self.real_type = np.float64 if use_double else np.float32 + self.complex_type = np.complex128 if use_double else np.complex64 + + # NUFFT processor for computing transforms + self.nufft_proc = NFFTAsyncProcess( + sigma=sigma, m=(8 if m is None else m), use_double=use_double, + use_fast_math=use_fast_math, block_size=block_size, + autoset_m=autoset_m, **kwargs + ) + + self.function_names = [ + 'nufft_matched_filter', + 'estimate_power_spectrum', + 'compute_frequency_weights', + 'demean_data', + 'compute_mean', + 'generate_transit_template' + ] + + # Module options + self.module_options = ['--use_fast_math'] if use_fast_math else [] + # Preprocessor defines for CUDA kernels + self._cpp_defs = {} + if use_double: + self._cpp_defs['DOUBLE_PRECISION'] = None + + def _compile_and_prepare_functions(self, **kwargs): + """Compile CUDA kernels and prepare function calls.""" + module_txt = _module_reader(find_kernel('nufft_lrt'), self._cpp_defs) + + self.module = SourceModule(module_txt, options=self.module_options) + + # Function signatures + self.dtypes = dict( + nufft_matched_filter=[np.intp, np.intp, np.intp, np.intp, + np.intp, np.int32, self.real_type], + estimate_power_spectrum=[np.intp, np.intp, np.int32, np.int32, + self.real_type], + compute_frequency_weights=[np.intp, np.int32, np.int32], + demean_data=[np.intp, np.int32, self.real_type], + compute_mean=[np.intp, np.intp, np.int32], + generate_transit_template=[np.intp, np.intp, np.int32, + self.real_type, self.real_type, + self.real_type, self.real_type] + ) + + # Prepare functions + self.prepared_functions = {} + for func_name in self.function_names: + func = self.module.get_function(func_name) + func.prepare(self.dtypes[func_name]) + self.prepared_functions[func_name] = func + + def _nfft_memory(self, t, nf, l1_max, **kwargs): + """Allocate ONE :class:`NFFTMemory` (device buffers, cuFFT plan, + pinned host buffer) for the epoch-subtracted times ``t`` and + ``nf`` modes, reused by :meth:`run` for the data, the basis + vectors and every template. The truncation radius ``m`` is + sized from ``l1_max``, an upper bound on the L1 norm of every + vector that will be transformed (the NFFT error bound scales + with ``||y||_1``; see :meth:`NFFTAsyncProcess.estimate_m`). + Allocating per transform cost 2.5-12 ms per template against + ~0.1 ms of transform (audit Sep 2026). + """ + proc = self.nufft_proc + if not proc.streams: + proc._create_streams(1) + m = proc.get_m(int(nf), y=np.array([float(l1_max)])) + t = np.ascontiguousarray(t, dtype=self.real_type) + mem = NFFTMemory(proc.sigma, proc.streams[0], m, + use_double=self.use_double, **kwargs) + return mem.fromdata(t, np.zeros(len(t), dtype=self.real_type), + nf=int(nf), allocate=True, **kwargs) + + def compute_nufft(self, t, y, nf, memory=None, **kwargs): + """ + Compute the adjoint NUFFT of data on the GPU. + + Parameters + ---------- + t : array-like + Time values (any origin; ``floor(min(t))`` is subtracted in + float64 before the cast to the device precision) + y : array-like + Observation values + nf : int + Number of frequency samples + memory : NFFTMemory, optional + A buffer set from :meth:`_nfft_memory` already holding + these times; only ``y`` is uploaded and the buffers are + reused (``t`` must be the array the memory was built from). + **kwargs : dict + Additional parameters for NUFFT + + Returns + ------- + nufft_result : np.ndarray, complex + ``ghat[k] = sum_j y_j exp(2 pi i f_k (t_j - t_ref))`` at the + modes ``f_k = k / (max(t) - min(t))``, ``k = 0..nf-1``, + with ``t_ref = floor(min(t))``. The transform's own time + reference is a common per-mode phase that cancels in every + ``Re sum A B* / P`` inner product of the detectors. Every + one of the nf modes is accurate to the Gaussian-window + bound with the default ``sigma = 4`` (~4e-4 relative in + float32, ~1e-6 in float64 against the exact adjoint DFT + over the FULL band); with ``sigma = 2`` the upper half band + ``k >= nf/2`` is aliased at O(1) -- it does not "cancel" + between data and template. + """ + # GPU adjoint NFFT of the (non-uniform) samples. Unlike a uniform- + # grid RFFT, the adjoint NFFT takes the raw times directly and + # normalizes by the true [min(t), max(t)] baseline, so it (a) runs + # on the device -- actually exercising the compiled kernels rather + # than computing on the host -- and (b) covers the full baseline + # with no ``median(dt)*nf`` span limit, so multi-season / gappy + # data is no longer silently truncated. ``ghat`` is returned at + # Fourier modes k = 0..nf-1, i.e. frequencies k/(max(t)-min(t)), + # with a common per-mode phase set by the transform's own time + # reference (the kernel references t=0, not min(t)); that phase + # cancels in every Re sum A B*/P inner product of the detectors. + # Every one of the nf modes must be accurate: the per-mode NFFT + # error does NOT cancel between data and template (it is the + # l = -1 aliasing term of the Gaussian window, different for each + # input), so the grid is oversampled with sigma = 4 (default), + # which keeps k = 0..nf-1 inside the window's accuracy band + # (~4e-4 relative in float32, ~1e-6 in float64 against the exact + # adjoint DFT over the full band; with sigma = 2 the modes + # k >= nf/2 were aliased at O(1), in double precision too). + if len(t) < 2: + return np.zeros(nf, dtype=self.complex_type) + y = np.ascontiguousarray(y, dtype=self.real_type) + if memory is not None: + memory.y = y + ghat = self.nufft_proc.run([(memory.t, y, int(nf))], + memory=[memory], **kwargs)[0] + # ghat_c is the memory's reused pinned buffer: copy it out + return np.array(ghat, dtype=self.complex_type) + # float64 epoch subtraction BEFORE the cast: float32 spacing at + # BJD ~ 2.457e6 is 0.25 d (wider than a transit), so gridding + # absolute times in float32 returned a different transform. + t64, _ = subtract_epoch(np.asarray(t, dtype=np.float64)) + t32 = np.ascontiguousarray(t64, dtype=self.real_type) + ghat = self.nufft_proc.run([(t32, y, int(nf))], **kwargs)[0] + return np.array(ghat, dtype=self.complex_type) + + def run(self, t, y, periods, durations=None, epochs=None, + depth=1.0, nf=None, estimate_psd=True, psd=None, + smooth_window=5, eps_floor=1e-3, + detector='matched', systematics_basis=None, + coeff_prior_mean=None, coeff_prior_cov=None, dy=None, + epoch_oversample=2.0, min_epochs=8, max_epochs=96, + **kwargs): + """ + Run NUFFT LRT for transit detection. + + Parameters + ---------- + t : array-like + Observation times, any origin (absolute BJD is fine): + ``floor(min(t))`` is subtracted in float64 before any cast + to the device precision. + y : array-like + Observation values (lightcurve) + periods : array-like + Trial periods to test (same units as ``t``) + durations : array-like, optional + Trial transit durations, searched as a full outer product + with ``periods``: every (period, duration) pair is + evaluated, not the elementwise pairing. + + ``None`` (the default) sets ``durations = 0.1 * periods``, + i.e. ``len(periods)`` durations, so the default call costs + ``len(periods)**2`` cells -- quadratic in the size of the + period grid, and with the automatic epoch grid + (``epochs=None``) up to ``max_epochs`` transforms per cell + (154 periods is already ~2.3 million templates at ~0.2 ms + each). **Pass an explicit, short duration array** (a + handful of physically motivated durations, or + ``0.1 * P`` for one representative ``P``) for anything but + a toy grid. + epochs : array-like, optional + Trial epochs (transit mid-times) in the caller's time scale. + ``None`` (default) scans an automatic epoch grid per + (period, duration) cell -- see :func:`epoch_grid`: + ``clip(ceil(epoch_oversample * P / duration), min_epochs, + max_epochs)`` epochs spaced ``P / n`` apart -- and reduces + by the maximum over epochs. Cost: that many NFFTs per + (period, duration) cell (~2P/duration transforms at the + default oversampling; 0.2-0.4 ms each on an A40). An + explicit array is used as given for every cell. + depth : float, optional (default: 1.0) + Transit depth of the template (the statistic is + normalized, so this only sets the template's scale) + nf : int, optional + Number of frequency samples for NUFFT. If None, uses 2 * len(t) + estimate_psd : bool, optional (default: True) + Estimate power spectrum from data. If False, must provide psd. + The estimate is the ``smooth_window``-bin boxcar-smoothed + periodogram ``|Y_k|^2`` of the demeaned data (for + ``detector='marginal'``: of the basis-projected residual + ``y - V c_ols``, since the mean-subtracted data ``y - V mu`` + still contain the realized systematics ``V (c - mu)``, whose + power the spectral window spreads over the whole band; the + PSD from ``y - V mu`` inflated the estimate ~36x and whitened + the transit away -- audit Sep 2026). + psd : array-like, optional + Pre-computed power spectrum of length ``nf`` in the + convention of the module docstring (``E|S_k|^2`` of the + noise's unnormalized adjoint NFFT; white noise: ``n sigma^2``). + Required if ``estimate_psd=False``. Floored at + ``eps_floor * median`` like the estimate. + smooth_window : int, optional (default: 5) + Window size (in frequency bins) for smoothing the power + spectrum estimate; clamped to ``nf`` when the grid is + shorter than the window. + eps_floor : float, optional (default: 1e-3) + The PSD (estimated or supplied) is floored at ``eps_floor`` + times its positive median once, for every detector, capping + any bin's whitening weight at ``1/eps_floor`` of typical. + detector : str, optional (default: 'matched') + Which detector of Taaki, Kamalabadi & Kemball (2020) to run: + + * ``'matched'`` -- the stationary PSD-whitened matched + filter (no systematics model). The pre-2026 behavior. + * ``'marginal'`` -- Detector A: the joint detector with the + Gaussian prior on systematics coefficients marginalized + in closed form (Woodbury, in the whitened frequency + domain). Requires ``systematics_basis`` and + ``coeff_prior_cov``. + * ``'sequential'`` -- the papers' "standard" baseline: + ordinary least-squares cotrend (with intercept) against + ``systematics_basis`` in the time domain, then the + stationary matched filter on the residual. + + The papers' Detector B (joint MAP plug-in over a depth + grid) is intentionally not implemented: the 2020 paper + found it comparable to Detector A ("exploratory"), and the + closed-form marginalization supersedes the plug-in. + systematics_basis : array-like (n, K), optional + K systematics basis vectors sampled at the observation + times (e.g. instrument cotrending vectors, or PCA modes of + a lightcurve population). Columns need not be zero-mean. + coeff_prior_mean : array-like (K,), optional + Prior mean of the systematics coefficients (default: zeros). + coeff_prior_cov : array-like (K, K), optional + Prior covariance of the coefficients (required for + ``detector='marginal'``; estimate it from population fits + as in the papers). Must be symmetric positive semidefinite; + a zero variance pins that mode to its prior mean (drop the + mode from the basis if that is not intended). The Gram + matrix that meets this prior is accumulated over ``nf`` + non-orthogonal NFFT modes and overcounts the corresponding + time-domain inner products by ~2.2-2.4x (audit Sep 2026), + so the prior acts as if it were about that much wider than + what you supply. + dy : array-like, optional + Not used by any detector (the noise model is the PSD); a + ``UserWarning`` is emitted if it is passed. + epoch_oversample, min_epochs, max_epochs : float, int, int + Automatic epoch grid parameters (``epochs=None`` only): + ``n = clip(ceil(epoch_oversample * P / duration), + min_epochs, max_epochs)``. Defaults 2.0, 8, 96 (the + validation harness's). At long periods the cap makes the + epoch step ``P / max_epochs`` exceed the duration; raise + ``max_epochs`` if those periods matter. + **kwargs : dict + Additional parameters passed to the NFFT. + + Returns + ------- + snr, best_epoch : tuple of ndarray + With ``epochs=None`` (the default), two float64 arrays of + shape ``(len(periods), len(durations))``. ``snr[i, j]`` is + the maximum of the statistic over the automatic epoch grid + of cell ``(periods[i], durations[j])`` and + ``best_epoch[i, j]`` the epoch (transit mid-time, in the + caller's time scale, within one period of ``floor(min(t))``) + that attains it. + snr : ndarray + With ``epochs`` given, one float64 array of shape + ``(len(periods), len(durations), len(epochs))`` with the + statistic at every template. In both cases the value is the + whitened correlation of the module docstring -- not N(0, 1); + calibrate thresholds empirically. + """ + # ---- validate and epoch-subtract (float64) before ANY cast + t = np.asarray(t, dtype=np.float64).ravel() + y = np.asarray(y, dtype=np.float64).ravel() + # Shared validator, so the message reads the same as every + # other entry point's. min_n = 3: the detrending and PSD + # estimate need more than a two-point series (Detector A's + # marginal statistic raises a broadcast error at N <= 2). + # ``dy`` is deliberately not passed: no detector uses it (the + # noise model is the PSD) and it is warned about below. + check_lightcurve(t, y, min_n=3, name='NUFFTLRTAsyncProcess.run') + if dy is not None: + warnings.warn("NUFFTLRTAsyncProcess.run: dy is not used by any " + "detector (the noise model is the PSD); it is " + "ignored", UserWarning, stacklevel=2) + t, t0 = subtract_epoch(t) + n = len(t) + + periods = np.atleast_1d(np.asarray(periods, dtype=np.float64)) + if periods.ndim != 1 or len(periods) == 0 or np.any(periods <= 0) \ + or not np.all(np.isfinite(periods)): + raise ValueError("periods must be a non-empty 1-D array of " + "positive finite values") + + if detector not in ('matched', 'marginal', 'sequential'): + raise ValueError("detector must be 'matched', 'marginal' or " + "'sequential' (got %r)" % (detector,)) + V = None + if detector in ('marginal', 'sequential'): + if systematics_basis is None: + raise ValueError("detector=%r requires systematics_basis" + % (detector,)) + V = np.atleast_2d(np.asarray(systematics_basis, + dtype=np.float64)) + if V.shape[0] != n: + V = V.T + if V.shape[0] != n: + raise ValueError("systematics_basis must be (n, K) with " + "n = len(t)") + if not np.all(np.isfinite(V)): + raise ValueError("systematics_basis must be finite") + if V.shape[1] == 0: + # an empty basis used to fall through to the plain + # matched filter for 'marginal'; since the Detector A + # precompute was hoisted out of the template loop it + # died in numpy (reshape of a size-0 array) after the + # data transforms had already run. Reject it here, + # before any device work: pass detector='matched'. + raise ValueError("systematics_basis must have at least " + "one column (K >= 1) for detector=%r; " + "use detector='matched' for no " + "systematics model" % (detector,)) + if detector == 'marginal' and coeff_prior_cov is None: + raise ValueError("detector='marginal' requires " + "coeff_prior_cov (estimate it from " + "population fits, as in Taaki et al. 2020)") + + # Durations: default to 10% of period if not provided + if durations is None: + durations = 0.1 * periods + durations = np.atleast_1d(np.asarray(durations, dtype=np.float64)) + if durations.ndim != 1 or len(durations) == 0 \ + or np.any(durations <= 0) \ + or not np.all(np.isfinite(durations)): + raise ValueError("durations must be a non-empty 1-D array of " + "positive finite values") + + # Epochs: None -> automatic per-cell grid (max over epochs, best + # epoch returned); explicit -> shifted into the epoch-subtracted + # frame and used for every cell (epoch axis in the output). + auto_epochs = epochs is None + if not auto_epochs: + epochs_arr = np.atleast_1d(np.asarray(epochs, dtype=np.float64)) + if epochs_arr.ndim != 1 or len(epochs_arr) == 0 \ + or not np.all(np.isfinite(epochs_arr)): + raise ValueError("epochs must be a non-empty 1-D finite " + "array (or None)") + epochs_arr = epochs_arr - t0 + + if nf is None: + nf = 2 * n + nf = int(nf) + if nf < 1: + raise ValueError("nf must be a positive integer") + + # NOTE: the matched-filter combination runs on the host (an O(nf) + # reduction, negligible next to the per-template NFFT), so the + # nufft_lrt.cu kernels are not compiled here. The only GPU work is + # the adjoint NFFT inside compute_nufft (compiled by nufft_proc). + + # ---- detector-specific data vector (float64 host algebra) + resid = None + if detector == 'sequential': + y_work = _sequential_detrend(t, y, V) + elif detector == 'marginal': + K = V.shape[1] + mu = (np.zeros(K) if coeff_prior_mean is None + else np.asarray(coeff_prior_mean, dtype=np.float64).ravel()) + if mu.shape != (K,): + raise ValueError("coeff_prior_mean must have length K = %d" + % K) + y_work = y - V @ mu + if estimate_psd: + # PSD source: the basis-projected residual, NOT y - V mu + # (which still holds V (c - mu); with gappy sampling the + # spectral window spreads that power over the whole band + # and the whitening then removes the transit too) + resid = _sequential_detrend(t, y, V) + resid = resid - resid.mean() + else: + y_work = y + y_demeaned = y_work - np.mean(y_work) + Vc = None + if detector == 'marginal': + Vc = V - V.mean(axis=0) + + # ---- one NFFT buffer set for everything transformed in this run + l1 = [float(np.sum(np.abs(y_demeaned))), float(n * abs(depth))] + if resid is not None: + l1.append(float(np.sum(np.abs(resid)))) + if Vc is not None: + l1.extend(float(np.sum(np.abs(Vc[:, j]))) + for j in range(Vc.shape[1])) + mem = self._nfft_memory(t, nf, max(l1), **kwargs) + + # Compute NUFFT of lightcurve + Y_nufft = self.compute_nufft(t, y_demeaned, nf, memory=mem, + **kwargs) + + # ---- power spectrum: estimated or supplied, floored ONCE here. + # The adjoint NFFT returns a physical Fourier coefficient at every + # one of the nf modes (no rfft-style zero-padded upper half), so + # the PSD spans all nf bins. + if estimate_psd: + if resid is not None: + src = self.compute_nufft(t, resid, nf, memory=mem, **kwargs) + else: + src = Y_nufft + psd = (np.abs(src) ** 2).astype(self.real_type, copy=False) + if smooth_window and smooth_window > 1: + psd = _smoothed_periodogram(psd, smooth_window) + else: + if psd is None: + raise ValueError("Must provide psd if estimate_psd=False") + psd = np.asarray(psd, dtype=np.float64).ravel() + if len(psd) != nf: + raise ValueError("psd must have length nf = %d (got %d); " + "see the module docstring for the PSD " + "convention" % (nf, len(psd))) + if not np.all(np.isfinite(psd)) or np.any(psd < 0): + raise ValueError("psd must be finite and non-negative") + psd = _floor_psd(psd, eps_floor, self.real_type) + + # Every NFFT mode is a physical positive-frequency coefficient, so + # all bins are weighted equally (the old rfft one-sided 1/2/1 + # weighting was tied to the now-removed uniform-grid RFFT packing). + weights = np.ones(nf, dtype=self.real_type) + wp = np.asarray(weights, dtype=np.float64) / np.asarray(psd, + np.float64) + Yw = np.asarray(Y_nufft) * wp + + # Detector A: transform the (demeaned) systematics basis once and + # hoist the template-independent algebra (G, M, w_y) out of the + # template loop; per template only K inner products remain. + if detector == 'marginal': + V_ks = [self.compute_nufft(t, Vc[:, j], nf, memory=mem, + **kwargs) + for j in range(Vc.shape[1])] + Vw, M, w_y = _marginal_precompute(Y_nufft, V_ks, psd, weights, + coeff_prior_cov) + + def _statistic(T_nufft): + return _marginal_evaluate(Yw, wp, T_nufft, Vw, M, w_y) + else: + def _statistic(T_nufft): + return _matched_filter_statistic(Yw, wp, T_nufft) + + def _template_statistic(period, epoch, duration): + template = self._generate_template(t, period, epoch, duration, + depth) + template = template - np.mean(template) + T_nufft = self.compute_nufft(t, template, nf, memory=mem, + **kwargs) + return _statistic(T_nufft) + + # ---- template loop + if auto_epochs: + snr_results = np.zeros((len(periods), len(durations))) + best_epochs = np.zeros((len(periods), len(durations))) + for i, period in enumerate(periods): + for j, duration in enumerate(durations): + grid = epoch_grid(period, duration, epoch_oversample, + min_epochs, max_epochs) + vals = np.array([_template_statistic(period, e, duration) + for e in grid]) + k = int(np.argmax(vals)) + snr_results[i, j] = vals[k] + best_epochs[i, j] = grid[k] + t0 + return snr_results, best_epochs + + snr_results = np.zeros((len(periods), len(durations), + len(epochs_arr))) + for i, period in enumerate(periods): + for j, duration in enumerate(durations): + for k, epoch in enumerate(epochs_arr): + snr_results[i, j, k] = _template_statistic( + period, epoch, duration) + return snr_results + + def _generate_template(self, t, period, epoch, duration, depth): + """ + Generate simple box transit template. + + Parameters + ---------- + t : array-like + Time values + period : float + Orbital period + epoch : float + Transit mid-time, in the same frame as ``t`` + duration : float + Transit duration + depth : float + Transit depth + + Returns + ------- + template : np.ndarray + Transit template (``-depth`` in transit, 0 elsewhere) + """ + t = np.asarray(t, dtype=np.float64) + # Phase fold + phase = np.fmod(t - epoch, period) / period + phase[phase < 0] += 1.0 + + # Center phase around 0.5 + phase[phase > 0.5] -= 1.0 + + # Generate box template + template = np.zeros_like(t) + phase_width = duration / (2.0 * period) + in_transit = np.abs(phase) <= phase_width + template[in_transit] = -depth + + return template + + def _compute_matched_filter_snr(self, Y, T, P_s, weights, eps_floor): + """ + Matched-filter statistic of one template from the raw transforms. + + Reference wrapper: floor the PSD (:func:`_floor_psd`), form the + float64 whitening weights and evaluate + :func:`_matched_filter_statistic` -- the same three steps + :meth:`run` performs (the first two once per run, the last per + template). :meth:`run` does not call this method; it is the + single-template entry point the tests use, and shares + ``run``'s helpers so that it cannot drift from the shipped + arithmetic. + + Parameters + ---------- + Y : np.ndarray + NUFFT of lightcurve + T : np.ndarray + NUFFT of template + P_s : np.ndarray + Power spectrum + weights : np.ndarray + Per-mode weights (``run`` passes all ones) + eps_floor : float + Floor for power spectrum + + Returns + ------- + snr : float + Signal-to-noise ratio + """ + # Exactly run()'s sequence: PSD floored in the device precision, + # whitening weights and the reduction in float64. + psd = _floor_psd(P_s, eps_floor, self.real_type) + wp = np.asarray(weights, dtype=np.float64) / np.asarray(psd, + np.float64) + Yw = np.asarray(Y) * wp + return _matched_filter_statistic(Yw, wp, T) diff --git a/cuvarbase/pdm.py b/cuvarbase/pdm.py index 0aaa8db5..2cbac1b5 100644 --- a/cuvarbase/pdm.py +++ b/cuvarbase/pdm.py @@ -1,21 +1,100 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from builtins import zip -from builtins import range - import numpy as np -import resource import warnings +from typing import Literal import pycuda.driver as cuda import pycuda.gpuarray as gpuarray from pycuda.compiler import SourceModule -# import pycuda.autoinit -from .core import GPUAsyncProcess -from .utils import weights, find_kernel, dphase, normalize_light_curves +from .base import GPUAsyncProcess +from .memory._host import host_array +from .utils import weights, find_kernel, dphase, normalize_light_curves, autofrequency +from .utils import check_lightcurve, check_freqs + + +__all__ = [ + 'var_tophat', + 'var_gauss', + 'binned_pdm_model', + 'var_binned', + 'binless_pdm_cpu', + 'pdm2_cpu', + 'pdm2_single_freq', + 'pdm_async', + 'PDMAsyncProcess', +] + + +# Minimum number of observations the PDM entry points accept. The +# statistic is 1 - sum(w (y - model)^2) / sum(w (y - ybar)^2); the +# denominator is identically zero for a single point, and the whole +# spectrum came back NaN with no warning. +_PDM_MIN_NDATA = 2 + + +def _check_pdm_data(data, freqs, where, is_deprecated): + """Validate a PDM batch before any GPU work. + + Two input formats: the current ``(t, y, err)`` (validated with + :func:`cuvarbase.utils.check_lightcurve`) and the deprecated + ``(t, y, w, freqs)``, whose third column is a weight rather than an + uncertainty -- it must still be finite and strictly positive, and + its own frequency grid is validated per light curve. A NaN sample, + ``dy = 0`` or a negative weight used to give an all-NaN spectrum + with no warning at all (Sep 2026 audit, defect 23), and so did a + constant ``y`` (audit id 115): the statistic divides by the + variance of ``y``, which is then zero. + """ + def _check_not_constant(y, name): + if np.all(y == y[0]): + raise ValueError( + "%s: y is constant (all %d values equal %r); the PDM " + "statistic divides by the variance of y, which is zero " + "(the spectrum was all NaN). Remove constant lightcurves " + "before searching" % (name, y.size, y[0])) + + for i, lc in enumerate(data): + name = '%s lightcurve %d' % (where, i) + # exactly (t, y, err) -- or (t, y, w, freqs) for the deprecated + # format, which is detected from the FIRST lightcurve: run() + # unpacks the tuples downstream, so a 2-tuple died there with a + # raw "not enough values to unpack" instead of this message + if is_deprecated: + if len(lc) != 4: + raise ValueError( + "%s: must be a (t, y, w, freqs) tuple like the first " + "lightcurve (deprecated format); got %d elements" + % (name, len(lc))) + t, y, w, frqs = lc + _t, y, _dy = check_lightcurve(t, y, min_n=_PDM_MIN_NDATA, + name=name) + _check_not_constant(y, name) + w = np.asarray(w) + if w.shape != np.asarray(t).shape: + raise ValueError("%s: t and w must have the same length; " + "got %d and %d" + % (name, len(t), w.size)) + if not np.all(np.isfinite(w)) or not np.all(w > 0): + raise ValueError( + "%s: w must be finite and > 0 (weights of any scale; " + "they are normalized to sum to one internally)" % name) + check_freqs(frqs, name=name) + else: + if len(lc) != 3: + raise ValueError( + "%s: must be a (t, y, err) tuple; got %d elements " + "(the deprecated (t, y, w, freqs) format is accepted " + "only when every lightcurve, the first included, " + "uses it)" % (name, len(lc))) + _t, y, _dy = check_lightcurve(lc[0], lc[1], lc[2], + min_n=_PDM_MIN_NDATA, name=name) + _check_not_constant(y, name) + if not is_deprecated and freqs is not None: + # ``freqs`` is either one shared grid or one per light curve + # (the same test run() makes) + grids = freqs if len(freqs) and np.ndim(freqs[0]) else [freqs] + for frq in grids: + check_freqs(frq, name=where) def var_tophat(t, y, w, freq, dphi): @@ -33,8 +112,9 @@ def var_tophat(t, y, w, freq, dphi): return var + def var_gauss(t, y, w, freq, dphi): - gaussian = lambda x: np.exp(-0.5 *x**2) + def gaussian(x): return np.exp(-0.5 * x**2) var = 0. for i, (T, Y, W) in enumerate(zip(t, y, w)): mbar = 0. @@ -42,7 +122,7 @@ def var_gauss(t, y, w, freq, dphi): for j, (T2, Y2, W2) in enumerate(zip(t, y, w)): dph = dphase(abs(T2 - T), freq) - wgt = W2 * gaussian(dph / dphi) + wgt = W2 * gaussian(dph / dphi) mbar += wgt * Y2 wtot += wgt @@ -50,6 +130,7 @@ def var_gauss(t, y, w, freq, dphi): return var + def binned_pdm_model(t, y, w, freq, nbins, linterp=True): if len(t) == 0: @@ -88,9 +169,9 @@ def var_binned(t, y, w, freq, nbins, linterp=True): def binless_pdm_cpu(t, y, w, freqs, dphi=0.05, tophat=True): - # Prepare data - t -= np.mean(t) - y -= np.mean(y) + # Prepare data (copies: don't mutate the caller's arrays) + t = t - np.mean(t) + y = y - np.mean(y) ybar = np.dot(w, y) var = np.dot(w, np.power(y - ybar, 2)) @@ -99,10 +180,11 @@ def binless_pdm_cpu(t, y, w, freqs, dphi=0.05, tophat=True): else: return [1 - var_gauss(t, y, w, freq, dphi) / var for freq in freqs] + def pdm2_cpu(t, y, w, freqs, nbins=30, linterp=True): - # Prepare data - t -= np.mean(t) - y -= np.mean(y) + # Prepare data (copies: don't mutate the caller's arrays) + t = t - np.mean(t) + y = y - np.mean(y) ybar = np.dot(w, y) var = np.dot(w, np.power(y - ybar, 2)) @@ -112,9 +194,9 @@ def pdm2_cpu(t, y, w, freqs, nbins=30, linterp=True): def pdm2_single_freq(t, y, w, freq, nbins=30, linterp=True): - # Prepare data - t -= np.mean(t) - y -= np.mean(y) + # Prepare data (copies: don't mutate the caller's arrays) + t = t - np.mean(t) + y = y - np.mean(y) ybar = np.dot(w, y) var = np.dot(w, np.power(y - ybar, 2)) @@ -122,7 +204,15 @@ def pdm2_single_freq(t, y, w, freq, nbins=30, linterp=True): def pdm_async(stream, data_cpu, data_gpu, pow_cpu, function, - dphi=0.05, block_size=256): + dphi=0.05, block_size=256, **kwargs): + # The *_fast kernels statically allocate shared-memory tiles of + # MAX_BLOCK_SIZE (= 256) floats; a larger launch would write past them. + if not (0 < block_size <= 256): + raise ValueError("block_size must be in (0, 256] " + "(the PDM kernels' shared-memory tiles are " + "sized for at most 256 threads per block); " + "got %r" % (block_size,)) + t, y, w, freqs = data_cpu t_g, y_g, w_g, freqs_g, pow_g = data_gpu @@ -139,14 +229,17 @@ def pdm_async(stream, data_cpu, data_gpu, pow_cpu, function, grid = (grid_size, 1) block = (block_size, 1, 1) - # weights + weighted variance + # weighted mean + weighted variance ybar = np.dot(w, y) var = np.float32(np.dot(w, np.power(y - ybar, 2))) # transfer data w_g.set_async(np.asarray(w).astype(np.float32), stream=stream) t_g.set_async(np.asarray(t).astype(np.float32), stream=stream) - y_g.set_async(np.asarray(y).astype(np.float32), stream=stream) + + # Ensure y is zero-weighted-meaned for fast kernels (one-pass SS_between) + y_norm = (np.asarray(y) - ybar).astype(np.float32) + y_g.set_async(y_norm, stream=stream) function.prepared_async_call(grid, block, stream, t_g.ptr, y_g.ptr, w_g.ptr, @@ -159,42 +252,97 @@ def pdm_async(stream, data_cpu, data_gpu, pow_cpu, function, class PDMAsyncProcess(GPUAsyncProcess): + """ + GPUAsyncProcess for the Phase Dispersion Minimization (PDM) period finder. + + Example + ------- + >>> proc = PDMAsyncProcess() + >>> Ndata = 1000 + >>> t = np.sort(365 * np.random.rand(Ndata)) + >>> y = 12 + 0.01 * np.cos(2 * np.pi * t / 5.0) + >>> y += 0.01 * np.random.randn(len(t)) + >>> dy = 0.01 * np.ones_like(y) + >>> results = proc.run([(t, y, dy)]) + >>> proc.finish() + >>> pdm_freqs, pdm_powers = results[0] + """ def __init__(self, *args, **kwargs): super(PDMAsyncProcess, self).__init__(*args, **kwargs) + # Device buffers kept from the last run() with allocation of its + # own, reused by the next call that asks for the same shapes. + # See _allocate_cached. + self._alloc_cache = None def _compile_and_prepare_functions(self, nbins=10): - pdm2_txt = open(find_kernel('pdm'), 'r').read() + with open(find_kernel('pdm'), 'r') as f: + pdm2_txt = f.read() pdm2_txt = pdm2_txt.replace('//INSERT_NBINS_HERE', - '#define NBINS %d' % (nbins)) + '#define NBINS %d' % nbins) self.module = SourceModule(pdm2_txt, options=['--use_fast_math']) self.dtypes = [np.intp, np.intp, np.intp, np.intp, np.intp, np.int32, np.int32, np.float32, np.float32] for function in ['pdm_binless_tophat', 'pdm_binless_gauss', - 'pdm_binned_linterp_%dbins' % (nbins), - 'pdm_binned_step_%dbins' % (nbins)]: - func = function.replace('_%dbins' % (nbins), '') + 'pdm_binned_linterp_%dbins' % nbins, + 'pdm_binned_step_%dbins' % nbins, + 'pdm_binned_linterp_fast_%dbins' % nbins, + 'pdm_binned_step_fast_%dbins' % nbins, + 'pdm_binless_tophat_fast', + 'pdm_binless_gauss_fast']: + func = function.replace('_%dbins' % nbins, '') func = self.module.get_function(func).prepare(self.dtypes) self.prepared_functions[function] = func - def allocate(self, data): + def allocate(self, data, freqs=None, **kwargs): + """ + Allocate GPU memory for PDM computations. + + Parameters + ---------- + data: list of tuples + List of [(t, y, err), ...] or [(t, y, w, freqs), ...] (deprecated) + freqs: list or np.ndarray, optional + Frequency grid(s) to search. + + Returns + ------- + gpu_data: list + List of GPU arrays. + pow_cpus: list + List of CPU arrays for results. + """ if len(data) > len(self.streams): self._create_streams(len(data) - len(self.streams)) gpu_data, pow_cpus = [], [] - for t, y, w, freqs in data: + is_deprecated = len(data) > 0 and len(data[0]) == 4 + + plot_data = [] + if is_deprecated: + plot_data = data + else: + frqs = freqs + if frqs is None: + frqs = [autofrequency(d[0], **kwargs) for d in data] + elif isinstance(frqs[0], (float, np.floating)): + frqs = [frqs] * len(data) + + for i, (t, y, err) in enumerate(data): + # We only need lengths for allocation + plot_data.append((t, y, None, frqs[i])) + + for t, y, w, freqs in plot_data: - pow_cpu = cuda.aligned_zeros(shape=(len(freqs),), - dtype=np.float32, - alignment=resource.getpagesize()) + pow_cpu = host_array((len(freqs),), np.float32) t_g, y_g, w_g = None, None, None if len(t) > 0: t_g, y_g, w_g = tuple([gpuarray.zeros(len(t), dtype=np.float32) - for i in range(3)]) + for _ in range(3)]) pow_g = gpuarray.zeros(len(pow_cpu), dtype=pow_cpu.dtype) freqs_g = gpuarray.to_gpu(np.asarray(freqs).astype(np.float32)) @@ -203,29 +351,315 @@ def allocate(self, data): pow_cpus.append(pow_cpu) return gpu_data, pow_cpus - def run(self, data, gpu_data=None, pow_cpus=None, - kind='binned_linterp', nbins=10, dphi=0.05, **pdm_kwargs): + def _allocate_cached(self, norm_data, frqs, **kwargs): + """:meth:`allocate`, with the *device* buffers reused between + calls of the same shape. + + ``run()`` allocated and zero-filled five device arrays plus a + page-locked host buffer per lightcurve on every call, and + uploaded the frequency grid synchronously with + ``gpuarray.to_gpu``. For short lightcurves and modest grids + that is most of the wall time (Sep 2026 audit, id 161), and it + repeats for every chunk of :meth:`batched_run_const_nfreq` / + :meth:`large_run`, which always ask for the same shapes. + + The device buffers depend only on ``(len(t), len(freqs))`` per + lightcurve, so the last set is kept and reused whenever the + shape signature matches; the frequency grid is re-uploaded only + when it actually changed. The *result* buffers are always + freshly allocated, so arrays returned by an earlier ``run()`` + are never overwritten by a later one. + + Peak device memory is unchanged for repeated calls of the same + shape (the same buffers, reused rather than freed and + reallocated). A call with different shapes drops the cached set + *before* allocating the new one, so the two sets are never held + at once. + """ + sig = tuple((len(t), len(f)) for (t, y, w, f) in norm_data) + cache = self._alloc_cache + + if cache is None or cache[0] != sig: + # release the previous buffers BEFORE allocating the new + # ones, so a shape change never transiently holds both + # sets (the short final chunk of batched_run_const_nfreq / + # large_run is exactly that case) + self._alloc_cache = None + del cache + gpu_data, pow_cpus = self.allocate(norm_data, freqs=frqs, + **kwargs) + # a private copy: ``np.asarray`` returns the caller's own + # array for a float32 grid, and the change detection below + # then compared the caller's grid with itself -- a grid + # modified in place between two same-shape calls was never + # re-uploaded (the powers came back labelled with the new + # grid but computed on the old one) + grids = [np.array(f, dtype=np.float32, copy=True) + for (t, y, w, f) in norm_data] + self._alloc_cache = (sig, gpu_data, grids) + return gpu_data, pow_cpus + + _sig, gpu_data, grids = cache + for i, (t, y, w, f) in enumerate(norm_data): + f32 = np.asarray(f).astype(np.float32) + if not np.array_equal(f32, grids[i]): + # synchronous, exactly as gpuarray.to_gpu was + gpu_data[i][3].set(f32) + grids[i] = f32 + pow_cpus = [host_array((len(f),), np.float32) + for (t, y, w, f) in norm_data] + return gpu_data, pow_cpus - if kind in ['binless_tophat', 'binless_gauss']: - function = 'pdm_%s' % (kind) - elif kind in ['binned_linterp','binned_step']: + def run(self, data, gpu_data=None, pow_cpus=None, freqs=None, + kind: Literal['binless_tophat', 'binless_gauss', + 'binless_tophat_fast', 'binless_gauss_fast', + 'binned_linterp', 'binned_step', + 'binned_linterp_fast', 'binned_step_fast'] = 'binned_linterp', + nbins=10, dphi=0.05, **pdm_kwargs): + """ + Run PDM on a batch of data. + + Parameters + ---------- + data: list of tuples + list of [(t, y, err), ...] containing + * ``t``: observation times + * ``y``: observations + * ``err``: observation uncertainties + Alternatively, [(t, y, w, freqs), ...] for backward compatibility + (deprecated). ``w`` are observation weights of any scale (they + are normalized to sum to one internally); like ``err`` they + must be finite and strictly positive -- a zero weight (used + before 1.0 to mask a point) is rejected, so drop masked + points from the arrays instead. + gpu_data: list, optional + list of GPU arrays from ``allocate`` + pow_cpus: list, optional + list of CPU arrays from ``allocate`` + freqs: list or np.ndarray, optional + Frequency grid(s) to search. + kind: str, optional (default: 'binned_linterp') + PDM variant to use. Available options: + * 'binless_tophat' + * 'binless_gauss' + * 'binless_tophat_fast' + * 'binless_gauss_fast' + * 'binned_linterp' + * 'binned_step' + * 'binned_linterp_fast' + * 'binned_step_fast' + nbins: int, optional (default: 10) + Number of bins for binned PDM. + dphi: float, optional (default: 0.05) + Kernel width of the binless kinds, in units of phase (cycles): + the **half-width** of the tophat window for + ``binless_tophat[_fast]`` (points with phase distance + ``< dphi`` enter the local mean) and the **standard deviation** + of the Gaussian weight for ``binless_gauss[_fast]``. Ignored by + the binned kinds. + **pdm_kwargs: + Extra arguments passed to ``autofrequency`` (when ``freqs`` + is not given) and to ``pdm_async`` (e.g. ``block_size``, + which must be <= 256). + + Returns + ------- + results: list + If depracated format is used: list of power arrays. + If new format is used: list of (freqs, power) tuples. + The power arrays are page-locked host buffers filled + asynchronously: call :meth:`finish` before reading them + (or use :meth:`batched_run_const_nfreq` / :meth:`large_run`, + which synchronize for you). + + Notes + ----- + The returned power is the weighted sum-of-squares ratio + ``1 - sum(w * (y - model)**2) / sum(w * (y - ybar)**2)`` with + ``w`` normalized to sum to one and ``model`` the folded-lightcurve + model of the chosen ``kind`` at each observation's phase. It has + **no degrees-of-freedom correction**, so it is not Stellingwerf's + ``1 - Theta``: for pure noise its expectation is + ``(M - 1) / (N - 1)`` (``M`` occupied bins, ``N`` observations; + ~0.4 for 20 points in 10 bins) rather than 0, and values are only + comparable between runs with the same ``nbins`` / ``dphi`` and + ``N``. See ``docs/source/pdm.rst``. + """ + + if kind in ['binless_tophat', 'binless_gauss', + 'binless_tophat_fast', 'binless_gauss_fast']: + function = 'pdm_%s' % kind + elif kind in ['binned_linterp', 'binned_step', + 'binned_linterp_fast', 'binned_step_fast']: function = 'pdm_%s_%dbins' % (kind, nbins) else: - raise KeyError('Function not available. Please use one of the followings: ' + \ - 'binless_tophat, binless_gauss, binned_linterp, binned_step') + raise KeyError('Function not available. Please use one of the followings: ' + 'binless_tophat, binless_gauss, ' + 'binless_tophat_fast, binless_gauss_fast, ' + 'binned_linterp, binned_step, ' + 'binned_linterp_fast, binned_step_fast') + + # Backward compatibility check (before kernel compilation, so the + # warning is emitted even if compilation fails / no GPU is present) + is_deprecated = len(data) > 0 and len(data[0]) == 4 + if is_deprecated: + warnings.warn("The (t, y, w, freqs) format is deprecated " + "and will be removed in 2.0. Note that its " + "third element is the NORMALIZED WEIGHTS " + "(cuvarbase.utils.weights(err), summing to 1), " + "not the uncertainties. Please use the " + "(t, y, err) format with the uncertainties " + "and pass freqs as a separate argument " + "or pass optional keyword arguments " + "passed to ``autofrequency``.", + DeprecationWarning, stacklevel=2) + + _check_pdm_data(data, freqs, 'PDMAsyncProcess.run', is_deprecated) if function not in self.prepared_functions: self._compile_and_prepare_functions(nbins=nbins) - # Prepare data - data = normalize_light_curves(data) + # Prepare data and determine frequencies + if is_deprecated: + norm_data = normalize_light_curves(data) + # The host-side weighted mean/variance and the kernels assume + # sum(w) == 1; the statistic is invariant to the scale of w, + # so normalize whatever the caller supplied (raw 1/err^2 or + # all-ones weights used to give a flat spectrum of 1.0). + norm_data = [(t, y, np.asarray(w, dtype=np.float64) / np.sum(w), f) + for (t, y, w, f) in norm_data] + frqs = [d[3] for d in data] + else: + frqs = freqs + if frqs is None: + frqs = [autofrequency(d[0], **pdm_kwargs) for d in data] + elif isinstance(frqs[0], (float, np.floating)): + frqs = [frqs] * len(data) + + # Normalize t and y + norm_data_temp = normalize_light_curves(data) + norm_data = [] + for i, (t, y, err) in enumerate(norm_data_temp): + w = weights(err) + norm_data.append((t, y, w, frqs[i])) if pow_cpus is None or gpu_data is None: - gpu_data, pow_cpus = self.allocate(data) + gpu_data, pow_cpus = self._allocate_cached(norm_data, frqs, + **pdm_kwargs) + streams = [s for i, s in enumerate(self.streams) if i < len(data)] func = self.prepared_functions[function] + results = [pdm_async(stream, cdat, gdat, pcpu, func, dphi=dphi, **pdm_kwargs) for stream, cdat, gdat, pcpu in - zip(streams, data, gpu_data, pow_cpus)] - + zip(streams, norm_data, gpu_data, pow_cpus)] + + if is_deprecated: + return results + return list(zip(frqs, results)) + + @staticmethod + def _bytes_per_lc(max_ndata, nf): + """Approximate GPU bytes for one lightcurve's PDM buffers. + + t_g, y_g, w_g (``max_ndata`` float32 each) plus freqs_g and pow_g + (``nf`` float32 each). + """ + return (3 * int(max_ndata) + 2 * int(nf)) * 4 + + # run() creates one CUDA stream and one page-locked host buffer per + # lightcurve in the chunk, so device-buffer arithmetic alone would + # let a huge free-memory pod pick a batch size in the millions -- + # exhausting driver stream/pinned-allocation resources long before + # GPU memory runs out. + MAX_BATCH_SIZE = 256 + + def _batch_size_from_memory(self, max_ndata, nf, n_lcs, max_memory=None): + """Largest batch (number of lightcurves held on the GPU at once) + that fits in ``max_memory`` bytes; capped at ``n_lcs``, + ``MAX_BATCH_SIZE`` and >= 1. + + ``max_memory`` defaults to 90% of the device's free memory. + """ + if max_memory is None: + free, _total = cuda.mem_get_info() + max_memory = int(0.9 * free) + per_lc = self._bytes_per_lc(max_ndata, nf) + batch_size = max(1, int(max_memory // per_lc)) + return min(batch_size, int(n_lcs), self.MAX_BATCH_SIZE) + + def batched_run_const_nfreq(self, data, batch_size=10, freqs=None, + **kwargs): + """Run PDM on many lightcurves that share one frequency grid. + + Processes ``data`` in chunks of ``batch_size`` lightcurves, + synchronizing after each chunk and reusing its GPU buffers for + the next one (so peak GPU memory scales with ``batch_size``, not + ``len(data)``), and resolves the shared frequency grid once. + Results match per-lightcurve :meth:`run`. + + Parameters + ---------- + data : list of (t, y, err) + batch_size : int, optional (default: 10) + Lightcurves resident on the GPU per chunk. + freqs : array_like, optional + Shared frequency grid. If None, it is derived once from the + longest-baseline lightcurve via ``autofrequency`` and reused. + **kwargs : + Passed to :meth:`run` (e.g. ``kind``, ``nbins``, ``dphi``, + ``block_size``). + + Returns + ------- + list of (freqs, power) + """ + batch_size = int(batch_size) + if batch_size < 1: + raise ValueError("batch_size must be >= 1; got %d" % batch_size) + for i, d in enumerate(data): + if len(d) != 3: + raise ValueError( + "batched_run_const_nfreq lightcurve %d: must be a " + "(t, y, err) tuple; got %d elements (the deprecated " + "(t, y, w, freqs) run() format is not supported here)" + % (i, len(d))) + if len(data) == 0: + return [] + _check_pdm_data(data, freqs, 'batched_run_const_nfreq', False) + if freqs is None: + dmax = max(data, key=lambda d: np.max(d[0]) - np.min(d[0])) + freqs = autofrequency(dmax[0], **kwargs) + freqs = np.asarray(freqs).astype(np.float32) + + results = [] + for start in range(0, len(data), int(batch_size)): + chunk = data[start:start + int(batch_size)] + chunk_res = self.run(chunk, freqs=freqs, **kwargs) + self.finish() + for _f, p in chunk_res: + results.append((freqs, np.copy(p))) return results + + def large_run(self, data, freqs=None, max_memory=None, **kwargs): + """Memory-capped batched PDM for lightcurve collections too large + to fit on the GPU at once. + + Picks ``batch_size`` so that no more than ``max_memory`` bytes + (default: 90% of free GPU memory) of lightcurve buffers are + resident at a time, then defers to :meth:`batched_run_const_nfreq`. + Results match per-lightcurve :meth:`run`. + """ + if len(data) == 0: + return [] + _check_pdm_data(data, freqs, 'large_run', False) + if freqs is None: + dmax = max(data, key=lambda d: np.max(d[0]) - np.min(d[0])) + freqs = autofrequency(dmax[0], **kwargs) + freqs = np.asarray(freqs).astype(np.float32) + + max_ndata = max(len(d[0]) for d in data) + batch_size = self._batch_size_from_memory( + max_ndata, len(freqs), len(data), max_memory=max_memory) + return self.batched_run_const_nfreq( + data, batch_size=batch_size, freqs=freqs, **kwargs) diff --git a/cuvarbase/tests/_tls_reference_goldens.py b/cuvarbase/tests/_tls_reference_goldens.py new file mode 100644 index 00000000..93c4ba4f --- /dev/null +++ b/cuvarbase/tests/_tls_reference_goldens.py @@ -0,0 +1,255 @@ +"""Compact host outputs independently evaluated from frozen GTLS sources. + +No cuvarbase function was used to generate these values. Candidate selection +and final parameter cases execute only the original CPU statements after a +specified winning window; these fixtures do not claim GPU or recovery parity. +Floating comparisons allow platform math rounding; integer selections are exact. +""" + +PROVENANCE = {'gtls_commit': '74e449c325792a763dde4fbffab98039c5e8c111', + 'python': '3.9.6', + 'numpy': '1.26.4', + 'batman_package': '2.5.3', + 'batman_module_version': '2.5.1', + 'source_sha256': {'grid.py': 'b3f2f6b601f4bdd2b103d53f8ac8b6d38f3ac2ef8f4d4793984e155db750d7a5', + 'transit.py': '2f7c4d2c61c132e41089c8f5667f7e0640c15443d523d4a3b935f56d5c182a08', + 'validate.py': 'f442df670eebf613e50a671f2a20022e17798ee5c60b06f23fe385a2db9ce38c', + 'stats.py': '7b6eac3090282c294980e931ef48cfa1a84db9ffbdf27cafdebd8e18f923112a', + 'helpers.py': '4bdb67f63e17d9cae60378a130543bb9411215c719ab58f180f5ce83bb8fcfbe', + 'core.py': 'dc5ad7b322ef9cd475250c95c224e5fced0c4f42893c02b8383ed83c2522e92d'}, + 'generator_sha256': 'c202fcb8193725a187cb169427f2792ccfb0778ff70425c3e596900bffda3444'} + +GOLDEN = {'periods': [{'args': [25.75, 1.0, 1.0, 0.0, None], + 'count': 2324, + 'index': [0, 1, 581, 1162, 2322, 2323], + 'values': [12.874999999999991, 12.845507496615019, 4.273352049017529, + 1.911250437176361, 0.6020468797576393, 0.6015495470325805]}, + {'args': [100.0, 2.0, 1.5, 2.0, 35.0], + 'count': 3324, + 'index': [0, 1, 831, 1662, 3322, 3323], + 'values': [34.9724324853951, 34.922116383244756, 12.774799864480256, + 6.018433490341261, 2.002023942006803, 2.000913179942145]}, + {'args': [400.0, 0.3, 0.3, 1.0, 100.0], + 'count': 83350, + 'index': [0, 1, 20837, 41675, 83348, 83349], + 'values': [99.9975437316115, 99.98443808487103, 14.342751433757623, 4.455270032532948, + 1.0000291308374623, 1.0000008916202212]}], + 'caches': {'default': {'widths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, + 28, 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, 119, + 120], + 'unique_indices': [0, 1, 3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37], + 'lengths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, + 28, 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, + 119, 120], + 'overshoot': [2.998317241668701, 1.88797926902771, 1.7522733211517334, + 1.5624617338180542, 1.5067601203918457, 1.4316469430923462, + 1.4029959440231323, 1.363302230834961, 1.346086025238037, + 1.3217006921768188, 1.3103028535842896, 1.2938719987869263, + 1.2739835977554321, 1.2681009769439697, 1.2546792030334473, + 1.2441208362579346, 1.2358213663101196, 1.225317120552063, + 1.2202656269073486, 1.2146034240722656, 1.2111891508102417, + 1.2074686288833618, 1.2051684856414795, 1.2021347284317017, + 1.1999659538269043, 1.1976455450057983, 1.1949177980422974, + 1.193456768989563, 1.1915661096572876, 1.1899610757827759, + 1.1885312795639038, 1.1875519752502441, 1.1864969730377197, + 1.1851471662521362, 1.1849784851074219], + 'samples': [{'row': 0, + 'index': [0, 1, 2, 3, 119], + 'deficit': [0.00014030889724381268, 0.5, + 0.00014030889724381268, 1.0, 1.0]}, + {'row': 17, + 'index': [0, 1, 2, 13, 25, 26, 119], + 'deficit': [0.00014030889724381268, 0.2889607548713684, + 0.35533472895622253, 0.4997972846031189, + 0.00014030889724381268, 1.0, 1.0]}, + {'row': 34, + 'index': [0, 1, 2, 60, 119], + 'deficit': [0.00014030889724381268, 0.04583454504609108, + 0.12486201524734497, 0.5, + 0.00014030889724381268]}]}, + 'grazing': {'widths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, + 28, 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, 119, + 120], + 'unique_indices': [0, 1, 3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, + 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + 33, 34, 35, 36, 37], + 'lengths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, + 28, 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, + 119, 120], + 'overshoot': [2.9992170333862305, 1.589708685874939, 2.13680100440979, + 1.7570393085479736, 1.9627000093460083, 1.783870816230774, + 1.8844504356384277, 1.7812087535858154, 1.8394681215286255, + 1.7725889682769775, 1.8101266622543335, 1.763415813446045, + 1.7547715902328491, 1.7739909887313843, 1.7620879411697388, + 1.7526829242706299, 1.7448784112930298, 1.7268033027648926, + 1.7229634523391724, 1.7244441509246826, 1.714216709136963, + 1.709952712059021, 1.710409164428711, 1.7031253576278687, + 1.7004188299179077, 1.6994456052780151, 1.696427583694458, + 1.6937997341156006, 1.69041109085083, 1.689367651939392, + 1.6874561309814453, 1.68535315990448, 1.6839197874069214, + 1.6827239990234375, 1.6824843883514404], + 'samples': [{'row': 0, + 'index': [0, 1, 2, 3, 119], + 'deficit': [6.526977813336998e-05, 0.5, 6.526977813336998e-05, + 1.0, 1.0]}, + {'row': 17, + 'index': [0, 1, 2, 13, 25, 26, 119], + 'deficit': [6.526977813336998e-05, 0.030761782079935074, + 0.08263177424669266, 0.4989633858203888, + 6.526977813336998e-05, 1.0, 1.0]}, + {'row': 34, + 'index': [0, 1, 2, 60, 119], + 'deficit': [6.526977813336998e-05, 0.0032217244151979685, + 0.008669380098581314, 0.5, + 6.526977813336998e-05]}]}, + 'box': {'widths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, 28, + 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, 119, 120], + 'unique_indices': [0, 1, 3, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, + 35, 36, 37], + 'lengths': [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 26, 28, + 31, 34, 38, 41, 46, 50, 55, 61, 67, 74, 81, 89, 98, 108, 119, 120], + 'overshoot': [2.9998488426208496, 1.99994957447052, 1.666638731956482, + 1.499981164932251, 1.399985909461975, 1.3333221673965454, + 1.2857050895690918, 1.2499921321868896, 1.2222154140472412, + 1.201570749282837, 1.1910591125488281, 1.1870782375335693, + 1.1802372932434082, 1.1766963005065918, 1.1684789657592773, + 1.1597814559936523, 1.1519163846969604, 1.1463631391525269, + 1.1437723636627197, 1.1395494937896729, 1.135084629058838, + 1.1317517757415771, 1.1296340227127075, 1.1261335611343384, + 1.124210238456726, 1.1218554973602295, 1.1199150085449219, + 1.1181532144546509, 1.1165603399276733, 1.1153037548065186, + 1.1139966249465942, 1.1127245426177979, 1.1116138696670532, + 1.1107583045959473, 1.1106001138687134], + 'samples': [{'row': 0, + 'index': [0, 1, 2, 3, 119], + 'deficit': [1.2594176951097324e-05, 0.5, 1.2594176951097324e-05, + 1.0, 1.0]}, + {'row': 17, + 'index': [0, 1, 2, 13, 25, 26, 119], + 'deficit': [1.2594176951097324e-05, 0.20717094838619232, + 0.46292224526405334, 0.5, 1.2594176951097324e-05, 1.0, + 1.0]}, + {'row': 34, + 'index': [0, 1, 2, 60, 119], + 'deficit': [1.2594176951097324e-05, 0.02241148240864277, + 0.06146907061338425, 0.5, 1.2594176951097324e-05]}]}}, + 'spectra': {'16/float64': {'index': [0, 1, 5, 12, 13, 15], + 'masked': [], + 'SR': [0.9519406095790945, 0.9561998481225765, 0.978997971790927, + 0.9435317927612599, 0.9223601081028645, 0.8656760592953937], + 'power_raw': [0.35728216256048206, 0.4725569069107831, + 1.0895798443927447, 0.12970058921850874, + -0.4433033671333999, -1.9774365621432668], + 'power': [0.35728216256048206, 0.4725569069107831, 1.0895798443927447, + 0.12970058921850874, -0.4433033671333999, + -1.9774365621432668], + 'SDE_raw': 1.6579921105579325, + 'SDE': 1.6579921105579325, + 'primary': 4, + 'minimum_chi2': 4}, + '217/float64': {'index': [0, 1, 5, 12, 13, 35, 44, 45, 46, 90, 91, 100, 110, 111, 175, + 176, 177, 215, 216], + 'masked': [5, 177], + 'SR': [0.6378984212827972, 0.6407525505377255, None, + 0.6322636464670398, 0.618076433437585, 0.607656515707046, + 0.5370307552215698, 0.5242054512790848, 0.5108211493965411, + 0.5552830207361945, 0.5554376468953709, 0.6347696456802064, + 0.5746468098959107, 0.5848326993667723, 0.9357032583640895, + 0.9743492580384048, None, 0.9022837295234273, + 0.9110660409437878], + 'power_raw': [-0.290953033181775, -0.27331140862127395, None, + -0.3257820731108212, -0.41347448766889094, + -0.47788091783881403, -0.9144249701564187, + -0.993699304574506, -1.0764288545444396, + -0.8016061077900491, -0.8006503498674842, + -0.31029227103151524, -0.6819168197753093, + -0.6189569389116749, 1.5498049209359188, + 1.7886792521675063, None, 1.3432358667216575, + 1.3975201079079567], + 'power': [2.1113871403232194, 2.18003322224521, None, + 1.9758623693456825, 1.6346386542027802, 1.3840240315763281, + -0.31463125241873185, -0.6230990149035696, + -0.9450115148056877, -0.07288894322303356, + -0.09790229307269654, 1.2246234445292437, -1.108022286692954, + -0.9119261472784719, 0.9931647249636613, 1.9226588797989461, + None, 0.18937501900374662, 0.40060275796669187], + 'SDE_raw': 1.9472287497431218, + 'SDE': 2.885959594595142, + 'primary': 4, + 'minimum_chi2': 192}, + '217/float32': {'index': [0, 1, 5, 12, 13, 35, 44, 45, 46, 90, 91, 100, 110, 111, 175, + 176, 177, 215, 216], + 'masked': [5, 177], + 'SR': [0.6378984451293945, 0.6407525539398193, None, + 0.6322636008262634, 0.6180764436721802, 0.6076564788818359, + 0.5370307564735413, 0.5242054462432861, 0.5108211636543274, + 0.5552830100059509, 0.5554376244544983, 0.6347696185112, + 0.5746468305587769, 0.5848327279090881, 0.9357032179832458, + 0.9743492603302002, None, 0.9022837281227112, + 0.9110660552978516], + 'power_raw': [-0.2909528613090515, -0.2733113467693329, None, + -0.32578232884407043, -0.4134744107723236, + -0.47788113355636597, -0.9144250154495239, + -0.9936993718147278, -1.0764288902282715, + -0.8016062378883362, -0.8006505370140076, + -0.31029242277145386, -0.6819167137145996, + -0.6189568042755127, 1.549804925918579, + 1.788679599761963, None, 1.3432360887527466, + 1.3975204229354858], + 'power': [2.1113879680633545, 2.1800334453582764, None, + 1.975861668586731, 1.6346389055252075, 1.3840231895446777, + -0.31463122367858887, -0.6230990886688232, + -0.9450114369392395, -0.07289017736911774, + -0.09790314733982086, 1.2246224880218506, + -1.1080214977264404, -0.9119249582290649, 0.9931636452674866, + 1.9226590394973755, None, 0.18937484920024872, + 0.40060293674468994], + 'SDE_raw': 1.947228974453719, + 'SDE': 2.8859589099884033, + 'primary': 4, + 'minimum_chi2': 192}}, + 'final': {'False': {'T0': 3.7700149999999986, + 'depth': 0.0012826698106774665, + 'duration': 0.05999000000000168, + 'native_gtls_snr': 27.79904859474522, + 'transit_times': [3.7700149999999986, 7.470014999999998, 11.170015, 14.870015, + 18.570014999999998, 22.270014999999997, 25.970014999999997], + 'per_transit_count': [2.0, 2.0, 2.0, 2.0, 2.0, 3.0, 2.0], + 'width': 16, + 'epoch_index': 10}, + 'True': {'T0': 3.702064999999998, + 'depth': 0.0012768789763177879, + 'duration': 0.059990000000000036, + 'native_gtls_snr': 25.960389816826453, + 'transit_times': [3.702064999999998, 7.402064999999999, 11.102064999999998, + 14.802064999999999, 18.502064999999998, 22.202064999999997, + 25.902064999999997], + 'per_transit_count': [3.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], + 'width': 16, + 'epoch_index': 992}}, + 'durations': [0.003870327753502015, 0.004257360528852216, 0.004683096581737438, + 0.005151406239911182, 0.005666546863902301, 0.006233201550292532, + 0.006856521705321785, 0.007542173875853964, 0.00829639126343936, + 0.009126030389783298, 0.010038633428761629, 0.011042496771637792, + 0.012146746448801572, 0.01336142109368173, 0.014697563203049905, + 0.016167319523354897, 0.01778405147569039, 0.01956245662325943, 0.021518702285585375, + 0.023670572514143916, 0.02603762976555831, 0.028641392742114143, 0.03150553201632556, + 0.03465608521795812, 0.03812169373975394, 0.04193386311372933, 0.04612724942510227, + 0.050739974367612496, 0.05581397180437375, 0.061395368984811134, 0.06753490588329225, + 0.07428839647162148, 0.08171723611878363, 0.08988895973066201, 0.09887785570372822, + 0.10876564127410104, 0.11964220540151116, 0.12], + 'median': {'values': [7.0, 7.0, 7.0, 7.0, 7.5, 4.0, 6.0, 1.0, 0.0, 0.0, 0.0, 6.0, 9.5, 6.0, 8.0, + 6.0, 7.0, 7.0, 7.0, 7.0, 7.0, 8.0, 8.0, 8.0, 8.0, 8.0, 9.0, 9.0, 9.0, 9.0, + 9.0], + 'masked': []}, + 'candidates': {'False': {'count': 200, + 'first': [156, 61, 217, 122, 27, 183, 88, 244, 149, 54, 210, 115], + 'last': [130, 191, 96, 157, 62, 218, 123, 184, 89, 245, 150, 55], + 'sha256': '1c0bab4472a35169d363c3eb968744daf53a7ad8517bbbd4b3170ed1f9266c73'}, + 'True': {'count': 200, + 'first': [0, 61, 27, 54, 20, 47, 13, 40, 6, 33, 60, 26], + 'last': [110, 171, 76, 232, 137, 198, 103, 164, 69, 225, 130, 191], + 'sha256': '1a3e4f70466e1b42956fea3492cc234bd4a024fd59324b02f6111576beccb38c'}}} diff --git a/cuvarbase/tests/conftest.py b/cuvarbase/tests/conftest.py new file mode 100644 index 00000000..b3a6591e --- /dev/null +++ b/cuvarbase/tests/conftest.py @@ -0,0 +1,163 @@ +"""Test-package conftest: stub GPU dependencies for CPU-only test runs. + +The CUDA primary context is created lazily on first GPU use (see +``cuvarbase.base.ensure_context``), so ``import cuvarbase`` needs no +device -- but the GPU modules (``bls``, ``ce``, ``cunfft``, +``lombscargle``, ``pdm``, ``tls``, ``nufft_lrt``) still ``import +pycuda.driver`` at module top, and most test modules import them. On a +machine without pycuda the suite could therefore not even be collected. +When pycuda is genuinely unavailable this conftest installs minimal stub +modules so that: + +* the full suite collects, +* pure-CPU tests (sparse BLS ground truth, TLS grids/models/stats, + frequency grids, input validation, ...) run normally, and +* any test that actually touches the GPU raises :class:`GPUStubError`, + which the hook below converts into a pytest *skip* rather than a + failure. + +On machines with a working pycuda installation the stubs are not +installed and every test runs for real. + +This file lives inside the package (``cuvarbase/tests/conftest.py``, not +the repository root) so that it ships in the wheel and is loaded by +``pytest --pyargs cuvarbase`` from an installed copy. + +The ``gpu`` marker +------------------ +``pytest_configure`` registers a ``gpu`` marker (so ``--strict-markers`` +works with or without the ``[tool.pytest.ini_options]`` table) and +``pytest_collection_modifyitems`` applies it automatically. The +heuristic is deliberately simple and module-grained: an item is marked +``gpu`` when its module either + +* exposes a ``mark_cuda_test`` attribute (it imported + ``pycuda.tools.mark_cuda_test`` to decorate device tests), or +* is listed in :data:`GPU_TEST_MODULES` below -- the modules whose tests + are predominantly on-device (measured on a CPU-only host: >= 50 % of + their items skip through the GPUStubError hook). + +``-m "not gpu"`` therefore runs the CPU subset quickly. It is a +*heuristic*: a handful of CPU-only tests that live in GPU-dominant +modules are excluded by it, and the few device tests that live in +CPU-dominant modules still run (and skip via the hook on a GPU-less +host, or pass on a device). The hook, not the marker, is what keeps the +CPU run green; the marker is a selection convenience. +""" +import sys +import types + +import pytest + +try: + import pycuda.driver # noqa: F401 + _HAS_PYCUDA = True +except Exception: + _HAS_PYCUDA = False + + +# Test modules (basename without .py) whose items are predominantly +# on-device. Keep in sync with the heuristic documented above; a module +# that also imports ``mark_cuda_test`` is marked whether or not it is +# listed here. +GPU_TEST_MODULES = frozenset({ + 'test_bls', + 'test_ce', + 'test_lombscargle', + 'test_nfft', + 'test_nufft_lrt', + 'test_pdm', + 'test_readme_examples', + 'test_tls_fast', + 'test_tls_golden', +}) + + +class GPUStubError(RuntimeError): + """Raised when stubbed GPU functionality is exercised without a GPU.""" + + +if not _HAS_PYCUDA: + + class _GPUStub: + """Attribute sink that raises GPUStubError when called.""" + + def __init__(self, name): + self._name = name + + def __getattr__(self, attr): + if attr.startswith('__') and attr.endswith('__'): + raise AttributeError(attr) + return _GPUStub('%s.%s' % (self._name, attr)) + + def __call__(self, *args, **kwargs): + raise GPUStubError( + '%s requires a GPU (pycuda is stubbed by ' + 'cuvarbase/tests/conftest.py)' % self._name) + + def _make_module(name, **attrs): + mod = types.ModuleType(name) + for key, val in attrs.items(): + setattr(mod, key, val) + sys.modules[name] = mod + return mod + + pycuda_mod = _make_module('pycuda') + _make_module('pycuda.autoprimaryctx') + _make_module('pycuda.autoinit') + + def _module_getattr(modname): + def _getattr(attr): + # Dunders (__file__, __path__, ...) must follow normal module + # semantics or inspect/import machinery breaks during collection. + if attr.startswith('__') and attr.endswith('__'): + raise AttributeError(attr) + return _GPUStub('%s.%s' % (modname, attr)) + return _getattr + + driver = _make_module('pycuda.driver') + driver.__getattr__ = _module_getattr('pycuda.driver') + + gpuarray = _make_module('pycuda.gpuarray') + gpuarray.__getattr__ = _module_getattr('pycuda.gpuarray') + + _make_module('pycuda.compiler', + SourceModule=_GPUStub('pycuda.compiler.SourceModule')) + + # mark_cuda_test must be a passthrough decorator: it is applied at import + # time, and the decorated tests then skip via GPUStubError when they run. + _make_module('pycuda.tools', + mark_cuda_test=lambda f: f, + context_dependent_memoize=lambda f: f) + + pycuda_mod.driver = driver + pycuda_mod.gpuarray = gpuarray + + +def pytest_configure(config): + config.addinivalue_line('markers', 'gpu: needs a CUDA device') + + +def pytest_collection_modifyitems(config, items): + """Apply ``pytest.mark.gpu`` per the module-grained heuristic + documented in the module docstring.""" + for item in items: + module = getattr(item, 'module', None) + if module is None: + continue + name = module.__name__.rsplit('.', 1)[-1] + if name in GPU_TEST_MODULES or hasattr(module, 'mark_cuda_test'): + item.add_marker(pytest.mark.gpu) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Convert GPUStubError failures into skips on GPU-less machines.""" + outcome = yield + rep = outcome.get_result() + if rep.outcome == 'failed' and call.excinfo is not None: + if call.excinfo.errisinstance(GPUStubError): + rep.outcome = 'skipped' + rep.longrepr = (str(item.fspath), item.location[1], + 'requires GPU (pycuda stubbed by ' + 'cuvarbase/tests/conftest.py)') diff --git a/cuvarbase/tests/test_api_freeze.py b/cuvarbase/tests/test_api_freeze.py new file mode 100644 index 00000000..a48de771 --- /dev/null +++ b/cuvarbase/tests/test_api_freeze.py @@ -0,0 +1,413 @@ +"""The 1.0 API freeze (Sep 2026): the frozen top-level namespace, the +NUFFT-LRT quarantine, the keyword-only markers on the 1.0-new +signatures and the per-module ``__all__`` lists. Everything here runs +on CPU (under the pycuda stub of ``conftest.py`` when no GPU is +present).""" +import importlib +import os +import re +import subprocess +import sys +import warnings + +import pytest + +import cuvarbase + + +# --------------------------------------------------------------------- +# Top-level namespace (blocker 13) +# --------------------------------------------------------------------- + +def test_all_equals_lazy_attrs(): + assert set(cuvarbase.__all__) == set(cuvarbase._LAZY_ATTRS) + assert len(cuvarbase.__all__) == len(set(cuvarbase.__all__)) + + +@pytest.mark.parametrize('name', sorted(cuvarbase._LAZY_ATTRS)) +def test_public_name_resolves(name): + obj = getattr(cuvarbase, name) + module = importlib.import_module(cuvarbase._LAZY_ATTRS[name], + 'cuvarbase') + assert obj is getattr(module, name) + assert name in dir(cuvarbase) + + +def test_no_accidental_bls_names(): + # the unpublished v1.0 branch resolved any public name of + # cuvarbase.bls (np, cuda, compile_bls, ...) as cuvarbase. + assert not hasattr(cuvarbase, 'np') + assert not hasattr(cuvarbase, 'cuda') + with pytest.raises(AttributeError): + cuvarbase.eebls_gpu + with pytest.raises(AttributeError): + cuvarbase.compile_bls + assert 'np' not in dir(cuvarbase) + + +def test_submodules_reachable_as_attributes(): + for name in cuvarbase._SUBMODULES: + mod = getattr(cuvarbase, name) + assert mod.__name__ == 'cuvarbase.' + name + assert name in dir(cuvarbase) + + +# --------------------------------------------------------------------- +# NUFFT-LRT quarantine (decision D1) +# --------------------------------------------------------------------- + +def test_nufft_lrt_not_top_level(): + assert 'NUFFTLRTAsyncProcess' not in cuvarbase.__all__ + assert 'NUFFTLRTMemory' not in cuvarbase.__all__ + assert 'nufft_lrt' in cuvarbase._SUBMODULES + import cuvarbase.nufft_lrt as nufft_lrt + assert callable(nufft_lrt.NUFFTLRTAsyncProcess) + assert callable(nufft_lrt.NUFFTLRTMemory) + + +_STAR_IMPORT_SCRIPT = r""" +import sys, types +# Harmless pycuda stubs so the GPU modules import without a real GPU +# (the star-import resolves every lazy name, which imports every +# method module). +for name in ['pycuda', 'pycuda.driver', 'pycuda.gpuarray', + 'pycuda.compiler', 'pycuda.tools']: + sys.modules[name] = types.ModuleType(name) +sys.modules['pycuda.compiler'].SourceModule = object +_autoctx = types.ModuleType('pycuda.autoprimaryctx') +_autoctx.device = object() +_autoctx.context = object() +sys.modules['pycuda.autoprimaryctx'] = _autoctx + +import warnings +warnings.simplefilter('always') +with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter('always') + from cuvarbase import * + import cuvarbase.nufft_lrt +exp = [w for w in rec if 'EXPERIMENTAL' in str(w.message)] +assert not exp, [str(w.message) for w in exp] +names = sorted(n for n in dir() if not n.startswith('_') + and n not in ('warnings', 'rec', 'exp', 'cuvarbase', + 'sys', 'types', 'name')) +import cuvarbase +assert names == sorted(cuvarbase.__all__), (names, cuvarbase.__all__) +print('OK') +""" + + +def test_star_import_emits_no_experimental_warning(): + # star-import must not import nufft_lrt, and importing nufft_lrt + # must not warn either: the warning is emitted at construction. + repo_root = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + result = subprocess.run( + [sys.executable, '-c', _STAR_IMPORT_SCRIPT], + cwd=repo_root, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert 'OK' in result.stdout + + +def test_nufft_lrt_warns_at_construction(): + from cuvarbase import nufft_lrt + + class _Proc(nufft_lrt.NUFFTLRTAsyncProcess): + # GPUAsyncProcess.__init__ retains the CUDA context; skip it + # (and the NFFT process) so the warning is testable on CPU. + def __init__(self): + warnings.warn(nufft_lrt._EXPERIMENTAL_MSG, UserWarning, + stacklevel=2) + + with pytest.warns(UserWarning, + match='cuvarbase.nufft_lrt is EXPERIMENTAL'): + _Proc() + # the real constructor's first statement is the same warning + import inspect + src = inspect.getsource(nufft_lrt.NUFFTLRTAsyncProcess.__init__) + body = src.split('):', 1)[1].lstrip() + assert body.startswith('warnings.warn(_EXPERIMENTAL_MSG') + + +# --------------------------------------------------------------------- +# Compatibility shims kept for 1.x (decision D3: shipped in 0.2.5) +# --------------------------------------------------------------------- + +def test_core_module_is_deprecated_alias(): + sys.modules.pop('cuvarbase.core', None) + with pytest.warns(DeprecationWarning, match='removed in 2.0'): + import cuvarbase.core as core + from cuvarbase import base + assert core.GPUAsyncProcess is base.GPUAsyncProcess + assert core.ensure_context is base.ensure_context + + +def test_no_internal_import_of_core(): + pkg = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + offenders = [] + for dirpath, _, files in os.walk(pkg): + if os.path.basename(dirpath) == 'tests': + continue + for f in files: + if f.endswith('.py') and f != 'core.py': + src = open(os.path.join(dirpath, f)).read() + if 'from .core import' in src or 'cuvarbase.core' in src: + offenders.append(f) + assert offenders == [] + + +def test_bls_allocate_pinned_arrays_warns(monkeypatch): + from cuvarbase.bls import BLSMemory + mem = BLSMemory.__new__(BLSMemory) + calls = [] + monkeypatch.setattr(mem, 'allocate_host_arrays', + lambda **kw: calls.append(kw) or 'ok', + raising=False) + with pytest.warns(DeprecationWarning, match='removed in 2.0'): + assert mem.allocate_pinned_arrays(nfreqs=3, ndata=4) == 'ok' + assert calls == [{'nfreqs': 3, 'ndata': 4}] + + +def test_pdm_four_tuple_warning_wording(): + import inspect + from cuvarbase import pdm + src = inspect.getsource(pdm.PDMAsyncProcess.run) + assert 'removed in 2.0' in src + assert 'NORMALIZED WEIGHTS' in src + + +def test_gpu_async_process_device_keyword(): + from cuvarbase.base import GPUAsyncProcess + # device=0 (the default) and the other legacy keywords are silent + with warnings.catch_warnings(): + warnings.simplefilter('error') + proc = GPUAsyncProcess(reader=None, function_kwargs={}, device=0) + assert proc.device == 0 + with pytest.warns(UserWarning, match='CUDA_DEVICE'): + proc = GPUAsyncProcess(device=1) + assert proc.device == 1 + + +def test_utils_weights_is_canonical(): + import numpy as np + from cuvarbase import utils + from cuvarbase.memory import lombscargle_memory + import cuvarbase.memory as memory + assert lombscargle_memory.weights is utils.weights + assert memory.weights is utils.weights + err = np.array([0.1, 0.2, 0.4]) + w = utils.weights(err) + assert w.dtype == np.float64 + assert w.sum() == pytest.approx(1.0) + + +# --------------------------------------------------------------------- +# Removed (decision D3): never on PyPI, or the three 0.2.5-era helpers +# --------------------------------------------------------------------- + +@pytest.mark.parametrize('module, name', [ + ('cuvarbase.tls_stats', 'pink_noise_correction'), + ('cuvarbase.tls_grids', 'estimate_n_evaluations'), + ('cuvarbase.tls', '_next_pow2'), + ('cuvarbase.utils', 'tophat_window'), + ('cuvarbase.utils', 'gaussian_window'), + ('cuvarbase.utils', 'get_autofreqs'), +]) +def test_removed_names_are_gone(module, name): + mod = importlib.import_module(module) + assert not hasattr(mod, name) + + +def test_removed_parameters_are_gone(): + import inspect + from cuvarbase import tls, tls_stats + assert 'durations' not in inspect.signature(tls.tls_search_gpu).parameters + assert 'n_transits' not in inspect.signature( + tls_stats.signal_to_noise).parameters + assert 'window_length' not in inspect.signature( + tls_stats.signal_detection_efficiency).parameters + # TLS never shipped, so the misnamed method is renamed without alias + assert hasattr(tls.TLSMemory, 'allocate_host_arrays') + assert not hasattr(tls.TLSMemory, 'allocate_pinned_arrays') + + +# --------------------------------------------------------------------- +# Keyword-only markers on the 1.0-new signatures (finding 136) +# --------------------------------------------------------------------- + +def _kwonly_cases(): + import numpy as np + from cuvarbase import bls, bls_frequencies, tls + t = np.linspace(0.0, 10.0, 50) + y = np.ones(50) + dy = np.full(50, 1e-3) + periods = np.array([1.0, 2.0]) + freqs = np.array([0.5, 1.0]) + return [ + (tls.tls_search_gpu, (t, y, dy, periods), 'qmin'), + (tls.tls_search_batch, ([(t, y, dy)],), 'R_star'), + (tls.tls_transit, (t, y, dy), 'R_star'), + (bls.eebls_gpu_batch, ([(t, y, dy)], freqs), 'qmin'), + (bls_frequencies.keplerian_freq_grid, (1.0, 5.0, 100.0), 'R_star'), + (bls_frequencies.uniform_freq_grid, (1.0, 5.0, 100.0), + 'oversampling'), + (bls.convert_bls_power, (y, y, dy), 'convention'), + ] + + +@pytest.mark.parametrize('case', _kwonly_cases(), + ids=lambda c: c[0].__name__) +def test_keyword_only_after_data_arguments(case): + import inspect + func, positional, first_kw = case + params = inspect.signature(func).parameters + assert params[first_kw].kind is inspect.Parameter.KEYWORD_ONLY + n_pos = sum(p.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for p in params.values()) + assert n_pos == len(positional) + # one extra positional argument is a TypeError raised by the call + # machinery, before any body (and any GPU work) runs + with pytest.raises(TypeError): + func(*positional, None) + + +# --------------------------------------------------------------------- +# Explicit __all__ per user-facing module (finding 135) +# --------------------------------------------------------------------- + +_MODULES_WITH_ALL = ['bls', 'bls_frequencies', 'ce', 'cunfft', 'lombscargle', + 'pdm', 'tls', 'tls_grids', 'tls_models', 'tls_stats', + 'utils', 'cufinufft_backend', 'nufft_lrt'] + + +@pytest.mark.parametrize('modname', _MODULES_WITH_ALL) +def test_module_all_is_explicit_and_resolvable(modname): + import inspect + mod = importlib.import_module('cuvarbase.' + modname) + names = mod.__all__ + assert isinstance(names, list) and names + assert len(names) == len(set(names)) + for name in names: + assert not name.startswith('_'), name + obj = getattr(mod, name) # AttributeError == a stale entry + assert not inspect.ismodule(obj), name + if inspect.isfunction(obj) or inspect.isclass(obj): + assert obj.__module__ == mod.__name__, (name, obj.__module__) + # star-imports and autodoc must not publish the imported modules + for leaked in ('np', 'cuda', 'gpuarray', 'warnings', 'threading', + 'os', 'sys', 'SourceModule'): + assert leaked not in names + + +def _docs_dir(): + repo_root = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + return os.path.join(repo_root, 'docs', 'source') + + +def test_documented_names_are_in_module_all(): + # Sphinx autodoc ``:members:`` honours ``__all__``: a documented + # name missing from it silently drops off the API page. + import glob + import inspect + import re + docs = _docs_dir() + if not os.path.isdir(docs): + pytest.skip('docs/source not present (installed wheel)') + pattern = re.compile(r'cuvarbase\.([a-z_]+)\.([A-Za-z_][A-Za-z0-9_]*)') + referenced = set() + for path in glob.glob(os.path.join(docs, '*.rst')): + with open(path) as fh: + for m in pattern.finditer(fh.read()): + if m.group(1) in _MODULES_WITH_ALL: + referenced.add((m.group(1), m.group(2))) + assert referenced, 'no cuvarbase.. references found' + missing = [] + for modname, name in sorted(referenced): + mod = importlib.import_module('cuvarbase.' + modname) + obj = getattr(mod, name, None) + if obj is None or inspect.ismodule(obj) or name.startswith('_'): + continue # a typo in the docs is the docs' problem + if name not in mod.__all__: + missing.append('cuvarbase.%s.%s' % (modname, name)) + assert missing == [] + + +# --------------------------------------------------------------------- +# Docstring defaults match the code (finding 134) +# --------------------------------------------------------------------- + +_DEFAULT_RE = re.compile( + r'^\s{4}([A-Za-z_][A-Za-z0-9_]*)\s*(?:,\s*[A-Za-z_][A-Za-z0-9_]*)*\s*:' + r'[^\n]*?\(default:?\s*(.+?)\)\s*$', re.M) + + +def _doc_targets(): + import inspect + out = [] + for modname in _MODULES_WITH_ALL: + mod = importlib.import_module('cuvarbase.' + modname) + for name in mod.__all__: + obj = getattr(mod, name) + if inspect.isclass(obj): + out.append(('%s.%s.__init__' % (modname, name), + obj.__init__, obj.__doc__)) + for k, v in vars(obj).items(): + if inspect.isfunction(v) and not k.startswith('_'): + out.append(('%s.%s.%s' % (modname, name, k), v, + v.__doc__)) + elif inspect.isfunction(obj): + out.append(('%s.%s' % (modname, name), obj, obj.__doc__)) + return out + + +def test_docstring_defaults_match_signatures(): + import ast + import inspect + bad = [] + for qualname, func, doc in _doc_targets(): + if not doc: + continue + try: + params = inspect.signature(func).parameters + except (TypeError, ValueError): + continue + for m in _DEFAULT_RE.finditer(doc): + pname, stated = m.group(1), m.group(2).strip().rstrip('.') + if pname not in params: + continue + real = params[pname].default + if real is inspect.Parameter.empty: + continue + try: + val = ast.literal_eval(stated.strip('`')) + except Exception: + continue # prose defaults ("None -> 0.1 * periods") + numeric = (isinstance(val, (int, float)) + and isinstance(real, (int, float))) + if not (val == real or (numeric and float(val) == float(real))): + bad.append('%s(%s): doc %r vs code %r' + % (qualname, pname, stated, real)) + assert bad == [] + + +def test_finding_134_sites(): + import inspect + from cuvarbase import bls, ce, cunfft + assert inspect.signature(bls.eebls_gpu).parameters['dlogq'].default == 0.2 + assert '(default: 0.2)' in bls.eebls_gpu.__doc__.split('dlogq:')[1][:40] + assert inspect.signature( + bls.eebls_gpu_fast).parameters['max_nblocks'].default == 5000 + assert '(default: 5000)' in \ + bls.eebls_gpu_fast.__doc__.split('max_nblocks:')[1][:40] + # kwargs.get defaults: compare the constructor source with the doc + src = inspect.getsource(ce.ConditionalEntropyAsyncProcess.__init__) + assert "kwargs.get('mag_bins', 5)" in src + assert 'mag_bins: int, optional (default: 5)' in \ + ce.ConditionalEntropyAsyncProcess.__doc__ + src = inspect.getsource(cunfft.NFFTAsyncProcess.__init__) + assert "kwargs.get('sigma', 4)" in src + assert "kwargs.get('autoset_m', False)" in src + assert 'sigma: float, optional (default: 4)' in \ + cunfft.NFFTAsyncProcess.__doc__ + assert 'autoset_m: bool, optional (default: False)' in \ + cunfft.NFFTAsyncProcess.__doc__ diff --git a/cuvarbase/tests/test_bls.py b/cuvarbase/tests/test_bls.py index df82ca89..3c59683b 100644 --- a/cuvarbase/tests/test_bls.py +++ b/cuvarbase/tests/test_bls.py @@ -1,18 +1,22 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from builtins import zip -from builtins import range -from builtins import object -from itertools import product +from itertools import product +import os +import warnings + import pytest import numpy as np from numpy.testing import assert_allclose -from pycuda.tools import mark_cuda_test from ..bls import eebls_gpu, eebls_transit_gpu, \ q_transit, compile_bls, hone_solution,\ - single_bls, eebls_gpu_custom, eebls_gpu_fast + single_bls, eebls_gpu_custom, eebls_gpu_fast, \ + eebls_gpu_fast_optimized, \ + sparse_bls_cpu, sparse_bls_gpu, eebls_transit, \ + transit_autofreq, \ + count_tot_nbins, _bls_batch_table, _max_nbins_tot, \ + _per_freq_nbins_tot, _cap_freq_batch_size, \ + _q_bounds_to_nbins, _MAX_FOLD_THREADS, \ + _fast_path_nbins, _fast_bls_box_scan, \ + _fast_bls_solutions +from ..bls_frequencies import keplerian_freq_grid def transit_model(phi0, q, delta, q1=0.): @@ -66,7 +70,8 @@ def plot_bls_sol(t, y, dy, freq, q, phi0): def data(seed=100, sigma=0.1, ybar=12., snr=10, ndata=200, freq=10., - q=0.01, phi0=None, baseline=1., negative_delta=False): + q=0.01, phi0=None, baseline=1., negative_delta=False, + t0=4.5): rand = np.random.RandomState(seed) @@ -80,7 +85,12 @@ def data(seed=100, sigma=0.1, ybar=12., snr=10, ndata=200, freq=10., model = transit_model(phi0, q, delta) - t = baseline * np.sort(rand.rand(ndata)) + # Non-zero T0 so every test exercises a non-trivial epoch + # (floor(min(t)) > 0): phases reported by the BLS functions are in + # the original input timescale, and the injected transit is at + # original-timescale phase phi0 (the shift is applied BEFORE the + # model is evaluated). + t = baseline * np.sort(rand.rand(ndata)) + t0 y = model(t, freq) + sigma * rand.randn(len(t)) y += ybar - np.mean(y) err = sigma * np.ones_like(y) @@ -90,6 +100,7 @@ def data(seed=100, sigma=0.1, ybar=12., snr=10, ndata=200, freq=10., def get_total_nbins(nbins0, nbinsf, dlogq): nbins_tot = 0 + x = 1. while (int(x * nbins0) <= nbinsf): nb = int(x * nbins0) x *= 1 + dlogq @@ -136,7 +147,7 @@ def manual_binning(t, y, dy, freqs, nbins0, nbinsf, dlogq, bf = np.floor(nb * mod1(phi - s * q * dphi)) bf += i * nbins_tot * noverlap + s * nb + noverlap * nbtot - for b, YW, W in zip(bf[mask], yw[mask], w[mask]): + for b, YW, W in zip(bf.astype(int), yw, w): yw_bins[b] += YW w_bins[b] += W @@ -166,7 +177,13 @@ def __init__(self, freq, phi0, q, baseline, ybar, snr, negative_delta): @pytest.mark.parametrize("args", [( SolutionParams(freq=0.3, phi0=0.5, q=0.2, baseline=365., ybar=0., snr=50., negative_delta=True), - {'bls0': 0.8902446483898836, 'bls_ignore': 0} + # Deterministic single_bls value at the injected solution + # (pure-CPU float32 arithmetic; changes only if data() or + # single_bls numerics change -- e.g. this was + # 0.8902446483898836 before data() gained the t0=4.5 shift, + # which rotates the fold and re-draws which points host the + # injected dip). + {'bls0': 0.9223771210115413, 'bls_ignore': 0} ) ]) def test_ignore_positive_sols(self, args): @@ -181,6 +198,9 @@ def test_ignore_positive_sols(self, args): freq, q, phi0 = solution.freq, solution.q, solution.phi0 + # single_bls now takes phi0 in the ORIGINAL input timescale (it + # epoch-subtracts internally), so the injected phase is passed + # through unchanged. bls_default = single_bls(t, y_neg, dy, freq, q, phi0) bls0 = single_bls(t, y_neg, dy, freq, q, phi0, ignore_negative_delta_sols=False) bls_ignore = single_bls(t, y_neg, dy, freq, q, phi0, @@ -230,13 +250,29 @@ def test_transit_parameter_consistency(self, freq, phi0, dlogq, nstreams, if self.plot: plot_bls_sol(t, y, dy, freq, qs, phs) - pows, diffs = list(zip(*sorted(zip(pcpu, - np.absolute(power - pcpu)), - key=lambda x: -x[1]))) + qsols = np.array([s[0] for s in sols]) + pows, diffs, qq = list(zip(*sorted(zip(pcpu, + np.absolute(power - pcpu), + qsols), + key=lambda x: -x[1]))) + + # The binned (GPU) and exact (single_bls) powers can disagree + # by ~power/n_in_transit when a single point's float32 phase + # lands on the opposite side of a bin edge in the kernel's + # fast-math fold vs numpy's. For tiny-q solutions (n ~ ndata*q + # points in transit) that single-point jitter is O(0.1), so + # both criteria are scale-aware: tight where boxes hold >= ~8 + # points, loose (one-point jitter) below. + ndata = len(t) + n_in_transit = ndata * np.array(qq) + well_populated = n_in_transit >= 8 upper_bound = self.rtol * np.array(pows) + self.atol - mostly_ok = sum(np.array(diffs) > upper_bound) / len(pows) < 1e-2 - not_too_bad = max(diffs) < 1e-1 + viol = (np.array(diffs) > upper_bound) & well_populated + mostly_ok = viol.sum() / len(pows) < 1e-2 + + cap = np.where(well_populated, 1e-1, 2.5e-1) + not_too_bad = np.all(np.array(diffs) < cap) print(max(diffs)) assert mostly_ok and not_too_bad @@ -270,14 +306,12 @@ def test_custom(self, freq, q_index, phi_index, freq_batch_size, nstreams, for freq, (qg, phg), gpower in zip(freqs, gsols, power): q_and_phis = product(q_values, phi_values) - best_q, best_phi, best_p = None, None, None + best_p = None for Q, PHI in q_and_phis: p = single_bls(t, y, dy, freq, Q, PHI, ignore_negative_delta_sols=ignore_negative_delta_sols) if best_p is None or p > best_p: best_p = p - best_q = Q - best_phi = PHI assert np.abs(best_p - gpower) < 1e-5 @@ -345,14 +379,64 @@ def test_standard(self, freq, q_index, phi_index, nstreams, freq_batch_size, assert mostly_ok and not_too_bad # assert_allclose(bls_c, power, rtol=1e-3, atol=1e-5) + # use_optimized=True swaps in the bls_optimized.cu module, whose + # binning/store kernels are byte-shared with bls.cu via + # bls_common.cuh -- only reduction_max differs (warp-shuffle finish + # vs full tree). One focused equivalence test per entry point + # exercises that reduction + store path; cross-multiplying + # use_optimized into every test_standard/test_custom parametrization + # would double the suite while varying nothing else in the kernel. + def test_standard_use_optimized_matches(self): + q = 0.05 + t, y, dy = data(snr=10, q=q, phi0=0.317, freq=1.0, baseline=365.) + freqs = np.linspace(0.95, 1.05, 300) + + kw = dict(qmin=0.1 * q, qmax=2.0 * q, nstreams=1, + noverlap=2, dlogq=0.5) + p_std, sols_std = eebls_gpu(t, y, dy, freqs, **kw) + p_opt, sols_opt = eebls_gpu(t, y, dy, freqs, use_optimized=True, + **kw) + + # identical binning kernels: powers agree to float32 + # atomic-ordering noise + assert_allclose(p_opt, p_std, rtol=1e-4, atol=1e-6) + + # solutions may legitimately differ where two boxes tie in + # power (the two reductions break ties differently), so compare + # the powers of the solutions rather than the solutions + for f, s_std, s_opt in zip(freqs, sols_std, sols_opt): + if s_std != s_opt: + b_std = single_bls(t, y, dy, f, *s_std) + b_opt = single_bls(t, y, dy, f, *s_opt) + # ties: same binned power; exact powers can differ by + # one point's membership at most (~power / n_in_box) + assert abs(b_std - b_opt) < 0.15 * max(b_std, b_opt) + 1e-5 + + def test_custom_use_optimized_matches(self): + q_values = np.logspace(-1.1, -0.8, num=10) + phi_values = np.linspace(0, 1, int(np.ceil(2. / min(q_values)))) + t, y, dy = data(snr=10, q=q_values[5], phi0=phi_values[10], + freq=1.0, baseline=365., ndata=500) + freqs = np.linspace(0.9999, 1.0001, 20) + + p_std, sols_std = eebls_gpu_custom(t, y, dy, freqs, + q_values, phi_values) + p_opt, sols_opt = eebls_gpu_custom(t, y, dy, freqs, + q_values, phi_values, + use_optimized=True) + assert_allclose(p_opt, p_std, rtol=1e-4, atol=1e-6) + @pytest.mark.parametrize("freq", [1.0]) @pytest.mark.parametrize("dlogq", [0.5, -1.0]) @pytest.mark.parametrize("freq_batch_size", [1, 10, None]) @pytest.mark.parametrize("phi0", [0.0]) - @pytest.mark.parametrize("use_fast", [True, False]) + # one axis for the three kernel paths: a use_fast x use_optimized + # cross-product would add combinations (fast+optimized) that just + # re-run the fast branch + @pytest.mark.parametrize("mode", ["standard", "fast", "optimized"]) @pytest.mark.parametrize("nstreams", [1, 4]) @pytest.mark.parametrize("ignore_negative_delta_sols", [True, False]) - def test_transit(self, freq, use_fast, freq_batch_size, nstreams, phi0, dlogq, + def test_transit(self, freq, mode, freq_batch_size, nstreams, phi0, dlogq, ignore_negative_delta_sols): q = q_transit(freq) samples_per_peak = 2 @@ -366,14 +450,18 @@ def test_transit(self, freq, use_fast, freq_batch_size, nstreams, phi0, dlogq, ignore_negative_delta_sols=ignore_negative_delta_sols, nstreams=nstreams, noverlap=noverlap, fmin=0.9 * freq, fmax=1.1 * freq, - use_fast=use_fast) + use_fast=(mode == "fast"), + use_optimized=(mode == "optimized")) - if use_fast: - freqs, power = eebls_transit_gpu(t, y, err, **kw) + if mode in ("fast", "optimized"): + freqs, power, no_sols = eebls_transit_gpu(t, y, err, **kw) + # fast/optimized kernels do not track solutions but the + # return is a uniform 3-tuple + assert no_sols is None kw['use_fast'] = False + kw['use_optimized'] = False freqs, power_slow, sols = eebls_transit_gpu(t, y, err, **kw) - kw['use_fast'] = True dfsol = freqs[np.argmax(power)] - freqs[np.argmax(power_slow)] close_enough = abs(dfsol) * (max(t) - min(t)) / q < 3 if not close_enough and self.plot: @@ -402,10 +490,18 @@ def test_transit(self, freq, use_fast, freq_batch_size, nstreams, phi0, dlogq, print(list(zip(pows[:10], diffs[:10]))) plt.show() + # Same scale-aware criteria as test_transit_parameter_consistency: + # at freq=1 the Keplerian q is ~0.017, so every box holds < 8 + # points and binned-vs-exact powers jitter by ~power/n when a + # single point's float32 phase crosses a bin edge. diffs = np.absolute(power - power_cpu) + qsols = np.array([s[0] for s in sols]) + well_populated = len(t) * qsols >= 8 + upper_bound = 1e-3 * np.array(power_cpu) + 1e-5 - mostly_ok = sum(np.array(diffs) > upper_bound) / len(diffs) < 1e-2 - not_too_bad = max(diffs) < 1e-1 + viol = (diffs > upper_bound) & well_populated + mostly_ok = viol.sum() / len(diffs) < 1e-2 + not_too_bad = np.all(diffs < np.where(well_populated, 1e-1, 2.5e-1)) print(max(diffs)) assert mostly_ok and not_too_bad @@ -453,3 +549,3176 @@ def test_fast_eebls(self, freq, q, phi0, freq_batch_size, dlogq, dphi, fmax_fast = freqs[np.argmax(power)] fmax_regular = freqs[np.argmax(power0)] assert(abs(fmax_fast - fmax_regular) * (max(t) - min(t)) / q < 3) + + # ---- Sparse BLS tests: ground-truth correctness ---- + + @staticmethod + def _brute_force_bls(t, y, dy, freq, ignore_negative_delta_sols=False, + qmin=0.0, qmax=0.5): + """Exhaustive BLS over all observation-pair transit boundaries + (float32 fold like the kernels; flux centred in float64 and + sums in float64 -- the sparse paths centre in float64 since + defect 8 of the Sep 2026 audit).""" + t = np.asarray(t, dtype=np.float32) + y64 = np.asarray(y, dtype=np.float64) + w64 = np.power(np.asarray(dy, dtype=np.float64), -2) + w64 /= w64.sum() + y = (y64 - np.dot(w64, y64)).astype(np.float32) + dy = np.asarray(dy, dtype=np.float32) + + ndata = len(t) + w = np.power(dy, -2, dtype=np.float32) + w /= np.sum(w) + + phi = (t * freq) % 1.0 + idx = np.argsort(phi) + phi_s, y_s, w_s = phi[idx], y[idx], w[idx] + + ybar = np.dot(w, y) + YY = np.dot(w, (y - ybar) ** 2) + + max_bls, best_q, best_phi = 0.0, 0.0, 0.0 + + # Non-wrapped pairs + for i in range(ndata): + W_acc, YW_acc = 0.0, 0.0 + for j in range(i + 1, ndata + 1): + W_acc += w_s[j - 1] + YW_acc += w_s[j - 1] * y_s[j - 1] + if j < ndata: + q = 0.5 * (phi_s[j] + phi_s[j - 1]) - phi_s[i] + else: + q = phi_s[ndata - 1] - phi_s[i] + 1e-7 + if q <= 0 or q < qmin or q > qmax: + continue + W = W_acc + YW = YW_acc - ybar * W + if W < 1e-9 or W > 1.0 - 1e-9: + continue + if YW > 0 and ignore_negative_delta_sols: + continue + bls = (YW ** 2) / (W * (1 - W)) / YY + if bls > max_bls: + max_bls, best_q, best_phi = bls, q, phi_s[i] + + # Wrapped pairs + for i in range(ndata): + W_tail = float(np.sum(w_s[i:])) + YW_tail = float(np.dot(w_s[i:], y_s[i:])) + W_head, YW_head = 0.0, 0.0 + for k in range(i): + if k > 0: + W_head += w_s[k - 1] + YW_head += w_s[k - 1] * y_s[k - 1] + phi0 = phi_s[i] + if k > 0: + q = (1.0 - phi0) + 0.5 * (phi_s[k - 1] + phi_s[k]) + else: + q = 1.0 - phi0 + 1e-7 + if q <= 0 or q < qmin or q > qmax: + continue + W = W_tail + W_head + YW = (YW_tail + YW_head) - ybar * W + if W < 1e-9 or W > 1.0 - 1e-9: + continue + if YW > 0 and ignore_negative_delta_sols: + continue + bls = (YW ** 2) / (W * (1 - W)) / YY + if bls > max_bls: + max_bls, best_q, best_phi = bls, q, phi0 + + return max_bls, best_q, best_phi + + @pytest.mark.parametrize("ndata", [10, 15, 20]) + @pytest.mark.parametrize("freq", [1.0, 2.5]) + @pytest.mark.parametrize("seed", [42, 123]) + @pytest.mark.parametrize("ignore_negative_delta_sols", [True, False]) + def test_sparse_bls_vs_exhaustive(self, ndata, freq, seed, + ignore_negative_delta_sols): + """Verify sparse_bls_cpu matches exhaustive brute-force search.""" + rand = np.random.RandomState(seed) + sigma = 0.1 + q_true, phi0_true = 0.1, 0.3 + delta = 5.0 * sigma / np.sqrt(ndata * q_true) + + t = np.sort(rand.rand(ndata)) + y = np.zeros(ndata) + phi = (t * freq - phi0_true) % 1.0 + y[phi < q_true] -= delta + y += sigma * rand.randn(ndata) + dy = sigma * np.ones(ndata) + + freqs = np.array([freq], dtype=np.float32) + power, sols = sparse_bls_cpu( + t, y, dy, freqs, + ignore_negative_delta_sols=ignore_negative_delta_sols) + bf_power, _, _ = self._brute_force_bls( + t, y, dy, freq, + ignore_negative_delta_sols=ignore_negative_delta_sols) + + assert np.abs(power[0] - bf_power) < 1e-5, \ + f"sparse={power[0]:.8f}, brute={bf_power:.8f}" + + @pytest.mark.parametrize("freq", [1.0, 2.0]) + @pytest.mark.parametrize("q", [0.05, 0.1]) + @pytest.mark.parametrize("phi0", [0.0, 0.3, 0.5]) + @pytest.mark.parametrize("ndata", [100, 200]) + def test_sparse_bls_ground_truth(self, freq, q, phi0, ndata): + """Verify sparse_bls_cpu recovers a known injected transit.""" + t, y, dy = data(snr=50, q=q, phi0=phi0, freq=freq, + baseline=365., ndata=ndata) + + df = q / (10 * (max(t) - min(t))) + freqs = np.linspace(freq - 5 * df, freq + 5 * df, 21) + + power, sols = sparse_bls_cpu(t, y, dy, freqs) + + # Best frequency should be within the searched range + best_idx = np.argmax(power) + best_freq = freqs[best_idx] + T = max(t) - min(t) + assert np.abs(best_freq - freq) < q / T, \ + f"Expected freq~{freq}, got {best_freq}" + + # Verify solution is consistent with single_bls + q_found, phi_found = sols[best_idx] + p_single = single_bls(t, y, dy, best_freq, q_found, phi_found) + assert np.abs(power[best_idx] - p_single) < 1e-4, \ + f"sparse={power[best_idx]}, single_bls={p_single}" + + @pytest.mark.parametrize("freq", [1.0]) + @pytest.mark.parametrize("phi0", [0.95, 0.98]) + @pytest.mark.parametrize("q", [0.08, 0.1]) + @pytest.mark.parametrize("ndata", [80, 120]) + def test_sparse_bls_phase_wrapping(self, freq, phi0, q, ndata): + """Verify sparse_bls_cpu correctly finds transits that wrap phase 0/1.""" + t, y, dy = data(snr=50, q=q, phi0=phi0, freq=freq, + baseline=365., ndata=ndata) + + df = q / (10 * (max(t) - min(t))) + freqs = np.linspace(freq - 5 * df, freq + 5 * df, 21) + + power, sols = sparse_bls_cpu(t, y, dy, freqs) + + best_idx = np.argmax(power) + best_freq = freqs[best_idx] + + # Should find transit near the true frequency + T = max(t) - min(t) + assert np.abs(best_freq - freq) < q / T, \ + f"Expected freq~{freq}, got {best_freq}" + + # Power should be significant (SNR=50 should give high power) + assert power[best_idx] > 0.5, \ + f"Power too low: {power[best_idx]}" + + # Verify against brute-force at the best frequency + bf_power, _, _ = self._brute_force_bls(t, y, dy, best_freq) + assert np.abs(power[best_idx] - bf_power) < 1e-5, \ + f"sparse={power[best_idx]:.8f}, brute={bf_power:.8f}" + + @pytest.mark.parametrize("freq", [1.0, 2.0]) + @pytest.mark.parametrize("ndata", [50, 100]) + def test_sparse_bls_optimality(self, freq, ndata): + """Verify sparse_bls_cpu finds the global max (no pairs missed).""" + t, y, dy = data(snr=30, q=0.08, phi0=0.5, freq=freq, + baseline=365., ndata=ndata) + + freqs = np.array([freq], dtype=np.float32) + power, sols = sparse_bls_cpu(t, y, dy, freqs) + bf_power, _, _ = self._brute_force_bls(t, y, dy, freq) + + assert np.abs(power[0] - bf_power) < 1e-5, \ + f"sparse={power[0]:.8f} != brute={bf_power:.8f}" + + # ---- Sparse BLS q-bound (qmin/qmax) tests ---- + + @pytest.mark.parametrize("freq", [1.0, 2.0]) + @pytest.mark.parametrize("qbounds", [(0.02, 0.08), (0.05, 0.15)]) + @pytest.mark.parametrize("ndata", [50, 100]) + def test_sparse_bls_cpu_q_bounds_vs_brute(self, freq, qbounds, ndata): + """sparse_bls_cpu with q bounds matches the bounded brute force.""" + qmin, qmax = qbounds + t, y, dy = data(snr=30, q=0.1, phi0=0.4, freq=freq, + baseline=365., ndata=ndata) + + freqs = np.array([freq], dtype=np.float32) + power, sols = sparse_bls_cpu(t, y, dy, freqs, qmin=qmin, qmax=qmax) + bf_power, _, _ = self._brute_force_bls(t, y, dy, freq, + qmin=qmin, qmax=qmax) + + assert np.abs(power[0] - bf_power) < 1e-5, \ + f"sparse={power[0]:.8f}, brute={bf_power:.8f}" + q_found, _ = sols[0] + if power[0] > 0: + assert qmin <= q_found <= qmax + + def test_sparse_bls_cpu_q_bounds_change_solution(self): + """A qmax below the injected duration must exclude the + unbounded optimum (bounds demonstrably constrain the search).""" + t, y, dy = data(snr=50, q=0.2, phi0=0.3, freq=1.0, + baseline=365., ndata=100) + freqs = np.array([1.0]) + + power_free, sols_free = sparse_bls_cpu(t, y, dy, freqs) + power_bound, sols_bound = sparse_bls_cpu(t, y, dy, freqs, + qmin=0.01, qmax=0.05) + + assert sols_free[0][0] > 0.05 # unbounded finds the q~0.2 dip + assert power_bound[0] < power_free[0] + if power_bound[0] > 0: + assert 0.01 <= sols_bound[0][0] <= 0.05 + + def test_sparse_bls_cpu_q_bounds_per_frequency(self): + """Per-frequency qmin/qmax arrays bound each frequency + independently.""" + t, y, dy = data(snr=30, q=0.1, phi0=0.4, freq=1.0, + baseline=365., ndata=80) + freqs = np.array([0.8, 1.0, 1.25]) + qmins = np.array([0.01, 0.05, 0.02]) + qmaxes = np.array([0.05, 0.15, 0.3]) + + power, sols = sparse_bls_cpu(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes) + + for i in range(len(freqs)): + bf_power, _, _ = self._brute_force_bls( + t, y, dy, freqs[i], qmin=qmins[i], qmax=qmaxes[i]) + assert np.abs(power[i] - bf_power) < 1e-5, \ + f"freq={freqs[i]}: sparse={power[i]:.8f}, " \ + f"brute={bf_power:.8f}" + if power[i] > 0: + assert qmins[i] <= sols[i][0] <= qmaxes[i] + + def test_sparse_bls_cpu_q_bounds_bad_length_raises(self): + t, y, dy = data(ndata=50) + freqs = np.array([0.9, 1.0, 1.1]) + with pytest.raises(ValueError, match="qmin"): + sparse_bls_cpu(t, y, dy, freqs, qmin=np.array([0.01, 0.02])) + with pytest.raises(ValueError, match="qmax"): + sparse_bls_cpu(t, y, dy, freqs, qmax=np.array([0.1] * 5)) + + def test_sparse_bls_q_bounds_keyword_only(self): + """qmin/qmax were inserted mid-signature in v1.0: a pre-v1.0 + positional call like sparse_bls_cpu(t, y, dy, freqs, True) + (ignore_negative_delta_sols) would silently become qmin=True + -> qmin=1.0 > qmax and an all-zero periodogram. The bounds are + keyword-only so legacy positional calls fail loudly instead.""" + t, y, dy = data(ndata=50) + freqs = np.array([0.9, 1.0, 1.1]) + with pytest.raises(TypeError): + sparse_bls_cpu(t, y, dy, freqs, True) + with pytest.raises(TypeError): + sparse_bls_gpu(t, y, dy, freqs, False, 128) + + def test_use_simple_kernel_was_removed(self): + """The bubble-sort sparse kernel (sparse_bls_simple.cu) shipped + with the pre-PR#65 MAX_W_COMPLEMENT 1E-9 bound (powers up to + 4.6 in pure noise); it is gone and the old switch must fail + loudly on every entry point that used to accept it.""" + from ..bls import compile_sparse_bls + from ..utils import find_kernel + t, y, dy = data(ndata=50) + freqs = np.array([0.9, 1.0, 1.1]) + with pytest.raises(TypeError, match="use_simple"): + sparse_bls_gpu(t, y, dy, freqs, use_simple=True) + with pytest.raises(TypeError, match="use_simple"): + compile_sparse_bls(use_simple=False) + with pytest.raises(TypeError, match="use_simple"): + eebls_transit(t, y, dy, fmin=0.9, fmax=1.1, use_simple=True) + import os + assert not os.path.exists(find_kernel('sparse_bls_simple')) + + def test_sparse_bls_inverted_q_bounds_raise(self): + """qmin > qmax used to silently return an all-zero periodogram + (every candidate rejected) — a pipeline reads that as 'no + transit'. It must raise. Validation runs before any GPU work, + so the GPU variant is CPU-testable too.""" + t, y, dy = data(ndata=50) + freqs = np.array([0.9, 1.0, 1.1]) + for fn in (sparse_bls_cpu, sparse_bls_gpu): + with pytest.raises(ValueError, match="qmin > qmax"): + fn(t, y, dy, freqs, qmin=0.2, qmax=0.1) + with pytest.raises(ValueError, match="finite"): + fn(t, y, dy, freqs, qmin=np.nan) + with pytest.raises(ValueError, match="qmax"): + fn(t, y, dy, freqs, qmax=0.0) + + def test_sparse_bls_gpu_q_bounds(self): + """GPU sparse BLS honors per-frequency q bounds (matches CPU).""" + t, y, dy = data(snr=30, q=0.1, phi0=0.3, freq=1.0, + baseline=365., ndata=80) + freqs = np.linspace(0.95, 1.05, 11) + qmins = np.full(len(freqs), 0.03) + qmaxes = np.full(len(freqs), 0.2) + + power_cpu, _ = sparse_bls_cpu(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes) + power_gpu, sols_gpu = sparse_bls_gpu(t, y, dy, freqs, + qmin=qmins, qmax=qmaxes) + + assert_allclose(power_cpu, power_gpu, rtol=1e-3, atol=1e-5) + for (q_g, _), p in zip(sols_gpu, power_gpu): + if p > 0: + assert qmins[0] - 1e-6 <= q_g <= qmaxes[0] + 1e-6 + + @pytest.mark.parametrize("freq", [1.0, 2.0]) + @pytest.mark.parametrize("q", [0.02, 0.1]) + @pytest.mark.parametrize("phi0", [0.0, 0.5]) + @pytest.mark.parametrize("ndata", [50, 100]) + def test_sparse_bls_gpu(self, freq, q, phi0, ndata): + """Test GPU sparse BLS matches CPU and both match ground truth.""" + t, y, dy = data(snr=30, q=q, phi0=phi0, freq=freq, + baseline=365., ndata=ndata) + + df = q / (10 * (max(t) - min(t))) + freqs = np.linspace(freq - 5 * df, freq + 5 * df, 11) + + power_cpu, sols_cpu = sparse_bls_cpu(t, y, dy, freqs) + power_gpu, sols_gpu = sparse_bls_gpu(t, y, dy, freqs) + + # Powers should match closely across all frequencies + assert_allclose(power_cpu, power_gpu, rtol=1e-3, atol=1e-5, + err_msg=f"Power mismatch for freq={freq}, q={q}, phi0={phi0}") + + # Best powers should be close (argmax may differ due to float precision) + assert np.abs(np.max(power_cpu) - np.max(power_gpu)) < 1e-4, \ + f"Best power mismatch: cpu={np.max(power_cpu)}, gpu={np.max(power_gpu)}" + + @pytest.mark.parametrize("freq", [1.0]) + @pytest.mark.parametrize("phi0", [0.95]) + @pytest.mark.parametrize("q", [0.08]) + @pytest.mark.parametrize("ndata", [80]) + def test_sparse_bls_gpu_phase_wrapping(self, freq, phi0, q, ndata): + """Test GPU sparse BLS with wrapped transits matches CPU.""" + t, y, dy = data(snr=50, q=q, phi0=phi0, freq=freq, + baseline=365., ndata=ndata) + + df = q / (10 * (max(t) - min(t))) + freqs = np.linspace(freq - 5 * df, freq + 5 * df, 11) + + power_cpu, _ = sparse_bls_cpu(t, y, dy, freqs) + power_gpu, _ = sparse_bls_gpu(t, y, dy, freqs) + + assert_allclose(power_cpu, power_gpu, rtol=1e-4, atol=1e-6) + + # Both should find significant power + assert np.max(power_gpu) > 0.1 + + @pytest.mark.parametrize("ndata", [50, 100]) + @pytest.mark.parametrize("use_sparse_override", [None, True]) + def test_eebls_transit_auto_select(self, ndata, use_sparse_override): + """Test eebls_transit automatic selection with sparse BLS.""" + freq_true = 1.0 + q = 0.05 + phi0 = 0.3 + + t, y, dy = data(snr=30, q=q, phi0=phi0, freq=freq_true, + baseline=365., ndata=ndata) + + freqs, powers, sols = eebls_transit( + t, y, dy, + fmin=freq_true * 0.99, + fmax=freq_true * 1.01, + use_sparse=use_sparse_override, + sparse_threshold=150 + ) + + assert len(freqs) > 0 + assert len(powers) == len(freqs) + assert sols is not None + assert len(sols) == len(freqs) + + # The sparse statistic is piecewise constant in frequency (the + # power only changes when a point crosses a box edge): with 50 + # points the maximum is a plateau of ~20 grid frequencies + # spanning +-7 q/T around the injected frequency, and which of + # them argmax returns is a tie-break. Before the float64 + # centring (defect 8) float32 noise broke the tie by luck within + # 2 q/T. Require the found peak to lie on the float64 + # reference's maximum plateau, and that plateau to cover the + # injected frequency to within ~q/T (one phase-smear width). + qv = q_transit(freqs) + ref = _sparse_reference(t, y, dy, freqs, 0.5 * qv, 2.0 * qv) + plateau = ref >= ref.max() * (1 - 1e-5) + assert plateau[int(np.argmax(powers))] + T = max(t) - min(t) + assert np.min(np.abs(freqs[plateau] - freq_true)) < 2 * q / T + + @pytest.mark.parametrize("ndata", [50, 100]) + def test_eebls_transit_standard_returns_3(self, ndata): + """Test eebls_transit always returns 3 values, even with use_fast.""" + freq_true = 1.0 + q = 0.05 + phi0 = 0.3 + + t, y, dy = data(snr=30, q=q, phi0=phi0, freq=freq_true, + baseline=365., ndata=ndata) + + # use_fast=True should still return 3 values (sols=None) + result = eebls_transit( + t, y, dy, + fmin=freq_true * 0.99, + fmax=freq_true * 1.01, + use_sparse=False, + use_fast=True + ) + assert len(result) == 3 + freqs, powers, sols = result + assert sols is None + + +class TestHoneSolution(object): + """hone_solution refines an initial (f, q, phi) via successive + eebls_gpu_custom grids. This is the regression coverage for the + original-timescale phi convention through the whole custom chain: + trial phi values are passed in the original input timescale and the + kernel re-references them to the subtracted epoch. With the fixture + epoch (floor(min(t)) = 5) and freq = 0.7 the phase rotation + (epoch * freq) % 1 = 0.5 is maximal -- a convention slip anywhere + in the chain puts every trial box half a cycle off the transit.""" + + def test_hone_refines_and_matches_single_bls(self): + freq, q, phi0 = 0.7, 0.05, 0.3 + t, y, dy = data(snr=50, q=q, phi0=phi0, freq=freq, + baseline=365.) + + q0 = 1.3 * q + phi_start = phi0 + 0.03 + p_start = single_bls(t, y, dy, freq, q0, phi_start) + + f, pn, niter, (qs, phs) = hone_solution( + t, y, dy, freq, 1e-6, q0, 0.3, phi_start, + stop=1e-4, max_iter=10) + + # refinement must improve on the deliberately misaligned start + assert pn > p_start + + # the reported (f, q, phi) must reproduce the reported power + # through single_bls: custom-kernel boxes are exact (unbinned) + # box memberships, so agreement is at the float32-accumulation + # level. If phs were epoch-relative instead of original-scale, + # single_bls would evaluate a box 0.5 cycles from the transit + # and disagree at the 0.1-1 level. + p_check = single_bls(t, y, dy, f, qs, phs) + assert abs(pn - p_check) < 1e-3 * pn + 1e-4 + + # the refined box overlaps the injected transit in the ORIGINAL + # timescale (circular distance between box centers below q) + c_found = (phs + 0.5 * qs) % 1.0 + c_true = (phi0 + 0.5 * q) % 1.0 + dist = abs(c_found - c_true) + dist = min(dist, 1.0 - dist) + assert dist < q + + # frequency recovered to within a few phase-smear widths + assert abs(f - freq) * (np.max(t) - np.min(t)) / q < 3 + + +class TestEeblsTransitSparseKwargs(object): + """Regression tests: eebls_transit's sparse path must tolerate the + documented pass-through kwargs (rho, samples_per_peak, dlogq, ...) + instead of crashing with TypeError (sparse_bls_gpu has a closed + signature), and must honor the Keplerian q constraints.""" + + def _data(self, ndata=100): + t, y, dy = data(snr=20, q=0.05, phi0=0.3, freq=1.0, + baseline=365., ndata=ndata) + return t, y, dy + + def test_sparse_gpu_path_accepts_documented_kwargs(self): + # Before the fix: TypeError('sparse_bls_gpu() got an unexpected + # keyword argument "rho"') raised at call time, before any GPU + # work. Runs on a device; on CPU-only hosts the conftest turns + # the GPUStubError into a skip (it used to swallow every + # exception and so passed while asserting nothing). + t, y, dy = self._data() + freqs, powers, sols = eebls_transit(t, y, dy, rho=1.5, + samples_per_peak=2, + fmin=0.95, fmax=1.05, + use_gpu=True) + assert len(freqs) == len(powers) == len(sols) + assert len(freqs) > 0 + assert np.all(np.isfinite(powers)) + # the injected transit (freq = 1.0) is the peak + assert abs(freqs[np.argmax(powers)] - 1.0) < 0.01 + + def test_sparse_cpu_path_accepts_documented_kwargs(self): + t, y, dy = self._data() + freqs, powers, sols = eebls_transit(t, y, dy, rho=1.0, + fmin=0.95, fmax=1.05, + use_gpu=False) + assert len(freqs) == len(powers) + assert np.all(np.isfinite(powers)) + + def test_sparse_path_honors_q_constraints(self): + # The sparse path applies the same per-frequency Keplerian + # qmin_fac/qmax_fac bounds as the standard path (no more + # discontinuity warning across the sparse_threshold boundary). + import warnings as _warnings + t, y, dy = self._data() + qmin_fac, qmax_fac = 0.3, 1.5 + with _warnings.catch_warnings(): + _warnings.simplefilter("error", UserWarning) + freqs, powers, sols = eebls_transit( + t, y, dy, qmin_fac=qmin_fac, qmax_fac=qmax_fac, + fmin=0.95, fmax=1.05, use_gpu=False) + + qvals = q_transit(freqs) + for (q_found, _), p, qv in zip(sols, powers, qvals): + if p > 0: + assert qmin_fac * qv - 1e-6 <= q_found + assert q_found <= qmax_fac * qv + 1e-6 + + def test_standard_path_unaffected(self): + # No warning and no kwargs filtering on the standard path. + # Runs on a device (the conftest skips it on CPU-only hosts); + # a UserWarning is an error here, so "should not warn" is + # asserted rather than swallowed. + import warnings as _warnings + t, y, dy = self._data(ndata=100) + with _warnings.catch_warnings(): + _warnings.simplefilter("error", UserWarning) + freqs, powers, sols = eebls_transit(t, y, dy, fmin=0.95, + fmax=1.05, use_sparse=False) + assert len(freqs) == len(powers) == len(sols) + assert len(freqs) > 0 + assert np.all(np.isfinite(powers)) + assert abs(freqs[np.argmax(powers)] - 1.0) < 0.01 + + +class TestEeblsGpuFastNoverlap(object): + """eebls_gpu_fast(noverlap=k) must equal the elementwise max over + k dphi-shifted single passes (the manual re-run procedure the + docstring used to recommend; previously noverlap was silently + ignored on the fast path).""" + + def _data(self): + return data(snr=30, q=0.05, phi0=0.317, freq=1.0, + baseline=365., ndata=300) + + def test_noverlap_validation(self): + # Runs CPU-side: validation precedes any GPU work. + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 20) + for bad in (0, -1, 1.5, "2"): + with pytest.raises(ValueError, match="noverlap"): + eebls_gpu_fast(t, y, dy, freqs, noverlap=bad) + with pytest.raises(ValueError, match="noverlap"): + eebls_gpu_fast_optimized(t, y, dy, freqs, noverlap=0) + + @pytest.mark.parametrize("use_optimized", [False, True]) + def test_noverlap_matches_manual_dphi_runs(self, use_optimized): + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + fn = eebls_gpu_fast_optimized if use_optimized else eebls_gpu_fast + k = 3 + kw = dict(qmin=0.01, qmax=0.1, dlogq=0.2) + + power_k = fn(t, y, dy, freqs, noverlap=k, **kw) + manual = np.max([fn(t, y, dy, freqs, noverlap=1, + dphi=float(i) / k, **kw) + for i in range(k)], axis=0) + + assert_allclose(power_k, manual, rtol=1e-4, atol=1e-6) + + def test_noverlap_never_decreases_power(self): + # Pass 0 of the noverlap=3 run is exactly the noverlap=1 run, + # so the elementwise max can only gain power. + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + kw = dict(qmin=0.01, qmax=0.1) + + p1 = eebls_gpu_fast(t, y, dy, freqs, noverlap=1, **kw) + p3 = eebls_gpu_fast(t, y, dy, freqs, noverlap=3, **kw) + + assert np.all(p3 >= p1 - 1e-6) + + +class TestFusedNoverlapKernel(object): + """The fused-noverlap kernel (full_bls_no_sol_fused / + full_bls_batch_fused) replaces the dphi-shifted multi-pass host + loop for power-of-two noverlap with dphi == 0: it histograms once + at noverlap-times finer phase resolution and derives every pass's + box sums from runs of fine bins. Bin assignment is bit-identical + to the multi-pass launches on this path; box sums differ only at + float32 accumulation-order level (which the multi-pass path + already doesn't pin down, shared atomics being order-free).""" + + def _data(self, **kw): + kw.setdefault('snr', 30) + kw.setdefault('q', 0.05) + kw.setdefault('phi0', 0.317) + kw.setdefault('freq', 1.0) + kw.setdefault('baseline', 365.) + kw.setdefault('ndata', 300) + return data(**kw) + + @pytest.mark.parametrize("use_optimized,k", + list(product([False, True], [2, 4]))) + def test_fused_matches_manual_dphi_runs(self, use_optimized, k): + # For power-of-two k the fused kernel must reproduce the manual + # k-pass elementwise max (same gate as the multi-pass loop). + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + fn = eebls_gpu_fast_optimized if use_optimized else eebls_gpu_fast + kw = dict(qmin=0.01, qmax=0.1, dlogq=0.2) + + power_k = fn(t, y, dy, freqs, noverlap=k, **kw) + manual = np.max([fn(t, y, dy, freqs, noverlap=1, + dphi=float(i) / k, **kw) + for i in range(k)], axis=0) + + assert_allclose(power_k, manual, rtol=1e-4, atol=1e-6) + + def test_fused_nonzero_dphi_falls_back(self): + # dphi != 0 keeps the multi-pass path: noverlap=2 with base + # dphi=0.25 must equal the manual dphi = 0.25, 0.75 passes. + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + kw = dict(qmin=0.01, qmax=0.1) + + p = eebls_gpu_fast(t, y, dy, freqs, noverlap=2, dphi=0.25, **kw) + manual = np.max([eebls_gpu_fast(t, y, dy, freqs, noverlap=1, + dphi=0.25 + 0.5 * i, **kw) + for i in range(2)], axis=0) + assert_allclose(p, manual, rtol=1e-4, atol=1e-6) + + def test_fused_bjd_scale(self): + # BJD-scale timestamps (epoch ~2.455e6): the fused kernel must + # (i) keep the recovered peak at the same frequency as the + # epoch-subtracted input and (ii) match the manual dphi-shifted + # passes bit-tightly ON the BJD input. (Full periodogram + # correlation between BJD and non-BJD inputs is NOT gated at + # 0.999 here: the phase origin moves by epoch*f mod 1, so + # bin-edge quantization decorrelates off-peak power on the + # multi-pass path too -- measured corr 0.95 for the pre-fusion + # noverlap=3 loop on this exact dataset.) + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 500) + kw = dict(qmin=0.01, qmax=0.1) + t_bjd = t + 2455197.5 + + p0 = eebls_gpu_fast(t, y, dy, freqs, noverlap=2, **kw) + p1 = eebls_gpu_fast(t_bjd, y, dy, freqs, noverlap=2, **kw) + assert int(np.argmax(p0)) == int(np.argmax(p1)) + + manual = np.max([eebls_gpu_fast(t_bjd, y, dy, freqs, noverlap=1, + dphi=0.5 * i, **kw) + for i in range(2)], axis=0) + assert_allclose(p1, manual, rtol=1e-4, atol=1e-6) + + def test_batch_fused_matches_manual_passes(self): + from ..bls import eebls_gpu_batch + + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + kw = dict(qmin=0.01, qmax=0.1) + + p2 = eebls_gpu_batch([(t, y, dy)], freqs, noverlap=2, **kw)[0] + manual = np.max([eebls_gpu_batch([(t, y, dy)], freqs, + noverlap=1, + dphi=0.5 * i, **kw)[0] + for i in range(2)], axis=0) + assert_allclose(p2, manual, rtol=1e-4, atol=1e-6) + + +class TestBatchMemoryReuse(object): + """eebls_gpu_batch(memory=...) reuses one BLSBatchMemory across + calls and chunks (per-call pinned/device allocation costs several + ms at survey nfreq); results must match the allocate-per-call + path, including across chunked processing and back-to-back calls + with different data.""" + + @staticmethod + def _lcs(seeds, ndatas, baseline=365.0): + out = [] + for seed, nd in zip(seeds, ndatas): + rand = np.random.RandomState(seed) + t = np.sort(baseline * rand.rand(nd)) + 4.5 + phase = (t * 0.5) % 1.0 + y = 12.0 - 0.05 * (phase < 0.04) + y += 0.01 * rand.randn(nd) + dy = 0.01 * np.ones(nd) + out.append((t, y, dy)) + return out + + def test_memory_reuse_matches_fresh(self): + from ..bls import eebls_gpu_batch + from ..memory.bls_memory import BLSBatchMemory + import pycuda.driver as cuda + + freqs = np.linspace(0.1, 1.0, 500) + mem = BLSBatchMemory(400, 2, len(freqs), stream=cuda.Stream()) + + for seeds in ((1, 2), (3, 4)): + lcs = self._lcs(seeds, (200, 400)) + expect = eebls_gpu_batch(lcs, freqs) + got = eebls_gpu_batch(lcs, freqs, memory=mem) + for a, b in zip(expect, got): + assert_allclose(a, b, rtol=1e-4, atol=1e-6) + + def test_chunked_matches_single_chunk(self): + from ..bls import eebls_gpu_batch + + freqs = np.linspace(0.1, 1.0, 300) + lcs = self._lcs((5, 6, 7, 8, 9), (150, 220, 300, 80, 260)) + + p_one = eebls_gpu_batch(lcs, freqs) + p_chunks = eebls_gpu_batch(lcs, freqs, max_batch_lcs=2) + for a, b in zip(p_one, p_chunks): + assert_allclose(a, b, rtol=1e-4, atol=1e-6) + + def test_too_small_memory_raises(self): + from ..bls import eebls_gpu_batch + from ..memory.bls_memory import BLSBatchMemory + + freqs = np.linspace(0.1, 1.0, 100) + lcs = self._lcs((1,), (200,)) + mem = BLSBatchMemory(100, 1, len(freqs)) # max_ndata too small + with pytest.raises(ValueError, match="too small"): + eebls_gpu_batch(lcs, freqs, memory=mem) + + def test_freq_chunked_batch_matches(self): + # freq-chunked launches (occupancy-aware path) must reproduce + # the single-launch result; odd chunk size to catch + # offset/stride mistakes. + from ..bls import eebls_gpu_batch + + freqs = np.linspace(0.1, 1.0, 500) + lcs = self._lcs((1, 2), (200, 400)) + p_full = eebls_gpu_batch(lcs, freqs) + p_chunk = eebls_gpu_batch(lcs, freqs, freq_batch_size=97) + for a, b in zip(p_full, p_chunk): + assert_allclose(a, b, rtol=1e-4, atol=1e-6) + + def test_oversized_memory_reuse_matches(self): + # memory allocated for MORE freqs/LCs/ndata than the call uses: + # output row pitch is the allocation, results must still match. + from ..bls import eebls_gpu_batch + from ..memory.bls_memory import BLSBatchMemory + import pycuda.driver as cuda + + freqs = np.linspace(0.1, 1.0, 400) + lcs = self._lcs((3, 4), (150, 250)) + mem = BLSBatchMemory(600, 4, 900, stream=cuda.Stream()) + expect = eebls_gpu_batch(lcs, freqs) + got = eebls_gpu_batch(lcs, freqs, memory=mem) + for a, b in zip(expect, got): + assert len(a) == len(b) == len(freqs) + assert_allclose(a, b, rtol=1e-4, atol=1e-6) + + +class TestAllWeightBoxStability(object): + """Regression tests for the nondeterministic bogus-peak bug behind + PR #65's fabs(ybar) guard (attila's HATPI reproducer): bls_value's + upper w bound `1.f - 1e-10f` is a float32 no-op (compiles to + `w < 1.f`), so a trial box capturing ALL the statistical weight -- + routine for single-site data at ~1 cycle/day aliases with q up to + 0.5 -- divided atomic roundoff by atomic roundoff, producing + run-to-run-varying spurious power. The bound is now a meaningful + 1e-4 complement across bls_common.cuh / bls_batch.cu / + sparse_bls.cu / single_bls / sparse_bls_cpu.""" + + @staticmethod + def _single_site_data(n_nights=60, per_night=50, seed=21): + rand = np.random.RandomState(seed) + nights = np.arange(n_nights) + t = np.concatenate([n + 0.25 * np.sort(rand.rand(per_night)) + for n in nights]) + y = 12.0 + 0.01 * rand.randn(len(t)) + dy = 0.01 * np.ones_like(y) + return t, y, dy + + def test_single_bls_all_weight_box_is_zero(self): + # deterministic CPU check: at f = 1/day the whole lightcurve + # sits at phases < 0.25, so a q=0.5 box holds all the weight -- + # power must be exactly 0, not roundoff/roundoff + t, y, dy = self._single_site_data() + assert single_bls(t, y, dy, 1.0, 0.5, 0.0) == 0 + # a normal box is unaffected by the new bound + assert np.isfinite(single_bls(t, y, dy, 0.31, 0.05, 0.1)) + + def test_fast_path_repeatable_on_single_site_data(self): + # the GPU symptom: identical calls returned different + # periodograms (deviations > 1e-2, transient bogus peaks near + # 1 cycle/day). With the fixed bound the all-weight boxes score + # exactly 0 in every pass, so repeats must agree to float32 + # atomic-reordering noise and no order-0.01+ power appears in + # pure noise. + t, y, dy = self._single_site_data() + freqs = np.linspace(0.95, 1.05, 500) + kw = dict(qmin=0.01, qmax=0.5, noverlap=1) + p0 = eebls_gpu_fast(t, y, dy, freqs, **kw) + for _ in range(5): + p = eebls_gpu_fast(t, y, dy, freqs, **kw) + assert np.max(np.abs(p - p0)) < 1e-4 + assert np.max(p0) < 0.05 + + def test_shallow_transit_survives_w_bound(self): + # guard against "fixing" the instability with an absolute + # amplitude threshold instead (the PR #65 approach): a 500 ppm + # q=0.01 transit in normalized flux (kernel-internal + # s ~ 5e-6) must still be recovered. + rand = np.random.RandomState(42) + ndata, freq_inj, q_inj = 3000, 0.4, 0.01 + t = np.sort(370.0 * rand.rand(ndata)) + y = np.ones(ndata) - 5e-4 * ((t * freq_inj) % 1.0 < q_inj) + y += 1e-4 * rand.randn(ndata) + dy = 1e-4 * np.ones(ndata) + freqs = np.linspace(0.38, 0.42, 4001) + power = eebls_gpu_fast(t, y, dy, freqs, qmin=0.005, qmax=0.05) + fbest = freqs[int(np.argmax(power))] + assert abs(fbest - freq_inj) < 5 * (freqs[1] - freqs[0]) + + +class TestBatchFastParity(object): + """E1 regression: eebls_gpu_batch must match eebls_gpu_fast on the + same inputs. The batch kernel's noverlap argument is a no-op (like + the fast kernels', the A2 finding), so the batch launch is wrapped + in the same host-side dphi-shifted multi-pass; before that the + batch path was effectively noverlap=1 while the fast/adaptive + reference multi-passed, and the periodograms diverged at small + ndata (corr 0.77, peak match 5/10 at ndata=200 in the Jun 2026 + GPU benchmark).""" + + @pytest.mark.parametrize('ndata', [200, 2000]) + def test_batch_matches_fast(self, ndata): + from ..bls import eebls_gpu_batch, eebls_gpu_fast + + rand = np.random.RandomState(3) + baseline = 365.0 + freq_inj, q_inj, delta = 0.5, 0.03, 0.05 + t = np.sort(baseline * rand.rand(ndata)) + phase = (t * freq_inj) % 1.0 + y = 12.0 - delta * (phase < q_inj) + sigma = 0.01 + y += sigma * rand.randn(ndata) + dy = sigma * np.ones(ndata) + + freqs = np.linspace(0.1, 1.0, 5000) + p_fast = eebls_gpu_fast(t, y, dy, freqs) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + p_batch = eebls_gpu_batch([(t, y, dy)], freqs)[0] + + corr = float(np.corrcoef(p_fast, p_batch)[0, 1]) + assert corr > 0.999, corr + assert int(np.argmax(p_fast)) == int(np.argmax(p_batch)) + + def test_batch_noverlap_1_single_pass(self): + # noverlap=1 must reproduce the old single-pass behavior: + # everywhere <= the multi-pass result (elementwise max). + from ..bls import eebls_gpu_batch + + rand = np.random.RandomState(4) + ndata = 300 + t = np.sort(365.0 * rand.rand(ndata)) + y = 1.0 + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + freqs = np.linspace(0.1, 1.0, 2000) + + p1 = eebls_gpu_batch([(t, y, dy)], freqs, noverlap=1)[0] + p3 = eebls_gpu_batch([(t, y, dy)], freqs, noverlap=3)[0] + assert np.all(p3 >= p1 - 1e-7) + assert np.max(np.abs(p3 - p1)) > 0 + + +class TestPowerConventions(object): + """convert_bls_power + the convention= kwarg (#17): conversions + validated against astropy.timeseries.BoxLeastSquares definitions + on shared (period, duration, phase) solutions.""" + + def _data(self, ndata=120, freq=1.0, q=0.06, phi0=0.42, seed=7): + rand = np.random.RandomState(seed) + t = np.sort(365.0 * rand.rand(ndata)) + t -= np.floor(t.min()) + sigma = 0.01 + y = np.zeros(ndata) + phi = (t * freq) % 1.0 + y[(phi > phi0) & (phi < phi0 + q)] -= 12 * sigma / np.sqrt( + ndata * q) + y += sigma * rand.randn(ndata) + dy = sigma * np.ones(ndata) + return t, y, dy + + @staticmethod + def _chi2_0(y, dy): + w = np.power(np.asarray(dy, dtype=np.float64), -2) + ybar = np.dot(w, y) / np.sum(w) + return float(np.dot(w, (np.asarray(y) - ybar) ** 2)) + + def test_invalid_convention_raises(self): + from ..bls import convert_bls_power + t, y, dy = self._data() + with pytest.raises(ValueError, match="convention"): + convert_bls_power(0.5, y, dy, convention='banana') + with pytest.raises(ValueError, match="convention"): + sparse_bls_cpu(t, y, dy, np.array([1.0]), + convention='banana') + with pytest.raises(ValueError, match="convention"): + eebls_gpu_fast(t, y, dy, np.array([1.0]), + convention='banana') + # eebls_gpu_custom used to validate only at the return + # statement, i.e. AFTER the full GPU grid search. The + # ValueError (not a GPU error) must come before any GPU work. + with pytest.raises(ValueError, match="convention"): + eebls_gpu_custom(t, y, dy, np.array([1.0]), + q_values=np.array([0.05, 0.1]), + phi_values=np.linspace(0, 1, 10), + convention='banana') + + def test_chi2ratio_is_identity(self): + from ..bls import convert_bls_power + t, y, dy = self._data() + p = np.array([0.0, 0.1, 0.5]) + assert convert_bls_power(p, y, dy) is p + + def test_snr_uses_loaded_data_on_memory_reuse(self): + # A5 audit follow-up: on the memory-reuse path (memory= given, + # transfer_to_device=False) the y/dy ARGUMENTS may not be the + # data that produced the periodogram; the 'snr'/'loglik' + # scaling must come from the chi2_0 of the data actually + # loaded into the memory (recorded at setdata time), not from + # the arguments. + from ..bls import BLSMemory, eebls_gpu_fast + t, y, dy = self._data() + freqs = np.linspace(0.5, 1.5, 200) + + p_ref = eebls_gpu_fast(t, y, dy, freqs, convention='snr') + + mem = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True) + # deliberately junk arguments: must not affect the scaling + y_junk = 100.0 + 5.0 * y + dy_junk = 25.0 * dy + p_reuse = eebls_gpu_fast(t, y_junk, dy_junk, freqs, + memory=mem, + transfer_to_device=False, + convention='snr') + assert_allclose(p_reuse, p_ref, rtol=1e-6) + + def test_conversion_definitions(self): + from ..bls import convert_bls_power + t, y, dy = self._data() + chi2_0 = self._chi2_0(y, dy) + p = np.array([0.0, 0.05, 0.3]) + assert_allclose(convert_bls_power(p, y, dy, convention='snr'), + np.sqrt(chi2_0 * p)) + assert_allclose(convert_bls_power(p, y, dy, convention='loglik'), + 0.5 * chi2_0 * p) + + def _astropy_results(self, t, y, dy, objective): + astropy_ts = pytest.importorskip('astropy.timeseries') + model = astropy_ts.BoxLeastSquares(t, y, dy=dy) + periods = np.linspace(0.95, 1.05, 9) + durations = np.array([0.04, 0.06, 0.08]) + return model.power(periods, durations, method='slow', + oversample=10, objective=objective) + + def _our_power_at(self, t, y, dy, period, duration, transit_time): + # Evaluate the native power at astropy's exact solution. + # astropy's transit_time is mid-transit; single_bls takes phi0 + # (the transit START phase) in the ORIGINAL input timescale and + # re-references it to the subtracted epoch internally. + freq = 1.0 / period + q = duration / period + phi0 = ((transit_time - 0.5 * duration) * freq) % 1.0 + return single_bls(t, y, dy, freq, q, phi0), q + + def test_snr_matches_astropy(self): + from ..bls import convert_bls_power + t, y, dy = self._data() + res = self._astropy_results(t, y, dy, 'snr') + for i in range(len(res.period)): + p_native, _ = self._our_power_at( + t, y, dy, res.period[i], res.duration[i], + res.transit_time[i]) + snr = convert_bls_power(p_native, y, dy, convention='snr') + assert np.abs(snr - res.power[i]) <= 2e-3 * abs(res.power[i]), \ + f"period={res.period[i]}: ours={snr}, astropy={res.power[i]}" + + def test_loglik_matches_astropy_up_to_reference(self): + # astropy's likelihood objective uses the out-of-transit level + # as the null reference, so its power equals our 'loglik' + # (constant-weighted-mean reference) divided by (1 - r), with + # r the in-transit fraction of total statistical weight. + from ..bls import convert_bls_power + t, y, dy = self._data() + res = self._astropy_results(t, y, dy, 'likelihood') + w = np.power(dy, -2.0) + for i in range(len(res.period)): + p_native, q = self._our_power_at( + t, y, dy, res.period[i], res.duration[i], + res.transit_time[i]) + loglik = convert_bls_power(p_native, y, dy, convention='loglik') + + period, dur = res.period[i], res.duration[i] + hp = 0.5 * period + t0 = (res.transit_time[i] - t.min()) % period + m_in = np.abs((t - t.min() - t0 + hp) % period - hp) \ + < 0.5 * dur + r = np.sum(w[m_in]) / np.sum(w) + + expected = res.power[i] * (1.0 - r) + assert np.abs(loglik - expected) <= 2e-3 * abs(expected), \ + f"period={period}: ours={loglik}, astropy(1-r)={expected}" + + def test_sparse_cpu_convention_consistency(self): + from ..bls import convert_bls_power + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 11) + p_native, sols = sparse_bls_cpu(t, y, dy, freqs) + p_snr, sols_snr = sparse_bls_cpu(t, y, dy, freqs, + convention='snr') + assert_allclose(p_snr, convert_bls_power(p_native, y, dy, convention='snr'), + rtol=1e-6) + # solutions are convention-independent + assert sols == sols_snr + + def test_eebls_transit_sparse_path_convention(self): + t, y, dy = self._data() + freqs, p_native, _ = eebls_transit(t, y, dy, fmin=0.95, + fmax=1.05, use_gpu=False) + freqs2, p_loglik, _ = eebls_transit(t, y, dy, fmin=0.95, + fmax=1.05, use_gpu=False, + convention='loglik') + chi2_0 = self._chi2_0(y, dy) + assert_allclose(p_loglik, 0.5 * chi2_0 * p_native, rtol=1e-6) + + def test_gpu_entry_points_convention(self): + # GPU smoke test (pod): the kwarg flows through the standard + # and fast call chains and converts the returned host array. + from ..bls import convert_bls_power + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 50) + + p0, sols = eebls_gpu(t, y, dy, freqs, qmin=0.01, qmax=0.2) + p_snr, _ = eebls_gpu(t, y, dy, freqs, qmin=0.01, qmax=0.2, + convention='snr') + assert_allclose(p_snr, convert_bls_power(p0, y, dy, convention='snr'), + rtol=1e-4, atol=1e-6) + + f0 = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.2) + f_log = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.2, + convention='loglik') + assert_allclose(f_log, convert_bls_power(f0, y, dy, convention='loglik'), + rtol=1e-4, atol=1e-6) + + +class TestCompileBlsValidation(object): + """compile_bls should fail loudly on bad block sizes and on filter + results that would otherwise surface as confusing KeyErrors.""" + + def test_bad_block_size_raises(self): + from ..bls import _validate_block_size + for bad in (0, 16, 31, 48, 100, -64, 2.5, "256"): + with pytest.raises(ValueError): + _validate_block_size(bad) + for good in (32, 64, 128, 256, 512, 1024): + _validate_block_size(good) # should not raise + + def test_compile_bls_rejects_bad_block_size(self): + with pytest.raises(ValueError, match="block_size"): + compile_bls(block_size=48) + + def test_compile_bls_empty_filter_raises_value_error(self): + # full_bls_no_sol only exists in the standard kernel; requesting + # it alone with use_optimized=True used to produce an empty + # function dict and downstream KeyErrors. + with pytest.raises(ValueError, match="no loadable functions"): + compile_bls(function_names=['full_bls_no_sol'], + use_optimized=True) + with pytest.raises(ValueError, match="no loadable functions"): + compile_bls(function_names=['full_bls_no_sol_optimized'], + use_optimized=False) + + +class TestEpochHandling(object): + """Times must be epoch-subtracted before any float32 cast. + + With raw BJD-scale timestamps (~2.45e6 days), float32 phase folding + loses essentially all phase information: float32 carries ~7 + significant digits, so the fractional part of ``t * freq`` is + dominated by rounding error. All BLS paths subtract ``min(t)`` (in + float64) before casting; the phases they report are re-referenced + to the ORIGINAL input timescale (see + ``test_single_bls_phase_is_original_timescale``). + """ + + # Integer offset: epoch = floor(min(t)) makes the shifted and + # unshifted time arrays exactly identical, so powers must match to + # float rounding. (A fractional offset would rotate all phases by + # frac * freq mod 1 -- powers are invariant in exact math but bin + # alignments shift.) + bjd_offset = 2455197.0 + + def _signal(self, ndata=120, baseline=365., freq=0.3, q=0.05, + phi0=0.3, snr=50., sigma=0.01, seed=42): + rand = np.random.RandomState(seed) + t = baseline * np.sort(rand.rand(ndata)) + t -= t.min() # absolute and epoch-relative phases coincide + delta = snr * sigma / np.sqrt(ndata * q * (1 - q)) + phi = (t * freq) % 1.0 + y = -delta * ((phi > phi0) & (phi < phi0 + q)).astype(float) + y += sigma * rand.randn(ndata) + dy = sigma * np.ones(ndata) + return t, y, dy, freq, q, phi0 + + def test_single_bls_bjd_invariance(self): + # phi0 is now in the ORIGINAL input timescale, so a time-shifted + # run must use the covariantly shifted phase + # (phi0 + offset * freq) mod 1 to refer to the same transit. + t, y, dy, freq, q, phi0 = self._signal() + p_rel = single_bls(t, y, dy, freq, q, phi0) + phi0_raw = (phi0 + self.bjd_offset * freq) % 1.0 + p_raw = single_bls(t + self.bjd_offset, y, dy, freq, q, phi0_raw) + assert p_rel > 0.5 # signal actually detected + assert abs(p_raw - p_rel) < 1e-3 * p_rel + + def test_single_bls_phase_is_original_timescale(self): + # The convention itself: evaluating at the UNshifted phi0 on + # shifted times must MISS the transit (if it matched, phases + # would still be epoch-relative and the covariance test above + # would be vacuous). An integer-day offset o at freq=0.3 rotates + # the transit by (o * freq) mod 1 = 0.5 in phase, so the + # unshifted phi0 lands in pure out-of-transit noise. + t, y, dy, freq, q, phi0 = self._signal() + p_rel = single_bls(t, y, dy, freq, q, phi0) + p_wrong = single_bls(t + 4325.0, y, dy, freq, q, phi0) + assert p_rel > 0.5 + assert p_wrong < 0.25 * p_rel + + def test_sparse_bls_cpu_bjd_invariance(self): + t, y, dy, freq, q, phi0 = self._signal(ndata=60) + freqs = np.array([0.9 * freq, freq, 1.1 * freq]) + p_rel, _ = sparse_bls_cpu(t, y, dy, freqs) + p_raw, _ = sparse_bls_cpu(t + self.bjd_offset, y, dy, freqs) + assert p_rel[1] > 0.5 + assert_allclose(p_raw, p_rel, rtol=1e-3, atol=1e-4) + + def test_bls_memory_epoch_subtraction(self): + # Runs on GPU only (BLSMemory allocates pinned arrays); the + # conftest stub converts it to a skip on CPU-only machines. + from ..bls import BLSMemory + t, y, dy, freq, q, phi0 = self._signal() + freqs = np.linspace(0.2, 0.4, 10) + mem = BLSMemory.fromdata(t + self.bjd_offset, y, dy, + qmin=1e-2, qmax=0.5, freqs=freqs, + transfer=False) + # staging buffers hold the samples in conflict-scattered order + # (utils.conflict_scatter_perm); compare as sets via sort + assert_allclose(np.sort(mem.t[:len(t)]), + np.sort(t.astype(np.float32)), atol=1e-3) + assert mem.epoch == pytest.approx( + np.floor(self.bjd_offset + t.min())) + + def test_bls_batch_memory_epoch_subtraction(self): + # Runs on GPU only (pinned host arrays); skipped on CPU. + from ..memory.bls_memory import BLSBatchMemory + t, y, dy, freq, q, phi0 = self._signal() + mem = BLSBatchMemory(len(t), 1, 8) + mem.set_lightcurve(0, t + self.bjd_offset, y, dy) + # staging buffers hold the samples in conflict-scattered order + # (utils.conflict_scatter_perm); compare as sets via sort + assert_allclose(np.sort(mem.t[:len(t)]), + np.sort(t.astype(np.float32)), atol=1e-3) + assert mem.epochs[0] == pytest.approx( + np.floor(self.bjd_offset + t.min())) + + def test_eebls_gpu_bjd_invariance(self): + # Full GPU path; skipped on CPU-only machines. + t, y, dy, freq, q, phi0 = self._signal() + freqs = np.linspace(0.95 * freq, 1.05 * freq, 50) + p_rel, _ = eebls_gpu(t, y, dy, freqs, qmin=0.01, qmax=0.1) + p_raw, _ = eebls_gpu(t + self.bjd_offset, y, dy, freqs, + qmin=0.01, qmax=0.1) + # the binned estimator peaks well below the exact box power + # (~0.48 vs ~0.95 here); 0.3 still clears the ~0.15 noise floor + assert max(p_rel) > 0.3 + assert_allclose(p_raw, p_rel, rtol=1e-3, atol=1e-3) + + +class TestReductionMaxValidation(object): + """_reduction_max used to 'validate' block_size with an assert that + is always true under Python 3 division; a mismatched block_size + silently corrupts the tree reduction on the GPU.""" + + class _FakePtr(object): + ptr = 0 + + class _FakeKernel(object): + def __init__(self): + self.calls = [] + + def prepared_async_call(self, *args): + self.calls.append(args) + + def _call(self, block_size): + from ..bls import _reduction_max + kern = self._FakeKernel() + _reduction_max(kern, self._FakePtr(), self._FakePtr(), + 4, 64, None, self._FakePtr(), self._FakePtr(), + 0, block_size) + return kern + + def test_non_power_of_two_block_size_raises(self): + for bad in (48, 100, 0, -64, 2.5, "256"): + with pytest.raises(ValueError): + self._call(bad) + + def test_valid_block_size_launches(self): + kern = self._call(64) + assert len(kern.calls) >= 1 + + +class TestSparseBlsCpuVectorized: + """sparse_bls_cpu used to be a pure-Python O(N^3) loop (each pair + recomputed its slice sum) — minutes per frequency at the + ndata=500 sparse threshold. The vectorized scan must stay fast.""" + + def test_moderate_ndata_runs_in_seconds(self): + import time + t, y, dy = data(snr=20, q=0.05, phi0=0.4, freq=1.0, + baseline=365., ndata=250) + start = time.time() + power, _ = sparse_bls_cpu(t, y, dy, + np.array([0.9, 1.0, 1.1])) + elapsed = time.time() - start + assert elapsed < 10.0 # pre-vectorization: minutes + assert int(np.argmax(power)) == 1 + + +class TestPinnedBufferStreamParity(object): + """With page-locked host result buffers, device->host copies on a + user stream are genuinely asynchronous. BLSMemory.transfer_data_to_cpu + used to normalize (bls /= yy) right after enqueueing get_async, racing + the DMA — the returned periodogram could be unnormalized or torn. + Results on a user stream must match the default-stream results.""" + + @pytest.mark.parametrize("use_optimized", [False, True]) + def test_fast_path_stream_matches_default(self, use_optimized): + import pycuda.driver as cuda + from ..core import ensure_context + + t, y, dy = data(snr=30, q=0.05, phi0=0.317, freq=1.0, + baseline=365., ndata=300) + freqs = np.linspace(0.95, 1.05, 200) + fn = eebls_gpu_fast_optimized if use_optimized else eebls_gpu_fast + + p_default = fn(t, y, dy, freqs) + ensure_context() + p_stream = fn(t, y, dy, freqs, stream=cuda.Stream()) + + # rtol only needs to catch the failure modes (unnormalized: + # off by the factor 1/yy; torn: garbage), not atomic-order + # jitter between runs. + assert_allclose(p_stream, p_default, rtol=1e-3) + + +class TestBlsBatchSizing(object): + """Defect 1 of the Sep 2026 audit (``bls-overflow-oob``), the + default ``eebls_transit`` path for ndata >= 500 (``eebls_gpu``): + + (a) the fold kernels indexed their ``ndata * nfreq`` threads in 32 + bits while the host launched the exact product with an + uncapped auto batch, so a TESS 2-min year (262,800 points x an + 18,551-frequency batch = 4.9e9 > 2^32) silently returned + 65,372 zero powers, a power of 1.678 (> 1) and the wrong peak; + (b) the device bin buffers were sized from + ``count_tot_nbins(grid-wide min nbins0, grid-wide max nbinsf)``, + which is NOT an upper bound over batches (``count_tot_nbins`` + is non-monotone in ``nbins0``), so a Keplerian-q batched grid + could overrun its buffers: ``eebls_transit(t, y, dy, fmin=0.02, + fmax=0.5)`` on 70,000 points died with ``illegal memory + access``. + + The kernels now index in 64 bits, the host caps ``freq_batch_size`` + at ``len(freqs)`` and ``(2^31 - 1) // ndata``, and the batch table + is built before allocating so the buffers are sized from the + actual maximum over batches. + """ + + # ---- pure-CPU checks of the sizing helpers ---- + + def test_count_tot_nbins_is_not_monotone_in_nbins0(self): + # the property that broke the old sizing (audit's numbers) + assert [count_tot_nbins(nb0, 359, 0.2) for nb0 in (28, 29, 30)] \ + == [1875, 1939, 1704] + + def test_batch_table_sizes_from_the_actual_batches(self): + # batch 0 starts at nbins0 = 29 (1939 cells per frequency) + # although the grid-wide minimum nbins0 is 28 (1875 cells): the + # old gs = freq_batch_size * 1875 * noverlap under-allocated + # batch 0 and the fold kernel's atomics ran off the buffer + nbins0 = np.array([29] * 5 + [28] * 5 + [30] * 5) + nbinsf = np.full(15, 359) + noverlap = 3 + assert list(_per_freq_nbins_tot(nbins0, nbinsf, 0.2)) \ + == [1939] * 5 + [1875] * 5 + [1704] * 5 + table = _bls_batch_table(nbins0, nbinsf, 5, 0.2) + assert [(b[0], b[1]) for b in table] == [(0, 5), (5, 10), (10, 15)] + assert [b[2] for b in table] == [1939, 1875, 1704] + + old_gs = 5 * count_tot_nbins(int(nbins0.min()), int(nbinsf.max()), + 0.2) * noverlap + new_gs = max((b[1] - b[0]) * b[2] for b in table) * noverlap + batch0_bins = 5 * table[0][2] * noverlap + assert batch0_bins > old_gs # the overrun + assert batch0_bins <= new_gs # the fix + + # a mixed batch: the stride is the per-frequency maximum, NOT + # the count of the batch-wide (min nb0, max nbf) collapse + # (1875 here, less than the 1939 cells its nb0 = 29 members + # need) + table = _bls_batch_table(nbins0, nbinsf, 7, 0.2) + assert [(b[0], b[1]) for b in table] == [(0, 7), (7, 14), (14, 15)] + assert [b[2] for b in table] == [1939, 1875, 1704] + + # the memory-budget estimate is an upper bound over batches + assert _max_nbins_tot(nbins0, nbinsf, 0.2) >= max(b[2] + for b in table) + + def test_batch_table_last_batch_and_uneven_grids(self): + nbins0 = np.array([4, 4, 2, 2, 2, 8, 8]) + nbinsf = np.array([50, 40, 60, 60, 20, 100, 100]) + per_f = [count_tot_nbins(a, b, 0.3) for a, b in zip(nbins0, nbinsf)] + assert list(_per_freq_nbins_tot(nbins0, nbinsf, 0.3)) == per_f + table = _bls_batch_table(nbins0, nbinsf, 3, 0.3) + assert [(b[0], b[1]) for b in table] == [(0, 3), (3, 6), (6, 7)] + assert [b[2] for b in table] == [max(per_f[0:3]), max(per_f[3:6]), + per_f[6]] + with pytest.raises(ValueError): + _bls_batch_table(nbins0, nbinsf, 0, 0.3) + + def test_max_nbins_tot_bounds_every_batching_of_a_keplerian_grid(self): + # HAT-like Keplerian grid with 0.5 q .. 2 q bounds, as + # eebls_transit builds it: every batch of every batch size + # needs at most the estimated number of cells + freqs, qvals = keplerian_freq_grid(0.5, 100., 3650., + oversampling=2, + return_qvals=True) + qvals = qvals.astype(np.float64)[:5000] + nbins0, nbinsf = _q_bounds_to_nbins(0.5 * qvals, 2.0 * qvals) + for dlogq in (0.2, 0.3, -1.0): + bound = _max_nbins_tot(nbins0, nbinsf, dlogq) + for fbs in (1, 7, 100, 1234, len(qvals)): + table = _bls_batch_table(nbins0, nbinsf, fbs, dlogq) + assert max(b[2] for b in table) <= bound + + def test_cap_freq_batch_size(self): + # (2^31 - 1) // ndata: the audit's 66,000-point case + assert _cap_freq_batch_size(10 ** 9, 66000, 10 ** 9) \ + == _MAX_FOLD_THREADS // 66000 == 32537 + assert 66000 * 32537 <= 2 ** 31 - 1 < 66000 * 32538 + # never more than the grid + assert _cap_freq_batch_size(500, 100, 300) == 300 + # never less than one frequency + assert _cap_freq_batch_size(0, 100, 300) == 1 + # a sane request is left alone + assert _cap_freq_batch_size(5, 100, 300) == 5 + + def test_q_bounds_to_nbins(self): + nb0, nbf = _q_bounds_to_nbins([0.01, 0.02], [0.5, 0.25]) + assert list(nb0) == [2, 4] and list(nbf) == [100, 50] + with pytest.raises(ValueError, match="qmin must be > 0"): + _q_bounds_to_nbins([0.0], [0.5]) + with pytest.raises(ValueError, match="qmax must be <= 1"): + _q_bounds_to_nbins([0.1], [1.5]) + + def test_eebls_gpu_rejects_bad_bounds_before_any_gpu_work(self): + # used to be a ZeroDivisionError (qmin > qmax) or a device + # divide-by-zero (qmax > 1); validation now precedes the compile, + # so this runs on CPU-only machines too + t, y, dy = data(ndata=50) + freqs = np.array([0.9, 1.0, 1.1]) + with pytest.raises(ValueError, match="qmin > qmax"): + eebls_gpu(t, y, dy, freqs, qmin=0.2, qmax=0.1) + with pytest.raises(ValueError, match="qmax must be <= 1"): + eebls_gpu(t, y, dy, freqs, qmin=0.1, qmax=2.0) + with pytest.raises(ValueError, match="qmin must be > 0"): + eebls_gpu(t, y, dy, freqs, qmin=0.0, qmax=0.5) + with pytest.raises(ValueError, match="qmin"): + eebls_gpu(t, y, dy, freqs, qmin=np.array([0.01, 0.02])) + + # ---- GPU ---- + + @staticmethod + def _big_lc(ndata=131072, seed=1): + rng = np.random.RandomState(seed) + t = np.sort(rng.uniform(0, 30., ndata)) + y = 1 - 0.01 * (((t * 0.5) % 1) < 0.3) + 0.002 * rng.randn(ndata) + dy = np.full(ndata, 0.002) + return t, y, dy + + def test_fold_kernel_index_is_64_bit(self): + # Direct launch of bin_and_phase_fold_bst_multifreq with + # ndata * nfreq = 131072 * 32769 = 4.295e9 > 2^32 (the host + # entry points now cap the batch, so only a direct launch + # reaches this). With the old 32-bit bound `i < ndata * nfreq` + # the product wrapped to 65536: only half of frequency 0's + # points were binned and every other frequency stayed empty. + # One q level of 1024 bins keeps the atomics cheap (~1 s). + import pycuda.gpuarray as gpuarray + from ..bls import _default_block_size + ndata, nf, nb = 131072, 32769, 1024 + assert ndata * nf > 2 ** 32 + t, y, dy = self._big_lc(ndata) + t32 = (t - np.floor(t.min())).astype(np.float32) + rng = np.random.RandomState(5) + yw = (1e-4 * rng.randn(ndata)).astype(np.float32) + w = np.full(ndata, 1. / ndata, dtype=np.float32) + freqs = np.linspace(0.3, 0.7, nf).astype(np.float32) + + funcs = compile_bls( + function_names=['bin_and_phase_fold_bst_multifreq']) + func = funcs['bin_and_phase_fold_bst_multifreq'] + t_g, yw_g, w_g, f_g = (gpuarray.to_gpu(a) + for a in (t32, yw, w, freqs)) + nb_g = gpuarray.to_gpu(np.full(nf, nb, dtype=np.uint32)) + yw_bin = gpuarray.zeros(nf * nb, np.float32) + w_bin = gpuarray.zeros(nf * nb, np.float32) + bs = _default_block_size + grid = (int(np.ceil(float(ndata) * nf / bs)), 1) + args = (t_g.ptr, yw_g.ptr, w_g.ptr, yw_bin.ptr, w_bin.ptr, f_g.ptr, + nb_g.ptr, nb_g.ptr) + func.prepared_call(grid, (bs, 1, 1), *args, np.uint32(ndata), + np.uint32(nf), np.uint32(0), np.uint32(1), + np.float32(0.2), np.uint32(nb)) + wb = w_bin.get() + ywb = yw_bin.get() + + # float32 fold replica (bit-identical to the kernel's + # mod1(t * f) / floorf(nb * phi) for dphi = 0); check the first, + # a middle and the LAST frequency -- the last one's threads all + # lie beyond the 2^32 boundary + for k in (0, nf // 2, nf - 1): + phi = np.float32(t32 * freqs[k]) + phi = phi - np.floor(phi) + b = np.floor(np.float32(nb) * phi).astype(np.int64) % nb + ref_w = np.bincount(b, weights=w.astype(np.float64), + minlength=nb) + ref_yw = np.bincount(b, weights=yw.astype(np.float64), + minlength=nb) + assert_allclose(wb[k * nb:(k + 1) * nb], ref_w, + rtol=1e-5, atol=1e-9) + assert_allclose(ywb[k * nb:(k + 1) * nb], ref_yw, + rtol=1e-3, atol=1e-8) + # nothing was binned outside the requested cells, and every + # frequency saw all the weight + assert_allclose(wb.reshape(nf, nb).sum(axis=1), 1.0, rtol=1e-4) + + def test_eebls_gpu_above_2_32_threads_matches_safe_batching(self): + # eebls_gpu with a user-supplied freq_batch_size whose + # ndata * batch exceeds 2^32 (before the fix: zeros / powers > 1; + # the audit's 66,000 x 66,000 case had corr -0.003 with the + # correct periodogram). One q level of 1024 bins keeps the two + # full-grid runs to well under a second each. + ndata, nf = 131072, 32769 + t, y, dy = self._big_lc(ndata) + freqs = np.linspace(0.3, 0.7, nf) + q = 1. / 1024 + kw = dict(qmin=q, qmax=q, noverlap=1) + p_big, sols_big = eebls_gpu(t, y, dy, freqs, freq_batch_size=nf, + **kw) + p_safe, sols_safe = eebls_gpu(t, y, dy, freqs, + freq_batch_size=4096, **kw) + assert not np.any(p_big == 0) + assert np.all(p_big <= 1.0) + # The two batchings sum the same float32 shared-memory atomics + # in a different order, so near-zero powers differ by more than + # a 1e-4 relative tolerance (observed: 1.472e-4 vs 1.438e-4 on + # one of 32,769 frequencies). atol is set an order of magnitude + # above that floor; the peak, its location and the overflow + # invariants above are what this test is really guarding. + assert_allclose(p_big, p_safe, rtol=1e-4, atol=1e-5) + assert np.argmax(p_big) == np.argmax(p_safe) + assert np.corrcoef(p_big, p_safe)[0, 1] > 0.9999 + + def test_eebls_gpu_keplerian_batches_do_not_overrun(self): + # The audit's reproducer for (b): HAT-like Keplerian grid + # (keplerian_freq_grid(0.5, 100, 3650), first 20,000 + # frequencies, qmin = 0.5 q, qmax = 2 q), 600 points, + # freq_batch_size = 2435 (what a 1.5 GB budget gave the old + # sizing). Batch 0 starts at nbins0 = 131 and needs 2706 cells + # per frequency while the old buffers held 2565 (the grid-wide + # (81, 570) count): `illegal memory access` before the fix. + freqs, qvals = keplerian_freq_grid(0.5, 100., 3650., + oversampling=2, + return_qvals=True) + freqs = freqs.astype(np.float64)[:20000] + qvals = qvals.astype(np.float64)[:20000] + qmins, qmaxes = 0.5 * qvals, 2.0 * qvals + nbins0, nbinsf = _q_bounds_to_nbins(qmins, qmaxes) + fbs, dlogq, noverlap = 2435, 0.2, 3 + table = _bls_batch_table(nbins0, nbinsf, fbs, dlogq) + old_cells = count_tot_nbins(int(nbins0.min()), int(nbinsf.max()), + dlogq) + # the configuration really is one the old sizing overran: its + # batch-0 collapse (131, 570) needs more cells than the + # grid-wide (81, 570) collapse the buffers were sized from + nb0_b0 = int(nbins0[:fbs].min()) + nbf_b0 = int(nbinsf[:fbs].max()) + assert (nb0_b0, nbf_b0) == (131, 570) + assert count_tot_nbins(nb0_b0, nbf_b0, dlogq) > old_cells + # the per-frequency stride is what is allocated now + assert table[0][2] == int(np.max(_per_freq_nbins_tot( + nbins0[:fbs], nbinsf[:fbs], dlogq))) + + rng = np.random.RandomState(0) + ndata = 600 + t = np.sort(rng.uniform(0, 3650., ndata)) + y = 1 + 0.002 * rng.randn(ndata) + dy = np.full(ndata, 0.002) + p, sols = eebls_gpu(t, y, dy, freqs, qmin=qmins, qmax=qmaxes, + freq_batch_size=fbs, dlogq=dlogq, + noverlap=noverlap) + assert np.all(np.isfinite(p)) + assert np.all((p >= 0) & (p <= 1)) + assert len(sols) == len(freqs) + qs = np.array([s[0] for s in sols]) + assert np.all(qs >= 1. / nbinsf - 1e-6) + assert np.all(qs <= 1. / nbins0 + 1e-6) + + def test_eebls_gpu_small_grid_allocates_only_what_it_needs(self): + # finding 135 / plan item BLS-2: a 300-frequency grid used to + # allocate scratch for the ~100K-frequency batch the free + # memory allowed (4 arrays x 5 streams x ~0.9 x free). The + # batch is now capped at len(freqs), so the scratch buffers + # hold exactly nfreq * cells * noverlap floats, and one scratch + # set per batch (not per stream) is allocated. The periodogram + # is unchanged (scalar q: batch boundaries never change it). + import cuvarbase.bls as B + t, y, dy = data(snr=10, q=0.05, phi0=0.3, freq=1.0, baseline=365.) + freqs = np.linspace(0.95, 1.05, 300) + qmin, qmax, noverlap, dlogq = 0.01, 0.1, 3, 0.2 + need = len(freqs) * count_tot_nbins(10, 100, dlogq) * noverlap + + sizes = [] + real = B.gpuarray + + class Recorder(object): + to_gpu = staticmethod(real.to_gpu) + maximum = staticmethod(real.maximum) + + @staticmethod + def zeros(n, dtype=np.float32): + sizes.append(int(n)) + return real.zeros(n, dtype=dtype) + + B.gpuarray = Recorder + try: + p, sols = eebls_gpu(t, y, dy, freqs, qmin=qmin, qmax=qmax, + noverlap=noverlap, dlogq=dlogq) + finally: + B.gpuarray = real + assert max(sizes) == need + # single batch -> one scratch set of 4 arrays (+ the 4 + # per-frequency result arrays) + assert sizes.count(need) == 4 + + p2, sols2 = eebls_gpu(t, y, dy, freqs, qmin=qmin, qmax=qmax, + noverlap=noverlap, dlogq=dlogq, + freq_batch_size=50) + assert_allclose(p, p2, rtol=1e-4, atol=1e-6) + + +class TestPerFrequencyQBounds(object): + """Defect 7 of the Sep 2026 audit (``bls-q-collapse``): ``eebls_gpu`` + reduced per-frequency ``qmin``/``qmax`` arrays to one scalar pair + per batch (the batch-wide min/max) because the binned kernels took + scalar bin counts per launch, so every frequency in a batch was + searched over ``floor(1/max qmax) .. ceil(1/min qmin)`` bins: + 2670/3049 Keplerian-grid solutions fell outside their own window + and the result changed with ``freq_batch_size`` / free memory. + The kernels now read per-frequency bin-count arrays, and the + ``eebls_transit`` default (ndata >= sparse_threshold) runs the fast + kernel (which always honoured the bounds) with a top-K solution + pass. + """ + + @staticmethod + def _lc(ndata=1200, baseline=200., freq=0.2, q=0.03, phi0=0.6, + snr=12., seed=11, sigma=0.01): + rng = np.random.RandomState(seed) + t = np.sort(rng.uniform(0, baseline, ndata)) + 100.3 + delta = snr * sigma / np.sqrt(ndata * q * (1 - q)) + y = 12. - delta * (((t * freq) - phi0) % 1.0 < q) + y += sigma * rng.randn(ndata) + dy = np.full(ndata, sigma) + return t, y, dy + + # ---- CPU: the fast-kernel box scan used for the solution pass ---- + + def test_fast_box_scan_matches_brute_force_over_the_kernel_grid(self): + # every (q, phi) the fast kernel searches at one frequency is + # q = m / nbf, phi0 = (n + s / noverlap) / nbf; the scan must + # return the box single_bls scores highest + from ..utils import subtract_epoch + t, y, dy = self._lc(ndata=80, baseline=30., freq=1.0, q=0.1, + phi0=0.3, snr=20., seed=3) + freq = 1.0 + qmin, qmax, dlogq, noverlap = 0.05, 0.25, 0.3, 2 + t64, epoch = subtract_epoch(t) + w = dy ** -2 + w /= w.sum() + ybar = np.dot(w, y) + YY = np.dot(w, (y - ybar) ** 2) + t32 = t64.astype(np.float32) + nb0, nbf = _fast_path_nbins(np.float32([freq]), qmin, qmax) + nb0, nbf = int(nb0[0]), int(nbf[0]) + assert (nb0, nbf) == (4, 20) + + val, q, phi = _fast_bls_box_scan( + t32, ((y - ybar) * w).astype(np.float32), + w.astype(np.float32), np.float32(freq), nb0, nbf, dlogq, + noverlap) + p_scan = val / YY + + # brute force over the same grid, in the original timescale. + # The ladder runs up to and including nbf // nb0 = 5 (q = 0.25 + # = qmax); before the id-64 fix it stopped at 4 (q = 0.2). + ms, m = [], 1 + while m <= nbf // nb0: + ms.append(m) + m += m * 3 // 10 if m * 3 // 10 > 0 else 1 + assert ms[-1] == nbf // nb0 and ms[-1] / nbf == qmax + best = 0. + for s_pass in range(noverlap): + for m in ms: + for n in range(nbf): + phi0 = ((n + s_pass / noverlap) / nbf + + epoch * freq) % 1.0 + best = max(best, single_bls(t, y, dy, freq, m / nbf, + phi0)) + assert abs(p_scan - best) < 1e-5 * max(best, 1e-3) + # and the returned (q, phi) reproduces that power + p_sol = single_bls(t, y, dy, freq, q, + (phi + epoch * freq) % 1.0) + assert abs(p_sol - p_scan) < 1e-5 * max(best, 1e-3) + assert q in [mm / nbf for mm in ms] + + def test_fast_solutions_selects_the_top_k_and_marks_the_rest(self): + t, y, dy = self._lc(ndata=150, baseline=30., freq=1.0, q=0.1, + phi0=0.3, snr=20., seed=4) + freqs = np.linspace(0.9, 1.1, 41) + powers = np.exp(-0.5 * ((freqs - 1.0) / 0.01) ** 2) + sols = _fast_bls_solutions(t, y, dy, freqs, powers, 0.05, 0.25, 5, + dlogq=0.3, noverlap=2) + assert len(sols) == len(freqs) + filled = [i for i, s_ in enumerate(sols) if s_ is not None] + assert set(filled) == set(np.argsort(-powers)[:5]) + assert 20 in filled + for i in filled: + q, phi = sols[i] + assert 0.05 <= q <= 0.25 and 0. <= phi < 1. + # zero-power frequencies get no solution; K = 0 -> all None + assert all(s_ is None for s_ in + _fast_bls_solutions(t, y, dy, freqs, np.zeros(41), + 0.05, 0.25, 5)) + assert all(s_ is None for s_ in + _fast_bls_solutions(t, y, dy, freqs, powers, + 0.05, 0.25, 0)) + + # ---- GPU: eebls_gpu with per-frequency bounds ---- + + def test_eebls_gpu_array_bounds_are_honoured_per_frequency(self): + # two frequencies with disjoint windows in ONE batch; the + # injected transit at f = 0.05 has q = 0.15, outside that + # frequency's [0.01, 0.02] window. The old batch-wide collapse + # ([0.01, 0.2]) found q ~ 0.15 there. + t, y, dy = self._lc(ndata=1200, baseline=200., freq=0.05, q=0.15, + phi0=0.2, snr=40., seed=5) + freqs = np.array([0.05, 2.5]) + qmins = np.array([0.01, 0.10]) + qmaxes = np.array([0.02, 0.20]) + nb0, nbf = _q_bounds_to_nbins(qmins, qmaxes) + + p, sols = eebls_gpu(t, y, dy, freqs, qmin=qmins, qmax=qmaxes) + p1, sols1 = eebls_gpu(t, y, dy, freqs, qmin=qmins, qmax=qmaxes, + freq_batch_size=1) + for i in range(2): + assert 1. / nbf[i] - 1e-6 <= sols[i][0] <= 1. / nb0[i] + 1e-6 + # one frequency per batch always had per-frequency semantics: + # the default batching must now agree with it + assert_allclose(p, p1, rtol=1e-5, atol=1e-7) + assert [s_[0] for s_ in sols] == [s_[0] for s_ in sols1] + # the wide (unconstrained) box the collapse used to return + p_wide, sols_wide = eebls_gpu(t, y, dy, freqs[:1], qmin=0.01, + qmax=0.2) + assert sols_wide[0][0] > 0.1 and p_wide[0] > p[0] + + def test_eebls_gpu_keplerian_grid_independent_of_batching(self): + t, y, dy = self._lc() + freqs, q0 = transit_autofreq(t, qmin_fac=0.5, fmin=0.02, fmax=3.0) + freqs = freqs[::max(1, len(freqs) // 600)] + q0 = q_transit(freqs) + qmins, qmaxes = 0.5 * q0, 2.0 * q0 + nb0, nbf = _q_bounds_to_nbins(qmins, qmaxes) + + runs = {} + for fbs in (None, 200, 20, 1): + runs[fbs] = eebls_gpu(t, y, dy, freqs, qmin=qmins, qmax=qmaxes, + freq_batch_size=fbs) + p_ref, sols_ref = runs[None] + qs = np.array([s_[0] for s_ in sols_ref]) + # every solution inside its own window (bin-count rounding) + assert np.all(qs >= 1. / nbf - 1e-6) + assert np.all(qs <= 1. / nb0 + 1e-6) + for fbs in (200, 20, 1): + p, sols = runs[fbs] + # float32 atomic-order noise only (the audit measured + # 1.7e-2 differences between batchings before the fix) + assert_allclose(p, p_ref, rtol=1e-4, atol=1e-6) + qb = np.array([s_[0] for s_ in sols]) + # solutions may differ only where powers tie + diff = qb != qs + assert np.mean(diff) < 0.02 + + # ---- GPU: the eebls_transit default path ---- + + def test_eebls_transit_default_is_fast_kernel_plus_top_k_solutions(self): + t, y, dy = self._lc(ndata=2000, seed=12) + fr, p, sols = eebls_transit(t, y, dy, fmin=0.05, fmax=1.0) + q0 = q_transit(fr) + nb0, nbf = _fast_path_nbins(fr.astype(np.float32), 0.5 * q0, + 2.0 * q0) + + # the periodogram is the fast kernel's (per-frequency bounds) + p_fast = eebls_gpu_fast(t, y, dy, fr, qmin=0.5 * q0, qmax=2.0 * q0) + assert_allclose(p, p_fast, rtol=1e-4, atol=1e-6) + assert abs(fr[np.argmax(p)] - 0.2) < 3 * 0.03 / 200. + + # top-10 solutions, None elsewhere, argmax included + assert len(sols) == len(fr) + filled = [i for i, s_ in enumerate(sols) if s_ is not None] + assert set(filled) == set(np.argsort(-p, kind='stable')[:10]) + assert sols[int(np.argmax(p))] is not None + + for i in filled: + q, phi = sols[i] + # inside this frequency's own window: the ladder ends at + # floor(nbf / nb0) fine bins (_fast_box_widths), so no + # reported box may be wider than that + assert q >= 1. / nbf[i] - 1e-6 + assert q <= (int(nbf[i]) // int(nb0[i])) / float(nbf[i]) + 1e-6 + assert q <= 2.0 * q0[i] * (1 + 1. / nb0[i]) + 1e-6 + # and it is the box that produced the power: single_bls + # re-evaluates it exactly (float32 accumulation and, at a + # bin edge, one point's membership may differ) + p_single = single_bls(t, y, dy, fr[i], q, phi) + n_box = len(t) * q + assert abs(p_single - p[i]) < 1e-3 * p[i] + 1e-5 + 2. * p[i] / n_box + + def test_eebls_transit_default_independent_of_batching_and_memory(self): + # the pre-1.0 default path changed 28,628/36,585 frequencies by + # up to 2.3e-2 between a 24 GB and a 7 GB card (the auto batch + # size set the collapsed window) + t, y, dy = self._lc(ndata=1200) + kw = dict(fmin=0.05, fmax=1.0) + fr, p, sols = eebls_transit(t, y, dy, **kw) + fr2, p2, sols2 = eebls_transit(t, y, dy, freq_batch_size=97, **kw) + with pytest.warns(UserWarning, match="eebls_transit ignores"): + fr3, p3, sols3 = eebls_transit(t, y, dy, max_memory=int(2e9), + nstreams=2, **kw) + assert_allclose(p2, p, rtol=1e-4, atol=1e-6) + assert_allclose(p3, p, rtol=1e-4, atol=1e-6) + for a, b in ((sols2, sols), (sols3, sols)): + assert [i for i, s_ in enumerate(a) if s_ is not None] \ + == [i for i, s_ in enumerate(b) if s_ is not None] + + def test_eebls_transit_warns_about_ignored_eebls_gpu_kwargs(self): + # the default path runs eebls_gpu_fast, so nstreams / max_memory + # (a resource bound the caller may be relying on) do not apply; + # dropping them silently was the complaint. + t, y, dy = self._lc(ndata=600) + kw = dict(fmin=0.1, fmax=0.5) + for key, value in (('nstreams', 2), ('max_memory', int(2e9))): + with pytest.warns(UserWarning) as rec: + eebls_transit(t, y, dy, **dict(kw, **{key: value})) + msgs = [str(w.message) for w in rec + if issubclass(w.category, UserWarning)] + assert any(key in m and 'eebls_transit ignores' in m + for m in msgs), msgs + assert any('eebls_gpu' in m for m in msgs), msgs + # ... and no warning when they are not passed + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter('always') + eebls_transit(t, y, dy, **kw) + assert not [w for w in rec + if 'eebls_transit ignores' in str(w.message)] + + def test_eebls_transit_solution_keywords(self): + t, y, dy = self._lc(ndata=800) + kw = dict(fmin=0.1, fmax=0.5) + fr, p, sols = eebls_transit(t, y, dy, n_solutions=3, **kw) + assert sum(s_ is not None for s_ in sols) == 3 + fr, p0, sols0 = eebls_transit(t, y, dy, n_solutions=0, **kw) + assert len(sols0) == len(fr) and all(s_ is None for s_ in sols0) + assert_allclose(p0, p, rtol=1e-4, atol=1e-6) + # use_fast: same periodogram, no solution pass + fr, pf, none = eebls_transit(t, y, dy, use_fast=True, **kw) + assert none is None + assert_allclose(pf, p, rtol=1e-4, atol=1e-6) + # the binned search with a solution everywhere is still there + fr, pg, sg = eebls_transit_gpu(t, y, dy, **kw) + assert len(sg) == len(fr) and all(s_ is not None for s_ in sg) + + +def _sparse_reference(t, y, dy, freqs, qmin=0.0, qmax=0.5): + """Exact float64 sparse-BLS reference (Panahi & Zucker 2021: every + cyclic run of phase-sorted points), with the kernels' float32 fold + and box definition (phi0 = first in-transit phase, q to the egress + midpoint) and weight guards -- the reference of the Sep 2026 audit + (repro/local/sparse-batch/sparse_exp.py). Returns 'chi2ratio' + powers.""" + from ..utils import subtract_epoch + t64, epoch = subtract_epoch(np.asarray(t, dtype=np.float64)) + y64 = np.asarray(y, dtype=np.float64) + w = np.asarray(dy, dtype=np.float64) ** -2 + w /= w.sum() + x = y64 - np.dot(w, y64) + YY = np.dot(w, x ** 2) + N = len(t64) + out = np.zeros(len(freqs)) + qmin = np.broadcast_to(np.asarray(qmin, float), (len(freqs),)) + qmax = np.broadcast_to(np.asarray(qmax, float), (len(freqs),)) + i = np.arange(N)[:, None] + L = np.arange(1, N)[None, :] + j = i + L + for k, f in enumerate(freqs): + phi = (np.float32(t64) * np.float32(f)) % np.float32(1.0) + phi = phi.astype(np.float64) + o = np.argsort(phi, kind='stable') + ps, ws, xs = phi[o], w[o], x[o] + cw = np.concatenate([[0.0], np.cumsum(np.concatenate([ws, ws]))]) + cxw = np.concatenate([[0.0], np.cumsum(np.concatenate( + [ws * xs, ws * xs]))]) + W = cw[j] - cw[i] + S = cxw[j] - cxw[i] + ps2 = np.concatenate([ps, ps + 1.0]) + last = ps2[j - 1] + nxt = ps2[np.minimum(j, 2 * N - 1)] + q = 0.5 * (last + nxt) - ps[:, None] + valid = (q > 0) & (q >= qmin[k]) & (q <= qmax[k]) \ + & (W > 1e-9) & (W < 1.0 - 1e-4) + with np.errstate(divide='ignore', invalid='ignore'): + P = np.where(valid, S * S / (W * (1 - W)) / YY, 0.0) + out[k] = P.max() + return out + + +def _untied_frequencies(t, freqs): + """Mask of the frequencies at which the kernels' float32 fold gives + no two observations the same phase. At a tie the candidate runs + depend on the sort order (bitonic vs argsort vs the stable sort of + the reference) and the reported egress midpoint collapses onto the + tied point (audit ids 65/74), so exact comparisons are only + meaningful away from ties (~20-25 % of a 365-day mag-12 grid at + f ~ 1.4 has one).""" + from ..utils import subtract_epoch + t64, _ = subtract_epoch(np.asarray(t, dtype=np.float64)) + t32 = t64.astype(np.float32) + mask = np.ones(len(freqs), dtype=bool) + for k, f in enumerate(freqs): + phi = (t32 * np.float32(f)) % np.float32(1.0) + mask[k] = len(np.unique(phi)) == len(phi) + return mask + + +def _same_peak(p, ref, rtol=1e-4): + """The reference power at the tested periodogram's argmax is the + reference maximum (plateaus of equal power, e.g. 4 adjacent grid + frequencies with the same in-transit set, break argmax ties by + float32 rounding order).""" + return ref[int(np.argmax(p))] >= ref.max() * (1 - rtol) + + +class TestSparseCentering(object): + """Defect 8 of the Sep 2026 audit (``bls-sparse-uncentered``): the + sparse kernels (and ``sparse_bls_cpu`` / ``single_bls``) accumulated + float32 sums of raw ``w * y`` and subtracted ``ybar * W`` afterwards, + so on mag-12 fluxes (the ``eebls_transit`` default for ndata < 500) + the power was off by up to 1e-2 relative (argmax moved in 8/20 + seeds) and one point ~1e3x more precise than the rest gave powers + up to 52 (> 1) at every frequency. The wrappers now centre in + float64 before the float32 cast; the audit measured ~1e-6 after + the fix.""" + + @staticmethod + def _mag12(N=200, seed=0, base=365.0, ybar=12.0, depth=5e-3, + sig=5e-3, f=1.37, q=0.02): + r = np.random.RandomState(seed) + t = np.sort(base * r.rand(N)) + ph = (t * f) % 1 + y = ybar - depth * (ph < q) + sig * r.randn(N) + dy = sig * (0.7 + 0.6 * r.rand(N)) + return t, y, dy + + @staticmethod + def _grid(): + return 1.37 + (0.02 / 365 / 4) * np.arange(-100, 101) + + # ---- CPU ---- + + def test_sparse_bls_cpu_mag12_matches_float64_reference(self): + # before the fix: max rel 1.1e-2 (audit), 67-78 % of the grid + # off by > 1e-3 + freqs = self._grid() + qv = q_transit(freqs) + for seed in (0, 1): + t, y, dy = self._mag12(seed=seed) + ref = _sparse_reference(t, y, dy, freqs, 0.5 * qv, 2.0 * qv) + p, _ = sparse_bls_cpu(t, y, dy, freqs, qmin=0.5 * qv, + qmax=2.0 * qv) + ok = _untied_frequencies(t, freqs) + assert ok.mean() > 0.7 + assert_allclose(p[ok], ref[ok], rtol=1e-4, atol=1e-7) + assert _same_peak(p, ref) + + def test_sparse_bls_cpu_offset_invariance(self): + freqs = self._grid()[::4] + t, y, dy = self._mag12(seed=2) + p0, s0 = sparse_bls_cpu(t, y, dy, freqs) + p20, s20 = sparse_bls_cpu(t, y + 20., dy, freqs) + assert_allclose(p20, p0, rtol=1e-5, atol=1e-8) + assert [a[0] for a in s20] == [a[0] for a in s0] + + def test_single_bls_offset_invariance_and_reference(self): + t, y, dy = self._mag12(seed=3) + freqs = self._grid()[::8] + ref = _sparse_reference(t, y, dy, freqs) + _, sols = sparse_bls_cpu(t, y, dy, freqs) + ok = _untied_frequencies(t, freqs) + assert ok.sum() >= 15 + for k, f in enumerate(freqs): + if not ok[k]: + continue + q, phi = sols[k] + p = single_bls(t, y, dy, f, q, phi) + p20 = single_bls(t, y + 20., dy, f, q, phi) + assert abs(p20 - p) < 1e-5 * max(p, 1e-3) + # the solution reproduces the reference power + assert abs(p - ref[k]) < 1e-4 * max(ref[k], 1e-3) + + def test_cpu_one_precise_point_powers_stay_below_one(self): + r = np.random.RandomState(3) + N = 200 + t = np.sort(365 * r.rand(N)) + y = 12.0 + 0.01 * r.randn(N) + dy0 = 0.01 * np.ones(N) + freqs = np.linspace(0.5, 1.5, 51) + ok = _untied_frequencies(t, freqs) + assert ok.mean() > 0.7 + for R, tol in ((1e4, 1e-3), (1e6, 3e-2)): + dy = dy0.copy() + dy[17] = 0.01 / np.sqrt(R) + ref = _sparse_reference(t, y, dy, freqs) + p, _ = sparse_bls_cpu(t, y, dy, freqs) + assert np.all(p <= 1.0) + assert abs(p[ok].max() - ref[ok].max()) < tol * ref[ok].max() + assert _same_peak(p[ok], ref[ok]) + + # ---- GPU ---- + + def test_sparse_bls_gpu_mag12_matches_float64_reference(self): + freqs = self._grid() + qv = q_transit(freqs) + for seed in (0, 1, 2): + t, y, dy = self._mag12(seed=seed) + ref = _sparse_reference(t, y, dy, freqs, 0.5 * qv, 2.0 * qv) + p, _ = sparse_bls_gpu(t, y, dy, freqs, qmin=0.5 * qv, + qmax=2.0 * qv) + ok = _untied_frequencies(t, freqs) + assert ok.mean() > 0.7 + assert_allclose(p[ok], ref[ok], rtol=1e-4, atol=1e-7) + assert _same_peak(p, ref) + + def test_eebls_transit_default_sparse_path_matches_reference(self): + # the public default path (ndata < sparse_threshold) on mag-12 + # data: peak rel err was up to 7.2e-3 before the fix + freqs = self._grid() + t, y, dy = self._mag12(seed=4) + fr, p, sols = eebls_transit(t, y, dy, freqs=freqs) + qv = q_transit(fr) + ref = _sparse_reference(t, y, dy, fr, 0.5 * qv, 2.0 * qv) + ok = _untied_frequencies(t, fr) + assert ok.mean() > 0.7 + assert_allclose(p[ok], ref[ok], rtol=1e-4, atol=1e-7) + assert _same_peak(p, ref) + + def test_sparse_bls_gpu_offset_invariance(self): + freqs = self._grid()[::2] + t, y, dy = self._mag12(seed=5) + p0, s0 = sparse_bls_gpu(t, y, dy, freqs) + p20, s20 = sparse_bls_gpu(t, y + 20., dy, freqs) + assert_allclose(p20, p0, rtol=1e-5, atol=1e-8) + assert [a[0] for a in s20] == [a[0] for a in s0] + + def test_gpu_one_precise_point_powers_stay_below_one(self): + # R = 1e6: 401/401 powers > 1 (max 52) before the fix + r = np.random.RandomState(3) + N = 200 + t = np.sort(365 * r.rand(N)) + y = 12.0 + 0.01 * r.randn(N) + dy0 = 0.01 * np.ones(N) + freqs = np.linspace(0.5, 1.5, 201) + ok = _untied_frequencies(t, freqs) + assert ok.mean() > 0.7 + for R, tol in ((1e4, 1e-3), (1e6, 3e-2)): + dy = dy0.copy() + dy[17] = 0.01 / np.sqrt(R) + ref = _sparse_reference(t, y, dy, freqs) + p, _ = sparse_bls_gpu(t, y, dy, freqs) + assert np.all(p <= 1.0) + assert abs(p[ok].max() - ref[ok].max()) < tol * ref[ok].max() + assert _same_peak(p[ok], ref[ok]) + + +class TestBlsPrecisionDocs(object): + """Sep 2026 audit section 3.3: the float32 fold limit, the + time-origin sensitivity of binned power and the run-to-run + float32-atomic tolerance had to be stated somewhere a user reads. + CPU-only.""" + + @staticmethod + def _bls_rst(): + here = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + path = os.path.join(here, 'docs', 'source', 'bls.rst') + if not os.path.exists(path): + pytest.skip("docs/source/bls.rst not found (running outside " + "the source tree)") + with open(path, encoding='utf-8') as f: + return f.read() + + def test_bls_rst_has_precision_section(self): + rst = self._bls_rst() + assert 'Precision and reproducibility' in rst + # the section header must be underlined (valid rst) + i = rst.index('Precision and reproducibility') + underline = rst[i:].split('\n')[1] + assert set(underline) == {'-'} + assert len(underline) >= len('Precision and reproducibility') + for phrase in (r'\mathrm{ulp}', r'q_\mathrm{min}', + r'n_\mathrm{overlap}', 'float32 atomics', + 'fractional', 'bitwise'): + assert phrase in rst, phrase + + def test_eebls_gpu_fast_docstring_mirrors_it(self): + doc = ' '.join(eebls_gpu_fast.__doc__.split()) + assert 'ulp(T * max(freqs))' in doc + assert 'qmin / noverlap' in doc + assert '1e-8 to 1e-7' in doc + + +class TestFastPathQmaxBox(object): + """Sep 2026 audit, id 64: the fast (shared-memory) kernels built + their box ladder as ``max_bin_width = divrndup(nbinsf, nbins0)`` + and looped ``m < max_bin_width``. That is the same set of widths + whenever ``nbins0`` does not divide ``nbinsf``, but one level short + when it does, so ``qmax`` itself was never tested: with + ``qmin=0.025, qmax=0.1`` (nbinsf=40, nbins0=10) the widest box + searched was ``q = 0.075``, and an on-grid ``q = qmax`` transit was + recovered at ~74-86 % of its exact power. The bound is now + ``max_bin_width = nbinsf // nbins0`` with ``m <= max_bin_width``: + the widest box with ``q = m/nbinsf <= 1/nbins0`` is included and no + box wider than the discretized ``qmax`` is ever evaluated. + """ + + # ---- CPU: the ladder itself ---- + + def test_ladder_includes_the_qmax_box(self): + from ..bls import _fast_box_widths + # qmin = 0.025, qmax = 0.1 -> nbinsf = 40, nbins0 = 10 + nb0, nbf = _fast_path_nbins(np.float32([1.0]), 0.025, 0.1) + assert (int(nb0[0]), int(nbf[0])) == (10, 40) + widths = _fast_box_widths(int(nbf[0]), int(nb0[0]), 0.3) + assert widths == [1, 2, 3, 4] + assert widths[-1] / int(nbf[0]) == 0.1 # == qmax + # the old ladder stopped at 3 (q = 0.075) + assert 4 in widths + + def test_helper_docstrings_state_the_floor_bound(self): + # the bound is floor(nbinsf / nbins0), inclusive -- not the + # pre-fix ceil(...); _fast_path_nbins' docstring said "ceil" + # long after the kernels changed. + from ..bls import _fast_box_widths + for doc in (_fast_path_nbins.__doc__, _fast_box_widths.__doc__): + assert 'nbinsf / nbins0' in doc or 'nbinsf // nbins0' in doc + assert 'ceil(nbinsf /' not in _fast_path_nbins.__doc__.replace( + '\n', ' ').replace(' ', ' ') + assert 'floor(nbinsf / nbins0)' in ' '.join( + _fast_path_nbins.__doc__.split()) + + def test_ladder_never_exceeds_the_discretized_qmax(self): + from ..bls import _fast_box_widths, dnbins + for dlogq in (0.2, 0.3, 0.5, -1.0): + for nb0 in range(1, 25): + for nbf in range(nb0, 220, 7): + widths = _fast_box_widths(nbf, nb0, dlogq) + assert widths[0] == 1 + # every searched q is within the discretized qmax + assert widths[-1] <= nbf // nb0 + assert widths[-1] / nbf <= 1.0 / nb0 + 1e-12 + # ... and it is the LAST rung that fits: the next + # step would overshoot + assert (widths[-1] + dnbins(widths[-1], dlogq) + > nbf // nb0) + + def test_default_bounds_are_unchanged_by_the_fix(self): + # qmin=0.01, qmax=0.5 -> nbinsf=100, nbins0=2, max width 50, + # but the geometric step jumps 48 -> 62, so the default fast + # path searches exactly what it did before. + from ..bls import _fast_box_widths + assert _fast_box_widths(100, 2, 0.3)[-1] == 48 + assert _fast_box_widths(100, 2, 0.2)[-1] == 44 + + # ---- CPU: the box scan used for the eebls_transit solution pass ---- + + @staticmethod + def _on_grid_box(nbf=40, m=4, n0=10, ndays=10, depth=0.02, + sigma=1e-3, seed=17): + """Light curve whose flux dips in exactly bins ``n0 .. + n0+m-1`` of an ``nbf``-bin phase grid at f = 1 c/d, i.e. a box + of q = m/nbf starting at phi0 = n0/nbf.""" + rand = np.random.RandomState(seed) + phase = (np.arange(nbf) + 0.5) / nbf + t = np.concatenate([d + phase for d in range(ndays)]) + y = np.ones(len(t)) + b = np.tile(np.arange(nbf), ndays) + y[(b >= n0) & (b < n0 + m)] -= depth + y += sigma * rand.randn(len(t)) + dy = sigma * np.ones(len(t)) + return t, y, dy + + @staticmethod + def _scan_power(t, y, dy, freq, qmin, qmax, dlogq=0.3, noverlap=2): + """(power, q, phi0) of the fast-kernel box grid at one + frequency, in the caller's timescale.""" + from ..utils import subtract_epoch + t64, epoch = subtract_epoch(t) + w = np.asarray(dy, dtype=np.float64) ** -2 + w /= w.sum() + ybar = float(np.dot(w, y)) + YY = float(np.dot(w, (np.asarray(y) - ybar) ** 2)) + nb0, nbf = _fast_path_nbins(np.float32([freq]), qmin, qmax) + val, q, phi = _fast_bls_box_scan( + t64.astype(np.float32), ((y - ybar) * w).astype(np.float32), + w.astype(np.float32), np.float32(freq), + int(nb0[0]), int(nbf[0]), dlogq, noverlap) + return val / YY, q, (phi + epoch * freq) % 1.0 + + def test_box_scan_finds_the_qmax_wide_box(self): + t, y, dy = self._on_grid_box() + p, q, phi0 = self._scan_power(t, y, dy, 1.0, 0.025, 0.1) + # the injected box is exactly qmax wide and on the bin grid + assert q == pytest.approx(0.1, abs=1e-7) + assert phi0 == pytest.approx(0.25, abs=1e-6) + # the widest box the OLD ladder could reach (q = 0.075) leaves + # a quarter of the transit out and scores clearly lower + p3 = single_bls(t, y, dy, 1.0, 3. / 40., 0.25) + p3 = max(p3, single_bls(t, y, dy, 1.0, 3. / 40., 0.275)) + assert p > 1.2 * p3 + # the reported solution reproduces the power exactly + assert single_bls(t, y, dy, 1.0, q, phi0) == pytest.approx( + p, rel=1e-5) + + # ---- GPU ---- + + def test_fast_kernel_evaluates_the_qmax_box(self): + # the kernel must agree with the CPU replica of its own grid + # (which now includes m = nbinsf // nbins0) and must recover + # the on-grid q = qmax transit at nearly its exact power + t, y, dy = self._on_grid_box() + freqs = np.array([1.0], dtype=np.float64) + p_gpu = eebls_gpu_fast(t, y, dy, freqs, qmin=0.025, qmax=0.1, + dlogq=0.3, noverlap=2) + p_ref, q_ref, phi_ref = self._scan_power(t, y, dy, 1.0, + 0.025, 0.1) + assert_allclose(p_gpu[0], p_ref, rtol=2e-4, atol=1e-6) + assert q_ref == pytest.approx(0.1, abs=1e-7) + exact = single_bls(t, y, dy, 1.0, 0.1, 0.25) + assert p_gpu[0] > 0.95 * exact + + def test_optimized_kernel_matches_the_standard_one(self): + t, y, dy = self._on_grid_box(seed=18) + freqs = np.linspace(0.9, 1.1, 201) + kw = dict(qmin=0.025, qmax=0.1, dlogq=0.3, noverlap=2) + p_std = eebls_gpu_fast(t, y, dy, freqs, **kw) + p_opt = eebls_gpu_fast_optimized(t, y, dy, freqs, **kw) + assert_allclose(p_opt, p_std, rtol=1e-4, atol=1e-6) + + def test_batch_kernel_uses_the_same_ladder(self): + # bls_batch.cu carries its own copy of the box loop; it must + # keep the same widths as the single-LC kernels or the batch + # periodogram silently differs at the widest box + from ..bls import eebls_gpu_batch + t, y, dy = self._on_grid_box(seed=19) + freqs = np.linspace(0.9, 1.1, 201) + kw = dict(qmin=0.025, qmax=0.1, dlogq=0.3, noverlap=2) + p_fast = eebls_gpu_fast(t, y, dy, freqs, **kw) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + p_batch = eebls_gpu_batch([(t, y, dy)], freqs, **kw)[0] + assert_allclose(p_batch, p_fast, rtol=1e-3, atol=1e-5) + + +class TestBatchNoverlapValidation(object): + """Sep 2026 audit, id 75: ``eebls_gpu_batch(noverlap=0)`` computed + ``n_passes = 1 if fused else noverlap`` and therefore launched + nothing, returning the untouched device buffer -- all zeros, or a + stale periodogram when a ``memory=`` was reused -- while + ``eebls_gpu_fast(noverlap=0)`` raised. A non-integer ``noverlap`` + hit ``range(3.0)`` with a TypeError. The batch entry point now runs + the same ``_validate_noverlap`` guard as the fast paths.""" + + @staticmethod + def _data(): + rand = np.random.RandomState(23) + t = np.sort(365. * rand.rand(300)) + y = 1. + 0.01 * rand.randn(300) + dy = 0.01 * np.ones(300) + return t, y, dy + + def test_bad_noverlap_raises(self): + # CPU-runnable: validation precedes any GPU work + from ..bls import eebls_gpu_batch + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 20) + for bad in (0, -1, 1.5, 3.0, "2", None): + with pytest.raises(ValueError, match="noverlap"): + eebls_gpu_batch([(t, y, dy)], freqs, noverlap=bad) + + def test_valid_noverlap_still_runs(self): + from ..bls import eebls_gpu_batch + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + p = eebls_gpu_batch([(t, y, dy)], freqs, noverlap=1)[0] + assert np.all(np.isfinite(p)) and np.max(p) > 0. + + +class TestSparseSharedMemoryLimit(object): + """Sep 2026 audit, ids 77/126: ``sparse_bls_gpu`` sized its dynamic + shared memory from ``ndata`` and never compared it with the + device's per-block limit, so anything above ~2,000 points died with + a bare ``cuLaunchKernel failed: invalid argument``. The size is now + checked before the launch and reported with the point limit.""" + + def test_shared_memory_formula(self): + from ..bls import _sparse_shared_mem_bytes + # matches the audit's measurements on a 48 KB device + assert _sparse_shared_mem_bytes(2000, 64) == 41344 + assert _sparse_shared_mem_bytes(2500, 64) == 69920 + + def test_max_ndata_is_the_largest_that_fits(self): + from ..bls import _sparse_shared_mem_bytes, _sparse_max_ndata + for lim in (16384, 49152, 65536, 101376): + for block_size in (32, 64, 256): + n = _sparse_max_ndata(lim, block_size) + assert n > 0 + assert _sparse_shared_mem_bytes(n, block_size) <= lim + assert _sparse_shared_mem_bytes(n + 1, block_size) > lim + + def test_too_many_points_raises_a_clear_error(self): + rand = np.random.RandomState(29) + ndata = 6000 + t = np.sort(365. * rand.rand(ndata)) + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + freqs = np.linspace(0.95, 1.05, 5) + with pytest.raises(ValueError, match="shared memory"): + sparse_bls_gpu(t, y, dy, freqs) + + def test_small_light_curve_still_runs(self): + rand = np.random.RandomState(31) + ndata = 200 + t = np.sort(365. * rand.rand(ndata)) + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + freqs = np.linspace(0.95, 1.05, 25) + p, sols = sparse_bls_gpu(t, y, dy, freqs) + assert np.all(np.isfinite(p)) and len(sols) == len(freqs) + + +class TestBLSMemoryKeywords(object): + """Sep 2026 audit, id 67: ``BLSMemory.fromdata`` read + ``max_ndata``/``max_nfreqs`` with ``kwargs.get`` and then forwarded + the same ``kwargs`` to ``__init__``, so passing either raised + ``TypeError: got multiple values for argument``. Reusing a memory + with a different number of frequencies used to fail deep inside + pycuda with ``ary and self must be the same size``.""" + + @staticmethod + def _data(ndata=200): + rand = np.random.RandomState(37) + t = np.sort(365. * rand.rand(ndata)) + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + return t, y, dy + + def test_fromdata_accepts_max_ndata_and_max_nfreqs(self): + from ..bls import BLSMemory + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 50) + mem = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True, + max_ndata=len(t) + 100, + max_nfreqs=1000) + assert mem.max_ndata == len(t) + 100 + assert mem.max_nfreqs == 1000 + assert len(mem.t) == len(t) + 100 + + def test_reuse_with_a_different_nfreqs_raises_clearly(self): + from ..bls import BLSMemory + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 50) + mem = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True) + # same length: fine + mem.setdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs + 0.01, transfer=True) + with pytest.raises(ValueError, match="frequencies"): + mem.setdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=np.linspace(0.95, 1.05, 120), + transfer=True) + + +class TestKernelCompileCaching(object): + """Sep 2026 audit, ids 3/7/43/60/126 (plan item BLS-1). + + ``eebls_gpu``, ``eebls_gpu_custom``, ``hone_solution`` and + ``sparse_bls_gpu`` used to call ``compile_bls`` / + ``compile_sparse_bls`` directly whenever the caller did not supply + kernels, bypassing the LRU cache the fast/batch paths use. pycuda + runs an ``nvcc --preprocess`` subprocess on every ``SourceModule`` + even when its own disk cache holds the cubin, so that cost ~0.4-0.5 s + (standard) and ~0.4-1.6 s (sparse) *per call*. + + These tests assert compile *counts*, never wall times. + """ + + @staticmethod + def _data(ndata=100): + rand = np.random.RandomState(11) + t = np.sort(100. * rand.rand(ndata)) + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + return t, y, dy + + @staticmethod + def _counting(monkeypatch, name): + """Swap in a fresh kernel cache and count real compiles.""" + from collections import OrderedDict + from .. import bls as B + monkeypatch.setattr(B, '_kernel_cache', OrderedDict()) + calls = [] + orig = getattr(B, name) + + def counted(*args, **kwargs): + calls.append((args, tuple(sorted(kwargs.items())))) + return orig(*args, **kwargs) + + monkeypatch.setattr(B, name, counted) + return calls + + def test_sparse_bls_gpu_compiles_once_per_block_size(self, monkeypatch): + calls = self._counting(monkeypatch, 'compile_sparse_bls') + t, y, dy = self._data() + freqs = np.linspace(0.9, 1.1, 30) + + p1, _ = sparse_bls_gpu(t, y, dy, freqs) + assert len(calls) == 1 + p2, _ = sparse_bls_gpu(t, y, dy, freqs) + assert len(calls) == 1, "second call recompiled the sparse kernel" + # identical kernel, identical numbers + assert np.array_equal(p1, p2) + + # a different block_size is a different kernel: compile again + sparse_bls_gpu(t, y, dy, freqs, block_size=32) + assert len(calls) == 2 + sparse_bls_gpu(t, y, dy, freqs, block_size=32) + assert len(calls) == 2 + + def test_eebls_transit_sparse_path_shares_the_cached_kernel( + self, monkeypatch): + calls = self._counting(monkeypatch, 'compile_sparse_bls') + t, y, dy = self._data() + freqs = np.linspace(0.9, 1.1, 30) + qvals = q_transit(freqs) + for _ in range(3): + eebls_transit(t, y, dy, freqs=freqs, qvals=qvals, + use_sparse=True) + assert len(calls) == 1 + + def test_eebls_gpu_compiles_once(self, monkeypatch): + calls = self._counting(monkeypatch, 'compile_bls') + t, y, dy = self._data() + freqs = np.linspace(0.9, 1.1, 20) + + p1, _ = eebls_gpu(t, y, dy, freqs) + assert len(calls) == 1 + p2, _ = eebls_gpu(t, y, dy, freqs) + assert len(calls) == 1, "second call recompiled the BLS kernels" + # same kernels, same numbers (eebls_gpu's multi-stream global + # atomics are not bit-reproducible run to run, hence allclose) + assert_allclose(p1, p2, rtol=1e-5, atol=1e-7) + + # eebls_gpu_custom asks for the same (block_size, use_optimized, + # function_names) key: still one compile + eebls_gpu_custom(t, y, dy, freqs, np.array([0.05, 0.1]), + np.array([0.0, 0.5])) + assert len(calls) == 1 + + # a different block_size must recompile + eebls_gpu(t, y, dy, freqs, block_size=128) + assert len(calls) == 2 + eebls_gpu(t, y, dy, freqs, block_size=128) + assert len(calls) == 2 + + def test_prepare_false_bypasses_the_cache(self, monkeypatch): + # prepare=False returns unprepared functions, which the cache + # key does not model: it must fall through to a direct compile + # every time rather than hand back prepared kernels. + from .. import bls as B + calls = self._counting(monkeypatch, 'compile_bls') + fns1 = B._cached_compile_bls(prepare=False) + assert len(calls) == 1 + fns2 = B._cached_compile_bls(prepare=False) + assert len(calls) == 2 + assert fns1 is not fns2 + # ... while the default (prepare=True) is cached and shared + c1 = B._cached_compile_bls() + c2 = B._cached_compile_bls() + assert c1 is c2 + assert len(calls) == 3 + + +class TestAdaptiveUsesFusedKernel(object): + """Sep 2026 audit, ids 40/63 (plan item BLS-5). + + ``eebls_gpu_fast_adaptive`` and ``eebls_transit(use_optimized=True)`` + loaded a function dict without ``full_bls_no_sol_fused``, so the + shared implementation could only take the ``noverlap``-pass loop: + two launches and 1.7-2.3x the GPU time of ``eebls_gpu_fast`` on + identical inputs. They must now take the fused kernel wherever it is + valid (power-of-two ``noverlap``, ``dphi == 0``, shared memory + permitting) and keep the multi-pass fallback otherwise. + """ + + @staticmethod + def _data(): + return data(snr=30, q=0.05, phi0=0.317, freq=1.0, + baseline=365., ndata=300) + + @staticmethod + def _launch_counter(monkeypatch): + """Count prepared launches by kernel name.""" + from .. import bls as B + counts = {} + + class Spy(object): + def __init__(self, name, func): + self._name, self._func = name, func + + def prepared_call(self, *a, **k): + counts[self._name] = counts.get(self._name, 0) + 1 + return self._func.prepared_call(*a, **k) + + def prepared_async_call(self, *a, **k): + counts[self._name] = counts.get(self._name, 0) + 1 + return self._func.prepared_async_call(*a, **k) + + def __getattr__(self, k): + return getattr(self._func, k) + + orig = B._get_cached_kernels + + def spied(*a, **k): + return {name: Spy(name, f) for name, f in orig(*a, **k).items()} + + monkeypatch.setattr(B, '_get_cached_kernels', spied) + return counts + + def test_adaptive_launches_the_fused_kernel_once(self, monkeypatch): + from ..bls import eebls_gpu_fast_adaptive + counts = self._launch_counter(monkeypatch) + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + + eebls_gpu_fast_adaptive(t, y, dy, freqs, qmin=0.01, qmax=0.1, + noverlap=2) + assert counts == {'full_bls_no_sol_fused': 1}, counts + + def test_transit_use_optimized_launches_the_fused_kernel_once( + self, monkeypatch): + counts = self._launch_counter(monkeypatch) + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + + eebls_transit(t, y, dy, freqs=freqs, qvals=q_transit(freqs), + use_optimized=True, use_sparse=False, noverlap=2) + assert counts == {'full_bls_no_sol_fused': 1}, counts + + @pytest.mark.parametrize("kw", [dict(noverlap=3), dict(dphi=0.25)]) + def test_adaptive_falls_back_when_fused_is_invalid(self, kw, + monkeypatch): + # non-power-of-two noverlap / a non-zero base phase offset are + # outside what the fused kernel implements + from ..bls import eebls_gpu_fast_adaptive + counts = self._launch_counter(monkeypatch) + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 200) + + eebls_gpu_fast_adaptive(t, y, dy, freqs, qmin=0.01, qmax=0.1, + **kw) + assert 'full_bls_no_sol_fused' not in counts, counts + assert counts.get('full_bls_no_sol_optimized', 0) >= 2, counts + + def test_fused_and_multipass_agree(self): + # Parity of the two paths at the default noverlap: hand the + # adaptive entry point a function dict WITHOUT the fused kernel + # to force the multi-pass loop. + from .. import bls as B + from ..bls import eebls_gpu_fast_adaptive, eebls_gpu_fast + t, y, dy = self._data() + freqs = np.linspace(0.95, 1.05, 500) + bs = B._choose_block_size(len(t)) + multi = B._get_cached_kernels(bs, True, + ['full_bls_no_sol_optimized']) + assert 'full_bls_no_sol_fused' not in multi + + kw = dict(qmin=0.01, qmax=0.1, block_size=bs) + p_fused = eebls_gpu_fast_adaptive(t, y, dy, freqs, **kw) + p_multi = eebls_gpu_fast_adaptive(t, y, dy, freqs, + functions=multi, **kw) + assert int(np.argmax(p_fused)) == int(np.argmax(p_multi)) + assert_allclose(p_fused, p_multi, rtol=1e-4, atol=1e-5) + + # and the adaptive path now returns what eebls_gpu_fast (fused + # since 1.0) returns, to float32 atomic-ordering noise + p_fast = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1) + assert_allclose(p_fused, p_fast, rtol=1e-4, atol=1e-6) + + +class TestBLSMemoryHostStaging(object): + """Sep 2026 audit, id 41 (plan item BLS-6). + + ``BLSMemory.allocate_host_arrays`` page-locked all six host buffers, + three of which are never the source or destination of an async copy + (``nbins0``/``nbinsf`` are replaced by fresh pageable arrays in + ``setdata``; ``bls`` is only an async destination when a stream is + attached). And the ``memory=None`` fast path allocated a whole + ``BLSMemory`` -- six host buffers plus four device buffers -- per + call, so back-to-back calls of the same shape re-paid it every time. + """ + + @staticmethod + def _data(ndata=200, seed=17): + rand = np.random.RandomState(seed) + t = np.sort(365. * rand.rand(ndata)) + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + return t, y, dy + + def test_only_transfer_buffers_are_page_locked(self): + import pycuda.driver as cuda + from ..bls import BLSMemory + mem = BLSMemory(64, 128) + pinned = cuda.pagelocked_empty(1, np.float32).base.__class__ + for attr in ('t', 'yw', 'w'): + assert isinstance(getattr(mem, attr).base, pinned), attr + for attr in ('bls', 'nbins0', 'nbinsf'): + assert not isinstance(getattr(mem, attr).base, pinned), attr + + def test_result_buffer_is_page_locked_with_a_stream(self): + # get_async into a pageable buffer is not asynchronous, and the + # normalization after it would race the DMA (see + # TestPinnedBufferStreamParity) + import pycuda.driver as cuda + from ..core import ensure_context + from ..bls import BLSMemory + ensure_context() + mem = BLSMemory(64, 128, stream=cuda.Stream()) + pinned = cuda.pagelocked_empty(1, np.float32).base.__class__ + assert isinstance(mem.bls.base, pinned) + + def test_pooled_memory_stages_identical_bytes(self): + # The pool must never hand back another light curve's data: the + # staged host buffers, the uploaded device buffers and the + # normalization scalars have to equal what a freshly-allocated + # memory produces, bit for bit. + from .. import bls as B + from ..bls import BLSMemory + freqs = np.linspace(0.95, 1.05, 64) + B._memory_pool_tls.pool = None + for seed in (1, 2, 3): + t, y, dy = self._data(seed=seed) + pooled = B._pooled_bls_memory(t, y, dy, 1e-2, 0.5, freqs, {}) + fresh = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True) + for attr in ('t', 'yw', 'w', 'freqs', 'nbins0', 'nbinsf'): + assert np.array_equal(np.asarray(getattr(pooled, attr)), + np.asarray(getattr(fresh, attr))), attr + for attr in ('t_g', 'yw_g', 'w_g', 'freqs_g', 'nbins0_g', + 'nbinsf_g'): + assert np.array_equal(getattr(pooled, attr).get(), + getattr(fresh, attr).get()), attr + for attr in ('yy', 'chi2_0', 'ybar', 'epoch'): + assert getattr(pooled, attr) == getattr(fresh, attr), attr + B._memory_pool_tls.pool = None + + def test_pool_reuses_one_memory_per_shape(self): + from .. import bls as B + freqs = np.linspace(0.95, 1.05, 64) + B._memory_pool_tls.pool = None + t, y, dy = self._data() + m1 = B._pooled_bls_memory(t, y, dy, 1e-2, 0.5, freqs, {}) + m2 = B._pooled_bls_memory(t, y, dy, 1e-2, 0.5, freqs, {}) + assert m1 is m2 + # a different ndata is a different entry + t2, y2, dy2 = self._data(ndata=100) + m3 = B._pooled_bls_memory(t2, y2, dy2, 1e-2, 0.5, freqs, {}) + assert m3 is not m1 + # ... and a different number of frequencies too (the device + # frequency arrays keep their first size) + f2 = np.linspace(0.95, 1.05, 128) + m4 = B._pooled_bls_memory(t, y, dy, 1e-2, 0.5, f2, {}) + assert m4 is not m1 + assert len(m4.freqs_g) == 128 + B._memory_pool_tls.pool = None + + def test_pooled_and_unpooled_results_agree(self): + # Interleave three different light curves through the pool and + # compare against the allocate-per-call path; also check that + # holding an earlier result across later calls is safe (the + # returned array must not alias a pooled buffer). + from .. import bls as B + freqs = np.linspace(0.95, 1.05, 300) + lcs = [data(snr=30, q=0.05, phi0=0.317, freq=1.0, baseline=365., + ndata=300, seed=s) for s in (11, 12, 13)] + + old = B._MEMORY_POOL_MAX_SIZE + try: + B._MEMORY_POOL_MAX_SIZE = 0 + B._memory_pool_tls.pool = None + ref = [eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1) + for (t, y, dy) in lcs] + B._MEMORY_POOL_MAX_SIZE = 2 + B._memory_pool_tls.pool = None + got = [eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1) + for (t, y, dy) in lcs] + finally: + B._MEMORY_POOL_MAX_SIZE = old + B._memory_pool_tls.pool = None + + for a, b in zip(got, ref): + assert int(np.argmax(a)) == int(np.argmax(b)) + assert_allclose(a, b, rtol=1e-4, atol=1e-6) + # distinct light curves must give distinct periodograms (a pool + # bug that reused stale data would make these equal) + assert not np.allclose(got[0], got[1], rtol=1e-3) + + def test_pool_is_skipped_when_it_would_be_visible(self): + # A stream-attached call hands back the pinned bls buffer, and + # transfer_to_host=False hands back the raw buffer: neither may + # come from the pool. + import pycuda.driver as cuda + from ..core import ensure_context + from .. import bls as B + ensure_context() + t, y, dy = self._data(ndata=300) + freqs = np.linspace(0.95, 1.05, 100) + B._memory_pool_tls.pool = None + eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1, + stream=cuda.Stream()) + assert not getattr(B._memory_pool_tls, 'pool', None) + eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1, + transfer_to_host=False) + assert not getattr(B._memory_pool_tls, 'pool', None) + # the ordinary call does use it + eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1) + assert len(B._memory_pool_tls.pool) == 1 + B._memory_pool_tls.pool = None + + +class TestNoBlasThreadpoolInPrologues(object): + """Sep 2026 audit, id 45 (plan item BLS-8). + + ``np.dot`` on a long float vector goes to BLAS, which spawns a full + threadpool; on CPU-quota-limited containers (RunPod, Kubernetes) the + burst trips CFS throttling and stalls the process. The July 2026 work + moved ``BLSMemory.setdata`` and ``_chi2_null`` to ``np.einsum``; the + per-light-curve prologues of ``eebls_gpu``, ``eebls_gpu_custom``, + ``single_bls`` and ``sparse_bls_cpu`` were still on ``np.dot``. + Measured on the pod at ndata = 20000: the prologue's median went + 0.60 -> 0.17 ms with a 98 ms tail and 12 CFS throttle events per 50 + calls going to none, and ``single_bls`` 93.9 -> 0.7 ms. + + Source-level guard (there is no timing assertion anywhere here) plus + a check that the change is a summation-order change only. + """ + + @pytest.mark.parametrize("name", ['eebls_gpu', 'eebls_gpu_custom', + 'single_bls', 'sparse_bls_cpu']) + def test_prologue_does_not_call_np_dot(self, name): + import inspect + from .. import bls as B + src = inspect.getsource(getattr(B, name)) + # comments mention np.dot on purpose; look at the code only + code = '\n'.join(line.split('#')[0] for line in src.splitlines()) + assert 'np.dot' not in code, ( + "%s reintroduced np.dot: use np.einsum('i,i->', ...) so the " + "per-light-curve prologue stays off the BLAS threadpool" + % name) + assert "np.einsum('i,i->'" in code + + def test_einsum_and_dot_agree_to_rounding(self): + # The replacement is the same mathematical reduction in a + # different summation order: a few float64 ulps. + rand = np.random.RandomState(3) + for ndata in (150, 2000, 20000): + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + w = np.power(dy, -2.) + w /= np.sum(w) + ybar_dot = np.dot(w, y) + ybar_ein = float(np.einsum('i,i->', w, y)) + assert abs(ybar_dot - ybar_ein) <= 64 * np.spacing(abs(ybar_ein)) + yy_dot = np.dot(w, np.power(y - ybar_dot, 2)) + yy_ein = float(np.einsum('i,i->', w, np.power(y - ybar_ein, 2))) + assert abs(yy_dot - yy_ein) <= 64 * np.spacing(abs(yy_ein)) + + def test_sparse_bls_cpu_still_matches_the_gpu_kernel(self): + # sparse_bls_cpu is the CPU reference for sparse_bls_gpu; the + # reordered sums must not move it away from the kernel. + t, y, dy = data(snr=30, q=0.05, phi0=0.317, freq=1.0, + baseline=365., ndata=120) + freqs = np.linspace(0.95, 1.05, 60) + p_cpu, s_cpu = sparse_bls_cpu(t, y, dy, freqs) + p_gpu, s_gpu = sparse_bls_gpu(t, y, dy, freqs) + assert int(np.argmax(p_cpu)) == int(np.argmax(p_gpu)) + assert_allclose(p_cpu, p_gpu, rtol=1e-4, atol=1e-6) + + +class TestPerFrequencyHostWork(object): + """Sep 2026 audit, ids 136/137 (plan item BLS-9). + + Three per-call host costs that were pure overhead: + the per-frequency solution re-phasing comprehension (39 ms at 60,121 + frequencies, 74 ms at 117,403 -- more than the GPU work it followed), + ``_chi2_null``'s second full pass over the light curve in + ``BLSMemory.setdata`` when ``chi2_0`` follows from ``yy``, and + ``conflict_scatter_perm`` rebuilt on every ``setdata`` although it is + a pure function of ``ndata``. + """ + + @staticmethod + def _lc(ndata=300, seed=5): + rand = np.random.RandomState(seed) + t = np.sort(365. * rand.rand(ndata)) + 2455197.5 + y = 1. + 0.01 * rand.randn(ndata) + dy = 0.01 * np.ones(ndata) + return t, y, dy + + @pytest.mark.parametrize("freqs_kind", + ['float64', 'float32', 'list', 'np_scalars']) + def test_rephasing_matches_the_per_frequency_loop(self, freqs_kind): + from ..bls import _rephase_solutions + rand = np.random.RandomState(4) + n = 500 + q = rand.rand(n).astype(np.float32) + phi = rand.rand(n).astype(np.float32) + base = np.linspace(0.01, 2.0, n) + freqs = {'float64': base, + 'float32': base.astype(np.float32), + 'list': [float(x) for x in base], + 'np_scalars': list(base)}[freqs_kind] + # epoch is np.float64 everywhere in the package (subtract_epoch + # returns np.floor(np.min(t))), which is what keeps the + # expression in float64 for a float32 grid. + epoch = np.float64(2455197.0) + + old = [(a, (b + (epoch * f)) % 1.0) + for (a, b), f in zip(list(zip(q, phi)), freqs)] + new = _rephase_solutions(q, phi, epoch, freqs) + assert len(new) == len(old) + assert np.array_equal(np.asarray(old, dtype=np.float64), + np.asarray(new, dtype=np.float64)) + # and it is the exact float64 answer + exact = (phi.astype(np.float64) + + epoch * np.asarray(freqs, dtype=np.float64)) % 1.0 + assert np.array_equal(np.array([x[1] for x in new]), exact) + + def test_setdata_chi2_0_matches_the_two_pass_form(self): + from ..bls import BLSMemory, _chi2_null + freqs = np.linspace(0.95, 1.05, 64) + for ndata in (150, 2000): + t, y, dy = self._lc(ndata=ndata) + mem = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True) + # chi2_0 = yy * sum(dy**-2): the same weighted sum of + # squares with un-normalized weights + assert_allclose(mem.chi2_0, _chi2_null(y, dy), rtol=1e-12) + assert_allclose(mem.chi2_0, + mem.yy * np.sum(np.power(dy, -2.)), rtol=1e-14) + + def test_setdata_chi2_0_with_float32_inputs(self): + # float32 y/dy make yy (and hence chi2_0) a float32-accumulated + # sum where _chi2_null forced float64; the difference is ~1 + # float32 ulp, well inside the data's own precision. + from ..bls import BLSMemory, _chi2_null + freqs = np.linspace(0.95, 1.05, 64) + t, y, dy = self._lc(ndata=2000) + y = y.astype(np.float32) + dy = dy.astype(np.float32) + mem = BLSMemory.fromdata(t, y, dy, qmin=1e-2, qmax=0.5, + freqs=freqs, transfer=True) + assert_allclose(mem.chi2_0, _chi2_null(y, dy), rtol=1e-5) + + def test_scatter_perm_cache(self): + from ..bls import _cached_conflict_scatter_perm + from ..utils import conflict_scatter_perm + for n in (63, 64, 150, 2000): + cached = _cached_conflict_scatter_perm(n) + direct = conflict_scatter_perm(n) + if direct is None: + assert cached is None + continue + assert np.array_equal(cached, direct) + # same object on the second call, and read-only so a caller + # cannot corrupt the shared permutation + assert _cached_conflict_scatter_perm(n) is cached + assert not cached.flags.writeable + + def test_conventions_still_consistent(self): + # chi2_0 feeds the 'snr'/'loglik' conversions + t, y, dy = self._lc(ndata=400) + freqs = np.linspace(0.95, 1.05, 120) + p = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1) + p_snr = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1, + convention='snr') + p_ll = eebls_gpu_fast(t, y, dy, freqs, qmin=0.01, qmax=0.1, + convention='loglik') + w = np.power(dy, -2.) + ybar = float(np.einsum('i,i->', w, y)) / np.sum(w) + chi2_0 = float(np.einsum('i,i->', w, (np.asarray(y) - ybar) ** 2)) + assert_allclose(p_snr, np.sqrt(chi2_0 * p), rtol=1e-5, atol=1e-6) + assert_allclose(p_ll, 0.5 * chi2_0 * p, rtol=1e-5, atol=1e-6) + + +class TestAdaptiveBlockSize(object): + """``eebls_gpu_fast_adaptive`` picks the CUDA block size from + ``ndata`` (ported from ``scripts/test_adaptive_correctness.py``). + The heuristic is CPU-checkable; the parity of the adaptive wrapper + with ``eebls_gpu_fast_optimized`` at the same block size runs on a + device (the conftest skips it on CPU-only hosts).""" + + EXPECTED = [(2, 32), (10, 32), (32, 32), (33, 64), (50, 64), (64, 64), + (65, 128), (100, 128), (128, 128), (129, 256), (500, 256), + (65536, 256)] + + @pytest.mark.parametrize("ndata,expected", EXPECTED) + def test_choose_block_size(self, ndata, expected): + from ..bls import _choose_block_size + bs = _choose_block_size(ndata) + assert bs == expected + assert bs in (32, 64, 128, 256) + + def test_choose_block_size_is_monotonic(self): + from ..bls import _choose_block_size + sizes = [_choose_block_size(n) for n in range(2, 600)] + assert all(a <= b for a, b in zip(sizes, sizes[1:])) + assert set(sizes) == {32, 64, 128, 256} + + @staticmethod + def _lc(ndata, seed=42): + rand = np.random.RandomState(seed) + t = np.sort(rand.uniform(0, 100, ndata)) + period, depth = 5.0, 0.01 + phase = (t % period) / period + y = np.ones(ndata) - depth * ((phase > 0.4) & (phase < 0.5)) + y += rand.normal(0, 0.01, ndata) + dy = 0.01 * np.ones(ndata) + return t, y, dy + + @pytest.mark.parametrize("ndata", [10, 50, 100, 500]) + def test_adaptive_matches_optimized_at_the_chosen_block_size(self, + ndata): + # GPU only. The adaptive wrapper is eebls_gpu_fast_optimized with + # block_size=_choose_block_size(ndata) and the same cached + # kernel set; results must agree to float32 rounding (the + # fold/bin arithmetic is identical, only the launch shape + # differs) and the peak must be the same grid point. + from ..bls import (eebls_gpu_fast_adaptive, eebls_gpu_fast_optimized, + _choose_block_size) + t, y, dy = self._lc(ndata) + freqs = np.linspace(0.05, 0.5, 100) + bs = _choose_block_size(ndata) + p_adaptive = eebls_gpu_fast_adaptive(t, y, dy, freqs) + p_fixed = eebls_gpu_fast_optimized(t, y, dy, freqs, block_size=bs) + assert p_adaptive.shape == freqs.shape + assert np.all(np.isfinite(p_adaptive)) + assert_allclose(p_adaptive, p_fixed, rtol=1e-5, atol=1e-6) + assert np.argmax(p_adaptive) == np.argmax(p_fixed) + + +class TestHostLadderMirrorsDevice(object): + """The host q ladder (``dnbins`` and everything built on it: + ``count_tot_nbins`` sizing ``eebls_gpu``'s bin rows, and + ``_fast_box_widths`` replicating the fast kernels' box grid) must + agree with the device ladder rung for rung. The kernels form + ``floorf(dlogq * nbins)`` in float32 (``dlogq`` is a ``float`` + kernel argument), and the old float64 host arithmetic disagreed for + e.g. ``dlogq = 0.65, nbins = 180`` (117.0 vs floorf(116.99999) = + 116), so ``eebls_gpu(dlogq=0.65)`` could under-size a frequency's + row (host 180 cells, device 476) and the fold kernel's atomics ran + into the next row (Sep 2026 fresh-eyes review, finding 26).""" + + DLOGQS = [round(0.05 * k, 2) for k in range(2, 21)] # 0.1 .. 1.0 + + @staticmethod + def _device_dnbins(nbins, dlogq): + # bls_common.cuh: `unsigned int n = (unsigned int) floorf(dlogq + # * nbins); return (n == 0) ? 1 : n;` with float dlogq and + # unsigned int nbins (exact in float32 below 2^24) + if dlogq < 0: + return 1 + n = int(np.floor(np.float32(dlogq) * np.float32(nbins))) + return n if n > 0 else 1 + + @pytest.mark.parametrize("dlogq", DLOGQS) + def test_dnbins_matches_the_float32_device_arithmetic(self, dlogq): + from ..bls import dnbins + nb = np.arange(1, 200001) + f32 = np.floor(np.float32(dlogq) * nb.astype(np.float32)) + f64 = np.floor(dlogq * nb.astype(np.float64)) + # every nbins where float32 and float64 disagree, plus a sample + # of those where they agree (the whole range would be 200000 + # scalar calls per dlogq) + differ = nb[f32 != f64] + same = nb[f32 == f64][::997] + for n in np.concatenate([differ, same]): + assert dnbins(int(n), dlogq) == self._device_dnbins(int(n), + dlogq) + if dlogq in (0.2, 0.3): + # the defaults of eebls_gpu / the fast paths: the fix must + # not move a single rung there + assert len(differ) == 0 + elif dlogq in (0.35, 0.65, 0.7): + # the values where the review found the divergence + assert len(differ) > 0 + + def test_count_tot_nbins_matches_the_device_count(self): + # the review's cases: (nbins0, nbinsf, dlogq) -> device count + from ..bls import count_tot_nbins + + def device_count(nb0, nbf, dlogq): + tot, nb = 0, nb0 + while nb <= nbf: + tot += nb + nb += self._device_dnbins(nb, dlogq) + return tot + + for nb0, nbf, dlogq, expect in [(180, 296, 0.65, 476), + (180, 243, 0.35, 423), + (90, 153, 0.7, 243)]: + assert device_count(nb0, nbf, dlogq) == expect + assert count_tot_nbins(nb0, nbf, dlogq) == expect + # and the defaults are what they always were + assert count_tot_nbins(2, 100, 0.2) == 2 + 3 + 4 + 5 + 6 + 7 + \ + 8 + 9 + 10 + 12 + 14 + 16 + 19 + 22 + 26 + 31 + 37 + 44 + \ + 52 + 62 + 74 + 88 + + def test_fast_box_widths_matches_the_device_ladder(self): + from ..bls import _fast_box_widths + # (1, 6000, 0.53) is a pair where the old float64 host ladder + # and the device's float32 ladder differ, so this test fails on + # the pre-fix code (the smaller pairs happen to agree there) + for dlogq in (0.35, 0.65, 0.7, 0.3, 0.53): + for nb0, nbf in [(1, 180), (1, 340), (2, 360), (1, 90), + (1, 6000)]: + widths = _fast_box_widths(nbf, nb0, dlogq) + m, expect = 1, [] + while m <= nbf // nb0: + expect.append(m) + m += self._device_dnbins(m, dlogq) + assert widths == expect + + +class TestFastSolutionLadderMatchesKernel(object): + """``eebls_transit``'s top-K ``(q, phi)`` re-scan must walk the SAME + bin ladder the kernel searched. ``BLSMemory.setdata`` computes the + kernel's ``nbins0`` / ``nbinsf`` with ``_fast_path_nbins`` on the + bounds as passed (their own dtype); ``_fast_bls_solutions`` used to + promote them to float64 first, so for float32 ``qvals`` (the + documented override, e.g. ``keplerian_freq_grid(return_qvals=True)`` + output) the two ladders were one bin apart at some frequencies and + the reported box was one the kernel never evaluated (Sep 2026 + fresh-eyes review, finding 18).""" + + ROUND_Q32 = np.float32([0.025, 0.05, 1. / 7., 0.1, 0.2, 1. / 9., + 0.03, 0.07]) + + @staticmethod + def _kernel_ladder(freqs, qmin, qmax): + # exactly BLSMemory.setdata: `self.freqs = np.asarray(freqs) + # .astype(self.rtype)`; `_fast_path_nbins(self.freqs, qmin, qmax)` + return _fast_path_nbins(np.asarray(freqs).astype(np.float32), + qmin, qmax) + + @staticmethod + def _record_solution_ladder(monkeypatch, *args, **kwargs): + """Run _fast_bls_solutions and return the (nbins0, nbinsf) it + derived, captured from its _fast_path_nbins call.""" + import cuvarbase.bls as bls_mod + seen = [] + real = bls_mod._fast_path_nbins + + def recorder(freqs32, qmin, qmax): + out = real(freqs32, qmin, qmax) + seen.append(out) + return out + + monkeypatch.setattr(bls_mod, '_fast_path_nbins', recorder) + sols = bls_mod._fast_bls_solutions(*args, **kwargs) + assert len(seen) == 1 + return sols, seen[0] + + @staticmethod + def _lc(n=300, seed=4): + rand = np.random.RandomState(seed) + t = np.sort(30. * rand.rand(n)) + y = 1. + 1e-3 * rand.randn(n) + dy = 1e-3 * np.ones(n) + return t, y, dy + + def test_float32_bounds_use_the_uploaded_ladder(self, monkeypatch): + from ..bls import _broadcast_q_bound + t, y, dy = self._lc() + q32 = self.ROUND_Q32 + freqs = np.linspace(0.5, 1.5, len(q32)) + qmins, qmaxes = q32 * 0.5, q32 * 2.0 # as eebls_transit forms them + assert qmins.dtype == np.float32 and qmaxes.dtype == np.float32 + + nb0_k, nbf_k = self._kernel_ladder(freqs, qmins, qmaxes) + _, (nb0_s, nbf_s) = self._record_solution_ladder( + monkeypatch, t, y, dy, freqs, np.ones(len(freqs)), + qmins, qmaxes, len(freqs)) + assert np.array_equal(nb0_s, nb0_k) + assert np.array_equal(nbf_s, nbf_k) + + # ... and the test bites: the float64-promoted ladder the old + # code walked differs at some of these 'round' float32 values + nb0_p, nbf_p = _fast_path_nbins( + freqs.astype(np.float32), + _broadcast_q_bound(qmins, len(freqs), 1e-2, 'qmin'), + _broadcast_q_bound(qmaxes, len(freqs), 0.5, 'qmax')) + assert np.any(nbf_p != nbf_k) and np.any(nb0_p != nb0_k) + + def test_float64_default_path_is_unchanged(self, monkeypatch): + # the default eebls_transit path (float64 qvals from + # transit_autofreq): promoting to float64 was the identity, so + # the ladder is bit-identical before and after the fix, and + # identical to the kernel's + from ..bls import _broadcast_q_bound + t, y, dy = self._lc() + freqs, q0 = transit_autofreq(t, fmin=0.2, fmax=2.0) + freqs, q0 = freqs[::50], q0[::50] + qmins, qmaxes = q0 * 0.5, q0 * 2.0 + assert qmins.dtype == np.float64 + nb0_k, nbf_k = self._kernel_ladder(freqs, qmins, qmaxes) + nb0_old, nbf_old = _fast_path_nbins( + freqs.astype(np.float32), + _broadcast_q_bound(qmins, len(freqs), 1e-2, 'qmin'), + _broadcast_q_bound(qmaxes, len(freqs), 0.5, 'qmax')) + _, (nb0_s, nbf_s) = self._record_solution_ladder( + monkeypatch, t, y, dy, freqs, np.ones(len(freqs)), + qmins, qmaxes, len(freqs)) + for a in (nb0_old, nb0_s): + assert np.array_equal(a, nb0_k) + for a in (nbf_old, nbf_s): + assert np.array_equal(a, nbf_k) + + def test_scalar_and_none_bounds_match_the_fast_path_defaults( + self, monkeypatch): + t, y, dy = self._lc() + freqs = np.linspace(0.5, 1.5, 5) + _, (nb0_s, nbf_s) = self._record_solution_ladder( + monkeypatch, t, y, dy, freqs, np.ones(5), None, None, 5) + nb0_k, nbf_k = self._kernel_ladder(freqs, 1e-2, 0.5) + assert np.array_equal(nb0_s, nb0_k) and np.array_equal(nbf_s, + nbf_k) + _, (nb0_s, nbf_s) = self._record_solution_ladder( + monkeypatch, t, y, dy, freqs, np.ones(5), 0.05, 0.25, 5) + nb0_k, nbf_k = self._kernel_ladder(freqs, 0.05, 0.25) + assert np.array_equal(nb0_s, nb0_k) and np.array_equal(nbf_s, + nbf_k) + + def test_reported_box_is_on_the_kernel_grid_for_float32_bounds(self): + # an on-grid q = 4/40 box at phi0 = 0.25; float32 bounds + # qmin = 0.025, qmax = 0.2 give the kernel nbinsf = 40 and + # nbins0 = 5, while the float64-promoted ladder is 39 / 4, on + # which no q = m/39 box is the kernel's + t, y, dy = TestFastPathQmaxBox._on_grid_box(nbf=40, m=4, n0=10) + qmin, qmax = np.float32([0.025]), np.float32([0.2]) + nb0_k, nbf_k = self._kernel_ladder([1.0], qmin, qmax) + assert (int(nb0_k[0]), int(nbf_k[0])) == (5, 40) + sols = _fast_bls_solutions(t, y, dy, np.array([1.0]), + np.array([1.0]), qmin, qmax, 1) + q, phi0 = sols[0] + assert q == pytest.approx(4. / 40., abs=1e-9) + assert phi0 == pytest.approx(0.25, abs=1e-6) + # a q = m/39 (the old ladder) is never within 1e-9 of m/40 + assert np.min(np.abs(q - np.arange(1, 40) / 39.)) > 1e-4 + + +class TestSingleBlsQDomain(object): + """``single_bls`` input domain: ``freq > 0``, ``q`` in ``[0, 1]``, + ``phi0`` any finite phase. A negative or > 1 ``q`` used to return + a silent power of 0 (Sep 2026 fresh-eyes review, finding 38).""" + + @staticmethod + def _lc(n=200, seed=9): + rand = np.random.RandomState(seed) + t = np.sort(20. * rand.rand(n)) + y = 1. - 0.01 * (((t * 0.7) % 1.) < 0.1) + 1e-3 * rand.randn(n) + dy = 1e-3 * np.ones(n) + return t, y, dy + + @pytest.mark.parametrize("q", [-0.1, -1e-9, 1.0000001, 1.5, 7.]) + def test_q_outside_unit_interval_raises(self, q): + t, y, dy = self._lc() + with pytest.raises(ValueError, match=r"q must be in \[0, 1\]"): + single_bls(t, y, dy, 0.7, q, 0.1) + + @pytest.mark.parametrize("freq", [0., -0.7]) + def test_non_positive_freq_raises(self, freq): + t, y, dy = self._lc() + with pytest.raises(ValueError, match="freq must be > 0"): + single_bls(t, y, dy, freq, 0.1, 0.1) + + @pytest.mark.parametrize("bad", [np.nan, np.inf, -np.inf]) + def test_non_finite_parameters_raise(self, bad): + t, y, dy = self._lc() + for args in [(bad, 0.1, 0.1), (0.7, bad, 0.1), (0.7, 0.1, bad)]: + with pytest.raises(ValueError, match="must be finite"): + single_bls(t, y, dy, *args) + + def test_q_endpoints_evaluate_to_zero_power(self): + # q = 0 (the sparse paths' no-solution sentinel) is an empty + # box; q = 1 is an all-weight box: both are power 0, not errors + t, y, dy = self._lc() + assert single_bls(t, y, dy, 0.7, 0.0, 0.1) == 0 + assert single_bls(t, y, dy, 0.7, 1.0, 0.1) == 0 + + def test_phi0_is_any_finite_phase(self): + # phi0 = 0 and negative phases are valid and wrap mod 1 + t, y, dy = self._lc() + p0 = single_bls(t, y, dy, 0.7, 0.1, 0.0) + assert np.isfinite(p0) and p0 > 0.5 + assert single_bls(t, y, dy, 0.7, 0.1, -0.3) == \ + single_bls(t, y, dy, 0.7, 0.1, 0.7) + assert single_bls(t, y, dy, 0.7, 0.1, -1.0) == p0 diff --git a/cuvarbase/tests/test_bls_frequencies.py b/cuvarbase/tests/test_bls_frequencies.py new file mode 100644 index 00000000..4248ff4e --- /dev/null +++ b/cuvarbase/tests/test_bls_frequencies.py @@ -0,0 +1,193 @@ +""" +Tests for the Keplerian/uniform frequency grid utilities and the +per-frequency q-bound wiring into the batch BLS API. +""" +import numpy as np +import pytest + +from ..bls_frequencies import (_q_transit, keplerian_freq_grid, + uniform_freq_grid, freq_grid_stats) + + +class TestKeplerianFreqGrid: + + def test_grid_covers_range(self): + freqs = keplerian_freq_grid(1.0, 10.0, baseline=365.0) + assert freqs[0] == pytest.approx(0.1, rel=1e-5) + assert freqs[-1] >= 1.0 * 0.999 + assert np.all(np.diff(freqs) > 0) + + def test_fewer_freqs_than_uniform(self): + kep = keplerian_freq_grid(1.0, 50.0, baseline=1000.0) + uni = uniform_freq_grid(1.0, 50.0, baseline=1000.0) + assert len(kep) < len(uni) + + def test_return_qvals(self): + freqs, qvals = keplerian_freq_grid(1.0, 10.0, baseline=365.0, + return_qvals=True) + assert len(qvals) == len(freqs) + assert qvals.dtype == np.float32 + assert np.all(qvals > 0) + assert np.all(qvals <= 0.5) + # Keplerian q grows with frequency (shorter periods -> larger + # duration fraction) + assert np.all(np.diff(qvals) >= 0) + # consistent with the q model used to build the grid + np.testing.assert_allclose( + qvals, _q_transit(freqs.astype(np.float64)), rtol=1e-5) + + def test_default_return_unchanged(self): + out = keplerian_freq_grid(1.0, 10.0, baseline=365.0) + assert isinstance(out, np.ndarray) + + def test_grid_stats(self): + freqs = keplerian_freq_grid(1.0, 50.0, baseline=1000.0) + stats = freq_grid_stats(freqs, 1000.0) + assert stats['nfreq'] == len(freqs) + assert stats['reduction_factor'] > 1 + + +class TestBatchPerFrequencyQBounds: + """eebls_gpu_batch accepts per-frequency qmin/qmax arrays (the + batch kernel reads per-frequency bin counts); combined with + keplerian_freq_grid(return_qvals=True) this enables + duration-constrained Keplerian searches in batch mode.""" + + def test_batch_keplerian_q_bounds(self): + # GPU only: skipped on CPU machines via the conftest stub. + from ..bls import eebls_gpu_batch + + freq_inj, q_inj, delta = 0.5, 0.03, 0.05 + ndata, baseline = 300, 365.0 + + lcs = [] + for seed in (1, 2): + r = np.random.RandomState(seed) + t = np.sort(baseline * r.rand(ndata)) + phase = (t * freq_inj) % 1.0 + y = 12.0 - delta * (phase < q_inj) + y += 0.01 * r.randn(ndata) + lcs.append((t, y, 0.01 * np.ones(ndata))) + + freqs, qvals = keplerian_freq_grid(1.5, 3.0, baseline, + return_qvals=True) + results = eebls_gpu_batch(lcs, freqs, + qmin=0.5 * qvals, qmax=2.0 * qvals) + assert len(results) == 2 + for power in results: + best = freqs[int(np.argmax(power))] + assert abs(best - freq_inj) / freq_inj < 0.02 + + +class TestVectorizedGridRecursion: + """Sep 2026 audit, ids 4/44 (plan item BLS-4). + + ``keplerian_freq_grid`` and ``cuvarbase.bls.transit_autofreq`` built + the Ofir (2014) duty-cycle grid with a scalar Python ``while`` loop, + one ``q`` evaluation per frequency: 0.2-1.0 s per call at survey + grid sizes, which dwarfed the GPU search that followed. They now + solve the same recursion with numpy (``method='vectorized'``, the + default) and keep the loop as ``method='recursion'``. + + The vectorized form converges to a fixed point of the *same* + recursion (seed from the continuum integral, then defect + correction), so these tests pin agreement, not a tolerance chosen to + accommodate a different grid. + """ + + CASES = [ + dict(period_min=0.5, period_max=100., baseline=730.), + dict(period_min=0.5, period_max=13.5, baseline=27.), + dict(period_min=0.5, period_max=300., baseline=1400.), + dict(period_min=0.3, period_max=50., baseline=200., + R_star=0.3, M_star=0.3), + dict(period_min=1.0, period_max=200., baseline=500., R_star=3.0), + dict(period_min=0.5, period_max=100., baseline=365., + oversampling=10), + dict(period_min=0.5, period_max=100., baseline=365., + oversampling=0.5), + dict(period_min=9.0, period_max=10., baseline=100.), + # degenerate: period_min > period_max + dict(period_min=100., period_max=0.5, baseline=100.), + ] + + @pytest.mark.parametrize("case", CASES) + def test_keplerian_grid_matches_the_recursion(self, case): + rec = keplerian_freq_grid(method='recursion', **case) + vec = keplerian_freq_grid(method='vectorized', **case) + # float32 output: bit-identical + assert rec.shape == vec.shape + assert np.array_equal(rec, vec) + + @pytest.mark.parametrize("case", CASES[:4]) + def test_keplerian_qvals_match(self, case): + fr, qr = keplerian_freq_grid(method='recursion', return_qvals=True, + **case) + fv, qv = keplerian_freq_grid(method='vectorized', return_qvals=True, + **case) + assert np.array_equal(qr, qv) + + def test_bad_method_raises(self): + with pytest.raises(ValueError, match="grid method"): + keplerian_freq_grid(1.0, 10.0, 365.0, method='euler') + + def test_recursion_still_reproduces_its_own_definition(self): + # guard against the shared helper drifting from the scalar loop + # it replaced (this is the literal pre-1.0 body) + from ..bls_frequencies import _recursion_transit_grid + rho, oversampling, T = 1.0, 2, 365.0 + f_min, f_max = 1. / 100., 1. / 0.5 + freqs = [f_min] + while freqs[-1] < f_max: + q = float(_q_transit(freqs[-1], rho=rho)) + q = max(q, 1e-6) + freqs.append(freqs[-1] + q / (oversampling * T)) + ref = np.array(freqs) + got = _recursion_transit_grid(f_min, f_max, 1.0, oversampling * T, + rho=rho, q_floor=1e-6) + assert np.array_equal(ref, got) + + @pytest.mark.parametrize("kw", [ + {}, dict(samples_per_peak=5), dict(rho=5.), dict(rho=0.05), + dict(qmin_fac=0.5), dict(qmin_fac=0.1, qmax_fac=4.), + ]) + def test_transit_autofreq_matches_the_recursion(self, kw): + from ..bls import transit_autofreq + rand = np.random.RandomState(21) + t = np.sort(180. * rand.rand(400)) + + fr, qr = transit_autofreq(t, method='recursion', **kw) + fv, qv = transit_autofreq(t, method='vectorized', **kw) + assert len(fr) == len(fv), "grid length changed" + # float64 output: the accumulated Euler sum agrees to rounding + np.testing.assert_allclose(fv, fr, rtol=1e-13, atol=0.) + # the trial frequencies the kernels actually search (float32) + # are bit-identical + assert np.array_equal(fr.astype(np.float32), + fv.astype(np.float32)) + np.testing.assert_allclose(qv, qr, rtol=1e-12, atol=0.) + + def test_transit_autofreq_bad_method_raises(self): + from ..bls import transit_autofreq + rand = np.random.RandomState(3) + t = np.sort(180. * rand.rand(200)) + with pytest.raises(ValueError, match="grid method"): + transit_autofreq(t, method='integral') + + def test_vectorized_grid_satisfies_the_recursion(self): + # the defining property, checked directly on the returned grid + from ..bls import transit_autofreq, q_transit, fmax_transit + rand = np.random.RandomState(5) + t = np.sort(365. * rand.rand(500)) + T = float(np.max(t) - np.min(t)) + freqs, _ = transit_autofreq(t, samples_per_peak=2, qmin_fac=0.2) + step = (0.2 * q_transit(freqs[:-1], rho=1.)) / (2 * T) + np.testing.assert_allclose(freqs[1:], freqs[:-1] + step, + rtol=1e-13, atol=0.) + # ... and it stops exactly where the loop would: the recursion + # runs `while freqs[-1] < fmax`, so the grid ends at the FIRST + # point at or above fmax (with fmax as transit_autofreq derives + # it: qmax_fac defaults to 1/qmin_fac) + fmax = fmax_transit(rho=1., qmax=0.5 / (1. / 0.2)) + assert len(freqs) >= 2 + assert freqs[-2] < fmax <= freqs[-1] diff --git a/cuvarbase/tests/test_ce.py b/cuvarbase/tests/test_ce.py index 6b7078d6..4dc2c99b 100644 --- a/cuvarbase/tests/test_ce.py +++ b/cuvarbase/tests/test_ce.py @@ -1,15 +1,16 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function +import types -from builtins import zip -from builtins import range -from builtins import object import pytest -from pycuda.tools import mark_cuda_test +import pycuda.gpuarray as gpuarray import numpy as np -from numpy.testing import assert_allclose -from ..ce import ConditionalEntropyAsyncProcess +from numpy.testing import assert_allclose, assert_array_equal +from scipy.special import ndtr +from .. import ce as ce_module +from ..ce import (ConditionalEntropyAsyncProcess, _needs_compile, + _CE_KERNELS, _is_single_freq_grid, _fast_grid_size, + _MAX_BLOCKS_PER_SM) +from ..memory import ConditionalEntropyMemory +from ..utils import normalize_light_curves lsrtol = 1E-2 lsatol = 1E-5 seed = 100 @@ -39,6 +40,127 @@ def assert_similar(pdg0, pdg, top=5): assert(all(diff < lsrtol * 0.5 * (p + p0) + lsatol)) +# --------------------------------------------------------------------------- +# Independent CPU references (float64 sums, cuvarbase's bin conventions) +# --------------------------------------------------------------------------- + +def _prep(t, y, dtype): + """Emulate normalize_light_curves + ConditionalEntropyMemory.setdata.""" + t = np.asarray(t, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + t = (t - t.mean()).astype(dtype) + y = (y - y.mean()).astype(dtype) + yscale = y.max() - y.min() + y0 = y.min() + return t, ((y - y0) / yscale).astype(dtype), yscale + + +def _phase_bins(t, f, nphase, dtype): + ft = (t * dtype(f)).astype(dtype) + ph = ft - np.floor(ft) + return (np.floor(ph.astype(np.float64) * nphase).astype(int)) % nphase + + +def cpu_ce(t, y, freqs, nphase, nmag, phase_overlap=0, mag_overlap=0, + dtype=np.float32): + """Graham et al. (2013) conditional entropy with cuvarbase's bin + definitions (uniform magnitude bins over [min, max], the brightest + point in the top bin), overlap handling and its density offset + ``log(dm)``; histogram counts are exact integers and the entropy sum + runs in float64.""" + t, y01, _ = _prep(t, y, dtype) + m0 = np.minimum(np.floor(y01 * dtype(nmag)).astype(int), nmag - 1) + dm0 = (mag_overlap + 1.0) / nmag + mm = np.arange(nmag) + dm = np.where(mm + mag_overlap + 1 > nmag, + (nmag - mm) * dm0 / (1.0 + mag_overlap), dm0) + out = np.empty(len(freqs)) + for k, f in enumerate(freqs): + n0 = _phase_bins(t, f, nphase, dtype) + H = np.zeros((nphase, nmag)) + for dn in range(phase_overlap + 1): + for dmm in range(mag_overlap + 1): + m = m0 - dmm + ok = m >= 0 + np.add.at(H, ((n0[ok] - dn) % nphase, m[ok]), 1) + Nphi = H.sum(axis=1, keepdims=True) + with np.errstate(divide='ignore', invalid='ignore'): + term = np.where(H > 0, + H * np.log(dm[None, :] * Nphi + / np.where(H > 0, H, 1)), 0.0) + out[k] = term.sum() / H.sum() + return out + + +def exact_weighted_hist(t, y, dy, freqs, nphase, nmag, mag_overlap=0): + """Weighted-CE histogram with the EXACT Gaussian probability mass of + every point in every magnitude bin (no truncation). + + With ``mag_overlap > 0`` the weighted kernel widens every bin + upwards without clipping, so bin ``m`` spans + ``[m / nmag, (m + 1 + mag_overlap) / nmag]``. + """ + t, Y, yscale = _prep(t, y, np.float32) + Y = Y.astype(np.float64) + DY = (np.asarray(dy, dtype=np.float32) / yscale).astype(np.float64) + m = np.arange(nmag) + P = (ndtr(((m + 1 + mag_overlap) / nmag - Y[:, None]) / DY[:, None]) + - ndtr((m / nmag - Y[:, None]) / DY[:, None])) + H = np.zeros((len(freqs), nphase, nmag)) + for i, f in enumerate(freqs): + n0 = _phase_bins(t, f, nphase, np.float32) + np.add.at(H, (i, n0), P) + return H + + +def weighted_ce_from_hist(H, nmag, mag_overlap=0): + Nphi = H.sum(axis=2, keepdims=True) + # ``weighted_ce`` uses the constant window width for every bin + # (unlike the unweighted kernels, which truncate the top bins) + dm = (mag_overlap + 1.0) / nmag + with np.errstate(divide='ignore', invalid='ignore'): + term = np.where((H > 0) & (Nphi > 1e-10), + H * np.log(dm * Nphi / np.where(H > 0, H, 1)), 0) + return term.sum(axis=(1, 2)) / H.sum(axis=(1, 2)) + + +def run_ce(proc, t, y, dy, freqs, **kw): + r = proc.run([(t, y, dy)], freqs=freqs, **kw) + proc.finish() + return np.copy(r[0][1]) + + +def run_ce_with_memory(proc, t, y, dy, freqs, **kw): + """Run and also return the memory object (to inspect ``bins_g``).""" + mems = proc.allocate(normalize_light_curves([(t, y, dy)]), + freqs=[freqs], **kw) + mems[0].transfer_freqs_to_gpu() + r = proc.run([(t, y, dy)], memory=mems, freqs=[freqs], **kw) + proc.finish() + return np.copy(r[0][1]), mems[0] + + +def balance_magbins_cpu(mag_bins, y): + """``ConditionalEntropyMemory.balance_magbins`` without a CUDA context. + + The method is pure numpy; only ``mag_bins``, ``real_type`` and the + ``balanced_min_width`` class attribute are used, so it can be checked + on a machine without a GPU (the constructor would retain the primary + context). + """ + stub = types.SimpleNamespace( + mag_bins=mag_bins, real_type=np.float32, + balanced_min_width=ConditionalEntropyMemory.balanced_min_width) + return ConditionalEntropyMemory.balance_magbins(stub, y) + + +def lightcurve(ndata, seed, baseline=30., f0=1.3, noise=0.1, amp=0.3): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, baseline, ndata)) + y = amp * np.sin(2 * np.pi * f0 * t) + noise * r.randn(ndata) + return t, y, noise * np.ones(ndata) + + class TestCE(object): plot = False @@ -190,19 +312,24 @@ def test_batched_run_const_nfreq(self, ndatas, batch_size, use_double, assert_allclose(pnb, pb, rtol=lsrtol, atol=lsatol) assert_allclose(fnb, fb, rtol=lsrtol, atol=lsatol) + # balanced_magbins is only implemented for the standard, unweighted + # kernel (the other combinations raise ValueError); it used to be + # parametrized independently, which silently ran the uniform kernel + # because the constructor dropped the flag. @pytest.mark.parametrize('use_double', [True, False]) - @pytest.mark.parametrize('use_fast,weighted,shmem_lc,freq_batch_size', - [(True, False, False, 1), - (True, False, True, None), - (False, True, False, None), - (False, False, False, None)]) + @pytest.mark.parametrize( + 'use_fast,weighted,shmem_lc,freq_batch_size,balanced_magbins', + [(True, False, False, 1, False), + (True, False, True, None, False), + (False, True, False, None, False), + (False, False, False, None, False), + (False, False, False, None, True)]) @pytest.mark.parametrize('phase_bins,phase_overlap', [(10, 1)]) @pytest.mark.parametrize('mag_bins,mag_overlap', [(5, 0)]) @pytest.mark.parametrize('freq', [10.0]) @pytest.mark.parametrize('t0', [0.0]) - @pytest.mark.parametrize('balanced_magbins', [True, False]) def test_inject_and_recover(self, freq, use_double, mag_bins, phase_bins, mag_overlap, phase_overlap, use_fast, t0, balanced_magbins, @@ -264,14 +391,15 @@ def test_large_run(self, make_plot=False, **kwargs): assert_allclose(p0, p1, rtol=1e-4, atol=1e-2) @pytest.mark.parametrize('use_double', [True, False]) - @pytest.mark.parametrize('use_fast,weighted,shmem_lc,freq_batch_size', - [(True, False, False, 1)]) + @pytest.mark.parametrize( + 'use_fast,weighted,shmem_lc,freq_batch_size,balanced_magbins', + [(True, False, False, 1, False), + (False, False, False, None, True)]) @pytest.mark.parametrize('phase_bins,phase_overlap', [(10, 1)]) @pytest.mark.parametrize('mag_bins,mag_overlap', [(5, 0)]) @pytest.mark.parametrize('freq', [10.0]) - @pytest.mark.parametrize('balanced_magbins', [True, False]) def test_time_shift_invariance(self, freq, use_double, mag_bins, phase_bins, mag_overlap, phase_overlap, use_fast, @@ -334,14 +462,17 @@ def test_time_shift_invariance(self, freq, print(pct_out_of_bounds, delta_f * baseline) assert(top_freq_is_close and pct_out_of_bounds < 5e-2) + # (phase_bins, mag_bins) combinations with (mag_bins + 1) * phase_bins + # odd -- (5, 4), (7, 6), (3, 4) -- used to crash the double-precision + # fast kernels with 'misaligned address' (defect 17). @pytest.mark.parametrize('use_double', [True, False]) @pytest.mark.parametrize('shmem_lc', [True, False]) @pytest.mark.parametrize('freq_batch_size', [1, None]) @pytest.mark.parametrize('phase_bins,phase_overlap,mag_bins,mag_overlap', - [(10, 0, 5, 0), (10, 1, 5, 1)]) + [(10, 0, 5, 0), (10, 1, 5, 1), (5, 0, 4, 0), + (7, 0, 6, 0), (3, 0, 4, 0)]) @pytest.mark.parametrize('freq', [12.0]) @pytest.mark.parametrize('t0', [0.0]) - #@pytest.mark.parametrize('balanced_magbins', [True, False]) @pytest.mark.parametrize('balanced_magbins', [False]) @pytest.mark.parametrize('weighted', [False]) @pytest.mark.parametrize('force_nblocks', [1, None]) @@ -397,4 +528,1076 @@ def test_fast(self, freq, use_double, mag_bins, phase_bins, mag_overlap, # print best_freq, freq, abs(best_freq - freq) / freq assert(not any(np.isnan(p_slow))) assert(not any(np.isnan(p_fast))) - assert_allclose(p_slow, p_fast, atol=2e-2 * max(np.absolute(p_slow))) + # Both kernels histogram the same integer bins; the only + # difference is float summation order (the old 2e-2 * max + # tolerance hid the brightest-point mis-binning of defect 9). + assert_allclose(p_slow, p_fast, rtol=0, + atol=(1e-10 if use_double else 1e-5)) + + +# --------------------------------------------------------------------------- +# Regression tests for the Sep-2026 audit defects +# --------------------------------------------------------------------------- + +class TestCEBrightestPoint(object): + """Defect 9 (ce-brightest-bin): the brightest point (normalized + magnitude exactly 1.0) got bin index ``mag_bins`` and spilled into the + next phase bin / next frequency / past the end of ``bins_g``.""" + + @pytest.mark.parametrize('phase_overlap,mag_overlap', + [(0, 0), (1, 0), (0, 1), (1, 1)]) + def test_histogram_totals_exact(self, phase_overlap, mag_overlap): + N = 100 + t, y, dy = lightcurve(N, seed=3) + freqs = np.linspace(0.3, 1.2, 50) + proc = ConditionalEntropyAsyncProcess(phase_overlap=phase_overlap, + mag_overlap=mag_overlap) + _, mem = run_ce_with_memory(proc, t, y, dy, freqs) + assert mem.y[:N].max() == proc.mag_bins - 1 + bins = mem.bins_g.get().reshape(len(freqs), proc.phase_bins, + proc.mag_bins) + totals = bins.sum(axis=(1, 2)) + # every point is counted (phase_overlap + 1) times in each of its + # (mag_overlap + 1) magnitude bins, except that overlapping bins + # below bin 0 do not exist; the total is the same at EVERY + # frequency (it used to be N - 1 .. N + 1 from the spilled point) + m0 = mem.y[:N].astype(int) + expected = (phase_overlap + 1) * np.minimum(m0 + 1, + mag_overlap + 1).sum() + if mag_overlap == 0: + assert expected == N * (phase_overlap + 1) + assert_array_equal(totals, np.full(len(freqs), expected)) + + def test_no_write_past_bins(self): + """The brightest point in the LAST phase bin of the LAST frequency + used to be written one element past ``bins_g``.""" + N = 100 + t, y, dy = lightcurve(N, seed=3) + imax = np.argmax(y) + tt = np.float32(t - t.mean()) + + def phase_bin(f): + return _phase_bins(tt[imax:imax + 1], f, 10, np.float32)[0] + + cands = [f for f in np.linspace(0.3, 1.3, 4000) if phase_bin(f) == 9] + freqs = np.concatenate([np.linspace(0.5, 0.9, 63), [cands[0]]]) + proc = ConditionalEntropyAsyncProcess() + mems = proc.allocate([(t, y, dy)], freqs=[freqs]) + mem = mems[0] + # ``allocate`` only creates a zero-filled ``freqs_g``; without this + # upload every trial frequency would be f = 0, the brightest point + # would never reach the last phase bin of the last frequency and + # the guard below could not fire (defect 19 closes the same trap + # inside ``run``, this makes the test independent of it) + mem.transfer_freqs_to_gpu() + nb = mem.nbins + guard = np.uint32(0xDEAD) + big = gpuarray.zeros(nb + 8, dtype=np.uint32) + big.fill(guard) + mem.bins_g = big[:nb] + proc.run([(t, y, dy)], memory=mems, freqs=[freqs]) + proc.finish() + assert mem.freqs_g.get().max() > 0 + full = big.get() + assert_array_equal(full[nb:], np.full(8, guard)) + totals = full[:nb].reshape(len(freqs), -1).sum(axis=1) + assert_array_equal(totals, np.full(len(freqs), N)) + # the count that used to be written one element past ``bins_g``: + # brightest magnitude bin, last phase bin, last frequency + bins = full[:nb].reshape(len(freqs), proc.phase_bins, proc.mag_bins) + assert bins[-1, -1, -1] > 0 + + @pytest.mark.parametrize('ndata', [5, 60]) + @pytest.mark.parametrize('use_double', [False, True]) + @pytest.mark.parametrize('use_fast', [False, True]) + def test_matches_cpu_reference(self, ndata, use_double, use_fast): + t, y, dy = lightcurve(ndata, seed=1) + freqs = np.linspace(0.05, 3.0, 200) + proc = ConditionalEntropyAsyncProcess(use_double=use_double, + use_fast=use_fast) + p = run_ce(proc, t, y, dy, freqs) + dtype = np.float64 if use_double else np.float32 + ref = cpu_ce(t, y, freqs, 10, 5, dtype=dtype) + assert np.all(np.isfinite(p)) + atol = 1e-10 if use_double else 2e-6 + assert_allclose(p, ref, rtol=0, atol=atol) + # (at N = 5 the CE takes few distinct values, so the argmin can + # legitimately land on a tied minimum: compare the values) + assert abs(ref[np.argmin(p)] - ref.min()) <= atol + + @pytest.mark.parametrize('phase_overlap,mag_overlap', [(1, 1), (2, 1)]) + def test_matches_cpu_reference_overlap(self, phase_overlap, mag_overlap): + t, y, dy = lightcurve(60, seed=1) + freqs = np.linspace(0.05, 3.0, 200) + proc = ConditionalEntropyAsyncProcess(phase_overlap=phase_overlap, + mag_overlap=mag_overlap, + phase_bins=8, mag_bins=6) + p = run_ce(proc, t, y, dy, freqs) + ref = cpu_ce(t, y, freqs, 8, 6, phase_overlap, mag_overlap) + assert_allclose(p, ref, rtol=0, atol=2e-6) + + @pytest.mark.parametrize('use_fast', [False, True]) + def test_frequency_grid_order_invariance(self, use_fast): + """The standard kernel's output depended on the ORDER of the grid + because the spilled count landed in the next frequency's bin.""" + t, y, dy = lightcurve(500, seed=1) + freqs = np.linspace(0.05, 3.0, 200) + proc = ConditionalEntropyAsyncProcess(use_fast=use_fast) + fwd = run_ce(proc, t, y, dy, freqs) + rev = run_ce(proc, t, y, dy, freqs[::-1].copy())[::-1] + assert_array_equal(fwd, rev) + + def test_mag_bin_fracs_sum_to_one(self): + t, y, dy = lightcurve(100, seed=3) + mem = ConditionalEntropyMemory(phase_bins=10, mag_bins=5, + compute_log_prob=True) + mem.setdata(t - t.mean(), y - y.mean()) + assert mem.y.max() == 4 + assert_allclose(mem.mag_bin_fracs.sum(), 1.0, rtol=0, atol=1e-6) + + +class TestCEWeighted(object): + """Defect 16 (ce-weighted-asym): the weighted histogram skipped a bin + by the distance to its LOWER edge only, dropping the mass of bins + below the datum, and the brightest point entirely.""" + + def test_hand_placed_points_match_exact_masses(self): + MB, PB, sig = 5, 1, 0.02 + Yc = np.array([0.0, 0.41, 0.5, 0.59, 1.0]) + proc = ConditionalEntropyAsyncProcess(phase_bins=PB, mag_bins=MB, + weighted=True, max_phi=3.0) + # any trial frequency gives the same answer here: with + # phase_bins=1 every point folds into the single phase bin. + # (It used to be f = 0; entry points now require freqs > 0, + # since every method folds the data at 1 / f.) + _, mem = run_ce_with_memory(proc, np.linspace(0, 1, 5), Yc, + sig * np.ones(5), np.array([1.0])) + bins = mem.bins_g.get().reshape(1, PB, MB)[0, 0] + m = np.arange(MB) + P = (ndtr(((m + 1) / MB - Yc[:, None]) / sig) + - ndtr((m / MB - Yc[:, None]) / sig)) + # old kernel: [0.5, 0, 2.38, 0.31, 0] (bin 1 and the Y=1 point lost) + assert_allclose(bins, P.sum(axis=0), rtol=0, atol=1e-4) + assert bins[1] > 0.3 and bins[4] > 0.49 + + @pytest.mark.parametrize('mag_bins', [5, 10]) + @pytest.mark.parametrize('noise', [0.05, 0.15]) + @pytest.mark.parametrize('mag_overlap', [0, 1, 2]) + def test_bins_and_ce_vs_ndtr_reference(self, mag_bins, noise, + mag_overlap): + # ``mag_overlap > 0`` is where the symmetric-truncation fix + # matters most (the audit measured a 0.21 nat change in the CE + # itself, 0.14 on the default lightcurve); the overlapping + # window makes bin m span [m, m + 1 + mag_overlap] / mag_bins. + r = np.random.RandomState(3) + N = 300 + t = np.sort(r.rand(N)) * 20.0 + y = (12 + np.sin(2 * np.pi * 1.3 * t) + 0.3 * np.sin(4 * np.pi * 1.3 * t) + + noise * r.randn(N)) + dy = noise * np.ones(N) + freqs = np.linspace(0.1, 3.0, 40) + He = exact_weighted_hist(t, y, dy, freqs, 10, mag_bins, + mag_overlap=mag_overlap) + ce_exact = weighted_ce_from_hist(He, mag_bins, + mag_overlap=mag_overlap) + + # default max_phi=3: only bins wholly beyond 3 sigma are skipped + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=mag_bins, + mag_overlap=mag_overlap, + weighted=True, max_phi=3.0) + ce, mem = run_ce_with_memory(proc, t, y, dy, freqs) + bins = mem.bins_g.get().reshape(len(freqs), 10, mag_bins) + assert np.all(np.isfinite(ce)) + # audit-measured post-fix levels: bins 6e-3 (mag_overlap 0) and + # 4.2e-3 (mag_overlap 1-2), CE 1.1e-3 (old: 1.5-4.2 in the bins, + # 2e-2 .. 5e-2 in the CE) + assert_allclose(bins, He, rtol=0, atol=2e-2) + assert_allclose(ce, ce_exact, rtol=0, atol=5e-3) + # the per-frequency mass totals match the exact ones to the mass + # of the skipped > 3-sigma bins (points near the range edges + # legitimately lose the mass outside [0, 1]; old: -2 .. -12%) + assert_allclose(bins.sum(axis=(1, 2)), He.sum(axis=(1, 2)), + rtol=3e-3, atol=0) + + # with a wide max_phi nothing is truncated: float32 normcdf level + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=mag_bins, + mag_overlap=mag_overlap, + weighted=True, max_phi=50.0) + ce, mem = run_ce_with_memory(proc, t, y, dy, freqs) + bins = mem.bins_g.get().reshape(len(freqs), 10, mag_bins) + assert_allclose(bins, He, rtol=0, atol=2e-3) + assert_allclose(bins.sum(axis=(1, 2)), He.sum(axis=(1, 2)), + rtol=1e-5, atol=0) + assert_allclose(ce, ce_exact, rtol=0, atol=1e-4) + + def test_large_max_phi_is_finite(self): + """Tiny bin masses used to make ``dm * p_phi / pmn`` overflow to + inf (3 of 3000 frequencies for this lightcurve).""" + r = np.random.RandomState(2) + N = 200 + t = np.sort(r.rand(N)) * 20.0 + y = 12 + np.sin(2 * np.pi * 1.3 * t) + 0.3 * np.sin(4 * np.pi * 1.3 * t) + 0.05 * r.randn(N) + dy = 0.05 * np.ones(N) + freqs = np.linspace(0.1, 3.0, 3000) + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5, + weighted=True, max_phi=1e6) + ce = run_ce(proc, t, y, dy, freqs) + assert np.all(np.isfinite(ce)) + proc3 = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5, + weighted=True, max_phi=3.0) + ce3 = run_ce(proc3, t, y, dy, freqs) + assert np.all(np.isfinite(ce3)) + assert abs(freqs[np.argmin(ce3)] - 1.3) < 0.01 + assert abs(freqs[np.argmin(ce)] - 1.3) < 0.01 + + +class TestCEDoubleFast(object): + """Defect 17 (ce-double-fast-crash): shared-memory misalignment for + ``use_double=True, use_fast=True`` when (mag_bins + 1) * phase_bins is + odd, and a 4-byte shared-memory shortfall for odd ndata.""" + + @pytest.mark.parametrize('ndata', [200, 201]) + @pytest.mark.parametrize('shmem_lc', [True, False]) + @pytest.mark.parametrize('phase_bins,mag_bins', + [(5, 4), (7, 6), (3, 4), (10, 5)]) + def test_double_fast_matches_double_standard(self, phase_bins, mag_bins, + shmem_lc, ndata): + r = np.random.RandomState(0) + t = np.sort(r.rand(ndata) * 20) + y = 12 + 0.3 * np.cos(2 * np.pi * t * 1.7) + 0.05 * r.randn(ndata) + dy = 0.05 * np.ones(ndata) + freqs = np.linspace(0.1, 3.0, 256) + ref = run_ce(ConditionalEntropyAsyncProcess( + phase_bins=phase_bins, mag_bins=mag_bins, use_double=True), + t, y, dy, freqs) + proc = ConditionalEntropyAsyncProcess(phase_bins=phase_bins, + mag_bins=mag_bins, + use_double=True, use_fast=True) + p = run_ce(proc, t, y, dy, freqs, shmem_lc=shmem_lc) + assert np.all(np.isfinite(p)) + assert_allclose(p, ref, rtol=0, atol=1e-10) + cpu = cpu_ce(t, y, freqs, phase_bins, mag_bins, dtype=np.float64) + assert_allclose(p, cpu, rtol=0, atol=1e-10) + + +class TestCEBalanced(object): + """Defect 18 (ce-balanced-ignored) and ids 105/106.""" + + @staticmethod + def _lc(): + r = np.random.RandomState(0) + N = 400 + t = np.sort(30 * r.rand(N)) + y = 12 + 0.3 * np.cos(2 * np.pi * 3.1 * t) + 0.05 * r.randn(N) + y[:3] += 5.0 # outliers: balanced bins differ strongly from uniform + return t, y, 0.05 * np.ones(N) + + def test_constructor_flag_is_forwarded(self): + t, y, dy = self._lc() + freqs = np.linspace(2.5, 3.7, 1000) + plain = run_ce(ConditionalEntropyAsyncProcess(), t, y, dy, freqs) + + def large(proc, **kw): + r = proc.large_run([(t, y, dy)], freqs=freqs, **kw) + proc.finish() + return np.copy(r[0][1]) + + def batched(proc, **kw): + r = proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs, **kw) + return np.copy(r[0][1]) + + for fn in (run_ce, large, batched): + if fn is run_ce: + ctor = fn(ConditionalEntropyAsyncProcess(balanced_magbins=True), + t, y, dy, freqs) + runkw = fn(ConditionalEntropyAsyncProcess(), t, y, dy, freqs, + balanced_magbins=True) + else: + ctor = fn(ConditionalEntropyAsyncProcess(balanced_magbins=True)) + runkw = fn(ConditionalEntropyAsyncProcess(), + balanced_magbins=True) + assert_array_equal(ctor, runkw) + assert np.max(np.abs(ctor - plain)) > 0.1 + + proc = ConditionalEntropyAsyncProcess(balanced_magbins=True) + assert proc.balanced_magbins + mems = proc.allocate([(t, y, dy)], freqs=[freqs]) + assert mems[0].balanced_magbins + proc.preallocate(len(t), freqs, nlcs=1) + assert proc.memory[0].balanced_magbins + + def test_widen_mag_range_is_forwarded(self): + t, y, dy = self._lc() + freqs = np.linspace(2.5, 3.7, 500) + plain = run_ce(ConditionalEntropyAsyncProcess(weighted=True), + t, y, dy, freqs) + ctor = run_ce(ConditionalEntropyAsyncProcess(weighted=True, + widen_mag_range=True), + t, y, dy, freqs) + runkw = run_ce(ConditionalEntropyAsyncProcess(weighted=True), + t, y, dy, freqs, widen_mag_range=True) + assert_allclose(ctor, runkw, rtol=0, atol=1e-6) + assert np.max(np.abs(ctor - plain)) > 1e-3 + proc = ConditionalEntropyAsyncProcess(weighted=True, + widen_mag_range=True) + proc.preallocate(len(t), freqs, nlcs=1) + assert proc.memory[0].widen_mag_range + + def test_unsupported_combinations_raise_in_constructor(self): + # CPU-runnable: the checks run before the GPU context is touched + bad = [dict(weighted=True, use_fast=True), + dict(weighted=True, balanced_magbins=True), + dict(weighted=True, compute_log_prob=True), + dict(use_fast=True, balanced_magbins=True), + dict(use_fast=True, compute_log_prob=True), + dict(balanced_magbins=True, compute_log_prob=True), + dict(mag_overlap=1, balanced_magbins=True)] + for kw in bad: + with pytest.raises(ValueError): + ConditionalEntropyAsyncProcess(**kw) + + def test_use_fast_with_log_prob_raises_everywhere(self): + # CPU-runnable: conditional_entropy_fast only launches the CE + # kernels, so this combination used to return the plain CE + # instead of the log-probability, without a word (Sep 2026 + # review). The constructor, the memory class and the per-call + # kwargs of run/preallocate all reject it now, before any GPU + # work. + t, y, dy = self._lc() + freqs = np.linspace(2.5, 3.7, 100) + with pytest.raises(ValueError, match='compute_log_prob'): + ConditionalEntropyAsyncProcess(use_fast=True, + compute_log_prob=True) + with pytest.raises(ValueError, match='compute_log_prob'): + ConditionalEntropyMemory(use_fast=True, compute_log_prob=True) + proc = ConditionalEntropyAsyncProcess(use_fast=True) + with pytest.raises(ValueError, match='compute_log_prob'): + proc.run([(t, y, dy)], freqs=freqs, compute_log_prob=True) + with pytest.raises(ValueError, match='compute_log_prob'): + proc.preallocate(len(t), freqs, compute_log_prob=True) + with pytest.raises(ValueError, match='compute_log_prob'): + proc.large_run([(t, y, dy)], freqs=freqs, compute_log_prob=True) + with pytest.raises(ValueError, match='compute_log_prob'): + proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs, + compute_log_prob=True) + + @pytest.mark.parametrize('ctor', [dict(weighted=True), dict(use_fast=True), + dict(compute_log_prob=True), + dict(mag_overlap=1)]) + def test_unsupported_combinations_raise_for_run_kwargs(self, ctor): + t, y, dy = self._lc() + freqs = np.linspace(2.5, 3.7, 100) + proc = ConditionalEntropyAsyncProcess(**ctor) + with pytest.raises(ValueError): + proc.run([(t, y, dy)], freqs=freqs, balanced_magbins=True) + with pytest.raises(ValueError): + proc.preallocate(len(t), freqs, balanced_magbins=True) + + def test_balanced_matches_reference(self): + t, y, dy = self._lc() + freqs = np.linspace(2.5, 3.7, 300) + proc = ConditionalEntropyAsyncProcess(balanced_magbins=True) + p, mem = run_ce_with_memory(proc, t, y, dy, freqs) + ybins = mem.y[:mem.n0].astype(int) + bwf = mem.mag_bwf.astype(np.float64) + # each bin holds N / mag_bins points; widths tile [0, 1] + assert_array_equal(np.bincount(ybins), np.full(5, 80)) + assert_allclose(bwf.sum(), 1.0, rtol=0, atol=1e-6) + t32, _, _ = _prep(t, y, np.float32) + H = np.zeros((len(freqs), 10, 5)) + for i, f in enumerate(freqs): + np.add.at(H, (i, _phase_bins(t32, f, 10, np.float32), ybins), 1) + Nphi = H.sum(axis=2, keepdims=True) + with np.errstate(divide='ignore', invalid='ignore'): + term = np.where(H > 0, H * np.log(bwf[None, None, :] * Nphi + / np.where(H > 0, H, 1)), 0) + ref = term.sum(axis=(1, 2)) / H.sum(axis=(1, 2)) + assert_allclose(p, ref, rtol=0, atol=2e-6) + assert abs(freqs[np.argmin(p)] - 3.1) < 0.01 + + def test_quantized_magnitudes_are_finite(self): + """id 106: a bin of identical values had zero width -> CE = -inf.""" + r = np.random.RandomState(4) + N = 400 + t = np.sort(r.rand(N) * 20) + y = np.round(12 + np.sin(2 * np.pi * 1.3 * t) + 0.3 * r.randn(N)) + assert len(np.unique(y)) <= 6 + dy = np.ones(N) + freqs = np.linspace(0.1, 3.0, 300) + proc = ConditionalEntropyAsyncProcess(balanced_magbins=True) + p, mem = run_ce_with_memory(proc, t, y, dy, freqs) + assert np.all(np.isfinite(p)) + assert np.all(mem.mag_bwf > 0) + assert_allclose(mem.mag_bwf.sum(), 1.0, rtol=0, atol=1e-5) + assert abs(freqs[np.argmin(p)] - 1.3) < 0.02 + + @pytest.mark.parametrize('mag_bins', [2, 3, 5, 7, 11, 20]) + def test_balanced_bin_bounds_cover_every_point(self, mag_bins): + """Defect 18 (2nd round): the group boundaries were + ``int(i * len(y) / mag_bins)``, and for 471 of the 37,810 + ``(mag_bins, N)`` combinations with ``mag_bins`` in 2..20 and + ``N`` up to 2000 (e.g. ``(7, 61)``) the float product fell short + of ``len(y)``, so the brightest point(s) were never assigned and + kept ``ybins = 0`` -- the brightest star of the lightcurve was put + in the FAINTEST magnitude bin. CPU-only (pure numpy).""" + r = np.random.RandomState(7) + for n in range(mag_bins, 4 * mag_bins + 260): + y = r.rand(n) + ybins, bwf = balance_magbins_cpu(mag_bins, y) + ybins = ybins.astype(int) + counts = np.bincount(ybins, minlength=mag_bins) + # every point is assigned, and to a group of the right size + assert counts.sum() == n + assert counts.min() == n // mag_bins + assert counts.max() == -(-n // mag_bins) + # bins increase monotonically with magnitude + assert np.all(np.diff(ybins[np.argsort(y, kind='stable')]) >= 0) + assert ybins[np.argmax(y)] == mag_bins - 1 + assert ybins[np.argmin(y)] == 0 + # widths still tile the magnitude range + assert len(bwf) == mag_bins + assert np.all(bwf > 0) + assert abs(float(bwf.astype(np.float64).sum()) - 1.0) < 1e-4 + + def test_balanced_brightest_point_on_gpu_ragged_n(self): + """End-to-end version of the above: ``mag_bins=7``, ``N=61`` was + one of the affected combinations (the brightest point landed in + bin 0, giving ``bincount = [9 9 9 8 9 9 8]``).""" + N, mag_bins = 61, 7 + t, y, dy = lightcurve(N, seed=11) + freqs = np.linspace(0.5, 2.5, 200) + proc = ConditionalEntropyAsyncProcess(mag_bins=mag_bins, + balanced_magbins=True) + p, mem = run_ce_with_memory(proc, t, y, dy, freqs) + ybins = mem.y[:mem.n0].astype(int) + counts = np.bincount(ybins, minlength=mag_bins) + assert counts.sum() == N + expected = np.full(mag_bins, N // mag_bins) + expected[:N % mag_bins] += 1 + assert_array_equal(np.sort(counts), np.sort(expected)) + assert ybins[np.argmax(y)] == mag_bins - 1 + assert np.all(np.isfinite(p)) + assert_allclose(mem.mag_bwf.astype(np.float64).sum(), 1.0, + rtol=0, atol=1e-5) + + +class TestCEConstantY(object): + """Sep 2026 review (idx 17, audit id 115): a constant ``y`` passed + the validator; ``setdata`` then computed ``(y - min) / (max - min)`` + = 0/0 and cast the NaN bin indices to uint32 (platform-defined), + so the spectrum was flat garbage. CPU-runnable: the validator + raises before any GPU work.""" + + def test_constant_y_is_rejected(self): + t, y, dy = lightcurve(60, seed=0) + const = np.full_like(y, 12.5) + freqs = np.linspace(0.1, 3.0, 50) + proc = ConditionalEntropyAsyncProcess() + for entry in (lambda d: proc.run(d, freqs=freqs), + lambda d: proc.large_run(d, freqs=freqs), + lambda d: proc.batched_run_const_nfreq( + d, freqs=freqs)): + with pytest.raises(ValueError, match='lightcurve 1: y is ' + 'constant'): + entry([(t, y, dy), (t, const, dy)]) + # stub-independent: the validator itself raises (a regression + # would otherwise reach the pycuda stub and skip, not fail) + with pytest.raises(ValueError, match='y is constant'): + ce_module._check_ce_data([(t, const, dy)], 'x') + # two distinct values are enough to build the magnitude bins + two = np.where(np.arange(60) % 2 == 0, 12.0, 12.5) + ce_module._check_ce_data([(t, two, dy)], 'x') + + def test_setdata_on_constant_y_was_the_failure(self): + # the defect the validator now prevents: NaN bin indices + mem = ConditionalEntropyMemory() + t = np.linspace(0, 10, 20) + with np.errstate(invalid='ignore'): + mem.setdata(t, np.full(20, 12.0)) + assert mem.y.dtype == np.uint32 + assert len(set(mem.y.tolist())) == 1 # every point in one bin + + +class TestCEMemoryOptionMismatch(object): + """Sep 2026 review (idx 8): ``run(memory=...)`` dispatches on the + memory's flags, so a per-call option kwarg that disagreed with the + memory was silently ignored (the docs claimed it raised). All + CPU-runnable: the checks run before the kernels are compiled.""" + + @staticmethod + def _proc_and_mem(**kw): + proc = ConditionalEntropyAsyncProcess(**kw) + # no allocation: the option check needs only the flags + return proc, ConditionalEntropyMemory(**proc._memory_kwargs()) + + def test_matching_and_unrelated_kwargs_pass(self): + proc, mem = self._proc_and_mem(weighted=True, max_phi=2.5) + proc._check_memory_options(mem, {}) + proc._check_memory_options(mem, dict(weighted=True, max_phi=2.5, + block_size=128, + samples_per_peak=5)) + + @pytest.mark.parametrize('kw', [dict(weighted=True), + dict(compute_log_prob=True), + dict(balanced_magbins=True), + dict(mag_bins=7), dict(phase_bins=20), + dict(mag_overlap=1), + dict(phase_overlap=1), + dict(max_phi=1.0), + dict(use_double=True), + dict(widen_mag_range=True)]) + def test_mismatched_kwarg_raises(self, kw): + proc, mem = self._proc_and_mem() + key = list(kw)[0] + with pytest.raises(ValueError, match=key): + proc._check_memory_options(mem, kw) + + def test_run_raises_before_any_gpu_work(self): + t, y, dy = lightcurve(60, seed=0) + freqs = np.linspace(0.1, 3.0, 50) + proc, mem = self._proc_and_mem() + with pytest.raises(ValueError, match='do not match the memory'): + proc.run([(t, y, dy)], memory=[mem], freqs=freqs, + balanced_magbins=True) + proc.memory = [mem] # what preallocate() would have set + with pytest.raises(ValueError, match='do not match the memory'): + proc.run([(t, y, dy)], freqs=freqs, weighted=True) + # stub-independent: the guard itself raises + with pytest.raises(ValueError, match='do not match the memory'): + proc._check_memory_options(mem, {'balanced_magbins': True}) + + def test_fast_process_rejects_a_weighted_memory(self): + # conditional_entropy_fast ignores ``weighted`` and would read + # the weighted memory's float magnitudes as uint32 bin indices + t, y, dy = lightcurve(60, seed=0) + freqs = np.linspace(0.1, 3.0, 50) + proc = ConditionalEntropyAsyncProcess(use_fast=True) + mem = ConditionalEntropyMemory(weighted=True) + with pytest.raises(ValueError, match='use_fast must be False'): + proc.run([(t, y, dy)], memory=[mem], freqs=freqs) + + +class TestCEPreallocate(object): + """Defect 19 (ce-preallocate): ``preallocate()`` never uploaded the + frequency grid (every frequency evaluated at f = 0) and left + ``memory.stream = None`` (results read before the copy landed).""" + + @staticmethod + def _lc(N, seed): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, 100, N)) + y = 0.3 * np.sin(2 * np.pi * t / 1.7) + 0.05 * r.randn(N) + return t, y, 0.05 * np.ones(N) + + @pytest.mark.parametrize('use_fast', [False, True]) + def test_preallocate_then_run(self, use_fast): + F = np.linspace(0.05, 5.0, 4000) + B = self._lc(900, 2) + C = self._lc(300, 5) + proc = ConditionalEntropyAsyncProcess(use_fast=use_fast) + fB = run_ce(proc, *B, F) + fC = run_ce(proc, *C, F) + assert fB.std() > 0 and fC.std() > 0 + + proc.preallocate(max_nobs=900, freqs=F, nlcs=1) + mem = proc.memory[0] + assert mem.stream is proc.streams[0] + assert_allclose(mem.freqs_g.get(), F.astype(np.float32), + rtol=0, atol=0) + for k in range(3): + for lc, ref in ((B, fB), (C, fC)): + r = proc.run([lc], freqs=[F]) + proc.finish() + assert_array_equal(np.copy(r[0][1]), ref) + + def test_preallocate_batch(self): + F = np.linspace(0.05, 5.0, 2000) + lcs = [self._lc(n, s) for n, s in ((900, 2), (300, 5), (600, 7))] + proc = ConditionalEntropyAsyncProcess() + refs = [run_ce(proc, *lc, F) for lc in lcs] + proc.preallocate(max_nobs=900, freqs=F, nlcs=3) + assert len(proc.memory) == 3 + assert len(set(id(m.stream) for m in proc.memory)) == 3 + r = proc.run(lcs, freqs=F) + proc.finish() + for (f, p), ref in zip(r, refs): + assert_array_equal(np.copy(p), ref) + with pytest.raises(ValueError): + proc.run(lcs + [lcs[0]], freqs=F) + + def test_run_reuploads_changed_freqs(self): + F1 = np.linspace(0.05, 5.0, 2000) + F2 = np.linspace(0.5, 2.5, 2000) + F3 = np.linspace(0.5, 2.5, 1000) + lc = self._lc(500, 2) + proc = ConditionalEntropyAsyncProcess() + ref2 = run_ce(proc, *lc, F2) + proc.preallocate(max_nobs=500, freqs=F1, nlcs=1) + r = proc.run([lc], freqs=F2) + proc.finish() + assert_array_equal(np.copy(r[0][1]), ref2) + assert_allclose(proc.memory[0].freqs_g.get(), F2.astype(np.float32), + rtol=0, atol=0) + with pytest.raises(ValueError): + proc.run([lc], freqs=F3) + + def test_sync_memory_freqs_sees_in_place_mutation_cpu(self): + """Sep 2026 review (idx 15): ``transfer_freqs_to_gpu`` stored + ``np.ascontiguousarray(freqs, real_type)`` -- the caller's own + array for a float32 grid -- so ``_sync_memory_freqs`` compared a + grid modified in place with itself and skipped the upload. + CPU-runnable with a recording fake device array.""" + class FakeDevice(object): + def __init__(self, n): + self.size = n + self.uploads = [] + + def set_async(self, a, stream=None): + self.uploads.append(np.array(a, copy=True)) + + n = 16 + mem = ConditionalEntropyMemory() + mem.freqs_g = FakeDevice(n) + mem.nf = n + g = np.linspace(0.1, 2.0, n).astype(np.float32) + ConditionalEntropyAsyncProcess._sync_memory_freqs(mem, g) + assert mem.freqs is not g + assert len(mem.freqs_g.uploads) == 1 + # unchanged grid: no second upload + ConditionalEntropyAsyncProcess._sync_memory_freqs(mem, g) + assert len(mem.freqs_g.uploads) == 1 + g *= 2.0 + ConditionalEntropyAsyncProcess._sync_memory_freqs(mem, g) + assert len(mem.freqs_g.uploads) == 2 + assert_array_equal(mem.freqs_g.uploads[-1], g) + # the float64 control case (a cast copy) was never affected + g64 = np.linspace(0.1, 2.0, n) + ConditionalEntropyAsyncProcess._sync_memory_freqs(mem, g64) + g64 *= 2.0 + ConditionalEntropyAsyncProcess._sync_memory_freqs(mem, g64) + assert len(mem.freqs_g.uploads) == 4 + + def test_run_reuploads_in_place_mutated_float32_grid(self): + """GPU counterpart: preallocate, run, mutate the same float32 + grid object in place, run again -- the second spectrum must be + the one of the mutated grid.""" + F = np.linspace(0.05, 5.0, 2000).astype(np.float32) + lc = self._lc(500, 3) + proc = ConditionalEntropyAsyncProcess() + proc.preallocate(max_nobs=500, freqs=F, nlcs=1) + r = proc.run([lc], freqs=F) + proc.finish() + first = np.copy(r[0][1]) + F += np.float32(0.25) # in place: same object, new grid + r = proc.run([lc], freqs=F) + proc.finish() + second = np.copy(r[0][1]) + ref = run_ce(ConditionalEntropyAsyncProcess(), *lc, + np.array(F, copy=True)) + assert_array_equal(second, ref) + assert not np.array_equal(second, first) + assert_allclose(proc.memory[0].freqs_g.get(), F, rtol=0, atol=0) + + def test_preallocate_then_large_run(self): + # large_run slices the grid into batches, so a preallocated + # self.memory (nf = the full grid) can never serve them: the + # combination raised "memory was allocated for N frequencies". + # large_run now allocates per batch and passes it explicitly. + F = np.linspace(0.05, 5.0, 3000) + lc = self._lc(400, 11) + proc = ConditionalEntropyAsyncProcess() + ref = proc.large_run([lc], freqs=F, max_memory=1e5) + proc.finish() + ref = np.copy(ref[0][1]) + assert ref.std() > 0 + + proc.preallocate(max_nobs=400, freqs=F, nlcs=1) + r = proc.large_run([lc], freqs=F, max_memory=1e5) + proc.finish() + assert_array_equal(np.copy(r[0][1]), ref) + # the preallocated memory is untouched and still usable + assert_allclose(proc.memory[0].freqs_g.get(), F.astype(np.float32), + rtol=0, atol=0) + r2 = proc.run([lc], freqs=F) + proc.finish() + assert np.all(np.isfinite(r2[0][1])) + + def test_preallocate_then_run_without_freqs(self): + # run(freqs=None) used to build an autofrequency grid whose + # length is never mem.nf, so it raised on preallocated memory. + # The grid preallocate() uploaded is the one to use. + F = np.linspace(0.05, 5.0, 2000) + lc = self._lc(500, 13) + proc = ConditionalEntropyAsyncProcess() + ref = run_ce(proc, *lc, F) + + proc.preallocate(max_nobs=500, freqs=F, nlcs=1) + r = proc.run([lc]) + proc.finish() + assert_array_equal(np.asarray(r[0][0]), F.astype(np.float32)) + assert_array_equal(np.copy(r[0][1]), ref) + + # explicit memory from allocate() behaves the same way + proc2 = ConditionalEntropyAsyncProcess() + mems = proc2.allocate(normalize_light_curves([lc]), freqs=[F]) + r = proc2.run([lc], memory=mems) + proc2.finish() + assert_array_equal(np.asarray(r[0][0]), F.astype(np.float32)) + assert_allclose(np.copy(r[0][1]), ref, rtol=0, atol=1e-6) + + def test_run_without_freqs_and_without_memory_uses_autofrequency(self): + lc = self._lc(200, 17) + proc = ConditionalEntropyAsyncProcess() + r = proc.run([lc]) + proc.finish() + assert len(r[0][0]) == len(proc.autofrequency(lc[0])) + + +class TestCEFastSharedMemoryLimit(object): + """audit section 4 row 122: a phase_bins x mag_bins histogram that + does not fit in shared memory died with an opaque pycuda + ``LogicError: cuLaunchKernel failed: invalid argument``.""" + + def test_oversized_histogram_raises_value_error(self): + t, y, dy = lightcurve(200, seed=1) + freqs = np.linspace(0.1, 3.0, 64) + proc = ConditionalEntropyAsyncProcess(use_fast=True, + phase_bins=200, mag_bins=50) + with pytest.raises(ValueError, + match=r"shared memory.*200 x 50"): + proc.run([(t, y, dy)], freqs=freqs) + + def test_small_histogram_still_runs(self): + t, y, dy = lightcurve(200, seed=1) + freqs = np.linspace(0.1, 3.0, 64) + proc = ConditionalEntropyAsyncProcess(use_fast=True, + phase_bins=10, mag_bins=5) + ce = run_ce(proc, t, y, dy, freqs) + assert np.all(np.isfinite(ce)) + + +class TestCEReuse(object): + """id 112 (``set_data=False`` accumulated histograms across calls) and + CE-1 (the module was recompiled with nvcc on every call).""" + + @pytest.mark.parametrize('compute_log_prob', [False, True]) + def test_set_data_false_repeat_is_idempotent(self, compute_log_prob): + r = np.random.RandomState(0) + N = 60 + t = np.sort(r.rand(N) * 20) + d = [(t, r.randn(N), np.ones(N))] + freqs = np.linspace(0.1, 3.0, 50) + proc = ConditionalEntropyAsyncProcess(compute_log_prob=compute_log_prob) + mems = proc.allocate(normalize_light_curves(d), freqs=[freqs]) + mems[0].transfer_freqs_to_gpu() + first = None + for k in range(3): + res = proc.run(d, memory=mems, freqs=[freqs], set_data=(k == 0)) + proc.finish() + p = np.copy(res[0][1]) + assert mems[0].bins_g.get().sum() == N * len(freqs) + if first is None: + first = p + else: + assert_array_equal(p, first) + + def test_compile_gate_logic(self): + # CPU-runnable + assert _needs_compile({}) + assert _needs_compile(None) + assert _needs_compile({'ce_wt': object()}) # the old sentinel + assert not _needs_compile({k: object() for k in _CE_KERNELS}) + assert _needs_compile({k: object() for k in _CE_KERNELS[:-1]}) + + def test_compiles_once_per_process(self, monkeypatch): + calls = [] + real = ce_module.SourceModule + + def counting(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(ce_module, 'SourceModule', counting) + t, y, dy = lightcurve(100, seed=3) + freqs = np.linspace(0.3, 1.2, 50) + proc = ConditionalEntropyAsyncProcess() + run_ce(proc, t, y, dy, freqs) + run_ce(proc, t, y, dy, freqs) + proc.large_run([(t, y, dy)], freqs=freqs, max_memory=1e5) + assert len(calls) == 1 + + +class TestCEFastGridSize(object): + """CE-2 (audit ids 61/107): ``use_fast`` sized its grid from + ``floor(2 * shmem_lim / shmem)`` -- a per-block shared-memory ratio, + not a grid -- and capped the other branch at 200 blocks, so the + kernel ran on 3-34 blocks (5 at ndata = 2000) however large the + device. The kernels are block-per-frequency with a ``gridDim.x`` + stride, so the grid size must not change a single returned value. + """ + + # ------------------------------------------------------------------ + # CPU-runnable: the sizing arithmetic itself + # ------------------------------------------------------------------ + @staticmethod + def _limits(monkeypatch, nsm=84, shmem_sm=102400, thr_sm=1536): + monkeypatch.setattr(ce_module, '_device_occupancy_limits', + lambda: (nsm, shmem_sm, thr_sm)) + + def test_grid_fills_the_device(self, monkeypatch): + # A40-like: 84 SMs, 100 KB shared/SM, 1536 threads/SM. At + # ndata = 2000 (single precision) the fast kernel asks for + # 16440 B/block, so 6 blocks fit per SM by shared memory and 6 + # by threads -> 504 blocks. The old heuristic gave 5. + self._limits(monkeypatch) + assert _fast_grid_size(16440, 256, 100000) == 84 * 6 + # tiny histogram, no lightcurve in shared memory: threads bind + assert _fast_grid_size(440, 256, 100000) == 84 * 6 + # small blocks: the hardware blocks/SM limit binds + assert _fast_grid_size(440, 64, 100000) == 84 * _MAX_BLOCKS_PER_SM + + def test_grid_never_exceeds_the_frequency_count(self, monkeypatch): + self._limits(monkeypatch) + assert _fast_grid_size(16440, 256, 7) == 7 + assert _fast_grid_size(16440, 256, 1) == 1 + + def test_grid_is_at_least_one_block_per_sm(self, monkeypatch): + # a block so large that not even one fits in the per-SM shared + # memory budget: still one block per SM, never zero + self._limits(monkeypatch) + assert _fast_grid_size(102401, 256, 1000) == 84 + assert _fast_grid_size(0, 256, 1000) == 84 * 6 + + def test_grid_scales_with_the_device(self, monkeypatch): + self._limits(monkeypatch, nsm=8, shmem_sm=49152, thr_sm=1024) + assert _fast_grid_size(16440, 256, 100000) == 8 * min(2, 4) + self._limits(monkeypatch, nsm=132, shmem_sm=233472, thr_sm=2048) + assert _fast_grid_size(16440, 256, 100000) == 132 * 8 + + # ------------------------------------------------------------------ + # GPU: the launch really uses it, and the result does not depend on it + # ------------------------------------------------------------------ + @staticmethod + def _record_grids(): + """Patch ``prepared_async_call`` to record the grid of every + launch that uses dynamic shared memory (i.e. the fast kernels).""" + import pycuda.driver as cuda + grids = [] + orig = cuda.Function.prepared_async_call + + def rec(self, grid, block, stream, *args, **kwargs): + if kwargs.get('shared_size', 0) > 0: + grids.append(int(grid[0])) + return orig(self, grid, block, stream, *args, **kwargs) + return grids, orig, rec + + def test_launch_grid_matches_the_occupancy_formula(self, monkeypatch): + import pycuda.driver as cuda + t, y, dy = lightcurve(2000, seed=5) + freqs = np.linspace(0.5, 3.0, 4001) + proc = ConditionalEntropyAsyncProcess(use_fast=True) + run_ce(proc, t, y, dy, freqs[:16]) # compile + grids, orig, rec = self._record_grids() + monkeypatch.setattr(cuda.Function, 'prepared_async_call', rec) + run_ce(proc, t, y, dy, freqs) + assert len(grids) == 1 + nsm = ce_module._device_occupancy_limits()[0] + # single precision, 10 x 5 bins, lightcurve in shared memory + shmem = 8 * 50 + 4 * 10 + 8 * 2000 + assert grids[0] == _fast_grid_size(shmem, 256, len(freqs)) + # the point of the change: at least one block per SM, and far + # more than the old floor(2 * shmem_lim / shmem) (5 on a 48 KB + # device at this ndata) + assert grids[0] >= nsm + assert grids[0] > 2 * 49152 // shmem + + def test_max_nblocks_still_caps_when_given(self, monkeypatch): + import pycuda.driver as cuda + t, y, dy = lightcurve(400, seed=6) + freqs = np.linspace(0.5, 3.0, 1000) + proc = ConditionalEntropyAsyncProcess(use_fast=True) + run_ce(proc, t, y, dy, freqs[:16]) + grids, orig, rec = self._record_grids() + monkeypatch.setattr(cuda.Function, 'prepared_async_call', rec) + run_ce(proc, t, y, dy, freqs, max_nblocks=13) + run_ce(proc, t, y, dy, freqs, force_nblocks=3) + assert grids == [13, 3] + + @pytest.mark.parametrize('use_double', [False, True]) + @pytest.mark.parametrize('ndata,nfreq,phase_bins,mag_bins', + [(300, 1013, 10, 5), + (2000, 4001, 10, 5), + (137, 257, 7, 6), + (5000, 733, 20, 8)]) + def test_result_is_bitwise_independent_of_the_grid( + self, ndata, nfreq, phase_bins, mag_bins, use_double): + """The frequency counts above are prime-ish on purpose: none of + the grids below divides them, so every block ends its stride + loop on a different frequency.""" + t, y, dy = lightcurve(ndata, seed=11) + freqs = np.linspace(0.5, 4.0, nfreq) + proc = ConditionalEntropyAsyncProcess(use_fast=True, + use_double=use_double, + phase_bins=phase_bins, + mag_bins=mag_bins) + ref = run_ce(proc, t, y, dy, freqs) # library default + assert np.all(np.isfinite(ref)) + for nblocks in (1, 3, 17, 64, 507, 4096): + other = run_ce(proc, t, y, dy, freqs, force_nblocks=nblocks) + assert_array_equal(other, ref) + # and through the batched frequency loop, whose last batch is + # shorter than the others + assert_array_equal(run_ce(proc, t, y, dy, freqs, + freq_batch_size=97), ref) + + +class TestCEFastSkipsGlobalHistogram(object): + """CE-3 (audit id 161): ``allocate_bins`` allocated an + ``nf * phase_bins * mag_bins`` histogram -- 20 MB for a + 100k-frequency 10 x 5 search -- that ``ce_classical_fast`` / + ``_faster`` never read, and ``run(memory=...)`` zero-filled it on + every call. The fast memory now skips it entirely; the returned + numbers must not move.""" + + @staticmethod + def _memory(proc, t, y, dy, freqs, use_fast): + """A memory object for ``proc`` with ``bins_g`` forced on or off.""" + kw = proc._memory_kwargs() + kw['use_fast'] = use_fast + if not proc.streams: + proc._create_streams(1) + kw['stream'] = proc.streams[0] + mem = ConditionalEntropyMemory(**kw) + tn, yn, dyn = normalize_light_curves([(t, y, dy)])[0] + mem.fromdata(tn, yn, dy=dyn, freqs=freqs, allocate=True) + mem.transfer_freqs_to_gpu() + return mem + + def test_fast_memory_has_no_global_histogram(self): + t, y, dy = lightcurve(300, seed=2) + freqs = np.linspace(0.5, 3.0, 2000) + fast = ConditionalEntropyAsyncProcess(use_fast=True) + std = ConditionalEntropyAsyncProcess(use_fast=False) + mf = fast.allocate(normalize_light_curves([(t, y, dy)]), + freqs=[freqs])[0] + ms = std.allocate(normalize_light_curves([(t, y, dy)]), + freqs=[freqs])[0] + assert mf.bins_g is None + assert ms.bins_g is not None + assert ms.bins_g.size == len(freqs) * 10 * 5 + # nbins is still reported (it describes the histogram shape) + assert mf.nbins == len(freqs) * 10 * 5 + + def test_memory_requirement_drops_the_histogram(self): + fast = ConditionalEntropyAsyncProcess(use_fast=True) + std = ConditionalEntropyAsyncProcess(use_fast=False) + n0, nf = 1000, 100000 + hist = nf * 10 * 5 * 4 + assert (std.memory_requirement(n0, nf) + - fast.memory_requirement(n0, nf)) == hist + assert fast.memory_requirement(n0, nf) > 0 + + @pytest.mark.parametrize('use_double', [False, True]) + @pytest.mark.parametrize('ndata,nfreq', [(300, 1013), (2000, 4001)]) + def test_results_identical_with_and_without_the_histogram( + self, ndata, nfreq, use_double): + t, y, dy = lightcurve(ndata, seed=4) + freqs = np.linspace(0.5, 4.0, nfreq) + proc = ConditionalEntropyAsyncProcess(use_fast=True, + use_double=use_double) + with_bins = self._memory(proc, t, y, dy, freqs, use_fast=False) + assert with_bins.bins_g is not None + res = proc.run([(t, y, dy)], memory=[with_bins], freqs=freqs) + proc.finish() + old = np.copy(res[0][1]) + new = run_ce(proc, t, y, dy, freqs) # default: no bins_g + assert np.all(np.isfinite(old)) + assert_array_equal(new, old) + + def test_standard_kernels_reject_a_fast_memory(self): + t, y, dy = lightcurve(200, seed=5) + freqs = np.linspace(0.5, 3.0, 128) + std = ConditionalEntropyAsyncProcess(use_fast=False) + mem = self._memory(std, t, y, dy, freqs, use_fast=True) + with pytest.raises(ValueError, match="use_fast=True"): + std.run([(t, y, dy)], memory=[mem], freqs=freqs) + + @pytest.mark.parametrize('use_fast', [False, True]) + def test_set_data_false_repeat_is_still_idempotent(self, use_fast): + """``set_gpu_arrays_to_zero`` must keep zeroing ``bins_g`` when + there is one (id 112) and must not trip over its absence.""" + t, y, dy = lightcurve(80, seed=6) + freqs = np.linspace(0.3, 3.0, 64) + proc = ConditionalEntropyAsyncProcess(use_fast=use_fast) + mems = proc.allocate(normalize_light_curves([(t, y, dy)]), + freqs=[freqs]) + mems[0].transfer_freqs_to_gpu() + first = None + for k in range(3): + res = proc.run([(t, y, dy)], memory=mems, freqs=[freqs], + set_data=(k == 0)) + proc.finish() + p = np.copy(res[0][1]) + if mems[0].bins_g is not None: + assert mems[0].bins_g.get().sum() == 80 * len(freqs) + if first is None: + first = p + else: + assert_array_equal(p, first) + + def test_preallocate_and_large_run_still_work(self): + t, y, dy = lightcurve(150, seed=7) + freqs = np.linspace(0.4, 3.0, 500) + proc = ConditionalEntropyAsyncProcess(use_fast=True) + mems = proc.preallocate(150, freqs, nlcs=1) + assert mems[0].bins_g is None + res = proc.run([(t, y, dy)], freqs=freqs) + proc.finish() + prealloc = np.copy(res[0][1]) + big = proc.large_run([(t, y, dy)], freqs=freqs, max_memory=2e5) + ref = run_ce(ConditionalEntropyAsyncProcess(use_fast=True), + t, y, dy, freqs) + assert_array_equal(prealloc, ref) + assert_allclose(big[0][1], ref, rtol=0, atol=1e-6) + + +class TestCEFrequencyInput(object): + """ids 108/163: float32 (or any non-Python-float) frequency arrays + were rejected with a misleading 'number of frequency grids' error.""" + + def test_single_grid_detection(self): + # CPU-runnable + assert _is_single_freq_grid(np.linspace(0, 1, 5).astype(np.float32)) + assert _is_single_freq_grid(np.linspace(0, 1, 5)) + assert _is_single_freq_grid([0.1, 0.2, 0.3]) + assert _is_single_freq_grid(np.arange(5)) + assert not _is_single_freq_grid([np.linspace(0, 1, 5)]) + assert not _is_single_freq_grid([[0.1, 0.2], [0.3, 0.4, 0.5]]) + assert not _is_single_freq_grid(np.ones((2, 5))) + + @pytest.mark.parametrize('ctor', [dict(), dict(use_fast=True), + dict(weighted=True)]) + def test_float32_freqs_accepted(self, ctor): + t, y, dy = lightcurve(60, seed=0) + freqs = np.linspace(0.1, 3.0, 50) + proc = ConditionalEntropyAsyncProcess(**ctor) + ref = run_ce(proc, t, y, dy, freqs) + + def same(p): + # (the weighted kernel's float32 atomicAdd order varies + # between runs at the 1e-7 level) + assert_allclose(p, ref, rtol=0, atol=1e-6) + + same(run_ce(proc, t, y, dy, freqs.astype(np.float32))) + same(run_ce(proc, t, y, dy, list(freqs))) + r = proc.large_run([(t, y, dy)], freqs=freqs.astype(np.float32)) + proc.finish() + same(np.copy(r[0][1])) + # a list of per-lightcurve grids still works + r = proc.run([(t, y, dy), (t, y, dy)], + freqs=[freqs.astype(np.float32), freqs]) + proc.finish() + same(np.copy(r[0][1])) + same(np.copy(r[1][1])) + mems = proc.allocate([(t, y, dy)], freqs=freqs.astype(np.float32)) + assert mems[0].nf == len(freqs) diff --git a/cuvarbase/tests/test_error_hygiene.py b/cuvarbase/tests/test_error_hygiene.py new file mode 100644 index 00000000..c1b192dd --- /dev/null +++ b/cuvarbase/tests/test_error_hygiene.py @@ -0,0 +1,211 @@ +""" +Error-handling hygiene: input validation must not be assert-based +(asserts vanish under ``python -O``) and user-facing errors must be +typed (ValueError/RuntimeError/NotImplementedError), not bare +Exception. +""" +import ast +import os +import re +import subprocess +import sys + +import numpy as np +import pytest + + +_PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _runtime_sources(): + for root, dirs, files in os.walk(_PKG_DIR): + if 'tests' in root: + continue + for f in files: + if f.endswith('.py'): + yield os.path.join(root, f) + + +def test_no_assert_based_validation_in_runtime_modules(): + offenders = [] + for path in _runtime_sources(): + for i, line in enumerate(open(path), 1): + if re.match(r"^\s*assert[ (]", line): + offenders.append("%s:%d" % (os.path.relpath(path), i)) + assert not offenders, offenders + + +def test_no_bare_exception_raises(): + offenders = [] + for path in _runtime_sources(): + for i, line in enumerate(open(path), 1): + if 'raise Exception' in line: + offenders.append("%s:%d" % (os.path.relpath(path), i)) + assert not offenders, offenders + + +def _docstring_nodes(tree): + for node in ast.walk(tree): + if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, + ast.AsyncFunctionDef)): + continue + body = getattr(node, 'body', None) + if not body: + continue + first = body[0] + if (isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str)): + yield node, first.value + + +def test_docstrings_with_backslashes_are_raw(): + # A non-raw docstring eats its LaTeX: "\right)" becomes a carriage + # return, "\times" a tab, and the rendered __doc__ / Sphinx math + # block is corrupt. Any docstring carrying an un-doubled backslash + # must be a raw string. + offenders = [] + for path in _runtime_sources(): + with open(path, encoding='utf-8') as f: + src = f.read() + tree = ast.parse(src) + for node, const in _docstring_nodes(tree): + seg = ast.get_source_segment(src, const) + if seg is None or '\\' not in seg: + continue + m = re.match(r"^(?P[rRbBuUfF]*)" + r"(?P\"\"\"|'''|\"|')", seg) + if m is None or 'r' in m.group('prefix').lower(): + continue + quote = m.group('q') + literal = seg[len(m.group(0)):-len(quote)] + # "\\\\" (an escaped backslash) and a trailing "\\" line + # continuation are deliberate; anything else is an escape + # sequence eating the text. + bare = re.sub(r'\\[\\\n]', '', literal) + if '\\' in bare: + offenders.append( + "%s:%d (%s)" % (os.path.relpath(path, _PKG_DIR), + const.lineno, + getattr(node, 'name', ''))) + assert not offenders, offenders + + +def test_nfft_memory_math_block_is_intact(): + # defect 12's documentation half: the NFFTMemory epoch/phase + # convention is a ".. math::" block, so the docstring must be raw. + from ..memory.nfft_memory import NFFTMemory + doc = NFFTMemory.__doc__ + assert '\r' not in doc + assert r'\exp\left(2\pi i f_k (t_j - \mathrm{epoch})\right)' in doc + + +def test_check_k0_raises_value_error(): + from ..lombscargle import check_k0 + # freqs[0] far from any integer multiple of df + bad = 0.05 + 0.1 * np.arange(10) + 0.033 + with pytest.raises(ValueError, match="k0"): + check_k0(bad) + + +def test_check_k0_survives_python_O(): + # Under -O an assert-based check silently disappears; the + # validation must still raise. The subprocess imports the packaged + # conftest (cuvarbase/tests/conftest.py) for its pycuda stubs, so + # this works from an installed wheel as well as from the checkout. + script = ( + "import numpy as np\n" + "import cuvarbase.tests.conftest # install GPU stubs (if needed)\n" + "from cuvarbase.lombscargle import check_k0\n" + "bad = 0.05 + 0.1 * np.arange(10) + 0.033\n" + "try:\n" + " check_k0(bad)\n" + "except ValueError:\n" + " print('OK')\n" + "else:\n" + " raise SystemExit('check_k0 validated nothing under -O')\n" + ) + result = subprocess.run([sys.executable, '-O', '-c', script], + cwd=os.path.dirname(_PKG_DIR), + capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert 'OK' in result.stdout + + +def test_eebls_transit_qvals_without_freqs_value_error(): + from ..bls import eebls_transit, eebls_transit_gpu + t = np.linspace(0, 10, 50) + y = np.ones(50) + dy = np.ones(50) + for func in (eebls_transit, eebls_transit_gpu): + with pytest.raises(ValueError, match="qvals"): + func(t, y, dy, qvals=np.array([0.01])) + + +def test_ce_memory_unsupported_combos_value_error(): + from ..memory.ce_memory import ConditionalEntropyMemory + with pytest.raises(ValueError, match="balanced_magbins"): + ConditionalEntropyMemory(weighted=True, balanced_magbins=True) + with pytest.raises(ValueError, match="compute_log_prob"): + ConditionalEntropyMemory(weighted=True, compute_log_prob=True) + + +class TestApiStubsImplemented(object): + """Public API stubs that raised NotImplementedError are now + implemented (or behave usefully).""" + + def test_ce_memory_requirement_returns_bytes(self): + from ..ce import ConditionalEntropyAsyncProcess + proc = ConditionalEntropyAsyncProcess.__new__( + ConditionalEntropyAsyncProcess) + proc.phase_bins, proc.mag_bins = 10, 5 + proc.weighted = False + proc.real_type = np.float32 + small = proc.memory_requirement(100, 1000) + large = proc.memory_requirement(100, 100000) + assert small > 0 + assert large > small + # histogram-dominated: 100k freqs * 50 bins * 4 bytes = 20 MB + assert large > 100000 * 50 * 4 + + def test_ls_memory_is_ready_raises_runtime_error(self): + from ..memory.lombscargle_memory import LombScargleMemory + mem = LombScargleMemory(2, None, 8, use_fft=False) + with pytest.raises(RuntimeError, match="nf is not set"): + mem.is_ready() + + +class TestBatchApiHonesty(object): + + def test_batched_run_const_nfreq_default_batch_size_is_1(self): + import inspect + from ..lombscargle import LombScargleAsyncProcess + sig = inspect.signature( + LombScargleAsyncProcess.batched_run_const_nfreq) + assert sig.parameters['batch_size'].default == 1 + + def test_batch_kernels_are_cached(self): + # E1: per-call compilation (~0.6-0.9 s vs 2-10 ms of kernel + # work) was the whole "batch is ~12x slower at TESS scale" + # regression; the batch kernel must go through the same LRU + # cache as the single-LC paths (and the old inefficiency + # warning is retired). + from .. import bls + assert not hasattr(bls, '_warn_if_batch_inefficient') + fns1 = bls._get_cached_batch_kernels(256) + fns2 = bls._get_cached_batch_kernels(256) + assert fns1 is fns2 + assert (256, 'batch') in bls._kernel_cache + + def test_bls_memory_host_array_naming(self): + from ..bls import BLSMemory + assert hasattr(BLSMemory, 'allocate_host_arrays') + # deprecated alias retained for compatibility + assert hasattr(BLSMemory, 'allocate_pinned_arrays') + # B3: host arrays are now page-locked (pinned) by default, with a + # graceful fallback to page-aligned memory if pinning fails. The + # docstring must reflect that (and no longer claim "NOT page-locked"). + doc = BLSMemory.allocate_host_arrays.__doc__ + assert 'page-locked' in doc + assert 'NOT page-locked' not in doc + assert 'fall back' in doc diff --git a/cuvarbase/tests/test_examples_compile.py b/cuvarbase/tests/test_examples_compile.py new file mode 100644 index 00000000..da5ee107 --- /dev/null +++ b/cuvarbase/tests/test_examples_compile.py @@ -0,0 +1,119 @@ +"""Compile-check the shipped examples and notebook code cells. + +``examples/*.py`` and the code cells of ``notebooks/*.ipynb`` are never +executed by the suite (they need a device and, for the notebooks, a +kernel); this is the cheap half of release finding 78: every example +and every notebook cell must at least ``compile()`` cleanly with +warnings turned into errors (a SyntaxWarning such as an invalid escape +sequence fails the test). IPython line/cell magics and shell escapes +(``%matplotlib inline``, ``!pip install``) are stripped first. + +Skips when the ``examples/`` or ``notebooks/`` directory is absent +(installed wheel, ``pytest --pyargs cuvarbase``). +""" +import glob +import json +import os +import warnings + +import pytest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) +_EXAMPLES = os.path.join(_REPO_ROOT, 'examples') +_NOTEBOOKS = os.path.join(_REPO_ROOT, 'notebooks') + + +def _compile_strict(src, filename): + with warnings.catch_warnings(): + warnings.simplefilter('error') + compile(src, filename, 'exec') + + +def _strip_magics(src): + """Drop IPython magics / shell escapes; keep line numbers stable.""" + out = [] + for line in src.splitlines(): + stripped = line.lstrip() + if stripped.startswith('%') or stripped.startswith('!'): + out.append('') + else: + out.append(line) + return '\n'.join(out) + '\n' + + +def _example_files(): + if not os.path.isdir(_EXAMPLES): + return [] + return sorted(glob.glob(os.path.join(_EXAMPLES, '*.py'))) + + +def _notebook_files(): + if not os.path.isdir(_NOTEBOOKS): + return [] + return sorted(glob.glob(os.path.join(_NOTEBOOKS, '*.ipynb'))) + + +def _ids(paths): + return [os.path.basename(p) for p in paths] + + +_EXAMPLE_FILES = _example_files() +_NOTEBOOK_FILES = _notebook_files() + +# Notebooks with a KNOWN compile warning, pending a fix outside the test +# suite. Phase 3 (Sep 2026) found two non-raw matplotlib label strings +# in the PDM notebook's first two code cells -- '$1-\Theta(f)$' and a +# '\c...' TeX macro -- i.e. "invalid escape sequence \T / \c" +# (a DeprecationWarning on 3.9, a SyntaxWarning on 3.12+, a SyntaxError +# in a future Python). The fix is to make those strings raw +# (r'$1-\Theta(f)$'). The entry is strict: once the notebook is fixed +# this xfail turns into a failure and must be deleted. +KNOWN_ESCAPE_OFFENDERS = { + 'Phase Dispersion Minimization.ipynb', +} +_NOTEBOOK_PARAMS = [ + pytest.param(p, marks=pytest.mark.xfail( + strict=True, raises=SyntaxError, + reason="known non-raw TeX label strings (see " + "KNOWN_ESCAPE_OFFENDERS)")) + if os.path.basename(p) in KNOWN_ESCAPE_OFFENDERS else p + for p in _NOTEBOOK_FILES] + + +def test_examples_directory_present_or_installed(): + # In the source tree both directories exist and are non-empty; from + # an installed wheel neither does and the parametrized tests below + # are skipped (they parametrize over an empty list). + if not os.path.isdir(_EXAMPLES) and not os.path.isdir(_NOTEBOOKS): + pytest.skip("examples/ and notebooks/ not found (running outside " + "the source tree)") + assert _EXAMPLE_FILES or _NOTEBOOK_FILES + + +@pytest.mark.parametrize('path', _EXAMPLE_FILES, ids=_ids(_EXAMPLE_FILES)) +def test_example_compiles_without_warnings(path): + with open(path, encoding='utf-8') as f: + src = f.read() + _compile_strict(src, path) + + +@pytest.mark.parametrize('path', _NOTEBOOK_PARAMS, + ids=_ids(_NOTEBOOK_FILES)) +def test_notebook_code_cells_compile_without_warnings(path): + with open(path, encoding='utf-8') as f: + nb = json.load(f) + cells = [c for c in nb.get('cells', []) if c.get('cell_type') == 'code'] + if not cells: + pytest.skip("%s has no code cells" % os.path.basename(path)) + n_checked = 0 + for i, cell in enumerate(cells): + src = cell.get('source', '') + if isinstance(src, list): + src = ''.join(src) + src = _strip_magics(src) + if not src.strip(): + continue + _compile_strict(src, '%s[cell %d]' % (os.path.basename(path), i)) + n_checked += 1 + assert n_checked > 0 diff --git a/cuvarbase/tests/test_host_array.py b/cuvarbase/tests/test_host_array.py new file mode 100644 index 00000000..61428700 --- /dev/null +++ b/cuvarbase/tests/test_host_array.py @@ -0,0 +1,76 @@ +"""Unit tests for the pinned-host-buffer helper (B3). + +These exercise the allocation-strategy logic directly (mocking the pycuda +allocators) so they run on CPU-only machines: pinned by default, graceful +fallback to page-aligned when pinning fails, and pinned=False bypassing +pinning entirely. +""" +import numpy as np + +from cuvarbase.memory import _host + + +def test_host_array_pinned_uses_pagelocked(monkeypatch): + calls = [] + monkeypatch.setattr(_host.cuda, 'pagelocked_zeros', + lambda shape, dtype: (calls.append('pinned') + or np.zeros(shape, dtype))) + monkeypatch.setattr(_host.cuda, 'aligned_zeros', + lambda shape, dtype: (calls.append('aligned') + or np.zeros(shape, dtype))) + arr = _host.host_array((4,), np.float32, pinned=True) + assert calls == ['pinned'] + assert arr.shape == (4,) and arr.dtype == np.float32 + + +def test_host_array_falls_back_when_pinning_fails(monkeypatch, recwarn): + _host._warned_fallback = False # reset the warn-once latch + calls = [] + + def boom(shape, dtype): + raise RuntimeError("locked-memory limit exhausted") + + monkeypatch.setattr(_host.cuda, 'pagelocked_zeros', boom) + monkeypatch.setattr(_host.cuda, 'aligned_zeros', + lambda shape, dtype: (calls.append('aligned') + or np.zeros(shape, dtype))) + arr = _host.host_array((8,), np.float64, pinned=True) + assert calls == ['aligned'] # fell back to page-aligned + assert arr.shape == (8,) + assert any('page-locked' in str(w.message) for w in recwarn.list), \ + "a fallback warning should be emitted" + + +def test_host_array_does_not_warn_when_fallback_also_fails(monkeypatch): + # On a GPU-less machine both allocators raise (stubbed); the helper + # must let that propagate WITHOUT claiming a fallback happened. + _host._warned_fallback = False + + def boom(shape, dtype): + raise RuntimeError("no GPU") + + monkeypatch.setattr(_host.cuda, 'pagelocked_zeros', boom) + monkeypatch.setattr(_host.cuda, 'aligned_zeros', boom) + try: + _host.host_array((2,), np.float32, pinned=True) + except RuntimeError: + pass + else: + raise AssertionError("expected the page-aligned failure to propagate") + assert _host._warned_fallback is False + + +def test_host_array_pinned_false_skips_pinning(monkeypatch): + calls = [] + + def must_not_call(shape, dtype): + raise AssertionError("pagelocked_zeros must not be called when " + "pinned=False") + + monkeypatch.setattr(_host.cuda, 'pagelocked_zeros', must_not_call) + monkeypatch.setattr(_host.cuda, 'aligned_zeros', + lambda shape, dtype: (calls.append('aligned') + or np.zeros(shape, dtype))) + arr = _host.host_array((3,), np.float32, pinned=False) + assert calls == ['aligned'] + assert arr.shape == (3,) diff --git a/cuvarbase/tests/test_input_validation.py b/cuvarbase/tests/test_input_validation.py new file mode 100644 index 00000000..de140747 --- /dev/null +++ b/cuvarbase/tests/test_input_validation.py @@ -0,0 +1,844 @@ +"""Input validation on every public entry point (Sep 2026 audit, +defect 23 ``input-validation``). + +Before 1.0 nothing checked the light curve. A single NaN in ``t`` gave +a finite periodogram with the wrong argmax on the BLS and CE paths; +``dy = 0`` or a non-finite ``y`` gave an all-NaN spectrum (PDM), an +undocumented power of ``-1`` at every frequency (Lomb-Scargle) or a +chi2 off by a factor 1.3e3 (TLS fast); and a NaN per-frequency ``q`` +bound, ``qmax >= 1``, or a Keplerian grid built from fewer points than +one transit needs crashed the kernel with + + cuMemcpyDtoH failed: an illegal memory access was encountered + +which **kills the CUDA context for the rest of the process** -- every +later call in the same interpreter then fails with +``cuMemAlloc failed: an illegal memory access``. + +Every entry point now calls :func:`cuvarbase.utils.check_lightcurve` +and :func:`cuvarbase.utils.check_freqs` before any device work +(compilation included), so almost all of these tests run without a +GPU: the ``ValueError`` is raised on the host. The one genuinely +device-bound test is +:func:`test_cuda_context_survives_rejected_calls`, which is the whole +point of the defect. +""" +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from ..utils import check_lightcurve, check_freqs +from ..bls import (sparse_bls_cpu, sparse_bls_gpu, eebls_gpu, + eebls_gpu_fast, eebls_gpu_fast_optimized, + eebls_gpu_fast_adaptive, eebls_gpu_custom, + eebls_gpu_batch, eebls_transit, eebls_transit_gpu, + single_bls, fmin_transit, transit_autofreq) +from .. import tls as TLS +from ..lombscargle import LombScargleAsyncProcess, lomb_scargle_simple +from ..ce import ConditionalEntropyAsyncProcess +from ..pdm import PDMAsyncProcess +from ..cunfft import NFFTAsyncProcess +from ..nufft_lrt import NUFFTLRTAsyncProcess + + +# ---------------------------------------------------------------- data + +def make_lc(ndata=60, baseline=10., seed=42, freq=1.0, q=0.15, + depth=0.05, sigma=0.005): + """Deterministic box-transit light curve.""" + rand = np.random.RandomState(seed) + t = baseline * np.sort(rand.rand(ndata)) + y = np.ones(ndata) + phase = (t * freq) % 1.0 + y[phase < q] -= depth + dy = sigma * np.ones(ndata) + y = y + dy * rand.randn(ndata) + return t, y, dy + + +BLS_FREQS = np.linspace(0.5, 2.0, 12) +LS_FREQS = 0.1 * (1 + np.arange(32)) # df * (k0 + arange(nf)) +TLS_PERIODS = np.linspace(0.8, 1.4, 6) + + +# ------------------------------------------------------- entry points +# +# Each entry is (name, callable(t, y, dy), min_n, takes_dy, grid_kind). +# ``grid_kind`` names the validator the trial grid goes through: +# 'freqs' -> check_freqs, 'periods' -> the TLS/LRT period validator, +# None -> the entry point takes no grid. + +def _ls_proc(): + return LombScargleAsyncProcess() + + +def _ce_proc(): + return ConditionalEntropyAsyncProcess() + + +def _pdm_proc(): + return PDMAsyncProcess() + + +ENTRY_POINTS = [ + # ---- BLS ------------------------------------------------------- + ('sparse_bls_cpu', + lambda t, y, dy, f=None: sparse_bls_cpu( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('sparse_bls_gpu', + lambda t, y, dy, f=None: sparse_bls_gpu( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_gpu', + lambda t, y, dy, f=None: eebls_gpu( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_gpu_fast', + lambda t, y, dy, f=None: eebls_gpu_fast( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_gpu_fast_optimized', + lambda t, y, dy, f=None: eebls_gpu_fast_optimized( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_gpu_fast_adaptive', + lambda t, y, dy, f=None: eebls_gpu_fast_adaptive( + t, y, dy, BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_gpu_custom', + lambda t, y, dy, f=None: eebls_gpu_custom( + t, y, dy, BLS_FREQS if f is None else f, + np.array([0.05, 0.1]), np.linspace(0, 1, 8, endpoint=False)), + 2, True, 'freqs'), + ('eebls_gpu_batch', + lambda t, y, dy, f=None: eebls_gpu_batch( + [(t, y, dy)], BLS_FREQS if f is None else f), 2, True, 'freqs'), + ('eebls_transit', + lambda t, y, dy, f=None: eebls_transit( + t, y, dy, freqs=BLS_FREQS if f is None else f, + qvals=np.full(len(BLS_FREQS if f is None else f), 0.1)), + 2, True, 'freqs'), + ('eebls_transit_gpu', + lambda t, y, dy, f=None: eebls_transit_gpu( + t, y, dy, freqs=BLS_FREQS if f is None else f, + qvals=np.full(len(BLS_FREQS if f is None else f), 0.1)), + 2, True, 'freqs'), + ('single_bls', + lambda t, y, dy, f=None: single_bls(t, y, dy, 1.0, 0.1, 0.0), + 2, True, None), + # ---- TLS ------------------------------------------------------- + ('tls_search', + lambda t, y, dy, f=None: TLS.tls_search( + t, y, dy, periods=TLS_PERIODS if f is None else f), + 2, True, 'periods'), + ('tls_search_gpu', + lambda t, y, dy, f=None: TLS.tls_search_gpu( + t, y, dy, periods=TLS_PERIODS if f is None else f), + 2, True, 'periods'), + ('tls_transit', + lambda t, y, dy, f=None: TLS.tls_transit( + t, y, dy, period_min=0.8, period_max=1.4), + 2, True, None), + ('tls_search_batch', + lambda t, y, dy, f=None: TLS.tls_search_batch( + [(t, y, dy)], periods=TLS_PERIODS if f is None else f), + 2, True, 'periods'), + # ---- Lomb-Scargle ---------------------------------------------- + ('lomb_scargle_simple', + lambda t, y, dy, f=None: lomb_scargle_simple( + t, y, dy, freqs=[LS_FREQS if f is None else f]), + 4, True, 'freqs'), + ('LombScargleAsyncProcess.run', + lambda t, y, dy, f=None: _ls_proc().run( + [(t, y, dy)], freqs=[LS_FREQS if f is None else f]), + 4, True, 'freqs'), + ('LombScargleAsyncProcess.batched_run_const_nfreq', + lambda t, y, dy, f=None: _ls_proc().batched_run_const_nfreq( + [(t, y, dy)], freqs=LS_FREQS if f is None else f), + 4, True, 'freqs'), + # ---- conditional entropy --------------------------------------- + ('ConditionalEntropyAsyncProcess.run', + lambda t, y, dy, f=None: _ce_proc().run( + [(t, y, dy)], freqs=[BLS_FREQS if f is None else f]), + 2, True, 'freqs'), + ('ConditionalEntropyAsyncProcess.large_run', + lambda t, y, dy, f=None: _ce_proc().large_run( + [(t, y, dy)], freqs=[BLS_FREQS if f is None else f]), + 2, True, 'freqs'), + ('ConditionalEntropyAsyncProcess.batched_run_const_nfreq', + lambda t, y, dy, f=None: _ce_proc().batched_run_const_nfreq( + [(t, y, dy)], freqs=BLS_FREQS if f is None else f), + 2, True, 'freqs'), + # ---- PDM ------------------------------------------------------- + ('PDMAsyncProcess.run', + lambda t, y, dy, f=None: _pdm_proc().run( + [(t, y, dy)], freqs=BLS_FREQS if f is None else f), + 2, True, 'freqs'), + ('PDMAsyncProcess.batched_run_const_nfreq', + lambda t, y, dy, f=None: _pdm_proc().batched_run_const_nfreq( + [(t, y, dy)], freqs=BLS_FREQS if f is None else f), + 2, True, 'freqs'), + ('PDMAsyncProcess.large_run', + lambda t, y, dy, f=None: _pdm_proc().large_run( + [(t, y, dy)], freqs=BLS_FREQS if f is None else f), + 2, True, 'freqs'), + # ---- NFFT / NUFFT-LRT ------------------------------------------ + ('NFFTAsyncProcess.run', + lambda t, y, dy, f=None: NFFTAsyncProcess().run([(t, y, 64)]), + 2, False, None), + ('NUFFTLRTAsyncProcess.run', + lambda t, y, dy, f=None: NUFFTLRTAsyncProcess().run( + t, y, TLS_PERIODS if f is None else f, + durations=np.array([0.1])), + 3, False, 'periods'), +] + +ALL_IDS = [e[0] for e in ENTRY_POINTS] +WITH_DY = [e for e in ENTRY_POINTS if e[3]] +WITH_FREQS = [e for e in ENTRY_POINTS if e[4] == 'freqs'] +WITH_PERIODS = [e for e in ENTRY_POINTS if e[4] == 'periods'] + + +def _ids(entries): + return [e[0] for e in entries] + + +# ------------------------------------------------ the validators alone + +class TestCheckLightcurve(object): + """``utils.check_lightcurve`` itself: message content and the + guarantee that it does not touch valid input.""" + + def test_accepts_valid_input_unchanged(self): + t, y, dy = make_lc(20) + t2, y2, dy2 = check_lightcurve(t, y, dy, min_n=5, name='x') + # returned as-is (no copy, no cast) so it cannot perturb results + assert t2 is t and y2 is y and dy2 is dy + + def test_dy_none_is_allowed(self): + t, y, _ = make_lc(20) + t2, y2, dy2 = check_lightcurve(t, y, None) + assert dy2 is None + + def test_integer_arrays_are_accepted(self): + t = np.arange(10) + y = np.arange(10) * 2 + check_lightcurve(t, y, np.ones(10, dtype=np.int64)) + + @pytest.mark.parametrize('dtype', [np.float32, np.float64]) + def test_dtypes(self, dtype): + t, y, dy = make_lc(10) + check_lightcurve(t.astype(dtype), y.astype(dtype), + dy.astype(dtype)) + + def test_nan_in_t_names_t_and_the_index(self): + t, y, dy = make_lc(20) + t = t.copy() + t[7] = np.nan + with pytest.raises(ValueError) as exc: + check_lightcurve(t, y, dy, name='thing') + msg = str(exc.value) + assert msg.startswith('thing: t ') + assert '1 non-finite' in msg + assert 'index/indices 7' in msg + + def test_counts_and_first_indices(self): + t, y, dy = make_lc(20) + y = y.copy() + y[[2, 5, 9, 11, 13, 17]] = np.inf + with pytest.raises(ValueError, match=r'y contains 6 non-finite'): + check_lightcurve(t, y, dy) + with pytest.raises(ValueError, + match=r'indices 2, 5, 9, 11, 13, \.\.\.'): + check_lightcurve(t, y, dy) + + def test_dy_zero_and_negative(self): + t, y, dy = make_lc(20) + for bad in (0.0, -1e-3): + d = dy.copy() + d[3] = bad + with pytest.raises(ValueError) as exc: + check_lightcurve(t, y, d, name='thing') + msg = str(exc.value) + assert 'dy must be > 0' in msg + assert '1 of 20' in msg + assert 'indices 3' in msg + + def test_length_mismatch(self): + t, y, dy = make_lc(20) + with pytest.raises(ValueError, match='t and y must have the same'): + check_lightcurve(t, y[:-1], dy) + with pytest.raises(ValueError, match='t and dy must have the same'): + check_lightcurve(t, y, dy[:-1]) + + def test_min_n(self): + t, y, dy = make_lc(3) + with pytest.raises(ValueError, match='at least 5 observation'): + check_lightcurve(t, y, dy, min_n=5) + with pytest.raises(ValueError, match='at least 1 observation'): + check_lightcurve(t[:0], y[:0], dy[:0]) + + def test_shape_and_dtype_guards(self): + with pytest.raises(ValueError, match='1-D'): + check_lightcurve(np.zeros((2, 3)), np.zeros((2, 3))) + with pytest.raises(ValueError, match='numeric'): + check_lightcurve(np.array(['a', 'b']), np.zeros(2)) + + +class TestCheckFreqs(object): + + def test_accepts_valid_grid_unchanged(self): + f = check_freqs(BLS_FREQS) + assert f is BLS_FREQS + + def test_non_finite(self): + f = BLS_FREQS.copy() + f[2] = np.nan + with pytest.raises(ValueError, match='freqs contains 1 non-finite'): + check_freqs(f, name='thing') + + def test_non_positive(self): + f = np.array([-1.0, 0.0, 1.0]) + with pytest.raises(ValueError) as exc: + check_freqs(f, name='thing') + msg = str(exc.value) + assert 'thing: freqs must be > 0' in msg + assert '2 of 3' in msg + assert 'indices 0, 1' in msg + + def test_empty(self): + with pytest.raises(ValueError, match='non-empty'): + check_freqs(np.array([])) + + +# ------------------------------------- every entry point, every poison + +class TestEntryPointsRejectBadLightcurves(object): + """Each public entry point must raise ``ValueError`` naming the + offending array, on the host, before any GPU work.""" + + @pytest.mark.parametrize('entry', ENTRY_POINTS, ids=ALL_IDS) + def test_nan_in_t(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + t = t.copy() + t[17] = np.nan + with pytest.raises(ValueError, match=r'\bt\b.*non-finite'): + fn(t, y, dy) + + @pytest.mark.parametrize('entry', ENTRY_POINTS, ids=ALL_IDS) + def test_inf_in_y(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + y = y.copy() + y[3] = np.inf + with pytest.raises(ValueError, match=r'\by\b.*non-finite'): + fn(t, y, dy) + + @pytest.mark.parametrize('entry', WITH_DY, ids=_ids(WITH_DY)) + def test_nan_in_dy(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + dy = dy.copy() + dy[41] = np.nan + with pytest.raises(ValueError, match=r'\bdy\b.*non-finite'): + fn(t, y, dy) + + @pytest.mark.parametrize('entry', WITH_DY, ids=_ids(WITH_DY)) + def test_zero_dy(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + dy = dy.copy() + dy[0] = 0.0 + with pytest.raises(ValueError, match='dy must be > 0'): + fn(t, y, dy) + + @pytest.mark.parametrize('entry', WITH_DY, ids=_ids(WITH_DY)) + def test_negative_dy(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + dy = dy.copy() + dy[59] = -dy[59] + with pytest.raises(ValueError, match='dy must be > 0'): + fn(t, y, dy) + + @pytest.mark.parametrize('entry', ENTRY_POINTS, ids=ALL_IDS) + def test_mismatched_lengths(self, entry): + _, fn, min_n, takes_dy, _ = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='same length'): + fn(t, y[:-1], dy) + if takes_dy: + with pytest.raises(ValueError, match='same length'): + fn(t, y, dy[:-1]) + + @pytest.mark.parametrize('entry', ENTRY_POINTS, ids=ALL_IDS) + def test_empty_arrays(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='at least'): + fn(t[:0], y[:0], dy[:0]) + + @pytest.mark.parametrize('entry', ENTRY_POINTS, ids=ALL_IDS) + def test_below_method_minimum(self, entry): + name, fn, min_n, _, _ = entry + if min_n < 2: + pytest.skip('%s accepts a single observation' % name) + t, y, dy = make_lc(min_n - 1) + with pytest.raises(ValueError, + match='at least %d observation' % min_n): + fn(t, y, dy) + + +class TestEntryPointsRejectBadGrids(object): + + @pytest.mark.parametrize('entry', WITH_FREQS, ids=_ids(WITH_FREQS)) + def test_non_finite_freqs(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + bad = np.array([0.5, np.nan, 1.5, 2.0]) + with pytest.raises(ValueError, match='freqs contains 1 non-finite'): + fn(t, y, dy, bad) + + @pytest.mark.parametrize('entry', WITH_FREQS, ids=_ids(WITH_FREQS)) + def test_non_positive_freqs(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + bad = np.array([0.0, 0.5, 1.0, 1.5]) + with pytest.raises(ValueError, match='freqs must be > 0'): + fn(t, y, dy, bad) + + @pytest.mark.parametrize('entry', WITH_PERIODS, ids=_ids(WITH_PERIODS)) + def test_non_finite_periods(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='periods'): + fn(t, y, dy, np.array([1.0, np.nan])) + + @pytest.mark.parametrize('entry', WITH_PERIODS, ids=_ids(WITH_PERIODS)) + def test_non_positive_periods(self, entry): + _, fn, min_n, _, _ = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='periods'): + fn(t, y, dy, np.array([1.0, -1.0])) + + +# --------------------------------------------------- BLS q-bound rules + +#: entry points whose kernels bin phase, so ``0 < qmin <= qmax <= 1`` +BINNED_Q_ENTRIES = [ + ('eebls_gpu', lambda t, y, dy, **kw: eebls_gpu( + t, y, dy, BLS_FREQS, **kw)), + ('eebls_gpu_fast', lambda t, y, dy, **kw: eebls_gpu_fast( + t, y, dy, BLS_FREQS, **kw)), + ('eebls_gpu_fast_optimized', + lambda t, y, dy, **kw: eebls_gpu_fast_optimized( + t, y, dy, BLS_FREQS, **kw)), + ('eebls_gpu_fast_adaptive', + lambda t, y, dy, **kw: eebls_gpu_fast_adaptive( + t, y, dy, BLS_FREQS, **kw)), + ('eebls_gpu_batch', lambda t, y, dy, **kw: eebls_gpu_batch( + [(t, y, dy)], BLS_FREQS, **kw)), +] +BINNED_Q_IDS = [e[0] for e in BINNED_Q_ENTRIES] + +SPARSE_Q_ENTRIES = [ + ('sparse_bls_cpu', lambda t, y, dy, **kw: sparse_bls_cpu( + t, y, dy, BLS_FREQS, **kw)), + ('sparse_bls_gpu', lambda t, y, dy, **kw: sparse_bls_gpu( + t, y, dy, BLS_FREQS, **kw)), +] +SPARSE_Q_IDS = [e[0] for e in SPARSE_Q_ENTRIES] + + +class TestQBoundValidation(object): + """``BLSMemory.setdata`` casts ``1/qmin`` and ``1/qmax`` to uint32: + ``(1 / [nan, 0.01, 5, inf]).astype(uint32)`` is ``[0, 100, 0, 0]``, + and a zero bin count divides by zero in the kernel and atomicAdds + outside the shared-memory histogram (illegal memory access, dead + CUDA context). Every bound is checked before that cast.""" + + @pytest.mark.parametrize('entry', BINNED_Q_ENTRIES + SPARSE_Q_ENTRIES, + ids=BINNED_Q_IDS + SPARSE_Q_IDS) + def test_nan_scalar_q(self, entry): + _, fn = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='finite'): + fn(t, y, dy, qmin=np.nan, qmax=0.2) + with pytest.raises(ValueError, match='finite'): + fn(t, y, dy, qmin=0.01, qmax=np.nan) + + @pytest.mark.parametrize('entry', BINNED_Q_ENTRIES + SPARSE_Q_ENTRIES, + ids=BINNED_Q_IDS + SPARSE_Q_IDS) + def test_nan_per_frequency_q(self, entry): + """The exact case that killed the CUDA context.""" + _, fn = entry + t, y, dy = make_lc(60) + qmin = np.full(len(BLS_FREQS), 0.01) + qmax = np.full(len(BLS_FREQS), 0.2) + qmin[4] = np.nan + with pytest.raises(ValueError, match='finite'): + fn(t, y, dy, qmin=qmin, qmax=qmax) + qmin[4] = 0.01 + qmax[7] = np.nan + with pytest.raises(ValueError, match='finite'): + fn(t, y, dy, qmin=qmin, qmax=qmax) + + @pytest.mark.parametrize('entry', BINNED_Q_ENTRIES + SPARSE_Q_ENTRIES, + ids=BINNED_Q_IDS + SPARSE_Q_IDS) + def test_inverted_q(self, entry): + _, fn = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='qmin > qmax'): + fn(t, y, dy, qmin=0.3, qmax=0.1) + + @pytest.mark.parametrize('entry', BINNED_Q_ENTRIES, ids=BINNED_Q_IDS) + def test_qmax_at_or_above_one(self, entry): + """``qmax >= 1`` makes ``nbins0 = floor(1/qmax) = 0``: a device + divide by zero that returned finite garbage.""" + _, fn = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='qmax must be <= 1'): + fn(t, y, dy, qmin=0.01, qmax=1.5) + # inf is caught one step earlier, by the finiteness check + with pytest.raises(ValueError, match='finite'): + fn(t, y, dy, qmin=0.01, qmax=np.inf) + + @pytest.mark.parametrize('entry', BINNED_Q_ENTRIES, ids=BINNED_Q_IDS) + def test_qmin_zero(self, entry): + """``qmin = 0`` asks for infinitely many phase bins (and casts + to a bin count of 0).""" + _, fn = entry + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='qmin must be > 0'): + fn(t, y, dy, qmin=0.0, qmax=0.2) + + def test_qmax_exactly_one_is_allowed(self): + """The boundary must stay usable: nbins0 = 1 is a single box + covering the whole period.""" + from ..bls import _validate_fast_q_bounds + _validate_fast_q_bounds(4, 0.01, 1.0) + + +class TestKeplerianGridGuards(object): + """``fmin_transit`` gives ``q = min_obs_per_transit / N > 1`` below + ``min_obs_per_transit`` points, and ``freq_transit`` of ``q > 1`` is + NaN: ``transit_autofreq`` used to return ``freqs = [nan]``, + ``q = [nan]``, which reached the kernels as a zero uint32 bin count + and killed the CUDA context on the ``use_fast=True`` path.""" + + def test_fmin_transit_raises_below_min_obs(self): + t = np.linspace(0, 10, 4) + with pytest.raises(ValueError, match='min_obs_per_transit'): + fmin_transit(t) + # explicitly lowering the requirement still works + assert np.isfinite(fmin_transit(t, min_obs_per_transit=2)) + + def test_fmin_transit_rejects_non_finite_times(self): + t = np.linspace(0, 10, 20) + t[3] = np.nan + with pytest.raises(ValueError, match='finite'): + fmin_transit(t) + with pytest.raises(ValueError, match='non-empty'): + fmin_transit(t[:0]) + + def test_transit_autofreq_rejects_non_finite_times(self): + t = np.linspace(0, 10, 20) + t[3] = np.nan + with pytest.raises(ValueError, match='finite'): + transit_autofreq(t) + + def test_transit_autofreq_grid_is_finite(self): + t = np.linspace(0, 100, 500) + freqs, qvals = transit_autofreq(t) + assert np.all(np.isfinite(freqs)) and np.all(freqs > 0) + assert np.all(np.isfinite(qvals)) and np.all(qvals > 0) + + @pytest.mark.parametrize('fn', [eebls_transit, eebls_transit_gpu], + ids=['eebls_transit', 'eebls_transit_gpu']) + @pytest.mark.parametrize('use_fast', [False, True]) + def test_keplerian_entry_points_with_too_few_points(self, fn, + use_fast): + t, y, dy = make_lc(4) + with pytest.raises(ValueError): + fn(t, y, dy, use_fast=use_fast) + + @pytest.mark.parametrize('fn', [eebls_transit, eebls_transit_gpu], + ids=['eebls_transit', 'eebls_transit_gpu']) + @pytest.mark.parametrize('use_fast', [False, True]) + def test_keplerian_entry_points_with_nan_time(self, fn, use_fast): + t, y, dy = make_lc(60) + t = t.copy() + t[11] = np.nan + with pytest.raises(ValueError, match=r'\bt\b.*non-finite'): + fn(t, y, dy, use_fast=use_fast) + + +class TestBatchEntryPoints(object): + """The batch APIs validate every light curve and name the bad one.""" + + def test_bls_batch_names_the_bad_lightcurve(self): + good = make_lc(60, seed=1) + bad = list(make_lc(60, seed=2)) + bad[2] = bad[2].copy() + bad[2][5] = 0.0 + with pytest.raises(ValueError, match='lightcurve 1'): + eebls_gpu_batch([good, tuple(bad)], BLS_FREQS) + + def test_tls_batch_names_the_bad_lightcurve(self): + good = make_lc(60, seed=1) + bad = list(make_lc(60, seed=2)) + bad[0] = bad[0].copy() + bad[0][5] = np.nan + with pytest.raises(ValueError, match='lightcurve 1'): + TLS.tls_search_batch([good, tuple(bad)], periods=TLS_PERIODS) + + def test_ls_run_names_the_bad_lightcurve(self): + good = make_lc(60, seed=1) + bad = list(make_lc(60, seed=2)) + bad[1] = bad[1].copy() + bad[1][5] = np.nan + with pytest.raises(ValueError, match='lightcurve 1'): + _ls_proc().run([good, tuple(bad)], freqs=[LS_FREQS] * 2) + + def test_ce_run_names_the_bad_lightcurve(self): + good = make_lc(60, seed=1) + bad = list(make_lc(60, seed=2)) + bad[1] = bad[1].copy() + bad[1][5] = np.inf + with pytest.raises(ValueError, match='lightcurve 1'): + _ce_proc().run([good, tuple(bad)], freqs=BLS_FREQS) + + def test_pdm_run_names_the_bad_lightcurve(self): + good = make_lc(60, seed=1) + bad = list(make_lc(60, seed=2)) + bad[2] = bad[2].copy() + bad[2][5] = -1.0 + with pytest.raises(ValueError, match='lightcurve 1'): + _pdm_proc().run([good, tuple(bad)], freqs=BLS_FREQS) + + def test_pdm_deprecated_format_is_validated(self): + from ..utils import weights + t, y, dy = make_lc(60) + w = weights(dy) + bad_w = w.copy() + bad_w[3] = 0.0 + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError, match='w must be finite'): + _pdm_proc().run([(t, y, bad_w, BLS_FREQS)]) + t_bad = t.copy() + t_bad[3] = np.nan + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError, match=r'\bt\b.*non-finite'): + _pdm_proc().run([(t_bad, y, w, BLS_FREQS)]) + + +class TestSingleBlsScalarGuards(object): + + def test_non_finite_scalars(self): + t, y, dy = make_lc(60) + for kw in ({'freq': np.nan}, {'q': np.nan}, {'phi0': np.nan}): + args = dict(freq=1.0, q=0.1, phi0=0.0) + args.update(kw) + with pytest.raises(ValueError, match='finite'): + single_bls(t, y, dy, **args) + + def test_non_positive_frequency(self): + t, y, dy = make_lc(60) + with pytest.raises(ValueError, match='freq must be > 0'): + single_bls(t, y, dy, 0.0, 0.1, 0.0) + + +class TestNFFTGuards(object): + + def test_nf_must_be_a_positive_integer(self): + t, y, _ = make_lc(30) + for bad in (0, -8, 12.5, np.nan): + with pytest.raises(ValueError, match='nf'): + NFFTAsyncProcess().run([(t, y, bad)]) + + def test_adjoint_scalar_guards(self): + """``nfft_adjoint_async`` gets its light curve through + ``memory``; its own scalars are still checked. A NEGATIVE + ``minimum_frequency`` is legal -- the adjoint transform runs + over modes -nf/2 .. nf/2 -- so only finiteness is required.""" + from ..cunfft import nfft_adjoint_async + for bad in (np.nan, np.inf, -np.inf): + with pytest.raises(ValueError, match='minimum_frequency'): + nfft_adjoint_async(None, None, minimum_frequency=bad) + for bad in (np.nan, 0.0, -2.0): + with pytest.raises(ValueError, match='samples_per_peak'): + nfft_adjoint_async(None, None, samples_per_peak=bad) + + +# -------------------------------------------------------- on a device +# +# These two need a real GPU. They are NOT decorated with +# ``pycuda.tools.mark_cuda_test`` -- like every other BLS test +# (``test_bls.py`` uses none) -- because that decorator runs each test +# in a freshly created, non-primary CUDA context while ``bls.py`` keeps +# a process-wide LRU cache of compiled kernels: a cache entry compiled +# under an earlier test's context raises ``cuFuncSetBlockShape failed: +# invalid resource handle`` when it is reused under a new one. Calling +# the entry points directly runs them in cuvarbase's own primary +# context; on a GPU-less machine ``cuvarbase/tests/conftest.py`` turns +# the resulting ``GPUStubError`` into a skip. + +def test_cuda_context_survives_rejected_calls(): + """The payoff of defect 23. + + Each of these calls used to raise ``cuMemcpyDtoH failed: an illegal + memory access was encountered`` from inside the kernel, which + destroys the process's CUDA context: every subsequent GPU call in + the same interpreter then failed with ``cuMemAlloc failed: an + illegal memory access``, so a single bad light curve in a survey + pipeline poisoned the whole worker. They must now be rejected on + the host, leaving the context untouched. + """ + freq = 1.0 + t, y, dy = make_lc(ndata=400, baseline=20., freq=freq, q=0.12, + depth=0.05, sigma=0.004, seed=5) + freqs = np.linspace(0.6, 1.6, 400) + + # reference periodogram on a healthy context + ref = eebls_gpu_fast(t, y, dy, freqs, qmin=0.03, qmax=0.3) + assert np.all(np.isfinite(ref)) + assert abs(freqs[np.argmax(ref)] - freq) < 0.02 + + t_nan = t.copy() + t_nan[137] = np.nan + qmin_nan = np.full(len(freqs), 0.03) + qmin_nan[10] = np.nan + qmax_nan = np.full(len(freqs), 0.3) + qmax_nan[10] = np.nan + + bad_calls = [ + # per-frequency NaN q bounds on the fast kernel + lambda: eebls_gpu_fast(t, y, dy, freqs, qmin=qmin_nan, + qmax=qmax_nan), + # qmax >= 1 -> nbins0 = 0 -> device divide by zero + lambda: eebls_gpu_fast(t, y, dy, freqs, qmin=0.03, qmax=np.inf), + lambda: eebls_gpu_fast(t, y, dy, freqs, qmin=0.03, qmax=5.0), + # NaN timestamp on the fast kernel + lambda: eebls_gpu_fast(t_nan, y, dy, freqs, qmin=0.03, qmax=0.3), + # the Keplerian wrapper with too few points and with a NaN time + lambda: eebls_transit_gpu(t[:4], y[:4], dy[:4], use_fast=True), + lambda: eebls_transit_gpu(t_nan, y, dy, use_fast=True), + # dy = 0 on the binned and batch kernels + lambda: eebls_gpu(t, y, np.where(np.arange(len(t)) == 3, 0., dy), + freqs, qmin=0.03, qmax=0.3), + lambda: eebls_gpu_batch([(t_nan, y, dy)], freqs, qmin=0.03, + qmax=0.3), + ] + for call in bad_calls: + with pytest.raises(ValueError): + call() + + # ... and the context is still alive and correct, in this module + again = eebls_gpu_fast(t, y, dy, freqs, qmin=0.03, qmax=0.3) + assert np.all(np.isfinite(again)) + assert np.argmax(again) == np.argmax(ref) + assert_allclose(again, ref, rtol=1e-5, atol=1e-7) + + # ... and in another module that allocates its own device memory + proc = LombScargleAsyncProcess() + ls_freqs = (1. / 20.) * (1 + np.arange(512)) + power = np.copy(proc.run([(t, y, dy)], freqs=[ls_freqs])[0][1]) + proc.finish() + assert np.all(np.isfinite(power)) + assert np.all(power >= 0) # -1 sentinel must not appear + assert power.max() > 0.1 + + +def _passthrough_validators(monkeypatch): + """Replace the BLS module's ``check_lightcurve``/``check_freqs`` + with recording pass-throughs, so an entry point runs exactly the + pre-defect-23 computation. Returns the list the pass-throughs + append to, so a test can prove the swap took effect.""" + from .. import bls as bls_module + calls = [] + + def no_check_lightcurve(t, y, dy=None, **kwargs): + calls.append('check_lightcurve') + return t, y, dy + + def no_check_freqs(freqs, **kwargs): + calls.append('check_freqs') + return freqs + + monkeypatch.setattr(bls_module, 'check_lightcurve', no_check_lightcurve) + monkeypatch.setattr(bls_module, 'check_freqs', no_check_freqs) + return calls + + +@pytest.mark.parametrize('dtype', [np.float64, np.float32]) +def test_validators_do_not_mutate_their_arguments(dtype): + """The validators only read. After a call every array is + element-for-element what it was, with its dtype. The entry points + discard the validators' return values, so this -- not the + return-identity check in ``TestCheckLightcurve`` -- is what keeps + the kernels' input untouched.""" + t, y, dy = (a.astype(dtype) + for a in make_lc(ndata=300, baseline=20., seed=11)) + freqs = np.linspace(0.6, 1.6, 256).astype(dtype) + before = [a.copy() for a in (t, y, dy, freqs)] + check_lightcurve(t, y, dy, min_n=2, name='x') + check_freqs(freqs, name='x') + for a, b in zip((t, y, dy, freqs), before): + assert a.dtype == dtype + assert np.array_equal(a, b) + + +@pytest.mark.parametrize('dtype', [np.float64, np.float32]) +def test_valid_input_is_unaffected_by_the_validators_cpu(monkeypatch, + dtype): + """The acceptance criterion of defect 23 ("nothing changes for valid + finite input; results are bit-identical") on a path that needs no + GPU: ``sparse_bls_cpu`` with the validators in place returns + bit-for-bit what it returns with them replaced by pass-throughs, + i.e. the pre-defect-23 computation, for float64 and float32 + input alike. The pass-through run uses pristine copies of the + arrays, so a validator that perturbed its input in place (the + entry points discard the validators' return values, so in-place + mutation is the only way one could perturb the kernels' input) is + caught: the run it touched no longer matches.""" + t, y, dy = (a.astype(dtype) + for a in make_lc(ndata=80, baseline=20., seed=11)) + freqs = np.linspace(0.6, 1.6, 64).astype(dtype) + pristine = [a.copy() for a in (t, y, dy, freqs)] + kwargs = dict(qmin=0.03, qmax=0.3) + power, sols = sparse_bls_cpu(t, y, dy, freqs, **kwargs) + + calls = _passthrough_validators(monkeypatch) + power0, sols0 = sparse_bls_cpu(*pristine, **kwargs) + # the swap took effect: the entry point went through the pass-throughs + assert set(calls) == {'check_lightcurve', 'check_freqs'} + assert np.array_equal(power, power0) + assert np.array_equal(np.asarray(sols), np.asarray(sols0)) + + +def test_valid_input_is_unaffected_by_the_validators(monkeypatch): + """The acceptance criterion of defect 23 on a GPU path: with the + validators replaced by pass-throughs (the pre-defect-23 + computation, run on pristine copies of the arrays as in the CPU + test above) ``eebls_gpu_fast`` returns the same periodogram -- + bit-identical when the kernel is run-to-run deterministic on this + device, otherwise within its own run-to-run float32 + accumulation noise. float32 inputs are still accepted (they used + to reach the kernels untouched, and they still do).""" + t, y, dy = make_lc(ndata=300, baseline=20., seed=11) + freqs = np.linspace(0.6, 1.6, 256) + pristine = [a.copy() for a in (t, y, dy, freqs)] + kwargs = dict(qmin=0.03, qmax=0.3, noverlap=1) + a = eebls_gpu_fast(t, y, dy, freqs, **kwargs) + b = eebls_gpu_fast(t, y, dy, freqs, **kwargs) # the kernel's own noise + assert_allclose(a, b, rtol=1e-6, atol=1e-8) + + calls = _passthrough_validators(monkeypatch) + a0 = eebls_gpu_fast(*pristine, **kwargs) + assert 'check_lightcurve' in calls + assert_allclose(a0, a, rtol=1e-6, atol=1e-8) + if np.array_equal(a, b): + # deterministic on this device: the validators cost nothing + assert np.array_equal(a0, a) + + # float32 inputs are accepted unchanged by the validator + c = eebls_gpu_fast(t.astype(np.float32), y.astype(np.float32), + dy.astype(np.float32), freqs.astype(np.float32), + **kwargs) + assert_allclose(c, a, rtol=1e-3, atol=1e-5) diff --git a/cuvarbase/tests/test_keplerian_relations.py b/cuvarbase/tests/test_keplerian_relations.py new file mode 100644 index 00000000..cbdc34c2 --- /dev/null +++ b/cuvarbase/tests/test_keplerian_relations.py @@ -0,0 +1,75 @@ +"""Keplerian transit-search relations + citation correctness (G1). + +The transit-duration/period relation is Seager & Mallen-Ornelas (2003, +ApJ 585, 1038); the frequency-grid spacing and surface-orbit cutoff are +Ofir (2014, A&A 561, A138, "Optimizing the search for transiting planets +in long time series"). These tests pin the derived ``fmax0`` constant to +its physical value and guard the Ofir citation against the +Hippke & Heller TLS-paper title that earlier drafts pasted onto it. +""" +import glob +import os +import re + +import numpy as np + +import cuvarbase +from cuvarbase.bls import fmax_transit0, q_transit, freq_transit + + +def test_fmax_transit0_is_derived_surface_orbit_frequency(): + # fmax0 is the documented derived constant, NOT a literature value: + # the Keplerian orbital frequency at the stellar surface, + # f = sqrt(G rho_star / 3pi), evaluated at solar mean density. + assert fmax_transit0(rho=1.0) == 8.6307 + + # Validate the derivation from first principles (SI -> cycles/day). + G = 6.674e-11 # m^3 kg^-1 s^-2 + M_sun = 1.989e30 # kg + R_sun = 6.957e8 # m + rho_sun = M_sun / ((4.0 / 3.0) * np.pi * R_sun ** 3) + f_surface = np.sqrt(G * rho_sun / (3.0 * np.pi)) # per second + f_cpd = f_surface * 86400.0 # cycles/day + # within ~1% -- the small offset is the precision of the adopted + # constants (the docs quote 8.612, the code 8.6307; same quantity). + assert abs(f_cpd - 8.6307) / 8.6307 < 0.01 + + # scales as sqrt(rho) + assert np.isclose(fmax_transit0(rho=4.0), 2.0 * 8.6307) + + +def test_q_freq_transit_are_inverses(): + # q_transit and freq_transit implement SM03 eq. (3) and its inverse; + # round-tripping must recover the input across the valid q range. + qs = np.linspace(0.02, 0.45, 25) + f = freq_transit(qs, rho=1.0) + q_back = q_transit(f, rho=1.0) + assert np.allclose(qs, q_back, atol=1e-6) + + +def test_ofir_2014_cited_with_correct_title(): + # Ofir (2014), A&A 561, A138 must carry its real title. Earlier drafts + # pasted Hippke & Heller's TLS-paper title ("...periodic transits of + # small planets") onto the Ofir citation. No cuvarbase source file + # cites H&H by that formal title (they use "Transit Least Squares"), + # so the phrase must not appear in package source at all. + pkg_dir = os.path.dirname(os.path.abspath(cuvarbase.__file__)) + hh_title = "periodic transits of small planets" + ofir_title = "Optimizing the search for transiting planets" + + offenders = [] + cites_ofir = [] + for path in glob.glob(os.path.join(pkg_dir, "*.py")): + # collapse whitespace so wrapped titles still match + flat = re.sub(r"\s+", " ", open(path).read()) + if hh_title in flat: + offenders.append(os.path.basename(path)) + if "A&A 561" in flat or "561, A138" in flat: + cites_ofir.append((os.path.basename(path), flat)) + + assert not offenders, ( + "Hippke & Heller title pasted onto a citation in %s" % offenders) + # and every file that cites Ofir 2014 uses the correct title + for name, flat in cites_ofir: + assert ofir_title in flat, ( + "%s cites A&A 561, A138 without Ofir's correct title" % name) diff --git a/cuvarbase/tests/test_kernel_cache.py b/cuvarbase/tests/test_kernel_cache.py new file mode 100644 index 00000000..d37ebdfc --- /dev/null +++ b/cuvarbase/tests/test_kernel_cache.py @@ -0,0 +1,252 @@ +"""CPU tests for the BLS kernel LRU cache in ``cuvarbase.bls``. + +Ported from ``scripts/test_kernel_cache.py`` (which needed a device and +timed real nvcc compilations). Here the compile functions behind the +cache (``compile_bls``, ``compile_sparse_bls``, ``compile_bls_batch``) +are replaced by counting stubs and ``ensure_context`` by a no-op, so the +cache's contract -- same object on a hit, one compile per key, bounded +size with least-recently-used eviction, no duplicate compiles under +concurrent first use -- is checked without a GPU. The cache itself is +swapped for a fresh ``OrderedDict`` per test so nothing leaks into (or +from) the process-wide cache the other tests share. +""" +import threading +import time +from collections import OrderedDict + +import pytest + +from .. import bls + + +@pytest.fixture +def cache(monkeypatch): + """Isolated cache + counting compile stubs. Returns a namespace with + ``calls`` (list of keys compiled, in order) and ``store`` (the + OrderedDict standing in for ``bls._kernel_cache``).""" + calls = [] + store = OrderedDict() + + def fake_compile_bls(block_size=bls._default_block_size, + function_names=bls._all_function_names, + prepare=True, use_optimized=False, **kwargs): + # a short sleep widens the window in which a second thread could + # race into a duplicate compile if the lock were missing + time.sleep(0.002) + key = (block_size, use_optimized, tuple(sorted(function_names))) + calls.append(key) + return {'key': key, 'prepare': prepare} # fresh object per call + + def fake_compile_sparse_bls(block_size=bls._default_block_size, + **kwargs): + time.sleep(0.002) + calls.append((block_size, 'sparse')) + return {'key': (block_size, 'sparse')} + + def fake_compile_bls_batch(block_size=bls._default_block_size, + **kwargs): + time.sleep(0.002) + calls.append((block_size, 'batch')) + return {'key': (block_size, 'batch')} + + monkeypatch.setattr(bls, 'compile_bls', fake_compile_bls) + monkeypatch.setattr(bls, 'compile_sparse_bls', fake_compile_sparse_bls) + monkeypatch.setattr(bls, 'compile_bls_batch', fake_compile_bls_batch) + monkeypatch.setattr(bls, 'ensure_context', lambda: None) + monkeypatch.setattr(bls, '_kernel_cache', store) + + class NS(object): + pass + ns = NS() + ns.calls = calls + ns.store = store + return ns + + +FN = ['full_bls_no_sol_optimized'] + + +def test_hit_returns_the_same_object_and_compiles_once(cache): + f1 = bls._get_cached_kernels(256, use_optimized=True, function_names=FN) + f2 = bls._get_cached_kernels(256, use_optimized=True, function_names=FN) + assert f1 is f2 + assert len(cache.calls) == 1 + assert len(cache.store) == 1 + # the key is (block_size, use_optimized, sorted function names) + assert (256, True, ('full_bls_no_sol_optimized',)) in cache.store + + # a different block size / kernel variant / function set is a miss + bls._get_cached_kernels(128, use_optimized=True, function_names=FN) + bls._get_cached_kernels(256, use_optimized=False, + function_names=['full_bls_no_sol']) + bls._get_cached_kernels(256, use_optimized=True, + function_names=FN + ['full_bls_no_sol_fused']) + assert len(cache.calls) == 4 + assert len(cache.store) == 4 + + # function-name order does not change the key + f3 = bls._get_cached_kernels( + 256, use_optimized=True, + function_names=['full_bls_no_sol_fused'] + FN) + assert len(cache.calls) == 4 + assert f3['key'][2] == ('full_bls_no_sol_fused', + 'full_bls_no_sol_optimized') + + +def test_default_function_names_use_the_full_list(cache): + f = bls._get_cached_kernels(256) + assert f['key'] == (256, False, tuple(sorted(bls._all_function_names))) + + +def test_sparse_and_batch_routes_share_the_cache_with_distinct_keys(cache): + s1 = bls._get_cached_sparse_kernel(256) + s2 = bls._get_cached_sparse_kernel(256) + b1 = bls._get_cached_batch_kernels(256) + b2 = bls._get_cached_batch_kernels(256) + k = bls._get_cached_kernels(256, use_optimized=False, function_names=FN) + assert s1 is s2 and b1 is b2 + assert s1 is not b1 and k is not s1 + assert cache.calls == [(256, 'sparse'), (256, 'batch'), + (256, False, ('full_bls_no_sol_optimized',))] + assert set(cache.store) == {(256, 'sparse'), (256, 'batch'), + (256, False, ('full_bls_no_sol_optimized',))} + + +def test_cached_compile_bls_routes_through_the_cache(cache): + # the default entry points call compile_bls through this wrapper + a = bls._cached_compile_bls(block_size=128, use_optimized=True, + function_names=FN) + b = bls._cached_compile_bls(block_size=128, use_optimized=True, + function_names=FN) + assert a is b + assert len(cache.calls) == 1 + # prepare=False is the one option the cache does not model: it + # falls through to a direct (uncached) compile every time + c = bls._cached_compile_bls(block_size=128, use_optimized=True, + function_names=FN, prepare=False) + d = bls._cached_compile_bls(block_size=128, use_optimized=True, + function_names=FN, prepare=False) + assert c is not d and c['prepare'] is False + assert len(cache.calls) == 3 + assert len(cache.store) == 1 + + +def _unique_keys(n): + """n distinct (block_size, use_optimized, function_names) keys.""" + keys = [] + block_sizes = [32, 64, 128, 256] + fn_sets = [['full_bls_no_sol_optimized'], ['full_bls_no_sol'], + ['reduction_max'], ['bin_and_phase_fold_bst_multifreq']] + for i in range(n): + bs = block_sizes[i % 4] + opt = bool((i // 4) % 2) + fns = fn_sets[(i // 8) % 4] + keys.append((bs, opt, fns)) + assert len({(bs, opt, tuple(f)) for bs, opt, f in keys}) == n + return keys + + +def test_cache_is_bounded_and_evicts_the_oldest(cache): + max_size = bls._KERNEL_CACHE_MAX_SIZE + assert max_size >= 2 + n_extra = 5 + keys = _unique_keys(max_size + n_extra) + for bs, opt, fns in keys: + bls._get_cached_kernels(bs, opt, fns) + assert len(cache.store) <= max_size + assert len(cache.store) == max_size + assert len(cache.calls) == max_size + n_extra + + as_keys = [(bs, opt, tuple(sorted(f))) for bs, opt, f in keys] + # the n_extra oldest insertions are gone, the rest retained, and the + # OrderedDict order is insertion (= recency) order + for k in as_keys[:n_extra]: + assert k not in cache.store + assert list(cache.store) == as_keys[n_extra:] + + # an evicted key recompiles (and lands at the most-recent end) + bs, opt, fns = keys[0] + bls._get_cached_kernels(bs, opt, fns) + assert len(cache.calls) == max_size + n_extra + 1 + assert list(cache.store)[-1] == as_keys[0] + assert len(cache.store) == max_size + + +def test_eviction_is_least_recently_used_not_fifo(cache): + max_size = bls._KERNEL_CACHE_MAX_SIZE + keys = _unique_keys(max_size + 1) + as_keys = [(bs, opt, tuple(sorted(f))) for bs, opt, f in keys] + # fill exactly to capacity + for bs, opt, fns in keys[:max_size]: + bls._get_cached_kernels(bs, opt, fns) + assert len(cache.store) == max_size + # touch the OLDEST entry: a hit must refresh its recency ... + bs, opt, fns = keys[0] + first = bls._get_cached_kernels(bs, opt, fns) + assert len(cache.calls) == max_size # a hit, no compile + assert list(cache.store)[-1] == as_keys[0] + # ... so the next insertion evicts the SECOND-oldest, not it + bs, opt, fns = keys[max_size] + bls._get_cached_kernels(bs, opt, fns) + assert as_keys[0] in cache.store + assert as_keys[1] not in cache.store + assert len(cache.store) == max_size + assert bls._get_cached_kernels(*keys[0]) is first + + +def _run_threads(n, target): + errors = [] + results = [None] * n + + def worker(i): + try: + results[i] = target(i) + except Exception as e: # pragma: no cover - reported below + errors.append((i, repr(e))) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)] + for th in threads: + th.start() + for th in threads: + th.join() + assert not errors, errors + return results + + +def test_concurrent_first_use_of_one_key_compiles_once(cache): + n = 20 + results = _run_threads( + n, lambda i: bls._get_cached_kernels(128, use_optimized=True, + function_names=FN)) + assert len(cache.calls) == 1, cache.calls + assert len(cache.store) == 1 + assert all(r is results[0] for r in results) + + +def test_concurrent_mixed_keys_compile_each_key_once(cache): + # 10 threads x 5 lookups over 4 distinct block sizes, with heavy + # overlap between threads: exactly one compile per distinct key, + # cache within bounds, every thread sees the cached object + sizes = [32, 64, 128, 256, 32] + per_thread = [(sizes * 2)[i % 5:i % 5 + 5] for i in range(10)] + + def target(i): + return [bls._get_cached_kernels(bs, use_optimized=True, + function_names=FN) + for bs in per_thread[i]] + + results = _run_threads(10, target) + distinct = {bs for row in per_thread for bs in row} + assert len(cache.calls) == len(distinct), cache.calls + assert len(cache.store) == len(distinct) <= bls._KERNEL_CACHE_MAX_SIZE + by_bs = {} + for row, objs in zip(per_thread, results): + for bs, obj in zip(row, objs): + assert by_bs.setdefault(bs, obj) is obj + + +def test_lock_is_a_real_lock_and_the_cache_an_ordered_dict(): + # the process-wide objects the routes above rely on + assert isinstance(bls._kernel_cache, OrderedDict) + assert hasattr(bls._kernel_cache_lock, 'acquire') + assert bls._KERNEL_CACHE_MAX_SIZE == 20 diff --git a/cuvarbase/tests/test_kernel_drift.py b/cuvarbase/tests/test_kernel_drift.py new file mode 100644 index 00000000..e1f88ed5 --- /dev/null +++ b/cuvarbase/tests/test_kernel_drift.py @@ -0,0 +1,393 @@ +""" +Single-source guard for the BLS kernel files. + +``bls.cu`` and ``bls_optimized.cu`` share most of their device/global +functions. The duplication once shipped a silent-wrong-results bug (the +``reduction_max`` s>32 candidate drop was originally fixed in only one +copy -- commit 77b4333), so the shared functions now live in a single +file, ``bls_common.cuh``, which both kernels inline via the +``//{INCLUDE bls_common.cuh}`` directive (expanded by +``utils._module_reader`` at load time). + +These tests assert the include mechanism instead of comparing two copies: +- both kernels carry the include directive, +- the shared functions are defined once (in bls_common.cuh) and never + redefined in either .cu file, so drift is structurally impossible, +- the directive really expands (so both kernels see the shared bodies), +- the intentionally-divergent functions still live in each .cu file, +- and any function name defined in BOTH .cu files (a helper duplicated + instead of moved to the header) must have an identical normalized + body -- the cross-file comparison the Jul-2026 audit found missing: + without it, a same-name helper added to both files could drift again + exactly like the original reduction_max bug. Only ``reduction_max`` + itself is exempt (divergent by design). + +The last two checks are generalized to EVERY kernel file +(``kernels/*.cu`` and ``*.cuh``): a ``#define NAME value`` that appears +in more than one file must carry the same value(s) everywhere, and a +``__device__``/``__global__`` function defined in more than one file +must have one body, with the intentionally divergent copies listed +explicitly. The Sep 2026 audit (defect 20) found +``sparse_bls_simple.cu`` still carrying ``MAX_W_COMPLEMENT 1E-9`` after +PR #65 had set 1E-4 in ``sparse_bls.cu`` (powers up to 4.6 in pure +noise on the opt-in kernel); the define check would have caught it. + +The function extractor accepts a definition wherever its qualifier +appears on the line -- indented, or behind ``extern "C"``, ``static``, +``inline``, ``__forceinline__`` or a one-line ``template<...>`` -- so +the ``extern "C" __global__`` TLS kernels are covered too (the +column-0 anchor the guard first shipped with skipped them); a name +defined more than once in one file keeps every body. Both guards are +proved to bite on a mutated scratch copy of the kernel directory +(:func:`test_guards_bite_on_a_mutated_copy`). +""" +import glob +import os +import re + +from cuvarbase.utils import find_kernel, _module_reader + +# Functions that are *supposed* to differ between the two files and so +# stay out of the shared header. +INTENTIONALLY_DIVERGENT = { + # full tree reduction (bls.cu) vs tree-to-warp + shuffle + # (bls_optimized.cu, including the s >= 32 fix from 72ae029) + 'reduction_max', + # interleaved [yw, w] shared layout (bls.cu) vs separate arrays + # (bls_optimized.cu) + 'full_bls_no_sol', + 'full_bls_no_sol_optimized', +} + +INCLUDE_DIRECTIVE = '//{INCLUDE bls_common.cuh}' + +# Cross-file duplicated functions whose divergence is intentional: the +# named FILES hold a sanctioned variant and are excluded from the +# body comparison for that name; every other copy must still be +# identical. Keep this list short and justified. +INTENTIONALLY_DIVERGENT_COPIES = { + # full tree reduction (bls.cu) vs tree-to-warp + shuffle + # (bls_optimized.cu); see INTENTIONALLY_DIVERGENT above + 'reduction_max': {'bls.cu', 'bls_optimized.cu'}, + # cunfft.cu const-qualifies the parameters (CONSTANT int); the + # arithmetic is the same as bls_common.cuh's mod() + 'mod': {'cunfft.cu'}, + # ce.cu is the FLT (float-or-double) variant using floor(); + # tls.cu declares it inline with a different parameter name. The + # float copies in bls_common.cuh and sparse_bls.cu must agree. + 'mod1': {'ce.cu', 'tls.cu'}, +} + +# #define names whose values legitimately differ between files (none +# today: MIN_W was a dead define in bls.cu/bls_optimized.cu -- the +# shared bls_value uses literals -- and was deleted rather than +# whitelisted). Map name -> set of files allowed to disagree. +INTENTIONALLY_DIVERGENT_DEFINES = {} + +# An INCLUDE directive standing on its own line (the form _module_reader +# expands). Anchored so it ignores prose that merely mentions the +# directive inside a comment. +_DIRECTIVE_LINE = re.compile(r"^[ \t]*//\{INCLUDE\s", re.M) + + +def _common_path(): + return os.path.join(os.path.dirname(find_kernel('bls')), + 'bls_common.cuh') + + +# A __device__/__global__ definition (or prototype) header. The +# qualifier may be indented and may follow ``extern "C"``, ``static``, +# ``inline``, ``__forceinline__``/``__noinline__`` or a one-line +# ``template<...>``, and several CUDA qualifiers may be chained +# (``__host__ __device__``). ``[^\n{;]*?`` keeps the match on one +# line and stops at a body or a prototype's ``;``. +_FUNC_DEF = re.compile( + r"^[ \t]*(?:(?:extern\s+\"C\"|static|inline|__forceinline__|" + r"__noinline__|template\s*<[^>\n]*>)\s+)*" + r"__(?:device|global|host)__" + r"(?:\s+__(?:device|global|host|forceinline__|noinline__)__)*" + r"[^\n{;]*?(\w+)\s*\(", re.M) + + +def _func_names(src): + """Names of every __device__/__global__ function defined in ``src``.""" + return set(_FUNC_DEF.findall(src)) + + +def _strip_comments(src): + src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S) + src = re.sub(r"//[^\n]*", " ", src) + return src + + +def _func_bodies(src): + """Map name -> frozenset of normalized sources (signature + + brace-matched body) for every __device__/__global__ function + defined in ``src``. A name defined more than once in the file + (e.g. in both branches of an ``#ifdef``) keeps every body; + prototypes (``;`` before any ``{``) are skipped.""" + src = _strip_comments(src) + bodies = {} + for m in _FUNC_DEF.finditer(src): + name = m.group(1) + open_brace = src.find('{', m.end()) + semicolon = src.find(';', m.end()) + if open_brace < 0 or 0 <= semicolon < open_brace: + continue + depth, i = 1, open_brace + 1 + while i < len(src) and depth: + if src[i] == '{': + depth += 1 + elif src[i] == '}': + depth -= 1 + i += 1 + # normalize whitespace so formatting-only differences don't count + text = ' '.join(src[m.start():i].split()) + bodies[name] = bodies.get(name, frozenset()) | {text} + return bodies + + +def test_both_kernels_inline_the_shared_header(): + for name in ('bls', 'bls_optimized'): + raw = open(find_kernel(name)).read() + assert _DIRECTIVE_LINE.search(raw), ( + "%s.cu must inline the shared functions via %r on its own line" + % (name, INCLUDE_DIRECTIVE)) + + +def test_shared_functions_defined_once_in_common_header(): + common = open(_common_path()).read() + shared = _func_names(common) + # the shared surface should not silently shrink + assert len(shared) >= 12, sorted(shared) + + # none of the shared functions may be redefined in either .cu file -- + # that is the only way they could drift again. + for name in ('bls', 'bls_optimized'): + local = _func_names(open(find_kernel(name)).read()) + clash = shared & local + assert not clash, ( + "%s.cu redefines shared function(s) %s already provided by " + "bls_common.cuh -- delete the local copy so they cannot drift" + % (name, sorted(clash))) + + +def test_include_directive_expands_shared_bodies(): + # _module_reader must inline the header so nvcc sees the shared + # bodies; check a representative shared function appears in both + # assembled sources. + shared = _func_names(open(_common_path()).read()) + for name in ('bls', 'bls_optimized'): + assembled = _module_reader(find_kernel(name), + cpp_defs=dict(BLOCK_SIZE=256)) + assert not _DIRECTIVE_LINE.search(assembled), ( + "%s: include directive line was not expanded" % name) + missing = shared - _func_names(assembled) + assert not missing, ( + "%s: shared function(s) %s missing after include expansion" + % (name, sorted(missing))) + + +def test_intentionally_divergent_functions_live_in_the_cu_files(): + std = _func_names(open(find_kernel('bls')).read()) + opt = _func_names(open(find_kernel('bls_optimized')).read()) + assert 'reduction_max' in std and 'reduction_max' in opt + assert 'full_bls_no_sol' in std + assert 'full_bls_no_sol_optimized' in opt + + +def test_no_cross_file_drift_of_duplicated_functions(): + # A helper defined in BOTH .cu files (rather than moved into + # bls_common.cuh) is a fresh drift hazard the header mechanism + # cannot see. Any such duplicate must be byte-identical after + # comment stripping + whitespace normalization. reduction_max is + # the one sanctioned divergence (tree reduction vs warp shuffle). + std = _func_bodies(open(find_kernel('bls')).read()) + opt = _func_bodies(open(find_kernel('bls_optimized')).read()) + + duplicated = (set(std) & set(opt)) - {'reduction_max'} + drifted = sorted(name for name in duplicated + if std[name] != opt[name]) + assert not drifted, ( + "function(s) %s are defined in BOTH bls.cu and bls_optimized.cu " + "with differing bodies -- move the shared implementation into " + "bls_common.cuh (or, if the divergence is intentional, rename " + "or whitelist it here) so the copies cannot silently drift" + % drifted) + + # the guard itself must stay exercised: reduction_max is the known + # duplicated-and-divergent pair, so the extractor must see it in + # both files (guards against the regex/brace-matcher going stale) + assert 'reduction_max' in std and 'reduction_max' in opt + assert std['reduction_max'] != opt['reduction_max'] + + +# -------------------------------------------------------------------- +# all kernel files +# -------------------------------------------------------------------- + +def _all_kernel_files(): + kdir = os.path.dirname(find_kernel('bls')) + files = sorted(glob.glob(os.path.join(kdir, '*.cu')) + + glob.glob(os.path.join(kdir, '*.cuh'))) + assert len(files) >= 10, files + return files + + +_DEFINE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+(\w+(?:\([^)]*\))?)" + r"(?:[ \t]+(.*?))?[ \t]*$", re.M) + + +def _defines(src): + """name -> set of values defined for it in ``src`` (a name defined + in both branches of an #ifdef, e.g. FLT double/float, yields both + values; the SET must then agree across files).""" + src = re.sub(r"/\*.*?\*/", " ", src, flags=re.S) + out = {} + for m in _DEFINE.finditer(src): + value = re.sub(r"//.*$", "", m.group(2) or "").strip() + out.setdefault(m.group(1), set()).add(' '.join(value.split())) + return out + + +def _define_drift(paths): + """(drifted, per_name) for the #defines of ``paths``: ``drifted`` + lists ``(name, {file: sorted values})`` for every name whose value + set differs between two non-whitelisted files.""" + per_name = {} + for path in paths: + for name, values in _defines(open(path).read()).items(): + per_name.setdefault(name, {})[os.path.basename(path)] = values + + drifted = [] + for name, per_file in sorted(per_name.items()): + files = {f: v for f, v in per_file.items() + if f not in INTENTIONALLY_DIVERGENT_DEFINES.get(name, ())} + if len(files) < 2: + continue + if len(set(frozenset(v) for v in files.values())) > 1: + drifted.append((name, {f: sorted(v) for f, v in files.items()})) + return drifted, per_name + + +def _function_drift(paths): + """(drifted, per_name) for the __device__/__global__ functions of + ``paths``: ``drifted`` lists ``(name, sorted files)`` for every name + whose bodies differ between two non-whitelisted files.""" + per_name = {} + for path in paths: + for name, body in _func_bodies(open(path).read()).items(): + per_name.setdefault(name, {})[os.path.basename(path)] = body + + drifted = [] + for name, per_file in sorted(per_name.items()): + copies = {f: b for f, b in per_file.items() + if f not in INTENTIONALLY_DIVERGENT_COPIES.get(name, ())} + if len(copies) >= 2 and len(set(copies.values())) > 1: + drifted.append((name, sorted(copies))) + return drifted, per_name + + +def test_same_named_defines_agree_across_all_kernel_files(): + drifted, per_name = _define_drift(_all_kernel_files()) + assert not drifted, ( + "#define(s) with different values in different kernel files " + "(the MAX_W_COMPLEMENT 1E-9 vs 1E-4 drift of sparse_bls_simple.cu " + "was exactly this): %s -- use one value, or move the constant " + "into a shared header" % drifted) + + # the guard itself must see the shared constants it protects + assert 'MAX_W_COMPLEMENT' in per_name and 'RESTRICT' in per_name + assert len(per_name['RESTRICT']) >= 5 + + +def test_no_cross_file_drift_of_duplicated_functions_in_any_kernel(): + drifted, per_name = _function_drift(_all_kernel_files()) + assert not drifted, ( + "function(s) defined in several kernel files with differing " + "bodies: %s -- share one implementation (bls_common.cuh-style " + "include) or list the sanctioned variant in " + "INTENTIONALLY_DIVERGENT_COPIES with a reason" % drifted) + + # every whitelisted entry must still correspond to a real duplicate + # (a stale whitelist would hide a future rename) + for name, files in INTENTIONALLY_DIVERGENT_COPIES.items(): + assert name in per_name and len(per_name[name]) >= 2, name + assert files <= set(per_name[name]), (name, files, + sorted(per_name[name])) + # the extractor sees the known duplicates + assert {'get_id', 'mod1', 'atomicAddDouble'} <= set( + n for n, d in per_name.items() if len(d) >= 2) + # ... and the ``extern "C" __global__`` kernels the column-0 anchor + # of the first version of this guard could not see (review finding + # on 398cd60): a copy of one of these drifting in another file must + # be caught like any other. + assert set(per_name['tls_search_kernel']) == {'tls.cu'} + assert set(per_name['tls_search_kernel_keplerian']) == {'tls.cu'} + assert set(per_name['tls_fast_search_kernel']) == {'tls_fast.cu'} + assert set(per_name['tls_refine_kernel']) == {'tls_fast.cu'} + # every name is a definition, never a prototype: each body is braced + for name, per_file in per_name.items(): + for file, bodies in per_file.items(): + assert all(b.endswith('}') for b in bodies), (name, file) + + +def test_extractor_accepts_prefixed_and_indented_qualifiers(): + src = """ +extern "C" __global__ void k_extern(int a) { return; } + __device__ int k_indented(int a) { return a; } +static __device__ __forceinline__ float k_static(float x) { return x; } +inline __device__ float k_inline(float x) { return x; } +template __device__ T k_template(T x) { return x; } +__host__ __device__ int k_host_device(int a) { return a; } +__device__ int k_prototype(int a); +""" + names = _func_names(src) + assert names == {'k_extern', 'k_indented', 'k_static', 'k_inline', + 'k_template', 'k_host_device', 'k_prototype'} + bodies = _func_bodies(src) + assert set(bodies) == names - {'k_prototype'} # prototype skipped + assert bodies['k_extern'] == { + 'extern "C" __global__ void k_extern(int a) { return; }'} + + +def test_guards_bite_on_a_mutated_copy(tmp_path): + """Proof that both all-kernel guards detect real drift: on a scratch + copy of the kernel directory, re-create defect 20 (a second sparse + kernel file whose ``MAX_W_COMPLEMENT`` disagrees) and add a + divergent copy of an ``extern "C" __global__`` kernel (indented, + to exercise both blind spots of the original extractor) and check + that exactly those two names are reported.""" + import shutil + live = _all_kernel_files() + copies = [] + for path in live: + dst = tmp_path / os.path.basename(path) + shutil.copy(path, dst) + copies.append(str(dst)) + assert not _define_drift(copies)[0] + assert not _function_drift(copies)[0] + + # 1. the sparse_bls_simple.cu drift of defect 20, re-created: a + # second file with the same functions but the stale define + src = (tmp_path / 'sparse_bls.cu').read_text() + assert re.search(r'^#define MAX_W_COMPLEMENT 1E-4$', src, re.M) + simple = tmp_path / 'sparse_bls_simple.cu' + simple.write_text(re.sub(r'^(#define MAX_W_COMPLEMENT )\S+$', + r'\g<1>1E-9', src, count=1, flags=re.M)) + copies.append(str(simple)) + drifted, _ = _define_drift(copies) + assert [name for name, _ in drifted] == ['MAX_W_COMPLEMENT'], drifted + # identical function copies are not drift + assert not _function_drift(copies)[0] + + # 2. a divergent copy of an extern "C" kernel in another file + (body,) = _func_bodies((tmp_path / 'tls.cu').read_text())[ + 'tls_search_kernel'] + assert body.startswith('extern "C" __global__ void tls_search_kernel(') + mutant = body[:-1] + ' int drift_mutant = 1; }' + with open(tmp_path / 'tls_fast.cu', 'a') as f: + f.write('\n ' + mutant + '\n') + drifted, per_name = _function_drift(copies) + assert [name for name, _ in drifted] == ['tls_search_kernel'], drifted + assert set(per_name['tls_search_kernel']) == {'tls.cu', 'tls_fast.cu'} diff --git a/cuvarbase/tests/test_kernel_inventory.py b/cuvarbase/tests/test_kernel_inventory.py new file mode 100644 index 00000000..0d2efad9 --- /dev/null +++ b/cuvarbase/tests/test_kernel_inventory.py @@ -0,0 +1,125 @@ +""" +Orphan-kernel guard: the packaged CUDA sources and the Python loaders +must agree. + +``cuvarbase/kernels/wavelet.cu`` shipped in every 0.2.x wheel although +nothing loaded it (Sep 2026 audit, findings 19/61/88). This test keeps +that from happening again, in both directions: + +- every ``kernels/*.cu`` stem must appear as a quoted string literal in + some ``cuvarbase/*.py`` module, i.e. something hands it to + :func:`cuvarbase.utils.find_kernel` (``bls.py``/``tls.py`` pick the + stem into a local first, so the literal is what is checked, not the + ``find_kernel('...')`` call form); +- every ``find_kernel('')`` literal must resolve to an existing + ``kernels/.cu`` file; +- every ``//{INCLUDE }`` directive in a kernel must resolve, and + every packaged ``*.cuh`` must be included by at least one kernel. + +The checks read the installed package (``cuvarbase.__file__``), so they +run unchanged under ``pytest --pyargs cuvarbase`` against a wheel and +double as a package-data check there. Pure CPU; no pycuda needed. +""" +import glob +import os +import re + +import cuvarbase +import cuvarbase.utils as utils + +PKG_DIR = os.path.dirname(os.path.abspath(cuvarbase.__file__)) +KERNEL_DIR = os.path.join(PKG_DIR, 'kernels') + +# ``find_kernel('stem')`` / ``find_kernel("stem")`` with a literal argument. +_FIND_KERNEL_LITERAL = re.compile(r"""find_kernel\(\s*['"]([A-Za-z0-9_]+)['"]\s*\)""") + +# Stems that are loaded via ``find_kernel``, as of 1.0.0. Kept explicit so +# a renamed kernel file shows up as a failure with a clear message rather +# than as a silent change in the inventory. +EXPECTED_STEMS = { + 'bls', 'bls_optimized', 'bls_batch', 'sparse_bls', + 'ce', 'cunfft', 'lomb', 'nufft_lrt', 'pdm', + 'tls', 'tls_fast', 'tls_reference', 'tls_reference_prepare', + 'tls_reference_short_prefix', 'tls_reference_experimental', +} + + +def _package_sources(): + """(path, text) for every .py module in the package (tests excluded).""" + out = [] + for path in sorted(glob.glob(os.path.join(PKG_DIR, '*.py')) + + glob.glob(os.path.join(PKG_DIR, '*', '*.py'))): + if os.sep + 'tests' + os.sep in path: + continue + with open(path, 'r') as f: + out.append((path, f.read())) + return out + + +def _kernel_stems(): + return sorted(os.path.splitext(os.path.basename(p))[0] + for p in glob.glob(os.path.join(KERNEL_DIR, '*.cu'))) + + +def _header_names(): + return sorted(os.path.basename(p) + for p in glob.glob(os.path.join(KERNEL_DIR, '*.cuh'))) + + +def test_kernels_directory_is_packaged(): + assert os.path.isdir(KERNEL_DIR), KERNEL_DIR + assert _kernel_stems(), "no *.cu files packaged in %s" % KERNEL_DIR + + +def test_kernel_inventory_matches_expected(): + assert set(_kernel_stems()) == EXPECTED_STEMS, ( + "kernels/*.cu inventory changed; update EXPECTED_STEMS (and the " + "loader) deliberately. packaged=%r" % _kernel_stems()) + + +def test_every_kernel_file_is_referenced_by_a_loader(): + """No orphan kernels: each *.cu stem is a quoted literal in cuvarbase/*.py.""" + sources = _package_sources() + orphans = [] + for stem in _kernel_stems(): + literal = re.compile(r"""['"]%s['"]""" % re.escape(stem)) + if not any(literal.search(text) for _, text in sources): + orphans.append(stem) + assert not orphans, ( + "kernel file(s) shipped but never loaded (no quoted %r literal in " + "any cuvarbase/*.py): %r" % ('', orphans)) + + +def test_every_find_kernel_literal_has_a_file(): + """Every find_kernel('') literal resolves to a packaged file.""" + literals = set() + for path, text in _package_sources(): + literals.update(_FIND_KERNEL_LITERAL.findall(text)) + assert literals, "no find_kernel('...') literals found in the package" + missing = [stem for stem in sorted(literals) + if not os.path.isfile(utils.find_kernel(stem))] + assert not missing, "find_kernel literal(s) without a .cu file: %r" % missing + assert literals <= EXPECTED_STEMS, ( + "find_kernel literal(s) not in EXPECTED_STEMS: %r" + % sorted(literals - EXPECTED_STEMS)) + + +def test_find_kernel_resolves_every_expected_stem(): + for stem in sorted(EXPECTED_STEMS): + path = utils.find_kernel(stem) + assert os.path.isfile(path), path + assert os.path.getsize(path) > 0, path + + +def test_include_directives_resolve_and_headers_are_used(): + """//{INCLUDE x} targets exist; every packaged .cuh is included somewhere.""" + included = set() + for path in glob.glob(os.path.join(KERNEL_DIR, '*.cu')): + with open(path, 'r') as f: + text = f.read() + for target in utils._INCLUDE_RE.findall(text): + included.add(target) + assert os.path.isfile(os.path.join(KERNEL_DIR, target)), ( + "%s includes missing file %s" % (os.path.basename(path), target)) + unused = sorted(set(_header_names()) - included) + assert not unused, "packaged .cuh never //{INCLUDE}d by any kernel: %r" % unused diff --git a/cuvarbase/tests/test_lazy_imports.py b/cuvarbase/tests/test_lazy_imports.py new file mode 100644 index 00000000..3440e1da --- /dev/null +++ b/cuvarbase/tests/test_lazy_imports.py @@ -0,0 +1,147 @@ +"""Lazy-import contract: `import cuvarbase` and every public process +must import even when scikit-cuda is broken/absent. As of v1.0 the cuFFT +binding is in-house (`cuvarbase._cufft`), so NO cuvarbase module imports +scikit-cuda anymore -- not even Lomb-Scargle/NFFT (they need libcufft +only when a transform actually runs).""" +import os +import subprocess +import sys + +import pytest + +_SCRIPT = r""" +import sys, types +for name in ['pycuda', 'pycuda.autoprimaryctx', 'pycuda.autoinit', + 'pycuda.driver', 'pycuda.gpuarray', 'pycuda.compiler', + 'pycuda.tools']: + sys.modules[name] = types.ModuleType(name) +sys.modules['pycuda.compiler'].SourceModule = object + +class _BrokenSkcudaFinder: + def find_module(self, fullname, path=None): + if fullname.startswith('skcuda'): + return self + def load_module(self, fullname): + raise ImportError('simulated scikit-cuda failure') +sys.meta_path.insert(0, _BrokenSkcudaFinder()) + +import cuvarbase +from cuvarbase import bls +assert callable(bls.eebls_gpu) +from cuvarbase import ConditionalEntropyAsyncProcess +assert cuvarbase.BLSMemory is bls.BLSMemory +# Since v1.0 the cuFFT binding is in-house, so Lomb-Scargle no longer +# imports scikit-cuda: accessing it must succeed even with skcuda broken. +assert callable(cuvarbase.LombScargleAsyncProcess), \ + 'LombScargleAsyncProcess should import without scikit-cuda' +assert callable(cuvarbase.NFFTAsyncProcess), \ + 'NFFTAsyncProcess should import without scikit-cuda' +print('OK') +""" + + +def test_import_survives_broken_skcuda(): + repo_root = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + result = subprocess.run( + [sys.executable, '-c', _SCRIPT], + cwd=repo_root, capture_output=True, text=True, timeout=120) + assert result.returncode == 0, result.stderr + assert 'OK' in result.stdout + + +_IMPORT_WITHOUT_PYCUDA = r""" +# `import cuvarbase` must require neither pycuda nor a CUDA context: the +# primary context is now created lazily on first GPU use, not at import. +import builtins +_orig_import = builtins.__import__ + + +def _blocked(name, *args, **kwargs): + if name == 'pycuda' or name.startswith('pycuda.'): + raise ImportError('pycuda blocked for this test') + return _orig_import(name, *args, **kwargs) + + +builtins.__import__ = _blocked + +import cuvarbase +assert cuvarbase.__version__ +# a pure-CPU utility must be reachable without pycuda +from cuvarbase.utils import weights # noqa: F401 +print('OK') +""" + +_NO_CONTEXT_UNTIL_GPU_USE = r""" +import sys, types +import numpy as np + +# Harmless pycuda stubs so the GPU modules import without a real GPU. +for name in ['pycuda', 'pycuda.driver', 'pycuda.gpuarray', + 'pycuda.compiler', 'pycuda.tools']: + sys.modules[name] = types.ModuleType(name) +sys.modules['pycuda.compiler'].SourceModule = object +# A fake retained-context module so ensure_context() succeeds off-GPU. +_autoctx = types.ModuleType('pycuda.autoprimaryctx') +_autoctx.device = object() +_autoctx.context = object() +sys.modules['pycuda.autoprimaryctx'] = _autoctx + +import cuvarbase +from cuvarbase import bls +from cuvarbase.base import context as ctxmod, ensure_context + +# Importing the package + the BLS module must NOT have created a context. +assert ctxmod._autoctx is None, 'CUDA context created at import time' + +# A CPU-only helper must run without creating a context. +t = np.linspace(0, 10, 50) +y = np.sin(2 * np.pi * t) +dy = 0.1 * np.ones_like(t) +bls.single_bls(t, y, dy, 1.0, 0.1, 0.0) +assert ctxmod._autoctx is None, 'CPU helper created a CUDA context' + +# First explicit GPU use retains the context (and caches it). +ensure_context() +assert ctxmod._autoctx is _autoctx, 'ensure_context did not retain the context' +print('OK') +""" + + +def _run_in_subprocess(script): + repo_root = os.path.dirname(os.path.dirname( + os.path.dirname(os.path.abspath(__file__)))) + return subprocess.run( + [sys.executable, '-c', script], + cwd=repo_root, capture_output=True, text=True, timeout=120) + + +def test_import_cuvarbase_without_pycuda(): + result = _run_in_subprocess(_IMPORT_WITHOUT_PYCUDA) + assert result.returncode == 0, result.stderr + assert 'OK' in result.stdout + + +def test_no_cuda_context_until_first_gpu_use(): + result = _run_in_subprocess(_NO_CONTEXT_UNTIL_GPU_USE) + assert result.returncode == 0, result.stderr + assert 'OK' in result.stdout + + +def test_nufft_lrt_quarantined(): + # NUFFT-LRT (contributed by Jamila Taaki / @xiaziyna) ships in v1.0 + # QUARANTINED (release decision D1): importable as + # ``cuvarbase.nufft_lrt`` but outside the frozen top-level namespace + # and the 1.x stability promise until its re-validation (Phase 4). + import cuvarbase + import cuvarbase.nufft_lrt as nufft_lrt + assert 'NUFFTLRTAsyncProcess' not in cuvarbase.__all__ + assert 'NUFFTLRTMemory' not in cuvarbase.__all__ + assert 'NUFFTLRTAsyncProcess' not in cuvarbase._LAZY_ATTRS + with pytest.raises(AttributeError): + cuvarbase.NUFFTLRTAsyncProcess + with pytest.raises(AttributeError): + cuvarbase.NUFFTLRTMemory + assert cuvarbase.nufft_lrt is nufft_lrt + assert callable(nufft_lrt.NUFFTLRTAsyncProcess) + assert callable(nufft_lrt.NUFFTLRTMemory) diff --git a/cuvarbase/tests/test_lombscargle.py b/cuvarbase/tests/test_lombscargle.py index 623323fb..cd00c0b6 100644 --- a/cuvarbase/tests/test_lombscargle.py +++ b/cuvarbase/tests/test_lombscargle.py @@ -1,10 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from builtins import zip -from builtins import range -from builtins import object import numpy as np import pytest @@ -12,13 +5,19 @@ from astropy.timeseries import LombScargle from ..lombscargle import LombScargleAsyncProcess -from pycuda.tools import mark_cuda_test -#import pycuda.autoinit -import pycuda.autoprimaryctx +# NOT `import pycuda.autoprimaryctx`/`autoinit` here: cuvarbase retains +# the primary context itself, lazily (cuvarbase.base.ensure_context). spp = 3 nfac = 3 -lsrtol = 1E-2 -lsatol = 1E-2 +# Tolerances vs astropy / between GPU paths. Before the Sep-2026 NFFT +# fixes (psi tables shared between differently-sized grids, grids sized +# without k0) these had to be 1e-2 -- the default path carried a +# 3e-3..2e-2 bias. The fixed float32 path is at ~3e-6 on the problems in +# this file (measured on an A40; float32 ~2e-4 at survey-scale f*T, see +# TestLombScargleAccuracy), so 1e-4 is a 30x margin here and would have +# failed on the old code. +lsrtol = 1E-4 +lsatol = 1E-4 nfft_sigma = 5 rand = np.random.RandomState(100) @@ -113,6 +112,65 @@ def test_ls_kernel_direct_sums(self): assert_similar(power, pgpu) + def test_ls_kernel_direct_sums_double_pi(self): + """Regression test for the float32 PI literal in lomb.cu + (Jul 2026 kernel-hygiene pass; same defect class as the + cunfft.cu A3 fix). With a float32 PI, the double-precision + direct-sums kernels evaluate the periodogram on a frequency + axis stretched by 1 + 2.8e-8; at f*T ~ 3000 (f ~ 100 c/d, + T = 30 d) that shows up as ~1e-4 absolute power errors against + a float64 CPU port of the kernel. The fixed kernel matches the + port to float64 roundoff (measured 1.1e-10 on an A5000; the + buggy kernel measured 1.2e-4).""" + T, n, f0 = 30.0, 200, 97.0 + rng = np.random.RandomState(7) + t = np.sort(rng.rand(n)) * T + 4.5 + y = 0.3 * np.cos(2 * np.pi * f0 * t) + 12.0 + y += 0.1 * rng.randn(n) + err = 0.1 * (0.8 + 0.4 * rng.rand(n)) + + df = 1.0 / (5 * T) + k0 = int(round(95.0 / df)) + freqs = df * (k0 + np.arange(600)) + + ls_proc = LombScargleAsyncProcess(use_double=True, + sigma=nfft_sigma) + results = ls_proc.run([(t, y, err)], freqs=freqs, use_fft=False) + ls_proc.finish() + pgpu = np.asarray(results[0][1][:len(freqs)], dtype=np.float64) + + # float64 CPU port of lomb_dirsum (FLOATING_MEAN mode, exact + # np.pi, same phase convention: phi = (t + 0.5) * f * 2 * pi). + # run() mean-centers t and y in float64 first; mirror that. + tc = np.asarray(t, dtype=np.float64) - np.nanmean(t) + yc = np.asarray(y, dtype=np.float64) - np.nanmean(y) + w = np.power(np.asarray(err, dtype=np.float64), -2) + w /= np.sum(w) + ybar = np.dot(w, yc) + yw = w * (yc - ybar) + YY = np.dot(w, (yc - ybar) ** 2) + + tp = tc + 0.5 + pref = np.empty(len(freqs)) + for i, f in enumerate(freqs): + arg1 = tp * f * 2.0 * np.pi + arg2 = tp * (2.0 * f) * 2.0 * np.pi + C, S = np.dot(w, np.cos(arg1)), np.dot(w, np.sin(arg1)) + C2, S2 = np.dot(w, np.cos(arg2)), np.dot(w, np.sin(arg2)) + YCh, YSh = np.dot(yw, np.cos(arg1)), np.dot(yw, np.sin(arg1)) + tan2wt = (S2 - 2 * S * C) / (C2 - (C * C - S * S)) + C2w = 1.0 / np.sqrt(1.0 + tan2wt ** 2) + S2w = tan2wt * C2w + Cw = np.sqrt(0.5 * (1.0 + C2w)) + Sw = np.sqrt(0.5 * (1.0 - C2w)) * (-1.0 if S2w < 0 else 1.0) + Cshft, Sshft = C * Cw + S * Sw, S * Cw - C * Sw + CC = 0.5 * (1.0 + C2 * C2w + S2 * S2w) - Cshft ** 2 + SS = 0.5 * (1.0 - C2 * C2w - S2 * S2w) - Sshft ** 2 + YC, YS = YCh * Cw + YSh * Sw, YSh * Cw - YCh * Sw + pref[i] = (YC * YC / CC + YS * YS / SS) / YY + + assert np.max(np.abs(pgpu - pref)) < 1e-7 + def test_ls_kernel_direct_sums_is_consistent(self): t, y, err = data() ls_proc = LombScargleAsyncProcess(use_double=False, @@ -245,3 +303,1631 @@ def test_batched_run_const_nfreq(self, make_plot=False, ndatas=27, assert_allclose(pnb, pb, rtol=lsrtol, atol=lsatol) assert_allclose(fnb, fb, rtol=lsrtol, atol=lsatol) + + +def _realistic_lc(N=300, T=365.0, f0=3.1, seed=1): + """Ground-based-like lightcurve: N points over T days, mag-scale + y, heteroscedastic dy, one sinusoid at f0 (cycles/day).""" + rng = np.random.RandomState(seed) + t = np.sort(rng.rand(N)) * T + dy = 0.1 * np.exp(0.5 * rng.randn(N)) + y = 12.0 + 0.3 * np.cos(2 * np.pi * f0 * t - 0.3) + dy * rng.randn(N) + return t, y, dy + + +def _uniform_grid(fmin, fmax, T, samples_per_peak=5): + """freqs = df * (k0 + arange(nf)) with df = 1 / (spp * T).""" + df = 1.0 / (samples_per_peak * T) + k0 = int(round(fmin / df)) + nf = int(round((fmax - fmin) / df)) + return df * (k0 + np.arange(nf)) + + +def _exact_dft(t, c, freqs, chunk=4000): + """sum_j c_j exp(2 pi i f t_j) in float64 (the adjoint NFFT's target).""" + out = np.empty(len(freqs), dtype=complex) + for a in range(0, len(freqs), chunk): + ph = 2 * np.pi * np.outer(freqs[a:a + chunk], t) + out[a:a + chunk] = (np.cos(ph) + 1j * np.sin(ph)) @ c + return out + + +def _run_gpu(proc, t, y, dy, freqs, **kwargs): + r = proc.run([(t, y, dy)], freqs=freqs, **kwargs) + proc.finish() + return np.asarray(r[0][1][:len(freqs)], dtype=np.float64) + + +class TestLombScargleAccuracy(object): + """Accuracy of the default (NFFT) path against astropy's float64 + generalized Lomb-Scargle on realistic problem sizes. + + Regression tests for the Sep-2026 NFFT defects: (a) the w-spectrum + grid reused the psi tables precomputed for the (2x smaller) yw grid, + displacing every point's window by a fraction of a cell -- 3e-3 to + 2.4e-2 power bias on every default call, in float32 AND float64, + independent of m (defect 3, ``nfft-psi-table``); (b) ``floorf()`` on + the double-precision grid coordinate misplaced ~n0*ng/2^24 points by + one cell, making ``use_double=True`` *less* accurate than float32 on + dense grids (``nfft-floorf-double``). Measured on an A40 after the + fixes: float32 1.9e-4 / double 4.3e-8 on the k0=1 grid below (both + 7.7e-3 before); double 2.0e-8 on the long-baseline grid (2.3e-3 + before, float32 1.8e-3 -> 1.6e-4). + """ + + @pytest.mark.parametrize("use_double,tol", [(False, 6e-4), + (True, 1e-6)]) + def test_default_grid_vs_astropy(self, use_double, tol): + t, y, dy = _realistic_lc() + freqs = _uniform_grid(1.0 / (5 * 365.0), 20.0, 365.0) + ref = LombScargle(t, y, dy).power(freqs) + + proc = LombScargleAsyncProcess(use_double=use_double, sigma=4, + m=8, autoset_m=False) + p = _run_gpu(proc, t, y, dy, freqs) + + assert np.max(np.abs(p - ref)) < tol + assert np.argmax(p) == np.argmax(ref) + + @pytest.mark.parametrize("use_double,tol", [(False, 6e-4), + (True, 1e-6)]) + def test_long_baseline_dense_grid_vs_astropy(self, use_double, tol): + # 1000 points over 3 yr, 109,499 frequencies: the double path + # hit the floorf() cell misplacement here (2.3e-3 before the + # fix, i.e. worse than float32). + t, y, dy = _realistic_lc(N=1000, T=1095.0, f0=2.7, seed=3) + freqs = _uniform_grid(1.0 / (5 * 1095.0), 20.0, 1095.0) + ref = LombScargle(t, y, dy).power(freqs, method='cython') + + proc = LombScargleAsyncProcess(use_double=use_double, sigma=4, + m=8, autoset_m=False) + p = _run_gpu(proc, t, y, dy, freqs) + + assert np.max(np.abs(p - ref)) < tol + assert np.argmax(p) == np.argmax(ref) + + @pytest.mark.parametrize("use_double,tol", [(False, 5e-3), + (True, 1e-6)]) + def test_device_spectra_match_exact_dft(self, use_double, tol): + # Read the two NFFT spectra straight off the device memory and + # compare with the exact float64 adjoint DFT. The w-spectrum + # (nfft_mem_w.ghat_g, modes k0 .. 2 nf + k0 - 1) was off by 0.15 + # with the shared psi tables; the yw-spectrum was always fine. + from ..lombscargle import get_k0 + from ..utils import normalize_light_curves + + t, y, dy = _realistic_lc() + freqs = _uniform_grid(1.0 / (5 * 365.0), 20.0, 365.0) + nf, k0, df = len(freqs), get_k0(freqs), freqs[1] - freqs[0] + + # run() centres t and y on the host; mirror that for the DFT + (tn, yn, dyn), = normalize_light_curves([(t, y, dy)]) + w = dyn ** -2 + w /= np.sum(w) + yw = w * (yn - np.dot(w, yn)) + + proc = LombScargleAsyncProcess(use_double=use_double, sigma=4, + m=8, autoset_m=False) + mem = proc.allocate([(tn, yn, dyn)], nfreqs=[nf], k0s=[k0]) + _run_gpu(proc, t, y, dy, freqs, memory=mem) + + sw = mem[0].nfft_mem_w.ghat_g.get() + syw = mem[0].nfft_mem_yw.ghat_g.get() + n_w = 2 * nf + k0 + assert len(sw) >= n_w and len(syw) >= nf + + sw_exact = _exact_dft(tn, w, (k0 + np.arange(n_w)) * df) + syw_exact = _exact_dft(tn, yw, (k0 + np.arange(nf)) * df) + # sum(w) == 1, so these are absolute errors on a unit scale + assert np.max(np.abs(sw[:n_w] - sw_exact)) < tol + assert np.max(np.abs(syw[:nf] - syw_exact)) < tol + + +class TestLombScargleNarrowBands(object): + """Frequency grids that do not start near zero (defect 4, + ``nfft-k0-size`` / ``ls-grid-near-zero``, Sep 2026). + + The NFFT grids were sized ``sigma * nf`` while the ``lomb`` kernel + reads modes ``k0 .. k0 + nf - 1`` (and ``2 k0 .. 2 (k0 + nf - 1)`` + from the w-spectrum), so the top mode sat at fraction + ``(k0 + nf) / (sigma nf)`` of the grid and crossed the Gaussian + window's alias-free limit at ``k0 = nf``: any band with + ``fmin >= ~fmax / 2`` -- ``run(minimum_frequency=20, + maximum_frequency=30)``, say -- returned powers of 1e4..1e36 with a + wrong best frequency, through every public entry point. Grids are + now sized from the top mode; measured on an A40 after the fix the + bands below agree with astropy to <= 8.7e-4 (float32) and <= 1.4e-7 + (double), the ordinary m = 8 truncation level. + """ + + T = 365.0 + + # (fmin, fmax) with k0 / nf = 1.2, 2, 4 + bands = [(1.2, 2.2), (2.0, 3.0), (4.0, 5.0)] + + def _case(self, fmin, fmax): + f0 = fmin + 0.9 * (fmax - fmin) # signal near the top of the band + t, y, dy = _realistic_lc(N=300, T=self.T, f0=f0, seed=3) + freqs = _uniform_grid(fmin, fmax, self.T) + ref = LombScargle(t, y, dy).power(freqs) + return t, y, dy, freqs, ref + + @pytest.mark.parametrize("band", bands) + @pytest.mark.parametrize("use_double,tol", [(False, 1e-3), + (True, 1e-6)]) + def test_band_vs_astropy(self, band, use_double, tol): + from ..lombscargle import get_k0 + t, y, dy, freqs, ref = self._case(*band) + k0, nf = get_k0(freqs), len(freqs) + assert k0 >= 1.1 * nf # this really is a narrow band + + proc = LombScargleAsyncProcess(use_double=use_double) + p = _run_gpu(proc, t, y, dy, freqs) + + assert np.max(np.abs(p - ref)) < tol + assert np.argmax(p) == np.argmax(ref) + + def test_run_with_minimum_maximum_frequency(self): + # documented kwargs path -> autofrequency grid, k0/nf = 2 + t, y, dy, _, _ = self._case(20.0, 30.0) + proc = LombScargleAsyncProcess() + r = proc.run([(t, y, dy)], minimum_frequency=20.0, + maximum_frequency=30.0) + proc.finish() + freqs, p = r[0] + p = np.asarray(p[:len(freqs)], dtype=np.float64) + ref = LombScargle(t, y, dy).power(freqs) + assert np.max(np.abs(p - ref)) < 2e-3 + assert np.argmax(p) == np.argmax(ref) + + def test_lomb_scargle_simple_on_band(self): + from ..lombscargle import lomb_scargle_simple + t, y, dy, freqs, ref = self._case(20.0, 30.0) + f, p = lomb_scargle_simple(t, y, dy, freqs=freqs) + p = np.asarray(p[:len(freqs)], dtype=np.float64) + assert np.max(np.abs(p - ref)) < 2e-3 + assert np.argmax(p) == np.argmax(ref) + + def test_batched_best_freq_on_band(self): + t, y, dy, freqs, ref = self._case(20.0, 30.0) + proc = LombScargleAsyncProcess() + best_freqs, _ = proc.batched_run_const_nfreq( + [(t, y, dy)], freqs=freqs, only_return_best_freqs=True) + assert best_freqs[0] == pytest.approx(freqs[np.argmax(ref)]) + + def test_small_grid_far_from_zero(self): + # nf = 8 at k0 = 50 used to return the -1 sentinel everywhere + t, y, dy, _, _ = self._case(20.0, 30.0) + df = 1.0 / (5 * self.T) + freqs = df * (50 + np.arange(8)) + ref = LombScargle(t, y, dy).power(freqs) + proc = LombScargleAsyncProcess() + p = _run_gpu(proc, t, y, dy, freqs) + assert np.all(p >= 0) + assert np.max(np.abs(p - ref)) < 1e-3 + + def test_sigma_below_3_raises(self): + # sigma = 2 leaves the top of every band aliased even with the + # grids sized from the top mode + t, y, dy, freqs, _ = self._case(2.0, 3.0) + proc = LombScargleAsyncProcess(sigma=2) + with pytest.raises(ValueError, match="sigma"): + proc.run([(t, y, dy)], freqs=freqs) + + +class TestNFFTGridChecks(object): + """CPU tests of the grid-sizing helper and the hard check that + ``lomb_scargle_async`` applies before touching the NFFT memories.""" + + def test_nfft_grid_sizes_cover_top_mode(self): + from ..memory.lombscargle_memory import nfft_grid_sizes + from ..memory.nfft_memory import next_fast_len + for H in (1, 2, 3): + for k0, nf in [(1, 100), (50, 8), (1000, 500), (36038, 18020)]: + for sigma in (3, 4, 5): + nf_yw, n_yw, nf_w, n_w = nfft_grid_sizes( + nf, k0, nharmonics=H, sigma=sigma) + # every spectrum entry the kernels read exists + assert (H - 1) * k0 + H * (nf - 1) < nf_yw + assert (2 * H - 1) * k0 + 2 * H * (nf - 1) < nf_w + # grids sized from the top mode, 7-smooth + assert n_yw >= sigma * (k0 + nf_yw) + assert n_w >= sigma * (k0 + nf_w) + assert next_fast_len(n_yw) == n_yw + assert next_fast_len(n_w) == n_w + + def _fake_memory(self, nf_yw, n_yw, nf_w, n_w, sigma=4): + import types + mk = lambda nf, n: types.SimpleNamespace(nf=nf, n=n, sigma=sigma, + ghat_g=np.zeros(n)) + return types.SimpleNamespace(nfft_mem_yw=mk(nf_yw, n_yw), + nfft_mem_w=mk(nf_w, n_w)) + + def test_check_passes_for_correctly_sized_grids(self): + from ..lombscargle import _check_nfft_grids + from ..memory.lombscargle_memory import nfft_grid_sizes + nf, k0, H = 500, 1000, 2 + mem = self._fake_memory(*nfft_grid_sizes(nf, k0, H, 4)) + _check_nfft_grids(mem, nf, k0, H) + + def test_check_rejects_old_sizing(self): + # the pre-1.0 allocation: sigma * count, k0 shaved off + from ..lombscargle import _check_nfft_grids + nf, k0, sigma = 500, 1000, 4 + fft_size = nf + k0 + mem = self._fake_memory(fft_size - k0, sigma * (fft_size - k0), + 2 * fft_size - k0, + sigma * (2 * fft_size - k0)) + with pytest.raises(ValueError, match="too short"): + _check_nfft_grids(mem, nf, k0, 1) + + def test_check_rejects_memory_for_smaller_grid(self): + from ..lombscargle import _check_nfft_grids + from ..memory.lombscargle_memory import nfft_grid_sizes + mem = self._fake_memory(*nfft_grid_sizes(100, 10, 1, 4)) + with pytest.raises(ValueError, match="different frequency grid"): + _check_nfft_grids(mem, 200, 10, 1) + + +def _two_harmonic_lc(seed=7, N=250, T=80.0, f0=0.9): + """Strongly non-sinusoidal signal so that H = 1 and H = 2, 3 differ.""" + rng = np.random.RandomState(seed) + t = np.sort(rng.rand(N)) * T + y = (10 + 0.4 * np.sin(2 * np.pi * f0 * t) + + 0.4 * np.sin(2 * np.pi * 2 * f0 * t + 1.0) + 0.05 * rng.randn(N)) + dy = 0.05 * np.ones(N) + df = 1.0 / (5 * (t.max() - t.min())) + freqs = df * (5 + np.arange(600)) + return t, y, dy, freqs + + +def _mh_reference(t, y, dy, freqs, H, **kwargs): + from ..lombscargle import lomb_scargle_direct_sums + w = dy ** -2 + w /= np.sum(w) + ybar = np.dot(w, y) + YY = np.dot(w, (y - ybar) ** 2) + return lomb_scargle_direct_sums(t, w * y, w, freqs, YY, nharms=H, + **kwargs) + + +class TestMultiharmonicDirectSums(object): + """``nharmonics > 1`` with ``use_fft=False`` / ``python_dir_sums=True`` + (defect 13, ``ls-nharmonics-nofft``, Sep 2026): both returned the + H = 1 periodogram (equal to the H = 1 reference to 1e-14) because + the direct-sum kernel forms only the H = 1 moments and the host + solve sat on the NFFT branch. They now run the float64 host + multiharmonic direct sums.""" + + @pytest.mark.parametrize("H", [2, 3]) + @pytest.mark.parametrize("use_double,tol", [(False, 2e-3), + (True, 1e-10)]) + @pytest.mark.parametrize("python_dir_sums", [False, True]) + def test_matches_multiharmonic_reference(self, H, use_double, tol, + python_dir_sums): + t, y, dy, freqs = _two_harmonic_lc() + ref_H = _mh_reference(t, y, dy, freqs, H) + ref_1 = _mh_reference(t, y, dy, freqs, 1) + assert np.max(np.abs(ref_H - ref_1)) > 0.3 # the signal is not a sinusoid + + proc = LombScargleAsyncProcess(use_double=use_double, nharmonics=H) + p = _run_gpu(proc, t, y, dy, freqs, use_fft=False, + python_dir_sums=python_dir_sums) + + assert np.max(np.abs(p - ref_H)) < tol + assert np.max(np.abs(p - ref_1)) > 0.3 + + def test_batched_const_nfreq_direct_sums(self): + t, y, dy, freqs = _two_harmonic_lc() + ref_2 = _mh_reference(t, y, dy, freqs, 2) + proc = LombScargleAsyncProcess(use_double=True, nharmonics=2) + (f, p), = proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs, + use_fft=False) + assert np.max(np.abs(np.asarray(p, dtype=np.float64) - ref_2)) < 1e-10 + + @pytest.mark.parametrize("kwargs", [dict(window=True), + dict(floating_mean=False)]) + def test_non_floating_mean_raises_for_H_gt_1(self, kwargs): + t, y, dy, freqs = _two_harmonic_lc() + proc = LombScargleAsyncProcess(nharmonics=2) + with pytest.raises(ValueError, match="floating-mean"): + proc.run([(t, y, dy)], freqs=freqs, **kwargs) + with pytest.raises(ValueError, match="floating-mean"): + proc.run([(t, y, dy)], freqs=freqs, use_fft=False, **kwargs) + + +class TestLombScargleSimpleWeights(object): + """Regression tests for lomb_scargle_simple's weight handling. + + The function used to pre-normalize dy**-2 and pass the result in the + dy slot of run(); LombScargleMemory.setdata then applied the + inverse-variance conversion AGAIN, producing effective weights + proportional to dy^4 -- the largest-error points got the MOST weight. + lomb_scargle_simple must pass raw dy straight through. + """ + + def test_weights_helper_is_inverse_variance(self): + from ..memory.lombscargle_memory import weights + dy = np.array([0.1, 0.2, 0.4]) + w = weights(dy) + expected = (dy ** -2) / np.sum(dy ** -2) + assert_allclose(w, expected, rtol=1e-6) + assert_allclose(w, [0.76190476, 0.19047619, 0.04761905], rtol=1e-5) + # double application inverts the ordering (the old bug) + w2 = weights(weights(dy)) + assert np.argmax(w2) == np.argmax(dy) # largest error dominates + assert np.argmax(w) == np.argmin(dy) # correct: smallest error + + def test_lomb_scargle_simple_passes_raw_dy(self, monkeypatch): + from .. import lombscargle as ls + # >= _LS_MIN_NDATA points: lomb_scargle_simple validates the + # light curve before forwarding it (Sep 2026 audit, defect 23) + dy = np.array([0.1, 0.2, 0.4, 0.3, 0.15]) + t = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + y = np.array([1.0, 2.0, 3.0, 2.5, 1.5]) + captured = {} + + def fake_run(self, data, **kwargs): + captured['data'] = data + return [(np.array([1.0]), np.array([0.5]))] + + monkeypatch.setattr(ls.LombScargleAsyncProcess, 'run', fake_run) + ls.lomb_scargle_simple(t, y, dy) + + passed_dy = captured['data'][0][2] + assert_allclose(passed_dy, dy) # raw uncertainties, not weights + + +class TestFapBaluev(object): + """fap_baluev must not underflow to exactly 0 for significant + peaks (issue #14): for z near 1, both (1 - z)**(0.5 * N_K) and + exp(-tau) round to 1.0 and the final subtraction cancels + catastrophically. + """ + + def setup_method(self): + rand = np.random.RandomState(42) + self.t = np.sort(365 * rand.rand(100)) + self.dy = 0.01 * (1 + 0.1 * rand.rand(100)) + self.fmax = 10.0 + + def _fap_naive(self, t, dy, z, fmax, d_K=3, d_H=1): + # Direct evaluation of Baluev (2008); valid away from the + # z -> 1 underflow regime. Mirrors the pre-fix implementation. + from scipy.special import gammaln + N = len(t) + d = d_K - d_H + N_K = N - d_K + N_H = N - d_H + g = np.exp(gammaln(0.5 * N_H) - gammaln(0.5 * (N_K + 1))) + w = np.power(dy, -2) + tbar = np.dot(w, t) / sum(w) + Dt = np.dot(w, np.power(t - tbar, 2)) / sum(w) + Teff = np.sqrt(4 * np.pi * Dt) + A = (2 * np.pi ** 1.5) * fmax * Teff + eZ1 = (z / np.pi) ** 0.5 * (d - 1) + eZ2 = (1 - z) ** (0.5 * (N_K - 1)) + tau = (g * A / (2 * np.pi)) * eZ1 * eZ2 + Psing = 1 - (1 - z) ** (0.5 * N_K) + return 1 - Psing * np.exp(-tau) + + def test_matches_naive_formula_at_moderate_z(self): + from ..lombscargle import fap_baluev + # The naive formula computes FAP as 1 - (1 - tiny), so its own + # precision is only ~1e-16/FAP relative; compare strictly where + # the reference itself is accurate, loosely at FAP ~ 1e-11. + z = np.array([0.05, 0.1, 0.2, 0.3]) + fap = fap_baluev(self.t, self.dy, z, self.fmax) + ref = self._fap_naive(self.t, self.dy, z, self.fmax) + assert_allclose(fap, ref, rtol=1e-8) + + z = np.array([0.5]) + fap = fap_baluev(self.t, self.dy, z, self.fmax) + ref = self._fap_naive(self.t, self.dy, z, self.fmax) + assert_allclose(fap, ref, rtol=1e-4) + + def test_no_underflow_to_zero_for_significant_peaks(self): + from ..lombscargle import fap_baluev + # N=100 -> N_K=97; z=0.95 gives FAP ~ 1e-59: representable in + # float64, but the naive formula returns exactly 0.0 + fap = fap_baluev(self.t, self.dy, np.array([0.95, 0.99]), + self.fmax) + assert np.all(fap > 0) + assert np.all(fap < 1e-20) + + def test_monotonically_decreasing_in_z(self): + from ..lombscargle import fap_baluev + z = np.linspace(0.01, 0.995, 200) + fap = fap_baluev(self.t, self.dy, z, self.fmax) + assert np.all(np.diff(fap) <= 0) + assert np.all(fap > 0) + + def test_z_edge_cases(self): + from ..lombscargle import fap_baluev + fap = fap_baluev(self.t, self.dy, np.array([0.0, 1.0]), + self.fmax) + assert fap[0] == pytest.approx(1.0) + assert fap[1] >= 0.0 + + +class _FakePtr(object): + ptr = 0 + + +class _FakeKernel(object): + def __init__(self): + self.calls = [] + + def prepared_async_call(self, *args): + self.calls.append(args) + + +class _FakeLSMemory(object): + """Minimal stand-in for LombScargleMemory: just enough attributes + for the use_fft=False (direct sums) branch of lomb_scargle_async.""" + + def __init__(self, freqs): + from ..lombscargle import get_k0 + self.tmin, self.tmax = 0.0, 100.0 + self.k0 = get_k0(freqs) + self.stream = None + self.nf = len(freqs) + self.n0 = 50 + self.real_type = np.float32 + self.yy = 1.0 + self.ybar = 0.0 + self.mode = np.int32(0) + self.t_g = _FakePtr() + self.yw_g = _FakePtr() + self.w_g = _FakePtr() + self.lsp_g = _FakePtr() + self.reg_g = _FakePtr() + self.lsp_c = np.zeros(len(freqs), dtype=np.float32) + self.n_gpu_transfers = 0 + self.n_lsp_transfers = 0 + + def transfer_data_to_gpu(self): + self.n_gpu_transfers += 1 + + def transfer_lsp_to_cpu(self): + self.n_lsp_transfers += 1 + + +class TestLombScargleAsyncGating(object): + """Argument-gating bugs in the module-level lomb_scargle_async: + the direct-sums branch used to key the host transfer on + transfer_to_device, and use_cufinufft=True was silently ignored + when cufinufft was missing.""" + + def _setup(self): + df = 0.01 + freqs = df * (1 + np.arange(64)) + memory = _FakeLSMemory(freqs) + functions = ((_FakeKernel(), _FakeKernel()), None) + return freqs, memory, functions + + def test_dirsums_transfer_to_host_true_copies(self): + from ..lombscargle import lomb_scargle_async + freqs, memory, functions = self._setup() + lomb_scargle_async(memory, functions, freqs, use_fft=False, + transfer_to_device=False, + transfer_to_host=True) + assert memory.n_gpu_transfers == 0 + assert memory.n_lsp_transfers == 1 + + def test_dirsums_transfer_to_host_false_suppresses_copy(self): + from ..lombscargle import lomb_scargle_async + freqs, memory, functions = self._setup() + lomb_scargle_async(memory, functions, freqs, use_fft=False, + transfer_to_device=True, + transfer_to_host=False) + assert memory.n_gpu_transfers == 1 + assert memory.n_lsp_transfers == 0 + + def test_use_cufinufft_without_cufinufft_raises(self, monkeypatch): + from .. import lombscargle as ls + monkeypatch.setattr(ls, 'HAS_CUFINUFFT', False) + freqs, memory, functions = self._setup() + with pytest.raises(ImportError, match="cufinufft"): + ls.lomb_scargle_async(memory, functions, freqs, + use_fft=False, use_cufinufft=True) + + +class TestCufinufftPlanCache(object): + """cufinufft Plans were created (and never destroyed) on every + call — the dominant cost that made the backend slower than the + custom NFFT. Plans must be cached per problem shape.""" + + class _FakePlan(object): + instances = [] + + def __init__(self, **kwargs): + type(self).instances.append(kwargs) + self.setpts_calls = 0 + + def setpts(self, x): + self.setpts_calls += 1 + + def execute(self, c, out): + out[:] = 0 + + class _FakeNFFTMemory(object): + def __init__(self, ndata=64, nf=32): + rand = np.random.RandomState(2) + self.t_g = np.sort(rand.rand(ndata)).astype(np.float32) + self.y_g = rand.randn(ndata).astype(np.float32) + self.tmin = float(self.t_g.min()) + self.tmax = float(self.t_g.max()) + self.nf = nf + self.ghat_g = np.zeros(nf, dtype=np.complex64) + self.ghat_c = np.zeros(nf, dtype=np.complex64) + + def transfer_data_to_gpu(self): + pass + + def transfer_nfft_to_cpu(self): + pass + + def _patched_backend(self, monkeypatch): + import types + from .. import cufinufft_backend as cb + self._FakePlan.instances = [] + monkeypatch.setattr(cb, 'HAS_CUFINUFFT', True) + monkeypatch.setattr(cb, 'cufinufft', + types.SimpleNamespace(Plan=self._FakePlan), + raising=False) + monkeypatch.setattr(cb, 'gpuarray', + types.SimpleNamespace(zeros=np.zeros)) + cb.free_plan_cache() + return cb + + def test_plan_reused_for_same_shape(self, monkeypatch): + cb = self._patched_backend(monkeypatch) + mem = self._FakeNFFTMemory() + cb.cufinufft_nfft_adjoint(mem, transfer_to_device=False, + transfer_to_host=False) + cb.cufinufft_nfft_adjoint(mem, transfer_to_device=False, + transfer_to_host=False) + assert len(self._FakePlan.instances) == 1 + cb.free_plan_cache() + + def test_new_plan_for_different_shape(self, monkeypatch): + cb = self._patched_backend(monkeypatch) + cb.cufinufft_nfft_adjoint(self._FakeNFFTMemory(nf=32), + transfer_to_device=False, + transfer_to_host=False) + cb.cufinufft_nfft_adjoint(self._FakeNFFTMemory(nf=64), + transfer_to_device=False, + transfer_to_host=False) + assert len(self._FakePlan.instances) == 2 + cb.free_plan_cache() + + def test_cache_eviction_bounded(self, monkeypatch): + cb = self._patched_backend(monkeypatch) + for nf in 16 * (1 + np.arange(cb._PLAN_CACHE_MAX_SIZE + 3)): + cb.cufinufft_nfft_adjoint(self._FakeNFFTMemory(nf=int(nf)), + transfer_to_device=False, + transfer_to_host=False) + assert len(cb._plan_cache) == cb._PLAN_CACHE_MAX_SIZE + cb.free_plan_cache() + assert len(cb._plan_cache) == 0 + + +class TestCufinufftBackendOnDevice(object): + """The real cuFINUFFT backend against the built-in NFFT backend on + a device (release finding 74: only the fake-Plan tests above ran in + the suite; the cross-check lived in + ``scripts/benchmark_new_features.py --tests-only``). Guarded by + ``importorskip('cufinufft')`` so the zero-skip gate policy covers + it; on CPU-only hosts it skips at the import.""" + + @staticmethod + def _sinusoid(ndata, baseline, period, seed, amplitude=0.01, + noise=0.002): + rng = np.random.RandomState(seed) + t = np.sort(rng.uniform(0, baseline, ndata)).astype(np.float32) + y = amplitude * np.cos(2 * np.pi * t / period).astype(np.float32) + y += rng.randn(ndata).astype(np.float32) * noise + dy = np.full(ndata, noise, dtype=np.float32) + return t, y, dy + + @pytest.mark.parametrize("ndata,nfreq,period", [ + (1000, 5000, 5.0), (5000, 10000, 3.0)]) + def test_cufinufft_matches_builtin_nfft(self, ndata, nfreq, period): + pytest.importorskip('cufinufft') + from ..cufinufft_backend import HAS_CUFINUFFT + assert HAS_CUFINUFFT + fmax = 2.0 + df = fmax / nfreq + freqs = (np.arange(1, nfreq + 1) * df).astype(np.float32) + for seed in (100, 101): + t, y, dy = self._sinusoid(ndata, 365.0, period, seed) + proc = LombScargleAsyncProcess(use_cufinufft=False) + _, p_builtin = proc.run([(t, y, dy)], freqs=[freqs])[0] + proc.finish() + proc = LombScargleAsyncProcess(use_cufinufft=True) + _, p_cufi = proc.run([(t, y, dy)], freqs=[freqs])[0] + proc.finish() + p_builtin = np.asarray(p_builtin, dtype=np.float64) + p_cufi = np.asarray(p_cufi, dtype=np.float64) + assert p_cufi.shape == p_builtin.shape == freqs.shape + assert np.all(np.isfinite(p_cufi)) + # the benchmark script's acceptance: corr > 0.9999, max abs + # diff < 0.01 (power is in [0, 1]), peaks within 2 df + assert np.corrcoef(p_builtin, p_cufi)[0, 1] > 0.9999 + assert np.max(np.abs(p_builtin - p_cufi)) < 0.01 + peak_b = freqs[np.argmax(p_builtin)] + peak_c = freqs[np.argmax(p_cufi)] + assert abs(peak_b - peak_c) < 2 * df + assert abs(peak_b - 1.0 / period) < 2 * df + + +class TestAmplitudePrior(object): + """``amplitude_prior`` on the multiharmonic NFFT path (defect 14, + ``ls-amplitude-prior``, Sep 2026): ``_mh_power_from_spectra`` was + called without ``reg_kwargs``, so H > 1 silently returned the + UNregularized power (0.9 away from the ridge reference on this + data). The prior is the standard deviation of a Gaussian prior on + the amplitudes, i.e. a ridge term 1 / s**2 (``add_regularization``). + Measured on an A40 after the fix: 1.1e-5 (float32) / 1.8e-8 + (double) vs the float64 regularized direct sums.""" + + s = 0.3 + + @pytest.mark.parametrize("H", [2, 3]) + @pytest.mark.parametrize("use_double,tol", [(False, 1e-4), + (True, 1e-7)]) + def test_nfft_path_matches_regularized_reference(self, H, use_double, + tol): + t, y, dy, freqs = _two_harmonic_lc() + ref_reg = _mh_reference(t, y, dy, freqs, H, amplitude_priors=self.s) + ref_unreg = _mh_reference(t, y, dy, freqs, H) + assert np.max(np.abs(ref_reg - ref_unreg)) > 0.5 + + proc = LombScargleAsyncProcess(use_double=use_double, nharmonics=H) + p = _run_gpu(proc, t, y, dy, freqs, amplitude_prior=self.s) + + assert np.max(np.abs(p - ref_reg)) < tol + assert np.max(np.abs(p - ref_unreg)) > 0.5 + + def test_single_harmonic_kernel_path(self): + # H = 1 goes through reg_g in the lomb kernel (was already right) + t, y, dy, freqs = _two_harmonic_lc() + ref_reg = _mh_reference(t, y, dy, freqs, 1, amplitude_priors=self.s) + proc = LombScargleAsyncProcess(nharmonics=1) + p = _run_gpu(proc, t, y, dy, freqs, amplitude_prior=self.s) + assert np.max(np.abs(p - ref_reg)) < 1e-5 + + def test_direct_sums_path(self): + t, y, dy, freqs = _two_harmonic_lc() + ref_reg = _mh_reference(t, y, dy, freqs, 2, amplitude_priors=self.s) + proc = LombScargleAsyncProcess(use_double=True, nharmonics=2) + p = _run_gpu(proc, t, y, dy, freqs, amplitude_prior=self.s, + use_fft=False) + assert np.max(np.abs(p - ref_reg)) < 1e-10 + + +class TestCheckK0(object): + """``check_k0`` must reject every grid the kernels cannot evaluate + (defect 15, ``ls-nonuniform-grid``, Sep 2026): before 1.0 only + ``freqs[0:2]`` were inspected, so concatenated / thinned grids + passed and were silently evaluated on the implied uniform grid + (corr 0.009 with astropy at the user's labels). CPU-only.""" + + @staticmethod + def _grid(k0=50, nf=600, T=100.0, spp=5): + df = 1.0 / (spp * T) + return df * (k0 + np.arange(nf)) + + def test_concatenated_segments_raise_naming_the_junction(self): + from ..lombscargle import check_k0 + fu = np.concatenate([np.arange(0.1, 1.0, 0.002), + np.arange(1.0, 5.0, 0.01)]) + with pytest.raises(ValueError, + match=r"not uniformly spaced.*freqs\[451\] - " + r"freqs\[450\]"): + check_k0(fu) + + def test_deleted_points_raise_naming_the_gap(self): + from ..lombscargle import check_k0 + fdel = np.delete(self._grid(), np.arange(100, 120)) + with pytest.raises(ValueError, + match=r"freqs\[100\] - freqs\[99\]"): + check_k0(fdel) + + def test_auditor_grid_uniform_for_two_points_raises(self): + from ..lombscargle import check_k0 + f = self._grid() + fb = f.copy() + fb[2:] = f[2] + 3 * (f[2:] - f[2]) + with pytest.raises(ValueError, match="not uniformly spaced"): + check_k0(fb) + + def test_fractional_first_mode_raises(self): + from ..lombscargle import check_k0 + # linspace(0.1, 10, 50001): df = 1.98e-4, freqs[0] / df = 505.05 + with pytest.raises(ValueError, match="not an integer multiple"): + check_k0(np.linspace(0.1, 10.0, 50001)) + f = self._grid() + df = f[1] - f[0] + with pytest.raises(ValueError, match="not an integer multiple"): + check_k0(f + 0.3 * df) + # 1e-3 of a mode used to pass the old 1 % tolerance + with pytest.raises(ValueError, match="not an integer multiple"): + check_k0(f + 1e-3 * df) + + def test_descending_short_and_geomspace_raise(self): + from ..lombscargle import check_k0, get_k0 + f = self._grid() + with pytest.raises(ValueError, match="strictly increasing"): + check_k0(f[::-1]) + with pytest.raises(ValueError, match="at least two"): + check_k0(f[:1]) + with pytest.raises(ValueError, match="at least two"): + get_k0(f[:1]) + with pytest.raises(ValueError): + check_k0(np.geomspace(0.1, 10.0, 1000)) + + def test_valid_grids_pass(self): + from ..lombscargle import check_k0, get_k0 + from ..utils import autofrequency + rng = np.random.RandomState(1) + t = np.sort(rng.uniform(0, 100.0, 600)) + for f, k0 in [(autofrequency(t), 1), + (autofrequency(t, minimum_frequency=2.0, + maximum_frequency=3.0), None), + (np.linspace(0.1, 10.0, 991), 10), + (self._grid(), 50), + # float64 rounding at large k0 must not trip the + # k0 test (k0**2 eps = 3e-5 modes with the naive + # f[1] - f[0] spacing) + ((1.0 / (5 * 3650.0)) * (365000 + np.arange(10)), + 365000), + ((1.0 / (5 * 3650.0)) * (365000 + np.arange(1000)), + 365000), + # float32 grids of moderate size + (self._grid().astype(np.float32), 50), + (self._grid(k0=1000, nf=10000).astype(np.float32), + 1000), + (list(self._grid()), 50)]: + check_k0(f) + if k0 is not None: + assert get_k0(f) == k0 + + def test_float32_survey_grid_fractional_first_mode_raises(self): + # The freqs[0] term of the tolerance must use the rounding of + # freqs[0] itself (eps * |f[0]|), not eps * max|f|: with the + # latter a float32 survey-scale grid (10 yr baseline, 5 samples + # per peak, nf ~ 9e5) accepted a first mode fractional by up to + # ~0.43 df, and the kernels then evaluated a band shifted off + # the user's labels (0.52 relative power error at 0.1 df). + from ..lombscargle import check_k0 + df = 1.0 / (5 * 3650.0) + f64 = df * (1 + np.arange(912500)) + for offset in (0.02, 0.05, 0.1, 0.3, 0.5): + for dtype in (np.float32, np.float64): + grid = (f64 + offset * df).astype(dtype) + with pytest.raises(ValueError, + match="not an integer multiple"): + check_k0(grid) + + def test_survey_scale_grids_of_two_million_points_pass(self): + # ... and the tightened bound must not reject any grid a user + # would actually build, in either precision. + from ..lombscargle import check_k0, get_k0 + from ..utils import autofrequency + nf = 2000000 + rng = np.random.RandomState(7) + t = np.sort(rng.uniform(0, 3650.0, 4000)) + auto = autofrequency(t, maximum_frequency=120.0) + assert len(auto) > nf + grids = [('autofrequency', auto, None)] + for df, k0 in ((1.0 / (5 * 3650.0), 1), + (1.0 / (5 * 365.0), 100)): + f0, f1 = df * k0, df * (k0 + nf - 1) + grids += [('arange*df', df * (k0 + np.arange(nf)), k0), + ('arange', np.arange(k0, k0 + nf) * df, k0), + ('linspace', np.linspace(f0, f1, nf), k0)] + for name, grid, k0 in grids: + for dtype in (np.float64, np.float32): + g = grid.astype(dtype) + check_k0(g) # must not raise + if k0 is not None: + assert get_k0(g) == k0, name + + def test_float32_grid_that_is_really_nonuniform_raises(self): + from ..lombscargle import check_k0 + f = self._grid(k0=50, nf=600).astype(np.float32) + f[300:] += np.float32(0.05 * (f[1] - f[0])) + with pytest.raises(ValueError, match="not uniformly spaced"): + check_k0(f) + + +class TestRunGridValidation(object): + """The public entry points must reject non-uniform grids before any + GPU work and echo valid grids untouched.""" + + def _lc(self): + rng = np.random.RandomState(1) + N, T = 200, 100.0 + t = np.sort(rng.uniform(0, T, N)) + y = 1 + 0.01 * np.sin(2 * np.pi * t / 0.7) + 0.005 * rng.randn(N) + dy = 0.005 * np.ones(N) + return t, y, dy + + def test_run_and_batched_reject_nonuniform_grid(self): + t, y, dy = self._lc() + fu = np.concatenate([np.arange(0.1, 1.0, 0.002), + np.arange(1.0, 5.0, 0.01)]) + proc = LombScargleAsyncProcess() + with pytest.raises(ValueError, match="not uniformly spaced"): + proc.run([(t, y, dy)], freqs=fu) + with pytest.raises(ValueError, match="not uniformly spaced"): + proc.run([(t, y, dy)], freqs=fu, use_fft=False) + with pytest.raises(ValueError, match="not uniformly spaced"): + proc.batched_run_const_nfreq([(t, y, dy)], freqs=fu) + with pytest.raises(ValueError, match="not uniformly spaced"): + proc.preallocate(max_nobs=len(t), freqs=fu) + + def test_single_frequency_raises_clearly(self): + # nf = 1 used to die with IndexError (id 100) + t, y, dy = self._lc() + proc = LombScargleAsyncProcess() + with pytest.raises(ValueError, match="at least two"): + proc.run([(t, y, dy)], freqs=np.array([1.0])) + + def test_dy_none_means_unit_weights(self): + # documented pass-through that raised TypeError before 1.0 (id 100) + t, y, dy = self._lc() + freqs = 0.001 * (50 + np.arange(3000)) + proc = LombScargleAsyncProcess() + p_none = _run_gpu(proc, t, y, None, freqs) + p_ones = _run_gpu(proc, t, y, np.ones_like(t), freqs) + p_const = _run_gpu(proc, t, y, 0.3 * np.ones_like(t), freqs) + ref = LombScargle(t, y).power(freqs) + assert_allclose(p_none, p_ones, rtol=1e-6, atol=1e-6) + assert_allclose(p_none, p_const, rtol=1e-6, atol=1e-6) + assert np.max(np.abs(p_none - ref)) < 1e-4 + + +class TestPreallocate(object): + """``preallocate`` left ``memory.stream = None`` (the null stream), + so ``finish()`` -- which synchronizes ``self.streams`` only -- did + not wait for the asynchronous result copy and ``run()`` after + ``preallocate()`` returned stale powers (29 of 30 reads on an A40 + with the 7-smooth grids; the audit saw 14/30).""" + + @staticmethod + def _lc(N, seed): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, 100.0, N)) + y = 0.3 * np.sin(2 * np.pi * t / 1.7) + 0.05 * r.randn(N) + return t, y, 0.05 * np.ones(N) + + def test_run_after_preallocate_matches_fresh_runs(self): + f = 0.001 * (50 + np.arange(3000)) + B, C = self._lc(900, 2), self._lc(300, 5) + proc = LombScargleAsyncProcess() + fresh = {} + for name, d in (('B', B), ('C', C)): + fresh[name] = _run_gpu(proc, *d, f) + + proc.preallocate(max_nobs=900, nlcs=1, freqs=f) + mem = proc.memory[0] + assert mem.stream is not None + assert any(mem.stream is s for s in proc.streams) + + for k in range(10): + for name, d in (('B', B), ('C', C)): + r = proc.run([d], freqs=[f]) + proc.finish() + p = np.asarray(r[0][1][:len(f)], dtype=np.float64) + assert_allclose(p, fresh[name], rtol=1e-5, atol=1e-6) + + def test_user_streams_are_synchronized_by_finish(self): + import pycuda.driver as cuda + f = 0.001 * (50 + np.arange(3000)) + B = self._lc(900, 2) + proc = LombScargleAsyncProcess() + ref = _run_gpu(proc, *B, f) + s = cuda.Stream() + proc.preallocate(max_nobs=900, nlcs=1, freqs=f, streams=[s]) + assert any(s is s0 for s0 in proc.streams) + for k in range(5): + r = proc.run([B], freqs=[f]) + proc.finish() + assert_allclose(np.asarray(r[0][1][:len(f)], dtype=np.float64), + ref, rtol=1e-5, atol=1e-6) + + +class TestBatchedBestFreqs(object): + """``batched_run_const_nfreq(only_return_best_freqs=True)`` returns + the false-alarm probability of the best peak (``fap_baluev`` with + ``d_K = 2 H + 1``) -- before 1.0 it returned ``1 - FAP``, exactly + 1.0 for every FAP below 1e-16, with ``d_K = 3`` for any H (ids 96, + 129, 144).""" + + @staticmethod + def _lc(N=100, T=100.0, amp=0.06, seed=3): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, T, N)) + y = amp * np.sin(2 * np.pi * t / 1.7) + 0.05 * r.randn(N) + return t, y, 0.05 * np.ones(N) + + @pytest.mark.parametrize("H", [1, 2]) + def test_returns_fap_of_best_peak(self, H): + from ..lombscargle import fap_baluev + t, y, dy = self._lc() + freqs = 0.002 * (50 + np.arange(1500)) + proc = LombScargleAsyncProcess(nharmonics=H) + (f, p), = proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs) + p = np.asarray(p[:len(freqs)], dtype=np.float64) + i = int(np.argmax(p)) + expected = float(fap_baluev(t, dy, p[i], freqs.max(), + d_K=2 * H + 1)) + wrong_dK = float(fap_baluev(t, dy, p[i], freqs.max(), d_K=3)) + + bf, faps = proc.batched_run_const_nfreq( + [(t, y, dy)], freqs=freqs, only_return_best_freqs=True) + assert bf[0] == freqs[i] + assert faps[0] == pytest.approx(expected, rel=1e-6) + # a real FAP: representable, small, and not the old 1 - FAP + assert 0.0 < faps[0] < 1e-2 + if H > 1: + assert wrong_dK != pytest.approx(expected, rel=1e-3) + + def test_mask_is_honoured(self): + t, y, dy = self._lc() + freqs = 0.002 * (50 + np.arange(1500)) + proc = LombScargleAsyncProcess() + (f, p), = proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs) + p = np.asarray(p[:len(freqs)], dtype=np.float64) + i = int(np.argmax(p)) + ignore = np.zeros(len(freqs), dtype=bool) + ignore[max(0, i - 5):i + 6] = True + bf, faps = proc.batched_run_const_nfreq( + [(t, y, dy)], freqs=freqs, only_return_best_freqs=True, + ignore_freq_mask=ignore) + assert not ignore[np.flatnonzero(freqs == bf[0])[0]] + assert bf[0] == freqs[~ignore][np.argmax(p[~ignore])] + + def test_dy_none_through_the_fap_path(self): + t, y, dy = self._lc() + freqs = 0.002 * (50 + np.arange(1500)) + proc = LombScargleAsyncProcess() + ref = LombScargle(t, y).power(freqs) + bf, faps = proc.batched_run_const_nfreq( + [(t, y, None)], freqs=freqs, only_return_best_freqs=True) + assert bf[0] == freqs[np.argmax(ref)] + assert 0.0 <= faps[0] < 1.0 + + def test_freqs_none_keeps_every_autofrequency_point(self): + # the rebuilt grid dropped the last point (id 147) + from ..utils import autofrequency + t, y, dy = self._lc() + proc = LombScargleAsyncProcess() + (f, p), = proc.batched_run_const_nfreq([(t, y, dy)]) + fa = autofrequency(t) + assert len(f) == len(fa) + assert_allclose(f, fa, rtol=1e-12) + r = proc.run([(t, y, dy)]) + proc.finish() + assert len(r[0][0]) == len(fa) + + +class TestFapBaluevInputs(object): + def test_dy_none_is_unit_weights(self): + from ..lombscargle import fap_baluev + rng = np.random.RandomState(4) + t = np.sort(rng.rand(80)) * 50.0 + z = np.array([0.1, 0.3, 0.5]) + assert_allclose(fap_baluev(t, None, z, 5.0), + fap_baluev(t, np.ones_like(t), z, 5.0), rtol=1e-12) + assert_allclose(fap_baluev(t, None, z, 5.0), + fap_baluev(t, 0.2 * np.ones_like(t), z, 5.0), + rtol=1e-12) + + +class TestCufinufftBackend(object): + """The cufinufft backend was complex64 only and raised TypeError + for ``use_double=True`` (id 97); the precision now follows the + memory.""" + + def _proc(self, use_double): + from ..cufinufft_backend import HAS_CUFINUFFT + if not HAS_CUFINUFFT: + pytest.skip("cufinufft not installed") + return LombScargleAsyncProcess(use_cufinufft=True, + use_double=use_double) + + @pytest.mark.parametrize("use_double,tol", [(False, 2e-3), + (True, 1e-6)]) + def test_matches_astropy(self, use_double, tol): + t, y, dy = _realistic_lc() + freqs = _uniform_grid(1.0 / (5 * 365.0), 20.0, 365.0) + ref = LombScargle(t, y, dy).power(freqs) + proc = self._proc(use_double) + p = _run_gpu(proc, t, y, dy, freqs) + assert np.max(np.abs(p - ref)) < tol + assert np.argmax(p) == np.argmax(ref) + + def test_narrow_band_double(self): + t, y, dy = _realistic_lc(N=300, T=365.0, f0=29.0, seed=3) + freqs = _uniform_grid(20.0, 30.0, 365.0) + ref = LombScargle(t, y, dy).power(freqs) + proc = self._proc(True) + p = _run_gpu(proc, t, y, dy, freqs) + assert np.max(np.abs(p - ref)) < 1e-6 + + +class TestWeightsUseNumpyReductions(object): + """``weights()`` and ``LombScargleMemory.setdata`` used the Python + builtins ``sum``/``min``/``max`` on numpy arrays, which iterate the + array element by element: 11.4 ms per lightcurve at N = 65,000 and + 150 ms at N = 1e6 of pure interpreter time for the same values + (Sep-2026 algorithm audit, LS-1). They now use ``np.sum`` / + ``np.min`` / ``np.max``. + + The weight normalization moves by the last ulp (``np.sum`` is + pairwise, the builtin is a left-to-right accumulation), which is + also what makes the copy here agree with the canonical + ``cuvarbase.utils.weights`` bit for bit -- it already used + ``np.sum``, so the two disagreed before. + """ + + @staticmethod + def _dy(n, seed=5): + r = np.random.RandomState(seed) + return 0.01 * (1.0 + r.rand(n)) + + @pytest.mark.parametrize("n", [7, 300, 4096]) + def test_matches_the_canonical_utils_weights_bitwise(self, n): + from ..memory.lombscargle_memory import weights as mem_weights + from ..utils import weights as utils_weights + dy = self._dy(n) + w = mem_weights(dy) + assert np.array_equal(w, utils_weights(dy)) + assert np.array_equal(w, np.power(dy, -2) / np.sum(np.power(dy, -2))) + assert_allclose(np.sum(w), 1.0, rtol=1e-14) + + @pytest.mark.parametrize("n", [7, 300, 4096]) + def test_agrees_with_the_builtin_sum_to_the_last_ulp(self, n): + """Guards the direction of the change: the values are the same + to a few ulps, so nothing but rounding moved.""" + from ..memory.lombscargle_memory import weights as mem_weights + dy = self._dy(n) + w = np.power(dy, -2) + assert_allclose(mem_weights(dy), w / sum(w), rtol=1e-14, atol=0.0) + + @pytest.mark.parametrize("use_double", [False, True]) + def test_setdata_tmin_tmax_are_the_array_extremes(self, use_double): + from ..memory.lombscargle_memory import LombScargleMemory + r = np.random.RandomState(11) + n = 500 + t = np.sort(2455000.0 + 30.0 * r.rand(n)) + y = 12 + 0.01 * r.randn(n) + dy = 0.01 * np.ones(n) + proc = LombScargleAsyncProcess(use_double=use_double) + freqs = 0.01 * (5 + np.arange(400)) + mem = proc.allocate([(t, y, dy)], nfreqs=[len(freqs)], + k0s=[5])[0] + mem.setdata(t=t, y=y, dy=dy) + tc = np.asarray(t).astype(mem.real_type) + assert mem.tmin == np.min(tc) + assert mem.tmax == np.max(tc) + # ... and the same values the Python builtins produced + assert mem.tmin == min(tc) + assert mem.tmax == max(tc) + assert isinstance(mem, LombScargleMemory) + + +class TestBatchedMemoryReuse(object): + """``batched_run_const_nfreq`` rebuilt its ``LombScargleMemory`` + set -- pinned host buffers, device arrays and two cuFFT plans -- on + every call, and built an ``np.array([True] * nf)`` mask whether or + not one was asked for (16 ms at nf = 365,000). It now reuses a + fitting memory set (``preallocate``'s first, then the one it built + last) and skips the mask entirely when ``ignore_freq_mask`` is None + (Sep-2026 algorithm audit, LS-4). + """ + + @staticmethod + def _lc(N=400, T=90.0, seed=2): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, T, N)) + y = 0.2 * np.sin(2 * np.pi * t / 1.9) + 0.05 * r.randn(N) + return t, y, 0.05 * np.ones(N) + + @staticmethod + def _counting_memory(monkeypatch): + from .. import lombscargle as lsmod + built = [] + original = lsmod.LombScargleMemory + + class Counting(original): + def __init__(self, *args, **kwargs): + built.append(1) + super(Counting, self).__init__(*args, **kwargs) + + monkeypatch.setattr(lsmod, 'LombScargleMemory', Counting) + return built + + def test_memory_is_built_once_for_many_calls(self, monkeypatch): + freqs = 0.002 * (30 + np.arange(4000)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + built = self._counting_memory(monkeypatch) + proc.batched_run_const_nfreq(d, freqs=freqs) + assert sum(built) == 1 + del built[:] + for _ in range(4): + proc.batched_run_const_nfreq(d, freqs=freqs) + assert sum(built) == 0 + + def test_reused_memory_gives_identical_powers(self): + # Deliberately bitwise, and deliberately in float32. The + # *Reproducibility* paragraph of docs/source/lomb.rst says the + # float32 NFFT "need not be bitwise identical" in general + # because the gridding accumulates with atomicAdd in an + # unspecified order -- and then carves out this regime: "sparse + # light curves on coarse grids are often bitwise stable". Here + # N = 400 points are spread onto a grid of ~16,000 cells with + # m = 8, so no two observations' Gaussian footprints contend for + # a cell in a way that changes the float32 sum with the order + # (the same-buffer runs measured 15/15 bitwise on the A40, see + # test_padded_buffers_do_not_change_the_result), and the three + # repeats go through the SAME buffers with the same launch + # sequence. That is exactly the LS-4 property under test: the + # reused set is zeroed and overwritten before every run, so it + # cannot leak anything from the previous call -- a tolerance + # would also pass a stale-buffer bug of order 1e-7. Dense + # configurations (N = 65,000, nf = 210,000) are NOT bitwise + # stable and must be compared with assert_allclose, as the + # sibling tests do. If this ever fails by ~1e-8 on some GPU, + # that is the documented atomic noise, not a reuse bug. + freqs = 0.002 * (30 + np.arange(4000)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + proc.batched_run_const_nfreq(d, freqs=freqs) # warm/compile + proc._batch_memory = None # force a rebuild + fresh = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + for _ in range(3): + again = np.copy(proc.batched_run_const_nfreq(d, + freqs=freqs)[0][1]) + assert np.array_equal(fresh, again) + + def test_a_different_grid_is_not_reused(self, monkeypatch): + f1 = 0.002 * (30 + np.arange(4000)) + f2 = 0.002 * (30 + np.arange(2500)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + proc.batched_run_const_nfreq(d, freqs=f1) + built = self._counting_memory(monkeypatch) + p2 = np.copy(proc.batched_run_const_nfreq(d, freqs=f2)[0][1]) + assert sum(built) == 1 + del built[:] + proc.batched_run_const_nfreq(d, freqs=f2) + assert sum(built) == 0 + # and the shorter grid's powers are the head of the longer one + # (only to float32 NFFT accuracy: the two grids are padded to + # different 7-smooth lengths, so the spreading differs by ~2e-4 + # relative near the top of the band) + p1 = np.copy(proc.batched_run_const_nfreq(d, freqs=f1)[0][1]) + assert_allclose(np.asarray(p2[:len(f2)], dtype=np.float64), + np.asarray(p1[:len(f2)], dtype=np.float64), + rtol=1e-3, atol=1e-5) + + def test_a_longer_lightcurve_forces_a_rebuild(self, monkeypatch): + freqs = 0.002 * (30 + np.arange(4000)) + short, long_ = [self._lc(N=200, seed=3)], [self._lc(N=900, seed=4)] + proc = LombScargleAsyncProcess() + proc.batched_run_const_nfreq(short, freqs=freqs) + built = self._counting_memory(monkeypatch) + proc.batched_run_const_nfreq(long_, freqs=freqs) + assert sum(built) == 1 + del built[:] + # the bigger buffers serve the short lightcurve too + proc.batched_run_const_nfreq(short, freqs=freqs) + assert sum(built) == 0 + + def test_padded_buffers_do_not_change_the_result(self): + # Two different device allocations, so this is the ~1e-8 float32 + # tolerance of the NFFT gridding atomics, not bitwise (measured + # on the A40: same buffer 15/15 bitwise, fresh allocations up to + # 1.1e-8 on powers of order 1 -- true of the pre-1.0 code too). + freqs = 0.002 * (30 + np.arange(4000)) + short = [self._lc(N=200, seed=3)] + proc = LombScargleAsyncProcess() + exact = np.asarray(proc.batched_run_const_nfreq(short, + freqs=freqs)[0][1], + dtype=np.float64) + # a run through buffers sized for 900 points + proc.batched_run_const_nfreq([self._lc(N=900, seed=4)], freqs=freqs) + padded = np.asarray(proc.batched_run_const_nfreq(short, + freqs=freqs)[0][1], + dtype=np.float64) + assert_allclose(padded, exact, rtol=1e-6, atol=1e-7) + + def test_preallocated_memory_is_used(self, monkeypatch): + freqs = 0.002 * (30 + np.arange(4000)) + d = [self._lc(N=400)] + proc = LombScargleAsyncProcess() + ref = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + proc._batch_memory = None + proc.preallocate(max_nobs=400, nlcs=1, freqs=freqs) + built = self._counting_memory(monkeypatch) + p = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + assert sum(built) == 0 + assert proc._batch_memory is None # preallocate's set was used + assert_allclose(np.asarray(p, dtype=np.float64), + np.asarray(ref, dtype=np.float64), + rtol=1e-6, atol=1e-7) + + def test_amplitude_prior_change_is_not_reused(self, monkeypatch): + freqs = 0.002 * (30 + np.arange(2000)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + p0 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + built = self._counting_memory(monkeypatch) + p1 = np.copy(proc.batched_run_const_nfreq( + d, freqs=freqs, amplitude_prior=0.05)[0][1]) + assert sum(built) == 1 + # the prior really was applied (it is not the unregularized run) + assert not np.allclose(np.asarray(p0[:len(freqs)], dtype=np.float64), + np.asarray(p1[:len(freqs)], dtype=np.float64)) + del built[:] + p2 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + assert sum(built) == 1 # back to no prior: rebuild + assert_allclose(np.asarray(p2, dtype=np.float64), + np.asarray(p0, dtype=np.float64), + rtol=1e-6, atol=1e-7) + + def test_no_mask_matches_an_all_true_mask(self): + freqs = 0.002 * (30 + np.arange(3000)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + bf0, fap0 = proc.batched_run_const_nfreq( + d, freqs=freqs, only_return_best_freqs=True) + bf1, fap1 = proc.batched_run_const_nfreq( + d, freqs=freqs, only_return_best_freqs=True, + ignore_freq_mask=np.zeros(len(freqs), dtype=bool)) + assert bf0[0] == bf1[0] + assert fap0[0] == fap1[0] + + def test_per_call_nharmonics_is_not_reused(self, monkeypatch): + """``nharmonics`` is read off the memory object + (``lomb_scargle_async``), so a per-call ``nharmonics=`` must key + and build its own memory set. Matching a cached H = 1 set + against a request for H = 2 silently returned the + single-harmonic periodogram (found reviewing LS-4).""" + freqs = 0.002 * (30 + np.arange(1500)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + p1 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + + ref = LombScargleAsyncProcess(nharmonics=2) + p2ref = np.copy(ref.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + + built = self._counting_memory(monkeypatch) + p2 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs, + nharmonics=2)[0][1]) + assert sum(built) == 1 # not the cached H = 1 set + assert_allclose(np.asarray(p2, dtype=np.float64), + np.asarray(p2ref, dtype=np.float64), + rtol=1e-6, atol=1e-7) + # it really is a different periodogram from the H = 1 one + assert not np.allclose(np.asarray(p2[:len(freqs)], dtype=np.float64), + np.asarray(p1[:len(freqs)], dtype=np.float64)) + + del built[:] + proc.batched_run_const_nfreq(d, freqs=freqs, nharmonics=2) + assert sum(built) == 0 # the H = 2 set IS reused + + del built[:] + p3 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + assert sum(built) == 1 # back to H = 1: rebuild + assert_allclose(np.asarray(p3, dtype=np.float64), + np.asarray(p1, dtype=np.float64), + rtol=1e-6, atol=1e-7) + + def test_per_call_use_double_matching_the_process_is_accepted( + self, monkeypatch): + """``use_double`` equal to the process precision is accepted: + it is dropped from the keywords, so it neither rebuilds the + cached set nor changes the result.""" + freqs = 0.002 * (30 + np.arange(1500)) + d = [self._lc()] + proc = LombScargleAsyncProcess() + p1 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + assert np.asarray(p1).dtype == np.float32 + + built = self._counting_memory(monkeypatch) + p2 = np.copy(proc.batched_run_const_nfreq(d, freqs=freqs, + use_double=False)[0][1]) + assert sum(built) == 0 # the cached set served it + assert np.asarray(p2).dtype == np.float32 + assert_allclose(np.asarray(p2, dtype=np.float64), + np.asarray(p1, dtype=np.float64), + rtol=1e-6, atol=1e-7) + p3 = np.copy(proc.run(d, freqs=freqs, use_double=False)[0][1]) + proc.finish() + assert_allclose(np.asarray(p3[:len(freqs)], dtype=np.float64), + np.asarray(p1[:len(freqs)], dtype=np.float64), + rtol=1e-6, atol=1e-7) + + dbl = LombScargleAsyncProcess(use_double=True) + q1 = np.copy(dbl.batched_run_const_nfreq(d, freqs=freqs)[0][1]) + q2 = np.copy(dbl.batched_run_const_nfreq(d, freqs=freqs, + use_double=True)[0][1]) + assert np.asarray(q1).dtype == np.float64 + # same cached buffer set, but the NFFT gridding accumulates with + # atomicAdd, whose order varies run to run: double-precision + # repeats differ too (5 of 19 on an A40, max RELATIVE difference + # over all 1500 bins 6.7e-15, max absolute 1e-17), so equality + # holds to rounding, not bitwise (docs/source/lomb.rst, + # 'Reproducibility'). + assert_allclose(q2, q1, rtol=1e-12, atol=1e-13) + + def test_a_buffer_sizing_kwarg_opts_out_of_the_cache(self, monkeypatch): + """``n0_buffer`` (like every other key that hands the memory a + buffer or its size) opts the call out of the cache entirely, so + it allocates its own set exactly as it did before 1.0.""" + freqs = 0.002 * (30 + np.arange(1500)) + d = [self._lc(N=400)] + proc = LombScargleAsyncProcess() + proc.batched_run_const_nfreq(d, freqs=freqs) + cached = proc._batch_memory + assert cached is not None + + built = self._counting_memory(monkeypatch) + for _ in range(2): + proc.batched_run_const_nfreq(d, freqs=freqs, n0_buffer=1000) + assert sum(built) == 2 # never reused, never cached + assert proc._batch_memory is cached + + del built[:] + proc.batched_run_const_nfreq(d, freqs=freqs) + assert sum(built) == 0 # the plain cache survived + + @pytest.mark.parametrize("kw", ['sigma', 'm', 'stream']) + def test_constructor_positional_kwargs_raise_cold_and_warm(self, kw): + """``LombScargleMemory`` takes sigma/m/stream positionally, so + passing them as keywords has always raised TypeError. They must + opt out of the memory cache too: on a cache hit the constructor + is never called, so the call would otherwise succeed silently + and IGNORE the keyword, returning the process-default result.""" + freqs = 0.002 * (30 + np.arange(1500)) + d = [self._lc(N=400)] + value = {'sigma': 4, 'm': 10, 'stream': None}[kw] + proc = LombScargleAsyncProcess() + + # cold cache + with pytest.raises(TypeError): + proc.batched_run_const_nfreq(d, freqs=freqs, **{kw: value}) + + # warm the cache with a plain call, then the same request must + # still raise rather than quietly returning the default + proc.batched_run_const_nfreq(d, freqs=freqs) + assert proc._batch_memory is not None + with pytest.raises(TypeError): + proc.batched_run_const_nfreq(d, freqs=freqs, **{kw: value}) + + def test_grid_validation_cannot_be_switched_off_from_run(self): + """``_grid_prechecked`` is private to the batched path and may + suppress only the O(nf) uniformity check. ``check_freqs`` (the + defect-23 guard against non-finite / non-positive grids) runs + unconditionally, so no keyword reachable from a public entry + point can turn it off.""" + proc = LombScargleAsyncProcess() + d = [self._lc(N=400)] + bad = 0.002 * (30 + np.arange(1500)) + bad[7] = np.nan + with pytest.raises(ValueError): + proc.run(d, freqs=[bad], _grid_prechecked=True) + + negative = np.linspace(-1.0, 5.0, 500) + with pytest.raises(ValueError): + proc.run(d, freqs=[negative], _grid_prechecked=True) + + def test_grid_validation_still_rejects_a_bad_grid(self): + """The batched path validates the shared grid once and tells + run() to skip the repeat; the error must survive.""" + d = [self._lc()] + proc = LombScargleAsyncProcess() + bad = np.concatenate([0.002 * (30 + np.arange(500)), + 0.002 * (600 + np.arange(500))]) + with pytest.raises(ValueError): + proc.batched_run_const_nfreq(d, freqs=bad) + with pytest.raises(ValueError): + proc.batched_run_const_nfreq(d, freqs=np.geomspace(0.1, 5.0, 500)) + with pytest.raises(ValueError): + proc.run(d, freqs=[np.geomspace(0.1, 5.0, 500)]) + + def test_a_bad_grid_is_rejected_before_any_device_work(self, monkeypatch): + """The shared grid is validated ahead of the kernel compile and + the stream creation (Sep-2026 readiness review): a rejected + grid must leave the CUDA context untouched, exactly like a + rejected light curve. Until then the compile came first, which + is also why this case could only be exercised on a GPU. Runs + without one because nothing below the validation is reached.""" + proc = LombScargleAsyncProcess() + touched = [] + monkeypatch.setattr(proc, '_compile_and_prepare_functions', + lambda **kw: touched.append('compile')) + monkeypatch.setattr(proc, '_create_streams', + lambda n: touched.append('streams')) + d = [self._lc()] + bad = 0.002 * (30 + np.arange(1500)) + bad[7] = np.nan + with pytest.raises(ValueError): + proc.batched_run_const_nfreq(d, freqs=bad) + with pytest.raises(ValueError): + proc.batched_run_const_nfreq(d, freqs=np.geomspace(0.1, 5.0, 500)) + with pytest.raises(ValueError): + proc.batched_run_const_nfreq(d, freqs=-bad) + assert touched == [] + + +class TestPerCallUseDoubleIsRejected(object): + """``use_double`` is a property of the process object: the ``lomb`` + and ``cunfft`` kernels are compiled and prepared once, at + construction, in the process precision, while the memory classes + take ``use_double`` too. Every entry point used to forward its + keywords to the memory constructor, so ``use_double=True`` on a + default (float32) process built float64/complex128 buffers that the + float32 kernels read as float32 -- a wrong periodogram with a + plausible float64 dtype -- and the LS-4 docs/test presented that as + a supported per-call override (Sep-2026 readiness review, idx 23). + A disagreeing value now raises ``ValueError`` before any device + work; an equal one is accepted. These run without a GPU because + the check precedes the kernel compile.""" + + @staticmethod + def _lc(N=200, seed=5): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, 60.0, N)) + y = 0.1 * np.sin(2 * np.pi * t / 1.3) + 0.05 * r.randn(N) + return t, y, 0.05 * np.ones(N) + + @staticmethod + def _no_device_work(proc, monkeypatch): + touched = [] + monkeypatch.setattr(proc, '_compile_and_prepare_functions', + lambda **kw: touched.append('compile')) + monkeypatch.setattr(proc, '_create_streams', + lambda n: touched.append('streams')) + return touched + + @pytest.mark.parametrize('process_double', [False, True]) + def test_run_and_batched_run_raise(self, process_double, monkeypatch): + proc = LombScargleAsyncProcess(use_double=process_double) + assert proc.use_double is process_double + touched = self._no_device_work(proc, monkeypatch) + other = not process_double + d = [self._lc()] + freqs = 0.01 * (20 + np.arange(500)) + with pytest.raises(ValueError, match='use_double'): + proc.run(d, freqs=freqs, use_double=other) + with pytest.raises(ValueError, match='use_double'): + proc.batched_run_const_nfreq(d, freqs=freqs, use_double=other) + # the message points at the fix + with pytest.raises(ValueError, + match=r'LombScargleAsyncProcess\(use_double='): + proc.batched_run_const_nfreq(d, freqs=freqs, use_double=other) + assert touched == [] + + def test_allocation_entry_points_raise(self, monkeypatch): + proc = LombScargleAsyncProcess() + touched = self._no_device_work(proc, monkeypatch) + t, y, dy = self._lc() + freqs = 0.01 * (20 + np.arange(500)) + with pytest.raises(ValueError, match='use_double'): + proc.allocate_for_single_lc(t, y, dy, len(freqs), k0=20, + use_double=True) + with pytest.raises(ValueError, match='use_double'): + proc.allocate([(t, y, dy)], nfreqs=len(freqs), k0s=[20], + use_double=True) + with pytest.raises(ValueError, match='use_double'): + proc.preallocate(max_nobs=len(t), nlcs=1, freqs=freqs, + use_double=True) + assert touched == [] + + def test_memory_at_the_other_precision_raises(self, monkeypatch): + """A memory object built at the other precision (e.g. by a + ``LombScargleAsyncProcess(use_double=True)``) is caught too, + before the compile.""" + proc = LombScargleAsyncProcess() + touched = self._no_device_work(proc, monkeypatch) + + class Mem(object): + use_double = True + + with pytest.raises(ValueError, match='use_double'): + proc.run([self._lc()], memory=[Mem()], + freqs=0.01 * (20 + np.arange(500))) + assert touched == [] + + def test_lomb_scargle_simple_builds_a_double_process(self, monkeypatch): + """The convenience wrapper constructs its own process, so its + ``use_double`` selects the process precision instead of + reaching ``run()`` (where it would now raise).""" + from .. import lombscargle as lsmod + seen = [] + + class Recording(object): + def __init__(self, **kwargs): + seen.append(dict(kwargs)) + + def run(self, data, **kwargs): + seen.append(dict(kwargs)) + return [(np.arange(3.0), np.ones(3))] + + def finish(self): + pass + + monkeypatch.setattr(lsmod, 'LombScargleAsyncProcess', Recording) + t, y, dy = self._lc() + lsmod.lomb_scargle_simple(t, y, dy, use_double=True, nharmonics=2) + assert seen[0] == dict(use_double=True) + assert seen[1] == dict(nharmonics=2) + del seen[:] + lsmod.lomb_scargle_simple(t, y, dy) + assert seen[0] == dict(use_double=False) + assert seen[1] == {} + + +class TestBaluevDKUsesEffectiveNharmonics(object): + """``batched_run_const_nfreq(only_return_best_freqs=True)`` computed + the Baluev ``d_K`` from the *process* attribute even when the call + overrode ``nharmonics=`` (which the memory settings and the + periodogram do honour): a 2-harmonic peak got a ``d_K=3`` FAP + (Sep-2026 readiness audit; Phase 2 verification carry-over). The + choice is now a pure helper fed the effective per-call value.""" + + def test_helper_values(self): + from ..lombscargle import _baluev_d_K + assert _baluev_d_K(1) == 3 + assert _baluev_d_K(2) == 5 + assert _baluev_d_K(3) == 7 + assert _baluev_d_K(np.int64(2)) == 5 + with pytest.raises(ValueError): + _baluev_d_K(0) + + def test_helper_tracks_the_memory_settings(self): + # the same resolution the memory settings use: a per-call + # nharmonics= written over the process default + from ..lombscargle import _baluev_d_K, _ls_memory_settings + kwargs_lsmem = dict(use_double=False, nharmonics=1, use_fft=True) + kwargs_lsmem.update(dict(nharmonics=2)) + settings = _ls_memory_settings(1500, 50, 8, 5, False, 1, True, + kwargs_lsmem) + assert settings['nharmonics'] == 2 + assert _baluev_d_K(kwargs_lsmem['nharmonics']) == 5 + assert _baluev_d_K(settings['nharmonics']) == 5 + + def test_per_call_nharmonics_sets_d_K_on_device(self, monkeypatch): + from .. import lombscargle as lsmod + seen = [] + real = lsmod.fap_baluev + + def recording(t, dy, z, fmax, d_K=3, **kw): + seen.append(int(d_K)) + return real(t, dy, z, fmax, d_K=d_K, **kw) + + monkeypatch.setattr(lsmod, 'fap_baluev', recording) + r = np.random.RandomState(3) + t = np.sort(r.uniform(0, 100.0, 100)) + y = 0.06 * np.sin(2 * np.pi * t / 1.7) + 0.05 * r.randn(100) + dy = 0.05 * np.ones(100) + freqs = 0.002 * (50 + np.arange(1500)) + + proc = LombScargleAsyncProcess() # process default H = 1 + assert proc.nharmonics == 1 + proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs, + nharmonics=2, + only_return_best_freqs=True) + assert seen == [5] + # and the process default still gives d_K = 3 on the next call + proc.batched_run_const_nfreq([(t, y, dy)], freqs=freqs, + only_return_best_freqs=True) + assert seen == [5, 3] diff --git a/cuvarbase/tests/test_mhgls_hybrid.py b/cuvarbase/tests/test_mhgls_hybrid.py new file mode 100644 index 00000000..54771872 --- /dev/null +++ b/cuvarbase/tests/test_mhgls_hybrid.py @@ -0,0 +1,191 @@ +"""CPU verification of the multiharmonic GLS hybrid path (C2, #...). + +The GPU multiharmonic Lomb-Scargle reads the NFFT spectra of ``w`` and +``w*(y-ybar)`` off the device and does the small per-frequency 2H x 2H +solve on the host via ``_mh_power_from_spectra``. Here we build those +spectra on the CPU (direct exponential sums, the same convention as +``mhdirect_sums``) and assert the hybrid power matches the tested +``lomb_scargle_direct_sums`` reference for H = 2 and 3 -- no GPU needed. +The remaining GPU-only question (that the real ``ghat_g`` layout matches +this convention) is covered by a pod smoke-test, queued separately. +""" +import numpy as np +import pytest + +from cuvarbase.lombscargle import (lomb_scargle_direct_sums, + _mh_power_from_spectra, + mhdirect_sums, + _mh_assemble_from_centered) + + +def _spectra(coef, t, k0, df, max_index): + """Adjoint-NFFT-style spectrum: entry k = sum_j coef_j exp(2 pi i + (k0+k) df t_j), so entry k holds the transform at frequency + (k0+k)*df (the layout the GPU emits).""" + kk = np.arange(max_index + 1) + f = (k0 + kk) * df + ang = 2 * np.pi * np.outer(f, t) # (n_k, n_data) + return (np.cos(ang) + 1j * np.sin(ang)) @ coef + + +def _data(H, seed=3): + rng = np.random.RandomState(seed) + n = 200 + t = np.sort(20.0 * rng.rand(n)) + f0 = 1.3 + y = (np.sin(2 * np.pi * f0 * t) + 0.4 * np.sin(2 * np.pi * 2 * f0 * t) + + 0.2 * np.cos(2 * np.pi * 3 * f0 * t) + 0.05 * rng.randn(n)) + dy = 0.05 * np.ones(n) + w = dy ** -2 + w = w / w.sum() + ybar = float(np.dot(w, y)) + YY = float(np.dot(w, (y - ybar) ** 2)) + + df = 1.0 / (5.0 * (t.max() - t.min())) + k0, nf = 3, 150 + + # spectra the GPU would emit: w-spectrum to 2H harmonics, w*(y-ybar) to H + sw = _spectra(w, t, k0, df, (2 * H - 1) * k0 + 2 * H * (nf - 1)) + syw = _spectra(w * (y - ybar), t, k0, df, (H - 1) * k0 + H * (nf - 1)) + + freqs = df * (k0 + np.arange(nf)) + return t, y, w, ybar, YY, k0, nf, freqs, sw, syw + + +@pytest.mark.parametrize("H", [2, 3]) +def test_mh_power_from_spectra_matches_direct_sums(H): + t, y, w, ybar, YY, k0, nf, freqs, sw, syw = _data(H) + + p_ref = lomb_scargle_direct_sums(t, w * y, w, freqs, YY, nharms=H) + p_hyb = _mh_power_from_spectra(sw, syw, k0, H, nf, YY) + + corr = np.corrcoef(p_ref, p_hyb)[0, 1] + assert corr > 0.999, "corr=%.6f for H=%d" % (corr, H) + # both float64 here -> should agree to ~machine precision + np.testing.assert_allclose(p_hyb, p_ref, rtol=1e-6, atol=1e-9) + + +def test_assemble_refactor_matches_mhdirect_sums(): + # _mh_assemble_from_centered fed the same moments mhdirect_sums computes + # internally must reproduce mhdirect_sums exactly (guards the refactor). + rng = np.random.RandomState(7) + n, H, freq = 120, 3, 0.37 + t = np.sort(15.0 * rng.rand(n)) + y = np.sin(2 * np.pi * freq * t) + 0.1 * rng.randn(n) + w = np.ones(n) / n + yw = w * y + YY = float(np.dot(w, (y - np.dot(w, y)) ** 2)) + + expected = mhdirect_sums(t, yw, w, freq, YY, nharms=H) + + phase = 2 * np.pi * ((t * freq) % 1.0) + c = [np.dot(w, np.cos(m * phase)) for m in range(2 * H + 1)] + s = [np.dot(w, np.sin(m * phase)) for m in range(2 * H + 1)] + ybar = float(np.sum(yw)) + C = np.asarray(c)[1:H + 1] + S = np.asarray(s)[1:H + 1] + yc = np.array([np.dot(yw, np.cos(m * phase)) for m in range(1, H + 1)]) + ys = np.array([np.dot(yw, np.sin(m * phase)) for m in range(1, H + 1)]) + got = _mh_assemble_from_centered(c, s, yc - ybar * C, ys - ybar * S, H) + + for a, b in zip(expected, got): + np.testing.assert_allclose(np.asarray(a), np.asarray(b), atol=1e-12) + + +def test_lombscargle_accepts_nharmonics_gt_1(): + # Previously LombScargleAsyncProcess(nharmonics>1) raised + # NotImplementedError; the GPU multiharmonic path is now implemented. + from cuvarbase.lombscargle import LombScargleAsyncProcess + proc = LombScargleAsyncProcess(nharmonics=3) + assert proc.nharmonics == 3 + with pytest.raises(ValueError): + LombScargleAsyncProcess(nharmonics=0) + + +def _mh_power_loop(sw, syw, k0, nharms, nf, YY, reg_kwargs=None): + """Reference: the per-frequency Python loop ``_mh_power_from_spectra`` + used before it was vectorized, written on the public helpers it + called (``_mh_assemble_from_centered`` + ``add_regularization`` + + ``mhgls_from_sums``, all unchanged).""" + from cuvarbase.lombscargle import add_regularization, mhgls_from_sums + H = int(nharms) + i = np.arange(nf) + cm = np.empty((2 * H + 1, nf), dtype=np.float64) + sm = np.empty((2 * H + 1, nf), dtype=np.float64) + cm[0], sm[0] = 1.0, 0.0 + for m in range(1, 2 * H + 1): + vals = sw[(m - 1) * k0 + m * i] + cm[m], sm[m] = vals.real, vals.imag + YC = np.empty((H, nf), dtype=np.float64) + YS = np.empty((H, nf), dtype=np.float64) + for h in range(1, H + 1): + vals = syw[(h - 1) * k0 + h * i] + YC[h - 1], YS[h - 1] = vals.real, vals.imag + power = np.empty(nf, dtype=np.float64) + for j in range(nf): + sums = _mh_assemble_from_centered(cm[:, j], sm[:, j], + YC[:, j], YS[:, j], H) + if reg_kwargs: + sums = add_regularization(sums, **reg_kwargs) + power[j] = mhgls_from_sums(sums, YY, 0.0) + return power + + +@pytest.mark.parametrize("H", [1, 2, 3]) +@pytest.mark.parametrize("prior", [None, 0.5, 'per-harmonic']) +def test_stacked_solve_matches_the_per_frequency_loop(H, prior): + """LS-5: ``_mh_power_from_spectra`` solves the 2H x 2H systems for + every frequency in one stacked ``np.linalg.solve`` instead of a + Python loop (60-90 us per frequency before; 84x faster at + nf = 20,000, H = 2 on the A40 pod host). Same arithmetic, so the + powers must agree to ~1e-15.""" + t, y, w, ybar, YY, k0, nf, freqs, sw, syw = _data(H) + if prior == 'per-harmonic': + prior = list(0.3 + 0.1 * np.arange(H)) + reg = None if prior is None else dict(amplitude_priors=prior) + + ref = _mh_power_loop(sw, syw, k0, H, nf, YY, reg_kwargs=reg) + got = _mh_power_from_spectra(sw, syw, k0, H, nf, YY, reg_kwargs=reg) + + assert got.shape == ref.shape + assert got.dtype == np.float64 + np.testing.assert_allclose(got, ref, rtol=1e-12, atol=1e-14) + if H == 1: + # the H = 1 assembly involves no reordering at all + assert np.array_equal(got, ref) + + +@pytest.mark.parametrize("H", [2, 3]) +def test_solve_chunking_does_not_change_the_result(H): + """The stack is solved in chunks of ``_MH_SOLVE_CHUNK`` frequencies + to bound the (chunk, 2H, 2H) temporary; the chunk size must not + touch the numbers.""" + import cuvarbase.lombscargle as lsmod + t, y, w, ybar, YY, k0, nf, freqs, sw, syw = _data(H) + full = _mh_power_from_spectra(sw, syw, k0, H, nf, YY) + old = lsmod._MH_SOLVE_CHUNK + try: + for chunk in (1, 7, nf - 1, nf, 10 * nf): + lsmod._MH_SOLVE_CHUNK = chunk + assert np.array_equal( + _mh_power_from_spectra(sw, syw, k0, H, nf, YY), full), chunk + finally: + lsmod._MH_SOLVE_CHUNK = old + + +def test_stacked_solve_is_not_a_python_loop(monkeypatch): + """Behavioural guard for LS-5: one ``np.linalg.solve`` call per + chunk, not one per frequency.""" + H = 2 + t, y, w, ybar, YY, k0, nf, freqs, sw, syw = _data(H) + calls = [] + real_solve = np.linalg.solve + + def counting_solve(a, b): + calls.append(np.shape(a)) + return real_solve(a, b) + + monkeypatch.setattr(np.linalg, 'solve', counting_solve) + _mh_power_from_spectra(sw, syw, k0, H, nf, YY) + assert len(calls) == 1, calls + assert calls[0] == (nf, 2 * H, 2 * H) diff --git a/cuvarbase/tests/test_nfft.py b/cuvarbase/tests/test_nfft.py index d982a13d..e6ef4e01 100644 --- a/cuvarbase/tests/test_nfft.py +++ b/cuvarbase/tests/test_nfft.py @@ -1,26 +1,19 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - -from builtins import zip -from builtins import range -from builtins import object import pytest import numpy as np from numpy.testing import assert_allclose from scipy import fftpack -from pycuda.tools import mark_cuda_test from pycuda import gpuarray -import skcuda.fft as cufft - -from nfft import nfft_adjoint as nfft_adjoint_cpu -from nfft.utils import nfft_matrix -from nfft.kernels import KERNELS - +from .. import _cufft as cufft from ..cunfft import NFFTAsyncProcess +# The optional 'nfft' package is the CPU reference for exactly two tests +# (the *_jvdp_nfft ones); they importorskip it themselves so that the +# other GPU tests in this module cannot silently skip when it is absent. +_NFFT_SKIP_REASON = ("the optional 'nfft' package is the CPU reference " + "for this test") + nfft_sigma = 5 nfft_m = 8 nfft_rtol = 5E-3 @@ -104,6 +97,8 @@ def simple_gpu_nfft(t, y, nf, sigma=nfft_sigma, use_double=False, def get_cpu_grid(t, y, nf, sigma=nfft_sigma, m=nfft_m): + from nfft.utils import nfft_matrix + from nfft.kernels import KERNELS kernel = KERNELS.get('gaussian', 'gaussian') mat = nfft_matrix(t, int(nf * sigma), m, sigma, kernel, truncated=True) return mat.T.dot(y) @@ -113,6 +108,7 @@ def get_cpu_grid(t, y, nf, sigma=nfft_sigma, m=nfft_m): class TestNFFT(object): def test_fast_gridding_with_jvdp_nfft(self): + pytest.importorskip("nfft", reason=_NFFT_SKIP_REASON) t, tsc, y, err = data() nf = int(nfft_sigma * len(t)) @@ -160,6 +156,7 @@ def test_slow_gridding_against_scalar_fast_gridding(self): assert_allclose(gpu_grid, cpu_grid, **tols) def test_slow_gridding_against_jvdp_nfft(self): + pytest.importorskip("nfft", reason=_NFFT_SKIP_REASON) t, tsc, y, err = data() nf = int(nfft_sigma * len(t)) @@ -254,6 +251,219 @@ def test_nfft_against_existing_impl_unscaled_centered_spp1(self): def test_nfft_against_existing_impl_unscaled_uncentered_spp5(self): self.nfft_against_direct_sums(samples_per_peak=5, scaled=False, f0=0.) + @pytest.mark.parametrize("use_double,tol", [(False, 1e-2), + (True, 1e-2), + (True, 1e-6)]) + def test_autoset_m_l1_bound_meets_tolerance(self, use_double, tol): + # autoset_m sizes the filter radius m from the data-driven + # L1-norm *truncation* bound (cunfft.estimate_m). We check both + # that estimate_m returns the closed-form bound value and that + # the realized GPU NFFT then meets the requested absolute + # tolerance against the exact DFT. + # + # float32 is held at tol=1e-2: single precision has a genuine + # ~1e-3 absolute error floor (float32 trig on large phases + + # grid/FFT roundoff -- see estimate_m's docstring). float64 is + # additionally checked at tol=1e-6, which the double path meets + # since the float-PI phase-factor fix (A3, Jul 2026); before + # that fix the realized error floored at ~1e-3 in both + # precisions. Note ||y||_1 (~67) < nf (500) here, so the + # chosen m is *smaller* than the old N-based heuristic -- this + # validates the rigorous-but-tighter direction. + t, tsc, y, err = data() + nf = int(nfft_sigma * len(t)) + sigma = 2 + + proc = NFFTAsyncProcess(sigma=sigma, autoset_m=True, tol=tol, + use_double=use_double) + + # estimate_m returns the smallest m with + # 4 exp(-m pi (1 - 1/(2 sigma - 1))) ||y||_1 <= tol + l1 = float(np.sum(np.abs(y))) + D = np.pi * (1. - 1. / (2. * sigma - 1.)) + m_expected = max(1, int(np.ceil(-np.log(0.25 * tol / l1) / D))) + assert proc.estimate_m(y=y) == m_expected + + results = proc.run([(tsc, y, nf)], + minimum_frequency=-int(nf / 2), + samples_per_peak=spp) + proc.finish() + gpu_nfft = results[0] + + freqs = -int(nf / 2) + np.arange(nf) + direct_dft = direct_sums(tsc, y, freqs) + + # float32 gridding/FFT roundoff adds noise unrelated to the + # truncation bound under test + roundoff = 1e-10 if use_double else 5e-6 + err_max = np.max(np.absolute(direct_dft - gpu_nfft)) + assert err_max <= tol + roundoff * np.sum(np.abs(y)) + + def test_double_precision_tracks_truncation_bound(self): + # Regression test for the float-PI phase-factor bug (A3, + # Jul 2026): cunfft.cu defined PI as a float32 literal, so the + # nfft_shift/normalize phases carried a ~2.8e-8 relative error + # that, multiplied by unreduced phase arguments up to + # 2*pi*|k0|, produced an m-independent ~1e-3 absolute error + # floor even at float64 (amplified with m by the Gaussian + # deconvolution). With the fix, the realized float64 error + # tracks the L1 truncation bound 4*exp(-m*D)*||y||_1; on the + # A5000 the m=12 error is 1.2e-10 vs a 3.3e-9 bound. We assert + # a 100x margin (buggy value was ~1e6 x the bound). + t, tsc, y, err = data() + nf = int(nfft_sigma * len(t)) + m, sigma = 12, 2 + + gpu_nfft = simple_gpu_nfft(tsc, y, nf, sigma=sigma, m=m, + use_double=True, + minimum_frequency=-int(nf / 2), + samples_per_peak=1) + + freqs = -int(nf / 2) + np.arange(nf) + direct_dft = direct_sums(tsc, y, freqs) + + D = np.pi * (1. - 1. / (2. * sigma - 1.)) + bound = 4. * np.exp(-m * D) * np.sum(np.abs(y)) + err_max = np.max(np.absolute(direct_dft - gpu_nfft)) + assert err_max <= 100. * bound + + def test_fast_grid_double_precision_floor(self): + # Regression test for floorf() on the double grid coordinate in + # fast_gaussian_grid (Sep 2026): a point whose scaled position + # ng*x - m lies within a float32 ulp below an integer K was + # floored to K after the float32 rounding, so its window was + # deposited one cell right of where precompute_psi (exact + # fraction) centred it. ng*t1 - m = 16 - 2^-30 here: floor is + # 15 in double, 16 after rounding to float32. t1 is exact in + # binary (ng is a power of two), so the case is deterministic. + nf, sigma, m = 32, 2, 4 + ng = sigma * nf + K = 20 + t1 = (K - 2.0 ** -30) / ng + t = np.array([0.0, t1, 1.0]) + y = np.array([0.0, 1.0, 0.0]) + b = get_b(sigma, m) + + ref = np.zeros(ng) + u = int(np.floor(ng * t1 - m)) + for k in range(2 * m + 1): + ref[(u + k) % ng] += np.exp(-((ng * t1 - (u + k)) ** 2) / b) \ + / np.sqrt(np.pi * b) + assert u == K - m - 1 + + grid = simple_gpu_nfft(t, y, nf, sigma=sigma, m=m, + use_double=True, + just_return_gridded_data=True, + fast_grid=True, minimum_frequency=0., + samples_per_peak=1) + grid = np.asarray(grid, dtype=np.float64) + + nonzero = np.flatnonzero(grid) + assert nonzero.min() == u and nonzero.max() == u + 2 * m + assert np.max(np.abs(grid - ref)) < 1e-12 + + @pytest.mark.parametrize("use_double,mag_tol,phase_tol", + [(False, 5e-5, 1e-3), (True, 3e-7, 1e-5)]) + def test_absolute_times_bjd(self, use_double, mag_tol, phase_tol): + # Regression test for defect 12 (nfft-absolute-time, Sep 2026): + # NFFTMemory.fromdata cast absolute times to float32 as given, + # so at BJD scale (~2.457e6 d, float32 spacing 0.25 d) the + # transform's MAGNITUDES were wrong (rel. error 0.8 on this + # data). fromdata now subtracts epoch = floor(min(t)) in + # float64 first and records it as memory.epoch; the phases are + # relative to that epoch (class docstring). + rng = np.random.RandomState(5) + n = 400 + t = np.sort(rng.rand(n)) * 30.0 + y = np.cos(2 * np.pi * 1.3 * t) + 0.1 * rng.randn(n) + nf = 256 + T = t.max() - t.min() + freqs = np.arange(nf) / T + + def exact(tt, epoch): + return direct_sums(tt - epoch, y, freqs) + + proc = NFFTAsyncProcess(sigma=nfft_sigma, m=nfft_m, + autoset_m=False, use_double=use_double) + + mem0 = proc.allocate([(t, y, nf)]) + proc.run([(t, y, nf)], memory=mem0) + proc.finish() + g0 = np.array(mem0[0].ghat_c) + assert mem0[0].epoch == 0.0 + scale = np.abs(exact(t, 0.0)).max() + + for offset in (1000.5, 2457000.5): + tb = t + offset + mem = proc.allocate([(tb, y, nf)]) + proc.run([(tb, y, nf)], memory=mem) + proc.finish() + g = np.array(mem[0].ghat_c) + + epoch = mem[0].epoch + assert epoch == np.floor(tb.min()) + + # magnitudes are shift-invariant and must match t ~ 0 + assert np.max(np.abs(np.abs(g) - np.abs(g0))) / scale < mag_tol + # phases follow the documented convention: relative to epoch + ref = exact(tb, epoch) + assert np.max(np.abs(g - ref)) / scale < mag_tol + phase_err = np.angle(g * np.conj(ref)) + assert np.sqrt(np.mean(phase_err ** 2)) < phase_tol + + @staticmethod + def _high_k0_case(seed=9, N=500, T=365.0, k0=20000, nf=2000, spp=5.0): + rng = np.random.RandomState(seed) + t = np.sort(rng.rand(N)) * T + y = rng.randn(N) + df = 1.0 / (spp * (t.max() - t.min())) + return t, y, df, k0, nf, spp + + def _run_band(self, proc, t, y, f0, k0, nf, spp): + # the one-sided modes k0 .. k0 + nf - 1 must sit inside the + # Gaussian window's alias-free band, so allocate k0 + nf modes + # (grid sigma * (k0 + nf)) and read the first nf entries + g = proc.run([(t, y, k0 + nf)], minimum_frequency=f0, + samples_per_peak=spp)[0] + proc.finish() + return np.array(g)[:nf] + + def test_minimum_frequency_rounds_to_an_integer_mode(self): + # id 104 (Sep 2026): nfft_shift / normalize computed the first + # mode k0 = f0 * spp * T as a FLT and used it as is; the periodic + # grid only has integer modes, so a fractional value -- from a + # user's f0 that is not a multiple of df, or from float32 + # rounding of the product (~k0 * 2e-7) -- produced a Dirichlet- + # leakage mixture. The kernels now round k0 to the nearest + # integer; f0 = (k0 + 0.3) df is therefore identical to k0 df. + t, y, df, k0, nf, spp = self._high_k0_case() + proc = NFFTAsyncProcess(sigma=4, m=8, autoset_m=False) + g_int = self._run_band(proc, t, y, k0 * df, k0, nf, spp) + g_frac = self._run_band(proc, t, y, (k0 + 0.3) * df, k0, nf, spp) + scale = np.abs(g_int).max() + assert np.max(np.abs(g_frac - g_int)) <= 1e-6 * scale + + @pytest.mark.parametrize("use_double,tol", [(False, 2e-3), + (True, 5e-9)]) + def test_large_k0_band_matches_exact_dft(self, use_double, tol): + # ids 98/160 (Sep 2026): the shift phase 2 pi (i mod ng) k0 / ng + # and the normalize phase 2 pi f_k x0 were evaluated un-reduced + # in float32 (arguments ~1e5 rad at k0 = 2e4 .. 5e5), giving + # 0.1-0.4 rad phase errors at the top of high-frequency bands. + # They are now reduced modulo one cycle (exact integer + # arithmetic / double) before the trig. Measured on an A40 + # (m = 12): float32 4.4e-3 -> 1.0e-3 relative to max|exact| + # (the rest is the float32 storage of t); double 3.5e-10. + t, y, df, k0, nf, spp = self._high_k0_case() + proc = NFFTAsyncProcess(sigma=4, m=12, autoset_m=False, + use_double=use_double) + g = self._run_band(proc, t, y, k0 * df, k0, nf, spp) + # phases are relative to epoch = floor(min t) (NFFTMemory notes) + exact = direct_sums(t - np.floor(t.min()), y, + (k0 + np.arange(nf)) * df) + err = np.max(np.abs(g - exact)) / np.abs(exact).max() + assert err < tol, err + def test_nfft_adjoint_async(self, f0=0., ndata=10, batch_size=3, use_double=False): datas = [] @@ -287,3 +497,344 @@ def test_nfft_adjoint_async(self, f0=0., ndata=10, assert_allclose(ghat_s.real, ghat_b.real, **tols) assert_allclose(ghat_s.imag, ghat_b.imag, **tols) + + +class _FakePtr(object): + ptr = 0 + + +class _FakeKernel(object): + def __init__(self): + self.calls = [] + + def prepared_async_call(self, *args): + self.calls.append(args) + + +class _FakeStream(object): + def synchronize(self): + pass + + +class _FakeGrid(object): + ptr = 0 + + def __init__(self, n): + self.n = n + + def fill(self, value, stream=None): + pass + + def get(self): + return np.zeros(self.n, dtype=np.complex64) + + +class _FakeNFFTMemory(object): + """Just enough of NFFTMemory for nfft_adjoint_async's gridding + dispatch (just_return_gridded_data=True stops right after it).""" + + def __init__(self, precomp_psi): + self.precomp_psi = precomp_psi + self.stream = _FakeStream() + self.real_type = np.float32 + self.complex_type = np.complex64 + self.n0, self.nf, self.m = 20, 40, 4 + self.n = 200 + self.b = 1.5 + self.tmin, self.tmax = 0.0, 1.0 + self.t_g, self.y_g = _FakePtr(), _FakePtr() + self.ghat_g = _FakeGrid(self.n) + # exactly what NFFTMemory holds when built with precomp_psi=False + self.q1 = self.q2 = self.q3 = (_FakePtr() if precomp_psi else None) + + def transfer_data_to_gpu(self): + pass + + +class TestPrecompPsiDispatch(object): + """``precomp_psi=False`` used to raise AttributeError: the gridding + branch dispatched on ``fast_grid`` alone and then dereferenced the + psi tables ``q1/q2/q3`` that ``NFFTMemory`` only allocates with + ``precomp_psi=True`` (Sep-2026 readiness audit; Phase 2 verification + carry-over). It now uses the inline-psi ``slow_gaussian_grid`` + kernel, and the default path is untouched. These run without a + device on fake kernels.""" + + @staticmethod + def _call(memory, **kwargs): + from ..cunfft import nfft_adjoint_async + names = ('precompute_psi', 'fast_gaussian_grid', + 'slow_gaussian_grid', 'nfft_shift', 'normalize') + funcs = dict((n, _FakeKernel()) for n in names) + out = nfft_adjoint_async(memory, tuple(funcs[n] for n in names), + just_return_gridded_data=True, **kwargs) + assert out.shape == (memory.n,) + return dict((n, len(funcs[n].calls)) for n in names) + + def test_memory_without_psi_tables_uses_the_inline_kernel(self): + calls = self._call(_FakeNFFTMemory(precomp_psi=False)) + assert calls['slow_gaussian_grid'] == 1 + assert calls['precompute_psi'] == 0 + assert calls['fast_gaussian_grid'] == 0 + + def test_kwarg_false_uses_the_inline_kernel(self): + # a memory that has tables but a call that asks not to use them + calls = self._call(_FakeNFFTMemory(precomp_psi=True), + precomp_psi=False) + assert calls['slow_gaussian_grid'] == 1 + assert calls['precompute_psi'] == 0 + assert calls['fast_gaussian_grid'] == 0 + + def test_default_path_is_unchanged(self): + calls = self._call(_FakeNFFTMemory(precomp_psi=True)) + assert calls['precompute_psi'] == 1 + assert calls['fast_gaussian_grid'] == 1 + assert calls['slow_gaussian_grid'] == 0 + + def test_fast_grid_false_still_uses_the_inline_kernel(self): + calls = self._call(_FakeNFFTMemory(precomp_psi=True), + fast_grid=False) + assert calls['slow_gaussian_grid'] == 1 + assert calls['precompute_psi'] == 0 + + def test_missing_tables_with_precomp_psi_true_is_a_clear_error(self): + mem = _FakeNFFTMemory(precomp_psi=True) + mem.q1 = None + with pytest.raises(ValueError, match='q1/q2/q3'): + self._call(mem) + + +class TestPrecompPsiFalseOnDevice(object): + """End to end on the GPU (skips without one): ``precomp_psi=False`` + through ``NFFTAsyncProcess.run`` now returns the transform instead + of raising AttributeError, and it agrees with the default path and + with the exact direct sums.""" + + def test_precomp_psi_false_matches_default(self): + t, tsc, y, err = data(ndata=100) + nf = int(nfft_sigma * len(t)) + kw = dict(sigma=nfft_sigma, m=nfft_m, minimum_frequency=0., + samples_per_peak=spp) + ref = np.array(simple_gpu_nfft(t, y, nf, **kw)) + proc = NFFTAsyncProcess(sigma=nfft_sigma, m=nfft_m, autoset_m=False) + mem = proc.allocate([(t, y, nf)], precomp_psi=False) + assert mem[0].precomp_psi is False + assert mem[0].q1 is None and mem[0].q2 is None and mem[0].q3 is None + got = np.array(proc.run([(t, y, nf)], memory=mem, + minimum_frequency=0., samples_per_peak=spp, + precomp_psi=False)[0]) + proc.finish() + scale = np.max(np.abs(ref)) + assert np.all(np.isfinite(got)) + # the inline-psi kernel differs from the factorized table + # product by float32 roundoff and atomic order only + assert np.max(np.abs(got - ref)) / scale < 1e-4 + # ... and both are the transform (phases relative to floor(min t)) + exact = direct_sums(t - np.floor(t.min()), y, + np.arange(nf) / (spp * (t.max() - t.min()))) + assert np.max(np.abs(got - exact)) / scale < 5e-3 + + def test_precomp_psi_false_through_run_kwargs(self): + # the kwarg alone (run allocates the memory itself) + t, tsc, y, err = data(ndata=60) + nf = int(nfft_sigma * len(t)) + ref = np.array(simple_gpu_nfft(t, y, nf, sigma=nfft_sigma, + m=nfft_m, minimum_frequency=0., + samples_per_peak=spp)) + got = np.array(simple_gpu_nfft(t, y, nf, sigma=nfft_sigma, + m=nfft_m, minimum_frequency=0., + samples_per_peak=spp, + precomp_psi=False)) + assert np.max(np.abs(got - ref)) / np.max(np.abs(ref)) < 1e-4 + + +class TestPerCallUseDoubleIsRejected(object): + """``use_double`` is fixed when the process is constructed (the + kernels are compiled in that precision). A per-call value that + differs raises ``ValueError`` before any device work -- before 1.0 + it raised ``TypeError`` through ``allocate`` and, with a + user-supplied ``memory``, silently ran float32 kernels on float64 + buffers -- and a memory allocated at the other precision is + rejected the same way (Sep-2026 readiness review, idx 23). CPU + tests: nothing below the check is reached.""" + + @staticmethod + def _no_device_work(proc, monkeypatch): + touched = [] + monkeypatch.setattr(proc, '_compile_and_prepare_functions', + lambda **kw: touched.append('compile')) + monkeypatch.setattr(proc, '_create_streams', + lambda n: touched.append('streams')) + return touched + + @pytest.mark.parametrize('process_double', [False, True]) + def test_run_raises_before_device_work(self, process_double, + monkeypatch): + proc = NFFTAsyncProcess(use_double=process_double) + touched = self._no_device_work(proc, monkeypatch) + t = np.sort(np.random.RandomState(1).rand(50)) + y = np.random.RandomState(2).randn(50) + with pytest.raises(ValueError, + match=r'NFFTAsyncProcess\(use_double='): + proc.run([(t, y, 100)], use_double=not process_double) + with pytest.raises(ValueError, match='use_double'): + proc.allocate([(t, y, 100)], use_double=not process_double) + assert touched == [] + + def test_memory_at_the_other_precision_raises(self, monkeypatch): + proc = NFFTAsyncProcess() + touched = self._no_device_work(proc, monkeypatch) + + class Mem(object): + use_double = True + + with pytest.raises(ValueError, match='use_double'): + proc.run(None, memory=[Mem()]) + assert touched == [] + + def test_matching_use_double_is_accepted(self): + # equal to the process precision: dropped, and the transform is + # the one the plain call gives (bitwise: same buffers, same + # launches -- the NFFT of a single light curve is deterministic + # apart from the gridding atomics, which a 50-point light curve + # on a 400-point grid does not exercise) + t = np.sort(np.random.RandomState(1).rand(50)) + y = np.random.RandomState(2).randn(50) + proc = NFFTAsyncProcess(sigma=nfft_sigma, m=nfft_m, autoset_m=False) + g0 = np.array(proc.run([(t, y, 100)])[0]) + g1 = np.array(proc.run([(t, y, 100)], use_double=False)[0]) + assert_allclose(g1, g0, rtol=1e-6, atol=1e-6) + + +class TestFirstModeIsExactOnTheHost(object): + """``nfft_shift``/``normalize`` re-derived the integer first mode as + ``rint(f0 * spp * (xf - x0))`` from their float32 arguments, whose + rounding reaches half a mode from ``k0 ~ 2e6`` upward -- and the two + kernels' different association orders could round to different + integers, shifting the band by one mode in one of them (Sep-2026 + readiness review, idx 24; the rint itself was id 104). The host now + computes ``k0`` in float64 (:func:`cuvarbase.cunfft._first_mode`) + and passes the integer to both kernels.""" + + # the geometry of the device test below: epoch-relative times in + # [0.25, T + 0.25] over T = 1612.9 d at 5 samples per peak + T, TMIN, SPP = 1612.916152213505, 0.25, 5.0 + # at k0 = 4213813 both kernels rounded to 4213812 (the whole band + # shifted by one mode); at 4229651 nfft_shift rounded to 4229652 + # while normalize got 4229651 (an inconsistent transform) + K0_BOTH_OFF, K0_INCONSISTENT = 4213813, 4229651 + + @staticmethod + def _old_float32_chain(k0, tmin, tmax, spp): + # the kernels' arguments as the host cast them, and each + # kernel's own association of the FLT product + f32 = np.float32 + df = 1.0 / (spp * (tmax - tmin)) + x0, xf, s, f = f32(tmin), f32(tmax), f32(spp), f32(k0 * df) + shift = int(np.rint((f * s) * (xf - x0))) + norm = int(np.rint(f * (s * (xf - x0)))) + return shift, norm + + def test_the_float32_chain_misrounded(self): + tmin, tmax = self.TMIN, self.T + self.TMIN + k0 = self.K0_BOTH_OFF + assert self._old_float32_chain(k0, tmin, tmax, self.SPP) \ + == (k0 - 1, k0 - 1) + k0 = self.K0_INCONSISTENT + assert self._old_float32_chain(k0, tmin, tmax, self.SPP) \ + == (k0 + 1, k0) + # ... and no misround at all in the survey regime below ~2e6 + rng = np.random.RandomState(4) + for _ in range(2000): + k0 = int(rng.uniform(1, 2e6)) + tmin = rng.rand() + tmax = tmin + rng.uniform(100, 3650) + assert self._old_float32_chain(k0, tmin, tmax, 5.0) == (k0, k0) + + def test_host_first_mode_is_exact(self): + from ..cunfft import _first_mode + rng = np.random.RandomState(5) + for _ in range(5000): + k0 = int(rng.uniform(1, 1e9)) + T = rng.uniform(1, 1e4) + spp = rng.uniform(1, 50) + tmin = rng.uniform(0, 1) + df = 1.0 / (spp * T) + assert _first_mode(k0 * df, spp, tmin, tmin + T) == k0 + # the two misrounding cases above + tmin, tmax = self.TMIN, self.T + self.TMIN + for k0 in (self.K0_BOTH_OFF, self.K0_INCONSISTENT): + df = 1.0 / (self.SPP * (tmax - tmin)) + assert _first_mode(k0 * df, self.SPP, tmin, tmax) == k0 + # a fractional first mode rounds to the nearest integer mode + # (id 104), negative modes are legal, and the result is an int + assert _first_mode(20.3 / 100.0, 1.0, 0.0, 100.0) == 20 + assert _first_mode(-50.0, 1.0, 0.0, 1.0) == -50 + assert isinstance(_first_mode(3.0, 1.0, 0.0, 1.0), int) + assert _first_mode(0.0, 1.0, 0.0, 1.0) == 0 + with pytest.raises(ValueError, match='int32'): + _first_mode(3e9, 1.0, 0.0, 1.0) + + def test_kernels_receive_the_integer_mode(self, monkeypatch): + """On fake kernels: the last argument of ``nfft_shift`` and + ``normalize`` is the host's ``np.int32`` first mode (not a + float frequency), and the shift is skipped for ``k0 = 0``.""" + from .. import cunfft as cunfft_mod + monkeypatch.setattr(cunfft_mod.cufft, 'ifft', + lambda *a, **k: None) + names = ('precompute_psi', 'fast_gaussian_grid', + 'slow_gaussian_grid', 'nfft_shift', 'normalize') + + def run(minimum_frequency, spp): + funcs = dict((n, _FakeKernel()) for n in names) + mem = _FakeNFFTMemory(precomp_psi=True) # tmin, tmax = 0, 1 + mem.ghat_c = None + mem.cu_plan = None + cunfft_mod.nfft_adjoint_async( + mem, tuple(funcs[n] for n in names), + minimum_frequency=minimum_frequency, samples_per_peak=spp, + transfer_to_host=False) + return funcs + + funcs = run(20.0, 1.0) # k0 = 20 * 1 * 1 + assert len(funcs['nfft_shift'].calls) == 1 + for name in ('nfft_shift', 'normalize'): + last = funcs[name].calls[0][-1] + assert isinstance(last, np.int32) + assert last == 20 + funcs = run(0.0, 1.0) + assert len(funcs['nfft_shift'].calls) == 0 + assert funcs['normalize'].calls[0][-1] == 0 + # a fractional first mode is rounded on the host (id 104) + funcs = run(20.3, 1.0) + assert funcs['nfft_shift'].calls[0][-1] == 20 + + @pytest.mark.parametrize("k0", [K0_BOTH_OFF, K0_INCONSISTENT]) + def test_large_k0_band_in_double_matches_exact_dft(self, k0): + # GPU: the int32 mode argument end to end at a k0 where the + # float32 chain misrounded (in double the old kernels rounded + # correctly, so this pins the new plumbing rather than the old + # defect; the float32 build is not meaningful at k0 ~ 4e6 -- + # its grid coordinate ulp is ~2 cells of a 1.7e7-point grid). + # ~280 MB of complex128 grid. + rng = np.random.RandomState(11) + N, nf, spp, T = 300, 48, self.SPP, self.T + t = np.sort(rng.rand(N)) * T + t = t - t.min() + self.TMIN # tmin = 0.25: exercises the + t[-1] = T + self.TMIN # x0 phase; T is the baseline + y = rng.randn(N) + df = 1.0 / (spp * (t.max() - t.min())) + proc = NFFTAsyncProcess(sigma=4, m=12, autoset_m=False, + use_double=True) + g = proc.run([(t, y, k0 + nf)], minimum_frequency=k0 * df, + samples_per_peak=spp)[0] + proc.finish() + g = np.array(g)[:nf] + exact = direct_sums(t - np.floor(t.min()), y, + (k0 + np.arange(nf)) * df) + scale = np.abs(exact).max() + assert np.max(np.abs(g - exact)) / scale < 1e-7 + # a one-mode shift would be an O(1) error at spp = 5 + shifted = direct_sums(t - np.floor(t.min()), y, + (k0 + 1 + np.arange(nf)) * df) + assert np.max(np.abs(shifted - exact)) / scale > 1e-2 diff --git a/cuvarbase/tests/test_nfft_m.py b/cuvarbase/tests/test_nfft_m.py new file mode 100644 index 00000000..a72c17b0 --- /dev/null +++ b/cuvarbase/tests/test_nfft_m.py @@ -0,0 +1,107 @@ +"""CPU-side tests for the NFFT filter-radius (m) selection. + +These exercise ``NFFTAsyncProcess.estimate_m``/``get_m`` only -- no +GPU work -- so they run on CPU-only machines (under the conftest +stubs) as well as on the pod. +""" +import numpy as np +import pytest + +from ..cunfft import NFFTAsyncProcess +from ..memory.nfft_memory import next_fast_len + + +def _D(sigma): + return np.pi * (1. - 1. / (2. * sigma - 1.)) + + +class TestEstimateM(object): + + def _proc(self, tol=1e-8, sigma=4): + return NFFTAsyncProcess(sigma=sigma, autoset_m=True, tol=tol) + + @pytest.mark.parametrize("tol", [1e-4, 1e-8, 1e-12]) + @pytest.mark.parametrize("sigma", [2, 4]) + @pytest.mark.parametrize("scale", [1e-3, 1.0, 1e3]) + def test_l1_bound_is_rigorous_and_minimal(self, tol, sigma, scale): + # The chosen m must satisfy 4 exp(-m D) ||y||_1 <= tol, and be + # the smallest such integer (no over-padding). + proc = self._proc(tol=tol, sigma=sigma) + rand = np.random.RandomState(42) + y = scale * rand.randn(500) + + m = proc.estimate_m(y=y) + l1 = np.sum(np.abs(y)) + D = _D(sigma) + + assert 4 * np.exp(-m * D) * l1 <= tol + if m > 1: + assert 4 * np.exp(-(m - 1) * D) * l1 > tol + + def test_fallback_heuristic_unchanged(self): + # Without data, estimate_m must reproduce the historical + # jakevdp/nfft heuristic exactly. + proc = self._proc() + N = 1024 + expected = proc.m_from_C(proc.m_tol / N, proc.sigma) + assert proc.estimate_m(N) == expected + assert proc.get_m(N) == expected + + def test_fallback_clamps_m_to_at_least_one(self): + # Pathological tolerance (m_tol > 4N) used to return m <= 0 on + # the N-fallback path (the y-path was already clamped), giving + # a negative Gaussian shape parameter b and garbage gridding. + proc = self._proc(tol=1e6) + assert proc.estimate_m(100) >= 1 + assert proc.get_m(100) >= 1 + + def test_data_driven_m_scales_with_l1_norm(self): + proc = self._proc(tol=1e-8, sigma=4) + N = 1000 + m_small = proc.get_m(N, y=1e-3 * np.ones(N)) + m_heur = proc.get_m(N) + m_big = proc.get_m(N, y=1e3 * np.ones(N)) + # ||y||_1 = 1 < N < ||y||_1 = 1e6 + assert m_small < m_heur < m_big + + def test_zero_data_returns_minimal_m(self): + proc = self._proc() + assert proc.estimate_m(y=np.zeros(16)) == 1 + + def test_estimate_m_requires_N_or_y(self): + proc = self._proc() + with pytest.raises(ValueError, match="requires N"): + proc.estimate_m() + + def test_autoset_false_ignores_data(self): + proc = NFFTAsyncProcess(m=8, autoset_m=False) + assert proc.get_m() == 8 + assert proc.get_m(100, y=1e6 * np.ones(100)) == 8 + + +class TestNextFastLen(object): + """``next_fast_len`` (7-smooth padding of the NFFT grids, Sep 2026) + must return the smallest 2^a 3^b 5^c 7^d >= n.""" + + @staticmethod + def _smooth(x): + for p in (2, 3, 5, 7): + while x % p == 0: + x //= p + return x == 1 + + def test_matches_brute_force(self): + for n in list(range(1, 3000)) + [145996, 291996, 2920004]: + got = next_fast_len(n) + assert got >= max(n, 1) + assert self._smooth(got) + # minimal: nothing 7-smooth in [n, got) + assert not any(self._smooth(x) for x in range(max(n, 1), got)) + + def test_fixed_points_and_edges(self): + assert next_fast_len(0) == 1 + assert next_fast_len(1) == 1 + assert next_fast_len(7) == 7 + assert next_fast_len(11) == 12 + assert next_fast_len(1024) == 1024 + assert next_fast_len(1025) == 1029 # 3 * 7^3 diff --git a/cuvarbase/tests/test_nufft_lrt.py b/cuvarbase/tests/test_nufft_lrt.py new file mode 100644 index 00000000..85494b24 --- /dev/null +++ b/cuvarbase/tests/test_nufft_lrt.py @@ -0,0 +1,737 @@ +""" +GPU tests for the NUFFT-based Likelihood Ratio Test (LRT) transit search. + +Every random draw is seeded (``np.random.RandomState``); the data models +mirror the audit repro scripts (``analysis/audit-sep2026/repro/local/`` +vfy-lrt-bjd, verify-lrt-epochs, vseq, vfy-detA, verify-lrt-band) and the +validation harness (``benchmarks/nufft_lrt/validate.py``). +""" +import pytest +import numpy as np +from numpy.testing import assert_allclose +from pycuda.tools import mark_cuda_test + +try: + from ..nufft_lrt import NUFFTLRTAsyncProcess, epoch_grid + from ..cunfft import NFFTAsyncProcess + NUFFT_LRT_AVAILABLE = True +except ImportError: + NUFFT_LRT_AVAILABLE = False + +pytestmark = pytest.mark.filterwarnings( + "ignore:cuvarbase.nufft_lrt is EXPERIMENTAL") + +BJD_OFFSET = 2457000.5 + + +# ------------------------------------------------------------ data models + +def ground_times(rng, baseline=90.0, n=600): + """Nightly visibility windows with weather losses (the harness's + 'ground' sampling).""" + nights = np.arange(int(baseline)) + nights = nights[rng.rand(len(nights)) > 0.35] + per_night = max(1, int(round(n / max(len(nights), 1)))) + t = (nights[:, None] + 0.25 * rng.rand(len(nights), per_night)).ravel() + return np.sort(t[:n]) + + +def ou_noise(rng, t, sigma_red, tau): + x = np.zeros(len(t)) + x[0] = sigma_red * rng.randn() + for i in range(1, len(t)): + a = np.exp(-(t[i] - t[i - 1]) / tau) + x[i] = x[i - 1] * a + sigma_red * np.sqrt(1 - a * a) * rng.randn() + return x + + +def box_transit(t, period, epoch, duration, depth): + phase = np.fmod(t - epoch, period) / period + phase[phase < 0] += 1.0 + phase[phase > 0.5] -= 1.0 + y = np.zeros_like(t) + y[np.abs(phase) <= duration / (2.0 * period)] = -depth + return y + + +def adjoint_dft(t, y, nf, chunk=1024): + """Exact float64 adjoint DFT at the GPU convention (modes k = 0..nf-1, + f_k = k / (max t - min t)).""" + t = np.asarray(t, np.float64) + y = np.asarray(y, np.float64) + x = t / (t.max() - t.min()) + out = np.empty(nf, np.complex128) + for a in range(0, nf, chunk): + k = np.arange(a, min(nf, a + chunk)) + out[a:a + len(k)] = np.exp(2j * np.pi * np.outer(k, x)) @ y + return out + + +def population_basis(rng, t, baseline, sigma_w, sigma_r, tau, amps, + n_pop=40, K=3): + """The harness's paper-style systematics model: three shared modes, a + PCA basis from a signal-free population and a coefficient prior from + per-lightcurve fits.""" + m1 = (t - t.mean()) / (0.5 * baseline) + tn = t - np.floor(t) - 0.125 + m2 = (tn / 0.125) ** 2 - 0.5 + m3 = np.sin(2 * np.pi * t / (0.4 * baseline)) + M = np.stack([m1, m2, m3], axis=1) + M = M / np.std(M, axis=0) + pop = np.empty((n_pop, len(t))) + for i in range(n_pop): + c = rng.randn(M.shape[1]) * amps + y = 1.0 + sigma_w * rng.randn(len(t)) + ou_noise(rng, t, sigma_r, tau) + pop[i] = y + M @ c + pop -= pop.mean(axis=1, keepdims=True) + _, _, VT = np.linalg.svd(pop, full_matrices=False) + V = VT[:K].T + coeffs = pop @ V + return M, V, coeffs.mean(axis=0), np.cov(coeffs.T) + + +def _log_period_grid(p_true, n=16, lo=2.0, hi=18.0): + periods = np.exp(np.linspace(np.log(lo), np.log(hi), n)) + periods[np.argmin(np.abs(periods - p_true))] = p_true + return periods + + +# -------------------------------------------------------------- the tests + +@pytest.mark.skipif(not NUFFT_LRT_AVAILABLE, + reason="NUFFT LRT not available") +class TestNUFFTLRT: + """Test NUFFT LRT functionality""" + + def setup_method(self): + """Set up test fixtures""" + self.n_data = 100 + self.rng = np.random.RandomState(20260904) + self.t = np.sort(self.rng.uniform(0, 10, self.n_data)) + + def generate_transit_signal(self, t, period, epoch, duration, depth): + """Generate a simple transit signal""" + return box_transit(np.asarray(t, np.float64), period, epoch, + duration, depth) + + @mark_cuda_test + def test_basic_initialization(self): + """Test that NUFFTLRTAsyncProcess can be initialized""" + proc = NUFFTLRTAsyncProcess() + assert proc is not None + # sigma = 4 keeps the full returned band k = 0..nf-1 inside the + # Gaussian window's accuracy band (sigma = 2 aliased k >= nf/2) + assert proc.sigma == 4.0 + assert proc.nufft_proc.sigma == 4.0 + assert proc.use_double is False + + @mark_cuda_test + def test_template_generation(self): + """Test transit template generation""" + proc = NUFFTLRTAsyncProcess() + + period = 2.0 + epoch = 0.0 + duration = 0.2 + depth = 1.0 + + template = proc._generate_template( + self.t, period, epoch, duration, depth + ) + + # Check template properties + assert len(template) == len(self.t) + assert np.min(template) == -depth + assert np.max(template) == 0.0 + + # Check that some points are in transit + in_transit = template < 0 + assert np.sum(in_transit) > 0 + assert np.sum(in_transit) < len(template) + + @mark_cuda_test + def test_nufft_computation(self): + """Test NUFFT computation""" + proc = NUFFTLRTAsyncProcess() + + # Generate simple sinusoidal signal + y = np.sin(2 * np.pi * self.t / 2.0) + + nf = 2 * len(self.t) + Y_nufft = proc.compute_nufft(self.t, y, nf) + + # Check output properties + assert len(Y_nufft) == nf + assert Y_nufft.dtype in [np.complex64, np.complex128] + + # Peak should be near the signal frequency. The adjoint NFFT + # returns Fourier coefficients at modes k = 0..nf-1, i.e. + # frequencies f_k = k / (max(t) - min(t)) -- NOT the rfft grid. + freqs = np.arange(nf) / (self.t.max() - self.t.min()) + power = np.abs(Y_nufft) ** 2 + peak_freq_idx = np.argmax(power[1:]) + 1 # Skip DC + peak_freq = freqs[peak_freq_idx] + + # Should be close to 0.5 Hz (period 2.0) + assert np.abs(peak_freq - 0.5) < 0.1 + + @mark_cuda_test + def test_matched_filter_snr_computation(self): + """Test matched filter SNR computation""" + proc = NUFFTLRTAsyncProcess() + + # Generate signals + nf = 200 + Y = self.rng.randn(nf) + 1j * self.rng.randn(nf) + T = self.rng.randn(nf) + 1j * self.rng.randn(nf) + P_s = np.ones(nf) + weights = np.ones(nf) + + snr = proc._compute_matched_filter_snr( + Y, T, P_s, weights, eps_floor=1e-12 + ) + + # SNR should be a finite scalar + assert np.isfinite(snr) + assert isinstance(snr, (float, np.floating)) + + @mark_cuda_test + def test_detection_of_known_transit(self): + """Detection of a known transit at a NON-zero epoch with the + default ``epochs=None`` (automatic epoch grid). Before the + Sep-2026 fix ``epochs=None`` evaluated a single phase-0 template, + and this test passed only because it injected at epoch 0.""" + proc = NUFFTLRTAsyncProcess() + + # Generate transit signal + true_period = 2.5 + true_duration = 0.2 + true_epoch = 0.7 + depth = 0.5 + noise_level = 0.1 + + signal = self.generate_transit_signal( + self.t, true_period, true_epoch, true_duration, depth + ) + noise = noise_level * self.rng.randn(len(self.t)) + y = signal + noise + + # Search over periods + periods = np.linspace(2.0, 3.0, 20) + durations = np.array([true_duration]) + + snr, best_epoch = proc.run(self.t, y, periods, durations=durations) + + # Check output shape + assert snr.shape == (len(periods), len(durations)) + assert best_epoch.shape == snr.shape + + # Peak within two grid steps of the true period, best epoch + # within a transit duration of the truth (mod P) + best_period_idx = np.argmax(snr[:, 0]) + best_period = periods[best_period_idx] + step = periods[1] - periods[0] + assert np.abs(best_period - true_period) <= 2 * step + 1e-9 + d = np.abs(best_epoch[best_period_idx, 0] - true_epoch) % true_period + d = min(d, true_period - d) + assert d < true_duration + + @mark_cuda_test + def test_white_noise_gives_low_snr(self): + """Test that white noise gives low SNR""" + proc = NUFFTLRTAsyncProcess() + + # Pure white noise + y = self.rng.randn(len(self.t)) + + periods = np.array([2.0, 3.0, 4.0]) + durations = np.array([0.2]) + + snr = proc.run(self.t, y, periods, durations=durations, + epochs=np.array([0.0])) + + # SNR should be relatively low for pure noise (single template) + assert np.all(np.abs(snr) < 5.0) + # and bounded after the max over the automatic epoch grid + # (measured 3.6) + snr_max, _ = proc.run(self.t, y, periods, durations=durations) + assert np.all(snr_max < 8.0) + + @mark_cuda_test + def test_custom_psd(self): + """Test using custom power spectrum""" + proc = NUFFTLRTAsyncProcess() + + # Generate simple signal + y = np.sin(2 * np.pi * self.t / 2.0) + 0.1 * self.rng.randn(len(self.t)) + + periods = np.array([2.0]) + durations = np.array([0.2]) + nf = 2 * len(self.t) + + # Create custom PSD (flat spectrum) + custom_psd = np.ones(nf) + + snr, best_epoch = proc.run( + self.t, y, periods, durations=durations, + nf=nf, estimate_psd=False, psd=custom_psd + ) + + # Should run without error + assert snr.shape == (1, 1) + assert np.isfinite(snr[0, 0]) + + @mark_cuda_test + def test_user_psd_zero_bin_is_floored(self): + """audit id 121: a zero bin in a user PSD gave SNR ~1e6 (matched; + measured -998957 on the base tree) or nan (marginal); the PSD is + now floored at eps_floor * median once in run() for every + detector -- the result equals a run with the bin explicitly set + to that floor -- and its length is validated.""" + proc = NUFFTLRTAsyncProcess() + y = self.rng.randn(len(self.t)) + nf = 2 * len(self.t) + psd = np.ones(nf) + psd[37] = 0.0 + psd_floored = psd.copy() + psd_floored[37] = 1e-3 # eps_floor * median + kw = dict(durations=np.array([0.2]), epochs=np.array([0.3]), nf=nf, + estimate_psd=False) + got = proc.run(self.t, y, np.array([2.0]), psd=psd, **kw) + want = proc.run(self.t, y, np.array([2.0]), psd=psd_floored, **kw) + assert np.all(np.isfinite(got)) + assert_allclose(got, want, rtol=1e-6) + V = np.sin(self.t)[:, None] + mkw = dict(detector='marginal', systematics_basis=V, + coeff_prior_cov=np.array([[1.0]])) + marg = proc.run(self.t, y, np.array([2.0]), psd=psd, **mkw, **kw) + marg_want = proc.run(self.t, y, np.array([2.0]), psd=psd_floored, + **mkw, **kw) + assert np.all(np.isfinite(marg)) + assert_allclose(marg, marg_want, rtol=1e-6) + with pytest.raises(ValueError, match="length nf"): + proc.run(self.t, y, np.array([2.0]), psd=np.ones(nf + 5), **kw) + + @mark_cuda_test + def test_double_precision(self): + """Test double precision computation""" + proc = NUFFTLRTAsyncProcess(use_double=True) + + y = np.sin(2 * np.pi * self.t / 2.0) + periods = np.array([2.0]) + durations = np.array([0.2]) + + snr, best_epoch = proc.run(self.t, y, periods, durations=durations) + + assert snr.shape == (1, 1) + assert np.isfinite(snr[0, 0]) + + @mark_cuda_test + def test_marginal_detector_ignores_shared_systematic(self): + """Detector A (marginalized joint detector): a strong + basis-aligned trend must not derail the period search, and the + detector must rank the true period more sharply than the plain + matched filter on the same data (the contrast this test always + promised; release finding 105).""" + proc = NUFFTLRTAsyncProcess() + + true_period, true_duration, depth = 2.5, 0.25, 0.5 + signal = self.generate_transit_signal( + self.t, true_period, 0.0, true_duration, depth) + trend = np.sin(2 * np.pi * self.t / 9.0) # slow systematic + rng = np.random.RandomState(11) + y = signal + 4.0 * trend + 0.1 * rng.randn(len(self.t)) + + periods = np.linspace(2.0, 3.0, 20) + durations = np.array([true_duration]) + epochs = np.array([0.0]) + V = trend[:, None] + + snr_marg = proc.run(self.t, y, periods, durations=durations, + epochs=epochs, + detector='marginal', systematics_basis=V, + coeff_prior_cov=np.array([[100.0]]))[:, 0, 0] + snr_matched = proc.run(self.t, y, periods, durations=durations, + epochs=epochs)[:, 0, 0] + step = periods[1] - periods[0] + i_true = int(np.argmin(np.abs(periods - true_period))) + best = periods[int(np.argmax(snr_marg))] + assert np.abs(best - true_period) <= 2 * step + 1e-9 + + def contrast(s): + # peak height at the true period over the off-peak spread + off = np.delete(s, i_true) + return (s[i_true] - np.median(off)) / (np.std(off) + 1e-12) + + # measured: marginal 2.5, matched -0.45 + assert contrast(snr_marg) > contrast(snr_matched) + + @mark_cuda_test + def test_sequential_detector_runs_and_detects(self): + proc = NUFFTLRTAsyncProcess() + true_period, true_duration, depth = 2.5, 0.25, 0.5 + signal = self.generate_transit_signal( + self.t, true_period, 0.0, true_duration, depth) + trend = (self.t - self.t.mean()) / self.t.std() + rng = np.random.RandomState(12) + y = signal + 2.0 * trend + 0.1 * rng.randn(len(self.t)) + + periods = np.linspace(2.0, 3.0, 20) + snr = proc.run(self.t, y, periods, + durations=np.array([true_duration]), + epochs=np.array([0.0]), + detector='sequential', + systematics_basis=trend[:, None]) + assert snr.shape == (len(periods), 1, 1) + best = periods[int(np.argmax(snr[:, 0, 0]))] + step = periods[1] - periods[0] + assert np.abs(best - true_period) <= 2 * step + 1e-9 + + @mark_cuda_test + def test_marginal_requires_basis_and_prior(self): + proc = NUFFTLRTAsyncProcess() + y = self.rng.randn(len(self.t)) + with pytest.raises(ValueError, match="systematics_basis"): + proc.run(self.t, y, np.array([2.0]), detector='marginal') + with pytest.raises(ValueError, match="coeff_prior_cov"): + proc.run(self.t, y, np.array([2.0]), detector='marginal', + systematics_basis=np.ones((len(self.t), 1))) + with pytest.raises(ValueError, match="detector"): + proc.run(self.t, y, np.array([2.0]), detector='bogus') + # audit ids 122/156: a non-PSD prior is rejected instead of + # being pinv'ed into a flat prior + with pytest.raises(ValueError, match="positive semidefinite"): + proc.run(self.t, y, np.array([2.0]), detector='marginal', + systematics_basis=np.sin(self.t)[:, None], + coeff_prior_cov=np.array([[-1.0]])) + + @mark_cuda_test + def test_multiple_epochs(self): + """Test searching over multiple epochs""" + proc = NUFFTLRTAsyncProcess() + + # Generate transit signal + true_period = 2.5 + true_duration = 0.2 + true_epoch = 0.5 + depth = 0.5 + + signal = self.generate_transit_signal( + self.t, true_period, true_epoch, true_duration, depth + ) + y = signal + 0.1 * self.rng.randn(len(self.t)) + + periods = np.array([true_period]) + durations = np.array([true_duration]) + epochs = np.linspace(0, true_period, 10) + + snr = proc.run( + self.t, y, periods, durations=durations, epochs=epochs + ) + + # Check output shape + assert snr.shape == (1, 1, len(epochs)) + + # Best epoch should be close to true epoch (two grid steps) + best_epoch_idx = np.argmax(snr[0, 0, :]) + best_epoch = epochs[best_epoch_idx] + step = epochs[1] - epochs[0] + epoch_diff = np.abs(best_epoch - true_epoch) + epoch_diff = min(epoch_diff, true_period - epoch_diff) + assert epoch_diff <= 2 * step + 1e-9 + + +@pytest.mark.skipif(not NUFFT_LRT_AVAILABLE, + reason="NUFFT LRT not available") +class TestSep2026Defects: + """Regression tests derived from the Sep-2026 algorithm audit + (analysis/audit-sep2026/ALGORITHM_AUDIT.md section 2).""" + + @staticmethod + def _bjd_data(): + """The verifier's BJD data model (repro/local/vfy-lrt-bjd): 600 + points over 60 d, a 1% box transit at P = 5.3 d, two small + systematics that the basis detectors get as V.""" + rng = np.random.RandomState(1) + N, T = 600, 60.0 + t = np.sort(rng.uniform(0, T, N)) + P0, dur, depth, sig, e0 = 5.3, 0.22, 0.01, 0.003, 1.2 + y = 1.0 + box_transit(t, P0, e0, dur, depth) + sig * rng.randn(N) + V = np.stack([np.sin(2 * np.pi * t / T), (t - T / 2) / T], 1) + y = y + 0.002 * V[:, 0] + 0.003 * V[:, 1] + periods = np.array([4.1, 4.7, 5.3, 5.9, 6.5]) + durations = np.array([0.22]) + epochs = np.linspace(0, P0, 24, endpoint=False) + return t, y, V, periods, durations, epochs + + # (parametrized GPU tests carry no @mark_cuda_test, as in test_bls.py: + # the conftest stub turns the first GPU touch into a skip on CPU) + @pytest.mark.parametrize('detector', ['matched', 'marginal', + 'sequential']) + def test_bjd_invariance(self, detector): + """Defect 5 (lrt-bjd-float32): times must be epoch-subtracted in + float64 before the float32 cast. With BJD-scale input the three + detectors returned a different statistic (corr 0.47-0.51, argmax + moved, max 7.4 -> 13.9); with the fix the shifted run matches + to float32 NFFT noise (audit: rel <= 4e-4 at sigma = 2; measured + 3e-6 at sigma = 4) with the same argmax, and the transit is seen + at the true period.""" + t, y, V, periods, durations, epochs = self._bjd_data() + kw = {} + if detector == 'marginal': + kw = dict(systematics_basis=V, coeff_prior_cov=np.eye(2) * 1e-4) + elif detector == 'sequential': + kw = dict(systematics_basis=V) + proc = NUFFTLRTAsyncProcess() + base = proc.run(t, y, periods, durations, epochs=epochs, + detector=detector, **kw) + shifted = proc.run(t + BJD_OFFSET, y, periods, durations, + epochs=epochs + BJD_OFFSET, detector=detector, + **kw) + rel = np.abs(shifted - base).max() / np.abs(base).max() + assert rel < 1e-4 + assert np.argmax(shifted) == np.argmax(base) + # the transit is seen (measured max 11.7-12.3) at the true period + assert base.max() > 5.0 + assert np.unravel_index(np.argmax(base), base.shape)[0] == 2 + # the automatic epoch grid reports epochs in the caller's scale; + # it is anchored at floor(min t), so an INTEGER offset reproduces + # the same templates (as test_bls.py's integer bjd_offset) + off = np.floor(BJD_OFFSET) + s0, e0 = proc.run(t, y, periods[2:3], durations, detector=detector, + **kw) + s1, e1 = proc.run(t + off, y, periods[2:3], durations, + detector=detector, **kw) + assert_allclose(e1 - e0, off, atol=1e-6) + assert abs(s1[0, 0] - s0[0, 0]) < 1e-4 * abs(s0[0, 0]) + + @mark_cuda_test + def test_epochs_none_recovers_random_epoch(self): + """Defect 6 (lrt-epochs-none): the default ``epochs=None`` used + to evaluate one phase-0 template per (period, duration) and + recovered 0/12 transits injected at random epochs (0/6 on the + base tree with this data); it now scans an automatic epoch grid + and returns (max over epochs, best epoch).""" + rng = np.random.RandomState(7) + t = ground_times(rng) + P, dur, depth = 5.3, 0.22, 0.01 + periods = _log_period_grid(P, n=16) + ip = int(np.argmin(np.abs(periods - P))) + proc = NUFFTLRTAsyncProcess() + for trial in range(2): + epoch = rng.uniform(0.2 * P, 0.9 * P) # never phase 0 + y = 1 + 3e-3 * rng.randn(len(t)) + box_transit(t, P, epoch, + dur, depth) + snr, best_epoch = proc.run(t, y, periods, + durations=np.array([dur])) + assert snr.shape == (len(periods), 1) + assert int(np.argmax(snr[:, 0])) == ip + assert snr[ip, 0] > 8.0 # measured 21-23 + d = np.abs(best_epoch[ip, 0] - epoch) % P + d = min(d, P - d) + assert d < 0.75 * dur # grid step P/n < dur/2 + # the best epoch lies on the documented grid + grid = epoch_grid(P, dur) + np.floor(t.min()) + assert np.min(np.abs(grid - best_epoch[ip, 0])) < 1e-9 + + @mark_cuda_test + def test_marginal_psd_from_residual_matches_sequential(self): + """Defect 22 (lrt-detectorA-defeated): with ``estimate_psd=True`` + the marginal detector's PSD came from ``y - V mu``, which still + holds the realized systematics (median inflation ~36x across the + band), whitening the transit away: SNR at the true template 2.5 + vs 8.3 for the sequential baseline (harness data model, depth + 0.008, base tree). With the PSD from the basis-projected + residual the two agree (audit: 8.89 vs 8.89; measured 10.3 vs + 10.2).""" + rng = np.random.RandomState(11) + t = ground_times(rng) + n = len(t) + nf = 2 * n + sigma_w = 3e-3 + sigma_r, tau = sigma_w, 0.8 + amps = np.array([6.0, 3.0, 6.0]) * sigma_w + M, V, mu_c, cov_c = population_basis(rng, t, 90.0, sigma_w, sigma_r, + tau, amps) + proc = NUFFTLRTAsyncProcess() + P, dur, ep = 5.3, 0.22, 1.0 + kw = dict(durations=np.array([dur]), epochs=np.array([ep]), nf=nf) + marg, seq = [], [] + for r in range(4): + noise = sigma_w * rng.randn(n) + ou_noise(rng, t, sigma_r, tau) + y = 1.0 + noise + M @ (rng.randn(3) * amps) \ + + box_transit(t, P, ep, dur, 0.008) + marg.append(float(proc.run( + t, y, np.array([P]), detector='marginal', + systematics_basis=V, coeff_prior_mean=mu_c, + coeff_prior_cov=cov_c, **kw).max())) + seq.append(float(proc.run( + t, y, np.array([P]), detector='sequential', + systematics_basis=V, **kw).max())) + marg, seq = np.mean(marg), np.mean(seq) + assert seq > 5.0 + assert marg > 0.8 * seq # was 0.26-0.29 + assert marg < 1.25 * seq + + @pytest.mark.parametrize('use_double', [False, True]) + def test_full_band_nfft_matches_exact_dft(self, use_double): + """Defect 24 (lrt-upper-half-band): with the old sigma = 2 the + modes k >= nf/2 carried O(1) aliasing error (max|G-E|/rms 1.3 in + both precisions); with sigma = 4 every returned mode matches the + exact adjoint DFT. Compared through the phase-invariant + quantities the detectors use (moduli and the data x template + cross-spectrum), so the transform's time reference is free.""" + rng = np.random.RandomState(0) + t = ground_times(rng) + n = len(t) + nf = 2 * n + y = 3e-3 * rng.randn(n) + y -= y.mean() + tmpl = box_transit(t, 5.3, 1.3, 0.22, 1.0) + tmpl -= tmpl.mean() + proc = NUFFTLRTAsyncProcess(use_double=use_double) + Gy = proc.compute_nufft(t, y, nf).astype(np.complex128) + Gt = proc.compute_nufft(t, tmpl, nf).astype(np.complex128) + Ey = adjoint_dft(t, y, nf) + Et = adjoint_dft(t, tmpl, nf) + # measured (A40): 1.6e-4 float32, 3.5e-7 float64; was 0.49 at sigma=2 + tol = 2e-6 if use_double else 1e-3 + for G, E in ((Gy, Ey), (Gt, Et)): + rel = np.abs(np.abs(G) - np.abs(E)).max() / np.abs(E).max() + assert rel < tol, rel + cross_g = Gy * np.conj(Gt) + cross_e = Ey * np.conj(Et) + rel = np.abs(cross_g - cross_e).max() / np.abs(cross_e).max() + assert rel < 2 * tol, rel + # the upper half band specifically (the aliased region) + hi = slice(nf // 2, nf) + rel_hi = np.abs(np.abs(Gy[hi]) - np.abs(Ey[hi])).max() / np.abs(Ey).max() + assert rel_hi < tol, rel_hi + + @pytest.mark.parametrize('use_double', [False, True]) + def test_reused_memory_parity(self, use_double): + """LRT-1: run() allocates one NFFT buffer set and reuses it for + the data, the basis vectors and every template. The result must + equal the per-template path (a fresh transform per call) to + run-to-run NFFT noise (audit: 1.4e-6 on the transform).""" + rng = np.random.RandomState(3) + t = ground_times(rng, n=300) + n = len(t) + nf = 2 * n + P, dur = 5.3, 0.22 + y = 1 + 3e-3 * rng.randn(n) + box_transit(t, P, 1.3, dur, 0.01) + periods = np.array([4.0, P, 7.0]) + epochs = np.linspace(0, P, 6, endpoint=False) + proc = NUFFTLRTAsyncProcess(use_double=use_double) + got = proc.run(t, y, periods, np.array([dur]), epochs=epochs, + eps_floor=1e-12) + # independent per-template evaluation with fresh memory per call + from ..nufft_lrt import _smoothed_periodogram + y0 = y - y.mean() + Y = proc.compute_nufft(t, y0, nf) + psd = _smoothed_periodogram((np.abs(Y) ** 2).astype(proc.real_type), 5) + w = np.ones(nf) + want = np.zeros_like(got) + for i, p in enumerate(periods): + for k, e in enumerate(epochs): + tm = proc._generate_template(t, p, e, dur, 1.0) + tm -= tm.mean() + T = proc.compute_nufft(t, tm, nf) + want[i, 0, k] = proc._compute_matched_filter_snr( + Y, T, psd, w, 1e-12) + rel = np.abs(got - want).max() / np.abs(want).max() + # measured (A40, bit-reproducible over 3 repeats): 3.64e-6 + # float32, 4.10e-8 float64 -- the residual is the different + # NFFT truncation radius m (the reused memory is sized from an + # L1 bound over all vectors, the per-call path from each y) + assert rel < (1e-6 if use_double else 3e-5), rel + + def test_small_nf_does_not_break_the_psd_smoother(self): + """nf < smooth_window used to die inside numpy with 'operands + could not be broadcast together with shapes (4,) (5,)': the + boxcar 'same' convolution returns max(nf, window) samples. The + window is now clamped to nf.""" + rng = np.random.RandomState(5) + n = 40 + t = np.sort(rng.rand(n) * 12.0) + y = 1.0 + 0.004 * rng.randn(n) + periods = np.array([2.0, 3.0]) + durations = np.array([0.2]) + proc = NUFFTLRTAsyncProcess() + for nf in (1, 2, 3, 4, 5, 6, 9): + s = proc.run(t, y, periods, durations, + epochs=np.array([0.0, 0.5]), nf=nf) + assert s.shape == (2, 1, 2) + assert np.all(np.isfinite(s)) + # a huge window is equally harmless + s = proc.run(t, y, periods, durations, epochs=np.array([0.0]), + nf=8, smooth_window=1000) + assert np.all(np.isfinite(s)) + + @mark_cuda_test + def test_sequential_nonzero_mean_basis(self): + """Defect 21 (lrt-sequential-intercept): a basis column with a 1% + mean on relative flux dropped the sequential detector's SNR at + the true period from ~25 to ~5 (no intercept in the OLS); the + centred fit is insensitive to the column mean.""" + rng = np.random.RandomState(7) + n, T = 2000, 90.0 + t = np.sort(rng.rand(n) * T) + P, dur, depth, e0, sig = 3.3, 0.15, 0.006, 1.1, 0.003 + V0 = np.stack([np.sin(2 * np.pi * t / 30.), np.cos(2 * np.pi * t / 17.)], + axis=1) + V0 = (V0 - V0.mean(axis=0)) / V0.std(axis=0) + c = np.array([0.004, -0.004]) + noise = sig * rng.randn(n) + transit = box_transit(t, P, e0, dur, depth) + periods = np.array([P, 2.9, 3.1, 3.5, 3.7, 4.1]) + epochs = np.arange(0, P, dur / 2) + proc = NUFFTLRTAsyncProcess() + got = {} + for mean_off in (0.0, 1e-2): + V = V0 + mean_off + y = 1.0 + transit + V @ c + noise + s = proc.run(t, y, periods, np.array([dur]), epochs=epochs, + detector='sequential', systematics_basis=V) + got[mean_off] = s.max(axis=(1, 2)) + for mean_off, m in got.items(): + assert int(np.argmax(m)) == 0, mean_off + assert m[0] > 2.0 * m[1:].max(), mean_off + # the column mean must not change the statistic (measured 4e-9) + assert_allclose(got[1e-2], got[0.0], rtol=5e-3) + + +@pytest.mark.skipif(not NUFFT_LRT_AVAILABLE, + reason="NUFFT LRT not available") +class TestNFFTMemoryReuse: + """audit ids 118/155: ``NFFTAsyncProcess.run(memory=...)`` returned + the pinned host buffer before the async D2H copy landed and never + zeroed the atomic grid, so a second run on reused memory summed onto + the first (off by ~1e5-1e8).""" + + @mark_cuda_test + def test_reused_memory_equals_fresh_runs(self): + rng = np.random.RandomState(3) + n = 5000 + t = np.sort(rng.rand(n) * 30) + y1 = rng.randn(n) + y2 = rng.randn(n) + nf = 2 * n + proc = NFFTAsyncProcess() + fresh1 = np.array(proc.run([(t, y1, nf)])[0]) + fresh2 = np.array(proc.run([(t, y2, nf)])[0]) + mem = proc.allocate([(t, y1, nf)]) + got1 = np.array(proc.run([(t, y1, nf)], memory=mem)[0]) # immediate + mem[0].y = y2.astype(mem[0].real_type) + got2 = np.array(proc.run([(t, y2, nf)], memory=mem)[0]) + scale = np.abs(fresh1).max() + # measured (A40): 2e-5 for both; the un-zeroed grid gave 2.3e5 + assert np.abs(got1 - fresh1).max() < 1e-3 * scale + assert np.abs(got2 - fresh2).max() < 1e-3 * scale + # the immediate read was complete (run() synchronized) + mem[0].stream.synchronize() + np.testing.assert_array_equal(got2, np.array(mem[0].ghat_c)) + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/cuvarbase/tests/test_nufft_lrt_algorithm.py b/cuvarbase/tests/test_nufft_lrt_algorithm.py new file mode 100644 index 00000000..eb25f268 --- /dev/null +++ b/cuvarbase/tests/test_nufft_lrt_algorithm.py @@ -0,0 +1,306 @@ +""" +Test NUFFT LRT algorithm logic without requiring GPU. + +These tests exercise the *shipped* template-generation and matched-filter +code in cuvarbase.nufft_lrt (both are pure numpy). An earlier version of +this file defined local copies of the algorithms and tested those, which +validated nothing about the package. + +``_compute_matched_filter_snr`` is not called by ``run()``; it is a +single-template reference wrapper over the same ``_floor_psd`` / +``_matched_filter_statistic`` helpers that ``run()`` evaluates per +template, so testing it tests the shipped arithmetic. +``TestRunHostPipeline`` closes the loop by running ``run()`` itself with +the GPU transform replaced by an exact host adjoint DFT. +""" +import numpy as np +import pytest + +from ..nufft_lrt import NUFFTLRTAsyncProcess + +pytestmark = pytest.mark.filterwarnings( + "ignore:cuvarbase.nufft_lrt is EXPERIMENTAL") + + +@pytest.fixture(scope='module') +def proc(): + return NUFFTLRTAsyncProcess() + + +def test_smoothed_periodogram_clamps_the_window(): + # CPU-only: np.convolve(..., 'same') returns max(len, window) + # samples, so an unclamped window > nf lengthened the PSD and blew + # up downstream with a raw numpy broadcast error. + from ..nufft_lrt import _smoothed_periodogram + for n in (1, 2, 3, 4, 5, 6, 7, 33): + p = np.arange(1.0, n + 1.0) + for window in (1, 2, 5, 1000): + out = _smoothed_periodogram(p, window) + assert len(out) == n, (n, window) + assert np.all(np.isfinite(out)) + # any window >= n gives the same (fully clamped) result + assert np.allclose(_smoothed_periodogram(p, 1000), + _smoothed_periodogram(p, n)) + # ... and smoothing preserves the total (edge-corrected mean of + # the available neighbours, never zero-padded) + assert _smoothed_periodogram(p, 3).min() >= p.min() + assert _smoothed_periodogram(p, 3).max() <= p.max() + + +def test_run_docstring_warns_about_the_duration_outer_product(): + doc = ' '.join(NUFFTLRTAsyncProcess.run.__doc__.split()) + assert 'outer product' in doc + assert 'len(periods)**2' in doc + assert '0.1 * periods' in doc + + +class TestNUFFTLRTAlgorithm: + """Test NUFFT LRT algorithm logic (CPU-only, real implementation)""" + + def test_template_generation(self, proc): + """Test transit template generation""" + t = np.linspace(0, 10, 100) + period = 2.0 + epoch = 0.0 + duration = 0.2 + depth = 1.0 + + template = proc._generate_template(t, period, epoch, duration, depth) + + # Check properties + assert len(template) == len(t) + assert np.min(template) == -depth + assert np.max(template) == 0.0 + + # Check that some points are in transit + in_transit = template < 0 + assert np.sum(in_transit) > 0 + assert np.sum(in_transit) < len(template) + + # Check expected number of points in transit + expected_fraction = duration / period + actual_fraction = np.sum(in_transit) / len(template) + + # Should be roughly correct (within factor of 2) + assert 0.5 * expected_fraction < actual_fraction < 2.0 * expected_fraction + + def test_matched_filter_perfect_match(self, proc): + """Test matched filter with perfect match gives high SNR""" + nf = 100 + + # Perfect match should give high SNR + rng = np.random.RandomState(0) + T = rng.randn(nf) + 1j * rng.randn(nf) + Y = T.copy() # Perfect match + P_s = np.ones(nf) + weights = np.ones(nf) + + snr = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-12) + + # Perfect match should give SNR ~ sqrt(sum(|T|^2)) + expected_snr = np.sqrt(np.sum(np.abs(T) ** 2)) + assert np.abs(snr - expected_snr) / expected_snr < 0.01 + + def test_matched_filter_orthogonal_signals(self, proc): + """Test matched filter with orthogonal signals gives low SNR""" + nf = 100 + + rng = np.random.RandomState(1) + T = rng.randn(nf) + 1j * rng.randn(nf) + Y = rng.randn(nf) + 1j * rng.randn(nf) + Y = Y - np.vdot(Y, T) * T / np.vdot(T, T) # Make orthogonal + + P_s = np.ones(nf) + weights = np.ones(nf) + + snr = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-12) + + # Orthogonal signals should give SNR ~ 0 + assert np.abs(snr) < 1.0 + + def test_matched_filter_scale_invariance(self, proc): + """Test matched filter is invariant to template scaling""" + nf = 100 + + rng = np.random.RandomState(2) + T = rng.randn(nf) + 1j * rng.randn(nf) + Y = 2.0 * T # Scaled version + P_s = np.ones(nf) + weights = np.ones(nf) + + snr1 = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-12) + snr2 = proc._compute_matched_filter_snr(Y, 0.5 * T, P_s, weights, + 1e-12) + + # SNR should be invariant to template scaling + assert np.abs(snr1 - snr2) < 0.01 + + def test_matched_filter_noise_distribution(self, proc): + """Test matched filter gives reasonable SNR distribution for noise""" + nf = 100 + P_s = np.ones(nf) + weights = np.ones(nf) + + snrs = [] + rng = np.random.RandomState(42) + for _ in range(50): + Y = rng.randn(nf) + 1j * rng.randn(nf) + T = rng.randn(nf) + 1j * rng.randn(nf) + snr = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-12) + snrs.append(snr) + + mean_snr = np.mean(snrs) + std_snr = np.std(snrs) + + # Mean should be close to 0, std should be reasonable + assert np.abs(mean_snr) < 2.0 + assert std_snr > 0 + + def test_power_spectrum_floor_prevents_blowup(self, proc): + """Zero entries in the power spectrum must not produce inf/nan""" + nf = 100 + rng = np.random.RandomState(3) + T = rng.randn(nf) + 1j * rng.randn(nf) + Y = T.copy() + weights = np.ones(nf) + + P_s = np.ones(nf) + P_s[::7] = 0.0 # exact zeros, would divide-by-zero without floor + + snr = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-6) + assert np.isfinite(snr) + assert snr > 0 + + def test_matched_filter_with_colored_noise(self, proc): + """Test matched filter with non-uniform power spectrum""" + nf = 100 + + rng = np.random.RandomState(4) + # Create frequency-dependent noise (colored noise) + P_s = np.linspace(0.5, 2.0, nf) # Varying power + weights = np.ones(nf) + + T = rng.randn(nf) + 1j * rng.randn(nf) + Y = T + np.sqrt(P_s) * (rng.randn(nf) + 1j * rng.randn(nf)) + + snr = proc._compute_matched_filter_snr(Y, T, P_s, weights, 1e-12) + + # SNR should be positive and finite + assert snr > 0 + assert np.isfinite(snr) + + +def test_empty_basis_is_rejected_before_device_work(proc): + """A (n, 0) systematics basis with detector='marginal' used to fall + through to the plain matched filter; after the Detector A + precompute was hoisted out of the template loop it raised a raw + numpy 'cannot reshape array of size 0' AFTER the data transforms + had run. Both basis detectors now reject K = 0 with a ValueError + before touching the device (so this runs under the CPU stub).""" + from ..nufft_lrt import _marginal_precompute + rng = np.random.RandomState(0) + n = 60 + t = np.sort(rng.rand(n) * 20.0) + y = 1.0 + 1e-3 * rng.randn(n) + empty = np.zeros((n, 0)) + with pytest.raises(ValueError, match="at least one column"): + proc.run(t, y, np.array([3.0]), durations=np.array([0.2]), + epochs=np.array([0.0]), detector='marginal', + systematics_basis=empty, coeff_prior_cov=np.zeros((0, 0))) + with pytest.raises(ValueError, match="at least one column"): + proc.run(t, y, np.array([3.0]), durations=np.array([0.2]), + epochs=np.array([0.0]), detector='sequential', + systematics_basis=empty) + # the hoisted precompute mirrors the guard + nf = 16 + Y = rng.randn(nf) + 1j * rng.randn(nf) + with pytest.raises(ValueError, match="K >= 1"): + _marginal_precompute(Y, [], np.ones(nf), np.ones(nf), + np.zeros((0, 0))) + + +def _adjoint_dft(t, y, nf): + """Exact float64 adjoint DFT at the GPU convention (modes k = 0..nf-1, + f_k = k / (max t - min t)).""" + t = np.asarray(t, np.float64) + y = np.asarray(y, np.float64) + x = t / (t.max() - t.min()) + k = np.arange(nf) + return np.exp(2j * np.pi * np.outer(k, x)) @ y + + +class TestRunHostPipeline: + """``run()`` end to end on the CPU: the adjoint NFFT (the only GPU + work) is replaced by the exact adjoint DFT, so everything else -- + validation, epoch subtraction, PSD estimate and floor, the whitening + weights and the per-template reduction ``run()`` actually executes -- + is exercised under the stub. The earlier CPU tests only reached the + helper ``_compute_matched_filter_snr``, which ``run()`` no longer + calls (finding 32 of the Sep-2026 review).""" + + @pytest.fixture + def cpu_proc(self, proc, monkeypatch): + monkeypatch.setattr(proc, '_nfft_memory', + lambda t, nf, l1_max, **kw: None) + monkeypatch.setattr(proc, 'compute_nufft', + lambda t, y, nf, memory=None, **kw: + _adjoint_dft(t, y, nf)) + return proc + + @staticmethod + def _data(rng, n=80): + t = np.sort(rng.rand(n) * 30.0) + 2457000.0 # absolute BJD + P, e, d = 4.3, 2457001.1, 0.25 + phase = np.fmod(t - e, P) / P + phase[phase > 0.5] -= 1.0 + y = 1.0 + 2e-3 * rng.randn(n) + y[np.abs(phase) <= d / (2 * P)] -= 0.02 + return t, y, P, e, d + + def test_matched_path_equals_reference_wrapper(self, cpu_proc): + rng = np.random.RandomState(11) + t, y, P, e, d = self._data(rng) + periods = np.array([3.0, P, 6.0]) + epochs = np.array([0.0, e - np.floor(t.min()), 1.7]) + got = cpu_proc.run(t, y, periods, durations=np.array([d]), + epochs=epochs + np.floor(t.min())) + assert got.shape == (3, 1, 3) + # independent per-template evaluation through the wrapper, on + # the same host transforms run() saw + from ..nufft_lrt import _smoothed_periodogram + t0 = t - np.floor(t.min()) + nf = 2 * len(t) + Y = _adjoint_dft(t0, y - y.mean(), nf) + psd = _smoothed_periodogram( + (np.abs(Y) ** 2).astype(cpu_proc.real_type), 5) + want = np.zeros_like(got) + for i, p in enumerate(periods): + for k, ep in enumerate(epochs): + tm = cpu_proc._generate_template(t0, p, ep, d, 1.0) + tm -= tm.mean() + T = _adjoint_dft(t0, tm, nf) + want[i, 0, k] = cpu_proc._compute_matched_filter_snr( + Y, T, psd, np.ones(nf), 1e-3) + np.testing.assert_allclose(got, want, rtol=1e-10) + # and the injected template is the maximum + i, j, k = np.unravel_index(np.argmax(got), got.shape) + assert (i, k) == (1, 1) + + def test_marginal_and_sequential_run_on_the_host(self, cpu_proc): + rng = np.random.RandomState(5) + t, y, P, e, d = self._data(rng) + v = np.sin(2 * np.pi * (t - t.min()) / 11.0) + y_sys = y + 0.05 * v + periods = np.array([3.0, P, 6.0]) + for detector, kw in (('sequential', {}), + ('marginal', + dict(coeff_prior_cov=np.array([[1.0]])))): + snr, best = cpu_proc.run(t, y_sys, periods, + durations=np.array([d]), + detector=detector, + systematics_basis=v[:, None], **kw) + assert snr.shape == best.shape == (3, 1) + assert np.all(np.isfinite(snr)) + assert int(np.argmax(snr[:, 0])) == 1, detector + # best epoch is returned in the caller's (BJD) time scale + assert best[1, 0] > 2457000.0 diff --git a/cuvarbase/tests/test_nufft_lrt_import.py b/cuvarbase/tests/test_nufft_lrt_import.py new file mode 100644 index 00000000..43b8f337 --- /dev/null +++ b/cuvarbase/tests/test_nufft_lrt_import.py @@ -0,0 +1,237 @@ +""" +CPU tests for the NUFFT-LRT host-side algebra. + +``TestDetectorAlgebra`` checks the Detector-A (marginalized joint +detector) statistic against a dense-inverse reference and the +sequential detrend; ``TestPsdSmoothing`` checks the edge-corrected +periodogram smoother. (The repository-layout checks that used to live +here -- source syntax, kernel/docs/example file existence -- were not +tests of the package and could not run from an installed wheel; the +kernel is exercised by ``test_nufft_lrt.py`` on a device.) +""" + + +class TestDetectorAlgebra: + """CPU tests for the Detector-A (marginalized joint detector) + algebra. The Woodbury frequency-domain path is verified against a + dense inverse of the realified combined covariance -- an + independent computation of the same statistic.""" + + @staticmethod + def _realify(a): + import numpy as np + return np.concatenate([np.real(a), np.imag(a)]) + + def _dense_statistic(self, Y, T, V_ks, psd, weights, prior_cov): + # Cov_s^{-1} is diagonal (w/P) in the realified space; the + # combined covariance is Cov_z = Cov_s + R Cov_c R^T with R the + # realified basis. Invert it densely (small nf) and evaluate + # the matched filter directly. + import numpy as np + d = np.concatenate([weights / psd, weights / psd]) + Cov_s = np.diag(1.0 / d) + R = np.stack([self._realify(v) for v in V_ks], axis=1) + Cov_z = Cov_s + R @ np.atleast_2d(prior_cov) @ R.T + Wz = np.linalg.inv(Cov_z) + ry, rt = self._realify(Y), self._realify(T) + return float(ry @ Wz @ rt / np.sqrt(rt @ Wz @ rt)) + + def test_marginal_matches_dense_inverse(self): + import numpy as np + from cuvarbase.nufft_lrt import _marginal_statistic + + rng = np.random.RandomState(7) + nf, K = 24, 3 + Y = rng.randn(nf) + 1j * rng.randn(nf) + T = rng.randn(nf) + 1j * rng.randn(nf) + V_ks = [rng.randn(nf) + 1j * rng.randn(nf) for _ in range(K)] + psd = 0.5 + rng.rand(nf) + weights = np.ones(nf) + A = rng.randn(K, K) + prior_cov = A @ A.T + 0.5 * np.eye(K) # positive definite + + got = _marginal_statistic(Y, T, V_ks, psd, weights, prior_cov) + want = self._dense_statistic(Y, T, V_ks, psd, weights, prior_cov) + np.testing.assert_allclose(got, want, rtol=1e-9) + + def test_no_basis_reduces_to_matched_filter(self): + import numpy as np + from cuvarbase.nufft_lrt import (_marginal_statistic, + _whitened_inner) + + rng = np.random.RandomState(1) + nf = 32 + Y = rng.randn(nf) + 1j * rng.randn(nf) + T = rng.randn(nf) + 1j * rng.randn(nf) + psd = 1.0 + rng.rand(nf) + w = np.ones(nf) + got = _marginal_statistic(Y, T, [], psd, w, np.zeros((0, 0))) + want = (_whitened_inner(Y, T, psd, w) + / np.sqrt(_whitened_inner(T, T, psd, w))) + np.testing.assert_allclose(got, want, rtol=1e-12) + + def test_wide_prior_suppresses_basis_component(self): + # With a very wide prior, any data component along the basis is + # marginalized away: adding a huge basis-aligned contaminant to + # Y must not change the statistic (while it wrecks the plain + # matched filter). + import numpy as np + from cuvarbase.nufft_lrt import (_marginal_statistic, + _whitened_inner) + + rng = np.random.RandomState(3) + nf = 24 + Y = rng.randn(nf) + 1j * rng.randn(nf) + T = rng.randn(nf) + 1j * rng.randn(nf) + v = rng.randn(nf) + 1j * rng.randn(nf) + psd = np.ones(nf) + w = np.ones(nf) + prior = np.array([[1e8]]) + + clean = _marginal_statistic(Y, T, [v], psd, w, prior) + contaminated = _marginal_statistic(Y + 50.0 * v, T, [v], psd, w, + prior) + np.testing.assert_allclose(contaminated, clean, rtol=1e-4) + + plain = _whitened_inner(Y, T, psd, w) \ + / np.sqrt(_whitened_inner(T, T, psd, w)) + plain_cont = _whitened_inner(Y + 50.0 * v, T, psd, w) \ + / np.sqrt(_whitened_inner(T, T, psd, w)) + assert abs(plain_cont - plain) > 10 * abs(contaminated - clean) + + def test_sequential_detrend_removes_basis(self): + import numpy as np + from cuvarbase.nufft_lrt import _sequential_detrend + + rng = np.random.RandomState(5) + n = 200 + t = np.sort(rng.rand(n)) * 30 + V = np.stack([t - t.mean(), (t - t.mean()) ** 2], axis=1) + y = 1.0 + 0.01 * rng.randn(n) + V @ np.array([0.3, -0.02]) + r = _sequential_detrend(t, y, V) + # the fit has an intercept: the demeaned residual is orthogonal + # to the CENTRED basis (the second column has mean var(t) != 0), + # and the residual keeps the mean of y + Vc = V - V.mean(axis=0) + np.testing.assert_allclose(Vc.T @ (r - r.mean()), 0.0, + atol=1e-8 * n) + np.testing.assert_allclose(r.mean(), y.mean(), rtol=1e-10) + + def test_sequential_detrend_nonzero_mean_column(self): + # audit Sep 2026 (lrt-sequential-intercept): OLS without an + # intercept on relative flux (mean 1) with a basis column of + # mean 0.01 and unit std absorbs the mean flux into the + # coefficient and leaves a residual systematic of amplitude + # ybar * m_v / s_v = 0.01 -- 10x this noise. With the intercept + # the residual is the noise (up to the O(sigma/sqrt n) fit error). + import numpy as np + from cuvarbase.nufft_lrt import _sequential_detrend + + rng = np.random.RandomState(7) + n, sigma = 2000, 1e-3 + t = np.sort(rng.rand(n)) * 90.0 + v = np.sin(2 * np.pi * t / 30.0) + v = (v - v.mean()) / v.std() + 0.01 # mean 0.01, std 1 + noise = sigma * rng.randn(n) + y = 1.0 + 0.004 * v + noise + r = _sequential_detrend(t, y, v[:, None]) + leftover = (r - r.mean()) - (noise - noise.mean()) + assert np.std(leftover) < 0.1 * sigma # was ~10 sigma + assert np.std(r - r.mean()) < 1.2 * sigma + + def test_prior_response_matrix_singular_prior_limit(self): + # audit Sep 2026 (ids 122/156): pinv(prior_cov) turned a zero + # prior variance into an improper FLAT prior (base tree: + # diag(1, 0) gave -1.5934 == diag(1, 1e12), vs -1.5999 for + # diag(1, 1e-12)). The push-through form C (I + G C)^-1 gives + # the correct "pinned to the prior mean" limit, equal to the + # 1e-12-variance result, and equals inv(inv(C) + G) for a + # positive-definite prior. + import numpy as np + from cuvarbase.nufft_lrt import (_marginal_statistic, + _prior_response_matrix) + + rng = np.random.RandomState(7) + nf, K = 24, 2 + Y = rng.randn(nf) + 1j * rng.randn(nf) + T = rng.randn(nf) + 1j * rng.randn(nf) + V_ks = [rng.randn(nf) + 1j * rng.randn(nf) for _ in range(K)] + psd = 0.5 + rng.rand(nf) + w = np.ones(nf) + pinned = _marginal_statistic(Y, T, V_ks, psd, w, np.diag([1.0, 0.0])) + tiny = _marginal_statistic(Y, T, V_ks, psd, w, np.diag([1.0, 1e-12])) + flat = _marginal_statistic(Y, T, V_ks, psd, w, np.diag([1.0, 1e12])) + np.testing.assert_allclose(pinned, tiny, rtol=1e-8) + assert abs(pinned - flat) > 1e-3 * abs(flat) + + A = rng.randn(K, K) + C = A @ A.T + 0.5 * np.eye(K) + G = rng.randn(K, K) + G = G @ G.T + np.eye(K) + M = _prior_response_matrix(G, C) + np.testing.assert_allclose(M, np.linalg.inv(np.linalg.inv(C) + G), + rtol=1e-10, atol=1e-12) + + def test_prior_response_matrix_rejects_bad_priors(self): + import numpy as np + import pytest + from cuvarbase.nufft_lrt import _prior_response_matrix + + G = np.eye(2) + with pytest.raises(ValueError, match="positive semidefinite"): + _prior_response_matrix(G, np.diag([1.0, -1.0])) + with pytest.raises(ValueError, match="symmetric"): + _prior_response_matrix(G, np.array([[1.0, 0.5], [0.0, 1.0]])) + with pytest.raises(ValueError, match="\\(K, K\\)"): + _prior_response_matrix(G, np.eye(3)) + with pytest.raises(ValueError, match="finite"): + _prior_response_matrix(G, np.array([[1.0, 0.0], [0.0, np.nan]])) + + def test_epoch_grid(self): + import numpy as np + from cuvarbase.nufft_lrt import epoch_grid + + g = epoch_grid(5.3, 0.22) # ceil(2*5.3/0.22)=49 + assert len(g) == 49 + assert g[0] == 0.0 + np.testing.assert_allclose(np.diff(g), 5.3 / 49) + assert len(epoch_grid(0.5, 0.3)) == 8 # min clamp + assert len(epoch_grid(18.0, 0.12)) == 96 # max clamp + assert len(epoch_grid(18.0, 0.12, max_epochs=300)) == 300 + assert len(epoch_grid(5.3, 0.22, oversample=3.0)) == 73 + + +class TestPsdSmoothing: + """CPU tests for the edge-corrected periodogram smoothing (audit + finding: plain np.convolve 'same' depressed the PSD at the spectrum + edges, overweighting those bins by up to ~2x after 1/P whitening).""" + + def test_flat_periodogram_stays_flat_at_edges(self): + import numpy as np + from cuvarbase.nufft_lrt import _smoothed_periodogram + + power = np.ones(64, dtype=np.float32) + smoothed = _smoothed_periodogram(power, 5) + # Un-corrected smoothing gives 3/5 and 4/5 at the edges; the + # count-normalized version is exactly flat everywhere. + np.testing.assert_allclose(smoothed, 1.0, rtol=1e-6) + + def test_interior_matches_plain_boxcar(self): + import numpy as np + from cuvarbase.nufft_lrt import _smoothed_periodogram + + rng = np.random.RandomState(0) + power = rng.rand(128).astype(np.float64) + k = 7 + smoothed = _smoothed_periodogram(power, k) + plain = np.convolve(power, np.ones(k) / k, mode='same') + # away from the edges the two agree + np.testing.assert_allclose(smoothed[k:-k], plain[k:-k], rtol=1e-12) + + def test_window_one_is_identity(self): + import numpy as np + from cuvarbase.nufft_lrt import _smoothed_periodogram + + power = np.arange(16, dtype=np.float32) + out = _smoothed_periodogram(power, 1) + np.testing.assert_array_equal(out, power) diff --git a/cuvarbase/tests/test_nufft_lrt_pipeline.py b/cuvarbase/tests/test_nufft_lrt_pipeline.py new file mode 100644 index 00000000..4a6cf55e --- /dev/null +++ b/cuvarbase/tests/test_nufft_lrt_pipeline.py @@ -0,0 +1,215 @@ +"""CPU verification of the NUFFT-LRT host pipeline (no GPU). + +``compute_nufft`` is mocked with a direct adjoint DFT -- the exact math +the GPU NFFT approximates, at the same convention (modes k=0..nf-1, +frequency k/(max(t)-min(t)); the transform's time reference is a common +per-mode phase that cancels in every whitened inner product) -- and the +per-run NFFT buffer allocation is mocked away, so the host pipeline (epoch subtraction, PSD, weights, matched filter, epoch +grid, return shapes, input validation) runs on CPU: + +* the matched filter is sensitive to data across the WHOLE baseline + (perturbing a late, well-separated season changes the result -- the + defect that got the module cut is gone), +* the weights span all nf bins (the rfft one-sided packing is gone), +* absolute-time input is handled exactly (float64 epoch subtraction), and +* ``epochs=None`` returns ``(snr, best_epoch)`` with epochs in the + caller's time scale. + +The GPU NFFT itself (and its accuracy vs this exact reference) is +checked in ``test_nufft_lrt.py`` on a GPU. +""" +import numpy as np +import pytest + +from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess + +pytestmark = pytest.mark.filterwarnings( + "ignore:cuvarbase.nufft_lrt is EXPERIMENTAL") + +BJD_OFFSET = 2457000.5 + + +def _adjoint_dft(t, y, nf): + """Exact adjoint NFFT: ghat[k] = sum_j y_j exp(2 pi i k t_j/(tmax - + tmin)), k = 0..nf-1 (chunked over k to bound memory).""" + t = np.asarray(t, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + x = t / (t.max() - t.min()) + out = np.empty(nf, dtype=np.complex128) + for a in range(0, nf, 512): + k = np.arange(a, min(nf, a + 512)) + out[a:a + len(k)] = np.exp(2j * np.pi * np.outer(k, x)) @ y + return out + + +def _mock_proc(monkeypatch): + proc = NUFFTLRTAsyncProcess() + monkeypatch.setattr( + proc, 'compute_nufft', + lambda t, y, nf, **kw: _adjoint_dft(t, y, nf).astype(proc.complex_type)) + # no device buffers on CPU: run() passes memory=None to the mock + monkeypatch.setattr(proc, '_nfft_memory', lambda *a, **kw: None) + return proc + + +def _two_season_lc(seed=0): + rng = np.random.RandomState(seed) + # two well-separated observing seasons (a 260-day gap) -- the old + # uniform grid (span ~ median(dt)*2N << 340 d) would drop season 2. + t = np.concatenate([np.sort(rng.uniform(0.0, 40.0, 120)), + np.sort(rng.uniform(300.0, 340.0, 120))]) + period = 2.3 + phase = (t % period) / period + y = np.ones_like(t) + y[(phase < 0.06) | (phase > 0.94)] -= 0.2 # box transit + y += 0.01 * rng.randn(len(t)) + return t, y, period + + +def test_pipeline_runs_end_to_end(monkeypatch): + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + periods = np.linspace(1.5, 4.0, 12) + durations = np.array([0.15, 0.3]) + snr, best_epoch = proc.run(t, y, periods, durations=durations, + max_epochs=8) + assert snr.shape == (len(periods), len(durations)) + assert best_epoch.shape == snr.shape + assert np.all(np.isfinite(snr)) + + +def test_late_season_data_changes_result(monkeypatch): + # The full-baseline NFFT must let late-season observations affect the + # detection statistic; the old median(dt)*nf grid silently ignored them. + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + periods = np.linspace(1.5, 4.0, 40) + durations = np.array([0.2]) + epochs = np.array([0.0]) + + snr0 = proc.run(t, y, periods, durations=durations, epochs=epochs) + + # perturb ONLY the late (second) season + y2 = y.copy() + late = t > 200.0 + assert late.sum() > 0 + rng = np.random.RandomState(1) + y2[late] += 0.5 * rng.randn(int(late.sum())) + + snr1 = proc.run(t, y2, periods, durations=durations, epochs=epochs) + + # the statistic must respond to the late-season change (it would be + # identical if that data were truncated away) + assert np.max(np.abs(snr1 - snr0)) > 1e-6 + + +def test_snr_responds_to_injected_transit(monkeypatch): + # Sanity: the SNR spectrum is non-degenerate and the transit period + # produces a finite, above-median response (full recovery / harmonic + # disambiguation is left to the GPU injection-recovery validation). + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + periods = np.linspace(1.5, 4.0, 60) + snr = proc.run(t, y, periods, durations=np.array([0.2]), + epochs=np.array([0.0]))[:, 0, 0] + assert np.ptp(snr) > 0 # not constant + i = int(np.argmin(np.abs(periods - period))) + assert snr[i] >= np.median(snr) + + +def test_return_shapes_for_both_epoch_modes(monkeypatch): + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + periods = np.array([2.0, period, 3.1]) + durations = np.array([0.15, 0.3]) + # explicit epochs: one 3-D array + epochs = np.linspace(0.0, 2.0, 5) + out = proc.run(t, y, periods, durations=durations, epochs=epochs) + assert isinstance(out, np.ndarray) + assert out.shape == (3, 2, 5) + # epochs=None: (snr, best_epoch), both (nP, nD); the best epoch lies + # on the automatic grid, inside [floor(min t), floor(min t) + P) + snr, best = proc.run(t, y, periods, durations=durations, max_epochs=16) + assert snr.shape == (3, 2) and best.shape == (3, 2) + for i, P in enumerate(periods): + assert np.all(best[i] >= np.floor(t.min())) + assert np.all(best[i] < np.floor(t.min()) + P) + + +def test_absolute_time_input_is_exact_and_epochs_reported_absolute( + monkeypatch): + # run() subtracts floor(min t) in float64 before anything else, so a + # BJD-scale offset (with explicit epochs shifted identically) gives + # the same statistic, and the best epochs of the automatic grid come + # back in the caller's time scale. + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + periods = np.array([2.0, period, 3.1]) + durations = np.array([0.2]) + epochs = np.linspace(0.0, 2.0, 4) + base = proc.run(t, y, periods, durations=durations, epochs=epochs) + shifted = proc.run(t + BJD_OFFSET, y, periods, durations=durations, + epochs=epochs + BJD_OFFSET) + np.testing.assert_allclose(shifted, base, rtol=1e-6, atol=1e-9) + + # The automatic epoch grid is anchored at floor(min t), so only an + # INTEGER offset reproduces the same templates exactly (a fractional + # offset shifts the grid by its fractional part -- like test_bls.py's + # integer bjd_offset); the best epochs come back in the caller's scale. + off = np.floor(BJD_OFFSET) + snr0, ep0 = proc.run(t, y, periods, durations=durations, max_epochs=12) + snr1, ep1 = proc.run(t + off, y, periods, durations=durations, + max_epochs=12) + np.testing.assert_allclose(snr1, snr0, rtol=1e-6, atol=1e-9) + np.testing.assert_allclose(ep1 - ep0, off, atol=1e-6) + + +def test_dy_is_ignored_with_a_warning(monkeypatch): + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + kw = dict(durations=np.array([0.2]), epochs=np.array([0.0])) + ref = proc.run(t, y, np.array([period]), **kw) + with pytest.warns(UserWarning, match="dy"): + got = proc.run(t, y, np.array([period]), dy=np.full(len(t), 0.01), + **kw) + np.testing.assert_array_equal(got, ref) + + +def test_user_psd_is_validated_and_floored(monkeypatch): + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + nf = 2 * len(t) + kw = dict(durations=np.array([0.2]), epochs=np.array([0.0]), nf=nf, + estimate_psd=False) + with pytest.raises(ValueError, match="length nf"): + proc.run(t, y, np.array([period]), psd=np.ones(nf + 3), **kw) + with pytest.raises(ValueError, match="finite"): + bad = np.ones(nf) + bad[5] = np.nan + proc.run(t, y, np.array([period]), psd=bad, **kw) + # a zero bin is floored at eps_floor * median (1e-3 here): the + # result equals a run with that bin explicitly set to the floor + zero = np.ones(nf) + zero[37] = 0.0 + floored = zero.copy() + floored[37] = 1e-3 + got = proc.run(t, y, np.array([period]), psd=zero, **kw) + want = proc.run(t, y, np.array([period]), psd=floored, **kw) + assert np.all(np.isfinite(got)) + np.testing.assert_allclose(got, want, rtol=1e-9) + + +def test_input_validation(monkeypatch): + proc = _mock_proc(monkeypatch) + t, y, period = _two_season_lc() + with pytest.raises(ValueError, match="same length"): + proc.run(t[:-1], y, np.array([period])) + with pytest.raises(ValueError, match="finite"): + proc.run(t, np.where(np.arange(len(y)) == 3, np.nan, y), + np.array([period])) + with pytest.raises(ValueError, match="periods"): + proc.run(t, y, np.array([-1.0])) + with pytest.raises(ValueError, match="durations"): + proc.run(t, y, np.array([period]), durations=np.array([0.0])) + with pytest.raises(ValueError, match="epochs"): + proc.run(t, y, np.array([period]), epochs=np.array([np.nan])) diff --git a/cuvarbase/tests/test_pdm.py b/cuvarbase/tests/test_pdm.py index 40fd42c7..469a0560 100644 --- a/cuvarbase/tests/test_pdm.py +++ b/cuvarbase/tests/test_pdm.py @@ -1,13 +1,10 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - import numpy as np -from numpy.testing import assert_allclose +from numpy.testing import assert_allclose, assert_array_equal import pytest +from pycuda.tools import mark_cuda_test from ..utils import weights +from .. import pdm as pdm_module from ..pdm import pdm2_cpu, binless_pdm_cpu, PDMAsyncProcess -from pycuda.tools import mark_cuda_test pytest.nbins = 10 pytest.seed = 100 @@ -71,7 +68,9 @@ def pow_gpu(request): freqs += 0.5 * (freqs[1] - freqs[0]) pdm_proc = PDMAsyncProcess() - results = pdm_proc.run([(t, y, w, freqs)], kind=request.param, nbins=pytest.nbins) + # Test deprecated format + with pytest.warns(DeprecationWarning): + results = pdm_proc.run([(t, y, w, freqs)], kind=request.param, nbins=pytest.nbins) pdm_proc.finish() return results[0] @@ -93,3 +92,533 @@ def test_cuda_pdm_binless_gauss(binless_pow_cpu,pow_gpu): @pytest.mark.parametrize(["binless_pow_cpu","pow_gpu"], [("binless_tophat","binless_tophat")], indirect=True) def test_cuda_pdm_binless_tophat(binless_pow_cpu,pow_gpu): assert_allclose(binless_pow_cpu, pow_gpu, atol=1E-2, rtol=0) + + +@pytest.mark.parametrize(["pow_cpu", "pow_gpu"], [("binned_linterp", "binned_linterp_fast")], indirect=True) +def test_cuda_pdm_binned_linterp_fast(pow_cpu, pow_gpu): + assert_allclose(pow_cpu, pow_gpu, atol=1E-2, rtol=0) + + +@pytest.mark.parametrize(["pow_cpu", "pow_gpu"], [("binned_step", "binned_step_fast")], indirect=True) +def test_cuda_pdm_binned_step_fast(pow_cpu, pow_gpu): + assert_allclose(pow_cpu, pow_gpu, atol=1E-2, rtol=0) + + +@pytest.mark.parametrize(["binless_pow_cpu", "pow_gpu"], [("binless_gauss", "binless_gauss_fast")], indirect=True) +def test_cuda_pdm_binless_gauss_fast(binless_pow_cpu ,pow_gpu): + assert_allclose(binless_pow_cpu, pow_gpu, atol=1E-2, rtol=0) + + +@pytest.mark.parametrize(["binless_pow_cpu", "pow_gpu"], [("binless_tophat", "binless_tophat_fast")], indirect=True) +def test_cuda_pdm_binless_tophat_fast(binless_pow_cpu, pow_gpu): + assert_allclose(binless_pow_cpu, pow_gpu, atol=1E-2, rtol=0) + + +def test_pdm_new_format(): + rand = np.random.RandomState(pytest.seed) + + t = np.sort(rand.rand(pytest.ndata)) + y = np.cos(2 * np.pi * (10./(max(t) - min(t))) * t) + y += pytest.sigma * rand.randn(len(t)) + err = pytest.sigma * np.ones_like(y) + + freqs = np.linspace(0, 100./(max(t) - min(t)), pytest.nfreqs) + freqs += 0.5 * (freqs[1] - freqs[0]) + + pdm_proc = PDMAsyncProcess() + + # Test (t, y, err) with explicit freqs as array + results = pdm_proc.run([(t, y, err)], freqs=freqs, kind='binned_linterp', nbins=pytest.nbins) + assert_allclose(results[0][0], freqs) + # Test (t, y, err) with explicit freqs as list + results = pdm_proc.run([(t, y, err)], freqs=list(freqs), kind='binned_linterp', nbins=pytest.nbins) + assert_allclose(results[0][0], freqs) + + # Test (t, y, err) with automatic freqs + results_auto = pdm_proc.run([(t, y, err)], kind='binned_linterp', nbins=pytest.nbins) + assert len(results_auto[0][0]) > 0 + assert len(results_auto[0][1]) == len(results_auto[0][0]) + + pdm_proc.finish() + + +class TestCpuFunctionsDoNotMutateInputs(object): + """The CPU reference functions used to do `t -= mean(t)` in place, + silently modifying the caller's arrays.""" + + def _data(self): + rand = np.random.RandomState(7) + t = np.sort(10 * rand.rand(40)) + y = np.cos(2 * np.pi * 2.0 * t) + 0.1 * rand.randn(40) + w = np.ones_like(y) / len(y) + return t, y, w + + def test_binless_pdm_cpu(self): + from ..pdm import binless_pdm_cpu + t, y, w = self._data() + t0, y0, w0 = t.copy(), y.copy(), w.copy() + binless_pdm_cpu(t, y, w, np.array([1.0, 2.0])) + assert np.array_equal(t, t0) + assert np.array_equal(y, y0) + assert np.array_equal(w, w0) + + def test_pdm2_cpu(self): + from ..pdm import pdm2_cpu + t, y, w = self._data() + t0, y0, w0 = t.copy(), y.copy(), w.copy() + pdm2_cpu(t, y, w, np.array([1.0, 2.0])) + assert np.array_equal(t, t0) + assert np.array_equal(y, y0) + assert np.array_equal(w, w0) + + def test_pdm2_single_freq(self): + from ..pdm import pdm2_single_freq + t, y, w = self._data() + t0, y0, w0 = t.copy(), y.copy(), w.copy() + pdm2_single_freq(t, y, w, 2.0) + assert np.array_equal(t, t0) + assert np.array_equal(y, y0) + assert np.array_equal(w, w0) + + +# --------------------------------------------------------------------------- +# Regression tests from the Sep-2026 algorithm audit (finding ids 110, 111, 113) +# --------------------------------------------------------------------------- + +def _ref_binned_step(t, y, w, freqs, nbins, fold_dtype=np.float32): + """The documented statistic ``1 - SS_within / SS_total`` (float64 + accumulation, weights normalized, no degrees-of-freedom factor) for + ``kind='binned_step'``, with the phase fold done in ``fold_dtype``. + ``np.float32`` emulates ``pdm.cu`` exactly (``PHASE(t, f) = t*f - + floorf(t*f)``, ``bin = int(phase*nbins) % nbins``). + + Returns ``(power, n_occupied_bins)`` per frequency. + """ + t = t - np.mean(t) + y = y - np.mean(y) + w = w / np.sum(w) + ybar = np.dot(w, y) + ss_tot = np.dot(w, (y - ybar) ** 2) + t32 = t.astype(fold_dtype) + f32 = np.asarray(freqs).astype(fold_dtype) + power = np.empty(len(f32)) + n_occ = np.empty(len(f32), dtype=int) + for i, f in enumerate(f32): + tf = t32 * f + phase = (tf - np.floor(tf)).astype(np.float64) + b = (phase * nbins).astype(int) % nbins + wtot = np.bincount(b, weights=w, minlength=nbins) + wsum = np.bincount(b, weights=w * y, minlength=nbins) + means = np.where(wtot > 0, wsum / np.where(wtot > 0, wtot, 1.0), 0.0) + power[i] = 1 - np.dot(w, (y - means[b]) ** 2) / ss_tot + n_occ[i] = np.count_nonzero(wtot) + return power, n_occ + + +def _phase_exactly_one_lightcurve(): + """Times whose float64 mean is ~0 and which contain a point at t = -1e-9. + + In float32, ``t*f`` for that point lies in (-2**-25, 0) at the trial + frequencies returned here, so ``PHASE(t, f) = t*f - floorf(t*f)`` rounds + to exactly 1.0f and ``(int)(PHASE * NBINS)`` is ``NBINS`` -- one past the + end of the per-thread bin arrays unless the kernel wraps it. + """ + rand = np.random.RandomState(4) + base = np.array([-3.0, 3.0, -2.5, 2.5, -1.7, 1.7, -0.9, 0.9, -1e-9, 1e-9]) + more = 3 * rand.rand(40) + t = np.concatenate([base, more, -more]) + freqs = np.array([2.0, 3.0, 0.5, 4.0]) + # precondition: the -1e-9 point really folds to float32 phase 1.0 + t32 = (t - np.mean(t)).astype(np.float32) + tf = t32[8] * freqs.astype(np.float32) + assert np.all(tf - np.floor(tf) == np.float32(1.0)) + return t, freqs + + +@mark_cuda_test +def test_binned_step_phase_exactly_one_no_oob_read(): + """Audit id 113: ``var_step_function`` (kind='binned_step') indexed + ``bin_means[NBINS]`` when a float32 phase rounds to exactly 1.0, while + its own accumulation loop, the linterp kernel and the ``_fast`` kernels + all wrap with ``bin % NBINS``. Pre-fix (A40): |binned_step - + binned_step_fast| = 0.012..0.024 at the affected frequencies; post-fix + the kernels agree to float32 round-off (< 1e-7). + """ + t, freqs = _phase_exactly_one_lightcurve() + err = np.ones_like(t) + rand = np.random.RandomState(113) + proc = PDMAsyncProcess() + + def run(kind, t, y, err): + res = proc.run([(t, y, err)], freqs=freqs, kind=kind, nbins=10) + proc.finish() + return np.copy(res[0][1]) + + worst_vs_fast, worst_vs_ref = 0.0, 0.0 + for _ in range(10): + y = 12 + rand.randn(len(t)) + step = run('binned_step', t, y, err) + fast = run('binned_step_fast', t, y, err) + ref, _ = _ref_binned_step(t, y, weights(err), freqs, 10) + assert np.all(np.isfinite(step)) + worst_vs_fast = max(worst_vs_fast, np.max(np.abs(step - fast))) + worst_vs_ref = max(worst_vs_ref, np.max(np.abs(step - ref))) + assert worst_vs_fast < 5e-6 + assert worst_vs_ref < 5e-6 + + # the statistic must not depend on the order of the observations + perm = rand.permutation(len(t)) + assert_allclose(run('binned_step', t[perm], y[perm], err[perm]), step, + atol=5e-6, rtol=0) + + +@mark_cuda_test +def test_deprecated_format_normalizes_weights(): + """Audit id 111: the deprecated ``(t, y, w, freqs)`` input format assumed + ``sum(w) == 1``. With raw inverse-variance weights (or all ones) the + host-side weighted mean and variance were scaled by ``sum(w)`` and every + kind returned a flat spectrum of exactly 1.0. ``run()`` now normalizes + ``w`` (the statistic is invariant to the scale of ``w``), so the legacy + path must agree with the modern ``(t, y, err)`` path for any scaling. + """ + rand = np.random.RandomState(111) + n = 300 + t = np.sort(30 * rand.rand(n)) + y = 12 + np.sin(2 * np.pi * 1.7 * t) + 0.2 * rand.randn(n) + err = 0.2 * (0.5 + rand.rand(n)) + freqs = np.linspace(0.05, 5.0, 400) + proc = PDMAsyncProcess() + + def run(data, kind, **kw): + res = proc.run(data, kind=kind, nbins=10, dphi=0.05, **kw) + proc.finish() + return res + + cases = [ + (err ** -2, err), # raw inverse variance: sum(w) != 1 + (weights(err), err), # already normalized + (np.ones(n), np.ones(n)), # uniform weights: sum(w) == n + ] + for kind in ['binned_linterp', 'binned_step_fast', + 'binless_tophat', 'binless_gauss_fast']: + for w, err_equiv in cases: + modern = np.copy(run([(t, y, err_equiv)], kind, freqs=freqs)[0][1]) + with pytest.warns(DeprecationWarning): + legacy = np.copy(run([(t, y, w, freqs)], kind)[0]) + assert np.ptp(modern) > 0.5 # a real periodogram + assert_allclose(legacy, modern, atol=1e-6, rtol=0) + + +def test_pdm2_cpu_is_ss_ratio_without_dof_correction(): + """Audit id 110: the statistic is ``1 - SS_within / SS_total`` with + normalized weights and *no* ``(N - M) / (N - 1)`` degrees-of-freedom + factor -- it is not Stellingwerf's ``1 - Theta``. + """ + rand = np.random.RandomState(110) + n, nbins = 50, 10 + t = np.sort(30 * rand.rand(n)) + y = rand.randn(n) + w = weights(0.1 * (0.5 + rand.rand(n))) + freqs = np.linspace(0.1, 5.0, 40) + + p = np.asarray(pdm2_cpu(t, y, w, freqs, nbins=nbins, linterp=False)) + ref, n_occ = _ref_binned_step(t, y, w, freqs, nbins, fold_dtype=np.float64) + assert_allclose(p, ref, atol=1e-12, rtol=0) + + # the dof-corrected statistic is a different function of the data + one_minus_theta = 1 - (n - 1) / (n - n_occ) * (1 - ref) + assert np.max(np.abs(one_minus_theta - p)) > 0.05 + + +def test_pdm2_cpu_noise_floor_is_M_minus_1_over_N_minus_1(): + """Audit id 110: for pure Gaussian noise with uniform weights + ``SS_between / SS_total ~ Beta((M - 1)/2, (N - M)/2)``, so the returned + power has expectation ``(M - 1) / (N - 1)`` with ``M`` the number of + occupied bins -- about 0.4 at N = 20 in 10 bins, not ~0 as the + dof-corrected ``1 - Theta`` would give. + """ + rand = np.random.RandomState(110) + n, nbins = 20, 10 + p_all, expect_all = [], [] + for _ in range(100): + t = np.sort(30 * rand.rand(n)) + y = rand.randn(n) + w = np.ones(n) / n + freqs = 0.05 + 5.0 * rand.rand(30) + p_all.extend(pdm2_cpu(t, y, w, freqs, nbins=nbins, linterp=False)) + _, n_occ = _ref_binned_step(t, y, w, freqs, nbins, fold_dtype=np.float64) + expect_all.extend((n_occ - 1) / (n - 1)) + assert abs(np.mean(p_all) - np.mean(expect_all)) < 0.02 # measured 0.002 + assert np.mean(p_all) > 0.3 + + +@mark_cuda_test +def test_gpu_binned_step_statistic_and_noise_floor(): + """Audit id 110 on the device: ``kind='binned_step'`` returns exactly + the float32-fold ``1 - SS_within / SS_total`` (no dof correction), so + pure noise sits at ``(M - 1) / (N - 1)`` (0.40 at N = 20 on the A40), + not near zero. + """ + proc = PDMAsyncProcess() + freqs = np.linspace(0.05, 5.0, 500) + for n in (20, 200): + p_means, expect_means = [], [] + for seed in range(4): + rand = np.random.RandomState(1000 * n + seed) + t = np.sort(30 * rand.rand(n)) + y = rand.randn(n) + err = np.ones(n) + res = proc.run([(t, y, err)], freqs=freqs, kind='binned_step', + nbins=10) + proc.finish() + p = np.copy(res[0][1]) + ref, n_occ = _ref_binned_step(t, y, weights(err), freqs, 10) + assert_allclose(p, ref, atol=5e-6, rtol=0) # measured 2e-7..2e-6 + p_means.append(p.mean()) + expect_means.append(np.mean((n_occ - 1) / (n - 1))) + assert abs(np.mean(p_means) - np.mean(expect_means)) < 0.03 + if n == 20: + assert np.mean(p_means) > 0.3 + + +def test_run_docstring_states_statistic_and_dphi_semantics(): + """Audit ids 110/117: run() must say what the returned power is and + what ``dphi`` means (tophat half-width / Gaussian standard deviation).""" + doc = PDMAsyncProcess.run.__doc__ + assert 'half-width' in doc + assert 'standard deviation' in doc + assert 'no degrees-of-freedom correction' in doc + + +# --------------------------------------------------------------------------- +# PDM-1 (audit id 161): run() reallocated five device arrays, a page-locked +# host buffer and a synchronous frequency upload on every call. The device +# buffers are now kept and reused when the next call asks for the same +# shapes; nothing about the returned numbers may change. +# --------------------------------------------------------------------------- + +def _reuse_lc(ndata, seed, baseline=20.): + r = np.random.RandomState(seed) + t = np.sort(r.uniform(0, baseline, ndata)) + y = 0.4 * np.sin(2 * np.pi * 1.7 * t) + 0.1 * r.randn(ndata) + return t, y, 0.1 * np.ones(ndata) + + +class TestPDMTupleShape(object): + """Sep 2026 review (idx 46): a (t, y) 2-tuple passed the validator + (``lc[2] if len(lc) > 2 else None``) and died in ``run()`` with a + raw "not enough values to unpack (expected 3, got 2)". CPU-runnable: + the validator raises before any GPU work.""" + + grid = np.linspace(0.2, 4.0, 65) + + def test_two_tuple_is_rejected_with_a_clear_message(self): + t, y, dy = _reuse_lc(40, 21) + proc = PDMAsyncProcess() + for entry in (lambda d: proc.run(d, freqs=self.grid), + lambda d: proc.large_run(d, freqs=self.grid), + lambda d: proc.batched_run_const_nfreq( + d, freqs=self.grid)): + with pytest.raises(ValueError, match=r'\(t, y, err\) tuple'): + entry([(t, y)]) + # the bad lightcurve is named when it is not the first one + with pytest.raises(ValueError, match='1'): + entry([(t, y, dy), (t, y)]) + # stub-independent: the validator itself raises + from ..pdm import _check_pdm_data + with pytest.raises(ValueError, match=r'\(t, y, err\) tuple'): + _check_pdm_data([(t, y)], self.grid, 'x', False) + + def test_mixed_deprecated_batch_is_rejected(self): + t, y, dy = _reuse_lc(40, 22) + w = weights(dy) + proc = PDMAsyncProcess() + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError, + match=r'lightcurve 1: must be a \(t, y, w, ' + r'freqs\) tuple'): + proc.run([(t, y, w, self.grid), (t, y, dy)]) + + +class TestPDMConstantY(object): + """Sep 2026 review (idx 17, audit id 115): a constant ``y`` passed + the validator and the kernels returned ``1 - x / 0`` = NaN at every + frequency. CPU-runnable: the validator raises before any GPU work.""" + + grid = np.linspace(0.2, 4.0, 65) + + def test_constant_y_is_rejected(self): + t, y, dy = _reuse_lc(40, 31) + const = np.full_like(y, 12.5) + proc = PDMAsyncProcess() + for entry in (lambda d: proc.run(d, freqs=self.grid), + lambda d: proc.large_run(d, freqs=self.grid), + lambda d: proc.batched_run_const_nfreq( + d, freqs=self.grid)): + with pytest.raises(ValueError, match='lightcurve 1: y is ' + 'constant'): + entry([(t, y, dy), (t, const, dy)]) + # stub-independent: the validator itself raises + from ..pdm import _check_pdm_data + with pytest.raises(ValueError, match='y is constant'): + _check_pdm_data([(t, const, dy)], self.grid, 'x', False) + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError, match='y is constant'): + proc.run([(t, const, weights(dy), self.grid)]) + # the host-side variance the kernels divide by really is zero + w = weights(dy) + yc = const - np.mean(const) + assert np.dot(w, (yc - np.dot(w, yc)) ** 2) == 0.0 + + +class TestPDMAllocationReuse(object): + + grid = np.linspace(0.2, 4.0, 257) + + def test_same_shapes_reuse_the_device_buffers(self): + proc = PDMAsyncProcess() + t, y, dy = _reuse_lc(120, 1) + proc.run([(t, y, dy)], freqs=self.grid) + proc.finish() + first = proc._alloc_cache[1][0] + proc.run([(t, y, dy)], freqs=self.grid) + proc.finish() + second = proc._alloc_cache[1][0] + # t_g, y_g, w_g, freqs_g, pow_g: the same five device arrays + assert all(a is b for a, b in zip(first, second)) + + def test_new_shapes_replace_the_cache(self): + proc = PDMAsyncProcess() + t, y, dy = _reuse_lc(120, 2) + proc.run([(t, y, dy)], freqs=self.grid) + proc.finish() + first = proc._alloc_cache[1][0] + t2, y2, dy2 = _reuse_lc(200, 3) + proc.run([(t2, y2, dy2)], freqs=self.grid) + proc.finish() + assert proc._alloc_cache[0] == ((200, len(self.grid)),) + assert proc._alloc_cache[1][0][0] is not first[0] + # ... and a different grid length too + proc.run([(t2, y2, dy2)], freqs=self.grid[:64]) + proc.finish() + assert proc._alloc_cache[0] == ((200, 64),) + + def test_results_are_not_shared_between_calls(self): + """The reused buffers are on the device; each call still gets its + own host result array, so an earlier result is never clobbered.""" + proc = PDMAsyncProcess() + a = _reuse_lc(150, 4) + b = _reuse_lc(150, 5) + r1 = proc.run([a], freqs=self.grid) + proc.finish() + keep = np.copy(r1[0][1]) + r2 = proc.run([b], freqs=self.grid) + proc.finish() + assert r1[0][1] is not r2[0][1] + assert_array_equal(np.asarray(r1[0][1]), keep) + assert not np.array_equal(np.asarray(r2[0][1]), keep) + + @pytest.mark.parametrize('kind', ['binned_linterp', 'binned_step', + 'binned_linterp_fast', + 'binless_tophat']) + def test_reused_buffers_give_identical_results(self, kind): + """Bit-for-bit: the same call through a fresh allocation and + through the reused one.""" + warm = PDMAsyncProcess() + warm.run([_reuse_lc(150, 6)], freqs=self.grid, kind=kind) + warm.finish() + for seed in (7, 8, 9): + d = _reuse_lc(150, seed) + fresh = PDMAsyncProcess() + p_fresh = fresh.run([d], freqs=self.grid, kind=kind) + fresh.finish() + ref = np.copy(p_fresh[0][1]) + p_warm = warm.run([d], freqs=self.grid, kind=kind) + warm.finish() + assert_array_equal(np.asarray(p_warm[0][1]), ref) + + def test_changed_grid_of_the_same_length_is_reuploaded(self): + """The cache keys on shapes only, so a *different* grid with the + same length has to be pushed to the device again.""" + proc = PDMAsyncProcess() + d = _reuse_lc(150, 10) + g1 = self.grid + g2 = self.grid + 0.37 + proc.run([d], freqs=g1) + proc.finish() + got = proc.run([d], freqs=g2) + proc.finish() + clean = PDMAsyncProcess() + ref = clean.run([d], freqs=g2) + clean.finish() + assert_array_equal(np.asarray(got[0][1]), np.asarray(ref[0][1])) + assert_array_equal(np.asarray(got[0][0]), g2) + + def test_in_place_mutated_float32_grid_is_reuploaded_cpu(self, + monkeypatch): + """Sep 2026 review (idx 15): the cache stored ``np.asarray(f, + float32)`` -- the caller's own array for a float32 grid -- so a + grid modified in place compared equal to itself and the device + kept the old one. CPU-runnable with recording fake device + arrays.""" + class FakeDevice(object): + def __init__(self): + self.sets = [] + + def set(self, a): + self.sets.append(np.array(a, copy=True)) + + proc = PDMAsyncProcess() + + def fake_allocate(norm_data, freqs=None, **kw): + gpu = [(None, None, None, FakeDevice(), None) for _ in norm_data] + return gpu, [np.zeros(len(f), np.float32) + for (t, y, w, f) in norm_data] + + monkeypatch.setattr(proc, 'allocate', fake_allocate) + monkeypatch.setattr(pdm_module, 'host_array', + lambda shape, dtype: np.zeros(shape, dtype)) + t, y, dy = _reuse_lc(40, 11) + w = weights(dy) + f = np.linspace(0.2, 4.0, 33).astype(np.float32) + gpu_data, _ = proc._allocate_cached([(t, y, w, f)], [f]) + assert proc._alloc_cache[2][0] is not f + f *= 2.0 + gpu_data, _ = proc._allocate_cached([(t, y, w, f)], [f]) + dev = gpu_data[0][3] + assert len(dev.sets) == 1 + assert_array_equal(dev.sets[0], f) + assert_array_equal(proc._alloc_cache[2][0], f) + # the stored grid is still private: a later mutation is seen too + f += 0.5 + gpu_data, _ = proc._allocate_cached([(t, y, w, f)], [f]) + assert len(dev.sets) == 2 + assert_array_equal(dev.sets[1], f) + + def test_in_place_mutated_float32_grid_is_reuploaded(self): + """GPU counterpart: the powers of the second call must be those + of the mutated grid, not of the grid the first call uploaded.""" + proc = PDMAsyncProcess() + d = _reuse_lc(150, 12) + g = np.asarray(self.grid, dtype=np.float32) + proc.run([d], freqs=g) + proc.finish() + g += np.float32(0.37) # in place: same object, new grid + got = proc.run([d], freqs=g) + proc.finish() + clean = PDMAsyncProcess() + ref = clean.run([d], freqs=np.array(g, copy=True)) + clean.finish() + assert_array_equal(np.asarray(got[0][1]), np.asarray(ref[0][1])) + + def test_batched_run_matches_single_runs(self): + data = [_reuse_lc(90 + 0 * i, 20 + i) for i in range(7)] + freqs = np.linspace(0.3, 3.0, 129) + proc = PDMAsyncProcess() + batched = proc.batched_run_const_nfreq(data, batch_size=3, + freqs=freqs) + for (t, y, dy), (_f, p) in zip(data, batched): + clean = PDMAsyncProcess() + single = clean.run([(t, y, dy)], freqs=freqs) + clean.finish() + assert_array_equal(np.asarray(p), np.asarray(single[0][1])) diff --git a/cuvarbase/tests/test_pdm_batch.py b/cuvarbase/tests/test_pdm_batch.py new file mode 100644 index 00000000..6a82b150 --- /dev/null +++ b/cuvarbase/tests/test_pdm_batch.py @@ -0,0 +1,119 @@ +"""CPU-side tests for the PDM batch API (C1, issue #33). + +The actual GPU correctness (batch matching per-LC results, large_run +respecting max_memory) is validated on a pod; here we test the +batch-sizing arithmetic and the chunking/result-shaping logic by mocking +``PDMAsyncProcess.run`` so they run on CPU-only machines. +""" +import numpy as np + +from cuvarbase.pdm import PDMAsyncProcess + + +def _proc(): + # Constructing the process does no GPU work (streams are lazy); on a + # GPU-less machine ensure_context() just imports the stubbed module. + return PDMAsyncProcess() + + +def test_batch_size_from_memory_arithmetic(): + proc = _proc() + # per_lc = (3*1000 + 2*5000) * 4 = 52000 bytes + assert proc._bytes_per_lc(1000, 5000) == 52000 + assert proc._batch_size_from_memory( + 1000, 5000, n_lcs=100, max_memory=520000) == 10 + # capped at n_lcs + assert proc._batch_size_from_memory( + 1000, 5000, n_lcs=3, max_memory=10 ** 9) == 3 + # never below 1, even if a single LC exceeds the budget + assert proc._batch_size_from_memory( + 1000, 5000, n_lcs=100, max_memory=1) == 1 + + +def test_batched_run_const_nfreq_chunks_and_reuses_freqs(monkeypatch): + proc = _proc() + chunk_sizes = [] + freqs_seen = [] + + def fake_run(data, freqs=None, **kw): + chunk_sizes.append(len(data)) + freqs_seen.append(freqs) + return [(freqs, np.zeros(len(freqs))) for _ in data] + + monkeypatch.setattr(proc, 'run', fake_run) + monkeypatch.setattr(proc, 'finish', lambda: None) + + # (a non-constant y: the validator now rejects a constant one) + data = [(np.linspace(0, 10, 50 + i), + np.sin(np.linspace(0, 10, 50 + i)), np.ones(50 + i)) + for i in range(5)] + freqs = np.linspace(0.1, 1.0, 20) + + res = proc.batched_run_const_nfreq(data, batch_size=2, freqs=freqs) + + assert chunk_sizes == [2, 2, 1] # chunked by batch_size + assert len(res) == 5 # one result per lightcurve + assert all(len(f) == 20 for f, p in res) + # the same const grid is reused for every chunk (no per-LC recompute) + assert all(fs is freqs_seen[0] for fs in freqs_seen) + + +def test_batched_run_const_nfreq_empty(): + proc = _proc() + assert proc.batched_run_const_nfreq([], freqs=np.linspace(0.1, 1, 5)) == [] + + +def test_batched_run_const_nfreq_rejects_bad_batch_size(): + # batch_size=-2 used to silently return [] (all results dropped); + # batch_size=0 crashed with an opaque range() error. + import pytest + proc = _proc() + data = [(np.linspace(0, 10, 50), np.zeros(50), np.ones(50))] + freqs = np.linspace(0.1, 1, 5) + for bad in (0, -2): + with pytest.raises(ValueError, match="batch_size"): + proc.batched_run_const_nfreq(data, batch_size=bad, freqs=freqs) + + +def test_batched_run_const_nfreq_rejects_legacy_format(): + # The deprecated (t, y, w, freqs) run() format used to die deep in + # the result loop with an opaque unpack error and silently ignored + # the shared freqs argument. + import pytest + proc = _proc() + t = np.linspace(0, 10, 50) + legacy = [(t, np.zeros(50), np.ones(50), np.linspace(0.1, 1, 5))] + with pytest.raises(ValueError, match="deprecated"): + proc.batched_run_const_nfreq(legacy, freqs=np.linspace(0.1, 1, 5)) + + +def test_batch_size_from_memory_stream_cap(): + # run() creates one stream + one pinned buffer per LC in the chunk; + # unbounded free memory must not translate into a driver-resource + # exhausting batch size (audit: 21.6 GB free -> batch_size ~ 1M). + proc = _proc() + assert proc._batch_size_from_memory( + 150, 2000, n_lcs=10 ** 6, max_memory=20 * 10 ** 9) == \ + PDMAsyncProcess.MAX_BATCH_SIZE + + +def test_large_run_uses_memory_capped_batch_size(monkeypatch): + proc = _proc() + captured = {} + + def fake_batched(data, batch_size=None, freqs=None, **kw): + captured['batch_size'] = batch_size + captured['nfreqs'] = len(freqs) + return [(freqs, np.zeros(len(freqs))) for _ in data] + + monkeypatch.setattr(proc, 'batched_run_const_nfreq', fake_batched) + + # 6 LCs, max_ndata=1000, nf=5000 -> per_lc=52000; budget fits 4 + data = [(np.linspace(0, 10, 1000), np.sin(np.linspace(0, 10, 1000)), + np.ones(1000)) for _ in range(6)] + freqs = np.linspace(0.1, 1.0, 5000) + res = proc.large_run(data, freqs=freqs, max_memory=4 * 52000) + + assert captured['batch_size'] == 4 + assert captured['nfreqs'] == 5000 + assert len(res) == 6 diff --git a/cuvarbase/tests/test_readme_consistency.py b/cuvarbase/tests/test_readme_consistency.py new file mode 100644 index 00000000..4b8ab1fb --- /dev/null +++ b/cuvarbase/tests/test_readme_consistency.py @@ -0,0 +1,70 @@ +"""Guard a few README factual claims that map to real code/release state. + +These are the claims that silently rot or contradict the code: +- the removed ``periodograms`` subpackage must not be advertised, +- ``import cuvarbase`` no longer requires a GPU / creates a context (B1), +- the candidate install points to the measured branch and distinguishes the + published PyPI version; every link is absolute, +- ADS links should be https, and the test suite is CPU-runnable. +""" +import os +import re + +import pytest + + +def _readme(): + root = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) + path = os.path.join(root, "README.md") + if not os.path.exists(path): + pytest.skip("README.md not found (running outside the source tree)") + return open(path, encoding="utf-8").read() + + +def test_readme_does_not_advertise_removed_periodograms_subpackage(): + readme = _readme() + assert "periodograms/`" not in readme, ( + "README still lists the removed `periodograms/` subpackage") + + +def test_readme_import_does_not_claim_gpu_required(): + # B1: import creates no CUDA context and needs no GPU. + readme = _readme().lower() + assert "creates a cuda context at import" not in readme + assert "importing cuvarbase still requires a working cuda" not in readme + + +def test_readme_installs_the_benchmarked_candidate(): + # The measured v1 candidate is not the currently published 0.2.5. + # Keep an ordinary PyPI install from silently selecting a different + # implementation from the one advertised by the benchmark figure. + readme = _readme() + installation = re.search( + r"^## Installation\n(.*?)(?=^## |\Z)", readme, re.M | re.S) + assert installation is not None + text = installation.group(1) + assert "0.2.5" in text and "PyPI" in text + assert ("pip install 'cuvarbase @ git+https://github.com/" + "johnh2o2/cuvarbase@v1.0-fixes'") in text + assert "pip install cuvarbase\n" not in text + + +def test_readme_links_are_absolute(): + # Relative links do not resolve from PyPI; every ``](...)`` target + # must be an absolute URL or an in-page anchor. + readme = _readme() + bad = [m for m in re.findall(r"\]\(([^)]+)\)", readme) + if not (m.startswith("http") or m.startswith("#"))] + assert bad == [], bad + + +def test_readme_ads_links_are_https(): + readme = _readme() + assert "http://adsabs" not in readme + assert "http://ui.adsabs" not in readme + + +def test_readme_testing_section_is_cpu_runnable(): + readme = _readme().lower() + assert "tests require a cuda-capable gpu" not in readme diff --git a/cuvarbase/tests/test_readme_examples.py b/cuvarbase/tests/test_readme_examples.py new file mode 100644 index 00000000..ea74c943 --- /dev/null +++ b/cuvarbase/tests/test_readme_examples.py @@ -0,0 +1,84 @@ +""" +BLS API smoke tests in the shape of the README's Quick Start. + +``test_quick_start_example`` mirrors the README's ``eebls_gpu`` snippet; +the other two are API smoke tests for ``eebls_gpu_fast_adaptive`` (the +README no longer carries an adaptive example) and for the agreement of +the standard and adaptive periodograms. + +These require a GPU; on CPU-only machines ``cuvarbase/tests/conftest.py`` +converts them to skips. (An earlier version of this file was silently never +collected — @mark_cuda_test on the class turned it into a plain +function — and unpacked eebls_gpu's tuple return incorrectly.) +""" +import numpy as np + + +class TestReadmeExamples: + """README Quick Start snippet plus BLS API smoke tests (GPU).""" + + def _data(self, ndata=1000): + np.random.seed(42) # For reproducibility + t = np.sort(np.random.uniform(0, 10, ndata)).astype(np.float32) + y = np.sin(2 * np.pi * t / 2.5) + np.random.normal(0, 0.1, len(t)) + dy = np.ones_like(y) * 0.1 # uncertainties + return t, y, dy + + def test_quick_start_example(self): + """Test the Quick Start example from README""" + from cuvarbase import bls + + t, y, dy = self._data() + freqs = np.linspace(0.1, 2.0, 5000).astype(np.float32) + + # Standard BLS returns (power, solutions) — as in the README + power, solutions = bls.eebls_gpu(t, y, dy, freqs) + best_freq = freqs[np.argmax(power)] + best_period = 1 / best_freq + + assert power.shape == freqs.shape + assert len(solutions) == len(freqs) + assert np.max(power) > 0.0 + + # Period should be close to true period (2.5 days); BLS on a + # sinusoid typically locks onto P or P/2. + assert (2.0 < best_period < 3.0) or (1.0 < best_period < 1.5), \ + "Best period %s not near 2.5 or 1.25" % best_period + + def test_adaptive_bls_example(self): + """API smoke test: eebls_gpu_fast_adaptive on the Quick Start + data returns a finite, non-trivial periodogram.""" + from cuvarbase import bls + + t, y, dy = self._data() + freqs = np.linspace(0.1, 2.0, 5000).astype(np.float32) + + power_adaptive = bls.eebls_gpu_fast_adaptive(t, y, dy, freqs) + + assert power_adaptive.shape == freqs.shape + assert np.max(power_adaptive) > 0.0 + + def test_standard_vs_adaptive_consistency(self): + """Standard and adaptive BLS should agree on the periodogram. + + They use different binning strategies (eebls_gpu bins per-(q,phi) + solution; the fast/adaptive kernel scans a binned histogram), so + exact equality is not expected — require strong correlation and + matching peak, the same criteria used for the GPU/GPU checks in + scripts/benchmark_new_features.py. + """ + from cuvarbase import bls + + t, y, dy = self._data(ndata=500) + freqs = np.linspace(0.1, 2.0, 1000).astype(np.float32) + + power_standard, _ = bls.eebls_gpu(t, y, dy, freqs) + power_adaptive = bls.eebls_gpu_fast_adaptive(t, y, dy, freqs) + + corr = np.corrcoef(power_standard, power_adaptive)[0, 1] + assert corr > 0.95, "standard/adaptive correlation %.4f" % corr + + # Peak frequencies should agree to within a few grid points + ipeak_standard = np.argmax(power_standard) + ipeak_adaptive = np.argmax(power_adaptive) + assert abs(int(ipeak_standard) - int(ipeak_adaptive)) <= 3 diff --git a/cuvarbase/tests/test_tls_basic.py b/cuvarbase/tests/test_tls_basic.py new file mode 100644 index 00000000..0eedca64 --- /dev/null +++ b/cuvarbase/tests/test_tls_basic.py @@ -0,0 +1,1694 @@ +""" +Basic tests for TLS GPU implementation. + +These tests verify the basic functionality of the TLS implementation, +focusing on API correctness and basic execution rather than scientific +accuracy (the golden-reference comparisons against transitleastsquares +and batman live in test_tls_golden.py; the fast-path behaviour in +test_tls_fast.py). +""" + +import pytest +import numpy as np + +try: + # NOT pycuda.autoinit: it creates its own (non-primary) CUDA context + # at import time, while cuvarbase lazily retains the PRIMARY context + # (cuvarbase.base.ensure_context). pytest imports every test module + # during collection, so the stray autoinit context outlived this file + # and left two contexts on the stack for the whole session; pycuda's + # context-dependent kernel cache then handed out handles from the + # wrong one and unrelated tests died with + # "cuFuncSetBlockShape failed: invalid resource handle" + # (23 failures in test_nfft.py / the cuFINUFFT tests, Sep 2026). + import pycuda.driver # noqa: F401 + PYCUDA_AVAILABLE = True +except Exception: + PYCUDA_AVAILABLE = False + +# Import modules to test +from cuvarbase import tls_grids, tls_models, tls_stats + + +class TestGridGeneration: + """Test period and duration grid generation.""" + + def test_period_grid_basic(self): + """Test basic period grid generation.""" + t = np.linspace(0, 100, 1000) # 100-day observation + + periods = tls_grids.period_grid_ofir(t, R_star=1.0, M_star=1.0) + + assert len(periods) > 0 + assert np.all(periods > 0) + assert np.all(np.diff(periods) > 0) # Increasing + assert periods[0] < periods[-1] + + def test_period_grid_limits(self): + """Test period grid with custom limits.""" + t = np.linspace(0, 100, 1000) + + periods = tls_grids.period_grid_ofir( + t, period_min=5.0, period_max=20.0 + ) + + assert periods[0] >= 5.0 + assert periods[-1] <= 20.0 + + def test_duration_grid(self): + """Test duration grid generation.""" + periods = np.array([10.0, 20.0, 30.0]) + + durations, counts = tls_grids.duration_grid(periods) + + assert len(durations) == len(periods) + assert len(counts) == len(periods) + assert all(c > 0 for c in counts) + + # Check durations are reasonable (< period) + for i, period in enumerate(periods): + assert all(d < period for d in durations[i]) + assert all(d > 0 for d in durations[i]) + + def test_transit_duration_max(self): + """Test maximum transit duration calculation.""" + period = 10.0 # days + + duration = tls_grids.transit_duration_max( + period, R_star=1.0, M_star=1.0, R_planet=1.0 + ) + + assert duration > 0 + assert duration < period # Duration must be less than period + assert duration < 1.0 # For Earth-Sun system, ~0.5 days + + def test_t0_grid(self): + """Test T0 grid generation.""" + period = 10.0 + duration = 0.1 + + t0_values = tls_grids.t0_grid(period, duration, oversampling=5) + + assert len(t0_values) > 0 + assert np.all(t0_values >= 0) + assert np.all(t0_values <= 1) + + def test_validate_stellar_parameters(self): + """Test stellar parameter validation.""" + # Valid parameters + tls_grids.validate_stellar_parameters(R_star=1.0, M_star=1.0) + + # Invalid radius + with pytest.raises(ValueError): + tls_grids.validate_stellar_parameters(R_star=10.0, M_star=1.0) + + # Invalid mass + with pytest.raises(ValueError): + tls_grids.validate_stellar_parameters(R_star=1.0, M_star=5.0) + + +class TestTransitTemplate: + """Test transit template generation for GPU kernel.""" + + def test_trapezoid_template_shape(self): + """Test trapezoidal fallback template has correct shape.""" + template = tls_models._trapezoid_template(n_template=500) + + assert template.shape == (500,) + assert template.dtype == np.float32 + + def test_trapezoid_template_normalization(self): + """Test trapezoidal template values are in [0, 1].""" + template = tls_models._trapezoid_template(n_template=1000) + + assert np.all(template >= 0.0) + assert np.all(template <= 1.0) + # Center should be at max depth + assert template[500] == pytest.approx(1.0) + # Edges should be near zero + assert template[0] == pytest.approx(0.0, abs=0.01) + assert template[-1] == pytest.approx(0.0, abs=0.01) + + def test_trapezoid_template_symmetric(self): + """Test trapezoidal template is symmetric.""" + template = tls_models._trapezoid_template(n_template=1001) + np.testing.assert_allclose(template, template[::-1], atol=1e-6) + + @pytest.mark.skipif(not tls_models.BATMAN_AVAILABLE, + reason="batman-package not installed") + def test_batman_template_shape(self): + """Test batman template has correct shape and dtype.""" + template = tls_models.generate_transit_template(n_template=1000) + + assert template.shape == (1000,) + assert template.dtype == np.float32 + + @pytest.mark.skipif(not tls_models.BATMAN_AVAILABLE, + reason="batman-package not installed") + def test_batman_template_normalization(self): + """Test batman template values are in [0, 1] with max = 1.""" + template = tls_models.generate_transit_template(n_template=1000) + + assert np.all(template >= 0.0) + assert np.all(template <= 1.0) + assert np.max(template) == pytest.approx(1.0, abs=0.01) + # Edges should be near zero + assert template[0] < 0.1 + assert template[-1] < 0.1 + + @pytest.mark.skipif(not tls_models.BATMAN_AVAILABLE, + reason="batman-package not installed") + def test_batman_template_limb_darkened(self): + """Test batman template shows limb darkening (not a box).""" + template = tls_models.generate_transit_template(n_template=1000) + + # The template should NOT be a perfect box (all 0 or 1). + # With limb darkening, there should be intermediate values. + n_intermediate = np.sum((template > 0.1) & (template < 0.9)) + assert n_intermediate > 10, "Template should have limb-darkened shape, not a box" + + def test_generate_fallback_without_batman(self): + """Test generate_transit_template falls back to trapezoid.""" + # Force fallback by testing _trapezoid_template directly + template = tls_models._trapezoid_template(n_template=500) + + assert template.shape == (500,) + assert np.max(template) == pytest.approx(1.0) + assert np.min(template) == pytest.approx(0.0, abs=0.01) + + +@pytest.mark.skipif(not tls_models.BATMAN_AVAILABLE, + reason="batman-package not installed") +class TestTransitModels: + """Test transit model generation (requires batman).""" + + def test_reference_transit(self): + """Test reference transit model creation.""" + phases, flux = tls_models.create_reference_transit(n_samples=100) + + assert len(phases) == len(flux) + assert len(phases) == 100 + assert np.all((phases >= 0) & (phases <= 1)) + assert np.all(flux <= 1.0) # Transit causes dimming + assert np.min(flux) < 1.0 # There is a transit + + def test_transit_model_cache(self): + """Test transit model cache creation.""" + durations = np.array([0.05, 0.1, 0.15]) + + models, phases = tls_models.create_transit_model_cache( + durations, period=10.0, n_samples=100 + ) + + assert len(models) == len(durations) + assert len(phases) == 100 + for model in models: + assert len(model) == len(phases) + + +class TestSimpleTransitModels: + """Test simple transit models (no batman required).""" + + def test_simple_trapezoid(self): + """Test simple trapezoidal transit.""" + phases = np.linspace(0, 1, 1000) + duration_phase = 0.1 + + flux = tls_models.simple_trapezoid_transit( + phases, duration_phase, depth=0.01 + ) + + assert len(flux) == len(phases) + assert np.all(flux <= 1.0) + assert np.min(flux) < 1.0 # There is a transit + assert np.max(flux) == 1.0 # Out of transit = 1.0 + + def test_interpolate_transit_model(self): + """Test transit model interpolation.""" + model_phases = np.linspace(0, 1, 100) + model_flux = np.ones(100) + model_flux[40:60] = 0.99 # Simple transit + + target_phases = np.linspace(0, 1, 200) + + flux_interp = tls_models.interpolate_transit_model( + model_phases, model_flux, target_phases, target_depth=0.01 + ) + + assert len(flux_interp) == len(target_phases) + assert np.all(flux_interp <= 1.0) + + def test_default_limb_darkening(self): + """Test default limb darkening coefficient lookup.""" + u_kepler = tls_models.get_default_limb_darkening('Kepler', T_eff=5500) + assert len(u_kepler) == 2 + assert all(0 < coeff < 1 for coeff in u_kepler) + + u_tess = tls_models.get_default_limb_darkening('TESS', T_eff=5500) + assert len(u_tess) == 2 + + def test_validate_limb_darkening(self): + """Test limb darkening validation.""" + # Valid quadratic + tls_models.validate_limb_darkening_coeffs([0.4, 0.2], 'quadratic') + + # Invalid - wrong number + with pytest.raises(ValueError): + tls_models.validate_limb_darkening_coeffs([0.4], 'quadratic') + + +class TestStatistics: + """Test TLS statistics calculations.""" + + def test_signal_residue_with_signal(self): + """Test SR is positive for a signal.""" + # Simulate chi2 values where one period has much lower chi2 + chi2 = np.ones(100) * 1000.0 + chi2[50] = 500.0 # Signal at index 50 + + SR = tls_stats.signal_residue(chi2) + + # SR at signal should be highest + assert SR[50] > SR[0] + assert SR[50] > 0 + + def test_sde_positive_for_signal(self): + """Test SDE > 0 for an injected signal (regression test).""" + # Simulate chi2 values with a clear signal + np.random.seed(42) + chi2 = np.random.normal(1000, 10, size=200) + chi2[100] = 500.0 # Strong signal + + SDE, SDE_raw, power = tls_stats.signal_detection_efficiency( + chi2, detrend=False + ) + + assert SDE > 0, "SDE should be > 0 for injected signal" + assert SDE_raw > 0 + + def test_snr_with_chi2(self): + """Test SNR estimation from chi2 values.""" + snr = tls_stats.signal_to_noise( + 0.01, chi2_null=1000.0, chi2_best=500.0 + ) + assert snr > 0 + + def test_snr_returns_zero_without_info(self): + """Test SNR returns 0 when no depth_err or chi2 provided.""" + snr = tls_stats.signal_to_noise(0.01) + assert snr == 0.0 + + +class TestSDEKernelSize: + """SDE median-detrend kernel selection (fast-TLS survey rework): + auto kernel = min(len//10 forced odd, 91), even values round up, + and short series skip detrending like the reference package.""" + + @staticmethod + def _trended_chi2(n, seed=0): + # slow trend + one sharp dip so different medfilt windows give + # measurably different detrended spectra + rng = np.random.RandomState(seed) + chi2 = 1000.0 - 30.0 * np.sin(np.linspace(0, 3, n)) \ + + rng.normal(0, 1.0, n) + chi2[int(0.7 * n)] -= 200.0 + return chi2 + + def test_auto_kernel_capped_at_91(self): + chi2 = self._trended_chi2(5000) + auto = tls_stats.signal_detection_efficiency(chi2, detrend=True) + capped = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=91) + uncapped = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=501) + assert auto[0] == capped[0] + np.testing.assert_array_equal(auto[2], capped[2]) + assert auto[0] != uncapped[0] + + def test_small_grids_keep_length_scaled_kernel(self): + chi2 = self._trended_chi2(400) # len//10 = 40 -> odd 41 < 91 + auto = tls_stats.signal_detection_efficiency(chi2, detrend=True) + k41 = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=41) + assert auto[0] == k41[0] + + def test_even_kernel_rounds_up_to_odd(self): + chi2 = self._trended_chi2(2000) + k10 = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=10) + k11 = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=11) + assert k10[0] == k11[0] + np.testing.assert_array_equal(k10[2], k11[2]) + + def test_short_series_skips_detrending(self): + chi2 = self._trended_chi2(100) + # len(SR) <= 2 * kernel_size -> reference behavior: raw SDE + sde, sde_raw, power = tls_stats.signal_detection_efficiency( + chi2, detrend=True, kernel_size=51) + assert sde == sde_raw + np.testing.assert_array_equal( + power, tls_stats.signal_residue(chi2)) + + +class TestBatchPreprocessValidation: + """CPU-side validation in the fast path's batch preprocessing.""" + + class _FakeArr(object): + def __init__(self, n): + self.n = n + + def __len__(self): + return self.n + + def test_int32_point_count_guard(self): + from cuvarbase import tls + fake = self._FakeArr(2 ** 31) + with pytest.raises(ValueError, match="int32"): + tls._preprocess_batch([(fake, fake, fake)]) + + def test_durations_param_removed(self): + # The never-released ``durations=`` no-op was removed in the + # Sep-2026 API freeze: it is rejected by the signature (keyword- + # only parameters after ``periods``) before any validation or + # GPU work. + from cuvarbase import tls + t = np.linspace(0, 10, 100) + y = np.ones(100) + dy = np.full(100, 1e-3) + with pytest.raises(TypeError, match="durations"): + tls.tls_search_gpu(t, y, dy, periods=np.array([1.0]), + durations=np.array([0.1])) + + +@pytest.mark.skipif(not PYCUDA_AVAILABLE, + reason="PyCUDA not available") +class TestTLSKernel: + """Test TLS kernel compilation and basic execution.""" + + def test_kernel_compilation(self): + """Test that TLS kernel compiles.""" + from cuvarbase import tls + + kernel = tls.compile_tls(block_size=128) + assert kernel is not None + + def test_kernel_caching(self): + """Test kernel caching mechanism.""" + from cuvarbase import tls + + # First call - compiles + kernel1 = tls._get_cached_kernels(128) + assert kernel1 is not None + + # Second call - should use cache + kernel2 = tls._get_cached_kernels(128) + assert kernel2 is kernel1 + + def test_block_size_selection(self): + """Test automatic block size selection.""" + from cuvarbase import tls + + assert tls._choose_block_size(10) == 32 + assert tls._choose_block_size(50) == 64 + assert tls._choose_block_size(100) == 128 + + +@pytest.mark.skipif(not PYCUDA_AVAILABLE, + reason="PyCUDA not available") +class TestTLSMemory: + """Test TLS memory management.""" + + def test_memory_allocation(self): + """Test memory allocation.""" + from cuvarbase.tls import TLSMemory + + mem = TLSMemory(max_ndata=1000, max_nperiods=100) + + assert mem.t is not None + assert len(mem.t) == 1000 + assert len(mem.periods) == 100 + + def test_memory_setdata(self): + """Test setting data.""" + from cuvarbase.tls import TLSMemory + + t = np.linspace(0, 100, 100) + y = np.ones(100) + dy = np.ones(100) * 0.01 + periods = np.linspace(1, 10, 50) + + mem = TLSMemory(max_ndata=1000, max_nperiods=100) + mem.setdata(t, y, dy, periods=periods, transfer=False) + + assert np.allclose(mem.t[:100], t) + assert np.allclose(mem.periods[:50], periods) + + def test_memory_fromdata(self): + """Test creating memory from data.""" + from cuvarbase.tls import TLSMemory + + t = np.linspace(0, 100, 100) + y = np.ones(100) + dy = np.ones(100) * 0.01 + periods = np.linspace(1, 10, 50) + + mem = TLSMemory.fromdata(t, y, dy, periods=periods, transfer=False) + + assert mem.max_ndata >= 100 + assert mem.max_nperiods >= 50 + + def test_memory_setdata_subtracts_epoch(self): + """Defect 11 (tls-T0): the legacy kernel folds relative to + floor(min t), subtracted in float64 BEFORE the float32 cast, so + BJD-scale times keep their phase and 't0_phase' means the same + thing on both paths.""" + from cuvarbase.tls import TLSMemory + + t = 2457000.3 + np.linspace(0, 100, 100) + y = np.ones(100) + dy = np.ones(100) * 0.01 + + mem = TLSMemory(max_ndata=1000, max_nperiods=100) + mem.setdata(t, y, dy, periods=np.linspace(1, 10, 50), + transfer=False) + + assert mem.epoch == 2457000.0 + np.testing.assert_allclose(mem.t[:100], t - 2457000.0, + rtol=0, atol=1e-5) + + +@pytest.mark.skipif(not PYCUDA_AVAILABLE, + reason="PyCUDA not available") +class TestTLSBasicExecution: + """Test basic TLS execution (not accuracy).""" + + def test_tls_search_runs(self): + """Test that TLS search runs without errors.""" + from cuvarbase import tls + + # Create simple synthetic data. Note: y needs (tiny) noise — + # a perfectly flat lightcurve gives depth == 0 for every + # (t0, duration) candidate, so no trial period records a + # solution and tls_search_gpu raises RuntimeError (all + # periods masked as failed). + rand = np.random.RandomState(99) + t = np.linspace(0, 100, 500) + y = np.ones(500) + 0.001 * rand.randn(500) + dy = np.ones(500) * 0.001 + + # Use small period range for speed + periods = np.linspace(5, 15, 20) + + # This should run without errors + results = tls.tls_search_gpu( + t, y, dy, + periods=periods, + block_size=64, + method='binned', + ) + + assert results is not None + assert 'periods' in results + assert 'chi2' in results + assert len(results['periods']) == 20 + + def test_tls_search_with_transit(self): + """Test TLS with injected transit.""" + from cuvarbase import tls + + # Create data with simple transit + t = np.linspace(0, 100, 500) + y = np.ones(500) + + # Inject transit at period = 10 days + period_true = 10.0 + duration = 0.1 + depth = 0.01 + + phases = (t % period_true) / period_true + in_transit = (phases < duration / period_true) | (phases > 1 - duration / period_true) + y[in_transit] -= depth + + dy = np.ones(500) * 0.0001 + + # Search with periods around the true value + periods = np.linspace(8, 12, 30) + + results = tls.tls_search_gpu(t, y, dy, periods=periods, method='binned') + + # Should return results + assert results['chi2'] is not None + assert len(results['chi2']) == 30 + + # Minimum chi2 should be near period = 10 (within a few samples) + min_idx = np.argmin(results['chi2']) + best_period = results['periods'][min_idx] + + # Should be within 20% of true period (very loose for Phase 1) + assert 8 < best_period < 12 + + def test_sde_positive_with_transit(self): + """Test SDE > 0 when a transit is present (regression test).""" + from cuvarbase import tls + + # Create data with obvious transit + t = np.linspace(0, 100, 500) + y = np.ones(500) + + period_true = 10.0 + depth = 0.02 + phases = (t % period_true) / period_true + in_transit = phases < 0.02 + y[in_transit] -= depth + + dy = np.ones(500) * 0.0001 + + periods = np.linspace(8, 12, 50) + results = tls.tls_search_gpu(t, y, dy, periods=periods, method='binned') + + assert results['SDE'] > 0, ( + "SDE should be > 0 for a clear transit signal" + ) + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) + + +class TestSharedMemoryGuard: + """The legacy kernel (method='legacy') must fail loudly (before + touching the GPU) when its shared-memory layout exceeds the 48 KB + per-block budget. The binned engine has no such cap.""" + + def test_large_ndata_raises_value_error(self): + from cuvarbase.tls import tls_search_gpu + rand = np.random.RandomState(3) + ndata = 20000 # TESS-like; needs ~245 KB of shared memory + t = np.sort(27 * rand.rand(ndata)) + y = 1 + 0.001 * rand.randn(ndata) + dy = 0.001 * np.ones(ndata) + with pytest.raises(ValueError, match="shared memory"): + tls_search_gpu(t, y, dy, periods=np.array([1.0, 2.0]), + method='legacy') + + def test_guard_accounts_for_template_size(self): + from cuvarbase.tls import tls_search_gpu + rand = np.random.RandomState(3) + # ndata below the default cap, but a huge template pushes the + # layout over the budget + ndata = 3000 + t = np.sort(27 * rand.rand(ndata)) + y = 1 + 0.001 * rand.randn(ndata) + dy = 0.001 * np.ones(ndata) + with pytest.raises(ValueError, match="shared memory"): + tls_search_gpu(t, y, dy, periods=np.array([1.0, 2.0]), + n_template=4000, method='legacy') + + def test_fast_path_has_no_ndata_cap(self): + # regression for the removed cap: the binned path must + # accept TESS-length lightcurves outright + from cuvarbase.tls import tls_search_gpu + rand = np.random.RandomState(3) + ndata = 20000 + t = np.sort(27 * rand.rand(ndata)) + y = 1 + 0.001 * rand.randn(ndata) + dy = 0.001 * np.ones(ndata) + results = tls_search_gpu(t, y, dy, + periods=np.linspace(2.0, 5.0, 50), method='binned') + assert np.isfinite(results['chi2_min']) + + +class TestFailedPeriodMasking: + """chi2 == 1e30 sentinels (failed periods) must be masked before + computing argmin/SDE/FAP.""" + + def _chi2_with_dip(self, nperiods=200, dip_idx=100): + chi2 = np.full(nperiods, 1000.0) + np.random.RandomState(5).randn(nperiods) + chi2[dip_idx] = 900.0 # clear transit signal + return chi2 + + def test_mask_warns_and_excludes_sentinels(self): + from cuvarbase.tls import _mask_failed_periods, TLS_CHI2_SENTINEL + chi2 = self._chi2_with_dip() + chi2[[3, 50, 150]] = TLS_CHI2_SENTINEL + with pytest.warns(UserWarning, match="3 of 200"): + valid = _mask_failed_periods(chi2) + assert valid.sum() == 197 + assert not valid[3] and not valid[50] and not valid[150] + + def test_all_failed_warns_and_masks_everything(self): + # id 89: a flat/noiseless light curve fails every trial period; + # since 1.0 that is a warning + all-False mask (the wrappers then + # return SDE = 0), not a RuntimeError + from cuvarbase.tls import _mask_failed_periods, TLS_CHI2_SENTINEL + chi2 = np.full(20, TLS_CHI2_SENTINEL) + with pytest.warns(UserWarning, match="no valid solution"): + valid = _mask_failed_periods(chi2) + assert valid.dtype == bool and valid.shape == (20,) + assert not valid.any() + + def test_no_failures_no_warning(self): + import warnings as _warnings + from cuvarbase.tls import _mask_failed_periods + chi2 = self._chi2_with_dip() + with _warnings.catch_warnings(): + _warnings.simplefilter("error", UserWarning) + valid = _mask_failed_periods(chi2) + assert valid.all() + + def test_sde_survives_sentinels_when_masked(self): + # The audit reproduced SDE collapsing 15.3 -> 0.06 when 1e30 + # sentinels entered the statistics; masking must prevent that. + from cuvarbase.tls import _mask_failed_periods, TLS_CHI2_SENTINEL + chi2 = self._chi2_with_dip() + sde_clean, _, _ = tls_stats.signal_detection_efficiency(chi2) + + chi2_corrupt = chi2.copy() + chi2_corrupt[::7] = TLS_CHI2_SENTINEL # 29 failed periods + sde_corrupt, _, _ = tls_stats.signal_detection_efficiency( + chi2_corrupt) + + with pytest.warns(UserWarning): + valid = _mask_failed_periods(chi2_corrupt) + sde_masked, _, _ = tls_stats.signal_detection_efficiency( + chi2_corrupt[valid]) + + assert sde_corrupt < 0.5 * sde_clean # corruption is real + assert sde_masked > 0.8 * sde_clean # masking restores it + + +class TestSnrNotInflated: + """signal_to_noise is the chi2-based delta-chi-squared significance: + the depth_err already includes every in-transit point, so there is + no per-transit inflation (the pre-1.0 ``n_transits`` factor, which + multiplied by ``sqrt(n_transits)``, was removed in the Sep-2026 API + freeze together with the ignored parameter).""" + + def test_chi2_based_value(self): + snr = tls_stats.signal_to_noise( + 0.01, chi2_null=200.0, chi2_best=100.0) + assert snr == pytest.approx(np.sqrt(100.0)) + + def test_n_transits_parameter_is_gone(self): + with pytest.raises(TypeError, match="n_transits"): + tls_stats.signal_to_noise( + 0.01, chi2_null=200.0, chi2_best=100.0, n_transits=9) + + def test_explicit_depth_err(self): + snr = tls_stats.signal_to_noise(0.01, depth_err=0.002) + assert snr == pytest.approx(5.0) + + +class TestTemplateFallbackWarns: + """generate_transit_template must warn (not silently degrade) when + batman fails at call time.""" + + def test_batman_exception_warns(self, monkeypatch): + monkeypatch.setattr(tls_models, 'BATMAN_AVAILABLE', True) + + def _boom(**kwargs): + raise RuntimeError("batman exploded") + + monkeypatch.setattr(tls_models, 'create_reference_transit', + _boom) + with pytest.warns(UserWarning, match="trapezoid"): + template = tls_models.generate_transit_template( + n_template=100) + assert len(template) == 100 + assert template.max() == pytest.approx(1.0) + + +class TestT0GridDurationScaled: + """The epoch (t0) grid must scale with transit duration: the old + fixed 30-point grid missed transits narrower than 1/30 of the + period (audit: 8/8 injected epochs missed at P=100 d).""" + + def test_grid_size_scales_with_duration(self): + assert tls_grids.t0_grid_size(0.2) == 30 # wide: floor + assert tls_grids.t0_grid_size(0.01) == 300 + assert tls_grids.t0_grid_size(0.001) == 3000 + assert tls_grids.t0_grid_size(1e-6) == 20000 # capped + + def test_coverage_guarantee(self): + # Every possible transit epoch must lie within half a transit + # duration of a tested t0 (with margin: stride <= q/3). + rand = np.random.RandomState(11) + for q in (0.05, 0.008, 0.003): + n = tls_grids.t0_grid_size(q) + grid = np.arange(n) / n + epochs = rand.rand(500) + # circular distance to the nearest tested t0 + dist = np.abs((epochs[:, None] - grid[None, :] + 0.5) % 1.0 + - 0.5).min(axis=1) + assert dist.max() <= 0.5 / n + 1e-12 + assert 1.0 / n <= q / 3 + 1e-12 + + def test_kernel_source_uses_duration_scaled_grid(self): + # Both CUDA kernels must derive n_t0 from the duration; the + # GPU-side recovery test runs in the pod batch. + from cuvarbase.utils import find_kernel + src = open(find_kernel('tls')).read() + assert 'int n_t0 = 30;' not in src + assert src.count('t0_grid_size(duration_phase)') == 2 + assert 'T0_OVERSAMPLE' in src + + +class TestNoBitonicSort: + """The bitonic sort was provably incomplete for non-power-of-2 + sizes AND its output order was never consumed (the depth/chi2 + accumulations are order-independent) — pure wasted GPU work with + misleading naming. It must stay removed.""" + + def test_kernel_has_no_sort(self): + from cuvarbase.utils import find_kernel + src = open(find_kernel('tls')).read() + assert 'bitonic_sort_phases' not in src + assert 'y_sorted' not in src + + +@pytest.mark.skipif(not PYCUDA_AVAILABLE, + reason="PyCUDA not available") +class TestTLSStreamParity: + """TLSMemory.transfer_from_gpu enqueues async copies into + page-locked buffers; tls_search_gpu used to synchronize BEFORE + enqueueing them and then read the host arrays immediately, so + stream runs could return stale/zero chi2. Results on a user + stream must match the default-stream results.""" + + def test_stream_matches_default(self): + import pycuda.driver as cuda + from cuvarbase import tls + from cuvarbase.base import ensure_context + + rand = np.random.RandomState(7) + t = np.linspace(0, 100, 400) + y = np.ones(400) + 0.001 * rand.randn(400) + dy = np.ones(400) * 0.001 + periods = np.linspace(5, 15, 10) + + # method='legacy' on both sides: this is a regression test for + # the LEGACY kernel's async D2H sequencing (the fast path does + # not take a user stream and would silently fall back to the + # legacy kernel anyway when one is passed) + r_default = tls.tls_search_gpu(t, y, dy, periods=periods, + block_size=64, method='legacy') + ensure_context() + r_stream = tls.tls_search_gpu(t, y, dy, periods=periods, + block_size=64, + stream=cuda.Stream(), method='legacy') + np.testing.assert_allclose(r_stream['chi2'], r_default['chi2'], + rtol=1e-3) + + +# --------------------------------------------------------------------- +# 1.0 correctness fixes (Sep 2026 audit): CPU-runnable regression tests +# --------------------------------------------------------------------- + +import warnings as _w +import inspect as _inspect + + +class TestDefaultDurationWindow: + """Preserved binned engine duration window (audit id 9): tls_search_gpu / + tls_search without qmin/qmax used a constant q window [0.005, 0.15] + at every period while the default Ofir grid runs to span/2; beyond + P ~ 60 d (Sun-like) no trial duration was physical and a P = 365 d + transit on a 1400-d baseline came back at 182.5 d with half the + depth. The default window is now the Keplerian one that + tls_search_batch/tls_transit always used; the constant window is an + opt-in that warns.""" + + def test_keplerian_window_equals_q_transit_window(self): + periods = np.array([0.5, 1.0, 10.0, 100.0, 365.0, 700.0]) + for R, M, Rp in ((1.0, 1.0, 1.0), (0.3, 0.3, 2.0), (1.5, 1.2, 1.0)): + qmin, qmax = tls_grids.duration_window( + periods, R_star=R, M_star=M, R_planet=Rp) + q = tls_grids.q_transit(periods, R, M, Rp) + np.testing.assert_allclose(qmin, 0.5 * q, rtol=1e-12) + np.testing.assert_allclose(qmax, 2.0 * q, rtol=1e-12) + # physical at every period, inside the kernels' (0, 1) bounds + assert np.all(qmin < q) and np.all(q < qmax) + assert np.all(qmin > 0) and np.all(qmax < 1) + + def test_window_factors_honoured(self): + periods = np.array([3.0, 30.0]) + qmin, qmax = tls_grids.duration_window(periods, qmin_fac=0.25, + qmax_fac=4.0) + q = tls_grids.q_transit(periods) + np.testing.assert_allclose(qmin, 0.25 * q) + np.testing.assert_allclose(qmax, 4.0 * q) + + def test_fixed_window_crossover_near_60d(self): + # the documented crossover: q_kep(Sun, 1 R_earth) drops below the + # old constant qmin = 0.005 between P = 50 and 70 d + assert tls_grids.q_transit(50.0) > tls_grids.FIXED_QMIN + assert tls_grids.q_transit(70.0) < tls_grids.FIXED_QMIN + assert tls_grids.q_transit(365.0) < 0.5 * tls_grids.FIXED_QMIN + + def test_fixed_window_warns_when_unphysical(self): + periods = np.array([1.0, 10.0, 120.0, 365.0]) + with pytest.warns(UserWarning, match="excludes the Keplerian"): + qmin, qmax = tls_grids.duration_window(periods, window='fixed') + assert np.all(qmin == tls_grids.FIXED_QMIN) + assert np.all(qmax == tls_grids.FIXED_QMAX) + + def test_fixed_window_silent_when_physical(self): + periods = np.array([1.0, 3.0, 10.0, 30.0]) + with _w.catch_warnings(): + _w.simplefilter("error") + qmin, qmax = tls_grids.duration_window(periods, window='fixed') + assert np.all(qmin == 0.005) and np.all(qmax == 0.15) + + def test_unknown_window_rejected(self): + with pytest.raises(ValueError, match="window"): + tls_grids.duration_window(np.array([1.0]), window='boxy') + + @staticmethod + def _capture_batch(monkeypatch): + """Intercept the fast path's tls_search_batch call (no GPU).""" + from cuvarbase import tls + captured = {} + + def fake_batch(lightcurves, **kw): + captured.update(kw) + n = len(kw['periods']) + return [tls._null_result(n, 1.0, 'intercepted', + periods=kw['periods'], arrays=True)] + + monkeypatch.setattr(tls, '_tls_search_batch_binned', fake_batch) + return captured + + def test_search_gpu_default_passes_keplerian_window(self, monkeypatch): + from cuvarbase import tls + captured = self._capture_batch(monkeypatch) + t = np.linspace(0, 1400, 2000) + y = np.ones(2000) + dy = np.full(2000, 3e-4) + periods = np.array([10.0, 100.0, 365.0]) + r = tls.tls_search_gpu(t, y, dy, periods=periods, + R_star=0.8, M_star=0.9, method='binned') + q = tls_grids.q_transit(periods, 0.8, 0.9, 1.0) + np.testing.assert_allclose(captured['qmin'], 0.5 * q, rtol=1e-6) + np.testing.assert_allclose(captured['qmax'], 2.0 * q, rtol=1e-6) + assert captured['n_durations'] == 15 + assert 'FAP' not in r + + def test_search_gpu_window_kwargs_reach_the_batch(self, monkeypatch): + from cuvarbase import tls + captured = self._capture_batch(monkeypatch) + t = np.linspace(0, 100, 500) + y = np.ones(500) + dy = np.full(500, 1e-3) + periods = np.array([3.0, 30.0]) + tls.tls_search_gpu(t, y, dy, periods=periods, R_planet=3.0, + qmin_fac=0.3, qmax_fac=3.0, n_durations=7, method='binned') + q = tls_grids.q_transit(periods, 1.0, 1.0, 3.0) + np.testing.assert_allclose(captured['qmin'], 0.3 * q, rtol=1e-6) + np.testing.assert_allclose(captured['qmax'], 3.0 * q, rtol=1e-6) + assert captured['n_durations'] == 7 + + def test_search_gpu_fixed_window_optin_warns(self, monkeypatch): + from cuvarbase import tls + captured = self._capture_batch(monkeypatch) + t = np.linspace(0, 1400, 2000) + y = np.ones(2000) + dy = np.full(2000, 3e-4) + with pytest.warns(UserWarning, match="excludes the Keplerian"): + tls.tls_search_gpu(t, y, dy, periods=np.array([10.0, 365.0]), + duration_window='fixed', method='binned') + assert np.all(captured['qmin'] == 0.005) + assert np.all(captured['qmax'] == 0.15) + + def test_search_gpu_explicit_q_conflicts_with_window(self): + from cuvarbase import tls + t = np.linspace(0, 100, 500) + y = np.ones(500) + dy = np.full(500, 1e-3) + periods = np.array([3.0, 30.0]) + with pytest.raises(ValueError, match="duration_window"): + tls.tls_search_gpu(t, y, dy, periods=periods, + qmin=np.full(2, 0.01), qmax=np.full(2, 0.05), + duration_window='fixed') + with pytest.raises(ValueError, match="both qmin and qmax"): + tls.tls_search_gpu(t, y, dy, periods=periods, + qmin=np.full(2, 0.01)) + with pytest.raises(ValueError, match="aligned with periods"): + tls.tls_search_gpu(t, y, dy, periods=periods, + qmin=np.full(3, 0.01), qmax=np.full(3, 0.05)) + with pytest.raises(ValueError, match="0 < qmin <= qmax < 1"): + tls.tls_search_gpu(t, y, dy, periods=periods, + qmin=np.full(2, 0.05), qmax=np.full(2, 0.01)) + + def test_tls_cu_standard_kernel_is_marked_retired(self): + # the legacy path must not launch the kernel with the hard-coded + # [0.005, 0.15] window + from cuvarbase.utils import find_kernel + src = open(find_kernel('tls')).read() + assert 'RETAINED FOR API COMPATIBILITY ONLY' in src + from cuvarbase import tls + body = _inspect.getsource(tls._tls_search_gpu_binned) + assert "kernels['standard']" not in body + assert "kernels['keplerian']" in body + + +class TestTransitDurationWindowBounds: + """Phase 2 TLS-1 (audit section 5, id 52): tls_transit built the + whole (nperiods x n_durations) Keplerian duration table with + duration_grid_keplerian and then threw it away -- only the q_values + it also returns were used. It now calls tls_grids.duration_window, + the shared window helper the other entry points use, which returns + exactly the same bounds. Bit-neutral: these tests pin the bounds + handed to tls_search_gpu to the legacy expression, bitwise.""" + + @staticmethod + def _capture_search(monkeypatch): + """Intercept tls_transit's tls_search_gpu call (no GPU).""" + from cuvarbase import tls + captured = {} + + def fake_search(t, y, dy, **kw): + captured.update(kw) + n = len(kw['periods']) + return tls._null_result(n, 1.0, 'intercepted', + periods=kw['periods'], arrays=True) + + monkeypatch.setattr(tls, '_tls_search_gpu_binned', fake_search) + return captured + + PARAMS = [dict(), dict(R_star=0.7, M_star=0.65, R_planet=2.3, + qmin_fac=0.4, qmax_fac=2.5, n_durations=9), + dict(R_star=2.2, M_star=1.9, R_planet=11.0, + qmin_fac=0.25, qmax_fac=3.0), + dict(R_star=0.3, M_star=0.3)] + + def test_bounds_bitwise_match_duration_grid_keplerian(self, monkeypatch): + from cuvarbase import tls + t = np.linspace(0, 90.0, 1200) + y = np.ones(1200) + dy = np.full(1200, 1e-3) + for kw in self.PARAMS: + captured = self._capture_search(monkeypatch) + tls._tls_transit_binned(t, y, dy, period_min=0.5, period_max=30.0, **kw) + periods = captured['periods'] + # the pre-1.0 expression, verbatim + _, _, q_values = tls_grids.duration_grid_keplerian( + periods, R_star=kw.get('R_star', 1.0), + M_star=kw.get('M_star', 1.0), + R_planet=kw.get('R_planet', 1.0), + qmin_fac=kw.get('qmin_fac', 0.5), + qmax_fac=kw.get('qmax_fac', 2.0), + n_durations=kw.get('n_durations', 15)) + assert len(periods) > 100 + assert np.array_equal(captured['qmin'], + q_values * kw.get('qmin_fac', 0.5)) + assert np.array_equal(captured['qmax'], + q_values * kw.get('qmax_fac', 2.0)) + assert captured['n_durations'] == kw.get('n_durations', 15) + + def test_duration_table_is_not_built(self, monkeypatch): + """The (nperiods x n_durations) table nothing reads: 59 ms of a + 237 ms Kepler-4yr call (A40, shared).""" + from cuvarbase import tls + self._capture_search(monkeypatch) + calls = [] + real = tls_grids.duration_grid_keplerian + + def counting(*a, **kw): + calls.append(1) + return real(*a, **kw) + + monkeypatch.setattr(tls_grids, 'duration_grid_keplerian', counting) + t = np.linspace(0, 90.0, 1200) + tls._tls_transit_binned(t, np.ones(1200), np.full(1200, 1e-3), + period_min=0.5, period_max=30.0) + assert calls == [] + + def test_bounds_match_the_other_entry_points(self, monkeypatch): + """tls_transit and tls_search_gpu must agree on the window.""" + from cuvarbase import tls + captured = self._capture_search(monkeypatch) + t = np.linspace(0, 90.0, 1200) + tls._tls_transit_binned(t, np.ones(1200), np.full(1200, 1e-3), + R_star=0.8, M_star=0.9, period_min=0.5, + period_max=30.0) + qmin, qmax = tls_grids.duration_window( + captured['periods'], R_star=0.8, M_star=0.9) + assert np.array_equal(captured['qmin'], qmin) + assert np.array_equal(captured['qmax'], qmax) + + +class TestTemplateTableMemoization: + """Phase 2 TLS-3 (audit section 5, ids 95/152): every + single-lightcurve search rebuilt the batman reference model behind + the fast kernel's template tables. generate_template_tables now + memoizes on (n_table, limb_dark, u, oversample); it still returns + fresh, writable arrays, and a degraded (trapezoid-fallback) result + is never cached so its warning keeps firing.""" + + def setup_method(self): + tls_models._clear_template_table_cache() + + teardown_method = setup_method + + def test_repeat_call_is_bitwise_identical(self): + first = tls_models.generate_template_tables(n_table=128) + second = tls_models.generate_template_tables(n_table=128) + for a, b in zip(first, second): + assert np.array_equal(a, b) + assert a.dtype == np.float32 + + def test_returns_independent_arrays(self): + """A caller that writes to the tables must not poison the cache.""" + first = tls_models.generate_template_tables(n_table=128) + for a in first: + a[:] = -12345.0 + second = tls_models.generate_template_tables(n_table=128) + assert all(a is not b for a, b in zip(first, second)) + assert not np.any(second[0] == -12345.0) + third = tls_models.generate_template_tables(n_table=128) + for b, c in zip(second, third): + assert np.array_equal(b, c) + + def test_underlying_model_is_built_once_per_key(self): + calls = [] + real = tls_models.generate_transit_template + + def counting(**kw): + calls.append(kw.get('n_template')) + return real(**kw) + + try: + tls_models.generate_transit_template = counting + tls_models.generate_template_tables(n_table=128) + tls_models.generate_template_tables(n_table=128) + tls_models.generate_template_tables(n_table=128) + assert len(calls) == 1 + finally: + tls_models.generate_transit_template = real + + def test_cache_does_not_leak_across_parameters(self): + base = tls_models.generate_template_tables(n_table=128) + variants = [ + dict(n_table=128, limb_dark='linear', u=[0.5]), + dict(n_table=128, u=[0.1, 0.05]), + dict(n_table=128, oversample=4), + dict(n_table=256), + ] + for kw in variants: + got = tls_models.generate_template_tables(**kw) + assert len(got[0]) == kw.get('n_table', 128) + 1 + if len(got[0]) == len(base[0]): + if tls_models.BATMAN_AVAILABLE or 'oversample' in kw: + assert not np.array_equal(got[0], base[0]) or \ + not np.array_equal(got[1], base[1]) + # the original key still returns the original tables + again = tls_models.generate_template_tables(n_table=128) + for a, b in zip(base, again): + assert np.array_equal(a, b) + + def test_cache_is_bounded(self): + for i in range(2 * tls_models._TEMPLATE_TABLE_CACHE_MAX + 3): + tls_models.generate_template_tables(n_table=32 + i) + assert (len(tls_models._template_table_cache) + <= tls_models._TEMPLATE_TABLE_CACHE_MAX) + + def test_fallback_result_is_not_cached(self, monkeypatch): + """The trapezoid fallback warns on every call, so it must not be + memoized away.""" + monkeypatch.setattr(tls_models, 'BATMAN_AVAILABLE', True) + + def _boom(**kwargs): + raise RuntimeError("batman exploded") + + monkeypatch.setattr(tls_models, 'create_reference_transit', _boom) + for _ in range(2): + with pytest.warns(UserWarning, match="trapezoid"): + tls_models.generate_template_tables(n_table=128) + assert tls_models._template_table_cache == {} + + def test_missing_batman_tables_are_cached_under_their_own_key( + self, monkeypatch): + """Release review (findings 0/12): with batman not installed + the trapezoid IS the template -- deterministic, warned about + once at import rather than per call -- so its tables are + memoized like any other, under a key that records batman's + absence so a batman-backed table can never collide with it.""" + monkeypatch.setattr(tls_models, 'BATMAN_AVAILABLE', False) + with _w.catch_warnings(): + _w.simplefilter("error") + first = tls_models.generate_template_tables(n_table=128) + second = tls_models.generate_template_tables(n_table=128) + for a, b in zip(first, second): + assert np.array_equal(a, b) + keys = list(tls_models._template_table_cache) + assert len(keys) == 1 and keys[0][-1] is False + assert keys[0] == tls_models._template_table_key( + 128, 'quadratic', [0.4804, 0.1867], 8) + # the cached tables are the trapezoid's + expect = tls_models._trapezoid_template(128 * 8 + 1)[::8] + assert np.array_equal(first[0], expect.astype(np.float32)) + # a batman-backed table lives under a different key + monkeypatch.setattr(tls_models, 'BATMAN_AVAILABLE', True) + assert tls_models._template_table_key( + 128, 'quadratic', [0.4804, 0.1867], 8) not in \ + tls_models._template_table_cache + + +class TestBatchHasNoThreadPool: + """Phase 2 TLS-2 (audit section 5, id 53): tls_search_batch must not + reintroduce the per-light-curve ThreadPoolExecutor -- the work is + GIL-bound numpy/scipy and the pool made it 1.2-2.2x slower (A40, + shared) while randomizing the order of per-light-curve warnings.""" + + def test_module_does_not_import_a_thread_pool(self): + from cuvarbase import tls + assert not hasattr(tls, 'ThreadPoolExecutor') + body = _inspect.getsource(tls._tls_search_batch_binned) + assert 'ThreadPoolExecutor(' not in body + assert 'cpu_count' not in body + + +class TestReferenceSRDefinition: + """ids 81/146: SR was 1 - chi2/max(chi2); the reference package uses + chi2_min/chi2. Identical under the null but ~2x lower SDE for strong + signals, so published thresholds did not transfer. 1.0 adopts the + reference definition on every path.""" + + @staticmethod + def _ref_running_median(data, kernel): + # literal transcription of transitleastsquares.stats.running_median + idx = np.arange(kernel) + np.arange(len(data) - kernel + 1)[:, None] + med = np.median(data[idx], axis=1) + missing = len(data) - len(med) + front = int(missing * 0.5) + end = missing - front + med = np.append(np.full(front, med[0]), med) + med = np.append(med, np.full(end, med[-1])) + return med + + @classmethod + def _ref_spectra(cls, chi2, kernel): + # literal transcription of transitleastsquares.stats.spectra + SR = np.min(chi2) / chi2 + SDE_raw = (1 - np.mean(SR)) / np.std(SR) + power_raw = SR - np.mean(SR) + scale = SDE_raw / np.max(power_raw) + power_raw = power_raw * scale + if kernel % 2 == 0: + kernel = kernel + 1 + if len(power_raw) > 2 * kernel: + my_median = cls._ref_running_median(power_raw, kernel) + power = power_raw - my_median + power = power - np.mean(power) + SDE = np.max(power / np.std(power)) + else: + SDE = SDE_raw + return SDE_raw, SDE + + @staticmethod + def _spectrum(n, dip_frac, seed=0): + rng = np.random.RandomState(seed) + chi2 = 1000.0 - 30.0 * np.sin(np.linspace(0, 3, n)) \ + + rng.normal(0, 1.0, n) + chi2[int(0.7 * n)] -= dip_frac * 1000.0 + return chi2 + + def test_signal_residue_is_chi2min_over_chi2(self): + chi2 = self._spectrum(500, 0.1) + SR = tls_stats.signal_residue(chi2) + np.testing.assert_allclose(SR, chi2.min() / chi2, rtol=1e-14) + assert SR.max() == 1.0 + assert SR[int(0.7 * 500)] == 1.0 + + @pytest.mark.parametrize("n,kernel", [(2000, 91), (500, 51), (5000, 91)]) + def test_sde_matches_reference_spectra(self, n, kernel): + chi2 = self._spectrum(n, 0.1) + SDE, SDE_raw, power = tls_stats.signal_detection_efficiency( + chi2, kernel_size=kernel) + ref_raw, ref = self._ref_spectra(chi2, kernel) + assert SDE_raw == pytest.approx(ref_raw, rel=1e-10) + assert SDE == pytest.approx(ref, rel=1e-10) + + def test_auto_kernel_matches_reference_at_91(self): + # grids with >= 910 periods use the reference's 91-point kernel + chi2 = self._spectrum(3000, 0.05) + SDE = tls_stats.signal_detection_efficiency(chi2)[0] + assert SDE == pytest.approx(self._ref_spectra(chi2, 91)[1], + rel=1e-10) + + def test_strong_signal_no_longer_halved(self): + # A dip that removes 80% of chi2 on a noisy background (audit: + # score/chi2_0 = 0.79 gave SDE 21.8 old vs 45.4 reference). The + # old SR = 1 - chi2/max(chi2) keeps the background noise at + # sigma_chi2/chi2_bg while chi2_min/chi2 shrinks it by + # chi2_min/chi2_bg, so the peak's z-score roughly doubles. + rng = np.random.RandomState(0) + n = 20000 + chi2 = 1000.0 + rng.normal(0, 10.0, n) + chi2[int(0.7 * n)] = 200.0 + SDE_new = tls_stats.signal_detection_efficiency(chi2)[0] + SR_old = 1.0 - chi2 / chi2.max() + SDE_old = (SR_old.max() - SR_old.mean()) / SR_old.std() + assert SDE_new > 1.7 * SDE_old + # ...while a weak signal is essentially unchanged (both linear in + # delta-chi2 when the dip is small relative to chi2) + chi2w = 1000.0 + rng.normal(0, 10.0, n) + chi2w[int(0.7 * n)] = 980.0 + SDE_new_w = tls_stats.signal_detection_efficiency( + chi2w, detrend=False)[0] + SR_old_w = 1.0 - chi2w / chi2w.max() + SDE_old_w = (SR_old_w.max() - SR_old_w.mean()) / SR_old_w.std() + assert SDE_new_w == pytest.approx(SDE_old_w, rel=0.05) + + def test_chi2_null_argument_deprecated(self): + chi2 = self._spectrum(300, 0.1) + with pytest.warns(DeprecationWarning, match="chi2_null"): + SR = tls_stats.signal_residue(chi2, chi2_null=5000.0) + np.testing.assert_allclose(SR, chi2.min() / chi2) + + def test_perfect_fit_and_flat_spectra(self): + chi2 = np.array([10.0, 0.0, 5.0]) # noiseless perfect fit + SR = tls_stats.signal_residue(chi2) + assert np.all(np.isfinite(SR)) and SR[1] == 1.0 and SR[0] == 0.0 + sde, sde_raw, power = tls_stats.signal_detection_efficiency( + np.full(50, 100.0)) + assert sde == 0.0 and sde_raw == 0.0 + + def test_compute_all_statistics_uses_reference_sr(self): + chi2 = self._spectrum(400, 0.1) + stats = tls_stats.compute_all_statistics( + chi2, np.arange(400.0) + 1, int(0.7 * 400), 0.01, 0.1, 5) + np.testing.assert_allclose(stats['SR'], chi2.min() / chi2) + assert stats['SDE'] == pytest.approx( + tls_stats.signal_detection_efficiency(chi2)[0]) + + +class TestRunningMedianEdges: + """id 83: scipy.signal.medfilt zero-pads and drags the SR trend to + zero over the outer kernel//2 points (edge power inflated; null + peaks within 45 points of an edge 2.3x more often than uniform). + The trend must match the reference's edge-extended running median.""" + + def test_matches_reference_running_median_exactly(self): + rng = np.random.RandomState(4) + for n, kernel in ((300, 3), (300, 21), (300, 91), (300, 299), + (1000, 91), (7, 5)): + x = rng.randn(n).cumsum() + got = tls_stats.running_median(x, kernel) + ref = TestReferenceSRDefinition._ref_running_median(x, kernel) + np.testing.assert_array_equal(got, ref) + + def test_no_zero_padding_bias(self): + # a ramp: the reference trend at the ends is the first/last + # full-window median (x[45], x[-46]); zero-padded medfilt drags + # the first value down to x[0] + x = 100.0 + np.arange(200.0) + trend = tls_stats.running_median(x, 91) + assert trend[0] == x[45] and trend[44] == x[45] + assert trend[-1] == x[-46] and trend[-45] == x[-46] + np.testing.assert_array_equal(trend[45:-45], x[45:-45]) + from scipy import signal + assert signal.medfilt(x, 91)[0] == x[0] < trend[0] + # a constant series has a constant trend + np.testing.assert_array_equal( + tls_stats.running_median(np.full(200, 5.0), 91), 5.0) + + def test_detrended_power_flat_at_edges(self): + # an SR spectrum that is pure trend + noise must not get raised + # power at the ends + rng = np.random.RandomState(1) + chi2 = 1000.0 + 50.0 * np.linspace(0, 1, 2000) + rng.normal(0, 1, 2000) + _, _, power = tls_stats.signal_detection_efficiency(chi2) + edge = np.r_[power[:45], power[-45:]] + interior = power[45:-45] + assert abs(edge.mean() - interior.mean()) < 3 * interior.std() / np.sqrt(90) + + def test_even_kernel_rounds_up_and_too_long_raises(self): + x = np.arange(20.0) + np.testing.assert_array_equal(tls_stats.running_median(x, 4), + tls_stats.running_median(x, 5)) + with pytest.raises(ValueError, match="kernel"): + tls_stats.running_median(x, 21) + np.testing.assert_array_equal(tls_stats.running_median(x, 1), x) + + +class TestFAPRemoved: + """Defect 10 (tls-fap, audit id 10): the returned 'FAP' was a fixed + piecewise function of the SDE (discontinuous at SDE = 7) unrelated + to the null; 23% of pure-noise light curves got FAP < 0.01. No + result dict carries a FAP unless a null bootstrap was requested.""" + + def test_compute_all_statistics_has_no_fap_key(self): + rng = np.random.RandomState(0) + chi2 = 1000 + rng.randn(300) + chi2[100] = 900 + stats = tls_stats.compute_all_statistics( + chi2, np.arange(300.0) + 1, 100, 0.01, 0.1, 5) + assert 'FAP' not in stats + for k in ('SDE', 'SDE_raw', 'SNR', 'power', 'SR'): + assert k in stats + + def test_null_result_has_no_fap(self): + from cuvarbase import tls + r = tls._null_result(10, 123.0, 'msg', periods=np.arange(10.0), + arrays=True) + assert 'FAP' not in r + assert r['SDE'] == 0.0 and r['SDE_raw'] == 0.0 and r['SNR'] == 0.0 + assert np.isnan(r['period']) and np.isnan(r['T0']) + assert r['chi2_min'] == 123.0 and r['error'] == 'msg' + assert r['n_failed_periods'] == 10 + assert not r['valid_periods'].any() + assert np.all(np.isnan(r['chi2'])) and np.all(np.isnan(r['power'])) + + def test_heuristic_helper_still_works_but_warns(self): + with pytest.warns(UserWarning, match="uncalibrated"): + assert tls_stats.false_alarm_probability(9.0) == pytest.approx(1e-4) + with _w.catch_warnings(): + _w.simplefilter("ignore") + # the documented discontinuity at SDE = 7 + assert tls_stats.false_alarm_probability(6.999) == pytest.approx(0.1, rel=2e-3) + assert tls_stats.false_alarm_probability(7.0) == pytest.approx(0.01) + assert tls_stats.false_alarm_probability(4.0) == 1.0 + with _w.catch_warnings(): + _w.simplefilter("error") + g = tls_stats.false_alarm_probability(3.0, method='gaussian') + assert 0 < g < 0.01 + + def test_docs_do_not_claim_a_calibration(self): + import os + import cuvarbase + doc = tls_stats.signal_detection_efficiency.__doc__ + assert '1% false alarm' not in doc + assert 'SDE > 7 for' not in doc + rst = os.path.join(os.path.dirname(cuvarbase.__file__), '..', + 'docs', 'source', 'tls.rst') + if os.path.exists(rst): + txt = open(rst).read() + assert 'preserves the false-alarm calibration' not in txt + assert 'fap_null_draws' in txt + # the wrong inline comment ("~10% at SDE=5, ~1% at SDE=7") is gone + src = _inspect.getsource(tls_stats.false_alarm_probability) + assert '~10% at SDE=5' not in src + + def test_fast_path_result_has_no_fap(self, monkeypatch): + from cuvarbase import tls + + def fake_batch(lightcurves, **kw): + n = len(kw['periods']) + r = tls._null_result(n, 1.0, 'x', periods=kw['periods'], + arrays=True) + r['FAP'] = 0.5 # even if a batch result carried one... + return [r] + + monkeypatch.setattr(tls, '_tls_search_batch_binned', fake_batch) + t = np.linspace(0, 100, 500) + r = tls.tls_search_gpu(t, np.ones(500), np.full(500, 1e-3), + periods=np.array([3.0, 4.0]), method='binned') + assert 'FAP' not in r # ...tls_search_gpu never forwards it + assert 't0_phase' in r and 'T0' in r + + +class TestSortedPeriodGrid: + """id 82: descending/shuffled user grids gave negative + period_uncertainty and a changed SDE (running median and neighbour + walk assume period order). Grids are sorted on entry and per-period + outputs scattered back to the caller's order.""" + + def test_sort_helper(self): + from cuvarbase import tls + asc = np.array([1.0, 2.0, 3.0]) + p, order = tls._sort_period_grid(asc) + assert order is None and p is asc + desc = asc[::-1].copy() + p, order = tls._sort_period_grid(desc) + np.testing.assert_array_equal(p, asc) + np.testing.assert_array_equal(desc[order], p) + vals = np.array([10.0, 20.0, 30.0]) # aligned with ascending p + back = tls._to_caller_order(vals, order) + # caller order is descending: caller[i] = value of desc[i] + np.testing.assert_array_equal(back, [30.0, 20.0, 10.0]) + assert tls._to_caller_order(vals, None) is vals + # bool arrays round-trip too + flags = np.array([True, False, True]) + np.testing.assert_array_equal(tls._to_caller_order(flags, order), + flags[::-1]) + + def test_shuffled_round_trip(self): + from cuvarbase import tls + rng = np.random.RandomState(2) + grid = rng.uniform(1, 10, 50) + p, order = tls._sort_period_grid(grid) + assert np.all(np.diff(p) >= 0) + vals = p * 2 + np.testing.assert_array_equal(tls._to_caller_order(vals, order), + grid * 2) + + def test_validate_periods(self): + from cuvarbase import tls + for bad in (np.array([]), np.array([1.0, np.nan]), + np.array([0.0, 1.0]), np.array([[1.0, 2.0]]), + np.array([-1.0, 2.0])): + with pytest.raises(ValueError): + tls._validate_periods(bad) + np.testing.assert_array_equal(tls._validate_periods([3.0, 1.0]), + [3.0, 1.0]) + + def test_caller_staged_memory_requires_ascending_grid(self): + # legacy path with transfer_to_device=False: the caller staged + # the periods on the device, so a grid we would have to reorder + # is refused before any GPU work + from cuvarbase import tls + t = np.linspace(0, 100, 500) + with pytest.raises(ValueError, match="ascending"): + tls.tls_search_gpu(t, np.ones(500), np.full(500, 1e-3), + periods=np.array([5.0, 3.0, 4.0]), + method='legacy', memory=object(), + transfer_to_device=False) + + def test_period_uncertainty_positive_on_sorted_input(self): + rng = np.random.RandomState(0) + periods = np.linspace(2, 4, 200) + chi2 = 1000 + rng.randn(200) + chi2[95:106] -= 50 * np.exp(-0.5 * ((np.arange(95, 106) - 100) / 2.0) ** 2) + best = int(np.argmin(chi2)) + unc = tls_stats.compute_period_uncertainty(periods, chi2, best) + assert unc > 0 + + +class TestT0Convention: + """Defect 11 (tls-T0, audit ids 11/145): 'T0' was a fold phase on the + fast path (relative to floor(min t)), a phase relative to t = 0 on + the legacy path, and an absolute time that could precede the first + observation on the batch path. 1.0: 'T0' is the absolute time of the + first mid-transit at or after min(t) everywhere, plus 't0_phase'.""" + + def test_first_transit_at_or_after(self): + from cuvarbase.tls import _first_transit_at_or_after as f + P = 3.0 + # audit case: t_start = 100.9, epoch 100, phase 0.1 -> 100.3 + # precedes min(t); shift up one period + assert f(100.0 + 0.1009 * P, P, 100.9) == pytest.approx(100.3027 + P) + # already inside [tmin, tmin + P): unchanged + assert f(101.41, P, 100.3) == pytest.approx(101.41) + # many periods early or late: wrapped into range + assert f(101.41 - 5 * P, P, 100.3) == pytest.approx(101.41) + assert f(101.41 + 7 * P, P, 100.3) == pytest.approx(101.41) + # boundary: exactly tmin stays tmin + assert f(100.3, P, 100.3) == 100.3 + # NaN propagates (null results) + assert np.isnan(f(np.nan, P, 100.3)) + + def test_docstrings_state_the_convention(self): + from cuvarbase import tls + for fn in (tls.tls_search_gpu, tls.tls_search_batch, tls.tls_transit): + assert 'at or after' in fn.__doc__, fn.__name__ + assert 't0_phase' in fn.__doc__, fn.__name__ + + +class TestTlsSearchDispatch: + """``tls.tls_search`` is a thin, validated forward to + ``tls_search_gpu`` (release finding 71/149: the documented "main + user-facing function" had no test). CPU: the GPU function is + replaced by a recorder.""" + + def test_forwards_all_kwargs_to_tls_search_gpu(self, monkeypatch): + from cuvarbase import tls + seen = {} + sentinel = object() + + def fake_search_gpu(t, y, dy, **kwargs): + seen['args'] = (t, y, dy) + seen['kwargs'] = kwargs + return sentinel + + monkeypatch.setattr(tls, 'tls_search_gpu', fake_search_gpu) + t = np.linspace(0, 30.0, 400) + y = np.ones(400) + dy = np.full(400, 1e-3) + periods = np.linspace(2.0, 5.0, 50) + out = tls.tls_search(t, y, dy, periods=periods, n_durations=7, + use_fast=False, refine_top_k=3, R_star=0.8) + assert out is sentinel + assert seen['args'][0] is t and seen['args'][1] is y + assert seen['args'][2] is dy + assert seen['kwargs'] == dict(periods=periods, n_durations=7, + use_fast=False, refine_top_k=3, + R_star=0.8) + + def test_validates_before_forwarding(self, monkeypatch): + from cuvarbase import tls + calls = [] + monkeypatch.setattr(tls, 'tls_search_gpu', + lambda *a, **k: calls.append(1)) + t = np.linspace(0, 30.0, 400) + y = np.ones(400) + dy = np.full(400, 1e-3) + with pytest.raises(ValueError, match='tls_search'): + tls.tls_search(t[:-1], y, dy) + with pytest.raises(ValueError, match='tls_search'): + tls.tls_search(t, np.r_[y[:-1], np.nan], dy) + assert calls == [] + + +class TestDurationGridKeplerian: + """CPU tests for ``tls_grids.duration_grid_keplerian`` (release + finding 71/149: untested). Shapes, bounds consistent with + ``q_transit`` and the qmin_fac/qmax_fac factors, log spacing, and + monotonicity in period.""" + + PERIODS = np.array([1.0, 2.5, 5.0, 10.0, 30.0, 100.0]) + + def test_shapes_and_counts(self): + durations, counts, q = tls_grids.duration_grid_keplerian( + self.PERIODS, n_durations=11) + assert len(durations) == len(self.PERIODS) + assert all(d.shape == (11,) for d in durations) + assert all(d.dtype == np.float32 for d in durations) + assert counts.shape == (len(self.PERIODS),) + assert counts.dtype == np.int32 and np.all(counts == 11) + assert q.shape == (len(self.PERIODS),) + + @pytest.mark.parametrize("kw", [ + dict(), dict(R_star=0.7, M_star=0.65, R_planet=2.3, + qmin_fac=0.4, qmax_fac=2.5, n_durations=9), + dict(R_star=2.2, M_star=1.9, R_planet=11.0, + qmin_fac=0.25, qmax_fac=3.0)]) + def test_bounds_follow_q_transit_and_the_factors(self, kw): + stellar = {k: kw[k] for k in ('R_star', 'M_star', 'R_planet') + if k in kw} + qmin_fac = kw.get('qmin_fac', 0.5) + qmax_fac = kw.get('qmax_fac', 2.0) + durations, _, q = tls_grids.duration_grid_keplerian( + self.PERIODS, **kw) + np.testing.assert_array_equal( + q, tls_grids.q_transit(self.PERIODS, **stellar)) + dur = np.stack(durations) + # first/last duration = (qmin_fac, qmax_fac) * q * P (absolute + # days), to float32 rounding + np.testing.assert_allclose(dur[:, 0], qmin_fac * q * self.PERIODS, + rtol=1e-6) + np.testing.assert_allclose(dur[:, -1], qmax_fac * q * self.PERIODS, + rtol=1e-6) + assert np.all(dur[:, 0] <= dur[:, -1]) + # every duration is inside the window, as a fraction of period + frac = dur / self.PERIODS[:, None] + assert np.all(frac >= qmin_fac * q[:, None] * (1 - 1e-6)) + assert np.all(frac <= qmax_fac * q[:, None] * (1 + 1e-6)) + + def test_log_spaced_within_a_period_and_monotonic_in_period(self): + durations, _, q = tls_grids.duration_grid_keplerian( + self.PERIODS, n_durations=15) + dur = np.stack(durations).astype(np.float64) + # strictly increasing along the duration axis, constant ratio + assert np.all(np.diff(dur, axis=1) > 0) + ratios = dur[:, 1:] / dur[:, :-1] + np.testing.assert_allclose( + ratios, np.broadcast_to(ratios[:, :1], ratios.shape), rtol=1e-5) + # a Keplerian duration grows with period (~ P^(1/3)) while the + # fractional duration q shrinks (~ P^(-2/3)) + assert np.all(np.diff(dur, axis=0) > 0) + assert np.all(np.diff(q) < 0) + + def test_single_period_and_scalar_input(self): + durations, counts, q = tls_grids.duration_grid_keplerian( + [7.5], n_durations=3) + assert len(durations) == 1 and counts.tolist() == [3] + assert q.shape == (1,) + assert q[0] == pytest.approx(float(tls_grids.q_transit(7.5))) + + +class TestTlsInputGuards: + """Sep-2026 release review (findings 2, 11, 14): ``dy=None`` passed + the shared validator and the fast path built NaN weights and + returned the flat-light-curve null result; the legacy path forwarded + ``n_durations`` to the kernel unchecked (``n_durations <= 1`` is a + 0/0 duration step, again a null result for a good light curve); and + ``tls_search``/``tls_search_gpu``/``tls_transit`` accepted and + dropped any unknown keyword, so ``fap_null_draws=`` silently + produced a result with no 'FAP' key. All three are host-side + errors raised before any GPU work (CPU tests).""" + + def _lc(self, n=400): + rand = np.random.RandomState(11) + t = np.sort(30.0 * rand.rand(n)) + y = 1.0 + 1e-3 * rand.randn(n) + dy = np.full(n, 1e-3) + return t, y, dy + + def test_dy_none_rejected_on_every_entry_point(self): + from cuvarbase import tls + t, y, _ = self._lc() + periods = np.array([2.0, 3.0]) + with pytest.raises(ValueError, match="tls_search_gpu: dy is required"): + tls.tls_search_gpu(t, y, None, periods=periods) + with pytest.raises(ValueError, match="tls_search_gpu: dy is required"): + tls.tls_search_gpu(t, y, None, periods=periods, use_fast=False) + with pytest.raises(ValueError, match="tls_search: dy is required"): + tls.tls_search(t, y, None, periods=periods) + with pytest.raises(ValueError, match="tls_transit: dy is required"): + tls.tls_transit(t, y, None) + with pytest.raises(ValueError, + match="tls_search_batch lightcurve 1: dy is required"): + tls.tls_search_batch([(t, y, np.full(len(t), 1e-3)), (t, y, None)], + periods=periods) + with pytest.raises(ValueError, match="lightcurve 0: dy is required"): + tls._preprocess_batch([(t, y, None)]) + + @pytest.mark.parametrize("use_fast", [True, False]) + @pytest.mark.parametrize("n_durations", [1, 0, -3]) + def test_n_durations_below_two_rejected_on_both_paths(self, use_fast, + n_durations): + from cuvarbase import tls + t, y, dy = self._lc() + with pytest.raises(ValueError, match="n_durations must be >= 2"): + tls.tls_search_gpu(t, y, dy, periods=np.array([2.0, 3.0]), + n_durations=n_durations, use_fast=use_fast) + + def test_n_durations_must_be_an_integer(self): + from cuvarbase import tls + t, y, dy = self._lc() + with pytest.raises(ValueError, match="n_durations must be an integer"): + tls.tls_search_gpu(t, y, dy, periods=np.array([2.0, 3.0]), + n_durations=2.5, use_fast=False) + with pytest.raises(ValueError, match="n_durations"): + tls.tls_search_batch([(t, y, dy)], periods=np.array([2.0, 3.0]), + n_durations=1) + # numpy integers are integers + assert tls._validate_n_durations(np.int64(7)) == 7 + + def test_unknown_keywords_are_rejected_with_a_fap_hint(self): + from cuvarbase import tls + t, y, dy = self._lc() + periods = np.array([2.0, 3.0]) + with pytest.raises(TypeError, match="fap_null_draws.*tls_search_batch"): + tls.tls_search(t, y, dy, periods=periods, fap_null_draws=100) + with pytest.raises(TypeError, match="fap_seed.*tls_search_batch"): + tls.tls_search_gpu(t, y, dy, periods=periods, fap_seed=1) + with pytest.raises(TypeError, match="tls_search_batch"): + tls.tls_transit(t, y, dy, fap_null_draws=10, fap_seed=1) + with pytest.raises(TypeError, match="'bogus_kwarg'") as excinfo: + tls.tls_search_gpu(t, y, dy, periods=periods, bogus_kwarg=42) + assert 'tls_search_batch' not in str(excinfo.value) + # (n_template, the one legitimate extra keyword, is exercised by + # the legacy-path GPU tests) diff --git a/cuvarbase/tests/test_tls_execution_isolation.py b/cuvarbase/tests/test_tls_execution_isolation.py new file mode 100644 index 00000000..f0266142 --- /dev/null +++ b/cuvarbase/tests/test_tls_execution_isolation.py @@ -0,0 +1,139 @@ +"""Host tests for release routing, immutable defaults and owned engine state. + +Fake CUDA objects exercise real module loading/cache orchestration here; device +arithmetic and actual CUDA cache lifetimes require the separate GPU suite. +""" +from concurrent.futures import ThreadPoolExecutor +import hashlib +import importlib.util +from pathlib import Path +import sys +import types + +import pytest + +import cuvarbase +from cuvarbase import tls_reference_math as baseline_math +from cuvarbase import tls_reference_experimental_math as experimental_math + +PACKAGE = Path(cuvarbase.__file__).resolve().parent +BASELINE_PINS = { + 'tls_reference.py': 'a38d5a9b83b02825374ba6603d3c7b4106a10c939f98c9fb8004c09ce41100e2', + 'tls_reference_math.py': '27e5a575e6279e64ce02fd17c40d64e9885f9c285d3145e13e2bee5ac94afe13', + 'kernels/tls_reference.cu': 'b060dbd2063b03d3295f8469ecb4293a1118131d41a1c55fa0399d0a24fc7d97', +} + + +@pytest.mark.parametrize('relative,pin', BASELINE_PINS.items()) +def test_default_execution_retains_6ced75d_source_bytes(relative, pin): + # A deliberate default change requires its own review and evidence; the + # historical optimized study did not qualify these changes for default use. + assert hashlib.sha256((PACKAGE / relative).read_bytes()).hexdigest() == pin + + +def test_experimental_kernel_retains_measured_precursor_source(): + pin = '0725f64bb424334abef574c7d482e946377738488e7a84cf320b731a019bc7a0' + assert hashlib.sha256((PACKAGE / 'kernels/tls_reference_experimental.cu').read_bytes()).hexdigest() == pin + + +def test_shared_math_uses_explicit_unchanged_dependencies_without_rebinding(): + for name in ('augment_duration_grid', 'build_cache', 'harmonic_candidate_indices', + 'native_spectra', 'preprocess_inputs'): + assert getattr(experimental_math, name) is getattr(baseline_math, name) + for name in ('chunk_width_masks', 'refinement_candidate_indices'): + assert getattr(experimental_math, name) is not getattr(baseline_math, name) + assert getattr(baseline_math, name).__module__ == 'cuvarbase.tls_reference_math' + + +@pytest.fixture +def isolated_engines(monkeypatch): + # Import the complete sources under private test names, with only their + # CUDA dependency replaced. No import or state reset of real GPU engines. + fake_cupy = types.ModuleType('cupy') + monkeypatch.setitem(sys.modules, 'cupy', fake_cupy) + loaded = [] + for suffix in ('', '_experimental'): + path = PACKAGE / ('tls_reference' + suffix + '.py') + name = 'cuvarbase._isolated_execution' + suffix + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, name, module) + # Prefix classes have no eager CUDA calls. Remove newly imported + # dependency modules afterward so this fake cupy cannot leak to tests. + for dependency in ('tls_reference_prefix', 'tls_reference_short_prefix'): + key = 'cuvarbase.' + dependency + if key not in sys.modules: + dependency_spec = importlib.util.spec_from_file_location(key, PACKAGE / (dependency + '.py')) + dependency_module = importlib.util.module_from_spec(dependency_spec) + monkeypatch.setitem(sys.modules, key, dependency_module) + dependency_spec.loader.exec_module(dependency_module) + spec.loader.exec_module(module) + loaded.append(module) + return loaded, fake_cupy + + +def test_module_compilation_and_lookup_are_owned_by_each_backend(isolated_engines): + (baseline, experimental), cp = isolated_engines + compiled = [] + + class Module: + def __init__(self, code): + self.code = code + self.compiles = 0 + + def compile(self): + self.compiles += 1 + compiled.append(self) + + cp.RawModule = Module + default_modules = baseline.modules() + assert len(compiled) == 2 + assert experimental._MODULES is None + optimized_modules = experimental.modules() + assert len(compiled) == 4 + assert baseline.modules() is default_modules + assert experimental.modules() is optimized_modules + assert all(a is not b for a, b in zip(default_modules, optimized_modules)) + assert default_modules[1].code == (PACKAGE / 'kernels/tls_reference.cu').read_text() + assert optimized_modules[1].code == (PACKAGE / 'kernels/tls_reference_experimental.cu').read_text() + assert all(module.compiles == 1 for module in compiled) + + +def test_prefix_state_and_short_dispatch_are_isolated_across_modes_and_threads(isolated_engines, monkeypatch): + (baseline, experimental), _ = isolated_engines + assert baseline._PREFIX_PLANS is not experimental._PREFIX_PLANS + short_calls = [] + + class Graph: + def prefix(self, value): + return (self, value) + + class Short: + def prefix(self, value): + short_calls.append(value) + return None + + for engine in (baseline, experimental): + monkeypatch.setattr(engine, '_PrefixPlanCache', Graph) + monkeypatch.setattr(experimental, 'NativeShortPrefixCache', Short) + marker = object() + default_cache, _ = baseline._native_flux_prefix(marker) + assert not short_calls + assert not hasattr(baseline._PREFIX_PLANS, 'short') + experimental_cache, _ = experimental._native_flux_prefix(marker) + assert short_calls == [marker] + assert default_cache is not experimental_cache + assert baseline._native_flux_prefix(marker)[0] is default_cache + assert short_calls == [marker] + assert experimental._native_flux_prefix(marker)[0] is experimental_cache + + def worker(): + return [engine._native_flux_prefix(marker)[0] for engine in (baseline, experimental)] + + with ThreadPoolExecutor(max_workers=1) as pool: + child = pool.submit(worker).result() + assert child[0] is not default_cache + assert child[1] is not experimental_cache + assert child[0] is not child[1] + assert baseline._PREFIX_PLANS.cache is default_cache + assert experimental._PREFIX_PLANS.cache is experimental_cache diff --git a/cuvarbase/tests/test_tls_fast.py b/cuvarbase/tests/test_tls_fast.py new file mode 100644 index 00000000..79a11a0e --- /dev/null +++ b/cuvarbase/tests/test_tls_fast.py @@ -0,0 +1,799 @@ +"""GPU tests for the fast (batched, phase-binned) TLS path. + +These tests explicitly select method='binned', the preserved approximate +engine. They cover batch consistency, +the coarse/refined statistics separation, adaptive binning, chunking, +and the removal of the legacy ndata cap. +""" +import numpy as np +import pytest + +try: + import pycuda.driver # noqa: F401 + PYCUDA_AVAILABLE = True +except Exception: + PYCUDA_AVAILABLE = False + +pytestmark = pytest.mark.skipif(not PYCUDA_AVAILABLE, + reason="pycuda unavailable") + + +def make_transit_lc(period, q, depth, ndata=1500, baseline=27.0, + noise=2e-3, seed=42, t0_frac=0.3): + rng = np.random.RandomState(seed) + t = np.sort(rng.uniform(0, baseline, ndata)) + y = 1.0 + rng.randn(ndata) * noise + t0 = t0_frac * period + rel = np.abs(((t - t0 + 0.5 * period) % period) - 0.5 * period) + y[rel < 0.5 * q * period] -= depth + dy = np.full(ndata, noise) + return t, y, dy + + +def shared_grid(baseline=27.0, period_min=1.0, period_max=12.0): + from cuvarbase import tls_grids + t_ref = np.linspace(0, baseline, 500) + return tls_grids.period_grid_ofir( + t_ref, R_star=1.0, M_star=1.0, oversampling_factor=3, + period_min=period_min, period_max=period_max) + + +class TestBatchConsistency: + def test_batch_matches_single(self): + from cuvarbase import tls + periods = shared_grid() + lcs = [make_transit_lc(3.3, 0.03, 0.012, seed=1), + make_transit_lc(7.7, 0.02, 0.012, ndata=2500, seed=2)] + batch = tls.tls_search_batch(lcs, periods=periods, method='binned') + singles = [tls.tls_search_batch([lc], periods=periods, method='binned')[0] + for lc in lcs] + for b, s in zip(batch, singles): + # atomics make near-tied neighbors non-deterministic; + # a few grid steps of slack + assert abs(b['period'] - s['period']) / s['period'] < 5e-3 + assert abs(b['SDE'] - s['SDE']) < 1.0 + + def test_recovers_injected_periods(self): + from cuvarbase import tls + periods = shared_grid() + p_injs = [3.3, 7.7] + lcs = [make_transit_lc(p, 0.03, 0.012, seed=10 + i) + for i, p in enumerate(p_injs)] + results = tls.tls_search_batch(lcs, periods=periods, method='binned') + for r, p in zip(results, p_injs): + assert abs(r['period'] - p) / p < 0.01 + assert r['SDE'] > 5 + + def test_noise_lc_scores_below_signal(self): + from cuvarbase import tls + periods = shared_grid() + rng = np.random.RandomState(3) + t = np.sort(rng.uniform(0, 27.0, 1500)) + noise_lc = (t, 1.0 + 2e-3 * rng.randn(1500), + np.full(1500, 2e-3)) + sig_lc = make_transit_lc(3.3, 0.03, 0.012, seed=4) + r_noise, r_sig = tls.tls_search_batch([noise_lc, sig_lc], + periods=periods, method='binned') + assert r_noise['SDE'] < r_sig['SDE'] + + +class TestStatisticsSeparation: + def test_spectrum_is_coarse_and_uniform(self): + """Refinement must not touch the per-period spectrum: SDE + computed with refine on and off must agree. + + Tolerances: the coarse spectrum is accumulated with float32 + shared-memory atomics (one block per (lightcurve, period); see + kernels/tls_fast.cu), whose summation order is not deterministic + between launches, so two runs of the SAME configuration are not + bit-identical. rtol=1e-2 on chi2 and |dSDE| < 0.5 are the + run-to-run envelope observed on the Jul-2026 gate hardware (A5000) + with a wide margin -- NOT a statement that refinement may perturb + the spectrum by that much. Tightening to ~10x the measured + run-to-run floor is a device task (release finding 84). + """ + from cuvarbase import tls + periods = shared_grid() + lc = make_transit_lc(3.3, 0.03, 0.012, seed=5) + r_ref = tls.tls_search_batch([lc], periods=periods, + refine_top_k=200, + return_arrays=True, method='binned')[0] + r_none = tls.tls_search_batch([lc], periods=periods, + refine_top_k=0, + return_arrays=True, method='binned')[0] + ok = (np.isfinite(r_ref['chi2']) & np.isfinite(r_none['chi2'])) + np.testing.assert_allclose(r_ref['chi2'][ok], + r_none['chi2'][ok], rtol=1e-2) + assert abs(r_ref['SDE'] - r_none['SDE']) < 0.5 + + def test_refined_chi2_min_not_above_coarse(self): + """The exact refinement searches a finer local grid around the + coarse optimum, so the reported chi2_min should be at or below + the coarse spectrum minimum (up to float noise).""" + from cuvarbase import tls + periods = shared_grid() + lc = make_transit_lc(3.3, 0.03, 0.012, seed=6) + r = tls.tls_search_batch([lc], periods=periods, + return_arrays=True, method='binned')[0] + coarse_min = np.nanmin(r['chi2']) + assert r['chi2_min'] <= coarse_min * (1 + 1e-3) + + +class TestScalability: + def test_ndata_beyond_legacy_cap(self): + from cuvarbase import tls + periods = shared_grid() + lc = make_transit_lc(4.56, 0.025, 0.008, ndata=20000, seed=7) + r = tls.tls_search_batch([lc], periods=periods, method='binned')[0] + assert abs(r['period'] - 4.56) / 4.56 < 0.01 + + def test_bjd_scale_times(self): + from cuvarbase import tls + periods = shared_grid() + t, y, dy = make_transit_lc(4.56, 0.025, 0.008, ndata=5000, + seed=8) + r = tls.tls_search_batch([(t + 2457000.0, y, dy)], + periods=periods, method='binned')[0] + assert abs(r['period'] - 4.56) / 4.56 < 0.01 + # T0 is the first mid-transit at or after the first observation + tmin = t.min() + 2457000.0 + assert tmin <= r['T0'] < tmin + r['period'] + assert 0.0 <= r['t0_phase'] < 1.0 + + def test_chunking_many_small_lcs(self): + """Force multiple chunks via the LC-count ceiling and check + every LC still gets a result.""" + from cuvarbase import tls + periods = shared_grid() + old = tls._TLS_FAST_MAX_OUT_FLOATS + tls._TLS_FAST_MAX_OUT_FLOATS = 3 * len(periods) # 3 LCs/chunk + try: + lcs = [make_transit_lc(3.3, 0.03, 0.012, ndata=400, + seed=20 + i) for i in range(8)] + results = tls.tls_search_batch(lcs, periods=periods, method='binned') + finally: + tls._TLS_FAST_MAX_OUT_FLOATS = old + assert len(results) == 8 + for r in results: + assert 'error' not in r + assert abs(r['period'] - 3.3) / 3.3 < 0.02 + + def test_mixed_lengths_offsets(self): + from cuvarbase import tls + periods = shared_grid() + lcs = [make_transit_lc(3.3, 0.03, 0.015, ndata=n, seed=30 + i) + for i, n in enumerate((300, 4000, 1100))] + results = tls.tls_search_batch(lcs, periods=periods, method='binned') + for r in results: + assert abs(r['period'] - 3.3) / 3.3 < 0.02 + + +class TestValidation: + def test_empty_batch(self): + from cuvarbase import tls + assert tls.tls_search_batch([]) == [] + + def test_mismatched_qmin_qmax(self): + from cuvarbase import tls + lc = make_transit_lc(3.3, 0.03, 0.012, ndata=300) + with pytest.raises(ValueError): + tls.tls_search_batch([lc], periods=np.linspace(2, 5, 50), + qmin=np.full(10, 0.01), + qmax=np.full(10, 0.05)) + + def test_qmin_only_rejected(self): + from cuvarbase import tls + lc = make_transit_lc(3.3, 0.03, 0.012, ndata=300) + with pytest.raises(ValueError, match="both qmin and qmax"): + tls.tls_search_batch([lc], periods=np.linspace(2, 5, 50), + qmin=np.full(50, 0.01)) + + def test_bad_n_durations(self): + from cuvarbase import tls + lc = make_transit_lc(3.3, 0.03, 0.012, ndata=300) + with pytest.raises(ValueError, match="n_durations"): + tls.tls_search_batch([lc], n_durations=100, method='binned') + + +class TestBanding: + """The period grid is banded by required bin count (NBINS variants + + period_map scatter); banded results must match a single-band + (fixed nbins) run over the identical trial grid.""" + + def test_banded_matches_single_band(self): + from cuvarbase import tls + periods = np.asarray(shared_grid(), dtype=np.float64) + n = len(periods) + # interleaved qmin values straddle a power-of-two boundary in + # need = t0_oversample/qmin (3/0.02 -> 256 bins, 3/0.008 -> 512 + # bins), so the banded run launches two NBINS variants with a + # non-contiguous period_map scatter; the duration and t0 trial + # grids depend only on qmin/qmax and are identical in both runs, + # and both duration windows bracket the injected q = 0.03 + qmin = np.where(np.arange(n) % 2 == 0, 0.02, 0.008) + qmax = np.full(n, 0.09) + lc = make_transit_lc(3.3, 0.03, 0.012, seed=11) + + r_banded = tls.tls_search_batch([lc], periods=periods, + qmin=qmin, qmax=qmax, + return_arrays=True, method='binned')[0] + r_fixed = tls.tls_search_batch([lc], periods=periods, + qmin=qmin, qmax=qmax, + nbins=512, + return_arrays=True, method='binned')[0] + + assert abs(r_banded['period'] - 3.3) / 3.3 < 0.01 + assert abs(r_banded['period'] - r_fixed['period']) / 3.3 < 5e-3 + ok = (np.isfinite(r_banded['chi2']) + & np.isfinite(r_fixed['chi2'])) + assert ok.sum() > 0.9 * n + # the odd-index periods run at 512 bins in BOTH configurations; + # the even-index ones differ only in bin resolution (256 vs + # 512), so the spectra must agree closely everywhere + corr = np.corrcoef(r_banded['chi2'][ok], + r_fixed['chi2'][ok])[0, 1] + assert corr > 0.99 + + +class TestEmptyBinTraversal: + """Skipping empty phase bins must preserve the numerical search. + + Compare the optimized kernel with its dense reference traversal on + identical grids, including bins straddling phase zero, capped narrow + durations, long empty phase intervals, and the conservative sparse + dispatch threshold and small-block fallback. + These are numerical regressions, not evidence about astrophysical + recovery or false-positive calibration. + """ + + @staticmethod + def _search_pair(tls, monkeypatch, lightcurves, **kwargs): + reader = tls._module_reader + compiled = {} + mode = 0 + + def get_kernels(bs, nb, oversample, refine_nd=3): + key = (mode, bs, nb, oversample, refine_nd) + if key not in compiled: + def read_variant(*args, **kw): + return ('#define TLS_SKIP_EMPTY_BINS %d\n' % mode + + reader(*args, **kw)) + with monkeypatch.context() as patch: + patch.setattr(tls, '_module_reader', read_variant) + compiled[key] = tls.compile_tls_fast( + bs, nb, oversample, refine_nd) + return compiled[key] + + monkeypatch.setattr(tls, '_get_cached_fast_kernels', get_kernels) + outputs = [] + for mode in (0, 1): + outputs.append(tls.tls_search_batch(lightcurves, **kwargs, method='binned')) + return outputs + + @pytest.mark.parametrize('nbins,ndata,q,center,clustered,block_size', [ + (8192, 700, .03, .9999, False, 512), + (8192, 256, .0001, .99999, False, 512), + (8192, 300, .03, .01, True, 512), + (1024, 3000, .03, .3, False, 256), + (8192, 2200, .03, .3, False, 512), + (8192, 300, .03, .99, False, 32), + ]) + def test_same_scores_and_signal_candidate( + self, monkeypatch, nbins, ndata, q, center, clustered, + block_size): + from cuvarbase import tls + + tls.ensure_context() + if tls._tls_fast_shared_size(block_size, nbins) > tls._device_max_shared(): + pytest.skip('device cannot fit the requested fine-bin kernel') + rng = np.random.RandomState(841) + cycles = rng.randint(0, 2744, ndata) + phase = rng.uniform(0, 1, ndata) + if clustered: + phase = np.mod(center + rng.uniform(-.02, .02, ndata), 1.) + # Ensure that even the tiny-duty-cycle case contains measured + # transits. The aim is traversal parity, not random observability. + phase[:24] = np.mod(center + np.linspace(-.3 * q, .3 * q, 24), 1.) + t = cycles + phase + rel = (phase - center + .5) % 1. - .5 + shape = np.maximum(0., 1. - (2. * rel / q) ** 2) + dy = rng.uniform(.001, .003, ndata) + y = 1. - .03 * shape + rng.randn(ndata) * dy + order = np.argsort(t) + lc = (t[order] + 2457000., y[order], dy[order]) + periods = np.array([.701, .913, 1., 1.127, 1.701]) + qmin = np.full(len(periods), q) + qmax = np.full(len(periods), 1.5 * q) + old_list, new_list = self._search_pair( + tls, monkeypatch, [lc], periods=periods, qmin=qmin, qmax=qmax, + n_durations=4, t0_oversample=8., nbins=nbins, + block_size=block_size, refine_top_k=0, return_arrays=True) + old, new = old_list[0], new_list[0] + np.testing.assert_array_equal(old['valid_periods'], new['valid_periods']) + chi2_0 = tls._preprocess_batch([lc])[6][0] + old_score = chi2_0 - old['chi2'] + new_score = chi2_0 - new['chi2'] + # Atomic histogram sums vary in order across launches. The + # sparse scan retains the dense scan's coordinate arithmetic. + np.testing.assert_allclose(new_score, old_score, + rtol=2e-5, atol=1e-5, equal_nan=True) + assert old['period'] == new['period'] + + def test_sparse_template_tail(self, monkeypatch): + """Tiny coordinate shifts can amplify integral-subtraction error. + + Most phases are unobserved. A single downward fluctuation can + sit in a transit's faint tail, where S2(right)-S2(left) is tiny. + Multiplying a skip distance by dc instead of repeating the + dense coordinate additions perturbs that subtraction and can + change the winning score. The synthetic fixture is deliberately + small and needs no survey archive. + """ + from cuvarbase import tls + + tls.ensure_context() + if tls._tls_fast_shared_size(512, 8192) > tls._device_max_shared(): + pytest.skip('device cannot fit the requested fine-bin kernel') + t = np.r_[np.linspace(0., .5, 128), 231.5752637386322] + y = np.r_[np.full(128, 1.001), .9982297870702772] + dy = np.r_[np.full(128, .001), .0006100752167838939] + lc = (t, y, dy) + old_list, new_list = self._search_pair( + tls, monkeypatch, [lc], periods=np.array([.9998087951893398]), + qmin=np.array([.038196352656710154]), + qmax=np.array([.15278541062684062]), + n_durations=32, t0_oversample=16., nbins=8192, + block_size=512, refine_top_k=0, return_arrays=True) + old, new = old_list[0], new_list[0] + chi2_0 = tls._preprocess_batch([lc])[6][0] + old_score, new_score = chi2_0 - old['chi2'], chi2_0 - new['chi2'] + assert old['valid_periods'].all() and new['valid_periods'].all() + assert np.isfinite(old_score).all() and np.isfinite(new_score).all() + assert (old_score > 0).all() and (new_score > 0).all() + np.testing.assert_allclose(new_score, old_score, rtol=2e-5, atol=1e-5) + + +class TestRefinementFallback: + """PR #68 review regression: the coarse-parameter fallback in _finish_lc + must not depend on return_arrays being set.""" + + def test_all_refinements_fail_falls_back_no_crash(self, monkeypatch): + # Force every top-K exact refinement to return the failure sentinel + # (rscore <= 0) while the coarse phase-binned scan still finds valid + # periods. With the default batch args (refine_top_k > 0 and + # return_arrays=False) the else-branch in _finish_lc must fall back to + # the coarse best-fit t0/duration/depth — it must NOT raise + # UnboundLocalError because those coarse arrays were fetched only under + # `return_arrays or not K`. + from cuvarbase import tls + orig = tls._get_cached_fast_kernels + + def patched(*a, **k): + kern = dict(orig(*a, **k)) # copy cached {'search','refine'} + + def fail_refine(*args, **kwargs): # rscore_g is positional arg 16 + args[16].fill(np.float32(-1.0)) + + kern['refine'] = fail_refine + return kern + + monkeypatch.setattr(tls, '_get_cached_fast_kernels', patched) + periods = shared_grid() + lcs = [make_transit_lc(3.3, 0.03, 0.012, seed=1), + make_transit_lc(7.7, 0.02, 0.012, seed=2)] + res = tls.tls_search_batch(lcs, periods=periods, method='binned') # defaults + assert len(res) == 2 + for r in res: + assert 'error' not in r + assert np.isfinite(r['period']) and r['period'] > 0 + assert np.isfinite(r['duration']) and np.isfinite(r['depth']) + + +# --------------------------------------------------------------------- +# 1.0 correctness fixes (Sep 2026 audit), GPU regressions on all paths +# --------------------------------------------------------------------- + +import warnings as _w + + +def _call_expect_warning(fn, match): + """Run fn() and assert a UserWarning containing `match` was emitted. + (pytest.warns around a GPU call would report DID NOT WARN instead of + letting the conftest's GPUStubError skip through on CPU-only hosts.)""" + with _w.catch_warnings(record=True) as rec: + _w.simplefilter("always") + r = fn() + msgs = [str(x.message) for x in rec if issubclass(x.category, UserWarning)] + assert any(match in m for m in msgs), msgs + return r + + +def _three_paths(t, y, dy, periods, **kw): + """(fast, legacy, batch) results for one light curve.""" + from cuvarbase import tls + fast = tls.tls_search_gpu(t, y, dy, periods=periods, **kw, method='binned') + legacy = tls.tls_search_gpu(t, y, dy, periods=periods, method='legacy', + **kw) + batch = tls.tls_search_batch([(t, y, dy)], periods=periods, + return_arrays=True, method='binned')[0] + return {'fast': fast, 'legacy': legacy, 'batch': batch} + + +def _box_lc(period, q, depth, t0, t_start, baseline=40.0, ndata=2000, + noise=2e-3, seed=3): + rng = np.random.RandomState(seed) + t = t_start + np.sort(rng.uniform(0, baseline, ndata)) + y = 1.0 + rng.randn(ndata) * noise + rel = np.abs(((t - t0 + 0.5 * period) % period) - 0.5 * period) + in_tr = rel < 0.5 * q * period + y[in_tr] -= depth + return t, y, np.full(ndata, noise), in_tr + + +class TestT0Semantics: + """Defect 11 (tls-T0, audit ids 11/145): 'T0' was a fold phase on + the fast path (relative to floor(min t)), a phase relative to t = 0 + on the legacy path, and an absolute time that could precede the + first observation on the batch path. It is now the absolute time of + the first mid-transit at or after min(t) on every path, with the + phase under 't0_phase'.""" + + @pytest.mark.parametrize("t_start,frac", [(100.3, 0.37), (100.9, 0.8), + (2457000.3, 0.37)]) + def test_T0_first_transit_after_min_t_on_all_paths(self, t_start, frac): + P, q, depth = 3.0, 0.03, 0.01 + t0_true = t_start + frac * P + t, y, dy, in_tr = _box_lc(P, q, depth, t0_true, t_start) + periods = np.linspace(2.9, 3.1, 300) + res = _three_paths(t, y, dy, periods) + tmin = t.min() + dur_true = q * P + for name, r in res.items(): + assert abs(r['period'] - P) / P < 0.01, name + # absolute time in [min(t), min(t) + P) + assert tmin <= r['T0'] < tmin + r['period'], (name, r['T0']) + assert 0.0 <= r['t0_phase'] < 1.0, name + # T0 and t0_phase describe the same epoch (relative to + # floor(min t)), up to whole periods + t_from_phase = np.floor(tmin) + r['t0_phase'] * r['period'] + frac_diff = ((r['T0'] - t_from_phase) / r['period']) % 1.0 + assert min(frac_diff, 1.0 - frac_diff) < 1e-4, name + # folding the data at T0 puts the injected transit at phase 0 + # (legacy coarse t0 stride is q/3 -> up to 0.17 durations off) + nearest = np.min(np.abs(r['T0'] - (t0_true + P * np.arange(-2, 20)))) + assert nearest < 0.4 * dur_true, (name, nearest) + ph = ((t - r['T0']) / r['period'] + 0.5) % 1.0 - 0.5 + sel = np.abs(ph) < 0.4 * q + assert sel.sum() > 20, name + assert y[sel].mean() < 1.0 - 0.7 * depth, name + assert 'FAP' not in r, name + + def test_paths_agree_on_T0(self): + P, q, depth = 3.0, 0.03, 0.01 + t, y, dy, _ = _box_lc(P, q, depth, 100.9 + 0.8 * P, 100.9) + res = _three_paths(t, y, dy, np.linspace(2.9, 3.1, 300)) + assert res['fast']['T0'] == pytest.approx(res['batch']['T0'], abs=1e-6) + assert res['fast']['T0'] == pytest.approx(res['legacy']['T0'], + abs=0.4 * q * P) + + +class TestUnsortedPeriodGrid: + """id 82: a descending (what transitleastsquares returns) or shuffled + user grid gave a negative period_uncertainty and a changed SDE.""" + + def test_descending_and_shuffled_match_ascending(self): + from cuvarbase import tls + periods = np.asarray(shared_grid(), dtype=np.float64) + lc = make_transit_lc(3.3, 0.03, 0.012, seed=1) + ref = tls.tls_search_gpu(*lc, periods=periods, method='binned') + assert ref['period_uncertainty'] > 0 + rng = np.random.RandomState(0) + for label, grid in (('descending', periods[::-1].copy()), + ('shuffled', periods[rng.permutation(len(periods))])): + for path in ('fast', 'legacy'): + r = tls.tls_search_gpu(*lc, periods=grid, + method='binned' if (path == 'fast') else 'legacy') + assert r['period'] == pytest.approx(ref['period'], rel=5e-3), (label, path) + assert r['period_uncertainty'] > 0, (label, path) + # per-period arrays come back in the caller's order + np.testing.assert_array_equal(r['periods'], + grid.astype(np.float32)) + back = np.argsort(grid) + if path == 'fast': + assert abs(r['SDE'] - ref['SDE']) < 0.05, label + ok = np.isfinite(r['chi2'][back]) & np.isfinite(ref['chi2']) + np.testing.assert_allclose(r['chi2'][back][ok], + ref['chi2'][ok], rtol=1e-4) + np.testing.assert_array_equal(r['valid_periods'][back], + ref['valid_periods']) + rb = tls.tls_search_batch([lc], periods=grid, + return_arrays=True, method='binned')[0] + assert rb['period_uncertainty'] > 0 + np.testing.assert_array_equal(rb['periods'], grid.astype(np.float32)) + assert abs(rb['SDE'] - ref['SDE']) < 0.05 + + +class TestFlatLightCurve: + """id 89: a flat/noiseless light curve fails every trial period; the + reference returns SDE = 0 with a warning, cuvarbase used to raise.""" + + def test_sde_zero_on_all_paths(self): + from cuvarbase import tls + t = np.linspace(0, 30, 1000) + y = np.ones(1000) + dy = np.full(1000, 1e-3) + periods = np.linspace(2, 5, 200) + calls = { + 'fast': lambda: tls.tls_search_gpu(t, y, dy, periods=periods, method='binned'), + 'legacy': lambda: tls.tls_search_gpu(t, y, dy, periods=periods, + method='legacy'), + 'batch': lambda: tls.tls_search_batch([(t, y, dy)], + periods=periods, method='binned')[0], + } + for name, fn in calls.items(): + r = _call_expect_warning(fn, "no valid solution") + assert r['SDE'] == 0.0 and r['SDE_raw'] == 0.0, name + assert np.isnan(r['period']) and np.isnan(r['T0']), name + assert r['n_failed_periods'] == 200, name + assert 'error' in r and 'FAP' not in r, name + # a flat light curve in a batch does not poison its neighbours + good = make_transit_lc(3.3, 0.03, 0.012, seed=1) + rs = _call_expect_warning( + lambda: tls.tls_search_batch([(t, y, dy), good], + periods=shared_grid(), method='binned'), + "no valid solution") + assert rs[0]['SDE'] == 0.0 + assert abs(rs[1]['period'] - 3.3) / 3.3 < 0.01 and rs[1]['SDE'] > 5 + + +class TestFAPKey: + """Defect 10 (tls-fap): no result carries a FAP unless a null + bootstrap was requested; the bootstrap is uniform under the null.""" + + def test_no_fap_without_calibration(self): + lc = make_transit_lc(3.3, 0.03, 0.012, seed=1) + for r in _three_paths(*lc, periods=shared_grid()).values(): + assert 'FAP' not in r and 'SDE_null' not in r + + def test_null_bootstrap(self): + from cuvarbase import tls + periods = shared_grid() + rng = np.random.RandomState(3) + t = np.sort(rng.uniform(0, 27.0, 1500)) + noise_lc = (t, 1.0 + 2e-3 * rng.randn(1500), np.full(1500, 2e-3)) + sig_lc = make_transit_lc(3.3, 0.03, 0.012, seed=4) + r_noise, r_sig = tls.tls_search_batch( + [noise_lc, sig_lc], periods=periods, fap_null_draws=40, + fap_seed=7, method='binned') + for r in (r_noise, r_sig): + assert 0 < r['FAP'] <= 1.0 + assert r['SDE_null'].shape == (40,) + assert np.all(np.isfinite(r['SDE_null'])) + # null SDEs sit in the expected range for this grid + assert 3 < r['SDE_null'].mean() < 10 + # the signal beats every permutation: minimum resolvable FAP + assert r_sig['FAP'] == pytest.approx(1.0 / 41.0) + assert r_sig['SDE'] > r_sig['SDE_null'].max() + # the noise light curve is not significant + assert r_noise['FAP'] > 0.05 + # the observed SDE is unchanged by the bootstrap + plain = tls.tls_search_batch([noise_lc, sig_lc], periods=periods, method='binned') + assert plain[1]['SDE'] == pytest.approx(r_sig['SDE'], abs=1e-3) + # seeded -> reproducible null + again = tls.tls_search_batch([noise_lc], periods=periods, + fap_null_draws=40, fap_seed=7, method='binned')[0] + np.testing.assert_allclose(again['SDE_null'], r_noise['SDE_null'], + atol=1e-2) + + def test_bad_draw_count(self): + from cuvarbase import tls + lc = make_transit_lc(3.3, 0.03, 0.012, ndata=300) + with pytest.raises(ValueError, match="fap_null_draws"): + tls.tls_search_batch([lc], periods=shared_grid(), + fap_null_draws=-1, method='binned') + + +class TestSNRDefinition: + """id 85: SNR is sqrt(chi2_0 - chi2_min) with the float64 + constant-model chi2_0 and the refined chi2_min (was max(chi2) over + the grid and the coarse chi2).""" + + def test_snr_is_delta_chi2_over_constant_model(self): + t, y, dy = make_transit_lc(3.3, 0.03, 0.012, seed=6) + chi2_0 = np.sum((1.0 - y) ** 2 / (dy ** 2 + 1e-10)) + res = _three_paths(t, y, dy, shared_grid()) + for name, r in res.items(): + assert r['SNR'] == pytest.approx(np.sqrt(chi2_0 - r['chi2_min']), + rel=1e-5), name + assert r['SNR'] > 10, name + + +class TestDurationWindowDefault: + """Defect 2 on device: the default window of tls_search_gpu equals + the explicit Keplerian window (identical trial grid), 'fixed' is an + opt-in that warns, and the legacy path honours the same window.""" + + def test_default_equals_explicit_keplerian(self): + from cuvarbase import tls, tls_grids + lc = make_transit_lc(3.3, 0.03, 0.012, seed=1) + periods = np.asarray(shared_grid(), dtype=np.float64) + q = tls_grids.q_transit(periods) + r_def = tls.tls_search_gpu(*lc, periods=periods, method='binned') + r_exp = tls.tls_search_gpu(*lc, periods=periods, qmin=0.5 * q, + qmax=2.0 * q, method='binned') + ok = np.isfinite(r_def['chi2']) & np.isfinite(r_exp['chi2']) + np.testing.assert_allclose(r_def['chi2'][ok], r_exp['chi2'][ok], + rtol=1e-5) + assert r_def['period'] == pytest.approx(r_exp['period'], rel=1e-3) + # tls_transit builds its own Ofir grid from the data's span and + # the same Keplerian window; it must find the same transit + r_tr = tls.tls_transit(*lc, period_min=1.0, period_max=12.0, method='binned') + assert r_tr['period'] == pytest.approx(3.3, rel=0.01) + assert r_tr['depth'] == pytest.approx(r_def['depth'], rel=0.1) + + def test_fixed_window_optin_warns_and_default_does_not(self): + from cuvarbase import tls + rng = np.random.RandomState(9) + t = np.sort(rng.uniform(0, 700.0, 2000)) + y = 1.0 + 1e-3 * rng.randn(2000) + dy = np.full(2000, 1e-3) + periods = np.linspace(100.0, 300.0, 50) + with _w.catch_warnings(): + _w.simplefilter("error") + tls.tls_search_gpu(t, y, dy, periods=periods, method='binned') + tls.tls_search_gpu(t, y, dy, periods=periods, method='legacy') + for path in ('fast', 'legacy'): + r = _call_expect_warning( + lambda: tls.tls_search_gpu(t, y, dy, periods=periods, + duration_window='fixed', + method='binned' if (path == 'fast') else 'legacy'), + "excludes the Keplerian") + assert np.isfinite(r['SDE']) + + def test_legacy_path_uses_keplerian_kernel(self, monkeypatch): + from cuvarbase import tls + seen = [] + orig = tls._get_cached_kernels + + def spy(*a, **k): + kern = dict(orig(*a, **k)) + real = kern['keplerian'] + + def kep(*args, **kwargs): + seen.append('keplerian') + return real(*args, **kwargs) + + def std(*args, **kwargs): + seen.append('standard') + return kern['standard'](*args, **kwargs) + + kern['keplerian'] = kep + kern['standard'] = std + return kern + + monkeypatch.setattr(tls, '_get_cached_kernels', spy) + lc = make_transit_lc(3.3, 0.03, 0.012, ndata=800, seed=2) + r = tls.tls_search_gpu(*lc, periods=shared_grid(), method='legacy') + assert seen == ['keplerian'] + assert abs(r['period'] - 3.3) / 3.3 < 0.02 + + +class TestBatchStatisticsAreSequential: + """Phase 2 TLS-2 (audit section 5, id 53): the per-light-curve + statistics ran on a ThreadPoolExecutor on the assumption that + scipy released the GIL in the running-median detrend. It does not: + measured on an A40 (shared), 64 tess-ffi light curves took 166 ms + with the pool and 77 ms without it, and the statistics alone cost + 21.7 ms sequentially versus 40.3 ms on 8 threads. Bit-neutral -- + only the executor changed -- and the light-curve order (and hence + the order of any per-light-curve warning) is now deterministic.""" + + def test_statistics_run_on_the_calling_thread_in_order(self, monkeypatch): + import threading + from cuvarbase import tls, tls_stats + seen = [] + real = tls_stats.compute_all_statistics + + def spy(*a, **k): + seen.append(threading.current_thread().name) + return real(*a, **k) + + monkeypatch.setattr(tls_stats, 'compute_all_statistics', spy) + periods = shared_grid() + lcs = [make_transit_lc(2.5 + 0.7 * i, 0.03, 0.012, ndata=600, + seed=30 + i) for i in range(6)] + results = tls.tls_search_batch(lcs, periods=periods, method='binned') + assert len(seen) == len(lcs) + assert set(seen) == {threading.current_thread().name} + assert all(r is not None for r in results) + + def test_results_are_returned_in_lightcurve_order(self): + from cuvarbase import tls + periods = shared_grid() + p_injs = [2.6, 4.1, 6.3, 9.5] + lcs = [make_transit_lc(p, 0.03, 0.015, ndata=900, seed=40 + i) + for i, p in enumerate(p_injs)] + results = tls.tls_search_batch(lcs, periods=periods, method='binned') + for r, p in zip(results, p_injs): + assert abs(r['period'] - p) / p < 0.01 + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) + + +class TestFastLegacyParity: + """Fast (phase-binned scan + exact top-K refinement) versus legacy + (per-point template) kernel on ONE explicit trial grid -- the parity + check ported from ``scripts/tls_fast_smoke.py`` (section 2). The + smoke script's other sections already have suite equivalents: + batch-vs-single agreement (``TestBatchConsistency``), ndata beyond + the legacy 3,500-point cap and BJD-scale times + (``TestScalability``), and the auto-grid ``tls_transit`` recovery + (``TestDurationWindowDefault``, ``TestTlsTransitSmoke``).""" + + P_INJ, DEPTH = 5.123, 0.01 + + def _grid_and_data(self): + from cuvarbase import tls_grids + t, y, dy = make_transit_lc(self.P_INJ, + 0.0763 * self.P_INJ ** (-2.0 / 3.0), + self.DEPTH, ndata=1200, seed=42) + periods = tls_grids.period_grid_ofir( + t, R_star=1.0, M_star=1.0, oversampling_factor=3, + period_min=1.0, period_max=12.0).astype(np.float64) + _, _, qv = tls_grids.duration_grid_keplerian( + periods, R_star=1.0, M_star=1.0, R_planet=1.0, + qmin_fac=0.5, qmax_fac=2.0, n_durations=15) + return (t, y, dy), periods, 0.5 * qv, 2.0 * qv + + def test_fast_matches_legacy_on_an_explicit_grid(self): + from cuvarbase import tls + lc, periods, qmin, qmax = self._grid_and_data() + kw = dict(periods=periods, qmin=qmin, qmax=qmax, n_durations=15) + r_old = tls.tls_search_gpu(*lc, method='legacy', **kw) + r_new = tls.tls_search_gpu(*lc, method='binned', **kw) + + c_old, c_new = r_old['chi2'], r_new['chi2'] + both = np.isfinite(c_old) & np.isfinite(c_new) + assert both.sum() > 0.9 * len(periods) + corr = np.corrcoef(c_old[both], c_new[both])[0, 1] + assert corr > 0.99, corr + # same chi2 scale (the binned scan is not a different statistic) + med_old, med_new = np.median(c_old[both]), np.median(c_new[both]) + assert abs(med_new / med_old - 1.0) < 0.05, (med_old, med_new) + # same best period, which is the injected one + assert abs(r_new['period'] - r_old['period']) / r_old['period'] < 0.01 + assert abs(r_new['period'] - self.P_INJ) / self.P_INJ < 0.01 + assert r_new['SDE'] > 0.8 * r_old['SDE'], (r_old['SDE'], r_new['SDE']) + assert abs(r_new['depth'] - self.DEPTH) / self.DEPTH < 0.5 + + +class TestTlsTransitSmoke: + """``tls.tls_transit`` end to end on an injected transit (release + finding 71/149: the Keplerian wrapper had no test of its own + result). Period within two steps of the grid it builds itself, + ``T0`` the first mid-transit at or after ``min(t)``, ``t0_phase`` a + fold phase in [0, 1).""" + + def test_recovers_injected_transit(self): + pytest.importorskip('cupy', reason='standard TLS needs the optional CUDA TLS extra') + from cuvarbase import tls + P, q, depth = 4.56, 0.025, 0.008 + t, y, dy = make_transit_lc(P, q, depth, ndata=3000, seed=11, + t0_frac=0.3) + res = tls.tls_transit(t, y, dy, R_star=1.0, M_star=1.0, + period_min=1.0, period_max=12.0) + grid = np.sort(np.asarray(res['periods'], dtype=np.float64)) + i = int(np.argmin(np.abs(grid - P))) + step = grid[min(i + 1, len(grid) - 1)] - grid[max(i - 1, 0)] + assert abs(res['period'] - P) <= 2 * step + 1e-9, (res['period'], step) + assert res['SDE'] > 5.0 + tmin = t.min() + assert tmin <= res['T0'] < tmin + res['period'] + assert 0.0 <= res['t0_phase'] < 1.0 + # T0 lands on the injected mid-transit (modulo whole periods) + t0_true = 0.3 * P + nearest = np.min(np.abs(res['T0'] - (t0_true + P * np.arange(-2, 20)))) + assert nearest < 0.5 * q * P, nearest + assert abs(res['depth'] - depth) / depth < 0.5 diff --git a/cuvarbase/tests/test_tls_golden.py b/cuvarbase/tests/test_tls_golden.py new file mode 100644 index 00000000..47b66573 --- /dev/null +++ b/cuvarbase/tests/test_tls_golden.py @@ -0,0 +1,237 @@ +""" +Golden regressions for the preserved method='binned' TLS implementation. + +The reference is the original CPU `transitleastsquares` package +(Hippke & Heller 2019). These tests need a GPU (the conftest stub +converts them to skips on CPU-only machines) and, for the comparison +tests, the optional `transitleastsquares` package — install both +`batman-package` and `transitleastsquares` on the validation pod. + +The narrow-transit recovery test reproduces the audit scenario that +the pre-rework fixed 30-epoch t0 grid failed (8/8 injected epochs +missed): it requires no reference package and documents that the +duration-scaled grid actually finds what the old grid could not. + +For this explicit older engine, the default duration window is Keplerian +(defect 2), the SDE uses the reference's ``SR = chi2_min / chi2`` +(ids 81/146) and its edge-extended running median (id 83). The +recovery-level expectations below were re-checked on an A40 after +those changes (period/depth unchanged; SDE values move -- the +thresholds are on the reference's own scale now). +""" +import numpy as np +import pytest + + +def make_transit_lc(period, q, depth=0.01, ndata=1500, baseline=60.0, + sigma=0.002, phase0=0.25, seed=0): + """Synthetic light curve with an injected box transit. + + The box shape is deliberately simple: both implementations fit + limb-darkened templates, and recovery-level assertions (period, + depth, SDE) are insensitive to the exact ingress shape. + """ + rand = np.random.RandomState(seed) + t = np.sort(baseline * rand.rand(ndata)) + phase = (t / period) % 1.0 + in_transit = np.abs(((phase - phase0 + 0.5) % 1.0) - 0.5) < q / 2 + y = np.ones(ndata) - depth * in_transit + y += sigma * rand.randn(ndata) + dy = sigma * np.ones(ndata) + return t, y, dy + + +class TestNarrowTransitRecovery: + """The audit scenario: a transit with duration < 1/30 of the + period, which the old fixed 30-epoch grid missed entirely.""" + + def test_long_period_narrow_transit(self): + from cuvarbase.tls import tls_search_gpu + period, q, depth = 15.0, 0.012, 0.012 + t, y, dy = make_transit_lc(period, q, depth=depth) + periods = np.linspace(14.0, 16.0, 400).astype(np.float32) + + results = tls_search_gpu(t, y, dy, periods=periods, method='binned') + + assert abs(results['period'] - period) / period < 0.01 + # SDE > 5 is a clear detection; the absolute value depends on + # the trial-period range (measured 5.75 here on an A5000) + assert results['SDE'] > 5 + assert results['depth'] == pytest.approx(depth, rel=0.5) + + def test_short_period_regression(self): + # Wide-transit case the old grid handled; must keep working. + from cuvarbase.tls import tls_search_gpu + period, q, depth = 3.0, 0.04, 0.01 + t, y, dy = make_transit_lc(period, q, depth=depth, baseline=30.0) + periods = np.linspace(2.8, 3.2, 400).astype(np.float32) + + results = tls_search_gpu(t, y, dy, periods=periods, method='binned') + + assert abs(results['period'] - period) / period < 0.01 + # 1.0: measured 6.90 on an A40 with the default Keplerian + # duration window ([0.018, 0.073] at 3 d; 8.55 with the retired + # fixed window, 6.55 / 8.00 under the pre-1.0 SR definition on + # the same spectra). The null on this 400-period grid is + # 4.2 +/- 0.9, with a tail to ~6.7 in 60 draws, so > 6 is a + # deterministic regression guard on this seeded light curve, + # NOT a detection threshold for this grid (a bootstrap of the + # golden light curve itself gives FAP 0.010, null max 6.34). + # The old threshold of 7 was set on the wider-window spectrum. + assert results['SDE'] > 6 + + +class TestGoldenVsTransitLeastSquares: + """Direct comparison against the reference CPU implementation on + identical data, over the same period range.""" + + @pytest.mark.parametrize("period,q,depth", [ + (3.0, 0.04, 0.01), # short period, wide transit + (12.0, 0.014, 0.012), # long period, narrow transit + ]) + def test_recovery_matches_reference(self, period, q, depth): + ref = pytest.importorskip('transitleastsquares') + from cuvarbase.tls import tls_search_gpu + + t, y, dy = make_transit_lc(period, q, depth=depth) + + # cuvarbase (GPU) + periods = np.linspace(0.9 * period, 1.1 * period, + 500).astype(np.float32) + res_gpu = tls_search_gpu(t, y, dy, periods=periods, method='binned') + + # reference (CPU); same period range to bound runtime + model = ref.transitleastsquares(t, y, dy) + res_cpu = model.power(period_min=0.9 * period, + period_max=1.1 * period, + show_progress_bar=False, + use_threads=2) + + # Both must find the injected signal... + assert abs(res_gpu['period'] - period) / period < 0.01 + assert abs(res_cpu.period - period) / period < 0.01 + # ...agree with each other on the period... + assert (abs(res_gpu['period'] - res_cpu.period) + / res_cpu.period < 0.01) + # ...and roughly on the depth (reference reports flux level) + ref_depth = 1.0 - res_cpu.depth + assert res_gpu['depth'] == pytest.approx(ref_depth, rel=0.5) + # both detections must be significant (SDE > 5; the reference + # itself measured 6.3 on the narrow-transit configuration) + assert res_gpu['SDE'] > 5 + assert res_cpu.SDE > 5 + + +def _batman_lc(period, rp, t0, baseline, cadence_min, sigma, seed, + R_star=1.0, M_star=1.0): + """Limb-darkened batman transit on a regular cadence (the audit's + make_lc); returns t, y, dy, true depth, T14 (days).""" + batman = pytest.importorskip('batman') + G, R_sun, M_sun = 6.67430e-11, 6.957e8, 1.9884e30 + rng = np.random.RandomState(seed) + n = int(baseline * 1440 / cadence_min) + t = np.arange(n) * cadence_min / 1440.0 + a = (G * M_star * M_sun * (period * 86400.0) ** 2 + / (4 * np.pi ** 2)) ** (1 / 3) / (R_star * R_sun) + p = batman.TransitParams() + p.t0, p.per, p.rp, p.a, p.inc = t0, period, rp, a, 90.0 + p.ecc, p.w, p.u, p.limb_dark = 0.0, 90.0, [0.4804, 0.1867], 'quadratic' + f = batman.TransitModel(p, t).light_curve(p) + y = f + sigma * rng.randn(n) + t14 = period / np.pi * np.arcsin(min(1.0, (1 + rp) / a)) + return t, y, sigma * np.ones(n), 1.0 - f.min(), t14 + + +class TestLongPeriodDurationWindow: + """Defect 2 (tls-duration-window, audit id 9): with the pre-1.0 + constant q window [0.005, 0.15] a P = 365 d transit on a 1400-d + baseline (30-min cadence, sigma 3e-4, rp = 0.04) came back at + 182.5 d with half the depth (q_true = 0.00154 is 3.2x below the old + qmin). The default window is now Keplerian, so the default call + recovers it; the old window is an opt-in that warns and still + shows the alias.""" + + def test_p365_on_1400d_baseline(self): + from cuvarbase.tls import tls_search_gpu + from cuvarbase import tls_grids + P = 365.0 + t, y, dy, depth_true, t14 = _batman_lc( + P, 0.04, 0.41 * P, baseline=1400.0, cadence_min=30.0, + sigma=3e-4, seed=11) + assert tls_grids.q_transit(P) < tls_grids.FIXED_QMIN / 3 + # reduced Ofir grid around the truth (~9000 periods, ~1.5 s); + # the failure is in the duration window, not the grid + periods = tls_grids.period_grid_ofir(t, period_min=0.5 * P, + period_max=1.5 * P) + + r = tls_search_gpu(t, y, dy, periods=periods, method='binned') # default window + assert abs(r['period'] - P) / P < 0.01, r['period'] + assert r['depth'] == pytest.approx(depth_true, rel=0.10) + assert r['duration'] == pytest.approx(t14, rel=0.25) + assert t.min() <= r['T0'] < t.min() + r['period'] + assert abs(r['T0'] - 0.41 * P) < 0.25 * t14 + + # the retired window reproduces the audit's failure and warns + import warnings + with warnings.catch_warnings(record=True) as rec: + warnings.simplefilter("always") + rf = tls_search_gpu(t, y, dy, periods=periods, + duration_window='fixed', method='binned') + assert any("excludes the Keplerian" in str(w.message) for w in rec) + assert abs(rf['period'] - 0.5 * P) / (0.5 * P) < 0.01, rf['period'] + assert rf['depth'] < 0.6 * depth_true + + def test_true_default_grid_recovers_p365(self): + # the actual default call: no period grid at all (Ofir grid to + # span/2, ~180k periods; ~2-3 s on an A40) + from cuvarbase.tls import tls_search_gpu + P = 365.0 + t, y, dy, depth_true, t14 = _batman_lc( + P, 0.04, 0.41 * P, baseline=1400.0, cadence_min=30.0, + sigma=3e-4, seed=11) + r = tls_search_gpu(t, y, dy, method='binned') + assert len(r['periods']) > 100000 + assert abs(r['period'] - P) / P < 0.01, r['period'] + assert r['depth'] == pytest.approx(depth_true, rel=0.10) + + +class TestSDEParityWithReference: + """ids 81/146: cuvarbase's SDE now uses the reference definition + (SR = chi2_min / chi2, edge-extended running median), so on the + reference's own period grid the two packages report the same SDE + for the same detection to within the coarse-vs-fine t0 grid + difference. The pre-1.0 SR (1 - chi2 / max chi2) gave about half + the SDE for strong signals.""" + + def test_strong_signal_sde_matches_reference(self): + ref = pytest.importorskip('transitleastsquares') + from cuvarbase.tls import tls_search_gpu + + period, q, depth = 3.0, 0.04, 0.02 + t, y, dy = make_transit_lc(period, q, depth=depth, sigma=0.002, + seed=3) + model = ref.transitleastsquares(t, y, dy) + res_cpu = model.power(period_min=0.8 * period, + period_max=1.25 * period, + oversampling_factor=3, + show_progress_bar=False, use_threads=2) + periods = np.sort(np.asarray(res_cpu.periods, dtype=np.float64)) + # both packages detrend with the 91-point kernel (reference: + # oversampling 3 x 30 + 1; cuvarbase's automatic kernel is + # length-scaled below 910 periods, so pin it) + res_gpu = tls_search_gpu(t, y, dy, periods=periods, + sde_kernel_size=91, method='binned') + + assert abs(res_gpu['period'] - period) / period < 0.01 + assert abs(res_cpu.period - period) / period < 0.01 + assert res_gpu['SDE'] == pytest.approx(res_cpu.SDE, rel=0.15), ( + res_gpu['SDE'], res_cpu.SDE) + # the old definition on the same cuvarbase spectrum + chi2 = res_gpu['chi2'][np.isfinite(res_gpu['chi2'])] + sr_old = 1.0 - chi2 / chi2.max() + sde_old_raw = (sr_old.max() - sr_old.mean()) / sr_old.std() + assert sde_old_raw < 0.75 * res_cpu.SDE, (sde_old_raw, res_cpu.SDE) + # SNR is the delta-chi2 significance, not the reference's snr + chi2_0 = np.sum((1.0 - y) ** 2 / (dy ** 2 + 1e-10)) + assert res_gpu['SNR'] == pytest.approx( + np.sqrt(chi2_0 - res_gpu['chi2_min']), rel=1e-5) diff --git a/cuvarbase/tests/test_tls_reference_frontend.py b/cuvarbase/tests/test_tls_reference_frontend.py new file mode 100644 index 00000000..f6d4e7e4 --- /dev/null +++ b/cuvarbase/tests/test_tls_reference_frontend.py @@ -0,0 +1,458 @@ +"""CPU API-contract tests using a mocked observation-level TLS engine. + +These tests cover routing, validation, units, ordering and null-search settings. +They do not evaluate a transit search or make physical-sensitivity claims. +""" + +import builtins +import sys +import types + +import numpy as np +import pytest + +import cuvarbase +from cuvarbase import base, tls, tls_reference_frontend as frontend + + +@pytest.fixture +def lightcurve(): + return (np.array([0., 1.5, 3., 5.]), + np.array([1., .999, 1.0002, .9997]), + np.array([.001, .002, .003, .002])) + + +@pytest.fixture +def mock_engine(monkeypatch, request): + execution = getattr(request, 'param', 'baseline') + name = 'tls_reference_experimental' if execution == 'experimental' else 'tls_reference' + engine = types.ModuleType('cuvarbase.' + name) + engine.execution = execution + engine.calls, engine.final_calls, engine.fit_calls = [], [], [] + engine.context_calls = 0 + engine.null = False + engine.invalid_final = False + engine.invalid_raw = False + engine.mask_first = False + + def context(): + engine.context_calls += 1 + + def stage(count, scale, first_time): + return dict(chi2=(3 + np.arange(count, dtype=float))*scale**2, + start=np.zeros(count, dtype=np.int64), + width_index=np.zeros(count, dtype=np.int64), + width=np.ones(count, dtype=np.int64), + depth=np.full(count, .001), start_time=np.full(count, first_time)) + + def run(full, t, y, dy, periods, **options): + engine.calls.append(dict(full=full, t=t.copy(), y=y.copy(), dy=dy.copy(), + periods=periods.copy(), options=options)) + prepared = frontend.reference.preprocess_inputs(t, y, dy) + engine.last_prepared = prepared + scale = prepared['error_scale'] + count = len(periods) + primary = min(1, count-1) + raw = stage(count, scale, t.min() + .1) + raw['chi2'][primary] = 2*scale**2 + raw['group_size'] = 1 + if engine.invalid_raw: + raw['width_index'][0] = -1 + raw['depth'][0] = 0 + power = np.arange(count, dtype=float) - 1 + power[primary] = 5 + spectra = dict(chi2=np.ma.array(raw['chi2']), + power=np.ma.array(power), SR=np.ma.array(np.full(count, .8)), + primary_index=None if engine.null else primary, SDE=5., SDE_raw=6.) + if engine.mask_first: + spectra['chi2'][0] = np.ma.masked + spectra['power'][0] = np.ma.masked + spectra['SR'][0] = np.ma.masked + cache = dict(overview=np.array([(.02, 1, 1.2), (.05, 2, 1.3)], + dtype=frontend.reference.OVERVIEW_DTYPE), + unique_indices=np.array([0, 1]), widths=np.array([1, 2]), + omitted_rows=[]) + result = dict(prepared=prepared, cache=cache, spectra=spectra, raw=raw, + primary_index=primary, period=None if engine.null else periods[primary], + duration_selection=None) + if full and not engine.null: + selected = stage(1, scale, t.min() + .1) + final = stage(1, scale, t.min() + .1) + final['chi2'][0] = scale**2 + if engine.invalid_final: + final['width_index'][0] = -1 + result.update(candidates=np.array([primary]), refined=selected, + harmonics=np.array([primary]), harmonic_results=selected, final=final) + return result + + def raw_search(periods, t, y, dy, cache, **options): + engine.final_calls.append(dict(periods=periods.copy(), options=options)) + result = stage(1, engine.last_prepared['error_scale'], t.min() + .1) + result['chi2'][0] = engine.last_prepared['error_scale']**2 + return result + + def final_parameters(t, y, dy, period, cache, width_index, epoch_index, **options): + engine.fit_calls.append(dict(t=t.copy(), period=period, options=options)) + scale = options['error_scale'] + T0 = float(t.min() + .4) + return dict(period=float(period), T0=T0, t0_phase=.123, duration=.05, depth=.001, + chi2_min=float(options['fit_chi2']), chi2_null=20*scale**2, + chi2_cpu_model=1.01*scale**2, delta_chi2=19., SNR=np.sqrt(19.), + n_transits=2, transit_times=np.array([T0, T0+period])) + + engine.search_full = lambda *args, **kwargs: run(True, *args, **kwargs) + engine.search_fast = lambda *args, **kwargs: run(False, *args, **kwargs) + engine.raw_search = raw_search + engine._select_durations = lambda selection, indices: None + monkeypatch.setitem(sys.modules, 'cuvarbase.' + name, engine) + monkeypatch.setattr(cuvarbase, name, engine, raising=False) + monkeypatch.setattr(base, 'ensure_context', context) + monkeypatch.setattr(frontend.reference, 'final_parameters', final_parameters) + return engine + + +def test_public_default_routes_to_broad_full_observation_search(lightcurve, mock_engine): + result = tls.tls_search_gpu(*lightcurve, periods=[1., 2., 3.]) + call = mock_engine.calls[0] + assert call['full'] is True + assert call['options']['qmin'] is None + assert call['options']['qmax'] is None + assert call['options']['n_durations'] is None + assert call['options']['refine_top_k'] is None + assert result['search_configuration']['method'] == 'reference' + assert result['search_configuration']['phase_binning'] is False + assert result['search_configuration']['duration_policy'] == 'reference' + + +def test_fractional_sde_window_is_rejected_before_gpu_work(lightcurve, mock_engine): + with pytest.raises(ValueError, match='sde_kernel_size'): + frontend.search(*lightcurve, periods=[1., 2., 3.], oversampling_factor=3.01) + assert mock_engine.context_calls == 0 + assert mock_engine.calls == [] + result = frontend.search(*lightcurve, periods=[1., 2., 3.], + oversampling_factor=3.01, sde_kernel_size=91) + assert result['period'] == 2. + assert mock_engine.calls[0]['options']['sde_kernel_size'] == 91 + + +def test_automatic_late_m_dwarf_grid_uses_reference_stellar_range(lightcurve, mock_engine): + t, y, dy = lightcurve + expected = np.sort(frontend.reference.period_grid(np.ptp(t), R_star=.1, M_star=.1)) + result = frontend.search(t, y, dy, R_star=.1, M_star=.1, return_arrays=False) + np.testing.assert_array_equal(mock_engine.calls[0]['periods'], expected) + assert result['R_star'] == result['M_star'] == .1 + assert mock_engine.calls[0]['full'] is True + assert mock_engine.calls[0]['options']['qmin'] is None + + +@pytest.mark.parametrize('origin', [-5.25, 0., 2457000.25]) +def test_time_origin_keeps_every_sample_and_restores_absolute_epoch(origin, lightcurve, mock_engine): + offset, y, dy = lightcurve + t = offset + origin + result = frontend.search(t, y, dy, periods=[1., 2., 3.]) + shifted = mock_engine.calls[0]['t'] + expected_origin = np.floor(t.min()) - 1 + np.testing.assert_array_equal(shifted, t-expected_origin) + assert np.min(shifted) > 0 + assert result['search_configuration']['samples_used'] == len(t) + assert result['T0'] == pytest.approx(t.min()+.4, abs=1e-9) + np.testing.assert_allclose(result['transit_times'], [t.min()+.4, t.min()+2.4], atol=1e-9) + expected_phase = ((result['T0']-np.floor(t.min()))/result['period']) % 1 + assert result['t0_phase'] == pytest.approx(expected_phase) + + +def test_result_chi_squared_uses_original_uncertainties(lightcurve, mock_engine): + result = frontend.search(*lightcurve, periods=[1., 2., 3.]) + assert result['chi2_min'] == pytest.approx(1.) + assert result['chi2_null'] == pytest.approx(20.) + assert result['chi2_cpu_model'] == pytest.approx(1.01) + assert result['SNR'] == pytest.approx(np.sqrt(19.)) + np.testing.assert_allclose(result['chi2'], [3., 2., 5.]) + assert mock_engine.fit_calls[0]['options']['error_scale'] == pytest.approx(.002) + + +def test_sorted_explicit_q_and_float64_periods_return_in_caller_order(lightcurve, mock_engine): + periods = np.array([3., 1.0000000001234, 2.]) + qmin, qmax = np.array([.03, .01, .02]), np.array([.06, .04, .05]) + result = frontend.search(*lightcurve, periods=periods, qmin=qmin, qmax=qmax, n_durations=7) + call = mock_engine.calls[0] + np.testing.assert_array_equal(call['periods'], periods[[1, 2, 0]]) + assert call['periods'].dtype == np.float64 + np.testing.assert_array_equal(call['options']['qmin'], qmin[[1, 2, 0]]) + np.testing.assert_array_equal(call['options']['qmax'], qmax[[1, 2, 0]]) + np.testing.assert_array_equal(result['periods'], periods) + np.testing.assert_allclose(result['chi2'], [5., 3., 2.]) + assert result['search_configuration']['duration_policy'] == 'explicit' + + +def test_fast_is_explicit_and_still_gets_the_native_final_noskip_fit(lightcurve, mock_engine): + result = frontend.search(*lightcurve, periods=[1., 2., 3.], full=False, t0_oversample=4) + assert mock_engine.calls[0]['full'] is False + assert mock_engine.calls[0]['options']['T0_fit_margin'] == .125 + assert len(mock_engine.final_calls) == 1 + assert mock_engine.final_calls[0]['options']['full'] is True + np.testing.assert_array_equal(mock_engine.final_calls[0]['periods'], [2.]) + assert result['search_configuration']['full'] is False + + +@pytest.mark.parametrize('change, kwargs', [ + ('dy_none', {}), ('nonfinite_time', {}), ('nonpositive_flux', {}), + ('none', {'periods': [1., np.nan]}), ('none', {'R_star': 0.}), + ('none', {'R_star': np.nan}), ('none', {'R_star': np.inf}), + ('none', {'M_star': -1.}), ('none', {'M_star': np.nan}), + ('none', {'qmin': .01}), ('none', {'qmin': [0.01, .02], 'qmax': .1}), + ('none', {'nbins': 8192}), ('none', {'sde_kernel_size': 0}), + ('none', {'n_durations': 1}), ('none', {'work_chunk': 0}), + ('none', {'transit_template': 'misspelled'}), +]) +def test_invalid_request_fails_before_engine_import_or_context(change, kwargs, lightcurve, mock_engine, monkeypatch): + t, y, dy = (array.copy() for array in lightcurve) + if change == 'dy_none': + dy = None + elif change == 'nonfinite_time': + t[1] = np.nan + elif change == 'nonpositive_flux': + y[1] = 0 + options = dict(periods=[1., 2., 3.]) + options.update(kwargs) + original_import = builtins.__import__ + + def guard(name, globals=None, locals=None, fromlist=(), level=0): + if 'tls_reference' in fromlist: + pytest.fail('Invalid input reached the GPU engine import') + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, '__import__', guard) + with pytest.raises(ValueError): + frontend.search(t, y, dy, **options) + assert mock_engine.context_calls == 0 + assert not mock_engine.calls + + +def test_degenerate_spectrum_returns_existing_null_contract(lightcurve, mock_engine): + mock_engine.null = True + with pytest.warns(UserWarning, match='null result'): + result = frontend.search(*lightcurve, periods=[3., 1., 2.]) + assert np.isnan(result['period']) + assert np.isnan(result['T0']) + assert np.isnan(result['duration']) + assert result['SDE'] == result['SNR'] == 0 + assert result['depth'] == 0 + assert not np.any(result['valid_periods']) + np.testing.assert_array_equal(result['periods'], [3., 1., 2.]) + assert not mock_engine.fit_calls + + +def test_finite_sentinel_without_a_fitted_window_is_a_null_result(lightcurve, mock_engine): + mock_engine.invalid_final = True + with pytest.warns(UserWarning, match='no fitted transit'): + result = frontend.search(*lightcurve, periods=[1., 2., 3.]) + assert np.isnan(result['period']) + assert result['SDE'] == result['SNR'] == 0 + assert not mock_engine.fit_calls + + +def test_native_masking_is_not_reported_as_a_failed_trial(lightcurve, mock_engine): + mock_engine.mask_first = True + result = frontend.search(*lightcurve, periods=[1., 2., 3.]) + assert result['n_failed_periods'] == 0 + assert result['n_masked_periods'] == 1 + assert not result['valid_periods'][0] + + +def test_finite_unfitted_trial_retains_score_but_has_no_physical_parameters(lightcurve, mock_engine): + mock_engine.invalid_raw = True + result = frontend.search(*lightcurve, periods=[1., 2., 3.]) + assert result['valid_periods'][0] + assert not result['parameter_valid_periods'][0] + assert np.isfinite(result['chi2'][0]) + assert np.isnan(result['best_duration_per_period'][0]) + assert np.isnan(result['best_t0_per_period'][0]) + + +def test_sparse_parameter_estimation_error_preserves_spectrum(lightcurve, mock_engine, monkeypatch): + def unavailable(*args, **kwargs): + raise ValueError('Native final duration estimate is nonpositive') + + monkeypatch.setattr(frontend.reference, 'final_parameters', unavailable) + with pytest.warns(UserWarning, match='parameters are unavailable'): + result = frontend.search(*lightcurve, periods=[1., 2., 3.]) + assert result['period'] == 2. + assert result['SDE'] == 5. + assert np.isnan(result['T0']) + assert np.isnan(result['duration']) + assert result['chi2_min'] == pytest.approx(1.) + assert 'nonpositive' in result['parameter_error'] + + +def test_batch_null_searches_keep_full_refinement_and_all_search_settings(lightcurve, mock_engine): + t, y, dy = lightcurve + settings = dict(periods=[3., 1., 2.], qmin=.01, qmax=.09, n_durations=7, + sde_kernel_size=31, transit_depth_min=2e-5, work_chunk=19) + results = frontend.search_batch([(t, y, dy)], fap_null_draws=3, fap_seed=123, **settings) + assert len(mock_engine.calls) == 4 + for call in mock_engine.calls: + assert call['full'] is True + assert call['options']['n_durations'] == 7 + assert call['options']['sde_kernel_size'] == 31 + assert call['options']['transit_depth_min'] == 2e-5 + assert call['options']['work_chunk'] == 19 + np.testing.assert_array_equal(call['options']['qmin'], [.01]*3) + np.testing.assert_array_equal(call['options']['qmax'], [.09]*3) + assert sorted(zip(call['y'], call['dy'])) == sorted(zip(y, dy)) + np.testing.assert_array_equal(results[0]['SDE_null'], [5., 5., 5.]) + assert results[0]['FAP'] == 1. + assert 'periods' not in results[0] + + +def test_batch_uses_one_longest_baseline_grid(lightcurve, mock_engine, monkeypatch): + t, y, dy = lightcurve + grid_calls = [] + + def grid(span, **kwargs): + grid_calls.append((span, kwargs)) + return np.array([3., 2., 1.]) + + monkeypatch.setattr(frontend.reference, 'period_grid', grid) + frontend.search_batch([(t, y, dy), (t*2, y, dy)]) + assert len(grid_calls) == 1 + assert grid_calls[0][0] == np.ptp(t*2) + for call in mock_engine.calls: + np.testing.assert_array_equal(call['periods'], [1., 2., 3.]) + + +def test_invalid_later_batch_input_is_rejected_before_any_gpu_work(lightcurve, mock_engine): + t, y, dy = lightcurve + with pytest.raises(ValueError): + frontend.search_batch([lightcurve, (t, y, dy[:-1])], periods=[1., 2., 3.]) + assert not mock_engine.calls + assert mock_engine.context_calls == 0 + + +@pytest.mark.parametrize('mock_engine', ['baseline', 'experimental'], indirect=True) +@pytest.mark.parametrize('entry', [tls.tls_search_gpu, tls.tls_search, tls.tls_transit, frontend.search]) +@pytest.mark.parametrize('full', [True, False]) +def test_execution_selector_routes_scalar_and_final_fit(entry, full, lightcurve, + mock_engine, monkeypatch): + chosen = mock_engine.execution + other = 'tls_reference' if chosen == 'experimental' else 'tls_reference_experimental' + original_import = builtins.__import__ + + def guard(name, globals=None, locals=None, fromlist=(), level=0): + if other in fromlist: + pytest.fail('Selected execution imported the other GPU backend') + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, '__import__', guard) + result = entry(*lightcurve, periods=[3., 1., 2.], execution=chosen, full=full, + qmin=[.03, .01, .02], qmax=[.09, .07, .08], n_durations=7) + assert len(mock_engine.calls) == 1 + call = mock_engine.calls[0] + assert call['full'] is full + np.testing.assert_array_equal(call['periods'], [1., 2., 3.]) + np.testing.assert_array_equal(call['options']['qmin'], [.01, .02, .03]) + np.testing.assert_array_equal(call['options']['qmax'], [.07, .08, .09]) + assert call['options']['n_durations'] == 7 + assert len(mock_engine.final_calls) == (0 if full else 1) + if not full: + assert mock_engine.final_calls[0]['options']['full'] is True + assert len(mock_engine.fit_calls) == 1 + assert result['search_configuration']['execution'] == chosen + assert result['search_configuration']['experimental_execution'] == (chosen == 'experimental') + assert result['search_configuration']['full'] is full + np.testing.assert_array_equal(result['periods'], [3., 1., 2.]) + + +@pytest.mark.parametrize('mock_engine', ['baseline', 'experimental'], indirect=True) +@pytest.mark.parametrize('failure', ['null', 'invalid_final']) +@pytest.mark.parametrize('return_arrays', [False, True]) +def test_execution_metadata_survives_both_null_results(mock_engine, lightcurve, + failure, return_arrays): + setattr(mock_engine, failure, True) + with pytest.warns(UserWarning): + result = tls.tls_search_gpu(*lightcurve, periods=[3., 1., 2.], + execution=mock_engine.execution, return_arrays=return_arrays) + assert result['SDE'] == result['SNR'] == 0 + assert result['search_configuration']['execution'] == mock_engine.execution + assert result['search_configuration']['experimental_execution'] == (mock_engine.execution == 'experimental') + assert ('periods' in result) is return_arrays + + +@pytest.mark.parametrize('mock_engine', ['baseline', 'experimental'], indirect=True) +@pytest.mark.parametrize('entry', [tls.tls_search_batch, frontend.search_batch]) +@pytest.mark.parametrize('full', [True, False]) +def test_execution_choice_covers_every_observed_and_fap_call(mock_engine, lightcurve, + entry, full, monkeypatch): + seen = [] + search = frontend.search + + def record(*args, **kwargs): + seen.append(kwargs.copy()) + return search(*args, **kwargs) + + monkeypatch.setattr(frontend, 'search', record) + results = entry([lightcurve, lightcurve], periods=[3., 1., 2.], + execution=mock_engine.execution, full=full, fap_null_draws=2, + fap_seed=20260912, qmin=.01, qmax=.09, n_durations=7, + work_chunk=19, sde_kernel_size=31) + assert len(mock_engine.calls) == len(seen) == 6 + for kwargs, call in zip(seen, mock_engine.calls): + assert kwargs['execution'] == mock_engine.execution + assert kwargs['return_arrays'] is False + assert call['full'] is full + assert call['options']['work_chunk'] == 19 + assert call['options']['sde_kernel_size'] == 31 + assert call['options']['n_durations'] == 7 + np.testing.assert_array_equal(call['options']['qmin'], [.01]*3) + np.testing.assert_array_equal(call['options']['qmax'], [.09]*3) + assert sorted(zip(call['y'], call['dy'])) == sorted(zip(lightcurve[1], lightcurve[2])) + for result in results: + assert result['search_configuration']['execution'] == mock_engine.execution + np.testing.assert_array_equal(result['SDE_null'], [5., 5.]) + assert result['FAP'] == 1. + assert 'periods' not in result + + +@pytest.mark.parametrize('execution', ['unknown', '', None, 1, True]) +@pytest.mark.parametrize('entry', [tls.tls_search_gpu, tls.tls_search, tls.tls_transit, + frontend.search, tls.tls_search_batch, frontend.search_batch]) +def test_invalid_execution_fails_before_import_or_context(execution, entry, lightcurve, + mock_engine, monkeypatch): + original_import = builtins.__import__ + + def guard(name, globals=None, locals=None, fromlist=(), level=0): + if {'tls_reference', 'tls_reference_experimental'}.intersection(fromlist): + pytest.fail('Invalid selector reached an engine import') + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, '__import__', guard) + args = ([],) if entry in (tls.tls_search_batch, frontend.search_batch) else lightcurve + with pytest.raises(ValueError, match='execution must be'): + entry(*args, execution=execution) + assert not mock_engine.calls + assert mock_engine.context_calls == 0 + + +@pytest.mark.parametrize('method', ['binned', 'legacy', 'misspelled']) +@pytest.mark.parametrize('entry', [tls.tls_search_gpu, tls.tls_search_batch]) +def test_experimental_execution_is_rejected_on_other_methods(method, entry, lightcurve, mock_engine): + args = ([],) if entry is tls.tls_search_batch else lightcurve + with pytest.raises(ValueError, match="requires method='reference'"): + entry(*args, method=method, execution='experimental') + assert not mock_engine.calls + assert mock_engine.context_calls == 0 + + +@pytest.mark.parametrize('use_fast', [True, False]) +def test_deprecated_engine_alias_cannot_ignore_experimental_execution(use_fast, lightcurve, mock_engine): + with pytest.warns(FutureWarning), pytest.raises(ValueError, match="requires method='reference'"): + tls.tls_search_gpu(*lightcurve, use_fast=use_fast, execution='experimental') + assert not mock_engine.calls + assert mock_engine.context_calls == 0 + + +def test_default_success_metadata_is_baseline(lightcurve, mock_engine): + result = tls.tls_search_gpu(*lightcurve, periods=[1., 2., 3.], return_arrays=False) + assert result['search_configuration']['execution'] == 'baseline' + assert result['search_configuration']['experimental_execution'] is False + assert 'periods' not in result diff --git a/cuvarbase/tests/test_tls_reference_math.py b/cuvarbase/tests/test_tls_reference_math.py new file mode 100644 index 00000000..f884799a --- /dev/null +++ b/cuvarbase/tests/test_tls_reference_math.py @@ -0,0 +1,437 @@ +"""CPU regressions for the independently verified, unbinned TLS host math. + +The compact goldens come from GTLS at the recorded source commit. They check +scientific search and score conventions without a CUDA device or importing the +GTLS package. They are numerical regression fixtures, not a recovery study. +""" + +import hashlib +import warnings + +import numpy as np +import pytest + +from cuvarbase import tls_reference_math as ref +from cuvarbase import tls_reference_experimental_math as experimental_ref +from cuvarbase.tests._tls_reference_goldens import GOLDEN, PROVENANCE + + +def test_reference_pin_is_the_independently_evaluated_source(): + assert ref.GTLS_COMMIT == PROVENANCE['gtls_commit'] + + +def test_cleaning_preserves_alignment_and_original_error_units(): + t = np.array([-1., 0., 1., 2., 3., 4., 5., 6., 7.]) + y = np.array([1., 1., 1., 1.001, 0., .999, 1., np.inf, 1.002]) + dy = np.array([1., 1., 0., .001, .002, .003, .002, .003, .004]) + result = ref.preprocess_inputs(t, y, dy) + np.testing.assert_array_equal(result['kept_indices'], [3, 5, 6, 8]) + np.testing.assert_array_equal(result['t'], t[[3, 5, 6, 8]]) + np.testing.assert_array_equal(result['y'], y[[3, 5, 6, 8]]) + np.testing.assert_allclose(result['dy'], [.4, 1.2, .8, 1.6], rtol=1e-15) + assert result['error_scale'] == pytest.approx(.0025) + assert result['input_count'] == len(t) + # Native missing-error behavior is distinct from its supplied-error branch. + missing = ref.preprocess_inputs([1, 2, 3], [.999, 1, 1.001]) + np.testing.assert_array_equal(missing['dy'], np.full(3, np.std([.999, 1, 1.001]))) + assert missing['error_scale'] == 1. + + +@pytest.mark.parametrize('case', GOLDEN['periods']) +def test_ofir_period_grid_matches_native_outputs(case): + span, radius, mass, low, high = case['args'] + periods = ref.period_grid(span, radius, mass, low, np.inf if high is None else high) + assert len(periods) == case['count'] + np.testing.assert_allclose(periods[case['index']], case['values'], rtol=2e-14) + assert np.all(np.diff(periods) < 0) + assert np.all(periods > low) + if high is not None: + assert np.all(periods <= high) + + +def test_narrow_period_request_is_preserved_and_native_reset_is_opt_in(): + narrow = ref.period_grid(25.75, period_min=5., period_max=5.01) + assert 0 < len(narrow) < 100 + assert np.all((narrow > 5.) & (narrow <= 5.01)) + with pytest.warns(UserWarning, match='resets short grids'): + compatible = ref.period_grid(25.75, period_min=5., period_max=5.01, native_fallback=True) + expected = GOLDEN['periods'][0] + assert len(compatible) == expected['count'] + np.testing.assert_allclose(compatible[expected['index']], expected['values'], rtol=2e-14) + with pytest.raises(ValueError, match='provide explicit periods'): + ref.period_grid(25.75, R_star=.005) + + +def test_duration_grid_matches_native_global_envelope(): + # The reference's host cache envelope differs from its CUDA period mask. + np.testing.assert_allclose(ref.duration_grid([.6, 12.8]), GOLDEN['durations'], rtol=2e-14) + assert GOLDEN['durations'][-1] == .12 + + +def test_automatic_duration_grid_rejects_unbounded_density_before_allocating(): + with pytest.raises(ValueError, match='coarser duration_grid_step'): + ref.duration_grid([.6, 12.8], duration_grid_step=1.000000000001) + # The bounded explicit override is an available sample-resolved alternative. + bounded = ref.augment_duration_grid([.6, 12.8], 1000, .001, .08, + duration_grid_step=1.000000000001) + assert len(bounded['fractional_durations']) <= 1001 + + +@pytest.mark.parametrize('template', ['default', 'grazing', 'box']) +def test_template_cache_matches_native_widths_shapes_and_depth_scale(template): + pytest.importorskip('batman') + cache = ref.build_cache([.6, 12.8], 1000, transit_template=template) + expected = GOLDEN['caches'][template] + np.testing.assert_array_equal(cache['widths'], expected['widths']) + np.testing.assert_array_equal(cache['unique_indices'], expected['unique_indices']) + np.testing.assert_array_equal(cache['signal_lengths'], expected['lengths']) + np.testing.assert_allclose(cache['overshoot'], expected['overshoot'], rtol=3e-6, atol=2e-7) + for row in expected['samples']: + np.testing.assert_allclose(cache['template_deficits'][row['row'], row['index']], + row['deficit'], rtol=3e-6, atol=2e-7) + assert not cache['omitted_rows'] + assert cache['template_deficits'].dtype == np.float32 + assert cache['template_deficits'].flags.c_contiguous + # GTLS pads the FLUX with zero. Deficits in the padded tail are therefore + # one; zero-padding the deficits would silently change the objective. + for row, length in enumerate(cache['signal_lengths']): + np.testing.assert_array_equal(cache['template_deficits'][row, length:], 1.) + + +def test_sparse_cache_omits_only_unrepresentable_rows_and_records_them(): + pytest.importorskip('batman') + cache = ref.build_cache([.6, 365.25], 800) + assert cache['omitted_rows'] + assert set(cache['overview_source_indices']).isdisjoint(row['index'] for row in cache['omitted_rows']) + assert len(cache['overview_source_indices']) + len(cache['omitted_rows']) == len(cache['duration_grid']) + np.testing.assert_array_equal(cache['overview']['duration'], cache['duration_grid'][cache['overview_source_indices']]) + assert np.all(cache['widths'] > 0) + assert np.all(cache['signal_lengths'] > 0) + assert np.all(np.isfinite(cache['template_deficits'])) + assert np.all(np.isfinite(cache['overshoot'])) + with pytest.raises(ValueError, match='zero-sample duration'): + ref.build_cache([.6, 365.25], 800, strict=True) + + +def test_explicit_duration_bounds_are_per_period_despite_a_shared_cache(): + qmin = np.array([.0011, .0501, .01011]) + qmax = np.array([.0039, .0909, .01019]) + grid = ref.augment_duration_grid([1., 2., 3.], 1000, qmin, qmax, n_durations=5) + np.testing.assert_array_equal(grid['width_minima'], [1, 50, 10]) + np.testing.assert_array_equal(grid['width_maxima'], [3, 90, 10]) + np.testing.assert_array_equal(grid['requested_qmin'], qmin) + np.testing.assert_array_equal(grid['requested_qmax'], qmax) + assert len(grid['fractional_durations']) <= 1000 + assert np.all(np.diff(grid['widths']) > 0) + for period_index in range(3): + allowed = ((grid['widths'] >= grid['width_minima'][period_index]) & + (grid['widths'] <= grid['width_maxima'][period_index])) + representative = np.maximum(grid['representative_durations'][allowed], qmin[period_index]) + assert np.all(representative >= qmin[period_index]) + assert np.all(representative <= qmax[period_index]) + mapped = ((representative/grid['maximum_fractional_duration'])*grid['reference_maxwidth']).astype(int) + np.testing.assert_array_equal(mapped, grid['widths'][allowed]) + # This period admits a single width even though the shared cache has + # numerous rows. A native logical-group union must not widen its bounds. + allowed = (grid['widths'] >= 10) & (grid['widths'] <= 10) + np.testing.assert_array_equal(grid['widths'][allowed], [10]) + + +def test_explicit_duration_grid_includes_requested_geometric_resolution(): + grid = ref.augment_duration_grid([1., 2.], 1000, [.0071, .0301], [.0319, .1609], n_durations=[3, 5]) + for low, high, count in zip([.0071, .0301], [.0319, .1609], [3, 5]): + requested = np.geomspace(low, high, count) + expected_widths = ((requested/grid['maximum_fractional_duration'])*grid['reference_maxwidth']).astype(int) + assert set(expected_widths).issubset(set(grid['widths'])) + assert grid['maximum_fractional_duration'] == .1609 + assert grid['metadata']['saturated_period_count'] == 0 + + +@pytest.mark.parametrize('ndata, upper', [(1000, .1609), (973, .1321), (801, .129837712472)]) +def test_augmented_grid_keeps_build_cache_normalization_and_actual_widths(ndata, upper): + pytest.importorskip('batman') + grid = ref.augment_duration_grid([.6, 12.8], ndata, [.0043, .0601], [.0329, upper], n_durations=31) + cache = ref.build_cache([.6, 12.8], ndata, fractional_durations=grid['fractional_durations']) + assert np.max(cache['duration_grid']) == grid['maximum_fractional_duration'] + assert cache['reference_maxwidth'] == grid['reference_maxwidth'] + omitted_widths = {row['width_in_samples'] for row in cache['omitted_rows']} + np.testing.assert_array_equal(cache['widths'], [w for w in grid['widths'] if w not in omitted_widths]) + for i in range(2): + allowed = ((cache['widths'] >= grid['width_minima'][i]) & + (cache['widths'] <= grid['width_maxima'][i])) + q = np.maximum(cache['overview']['duration'][cache['unique_indices']][allowed], grid['requested_qmin'][i]) + assert np.all(q >= grid['requested_qmin'][i]) + assert np.all(q <= grid['requested_qmax'][i]) + mapped = ((q/np.max(cache['duration_grid']))*cache['reference_maxwidth']).astype(int) + np.testing.assert_array_equal(mapped, cache['widths'][allowed]) + + +def test_duration_override_saturates_at_sample_resolution_without_large_grid(): + grid = ref.augment_duration_grid([1., 2.], 1000, [.0011, .0501], [.0309, .0809], n_durations=10**12) + assert grid['metadata']['saturated_period_count'] == 2 + assert len(grid['fractional_durations']) <= 1000 + for low, high in [(1, 30), (50, 80)]: + assert set(range(low, high+1)).issubset(set(grid['widths'])) + tiny = ref.augment_duration_grid([1.], 1000, 1e-300, .0809, duration_grid_step=1.000000000001) + assert tiny['metadata']['saturated_period_count'] == 1 + assert np.all(np.isfinite(tiny['fractional_durations'])) + + +@pytest.mark.parametrize('kwargs', [ + dict(qmin=0., qmax=.1), dict(qmin=.1, qmax=1.), + dict(qmin=[.01, .02], qmax=.1), dict(qmin=.1, qmax=.01), + dict(qmin=.01, qmax=.1, n_durations=2.5), +]) +def test_invalid_explicit_duration_requests_fail_clearly(kwargs): + with pytest.raises(ValueError): + ref.augment_duration_grid([1.], 1000, **kwargs) + + +def _spectrum_input(size, dtype): + i = np.arange(size) + raw = (1 + .3*np.sin(i*.025) + .04*np.cos(i*.8)).astype(dtype) + if size > 200: + raw[35] -= .10 + raw[100] -= .13 + raw[[5, 177]] = 1e6 + return raw + + +@pytest.mark.parametrize('case', list(GOLDEN['spectra'])) +def test_native_spectrum_masks_detrending_and_primary_rank(case): + size, dtype = case.split('/') + raw = _spectrum_input(int(size), dtype) + result = ref.native_spectra(raw) + expected = GOLDEN['spectra'][case] + np.testing.assert_array_equal(np.flatnonzero(np.ma.getmaskarray(result['chi2'])), expected['masked']) + tolerance = 2e-6 if dtype == 'float32' else 2e-13 + for key in ('SR', 'power_raw', 'power'): + target = np.array(expected[key], dtype=float) # None means masked/NaN. + actual = np.ma.filled(result[key], np.nan)[expected['index']] + np.testing.assert_allclose(actual, target, rtol=tolerance, atol=tolerance, equal_nan=True) + for key in ('SDE', 'SDE_raw'): + assert float(result[key]) == pytest.approx(expected[key], rel=tolerance) + assert result['primary_index'] == expected['primary'] + if int(size) > 200: + # A lower raw chi-squared is not necessarily the primary detection + # after the reference's running-median normalization. + assert expected['primary'] != expected['minimum_chi2'] + + +def test_spectrum_recalculation_preserves_its_supplied_mask(): + raw = _spectrum_input(217, 'float64') + first = ref.native_spectra(raw) + refined = first['chi2'].copy() + refined.data[5] = .1 + result = ref.native_spectra(refined, mask_outliers=False) + assert np.ma.getmaskarray(result['chi2'])[5] + assert result['primary_index'] != 5 + + +def test_explicit_spectrum_window_is_odd_and_changes_only_requested_statistic(): + raw = _spectrum_input(217, 'float64') + even = ref.native_spectra(raw, kernel_size=30) + odd = ref.native_spectra(raw, kernel_size=31) + np.testing.assert_array_equal(even['power'], odd['power']) + native = ref.native_spectra(raw) + np.testing.assert_array_equal(even['SR'], native['SR']) + np.testing.assert_array_equal(even['power_raw'], native['power_raw']) + assert not np.array_equal(even['power'], native['power']) + with pytest.raises(ValueError, match='positive integer'): + ref.native_spectra(raw, kernel_size=0) + + +def test_fractional_default_spectrum_window_requires_explicit_integer_window(): + raw = 1 + .1*np.sin(np.arange(217)) + with pytest.raises(ValueError, match='sde_kernel_size'): + ref.native_spectra(raw, oversampling_factor=3.01) + assert ref.spectrum_kernel_size(3.) == 91 + assert ref.spectrum_kernel_size(2.5) == 75 + assert ref.spectrum_kernel_size(3.01, 90) == 91 + explicit = ref.native_spectra(raw, oversampling_factor=3.01, kernel_size=90) + native = ref.native_spectra(raw) + np.testing.assert_array_equal(explicit['power'], native['power']) + + +@pytest.mark.parametrize('window_chunk', [1, 4, 8192]) +def test_running_median_chunking_preserves_full_masks_and_edge_padding(window_chunk): + values = ((np.arange(31)*7) % 17).astype(float) + mask = np.zeros(31, bool) + mask[6:13] = True + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + actual = ref._running_median(np.ma.array(values, mask=mask), 5, window_chunk) + np.testing.assert_allclose(np.ma.getdata(actual), np.array(GOLDEN['median']['values'], dtype=float), equal_nan=True) + np.testing.assert_array_equal(np.flatnonzero(np.ma.getmaskarray(actual)), GOLDEN['median']['masked']) + + +def test_epoch_stride_covers_thin_windows_and_full_mode_visits_every_start(): + widths = np.array([1, 7, 8, 9, 15, 16, 31, 64, 128]) + np.testing.assert_array_equal(ref.epoch_strides(widths), [1, 1, 1, 1, 1, 2, 3, 8, 16]) + np.testing.assert_array_equal(ref.epoch_strides(widths, T0_fit_margin=0), np.ones(len(widths))) + np.testing.assert_array_equal(ref.epoch_strides(widths, full=True), np.ones(len(widths))) + + +@pytest.mark.parametrize('math_backend', [ref, experimental_ref], ids=['baseline', 'experimental']) +def test_native_group_width_union_is_explicit(math_backend): + masks = math_backend.chunk_width_masks([2, 4, 8, 16], [1, 6, 12], [5, 9, 20], chunk_size=2) + np.testing.assert_array_equal(masks, [[True, True, True, False], [False, False, False, True]]) + + +@pytest.mark.parametrize('chunk_size', [1, 2, 17, 1000]) +@pytest.mark.parametrize('math_backend', [ref, experimental_ref], ids=['baseline', 'experimental']) +def test_group_width_union_matches_literal_membership_with_gaps_and_duplicate_widths(math_backend, chunk_size): + rng = np.random.default_rng(16271) + # No monotonicity or overlapping-interval shortcut is permitted: explicit + # group unions may contain gaps and input widths need not be sorted. + widths = rng.integers(1, 70, 23) + minima = rng.integers(1, 100, 301) + maxima = minima + rng.integers(-3, 8, len(minima)) + membership = ((widths[None, :] >= minima[:, None]) & + (widths[None, :] <= maxima[:, None])) + expected = np.array([np.any(membership[first:first + chunk_size], axis=0) + for first in range(0, len(minima), chunk_size)]) + np.testing.assert_array_equal( + math_backend.chunk_width_masks(widths, minima, maxima, chunk_size), expected) + + +def _literal_refinement_candidate_indices(periods, power): + """Pre-optimization finite-candidate implementation, retained as an oracle.""" + periods, power = np.ma.asarray(periods), np.ma.asarray(power) + valid = (~np.ma.getmaskarray(periods) & ~np.ma.getmaskarray(power) & + np.isfinite(np.ma.getdata(periods)) & + np.isfinite(np.ma.getdata(power))) + combined = [(i, (periods.data[i], -power.data[i])) + for i in np.flatnonzero(valid)] + ranked = sorted(combined, key=lambda item: item[1][1]) + top = [item[0] for item in ranked[:100]] + remaining = [item for item in combined if item[0] not in top and item[1][0] > 1] + next_best = sorted(remaining, key=lambda item: item[1][1])[:100] + return np.array(top + [item[0] for item in next_best], dtype=np.int64) + + +@pytest.mark.parametrize('size', [0, 1, 99, 100, 101, 199, 200, 201, 1025]) +@pytest.mark.parametrize('dtype', [np.float32, np.float64]) +@pytest.mark.parametrize('math_backend', [ref, experimental_ref], ids=['baseline', 'experimental']) +def test_candidate_vectorization_matches_literal_ranking_at_quota_boundaries(math_backend, size, dtype): + rng = np.random.default_rng(671 + size) + # Tie-heavy scores include both signs of zero, nonfinite entries and + # independent masks. Periods straddle the strict second-quota P>1 bound. + periods = rng.choice([.75, 1., np.nextafter(1., 2.), 2., np.nan, np.inf], size) + power = rng.choice([-np.inf, -2., -0., 0., 1., 2., np.inf, np.nan], size).astype(dtype) + periods = np.ma.array(periods, mask=rng.random(size) < .08) + power = np.ma.array(power, mask=rng.random(size) < .11) + np.testing.assert_array_equal(math_backend.refinement_candidate_indices(periods, power), + _literal_refinement_candidate_indices(periods, power)) + + +@pytest.mark.parametrize('math_backend', [ref, experimental_ref], ids=['baseline', 'experimental']) +def test_unmasked_full_candidate_rank_order_matches_frozen_native_selection(math_backend): + periods = np.linspace(.05, 5, 250) + power = ((np.arange(250)*37) % 251)/251. + actual = math_backend.refinement_candidate_indices(periods, power).astype('1 condition; rows 120:220 fill the second quota in input order. + actual = math_backend.refinement_candidate_indices(periods, power) + np.testing.assert_array_equal(actual, np.r_[np.arange(100), np.arange(120, 220)]) + + +@pytest.mark.parametrize('periods,power', [ + ([], []), + (np.ma.masked_all(3), [1., 2., 3.]), + ([1., 2., 3.], np.ma.masked_all(3)), + ([np.nan, np.inf, -np.inf], [1., 2., 3.]), + ([1., 2., 3.], [np.nan, np.inf, -np.inf]), +]) +@pytest.mark.parametrize('math_backend', [ref, experimental_ref], ids=['baseline', 'experimental']) +def test_full_candidate_selection_returns_empty_when_no_finite_unmasked_trial_exists(math_backend, periods, power): + actual = math_backend.refinement_candidate_indices(periods, power) + assert actual.shape == (0,) + assert np.issubdtype(actual.dtype, np.integer) + + +def test_harmonic_selection_preserves_native_order_and_mask_behavior(): + periods = np.ma.array([.2, .9, 1., 1.5, 2., 2.7, 3.8], mask=[0, 0, 1, 0, 0, 0, 0]) + # Native find_nearest_indices drops the period mask before nearest lookup. + np.testing.assert_array_equal(ref.harmonic_candidate_indices(periods, 1.), [0, 2, 4, 1, 3]) + np.testing.assert_array_equal(ref.harmonic_candidate_indices([1., 2.], 1.), [0, 0, 1, 0, 0]) + + +@pytest.mark.parametrize('copy_score_mask', [False, True]) +def test_valid_harmonic_refinement_can_rehabilitate_a_masked_coarse_period(copy_score_mask): + periods = np.ma.array([.2, .9, 1., 1.5, 2., 2.7, 3.8], mask=[0, 0, 1, 0, 0, 0, 0]) + chi2 = np.ma.array([4., 4., 1e6, 4., 4., 4., 4.], + mask=np.ma.getmaskarray(periods), copy=copy_score_mask) + indices = ref.harmonic_candidate_indices(periods, 2.) + np.testing.assert_array_equal(indices, [2, 4, 6, 3, 5]) + # These stand for successful, finite full-window evaluations, including + # P=1d, which was masked at the coarse stage. NumPy's indexed assignment + # deliberately clears its chi2 mask. Native shares that mask with periods; + # production keeps a separate copy of the original period mask. + chi2[indices] = [1., 3., 4., 4., 4.] + spectrum = ref.native_spectra(chi2, mask_outliers=False) + assert spectrum['primary_index'] == 2 + assert not np.ma.getmaskarray(spectrum['chi2'])[2] + # The actual grid value remains the correct finite input for this newly + # valid harmonic in either case, regardless of mask ownership. + assert np.ma.getdata(periods)[spectrum['primary_index']] == 1. + assert np.ma.is_masked(periods[spectrum['primary_index']]) == copy_score_mask + + +@pytest.mark.parametrize('wrap', [False, True]) +def test_final_parameters_match_native_sample_window_and_preserve_snr_units(wrap): + pytest.importorskip('batman') + cache = ref.build_cache([.6, 12.8], 1000) + i = np.arange(1000) + t = 1 + i*.026 + ((i*7) % 11)*.00001 + y = 1 + .0002*((i*11) % 23-11)/11 + expected = GOLDEN['final'][str(wrap)] + period, width_index = 3.7, 12 + rank = np.argsort((t % period)/period) + window = (expected['epoch_index'] + np.arange(expected['width'])) % len(t) + y[rank[window]] -= .001 + result = ref.final_parameters(t, y, np.ones(len(t)), period, cache, width_index, + expected['epoch_index'], exposure_days=200/86400, error_scale=.0002) + for key in ('T0', 'depth', 'duration', 'native_gtls_snr', 'transit_times', 'per_transit_count'): + np.testing.assert_allclose(result[key], expected[key], rtol=2e-12, atol=1e-14) + assert result['SNR'] == pytest.approx(np.sqrt(max(0, result['chi2_null'] - result['chi2_min']))/.0002) + assert result['exposure']['integrated_in_search'] is False + assert result['exposure']['median_days'] == pytest.approx(200/86400) + assert result['width_in_samples'] == expected['width'] + assert result['n_transits'] == len(expected['transit_times']) diff --git a/cuvarbase/tests/test_tls_reference_prefix.py b/cuvarbase/tests/test_tls_reference_prefix.py new file mode 100644 index 00000000..eeed63c1 --- /dev/null +++ b/cuvarbase/tests/test_tls_reference_prefix.py @@ -0,0 +1,321 @@ +"""GPU regressions for exact native TLS scans and physical workspace chunks. + +The baseline uses GTLS's one-dimensional float32 cumsum calls. Matrix-axis +scans can change their addition order and the subsequent mean-depth gates. +""" +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest + +cp = pytest.importorskip('cupy') + +from cuvarbase import tls_reference as baseline_engine +from cuvarbase import tls_reference_experimental as experimental_engine +from cuvarbase.tls_reference_prefix import NativePrefixPlan + + +pytestmark = pytest.mark.gpu + + +@pytest.fixture(scope='module', autouse=True) +def cuda_device(): + try: + available = cp.cuda.runtime.getDeviceCount() + except cp.cuda.runtime.CUDARuntimeError as error: + pytest.skip('CUDA device unavailable: {}'.format(error)) + if available < 1: + pytest.skip('CUDA device unavailable') + yield + for backend in (baseline_engine, experimental_engine): + cache = getattr(backend._PREFIX_PLANS, 'cache', None) + if cache is not None: + cache.close() + + +@pytest.fixture(params=[baseline_engine, experimental_engine], ids=['baseline', 'experimental']) +def engine(request): + return request.param + + +def _bitwise_equal(actual, expected): + actual = cp.asnumpy(actual) + expected = cp.asnumpy(expected) + np.testing.assert_array_equal(actual.view(np.uint32), expected.view(np.uint32)) + + +def _literal_download_winners(scan, t, order, chi2, starts, indices, widths, depths): + """Original six-download path, independent of the new pack kernel.""" + rows, ndata = order.shape + safe_start = cp.clip(starts, 0, ndata - 1) + epochs = t[order[cp.arange(rows, dtype=cp.int32), safe_start]] + epochs = cp.where(starts >= 0, epochs, np.nan) + return dict(chi2=chi2.get(), start=starts.get(), start_time=epochs.get(), + width_index=indices.get(), width=widths.get(), depth=depths.get()) + + +def test_packed_winners_preserve_float_bits_indices_and_absolute_epochs(): + engine = experimental_engine + rows, ndata = 257, 13 # Cross the launch-block boundary. + rng = np.random.default_rng(9251) + t = cp.asarray(2457000. + rng.uniform(0, 30., ndata)) + order = cp.asarray(np.array([rng.permutation(ndata) for _ in range(rows)]), cp.int32) + starts = cp.asarray(rng.integers(-1, ndata + 2, rows), cp.int32) + indices = cp.asarray(rng.integers(-1, 17, rows), cp.int32) + widths = cp.asarray(rng.integers(0, ndata, rows), cp.int32) + # Include payload-bearing NaNs, infinities, subnormals and signed zeros; + # none should be rounded or normalized by a float-to-float packing cast. + words = np.array([0, 0x80000000, 1, 0x7f800000, 0xff800000, + 0x7fc00123, 0xffc00123, 0x3f800000], dtype=np.uint32) + chi2 = cp.asarray(np.resize(words, rows).view(np.float32)) + depths = cp.asarray(np.resize(words[::-1], rows).view(np.float32)) + args = (engine.modules()[1], t, order, chi2, starts, indices, widths, depths) + expected = _literal_download_winners(*args) + actual = engine._download_winners(*args) + assert actual.dtype == engine._WINNER_DTYPE + for field in expected: + dtype = np.uint64 if expected[field].dtype.itemsize == 8 else np.uint32 + np.testing.assert_array_equal(actual[field].view(dtype), expected[field].view(dtype)) + + +@pytest.mark.parametrize('columns', [17, 129, 513, 1301, 10003]) +def test_graph_prefix_matches_native_float32_rows(engine, columns): + rng = np.random.default_rng(columns) + values = (1 + rng.normal(0., .005, (3, columns))).astype(np.float32) + values[:, :max(1, columns // 50)] -= .02 + flux = cp.asarray(values) + baseline = engine._row_flux_prefix(flux) + with NativePrefixPlan(flux.shape) as plan: + observed = plan(flux) + assert observed is plan.output + assert observed.data.ptr != flux.data.ptr + _bitwise_equal(observed, baseline) + assert plan.owned_bytes >= plan.buffer_bytes + assert plan.owned_bytes == plan.buffer_bytes + plan.workspace_bytes + assert plan.closed + assert plan.owned_bytes == 0 + + +def test_prefix_reuse_and_stream_changes_preserve_new_inputs(engine): + rng = np.random.default_rng(732) + values = (1 + rng.normal(0., .005, (3, 1301))).astype(np.float32) + with NativePrefixPlan(values.shape) as plan: + first = cp.asarray(values) + _bitwise_equal(plan(first), engine._row_flux_prefix(first)) + stream = cp.cuda.Stream(non_blocking=True) + with stream: + second = cp.asarray(values * np.float32(.999)) + _bitwise_equal(plan(second), engine._row_flux_prefix(second)) + # Returning to the default stream must not race the previous replay. + third = cp.asarray(values + np.float32(.00001)) + _bitwise_equal(plan(third), engine._row_flux_prefix(third)) + plan.close() # Explicit release is idempotent. + with pytest.raises(RuntimeError, match='closed'): + plan(third) + + +def test_prefix_contract_rejects_wrong_inputs_and_memory_budget(engine): + shape = (2, 129) + with pytest.raises(ValueError, match='positive two-dimensional'): + NativePrefixPlan((0, 129)) + with pytest.raises(MemoryError, match='buffers'): + NativePrefixPlan(shape, max_bytes=NativePrefixPlan.buffer_bytes_for(shape) - 1) + with NativePrefixPlan(shape) as plan: + with pytest.raises(TypeError, match='CuPy array'): + plan(np.ones(shape, np.float32)) + with pytest.raises(ValueError, match='shape and float32'): + plan(cp.ones(shape, cp.float64)) + with pytest.raises(ValueError, match='shape and float32'): + plan(cp.ones((3, 129), cp.float32)) + _bitwise_equal(plan(cp.ones(shape, cp.float32)), + engine._row_flux_prefix(cp.ones(shape, cp.float32))) + + +def test_prefix_cache_lru_and_byte_limits_release_evicted_plans(engine): + cache = engine._PrefixPlanCache(max_plans=2, max_bytes=64 * 1024) + try: + first, second, third = (cp.ones(shape, cp.float32) + for shape in ((2, 129), (3, 513), (4, 1024))) + for flux in (first, second): + _bitwise_equal(cache.prefix(flux), engine._row_flux_prefix(flux)) + old_second = cache.plans[NativePrefixPlan.key_for(second.shape)] + _bitwise_equal(cache.prefix(first), engine._row_flux_prefix(first)) + _bitwise_equal(cache.prefix(third), engine._row_flux_prefix(third)) + assert old_second.closed + assert len(cache.plans) == 2 + assert list(cache.plans) == [NativePrefixPlan.key_for(first.shape), + NativePrefixPlan.key_for(third.shape)] + assert cache.owned_bytes <= cache.max_bytes + finally: + cache.close() + assert cache.owned_bytes == 0 + + cache = engine._PrefixPlanCache(max_plans=4, max_bytes=20 * 1024) + try: + first, second = (cp.ones(shape, cp.float32) + for shape in ((2, 513), (3, 513))) + _bitwise_equal(cache.prefix(first), engine._row_flux_prefix(first)) + old_first = next(iter(cache.plans.values())) + _bitwise_equal(cache.prefix(second), engine._row_flux_prefix(second)) + assert old_first.closed + assert len(cache.plans) == 1 + assert cache.owned_bytes <= cache.max_bytes + finally: + cache.close() + + +def test_oversized_prefix_uses_exact_uncached_row_scans(engine): + cache = engine._PrefixPlanCache(max_bytes=64) + flux = cp.asarray(np.random.default_rng(713).normal(1., .005, (2, 129)), + dtype=cp.float32) + _bitwise_equal(cache.prefix(flux), engine._row_flux_prefix(flux)) + assert not cache.plans + assert cache.owned_bytes == 0 + + +def test_prefix_caches_are_thread_local(engine, monkeypatch): + # Exercise the graph cache explicitly even when short native CUB scans + # are supported on the test device. + if engine is experimental_engine: + monkeypatch.setattr(engine.NativeShortPrefixCache, 'unsupported_reason', + staticmethod(lambda array: 'graph-specific regression')) + main_flux = cp.ones((2, 129), cp.float32) + _bitwise_equal(engine._native_flux_prefix(main_flux), + engine._row_flux_prefix(main_flux)) + main_cache = engine._PREFIX_PLANS.cache + device = cp.cuda.runtime.getDevice() + + def worker(): + with cp.cuda.Device(device): + flux = cp.ones((2, 129), cp.float32) + _bitwise_equal(engine._native_flux_prefix(flux), + engine._row_flux_prefix(flux)) + cache = engine._PREFIX_PLANS.cache + different = cache is not main_cache + cache.close() + return different + + with ThreadPoolExecutor(max_workers=1) as pool: + assert pool.submit(worker).result() + assert not next(iter(main_cache.plans.values())).closed + + +@pytest.mark.parametrize('full', [False, True]) +def test_physical_workspace_budget_limits_rows_or_fails_before_search(engine, full): + plan = engine._physical_chunk_plan(10003, 11205, 35, 1400, 256, + full=full, free_bytes=64 * 1024**2) + assert 1 <= plan['rows'] < 256 + assert plan['budget_bytes'] == 16 * 1024**2 + assert plan['estimated_chunk_bytes'] <= plan['budget_bytes'] + with pytest.raises(MemoryError, match='One TLS period'): + engine._physical_chunk_plan(10003, 11205, 35, 1400, 256, + full=full, free_bytes=4) + + +def _search_fixture(engine): + pytest.importorskip('batman') + rng = np.random.default_rng(9320) + times = np.sort(rng.uniform(.1, 35., 1025)) + flux = 1 + rng.normal(0., .001, len(times)) + flux[np.remainder(times, 2.03) < .08] -= .01 + errors = rng.uniform(.0008, .0012, len(times)) + periods = np.linspace(2., 2.05, 6) + prepared = engine.reference.preprocess_inputs(times, flux, errors) + cache = engine.reference.build_cache(periods, len(prepared['t'])) + return periods, prepared, cache + + +@pytest.mark.parametrize('full', [False, True]) +def test_raw_search_chunking_and_graph_preserve_scores_and_observation_epochs(engine, monkeypatch, full): + periods, prepared, cache = _search_fixture(engine) + inputs = (periods, prepared['t'], prepared['y'], prepared['dy'], cache) + graph = engine._native_flux_prefix + download = getattr(engine, '_download_winners', None) + monkeypatch.setattr(engine, '_native_flux_prefix', engine._row_flux_prefix) + if download is not None: + monkeypatch.setattr(engine, '_download_winners', _literal_download_winners) + old = engine.raw_search(*inputs, group_size=4, work_chunk=256, + capture=True, full=full) + assert np.all(old['start'] >= 0) + assert np.all(np.isfinite(old['chi2']) & (old['chi2'] > 0)) + per_row = min(plan['estimated_bytes_per_row'] for plan in old['work_chunk_plans']) + monkeypatch.setattr(engine, '_WORKSPACE_BYTES', 2 * per_row) + monkeypatch.setattr(engine, '_native_flux_prefix', graph) + if download is not None: + monkeypatch.setattr(engine, '_download_winners', download) + new = engine.raw_search(*inputs, group_size=4, work_chunk=256, full=full) + for field in ('chi2', 'start', 'start_time', 'width_index', 'width', 'depth', + 'width_masks', 'group_ranges'): + np.testing.assert_array_equal(new[field], old[field]) + assert all(chunk['rows'] <= 2 for chunk in new['work_chunks']) + assert len(new['work_chunks']) > len(old['work_chunks']) + for chunk in old['captured']: + first, last = chunk['start'], chunk['stop'] + start = old['start'][first:last] + original = prepared['t'][chunk['order'][np.arange(last - first), start]] + np.testing.assert_array_equal(new['start_time'][first:last], original) + + +def test_explicit_duration_runs_never_broaden_another_periods_interval(engine): + periods, prepared, cache = _search_fixture(engine) + widths = cache['widths'] + assert len(widths) >= 3 + lower = np.array([widths[0], widths[0], widths[1], widths[2], widths[1], widths[1]]) + upper = lower.copy() + selection = dict(width_minima=lower, width_maxima=upper) + result = engine.raw_search(periods, prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=6, work_chunk=2, duration_selection=selection) + np.testing.assert_array_equal(result['group_ranges'], [[0, 2], [2, 3], [3, 4], [4, 6]]) + assert result['width_masks'] is None # No periods-by-widths retained table. + assert np.all(result['start'] >= 0) + np.testing.assert_array_equal(result['width'], lower) + for group, (first, last) in enumerate(result['group_ranges']): + admissible = (widths >= lower[first]) & (widths <= upper[first]) + assert np.count_nonzero(admissible) == 1 + assert np.all(lower[first:last] == lower[first]) + # Reconstruct each period independently to verify that run grouping does + # not add a trial from another period, including nonconsecutive repeats. + for index, period in enumerate(periods): + single = engine.raw_search([period], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=1, work_chunk=1, + duration_selection=dict(width_minima=lower[index:index + 1], + width_maxima=upper[index:index + 1])) + for field in ('chi2', 'start', 'start_time', 'width_index', 'width', 'depth'): + np.testing.assert_array_equal(result[field][index:index + 1], single[field]) + + +def test_real_backend_modules_and_graph_buffers_do_not_cross_execution_modes(monkeypatch): + import threading + + # New local state makes the lifetime proof independent of earlier tests. + for backend in (baseline_engine, experimental_engine): + monkeypatch.setattr(backend, '_PREFIX_PLANS', threading.local()) + short_calls = [] + + def graph_fallback(self, flux): + short_calls.append(flux) + return None + + monkeypatch.setattr(experimental_engine.NativeShortPrefixCache, 'prefix', graph_fallback) + flux = cp.ones((2, 129), cp.float32) + try: + default = baseline_engine._native_flux_prefix(flux) + assert not short_calls + assert not hasattr(baseline_engine._PREFIX_PLANS, 'short') + optimized = experimental_engine._native_flux_prefix(flux) + assert len(short_calls) == 1 + assert default.data.ptr != optimized.data.ptr + assert baseline_engine._PREFIX_PLANS.cache is not experimental_engine._PREFIX_PLANS.cache + _bitwise_equal(default, optimized) + baseline_engine._native_flux_prefix(flux) + assert len(short_calls) == 1 + baseline_modules = baseline_engine.modules() + experimental_modules = experimental_engine.modules() + assert all(a is not b for a, b in zip(baseline_modules, experimental_modules)) + assert baseline_engine.modules() is baseline_modules + assert experimental_engine.modules() is experimental_modules + finally: + for backend in (baseline_engine, experimental_engine): + cache = getattr(backend._PREFIX_PLANS, 'cache', None) + if cache is not None: + cache.close() diff --git a/cuvarbase/tests/test_tls_reference_short_prefix.py b/cuvarbase/tests/test_tls_reference_short_prefix.py new file mode 100644 index 00000000..39522882 --- /dev/null +++ b/cuvarbase/tests/test_tls_reference_short_prefix.py @@ -0,0 +1,196 @@ +"""Exact short CUB scan and protective native-graph dispatch regressions.""" +import json + +import numpy as np +import pytest + +cp = pytest.importorskip('cupy') +from cuvarbase import tls_reference_experimental as engine +from cuvarbase.tls_reference_short_prefix import NativeShortPrefixCache + +pytestmark = pytest.mark.gpu + + +@pytest.fixture(scope='module', autouse=True) +def cuda_device(): + try: + if cp.cuda.runtime.getDeviceCount() < 1: + pytest.skip('CUDA device unavailable') + except cp.cuda.runtime.CUDARuntimeError: + pytest.skip('CUDA device unavailable') + + +@pytest.fixture(scope='module') +def supported_cache(): + cache = NativeShortPrefixCache() + array = cp.ones((2, 31), cp.float32) + result = cache.prefix(array) + if result is None: + pytest.skip(cache.status['fallback_reason']) + assert cache.status['active'] + assert cache.status['cached_module_count'] == 1 + return cache + + +def equal_bits(actual, expected): + np.testing.assert_array_equal(actual.get().view(np.uint32), expected.get().view(np.uint32)) + + +@pytest.mark.parametrize('columns', [1, 15, 16, 31, 32, 127, 128, 129, + 255, 256, 511, 512, 513, 1212, 1317, + 1475, 1477, 1919, 1920]) +@pytest.mark.parametrize('offset', [0, 1]) +def test_short_scan_preserves_native_bits_at_boundaries(supported_cache, columns, offset): + rng = np.random.default_rng(columns) + values = rng.normal(1., .005, (3, columns)).astype(np.float32) + values[0, :max(1, columns // 50)] -= np.float32(.03) + values[1] *= np.resize(np.array([-1., 2**18, 1., -2**18], np.float32), columns) + # Include signed zeros and subnormals, which fast-math compilation could + # flush or normalize even if ordinary normalized flux happened to agree. + words = np.array([0, 0x80000000, 1, 0x80000001, 0x007fffff, 0x807fffff], np.uint32) + values[2] = np.resize(words.view(np.float32), columns) + storage = cp.empty(values.size + offset, dtype=cp.float32) + array = storage[offset:].reshape(values.shape) + array.set(values) + observed = supported_cache.prefix(array) + assert observed is not None + assert observed.data.ptr != array.data.ptr + equal_bits(observed, engine._row_flux_prefix(array)) + + +def test_short_scan_outputs_survive_later_calls_and_stream_changes(supported_cache): + first = cp.asarray(np.random.default_rng(81).normal(1., .01, (7, 1475)), cp.float32) + output = supported_cache.prefix(first) + expected = engine._row_flux_prefix(first) + stream = cp.cuda.Stream(non_blocking=True) + with stream: + second = cp.asarray(np.random.default_rng(82).normal(1., .01, (7, 1475)), cp.float32) + other = supported_cache.prefix(second) + equal_bits(other, engine._row_flux_prefix(second)) + equal_bits(output, expected) + assert output.data.ptr != other.data.ptr + + +def test_single_element_rows_preserve_special_float_bits(supported_cache): + words = np.array([0, 0x80000000, 1, 0x80000001, 0x7f800000, + 0xff800000, 0x7fc00123, 0xffc00123, 0x3f800000], np.uint32) + array = cp.asarray(words.view(np.float32).reshape(-1, 1)) + equal_bits(supported_cache.prefix(array), engine._row_flux_prefix(array)) + + +@pytest.mark.parametrize('kind', ['long', 'strided', 'float64', 'empty', 'vector']) +def test_ineligible_shapes_never_compile(kind, monkeypatch): + arrays = dict(long=cp.ones((2, 1921), cp.float32), + strided=cp.ones((2, 128), cp.float32)[:, ::2], + float64=cp.ones((2, 32), cp.float64), + empty=cp.ones((0, 32), cp.float32), vector=cp.ones(32, cp.float32)) + cache = NativeShortPrefixCache() + monkeypatch.setattr(cache, 'compile', lambda: pytest.fail('unsupported shape compiled')) + assert cache.prefix(arrays[kind]) is None + assert not cache.status['supported'] + assert cache.status['cached_context_count'] == 0 + + +@pytest.mark.parametrize('variable', ['NVCC', 'NVCC_PREPEND_FLAGS', 'NVCC_APPEND_FLAGS']) +def test_injected_compiler_configuration_uses_native_graph(variable, monkeypatch): + monkeypatch.setenv(variable, '--use_fast_math') + array = cp.ones((2, 31), cp.float32) + cache = NativeShortPrefixCache() + monkeypatch.setattr(cache, 'compile', lambda: pytest.fail('injected flags compiled')) + assert cache.prefix(array) is None + assert 'compiler' in cache.status['fallback_reason'] or 'NVCC' in cache.status['fallback_reason'] + + +def test_accelerator_changes_are_rechecked_after_success(supported_cache): + from cupy._core import _accelerator + array = cp.ones((2, 31), cp.float32) + original = _accelerator.get_routine_accelerators() + try: + _accelerator.set_routine_accelerators([]) + assert supported_cache.prefix(array) is None + assert 'disabled' in supported_cache.status['fallback_reason'] + finally: + _accelerator.set_routine_accelerators(original) + assert supported_cache.prefix(array) is not None + assert supported_cache.status['active'] + + +@pytest.mark.parametrize('failure', ['compiler', 'canary']) +def test_unavailable_compiler_or_canary_mismatch_is_cached_fallback(monkeypatch, failure): + array = cp.ones((2, 31), cp.float32) + cache = NativeShortPrefixCache() + monkeypatch.setattr(cache, 'unsupported_reason', lambda array: None) + attempts = [] + def compile(): + attempts.append(True) + if failure == 'compiler': + raise FileNotFoundError('nvcc unavailable') + return object(), lambda *args: pytest.fail('failed canary dispatched real input') + monkeypatch.setattr(cache, 'compile', compile) + monkeypatch.setattr(cache, 'canary', lambda kernel: False) + assert cache.prefix(array) is None + assert cache.prefix(array) is None + assert len(attempts) == 1 + assert cache.status['cached_context_count'] == 1 + assert cache.status['cached_module_count'] == 0 + assert cache.status['fallback_calls'] == 2 + json.dumps(cache.status, allow_nan=False) + + +def test_launched_kernel_failure_is_not_silently_fallback(monkeypatch): + cache = NativeShortPrefixCache() + monkeypatch.setattr(cache, 'unsupported_reason', lambda array: None) + def broken(*args): + raise RuntimeError('launched kernel failed') + monkeypatch.setattr(cache, 'compile', lambda: (object(), broken)) + monkeypatch.setattr(cache, 'canary', lambda kernel: True) + with pytest.raises(RuntimeError, match='launched kernel failed'): + cache.prefix(cp.ones((2, 31), cp.float32)) + assert cache.status['fallback_calls'] == 0 + + +def test_context_cache_does_not_reuse_modules_across_contexts(monkeypatch): + cache = NativeShortPrefixCache(max_contexts=2) + array = cp.ones((2, 31), cp.float32) + monkeypatch.setattr(cache, 'unsupported_reason', lambda array: None) + monkeypatch.setattr(cache, 'canary', lambda kernel: True) + current = [101] + used = [] + def compile(): + context = current[0] + return object(), lambda *args: used.append(context) + monkeypatch.setattr(cache, 'compile', compile) + monkeypatch.setattr(cp.cuda.driver, 'ctxGetCurrent', lambda: current[0]) + assert cache.prefix(array) is not None + current[0] = 202 + assert cache.prefix(array) is not None + current[0] = 101 + assert cache.prefix(array) is not None + assert used == [101, 202, 101] + current[0] = 303 + assert cache.prefix(array) is None + assert cache.status['fallback_reason'] == 'context cache limit reached' + assert cache.status['cached_context_count'] == 2 + with pytest.raises(TypeError): + NativeShortPrefixCache(max_contexts=1.5) + + +def test_engine_dispatches_both_short_and_native_graph_branches(monkeypatch, supported_cache): + monkeypatch.setattr(engine._PREFIX_PLANS, 'short', supported_cache, raising=False) + graph = engine._PrefixPlanCache() + monkeypatch.setattr(engine._PREFIX_PLANS, 'cache', graph, raising=False) + try: + short = cp.ones((2, 1475), cp.float32) + equal_bits(engine._native_flux_prefix(short), engine._row_flux_prefix(short)) + assert engine._native_short_prefix_status()['active'] + assert not graph.plans + long = cp.ones((2, 1921), cp.float32) + equal_bits(engine._native_flux_prefix(long), engine._row_flux_prefix(long)) + assert not engine._native_short_prefix_status()['active'] + assert len(graph.plans) == 1 + # A short but non-contiguous row view also retains graph semantics. + strided = cp.ones((2, 128), cp.float32)[:, ::2] + equal_bits(engine._native_flux_prefix(strided), engine._row_flux_prefix(strided)) + assert len(graph.plans) == 2 + finally: + graph.close() diff --git a/cuvarbase/tests/test_tls_t0_oversample.py b/cuvarbase/tests/test_tls_t0_oversample.py new file mode 100644 index 00000000..b6f204ac --- /dev/null +++ b/cuvarbase/tests/test_tls_t0_oversample.py @@ -0,0 +1,64 @@ +"""Preserved binned/legacy TLS t0-fidelity parameter plumbing (D1). + +``T0_OVERSAMPLE`` (transit-epoch trial positions per duration) is now a +Python-level parameter (``t0_oversample``) plumbed into the kernel's +``#define`` via cpp_defs, the kernel cache key, and the older engine. +These checks run on CPU (no kernel compilation needed). +""" +import inspect + +import cuvarbase.tls as tls_mod +from cuvarbase.tls import compile_tls, _get_cached_kernels, _tls_search_gpu_binned +from cuvarbase.tls_grids import t0_grid_size +from cuvarbase.utils import _module_reader, find_kernel + + +def test_t0_oversample_overrides_kernel_define(): + # The kernel guards its default with `#ifndef T0_OVERSAMPLE`, so the + # cpp_defs `#define` must appear *before* that guard to take effect. + txt = _module_reader(find_kernel('tls'), + cpp_defs={'BLOCK_SIZE': 128, 'T0_OVERSAMPLE': 33.0}) + assert '#define T0_OVERSAMPLE 33.0' in txt + assert (txt.index('#define T0_OVERSAMPLE 33.0') + < txt.index('#ifndef T0_OVERSAMPLE')) + + +def test_t0_oversample_in_binned_signatures(): + for fn in (compile_tls, _get_cached_kernels, _tls_search_gpu_binned): + params = inspect.signature(fn).parameters + assert 't0_oversample' in params, fn.__name__ + # default matches the kernel/grid default + assert inspect.signature(compile_tls).parameters[ + 't0_oversample'].default == 3.0 + + +def test_t0_oversample_is_part_of_cache_key(monkeypatch): + calls = [] + + def fake_compile(block_size, t0_oversample=3.0): + calls.append((block_size, t0_oversample)) + return {'standard': object(), 'keplerian': object()} + + monkeypatch.setattr(tls_mod, 'compile_tls', fake_compile) + # Snapshot and restore the module-level cache: monkeypatch undoes + # compile_tls but NOT cache contents — leaking the fake kernel + # objects under keys like (128, 3.0) crashes any later test in the + # same process that hits _get_cached_kernels with default settings. + saved = dict(tls_mod._kernel_cache) + tls_mod._kernel_cache.clear() + try: + _get_cached_kernels(128, t0_oversample=3.0) + _get_cached_kernels(128, t0_oversample=3.0) # cache hit + _get_cached_kernels(128, t0_oversample=33.0) # distinct key + assert calls == [(128, 3.0), (128, 33.0)] + finally: + tls_mod._kernel_cache.clear() + tls_mod._kernel_cache.update(saved) + + +def test_t0_grid_size_mirrors_oversample(): + # n_t0 = ceil(oversample / duration_phase), clamped to [30, 20000]; + # this is the Python mirror of the device t0_grid_size. + assert t0_grid_size(0.01, oversample=3.0) == 300 + assert t0_grid_size(0.01, oversample=33.0) == 3300 # ~11x finer + assert t0_grid_size(0.5, oversample=3.0) == 30 # floored at MIN_N_T0 diff --git a/cuvarbase/tests/test_utils.py b/cuvarbase/tests/test_utils.py new file mode 100644 index 00000000..6b3a530a --- /dev/null +++ b/cuvarbase/tests/test_utils.py @@ -0,0 +1,78 @@ +import numpy as np +from numpy.testing import assert_allclose + +from ..utils import normalize_light_curves, weights + + +def _fake_lc(n=50, seed=42): + rand = np.random.RandomState(seed) + t = np.sort(365 * rand.rand(n)) + y = 12 + 0.1 * np.cos(2 * np.pi * t / 5.0) + 0.1 * rand.randn(n) + dy = 0.1 * np.ones_like(y) + return t, y, dy + + +def test_normalize_subtracts_means(): + t, y, dy = _fake_lc() + (tn, yn, dyn), = normalize_light_curves([(t, y, dy)]) + + assert_allclose(np.mean(tn), 0, atol=1e-9) + assert_allclose(np.mean(yn), 0, atol=1e-9) + assert_allclose(tn, t - np.mean(t)) + assert_allclose(yn, y - np.mean(y)) + # columns beyond (t, y) pass through unchanged + assert_allclose(dyn, dy) + + +def test_normalize_does_not_mutate_input(): + t, y, dy = _fake_lc() + t0, y0 = t.copy(), y.copy() + normalize_light_curves([(t, y, dy)]) + assert_allclose(t, t0) + assert_allclose(y, y0) + + +def test_normalize_passes_none_through(): + # Regression test: unweighted CE/LS callers can pass (t, y, None); + # normalize_light_curves used to crash with AttributeError on + # None.copy(). + t, y, _ = _fake_lc() + (tn, yn, dyn), = normalize_light_curves([(t, y, None)]) + + assert dyn is None + assert_allclose(yn, y - np.mean(y)) + + +def test_normalize_legacy_four_tuple(): + # Deprecated PDM format: (t, y, w, freqs) — w and freqs must pass + # through untouched. + t, y, dy = _fake_lc() + w = weights(dy) + freqs = np.linspace(0.1, 10.0, 100) + (tn, yn, wn, fn), = normalize_light_curves([(t, y, w, freqs)]) + + assert_allclose(wn, w) + assert_allclose(fn, freqs) + assert_allclose(yn, y - np.mean(y)) + + +def test_conflict_scatter_perm_is_permutation(): + from cuvarbase.utils import conflict_scatter_perm + + for n in (64, 65, 1000, 20000, 65537): + p = conflict_scatter_perm(n) + assert p is not None + assert len(p) == n + # a true permutation of 0..n-1 + assert_allclose(np.sort(p), np.arange(n)) + # deterministic + assert np.array_equal(p, conflict_scatter_perm(n)) + # actually scatters: adjacent outputs come from far-apart inputs + assert np.min(np.abs(np.diff(p.astype(np.int64)))) > n // 4 + + +def test_conflict_scatter_perm_small_n_passthrough(): + from cuvarbase.utils import conflict_scatter_perm + + for n in (0, 1, 2, 32, 63): + assert conflict_scatter_perm(n) is None diff --git a/cuvarbase/tls.py b/cuvarbase/tls.py new file mode 100644 index 00000000..0e734789 --- /dev/null +++ b/cuvarbase/tls.py @@ -0,0 +1,2214 @@ +""" +GPU-accelerated Transit Least Squares (TLS) periodogram. + +This module implements a fast GPU version of the Transit Least Squares +algorithm for detecting planetary transits in photometric time series. + +References +---------- +- Hippke & Heller (2019), "Transit Least Squares", A&A 623, A39 +- Kovács et al. (2002), "Box Least Squares", A&A 391, 369 +""" + +import threading +import warnings +import operator +from collections import OrderedDict + +import pycuda.driver as cuda # noqa: E402 +import pycuda.gpuarray as gpuarray # noqa: E402 +from pycuda.compiler import SourceModule # noqa: E402 + +import numpy as np + +from .base import ensure_context # noqa: E402 +from .memory._host import host_array # noqa: E402 +from .utils import (find_kernel, _module_reader, + check_lightcurve) +from . import tls_grids +from . import tls_models +from . import tls_stats + + +__all__ = [ + 'compile_tls', + 'TLSMemory', + 'tls_search_gpu', + 'tls_search', + 'tls_transit', + 'compile_tls_fast', + 'tls_search_batch', +] + + +_default_block_size = 128 # Smaller default than BLS (TLS has more shared memory needs) +_KERNEL_CACHE_MAX_SIZE = 10 +_kernel_cache = OrderedDict() +_kernel_cache_lock = threading.Lock() + +# Default CUDA limit for dynamic shared memory per block; exceeding it +# fails at kernel launch, so we guard at the Python layer instead. +_SHARED_MEM_LIMIT = 48 * 1024 + +# The kernels initialize each period's chi2 to this sentinel and only +# overwrite it when a valid solution is found. +TLS_CHI2_SENTINEL = np.float32(1e30) + + +_NO_SOLUTION_MSG = ( + "TLS kernel returned no valid solution for any of the %d trial " + "periods (a flat or noiseless light curve gives zero depth at every " + "trial, which the kernels reject)") + + +# Minimum number of observations any TLS entry point accepts. The +# transit model is fitted against the constant-baseline chi2 of the +# same light curve, which is identically zero for a single point (the +# reported chi2 ratio came back 0/0), and the automatic Ofir period +# grid needs a non-zero baseline. +_TLS_MIN_NDATA = 2 + + +def _check_tls_lightcurve(t, y, dy, name): + """``utils.check_lightcurve`` with ``dy`` mandatory. + + The shared validator accepts ``dy=None`` (unit weights) for the + entry points that document that convention. TLS has none: every + path weights by ``dy ** -2`` and the fast path turned ``None`` into + an all-NaN weight vector, so a search without uncertainties came + back as the flat-light-curve null result (SDE = 0) instead of an + error. + """ + if dy is None: + raise ValueError( + "%s: dy is required (per-point flux uncertainties, same " + "units as y); TLS has no unit-weight convention" % name) + return check_lightcurve(t, y, dy, min_n=_TLS_MIN_NDATA, name=name) + + +def _validate_n_durations(n_durations): + """Reject ``n_durations < 2`` on every path. + + Both kernels place the trial durations log-uniformly between the + window bounds with step ``(log qmax - log qmin) / (n_durations - + 1)``: one duration is 0/0 (NaN, so every trial failed and the + legacy path returned the flat-light-curve null result for a good + light curve) and zero durations searches nothing. The fast path + additionally caps the count at ``_TLS_FAST_MAX_DURATIONS``. + """ + try: + n = operator.index(n_durations) + except TypeError: + raise ValueError("n_durations must be an integer >= 2 (got %r)" + % (n_durations,)) + if n < 2: + raise ValueError("n_durations must be >= 2 (got %d): the trial " + "durations are log-spaced between the window " + "bounds, so fewer than two are undefined" % n) + return n + + +# Keywords tls_search_gpu reads from **kwargs (everything else is a +# caller error; see the check at the top of tls_search_gpu). +_TLS_SEARCH_GPU_EXTRA_KWARGS = frozenset(['n_template']) + + +def _mask_failed_periods(chi2_vals): + """Return a boolean mask of trial periods with a valid solution. + + Failed periods keep the kernel's 1e30 chi2 initializer; left + unmasked they corrupt the best-fit argmin and collapse the SDE + statistics. Warns when any period failed. When EVERY period failed + (e.g. a flat/noiseless light curve) the mask is all-False and a + warning says so; the search wrappers then return a null result + (SDE = 0, NaN best-fit parameters) instead of raising, like the + reference ``transitleastsquares`` package. + """ + chi2_vals = np.asarray(chi2_vals) + valid = np.isfinite(chi2_vals) & (chi2_vals < 0.1 * TLS_CHI2_SENTINEL) + n_failed = int(chi2_vals.size - valid.sum()) + if n_failed == chi2_vals.size: + warnings.warn(_NO_SOLUTION_MSG % chi2_vals.size + + "; returning a null result (SDE = 0)") + elif n_failed: + warnings.warn( + "%d of %d trial periods returned no valid TLS solution " + "(chi2 sentinel); they are excluded from the best-fit " + "search and the SDE statistics and appear as NaN in " + "the returned arrays" % (n_failed, chi2_vals.size)) + return valid + + +def _null_result(nperiods, chi2_0, message, periods=None, arrays=False): + """Result dict for a light curve with no valid trial period: SDE = 0, + NaN best-fit parameters, and the failure message under 'error'.""" + res = { + 'period': np.nan, + 'period_uncertainty': np.nan, + 't0_phase': np.nan, + 'T0': np.nan, + 'duration': np.nan, + 'depth': 0.0, + 'chi2_min': float(chi2_0), + 'SDE': 0.0, + 'SDE_raw': 0.0, + 'SNR': 0.0, + 'n_transits': 0, + 'n_failed_periods': int(nperiods), + 'error': message, + } + if arrays: + def _nan(): + return np.full(nperiods, np.nan) + res.update({ + 'periods': periods, + 'chi2': _nan(), + 'best_t0_per_period': _nan(), + 'best_duration_per_period': _nan(), + 'best_depth_per_period': _nan(), + 'valid_periods': np.zeros(nperiods, dtype=bool), + 'power': _nan(), + 'SR': _nan(), + }) + return res + + +def _validate_periods(periods): + """Common checks on a trial-period grid (any order).""" + periods = np.asarray(periods) + if periods.ndim != 1: + raise ValueError("periods must be a 1-d array") + if periods.size == 0: + raise ValueError("periods must be non-empty") + if not np.all(np.isfinite(periods)) or np.any(periods <= 0): + raise ValueError("periods must be finite and > 0") + return periods + + +def _sort_period_grid(periods): + """Return (periods_ascending, order): the SDE running-median detrend + and the period-uncertainty neighbour walk assume period-ordered + neighbours, so user grids are sorted on entry. ``order`` is None + when the grid is already ascending, else the argsort that maps + the caller's order to ascending (see :func:`_to_caller_order`).""" + periods = np.asarray(periods) + if periods.size > 1 and np.any(np.diff(periods) < 0): + order = np.argsort(periods, kind='stable') + return periods[order], order + return periods, None + + +def _to_caller_order(values, order): + """Scatter a per-period array from ascending order back to the + caller's grid order (identity when ``order`` is None).""" + if order is None: + return values + out = np.empty_like(values) + out[order] = values + return out + + +def _validate_q_window(qmin, qmax, periods=None): + bad = (np.asarray(qmin) <= 0) | (np.asarray(qmax) < np.asarray(qmin)) \ + | (np.asarray(qmax) >= 1) + if not np.any(bad): + return + where = "" + if periods is not None and np.ndim(bad) and np.any(bad): + pbad = np.asarray(periods, dtype=float)[np.asarray(bad)] + where = (" at P = %.4g .. %.4g d" % (pbad.min(), pbad.max())) + raise ValueError( + "need 0 < qmin <= qmax < 1 at every period%s (the transit " + "duration must be shorter than the period; the binned scan " + "would double-count phase bins for q >= 1). The Keplerian " + "duration window reaches q >= 1 at sub-Roche periods: shrink " + "qmax_fac, pass explicit qmin/qmax, raise the shortest trial " + "period, or opt into the constant window with " + "duration_window='fixed'." % where) + + +def _first_transit_at_or_after(t_mid, period, tmin): + """Shift a mid-transit time by whole periods into [tmin, tmin + + period): the 'T0' convention of every TLS result (same as the + reference package's ``T0``).""" + return tmin + ((t_mid - tmin) % period) + + +def _choose_block_size(ndata): + """ + Choose optimal block size for TLS kernel based on data size. + + Parameters + ---------- + ndata : int + Number of data points + + Returns + ------- + block_size : int + Optimal CUDA block size (32, 64, or 128) + + Notes + ----- + TLS uses more shared memory than BLS, so we use smaller block sizes + to avoid shared memory limits. + """ + if ndata <= 32: + return 32 + elif ndata <= 64: + return 64 + else: + return 128 # Max for TLS (vs 256 for BLS) + + +def _get_cached_kernels(block_size, t0_oversample=3.0): + """ + Get compiled TLS kernel from cache. + + Parameters + ---------- + block_size : int + CUDA block size + t0_oversample : float, optional (default: 3.0) + Transit-epoch oversampling baked into the kernel's + ``T0_OVERSAMPLE`` define; part of the cache key, so distinct + values compile (and cache) distinct kernels. + + Returns + ------- + kernel : PyCUDA function + Compiled kernel function + """ + key = (block_size, float(t0_oversample)) + + with _kernel_cache_lock: + if key in _kernel_cache: + _kernel_cache.move_to_end(key) + return _kernel_cache[key] + + # Compile kernel + compiled = compile_tls(block_size=block_size, + t0_oversample=t0_oversample) + + # Add to cache + _kernel_cache[key] = compiled + _kernel_cache.move_to_end(key) + + # Evict oldest if needed + if len(_kernel_cache) > _KERNEL_CACHE_MAX_SIZE: + _kernel_cache.popitem(last=False) + + return compiled + + +def compile_tls(block_size=_default_block_size, t0_oversample=3.0): + """ + Compile TLS CUDA kernels. + + Parameters + ---------- + block_size : int, optional + CUDA block size (default: 128) + t0_oversample : float, optional (default: 3.0) + Transit-epoch (t0) oversampling: the on-device epoch stride is + ``duration_phase / t0_oversample``, so larger values test a finer + grid of transit times -- more sensitive to the exact epoch (and + to narrow transits) at a roughly linear cost in kernel time. + This compiles the kernel's ``T0_OVERSAMPLE`` ``#define`` and + mirrors :func:`cuvarbase.tls_grids.t0_grid_size`'s ``oversample``. + The reference ``transitleastsquares`` package steps t0 about + 100x finer than a duration (every cadence for dense data); the + default of 3 trades fidelity for speed -- see + :func:`tls_search_gpu` for the measured cost. + + Returns + ------- + kernels : dict + Dictionary with 'standard' and 'keplerian' kernel functions + + Notes + ----- + The kernels stage the data and a limb-darkened transit template in + shared memory for physically realistic fitting (the depth/chi2 + accumulations are order-independent, so no phase sort is needed). + The shared-memory layout caps datasets at ~3,500 points; see + tls_search_gpu, which raises ValueError above the budget. + + The 'keplerian' kernel accepts per-period qmin/qmax arrays and is + the one every legacy-path search launches since 1.0 (the default + duration window is Keplerian, and the fixed opt-in window is passed + as constant arrays). The 'standard' kernel hard-codes the pre-1.0 + constant window [0.005, 0.15] and is retained only for API + compatibility of this dict; no wrapper launches it. + """ + # Compiling a kernel needs an active CUDA context (lazily created). + ensure_context() + + cppd = dict(BLOCK_SIZE=block_size, + T0_OVERSAMPLE=float(t0_oversample)) + + kernel_name = 'tls' + kernel_txt = _module_reader(find_kernel(kernel_name), cpp_defs=cppd) + + # Compile with fast math + # no_extern_c=True needed for proper extern "C" handling + module = SourceModule(kernel_txt, options=['--use_fast_math'], no_extern_c=True) + + # Get both kernel functions + kernels = { + 'standard': module.get_function('tls_search_kernel'), + 'keplerian': module.get_function('tls_search_kernel_keplerian') + } + + return kernels + + +class TLSMemory: + """ + Memory management for TLS GPU computations. + + This class handles allocation and transfer of data between CPU and GPU + for TLS periodogram calculations. + + Parameters + ---------- + max_ndata : int + Maximum number of data points + max_nperiods : int + Maximum number of trial periods + stream : pycuda.driver.Stream, optional + CUDA stream for async operations + + Attributes + ---------- + t, y, dy : ndarray + Pinned CPU arrays for time, flux, uncertainties. ``t`` holds + the times MINUS ``epoch`` (see below), cast to float32 after + the subtraction so that BJD-scale inputs keep their phase + precision. + epoch : float + ``floor(min(t))`` of the last ``setdata`` call (0.0 before any + data is set); the legacy kernel folds relative to it, so its + per-period ``best_t0`` phases are relative to ``epoch``. + t_g, y_g, dy_g : gpuarray + GPU arrays for data + periods_g, chi2_g : gpuarray + GPU arrays for periods and chi-squared values + best_t0_g, best_duration_g, best_depth_g : gpuarray + GPU arrays for best-fit parameters + """ + + def __init__(self, max_ndata, max_nperiods, stream=None, **kwargs): + # Constructing GPU memory is a "first GPU use" -- retain the CUDA + # primary context now (no longer created eagerly at import). + ensure_context() + self.max_ndata = max_ndata + self.max_nperiods = max_nperiods + self.stream = stream + self.rtype = np.float32 + # Pinned (page-locked) host buffers by default for async overlap; + # graceful fallback to page-aligned if pinning fails. + self.pinned = kwargs.get('pinned', True) + # floor(min(t)) subtracted from the times in setdata + self.epoch = 0.0 + + # CPU pinned memory for fast transfers + self.t = None + self.y = None + self.dy = None + + # GPU memory + self.t_g = None + self.y_g = None + self.dy_g = None + self.periods_g = None + self.qmin_g = None # Keplerian duration constraints + self.qmax_g = None # Keplerian duration constraints + self.chi2_g = None + self.best_t0_g = None + self.best_duration_g = None + self.best_depth_g = None + self.template_g = None + + self.allocate_host_arrays() + + def allocate_host_arrays(self): + """Allocate host transfer buffers (page-locked by default, with a + page-aligned fallback if pinning fails).""" + p = self.pinned + nd, npd = (self.max_ndata,), (self.max_nperiods,) + + self.t = host_array(nd, self.rtype, pinned=p) + self.y = host_array(nd, self.rtype, pinned=p) + self.dy = host_array(nd, self.rtype, pinned=p) + + self.periods = host_array(npd, self.rtype, pinned=p) + self.chi2 = host_array(npd, self.rtype, pinned=p) + self.best_t0 = host_array(npd, self.rtype, pinned=p) + self.best_duration = host_array(npd, self.rtype, pinned=p) + self.best_depth = host_array(npd, self.rtype, pinned=p) + + # Keplerian duration constraints + self.qmin = host_array(npd, self.rtype, pinned=p) + self.qmax = host_array(npd, self.rtype, pinned=p) + + def allocate_gpu_arrays(self, ndata=None, nperiods=None): + """Allocate GPU memory.""" + if ndata is None: + ndata = self.max_ndata + if nperiods is None: + nperiods = self.max_nperiods + + self.t_g = gpuarray.zeros(ndata, dtype=self.rtype) + self.y_g = gpuarray.zeros(ndata, dtype=self.rtype) + self.dy_g = gpuarray.zeros(ndata, dtype=self.rtype) + self.periods_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.qmin_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.qmax_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.chi2_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.best_t0_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.best_duration_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.best_depth_g = gpuarray.zeros(nperiods, dtype=self.rtype) + + def set_template(self, template): + """Transfer transit template to GPU. + + Parameters + ---------- + template : ndarray + Float32 template array from generate_transit_template() + """ + template = np.asarray(template, dtype=self.rtype) + self.template_g = gpuarray.to_gpu(template) + + def setdata(self, t, y, dy, periods=None, qmin=None, qmax=None, transfer=True): + """ + Set data for TLS computation. + + Parameters + ---------- + t : array_like + Observation times + y : array_like + Flux measurements + dy : array_like + Flux uncertainties + periods : array_like, optional + Trial periods + qmin : array_like, optional + Minimum fractional duration per period (for Keplerian search) + qmax : array_like, optional + Maximum fractional duration per period (for Keplerian search) + transfer : bool, optional + Transfer to GPU immediately (default: True) + """ + ndata = len(t) + + # Subtract the epoch floor(min t) in float64 BEFORE the float32 + # cast: folding raw BJD-scale float32 times loses the phase + # entirely (float32 resolves 0.25 d at 2.45e6), and the fold + # origin must be the same floor(min t) the fast path uses so + # that 't0_phase' means the same thing on both paths. + t64 = np.asarray(t, dtype=np.float64) + self.epoch = float(np.floor(t64.min())) if ndata else 0.0 + self.t[:ndata] = (t64 - self.epoch).astype(self.rtype) + self.y[:ndata] = np.asarray(y).astype(self.rtype) + self.dy[:ndata] = np.asarray(dy).astype(self.rtype) + + if periods is not None: + nperiods = len(periods) + self.periods[:nperiods] = np.asarray(periods).astype(self.rtype) + + if qmin is not None: + nperiods = len(qmin) + self.qmin[:nperiods] = np.asarray(qmin).astype(self.rtype) + + if qmax is not None: + nperiods = len(qmax) + self.qmax[:nperiods] = np.asarray(qmax).astype(self.rtype) + + # Allocate GPU memory if needed + if self.t_g is None or len(self.t_g) < ndata: + self.allocate_gpu_arrays(ndata, len(periods) if periods is not None else self.max_nperiods) + + # Transfer to GPU + if transfer: + self.transfer_to_gpu(ndata, len(periods) if periods is not None else None, + qmin is not None, qmax is not None) + + def transfer_to_gpu(self, ndata, nperiods=None, has_qmin=False, has_qmax=False): + """Transfer data from CPU to GPU.""" + if self.stream is None: + self.t_g.set(self.t[:ndata]) + self.y_g.set(self.y[:ndata]) + self.dy_g.set(self.dy[:ndata]) + if nperiods is not None: + self.periods_g.set(self.periods[:nperiods]) + if has_qmin: + self.qmin_g.set(self.qmin[:nperiods]) + if has_qmax: + self.qmax_g.set(self.qmax[:nperiods]) + else: + self.t_g.set_async(self.t[:ndata], stream=self.stream) + self.y_g.set_async(self.y[:ndata], stream=self.stream) + self.dy_g.set_async(self.dy[:ndata], stream=self.stream) + if nperiods is not None: + self.periods_g.set_async(self.periods[:nperiods], stream=self.stream) + if has_qmin: + self.qmin_g.set_async(self.qmin[:nperiods], stream=self.stream) + if has_qmax: + self.qmax_g.set_async(self.qmax[:nperiods], stream=self.stream) + + def set_duration_bounds(self, qmin, qmax): + """Stage and transfer per-period duration bounds only (used when + the caller manages the data transfer itself with + ``transfer_to_device=False``).""" + nperiods = len(qmin) + self.qmin[:nperiods] = np.asarray(qmin).astype(self.rtype) + self.qmax[:nperiods] = np.asarray(qmax).astype(self.rtype) + if self.qmin_g is None or len(self.qmin_g) < nperiods: + self.qmin_g = gpuarray.zeros(nperiods, dtype=self.rtype) + self.qmax_g = gpuarray.zeros(nperiods, dtype=self.rtype) + if self.stream is None: + self.qmin_g.set(self.qmin[:nperiods]) + self.qmax_g.set(self.qmax[:nperiods]) + else: + self.qmin_g.set_async(self.qmin[:nperiods], stream=self.stream) + self.qmax_g.set_async(self.qmax[:nperiods], stream=self.stream) + + def transfer_from_gpu(self, nperiods): + """Transfer results from GPU to CPU.""" + if self.stream is None: + self.chi2[:nperiods] = self.chi2_g.get()[:nperiods] + self.best_t0[:nperiods] = self.best_t0_g.get()[:nperiods] + self.best_duration[:nperiods] = self.best_duration_g.get()[:nperiods] + self.best_depth[:nperiods] = self.best_depth_g.get()[:nperiods] + else: + self.chi2_g.get_async(ary=self.chi2, stream=self.stream) + self.best_t0_g.get_async(ary=self.best_t0, stream=self.stream) + self.best_duration_g.get_async(ary=self.best_duration, stream=self.stream) + self.best_depth_g.get_async(ary=self.best_depth, stream=self.stream) + # The host buffers are page-locked, so these copies are + # genuinely asynchronous; callers read them immediately after + # this returns, so sync here (matches BLSBatchMemory). + self.stream.synchronize() + + @classmethod + def fromdata(cls, t, y, dy, periods=None, **kwargs): + """ + Create TLSMemory instance from data. + + Parameters + ---------- + t, y, dy : array_like + Time series data + periods : array_like, optional + Trial periods + **kwargs + Passed to __init__ + + Returns + ------- + memory : TLSMemory + Initialized memory object + """ + max_ndata = kwargs.get('max_ndata', len(t)) + max_nperiods = kwargs.get('max_nperiods', + len(periods) if periods is not None else 10000) + + mem = cls(max_ndata, max_nperiods, **kwargs) + mem.setdata(t, y, dy, periods=periods, transfer=kwargs.get('transfer', True)) + + return mem + + +def _tls_search_gpu_binned(t, y, dy, periods=None, *, + qmin=None, qmax=None, n_durations=15, + R_star=1.0, M_star=1.0, + period_min=None, period_max=None, n_transits_min=2, + oversampling_factor=3, duration_grid_step=1.1, + R_planet_min=0.5, R_planet_max=5.0, + limb_dark='quadratic', u=None, + block_size=None, t0_oversample=3.0, + kernel=None, memory=None, stream=None, + transfer_to_device=True, transfer_to_host=True, + use_fast=True, refine_top_k=50, + refine_oversample=33.0, nbins=None, + R_planet=1.0, qmin_fac=0.5, qmax_fac=2.0, + duration_window='keplerian', sde_kernel_size=None, + **kwargs): + """ + Run Transit Least Squares search on GPU. + + Parameters + ---------- + t : array_like + Observation times (days). Absolute BJD-scale times are safe on + both paths: the epoch ``floor(min(t))`` is subtracted in float64 + before any float32 cast (the fast path then folds with a + float-float pair; the legacy path folds the shifted float32 + times, so its phase precision degrades with the baseline, about + 1e-4 d at 1400 d). + y : array_like + Fluxes, normalized so the out-of-transit baseline is ~1.0. The + transit model is ``1 - depth * T`` with a FIXED baseline of 1: + no TLS path rescales the input or fits a free out-of-transit + level (see Notes), so unnormalized flux (e.g. raw counts) gives + meaningless depths. + dy : array_like + Flux uncertainties + periods : array_like, optional + Custom period grid (any order; sorted internally, and every + per-period output array is returned in the caller's order). If + None, generated automatically (Ofir 2014 grid). + qmin, qmax : array_like, optional + Explicit per-period fractional duration bounds (aligned with + ``periods``; give both or neither). When omitted the window is + built by :func:`cuvarbase.tls_grids.duration_window` from the + stellar parameters (see ``duration_window``). + n_durations : int, optional + Number of log-spaced trial durations per period (default: 15). + Must be >= 2 on either path (at most 64 on the fast path). + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + period_min, period_max : float, optional + Period search range (days). Auto-computed if None. + n_transits_min : int, optional + Minimum number of transits required (default: 2) + oversampling_factor : float, optional + Period grid oversampling (default: 3) + duration_grid_step : float, optional + Duration grid spacing factor (default: 1.1) + R_planet_min, R_planet_max : float, optional + Planet radius range in Earth radii (default: 0.5 to 5.0) + limb_dark : str, optional + Limb darkening law (default: 'quadratic') + u : list, optional + Limb darkening coefficients (default: [0.4804, 0.1867]) + block_size : int, optional + CUDA block size (auto-selected if None) + t0_oversample : float, optional (default: 3.0) + Transit-epoch (t0) trial positions tested per transit duration. + The on-device epoch stride is ``duration_phase / t0_oversample``; + larger values resolve the transit time more finely (and recover + narrower transits) at a roughly linear increase in kernel time. + The reference ``transitleastsquares`` steps ~100x finer (every + cadence for dense data). Measured cost of the default 3: the + SDE of a P = 7.3 d, q = 0.021 transit varies by 17% (19.8-23.4) + with the injected epoch relative to the coarse grid (6.5% at + 33); for a narrow transit (M dwarf, 3.4 cadences of 30 min) + SDE 29.1 at 3 vs 32.9 at 10 and 32.7 at 33 (-11%). Raise it to + 10 (matches 33 within 1% in those runs) for sensitivity-critical + searches. Distinct values compile and cache distinct kernels. + See :func:`cuvarbase.tls_grids.t0_grid_size` for the resulting + grid size. + kernel : PyCUDA function, optional + Pre-compiled kernel (legacy path). Must be the ``'keplerian'`` + kernel of :func:`compile_tls` (per-period duration bounds); + the ``'standard'`` kernel has a different signature and is no + longer launched by any wrapper. + memory : TLSMemory, optional + Pre-allocated memory object (legacy path) + stream : cuda.Stream, optional + CUDA stream for async execution (legacy path) + transfer_to_device : bool, optional + Transfer data to GPU (default: True). With False the caller must + have staged ``t``, ``y``, ``dy`` and an ASCENDING ``periods`` + grid through ``memory.setdata`` (which epoch-subtracts the + times); a non-ascending grid raises ValueError. The per-period + duration bounds are uploaded here regardless. + transfer_to_host : bool, optional + Transfer results to CPU (default: True) + use_fast : bool, optional (default: True) + Use the phase-binned batch engine with exact top-K refinement + (no ndata cap; see :func:`tls_search_batch`). Ignored with a + warning when a pre-compiled kernel, external memory/stream, or + transfer control is supplied — those fall back to the legacy + per-point kernel. + refine_top_k : int, optional (default: 50) + Fast path only: number of best candidate periods per lightcurve + re-fit exactly on a finer local (duration, t0) grid (0 + disables). + refine_oversample : float, optional (default: 33.0) + Fast path only: refinement epoch stride = duration / this. + nbins : int, optional + Fast path only: phase-bin override (power of two). By default + the period grid is banded into per-band bin counts + automatically. + R_planet : float, optional + Fiducial planet radius (Earth radii) of the Keplerian duration + window (default: 1.0) + qmin_fac, qmax_fac : float, optional + Keplerian duration window factors: search ``[qmin_fac, + qmax_fac] * q_kep(P)`` at each period (default 0.5, 2.0) + duration_window : {'keplerian', 'fixed'}, optional + Duration window used when ``qmin``/``qmax`` are omitted. + 'keplerian' (default) derives per-period bounds from + ``R_star``/``M_star``/``R_planet`` (the same window + :func:`tls_transit` and :func:`tls_search_batch` use). 'fixed' + is the pre-1.0 constant window [0.005, 0.15] at every period, + kept as an opt-in that warns when the Keplerian duration falls + outside it: beyond P ~ 60 d (Sun-like) no trial duration is + physical there and a transit is returned at an alias period + with a biased depth (measured: P = 365 d on a 1400-d baseline + came back at 182.5 d with half the depth). + sde_kernel_size : int, optional + Running-median window of the SDE detrend (see + :func:`cuvarbase.tls_stats.signal_detection_efficiency`). + **kwargs + ``n_template`` (int, legacy path only): number of samples in + the transit template staged in shared memory (default 1000). + Any other keyword raises ``TypeError``; in particular the + null-bootstrap FAP (``fap_null_draws``/``fap_seed``) exists + only on :func:`tls_search_batch`. + + Returns + ------- + results : dict + Dictionary with keys: + + - 'periods': trial periods (the caller's grid and order) + - 'chi2': chi-squared per trial period (NaN where no valid + solution) + - 'best_t0_per_period', 'best_duration_per_period', + 'best_depth_per_period', 'valid_periods', 'n_failed_periods' + - 'period', 'period_uncertainty': best-fit period (days) + - 'T0': absolute mid-transit time (days, same scale as ``t``) + of the first transit at or after ``min(t)``, i.e. + ``min(t) <= T0 < min(t) + period`` -- the convention of the + reference package. Fold with ``((t - T0) / period) % 1`` to + put the transit at phase 0. + - 't0_phase': the same epoch as a fold phase in [0, 1) relative + to ``floor(min(t))``: ``T0 = floor(min(t)) + t0_phase * + period`` shifted by whole periods into the range above. + - 'duration' (days), 'depth' (fractional), 'chi2_min' + - 'SDE', 'SDE_raw': signal detection efficiency of the + per-period spectrum, ``SR = chi2_min / chi2`` (the reference + definition; see :mod:`cuvarbase.tls_stats`) + - 'SNR': ``sqrt(chi2_0 - chi2_min)``, the delta-chi-squared + significance of the best fit over the constant model + - 'power', 'SR': detrended / raw signal-residue spectra + - 'n_transits', 'R_star', 'M_star' + + There is NO 'FAP' key: the pre-1.0 value was an uncalibrated + function of the SDE (23% of pure-noise light curves got + FAP < 0.01). Use ``tls_search_batch(fap_null_draws=N)`` for an + empirical, per-configuration false-alarm probability. + + A light curve with no valid solution at any trial period (flat + or noiseless flux) returns SDE = 0, NaN best-fit parameters and + the message under 'error' (with a warning) instead of raising. + + Notes + ----- + The default fast path binds the data once per period into phase + bins and refines the best candidates exactly; the legacy path + (``use_fast=False``) is the original per-point kernel, capped at + ~3,500 points by its shared-memory layout. + + No free baseline term. The model is ``1 - depth * T(phase)`` with + the out-of-transit level fixed at exactly 1 (shared with the + reference package). A flux-normalization offset of a fraction of + the per-point scatter changes the SDE materially and asymmetrically + (measured, P = 7.3 d, sigma = 1e-3: +5e-4 raised the SDE from 21.5 + to 29.3 with the depth 20% low; -5e-4 halved it to 10.1; -1e-3 gave + the wrong period). Normalize to a median (not mean) out-of-transit + level of 1 to ~0.1 sigma per point before searching. + """ + # The only keyword the legacy kernel reads from **kwargs is + # n_template. Anything else used to be accepted and dropped without + # a word, so tls_search(t, y, dy, fap_null_draws=200) returned a + # result with no 'FAP' key and no diagnostic. Reject unknown + # keywords the way a normal Python signature would. + unknown = set(kwargs) - _TLS_SEARCH_GPU_EXTRA_KWARGS + if unknown: + hint = '' + if unknown & {'fap_null_draws', 'fap_seed'}: + hint = ("; the null-bootstrap FAP is available only from " + "tls_search_batch(fap_null_draws=..., fap_seed=...)") + raise TypeError( + "tls_search_gpu() got unexpected keyword argument(s) %s%s" + % (', '.join(repr(k) for k in sorted(unknown)), hint)) + if u is None: + u = [0.4804, 0.1867] + # Validate the light curve before anything else: the automatic + # period grid is built from t, and a NaN sample or dy = 0 used to + # travel all the way to the kernel (chi2 off by a factor ~1e3 on + # the fast path; Sep 2026 audit, defect 23). + _check_tls_lightcurve(t, y, dy, name='tls_search_gpu') + # Both paths: the legacy kernel took n_durations unchecked and + # n_durations <= 1 made its duration step 0/0. + n_durations = _validate_n_durations(n_durations) + + # Validate stellar parameters + tls_grids.validate_stellar_parameters(R_star, M_star) + + # Validate limb darkening + tls_models.validate_limb_darkening_coeffs(u, limb_dark) + + # Generate period grid if not provided + if periods is None: + periods = tls_grids.period_grid_ofir( + t, R_star=R_star, M_star=M_star, + oversampling_factor=oversampling_factor, + period_min=period_min, period_max=period_max, + n_transits_min=n_transits_min + ) + + # The fast path keeps t in float64 for epoch subtraction; only the + # legacy path (below) downcasts inputs to float32 up front. + periods = np.asarray(_validate_periods(periods), dtype=np.float32) + nperiods = len(periods) + + # ---- Per-period duration window (caller's grid order) ---- + if (qmin is None) != (qmax is None): + raise ValueError("provide both qmin and qmax, or neither") + if qmin is not None: + if duration_window != 'keplerian': + raise ValueError("duration_window applies only when qmin/qmax " + "are not given") + qmin_arr = np.asarray(qmin, dtype=np.float64) + qmax_arr = np.asarray(qmax, dtype=np.float64) + if len(qmin_arr) != nperiods or len(qmax_arr) != nperiods: + raise ValueError( + "qmin and qmax must have same length as periods " + "(%d)" % nperiods) + else: + qmin_arr, qmax_arr = tls_grids.duration_window( + periods.astype(np.float64), R_star=R_star, M_star=M_star, + R_planet=R_planet, qmin_fac=qmin_fac, qmax_fac=qmax_fac, + window=duration_window) + _validate_q_window(qmin_arr, qmax_arr, periods=periods) + + # Fast path: phase-binned batch engine with exact top-K refinement. + # Falls through to the legacy per-point kernel when the caller uses + # the low-level plumbing (pre-compiled kernel, external memory or + # stream, or transfer control), which the batch engine does not + # expose. + fast_gate = (kernel is None and memory is None and stream is None + and transfer_to_device and transfer_to_host) + if use_fast and not fast_gate: + warnings.warn( + "use_fast=True is ignored because a pre-compiled kernel, " + "external memory/stream, or transfer control was supplied; " + "falling back to the legacy per-point kernel (which caps " + "ndata at ~3,500 points)") + if use_fast and fast_gate: + r = _tls_search_batch_binned( + [(t, y, dy)], + periods=periods, qmin=qmin_arr, qmax=qmax_arr, + n_durations=n_durations, t0_oversample=t0_oversample, + refine_top_k=refine_top_k, + refine_oversample=refine_oversample, + block_size=block_size, nbins=nbins, + limb_dark=limb_dark, u=u, + R_star=R_star, M_star=M_star, + return_arrays=True, sde_kernel_size=sde_kernel_size, + _warn_failed=True)[0] + + results = { + 'periods': periods, + 'chi2': r['chi2'], + 'best_t0_per_period': r['best_t0_per_period'], + 'best_duration_per_period': r['best_duration_per_period'], + 'best_depth_per_period': r['best_depth_per_period'], + 'valid_periods': r['valid_periods'], + 'n_failed_periods': r['n_failed_periods'], + 'period': r['period'], + 'period_uncertainty': r['period_uncertainty'], + 'T0': r['T0'], + 't0_phase': r['t0_phase'], + 'duration': r['duration'], + 'depth': r['depth'], + 'chi2_min': r['chi2_min'], + 'SDE': r['SDE'], + 'SDE_raw': r['SDE_raw'], + 'SNR': r['SNR'], + 'power': r['power'], + 'SR': r['SR'], + 'n_transits': r['n_transits'], + 'R_star': R_star, + 'M_star': M_star, + } + if 'error' in r: + results['error'] = r['error'] + return results + + # ---- Legacy per-point kernel path ---- + + # float64 copies for the epoch, span and chi2_0; the kernel inputs + # are cast to float32 by TLSMemory.setdata (after epoch subtraction) + t64 = np.asarray(t, dtype=np.float64) + y64 = np.asarray(y, dtype=np.float64) + dy64 = np.asarray(dy, dtype=np.float64) + ndata = len(t64) + if len(y64) != ndata or len(dy64) != ndata: + raise ValueError("t, y, dy lengths differ (%d, %d, %d)" + % (ndata, len(y64), len(dy64))) + + # Ascending trial grid for the statistics; the duration window is + # aligned with the caller's order, so reorder it the same way. + periods_sorted, order = _sort_period_grid(periods) + if order is not None: + if memory is not None and not transfer_to_device: + raise ValueError( + "transfer_to_device=False requires an ascending period " + "grid: the periods staged on the device through " + "memory.setdata must match the sorted grid the " + "statistics assume") + qmin_arr = qmin_arr[order] + qmax_arr = qmax_arr[order] + qmin32 = np.ascontiguousarray(qmin_arr, dtype=np.float32) + qmax32 = np.ascontiguousarray(qmax_arr, dtype=np.float32) + + # Choose block size + if block_size is None: + block_size = _choose_block_size(ndata) + + # Shared-memory budget check BEFORE compiling kernels or touching + # the GPU. Layout: phases[ndata] + y_sorted[ndata] + + # dy_sorted[ndata] + template[n_template] + 4 thread arrays of + # block_size floats, 4 bytes each. The default CUDA cap of 48 KB + # per block bounds ndata at ~3,500 points for the default + # template/block sizes. + n_template = kwargs.get('n_template', 1000) + shared_mem_size = (3 * ndata + n_template + 4 * block_size) * 4 + if shared_mem_size > _SHARED_MEM_LIMIT: + max_ndata = (_SHARED_MEM_LIMIT // 4 + - n_template - 4 * block_size) // 3 + raise ValueError( + "ndata=%d requires %d bytes of shared memory per block but " + "the kernel limit is %d: the TLS kernels support at most " + "~%d points with n_template=%d and block_size=%d. Bin or " + "split the light curve." % (ndata, shared_mem_size, + _SHARED_MEM_LIMIT, max_ndata, + n_template, block_size)) + + # Get or compile kernels. Every legacy search runs the 'keplerian' + # kernel (per-period duration bounds); the 'standard' kernel with + # its hard-coded [0.005, 0.15] window is no longer launched. + if kernel is None: + kernels = _get_cached_kernels(block_size, t0_oversample=t0_oversample) + kernel = kernels['keplerian'] + + # Allocate or use existing memory (setdata epoch-subtracts t) + if memory is None: + memory = TLSMemory(ndata, nperiods, stream=stream) + memory.setdata(t64, y64, dy64, periods=periods_sorted, + qmin=qmin32, qmax=qmax32, + transfer=transfer_to_device) + elif transfer_to_device: + memory.setdata(t64, y64, dy64, periods=periods_sorted, + qmin=qmin32, qmax=qmax32, transfer=True) + else: + # the caller staged t/y/dy/periods; the duration bounds are ours + memory.set_duration_bounds(qmin32, qmax32) + + # Generate and transfer transit template (n_template and + # shared_mem_size were computed with the guard above) + if memory.template_g is None: + template = tls_models.generate_transit_template( + n_template=n_template, limb_dark=limb_dark, u=u + ) + memory.set_template(template) + + # Launch kernel + grid = (nperiods, 1, 1) + block = (block_size, 1, 1) + + kernel_args = [ + memory.t_g, memory.y_g, memory.dy_g, + memory.periods_g, memory.qmin_g, memory.qmax_g, + memory.template_g, + np.int32(ndata), np.int32(nperiods), np.int32(n_durations), + np.int32(n_template), + memory.chi2_g, memory.best_t0_g, + memory.best_duration_g, memory.best_depth_g, + ] + + kernel_kwargs = dict(block=block, grid=grid, shared=shared_mem_size) + if stream is not None: + kernel_kwargs['stream'] = stream + + kernel(*kernel_args, **kernel_kwargs) + + # Transfer results if requested + if transfer_to_host: + if stream is not None: + stream.synchronize() + memory.transfer_from_gpu(nperiods) + + chi2_vals = memory.chi2[:nperiods].copy() + best_t0_vals = memory.best_t0[:nperiods].copy() + best_duration_vals = memory.best_duration[:nperiods].copy() + best_depth_vals = memory.best_depth[:nperiods].copy() + + # constant-model chi2 (float64) for the SNR; the kernel uses the + # same sigma^2 + 1e-10 regularizer + chi2_0 = float(np.sum((1.0 - y64) ** 2 / (dy64 ** 2 + 1e-10))) + tmin = float(t64.min()) + epoch = getattr(memory, 'epoch', None) + if epoch is None: + epoch = float(np.floor(tmin)) + + # Mask failed periods (1e30 sentinel) before any statistics: + # unmasked they collapse SDE to ~0 + valid = _mask_failed_periods(chi2_vals) + if not valid.any(): + results = _null_result(nperiods, chi2_0, + _NO_SOLUTION_MSG % nperiods, + periods=periods, arrays=True) + results.update({'R_star': R_star, 'M_star': M_star}) + return results + chi2_valid = chi2_vals[valid] + periods_valid = periods_sorted[valid] + + # Find best period among the valid ones + best_valid_idx = int(np.argmin(chi2_valid)) + best_idx = int(np.flatnonzero(valid)[best_valid_idx]) + best_period = float(periods_sorted[best_idx]) + best_chi2 = float(chi2_vals[best_idx]) + best_t0 = float(best_t0_vals[best_idx]) + best_duration = float(best_duration_vals[best_idx]) + best_depth = float(best_depth_vals[best_idx]) + + # Estimate number of transits + T_span = float(t64.max() - tmin) + n_transits = int(T_span / best_period) + + # Compute statistics on the valid periods only + stats = tls_stats.compute_all_statistics( + chi2_valid, periods_valid, best_valid_idx, + best_depth, best_duration, n_transits, + kernel_size=sde_kernel_size, + chi2_null=chi2_0, chi2_best=best_chi2) + + # Period uncertainty + period_uncertainty = tls_stats.compute_period_uncertainty( + periods_valid, chi2_valid, best_valid_idx + ) + + # Absolute mid-transit time: the kernel's phase is relative to + # the epoch floor(min t); report the first transit >= min(t) + T0 = _first_transit_at_or_after(epoch + best_t0 * best_period, + best_period, tmin) + + # Failed periods appear as NaN in the returned spectra; every + # per-period array goes back to the caller's grid order + def _expand(values): + full = np.full(nperiods, np.nan) + full[valid] = values + return _to_caller_order(full, order) + + results = { + # Raw outputs (NaN at failed periods) + 'periods': periods, + 'chi2': _to_caller_order(np.where(valid, chi2_vals, np.nan), + order), + 'best_t0_per_period': _to_caller_order(best_t0_vals, order), + 'best_duration_per_period': _to_caller_order( + best_duration_vals, order), + 'best_depth_per_period': _to_caller_order(best_depth_vals, + order), + 'valid_periods': _to_caller_order(valid, order), + 'n_failed_periods': int(nperiods - valid.sum()), + + # Best-fit parameters + 'period': best_period, + 'period_uncertainty': period_uncertainty, + 'T0': T0, + 't0_phase': best_t0, + 'duration': best_duration, + 'depth': best_depth, + 'chi2_min': best_chi2, + + # Statistics (computed on valid periods, expanded to the + # full grid with NaN at failed periods) + 'SDE': stats['SDE'], + 'SDE_raw': stats['SDE_raw'], + 'SNR': stats['SNR'], + 'power': _expand(stats['power']), + 'SR': _expand(stats['SR']), + + # Metadata + 'n_transits': n_transits, + 'R_star': R_star, + 'M_star': M_star, + } + else: + # Just return periods if not transferring + results = { + 'periods': periods, + 'chi2': None, + 'best_t0_per_period': None, + 'best_duration_per_period': None, + 'best_depth_per_period': None, + } + + return results + + +def tls_search(t, y, dy, **kwargs): + """ + High-level TLS search function. + + This is the main user-facing function for TLS searches. + + Parameters + ---------- + t, y, dy : array_like + Time series data + **kwargs + Passed to tls_search_gpu + + Returns + ------- + results : dict + Search results + + See Also + -------- + tls_search_gpu : Lower-level GPU function + tls_transit : Keplerian-aware search wrapper + """ + _check_tls_lightcurve(t, y, dy, name='tls_search') + return tls_search_gpu(t, y, dy, **kwargs) + + +def _tls_transit_binned(t, y, dy, *, R_star=1.0, M_star=1.0, R_planet=1.0, + qmin_fac=0.5, qmax_fac=2.0, n_durations=15, + period_min=None, period_max=None, n_transits_min=2, + oversampling_factor=3, **kwargs): + """ + Transit Least Squares search with Keplerian duration constraints. + + This is the TLS analog of BLS's eebls_transit() function. It uses stellar + parameters to focus the duration search on physically plausible values. + Since 1.0 :func:`tls_search_gpu` builds the same Keplerian window by + default, so this wrapper is equivalent to ``tls_search_gpu(t, y, dy, + R_star=..., M_star=..., R_planet=..., qmin_fac=..., qmax_fac=...)`` + and is kept for its explicit name and the explicit qmin/qmax it + passes. + + Parameters + ---------- + t : array_like + Observation times (days). Absolute BJD-scale times are safe: + ``floor(min(t))`` is subtracted in float64 before any float32 + cast (see :func:`tls_search_gpu`). + y : array_like + Fluxes, normalized so the out-of-transit baseline is ~1.0 + (NOT arbitrary units: the model is ``1 - depth * T`` with a + fixed baseline of 1 and no path rescales the input, so raw + counts give meaningless depths; see :func:`tls_search_gpu`). + dy : array_like + Flux uncertainties, in the same (normalized) units as ``y`` + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + R_planet : float, optional + Fiducial planet radius in Earth radii (default: 1.0) + Sets the central duration value around which to search + qmin_fac : float, optional + Minimum duration factor (default: 0.5) + Searches down to qmin_fac × q_keplerian + qmax_fac : float, optional + Maximum duration factor (default: 2.0) + Searches up to qmax_fac × q_keplerian + n_durations : int, optional + Number of duration samples per period (default: 15) + period_min, period_max : float, optional + Period search range (days). Auto-computed if None. + n_transits_min : int, optional + Minimum number of transits required (default: 2) + oversampling_factor : float, optional + Period grid oversampling (default: 3) + **kwargs + Additional parameters passed to tls_search_gpu + + Returns + ------- + results : dict + Search results with keys: + + - 'period': Best-fit period + - 'T0': absolute mid-transit time (days, same scale as ``t``) + of the first transit at or after min(t); 't0_phase' is the + fold phase relative to floor(min(t)) + - 'duration': Best transit duration + - 'depth': Best transit depth + - 'SDE': Signal Detection Efficiency + - 'periods': Trial periods + - 'chi2': Chi-squared values per period + + ... (see tls_search_gpu for full list) + + Notes + ----- + This function automatically generates: + + 1. Optimal period grid using Ofir (2014) algorithm + 2. Per-period duration ranges based on Keplerian physics + 3. Qmin/qmax arrays for focused duration search + + The duration search at each period focuses on physically plausible values: + + - For short periods: searches shorter durations + - For long periods: searches longer durations + - Scales with stellar density (M_star, R_star) + + This is much more efficient than searching a fixed fractional duration + range (0.5%-15%) at all periods -- and, unlike that fixed window, + stays physical at long periods (the fixed window excludes the + Keplerian duration beyond P ~ 60 d for a Sun-like star). + + Examples + -------- + >>> from cuvarbase import tls + >>> results = tls.tls_transit(t, y, dy, + ... R_star=1.0, M_star=1.0, + ... period_min=5.0, period_max=20.0) + >>> print(f"Best period: {results['period']:.4f} days") + >>> print(f"Transit depth: {results['depth']:.4f}") + + See Also + -------- + tls_search_gpu : Lower-level GPU function + tls_grids.duration_window : Per-period duration bounds (used here) + tls_grids.q_transit : Calculate Keplerian fractional duration + """ + _check_tls_lightcurve(t, y, dy, name='tls_transit') + + # Generate period grid + periods = tls_grids.period_grid_ofir( + t, R_star=R_star, M_star=M_star, + oversampling_factor=oversampling_factor, + period_min=period_min, period_max=period_max, + n_transits_min=n_transits_min + ) + + # Per-period Keplerian duration bounds. These are the same bounds + # duration_grid_keplerian returns as ``q_values * (qmin_fac, + # qmax_fac)`` -- tls_grids.duration_window is the shared window + # helper every other TLS entry point uses -- but without building + # the (nperiods x n_durations) duration table, which nothing + # downstream reads: tls_search_gpu takes only qmin/qmax and + # n_durations. Measured on an A40 (shared), old and new bodies + # interleaved in one process: tls_transit 4.51 -> 3.71 ms at 2,486 + # trial periods, 43.01 -> 24.70 at 42,001, 219.49 -> 159.75 at + # 171,688 (the table alone costs 0.80 / 13.92 / 58.95 ms). + qmin, qmax = tls_grids.duration_window( + periods, R_star=R_star, M_star=M_star, R_planet=R_planet, + qmin_fac=qmin_fac, qmax_fac=qmax_fac + ) + + # Run TLS search with Keplerian constraints + results = _tls_search_gpu_binned( + t, y, dy, + periods=periods, + qmin=qmin, + qmax=qmax, + n_durations=n_durations, + R_star=R_star, + M_star=M_star, + **kwargs + ) + + return results + + +# ===================================================================== +# Fast batch TLS engine (phase-binned scan + exact top-K refinement) +# ===================================================================== +# +# One kernel launch searches a whole batch of lightcurves over a shared +# period grid: grid = (nperiods, n_lightcurves), one block per +# (lightcurve, period). Each block folds its lightcurve once into +# shared-memory phase bins and scans every (duration, t0) trial against +# the bins, so trial cost is independent of ndata and there is no +# shared-memory cap on the lightcurve length. A second, exact kernel +# then re-fits the best `refine_top_k` candidate periods per lightcurve +# with per-point template evaluation on a finer local (duration, t0) +# grid. See kernels/tls_fast.cu for the algorithm notes. + +_TLS_FAST_NTEMPLATE = 1024 +_TLS_FAST_MAX_DURATIONS = 64 +_TLS_FAST_MAX_NBINS = 8192 +_TLS_FAST_DEFAULT_BLOCK = 256 + +# Chunking budgets (per kernel launch) +_TLS_FAST_MAX_OUT_FLOATS = 32 * 1024 * 1024 # per output array +_TLS_FAST_MAX_POINTS = 16 * 1024 * 1024 # concatenated data points +_TLS_FAST_MAX_GRID_Y = 65535 + + +def _device_max_shared(): + """Max opt-in dynamic shared memory per block on the current device.""" + ensure_context() + dev = cuda.Context.get_device() + try: + return dev.get_attribute( + cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK_OPTIN) + except Exception: + return dev.get_attribute( + cuda.device_attribute.MAX_SHARED_MEMORY_PER_BLOCK) + + +def _tls_fast_shared_size(block_size, nbins): + """Dynamic shared memory (bytes) for tls_fast_search_kernel.""" + nt = _TLS_FAST_NTEMPLATE + md = _TLS_FAST_MAX_DURATIONS + n_floats = 2 * nbins + 2 * (nt + 1) + 4 * block_size + md + n_ints = md + 1 + return 4 * (n_floats + n_ints) + + +def _tls_refine_shared_size(block_size): + """Dynamic shared memory (bytes) for tls_refine_kernel.""" + return 4 * ((_TLS_FAST_NTEMPLATE + 1) + 4 * (block_size // 32)) + + +def compile_tls_fast(block_size=_TLS_FAST_DEFAULT_BLOCK, nbins=2048, + t0_oversample=3.0, refine_nd=3): + """ + Compile the fast (batched, phase-binned) TLS kernels. + + Parameters + ---------- + block_size : int + CUDA block size (multiple of 32). + nbins : int + Number of phase bins (power of two). + t0_oversample : float + Epoch oversampling: t0 stride = duration / t0_oversample in the + coarse scan (same convention as the legacy kernels). + refine_nd : int + Number of local durations in the refinement kernel (odd; + default 3 spans one coarse duration-grid step each way). + + Returns + ------- + kernels : dict + {'search': ..., 'refine': ...} PyCUDA functions. + """ + ensure_context() + if block_size < 32 or (block_size & (block_size - 1)): + # the block max-reduction assumes a power-of-two blockDim + raise ValueError("block_size must be a power of two >= 32") + if nbins & (nbins - 1): + raise ValueError("nbins must be a power of two") + if int(refine_nd) != refine_nd or refine_nd < 2: + raise ValueError("refine_nd must be an integer >= 2 " + "(odd recommended so the coarse duration sits " + "on the refinement grid)") + + cppd = dict(BLOCK_SIZE=block_size, + NBINS=nbins, + NTEMPLATE=_TLS_FAST_NTEMPLATE, + MAX_DURATIONS=_TLS_FAST_MAX_DURATIONS, + T0_OVERSAMPLE=float(t0_oversample), + REFINE_ND=refine_nd) + kernel_txt = _module_reader(find_kernel('tls_fast'), cpp_defs=cppd) + module = SourceModule(kernel_txt, options=['--use_fast_math'], + no_extern_c=True) + search = module.get_function('tls_fast_search_kernel') + refine = module.get_function('tls_refine_kernel') + + smem = _tls_fast_shared_size(block_size, nbins) + if smem > _SHARED_MEM_LIMIT: + max_shared = _device_max_shared() + if smem > max_shared: + raise ValueError( + "TLS fast kernel wants %d bytes of shared memory per " + "block but the device caps at %d; reduce nbins (or " + "block_size)" % (smem, max_shared)) + # opt in to >48KB dynamic shared memory (sm_70+) + search.set_attribute( + cuda.function_attribute.MAX_DYNAMIC_SHARED_SIZE_BYTES, smem) + + return {'search': search, 'refine': refine} + + +def _get_cached_fast_kernels(block_size, nbins, t0_oversample, + refine_nd=3): + key = ('fast', block_size, nbins, float(t0_oversample), refine_nd) + with _kernel_cache_lock: + if key in _kernel_cache: + _kernel_cache.move_to_end(key) + return _kernel_cache[key] + compiled = compile_tls_fast(block_size=block_size, nbins=nbins, + t0_oversample=t0_oversample, + refine_nd=refine_nd) + _kernel_cache[key] = compiled + _kernel_cache.move_to_end(key) + if len(_kernel_cache) > _KERNEL_CACHE_MAX_SIZE: + _kernel_cache.popitem(last=False) + return compiled + + +def _preprocess_batch(lightcurves): + """Epoch-subtract, weight, and concatenate lightcurves (float64 + accumulation; times stored as a float-float hi/lo pair so the + kernels can fold at ~float64 precision with pure FP32 math). + + Returns (t_hi, t_lo, a_c, b_c, offs, lens, chi2_0, epochs, spans); + chi2_0 stays float64 for cancellation-free chi2 reconstruction. + """ + n_lc = len(lightcurves) + lens = np.array([len(lc[0]) for lc in lightcurves], dtype=np.int64) + for i, (lc, n) in enumerate(zip(lightcurves, lens)): + if n > np.iinfo(np.int32).max: + raise ValueError( + "lightcurve %d has %d points; the TLS kernels index " + "points within a chunk with int32" % (i, n)) + # equal lengths, finite t/y/dy, dy > 0 (dy = 0 gave a chi2 + # 1.3e3 times too large on the fast path; Sep 2026 audit, + # defect 23) + _check_tls_lightcurve(lc[0], lc[1], lc[2], + name='lightcurve %d' % i) + # batch-wide offsets in int64 (a large survey can exceed 2^31 + # total points); per-chunk offsets are rebased and cast to int32 + # at upload, where the chunk-size cap keeps them small + offs = np.zeros(n_lc, dtype=np.int64) + if n_lc > 1: + offs[1:] = np.cumsum(lens)[:-1] + total = int(lens.sum()) + + t_hi = np.empty(total, dtype=np.float32) + t_lo = np.empty(total, dtype=np.float32) + a_c = np.empty(total, dtype=np.float32) + b_c = np.empty(total, dtype=np.float32) + chi2_0 = np.empty(n_lc, dtype=np.float64) + epochs = np.empty(n_lc, dtype=np.float64) + spans = np.empty(n_lc, dtype=np.float64) + + for i, (t, y, dy) in enumerate(lightcurves): + t64 = np.asarray(t, dtype=np.float64) + y64 = np.asarray(y, dtype=np.float64) + dy64 = np.asarray(dy, dtype=np.float64) + epoch = np.floor(t64.min()) + # sigma^2 regularizer matches the legacy kernel (float32 dy) + s2 = dy64 * dy64 + 1e-10 + o, n = int(offs[i]), int(lens[i]) + tshift = t64 - epoch + hi = tshift.astype(np.float32) + t_hi[o:o + n] = hi + t_lo[o:o + n] = (tshift - hi.astype(np.float64)).astype(np.float32) + resid = 1.0 - y64 + a_c[o:o + n] = resid / s2 + b_c[o:o + n] = 1.0 / s2 + chi2_0[i] = np.sum(resid * resid / s2) + epochs[i] = epoch + spans[i] = t64.max() - t64.min() + + return t_hi, t_lo, a_c, b_c, offs, lens, chi2_0, epochs, spans + + +def _tls_search_batch_binned(lightcurves, *, R_star=1.0, M_star=1.0, R_planet=1.0, + periods=None, qmin=None, qmax=None, + period_min=None, period_max=None, + n_transits_min=2, oversampling_factor=3, + qmin_fac=0.5, qmax_fac=2.0, n_durations=15, + t0_oversample=3.0, + refine_top_k=50, refine_oversample=33.0, + block_size=None, nbins=None, + limb_dark='quadratic', u=None, + return_arrays=False, sde_kernel_size=None, + fap_null_draws=0, fap_seed=None, + _warn_failed=False): + """ + Survey-scale Transit Least Squares search over a batch of + lightcurves sharing one trial-period grid. + + This is the fast path for N >> 1 lightcurves: a single kernel + launch (per chunk) searches every (lightcurve, period) pair with a + phase-binned scan, then an exact per-point refinement kernel + re-fits the ``refine_top_k`` best candidate periods per lightcurve + on a finer local (duration, t0) grid. There is no cap on ndata. + + Parameters + ---------- + lightcurves : list of (t, y, dy) tuples + Times (days), fluxes (normalized to a baseline of 1.0), and + flux uncertainties. Each lightcurve's epoch floor(min(t)) is + subtracted internally (float64), so BJD-scale times are safe. + R_star, M_star : float + Stellar radius/mass in solar units; set the period grid and the + Keplerian duration window (shared by all lightcurves). + R_planet : float + Fiducial planet radius (Earth radii) for the duration window. + periods, qmin, qmax : array_like, optional + Explicit trial grid: periods (days, any order -- sorted + internally, per-period output arrays come back in the caller's + order) and per-period fractional duration bounds aligned with + ``periods``. Auto-generated (Ofir 2014 grid + Keplerian + durations from :func:`cuvarbase.tls_grids.duration_window`) + when omitted. + period_min, period_max : float, optional + Period search range for the auto grid. + n_transits_min, oversampling_factor : optional + Auto period-grid parameters (see tls_grids.period_grid_ofir). + qmin_fac, qmax_fac : float + Keplerian duration window factors (search [qmin_fac*q, + qmax_fac*q] at each period). + n_durations : int + Trial durations per period (log-spaced), max 64. + t0_oversample : float + Coarse epoch oversampling; t0 stride = duration / t0_oversample. + refine_top_k : int + Number of best candidate periods per lightcurve re-fit exactly + (default 50; 0 disables refinement). + refine_oversample : float + Refinement epoch stride = duration / refine_oversample (the + reference transitleastsquares package uses ~100). + block_size : int, optional + CUDA block size override (power of two). By default each + bin-count band picks its own (256, or 512 for bands with 4096+ + bins, shrunk to fit the device's shared-memory cap). + nbins : int, optional + Phase bins (power of two). Auto-sized so a bin is no wider than + the narrowest trial duration / t0_oversample, within the + device's shared-memory limit. + limb_dark, u : optional + Limb-darkening law/coefficients for the transit template + (defaults: ``'quadratic'``, ``[0.4804, 0.1867]``). + return_arrays : bool + Also return the per-period chi2/t0/duration/depth arrays and + derived spectra for each lightcurve (adds D2H transfer time). + sde_kernel_size : int, optional + Median-detrend window for the SDE statistic (see tls_stats). + fap_null_draws : int, optional (default: 0) + Opt-in empirical false-alarm probability. For each lightcurve, + ``fap_null_draws`` null realizations are built by randomly + permuting the (y, dy) pairs over the observation times (a + white-noise null that keeps the sampling, the point count and + the noise distribution but destroys any coherent signal and + any red noise), searched on the identical trial grid and + settings (coarse scan only; the SDE never uses the + refinement), and the result gets ``'FAP' = (1 + n_exceed) / + (fap_null_draws + 1)`` where ``n_exceed`` counts null SDEs + >= the observed SDE, plus the null SDEs under ``'SDE_null'``. + Cost: ``fap_null_draws`` extra searches per lightcurve + (measured 400 pure-noise searches of 2880 points x 6157 + periods in 1.6 s on an A40). The smallest resolvable FAP is + ``1 / (fap_null_draws + 1)``. No 'FAP' key is returned + otherwise: the pre-1.0 value was an uncalibrated function of + the SDE. + fap_seed : int or None, optional + Seed of the ``numpy.random.RandomState`` used for the null + permutations (None: fresh entropy). + + Returns + ------- + results : list of dict + One dict per lightcurve: + 'period', 'period_uncertainty', 't0_phase' (fold phase of the + mid-transit relative to floor(min t)), 'T0' (absolute + mid-transit time of the first transit at or after min(t), so + ``min(t) <= T0 < min(t) + period``; fold with + ``((t - T0) / period) % 1``), 'duration', 'depth', 'chi2_min', + 'SDE', 'SDE_raw' (``SR = chi2_min / chi2`` statistic, see + :mod:`cuvarbase.tls_stats`), 'SNR' (``sqrt(chi2_0 - + chi2_min)``), 'n_transits', 'n_failed_periods'; plus the + per-period arrays (in the caller's period order) when + ``return_arrays`` is set, and 'FAP'/'SDE_null' when + ``fap_null_draws`` > 0. + + A lightcurve with no valid solution at any trial period (flat + or noiseless flux) gets the same keys with SDE = 0, NaN best-fit + parameters and the message under 'error' (a warning is raised). + + The best-fit parameters (including 'chi2_min') come from the + exact refinement pass, so 'chi2_min' is generally slightly + below the minimum of the returned coarse 'chi2' spectrum; the + SDE statistics are computed from the uniform coarse spectrum + only, keeping the detection statistic's scale consistent + across periods. + """ + if u is None: + u = [0.4804, 0.1867] + tls_grids.validate_stellar_parameters(R_star, M_star) + tls_models.validate_limb_darkening_coeffs(u, limb_dark) + + if len(lightcurves) == 0: + return [] + # Validate every light curve up front: the automatic period grid is + # built from the longest baseline, and the kernels are compiled and + # the trial grids uploaded well before _preprocess_batch runs. + for i, lc in enumerate(lightcurves): + if len(lc) != 3: + raise ValueError("tls_search_batch: lightcurve %d must be a " + "(t, y, dy) tuple; got %d elements" + % (i, len(lc))) + _check_tls_lightcurve(lc[0], lc[1], lc[2], + name='tls_search_batch lightcurve %d' % i) + n_durations = _validate_n_durations(n_durations) + if n_durations > _TLS_FAST_MAX_DURATIONS: + raise ValueError("n_durations must be in [2, %d] (got %d)" % + (_TLS_FAST_MAX_DURATIONS, n_durations)) + if refine_top_k is not None and refine_top_k < 0: + raise ValueError("refine_top_k must be >= 0 (got %r)" + % (refine_top_k,)) + if refine_top_k and not refine_oversample > 0: + raise ValueError("refine_oversample must be > 0 (got %r)" + % (refine_oversample,)) + + # ---- Trial grid (shared across the batch) ---- + if periods is None: + # build the grid from the longest lightcurve baseline + spans_probe = [np.max(lc[0]) - np.min(lc[0]) for lc in lightcurves] + t_ref = lightcurves[int(np.argmax(spans_probe))][0] + periods = tls_grids.period_grid_ofir( + t_ref, R_star=R_star, M_star=M_star, + oversampling_factor=oversampling_factor, + period_min=period_min, period_max=period_max, + n_transits_min=n_transits_min) + periods_in = np.asarray(_validate_periods(periods), dtype=np.float32) + nperiods = len(periods_in) + + if (qmin is None) != (qmax is None): + raise ValueError("provide both qmin and qmax, or neither") + if qmin is None: + # only the q bounds are needed here; skip building the + # (nperiods x n_durations) duration table + qmin, qmax = tls_grids.duration_window( + periods_in.astype(np.float64), R_star=R_star, M_star=M_star, + R_planet=R_planet, qmin_fac=qmin_fac, qmax_fac=qmax_fac) + qmin = np.ascontiguousarray(qmin, dtype=np.float32) + qmax = np.ascontiguousarray(qmax, dtype=np.float32) + if len(qmin) != nperiods or len(qmax) != nperiods: + raise ValueError("qmin and qmax must have same length as periods " + "(%d)" % nperiods) + _validate_q_window(qmin, qmax, periods=periods_in) + + # The statistics (running-median detrend, period uncertainty) + # assume an ascending grid: sort here, scatter outputs back to + # the caller's order at the end. + periods, order = _sort_period_grid(periods_in) + if order is not None: + qmin = np.ascontiguousarray(qmin[order]) + qmax = np.ascontiguousarray(qmax[order]) + + # ---- Kernel configuration: band the grid by required bin count. + # The trial-scan cost is proportional to NBINS, while the bin count + # a period actually needs scales with 1/qmin at that period, so + # running the whole grid at the finest band's NBINS overpays by 2x+ + # on long-baseline searches. Each band compiles (and caches) its + # own NBINS variant and scatters results through period_map. ---- + qmin_global = float(np.min(qmin)) + max_dev_shared = _device_max_shared() + ensure_context() + cc_major = cuda.Context.get_device().compute_capability()[0] + + def _band_block_size(nb): + if block_size is not None: + return block_size + # Swept on RTX A5000 (sm_86), RTX 4000 Ada (sm_89) and Tesla + # V100 (sm_70), kepler-4yr config with the float-float fold: + # 256 beats 128 everywhere; 512 wins on the big-bin bands on + # Ampere/Ada from 4096 bins up, while Volta prefers 256 until + # shared memory forces one block per SM (8192 bins). + # On devices with a hard 48KB cap (no opt-in; Pascal and + # earlier) prefer shrinking the block over losing phase bins. + big_bin_threshold = 4096 if cc_major >= 8 else 8192 + bs = 512 if nb >= big_bin_threshold else 256 + while bs > 64 and _tls_fast_shared_size(bs, nb) > max_dev_shared: + bs //= 2 + return bs + + need = t0_oversample / np.maximum(qmin.astype(np.float64), 1e-6) + if nbins is None: + nbins_per = np.power( + 2, np.ceil(np.log2(np.clip(need, 256, None)))).astype(np.int64) + nbins_per = np.minimum(nbins_per, _TLS_FAST_MAX_NBINS) + # shared-memory cap for this device + while _tls_fast_shared_size( + _band_block_size(int(nbins_per.max())), + int(nbins_per.max())) > max_dev_shared: + cap = int(nbins_per.max()) // 2 + nbins_per = np.minimum(nbins_per, cap) + if cap <= 256: + break + short = need > nbins_per + if np.any(short): + warnings.warn( + "TLS fast path: %d of %d trial periods have their " + "narrowest durations under-resolved by the phase bins " + "(bin-count or device shared-memory cap); their coarse " + "scan can lose sensitivity. Refinement only revisits " + "selected periods and cannot recover a period excluded " + "by the coarse search." % (int(short.sum()), nperiods)) + bands = [(int(nb), np.flatnonzero(nbins_per == nb).astype(np.int32)) + for nb in np.unique(nbins_per)] + smear = float(np.max(need / nbins_per)) + else: + bands = [(int(nbins), np.arange(nperiods, dtype=np.int32))] + smear = float(np.max(need / nbins)) + + # When the coarse bins under-resolve a duration (smear > 1), the + # coarse best duration is biased wide by the bin convolution; + # widen the refinement's duration window accordingly and use more + # local durations so the true value stays inside it. + smear = max(1.0, smear) + refine_nd = 3 if smear <= 1.3 else 5 + + band_launches = [] # (kernels, block_size, smem, n, per_g, qmn_g, qmx_g, map_g) + for nb, idx in bands: + bs = _band_block_size(nb) + kern = _get_cached_fast_kernels(bs, nb, t0_oversample, + refine_nd=refine_nd) + band_launches.append(( + kern, bs, _tls_fast_shared_size(bs, nb), len(idx), + gpuarray.to_gpu(periods[idx]), + gpuarray.to_gpu(qmin[idx]), + gpuarray.to_gpu(qmax[idx]), + gpuarray.to_gpu(idx))) + + # refinement runs at the first band's block size (any variant works) + refine_bs = _band_block_size(bands[0][0]) + refine_kern = band_launches[0][0] + refine_smem = _tls_refine_shared_size(refine_bs) + + # refinement trial-grid shape (see kernels/tls_fast.cu). The t0 + # halfwidth must cover the worst coarse quantization, which lives + # in the FINEST band if the device cap clamped it below its need. + dur_ratio = float(np.median(qmax / qmin)) + dur_span = dur_ratio ** (1.0 / (2.0 * max(n_durations - 1, 1))) + dur_span *= min(smear, 4.0) + nbins_finest = bands[-1][0] + t0_halfwidth = min(3.0, max(0.5, 1.5 / (nbins_finest * qmin_global))) + + # ---- Template tables ---- + T_tab, S1_tab, S2_tab = tls_models.generate_template_tables( + n_table=_TLS_FAST_NTEMPLATE, limb_dark=limb_dark, u=u) + + # ---- Host preprocessing ---- + t_hi_c, t_lo_c, a_c, b_c, offs, lens, chi2_0, epochs, spans = \ + _preprocess_batch(lightcurves) + n_lc = len(lightcurves) + tmins = np.array([np.min(np.asarray(lc[0], dtype=np.float64)) + for lc in lightcurves], dtype=np.float64) + + # ---- Static GPU arrays ---- + periods_g = gpuarray.to_gpu(periods) + T_g = gpuarray.to_gpu(T_tab) + S1_g = gpuarray.to_gpu(S1_tab) + S2_g = gpuarray.to_gpu(S2_tab) + + # ---- Chunk plan: bound output size, data size, and grid.y ---- + max_lcs_by_out = max(1, _TLS_FAST_MAX_OUT_FLOATS // max(nperiods, 1)) + chunks = [] # list of (i0, i1) + i0 = 0 + while i0 < n_lc: + i1 = i0 + 1 + pts = int(lens[i0]) + while (i1 < n_lc + and i1 - i0 < max_lcs_by_out + and i1 - i0 < _TLS_FAST_MAX_GRID_Y + and pts + int(lens[i1]) <= _TLS_FAST_MAX_POINTS): + pts += int(lens[i1]) + i1 += 1 + chunks.append((i0, i1)) + i0 = i1 + + max_chunk_lcs = max(i1 - i0 for i0, i1 in chunks) + max_chunk_pts = max(int(lens[i0:i1].sum()) for i0, i1 in chunks) + + # reusable per-chunk GPU buffers + thi_g = gpuarray.empty(max_chunk_pts, np.float32) + tlo_g = gpuarray.empty(max_chunk_pts, np.float32) + a_g = gpuarray.empty(max_chunk_pts, np.float32) + b_g = gpuarray.empty(max_chunk_pts, np.float32) + off_g = gpuarray.empty(max_chunk_lcs, np.int32) + len_g = gpuarray.empty(max_chunk_lcs, np.int32) + out_n = max_chunk_lcs * nperiods + score_g = gpuarray.empty(out_n, np.float32) + t0_g = gpuarray.empty(out_n, np.float32) + dur_g = gpuarray.empty(out_n, np.float32) + depth_g = gpuarray.empty(out_n, np.float32) + + # Refinement targets the peak region only: capping K at ~10% of the + # grid keeps the SDE background dominated by uniformly-treated + # (coarse) periods, so the refined peak stands out the same way it + # would in a full-fidelity spectrum. + K = int(min(refine_top_k, max(16, nperiods // 10), + nperiods)) if refine_top_k else 0 + if K: + cand_g = gpuarray.empty(max_chunk_lcs * K, np.int32) + # compact refined outputs, one slot per candidate; the coarse + # spectrum is never overwritten (SDE needs uniform fidelity) + rscore_g = gpuarray.empty(max_chunk_lcs * K, np.float32) + rt0_g = gpuarray.empty(max_chunk_lcs * K, np.float32) + rdur_g = gpuarray.empty(max_chunk_lcs * K, np.float32) + rdepth_g = gpuarray.empty(max_chunk_lcs * K, np.float32) + + results = [None] * n_lc + + for (i0, i1) in chunks: + nc = i1 - i0 + p0 = int(offs[i0]) + pts = int(lens[i0:i1].sum()) + + # H2D (chunk-relative offsets are bounded by the points cap, + # so the int32 cast is safe) + thi_g[:pts].set(t_hi_c[p0:p0 + pts]) + tlo_g[:pts].set(t_lo_c[p0:p0 + pts]) + a_g[:pts].set(a_c[p0:p0 + pts]) + b_g[:pts].set(b_c[p0:p0 + pts]) + off_g[:nc].set((offs[i0:i1] - p0).astype(np.int32)) + len_g[:nc].set(lens[i0:i1].astype(np.int32)) + + # coarse binned scan, one launch per bin-count band + for kern, bs, smem, band_n, per_g, qmn_g, qmx_g, map_g \ + in band_launches: + kern['search']( + thi_g, tlo_g, a_g, b_g, off_g, len_g, + per_g, qmn_g, qmx_g, map_g, S1_g, S2_g, + np.int32(band_n), np.int32(nperiods), + np.int32(n_durations), + score_g, t0_g, dur_g, depth_g, + block=(bs, 1, 1), grid=(band_n, nc, 1), + shared=smem) + + # score = chi2_0 - chi2 (cancellation-free); <= 0 marks failure + score_h = score_g[:nc * nperiods].get().reshape(nc, nperiods) + + # exact refinement of the best K candidate periods per LC + # (parameters only; the coarse spectrum feeds the statistics) + rscore_h = rt0_h = rdur_h = rdepth_h = cand = None + if K: + cand = np.empty((nc, K), dtype=np.int32) + for j in range(nc): + if K < nperiods: + # K largest scores = K smallest chi2; failed + # periods (score < 0) sort last automatically + cand[j] = np.argpartition(-score_h[j], K)[:K] + else: + cand[j] = np.arange(nperiods) + cand_g[:nc * K].set(cand.ravel()) + refine_kern['refine']( + thi_g, tlo_g, a_g, b_g, off_g, len_g, + periods_g, cand_g, T_g, + np.int32(nperiods), np.int32(K), + np.float32(dur_span), np.float32(t0_halfwidth), + np.float32(refine_oversample), + t0_g, dur_g, + rscore_g, rt0_g, rdur_g, rdepth_g, + block=(refine_bs, 1, 1), grid=(K, nc, 1), + shared=refine_smem) + rscore_h = rscore_g[:nc * K].get().reshape(nc, K) + rt0_h = rt0_g[:nc * K].get().reshape(nc, K) + rdur_h = rdur_g[:nc * K].get().reshape(nc, K) + rdepth_h = rdepth_g[:nc * K].get().reshape(nc, K) + + # Coarse per-period best-fit params. Needed when return_arrays is set, + # when there is no refinement (K == 0), AND as the fallback in + # _finish_lc when a light curve's top-K exact refinements all return + # the sentinel (the else-branch below reads t0_h/dur_h/depth_h). Fetch + # only when actually needed so the common default path pays no extra + # D2H. (`rscore_h` is only touched when K > 0, where it is bound.) + if (return_arrays or not K + or bool((rscore_h.max(axis=1) <= 0.0).any())): + t0_h = t0_g[:nc * nperiods].get().reshape(nc, nperiods) + dur_h = dur_g[:nc * nperiods].get().reshape(nc, nperiods) + depth_h = depth_g[:nc * nperiods].get().reshape(nc, nperiods) + + # ---- Per-LC statistics (pure CPU, one light curve at a + # time). This used to run on a ThreadPoolExecutor on the + # assumption that scipy released the GIL in the running-median + # detrend; it does not, and the pool made the work slower and + # the warning order nondeterministic. Measured on an A40 + # (shared), the pooled and the sequential module interleaved + # in one process: 64 tess-ffi light curves 166.2 -> 77.0 ms + # (2.16x), 16 tess-yr light curves 322.2 -> 269.4 ms (1.20x); + # the statistics alone are 21.7 ms sequential vs 40.3 ms on 8 + # threads. ---- + def _finish_lc(j): + lc_idx = i0 + j + srow = score_h[j] + valid = srow > 0.0 + n_failed = int(nperiods - valid.sum()) + if n_failed == nperiods: + msg = _NO_SOLUTION_MSG % nperiods + warnings.warn("lightcurve %d: %s; returning a null " + "result (SDE = 0)" % (lc_idx, msg)) + return lc_idx, _null_result( + nperiods, chi2_0[lc_idx], msg, periods=periods_in, + arrays=return_arrays) + if n_failed and _warn_failed: + warnings.warn( + "%d of %d trial periods returned no valid TLS " + "solution (chi2 sentinel); they are excluded from " + "the best-fit search and the SDE statistics and " + "appear as NaN in the returned arrays" + % (n_failed, nperiods)) + + # chi2 reconstructed in float64 against the float64 chi2_0 + row = chi2_0[lc_idx] - srow.astype(np.float64) + chi2_valid = row[valid] + periods_valid = periods[valid] + + # Best-fit parameters come from the exact refinement pass + # when available; the coarse spectrum (row) is what feeds + # the SDE statistics either way. + slot = int(np.argmax(rscore_h[j])) if K else 0 + if K and rscore_h[j, slot] > 0.0: + best_idx = int(cand[j, slot]) + best_t0 = float(rt0_h[j, slot]) + best_duration = float(rdur_h[j, slot]) + best_depth = float(rdepth_h[j, slot]) + chi2_min = float(chi2_0[lc_idx] - rscore_h[j, slot]) + best_valid_idx = int(np.searchsorted( + np.flatnonzero(valid), best_idx)) + else: + best_valid_idx = int(np.argmin(chi2_valid)) + best_idx = int(np.flatnonzero(valid)[best_valid_idx]) + chi2_min = float(row[best_idx]) + best_t0 = float(t0_h[j, best_idx]) + best_duration = float(dur_h[j, best_idx]) + best_depth = float(depth_h[j, best_idx]) + + best_period = float(periods[best_idx]) + n_transits = int(spans[lc_idx] / best_period) + + stats = tls_stats.compute_all_statistics( + chi2_valid, periods_valid, best_valid_idx, + best_depth, best_duration, n_transits, + kernel_size=sde_kernel_size, + chi2_null=float(chi2_0[lc_idx]), chi2_best=chi2_min) + period_uncertainty = tls_stats.compute_period_uncertainty( + periods_valid, chi2_valid, best_valid_idx) + + # Absolute mid-transit time: the kernel phase is relative + # to the epoch floor(min t); report the first transit at + # or after the first observation + T0 = _first_transit_at_or_after( + epochs[lc_idx] + best_t0 * best_period, best_period, + tmins[lc_idx]) + + res = { + 'period': best_period, + 'period_uncertainty': period_uncertainty, + 't0_phase': best_t0, + 'T0': float(T0), + 'duration': best_duration, + 'depth': best_depth, + 'chi2_min': chi2_min, + 'SDE': stats['SDE'], + 'SDE_raw': stats['SDE_raw'], + 'SNR': stats['SNR'], + 'n_transits': n_transits, + 'n_failed_periods': n_failed, + } + if return_arrays: + def _expand(values): + full = np.full(nperiods, np.nan) + full[valid] = values + return _to_caller_order(full, order) + res.update({ + 'periods': periods_in, + 'chi2': _to_caller_order( + np.where(valid, row, np.nan), order), + 'best_t0_per_period': _to_caller_order( + t0_h[j].copy(), order), + 'best_duration_per_period': _to_caller_order( + dur_h[j].copy(), order), + 'best_depth_per_period': _to_caller_order( + depth_h[j].copy(), order), + 'valid_periods': _to_caller_order(valid, order), + 'power': _expand(stats['power']), + 'SR': _expand(stats['SR']), + }) + return lc_idx, res + + for j in range(nc): + lc_idx, res = _finish_lc(j) + results[lc_idx] = res + + if fap_null_draws: + try: + n_null_draws = operator.index(fap_null_draws) + except TypeError: + raise ValueError( + "fap_null_draws must be an integer >= 1 (got %r)" + % (fap_null_draws,)) + _attach_null_fap( + results, lightcurves, n_null_draws, fap_seed, + dict(periods=periods, qmin=qmin, qmax=qmax, + n_durations=n_durations, t0_oversample=t0_oversample, + refine_top_k=0, block_size=block_size, nbins=nbins, + limb_dark=limb_dark, u=u, R_star=R_star, M_star=M_star, + sde_kernel_size=sde_kernel_size, method='binned')) + + return results + + +def _attach_null_fap(results, lightcurves, n_draws, seed, search_kwargs): + """Empirical FAP by flux permutation (see tls_search_batch, + ``fap_null_draws``): each lightcurve's (y, dy) pairs are permuted + over its times ``n_draws`` times, searched with the identical trial + grid and settings, and the exceedance of the observed SDE is + recorded under 'FAP' (add-one estimator) with the null SDEs under + 'SDE_null'.""" + if n_draws < 1: + raise ValueError( + "fap_null_draws must be an integer >= 1 (got %r)" % (n_draws,)) + rng = np.random.RandomState(seed) + n_lc = len(lightcurves) + lens = [len(lc[0]) for lc in lightcurves] + i0 = 0 + while i0 < n_lc: + # group lightcurves so one null batch stays within the + # per-launch point budget of the search + i1 = i0 + 1 + pts = lens[i0] * n_draws + while (i1 < n_lc + and pts + lens[i1] * n_draws <= _TLS_FAST_MAX_POINTS): + pts += lens[i1] * n_draws + i1 += 1 + null_lcs = [] + for i in range(i0, i1): + t, y, dy = lightcurves[i] + y = np.asarray(y) + dy = np.asarray(dy) + for _ in range(n_draws): + perm = rng.permutation(len(y)) + null_lcs.append((t, y[perm], dy[perm])) + null_res = tls_search_batch(null_lcs, **search_kwargs) + for k, i in enumerate(range(i0, i1)): + sde_null = np.array( + [r['SDE'] for r in null_res[k * n_draws:(k + 1) * n_draws]], + dtype=np.float64) + res = results[i] + n_exceed = int(np.sum(sde_null >= res['SDE'])) + res['FAP'] = (n_exceed + 1.0) / (n_draws + 1.0) + res['SDE_null'] = sde_null + i0 = i1 + + +def tls_search_gpu(t, y, dy, periods=None, *, qmin=None, qmax=None, + R_star=1., M_star=1., method=None, execution='baseline', **kwargs): + """Search for transits with the complete observation-level TLS algorithm. + + The default ``method='reference'`` follows the pinned GTLS numerical + objective: native transit templates, duration grid, sample-window trials, + depth estimates, spectrum ranking and full candidate/harmonic refinement. + ``execution='baseline'`` retains the 6ced75d execution implementation. + ``execution='experimental'`` opts into the survey optimization bundle, + which failed its frozen bitwise qualification (9 of 5,120 comparisons). + This selector changes execution, not the observation-level search policy. + It does not phase-bin observations. GPU workspace size does not narrow the + duration search. Install ``cuvarbase[tls]`` for its CUDA 12 dependencies. + + ``t`` is in days; ``y`` must have a positive out-of-transit baseline of one, + and ``dy`` contains positive uncertainties in the same units. All three + arrays must be finite and aligned, with at least three observations. The + time origin is shifted internally without dropping zero/negative times. + ``T0`` is the absolute mid-transit time of the first transit at or after + ``min(t)``; ``t0_phase`` is its fold phase relative to ``floor(min(t))``. + + Omit ``periods`` for the stellar-density/Ofir grid, controlled by + ``R_star``, ``M_star``, ``period_min``, ``period_max``, + ``n_transits_min`` and ``oversampling_factor`` (default 3). Explicit periods + retain float64 precision and their caller order in returned arrays. + + Omitted duration controls use the broad GTLS domain, including thin + transits. ``duration_grid_step`` (default 1.1) controls its density. + Optional scalar or aligned ``qmin``/``qmax`` replace the automatic domain. + ``duration_window='keplerian'`` with optional ``qmin_fac``/``qmax_fac`` + explicitly selects the older, narrower stellar-duration prior. + ``n_durations`` with an explicit window sets a minimum geometric density. + + ``full=True`` includes native candidate/harmonic refinement. ``full=False`` + is the explicit GTLS fast-mode policy. ``T0_fit_margin`` (default .125) + sets coarse epoch spacing; full refinement tests every sample start. + ``work_chunk`` (default 256) is a memory/performance control only. + ``u``/``limb_dark`` and ``transit_template`` select the transit template. + + Returns the existing cuvarbase dictionary: period, T0, duration, depth, + SDE, SDE_raw, SNR, period_uncertainty and per-period arrays. SNR retains + cuvarbase's sqrt(delta chi-squared) definition in input-error units; + detection ranking uses native GTLS SDE. Per-period duration/epoch arrays + describe nominal sample windows; final winner parameters use native + postprocessing. ``search_configuration`` records the engine and policy. + A degenerate spectrum returns SDE=0 and NaN parameters with an error. + + ``method='binned'`` explicitly selects the previous approximate phase-bin + engine and accepts its bin/refinement controls. ``method='legacy'`` selects + the old shared-memory per-observation kernel. Neither is the new default. + ``use_fast`` is a deprecated alias selecting those older engines. + """ + if execution not in ('baseline', 'experimental'): + raise ValueError("execution must be 'baseline' or 'experimental'") + old_fast = kwargs.pop('use_fast', None) + if {'fap_null_draws', 'fap_seed'} & kwargs.keys(): + raise TypeError('fap_null_draws/fap_seed are available only from tls_search_batch') + if old_fast is not None: + if method is not None: + raise ValueError('use method or use_fast, not both') + method = 'binned' if old_fast else 'legacy' + warnings.warn("use_fast selects an older TLS engine; use method='%s' " + "explicitly. The default observation-level engine is " + "method='reference'." % method, FutureWarning, stacklevel=2) + method = 'reference' if method is None else method + if method != 'reference' and execution != 'baseline': + raise ValueError("experimental execution requires method='reference'") + if method == 'reference': + from .tls_reference_frontend import search + return search(t, y, dy, periods=periods, qmin=qmin, qmax=qmax, + R_star=R_star, M_star=M_star, execution=execution, **kwargs) + if method in ('binned', 'legacy'): + return _tls_search_gpu_binned(t, y, dy, periods=periods, + qmin=qmin, qmax=qmax, + R_star=R_star, M_star=M_star, + use_fast=method == 'binned', **kwargs) + raise ValueError("method must be 'reference', 'binned' or 'legacy'") + + +def tls_transit(t, y, dy, *, R_star=1., M_star=1., **kwargs): + """Stellar-aware TLS convenience wrapper; see :func:`tls_search_gpu`. + + Uses the same broad observation-level search by default. A narrow + Keplerian duration prior is applied only when explicitly requested. + ``T0`` is the first mid-transit at or after ``min(t)``; ``t0_phase`` + is its fold phase relative to ``floor(min(t))``. + """ + _check_tls_lightcurve(t, y, dy, name='tls_transit') + return tls_search_gpu(t, y, dy, R_star=R_star, M_star=M_star, **kwargs) + + +def tls_search_batch(lightcurves, *, R_star=1., M_star=1., + method='reference', execution='baseline', **kwargs): + """Search a survey with the same sensitivity policy as tls_search_gpu. + + The standard engine processes light curves sequentially, parallelizing + each complete period search on the GPU and reusing bounded scan workspaces. + A batch shares the longest-baseline automatic grid unless ``periods`` is + supplied. ``return_arrays=False`` avoids keeping every period spectrum. + ``T0`` is the absolute mid-transit time of the first transit at or after + ``min(t)``; ``t0_phase`` is its fold phase relative to ``floor(min(t))``. + + ``fap_null_draws`` enables a flux/error permutation null: all nulls use the + same full search, including refinement. This destroys correlated noise; + it is a white-noise null, not a model of arbitrary survey systematics. + ``fap_seed`` makes the permutations reproducible. + ``execution`` selects 'baseline' (default) or 'experimental' for every + observed curve and every null permutation. + + ``method='binned'`` opts into the older approximate multi-lightcurve + kernel and its original controls; see :func:`tls_search_gpu`. + """ + if execution not in ('baseline', 'experimental'): + raise ValueError("execution must be 'baseline' or 'experimental'") + if method != 'reference' and execution != 'baseline': + raise ValueError("experimental execution requires method='reference'") + if method == 'reference': + from .tls_reference_frontend import search_batch + return search_batch(lightcurves, R_star=R_star, M_star=M_star, execution=execution, **kwargs) + if method == 'binned': + return _tls_search_batch_binned(lightcurves, R_star=R_star, M_star=M_star, + **kwargs) + raise ValueError("batch method must be 'reference' or 'binned'") diff --git a/cuvarbase/tls_grids.py b/cuvarbase/tls_grids.py new file mode 100644 index 00000000..7c97e345 --- /dev/null +++ b/cuvarbase/tls_grids.py @@ -0,0 +1,559 @@ +""" +Period and duration grid generation for Transit Least Squares. + +Implements the Ofir (2014) optimal frequency sampling algorithm and +logarithmically-spaced duration grids based on stellar parameters. + +References +---------- +- Ofir (2014), "Optimizing the search for transiting planets in + long time series", A&A 561, A138 (arXiv:1307.7330) +- Hippke & Heller (2019), "Transit Least Squares", A&A 623, A39 +""" + +import warnings + +import numpy as np + + +__all__ = [ + 'q_transit', + 'transit_duration_max', + 'period_grid_ofir', + 'duration_grid', + 'duration_grid_keplerian', + 'duration_window', + 't0_grid', + 't0_grid_size', + 'validate_stellar_parameters', +] + + +# Physical constants +G = 6.67430e-11 # Gravitational constant (m^3 kg^-1 s^-2) +R_sun = 6.95700e8 # Solar radius (m) +M_sun = 1.98840e30 # Solar mass (kg) +R_earth = 6.371e6 # Earth radius (m) + + +def q_transit(period, R_star=1.0, M_star=1.0, R_planet=1.0): + """ + Calculate fractional transit duration (q = duration/period) for Keplerian orbit. + + Not to be confused with :func:`cuvarbase.bls.q_transit`, which takes + a *frequency* and a stellar density ``rho`` (the 0.2.5-era BLS + helper); this one takes a period and stellar/planet radii and mass. + + This is the TLS analog of the BLS q parameter. For a circular, edge-on orbit, + the transit duration scales with stellar density and planet/star size ratio. + + Parameters + ---------- + period : float or array_like + Orbital period in days + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + R_planet : float, optional + Planet radius in Earth radii (default: 1.0) + + Returns + ------- + q : float or array_like + Fractional transit duration (duration/period) + + Notes + ----- + This follows the same Keplerian assumption as BLS but for TLS. + The duration is calculated for edge-on circular orbits and normalized by period. + + See Also + -------- + transit_duration_max : Calculate absolute transit duration + duration_grid_keplerian : Generate duration grid using Keplerian q values + """ + duration = transit_duration_max(period, R_star, M_star, R_planet) + return duration / period + + +def transit_duration_max(period, R_star=1.0, M_star=1.0, R_planet=1.0): + """ + Calculate maximum transit duration for circular orbit. + + Parameters + ---------- + period : float or array_like + Orbital period in days + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + R_planet : float, optional + Planet radius in Earth radii (default: 1.0) + + Returns + ------- + duration : float or array_like + Maximum transit duration in days (for edge-on circular orbit) + + Notes + ----- + Formula: T_14 = (R_star + R_planet) * (4 * P / (π * G * M_star))^(1/3) + + Assumes: + - Circular orbit (e = 0) + - Edge-on configuration (i = 90°) + - Planet + stellar radii contribute to transit chord + """ + period_sec = period * 86400.0 # Convert to seconds + R_total = R_star * R_sun + R_planet * R_earth # Total radius in meters + M_star_kg = M_star * M_sun # Mass in kg + + # Duration in seconds + duration_sec = R_total * (4.0 * period_sec / (np.pi * G * M_star_kg))**(1.0/3.0) + + # Convert to days + duration_days = duration_sec / 86400.0 + + return duration_days + + +def period_grid_ofir(t, R_star=1.0, M_star=1.0, oversampling_factor=3, + period_min=None, period_max=None, n_transits_min=2): + """ + Generate optimal period grid using Ofir (2014) algorithm. + + This creates a non-uniform period grid that optimally samples the + period space, with denser sampling at shorter periods where transit + durations are shorter. + + Parameters + ---------- + t : array_like + Observation times (days) + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + oversampling_factor : float, optional + Oversampling factor for period grid (default: 3) + Higher values give denser grids + period_min : float, optional + Minimum period to search (days). If None, calculated from + Roche limit and minimum transits + period_max : float, optional + Maximum period to search (days). If None, set to half the + total observation span + n_transits_min : int, optional + Minimum number of transits required (default: 2) + + Returns + ------- + periods : ndarray + Array of trial periods (days) + + Notes + ----- + Uses the Ofir (2014) frequency-to-cubic transformation: + + f_x = (A/3 * x + C)^3 + + where A = (2π)^(2/3) / π * R_star / (G * M_star)^(1/3) * 1/(S * OS) + + This ensures optimal statistical sampling across the period space. + """ + t = np.asarray(t) + T_span = np.max(t) - np.min(t) # Total observation span + + # Store user's requested limits (for filtering later) + user_period_min = period_min + user_period_max = period_max + + # Physical boundary conditions (following Ofir 2014 and CPU TLS) + # f_min: require n_transits_min transits over baseline + f_min = n_transits_min / (T_span * 86400.0) # 1/seconds + + # f_max: Roche limit (maximum possible frequency) + # P_roche = 2π * sqrt(a^3 / (G*M)) where a = 3*R at Roche limit + R_star_m = R_star * R_sun + M_star_kg = M_star * M_sun + f_max = 1.0 / (2.0 * np.pi) * np.sqrt(G * M_star_kg / (3.0 * R_star_m)**3) + + # Ofir (2014) parameters - equations (5), (6), (7) + T_span_sec = T_span * 86400.0 # Convert to seconds + + # Equation (5): optimal frequency sampling parameter + A = ((2.0 * np.pi)**(2.0/3.0) / np.pi * R_star_m / + (G * M_star_kg)**(1.0/3.0) / (T_span_sec * oversampling_factor)) + + # Equation (6): offset parameter + C = f_min**(1.0/3.0) - A / 3.0 + + # Equation (7): optimal number of frequency samples + n_freq = int(np.ceil((f_max**(1.0/3.0) - f_min**(1.0/3.0) + A / 3.0) * 3.0 / A)) + + # Ensure we have at least some frequencies + if n_freq < 10: + n_freq = 10 + + # Linear grid in cubic-root frequency space + x = np.arange(n_freq) + 1 # 1-indexed like CPU TLS + + # Transform to frequency space (Hz) + freqs = (A / 3.0 * x + C)**3 + + # Convert to periods (days) + periods = 1.0 / freqs / 86400.0 + + # Apply user-requested period limits + if user_period_min is not None or user_period_max is not None: + if user_period_min is None: + user_period_min = 0.0 + if user_period_max is None: + user_period_max = np.inf + + periods = periods[(periods > user_period_min) & (periods <= user_period_max)] + + # If we somehow got no periods, use simple linear grid + if len(periods) == 0: + if user_period_min is None: + user_period_min = T_span / 20.0 + if user_period_max is None: + user_period_max = T_span / 2.0 + periods = np.linspace(user_period_min, user_period_max, 100) + + # Sort in increasing order (standard convention) + periods = np.sort(periods) + + return periods + + +def duration_grid(periods, R_star=1.0, M_star=1.0, R_planet_min=0.5, + R_planet_max=5.0, duration_grid_step=1.1): + """ + Generate logarithmically-spaced duration grid for each period. + + Parameters + ---------- + periods : array_like + Trial periods (days) + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + R_planet_min : float, optional + Minimum planet radius to consider in Earth radii (default: 0.5) + R_planet_max : float, optional + Maximum planet radius to consider in Earth radii (default: 5.0) + duration_grid_step : float, optional + Multiplicative step for duration grid (default: 1.1) + 1.1 means each duration is 10% larger than previous + + Returns + ------- + durations : list of ndarray + List where durations[i] is array of durations for periods[i] + duration_counts : ndarray + Number of durations for each period + + Notes + ----- + Durations are sampled logarithmically from the minimum transit time + (small planet) to maximum transit time (large planet) for each period. + + The grid spacing ensures we don't miss any transit duration while + avoiding excessive oversampling. + """ + periods = np.asarray(periods) + + # Calculate duration bounds for each period + T_min = transit_duration_max(periods, R_star, M_star, R_planet_min) + T_max = transit_duration_max(periods, R_star, M_star, R_planet_max) + + durations = [] + duration_counts = np.zeros(len(periods), dtype=np.int32) + + for i, (period, t_min, t_max) in enumerate(zip(periods, T_min, T_max)): + # Generate logarithmically-spaced durations + dur = [] + t = t_min + while t <= t_max: + dur.append(t) + t *= duration_grid_step + + # Ensure we include the maximum duration + if dur[-1] < t_max: + dur.append(t_max) + + durations.append(np.array(dur, dtype=np.float32)) + duration_counts[i] = len(dur) + + return durations, duration_counts + + +def duration_grid_keplerian(periods, R_star=1.0, M_star=1.0, R_planet=1.0, + qmin_fac=0.5, qmax_fac=2.0, n_durations=15): + """ + Generate Keplerian-aware duration grid for each period. + + This is the TLS analog of BLS's Keplerian q-based duration search. + At each period, we calculate the expected transit duration for a + Keplerian orbit and search within qmin_fac to qmax_fac times that value. + + Parameters + ---------- + periods : array_like + Trial periods (days) + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + R_planet : float, optional + Fiducial planet radius in Earth radii (default: 1.0) + This sets the central duration value around which we search + qmin_fac : float, optional + Minimum duration factor (default: 0.5) + Searches down to qmin_fac * q_keplerian + qmax_fac : float, optional + Maximum duration factor (default: 2.0) + Searches up to qmax_fac * q_keplerian + n_durations : int, optional + Number of duration samples per period (default: 15) + Logarithmically spaced between qmin and qmax + + Returns + ------- + durations : list of ndarray + List where durations[i] is array of durations for periods[i] + duration_counts : ndarray + Number of durations for each period (constant = n_durations) + q_values : ndarray + Keplerian q values (duration/period) for each period + + Notes + ----- + This exploits the Keplerian assumption that transit duration scales + predictably with period based on stellar parameters. This is much + more efficient than searching all possible durations, as we focus + the search around the physically expected value. + + For example, for a Sun-like star (M=1, R=1) and Earth-size planet: + - At P=10 days: q ~ 0.0164, so we search 0.0082 to 0.0329 (0.5x to 2x) + - At P=100 days: q ~ 0.00354, so we search 0.00177 to 0.00709 + + This is equivalent to BLS's approach but applied to transit shapes. + + See Also + -------- + q_transit : Calculate Keplerian fractional transit duration + duration_grid : Alternative method that searches fixed planet radius range + """ + periods = np.asarray(periods) + + # Calculate Keplerian q value (fractional duration) for each period + q_values = q_transit(periods, R_star, M_star, R_planet) + + # Duration bounds based on q-factors + qmin_vals = q_values * qmin_fac + qmax_vals = q_values * qmax_fac + + duration_counts = np.full(len(periods), n_durations, dtype=np.int32) + + # Logarithmically-spaced durations from qmin*P to qmax*P per period + # (absolute time, not fractional), vectorized over the whole grid: + # equivalent to np.logspace per period, but one broadcast instead of + # len(periods) Python-level calls (which dominate at ~1e5 periods). + log_min = np.log10(qmin_vals * periods) + log_max = np.log10(qmax_vals * periods) + frac = np.linspace(0.0, 1.0, n_durations) + dur_2d = 10.0 ** (log_min[:, None] + + (log_max - log_min)[:, None] * frac[None, :]) + durations = list(dur_2d.astype(np.float32)) + + return durations, duration_counts, q_values + + +# The pre-1.0 constant duration window used by tls_search_gpu/tls_search +# when no qmin/qmax were given (and hard-coded in the legacy 'standard' +# kernel). It is unphysical beyond P ~ 60 d for a Sun-like star (18.5 d +# for R = M = 0.3) and is now an explicit opt-in. +FIXED_QMIN = 0.005 +FIXED_QMAX = 0.15 + + +def duration_window(periods, R_star=1.0, M_star=1.0, R_planet=1.0, + qmin_fac=0.5, qmax_fac=2.0, window='keplerian'): + """ + Per-period fractional transit-duration bounds for a TLS search. + + This is the default duration window of the explicit ``method='binned'`` + engine. The standard observation-level TLS engine uses the broader GTLS + domain; it calls this helper only for an explicit ``duration_window`` or + Keplerian q-factor override. + + Parameters + ---------- + periods : array_like + Trial periods (days) + R_star, M_star : float + Stellar radius/mass in solar units + R_planet : float + Fiducial planet radius (Earth radii) of the Keplerian duration + qmin_fac, qmax_fac : float + Window factors around the Keplerian duration + window : {'keplerian', 'fixed'} + - 'keplerian' (default): ``[qmin_fac, qmax_fac] * q_transit(P, + R_star, M_star, R_planet)`` at every period -- the transit + duration of a circular edge-on orbit scaled by the window + factors, so the window follows P^(-2/3) and stays physical + out to any period. + - 'fixed': the pre-1.0 constant window ``[0.005, 0.15]`` at + every period, kept as an opt-in for reproducing old results. + A UserWarning is raised when the Keplerian duration falls + outside it at any trial period: beyond P ~ 60 d (Sun-like, + 1 R_earth) every trial duration is then unphysical and a + transit is fit at the wrong period/depth (measured: P = 365 d + on a 1400-d light curve came back at 182.5 d with half the + depth). + + Returns + ------- + qmin, qmax : ndarray + Fractional duration bounds (float64) aligned with ``periods``. + """ + periods = np.asarray(periods, dtype=np.float64) + if window == 'keplerian': + q = q_transit(periods, R_star=R_star, M_star=M_star, + R_planet=R_planet) + return q * qmin_fac, q * qmax_fac + if window == 'fixed': + q = q_transit(periods, R_star=R_star, M_star=M_star, + R_planet=R_planet) + outside = (q < FIXED_QMIN) | (q > FIXED_QMAX) + if np.any(outside): + p_out = periods[outside] + warnings.warn( + "duration window 'fixed' [%g, %g] excludes the Keplerian " + "transit duration (R_star=%g, M_star=%g, R_planet=%g " + "R_earth) at %d of %d trial periods (P = %.3g .. %.3g d); " + "transits there are fit with an unphysical duration " + "(period aliases, biased depth). Use the default " + "'keplerian' window." + % (FIXED_QMIN, FIXED_QMAX, R_star, M_star, R_planet, + int(outside.sum()), periods.size, p_out.min(), + p_out.max()), UserWarning, stacklevel=2) + return (np.full(periods.shape, FIXED_QMIN), + np.full(periods.shape, FIXED_QMAX)) + raise ValueError("window must be 'keplerian' or 'fixed' (got %r)" + % (window,)) + + +def t0_grid(period, duration, n_transits=None, oversampling=5): + """ + Generate grid of T0 (mid-transit time) positions to test. + + Parameters + ---------- + period : float + Orbital period (days) + duration : float + Transit duration (days) + n_transits : int, optional + Number of transits in observation span. If None, assumes + you want to sample one full period cycle. + oversampling : int, optional + Number of T0 positions to test per transit duration (default: 5) + + Returns + ------- + t0_values : ndarray + Array of T0 positions (in phase, 0 to 1) + + Notes + ----- + This creates a grid of phase offsets to test. The spacing is + determined by the transit duration and oversampling factor. + + For computational efficiency, we typically use stride sampling + (not every possible phase offset). + """ + # Phase-space duration + q = duration / period + + # Step size in phase + step = q / oversampling + + # Number of steps to cover one full period + if n_transits is not None: + n_steps = int(np.ceil(1.0 / (step * n_transits))) + else: + n_steps = int(np.ceil(1.0 / step)) + + # Grid from 0 to 1 (phase) + t0_values = np.linspace(0, 1 - step, n_steps, dtype=np.float32) + + return t0_values + + +def t0_grid_size(duration_phase, oversample=3.0, n_min=30, n_max=20000): + """ + Number of transit-epoch (t0) trial positions for a fractional + transit duration. + + This is the Python mirror of the grid used inside the CUDA kernels + (``kernels/tls.cu::t0_grid_size``): the epoch stride is + ``duration_phase / oversample``, so every possible transit epoch + lies well within half a duration of a tested t0. The previous + fixed 30-point grid missed transits narrower than ~1/30 of the + period entirely (most periods > ~3.5 d in Keplerian mode). + + Parameters + ---------- + duration_phase : float + Transit duration as a fraction of the period (q) + oversample : float, optional + Tested epochs per transit duration (default: 3) + n_min : int, optional + Grid-size floor (default: 30, the old fixed grid) + n_max : int, optional + Grid-size cap bounding kernel runtime (default: 20000) + + Returns + ------- + n_t0 : int + Number of evenly spaced t0 positions in [0, 1) + """ + n = int(np.ceil(oversample / float(duration_phase))) + return int(np.clip(n, n_min, n_max)) + + +def validate_stellar_parameters(R_star=1.0, M_star=1.0, + R_star_min=0.13, R_star_max=3.5, + M_star_min=0.1, M_star_max=2.0): + """ + Validate stellar parameters are within reasonable bounds. + + Parameters + ---------- + R_star : float + Stellar radius in solar radii + M_star : float + Stellar mass in solar masses + R_star_min, R_star_max : float + Allowed range for stellar radius + M_star_min, M_star_max : float + Allowed range for stellar mass + + Raises + ------ + ValueError + If parameters are outside allowed ranges + """ + if not (R_star_min <= R_star <= R_star_max): + raise ValueError(f"R_star={R_star} outside allowed range " + f"[{R_star_min}, {R_star_max}] solar radii") + + if not (M_star_min <= M_star <= M_star_max): + raise ValueError(f"M_star={M_star} outside allowed range " + f"[{M_star_min}, {M_star_max}] solar masses") diff --git a/cuvarbase/tls_models.py b/cuvarbase/tls_models.py new file mode 100644 index 00000000..f1598f0a --- /dev/null +++ b/cuvarbase/tls_models.py @@ -0,0 +1,638 @@ +""" +Transit model generation for TLS. + +This module handles creation of physically realistic transit light curves +using the Batman package for limb-darkened transits. + +References +---------- +- Kreidberg (2015), "batman: BAsic Transit Model cAlculatioN in Python", + PASP 127, 1161 +- Mandel & Agol (2002), "Analytic Light Curves for Planetary Transit + Searches", ApJ 580, L171 +""" + +import threading +import warnings +from collections import OrderedDict + +import numpy as np + + +__all__ = [ + 'BATMAN_AVAILABLE', + 'create_reference_transit', + 'create_transit_model_cache', + 'simple_trapezoid_transit', + 'interpolate_transit_model', + 'generate_transit_template', + 'generate_template_tables', + 'get_default_limb_darkening', + 'validate_limb_darkening_coeffs', +] + + +try: + import batman + BATMAN_AVAILABLE = True +except ImportError: + BATMAN_AVAILABLE = False + warnings.warn("batman package not available. Install with: pip install batman-package") + +# Set by _warn_template_fallback so generate_template_tables can tell a +# batman template from the trapezoid substituted for a batman call that +# FAILED, and refuse to cache that degraded result (its warning must +# keep firing on every call). With batman absent altogether the +# trapezoid is the normal, deterministic template: generate_transit_template +# returns it without warning (the import above already warned once per +# process) and generate_template_tables caches it like any other table, +# under a key that records batman's absence. +# Thread-local: two concurrent searches must not clear each other's flag. +_fallback_state = threading.local() + +# LRU of template tables keyed on everything that defines them. The +# batman reference model behind them costs about 0.5-1.5 ms per call +# and is rebuilt identically on every single-lightcurve search. +_TEMPLATE_TABLE_CACHE_MAX = 8 +_template_table_cache = OrderedDict() +_template_table_lock = threading.Lock() + + +def _warn_template_fallback(reason): + _fallback_state.used = True + warnings.warn("batman transit template generation failed (%s); " + "falling back to a trapezoid template" % (reason,)) + + +def _template_table_key(n_table, limb_dark, u, oversample): + """Hashable key, or None when the arguments cannot form one.""" + try: + u_key = tuple(float(v) for v in np.atleast_1d(u).ravel()) + return (int(n_table), str(limb_dark), u_key, int(oversample), + bool(BATMAN_AVAILABLE)) + except (TypeError, ValueError): + return None + + +def _clear_template_table_cache(): + """Drop the memoized template tables (tests; parameter sweeps).""" + with _template_table_lock: + _template_table_cache.clear() + + +def create_reference_transit(n_samples=1000, limb_dark='quadratic', + u=None): + """ + Create a fiducial transit model normalized to unit depth. + + This generates a high-resolution transit template that can be scaled + and interpolated for different durations and depths. + + Parameters + ---------- + n_samples : int, optional + Number of samples in the model (default: 1000) + limb_dark : str, optional + Limb darkening law (default: 'quadratic') + Options: 'uniform', 'linear', 'quadratic', 'nonlinear' + u : list, optional + Limb darkening coefficients (default: [0.4804, 0.1867]) + Default values are for Sun-like star in Kepler bandpass + + Returns + ------- + phases : ndarray + Phase values (0 to 1) + flux : ndarray + Normalized flux (1.0 = out of transit, <1.0 = in transit) + + Notes + ----- + The reference model assumes: + + - Period = 1.0 (arbitrary units, we work in phase) + - Semi-major axis = 15 stellar radii + - Planet-to-star radius ratio = 0.1, central circular transit + - Flux deficit normalized to unit depth; this normalization does + not change the ingress shape to that of a smaller planet + """ + if u is None: + u = [0.4804, 0.1867] + if not BATMAN_AVAILABLE: + raise ImportError("batman package required for transit models. " + "Install with: pip install batman-package") + + # Batman parameters for reference transit + params = batman.TransitParams() + + # Fixed fiducial shape; depth normalization does not alter geometry. + params.t0 = 0.0 # Mid-transit time + params.per = 1.0 # Period (arbitrary, we use phase) + params.rp = 0.1 # Planet-to-star radius ratio (will normalize) + params.a = 15.0 # Semi-major axis in stellar radii (typical) + params.inc = 90.0 # Inclination (degrees) - edge-on + params.ecc = 0.0 # Eccentricity - circular + params.w = 90.0 # Longitude of periastron + params.limb_dark = limb_dark # Limb darkening model + params.u = u # Limb darkening coefficients + + # Create time array spanning the transit + # For a = 15, duration is approximately 0.05 in phase units + # We'll create a grid from -0.1 to 0.1 (well beyond transit) + t = np.linspace(-0.15, 0.15, n_samples) + + # Generate model + m = batman.TransitModel(params, t) + flux = m.light_curve(params) + + # Normalize: shift so out-of-transit = 1.0, in-transit depth = 1.0 at center + flux_oot = flux[0] # Out of transit flux + depth = flux_oot - np.min(flux) # Transit depth + + if depth < 1e-10: + raise ValueError("Transit depth too small - check parameters") + + flux_normalized = (flux - flux_oot) / depth + 1.0 + + # Convert time to phase (0 to 1) + phases = (t - t[0]) / (t[-1] - t[0]) + + return phases, flux_normalized + + +def create_transit_model_cache(durations, period=1.0, n_samples=1000, + limb_dark='quadratic', u=None, + R_star=1.0, M_star=1.0): + """ + Create cache of transit models for different durations. + + Parameters + ---------- + durations : array_like + Array of transit durations (days) to cache + period : float, optional + Reference period (days) - used for scaling (default: 1.0) + n_samples : int, optional + Number of samples per model (default: 1000) + limb_dark : str, optional + Limb darkening law (default: 'quadratic') + u : list, optional + Limb darkening coefficients (default: [0.4804, 0.1867]) + R_star : float, optional + Stellar radius in solar radii (default: 1.0) + M_star : float, optional + Stellar mass in solar masses (default: 1.0) + + Returns + ------- + models : list of ndarray + List of flux arrays for each duration + phases : ndarray + Phase array (same for all models) + + Notes + ----- + This creates models at different durations by adjusting the semi-major + axis in the batman model to produce the desired transit duration. + """ + if u is None: + u = [0.4804, 0.1867] + if not BATMAN_AVAILABLE: + raise ImportError("batman package required for transit models") + + durations = np.asarray(durations) + models = [] + + for duration in durations: + # Create batman parameters + params = batman.TransitParams() + params.t0 = 0.0 + params.per = period + params.rp = 0.1 # Will be scaled later + params.inc = 90.0 + params.ecc = 0.0 + params.w = 90.0 + params.limb_dark = limb_dark + params.u = u + + # Calculate semi-major axis to produce desired duration + # T_14 ≈ (P/π) * arcsin(R_star/a) for edge-on transit + # Approximation: a ≈ R_star * P / (π * duration) + a = R_star * period / (np.pi * duration) + params.a = max(a, 1.5) # Ensure a > R_star + R_planet + + # Create time array + t = np.linspace(-0.15, 0.15, n_samples) + + # Generate model + m = batman.TransitModel(params, t) + flux = m.light_curve(params) + + # Normalize + flux_oot = flux[0] + depth = flux_oot - np.min(flux) + + if depth < 1e-10: + # If depth is too small, use reference model + phases, flux_normalized = create_reference_transit( + n_samples, limb_dark, u) + else: + flux_normalized = (flux - flux_oot) / depth + 1.0 + phases = (t - t[0]) / (t[-1] - t[0]) + + models.append(flux_normalized.astype(np.float32)) + + return models, phases.astype(np.float32) + + +def simple_trapezoid_transit(phases, duration_phase, depth=1.0, + ingress_duration=0.1): + """ + Create a simple trapezoidal transit model (fast, no Batman needed). + + This is a simplified model for testing or when Batman is not available. + + Parameters + ---------- + phases : array_like + Phase values (0 to 1) + duration_phase : float + Total transit duration in phase units + depth : float, optional + Transit depth (default: 1.0) + ingress_duration : float, optional + Ingress/egress duration as fraction of total duration (default: 0.1) + + Returns + ------- + flux : ndarray + Flux values (1.0 = out of transit) + + Notes + ----- + This creates a trapezoid with linear ingress/egress. It's much faster + than Batman but less physically accurate (no limb darkening). + """ + phases = np.asarray(phases) + flux = np.ones_like(phases, dtype=np.float32) + + # Calculate ingress/egress duration + t_ingress = duration_phase * ingress_duration + t_flat = duration_phase * (1.0 - 2.0 * ingress_duration) + + # Transit centered at phase = 0.5 + t1 = 0.5 - duration_phase / 2.0 # Start of ingress + t2 = t1 + t_ingress # Start of flat bottom + t3 = t2 + t_flat # Start of egress + t4 = t3 + t_ingress # End of transit + + # Ingress + mask_ingress = (phases >= t1) & (phases < t2) + flux[mask_ingress] = 1.0 - depth * (phases[mask_ingress] - t1) / t_ingress + + # Flat bottom + mask_flat = (phases >= t2) & (phases < t3) + flux[mask_flat] = 1.0 - depth + + # Egress + mask_egress = (phases >= t3) & (phases < t4) + flux[mask_egress] = 1.0 - depth * (t4 - phases[mask_egress]) / t_ingress + + return flux + + +def interpolate_transit_model(model_phases, model_flux, target_phases, + target_depth=1.0): + """ + Interpolate a transit model to new phase grid and scale depth. + + Parameters + ---------- + model_phases : array_like + Phase values of the template model + model_flux : array_like + Flux values of the template model + target_phases : array_like + Desired phase values for interpolation + target_depth : float, optional + Desired transit depth (default: 1.0) + + Returns + ------- + flux : ndarray + Interpolated and scaled flux values + + Notes + ----- + Uses linear interpolation. For GPU implementation, texture memory + with hardware interpolation would be faster. + """ + # Interpolate to target phases + flux_interp = np.interp(target_phases, model_phases, model_flux) + + # Scale depth: current depth is (1.0 - min(model_flux)) + current_depth = 1.0 - np.min(model_flux) + + if current_depth < 1e-10: + return flux_interp + + # Scale: flux = 1 - target_depth * (1 - flux_normalized) + flux_scaled = 1.0 - target_depth * (1.0 - flux_interp) + + return flux_scaled.astype(np.float32) + + +def generate_transit_template(n_template=1000, limb_dark='quadratic', + u=None): + """ + Generate a 1D transit template for use in the GPU TLS kernel. + + The template maps transit_coord in [-1, 1] (edge-to-edge of transit) + to a normalized depth value in [0, 1] where 0 = no dimming (edges) + and 1 = maximum dimming (center, with limb darkening). + + Parameters + ---------- + n_template : int, optional + Number of points in the template (default: 1000) + limb_dark : str, optional + Limb darkening law (default: 'quadratic') + u : list, optional + Limb darkening coefficients (default: [0.4804, 0.1867]) + + Returns + ------- + template : ndarray + Float32 array of shape (n_template,) with values in [0, 1]. + Index 0 corresponds to transit_coord = -1 (leading edge), + index n_template-1 corresponds to transit_coord = +1 (trailing edge). + """ + if u is None: + u = [0.4804, 0.1867] + transit_coords = np.linspace(-1.0, 1.0, n_template) + + if BATMAN_AVAILABLE: + try: + # Generate a batman transit model + phases, flux = create_reference_transit( + n_samples=5000, limb_dark=limb_dark, u=u + ) + + # Find the in-transit region (where flux < 1.0 - small threshold) + threshold = 1e-6 + in_transit = flux < (1.0 - threshold) + + if not np.any(in_transit): + _warn_template_fallback( + "no in-transit points in the batman model") + return _trapezoid_template(n_template) + + # Get the in-transit indices + transit_indices = np.where(in_transit)[0] + i_start = transit_indices[0] + i_end = transit_indices[-1] + + # Extract in-transit portion + transit_phases = phases[i_start:i_end + 1] + transit_flux = flux[i_start:i_end + 1] + + # Map transit phases to transit_coord [-1, 1] + phase_center = 0.5 * (transit_phases[0] + transit_phases[-1]) + phase_half_width = 0.5 * (transit_phases[-1] - transit_phases[0]) + + if phase_half_width < 1e-10: + _warn_template_fallback("degenerate transit width") + return _trapezoid_template(n_template) + + source_coords = (transit_phases - phase_center) / phase_half_width + + # Depth values: 0 = no dimming, 1 = max dimming + depth_values = 1.0 - transit_flux + + # Normalize so max = 1 + max_depth = np.max(depth_values) + if max_depth < 1e-10: + _warn_template_fallback("degenerate transit depth") + return _trapezoid_template(n_template) + depth_values /= max_depth + + # Resample to uniform transit_coord grid + template = np.interp(transit_coords, source_coords, depth_values, + left=0.0, right=0.0) + + return template.astype(np.float32) + + except Exception as exc: + _warn_template_fallback(repr(exc)) + return _trapezoid_template(n_template) + else: + return _trapezoid_template(n_template) + + +def generate_template_tables(n_table=1024, limb_dark='quadratic', + u=None, oversample=8): + """ + Generate the template lookup tables used by the fast TLS kernel. + + The fast kernel evaluates the transit template two ways: + + - The binned scan needs the template's *running integrals* so it can + compute the exact bin-averaged template over any transit-coordinate + interval (area sampling): ``S1(x) = int_{-1}^{x} T dx`` and + ``S2(x) = int_{-1}^{x} T^2 dx``. + - The refinement kernel needs the pointwise template ``T(x)`` itself. + + All three are tabulated on the same uniform grid of ``n_table + 1`` + knots spanning transit_coord in [-1, 1]. The integrals are computed + from a template oversampled by ``oversample`` relative to the knot + grid (trapezoid rule), so S1/S2 are accurate even where T is curved. + + Parameters + ---------- + n_table : int, optional + Number of table intervals; the returned arrays have + ``n_table + 1`` entries (default: 1024). + limb_dark : str, optional + Limb darkening law (default: 'quadratic') + u : list, optional + Limb darkening coefficients (default: [0.4804, 0.1867]) + oversample : int, optional + Oversampling of the integrand relative to the knot grid. + + Returns + ------- + T, S1, S2 : ndarray + Float32 arrays of shape (n_table + 1,). Freshly-allocated, + writable copies: the tables are memoized on + ``(n_table, limb_dark, u, oversample, BATMAN_AVAILABLE)`` (a + small LRU) because the batman reference model behind them is + rebuilt identically on every search, but each call still + returns its own arrays. A trapezoid substituted for a batman + call that *failed* is never cached, so that warning keeps + firing; with batman not installed the trapezoid is the + template (the package warns once at import, not per call) and + its tables are cached under the ``BATMAN_AVAILABLE=False`` key. + """ + if u is None: + u = [0.4804, 0.1867] + key = _template_table_key(n_table, limb_dark, u, oversample) + if key is not None: + with _template_table_lock: + hit = _template_table_cache.get(key) + if hit is not None: + _template_table_cache.move_to_end(key) + if hit is not None: + # fresh copies: the caller owns (and may write to) these + return tuple(a.copy() for a in hit) + + n_fine = n_table * oversample + _fallback_state.used = False + fine = generate_transit_template(n_template=n_fine + 1, + limb_dark=limb_dark, u=u) + degraded = getattr(_fallback_state, 'used', False) + fine = np.asarray(fine, dtype=np.float64) + dx = 2.0 / n_fine + + def running_integral(values): + # cumulative trapezoid on the fine grid, then subsample to knots + cum = np.concatenate([ + [0.0], np.cumsum(0.5 * (values[1:] + values[:-1]) * dx)]) + return cum[::oversample] + + S1 = running_integral(fine) + S2 = running_integral(fine ** 2) + T = fine[::oversample] + + tables = (T.astype(np.float32), + S1.astype(np.float32), + S2.astype(np.float32)) + # Never cache a trapezoid substituted for a failed batman call: + # generate_transit_template warns once per failed call and that + # warning must not be memoized away. (batman absent is not + # "degraded": the trapezoid is then the template, keyed on + # BATMAN_AVAILABLE=False.) + if key is not None and not degraded: + with _template_table_lock: + _template_table_cache[key] = tuple(a.copy() for a in tables) + _template_table_cache.move_to_end(key) + while len(_template_table_cache) > _TEMPLATE_TABLE_CACHE_MAX: + _template_table_cache.popitem(last=False) + return tables + + +def _trapezoid_template(n_template=1000, ingress_fraction=0.1): + """ + Generate a trapezoidal transit template as fallback. + + Parameters + ---------- + n_template : int + Number of template points + ingress_fraction : float + Fraction of transit that is ingress/egress (each side) + + Returns + ------- + template : ndarray + Float32 array of shape (n_template,) with values in [0, 1]. + """ + transit_coords = np.linspace(-1.0, 1.0, n_template) + template = np.zeros(n_template, dtype=np.float32) + + # Trapezoidal shape: ramp up during ingress, flat bottom, ramp down during egress + edge_inner = 1.0 - 2.0 * ingress_fraction # Where flat bottom starts/ends + + for i in range(n_template): + coord = abs(transit_coords[i]) + if coord <= edge_inner: + template[i] = 1.0 # Flat bottom (max depth) + elif coord <= 1.0: + # Linear ramp from 1 to 0 during ingress/egress + template[i] = (1.0 - coord) / (1.0 - edge_inner) + else: + template[i] = 0.0 + + return template + + +def get_default_limb_darkening(filter='Kepler', T_eff=5500): + """ + Get default limb darkening coefficients for common filters and T_eff. + + Parameters + ---------- + filter : str, optional + Filter name: 'Kepler', 'TESS', 'Johnson_V', etc. (default: 'Kepler') + T_eff : float, optional + Effective temperature (K) (default: 5500) + + Returns + ------- + u : list + Quadratic limb darkening coefficients [u1, u2] + + Notes + ----- + These are approximate values. For precise work, calculate coefficients + for your specific stellar parameters using packages like ldtk. + + Values from Claret & Bloemen (2011), A&A 529, A75 + """ + # Simple lookup table for common cases + # Format: {filter: {T_eff_range: [u1, u2]}} + + if filter == 'Kepler': + if T_eff < 4500: + return [0.7, 0.1] # Cool stars + elif T_eff < 6000: + return [0.4804, 0.1867] # Solar-type + else: + return [0.3, 0.2] # Hot stars + + elif filter == 'TESS': + if T_eff < 4500: + return [0.5, 0.2] + elif T_eff < 6000: + return [0.3, 0.3] + else: + return [0.2, 0.3] + + else: + # Default to Solar-type in Kepler + return [0.4804, 0.1867] + + +def validate_limb_darkening_coeffs(u, limb_dark='quadratic'): + """ + Validate limb darkening coefficients are physically reasonable. + + Parameters + ---------- + u : list + Limb darkening coefficients + limb_dark : str + Limb darkening law + + Raises + ------ + ValueError + If coefficients are unphysical + """ + u = np.asarray(u) + + if limb_dark == 'quadratic': + if len(u) != 2: + raise ValueError("Quadratic limb darkening requires 2 coefficients") + # Physical constraints: 0 < u1 + u2 < 1, u1 > 0, u1 + 2*u2 > 0 + if not (0 < u[0] + u[1] < 1): + raise ValueError(f"u1 + u2 = {u[0] + u[1]} must be in (0, 1)") + if not (u[0] > 0): + raise ValueError(f"u1 = {u[0]} must be > 0") + if not (u[0] + 2*u[1] > 0): + raise ValueError(f"u1 + 2*u2 = {u[0] + 2*u[1]} must be > 0") + + elif limb_dark == 'linear': + if len(u) != 1: + raise ValueError("Linear limb darkening requires 1 coefficient") + if not (0 < u[0] < 1): + raise ValueError(f"u = {u[0]} must be in (0, 1)") diff --git a/cuvarbase/tls_reference.py b/cuvarbase/tls_reference.py new file mode 100644 index 00000000..58586ac5 --- /dev/null +++ b/cuvarbase/tls_reference.py @@ -0,0 +1,427 @@ +"""Observation-level TLS search using the pinned GTLS numerical objective. + +The standard frontend lives in cuvarbase.tls. This module keeps the complete +search domain independent of GPU workspace chunks. +""" +from collections import OrderedDict +import operator +import threading + +import numpy as np +import cupy as cp + +from . import tls_reference_math as reference +from .tls_reference_prefix import NativePrefixPlan +from .utils import find_kernel + + +_MODULES = None +_PREFIX_PLANS = threading.local() +_PREFIX_CACHE_BYTES = 64 * 1024**2 +_WORKSPACE_BYTES = 512 * 1024**2 + + +def _row_flux_prefix(flux): + """Literal native scans, also used when a graph cannot fit the cache.""" + prefix = cp.empty_like(flux) + for row in range(len(flux)): + cp.cumsum(flux[row], out=prefix[row]) + return prefix + + +class _PrefixPlanCache: + """One thread's bounded LRU of exact native scan graphs.""" + + def __init__(self, max_plans=4, max_bytes=_PREFIX_CACHE_BYTES): + self.max_plans = operator.index(max_plans) + self.max_bytes = operator.index(max_bytes) + if self.max_plans < 1 or self.max_bytes < 1: + raise ValueError('Native prefix cache limits must be positive') + self.plans = OrderedDict() + + @property + def owned_bytes(self): + return sum(plan.owned_bytes for plan in self.plans.values()) + + def _evict(self): + _, plan = self.plans.popitem(last=False) + # raw_search downloads each microchunk before another plan can be + # evicted. Synchronization also makes direct serial helper use safe. + plan.close() + + def prefix(self, flux): + key = NativePrefixPlan.key_for(flux.shape) + plan = self.plans.pop(key, None) + if plan is not None: + self.plans[key] = plan + return plan(flux) + required = NativePrefixPlan.buffer_bytes_for(flux.shape) + if required >= self.max_bytes: + return _row_flux_prefix(flux) + while self.plans and (len(self.plans) >= self.max_plans or + self.owned_bytes + required >= self.max_bytes): + self._evict() + try: + plan = NativePrefixPlan(flux.shape, + max_bytes=self.max_bytes - self.owned_bytes) + except MemoryError: + # The small CUB workspace is known only after warming the scan. + # Release older shapes and retry with the whole bounded budget. + if not self.plans: + return _row_flux_prefix(flux) + self.close() + try: + plan = NativePrefixPlan(flux.shape, max_bytes=self.max_bytes) + except MemoryError: + return _row_flux_prefix(flux) + self.plans[key] = plan + return plan(flux) + + def close(self): + while self.plans: + self._evict() + + +def _native_flux_prefix(flux): + cache = getattr(_PREFIX_PLANS, 'cache', None) + if cache is None: + cache = _PREFIX_PLANS.cache = _PrefixPlanCache() + return cache.prefix(flux) + + +def _physical_chunk_plan(ndata, stride, nwidths, ntiles, requested, *, + full=False, free_bytes=None): + """Bound physical rows without changing logical width unions or order. + + The estimate includes sorting/preparation scratch, retained prefix buffers, + tile outputs and, in full mode, the three-dimensional OOTR arrays. Reserving + at most a quarter of free device memory leaves headroom for the allocator, + input/cache storage and CUDA's internal workspaces. + """ + requested = operator.index(requested) + if requested < 1: + raise ValueError('work_chunk must be a positive integer') + if free_bytes is None: + free_bytes = cp.cuda.runtime.memGetInfo()[0] + budget = min(_WORKSPACE_BYTES, int(free_bytes) // 4) + per_row = 96 * int(stride) + 24 * int(ntiles) + 256 + if full: + per_row += 12 * int(nwidths) * int(ndata) + rows = min(requested, budget // per_row) + if rows < 1: + raise MemoryError('One TLS period requires an estimated {} bytes, ' + 'exceeding the {}-byte physical workspace budget' + .format(per_row, budget)) + return dict(rows=int(rows), budget_bytes=budget, + estimated_bytes_per_row=per_row, + estimated_chunk_bytes=int(rows) * per_row) + + +def modules(): + global _MODULES + if _MODULES is None: + with open(find_kernel('tls_reference_prepare')) as source: + prep = cp.RawModule(code=source.read()) + with open(find_kernel('tls_reference')) as source: + search = cp.RawModule(code=source.read()) + prep.compile() + search.compile() + _MODULES = prep, search + return _MODULES + + +def native_group_size(nperiods, ndata, cache, free_bytes=None): + """Reference's physical allocation rule, for exact run reconstruction.""" + if free_bytes is None: + free_bytes = cp.cuda.runtime.memGetInfo()[0] + stride = ndata + cache['padded_data_width'] + d = len(cache['widths']) + limit = free_bytes / (5 * (stride * 2 + 2 + d * stride * 4 + 2 * d)) + size = int(min(np.floor(limit), nperiods / 30)) + if size < 15: + size = int(size / 1.1) + return max(1, size) + + +def _tiles(widths, ndata, skip_factor): + durations, starts = [], [] + for d, width in enumerate(widths): + skip = max(int(width) // skip_factor, 1) + count = (ndata + skip - 1) // skip + first = np.arange(0, count, 256, dtype=np.int32) + durations.extend([d] * len(first)) + starts.extend(first) + return cp.asarray(durations, dtype=cp.int32), cp.asarray(starts, dtype=cp.int32) + + +def raw_search(periods, t, y, dy, cache, *, group_size=None, + work_chunk=256, skip_factor=8, transit_depth_min=1e-5, + native_prefix=True, capture=False, full=False, + duration_selection=None): + """Search prepared native inputs; retain group unions across work chunks. + + All inputs use reference preprocessing, including rescaled errors. This + function does not normalize scores or select cross-period candidates. + Its grouping is explicit: changing work_chunk cannot change trial widths. + work_chunk is an upper bound; actual physical chunks are limited by the + estimated workspace and recorded in the result. + Explicit duration_selection uses consecutive runs of identical bounds so + no other period can broaden a caller's requested duration interval. + """ + prep, scan = modules() + periods = np.ascontiguousarray(periods, dtype=np.float64) + t, y, dy = (np.asarray(v) for v in (t, y, dy)) + ndata, nperiods = len(t), len(periods) + if group_size is None: + group_size = max(1, int(nperiods / 30)) + if group_size < 15: + group_size = max(1, int(group_size / 1.1)) + widths = np.asarray(cache['widths'], dtype=np.int32) + pad = int(cache['padded_data_width']) + stride = ndata + pad + template_stride = cache['template_deficits'].shape[1] + t_gpu = cp.asarray(t, dtype=cp.float64) + y_gpu, dy_gpu = cp.asarray(y, dtype=cp.float32), cp.asarray(dy, dtype=cp.float32) + p_gpu = cp.asarray(periods) + n_gpu = cp.asarray([ndata], dtype=cp.int32) + span_gpu = cp.asarray([np.ptp(t)], dtype=cp.float32) + np_gpu = cp.asarray([nperiods], dtype=cp.int32) + pad_gpu = cp.asarray([pad], dtype=cp.int32) + stride_gpu = cp.asarray([stride], dtype=cp.int32) + if duration_selection is None: + minimum_gpu, maximum_gpu = cp.empty(nperiods, cp.int32), cp.empty(nperiods, cp.int32) + prep.get_function('durationsGrid')(((nperiods + 255) // 256,), (256,), + (p_gpu, maximum_gpu, minimum_gpu, span_gpu, n_gpu, np_gpu)) + minima, maxima = minimum_gpu.get(), maximum_gpu.get() + width_masks = reference.chunk_width_masks(widths, minima, maxima, group_size) + group_ranges = [(first, min(first + group_size, nperiods)) + for first in range(0, nperiods, group_size)] + else: + minima = np.asarray(duration_selection['width_minima'], dtype=np.int32) + maxima = np.asarray(duration_selection['width_maxima'], dtype=np.int32) + if minima.shape != periods.shape or maxima.shape != periods.shape: + raise ValueError('Explicit duration bounds must align with periods') + if np.any(minima > maxima): + raise ValueError('Explicit duration minima must not exceed maxima') + changed = np.flatnonzero((minima[1:] != minima[:-1]) | + (maxima[1:] != maxima[:-1])) + 1 + boundaries = np.r_[0, changed, nperiods] + group_ranges = np.column_stack((boundaries[:-1], boundaries[1:])) + # An explicit interval can differ at every period. Keep only its two + # bounds and construct one width mask at a time, rather than retaining + # a potentially enormous number-of-periods by number-of-widths table. + width_masks = None + result = dict(chi2=np.full(nperiods, np.nan, dtype=np.float32), + start=np.full(nperiods, -1, dtype=np.int32), + start_time=np.full(nperiods, np.nan, dtype=np.float64), + width_index=np.full(nperiods, -1, dtype=np.int32), + width=np.zeros(nperiods, dtype=np.int32), + depth=np.zeros(nperiods, dtype=np.float32), + group_size=int(group_size), work_chunk=int(work_chunk), + minima=minima, maxima=maxima, width_masks=width_masks, + group_ranges=np.asarray(group_ranges, dtype=np.int64), + skip_factor=int(skip_factor), stages={}, + work_chunk_plans=[], work_chunks=[]) + captured = [] + for group, (first, last) in enumerate(group_ranges): + mask = ((widths >= minima[first]) & (widths <= maxima[first]) + if duration_selection is not None else width_masks[group]) + ids = np.flatnonzero(mask) + if not len(ids): + continue + use_widths = widths[ids] + nw = len(ids) + w_gpu = cp.asarray(use_widths) + templates_gpu = cp.asarray(cache['template_deficits'][ids]) + overshoot_gpu = cp.asarray(cache['overshoot'][ids]) + tile_duration, tile_first = _tiles(use_widths, ndata, 2**30 if full else skip_factor) + nt = len(tile_duration) + plan = _physical_chunk_plan(ndata, stride, nw, nt, work_chunk, full=full) + result['work_chunk_plans'].append(dict(group=group, first=first, + last=last, **plan)) + for start in range(first, last, plan['rows']): + stop = min(start + plan['rows'], last) + rows = stop - start + result['work_chunks'].append(dict(group=group, start=start, + stop=stop, rows=rows)) + rows_gpu = cp.asarray([rows], dtype=cp.int32) + phases = cp.empty((rows, ndata), dtype=cp.float64) + prep.get_function('foldFast')(((ndata + 255) // 256, rows), (256,), + (t_gpu, p_gpu[start:stop], phases, rows_gpu, n_gpu)) + order = cp.argsort(phases, axis=1).astype(cp.int32) + flux = cp.empty((rows, stride), dtype=cp.float32) + errors = cp.empty_like(flux) + invvar = cp.empty_like(flux) + prep.get_function('patchData')(((stride + 255) // 256, rows), (256,), + (flux, errors, stride_gpu, order, pad_gpu, y_gpu, dy_gpu, n_gpu)) + prep.get_function('calcInverseSquaredPatchedDy')(((stride + 255) // 256, rows), (256,), + (invvar, errors, stride_gpu)) + edge = cp.empty(rows, dtype=cp.float32) + prep.get_function('calcEdgeEffectCorrections')(((rows + 255) // 256,), (256,), + (edge, flux, invvar, stride_gpu, pad_gpu, rows_gpu)) + if native_prefix: + prefix = _native_flux_prefix(flux) + else: + prefix = cp.cumsum(flux, axis=1) + base_error = cp.empty_like(flux) + prep.get_function('calculate_base_error')(((stride + 255) // 256, rows), (256,), + (base_error, flux, invvar, np.int32(stride), np.int32(rows))) + error_prefix = cp.cumsum(base_error, axis=1) + partial = cp.empty((rows, nt), dtype=cp.float32) + keys = cp.empty((rows, nt), dtype=cp.uint64) + depths = cp.empty((rows, nt), dtype=cp.float32) + if full: + fullsum = cp.empty((rows, nw), dtype=cp.float32) + scan.get_function('tls_reference_fullsum_legacy')(((rows + 255) // 256,), (256,), + (flux, invvar, w_gpu, np.int32(rows), np.int32(stride), np.int32(nw), fullsum)) + delta = cp.empty((rows, nw, ndata), dtype=cp.float32) + full_grid = ((ndata + 255) // 256, nw, rows) + scan.get_function('tls_reference_ootr_delta')(full_grid, (256,), + (flux, invvar, w_gpu, np.int32(rows), np.int32(ndata), + np.int32(stride), np.int32(nw), delta)) + ootr = cp.cumsum(delta, axis=-1) + scan.get_function('tls_reference_ootr_add')(full_grid, (256,), + (ootr, fullsum, np.int32(rows), np.int32(ndata), np.int32(nw))) + scan.get_function('tls_reference_full_search')((nt, rows), (256,), + (flux, invvar, prefix, fullsum, ootr, edge, w_gpu, templates_gpu, + overshoot_gpu, tile_duration, tile_first, np.int32(rows), + np.int32(ndata), np.int32(stride), np.int32(nw), + np.int32(template_stride), np.int32(nt), + np.float32(transit_depth_min), partial, keys, depths)) + else: + scan.get_function('tls_reference_search')((nt, rows), (256,), + (flux, invvar, prefix, error_prefix, edge, w_gpu, templates_gpu, + overshoot_gpu, tile_duration, tile_first, np.int32(rows), + np.int32(ndata), np.int32(stride), np.int32(nw), + np.int32(template_stride), np.int32(nt), np.int32(skip_factor), + np.float32(transit_depth_min), partial, keys, depths)) + out_chi2 = cp.empty(rows, dtype=cp.float32) + out_start, out_index, out_width = (cp.empty(rows, dtype=cp.int32) for _ in range(3)) + out_depth = cp.empty(rows, dtype=cp.float32) + scan.get_function('tls_reference_reduce')((rows,), (256,), + (partial, keys, depths, w_gpu, np.int32(rows), np.int32(ndata), + np.int32(nt), out_chi2, out_start, out_index, out_width, out_depth)) + safe_start = cp.clip(out_start, 0, ndata - 1) + original_start = t_gpu[order[cp.arange(rows, dtype=cp.int32), safe_start]] + original_start = cp.where(out_start >= 0, original_start, np.nan) + result['chi2'][start:stop] = out_chi2.get() + result['start'][start:stop] = out_start.get() + result['start_time'][start:stop] = original_start.get() + local_index = out_index.get() + result['width_index'][start:stop] = np.where(local_index >= 0, ids[np.maximum(local_index, 0)], -1) + result['width'][start:stop] = out_width.get() + result['depth'][start:stop] = out_depth.get() + if capture: + captured.append(dict(start=start, stop=stop, ids=ids, + phases=phases.get(), order=order.get(), flux=flux.get(), + invvar=invvar.get(), prefix=prefix.get(), + error_prefix=error_prefix.get(), edge=edge.get())) + if capture: + result['captured'] = captured + cp.cuda.runtime.deviceSynchronize() + return result + + +def _select_durations(selection, indices): + if selection is None: + return None + return {key: selection[key][indices] for key in + ('width_minima', 'width_maxima', 'requested_qmin', 'requested_qmax')} + + +def search_fast(t, y, dy, periods, *, group_size=None, work_chunk=256, + T0_fit_margin=.125, duration_grid_step=1.1, + oversampling_factor=3, u=None, limb_dark='quadratic', + transit_template='default', template_parameters=None, + qmin=None, qmax=None, n_durations=None, + sde_kernel_size=None, **kwargs): + prepared = reference.preprocess_inputs(t, y, dy) + periods = np.asarray(periods, dtype=np.float64) + order = np.argsort(periods, kind='stable') + periods = periods[order] + selection = None + if qmin is not None or qmax is not None: + if qmin is None or qmax is None: + raise ValueError('provide both qmin and qmax') + def aligned(value): + value = np.broadcast_to(np.asarray(value, dtype=np.float64), periods.shape) + return value[order] + selection = reference.augment_duration_grid( + periods, len(prepared['t']), aligned(qmin), aligned(qmax), + duration_grid_step=duration_grid_step, n_durations=n_durations) + cache = reference.build_cache( + periods, len(prepared['t']), duration_grid_step=duration_grid_step, + u=u, limb_dark=limb_dark, transit_template=transit_template, + template_parameters=template_parameters, + fractional_durations=None if selection is None else selection['fractional_durations']) + skip = max(int(1 / T0_fit_margin), 8) if T0_fit_margin > 0 else 2**30 + raw = raw_search(periods, prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group_size, work_chunk=work_chunk, skip_factor=skip, + duration_selection=selection, **kwargs) + spectra = reference.native_spectra(raw['chi2'], oversampling_factor, + kernel_size=sde_kernel_size) + index = spectra['primary_index'] + return dict(periods=periods, prepared=prepared, cache=cache, raw=raw, + duration_selection=selection, spectra=spectra, + period=None if index is None else periods[index]) + + +def search_full(t, y, dy, periods, *, group_size=None, work_chunk=256, + T0_fit_margin=.125, duration_grid_step=1.1, + oversampling_factor=3, u=None, limb_dark='quadratic', + transit_template='default', template_parameters=None, + qmin=None, qmax=None, n_durations=None, + sde_kernel_size=None, refine_top_k=None, **kwargs): + """Full-stage native arithmetic, with finite first-stage candidates. + + Masked coarse periods are excluded before candidate ranking. Harmonics + remain real trial periods and can legitimately replace a coarse mask + when their no-skip search obtains a valid fit. + """ + result = search_fast(t, y, dy, periods, group_size=group_size, + work_chunk=work_chunk, T0_fit_margin=T0_fit_margin, + duration_grid_step=duration_grid_step, oversampling_factor=oversampling_factor, + u=u, limb_dark=limb_dark, transit_template=transit_template, + template_parameters=template_parameters, qmin=qmin, qmax=qmax, + n_durations=n_durations, sde_kernel_size=sde_kernel_size, **kwargs) + if result['period'] is None: + return result + p, prepared, cache = result['periods'], result['prepared'], result['cache'] + initial_mask = np.ma.getmaskarray(result['spectra']['chi2']) + masked_periods = np.ma.array(p, mask=initial_mask) + chi2 = np.ma.array(result['raw']['chi2'], mask=initial_mask, copy=True) + initial_power = result['spectra']['power'].copy() + spectra_history = [result['spectra']] + candidates = reference.refinement_candidate_indices(masked_periods, initial_power) + if refine_top_k is not None: + candidates = candidates[:refine_top_k] + if not len(candidates): + return result + group = result['raw']['group_size'] + selection = result['duration_selection'] + refined = raw_search(p[candidates], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group, work_chunk=work_chunk, full=True, + duration_selection=_select_durations(selection, candidates), **kwargs) + chi2[candidates] = refined['chi2'] + spectra = reference.native_spectra(chi2, oversampling_factor, mask_outliers=False, + kernel_size=sde_kernel_size) + spectra_history.append(spectra) + primary = int(candidates[np.argmax(spectra['power'][candidates])]) + harmonic = reference.harmonic_candidate_indices(masked_periods, p[primary]) + harmonic_results = raw_search(p[harmonic], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group, work_chunk=work_chunk, full=True, + duration_selection=_select_durations(selection, harmonic), **kwargs) + chi2[harmonic] = harmonic_results['chi2'] + spectra = reference.native_spectra(chi2, oversampling_factor, mask_outliers=False, + kernel_size=sde_kernel_size) + spectra_history.append(spectra) + primary = int(harmonic[np.argmax(spectra['power'][harmonic])]) + final = raw_search(p[primary:primary+1], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=1, work_chunk=1, full=True, + duration_selection=_select_durations(selection, slice(primary, primary+1)), **kwargs) + result.update(coarse_raw=result['raw'], coarse_spectra=result['spectra'], spectra=spectra, + spectra_history=spectra_history, + coarse_period=result['period'], period=float(p[primary]), primary_index=primary, + candidates=candidates, refined=refined, harmonics=harmonic, + harmonic_results=harmonic_results, final=final) + return result diff --git a/cuvarbase/tls_reference_experimental.py b/cuvarbase/tls_reference_experimental.py new file mode 100644 index 00000000..3b353e8e --- /dev/null +++ b/cuvarbase/tls_reference_experimental.py @@ -0,0 +1,458 @@ +"""Observation-level TLS search using the pinned GTLS numerical objective. + +The standard frontend lives in cuvarbase.tls. This module keeps the complete +search domain independent of GPU workspace chunks. +""" +from collections import OrderedDict +import operator +import threading + +import numpy as np +import cupy as cp + +from . import tls_reference_experimental_math as reference +from .tls_reference_prefix import NativePrefixPlan +from .tls_reference_short_prefix import NativeShortPrefixCache +from .utils import find_kernel + + +_MODULES = None +_PREFIX_PLANS = threading.local() +_PREFIX_CACHE_BYTES = 64 * 1024**2 +_WORKSPACE_BYTES = 512 * 1024**2 +_WINNER_DTYPE = np.dtype(dict( + names=('chi2', 'start', 'start_time', 'width_index', 'width', 'depth'), + formats=('f4', 'i4', 'f8', 'i4', 'i4', 'f4'), + offsets=(0, 4, 8, 16, 20, 24), itemsize=32)) + + +def _row_flux_prefix(flux): + """Literal native scans, also used when a graph cannot fit the cache.""" + prefix = cp.empty_like(flux) + for row in range(len(flux)): + cp.cumsum(flux[row], out=prefix[row]) + return prefix + + +class _PrefixPlanCache: + """One thread's bounded LRU of exact native scan graphs.""" + + def __init__(self, max_plans=4, max_bytes=_PREFIX_CACHE_BYTES): + self.max_plans = operator.index(max_plans) + self.max_bytes = operator.index(max_bytes) + if self.max_plans < 1 or self.max_bytes < 1: + raise ValueError('Native prefix cache limits must be positive') + self.plans = OrderedDict() + + @property + def owned_bytes(self): + return sum(plan.owned_bytes for plan in self.plans.values()) + + def _evict(self): + _, plan = self.plans.popitem(last=False) + # raw_search downloads each microchunk before another plan can be + # evicted. Synchronization also makes direct serial helper use safe. + plan.close() + + def prefix(self, flux): + key = NativePrefixPlan.key_for(flux.shape) + plan = self.plans.pop(key, None) + if plan is not None: + self.plans[key] = plan + return plan(flux) + required = NativePrefixPlan.buffer_bytes_for(flux.shape) + if required >= self.max_bytes: + return _row_flux_prefix(flux) + while self.plans and (len(self.plans) >= self.max_plans or + self.owned_bytes + required >= self.max_bytes): + self._evict() + try: + plan = NativePrefixPlan(flux.shape, + max_bytes=self.max_bytes - self.owned_bytes) + except MemoryError: + # The small CUB workspace is known only after warming the scan. + # Release older shapes and retry with the whole bounded budget. + if not self.plans: + return _row_flux_prefix(flux) + self.close() + try: + plan = NativePrefixPlan(flux.shape, max_bytes=self.max_bytes) + except MemoryError: + return _row_flux_prefix(flux) + self.plans[key] = plan + return plan(flux) + + def close(self): + while self.plans: + self._evict() + + +def _native_flux_prefix(flux): + short = getattr(_PREFIX_PLANS, 'short', None) + if short is None: + short = _PREFIX_PLANS.short = NativeShortPrefixCache() + result = short.prefix(flux) + if result is not None: + return result + cache = getattr(_PREFIX_PLANS, 'cache', None) + if cache is None: + cache = _PREFIX_PLANS.cache = _PrefixPlanCache() + return cache.prefix(flux) + + +def _native_short_prefix_status(): + """Dispatch/build diagnostics for benchmark receipts, outside TLS results.""" + short = getattr(_PREFIX_PLANS, 'short', None) + return NativeShortPrefixCache().status if short is None else short.status + + +def _physical_chunk_plan(ndata, stride, nwidths, ntiles, requested, *, + full=False, free_bytes=None): + """Bound physical rows without changing logical width unions or order. + + The estimate includes sorting/preparation scratch, retained prefix buffers, + tile outputs and, in full mode, the three-dimensional OOTR arrays. Reserving + at most a quarter of free device memory leaves headroom for the allocator, + input/cache storage and CUDA's internal workspaces. + """ + requested = operator.index(requested) + if requested < 1: + raise ValueError('work_chunk must be a positive integer') + if free_bytes is None: + free_bytes = cp.cuda.runtime.memGetInfo()[0] + budget = min(_WORKSPACE_BYTES, int(free_bytes) // 4) + per_row = 96 * int(stride) + 24 * int(ntiles) + 256 + if full: + per_row += 12 * int(nwidths) * int(ndata) + rows = min(requested, budget // per_row) + if rows < 1: + raise MemoryError('One TLS period requires an estimated {} bytes, ' + 'exceeding the {}-byte physical workspace budget' + .format(per_row, budget)) + return dict(rows=int(rows), budget_bytes=budget, + estimated_bytes_per_row=per_row, + estimated_chunk_bytes=int(rows) * per_row) + + +def modules(): + global _MODULES + if _MODULES is None: + with open(find_kernel('tls_reference_prepare')) as source: + prep = cp.RawModule(code=source.read()) + with open(find_kernel('tls_reference_experimental')) as source: + search = cp.RawModule(code=source.read()) + prep.compile() + search.compile() + _MODULES = prep, search + return _MODULES + + +def native_group_size(nperiods, ndata, cache, free_bytes=None): + """Reference's physical allocation rule, for exact run reconstruction.""" + if free_bytes is None: + free_bytes = cp.cuda.runtime.memGetInfo()[0] + stride = ndata + cache['padded_data_width'] + d = len(cache['widths']) + limit = free_bytes / (5 * (stride * 2 + 2 + d * stride * 4 + 2 * d)) + size = int(min(np.floor(limit), nperiods / 30)) + if size < 15: + size = int(size / 1.1) + return max(1, size) + + +def _tiles(widths, ndata, skip_factor): + durations, starts = [], [] + for d, width in enumerate(widths): + skip = max(int(width) // skip_factor, 1) + count = (ndata + skip - 1) // skip + first = np.arange(0, count, 256, dtype=np.int32) + durations.extend([d] * len(first)) + starts.extend(first) + return cp.asarray(durations, dtype=cp.int32), cp.asarray(starts, dtype=cp.int32) + + +def _download_winners(scan, t, order, chi2, starts, indices, widths, depths): + """Transfer one bit-preserving record per period instead of six arrays. + + Epoch gathering shares the pack kernel, avoiding temporary clipped-index + and advanced-index arrays. Packing does no residual/depth arithmetic. + """ + rows, ndata = order.shape + packed = cp.empty((rows, 8), dtype=cp.uint32) + scan.get_function('tls_reference_pack_winners')(((rows + 255) // 256,), (256,), + (t, order, chi2, starts, indices, widths, depths, + np.int32(rows), np.int32(ndata), packed)) + return packed.get().view(_WINNER_DTYPE).reshape(rows) + + +def raw_search(periods, t, y, dy, cache, *, group_size=None, + work_chunk=256, skip_factor=8, transit_depth_min=1e-5, + native_prefix=True, capture=False, full=False, + duration_selection=None): + """Search prepared native inputs; retain group unions across work chunks. + + All inputs use reference preprocessing, including rescaled errors. This + function does not normalize scores or select cross-period candidates. + Its grouping is explicit: changing work_chunk cannot change trial widths. + work_chunk is an upper bound; actual physical chunks are limited by the + estimated workspace and recorded in the result. + Explicit duration_selection uses consecutive runs of identical bounds so + no other period can broaden a caller's requested duration interval. + """ + prep, scan = modules() + periods = np.ascontiguousarray(periods, dtype=np.float64) + t, y, dy = (np.asarray(v) for v in (t, y, dy)) + ndata, nperiods = len(t), len(periods) + if group_size is None: + group_size = max(1, int(nperiods / 30)) + if group_size < 15: + group_size = max(1, int(group_size / 1.1)) + widths = np.asarray(cache['widths'], dtype=np.int32) + pad = int(cache['padded_data_width']) + stride = ndata + pad + template_stride = cache['template_deficits'].shape[1] + t_gpu = cp.asarray(t, dtype=cp.float64) + y_gpu, dy_gpu = cp.asarray(y, dtype=cp.float32), cp.asarray(dy, dtype=cp.float32) + p_gpu = cp.asarray(periods) + n_gpu = cp.asarray([ndata], dtype=cp.int32) + span_gpu = cp.asarray([np.ptp(t)], dtype=cp.float32) + np_gpu = cp.asarray([nperiods], dtype=cp.int32) + pad_gpu = cp.asarray([pad], dtype=cp.int32) + stride_gpu = cp.asarray([stride], dtype=cp.int32) + if duration_selection is None: + minimum_gpu, maximum_gpu = cp.empty(nperiods, cp.int32), cp.empty(nperiods, cp.int32) + prep.get_function('durationsGrid')(((nperiods + 255) // 256,), (256,), + (p_gpu, maximum_gpu, minimum_gpu, span_gpu, n_gpu, np_gpu)) + minima, maxima = minimum_gpu.get(), maximum_gpu.get() + width_masks = reference.chunk_width_masks(widths, minima, maxima, group_size) + group_ranges = [(first, min(first + group_size, nperiods)) + for first in range(0, nperiods, group_size)] + else: + minima = np.asarray(duration_selection['width_minima'], dtype=np.int32) + maxima = np.asarray(duration_selection['width_maxima'], dtype=np.int32) + if minima.shape != periods.shape or maxima.shape != periods.shape: + raise ValueError('Explicit duration bounds must align with periods') + if np.any(minima > maxima): + raise ValueError('Explicit duration minima must not exceed maxima') + changed = np.flatnonzero((minima[1:] != minima[:-1]) | + (maxima[1:] != maxima[:-1])) + 1 + boundaries = np.r_[0, changed, nperiods] + group_ranges = np.column_stack((boundaries[:-1], boundaries[1:])) + # An explicit interval can differ at every period. Keep only its two + # bounds and construct one width mask at a time, rather than retaining + # a potentially enormous number-of-periods by number-of-widths table. + width_masks = None + result = dict(chi2=np.full(nperiods, np.nan, dtype=np.float32), + start=np.full(nperiods, -1, dtype=np.int32), + start_time=np.full(nperiods, np.nan, dtype=np.float64), + width_index=np.full(nperiods, -1, dtype=np.int32), + width=np.zeros(nperiods, dtype=np.int32), + depth=np.zeros(nperiods, dtype=np.float32), + group_size=int(group_size), work_chunk=int(work_chunk), + minima=minima, maxima=maxima, width_masks=width_masks, + group_ranges=np.asarray(group_ranges, dtype=np.int64), + skip_factor=int(skip_factor), stages={}, + work_chunk_plans=[], work_chunks=[]) + captured = [] + for group, (first, last) in enumerate(group_ranges): + mask = ((widths >= minima[first]) & (widths <= maxima[first]) + if duration_selection is not None else width_masks[group]) + ids = np.flatnonzero(mask) + if not len(ids): + continue + use_widths = widths[ids] + nw = len(ids) + w_gpu = cp.asarray(use_widths) + templates_gpu = cp.asarray(cache['template_deficits'][ids]) + overshoot_gpu = cp.asarray(cache['overshoot'][ids]) + tile_duration, tile_first = _tiles(use_widths, ndata, 2**30 if full else skip_factor) + nt = len(tile_duration) + plan = _physical_chunk_plan(ndata, stride, nw, nt, work_chunk, full=full) + result['work_chunk_plans'].append(dict(group=group, first=first, + last=last, **plan)) + for start in range(first, last, plan['rows']): + stop = min(start + plan['rows'], last) + rows = stop - start + result['work_chunks'].append(dict(group=group, start=start, + stop=stop, rows=rows)) + rows_gpu = cp.asarray([rows], dtype=cp.int32) + phases = cp.empty((rows, ndata), dtype=cp.float64) + prep.get_function('foldFast')(((ndata + 255) // 256, rows), (256,), + (t_gpu, p_gpu[start:stop], phases, rows_gpu, n_gpu)) + order = cp.argsort(phases, axis=1).astype(cp.int32) + flux = cp.empty((rows, stride), dtype=cp.float32) + errors = cp.empty_like(flux) + invvar = cp.empty_like(flux) + prep.get_function('patchData')(((stride + 255) // 256, rows), (256,), + (flux, errors, stride_gpu, order, pad_gpu, y_gpu, dy_gpu, n_gpu)) + prep.get_function('calcInverseSquaredPatchedDy')(((stride + 255) // 256, rows), (256,), + (invvar, errors, stride_gpu)) + edge = cp.empty(rows, dtype=cp.float32) + prep.get_function('calcEdgeEffectCorrections')(((rows + 255) // 256,), (256,), + (edge, flux, invvar, stride_gpu, pad_gpu, rows_gpu)) + if native_prefix: + prefix = _native_flux_prefix(flux) + else: + prefix = cp.cumsum(flux, axis=1) + # Full refinement uses its separate native delta/OOTR scan, not + # the coarse error prefix. Build the latter only for a consumer + # or when a numerical diagnostic explicitly requests capture. + if not full or capture: + base_error = cp.empty_like(flux) + prep.get_function('calculate_base_error')(((stride + 255) // 256, rows), (256,), + (base_error, flux, invvar, np.int32(stride), np.int32(rows))) + error_prefix = cp.cumsum(base_error, axis=1) + partial = cp.empty((rows, nt), dtype=cp.float32) + keys = cp.empty((rows, nt), dtype=cp.uint64) + depths = cp.empty((rows, nt), dtype=cp.float32) + if full: + fullsum = cp.empty((rows, nw), dtype=cp.float32) + scan.get_function('tls_reference_fullsum_legacy')(((rows + 255) // 256,), (256,), + (flux, invvar, w_gpu, np.int32(rows), np.int32(stride), np.int32(nw), fullsum)) + delta = cp.empty((rows, nw, ndata), dtype=cp.float32) + full_grid = ((ndata + 255) // 256, nw, rows) + scan.get_function('tls_reference_ootr_delta')(full_grid, (256,), + (flux, invvar, w_gpu, np.int32(rows), np.int32(ndata), + np.int32(stride), np.int32(nw), delta)) + ootr = cp.cumsum(delta, axis=-1) + scan.get_function('tls_reference_ootr_add')(full_grid, (256,), + (ootr, fullsum, np.int32(rows), np.int32(ndata), np.int32(nw))) + scan.get_function('tls_reference_full_search')((nt, rows), (256,), + (flux, invvar, prefix, fullsum, ootr, edge, w_gpu, templates_gpu, + overshoot_gpu, tile_duration, tile_first, np.int32(rows), + np.int32(ndata), np.int32(stride), np.int32(nw), + np.int32(template_stride), np.int32(nt), + np.float32(transit_depth_min), partial, keys, depths)) + else: + scan.get_function('tls_reference_search')((nt, rows), (256,), + (flux, invvar, prefix, error_prefix, edge, w_gpu, templates_gpu, + overshoot_gpu, tile_duration, tile_first, np.int32(rows), + np.int32(ndata), np.int32(stride), np.int32(nw), + np.int32(template_stride), np.int32(nt), np.int32(skip_factor), + np.float32(transit_depth_min), partial, keys, depths)) + out_chi2 = cp.empty(rows, dtype=cp.float32) + out_start, out_index, out_width = (cp.empty(rows, dtype=cp.int32) for _ in range(3)) + out_depth = cp.empty(rows, dtype=cp.float32) + scan.get_function('tls_reference_reduce')((rows,), (256,), + (partial, keys, depths, w_gpu, np.int32(rows), np.int32(ndata), + np.int32(nt), out_chi2, out_start, out_index, out_width, out_depth)) + winners = _download_winners(scan, t_gpu, order, out_chi2, out_start, + out_index, out_width, out_depth) + for field in ('chi2', 'start', 'start_time', 'width', 'depth'): + result[field][start:stop] = winners[field] + local_index = winners['width_index'] + result['width_index'][start:stop] = np.where(local_index >= 0, ids[np.maximum(local_index, 0)], -1) + if capture: + captured.append(dict(start=start, stop=stop, ids=ids, + phases=phases.get(), order=order.get(), flux=flux.get(), + invvar=invvar.get(), prefix=prefix.get(), + error_prefix=error_prefix.get(), edge=edge.get())) + if capture: + result['captured'] = captured + cp.cuda.runtime.deviceSynchronize() + return result + + +def _select_durations(selection, indices): + if selection is None: + return None + return {key: selection[key][indices] for key in + ('width_minima', 'width_maxima', 'requested_qmin', 'requested_qmax')} + + +def search_fast(t, y, dy, periods, *, group_size=None, work_chunk=256, + T0_fit_margin=.125, duration_grid_step=1.1, + oversampling_factor=3, u=None, limb_dark='quadratic', + transit_template='default', template_parameters=None, + qmin=None, qmax=None, n_durations=None, + sde_kernel_size=None, **kwargs): + prepared = reference.preprocess_inputs(t, y, dy) + periods = np.asarray(periods, dtype=np.float64) + order = np.argsort(periods, kind='stable') + periods = periods[order] + selection = None + if qmin is not None or qmax is not None: + if qmin is None or qmax is None: + raise ValueError('provide both qmin and qmax') + def aligned(value): + value = np.broadcast_to(np.asarray(value, dtype=np.float64), periods.shape) + return value[order] + selection = reference.augment_duration_grid( + periods, len(prepared['t']), aligned(qmin), aligned(qmax), + duration_grid_step=duration_grid_step, n_durations=n_durations) + cache = reference.build_cache( + periods, len(prepared['t']), duration_grid_step=duration_grid_step, + u=u, limb_dark=limb_dark, transit_template=transit_template, + template_parameters=template_parameters, + fractional_durations=None if selection is None else selection['fractional_durations']) + skip = max(int(1 / T0_fit_margin), 8) if T0_fit_margin > 0 else 2**30 + raw = raw_search(periods, prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group_size, work_chunk=work_chunk, skip_factor=skip, + duration_selection=selection, **kwargs) + spectra = reference.native_spectra(raw['chi2'], oversampling_factor, + kernel_size=sde_kernel_size) + index = spectra['primary_index'] + return dict(periods=periods, prepared=prepared, cache=cache, raw=raw, + duration_selection=selection, spectra=spectra, + period=None if index is None else periods[index]) + + +def search_full(t, y, dy, periods, *, group_size=None, work_chunk=256, + T0_fit_margin=.125, duration_grid_step=1.1, + oversampling_factor=3, u=None, limb_dark='quadratic', + transit_template='default', template_parameters=None, + qmin=None, qmax=None, n_durations=None, + sde_kernel_size=None, refine_top_k=None, **kwargs): + """Full-stage native arithmetic, with finite first-stage candidates. + + Masked coarse periods are excluded before candidate ranking. Harmonics + remain real trial periods and can legitimately replace a coarse mask + when their no-skip search obtains a valid fit. + """ + result = search_fast(t, y, dy, periods, group_size=group_size, + work_chunk=work_chunk, T0_fit_margin=T0_fit_margin, + duration_grid_step=duration_grid_step, oversampling_factor=oversampling_factor, + u=u, limb_dark=limb_dark, transit_template=transit_template, + template_parameters=template_parameters, qmin=qmin, qmax=qmax, + n_durations=n_durations, sde_kernel_size=sde_kernel_size, **kwargs) + if result['period'] is None: + return result + p, prepared, cache = result['periods'], result['prepared'], result['cache'] + initial_mask = np.ma.getmaskarray(result['spectra']['chi2']) + masked_periods = np.ma.array(p, mask=initial_mask) + chi2 = np.ma.array(result['raw']['chi2'], mask=initial_mask, copy=True) + initial_power = result['spectra']['power'].copy() + spectra_history = [result['spectra']] + candidates = reference.refinement_candidate_indices(masked_periods, initial_power) + if refine_top_k is not None: + candidates = candidates[:refine_top_k] + if not len(candidates): + return result + group = result['raw']['group_size'] + selection = result['duration_selection'] + refined = raw_search(p[candidates], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group, work_chunk=work_chunk, full=True, + duration_selection=_select_durations(selection, candidates), **kwargs) + chi2[candidates] = refined['chi2'] + spectra = reference.native_spectra(chi2, oversampling_factor, mask_outliers=False, + kernel_size=sde_kernel_size) + spectra_history.append(spectra) + primary = int(candidates[np.argmax(spectra['power'][candidates])]) + harmonic = reference.harmonic_candidate_indices(masked_periods, p[primary]) + harmonic_results = raw_search(p[harmonic], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=group, work_chunk=work_chunk, full=True, + duration_selection=_select_durations(selection, harmonic), **kwargs) + chi2[harmonic] = harmonic_results['chi2'] + spectra = reference.native_spectra(chi2, oversampling_factor, mask_outliers=False, + kernel_size=sde_kernel_size) + spectra_history.append(spectra) + primary = int(harmonic[np.argmax(spectra['power'][harmonic])]) + final = raw_search(p[primary:primary+1], prepared['t'], prepared['y'], prepared['dy'], cache, + group_size=1, work_chunk=1, full=True, + duration_selection=_select_durations(selection, slice(primary, primary+1)), **kwargs) + result.update(coarse_raw=result['raw'], coarse_spectra=result['spectra'], spectra=spectra, + spectra_history=spectra_history, + coarse_period=result['period'], period=float(p[primary]), primary_index=primary, + candidates=candidates, refined=refined, harmonics=harmonic, + harmonic_results=harmonic_results, final=final) + return result diff --git a/cuvarbase/tls_reference_experimental_math.py b/cuvarbase/tls_reference_experimental_math.py new file mode 100644 index 00000000..9d09d98e --- /dev/null +++ b/cuvarbase/tls_reference_experimental_math.py @@ -0,0 +1,42 @@ +"""Experimental allocation/ranking helpers; unchanged math stays in the baseline module.""" +import numpy as np +from .tls_reference_math import (augment_duration_grid, build_cache, + harmonic_candidate_indices, native_spectra, preprocess_inputs) + +def chunk_width_masks(widths, minima, maxima, chunk_size): + """Native union of admissible integer widths over each period chunk.""" + if not isinstance(chunk_size, (int, np.integer)) or chunk_size < 1: + raise ValueError('chunk_size must be a positive integer') + widths, minima, maxima = np.asarray(widths), np.asarray(minima), np.asarray(maxima) + if minima.shape != maxima.shape or minima.ndim != 1: + raise ValueError('minima and maxima must be aligned 1D arrays') + # Only one logical group's temporary membership matrix is needed. Dense + # M-dwarf grids can contain millions of periods, while the returned union + # normally has only thirty rows. Do not retain the full period/width table. + return np.array([np.any( + (widths[None, :] >= minima[start:start + chunk_size, None]) & + (widths[None, :] <= maxima[start:start + chunk_size, None]), axis=0) + for start in range(0, len(minima), chunk_size)], dtype=bool) + +def refinement_candidate_indices(periods, power): + """Rank valid candidates: top100, then next100 at P>1d. + + Filtering before the stable sort fixes a native GTLS host-mask defect. + Its masked scalars do not form a total ordering and can enter the top100 + period list as NaN, leading to undefined GPU integer conversions. Only + finite, unmasked scores and periods represent physical first-stage trials. + The native rank policy and tie order are unchanged on valid entries. + """ + periods, power = np.ma.asarray(periods), np.ma.asarray(power) + valid = (~np.ma.getmaskarray(periods) & ~np.ma.getmaskarray(power) & + np.isfinite(np.ma.getdata(periods)) & + np.isfinite(np.ma.getdata(power))) + indices = np.flatnonzero(valid) + ranked = indices[np.argsort(-power.data[indices], kind='stable')] + # Filtering a stable ordering preserves the native second sort's tie + # order. The first hundred rows are already excluded by this slice; no + # Python tuple construction, repeated membership scans or second sort. + remaining = ranked[100:] + next_best = remaining[periods.data[remaining] > 1][:100] + return np.concatenate((ranked[:100], next_best)).astype(np.int64, copy=False) + diff --git a/cuvarbase/tls_reference_frontend.py b/cuvarbase/tls_reference_frontend.py new file mode 100644 index 00000000..9a93a162 --- /dev/null +++ b/cuvarbase/tls_reference_frontend.py @@ -0,0 +1,310 @@ +"""Public input/result contract for the observation-level TLS engine. + +GPU dependencies are imported only after input validation. Native numerical +details are isolated in tls_reference and tls_reference_math. +""" +import operator +import warnings + +import numpy as np + +from . import tls_reference_math as reference +from . import tls_grids, tls_models, tls_stats + + +def _positive_integer(value, name, minimum=1): + try: + value = operator.index(value) + except TypeError: + raise ValueError('%s must be an integer >= %d' % (name, minimum)) + if value < minimum: + raise ValueError('%s must be an integer >= %d' % (name, minimum)) + return value + + +def _grid(t, periods, R_star, M_star, period_min, period_max, + oversampling_factor, n_transits_min): + from .tls import _validate_periods + if periods is None: + periods = reference.period_grid( + np.ptp(t), R_star=R_star, M_star=M_star, + period_min=0. if period_min is None else period_min, + period_max=np.inf if period_max is None else period_max, + oversampling_factor=oversampling_factor, + n_transits_min=n_transits_min) + return np.asarray(_validate_periods(periods), dtype=np.float64) + + +def _check_inputs(t, y, dy, name): + from .tls import _check_tls_lightcurve + _check_tls_lightcurve(t, y, dy, name) + t, y, dy = (np.asarray(v, dtype=np.float64) for v in (t, y, dy)) + if len(t) < 3: + raise ValueError('%s requires at least three observations' % name) + if np.any(y <= 0): + raise ValueError('%s requires positive flux normalized to a baseline of 1' % name) + return t, y, dy + + +def search(t, y, dy, periods=None, *, R_star=1., M_star=1., + period_min=None, period_max=None, n_transits_min=2, + oversampling_factor=3, duration_grid_step=1.1, + qmin=None, qmax=None, qmin_fac=None, qmax_fac=None, + duration_window=None, R_planet=1., n_durations=None, + limb_dark='quadratic', u=None, transit_template='default', + template_parameters=None, full=True, execution='baseline', T0_fit_margin=.125, + transit_depth_min=1e-5, work_chunk=256, return_arrays=True, + t0_oversample=None, refine_top_k=None, refine_oversample=None, + nbins=None, block_size=None, sde_kernel_size=None): + """Run the standard full GTLS-compatible numerical search. + + Omitted duration controls select the broad native duration domain. Explicit + q bounds replace that domain; they are never widened by workspace grouping. + """ + from .tls import (_sort_period_grid, _to_caller_order, _null_result, + _validate_n_durations) + if execution not in ('baseline', 'experimental'): + raise ValueError("execution must be 'baseline' or 'experimental'") + t, y, dy = _check_inputs(t, y, dy, 'tls_search_gpu') + for name, value in (('R_star', R_star), ('M_star', M_star)): + if not np.isscalar(value) or not np.isfinite(value) or value <= 0: + raise ValueError('%s must be finite and positive' % name) + if u is None: + u = [.4804, .1867] + tls_models.validate_limb_darkening_coeffs(u, limb_dark) + reference.resolve_template(transit_template, u, limb_dark, template_parameters) + if nbins is not None or block_size is not None or refine_oversample is not None: + raise ValueError('nbins, block_size and refine_oversample configure the ' + "approximate engine; select method='binned' to use them") + work_chunk = _positive_integer(work_chunk, 'work_chunk') + n_transits_min = _positive_integer(n_transits_min, 'n_transits_min') + if not np.isfinite(oversampling_factor) or oversampling_factor <= 0: + raise ValueError('oversampling_factor must be finite and positive') + if not np.isfinite(duration_grid_step) or duration_grid_step <= 1: + raise ValueError('duration_grid_step must be finite and > 1') + if not np.isfinite(T0_fit_margin) or T0_fit_margin < 0: + raise ValueError('T0_fit_margin must be finite and nonnegative') + if not np.isfinite(transit_depth_min) or transit_depth_min < 0: + raise ValueError('transit_depth_min must be finite and nonnegative') + if t0_oversample is not None: + if not np.isfinite(t0_oversample) or t0_oversample <= 0: + raise ValueError('t0_oversample must be finite and positive') + T0_fit_margin = 1. / max(8., t0_oversample) + if refine_top_k is not None: + refine_top_k = _positive_integer(refine_top_k, 'refine_top_k', 0) + if refine_top_k == 0: + full = False + if sde_kernel_size is not None: + sde_kernel_size = _positive_integer(sde_kernel_size, 'sde_kernel_size') + reference.spectrum_kernel_size(oversampling_factor, sde_kernel_size) + if n_durations is not None: + n_durations = _validate_n_durations(n_durations) + if (qmin is None) != (qmax is None): + raise ValueError('provide both qmin and qmax, or neither') + if duration_window not in (None, 'reference', 'keplerian', 'fixed'): + raise ValueError("duration_window must be 'reference', 'keplerian' or 'fixed'") + periods_in = _grid(t, periods, R_star, M_star, period_min, period_max, + oversampling_factor, n_transits_min) + periods, order = _sort_period_grid(periods_in) + explicit_q = qmin is not None + if explicit_q and (qmin_fac is not None or qmax_fac is not None or + duration_window not in (None, 'reference')): + raise ValueError('qmin/qmax cannot be combined with a duration_window or q factors') + if not explicit_q and (duration_window in ('keplerian', 'fixed') or + qmin_fac is not None or qmax_fac is not None): + qmin, qmax = tls_grids.duration_window( + periods_in, R_star=R_star, M_star=M_star, R_planet=R_planet, + qmin_fac=.5 if qmin_fac is None else qmin_fac, + qmax_fac=2. if qmax_fac is None else qmax_fac, + window='fixed' if duration_window == 'fixed' else 'keplerian') + explicit_q = True + if explicit_q: + bounds = [] + for value in (qmin, qmax): + value = np.asarray(value, dtype=np.float64) + if value.ndim == 0: + value = np.full(len(periods), value) + if value.shape != periods.shape or np.any(~np.isfinite(value)): + raise ValueError('qmin and qmax must be finite scalars or aligned with periods') + bounds.append(value if order is None else value[order]) + qmin, qmax = bounds + if np.any((qmin <= 0) | (qmax >= 1) | (qmin > qmax)): + raise ValueError('require 0 < qmin <= qmax < 1 at every period') + elif n_durations is not None: + raise ValueError('n_durations requires explicit qmin/qmax or a duration_window; ' + 'the default reference grid uses duration_grid_step') + + # Keep every observation, including legitimate zero/negative timestamps, + # and avoid losing phase precision when callers use absolute BJD times. + # Comparisons with public GTLS must give it this same positive-origin data. + epoch = float(np.floor(np.min(t)) - 1.) + shifted_t = t - epoch + try: + if execution == 'experimental': + from . import tls_reference_experimental as engine + else: + from . import tls_reference as engine + except ImportError as exc: + raise ImportError('The standard TLS engine requires CuPy and batman-package. ' + 'Install cuvarbase[tls] for CUDA 12, or install the CuPy ' + 'wheel matching your CUDA runtime plus batman-package.') from exc + from .base import ensure_context + ensure_context() + runner = engine.search_full if full else engine.search_fast + options = dict(work_chunk=work_chunk, T0_fit_margin=T0_fit_margin, + duration_grid_step=duration_grid_step, + oversampling_factor=oversampling_factor, u=u, + limb_dark=limb_dark, transit_template=transit_template, + template_parameters=template_parameters, + transit_depth_min=transit_depth_min, + sde_kernel_size=sde_kernel_size, qmin=qmin, qmax=qmax, + n_durations=n_durations) + if full: + options['refine_top_k'] = refine_top_k + result = runner(shifted_t, y, dy, periods, **options) + prepared, cache, spectra = result['prepared'], result['cache'], result['spectra'] + metadata = dict(method='reference', execution=execution, + experimental_execution=execution == 'experimental', + full=bool(full), phase_binning=False, + candidate_policy='finite_unmasked_before_ranking', + time_origin=epoch, input_count=len(t), + samples_used=len(prepared['t']), + duration_policy='explicit' if explicit_q else 'reference', + logical_group_size=result['raw']['group_size'], + work_chunk=work_chunk, + omitted_unrepresentable_durations=cache['omitted_rows'], + per_period_parameters='nominal sample-window diagnostics; ' + 'winner parameters use native final postprocessing') + null_chi2 = float(np.sum(((1. - y) / dy)**2)) + if result['period'] is None: + message = 'TLS search has no finite detection spectrum; returning a null result (SDE = 0)' + warnings.warn(message) + public = _null_result(len(periods), null_chi2, message, + periods=periods_in, arrays=return_arrays) + public.update(search_configuration=metadata, R_star=R_star, M_star=M_star) + return public + + primary = result.get('primary_index', spectra['primary_index']) + winning = result.get('final') + if winning is None: + # Supply cuvarbase's fitted-parameter result contract even in fast + # mode. Public GTLS fast=True returns only its coarse periodogram; + # this extra no-skip winner fit does not rerank that spectrum. + winning = engine.raw_search( + periods[primary:primary + 1], prepared['t'], prepared['y'], + prepared['dy'], cache, group_size=1, work_chunk=1, full=True, + transit_depth_min=transit_depth_min, + duration_selection=engine._select_durations( + result.get('duration_selection'), slice(primary, primary + 1))) + scale = prepared['error_scale'] + if (winning['width_index'][0] < 0 or winning['start'][0] < 0 or + not np.isfinite(winning['chi2'][0]) or winning['depth'][0] <= 0): + message = 'TLS winning sample window has no fitted transit; returning a null result (SDE = 0)' + warnings.warn(message) + public = _null_result(len(periods), null_chi2, message, + periods=periods_in, arrays=return_arrays) + public.update(search_configuration=metadata, R_star=R_star, M_star=M_star) + return public + fit_cache = cache + selection = result.get('duration_selection') + if selection is not None and winning['width_index'][0] >= 0: + fit_cache = dict(cache, overview=cache['overview'].copy()) + row = cache['unique_indices'][winning['width_index'][0]] + fit_cache['overview']['duration'][row] = max( + fit_cache['overview']['duration'][row], selection['requested_qmin'][primary]) + try: + fitted = reference.final_parameters( + prepared['t'], prepared['y'], prepared['dy'], periods[primary], + fit_cache, winning['width_index'][0], winning['start'][0], + fit_chi2=winning['chi2'][0], error_scale=scale) + except ValueError as exc: + # A numerical spectrum is useful even when a sparse winning sample + # window cannot support the native physical-duration estimator. + fitted = dict(period=float(periods[primary]), T0=np.nan, t0_phase=np.nan, + duration=np.nan, depth=float(winning['depth'][0]), + chi2_min=float(winning['chi2'][0]), + SNR=float(np.sqrt(max(0., null_chi2 - winning['chi2'][0] / scale**2))), + n_transits=0, parameter_error=str(exc)) + warnings.warn('TLS period detected, but final transit parameters are unavailable: %s' % exc) + fitted['T0'] += epoch + if np.isfinite(fitted['T0']): + fitted['T0'] = float(np.min(t) + ((fitted['T0'] - np.min(t)) % fitted['period'])) + fitted['t0_phase'] = float(((fitted['T0'] - np.floor(np.min(t))) / fitted['period']) % 1.) + if 'transit_times' in fitted: + fitted['transit_times'] = fitted['transit_times'] + epoch + for key in ('chi2_min', 'chi2_null', 'chi2_cpu_model'): + if key in fitted: + fitted[key] /= scale**2 + chi2 = np.ma.filled(spectra['chi2'], np.nan).astype(np.float64) / scale**2 + valid = np.isfinite(chi2) + public = dict(fitted, SDE=float(spectra['SDE']), SDE_raw=float(spectra['SDE_raw']), + period_uncertainty=tls_stats.compute_period_uncertainty(periods, chi2, primary), + n_failed_periods=int(np.sum(~np.isfinite(result['raw']['chi2']))), + n_masked_periods=int(np.sum(~valid)), R_star=R_star, M_star=M_star, + search_configuration=metadata) + if return_arrays: + raw = {key: np.array(result['raw'][key], copy=True) + for key in ('start', 'width_index', 'width', 'depth', 'start_time')} + if full: + for indices, stage in ((result['candidates'], result['refined']), + (result['harmonics'], result['harmonic_results'])): + for key in raw: + raw[key][indices] = stage[key] + safe_width = np.maximum(raw['width_index'], 0) + nominal_q = cache['overview']['duration'][cache['unique_indices']][safe_width] + if selection is not None: + nominal_q = np.maximum(nominal_q, selection['requested_qmin']) + duration = nominal_q * periods + phase = ((raw['start_time'] - 1.) / periods + nominal_q / 2.) % 1. + parameter_valid = valid & (raw['width_index'] >= 0) & (raw['depth'] > 0) + def scatter(values): + return _to_caller_order(values, order) + public.update(periods=periods_in, chi2=scatter(chi2), + power=scatter(np.ma.filled(spectra['power'], np.nan)), + SR=scatter(np.ma.filled(spectra['SR'], np.nan)), + valid_periods=scatter(valid), + parameter_valid_periods=scatter(parameter_valid), + best_t0_per_period=scatter(np.where(parameter_valid, phase, np.nan)), + best_duration_per_period=scatter(np.where(parameter_valid, duration, np.nan)), + best_depth_per_period=scatter(np.where(parameter_valid, raw['depth'], np.nan)), + best_start_index_per_period=scatter(raw['start']), + best_width_samples_per_period=scatter(raw['width'])) + return public + + +def search_batch(lightcurves, *, return_arrays=False, fap_null_draws=0, + fap_seed=None, execution='baseline', **kwargs): + """Process a survey with the same full search and one shared period grid.""" + if execution not in ('baseline', 'experimental'): + raise ValueError("execution must be 'baseline' or 'experimental'") + kwargs['execution'] = execution + lightcurves = list(lightcurves) + if not lightcurves: + return [] + n_null = _positive_integer(fap_null_draws, 'fap_null_draws', 0) + checked = [] + for i, lc in enumerate(lightcurves): + if len(lc) != 3: + raise ValueError('lightcurve %d must be a (t, y, dy) tuple' % i) + checked.append(_check_inputs(*lc, name='tls_search_batch lightcurve %d' % i)) + if kwargs.get('periods') is None: + t = max(checked, key=lambda lc: np.ptp(lc[0]))[0] + kwargs['periods'] = _grid( + t, None, kwargs.get('R_star', 1.), kwargs.get('M_star', 1.), + kwargs.get('period_min'), kwargs.get('period_max'), + kwargs.get('oversampling_factor', 3), kwargs.get('n_transits_min', 2)) + results = [search(*lc, return_arrays=return_arrays, **kwargs) for lc in checked] + if n_null: + # Refine nulls with exactly the observed search settings. The full + # engine's SDE includes refinement, so a coarse-only null is invalid. + rng = np.random.RandomState(fap_seed) + for result, (t, y, dy) in zip(results, checked): + null_sde = [] + for _ in range(n_null): + permutation = rng.permutation(len(t)) + null_sde.append(search(t, y[permutation], dy[permutation], + return_arrays=False, **kwargs)['SDE']) + null_sde = np.asarray(null_sde) + result.update(SDE_null=null_sde, + FAP=float((1. + np.sum(null_sde >= result['SDE'])) / (1. + n_null))) + return results diff --git a/cuvarbase/tls_reference_math.py b/cuvarbase/tls_reference_math.py new file mode 100644 index 00000000..8667b8e7 --- /dev/null +++ b/cuvarbase/tls_reference_math.py @@ -0,0 +1,701 @@ +"""CPU preparation and scoring for the unbinned reference TLS search. + +Reference: Farthing-0/GTLS at 74e449c325792a763dde4fbffab98039c5e8c111. +This module deliberately retains the reference template, index-based widths, +zero-padding convention, unit flux baseline and native spectrum normalization. +The functions do not initialize a GPU. Template construction imports the +optional ``batman`` dependency only when a cache is requested. + +Adapted portions: grid.py, transit.py, validate.py, stats.py and helpers.py. +MIT License +Copyright (c) 2018 Michael Hippke 2023 Quanquan Hu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import warnings + +import numpy as np + + +GTLS_COMMIT = '74e449c325792a763dde4fbffab98039c5e8c111' +G = 6.673e-11 +R_SUN = 695508000. +R_JUP = 69911000. +# Preserve the pinned expression: it differs by one binary64 ULP from 1.989e30. +M_SUN = 1.989 * 10**30 +SECONDS_PER_DAY = 86400. +DEFAULT_TEMPLATE = dict(per=12.9, rp=.03, a=23.1, inc=89.21, ecc=0., w=90., + u=[.4804, .1867], limb_dark='quadratic') +OVERVIEW_DTYPE = [('duration', 'f8'), ('width_in_samples', 'i8'), ('overshoot', 'f8')] + + +def preprocess_inputs(t, y, dy=None): + """Apply native GTLS cleaning and error rescaling, preserving row indices. + + In particular, GTLS drops t <= 0. A caller adopting a different time-origin + policy must do so explicitly before this function; that policy is not exact + preprocessing parity on arbitrary inputs. Flux is checked, not normalized. + Supplied dy is divided by its arithmetic mean. Omitted dy uses std(flux). + """ + t, y = np.asarray(t), np.asarray(y) + if t.ndim != 1 or y.ndim != 1 or len(t) != len(y): + raise ValueError('t and y must be one-dimensional arrays of equal length') + if dy is not None: + dy = np.asarray(dy) + if dy.ndim != 1 or len(dy) != len(y): + raise ValueError('dy must have the same shape as y') + + def valid(value): + return value is not None and not np.isnan(value) and value > 0 and value < np.inf + + kept = [i for i in range(len(y)) if valid(t[i]) and valid(y[i]) and + (dy is None or valid(dy[i]))] + index = np.array(kept, dtype=np.int64) + clean_t = np.array(t[index], dtype=float) + clean_y = np.array(y[index], dtype=float) + if len(clean_y) < 3 or np.ptp(clean_t) <= 0: + raise ValueError('At least three finite positive samples and a positive time span are required') + if np.mean(clean_y) > 1.01 or np.mean(clean_y) < .99: + warnings.warn('The mean flux should be normalized to 1; input mean is %s' % np.mean(clean_y)) + if dy is None: + clean_dy = np.full(len(clean_y), np.std(clean_y)) + dy_scale = 1. + else: + clean_dy = np.array(dy[index], dtype=float) + dy_scale = float(np.mean(clean_dy)) + clean_dy = clean_dy / np.mean(clean_dy) + return dict(t=clean_t, y=clean_y, dy=clean_dy, kept_indices=index, + input_count=len(t), error_scale=dy_scale) + + +def period_grid(time_span, R_star=1., M_star=1., period_min=0., period_max=np.inf, + oversampling_factor=3., n_transits_min=2, native_fallback=False): + """Ofir grid using pinned GTLS constants; returns native descending order. + + Normal mode respects requested stellar/period bounds, including grids with + fewer than 100 periods. It rejects unsupported stellar values explicitly. + native_fallback=True reproduces GTLS's historical clamps and its <100-point + fallback, which discards requested bounds. That option is for fixtures only. + The search caller must sort the result ascending, as GTLS.main.power does. + """ + R_star, M_star, time_span = float(R_star), float(M_star), float(time_span) + if not np.isfinite(time_span) or time_span <= 0 or not np.isfinite(oversampling_factor) or oversampling_factor <= 0: + raise ValueError('time_span and oversampling_factor must be finite and positive') + if not isinstance(n_transits_min, (int, np.integer)) or n_transits_min < 1: + raise ValueError('n_transits_min must be a positive integer') + if period_min < 0 or period_max <= period_min or np.isnan(period_max): + raise ValueError('Require 0 <= period_min < period_max') + if not np.isfinite(R_star) or not np.isfinite(M_star): + raise ValueError('Stellar radius and mass must be finite') + if native_fallback: + if R_star < .01: + warnings.warn('Native GTLS radius clamp sets R_star=0.1 below 0.01') + R_star = .1 + if R_star > 10000: + warnings.warn('Native GTLS radius clamp sets R_star=10000') + R_star = 10000. + if M_star < .01: + warnings.warn('Native GTLS mass clamp sets M_star=0.01') + M_star = .01 + if M_star > 1000: + warnings.warn('Native GTLS mass clamp sets M_star=1000') + M_star = 1000. + elif not (.01 <= R_star <= 10000 and .01 <= M_star <= 1000): + raise ValueError('Reference period grid supports 0.01 <= R_star <= 10000 and 0.01 <= M_star <= 1000; provide explicit periods for other ranges') + + radius, mass, span = R_star * R_SUN, M_star * M_SUN, time_span * SECONDS_PER_DAY + f_min = n_transits_min / span + f_max = 1. / (2 * np.pi) * np.sqrt(G * mass / (3 * radius)**3) + A = ((2 * np.pi)**(2. / 3) / np.pi * radius / (G * mass)**(1. / 3) / + (span * oversampling_factor)) + C = f_min**(1. / 3) - A / 3. + count = (f_max**(1. / 3) - f_min**(1. / 3) + A / 3) * 3 / A + X = np.arange(count) + 1 + frequencies = (A / 3 * X + C)**3 + periods = (1 / frequencies) / SECONDS_PER_DAY + periods = periods[(periods > period_min) & (periods <= period_max)] + if len(periods) > 10**6: + warnings.warn('Reference period grid contains more than one million periods') + if len(periods) < 100 and native_fallback: + warnings.warn('Native GTLS resets short grids to solar parameters and default period limits') + return period_grid(max(time_span, 5.), native_fallback=True) + return np.asarray(periods, dtype=np.float64) + + +def _t14(R_s, M_s, period, small=False, upper_limit=.12): + seconds = period * SECONDS_PER_DAY + radius = R_SUN * R_s + mass = M_SUN * M_s + if not small: + radius += 2 * R_JUP + duration = radius * ((4 * seconds) / (np.pi * G * mass))**(1. / 3) + return min(duration / seconds, upper_limit) + + +def duration_grid(periods, duration_grid_step=1.1): + """Pinned global duration grid; native accepted stellar bounds are unused. + + Host constants are R=[0.13,3.5], M=[0.1,1], cap=0.12. The separate CUDA + admissibility envelope uses different literal constants. These are sample + index widths once cached; they are not an exact phase-domain duration fit. + """ + periods = np.asarray(periods, dtype=np.float64) + if periods.ndim != 1 or len(periods) == 0 or not np.all(np.isfinite(periods)) or np.any(periods <= 0): + raise ValueError('periods must be a nonempty positive finite 1D array') + if not np.isfinite(duration_grid_step) or duration_grid_step <= 1: + raise ValueError('duration_grid_step must be greater than one') + maximum = _t14(3.5, 1., min(periods), small=False) + minimum = _t14(.13, .1, max(periods), small=True) + estimated_rows = max(2., np.ceil((np.log(maximum) - np.log(minimum)) / + np.log(duration_grid_step)) + 1.) + if not np.isfinite(estimated_rows) or estimated_rows > 1_000_000: + raise ValueError('duration_grid_step would create more than 1,000,000 ' + 'duration rows; choose a coarser duration_grid_step ' + 'or explicit sample-resolved qmin/qmax bounds') + widths = [minimum] + current = minimum + while current * duration_grid_step < maximum: + # Repeated multiplication is retained for exact native default rows. + # Guard its possible accumulated last-bit error at the estimate limit. + if len(widths) >= 999_999: + raise ValueError('duration_grid_step would create more than 1,000,000 ' + 'duration rows; choose a coarser duration_grid_step ' + 'or explicit sample-resolved qmin/qmax bounds') + current *= duration_grid_step + widths.append(current) + widths.append(maximum) + return np.array(widths, dtype=np.float64) + + +def resolve_template(transit_template='default', u=None, limb_dark='quadratic', + template_parameters=None): + """Mirror GTLS.validate_args template resolution, including default resets.""" + result = dict(DEFAULT_TEMPLATE) + result['u'] = list(DEFAULT_TEMPLATE['u'] if u is None else u) + result['limb_dark'] = limb_dark + supplied = {} if template_parameters is None else dict(template_parameters) + unsupported = set(supplied) - set(DEFAULT_TEMPLATE) - {'b'} + if unsupported: + raise ValueError('Unknown template parameters: ' + ', '.join(sorted(unsupported))) + result.update({k: v for k, v in supplied.items() if k != 'b'}) + if 'b' in supplied: + result['inc'] = np.degrees(np.arccos(supplied['b'] / result['a'])) + if transit_template == 'default': + for key in ('per', 'rp', 'a', 'inc'): + result[key] = DEFAULT_TEMPLATE[key] + elif transit_template == 'grazing': + result['inc'] = np.degrees(np.arccos(.99 / result['a'])) + elif transit_template == 'box': + result.update(per=29., rp=.1, a=26.9, inc=90., u=[0.], limb_dark='linear') + else: + raise ValueError('transit_template must be default, grazing, or box') + result['u'] = list(result['u']) + return result + + +def _interp(x_new, x, y): + """Native linear interpolation arithmetic, without a Numba dependency.""" + x, y, x_new = np.asarray(x), np.asarray(y), np.asarray(x_new) + if len(x) < 2: + raise ValueError('Reference interpolation needs at least two samples') + index = np.clip(np.searchsorted(x, x_new, side='right') - 1, 0, len(x) - 2) + theta = (x_new - x[index]) / (x[index + 1] - x[index]) + return (1 - theta) * y[index] + theta * y[index + 1] + + +def _reference_transit(samples, parameters): + import batman + t = np.linspace(-.5, .5, 10000) + params = batman.TransitParams() + params.t0 = 0 + for name, value in parameters.items(): + setattr(params, name, value) + flux = batman.TransitModel(params, t).light_curve(params) + first = np.argmax(flux < 1) + interior_flux = flux[first:-first + 1] + interior_time = t[first:-first + 1] + x_new = np.linspace(t[first], t[-first - 1], samples) + sampled = _interp(x_new, interior_time, interior_flux) + return (np.min(sampled) - sampled) / (np.min(sampled) - 1) + + +def build_cache(periods, ndata, transit_template='default', duration_grid_step=1.1, + u=None, limb_dark='quadratic', template_parameters=None, + fractional_durations=None, strict=False): + """Return exact host cache rows plus contiguous float32 GPU input buffers. + + fractional_durations is an explicit caller-owned search override. The + default preserves the pinned global duration grid. template_deficits uses + GTLS's literal *zero-flux* padding, so padded deficits are one. signal_lengths + records the trimmed cache lengths separately; the GPU still scans widths. + Normal mode omits rows with no representable in-transit sample and records + them in omitted_rows. This keeps all usable reference rows on sparse input + where GTLS otherwise fails constructing the entire cache. strict=True + retains that failure for native compatibility fixtures. + """ + if not isinstance(ndata, (int, np.integer)) or ndata < 3: + raise ValueError('ndata must be an integer >= 3') + durations = (duration_grid(periods, duration_grid_step) if fractional_durations is None + else np.asarray(fractional_durations, dtype=np.float64)) + if durations.ndim != 1 or len(durations) == 0 or not np.all(np.isfinite(durations)) or np.any(durations <= 0): + raise ValueError('fractional durations must be a nonempty positive finite 1D array') + maxwidth = int(np.max(durations) * ndata) + if maxwidth % 2: + maxwidth += 1 + if maxwidth < 2: + raise ValueError('Reference template cache is undersampled (maximum width < 2 samples)') + params = resolve_template(transit_template, u, limb_dark, template_parameters) + reference = _reference_transit(maxwidth, params) + overview = np.zeros(len(durations), dtype=OVERVIEW_DTYPE) + curves = [None] * len(durations) + usable = [] + omitted = [] + reference_time = np.linspace(-.5, .5, maxwidth) + for row, duration in enumerate(durations): + used = int((duration / np.max(durations)) * maxwidth) + if used < 1: + if strict: + raise ValueError('Reference cache contains a zero-sample duration') + omitted.append(dict(index=row, duration=float(duration), width_in_samples=used, + reason='zero-sample duration')) + continue + sampled = _interp(np.linspace(-.5, .5, used), reference_time, reference) + missing = maxwidth - used + empty = np.ones(int(missing * .5)) + scaled = np.append(np.append(empty, sampled), empty) + if len(scaled) < maxwidth: + scaled = np.append(scaled, np.ones(1)) + scaled = 1 - ((1 - scaled) * .5) + inside = np.where(scaled < (1 - .01e-6))[0] + if len(inside) == 0: + if strict: + raise ValueError('Reference cache contains a template with no in-transit samples') + omitted.append(dict(index=row, duration=float(duration), width_in_samples=used, + reason='no in-transit template samples')) + continue + signal = scaled[int(np.min(inside)):int(np.max(inside)) + 1] + overshoot = np.mean(signal) / np.min(signal) + overview[row] = duration, used, 1 / (2 - overshoot) + curves[row] = signal + usable.append(row) + if not usable: + raise ValueError('No reference template is representable at this sample count') + overview = overview[usable] + curves = [curves[i] for i in usable] + widths, indices = np.unique(overview['width_in_samples'], return_index=True) + curves_unique = [curves[i] for i in indices] + template_deficits = 1 - np.array([np.pad(curve, (0, int(np.max(widths)) - len(curve)), 'constant') + for curve in curves_unique]) + return dict(duration_grid=durations, overview=overview, unique_indices=indices, + widths=np.asarray(widths, dtype=np.int32), + template_deficits=np.ascontiguousarray(template_deficits, dtype=np.float32), + signal_lengths=np.array([len(v) for v in curves_unique], dtype=np.int32), + overshoot=np.ascontiguousarray(overview['overshoot'][indices], dtype=np.float32), + template_parameters=params, reference_flux=reference, + signal_flux=curves_unique, reference_maxwidth=maxwidth, + padded_data_width=int(np.max(widths)) + int(np.max(widths) % 2), + overview_source_indices=np.array(usable, dtype=np.int64), + omitted_rows=omitted, strict_native_cache=bool(strict)) + + +def augment_duration_grid(periods, ndata, qmin, qmax, duration_grid_step=1.1, + n_durations=None): + """Build a bounded-memory cache grid for explicit fractional durations. + + ``qmin`` and ``qmax`` are positive scalars or arrays aligned with periods; + they refer to the native cache's nominal duration/period, not width/N. + Requested boundaries and geometric rows augment the native global grid. + ``n_durations`` is an optional integer or aligned integer array >= 2 and + specifies a minimum density: other periods can contribute extra rows. + + Rows with equal integer sample widths have identical native templates, so + only their lowest nominal q is retained, plus a maximum-q sentinel that + keeps build_cache's template normalization unchanged. Both boundaries are + inserted before deduplication. Since q -> width is monotonic, a width has + at least one contributing row inside a period's bounds exactly when it is + between that period's returned width_minima and width_maxima. Apply those + limits per period; a logical-group union would broaden an explicit search. + + For winner metadata, max(representative_q, requested_qmin[period_index]) + gives its lowest admissible contributing nominal duration. The cache's + shared representative can lie below a particular period's lower bound. + + Working storage is O(Nperiod + Ndata), with at most Ndata cache rows. When + a requested geometric grid has more rows than representable sample widths, + all admissible integer widths are included, giving at least that density. + Empty/zero-sample templates are still recorded and omitted by build_cache. + """ + if not isinstance(ndata, (int, np.integer)) or ndata < 3: + raise ValueError('ndata must be an integer >= 3') + periods = np.asarray(periods, dtype=np.float64) + if periods.ndim != 1 or len(periods) == 0 or not np.all(np.isfinite(periods)) or np.any(periods <= 0): + raise ValueError('periods must be a nonempty positive finite 1D array') + if not np.isfinite(duration_grid_step) or duration_grid_step <= 1: + raise ValueError('duration_grid_step must be greater than one') + + def aligned(value, name, dtype): + result = np.asarray(value) + if result.ndim > 1 or (result.ndim == 1 and result.shape != periods.shape): + raise ValueError(name + ' must be a scalar or an array aligned with periods') + return np.full(len(periods), result, dtype=dtype) if result.ndim == 0 else result.astype(dtype, copy=True) + + lower, upper = aligned(qmin, 'qmin', float), aligned(qmax, 'qmax', float) + if (np.any(~np.isfinite(lower)) or np.any(~np.isfinite(upper)) or + np.any(lower <= 0) or np.any(upper >= 1) or np.any(lower > upper)): + raise ValueError('Require finite 0 < qmin <= qmax < 1 for every period') + original_lower = _t14(.13, .1, np.max(periods), small=True) + original_upper = _t14(3.5, 1., np.min(periods), small=False) + maximum_q = max(float(original_upper), float(np.max(upper))) + maximum_width = int(maximum_q * ndata) + maximum_width += maximum_width % 2 + if maximum_width < 2: + raise ValueError('Reference template cache is undersampled (maximum width < 2 samples)') + representative = np.full(maximum_width + 1, np.inf) + + def widths(values): + return ((np.asarray(values) / maximum_q) * maximum_width).astype(np.int64) + + def add(values): + values = np.asarray(values, dtype=np.float64) + np.minimum.at(representative, widths(values), values) + + original_count = max(2, int(np.ceil((np.log(original_upper) - np.log(original_lower)) / + np.log(duration_grid_step))) + 1) + original_saturated = original_count > maximum_width + 1 + if original_saturated: + add([original_lower, original_upper]) + else: + original = duration_grid(periods, duration_grid_step) + original_count = len(original) + add(original) + add(lower) + add(upper) + minima, maxima = widths(lower), widths(upper) + log_lower = np.log(lower) + log_range = np.log(upper) - log_lower + if n_durations is None: + log_step = np.full(len(periods), np.log(duration_grid_step)) + # Cap before conversion to integer: arbitrarily tiny grid steps should + # saturate sample resolution instead of overflowing an integer count. + count = np.minimum(np.ceil(log_range / log_step) + 1, maximum_width + 2).astype(np.int64) + count = np.maximum(count, 2) + else: + supplied = np.asarray(n_durations) + if supplied.dtype.kind not in 'iu' or np.any(supplied < 2): + raise ValueError('n_durations must contain integers >= 2') + count = aligned(n_durations, 'n_durations', np.int64) + if np.any(count < 2): + raise ValueError('n_durations is outside the supported integer range') + log_step = log_range / (count - 1) + saturated = count > maximum_width + 1 + ordinary = ~saturated + for level in range(1, int(np.max(count[ordinary])) - 1 if np.any(ordinary) else 1): + active = ordinary & (level < count - 1) + if np.any(active): + values = np.exp(log_lower[active] + level * log_step[active]) + # Endpoints were inserted exactly; constrain transcendental last + # bits so generated interior rows never exceed their own bounds. + add(np.clip(values, lower[active], upper[active])) + if np.any(saturated) or original_saturated: + # Partial first widths are already represented by exact qmin. Union + # the interior integer-width intervals with a difference array. + change = np.zeros(maximum_width + 2, dtype=np.int64) + np.add.at(change, minima[saturated] + 1, 1) + np.add.at(change, maxima[saturated] + 1, -1) + if original_saturated: + first, last = widths([original_lower, original_upper]) + change[first + 1] += 1 + change[last + 1] -= 1 + target = np.flatnonzero(np.cumsum(change)[:maximum_width + 1] > 0) + values = target / maximum_width * maximum_q + rounded_down = widths(values) < target + while np.any(rounded_down): + values[rounded_down] = np.nextafter(values[rounded_down], np.inf) + rounded_down = widths(values) < target + add(values) + usable = np.flatnonzero(np.isfinite(representative) & (np.arange(len(representative)) > 0)) + fractions = representative[usable] + # A smaller nominal duration can round to the same final width. Dropping + # the actual maximum would change build_cache's normalization and every + # template. Retain it as a duplicate-width row; build_cache dedups later. + if fractions[-1] != maximum_q: + fractions = np.append(fractions, maximum_q) + return dict(fractional_durations=fractions, widths=usable.astype(np.int32), + representative_durations=representative[usable], + width_minima=minima.astype(np.int32), width_maxima=maxima.astype(np.int32), + requested_qmin=lower, requested_qmax=upper, + reference_maxwidth=maximum_width, maximum_fractional_duration=maximum_q, + metadata=dict(duration_semantics='nominal cached duration/period', + eligibility='per-period OR of contributing nominal rows; no group widening', + n_durations_semantics='minimum geometric density, with extra admissible widths allowed', + saturated_period_count=int(np.sum(saturated)), + original_grid_saturated=bool(original_saturated), + grid_row_count=len(fractions), unique_width_count=len(usable), + original_grid_row_count=original_count)) + + +def nominal_width_bounds(periods, ndata, time_span): + """Host translation of pinned CUDA duration literals. + + CUDA computes pow in double and receives a float32 time span. Boundary + rounding must be checked against the device for exact integer-grid parity. + Returned bounds are nominal; the native scan uses their union per chunk. + """ + periods = np.asarray(periods, dtype=np.float64) + seconds = periods * 86400 + qmin = np.minimum(.15, (695508000 * .05) * ((4 * seconds) / (20848 * 1e15))**(1. / 3) / seconds) + qmax = np.minimum(.15, (695508000 * 4 + 2 * 69911000) * ((4 * seconds) / (416970 * 1e15))**(1. / 3) / seconds) + transits = float(np.float32(time_span)) / periods + correction = (transits + 1.) / transits + return np.floor(qmin * ndata).astype(np.int32), np.ceil(qmax * ndata * correction).astype(np.int32) + + +def chunk_width_masks(widths, minima, maxima, chunk_size): + """Native union of admissible integer widths over each period chunk.""" + if not isinstance(chunk_size, (int, np.integer)) or chunk_size < 1: + raise ValueError('chunk_size must be a positive integer') + widths, minima, maxima = np.asarray(widths), np.asarray(minima), np.asarray(maxima) + if minima.shape != maxima.shape or minima.ndim != 1: + raise ValueError('minima and maxima must be aligned 1D arrays') + admissible = (widths[None, :] >= minima[:, None]) & (widths[None, :] <= maxima[:, None]) + return np.array([np.any(admissible[start:start + chunk_size], axis=0) + for start in range(0, len(minima), chunk_size)], dtype=bool) + + +def epoch_strides(widths, T0_fit_margin=.125, full=False): + """Native coarse window-start stride; full candidate stages use every row.""" + widths = np.asarray(widths, dtype=np.int32) + if full or T0_fit_margin <= 0: + return np.ones_like(widths) + margin = min(float(T0_fit_margin), .125) + skip_point = int(1 / margin) + return np.where(widths > skip_point, widths // skip_point, 1).astype(np.int32) + + +def _running_median(data, kernel, window_chunk=8192): + """Native per-window masked medians with bounded temporary allocations. + + Concatenating masked chunk results with ma.concatenate preserves masks + until the exact native np.append edge-padding operations. Using np.append + for the intermediate join would prematurely discard masks on some NumPy + versions and change fully masked-window behavior. + """ + if not isinstance(window_chunk, (int, np.integer)) or window_chunk < 1: + raise ValueError('window_chunk must be a positive integer') + window_count = len(data) - kernel + 1 + offsets = np.arange(kernel) + pieces = [] + for start in range(0, int(np.ceil(window_count)), window_chunk): + stops = min(start + window_chunk, window_count) + index = offsets + np.arange(start, stops)[:, None] + pieces.append(np.ma.median(data[index.astype(int)], axis=1)) + med = np.ma.concatenate(pieces) + missing = len(data) - len(med) + front = int(missing * .5) + return np.append(np.append(np.full(front, med[0]), med), np.full(missing - front, med[-1])) + + +def spectrum_kernel_size(oversampling_factor=3, kernel_size=None): + """Validate the native median-window policy before any GPU execution.""" + if kernel_size is None: + width = float(oversampling_factor) * 30 + if not np.isfinite(width) or width < 1 or width != np.floor(width): + raise ValueError('30 * oversampling_factor must be a positive integer ' + 'for the native SDE window; provide an integer ' + 'sde_kernel_size for another oversampling factor') + kernel_size = int(width) + elif not isinstance(kernel_size, (int, np.integer)) or kernel_size < 1: + raise ValueError('kernel_size must be a positive integer or None') + return kernel_size + (kernel_size % 2 == 0) + + +def native_spectra(chi2, oversampling_factor=3, mask_outliers=True, kernel_size=None): + """Pinned GTLS spectrum arithmetic and max-detrended-power primary rank. + + Preserve input dtype (the GPU reference returns float32 residuals) and the + masked-array arithmetic. No replacement of unsupported/degenerate scores + by invented detections is performed. Full-mode stages preserve the current + mask and pass mask_outliers=False when recomputing this spectrum. + An explicit positive integer kernel_size changes the detection statistic; + even values are increased by one. None preserves the native default. + """ + kernel = spectrum_kernel_size(oversampling_factor, kernel_size) + raw_input = np.asanyarray(chi2).copy() + chi2 = np.ma.array(raw_input, copy=False) + if mask_outliers: + mask = raw_input > (100 * np.median(raw_input)) + chi2 = np.ma.array(raw_input, mask=np.ma.getmaskarray(raw_input) | np.ma.filled(mask, True)) + with np.errstate(divide='ignore', invalid='ignore'): + SR = np.min(chi2) / chi2 + SDE_raw = (1 - np.mean(SR)) / np.std(SR) + power_raw = SR - np.mean(SR) + power_raw = power_raw * (SDE_raw / np.max(power_raw)) + if len(power_raw) > 2 * kernel: + power = power_raw - _running_median(power_raw, kernel) + power = power - np.mean(power) + SDE = np.max(power / np.std(power)) + power = power * (SDE / np.max(power)) + else: + power, SDE = power_raw, SDE_raw + finite_power = np.ma.filled(power, np.nan) + primary = int(np.nanargmax(finite_power)) if np.any(np.isfinite(finite_power)) else None + return dict(chi2=chi2, SR=SR, power_raw=power_raw, power=power, + SDE_raw=SDE_raw, SDE=SDE, primary_index=primary) + + +def refinement_candidate_indices(periods, power): + """Rank valid candidates: top100, then next100 at P>1d. + + Filtering before the stable sort fixes a native GTLS host-mask defect. + Its masked scalars do not form a total ordering and can enter the top100 + period list as NaN, leading to undefined GPU integer conversions. Only + finite, unmasked scores and periods represent physical first-stage trials. + The native rank policy and tie order are unchanged on valid entries. + """ + periods, power = np.ma.asarray(periods), np.ma.asarray(power) + valid = (~np.ma.getmaskarray(periods) & ~np.ma.getmaskarray(power) & + np.isfinite(np.ma.getdata(periods)) & + np.isfinite(np.ma.getdata(power))) + combined = [(i, (periods.data[i], -power.data[i])) + for i in np.flatnonzero(valid)] + ranked = sorted(combined, key=lambda item: item[1][1]) + top = [item[0] for item in ranked[:100]] + remaining = [item for item in combined if item[0] not in top and item[1][0] > 1] + next_best = sorted(remaining, key=lambda item: item[1][1])[:100] + return np.array(top + [item[0] for item in next_best], dtype=np.int64) + + +def harmonic_candidate_indices(periods, primary_period): + """Nearest existing periods to [0.5,1,2,2/3,3/2] times the chosen period.""" + # Native find_nearest_indices explicitly converts masked periods to an + # ordinary ndarray before argmin, so masked grid rows can be selected here. + periods = np.array(periods) + return np.array([np.argmin(np.abs(periods - primary_period * scale)) + for scale in (.5, 1., 2., 2./3, 3./2)], dtype=np.int64) + + +def _native_transit_times(T0, t, period): + times = [T0 + period] if T0 < min(t) else [T0] + previous = times[0] + while True: + following = previous + period + if following < (np.min(t) + (np.max(t) - np.min(t))): + times.append(following) + previous = following + else: + return np.array(times) + + +def _native_duration_days(t, period, start_epoch, raw_duration): + shifted = start_epoch + period / 2 + phases = (t - shifted) / period - np.floor((t - shifted) / period) + sorted_phases = phases[np.argsort(phases)] + first = np.argmin(np.abs(sorted_phases - .5)) + last = first + int(np.array(raw_duration) * len(t)) + if not 0 <= last < len(t): + raise ValueError('Native final duration estimate exceeds the sorted phase array') + duration = (sorted_phases[last] - .5) * period + if not np.isfinite(duration) or duration <= 0: + raise ValueError('Native final duration estimate is nonpositive') + return duration + + +def final_parameters(t, y, dy, period, cache, width_index, epoch_index, + exposure_days=None, fit_chi2=None, error_scale=1.): + """CPU postprocessing for the winning no-skip GTLS sample window. + + t/y/dy must already be preprocessed. width_index addresses cache['widths']; + epoch_index is the start row in the phase-sorted sample array. Native GTLS + recomputes its depth in float64 after GPU selection; this helper does too. + + duration/T0/depth follow native sample-window conventions. SNR preserves + cuvarbase's sqrt(delta chi-squared) definition in original error units, + using error_scale from preprocess_inputs. The historically inconsistent + native GTLS SNR mask is reproduced only as native_gtls_snr, never as SNR. + Exposure information is diagnostic; neither cache nor fit integrates it. + """ + t, y, dy = [np.asarray(value, dtype=np.float64) for value in (t, y, dy)] + if t.ndim != 1 or y.shape != t.shape or dy.shape != t.shape or len(t) < 3: + raise ValueError('t, y and dy must be aligned 1D arrays') + if not np.isfinite(period) or period <= 0 or not np.isfinite(error_scale) or error_scale <= 0: + raise ValueError('period and error_scale must be finite and positive') + if not 0 <= int(width_index) < len(cache['widths']) or not 0 <= int(epoch_index) < len(t): + raise ValueError('Winning width/epoch index is out of range') + width_index, epoch_index = int(width_index), int(epoch_index) + width = int(cache['widths'][width_index]) + if width > len(t): + raise ValueError('Winning sample window exceeds one folded light curve') + row = cache['overview'][int(cache['unique_indices'][width_index])] + raw_duration = float(row['duration']) + # Match core.foldCPU, which deliberately recomputes the phase order here. + rank = np.argsort((t % period) / period) + sorted_time, sorted_flux = t[rank], y[rank] + window_rows = (np.arange(width) + epoch_index) % len(t) + window_flux = sorted_flux[window_rows] + mean = window_flux.mean() + depth = ((1 - mean) * row['overshoot']).item() + first_time = sorted_time[epoch_index] + start_epoch = first_time - int((first_time - min(t)) / period) * period - period + duration = float(_native_duration_days(t, period, start_epoch, raw_duration)) + predicted_times = _native_transit_times(start_epoch, t, period) + duration / 2 + T0 = start_epoch + duration / 2 + if T0 < min(t): + T0 += period + + unit_model = np.ones(len(t)) + deficit = np.asarray(cache['template_deficits'][width_index, :width], dtype=np.float64) + unit_model[rank[window_rows]] -= deficit * (depth * 2.) + chi2_null = float(np.sum(((1 - y) / dy)**2)) + chi2_cpu_model = float(np.sum(((unit_model - y) / dy)**2)) + chi2_fit = chi2_cpu_model if fit_chi2 is None else float(fit_chi2) + delta_chi2 = (chi2_null - chi2_fit) / float(error_scale)**2 + snr = np.sqrt(max(0., delta_chi2)) if np.isfinite(delta_chi2) else np.nan + + odd, even, per_event = [], [], [] + for i, epoch in enumerate(predicted_times): + inside = (t > epoch - duration / 2) & (t < epoch + duration / 2) + per_event.append(int(np.sum(inside))) + (even if i % 2 == 0 else odd).extend(y[inside]) + all_intransit = np.concatenate((np.array(odd), np.array(even))) + # Preserve the pinned expression only under an explicit native diagnostic: + # core passes fractional raw_duration to a mask accepting duration in days. + native_mask = np.abs((t - T0 + .5 * period) % period - .5 * period) < raw_duration + native_ootr = y[~native_mask] + if len(all_intransit) and len(native_ootr) and np.std(native_ootr) > 0: + native_snr = ((1 - np.mean(all_intransit)) / np.std(native_ootr)) * len(all_intransit)**.5 + else: + native_snr = np.nan + + exposure = dict(integrated_in_search=False, supplied=exposure_days is not None) + if exposure_days is not None: + values = np.asarray(exposure_days, dtype=np.float64) + if values.ndim > 1 or (values.ndim == 1 and len(values) != len(t)) or np.any(~np.isfinite(values)) or np.any(values < 0): + raise ValueError('exposure_days must be a finite nonnegative scalar or aligned array') + exposure.update(minimum_days=float(np.min(values)), maximum_days=float(np.max(values)), + median_days=float(np.median(values))) + return dict(period=float(period), T0=float(T0), + t0_phase=float(((T0 - np.floor(np.min(t))) / period) % 1.), + duration=duration, depth=float(depth), fractional_duration=raw_duration, + width_in_samples=width, width_index=width_index, epoch_index=epoch_index, + chi2_min=chi2_fit, chi2_null=chi2_null, chi2_cpu_model=chi2_cpu_model, + chi2_error_scale=float(error_scale), delta_chi2=delta_chi2, SNR=float(snr), + native_gtls_snr=float(native_snr), n_transits=len(predicted_times), + observed_transits=sum(v > 0 for v in per_event), transit_times=predicted_times, + per_transit_count=np.array(per_event, dtype=np.int64), exposure=exposure) diff --git a/cuvarbase/tls_reference_prefix.py b/cuvarbase/tls_reference_prefix.py new file mode 100644 index 00000000..928e5299 --- /dev/null +++ b/cuvarbase/tls_reference_prefix.py @@ -0,0 +1,153 @@ +"""Replay GTLS's native float32 row scans without a Python loop per batch. + +CuPy's cumsum with axis=None and with an explicit matrix axis can use different +summation orders. This plan captures the original one-dimensional operations; +it does not substitute a segmented scan with different floating-point outputs. +""" +import operator +import time + +import cupy as cp +import numpy as np + + +def _shape(shape): + shape = tuple(operator.index(n) for n in shape) + if len(shape) != 2 or min(shape) <= 0: + raise ValueError('A native prefix plan requires a positive two-dimensional shape') + return shape + + +class NativePrefixPlan: + """A reusable float32 row-prefix CUDA graph with owned buffers. + + ``plan(array)`` copies ``array`` into the plan's input buffer and returns + its output buffer. The copy and graph replay use CuPy's current stream. + The returned output is overwritten by the next call. Consumers must finish + reading it before reuse, or run in the same ordered stream. Instances must + not be called concurrently; a thread-local bounded cache is appropriate. + + The graph, input/output arrays, capture stream and a private temporary + memory pool remain alive together. The private pool prevents captured CUB + workspace pointers from being recycled by unrelated GPU work. + + ``max_bytes`` caps the owned input/output/workspace storage. CUDA's internal + graph bookkeeping is driver-managed and is not included in that accounting. + ``close()`` synchronizes the device before releasing resources; callers + that already completed all consumers may use ``close(synchronize=False)``. + """ + + @staticmethod + def key_for(shape, device_id=None): + """Key for a cache belonging to one thread and CUDA context.""" + if device_id is None: + device_id = cp.cuda.runtime.getDevice() + return (int(device_id), _shape(shape), np.dtype(np.float32).str) + + @staticmethod + def buffer_bytes_for(shape): + """Required input/output storage, before the small CUB workspace.""" + rows, columns = _shape(shape) + return 2 * rows * columns * np.dtype(np.float32).itemsize + + def __init__(self, shape, *, device_id=None, max_bytes=None): + self.key = self.key_for(shape, device_id) + self.device_id, self.shape, _ = self.key + self.max_bytes = None if max_bytes is None else operator.index(max_bytes) + self.buffer_bytes = self.buffer_bytes_for(self.shape) + if self.max_bytes is not None and self.buffer_bytes > self.max_bytes: + raise MemoryError('Native prefix buffers exceed max_bytes') + self.input = self.output = None + self._pool = self._capture_stream = self._graph = self._last_stream = None + self.workspace_bytes = self.owned_bytes = 0 + self.closed = False + begin = time.perf_counter() + try: + with cp.cuda.Device(self.device_id): + self.input = cp.zeros(self.shape, dtype=cp.float32) + self.output = cp.empty(self.shape, dtype=cp.float32) + self._pool = cp.cuda.MemoryPool() + self._capture_stream = cp.cuda.Stream(non_blocking=True) + cp.cuda.get_current_stream().synchronize() + with self._capture_stream, cp.cuda.using_allocator(self._pool.malloc): + # Warm every row alignment and allocate CUB's workspace + # before capture, where fresh cudaMalloc is prohibited. + for row in range(self.shape[0]): + cp.cumsum(self.input[row], out=self.output[row]) + self._capture_stream.synchronize() + self._check_footprint() + self._capture_stream.begin_capture() + try: + for row in range(self.shape[0]): + cp.cumsum(self.input[row], out=self.output[row]) + self._graph = self._capture_stream.end_capture() + except BaseException: + # End an invalidated capture so the stream and CuPy + # context are usable when the caller handles failure. + try: + self._capture_stream.end_capture() + except Exception: + pass + raise + self._check_footprint() + except BaseException: + try: + self.close() + except Exception: + pass + raise + self.setup_seconds = time.perf_counter() - begin + + def _check_footprint(self): + self.workspace_bytes = self._pool.total_bytes() + self.owned_bytes = self.buffer_bytes + self.workspace_bytes + if self.max_bytes is not None and self.owned_bytes > self.max_bytes: + raise MemoryError('Native prefix workspace exceeds max_bytes') + + @property + def footprint(self): + return dict(device_id=self.device_id, shape=self.shape, dtype='float32', + buffer_bytes=0 if self.closed else self.buffer_bytes, + workspace_bytes=self.workspace_bytes, owned_bytes=self.owned_bytes, + driver_graph_storage_included=False) + + def __call__(self, array): + if self.closed: + raise RuntimeError('Native prefix plan is closed') + if not isinstance(array, cp.ndarray): + raise TypeError('Native prefix input must be a CuPy array') + if array.shape != self.shape or array.dtype != cp.float32: + raise ValueError('Native prefix input must match the plan shape and float32 dtype') + if array.device.id != self.device_id or cp.cuda.runtime.getDevice() != self.device_id: + raise ValueError('Native prefix plan and current CuPy device differ') + stream = cp.cuda.get_current_stream() + if self._last_stream is not None and self._last_stream.ptr != stream.ptr: + # Serial use may change streams. Finish the prior replay before + # another stream overwrites the shared input/output buffers. + self._last_stream.synchronize() + cp.copyto(self.input, array) + self._graph.launch(stream) + self._last_stream = stream + return self.output + + def close(self, *, synchronize=True): + if self.closed: + return + with cp.cuda.Device(self.device_id): + if synchronize: + cp.cuda.runtime.deviceSynchronize() + self._graph = None + self.input = self.output = None + if self._pool is not None: + self._pool.free_all_blocks() + self._pool = self._capture_stream = self._last_stream = None + self.workspace_bytes = self.owned_bytes = 0 + self.closed = True + + def __enter__(self): + if self.closed: + raise RuntimeError('Native prefix plan is closed') + return self + + def __exit__(self, *exc): + self.close() diff --git a/cuvarbase/tls_reference_short_prefix.py b/cuvarbase/tls_reference_short_prefix.py new file mode 100644 index 00000000..56a52024 --- /dev/null +++ b/cuvarbase/tls_reference_short_prefix.py @@ -0,0 +1,167 @@ +"""Guarded reuse of CUB's native first-and-last-tile float32 scan. + +Rows beyond one tile retain the native graph implementation. This wrapper +uses the installed CUB agent, including its load mapping and addition tree; +an explicit matrix-axis cumsum would use a different floating-point tree. +""" +import operator +import os +from pathlib import Path +import time + +import cupy as cp +import numpy as np + +from .utils import find_kernel + + +class NativeShortPrefixCache: + """Small per-thread cache of audited modules belonging to CUDA contexts. + + ``prefix(array)`` returns a new output, or None to request native graphs. + No input/output buffers or mutable scan state are retained. Unsupported + builds and compilation/canary failures fall back; CUDA execution faults + propagate. The cache retains at most ``max_contexts`` entries, including + failed compilations. Additional contexts use native graphs. Retaining + entries avoids unloading a module while an earlier stream uses it. + """ + + def __init__(self, max_contexts=2): + self.max_contexts = operator.index(max_contexts) + if self.max_contexts < 1: + raise ValueError('Short prefix cache must retain at least one context') + self.entries = {} + self.compile_seconds = self.canary_seconds = 0. + self.dispatch_calls = self.fallback_calls = 0 + self._status = dict(active=False, supported=False, fallback_reason='unused', + device=None, context=None) + + @staticmethod + def unsupported_reason(array): + if not isinstance(array, cp.ndarray) or array.ndim != 2: + return 'input is not a CuPy matrix' + rows, columns = array.shape + if array.dtype != cp.float32 or not array.flags.c_contiguous: + return 'input is not contiguous float32' + if not 0 < rows <= np.iinfo(np.int32).max or not 0 < columns <= 1920: + return 'shape exceeds the native single-tile domain' + if cp.__version__ != '13.6.0' or cp.cuda.runtime.is_hip: + return 'CuPy build is not the audited CUDA 13.6.0 build' + if any(os.environ.get(name) for name in + ('NVCC', 'NVCC_PREPEND_FLAGS', 'NVCC_APPEND_FLAGS')): + return 'custom NVCC command or injected compiler flags' + from cupy._core import _accelerator + from cupy.cuda import cub + if cub.get_build_version() != 200800 or cp.cuda.driver.get_build_version() != 12090: + return 'CUB or CUDA build differs from the audited wheel' + if cp.cuda.Device().compute_capability != '86': + return 'GPU architecture is not SM86' + if array.device.id != cp.cuda.runtime.getDevice(): + return 'array and current CUDA device differ' + # This is mutable process state, so recheck it on every dispatch. + if _accelerator.ACCELERATOR_CUB not in _accelerator.get_routine_accelerators(): + return 'CUB routine accelerator is disabled' + return None + + @staticmethod + def compile(): + if not cp.cuda.get_nvcc_path(): + raise OSError('nvcc is unavailable') + include = Path(cp.__file__).parent / '_core/include/cupy/_cccl' + # Use the installed headers, retaining their native implementation + # and license notices. The C++ wrapper pins nvcc and the CUB policy. + with open(find_kernel('tls_reference_short_prefix')) as source: + code = source.read() + # RawModule's cache compiler unconditionally appends -ftz=true in + # CuPy 13.6, unlike the native CUB wheel. Compile directly so subnormal + # additions retain the native behavior; the bounded context cache + # below amortizes this compilation without changing compiler flags. + options = tuple(['--std=c++17', '-ftz=false'] + + ['-I' + str(include / name) + for name in ('cub', 'thrust', 'libcudacxx')]) + cubin = cp.cuda.compiler.compile_using_nvcc(code, options=options, + arch='86', code_type='cubin') + module = cp.cuda.function.Module() + module.load(cubin) + return module, module.get_function('native_cub_short_rows') + + @staticmethod + def canary(kernel): + """Check the installed compiler/wheel before dispatching real inputs. + + This finite check supplements the pinned source argument. It does + not establish equivalence for other algorithms, builds or shapes. + Downloads synchronize every launched comparison, including failure. + """ + for columns in (31, 513, 1475, 1920): + index = np.arange(columns, dtype=np.int32) + values = np.empty((4, columns), dtype=np.float32) + values[0] = 1. + (index % 17 - 8) * np.float32(2**-16) + values[1] = np.resize(np.array([2**18, -2**18, .001, -.003, 1.], + dtype=np.float32), columns) + words = np.array([0, 0x80000000, 1, 0x80000001, 0x007fffff, + 0x807fffff], dtype=np.uint32) + values[2] = np.resize(words.view(np.float32), columns) + values[3] = np.resize(np.array([1., -1., 2**-24, 2**24, -2**24], + dtype=np.float32), columns) + array = cp.asarray(values) + actual = cp.empty_like(array) + kernel((4,), (128,), (array, actual, np.int32(4), np.int32(columns))) + expected = cp.empty_like(array) + for row in range(4): + cp.cumsum(array[row], out=expected[row]) + if not np.array_equal(actual.get().view(np.uint32), + expected.get().view(np.uint32)): + return False + return True + + def _fallback(self, reason, *, supported=False): + self.fallback_calls += 1 + self._status.update(active=False, supported=supported, fallback_reason=reason) + return None + + def prefix(self, array): + reason = self.unsupported_reason(array) + if reason: + return self._fallback(reason) + key = (int(cp.cuda.runtime.getDevice()), int(cp.cuda.driver.ctxGetCurrent())) + self._status.update(device=key[0], context=key[1]) + entry = self.entries.get(key) + if entry is None: + if len(self.entries) >= self.max_contexts: + return self._fallback('context cache limit reached', supported=True) + before = time.perf_counter() + try: + module, kernel = self.compile() + except (OSError, cp.cuda.compiler.CompileException) as error: + entry = dict(module=None, kernel=None, failure=repr(error)) + else: + entry = dict(module=module, kernel=kernel, failure=None) + self.compile_seconds += time.perf_counter() - before + if entry['kernel'] is not None: + before = time.perf_counter() + good = self.canary(entry['kernel']) + self.canary_seconds += time.perf_counter() - before + if not good: + entry = dict(module=None, kernel=None, failure='native scan canary mismatch') + self.entries[key] = entry + if entry['kernel'] is None: + return self._fallback(entry['failure'], supported=True) + rows, columns = array.shape + result = cp.empty_like(array) + # Runtime/launch errors deliberately propagate; they are not an + # unsupported-build condition and must not silently change engines. + entry['kernel']((rows,), (128,), + (array, result, np.int32(rows), np.int32(columns))) + self.dispatch_calls += 1 + self._status.update(active=True, supported=True, fallback_reason=None) + return result + + @property + def status(self): + """Private serializable diagnostics, separate from scientific results.""" + return dict(self._status, cached_context_count=len(self.entries), + cached_module_count=sum(item['kernel'] is not None + for item in self.entries.values()), + compile_seconds=self.compile_seconds, canary_seconds=self.canary_seconds, + dispatch_calls=self.dispatch_calls, fallback_calls=self.fallback_calls) diff --git a/cuvarbase/tls_stats.py b/cuvarbase/tls_stats.py new file mode 100644 index 00000000..8099afbe --- /dev/null +++ b/cuvarbase/tls_stats.py @@ -0,0 +1,586 @@ +""" +Statistical calculations for Transit Least Squares. + +Implements the Signal Residue (SR), Signal Detection Efficiency (SDE), +a delta-chi-squared Signal-to-Noise Ratio (SNR), and related metrics. + +Definitions (identical to the reference ``transitleastsquares`` package, +so published SDE thresholds transfer): + +* ``SR = chi2_min / chi2`` (1 at the best trial period, < 1 elsewhere); +* ``SDE_raw = (1 - mean(SR)) / std(SR)``; +* ``SDE`` is the same z-score after subtracting a running median of SR + (edge-extended, see :func:`running_median`). + +No calibrated false-alarm probability is derived from the SDE: the null +SDE distribution depends on the period grid and the baseline (measured: +4% to 92% of pure-noise light curves exceed SDE = 7 across four common +configurations), so only a per-configuration null bootstrap can be +honest -- see ``tls_search_batch(fap_null_draws=...)``. + +References +---------- +- Hippke & Heller (2019), A&A 623, A39 +- Kovács et al. (2002), A&A 391, 369 +""" + +import warnings + +import numpy as np +from scipy import ndimage, stats + + +__all__ = [ + 'signal_residue', + 'running_median', + 'signal_detection_efficiency', + 'signal_to_noise', + 'false_alarm_probability', + 'odd_even_mismatch', + 'compute_all_statistics', + 'compute_period_uncertainty', +] + + +def signal_residue(chi2, chi2_null=None): + """ + Calculate the Signal Residue (SR) of a chi-squared spectrum. + + ``SR = chi2_min / chi2`` -- the definition of the reference + ``transitleastsquares`` package: SR = 1 at the best trial period and + decreases towards 0 for worse fits. + + Parameters + ---------- + chi2 : array_like + Chi-squared values at each trial period (finite, >= 0) + chi2_null : float, optional + Deprecated and ignored (a warning is raised if given). Before + 1.0 the SR was ``1 - chi2 / max(chi2)``, which agrees with the + reference definition under the null but is up to 2x lower at + the peak of a strong signal, so SDE thresholds from the + literature did not transfer. + + Returns + ------- + SR : ndarray + Signal residue values in [0, 1]; 1 at the minimum chi2. + """ + if chi2_null is not None: + warnings.warn( + "signal_residue: chi2_null is ignored; since 1.0 the signal " + "residue is chi2_min / chi2 (reference transitleastsquares " + "definition)", DeprecationWarning, stacklevel=2) + chi2 = np.asarray(chi2, dtype=np.float64) + if chi2.size == 0: + return chi2.copy() + # chi2 is a sum of squares; the batch path reconstructs it as + # chi2_0 - score in float64 from a float32 score, which can dip a + # hair below zero for a near-perfect fit + chi2 = np.maximum(chi2, 0.0) + chi2_min = np.min(chi2) + with np.errstate(divide='ignore', invalid='ignore'): + SR = chi2_min / chi2 + # 0/0 at a perfect (noiseless) fit; the best period has SR = 1 by + # definition + SR[chi2 == chi2_min] = 1.0 + return SR + + +def running_median(x, kernel): + """ + Sliding median of odd width ``kernel`` with the reference package's + edge handling. + + Interior points use the full centred window. The first and last + ``kernel // 2`` points, whose window would run off the array, are + filled with the first / last full-window median -- exactly the + ``running_median`` of ``transitleastsquares`` (which builds the + same edge-extended trend with an explicit index matrix), computed + here with :func:`scipy.ndimage.median_filter`. Zero-padding (what + ``scipy.signal.medfilt`` does) is NOT equivalent: it drags the + trend towards zero over the outermost ``kernel // 2`` points and + inflates the detrended power at the grid edges (measured: null + peaks landed within 45 points of an edge 2.3x more often than + uniform). + + Parameters + ---------- + x : array_like + Input series (float64 on output) + kernel : int + Window width; even values are rounded up to the next odd + integer. Must satisfy ``kernel <= len(x)``. + + Returns + ------- + trend : ndarray + Running median, same length as ``x``. + """ + x = np.asarray(x, dtype=np.float64) + kernel = int(kernel) + if kernel % 2 == 0: + kernel += 1 + n = len(x) + if kernel > n: + raise ValueError("running_median: kernel (%d) exceeds the series " + "length (%d)" % (kernel, n)) + if kernel <= 1: + return x.copy() + h = kernel // 2 + trend = ndimage.median_filter(x, size=kernel, mode='nearest') + # mode='nearest' only affects the outermost h points; overwrite + # them with the first/last full-window medians (indices h, n-1-h) + trend[:h] = trend[h] + trend[n - h:] = trend[n - 1 - h] + return trend + + +def signal_detection_efficiency(chi2, chi2_null=None, detrend=True, + kernel_size=None): + """ + Calculate Signal Detection Efficiency (SDE). + + SDE measures how many standard deviations the peak of the signal + residue spectrum stands above its mean. Higher SDE = more + significant detection. + + Parameters + ---------- + chi2 : array_like + Chi-squared values at each period, ordered by ascending period + (the running-median detrend assumes period-ordered neighbours) + chi2_null : float, optional + Deprecated and ignored (see :func:`signal_residue`). + detrend : bool, optional + Subtract a running median of SR before the z-score (default: + True) + kernel_size : int, optional + Running-median kernel size for detrending. If None (default), + uses ``min(len(SR)//10 forced odd (min 3), 91)``: small period + grids keep the length-proportional window, while large grids + are capped at 91 points -- the fixed-kernel convention of the + reference ``transitleastsquares`` package (oversampling factor + 3 x SDE_MEDIAN_KERNEL_SIZE 30, forced odd). Passing an explicit + value overrides the automatic choice (even values are rounded + up to the next odd integer, as required by the median filter). + + Returns + ------- + SDE : float + Signal detection efficiency (z-score) + SDE_raw : float + Raw SDE before detrending + power : ndarray + Detrended signal residue (``SR - trend + median(SR)``) when + detrending was applied, else ``SR`` + + Notes + ----- + With ``SR = chi2_min / chi2`` (see :func:`signal_residue`): + + - ``SDE_raw = (max(SR) - mean(SR)) / std(SR) = (1 - mean(SR)) / std(SR)`` + - ``SDE = (max(D) - mean(D)) / std(D)`` with ``D = SR - running_median(SR)`` + + which is the reference package's ``spectra()`` statistic (its + rescaling of the detrended spectrum to touch ``max = SDE`` does not + change the z-score). A flat spectrum (``std(SR) < 1e-10``) gives + SDE = 0. + + The SDE is a *contrast* statistic, not a calibrated significance: + under the null its distribution shifts with the number of trial + periods and the baseline (measured mean 6.4, std 1.0 for 6157 + periods on a 60-d light curve; 23% of pure-noise light curves above + 7 there, 92% above 7 at 365 d with 43,780 periods). There is no + fixed SDE threshold with a known false-alarm rate; use the + per-configuration null bootstrap of ``tls_search_batch`` + (``fap_null_draws``) or your own injection-recovery. + + Following ``transitleastsquares`` (Hippke & Heller 2019), detrending + is skipped entirely when ``len(SR) <= 2 * kernel_size``; in that + case the raw SDE and raw SR are returned unchanged. + """ + chi2 = np.asarray(chi2) + + # Calculate signal residue + SR = signal_residue(chi2, chi2_null) + + # Raw SDE (before detrending) + mean_SR = np.mean(SR) + std_SR = np.std(SR) + + if std_SR < 1e-10: + SDE_raw = 0.0 + else: + SDE_raw = (np.max(SR) - mean_SR) / std_SR + + # Detrend with a running median if requested + if detrend: + if kernel_size is None: + kernel_size = max(len(SR) // 10, 3) + # Ensure odd window + if kernel_size % 2 == 0: + kernel_size += 1 + # Cap at the fixed 91-point kernel used by the reference + # transitleastsquares implementation; an uncapped len//10 + # window makes the median filter O(n*k) ~ O(n^2/10) and + # takes minutes of CPU at survey-scale period grids + # (n ~ 1e5). + kernel_size = min(kernel_size, 91) + elif kernel_size % 2 == 0: + # the median filter requires an odd kernel + kernel_size += 1 + + if len(SR) <= 2 * kernel_size: + # Too few points to estimate a trend; follow the reference + # transitleastsquares behavior and skip detrending. + SDE = SDE_raw + power = SR + else: + # Edge-extended running median (reference convention) + SR_trend = running_median(SR, kernel_size) + + # Detrended signal residue + SR_detrended = SR - SR_trend + np.median(SR) + + # Calculate SDE on detrended signal + mean_SR_detrended = np.mean(SR_detrended) + std_SR_detrended = np.std(SR_detrended) + + if std_SR_detrended < 1e-10: + SDE = 0.0 + else: + SDE = ((np.max(SR_detrended) - mean_SR_detrended) + / std_SR_detrended) + + power = SR_detrended + else: + SDE = SDE_raw + power = SR + + return SDE, SDE_raw, power + + +def signal_to_noise(depth, depth_err=None, + chi2_null=None, chi2_best=None): + """ + Calculate signal-to-noise ratio. + + Parameters + ---------- + depth : float + Transit depth + depth_err : float, optional + Uncertainty in depth. If None, estimated from chi2 values or + Poisson statistics as a last resort. + chi2_null : float, optional + Null hypothesis chi-squared (no transit). Used to estimate + depth_err when depth_err is not provided. + chi2_best : float, optional + Best-fit chi-squared. Used with chi2_null to estimate depth_err. + + Returns + ------- + snr : float + Signal-to-noise ratio + + Notes + ----- + When depth_err is not provided, it is estimated as + depth / sqrt(chi2_null - chi2_best) if chi2 values are given, + otherwise this returns 0 -- i.e. the returned SNR is the + delta-chi-squared significance ``sqrt(chi2_null - chi2_best)`` of + the transit model over the constant model. The search wrappers pass + the constant-model ``chi2_0`` (float64) and the refined ``chi2_min`` + of the best fit, so ``SNR = sqrt(chi2_0 - chi2_min)``; this is not + the reference package's ``depth / std * sqrt(n_in_transit)``. A + depth_err derived from the full-dataset delta-chi-squared already + includes every in-transit point across all transits, so no + additional sqrt(n_transits) scaling is applied. + """ + if depth_err is None: + if chi2_null is not None and chi2_best is not None: + delta_chi2 = chi2_null - chi2_best + if delta_chi2 > 0: + depth_err = depth / np.sqrt(delta_chi2) + else: + return 0.0 + else: + return 0.0 + + if depth_err < 1e-10: + return 0.0 + + return depth / depth_err + + +def false_alarm_probability(SDE, method='empirical'): + """ + Heuristic SDE -> "FAP" map. NOT a calibrated false-alarm + probability; kept only as an explicit opt-in helper. + + Since 1.0 no TLS result dict carries this number: the audit + measured 23% of pure-noise light curves receiving ``FAP < 0.01`` + from it (60 d, 6157 periods), a 250x error at SDE = 9, and a null + SDE distribution that moves with the grid and baseline, so no + fixed map can be right. Use ``tls_search_batch(fap_null_draws=N)`` + for an empirical, per-configuration false-alarm probability. + + Parameters + ---------- + SDE : float + Signal Detection Efficiency + method : str, optional + - 'empirical': ad-hoc piecewise heuristic (see Notes); raises + a UserWarning every call + - 'gaussian': one-sided Gaussian tail ``1 - Phi(SDE)``, which + treats the SDE as a standard-normal z-score (it is not: it is + the maximum over thousands of correlated trials) + + Returns + ------- + FAP : float + Heuristic value in [1e-10, 1] + + Notes + ----- + .. warning:: + + The 'empirical' method is a hand-rolled piecewise heuristic: + 1 below SDE = 5, ``10**(-0.5 (SDE - 5))`` (0.1 just below 7), + then ``10**(-(SDE - 5))`` from 7 upwards (0.01 at 7, so it is + discontinuous there). It is NOT calibrated against any null + distribution or published injection-recovery results. Treat the + returned values as order-of-magnitude indicators at best. + """ + if method == 'gaussian': + # Gaussian approximation: FAP = 1 - Phi(SDE) + FAP = 1.0 - stats.norm.cdf(SDE) + else: + warnings.warn( + "false_alarm_probability: this is an uncalibrated heuristic " + "(1 below SDE 5, 0.1 just below 7, 0.01 at 7, then 10**-(SDE-5)); " + "measured null exceedance differs from it by orders of " + "magnitude. Use tls_search_batch(fap_null_draws=N) for an " + "empirical FAP.", UserWarning, stacklevel=2) + # Ad-hoc piecewise heuristic; no published calibration. + # Values: FAP(5) = 1, FAP(6) = 0.32, FAP(7-) = 0.1, FAP(7) = 0.01 + # (discontinuous), FAP(9) = 1e-4. + if SDE < 5: + FAP = 1.0 + elif SDE < 7: + FAP = 10 ** (-0.5 * (SDE - 5)) + else: + FAP = 10 ** (-(SDE - 5)) + + # Clip to reasonable range + FAP = np.clip(FAP, 1e-10, 1.0) + + return FAP + + +def odd_even_mismatch(depths_odd, depths_even): + """ + Calculate odd-even transit depth mismatch. + + This tests whether odd and even transits have significantly + different depths, which could indicate: + - Binary system + - Non-planetary signal + - Instrumental effects + + Parameters + ---------- + depths_odd : array_like + Depths of odd-numbered transits + depths_even : array_like + Depths of even-numbered transits + + Returns + ------- + mismatch : float + Significance of mismatch (z-score) + depth_diff : float + Difference between mean depths + + Notes + ----- + High mismatch (>3σ) suggests the signal may not be planetary. + """ + depths_odd = np.asarray(depths_odd) + depths_even = np.asarray(depths_even) + + mean_odd = np.mean(depths_odd) + mean_even = np.mean(depths_even) + + std_odd = np.std(depths_odd) / np.sqrt(len(depths_odd)) + std_even = np.std(depths_even) / np.sqrt(len(depths_even)) + + depth_diff = mean_odd - mean_even + combined_std = np.sqrt(std_odd**2 + std_even**2) + + if combined_std < 1e-10: + return 0.0, 0.0 + + mismatch = np.abs(depth_diff) / combined_std + + return mismatch, depth_diff + + +def compute_all_statistics(chi2, periods, best_period_idx, + depth, duration, n_transits, + depths_per_transit=None, kernel_size=None, + chi2_null=None, chi2_best=None): + """ + Compute all TLS statistics for a search result. + + Parameters + ---------- + chi2 : array_like + Chi-squared values at each trial period, in ascending period + order (the SDE detrend assumes period-ordered neighbours) + periods : array_like + Trial periods (ascending) + best_period_idx : int + Index of best period + depth : float + Best-fit transit depth + duration : float + Best-fit transit duration + n_transits : int + Number of transits at best period + depths_per_transit : array_like, optional + Individual transit depths + kernel_size : int, optional + Running-median kernel for SDE detrending, passed through to + :func:`signal_detection_efficiency`. Default (None) uses + ``min(len(chi2)//10 forced odd, 91)``, following the fixed + 91-point kernel convention of ``transitleastsquares``. + chi2_null : float, optional + Constant-model chi-squared ``chi2_0`` for the SNR. Default + (None) falls back to ``max(chi2)`` over the grid. + chi2_best : float, optional + Best-fit chi-squared for the SNR (the refined ``chi2_min`` on + the batch path). Default (None) uses ``chi2[best_period_idx]``. + + Returns + ------- + stats : dict + Dictionary with all statistics: + + - SDE: Signal Detection Efficiency (see + :func:`signal_detection_efficiency`) + - SDE_raw: Raw SDE before detrending + - SNR: ``sqrt(chi2_null - chi2_best)`` (delta-chi-squared + significance; see :func:`signal_to_noise`) + - power: Detrended signal residue spectrum + - SR: Signal residue ``chi2_min / chi2`` + - odd_even_mismatch: Odd/even depth difference (if available) + + No 'FAP' key: see :func:`false_alarm_probability` for why the + old heuristic was removed and ``tls_search_batch`` for the + null-bootstrap alternative. + """ + chi2 = np.asarray(chi2, dtype=np.float64) + + # Signal residue and SDE + SDE, SDE_raw, power = signal_detection_efficiency( + chi2, detrend=True, kernel_size=kernel_size) + + SR = signal_residue(chi2) + + # SNR (delta-chi2 of the best fit over the constant model) + if chi2_null is None: + chi2_null = np.max(chi2) + if chi2_best is None: + chi2_best = chi2[best_period_idx] + SNR = signal_to_noise(depth, chi2_null=chi2_null, chi2_best=chi2_best) + + # Compile statistics + stats = { + 'SDE': SDE, + 'SDE_raw': SDE_raw, + 'SNR': SNR, + 'power': power, + 'SR': SR, + 'best_period': periods[best_period_idx], + 'best_chi2': chi2[best_period_idx], + } + + # Odd-even mismatch if per-transit depths available + if depths_per_transit is not None and len(depths_per_transit) > 2: + depths = np.asarray(depths_per_transit) + n = len(depths) + + if n >= 4: # Need at least 2 odd and 2 even + depths_odd = depths[::2] + depths_even = depths[1::2] + + mismatch, diff = odd_even_mismatch(depths_odd, depths_even) + stats['odd_even_mismatch'] = mismatch + stats['odd_even_depth_diff'] = diff + else: + stats['odd_even_mismatch'] = 0.0 + stats['odd_even_depth_diff'] = 0.0 + + return stats + + +def compute_period_uncertainty(periods, chi2, best_idx, threshold=1.0): + """ + Estimate period uncertainty using FWHM approach. + + Parameters + ---------- + periods : array_like + Trial periods, ascending (the neighbour walk assumes sorted + periods; the search wrappers sort user grids before calling) + chi2 : array_like + Chi-squared values + best_idx : int + Index of minimum chi² + threshold : float, optional + Chi² increase threshold for FWHM (default: 1.0) + + Returns + ------- + uncertainty : float + Period uncertainty (half-width at threshold) + + Notes + ----- + Finds the width of the chi² minimum at threshold above minimum. + Default threshold=1 corresponds to 1σ for Gaussian errors. + """ + periods = np.asarray(periods) + chi2 = np.asarray(chi2) + + chi2_min = chi2[best_idx] + chi2_thresh = chi2_min + threshold + + # Find points below threshold + below = chi2 < chi2_thresh + + if not np.any(below): + # If no points below threshold, use grid spacing + if len(periods) > 1: + return np.abs(periods[1] - periods[0]) + else: + return 0.1 * periods[best_idx] + + # Find continuous region around best_idx + # Walk left from best_idx + left_idx = best_idx + while left_idx > 0 and below[left_idx]: + left_idx -= 1 + + # Walk right from best_idx + right_idx = best_idx + while right_idx < len(periods) - 1 and below[right_idx]: + right_idx += 1 + + # Uncertainty is half the width + width = periods[right_idx] - periods[left_idx] + uncertainty = width / 2.0 + + return uncertainty diff --git a/cuvarbase/utils.py b/cuvarbase/utils.py index 90d8b94e..e1d5e45b 100644 --- a/cuvarbase/utils.py +++ b/cuvarbase/utils.py @@ -1,25 +1,315 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function - from copy import deepcopy +import os +import re import numpy as np -from importlib.resources import files + + +__all__ = [ + 'check_lightcurve', + 'check_freqs', + 'weights', + 'conflict_scatter_perm', + 'subtract_epoch', + 'find_kernel', + 'autofrequency', + 'dphase', + 'normalize_light_curves', +] + + +# --------------------------------------------------------------------- +# Input validation (shared by every public entry point) +# +# Before 1.0 nothing checked the light curve: a single NaN in ``t`` +# produced a finite periodogram with a wrong argmax on the BLS and CE +# paths, ``dy = 0`` gave all-NaN (PDM), an undocumented ``-1`` sentinel +# (Lomb-Scargle) or a 1e3 relative chi2 error (TLS), and a NaN in a +# per-frequency q bound or a light curve with fewer points than the +# Keplerian grid needs crashed the kernel with an illegal memory +# access -- which kills the CUDA context for the rest of the process, +# so every later call in the same interpreter fails too (Sep 2026 +# audit, defect 23 ``input-validation``). The helpers below are called +# before any device work in every public entry point; they raise +# ``ValueError`` naming the array, the number of offending entries and +# the first few of their indices. +# --------------------------------------------------------------------- + +#: How many offending indices a validation message lists before "...". +_MAX_BAD_INDICES = 5 + + +def _bad_indices(bad): + """``(count, "i, j, k, ...")`` for a boolean mask of bad entries.""" + idx = np.flatnonzero(bad) + shown = ', '.join(str(int(i)) for i in idx[:_MAX_BAD_INDICES]) + if idx.size > _MAX_BAD_INDICES: + shown += ', ...' + return int(idx.size), shown + + +def _as_1d_numeric(arr, label, prefix): + """``np.asarray`` plus the shape/dtype checks the kernels assume. + + No copy is made for arrays that are already numeric ndarrays. + """ + a = np.asarray(arr) + if not np.issubdtype(a.dtype, np.number): + raise ValueError("%s%s must be a numeric array; got dtype %s" + % (prefix, label, a.dtype)) + if a.ndim != 1: + raise ValueError("%s%s must be a 1-D array; got shape %r" + % (prefix, label, a.shape)) + return a + + +def _check_finite(a, label, prefix): + """Raise unless every entry of ``a`` is finite (one pass).""" + finite = np.isfinite(a) + if finite.all(): + return + n_bad, where = _bad_indices(~finite) + raise ValueError( + "%s%s contains %d non-finite value(s) (NaN or inf) out of %d; " + "first at index/indices %s. Remove or interpolate the bad " + "samples before searching." % (prefix, label, n_bad, a.size, where)) + + +def check_lightcurve(t, y, dy=None, *, min_n=1, name=''): + """ + Validate a light curve before any GPU work. + + Every public periodogram entry point calls this first. It rejects + the inputs that used to produce a silently wrong periodogram, an + all-NaN spectrum, an undocumented sentinel value, or (with + per-frequency transit-duration bounds) an illegal memory access + that leaves the process's CUDA context unusable. + + Parameters + ---------- + t: array_like, float + Observation times. Must be 1-D, numeric and finite. + y: array_like, float + Observations. Must be the same length as ``t`` and finite. + dy: array_like, float, optional (default: ``None``) + Observation uncertainties. ``None`` (unit weights) is accepted + by the entry points that document it; otherwise ``dy`` must be + the same length as ``t``, finite and strictly positive -- it is + converted to inverse-variance weights ``dy ** -2``, so a zero + or negative entry is not a valid uncertainty. + min_n: int, optional (default: 1) + Minimum number of observations the caller's algorithm needs. + name: str, optional (default: ``''``) + Entry-point name, prefixed to the error message. + + Returns + ------- + t, y, dy: ndarray (``dy`` is ``None`` if it was ``None``) + The inputs as numpy arrays (no copy when they already were). + + Raises + ------ + ValueError + With the offending array's name, the number of offending + entries and the first few of their indices. + + Examples + -------- + ``check_lightcurve(np.array([0., 1., np.nan]), np.ones(3), + np.ones(3), name='eebls_gpu')`` raises:: + + ValueError: eebls_gpu: t contains 1 non-finite value(s) (NaN + or inf) out of 3; first at index/indices 2. Remove or + interpolate the bad samples before searching. + """ + prefix = ('%s: ' % name) if name else '' + + t = _as_1d_numeric(t, 't', prefix) + y = _as_1d_numeric(y, 'y', prefix) + if y.size != t.size: + raise ValueError("%st and y must have the same length; got %d " + "and %d" % (prefix, t.size, y.size)) + if dy is not None: + dy = _as_1d_numeric(dy, 'dy', prefix) + if dy.size != t.size: + raise ValueError("%st and dy must have the same length; got " + "%d and %d" % (prefix, t.size, dy.size)) + + min_n = max(1, int(min_n)) + if t.size < min_n: + raise ValueError("%sneed at least %d observation(s); got %d" + % (prefix, min_n, t.size)) + + _check_finite(t, 't', prefix) + _check_finite(y, 'y', prefix) + if dy is not None: + _check_finite(dy, 'dy', prefix) + positive = dy > 0 + if not positive.all(): + n_bad, where = _bad_indices(~positive) + raise ValueError( + "%sdy must be > 0 (uncertainties become " + "inverse-variance weights dy**-2); %d of %d entries are " + "not; first at index/indices %s" + % (prefix, n_bad, dy.size, where)) + + return t, y, dy + + +def check_freqs(freqs, *, name=''): + """ + Validate a trial-frequency grid before any GPU work. + + The grid must be a non-empty 1-D numeric array of finite, strictly + positive frequencies (every method folds the data at ``1 / f``). + + Parameters + ---------- + freqs: array_like, float + Trial frequencies (cycles per unit time). + name: str, optional (default: ``''``) + Entry-point name, prefixed to the error message. + + Returns + ------- + freqs: ndarray + ``freqs`` as a numpy array (no copy when it already was one). + + Raises + ------ + ValueError + With the number of offending entries and the first few of + their indices. + """ + prefix = ('%s: ' % name) if name else '' + + f = _as_1d_numeric(freqs, 'freqs', prefix) + if f.size == 0: + raise ValueError("%sfreqs must be a non-empty frequency grid" + % prefix) + _check_finite(f, 'freqs', prefix) + positive = f > 0 + if not positive.all(): + n_bad, where = _bad_indices(~positive) + raise ValueError( + "%sfreqs must be > 0 (the data are folded at 1 / f); %d of " + "%d entries are not; first at index/indices %s" + % (prefix, n_bad, f.size, where)) + return f def weights(err): """ generate observation weights from uncertainties """ w = np.power(err, -2) - return w/sum(w) + return w/np.sum(w) + + +def conflict_scatter_perm(n): + """ + Deterministic permutation that de-clusters time-ordered data for + the shared-memory histogram kernels. + + Survey lightcurves arrive time-sorted; at nearly every trial + frequency, consecutive samples of a dense cadence fold to the same + phase bin, so the 32 lanes of a warp fight for one shared-memory + atomic counter (measured on an RTX A5000: 3.1x kernel slowdown for + a TESS-like 2-minute cadence versus randomly ordered input). + Binning is order-independent (the histogram is a sum), so storing + the points in a scattered order removes the conflicts without + touching the math. + + Uses the golden-ratio stride ``p[i] = (i * k) % n`` with ``k`` + the largest integer <= 0.618 n coprime to ``n``: adjacent output + slots come from samples ~0.618 n apart in time, for any n, with no + RNG state involved. + + Returns ``None`` for ``n < 64`` (a warp or two; nothing to gain). + """ + if n < 64: + return None + k = max(1, int(round(0.6180339887498949 * n))) + while np.gcd(k, n) != 1: + k -= 1 + return (np.arange(n, dtype=np.int64) * k) % n + + +def subtract_epoch(t): + """ + Shift observation times so that they start near zero. + + Returns ``(t - floor(min(t)), floor(min(t)))``, with the + subtraction performed in float64. Phase folding on the GPU happens + in single precision, so for absolute timestamps (e.g. BJD ~ + 2,455,000 days) the product ``float32(t) * freq`` loses nearly all + phase information; times must be epoch-subtracted *before* any + cast to float32. All phases (``phi0`` solutions) are measured + relative to the returned epoch. + + The epoch is ``floor(min(t))`` rather than ``min(t)`` itself: a + round-number epoch is friendlier for reconstructing absolute + transit times, and subtracting ``min(t)`` exactly would place the + first observation at phase exactly 0.0 for *every* trial + frequency — a systematic bin-edge alignment that makes binned + (GPU) and exact (CPU) box memberships disagree at wrap-around + solutions. + + Parameters + ---------- + t: array_like, float + Observation times + + Returns + ------- + t_shifted: ndarray, float64 + ``t - floor(min(t))`` + epoch: float + ``floor(min(t))``, the epoch that was subtracted + """ + t = np.asarray(t, dtype=np.float64) + epoch = np.floor(t.min()) + return t - epoch, epoch def find_kernel(name): - return str(files("cuvarbase").joinpath('kernels', f'{name}.cu')) + # Resolve relative to this file rather than importlib.resources: + # setuptools PEP-660 editable installs hand files("cuvarbase") a + # MultiplexedPath that misresolves to the project root on py<3.12, + # and the kernels must be real on-disk files for open()/nvcc anyway. + return os.path.join(os.path.dirname(os.path.abspath(__file__)), + 'kernels', f'{name}.cu') + + +# ``//{INCLUDE filename}`` directive: inlined by _module_reader at load +# time, resolved relative to the including file's directory. This lets +# shared device code live in a single source file (e.g. bls_common.cuh) +# without an nvcc include path -- pycuda's SourceModule compiles from the +# assembled string, so nvcc never sees an #include of our own files. +_INCLUDE_RE = re.compile(r'^[ \t]*//\{INCLUDE\s+([^\s}]+)\}[ \t]*$', re.M) + + +def _expand_includes(txt, base_dir, _seen=None): + """Recursively inline ``//{INCLUDE filename}`` directives.""" + if _seen is None: + _seen = set() + + def _sub(match): + name = match.group(1) + real = os.path.abspath(os.path.join(base_dir, name)) + if real in _seen: + raise ValueError("circular kernel include: %s" % name) + _seen.add(real) + with open(real, 'r') as f: + included = f.read() + return _expand_includes(included, os.path.dirname(real), _seen) + + return _INCLUDE_RE.sub(_sub, txt) def _module_reader(fname, cpp_defs=None): txt = open(fname, 'r').read() + # Inline shared device code before any other substitution. + txt = _expand_includes(txt, os.path.dirname(os.path.abspath(fname))) + if cpp_defs is None: return txt @@ -32,17 +322,6 @@ def _module_reader(fname, cpp_defs=None): return txt -def tophat_window(t, t0, d): - w_window = np.zeros_like(t) - w_window[np.absolute(t - t0) < d] += 1. - return w_window / max(w_window) - - -def gaussian_window(t, t0, d): - w_window = np.exp(-0.5 * np.power(t - t0, 2) / (d * d)) - return w_window / (1. if len(w_window) == 0 else max(w_window)) - - def autofrequency(t, nyquist_factor=5, samples_per_peak=5, minimum_frequency=None, maximum_frequency=None, **kwargs): @@ -63,6 +342,8 @@ def autofrequency(t, nyquist_factor=5, samples_per_peak=5, Parameters ---------- + t : array_like + The observation times. samples_per_peak : float (optional, default=5) The approximate number of desired samples across the typical peak nyquist_factor : float (optional, default=5) @@ -80,7 +361,7 @@ def autofrequency(t, nyquist_factor=5, samples_per_peak=5, frequency : ndarray or Quantity The heuristically-determined optimal frequency bin """ - baseline = max(t) - min(t) + baseline = np.max(t) - np.min(t) n_samples = len(t) df = 1. / (baseline * samples_per_peak) @@ -103,13 +384,6 @@ def dphase(dt, freq): return dph_final -def get_autofreqs(t, **kwargs): - autofreqs_kwargs = {var: value for var, value in kwargs.items() - if var in ['minimum_frequency', 'maximum_frequency', - 'nyquist_factor', 'samples_per_peak']} - return autofrequency(t, **autofreqs_kwargs) - - def normalize_light_curves(data: list[tuple[np.array, ...]]): """ Normalize light curves by subtracting the mean from the magnitudes and the observation times. @@ -118,6 +392,7 @@ def normalize_light_curves(data: list[tuple[np.array, ...]]): ---------- data: list of tuples list of [(t, y, ...), ...] containing + * ``t``: observation times * ``y``: observations * ... other columns @@ -126,9 +401,11 @@ def normalize_light_curves(data: list[tuple[np.array, ...]]): ------- data: list of tuples list of [(t, y, ...), ...] containing + * ``t``: updated observation times * ``y``: updated observations - * ... other columns (preserved as in input) + * ... other columns (preserved as in input; ``None`` entries -- + e.g. ``dy=None`` for unweighted runs -- pass through unchanged) """ data = deepcopy(data) @@ -139,6 +416,8 @@ def normalize_light_curves(data: list[tuple[np.array, ...]]): for j in range(len(lc)): if j < 2: updated_lc.append((lc[j] - means[j]).copy()) + elif lc[j] is None: + updated_lc.append(None) else: updated_lc.append(lc[j].copy()) data[i] = tuple(updated_lc) diff --git a/docs/BENCHMARK_ARCHIVES.md b/docs/BENCHMARK_ARCHIVES.md new file mode 100644 index 00000000..adf77bf6 --- /dev/null +++ b/docs/BENCHMARK_ARCHIVES.md @@ -0,0 +1,152 @@ +# Benchmark evidence archives + +The repository keeps benchmark runners, protocols, readable reports, small +result tables and selected figures. Complete results, inputs, logs, archived +source copies and build artifacts live in immutable Cloudflare R2 archives. +Every original file, including failed and partial outcomes, is preserved. +Moving evidence does not change any numerical qualification or release claim. + +The [archive inventories](../benchmarks/archives/) identify each original path, +size and SHA256, the exact archive checksum, its object key, and the original +source commit. Each inventory row records whether the file remains in Git. +All archives were downloaded from R2 and checked against their complete SHA256; +every extracted member was also compared with the original source tree. + +## Access and restoration + +The bucket is private. A maintainer can supply the archive file or read-only R2 +access. No write credentials are needed to restore evidence. Normal package +and tooling tests run without archive access; reproducing the large studies +requires their original inputs and evidence. + +List studies and sizes: + +```sh +python tools/benchmark_archive.py list +``` + +Restore a downloaded archive into its original, ignored locations: + +```sh +python tools/benchmark_archive.py restore tls_survey_2026-09-10 --archive /path/to/tls_survey_2026-09-10.tar.gz +``` + +With a configured `rclone` remote named `archive` and bucket `cuvarbase`, the +same command can download, verify and restore in one step: + +```sh +python tools/benchmark_archive.py restore tls_survey_2026-09-10 --remote archive:cuvarbase +``` + +Set `CUVARBASE_ARCHIVE_REMOTE` to use that remote by default. Downloads are +cached under ignored `.benchmark-archives/downloads/`. The helper verifies the +complete archive and every member before installing any missing files. It +rejects traversal, symlinks, corrupt content and conflicting existing files. +It preserves current tracked reports and never overwrites existing evidence. + +To recover the complete original tree, including original report text, use +`--full --destination /path/to/empty-directory`. Archived reports preserve +their original relative links and scientific identities there. + +Some benchmark runners deliberately use their original input paths. Restore +the relevant studies before using those runners. In particular, TLS population +generation uses the `tls_sensitivity_2026-09-09` cadence archive; transit +generation uses `transit_2026-09-08`; follow-up reporting also uses the original +`tls_survey_2026-09-10` exactness receipt. Historical analysis must use its +recorded source and environment, not silently substitute a new experiment. + +## Studies + +The sections below are stable targets for links to externally stored evidence. +Hovering an archive link in a report exposes its original path; restore the +named study to inspect that file. + +### nufft_lrt_validation_2026-09-06 + +[Inventory](../benchmarks/archives/nufft_lrt_validation_2026-09-06.json): 16 files; 0.28 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/nufft_lrt_validation_2026-09-06.tar.gz`. + +### tls_accuracy_2026-09-09 + +[Inventory](../benchmarks/archives/tls_accuracy_2026-09-09.json): 59 files; 1.16 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/tls_accuracy_2026-09-09.tar.gz`. + +### tls_profile_2026-09-08 + +[Inventory](../benchmarks/archives/tls_profile_2026-09-08.json): 112 files; 1.01 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/tls_profile_2026-09-08.tar.gz`. + +### tls_reference_2026-09-10 + +[Inventory](../benchmarks/archives/tls_reference_2026-09-10.json): 159 files; 27.76 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/tls_reference_2026-09-10.tar.gz`. + +### tls_sensitivity_2026-09-09 + +[Inventory](../benchmarks/archives/tls_sensitivity_2026-09-09.json): 87 files; 45.21 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/tls_sensitivity_2026-09-09.tar.gz`. + +### tls_survey_2026-09-10 + +[Inventory](../benchmarks/archives/tls_survey_2026-09-10.json): 506 files; 7.99 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/tls_survey_2026-09-10.tar.gz`. + +### transit_2026-09-08 + +[Inventory](../benchmarks/archives/transit_2026-09-08.json): 1,249 files; 120.08 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/transit_2026-09-08.tar.gz`. + +### validation-release-prepared-20260927 + +[Inventory](../benchmarks/archives/validation-release-prepared-20260927.json): 9 files; 0.04 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/validation-release-prepared-20260927.tar.gz`. + +### validation-tls-default-20260910 + +[Inventory](../benchmarks/archives/validation-tls-default-20260910.json): 14 files; 0.02 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/validation-tls-default-20260910.tar.gz`. + +### validation-v1.0.0 + +[Inventory](../benchmarks/archives/validation-v1.0.0.json): 13 files; 0.06 MiB compressed. + +Object: `r2://cuvarbase/benchmark-evidence/20260928/validation-v1.0.0.tar.gz`. + + +## Original Git history + +The complete pre-cleanup local and GitHub histories are preserved in the private +R2 prefix `history-cleanup-20260928/before/`, with original refs and checksums. +`remote-before.bundle` is a self-contained Git bundle. Original commit IDs in +scientific receipts and historical reports refer to that preserved history. +The cleanup does not edit those identities or reclassify failed experiments. + +To inspect an original source commit, download and verify the bundle against +its archived manifest, then clone it into a separate directory: + +```sh +git clone --no-checkout /path/to/remote-before.bundle cuvarbase-original +git -C cuvarbase-original checkout ORIGINAL_COMMIT_ID +``` + +Do not merge an old clone back into the cleaned development branches: that +would restore the removed archive history. Use a fresh clone for development +and carry any local source changes across as patches. + +## Repository policy + +New runs write to an ignored workspace or object storage. Commit the runner, +protocol, concise results and failure summaries, plus a checksum inventory. +Keep bulk arrays, per-case JSON, logs and copied dependencies in the archive. +`tools/check_repository_artifacts.py` enforces the selected evidence files and +a 1 MiB per-file limit in CI. Updating an inventory must accompany a verified, +immutable archive; a green test suite never replaces scientific qualification. diff --git a/docs/BENCHMARK_PROVENANCE.md b/docs/BENCHMARK_PROVENANCE.md new file mode 100644 index 00000000..8c38a513 --- /dev/null +++ b/docs/BENCHMARK_PROVENANCE.md @@ -0,0 +1,40 @@ +# Provenance of retired benchmark claims + +The [current transit benchmark](TRANSIT_BENCHMARKS.md) replaces the older README timing, sensitivity and whole-survey cost claims. This audit explains why those claims were retired; its historical ratios are not current release performance promises. + +The audit inspected frozen v1 source `1032caf029570dc4841db1c594a2cbb1654e8fd8`. Original pre-audit wording is recoverable at commit `de0037dd8d2f81cd9296fc02f4ef73478b0b8908`; the [document SHA256 inventory](BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/claims-before.json") identifies the exact six files. For example: + +```bash +git show de0037dd8d2f81cd9296fc02f4ef73478b0b8908:docs/GTLS_COMPARISON.md +``` + +| Claim | Evidence and verdict | Appropriate use | +|---|---|---| +| TLS is 30–171× faster than GTLS at matched settings and equal significance | The archived ratios are **30.02, 55.33, 85.88, 123.75, 171.02** at 200/500/1000/1500/2000 days. However, the effective grids, templates, refinement, and statistical validation differ. | Historical warm single-lightcurve timing observations, with the limitations below. Do not describe them as proven equivalent sensitivity. | +| Same-GPU GTLS provenance | The cuvarbase JSON names an A5000 and CuPy 13.6.0, but both `cuvarbase` and `gputls` versions are `?`. The two GTLS JSONs lack their final `env` sections. No exact implementation commit, input-array hashes, GPU UUID, or CPU allocation was saved. | The same-host account is plausible and documented in prose; the raw files alone cannot independently verify all of it. | +| Eight epochs per duration on both TLS implementations | cuvarbase's coarse phase bins stop at 8,192; its epoch count separately stops at 20,000. GTLS samples indices in a sorted folded lightcurve, with integer-rounded skip counts. | Nominal oversampling is similar; the effective grids are not identical. | +| Matched duration search | cuvarbase uses a per-period log grid; GTLS uses a global grid of integer sample widths masked per period. The copied window omits GTLS's upper-bound factor `1 + P / baseline`, plus its floor/ceil rounding. | Similar physical bounds, not the same set of templates/durations. | +| Equal sensitivity / 100% recovery / SDE within 1–3% | One fixed, central, circular, 8.13-day transit per baseline; identical noise type, limb darkening, phase, and depth. No null calibration, weak-signal population, impact-parameter sweep, or realistic gaps. Search templates were explicitly **not** matched. | Recovery of those particular strong injections. It does not bound completeness loss at fixed false-alarm rate. | +| Realistic GTLS comparison noise | The prose says 110–400 ppm-class noise. The input code **and raw metadata use 0.004 = 4,000 ppm**, with nominal depth 4,000 ppm. Aggregate nominal SNR rises from 13.7 to 43.7 over the headline range. | Correct the input description. The strong aggregate detections are unsurprising. | +| Old SDE evidence applies to v1.0 | The archived re-scorer uses `1 - chi2/max(chi2)`. v1.0 uses `min(chi2)/chi2`. Raw chi-square spectra were not saved in those benchmark JSONs. | The old SDEs cannot be converted reliably into current SDEs from the saved scalar values. | +| GTLS recompiles on every call and this explains the speedup | GTLS creates `RawModule` objects and calls `compile()`, but CuPy caches compiled binaries. A tiny-grid full search is not an isolated compilation measurement. | Attribute the observed wall time to the API path measured. A compilation/launch bottleneck claim needs profiling. | +| Paper Figure 7 definitely used skip=8; the A5000-vs-4090 cross-check is decisive | The skip setting is inferred from runtime, not established by an archived paper runner. The paper and repository runs have different hardware, software, input details, and timing boundaries. | Treat the paper numbers as external context, not a controlled speedup comparison. | +| GTLS uses float32 throughout | The pinned September upstream source uses `double` for input times, trial periods, and folded phases. | This description is false for the current comparator; exact archived-source identity is missing. | +| TLS searches one TESS lightcurve in ~1.2 ms | The authoritative A5000 JSON divides a **100-lightcurve batch** by 100; `n_iter=1`. It is a warm throughput result. | Label it amortized batch time per lightcurve, not single-call latency. | +| Final release timings are unaffected by intervening fixes | Since the July campaign, TLS statistics changed, per-lightcurve statistics were made sequential (`9f1540e`), input validation was added, and other API changes landed. | Retest the frozen code. Do not assume timing invariance from a correctness gate. | +| Standard BLS is 257–354× faster than Astropy | Ratios recompute, but Astropy scans five fixed **absolute durations** of 0.01–0.2 days; GPU BLS uses default **fractional durations**, about 0.01–0.5 of each period. Phase coverage/density is also different. | These are different searches. Withdraw the unqualified apples-to-apples interpretation; the direction of bias is not uniform across periods. | +| Survey LS beats the fastest CPU by 1.5–12.6×, or >15–27× | The old benchmark loops over CPU calls with default FINUFFT threading. It does not evaluate CPU worker/thread tuning, shared-epoch batching, or nifty-ls's own GPU backend. CPU quota/model/thread settings are missing. | A measured comparison with that particular CPU invocation, not a general best-competitor result. | +| LS timeout is evidence of a lower bound | The archive stores `None` for both timeout and exception. The helper checks a time limit after calls finish and does not retain the elapsed warmup. | The raw `None` cells alone cannot establish the published timeout lower bounds. | +| Slow Astropy LS baselines represent its fast method | Some runners construct frequencies in float32, cast them to float64, then use `method='auto'`. That rounded grid fails Astropy's regular-grid test and selects the direct `cython` method. The local dispatch check reproduces this at 5K and 50K frequencies; an original float64 grid selects `fast`. | Do not present this as the fastest Astropy implementation. The September audit runner used an original float64 grid and explicitly selects its fast method. This issue is separate from the nifty-ls headline comparison. | +| Previous PyPI cuvarbase is 0.2.6 | **The published baseline at the September 2026 audit was 0.2.5**, dated 23 October 2023. The old comparison uses the unpublished 0.2.6 tag. | Name the actual version. Use a real PyPI 0.2.5 install for an upgrade comparison. | +| 34× per-lightcurve loop, 10× for 100 stars | 34.16× from saved medians; the 100-star totals are an extrapolation of a **20-star** run. Old/new stacks also differ in NumPy and PyCUDA. Precompiled-handle paths already existed in the old version. | Describe the API usage and stack, not a GPU-kernel speedup. Label 100-star totals as extrapolated. | +| Survey BLS improved 2.0–12.7× | Broadly traceable as a July development optimization. TESS is 12.68× against the initial environment but **5.80×** against the thread-pinned baseline. These JSONs record `git_sha: unknown`. | Useful historical engineering evidence, not a measured 0.2.5-to-frozen-v1.0 ratio. | +| fBLS ~6 s for 65K points / 100K periods, “their table 1” | Table 1 of the cited paper contains planet candidates, not runtime data. Runtime measurements are in Figure 4 under another grid/phase-resolution protocol. No matched fBLS run is archived here. | Retired. The current transit campaign screened fBLS as a CPU candidate. | +| Keplerian grids give fewer frequencies “with no loss” | The grid reduction is real for the chosen stellar/duration assumptions. This restricts the search family and requires a corresponding injection/recovery study to quantify completeness. | State the physical assumptions and comparison grid. Avoid a universal no-loss claim. | +| Entire surveys cost cents/dollars | These are arithmetic projections of warm search throughput at historical pod rates, without data access, detrending, calibration, candidate vetting, or measured sustained survey execution. | Label compute-only projections, date the rate, specify the search and batching, and avoid implying a measured total survey bill. | + +The [historical raw benchmark records](https://github.com/johnh2o2/cuvarbase/tree/f0dc98136ae34b34465b152be1af84faf063eb44/benchmarks/results) and [complete audit snapshot](https://github.com/johnh2o2/cuvarbase/tree/f0dc98136ae34b34465b152be1af84faf063eb44/analysis/benchmark-audit-20260906) remain available in Git history. Superseded writeups, exploratory figures and cloud orchestration records are no longer part of the current documentation. + +The September 8 source pins, inputs, configuration selection, timing records and recovery analysis are retained in the historical [transit evidence archive](../benchmarks/results/transit_2026-09-08/ARCHIVE.md). The [TLS component report](../benchmarks/results/tls_profile_2026-09-08/README.md) supplies the later profiling evidence for host-loop overhead and CPU failure stages. + +The [current transit report](TRANSIT_BENCHMARKS.md) supersedes those TLS timing headlines with the observation-level default and full-to-full GTLS comparison. The old binned-engine studies remain dated evidence, not default-engine claims. diff --git a/docs/BENCHMARK_RESULTS.md b/docs/BENCHMARK_RESULTS.md new file mode 100644 index 00000000..ab38c986 --- /dev/null +++ b/docs/BENCHMARK_RESULTS.md @@ -0,0 +1,13 @@ +# Benchmark results + +The [current transit benchmark](TRANSIT_BENCHMARKS.md) is the source for cuvarbase v1 performance claims. The standard TLS engine now searches individual observations with full refinement. Earlier measurements of the binned TLS engine remain dated evidence and do not describe the new default. + +- [Speed and recovery figure, methods and qualifications](TRANSIT_BENCHMARKS.md): v1 BLS versus actual PyPI 0.2.5 and the strongest tested CPU/GPU settings; v1 TLS versus public GTLS. +- [TLS implementation and component comparison](GTLS_COMPARISON.md): the shared numerical search, execution changes, and separate search/API timing boundaries. +- [Search-cost projections](TLS_COST_ANALYSIS.md): hardware rates, measured workloads and the limits of extrapolating their costs. +- [BLS competitor experiment](../benchmarks/results/transit_2026-09-08/README.md) and [its evidence archive](../benchmarks/results/transit_2026-09-08/ARCHIVE.md): frozen inputs, source pins, selected configurations and results. +- [Archived binned TLS study, 2026-09-09](../benchmarks/results/tls_sensitivity_2026-09-09/README.md): recovery/null cohorts, three binned resolutions, exclusive timing and a secondary BLS control. Its speed ratios and HATPI cost pilot apply to that earlier engine. +- [TLS numerical strategy](TLS_NUMERICS.md): the standard observation-level search and the accuracy audit that motivated replacing the binned default. +- [Historical-claim audit](BENCHMARK_PROVENANCE.md): why earlier claims were retired and how to retrieve their original wording and raw measurements. + +There is no current general ranking against the best competitors for Lomb–Scargle, NFFT, CE or PDM. Older timing tables do not establish one. Diagnostic and correctness records for those algorithms remain available in the [benchmark index](../benchmarks/README.md). diff --git a/docs/GTLS_COMPARISON.md b/docs/GTLS_COMPARISON.md new file mode 100644 index 00000000..1648132a --- /dev/null +++ b/docs/GTLS_COMPARISON.md @@ -0,0 +1,46 @@ +# cuvarbase TLS and GTLS + +cuvarbase's observation-level search follows [GTLS full mode at commit 74e449c](https://github.com/Farthing-0/GTLS/tree/74e449c325792a763dde4fbffab98039c5e8c111). The release keeps original `6ced75d` execution as the default and makes the measured optimization bundle explicit through `execution='experimental'`. The frozen survey evaluates its actual archived sources, not this later release wiring. [Release validation](../benchmarks/results/tls_survey_2026-09-10/release-validation/README.md) passed all 24 paired numerical comparisons and 86 device tests on eleven fixed development inputs. This checks release wiring; it does not requalify experimental sensitivity. + +| Contract | cuvarbase reference search | Pinned public GTLS | +| --- | --- | --- | +| Samples and templates | Individual observations; native sample-index cache | Same model family | +| Coarse/full policy | Native coarse epoch spacing; candidate and harmonic full refinement | Same logical search policy | +| Candidate eligibility | Finite, unmasked period/score pairs before ranking | Host masked-sort defect can admit NaN periods | +| Duration grouping | Logical groups fixed independently of workspace chunks | Physical groups can depend on available memory | +| Time cleaning | Shift in float64; retain valid nonpositive timestamps | Nonpositive timestamps otherwise dropped | +| Reported statistics | Native SDE; cuvarbase input-error-unit `sqrt(delta chi2)` SNR | Additional native depth/scatter and pink-noise diagnostics | + +Identical-input comparisons supply the same positive-origin timestamps, errors and full period grid to both packages. Small automatic grids, stellar-range validation and unrepresentable zero-sample rows have separately documented input-handling differences. The [API guide](source/tls.rst) specifies the supported input domain. + +## Canonical CPU TLS is a different comparison + +This is **not numerical equivalence to CPU `transitleastsquares`**. Archived CPU TLS 1.32 normally steps epochs by 1% of a window duration. GTLS uses 12.5% in the coarse stage, then every sample start for selected candidates/harmonics. GTLS's top-100 plus next-100-above-one-day policy does not fully refine every period. Float32 GPU prefixes and coarse/full residual arithmetic also differ from the CPU implementation. [Archived CPU source](BENCHMARK_ARCHIVES.md#tls_profile_2026-09-08 "Archived file: benchmarks/results/tls_profile_2026-09-08/sources/cpu-tls/transitleastsquares/core.py") · [Archived GTLS source](BENCHMARK_ARCHIVES.md#transit_2026-09-08 "Archived file: benchmarks/results/transit_2026-09-08/sources/gtls-head/core.py"). + +Neither search automatically integrates every exposure or searches a full eccentric, grazing and multiband physical family. Both use an unweighted sample-window mean with template overshoot to estimate depth, rather than solving an unrestricted weighted amplitude and constant at every trial. Sample-index templates can distort irregularly sampled signals. These retained choices must be separated from implementation-optimization losses. + +The default 10 ppm gate applies strictly to the unweighted mean before overshoot. CPU float64 window diagnostics cannot establish whether the actual float32 raw-flux prefix admitted a particular GPU trial. The grazing/smearing deficit in the new population is observed; attributing it to this gate, cancellation or a specific fit would exceed the persisted evidence. + +## What the completed science comparison establishes + +Native GTLS-compatible TLS and development-selected GPU BLS received identical physical signals, errors and full grids. Each regime used 512 paired calibration nulls and separate method-specific thresholds, independently of development, 256 test nulls and 256 injections. These are common **calibrated target** FPRs, with uncertain realized test FPRs. Package SDE/SNR values were not equated. [Recovery, simultaneous intervals and expected-SNR diagnostics](TRANSIT_BENCHMARKS.md). + +At both 5% and 1% targets, simultaneous intervals establish positive TLS-minus-BLS recovery differences in four TESS regimes and a negative difference in the grazing/smeared regime. Other subgroup results and poor synthetic-HATpi recovery prevent a universal advantage claim. Published canonical TLS results remain a separate body of evidence. [Literature interpretation](TLS_LITERATURE.md). + +## Implementation qualification failed + +The original optimized-versus-immutable-baseline comparison retained **nine chi-squared-spectrum/SDE mismatches among 5,120 pairs**, with no changed selected periods or either frozen-threshold decision. Its zero tolerance remains unchanged. The separate baseline pass used the candidate TLS thresholds on injections and independent test nulls; it did not recalculate baseline calibration spectra or cuts. No aggregate bitwise-equivalence or independently recalibrated baseline-FPR claim follows. + +The nine failures comprise two TESS high-impact, three TESS eccentric and four HATpi cases. Original development was already 79/80 exact. Repeats remain diagnostic, never replacements for a failed primary comparison. Earlier [184 corrected-GTLS comparisons](../benchmarks/results/tls_reference_2026-09-10/README.md) and the [long-control failure](../benchmarks/results/tls_reference_2026-09-10/stress/diagnostic/README.md) retain their dated source/input scopes; they do not certify this newer bundle. + +The release therefore restores the full baseline implementation and exposes the whole optimization bundle only experimentally. Disabling only its short-row kernel would leave the other changes active. Baseline restoration does not promise that native long-row scans are deterministic. + +## Invalid-candidate correction and timing scope + +Pinned GTLS can sort masked scores into its first candidate list, convert the associated periods to NaN on the GPU, and assign finite results for invalid trials back into the spectrum. cuvarbase filters nonfinite/masked candidates before sorting while preserving the valid-entry quotas and tie policy. This correction predates the new optimization bundle and remains in both execution modes. Comparisons distinguish untouched public GTLS from a separately corrected reference; no failed public call becomes a successful timing denominator. + +Full API timing includes normal output work. A common-search boundary ending at final GPU winner selection excludes GTLS's additional parameter/noise diagnostics and must be labeled separately. The [collected full-API campaign](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/primary/throughput-final/campaign.json") independently selected baseline four workers/batch eight, experimental four/batch four and public GTLS two/batch one. Its seven eligible panel rates include public GTLS at median 2.424906 light curves/s on dense TESS and 0.118107 on ZTF solar. GTLS long-gap and varied pools failed with out-of-memory errors in their first queues, and both remain excluded. The pinned automatic internal period grouping has no supported override; these are conditional tested operating settings, not a global optimum. + +The collected campaign has seven eligible engine/workload rates. The experimental candidate reaches a median 0.837289 light curves/s on ZTF solar versus baseline 0.452633, a **1.850×** ratio; long-gap TESS is 0.775998 versus 0.770469, **1.007×**. Both timing-cohort gates and the unchanged paired spectrum check passed in those two regimes. Baseline dense TESS and all varied-size panels remain excluded, so they supply no baseline/candidate ratio. These timings do not override the failed 5,111/5,120 aggregate gate. [Final rates, ranges and exclusions](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md) · [figure and value provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json"). + +The original BLS pool failed its selected-output repeatability gate. The separate native BLS execution supplement obtained no rates: its launcher omitted `VECLIB_MAXIMUM_THREADS` and `NUMEXPR_NUM_THREADS`, and the allocation guard rejected those unset values before creating workers. Its [failed pilot receipts and launch provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json") remain separate from the numerical failure; no replacement denominator is supplied. [Cold preparation, amortized cost and sampled memory](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md). [Collected recovery report](../benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md) · [original exactness receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json") · [report provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json"). Historical phase-binned-versus-fast-GTLS ratios do not describe this default. diff --git a/docs/JOB_MONITORING.md b/docs/JOB_MONITORING.md new file mode 100644 index 00000000..866eb529 --- /dev/null +++ b/docs/JOB_MONITORING.md @@ -0,0 +1,94 @@ +# Background job monitoring + +The September 24 collector verified the evidence and stopped the rental even +though the extra release gate failed. Collection success did not mean release +success. The September 27 monitor checks those outcomes separately and brings +actionable changes back to the same Codex conversation. + +`tools/watch_jobs.py` polls every 60 seconds in a background observer started +from Codex's authorized project context. The independent macOS LaunchAgent +`com.cuvarbase.job-monitor` runs a copy of `tools/watch_monitor.py` every 60 +seconds to check that observer's heartbeat. Its configuration, durable state, +delivery receipts, checkpoints, and acknowledgements are in: + +``` +/Users/johnhoffman/Library/Application Support/cuvarbase-job-monitor/ +``` + +The monitor: + +- Checks the remote controller, progress files, step exit codes, stage deadlines, + release validation, qualified panel counts, collection, shutdown, and backup. +- Flags three consecutive connection failures or 15 minutes without log or + checkpoint activity. Each job can declare a different inactivity threshold. +- Restarts a dead budget guard or collector at most three times per hour, + preserving the original rental identity, deadline, and budget. +- Keeps failures visible after successful collection. A job needs passing + required steps, verified local evidence, verified rental shutdown, and verified + R2 backup before it becomes ready for review. +- Queues one follow-up per distinct incident into the existing conversation + using the installed `codex queue` command. Multiple findings in one poll are + combined. A failed delivery remains pending and retries with backoff. +- Requests a desktop notification for new incidents and for a queued follow-up + still unacknowledged after ten minutes. Notification visibility depends on + macOS notification settings and Focus mode. + +Polling makes no model calls. Follow-up turns use the existing account and +permissions. The monitor creates no rentals, retries no numerical experiments, +changes no qualification thresholds, deletes no evidence, and publishes no +release. A completed failed experiment stays a failed experiment; operational +repairs receive separate receipts. + +The LaunchAgent reloads at login. If the observer has no successful poll for +three minutes, the watchdog queues a recovery turn in this conversation. The +observer and watchdog have separate code, logs, locks, and delivery state. +macOS prevents a login service from reading the Documents project directly; +the watchdog reads only its own Application Support state and asks Codex to +restore the project observer using its existing authorized access. + +The Mac must be powered on and online. Budget +guards hold an idle-sleep assertion during paid jobs; closing the lid or losing +power can still interrupt local supervision. Remote controllers have their own +bounded execution times, and queued follow-ups wait if Codex is unavailable or +the account is rate-limited. This is local supervision, not an always-on cloud +monitor. [Official scheduled-task documentation](https://learn.chatgpt.com/docs/automations?surface=app) +also describes the host availability requirements for local scheduled work. + +## Operations + +Read `status.json` for the latest poll, `state.json` for incidents and delivery +receipts, `checkpoints/` for the last remote states, `observer.log` for polling +failures, and `watchdog.json` for independent health checks. The watchdog's +own errors go to `launchd.err.log`. `monitor-error.json` records an attempt to +bring a polling failure back into the conversation. + +```sh +python3 tools/watch_jobs.py --config '/Users/johnhoffman/Library/Application Support/cuvarbase-job-monitor/config.json' status +``` + +Register every new long-running cuvarbase rental in `config.json` before leaving +it unattended. Each job specifies a unique `id`, local evidence `path`, +`remote_root`, exact controller script path, `required_steps`, `step_limits`, +`stall_seconds`, and whether verified cloud backup is required. Its existing +`ops/rental.py` owns budget enforcement and `ops/monitor.py` owns collection; +neither may reset a deadline on restart. Keep configuration free of credentials. + +When a monitor follow-up arrives, acknowledge its event with the command in the +message, inspect the evidence, and continue the authorized work. Only close the +job's review after inspecting its actual outcome and preserving its artifacts: + +```sh +python3 tools/watch_jobs.py --config '/Users/johnhoffman/Library/Application Support/cuvarbase-job-monitor/config.json' review --job JOB_ID --outcome 'Reviewed result and remaining limitations' +``` + +To disable the independent heartbeat watchdog without touching a GPU job's +existing guard: + +```sh +launchctl bootout gui/$(id -u)/com.cuvarbase.job-monitor +``` + +The supervisor's tests reproduce the archived-but-failed validation case, +missing evidence or backup, dead/stalled jobs, notification delivery failures, +deduplication across restarts, actual guard/collector process recovery, and an +independent watchdog detecting and recovering from a missing heartbeat. diff --git a/docs/RELEASE_NOTES_v1.0.0.md b/docs/RELEASE_NOTES_v1.0.0.md new file mode 100644 index 00000000..ca458365 --- /dev/null +++ b/docs/RELEASE_NOTES_v1.0.0.md @@ -0,0 +1,140 @@ +# cuvarbase 1.0.0 + +This is the superseded pre-publication draft. The reviewed candidate is now **1.0.1**, preserving the existing June `v1.0.0` tag. Use the [1.0.1 release notes](RELEASE_NOTES_v1.0.1.md) for the prepared release. The historical draft below describes the planned development release, not the contents of the June tag. + +**First major release.** cuvarbase provides GPU-accelerated period-finding and transit-detection algorithms for astronomical time series: Box Least Squares (BLS), Transit Least Squares (TLS), Lomb–Scargle (including multiharmonic), Phase Dispersion Minimization (PDM), Conditional Entropy (CE), and the non-uniform FFT (NFFT) that powers them. + +These are release-candidate notes for the first planned PyPI release since **0.2.5 (October 2023)**. The candidate contains everything from the tagged-but-never-published 0.2.6 maintenance release (May 2025) plus all of the 1.0 development work. As checked on 24 September 2026, `pip install cuvarbase` still installs 0.2.5; use the `v1.0-fixes` branch to install the candidate. + +In production: cuvarbase's BLS has powered the TESS Quick-Look Pipeline's planet search since Sector 59 (Kunimoto et al. 2023, RNAAS 7, 28). + +## Highlights + +- **New GPU Transit Least Squares:** a GTLS-compatible observation-level default with full candidate and harmonic refinement. The [current ZTF/TESS benchmark](TRANSIT_BENCHMARKS.md) compares full searches with public GTLS and records numerical agreement, recovery and noise-only outcomes. +- **Faster BLS searches and grid construction:** compare actual PyPI 0.2.5, v1 and tested CPU/GPU alternatives in the [current benchmark](TRANSIT_BENCHMARKS.md). +- **Versus actual PyPI 0.2.5:** fused phase searches, conflict-scatter staging, reusable batch memory, vectorized host scans and grid construction, plus support for the current NumPy/PyCUDA stack. Both releases receive warmed kernels and reusable PyPI memory in the new comparison; its warm speedup is not attributed entirely to compilation caching. +- **Correct results on absolute (BJD-scale) timestamps.** Pre-1.0, feeding BLS raw BJD times (~2.45 million days) silently destroyed the phase fold in float32. Measured: an injected P=3.46 d transit recovered at power 0.30 on near-zero timestamps collapses to power 0.089 at the wrong frequency when the same data carries BJD timestamps in 0.2.6 — no error, no warning. 1.0.0 returns identical periodograms on both timescales (r=1.000000); all BLS paths epoch-subtract in float64 first. +- **Fixed spurious BLS peaks from degenerate trial boxes.** A float32 guard bug produced run-to-run-varying peaks on single-site ground-based data (reported by @astrobatty against HATPI light curves). The guard is corrected, with regression tests checking that 500 ppm transits still survive. Native floating-point accumulation can still vary between calls: the [sustained benchmark](TRANSIT_BENCHMARKS.md) retains failed BLS repeatability qualification and labels its new rates as execution only. +- **New algorithms and APIs**: sparse BLS for small datasets (Panahi & Zucker 2021), batched multi-lightcurve BLS, Keplerian frequency grids with stellar-density and duration constraints, multiharmonic generalized Lomb–Scargle on GPU, fast PDM kernels, CE log-probability periodograms, and an experimental NUFFT matched-filter transit search. +- **Modern, lighter install**: Python 3.9–3.14, numpy 2.x, no more scikit-cuda or `future`; `import cuvarbase` works on GPU-less machines (the pure helpers need no pycuda at all; the method modules need the pycuda package but no device until the first GPU call). +- **Expanded release validation**: **2,091 passed + 1 xfailed of 2,092 collected** (0 failed, 0 skipped; full suite, NVIDIA A40, 24–25 September 2026), followed by 14 installed-wheel numerical/runtime checks and six dependency preflights on 27 September. The [receipts](../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) preserve the original launcher import failure and the separate corrected check. The expected failure is `test_examples_compile.py::test_notebook_code_cells_compile_without_warnings[Phase Dispersion Minimization.ipynb]`, for known non-raw TeX label strings. CPU CI spans Python 3.9–3.14. Passing release tests does not override the benchmark's failed qualifications. + +## Performance + +The [current transit benchmark](TRANSIT_BENCHMARKS.md) is the source for BLS/TLS release claims: blind recovery and false-positive results, expected signal response, original exactness failures, and sustained throughput with five unavailable panels. Its September 24–25 follow-up reports seven strictly qualified TLS/GTLS timing panels and four BLS execution-only panels. Historical single-source, batch and upgrade comparisons retain their original workload and source scopes. + +The published upgrade baseline in this campaign is 0.2.5; the 0.2.6 tag was not published to PyPI. The [benchmark index](BENCHMARK_RESULTS.md) links the current report, component evidence and historical-claim audit. + +## New features + +### BLS +- **Sparse BLS** (Panahi & Zucker 2021) on GPU and CPU (`sparse_bls_gpu`, `sparse_bls_cpu`) for small datasets (≲500 points); `eebls_transit` auto-selects it by dataset size and applies Keplerian duration constraints consistently on both paths. +- **Batched BLS**: `eebls_gpu_batch()` processes many light curves per kernel launch and accepts per-frequency `qmin`/`qmax` arrays. +- **Keplerian frequency grids**: `cuvarbase.bls_frequencies.keplerian_freq_grid()` (with `return_qvals=True` feeding duration bounds straight into the batch API). +- **Selectable power conventions**: `convention='chi2ratio' | 'snr' | 'loglik'` on all BLS entry points (+ `convert_bls_power()`); `'snr'` verified equal to astropy's `objective='snr'`. +- **Optimized/adaptive kernels**: `eebls_gpu_fast_optimized()` and `eebls_gpu_fast_adaptive()` provide warp-shuffle reductions and automatic block sizing. Their benefit depends on workload and settings. +- `noverlap` is now honored on the fast path (elementwise max over phase-shifted passes; default 2). +- **BLS throughput features (July 2026):** fused phase histograms, observation-scatter staging, frequency chunking, and host overhead fixes. The [current benchmark](TRANSIT_BENCHMARKS.md) measures their practical upgrade effect and diagnostic ablations; scattering does not demonstrate a benefit on its three selected cases. + +### Lomb–Scargle & NFFT +- **Multiharmonic generalized Lomb–Scargle on GPU** (`nharmonics>1`). The per-frequency solve runs on the host in float64; on device, after the Sep-2026 psi-table and grid-sizing fixes, the NFFT path agrees with the float64 `lomb_scargle_direct_sums` reference to 5.7e-7 in float32 and 7.4e-10 with `use_double=True` for H=2,3 (the host solve itself is exact to float64 roundoff). +- **scikit-cuda dependency removed**: cuFFT is called through a minimal in-house ctypes binding that preserves the cuFFT execution path. This unblocks numpy ≥1.24 / 2.x environments. +- **Optional cuFINUFFT backend** (`pip install cuvarbase[cufinufft]`, `use_cufinufft=True`) as a numerical cross-check; the built-in kernel remains the default. +- **Rigorous NFFT accuracy control**: `autoset_m` now uses the L1-norm truncation bound, and a float32 π-literal bug that imposed a ~1e-3 error floor on *double-precision* NFFTs is fixed — float64 error now tracks theory down to ~1e-10. +- Baluev false-alarm probability evaluates in log space (no more `FAP == 0` underflow for significant peaks). + +### PDM (community contribution: @astrobatty) +- Fast shared-memory CUDA kernels for all four PDM variants; modern `(t, y, err)` API with automatic frequency grids (legacy format deprecated, not removed). +- Batch processing: `batched_run_const_nfreq()` and memory-auto-sized `large_run()`. + +### Conditional Entropy (community contribution: @astrobatty) +- `compute_log_prob=True` log-probability periodograms, input normalization, overflow guards, and an implemented `memory_requirement()`. CE is otherwise in maintenance mode — for an actively developed GPU CE/AOV search see the `periodfind` package. + +### Transit Least Squares +- **Observation-level search is now the default** for `tls_search`, `tls_search_gpu`, `tls_transit` and `tls_search_batch`. It follows the pinned GTLS templates, duration/epoch trials, residual arithmetic and full candidate/harmonic refinement without phase binning. Thin transits use the same broad automatic duration policy. +- **Execution modes:** `execution='baseline'` preserves the implementation at `6ced75d`. The later survey optimizations require explicit `execution='experimental'`. Their frozen study matched 5,111/5,120 held-out results exactly; nine chi2/SDE differences failed the zero-mismatch contract, although selected periods and detection decisions agreed. Separate release validation passed 24 paired comparisons and 86 device tests; it does not requalify the experimental mode. See [TLS execution modes](TLS_EXECUTION.md). +- Fused residual evaluation and reduction remove repeated computation and large intermediate tensors. Reusable CUDA graphs replay the original row-wise cumulative sums. Physical workspace limits do not narrow the search domain. +- The previous approximate engine is explicit: `method='binned'`. The old shared-memory kernel is `method='legacy'`; `use_fast` remains a deprecated alias for these older engines. Old binning/refinement controls are not silently applied to the new default. +- Install `cuvarbase[tls]` for CuPy 13 (CUDA 12) and batman-package, using Python 3.9–3.13. The new default is validated on an A40 with Python 3.11, CuPy 13.6 and CUDA 12.4. Earlier multi-device-model tests apply to the retained older engines. +- **Statistics:** the default SDE follows GTLS's full refined spectrum. SNR remains cuvarbase's `sqrt(delta chi2)` in supplied-error units. Optional permutation FAP nulls use the same complete search as the observed curve. +- **Measured speed:** 3.6–4.6× faster single-source calls and 1.5–2.4× faster 16-source throughput than qualifying GTLS settings on the same RTX A6000. The report preserves one four-worker GTLS warmup OOM and separately audits the completed configurations. [Current measurements and numerical validation](TRANSIT_BENCHMARKS.md) supersede the earlier binned-versus-fast-GTLS headline. Those dated experiments remain archived. + +### Experimental (quarantined; not yet recommended for science use) +- **NUFFT-LRT likelihood-ratio transit search** (`cuvarbase.nufft_lrt`), contributed by Jamila Taaki (@xiaziyna): a frequency-domain matched filter for box transits in correlated noise, whitened by a noise PSD that is supplied or estimated from the data. `NUFFTLRTAsyncProcess.run(t, y, periods, durations=..., epochs=None, detector='matched' | 'marginal' | 'sequential', systematics_basis=None, coeff_prior_mean=None, coeff_prior_cov=None, ...)` selects the stationary whitened filter (default), Detector A of Taaki, Kamalabadi & Kemball (2020) — systematics coefficients marginalized under a Gaussian prior, computed in the whitened frequency domain via the Woodbury identity — or the papers' sequential baseline (least-squares cotrend with an intercept, then the filter). With `epochs=None` an automatic epoch grid is scanned per (period, duration) cell and `(snr, best_epoch)` is returned; explicit `epochs` return the `(nP, nD, nE)` array. +- **Status, honestly**: the module emits an `EXPERIMENTAL` `UserWarning` when `NUFFTLRTAsyncProcess` is first constructed (not at import) and is deliberately *not* exported from the top-level `cuvarbase` namespace (`import cuvarbase.nufft_lrt` explicitly). Its statistic is a whitened correlation, not an N(0,1) SNR, and thresholds must be calibrated per dataset. The dated NUFFT-LRT comparisons below use the earlier binned TLS engine. Test coverage: CPU tests of the Detector-A algebra (Woodbury path against a dense inverse) and of the pipeline, plus GPU behavioural tests (NFFT against the exact adjoint DFT, multi-season detection, BJD-scale invariance, the Sep-2026 regression tests). Its injection-recovery re-validation after the September 2026 fixes ran on 2026-09-06 (200 injections per depth, one A40; `benchmarks/results/nufft_lrt_validation_2026-09-06/`): the public default path is correct on BJD-scale times (identical statistics to 5e-8) and recovers random-epoch transits; with a systematics basis the Detector A and sequential detectors recover 3/44/98/100% of transits at depths 0.004/0.008/0.016/0.032 where basis-free BLS recovers 0/0/2/16% and TLS none; in OU red noise the whitened filter is 6-10 ± 3% more complete than BLS at the transition depths but a flat-PSD matched filter does as well or better; in white noise BLS and TLS are 10-12 ± 3% more complete. It stays **outside the 1.x API-stability promise** because that campaign showed its defaults (automatic epoch grid, whitening) and `run()` return conventions should still change before the API is frozen, so it may change incompatibly in a 1.x release. See the [NUFFT-LRT page](https://johnh2o2.github.io/cuvarbase/nufft_lrt.html) of the documentation. + +### Usability & infrastructure +- `import cuvarbase` no longer requires a GPU or creates a CUDA context; CPU-only helpers work on laptops. +- All host transfer buffers are genuinely page-locked, so async GPU transfers actually overlap compute. +- Typed exceptions (`ValueError`/`RuntimeError`) with clear messages replace bare `Exception`s and `assert`s; validation survives `python -O`. + +## Notable correctness fixes + +Beyond the highlights above (BJD epoch handling, nondeterministic degenerate-box peaks, `noverlap`): + +- `mod1_fast` integer overflow corrupted phases when `t × f ≥ 2³¹` (long baselines × high frequencies). +- The CPU reference `single_bls` folded phases in an order that lost up to ~1.5e-5 of phase precision per year of baseline (it subtracted the trial phase before wrapping); it now wraps first, bit-identically to the GPU kernels. +- The optimized kernel's block-level max reduction dropped half the per-block candidates. +- `eebls_gpu_batch` results now match the single-LC path exactly (it was silently single-pass, and recompiled kernels every call). +- `lomb_scargle_simple` double-applied inverse-variance weights (inverted weighting for heteroskedastic errors). +- The direct-sums LS path returned stale results for GPU-resident workflows (`transfer_to_host` was gated on the wrong flag). +- `eebls_transit`'s sparse path crashed on documented kwargs and silently dropped Keplerian duration constraints. +- PDM CPU reference functions no longer mutate caller arrays in place. +- Wheels/sdists now include all subpackages; editable installs resolve kernel files correctly. + +## September 2026 audit fixes + +A read-only algorithm audit of the release candidate (September 2026, on-device) found a set of default-path defects that changed *results*, and a performance pass followed. Every item is reproduced on device before its fix and carries a regression test; the full per-item list with root causes is in the 1.0.0 section of [CHANGELOG.rst](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/CHANGELOG.rst). The condensed list: + +**Correctness (result-changing):** +- **Input validation (BREAKING)** — every entry point rejects non-finite `t`/`y`/`dy`, `dy <= 0`, mismatched lengths, too-short light curves, bad frequency grids and inverted duration bounds with `ValueError` on the host, before any GPU work (see the migration table below). Previously a NaN gave a finite-but-wrong periodogram, and a bad `q` bound crashed the kernel and destroyed the process's CUDA context. +- **BLS**: 64-bit thread indexing in the phase-fold kernels (`eebls_gpu`/`eebls_transit` on > 2³¹ threads silently returned zeros, powers above 1 and the wrong peak); per-frequency `qmin`/`qmax` arrays are now honoured per frequency by `eebls_gpu` (they collapsed to one grid-wide window) and its bin buffers are sized correctly for Keplerian grids (out-of-bounds writes); the fast kernels evaluate the widest box allowed by `qmax` (the loop stopped one rung short); the sparse path centres the flux in float64; `eebls_transit` uses the fused fast kernel above the sparse threshold and recovers solutions at the top peaks; the Keplerian grid recursion of `transit_autofreq`/`keplerian_freq_grid` is solved with numpy — grids change at float64 rounding only. +- **TLS**: the standard engine uses GTLS's broad duration domain and full refinement, with explicit narrower duration overrides. Float64 period grids retain caller order. A float64 origin shift preserves zero/negative and absolute BJD times; `T0` is restored to the first mid-transit at or after `min(t)`. The fixed SDE-to-FAP table remains removed; flat spectra return SDE=0. Standard TLS requires at least three observations. +- **Lomb–Scargle / NFFT**: the w-spectrum was gridded with the psi tables of the differently sized yw grid; `floorf()` on the double-precision grid coordinate; aliased garbage for bands that do not start near zero (grid sizing); wrong NFFT magnitudes for absolute-time input; `nharmonics > 1` and `amplitude_prior` ignored on some paths; non-uniform frequency grids are now rejected instead of silently evaluated on the implied uniform grid; stale results after `preallocate()`; `only_return_best_freqs=True` returns the FAP itself (it returned `1 - FAP`); cuFINUFFT in double precision. +- **Conditional entropy**: the brightest point fell into an out-of-range magnitude bin (clamped now); weighted-CE `max_phi` truncation; `use_double=True, use_fast=True` crash; constructor `balanced_magbins`/`widen_mag_range` ignored; `preallocate()` never uploaded the grid; recompilation on every call; histogram accumulation across `set_data=False` calls; float32 frequency arrays rejected. +- **PDM**: out-of-bounds bin read in the `binned_step` kernel; the deprecated 4-tuple format returned a flat spectrum for unnormalized weights. +- **NUFFT-LRT**: BJD-scale times; `epochs=None` is a real epoch search (returns a tuple — breaking); the sequential detector fits an intercept; Detector A estimates its PSD from the basis-projected residual; NFFT `sigma = 4`; PSD validation and flooring; singular priors handled in the correct limit. + +**Additional implementation changes:** performance comparisons for release advertising are in the [current transit benchmark](TRANSIT_BENCHMARKS.md). +- **BLS**: `eebls_gpu`, `eebls_gpu_custom`, `hone_solution` and `sparse_bls_gpu` take their kernels from the LRU cache instead of compiling per call; the adaptive/optimized paths run the fused-`noverlap` kernel; no per-call `BLSMemory` on the single-call paths; vectorized solution re-phasing and `einsum` prologues. +- **Lomb–Scargle**: `batched_run_const_nfreq` reuses its memory set, cuFFT plans and pinned buffers across calls; the multiharmonic host solve is one stacked `numpy.linalg.solve`; vectorized NumPy reductions on the host path. +- **Conditional entropy / PDM**: `use_fast=True` sizes its grid from the device and no longer allocates the global histogram it never read; PDM `run()` reuses its device buffers across same-shape calls. +- **TLS**: the default optimization reuses observation-level arithmetic, reduces trial winners on the GPU and replays native row-wise prefix scans. The retained binned engine also skips empty-bin template work; its separate [accuracy audit](TLS_NUMERICS.md) motivated changing the default. + +## Breaking changes & migration + +| Change | Migration | +|---|---| +| Unreleased v1 TLS default changed to the complete observation-level engine | Install `cuvarbase[tls]`; omit duration controls for the broad default. Execution remains `execution='baseline'`; the survey optimization bundle is opt-in with `execution='experimental'`. Use `method='binned'` for old bin/refinement settings, or `method='legacy'` for low-level memory/stream controls. | +| **Every entry point now validates its input and raises `ValueError`** — non-finite `t`/`y`/`dy`, `dy <= 0`, mismatched lengths, an empty or too-short light curve (4 points for Lomb–Scargle, 3 for standard TLS and NUFFT-LRT, 2 elsewhere), non-finite/non-positive frequencies, and transit-duration bounds outside `0 < qmin <= qmax <= 1`. These used to be accepted silently: a NaN timestamp gave a finite BLS/CE periodogram with the wrong peak, `dy = 0` gave an all-NaN PDM spectrum or a Lomb–Scargle power of `-1` everywhere, and a NaN q bound or an under-populated Keplerian grid crashed the kernel and killed the process's CUDA context. Checks run on the host before any GPU work, so a rejected call leaves the context usable. Valid finite input is bit-identical. | Filter first: `m = np.isfinite(t) & np.isfinite(y) & (dy > 0)`. Pipelines that read an all-zero or `-1` periodogram as “no detection” must now catch `ValueError`. Helpers: `cuvarbase.utils.check_lightcurve` / `check_freqs`. | +| **Python ≥ 3.9 required** (was 2.7–3.6); numpy ≥ 1.22, scipy ≥ 1.8 (the oldest releases that install on 3.9; the previously declared 1.17/1.3 could not be installed on any supported interpreter) | Upgrade the interpreter; numpy 2.x is supported. | +| **BLS results on absolute (BJD-scale) timestamps change** — they were silently wrong before. Reported `phi0` stays referenced to your original input timescale (no convention change; internally times are epoch-subtracted in float64 for precision — thanks @astrobatty, #65) | Re-baseline stored results from absolute-timestamp runs; data starting near t=0 is numerically unaffected. | +| **`noverlap` now works** on fast BLS paths (default 2): peaks can rise, runtime ~doubles at defaults | Pass `noverlap=1` for old behavior/timing. | +| **Truly async results**: reading `run()` outputs before synchronizing is now a race | Call `proc.finish()` first (batched entry points synchronize internally); `pinned=False` opts out. | +| **`import cuvarbase` no longer creates a CUDA context** | Call `cuvarbase.base.ensure_context()` (or any GPU function) before raw pycuda work; set `CUDA_DEVICE` before first GPU use, not import. | +| **`sparse_bls_cpu`/`sparse_bls_gpu`: args after `freqs` are keyword-only**; q bounds validated | Pass `qmin=`, `qmax=`, etc. by keyword. Legacy positional calls now fail loudly instead of silently returning zeros. | +| `LombScargleAsyncProcess.batched_run_const_nfreq` default `batch_size` 10 → 1 (the PDM and CE `batched_run_const_nfreq` keep 10) | Pass `batch_size=10` to restore old Lomb–Scargle chunking. | +| PDM legacy `(t, y, w, freqs)` input deprecated (still works, warns) | Move to `(t, y, err)` tuples + `freqs=`. | +| `BLSMemory.allocate_pinned_arrays` → `allocate_host_arrays` (alias warns) | Rename the call. | +| scikit-cuda is no longer installed transitively | `pip install scikit-cuda` yourself if *your* code needs it. | +| Small numerical shifts everywhere (shared kernel literals, input normalization, degenerate-box guard, NFFT π fix) | Re-baseline golden outputs; parity with old results is >0.999 correlation in our tests, and the shifts are fixes, not drift. | + +## Packaging + +- `pyproject.toml` (PEP 517/621) is the only packaging file (`setup.py`, `setup.cfg`, `requirements*.txt` removed); `setuptools>=77` backend with PEP 639 license metadata (`License-Expression: GPL-3.0-only`, `LICENSE.txt` shipped); Python 3.9–3.14 classifiers; dynamic versioning; wheel tag `py3-none-any`. +- Dependencies removed: `scikit-cuda`, `future`. Floors: `numpy>=1.22`, `scipy>=1.8`. Pins: `pycuda>=2017.1.1,!=2024.1.2`. +- Optional extras: `cuvarbase[test]` (pytest, nfft, astropy, batman-package, transitleastsquares — matplotlib is no longer required for the tests), `cuvarbase[cufinufft]`, `cuvarbase[docs]` (sphinx, matplotlib), and `cuvarbase[tls]` (CuPy 13 for CUDA 12 and batman-package; Python 3.9–3.13). +- pytest is configured in `pyproject.toml` (`testpaths`, `-rs --strict-markers`, `gpu` marker); `cuvarbase/kernels/wavelet.cu` (never loaded) no longer ships, guarded by an orphan-kernel test. +- GitHub Actions CI: the CPU suite on Python 3.9–3.14, wheel and sdist install legs (including `pytest --pyargs cuvarbase` from the installed wheel), a docs build, and flake8. The repository's Dockerfile was removed: it never installed cuvarbase (a rebuilt image is queued for 1.1). +- **Release tag status:** as checked on 24 September 2026, `v1.0.0` still points to the earlier June commit `5553248`, not this candidate. Final tagging and PyPI publication remain pending; the candidate source is on `v1.0-fixes`. + +## Credits + +Major community contributions to this release from **Attila Bódi (@astrobatty)** — fast PDM kernels and batch APIs, Conditional Entropy enhancements, Lomb–Scargle normalization and memory-estimation improvements, and the BLS epoch/phase-reporting work (PRs #57–#62, #65) — and **Jamila Taaki (@xiaziyna)** — the NUFFT-LRT matched-filter transit search. Thanks also to the TESS QLP team for production adoption and feedback. + +## Known limitations + +- NUFFT-LRT is experimental (`UserWarning` at first construction; not in the top-level namespace; outside the 1.x stability promise). It has been re-validated by injection-recovery (see its docs page for the measured numbers), but its defaults and `run()` conventions may still change in 1.x; calibrate thresholds empirically and cite the measured numbers, not the papers'. +- No benchmark against CETRA (PLATO's GPU transit code, a different algorithm family) exists yet; the GPU-vs-GPU transit-search comparison published here covers GTLS. +- float32 NFFT has a genuine ~1e-3 accuracy floor from single-precision trig on large phases; pass `use_double=True` for tight tolerances. +- Conditional Entropy is maintained but not actively developed. diff --git a/docs/RELEASE_NOTES_v1.0.1.md b/docs/RELEASE_NOTES_v1.0.1.md new file mode 100644 index 00000000..a23b91ba --- /dev/null +++ b/docs/RELEASE_NOTES_v1.0.1.md @@ -0,0 +1,138 @@ +# cuvarbase 1.0.1 + +**First planned PyPI release of the 1.x series.** cuvarbase provides GPU-accelerated period-finding and transit-detection algorithms for astronomical time series: Box Least Squares (BLS), Transit Least Squares (TLS), Lomb–Scargle (including multiharmonic), Phase Dispersion Minimization (PDM), Conditional Entropy (CE), and the non-uniform FFT (NFFT) that powers them. + +Version **1.0.1** preserves the existing June `v1.0.0` tag and gives the reviewed candidate a distinct version. These are release-candidate notes for the first planned PyPI release since **0.2.5 (October 2023)**. The candidate contains everything from the tagged-but-never-published 0.2.6 maintenance release (May 2025) plus all of the 1.0 development work. As checked on 27 September 2026 (America/Chicago), `pip install cuvarbase` still installs 0.2.5; use the `v1.0-fixes` branch to install the candidate. + +In production: cuvarbase's BLS has powered the TESS Quick-Look Pipeline's planet search since Sector 59 (Kunimoto et al. 2023, RNAAS 7, 28). + +## Highlights + +- **New GPU Transit Least Squares:** a GTLS-compatible observation-level default with full candidate and harmonic refinement. The [current ZTF/TESS benchmark](TRANSIT_BENCHMARKS.md) compares full searches with public GTLS and records numerical agreement, recovery and noise-only outcomes. +- **Faster BLS searches and grid construction:** compare actual PyPI 0.2.5, v1 and tested CPU/GPU alternatives in the [current benchmark](TRANSIT_BENCHMARKS.md). +- **Versus actual PyPI 0.2.5:** fused phase searches, conflict-scatter staging, reusable batch memory, vectorized host scans and grid construction, plus support for the current NumPy/PyCUDA stack. Both releases receive warmed kernels and reusable PyPI memory in the new comparison; its warm speedup is not attributed entirely to compilation caching. +- **Correct results on absolute (BJD-scale) timestamps.** Pre-1.0, feeding BLS raw BJD times (~2.45 million days) silently destroyed the phase fold in float32. Measured: an injected P=3.46 d transit recovered at power 0.30 on near-zero timestamps collapses to power 0.089 at the wrong frequency when the same data carries BJD timestamps in 0.2.6 — no error, no warning. The 1.0.1 candidate returns identical periodograms on both timescales (r=1.000000); all BLS paths epoch-subtract in float64 first. +- **Fixed spurious BLS peaks from degenerate trial boxes.** A float32 guard bug produced run-to-run-varying peaks on single-site ground-based data (reported by @astrobatty against HATPI light curves). The guard is corrected, with regression tests checking that 500 ppm transits still survive. Native floating-point accumulation can still vary between calls: the [sustained benchmark](TRANSIT_BENCHMARKS.md) retains failed BLS repeatability qualification and labels its new rates as execution only. +- **New algorithms and APIs**: sparse BLS for small datasets (Panahi & Zucker 2021), batched multi-lightcurve BLS, Keplerian frequency grids with stellar-density and duration constraints, multiharmonic generalized Lomb–Scargle on GPU, fast PDM kernels, CE log-probability periodograms, and an experimental NUFFT matched-filter transit search. +- **Modern, lighter install**: Python 3.9–3.14, numpy 2.x, no more scikit-cuda or `future`; `import cuvarbase` works on GPU-less machines (the pure helpers need no pycuda at all; the method modules need the pycuda package but no device until the first GPU call). +- **Expanded release validation**: **2,091 passed + 1 xfailed of 2,092 collected** (0 failed, 0 skipped; full suite, NVIDIA A40, 24–25 September 2026), followed by 14 installed-wheel numerical/runtime checks and six dependency preflights on 27 September. The [receipts](../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) preserve the original launcher import failure and the separate corrected check. The expected failure is `test_examples_compile.py::test_notebook_code_cells_compile_without_warnings[Phase Dispersion Minimization.ipynb]`, for known non-raw TeX label strings. CPU CI spans Python 3.9–3.14. Passing release tests does not override the benchmark's failed qualifications. + +## Performance + +The [current transit benchmark](TRANSIT_BENCHMARKS.md) is the source for BLS/TLS release claims: blind recovery and false-positive results, expected signal response, original exactness failures, and sustained throughput with five unavailable panels. Its September 24–25 follow-up reports seven strictly qualified TLS/GTLS timing panels and four BLS execution-only panels. Historical single-source, batch and upgrade comparisons retain their original workload and source scopes. + +The published upgrade baseline in this campaign is 0.2.5; the 0.2.6 tag was not published to PyPI. The [benchmark index](BENCHMARK_RESULTS.md) links the current report, component evidence and historical-claim audit. + +## New features + +### BLS +- **Sparse BLS** (Panahi & Zucker 2021) on GPU and CPU (`sparse_bls_gpu`, `sparse_bls_cpu`) for small datasets (≲500 points); `eebls_transit` auto-selects it by dataset size and applies Keplerian duration constraints consistently on both paths. +- **Batched BLS**: `eebls_gpu_batch()` processes many light curves per kernel launch and accepts per-frequency `qmin`/`qmax` arrays. +- **Keplerian frequency grids**: `cuvarbase.bls_frequencies.keplerian_freq_grid()` (with `return_qvals=True` feeding duration bounds straight into the batch API). +- **Selectable power conventions**: `convention='chi2ratio' | 'snr' | 'loglik'` on all BLS entry points (+ `convert_bls_power()`); `'snr'` verified equal to astropy's `objective='snr'`. +- **Optimized/adaptive kernels**: `eebls_gpu_fast_optimized()` and `eebls_gpu_fast_adaptive()` provide warp-shuffle reductions and automatic block sizing. Their benefit depends on workload and settings. +- `noverlap` is now honored on the fast path (elementwise max over phase-shifted passes; default 2). +- **BLS throughput features (July 2026):** fused phase histograms, observation-scatter staging, frequency chunking, and host overhead fixes. The [current benchmark](TRANSIT_BENCHMARKS.md) measures their practical upgrade effect and diagnostic ablations; scattering does not demonstrate a benefit on its three selected cases. + +### Lomb–Scargle & NFFT +- **Multiharmonic generalized Lomb–Scargle on GPU** (`nharmonics>1`). The per-frequency solve runs on the host in float64; on device, after the Sep-2026 psi-table and grid-sizing fixes, the NFFT path agrees with the float64 `lomb_scargle_direct_sums` reference to 5.7e-7 in float32 and 7.4e-10 with `use_double=True` for H=2,3 (the host solve itself is exact to float64 roundoff). +- **scikit-cuda dependency removed**: cuFFT is called through a minimal in-house ctypes binding that preserves the cuFFT execution path. This unblocks numpy ≥1.24 / 2.x environments. +- **Optional cuFINUFFT backend** (`pip install cuvarbase[cufinufft]`, `use_cufinufft=True`) as a numerical cross-check; the built-in kernel remains the default. +- **Rigorous NFFT accuracy control**: `autoset_m` now uses the L1-norm truncation bound, and a float32 π-literal bug that imposed a ~1e-3 error floor on *double-precision* NFFTs is fixed — float64 error now tracks theory down to ~1e-10. +- Baluev false-alarm probability evaluates in log space (no more `FAP == 0` underflow for significant peaks). + +### PDM (community contribution: @astrobatty) +- Fast shared-memory CUDA kernels for all four PDM variants; modern `(t, y, err)` API with automatic frequency grids (legacy format deprecated, not removed). +- Batch processing: `batched_run_const_nfreq()` and memory-auto-sized `large_run()`. + +### Conditional Entropy (community contribution: @astrobatty) +- `compute_log_prob=True` log-probability periodograms, input normalization, overflow guards, and an implemented `memory_requirement()`. CE is otherwise in maintenance mode — for an actively developed GPU CE/AOV search see the `periodfind` package. + +### Transit Least Squares +- **Observation-level search is now the default** for `tls_search`, `tls_search_gpu`, `tls_transit` and `tls_search_batch`. It follows the pinned GTLS templates, duration/epoch trials, residual arithmetic and full candidate/harmonic refinement without phase binning. Thin transits use the same broad automatic duration policy. +- **Execution modes:** `execution='baseline'` preserves the implementation at `6ced75d`. The later survey optimizations require explicit `execution='experimental'`. Their frozen study matched 5,111/5,120 held-out results exactly; nine chi2/SDE differences failed the zero-mismatch contract, although selected periods and detection decisions agreed. Separate release validation passed 24 paired comparisons and 86 device tests; it does not requalify the experimental mode. See [TLS execution modes](TLS_EXECUTION.md). +- Fused residual evaluation and reduction remove repeated computation and large intermediate tensors. Reusable CUDA graphs replay the original row-wise cumulative sums. Physical workspace limits do not narrow the search domain. +- The previous approximate engine is explicit: `method='binned'`. The old shared-memory kernel is `method='legacy'`; `use_fast` remains a deprecated alias for these older engines. Old binning/refinement controls are not silently applied to the new default. +- Install `cuvarbase[tls]` for CuPy 13 (CUDA 12) and batman-package, using Python 3.9–3.13. The new default is validated on an A40 with Python 3.11, CuPy 13.6 and CUDA 12.4. Earlier multi-device-model tests apply to the retained older engines. +- **Statistics:** the default SDE follows GTLS's full refined spectrum. SNR remains cuvarbase's `sqrt(delta chi2)` in supplied-error units. Optional permutation FAP nulls use the same complete search as the observed curve. +- **Measured speed:** 3.6–4.6× faster single-source calls and 1.5–2.4× faster 16-source throughput than qualifying GTLS settings on the same RTX A6000. The report preserves one four-worker GTLS warmup OOM and separately audits the completed configurations. [Current measurements and numerical validation](TRANSIT_BENCHMARKS.md) supersede the earlier binned-versus-fast-GTLS headline. Those dated experiments remain archived. + +### Experimental (quarantined; not yet recommended for science use) +- **NUFFT-LRT likelihood-ratio transit search** (`cuvarbase.nufft_lrt`), contributed by Jamila Taaki (@xiaziyna): a frequency-domain matched filter for box transits in correlated noise, whitened by a noise PSD that is supplied or estimated from the data. `NUFFTLRTAsyncProcess.run(t, y, periods, durations=..., epochs=None, detector='matched' | 'marginal' | 'sequential', systematics_basis=None, coeff_prior_mean=None, coeff_prior_cov=None, ...)` selects the stationary whitened filter (default), Detector A of Taaki, Kamalabadi & Kemball (2020) — systematics coefficients marginalized under a Gaussian prior, computed in the whitened frequency domain via the Woodbury identity — or the papers' sequential baseline (least-squares cotrend with an intercept, then the filter). With `epochs=None` an automatic epoch grid is scanned per (period, duration) cell and `(snr, best_epoch)` is returned; explicit `epochs` return the `(nP, nD, nE)` array. +- **Status, honestly**: the module emits an `EXPERIMENTAL` `UserWarning` when `NUFFTLRTAsyncProcess` is first constructed (not at import) and is deliberately *not* exported from the top-level `cuvarbase` namespace (`import cuvarbase.nufft_lrt` explicitly). Its statistic is a whitened correlation, not an N(0,1) SNR, and thresholds must be calibrated per dataset. The dated NUFFT-LRT comparisons below use the earlier binned TLS engine. Test coverage: CPU tests of the Detector-A algebra (Woodbury path against a dense inverse) and of the pipeline, plus GPU behavioural tests (NFFT against the exact adjoint DFT, multi-season detection, BJD-scale invariance, the Sep-2026 regression tests). Its injection-recovery re-validation after the September 2026 fixes ran on 2026-09-06 (200 injections per depth, one A40; `benchmarks/results/nufft_lrt_validation_2026-09-06/`): the public default path is correct on BJD-scale times (identical statistics to 5e-8) and recovers random-epoch transits; with a systematics basis the Detector A and sequential detectors recover 3/44/98/100% of transits at depths 0.004/0.008/0.016/0.032 where basis-free BLS recovers 0/0/2/16% and TLS none; in OU red noise the whitened filter is 6-10 ± 3% more complete than BLS at the transition depths but a flat-PSD matched filter does as well or better; in white noise BLS and TLS are 10-12 ± 3% more complete. It stays **outside the 1.x API-stability promise** because that campaign showed its defaults (automatic epoch grid, whitening) and `run()` return conventions should still change before the API is frozen, so it may change incompatibly in a 1.x release. See the [NUFFT-LRT page](https://johnh2o2.github.io/cuvarbase/nufft_lrt.html) of the documentation. + +### Usability & infrastructure +- `import cuvarbase` no longer requires a GPU or creates a CUDA context; CPU-only helpers work on laptops. +- All host transfer buffers are genuinely page-locked, so async GPU transfers actually overlap compute. +- Typed exceptions (`ValueError`/`RuntimeError`) with clear messages replace bare `Exception`s and `assert`s; validation survives `python -O`. + +## Notable correctness fixes + +Beyond the highlights above (BJD epoch handling, nondeterministic degenerate-box peaks, `noverlap`): + +- `mod1_fast` integer overflow corrupted phases when `t × f ≥ 2³¹` (long baselines × high frequencies). +- The CPU reference `single_bls` folded phases in an order that lost up to ~1.5e-5 of phase precision per year of baseline (it subtracted the trial phase before wrapping); it now wraps first, bit-identically to the GPU kernels. +- The optimized kernel's block-level max reduction dropped half the per-block candidates. +- `eebls_gpu_batch` results now match the single-LC path exactly (it was silently single-pass, and recompiled kernels every call). +- `lomb_scargle_simple` double-applied inverse-variance weights (inverted weighting for heteroskedastic errors). +- The direct-sums LS path returned stale results for GPU-resident workflows (`transfer_to_host` was gated on the wrong flag). +- `eebls_transit`'s sparse path crashed on documented kwargs and silently dropped Keplerian duration constraints. +- PDM CPU reference functions no longer mutate caller arrays in place. +- Wheels/sdists now include all subpackages; editable installs resolve kernel files correctly. + +## September 2026 audit fixes + +A read-only algorithm audit of the release candidate (September 2026, on-device) found a set of default-path defects that changed *results*, and a performance pass followed. Every item is reproduced on device before its fix and carries a regression test; the full per-item list with root causes is in the 1.0 development section of [CHANGELOG.rst](https://github.com/johnh2o2/cuvarbase/blob/v1.0-fixes/CHANGELOG.rst). The condensed list: + +**Correctness (result-changing):** +- **Input validation (BREAKING)** — every entry point rejects non-finite `t`/`y`/`dy`, `dy <= 0`, mismatched lengths, too-short light curves, bad frequency grids and inverted duration bounds with `ValueError` on the host, before any GPU work (see the migration table below). Previously a NaN gave a finite-but-wrong periodogram, and a bad `q` bound crashed the kernel and destroyed the process's CUDA context. +- **BLS**: 64-bit thread indexing in the phase-fold kernels (`eebls_gpu`/`eebls_transit` on > 2³¹ threads silently returned zeros, powers above 1 and the wrong peak); per-frequency `qmin`/`qmax` arrays are now honoured per frequency by `eebls_gpu` (they collapsed to one grid-wide window) and its bin buffers are sized correctly for Keplerian grids (out-of-bounds writes); the fast kernels evaluate the widest box allowed by `qmax` (the loop stopped one rung short); the sparse path centres the flux in float64; `eebls_transit` uses the fused fast kernel above the sparse threshold and recovers solutions at the top peaks; the Keplerian grid recursion of `transit_autofreq`/`keplerian_freq_grid` is solved with numpy — grids change at float64 rounding only. +- **TLS**: the standard engine uses GTLS's broad duration domain and full refinement, with explicit narrower duration overrides. Float64 period grids retain caller order. A float64 origin shift preserves zero/negative and absolute BJD times; `T0` is restored to the first mid-transit at or after `min(t)`. The fixed SDE-to-FAP table remains removed; flat spectra return SDE=0. Standard TLS requires at least three observations. +- **Lomb–Scargle / NFFT**: the w-spectrum was gridded with the psi tables of the differently sized yw grid; `floorf()` on the double-precision grid coordinate; aliased garbage for bands that do not start near zero (grid sizing); wrong NFFT magnitudes for absolute-time input; `nharmonics > 1` and `amplitude_prior` ignored on some paths; non-uniform frequency grids are now rejected instead of silently evaluated on the implied uniform grid; stale results after `preallocate()`; `only_return_best_freqs=True` returns the FAP itself (it returned `1 - FAP`); cuFINUFFT in double precision. +- **Conditional entropy**: the brightest point fell into an out-of-range magnitude bin (clamped now); weighted-CE `max_phi` truncation; `use_double=True, use_fast=True` crash; constructor `balanced_magbins`/`widen_mag_range` ignored; `preallocate()` never uploaded the grid; recompilation on every call; histogram accumulation across `set_data=False` calls; float32 frequency arrays rejected. +- **PDM**: out-of-bounds bin read in the `binned_step` kernel; the deprecated 4-tuple format returned a flat spectrum for unnormalized weights. +- **NUFFT-LRT**: BJD-scale times; `epochs=None` is a real epoch search (returns a tuple — breaking); the sequential detector fits an intercept; Detector A estimates its PSD from the basis-projected residual; NFFT `sigma = 4`; PSD validation and flooring; singular priors handled in the correct limit. + +**Additional implementation changes:** performance comparisons for release advertising are in the [current transit benchmark](TRANSIT_BENCHMARKS.md). +- **BLS**: `eebls_gpu`, `eebls_gpu_custom`, `hone_solution` and `sparse_bls_gpu` take their kernels from the LRU cache instead of compiling per call; the adaptive/optimized paths run the fused-`noverlap` kernel; no per-call `BLSMemory` on the single-call paths; vectorized solution re-phasing and `einsum` prologues. +- **Lomb–Scargle**: `batched_run_const_nfreq` reuses its memory set, cuFFT plans and pinned buffers across calls; the multiharmonic host solve is one stacked `numpy.linalg.solve`; vectorized NumPy reductions on the host path. +- **Conditional entropy / PDM**: `use_fast=True` sizes its grid from the device and no longer allocates the global histogram it never read; PDM `run()` reuses its device buffers across same-shape calls. +- **TLS**: the default optimization reuses observation-level arithmetic, reduces trial winners on the GPU and replays native row-wise prefix scans. The retained binned engine also skips empty-bin template work; its separate [accuracy audit](TLS_NUMERICS.md) motivated changing the default. + +## Breaking changes & migration + +| Change | Migration | +|---|---| +| Unreleased v1 TLS default changed to the complete observation-level engine | Install `cuvarbase[tls]`; omit duration controls for the broad default. Execution remains `execution='baseline'`; the survey optimization bundle is opt-in with `execution='experimental'`. Use `method='binned'` for old bin/refinement settings, or `method='legacy'` for low-level memory/stream controls. | +| **Every entry point now validates its input and raises `ValueError`** — non-finite `t`/`y`/`dy`, `dy <= 0`, mismatched lengths, an empty or too-short light curve (4 points for Lomb–Scargle, 3 for standard TLS and NUFFT-LRT, 2 elsewhere), non-finite/non-positive frequencies, and transit-duration bounds outside `0 < qmin <= qmax <= 1`. These used to be accepted silently: a NaN timestamp gave a finite BLS/CE periodogram with the wrong peak, `dy = 0` gave an all-NaN PDM spectrum or a Lomb–Scargle power of `-1` everywhere, and a NaN q bound or an under-populated Keplerian grid crashed the kernel and killed the process's CUDA context. Checks run on the host before any GPU work, so a rejected call leaves the context usable. Valid finite input is bit-identical. | Filter first: `m = np.isfinite(t) & np.isfinite(y) & (dy > 0)`. Pipelines that read an all-zero or `-1` periodogram as “no detection” must now catch `ValueError`. Helpers: `cuvarbase.utils.check_lightcurve` / `check_freqs`. | +| **Python ≥ 3.9 required** (was 2.7–3.6); numpy ≥ 1.22, scipy ≥ 1.8 (the oldest releases that install on 3.9; the previously declared 1.17/1.3 could not be installed on any supported interpreter) | Upgrade the interpreter; numpy 2.x is supported. | +| **BLS results on absolute (BJD-scale) timestamps change** — they were silently wrong before. Reported `phi0` stays referenced to your original input timescale (no convention change; internally times are epoch-subtracted in float64 for precision — thanks @astrobatty, #65) | Re-baseline stored results from absolute-timestamp runs; data starting near t=0 is numerically unaffected. | +| **`noverlap` now works** on fast BLS paths (default 2): peaks can rise, runtime ~doubles at defaults | Pass `noverlap=1` for old behavior/timing. | +| **Truly async results**: reading `run()` outputs before synchronizing is now a race | Call `proc.finish()` first (batched entry points synchronize internally); `pinned=False` opts out. | +| **`import cuvarbase` no longer creates a CUDA context** | Call `cuvarbase.base.ensure_context()` (or any GPU function) before raw pycuda work; set `CUDA_DEVICE` before first GPU use, not import. | +| **`sparse_bls_cpu`/`sparse_bls_gpu`: args after `freqs` are keyword-only**; q bounds validated | Pass `qmin=`, `qmax=`, etc. by keyword. Legacy positional calls now fail loudly instead of silently returning zeros. | +| `LombScargleAsyncProcess.batched_run_const_nfreq` default `batch_size` 10 → 1 (the PDM and CE `batched_run_const_nfreq` keep 10) | Pass `batch_size=10` to restore old Lomb–Scargle chunking. | +| PDM legacy `(t, y, w, freqs)` input deprecated (still works, warns) | Move to `(t, y, err)` tuples + `freqs=`. | +| `BLSMemory.allocate_pinned_arrays` → `allocate_host_arrays` (alias warns) | Rename the call. | +| scikit-cuda is no longer installed transitively | `pip install scikit-cuda` yourself if *your* code needs it. | +| Small numerical shifts everywhere (shared kernel literals, input normalization, degenerate-box guard, NFFT π fix) | Re-baseline golden outputs; parity with old results is >0.999 correlation in our tests, and the shifts are fixes, not drift. | + +## Packaging + +- `pyproject.toml` (PEP 517/621) is the only packaging file (`setup.py`, `setup.cfg`, `requirements*.txt` removed); `setuptools>=77` backend with PEP 639 license metadata (`License-Expression: GPL-3.0-only`, `LICENSE.txt` shipped); Python 3.9–3.14 classifiers; dynamic versioning; wheel tag `py3-none-any`. +- Dependencies removed: `scikit-cuda`, `future`. Floors: `numpy>=1.22`, `scipy>=1.8`. Pins: `pycuda>=2017.1.1,!=2024.1.2`. +- Optional extras: `cuvarbase[test]` (pytest, nfft, astropy, batman-package, transitleastsquares — matplotlib is no longer required for the tests), `cuvarbase[cufinufft]`, `cuvarbase[docs]` (sphinx, matplotlib), and `cuvarbase[tls]` (CuPy 13 for CUDA 12 and batman-package; Python 3.9–3.13). +- pytest is configured in `pyproject.toml` (`testpaths`, `-rs --strict-markers`, `gpu` marker); `cuvarbase/kernels/wavelet.cu` (never loaded) no longer ships, guarded by an orphan-kernel test. +- GitHub Actions CI: the CPU suite on Python 3.9–3.14, wheel and sdist install legs (including `pytest --pyargs cuvarbase` from the installed wheel), a docs build, and flake8. The repository's Dockerfile was removed: it never installed cuvarbase (a rebuilt image is queued for 1.1). +- **Release version:** the candidate is **1.0.1**. The existing `v1.0.0` tag retains June commit `5553248`; the annotated `v1.0.1` tag identifies the reviewed candidate. The owner authorized pushing the completed `v1.0-fixes` work, the `release/v1.0.1` integration branch and the new tag for a pull request into `master`. GitHub release creation, PyPI publication and documentation deployment remain deferred. See [release preparation](RELEASE_PREPARATION.md). + +## Credits + +Major community contributions to this release from **Attila Bódi (@astrobatty)** — fast PDM kernels and batch APIs, Conditional Entropy enhancements, Lomb–Scargle normalization and memory-estimation improvements, and the BLS epoch/phase-reporting work (PRs #57–#62, #65) — and **Jamila Taaki (@xiaziyna)** — the NUFFT-LRT matched-filter transit search. Thanks also to the TESS QLP team for production adoption and feedback. + +## Known limitations + +- NUFFT-LRT is experimental (`UserWarning` at first construction; not in the top-level namespace; outside the 1.x stability promise). It has been re-validated by injection-recovery (see its docs page for the measured numbers), but its defaults and `run()` conventions may still change in 1.x; calibrate thresholds empirically and cite the measured numbers, not the papers'. +- No benchmark against CETRA (PLATO's GPU transit code, a different algorithm family) exists yet; the GPU-vs-GPU transit-search comparison published here covers GTLS. +- float32 NFFT has a genuine ~1e-3 accuracy floor from single-precision trig on large phases; pass `use_double=True` for tight tolerances. +- Conditional Entropy is maintained but not actively developed. diff --git a/docs/RELEASE_PREPARATION.md b/docs/RELEASE_PREPARATION.md new file mode 100644 index 00000000..baf1515a --- /dev/null +++ b/docs/RELEASE_PREPARATION.md @@ -0,0 +1,74 @@ +# Release preparation: 1.0.1 + +The owner authorized externalizing benchmark evidence and cleaning development +history on 28 September 2026. Complete originals and all pre-cleanup refs are +preserved in [verified archives](BENCHMARK_ARCHIVES.md). The cleaned branches +and the unpublished `v1.0.1` release tag have new commit identities; the original +prepared package files and build inputs remain byte-identical. Original source +IDs in validation receipts refer to the preserved pre-cleanup Git bundle. +Published release tags, `v1.0.0` and `master` are preserved. This source cleanup +does not publish a release or change any numerical qualification. + +The reviewed candidate is prepared as **1.0.1**. The completed work on +`v1.0-fixes` is integrated with `master` on **`release/v1.0.1`**, the source +branch for the release pull request. The owner authorized pushing these source +branches and the annotated `v1.0.1` tag on 28 September 2026. Creating a GitHub +release, uploading to PyPI and deploying documentation remain deferred. +The existing `v1.0.0` tag retains June commit `5553248`; it is not moved or replaced. + +The merge of `master` retains its normalization fixes, which were already in +the reviewed implementation. Its complete tree matches the prepared candidate +at `fcfee0e`; four normalization regression tests also pass. Subsequent handoff +documentation and monitoring portability fixes do not change the prepared +package or its build inputs. Initial Linux CI exposed truncated `ps` output in +service detection; requesting the complete command line fixes that operational +failure, with the recovery test checking a narrow display width explicitly. + +[Release notes](RELEASE_NOTES_v1.0.1.md) · +[Benchmark and retained qualifications](TRANSIT_BENCHMARKS.md) · +[GPU validation](../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) · +[Final package verification](validation/release-prepared-20260927/package-verification.json) · +[Preparation checks](validation/release-prepared-20260927/checks.json). + +The expanded GPU suite passed 2,091 tests with one expected notebook failure +and zero skips. The separately corrected installed-wheel gate passed 14 +numerical/runtime checks and six dependency preflights. Those runs used the +preserved 1.0.0 candidate wheel. For 1.0.1, 85 package files remain byte-identical; +the sole package-file change is the `__version__` string in `__init__.py`. +The comparison checks that replacement exactly, as well as the wheel and +source-distribution inventories. Distribution metadata and documentation +reflect the new version. + +Scientific conclusions are unchanged: baseline TLS remains the default, +experimental TLS remains opt-in after its failed aggregate exactness gate, +and all five unavailable timing panels stay unavailable. BLS execution rates +do not gain numerical qualification. No new benchmark or GPU rental is +required for this version and documentation preparation. + +The local delivery directory is +`/Users/johnhoffman/Documents/cuvarbase-release-prepared-20260927/`. +It contains `dist/`, artifact checksums, build and verification logs, a Git +bundle, the prepared GitHub release text and a publication runbook. The +committed source and that delivery are backed up in the private R2 bucket; +the local completion receipt records the exact object prefix and read-back. +That directory preserves the original local preparation snapshot. The first +source push, pull request, tag and CI receipts are recorded separately in +`/Users/johnhoffman/Documents/cuvarbase-release-pr-20260928/`. The subsequent +archive and history cleanup, current source refs and current CI receipts are in +`/Users/johnhoffman/Documents/cuvarbase-history-cleanup-20260928/`. + +To inspect the prepared state without publishing: + +```sh +git status --short +git show --no-patch v1.0.1 +git diff v1.0.1 -- cuvarbase pyproject.toml README.md CHANGELOG.rst +``` + +Before publication, review the pull request into `master` and its CI checks. +The history-cleanup delivery directory contains the current `PUBLISH.md`; it +supersedes the earlier runbooks' source identities and branch/tag push steps. +When publication is +authorized, verify the recorded commit, artifact checksums and current remote +state before creating a GitHub release and uploading the two distributions. +Neither publication nor PR creation moves the existing `v1.0.0` tag. diff --git a/docs/STUDY_STORAGE.md b/docs/STUDY_STORAGE.md new file mode 100644 index 00000000..f8e7418e --- /dev/null +++ b/docs/STUDY_STORAGE.md @@ -0,0 +1,70 @@ +# Study storage + +## September 28 repository cleanup + +The benchmark and release checks are complete, with failed scientific +qualifications retained. Bulk evidence is now kept in private R2 archives; +Git retains reports, selected figures, small summaries and checksum inventories. +See [benchmark archive access and restoration](BENCHMARK_ARCHIVES.md). +The sections below preserve the earlier storage decisions and their dates. + +## September 24 storage pause + +The first cloud archive transfer is complete and verified; local archive copies are still retained. The benchmark follow-up remains paused for the storage decision. The user selected an existing Cloudflare R2 `cuvarbase` bucket, whose public endpoints are disabled. Its new A40 rental was terminated after setup, before any benchmark searches; provider absence and supervisor exit were verified. Setup evidence was downloaded and all member hashes checked. Estimated compute was $0.086, with a separate $0.50 storage reserve retained in the conservative ledger. The [follow-up checkpoint](/Users/johnhoffman/Documents/cuvarbase-tls-throughput-20260924/PROGRESS.json) records how to resume. + +The data volume had about **25 GiB free** on September 24. Related cuvarbase workspaces occupied about **40 GiB** in allocated file blocks. The broader disk review is recorded in the local migration plan. These are filesystem usage measurements, not promises of space reclaimed: APFS sharing and snapshots can affect that result. + +The first cloud transfer contains the existing **489 compressed archives (12.974 GB)** plus **1,537 restore-kit files (0.129 GB)** and two inventory files: **2,028 objects, 13.104 GB in total**. Every remote object passed full SHA256 read-back. All 489 archives decoded directly from R2 to their complete original tar lengths and hashes. Eight NPZ samples were recovered with valid ZIP CRCs, array loading, modes and modification times; one also exercised a hardlink pair. A separate archive was restored using the preserved helper, with mode, mtime, uid/gid and xattrs verified. These tests used scratch paths; the complete historical NPZ restoration was not run. + +The [completion receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/storage-r2-archive-20260924/summary.json") records the result and evidence hashes. A 31-file recovery and receipt bundle was also uploaded and verified under `archive-20260924/_transfer-receipts/first-batch-v1/`. The [migration plan](/Users/johnhoffman/Documents/cuvarbase-storage-plan-20260924/PLAN.md) and [restore instructions](/Users/johnhoffman/Documents/cuvarbase-storage-plan-20260924/R2_RESTORE.md) describe the remaining local storage decision. No local study data was removed. The proposed removal list contains about **12.1 GiB** of archive file blocks; it does not include every file in the old study workspaces. + +## Completed local reclamation + +On September 12, 2026, storage reclamation for the inactive September 8 and 9 studies completed in two stages. First, removing verified archive-backed extracted NPZ copies reclaimed **26.148 GB of unique file data** (26,147,639,730 bytes). Then exact-byte compression of all **489 retained tar archives** reduced their 26.448 GB of raw bytes to **12.974 GB**, saving another **13.474 GB** of file bytes. Together the two stages reduced the retained file footprint by **39.622 GB** (39,621,672,056 bytes). The independent postcheck passed for all 489 compressed files and their original-matching decode receipts. No cloud storage was purchased or created. + +| Completed operation | Unique file bytes removed or saved | +| --- | ---: | +| September 9: 5,060 extracted NPZ files | 7.846 GB | +| September 8: 11,701 result files, each with two hardlink names | 18.183 GB | +| September 8: nine extracted input files | 0.119 GB | +| September 9: exact compression of 480 retained tar archives | 2.608 GB | +| September 8: exact compression of nine retained tar archives | 10.866 GB | + +Each removed NPZ matched a complete member in an original archive whose SHA256 matched the retained transfer evidence. Both hardlink names were accounted for before removing a group. Removing only one name would have freed no file data. The original tars were unchanged during this first stage. + +The second stage retained a sibling `.tar.zst` for each original tar. Before removing an original, the migration verified the complete compressed stream, independently decoded it to the original SHA256 and byte length, rehashed the original, and rechecked its recorded metadata. It preserved exact raw tar bytes, including padding and retained prefix/tail bytes; it did not reconstruct archives from their members. Durable receipts and removal intents preceded each unlink. Archive compression preserves the existing NPZ restoration plans and member offsets. The actual 489-file collection shrank by **50.95%**: September 9 archives by 32.19% and September 8 archives by 59.23%. The migration took 84.81 seconds; its largest sampled parent-plus-codec RSS was 236 MB. This is sampled resource evidence, not an instantaneous OS-enforced memory bound. + +The first stage's execution windows recorded a combined 26.15 GB increase in free space. A later, separate increase of about 40 GB was unattributed and is excluded. The new compression figure is the difference between verified original and compressed file byte lengths, rather than an attribution of all concurrent filesystem changes. APFS sharing, snapshots and unrelated writes can affect observed free space. The [NPZ reclamation receipts](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/storage-reclamation/summary.json") and [archive compression receipts](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/storage-archive-compression/summary.json") contain exact counts, hashes and original evidence locations. + +## Restoring removed data + +**Restore the original tar files first** using the shared [archive recovery kit](/Users/johnhoffman/Documents/CUVARBASE_ARCHIVE_RESTORE_20260912/RESTORE_AFTER_COMPLETION.md) and its [recovery notes](/Users/johnhoffman/Documents/CUVARBASE_ARCHIVE_RESTORE_20260912/RECOVERY_NOTES.md). It contains the pinned helper, plan, metadata, proof backups and transaction receipts. Automated restoration requires the pinned installed Zstandard 1.5.7 executable. If that environment later changes, a compatible generic decoder can recover the raw tar bytes, subject to complete original SHA/length verification and a separately reviewed metadata/publication procedure. + +After all tars required by an NPZ plan exist at their original paths, use the unchanged NPZ kits beside the old studies: + +- [September 9 NPZ instructions](/Users/johnhoffman/Documents/cuvarbase-tls-study-20260909/STORAGE_RESTORE_20260912/README.md) +- [September 8 NPZ instructions](/Users/johnhoffman/Documents/cuvarbase-work-archive-20260908/STORAGE_RESTORE_20260912/README.md) + +Keep the shared archive kit, compressed files and original NPZ kits together as one recoverable collection. The NPZ kits retain their exact tools, plans, metadata, execution journals and verification manifests. Their original 22 single-link and 31 hardlink tests passed, with both suites independently replayed. The archive helper added 36 synthetic tests, including actual exact tar restoration followed by both unchanged NPZ helpers, array verification, hardlink topology, xattrs, corruption, interrupted operations and destination conflicts. The actual old study arrays were not restored after cleanup, and the original tars were not materialized after compression. The independent postcheck rehashed compressed files and validated the complete decode receipts; it did not add a new decode. + +Replaying a restore requires the original absolute target/archive layout. The archive helper currently materializes its complete 489-entry plan, requiring at least 26.448 GB plus margin in addition to retained compressed files. Restoring the NPZ data then requires another 26.148 GB of unique file data. A partially completed migration or restore remains a partial result; follow its receipts before continuing. The September 8 hardlink transaction must run **outside the archived study, on the same filesystem**; its runbook explains staging the kit there before starting. Ordinary tar extraction elsewhere is distinct from restoring the recorded path and hardlink layout. Mode, modification time, ownership and xattrs are preserved; inode numbers and filesystem creation/change times are not reproduced. + +## Long-term storage choice + +**Cloudflare R2 is the selected destination for this study.** Backblaze B2 remains a lower storage-cost alternative. Keep active inputs and small reports locally; upload completed archives and their restore kits to private object storage. Current official list prices, checked September 24, 2026: + +| Service | Storage for 100 GB/month, before free allowances | Best fit | +| --- | ---: | --- | +| [Backblaze B2](https://www.backblaze.com/cloud-storage/pricing) | About $0.70 | Infrequently retrieved archives; free egress up to three times average monthly stored data, then $0.01/GB | +| [Cloudflare R2 Standard](https://developers.cloudflare.com/r2/pricing/) | $1.50 | Frequent retrieval; internet egress is free | +| [RunPod network volume](https://docs.runpod.io/storage/network-volumes) | $7.00 | Files needed directly by GPU jobs; charged on allocated capacity and persists after compute ends | + +B2 and R2 each offer an initial 10 GB storage allowance. B2 currently lists $6.95/TB/month, with Class A/B/C API calls free; R2 includes monthly request allowances and charges for excess requests. Any applicable taxes and transfer overages are additional. At these rates, 500 GB of B2 storage is about $3.41/month after the free allowance. The selected R2 bucket now holds the verified archive copy. Storage charges are tracked separately from GPU usage. + +Before removing the **last locally recoverable copy** of an archive, upload that retained representation and its restore kit, verify a full read-back against its recorded SHA256, and exercise a restore from the destination. For a compressed representation, also verify that a complete decode reproduces the original tar SHA256 and byte length. Preserve a local inventory and receipt; a multipart ETag alone is not an archive SHA256. A separately verified local lossless compressed copy, as used above, remains a local recoverable copy and does not require a cloud upload merely to remove its redundant uncompressed representation. + +## Avoiding future growth + +NPZ arrays are already compressed, so small within-file gzip samples are a poor estimate of whole-archive savings. Earlier 4 MiB samples gained only about 0.4–4.3%, which did not test repeated compressed streams across files. A complete 1.644 GB original archive saved **13.32%** with default-window Zstandard and **59.31%** with `-3 --long=27 --single-thread`; both full decoded streams matched the original SHA and length. The later 489-file migration provides the actual collection-wide total reported above. Keep one verified archival representation of each finished artifact and extract only what the next analysis needs. + +The September 10 TLS survey also uses a verified numerical input bank: roughly 14.6 GB of repeated raw NPZ inputs reduce to about 1.04 GB of unique arrays. This preserves the arrays and their identities, not the original ZIP-container bytes. Keep original manifests and verification receipts; regenerated NPZ hashes must not replace historical hashes. Its [final collection](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/primary-archive-receipt.json") completed on September 12, preserving the bank and original verification evidence in a 2.512 GB archive; the original GPU rental was then terminated. diff --git a/docs/TLS_COST_ANALYSIS.md b/docs/TLS_COST_ANALYSIS.md new file mode 100644 index 00000000..4cba0edd --- /dev/null +++ b/docs/TLS_COST_ANALYSIS.md @@ -0,0 +1,37 @@ +# Transit-search compute cost + +The [benchmark report](TRANSIT_BENCHMARKS.md) records the workloads and sensitivity checks. The BLS campaign rented an A40 and its included CPU allocation for **$0.49/hour**. The new TLS campaign rented an RTX A6000 and its included CPU allocation for **$0.53/hour**; cuvarbase and GTLS use that same machine. Compute cost follows measured elapsed time; these measurements do not price a complete survey pipeline. + +## BLS: measured batch throughput + +| Observing pattern | v1 BLS / million | PyPI 0.2.5 BLS / million | CPU BLS hourly break-even | +|---|---:|---:|---:| +| TESS 200 s | $0.21 | $0.90 | $0.0111/h | +| Separated TESS sectors | $2.14 | $5.86 | $0.0086/h | +| ZTF g/r | $7.50 | $13.63 | $0.0261/h | + +These are linear projections of the September 8 median 16-source throughput, not measured million-source jobs. BLS's executable source and kernels remain unchanged apart from a documentation link. The separated-TESS PyPI comparison supports the stated recovery and false-positive criterion; the other timing ratios retain their sensitivity qualifications. + +CPU break-even price is `$0.49 / (CPU time / v1 GPU time)`, for a CPU service delivering the measured throughput. No standalone CPU rental was benchmarked. The BLS measurement used a 7.65-CPU-equivalent allocation on a Xeon Gold 6342 host; the host's total logical CPU count was not the allocation. + +## TLS: measured batch throughput + +| Observing pattern | v1 TLS / million | GTLS / million | Cost ratio | GTLS workers | +| --- | ---: | ---: | ---: | ---: | +| TESS: dense sector | $23.69 | $47.00 | 1.98× | 4 | +| TESS: separated sectors | $225.85 | $542.44 | 2.40× | 2 | +| ZTF g/r | $458.80 | $669.17 | 1.46× | 4 | + +The whole GPU/CPU rental is charged once, regardless of worker count. TLS cost is `median batch seconds / 16 × $0.53 / 3600 × 1,000,000`. The table compares the standard cuvarbase batch with the fastest eligible tested GTLS pool. It projects three repetitions of one fixed 16-source cohort, not a measured million-source survey or population-wide cost distribution. + +The TLS machine has a 7.65-CPU quota on a Xeon Gold 6342 host; each worker uses one numerical-library thread; the GTLS batch comparison tests one, two and four workers. The standard TLS engine evaluates individual observations with full refinement. The older phase-binned study and synthetic HATPI pilot used a different engine and cannot price this default. The [current TLS evidence](../benchmarks/results/tls_reference_2026-09-10/README.md) records numerical agreement and the separate search/diagnostic timing boundaries. + +The original timing campaign failed when four-worker GTLS ran out of memory in the separated-TESS warmup. These projections use the separately audited completed configurations; the two-worker pool was the fastest eligible completed setting for that cadence. [Timing assessment](BENCHMARK_ARCHIVES.md#tls_reference_2026-09-10 "Archived file: benchmarks/results/tls_reference_2026-09-10/reporting_acceptance.json"). + +## Included work and limits + +The API timings include host work, GPU transfers and completed search results from prepared arrays. TLS includes construction, input validation, template preparation, full candidate/harmonic refinement and final fitting. Each API's normal output work is included; GTLS additionally computes SNR and pink-noise diagnostics. Common-search timings are reported separately. + +The calculations exclude input loading, preprocessing, imports and CUDA context startup, explicit period-grid construction, idle time, vetting and storage. The recorded 80 GB TLS container adds about $0.0111/hour while running under the ledger's 720-hour monthly conversion; it is separate from the recorded compute rate. Real workloads also vary in source length, stellar parameters, noise and search domain. Fresh-grid BLS timings appear separately in the benchmark report. + +[BLS evidence](../benchmarks/results/transit_2026-09-08/README.md) · [Current TLS evidence](../benchmarks/results/tls_reference_2026-09-10/README.md) · [Retired-claim provenance](BENCHMARK_PROVENANCE.md). diff --git a/docs/TLS_EXECUTION.md b/docs/TLS_EXECUTION.md new file mode 100644 index 00000000..4f184598 --- /dev/null +++ b/docs/TLS_EXECUTION.md @@ -0,0 +1,49 @@ +# TLS execution modes + +The default `method='reference'` uses the full observation-level TLS search. +Its `execution='baseline'` implementation preserves the backend, host math and +search kernel from commit `6ced75d`. + +```python +from cuvarbase.tls import tls_search_gpu, tls_search_batch + +result = tls_search_gpu(t, flux, error, periods=periods) +experimental = tls_search_gpu(t, flux, error, periods=periods, + execution='experimental') +survey = tls_search_batch(lightcurves, periods=periods, + execution='experimental', return_arrays=False) +``` + +The experimental mode opts into the survey optimization bundle: smaller host +allocations for duration groups, vectorized stable candidate ranking, packed +winner transfers, omitted unused refinement preparation, and guarded batched +short-row scans. It retains the observation-level trial policy. Approximate +binned TLS remains a separate explicit `method='binned'` choice. + +The precursor optimization bundle failed the frozen zero-mismatch numerical +qualification: 5,111 of 5,120 original held-out comparisons were exact, with +nine chi2/SDE differences. Selected periods, recovery/alias flags and decisions +at both frozen thresholds agreed on those original comparisons. Original +development qualification was 79 of 80. These results do not establish universal +numerical equivalence or sensitivity preservation; the observed differences +have not been isolated to a particular optimization. Disabling only the +short-row kernel does not cover the known long-row differences. + +The [release wiring checks](../benchmarks/results/tls_survey_2026-09-10/release-validation/README.md) passed all 24 paired numerical comparisons on eleven fixed development inputs, plus all 86 device tests. Historical survey +receipts describe the preserved precursor sources, not this default-restoring +release. Baseline execution also does not promise bitwise determinism: native +long-row floating-point scans have documented and observed repeatability +limitations. No acceptance tolerance is relaxed by labeling an execution mode. + +Every reference result records `search_configuration.execution` and +`search_configuration.experimental_execution`, including null results. Scalar +convenience calls forward the choice, and batches retain it for every observed +curve and FAP permutation. Unknown values and experimental selection with +another method are rejected. The batch permutation FAP remains a white-noise +null; it does not calibrate arbitrary correlated survey noise. + +The two backends own separate compiled-module and thread-local prefix caches. +Default execution never dispatches or compiles the experimental short-row +kernel. Shared CUDA allocator/driver history can still affect subsequent work; +switching back to baseline does not reset that history. Use fresh processes and +recorded sources/configurations for numerical comparisons. diff --git a/docs/TLS_LITERATURE.md b/docs/TLS_LITERATURE.md new file mode 100644 index 00000000..c4c0e590 --- /dev/null +++ b/docs/TLS_LITERATURE.md @@ -0,0 +1,106 @@ +# What the TLS literature establishes + +Primary-source audit, 2026-09-10. This note separates published evidence from the +new cuvarbase experiment; it does not replace the latter's sealed protocol. + +## The original result is a recovery advantage + +Hippke & Heller (2019), §3.1/Fig. 6, report **93.1% TLS versus 75.7% BLS recovery +at a 1% false-positive rate**: 17.4 percentage points, or approximately 23% +relative improvement. Their experiment used 10,000 signal curves and 10,000 +noise curves: three-year, 30-minute sampling, 110 ppm white noise, and three +Earth-sized transits around solar hosts with impact parameters in [0,1] and +Kepler-band quadratic limb darkening. Both searches used the same optimized +period grid. This is substantial reported detection evidence, not a 17% SNR +measurement. [Original paper, §3.1 and Fig. 6](https://arxiv.org/html/1901.02015#S3.SS1) + +Reproduction detail remains important: §3.1 describes a positive as the global +highest peak lying within 1% of the injected period; Fig. 6 describes the signal +histogram using the highest SDE *within* that window. Preserve the published +claim while recording that this wording needs experiment-code resolution. +The Astropy 3.1 implementation and 66 durations specified in §3.4 describe a +separate timing comparison; do not silently assign them to §3.1. Nor does the +common period grid justify calling the original BLS poorly sampled. +[Original paper, §§3.1 and 3.4](https://arxiv.org/html/1901.02015) + +## What the linked code resolves, and what it does not + +The paper links the author's `hippke/tls` repository. This audit inspected its +2019-02-18 snapshot, `160020aa31f4d1364cc73e8031700ef3394bf83b`, and current tree +`1440ca760a785bf06a56619f705539c3a7377dd7`. The tree inspection did not locate +the 10,000-injection comparison driver or its paired output table; this is a +bounded inspection, not proof that no archived script exists elsewhere. +[Historical repository tree](https://github.com/hippke/tls/tree/160020aa31f4d1364cc73e8031700ef3394bf83b) + +The historical comparison notebook instead demonstrates K2-110: TLS uses +`model.power()`, while BLS uses 20 durations from 0.05 to 0.2 days and +`autopower(..., frequency_factor=10)`. It is therefore **not a reproduction of +the paper's §3.1 common-grid experiment**, and its settings cannot establish +that experiment's BLS tuning. The historical synthetic test is also different: +one fixed-seed, two-hour-cadence, 5 ppm example with a restricted 360–370-day +search. The FAP unit test checks a lookup value at SDE=7; it does not regenerate +the null population. [Comparison notebook](https://github.com/hippke/tls/blob/160020aa31f4d1364cc73e8031700ef3394bf83b/tutorials/06%20Comparison%20between%20TLS%20and%20BLS.ipynb), +[synthetic test](https://github.com/hippke/tls/blob/160020aa31f4d1364cc73e8031700ef3394bf83b/transitleastsquares/tests/test_synthetic.py), +[FAP test](https://github.com/hippke/tls/blob/160020aa31f4d1364cc73e8031700ef3394bf83b/transitleastsquares/tests/test_FAP.py) + +## Why a few percent in SNR is a different claim + +Hord et al. (2021; Colón is second author) repeat the original recovery result +and note that realistic shapes can give as little as approximately 3% sensitivity +improvement when BLS is sufficiently sampled, citing Jenkins, Doyle & Cullers +(1996). That remark is not a new controlled TLS-versus-BLS recovery trial. +Their TESS hot-Jupiter companion search uses both default and grazing TLS +templates and finds no new validated companions. They describe the TLS, SPOC, +and QLP sensitivities as comparable indications, while explicitly noting the +absence of a direct TLS–SPOC sensitivity comparison. Their SDE>7 search threshold +is inherited from the original Kepler-like simulation, not independently +calibrated on their TESS noise population. [Hord et al., introduction, §III.1, +and §VI.1](https://arxiv.org/html/2109.08790) + +For this study, three quantities must remain distinct: + +| Quantity | What it measures | What it cannot establish alone | +| --- | --- | --- | +| Common expected matched-filter SNR | Response of a specified template to a noiseless injected signal under the same timestamps, weights, and nuisance projection | Blind period recovery or the null maximum over a template bank | +| A package's SDE | Its normalized periodogram peak, with package-specific baseline and normalization | A directly comparable SNR or a universal false-positive probability | +| Blind recovery at independently calibrated common FPR | Probability of exceeding a separately estimated null threshold and selecting the correct period | Equivalence outside the tested population | + +As an analytic diagnostic, with inverse-variance inner product and the same +weighted-mean subtraction, a searched template `h` has expected response +`rho(h) = _w / sqrt(_w)`. Relative to the perfectly matched signal, +`rho(h)/rho(s)` is the weighted correlation between signal and template. A +well-placed box can be close to a transit under this metric, especially when +ingress contributes little weight. Finite exposure and sparse sampling change +that correlation. Kipping (2023), §§3.2–3.3, derives separate optimal-box and +matched-trapezoid SNR expressions and their convergence in the box limit. +[Kipping, “SNR of a transit”](https://academic.oup.com/mnras/article/523/1/1182/7179431) + +Our interpretation: a percentage SNR change and a percentage-point recovery +change have no fixed conversion. Threshold crossing is nonlinear, and blind +recovery also depends on aliases, grid placement, noise maxima, template-bank +size, and the ranking statistic. This explains why the measurements answer +different questions; it does **not** quantitatively explain away or independently +reproduce the original 17.4-point result. + +## Real detections also depend on preparation + +TLS Survey I is a useful controlled example of that dependence. For K2-32e, +the authors report SDE 13.2 (TLS) versus 8.9 (BLS) in K2SFF data; in EVEREST +data both recover it, at 26.1 and 21.3 respectively. They also test a hyperfine +nonlinear BLS period grid with over 100,000 trials and restricted durations: +the troublesome short-period alias disappears, while the K2SFF signal remains +at SDE 8.9. These are real-data demonstrations, not a population comparison at +independently calibrated equal FPR. Dividing those SDE values does not yield +a matched-filter SNR gain. [Heller, Rodenbeck & Hippke (2019), §§3.3–4.2](https://arxiv.org/html/1904.00651) + +## Consequence for cuvarbase + +The original 93.1% versus 75.7% result remains substantial published canonical-TLS recovery evidence. It is not a 17.4% expected-SNR measurement and is not independently reproduced by the present experiment. A small ideal-box/shape response difference does not refute it; a percentage SNR change has no fixed conversion to percentage-point blind recovery. + +The completed cuvarbase science report instead measures its pinned GTLS-compatible floating-point search against development-selected native GPU BLS on identical arrays and full grids. Method-specific cuts use a paired calibration bank independent of development and test populations. Common 5%/1% calibrated targets have uncertain realized FPRs, as the independent test-null intervals show. [Per-regime recovery and uncertainty](TRANSIT_BENCHMARKS.md). + +At both operating points, the predeclared simultaneous intervals support large positive TLS-minus-BLS recovery differences in four TESS regimes and a severe negative difference for grazing/smeared TESS. Known-period native-family/ideal-box median white advantages of only +0.166% to +1.359% coexist with those blind outcomes. Those family ceilings use common signal/weight/nuisance conventions; package SDE ratios, actual native admission/ranking and an ideal box are different quantities. Sparse/high-impact and gapped signals have large negative response tails. Neither the paper nor these finite tests justify universal dominance or a default 1–2% SNR-loss allowance. + +The operative tolerance remained zero before held-out evaluation. The optimized-versus-baseline implementation gate failed on nine of 5,120 original pairs, although selected periods and frozen-threshold decisions agreed. Positive held-out advantages do not retroactively permit approximation losses or erase numerical mismatches. The release consequently keeps the original observation-level baseline default and exposes the complete optimization bundle only through an experimental selector; its separate [release wiring validation](../benchmarks/results/tls_survey_2026-09-10/release-validation/README.md) passed without changing the original failed scientific qualification. [Numerical contract](TLS_NUMERICS.md) · [GTLS versus canonical CPU TLS](GTLS_COMPARISON.md). + +[Collected recovery report](../benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md) · [report provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json"). This editorial update now uses the byte-verified completed science collection; it makes no new literature replication or statistical analysis claim. The paper audit and its bounded code-search limitations above are retained unchanged. diff --git a/docs/TLS_NUMERICS.md b/docs/TLS_NUMERICS.md new file mode 100644 index 00000000..cd60c82d --- /dev/null +++ b/docs/TLS_NUMERICS.md @@ -0,0 +1,31 @@ +# TLS numerical accuracy + +The observation-level TLS contract follows pinned GTLS full mode: individual observations, a broad native duration domain, coarse sample-window trials and candidate/harmonic refinement. It does not phase-bin observations or screen out thin signals before a fallback. This is a numerical compatibility target, not a guarantee of detecting every physical transit or equivalence to canonical CPU TLS. + +## Default and experimental execution + +The release retains `method='reference'` and restores the original `6ced75d` backend, math and kernel bytes as `execution='baseline'`, the default. `execution='experimental'` explicitly selects the entire survey optimization bundle. Scalar, convenience, batch and permutation-FAP calls retain that selection; the experimental selector is rejected on the older search methods. [Release validation](../benchmarks/results/tls_survey_2026-09-10/release-validation/README.md) passed all 24 paired numerical comparisons and 86 device tests on eleven fixed development inputs. This checks release wiring; it does not requalify experimental sensitivity. + +This separation follows the frozen numerical result: **5,111/5,120 original held-out implementation comparisons were exact; nine failed the zero-mismatch gate.** All compared selected periods and both frozen-threshold decisions agreed. Those observed decisions do not turn the nine spectrum/SDE differences into passing results. Original failures and diagnostic repeats remain retained; their causes are not established by these comparisons. + +Development evidence allowed no sensitivity expenditure. The prospective rule capped any possible allowance at 5% of a demonstrated positive advantage, with absolute caps of 0.1% fractional expected SNR and 0.1 percentage point recovery/FPR. Uncertain or nonpositive subgroup advantage gave zero allowance. The **operative frozen allowances were zero**; held-out gains do not retroactively change them. + +## Preserved search choices and limits + +Both executions retain the native template cache, integer sample widths, coarse epoch policy, depth estimate and distinct coarse/full residual arithmetic. Finite, unmasked trials are ranked before refinement, correcting the disclosed GTLS host-mask defect. Logical duration groups are independent of physical workspaces; explicit per-period duration bounds are retained during refinement. Times shift in float64 to a positive origin without dropping legitimate nonpositive timestamps, and returned epochs shift back. + +The native depth gate tests an **unweighted window mean strictly above 10 ppm** at the default `transit_depth_min=1e-5`, before template overshoot scaling. It is neither a maximum physical depth threshold nor an unrestricted weighted fitted amplitude. The GPU mean uses float32 cumulative raw flux near unity; a CPU float64 deficit-prefix mean does not reproduce that gate's rounding. Exposure smearing, irregular sample-index templates and native admission/ranking are limitations of the retained search. The eight-case development diagnostic suggests possible mechanisms; it does not trace held-out GPU gates or establish why a particular signal was missed. + +The new population comparison found large TLS gains in four TESS regimes and a severe grazing/smearing deficit: at the 5% calibrated target, TLS recovered 1/256 grazing cases versus BLS 109/256. All 512 grazing injection/test-null implementation comparisons matched the baseline under the candidate's frozen cuts. That finite result locates the observed deficit in the retained baseline too; it does not independently recalibrate the baseline or establish a causal mechanism. [Per-regime recovery and uncertainty](TRANSIT_BENCHMARKS.md). + +## Where experimental speed comes from + +The opt-in bundle removes repeated host ranking work, builds duration membership one logical group at a time, packs winner transfers without converting their values and skips an unused error prefix during full refinement. Its guarded short-row path batches the installed CUB scan agent for up to 1,920 float32 elements; longer or unsupported rows use the native graphs. The build guard and bitwise canary remain required. These changes preserve the intended trial policy, but the complete bundle did not pass the frozen numerical gate. + +The baseline already contains native row-scan CUDA graphs and bounded workspaces. Restoring it is not a new speedup. Neither baseline nor experimental execution promises universal float32 scan repeatability, and switching modes cannot undo allocator/driver history. An ordinary matrix-axis cumulative sum is not substituted for the baseline row scans. [Numerical and package differences](GTLS_COMPARISON.md). + +`method='binned'` remains a separately explicit approximation. Its narrow-transit losses are retained in the [September 9 audit](../benchmarks/results/tls_accuracy_2026-09-09/README.md); refining a coarse winner cannot recover every discarded candidate. + +[Collected recovery report](../benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md) · [original exactness receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json") · [report provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json"). The collected campaign has seven eligible engine/workload rates. The experimental candidate reaches a median 0.837289 light curves/s on ZTF solar versus baseline 0.452633, a **1.850×** ratio; long-gap TESS is 0.775998 versus 0.770469, **1.007×**. Both timing-cohort gates and the unchanged paired spectrum check passed in those two regimes. Baseline dense TESS and all varied-size panels remain excluded, so they supply no baseline/candidate ratio. These timings do not override the failed 5,111/5,120 aggregate gate. [Final rates, ranges and exclusions](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md) · [figure and value provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json"). + +Cold preparation, amortized costs and sampled GPU/host memory are collected in the [timing note](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md). The [original rental ledger](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/original-rental-closed-ledger.json") is closed at $71.6940 cumulative elapsed estimate ($71.8990 with its full storage reserve). New release validation is ongoing separately; no final combined validation cost or completed release-routing claim is made. diff --git a/docs/TRANSIT_BENCHMARKS.md b/docs/TRANSIT_BENCHMARKS.md new file mode 100644 index 00000000..ac32fb93 --- /dev/null +++ b/docs/TRANSIT_BENCHMARKS.md @@ -0,0 +1,149 @@ +# Transit-search recovery and throughput + +The completed science report finds a TLS detection advantage in four TESS populations, a severe grazing/smearing vulnerability and a failed aggregate implementation-exactness gate. This is the native GTLS-compatible observation-level search, not a reproduction of canonical CPU TLS. The release retains baseline execution by default and requires an experimental selector for the measured optimization bundle. [Numerical contract](TLS_NUMERICS.md) · [GTLS/CPU differences](GTLS_COMPARISON.md) · [Published evidence](TLS_LITERATURE.md). + +[Collected recovery report](../benchmarks/results/tls_survey_2026-09-10/final-report/RECOVERY.md) · [report provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-report/provenance.json") · [held-out expected-SNR receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/heldout-snr-final.json") · [original exactness receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-science/exactness-final.json"). The completed collection preserves the reviewed detection, expected-response and original mismatch receipts unchanged. **Sustained timing, release validation, collection and rental teardown are complete; failed timing panels remain unavailable.** Earlier September 8–10 speed figures retain their historical source/workload scopes and do not supply missing bars or denominators for the new sustained study. + +The [September 24 follow-up](../benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md) is complete, with 11 of 16 reportable timing panels. Native BLS execution retains its numerical discrepancies; the TLS/GTLS panels use their original strict gates. Five panels remain unavailable after repeatability or memory failures. The expanded GPU suite passed 2,091 tests with one expected failure and zero skips. A separate gate initially failed because its launcher could not import the package; the [September 27 installed-wheel check](../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) passed all 14 additional checks and six dependency preflights. Both rentals were terminated after verified collection, and their evidence passed R2 checksum read-back. The original study and its failed qualifications remain unchanged. + +## Blind recovery by regime + +Each regime contains 256 injections, 256 independent test nulls and 512 independently generated calibration nulls. TLS and BLS share the same paired input banks and full period arrays. BLS duration/epoch settings and its ranking statistic were selected on development data before the seal; the comparison uses the strongest development-selected control, not an ideal-box oracle. No threshold or detector setting was retuned on held-out outcomes. + +A detection requires strict threshold exceedance and a selected-period drift across the baseline no larger than half the physical contact duration. Aliases are separately descriptive. Unsampled/few-event signals remain in the denominator; all planned TLS/BLS injection and test-null executions completed validly. + +Both tables show the original rates and simultaneous paired TLS-minus-BLS intervals in percentage points. The predeclared Bonferroni family covers 40 recovery/FPR contrasts across ten regimes and two operating points (at least 95% simultaneous coverage). These are not pooled rates or newly calculated intervals. + +### 5% calibrated target + +| Regime | TLS recovery | BLS recovery | TLS − BLS, pp [simultaneous interval] | +| --- | ---: | ---: | ---: | +| TESS solar | 73/256 (28.52%) | 40/256 (15.62%) | +12.89 [+1.37, +23.50] | +| TESS high impact | 128/256 (50.00%) | 53/256 (20.70%) | +29.30 [+17.05, +39.77] | +| TESS eccentric | 83/256 (32.42%) | 28/256 (10.94%) | +21.48 [+9.34, +32.15] | +| TESS M dwarf | 154/256 (60.16%) | 60/256 (23.44%) | +36.72 [+23.67, +47.52] | +| ZTF solar | 183/256 (71.48%) | 197/256 (76.95%) | -5.47 [-13.55, +3.03] | +| ZTF high impact | 147/256 (57.42%) | 159/256 (62.11%) | -4.69 [-15.71, +6.67] | +| ZTF M dwarf | 103/256 (40.23%) | 124/256 (48.44%) | -8.20 [-20.98, +5.14] | +| TESS long gap | 40/256 (15.62%) | 59/256 (23.05%) | -7.42 [-16.50, +2.21] | +| TESS grazing/smeared | 1/256 (0.39%) | 109/256 (42.58%) | -42.19 [-53.06, -28.72] | +| Synthetic HATpi short | 3/256 (1.17%) | 0/256 (0.00%) | +1.17 [-3.05, +5.55] | + +### 1% calibrated target + +| Regime | TLS recovery | BLS recovery | TLS − BLS, pp [simultaneous interval] | +| --- | ---: | ---: | ---: | +| TESS solar | 53/256 (20.70%) | 19/256 (7.42%) | +13.28 [+1.67, +23.95] | +| TESS high impact | 112/256 (43.75%) | 33/256 (12.89%) | +30.86 [+18.42, +41.43] | +| TESS eccentric | 73/256 (28.52%) | 6/256 (2.34%) | +26.17 [+13.30, +37.26] | +| TESS M dwarf | 144/256 (56.25%) | 37/256 (14.45%) | +41.80 [+28.35, +52.67] | +| ZTF solar | 176/256 (68.75%) | 192/256 (75.00%) | -6.25 [-14.10, +2.07] | +| ZTF high impact | 131/256 (51.17%) | 156/256 (60.94%) | -9.77 [-20.76, +1.92] | +| ZTF M dwarf | 98/256 (38.28%) | 122/256 (47.66%) | -9.38 [-22.35, +4.24] | +| TESS long gap | 23/256 (8.98%) | 47/256 (18.36%) | -9.38 [-18.52, +0.46] | +| TESS grazing/smeared | 0/256 (0.00%) | 97/256 (37.89%) | -37.89 [-48.72, -24.74] | +| Synthetic HATpi short | 0/256 (0.00%) | 0/256 (0.00%) | +0.00 [-3.10, +3.10] | + +Four TESS gains and the grazing/smearing deficit exclude zero at both targets in those simultaneous intervals. ZTF and long-gap TESS favor BLS in point estimates, but their simultaneous intervals cross zero. More favorable marginal contrasts remain in the full report and do not replace this simultaneous interpretation. Both methods recover very few synthetic-HATpi signals. At assigned target SNR 12, primary grazing recovery is still 0/64 versus 36/64; assigned levels are not realized/package SNR values. + +## Calibrated targets and realized false positives + +The 5% and 1% labels are common calibrated target FPRs, not proven equal realized rates. Each method gets a separate threshold from the same paired calibration bank, independently of development and the test banks. Null noise-scale labels are IID draws from the equal four-level mixture; injection labels are balanced for subgroup precision. + +With 512 calibration scores, strict exceedance of ascending ranks 488 and 508 gives no-tie marginal bounds 25/513 = 4.8733% and 5/513 = 0.9747%. Ties can only make the strict rule more conservative; this calibration had no additional conservatism at the selected cuts. The guarantee is marginal over calibration draws under exchangeability, not a guarantee for the conditional FPR of this particular threshold. + +Observed test FPRs span 1.56–7.81% at the primary target and 0–2.73% at the secondary target. For grazing/smeared cases at 5%, TLS has 11/256 false positives (4.30%, marginal 95% interval 2.16–7.56%) and BLS 12/256 (4.69%, 2.45–8.04%). All paired simultaneous FPR intervals include zero; their width does not prove equal FPRs. Zero false positives in 256 still has a two-sided 95% upper bound near 1.43%. One outcome changes a regime rate by 0.390625 percentage points, so this study cannot establish 0.1-percentage-point noninferiority. + +## Comparable expected signal response + +At the known period, the native cached-template family and an ideal box use the same sampled noiseless signal, inverse-variance weights and fitted constant. White responses are ceilings for the enumerated families under the diagonal-error objective. OU values evaluate those same white-selected filters with the declared correlated-noise covariance; they are not independently OU-optimal maxima. All blind populations include heterogeneous errors and the OU component, so these white columns are not a separate white-noise recovery trial. These ratios are neither package SNR/SDE nor the selected blind BLS output. + +| Regime | Finite / 256 | White median advantage | OU median advantage | +| --- | ---: | ---: | ---: | +| TESS solar | 256/256 | +0.951% | +0.927% | +| TESS high impact | 256/256 | +0.978% | +0.471% | +| TESS eccentric | 256/256 | +0.903% | +0.663% | +| TESS M dwarf | 256/256 | +1.359% | +0.890% | +| ZTF solar | 254/256 | +0.589% | +0.580% | +| ZTF high impact | 256/256 | +0.166% | +0.179% | +| ZTF M dwarf | 256/256 | +0.348% | +0.308% | +| TESS long gap | 255/256 | +0.953% | +0.445% | +| TESS grazing/smeared | 256/256 | +0.936% | +0.708% | +| Synthetic HATpi short | 246/256 | +0.904% | +0.333% | + +Medians are descriptive, not confidence intervals. Median white advantages of +0.166% to +1.359% coexist with large blind-recovery gains in four TESS regimes: the actual detection advantage is not inferred to be only about 1%. Conversely, available family response need not be attained by native admission, fitting, candidate competition or ranking. + +Negative tails matter. Observed minimum white family/box differences reach −51.353% in ZTF high impact, −37.484% in ZTF M dwarfs and −44.885% in long-gap TESS; their OU counterparts are −51.518%, −37.868% and −50.768%. These occur in the primary TLS-missed groups and are observed extrema, not confidence limits or a causal explanation of every miss. All ten regimes contain a negative OU difference. Undefined ratios are excluded only from descriptive ratios, never from recovery denominators. + +Grazing/smearing has a +0.936% median white family/box advantage despite only 1/256 primary detections. That ceiling does not quantify the actually admitted/scored filter or prove a specific native-gate mechanism. Ratios to the ideal box alone do not measure either filter's retained fraction of physical-oracle SNR. The eight-case development float64 window analysis did not reproduce actual GPU prefix/gate decisions; no held-out gate tracing was performed. + +## Exactness, approximation policy and coverage + +The operative approximation allowances were frozen at **zero**. Original baseline/candidate comparisons give **5,111/5,120 exact pairs and nine chi2/SDE mismatches**, with no changed selected period or either frozen-threshold decision. The aggregate zero-mismatch gate failed; repeats never replace failures. The baseline pass reuses candidate cuts and does not independently calibrate the baseline. All 512 grazing implementation pairs matched, so that observed detector deficit also occurs in the retained baseline under those cuts. No baseline-gate causation is established. + +Coverage is finite: fixed observed TESS/ZTF cadences and synthetic HATpi-like cadence, two stellar-density points including small M dwarfs, high-impact/eccentric/grazing configurations, thin ingress, exposure smearing, gaps, aliases, heterogeneous errors, OU noise and few/unsampled events. The grazing regime has 1,800-second exposures throughout. Earth-size planets, fixed limb darkening and eccentric orientation ω=90° limit transport to other systems. Main ZTF injections cover 2–6 days; broader/joint-extreme boundary diagnostics are not additional blind-recovery populations. Rescaled ZTF errors make this a controlled sampling/algorithm experiment, not a predicted Earth-size ZTF survey yield. No universal equivalence or recovery outside represented subgroups is established. + +## Sustained single-GPU throughput + +### September 24–25 follow-up + +![Follow-up throughput with five unavailable panels and BLS execution-only rates](../benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/throughput.png) + +[Full report and observed ranges](../benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REPORT.md) · [exact CSV](../benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/measurements.csv) · [failure review](../benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/REVIEW.md) · [provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/throughput-followup-20260924/review.json"). + +These median rates are successful light curves per second on one A40 allocation at $0.49/hour. Each available panel contains three complete queues, each lasting at least 120 seconds with at least 96 attempts. Inputs, full period grids and numerical sources retain their frozen definitions. + +| Workload | Baseline TLS | Experimental TLS | Public GTLS | BLS execution only | +| --- | ---: | ---: | ---: | ---: | +| TESS solar | unavailable | 8.0722 | 2.4760 | 6.3618 | +| TESS long gap | 0.77039 | 0.77554 | unavailable | 11.4446 | +| ZTF solar | 0.45455 | 0.82349 | 0.12111 | 29.8506 | +| Varied | unavailable | unavailable | unavailable | 10.8464 | + +Seven TLS/GTLS panels passed their strict timing qualifications. Four BLS panels report execution speed under the separately declared contract: all 21,232 measured calls completed without API failures, but 1,654 selected-output discrepancies across queues and diagnostics remain recorded. Those BLS rates confer no numerical qualification. Repeated calls are not independent scientific populations. + +The experimental/baseline median ratios are **1.812×** for ZTF solar and **1.007×** for long-gap TESS, where the paired complete-spectrum timing checks passed. Baseline TESS solar and both TLS varied panels failed repeatability checks. GTLS long-gap ran out of memory; GTLS varied had both repeatability and memory failures. All five remain unavailable. No failed experiment was rerun to replace its outcome, and the original **5,111/5,120** aggregate exactness gate remains failed. + +The benchmark rental and the separate installed-wheel release check are terminated, with checksum-verified local collection and R2 read-back. Their estimated compute costs were $2.8053 and $0.0373. The [current conservative ledger](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/summary.json"), including prior allocations and retained storage reserves, is **$78.1846** within the authorized $100. These are estimates and reserves, not provider invoices. + +### Original September 10–12 allocation + +The original allocation below remains dated evidence. Its settings, rates, exclusions and ledger are separate from the follow-up above. + +![Collected full-API throughput; all missing gates and aggregate exactness withheld remain visible](../benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.png) + +[PDF](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.pdf") · [SVG](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.svg") · [exact CSV](../benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.csv) · [renderer provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json"). The frozen figure label “Optimized” means the opt-in experimental candidate, not the release default. + +| Workload | Engine | Workers / batch | Median light curves/s | Observed repetition range | +| --- | --- | ---: | ---: | ---: | +| TESS solar | Experimental TLS | 4 / 4 | 8.203064 | 8.039095–8.209117 | +| TESS solar | Public GTLS | 2 / 1 | 2.424906 | 2.254147–2.443050 | +| TESS long gap | Baseline TLS | 4 / 8 | 0.770469 | 0.766306–0.773155 | +| TESS long gap | Experimental TLS | 4 / 4 | 0.775998 | 0.774032–0.776135 | +| ZTF solar | Baseline TLS | 4 / 8 | 0.452633 | 0.451925–0.455362 | +| ZTF solar | Experimental TLS | 4 / 4 | 0.837289 | 0.826802–0.838994 | +| ZTF solar | Public GTLS | 2 / 1 | 0.118107 | 0.115367–0.122672 | + +Each rate has three whole-cohort queue repetitions of at least 96 calls and 120 seconds. The shared allocation was one A40, 7.65 CPU cores, 49,999,998,976 bytes of host RAM and $0.49/hour compute. Each backend independently tested workers 1/2/4 at batch 1, then batches 4/8 at the eligible winning worker count. This conditional search does not establish a global tuning optimum. Repetition ranges describe the three observed measurements, not inferential confidence intervals. Ordinary panels repeat 16 fresh null inputs; the varied panel uses 96 distinct deterministically masked null inputs and has no qualifying rate. + +The collected campaign has seven eligible engine/workload rates. The experimental candidate reaches a median 0.837289 light curves/s on ZTF solar versus baseline 0.452633, a **1.850×** ratio; long-gap TESS is 0.775998 versus 0.770469, **1.007×**. Both timing-cohort gates and the unchanged paired spectrum check passed in those two regimes. Baseline dense TESS and all varied-size panels remain excluded, so they supply no baseline/candidate ratio. These timings do not override the failed 5,111/5,120 aggregate gate. [Final rates, ranges and exclusions](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md) · [figure and value provenance](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-figures/survey-throughput-with-native-bls.data.json"). + +Seven of sixteen backend/panel bars are available. Baseline dense TESS failed post-queue required-output qualification after three queues; baseline varied failed pre-queue qualification; the experimental varied one-worker reference failed its post-queue gate before the selected pool ran. Public GTLS long-gap and varied failed with out-of-memory errors in their first queues. The original BLS trial failed selected-output repeatability; its execution supplement separately failed launcher/allocation checks because two required thread-limit variables were unset. All three supplemental worker-count pilots stopped before worker creation, leaving four explicitly unavailable measurement panels. No failed queue or reference supplies a passing speed denominator. [Full exclusions and native BLS launch audit](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/native-bls-launch-audit.json"). + +Queue wall time includes dispatch, public API validation, template work, transfers, search/refinement, result construction and scalar checks. Input loading, imports/context setup, first-cohort full-output checks and exact grid regeneration are recorded separately and included in cold amortization. Existing filesystem/compiler caches were retained; “cold” is a first complete cohort with setup, not single-lightcurve latency. On ZTF, cold first-cohort time was 149.757 seconds baseline and 85.627 experimental, with sampled GPU peaks 2.610/2.526 GB and worker RSS peaks 2.114/1.746 GB. Sampled memory is a lower bound, and GB here is decimal. + +Projected ZTF steady compute cost is $300.71 versus $162.56 per million calls, using the median repetition rates; cold-amortized projections are $371.04 versus $197.83 using total calls and summed queue elapsed plus preparation. No million-call run is claimed. Acquisition, detrending and vetting are outside this boundary. All seven rows’ cold, cost and memory values and the original cost-prose erratum are in the [collected timing note](../benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/TIMING_LINKED.md) and [verification receipt](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/final-timing/reporting/timing-verification.json"). The short-row dispatch was active for all four experimental ZTF workers with zero recorded fallbacks; both TESS panels used the shape fallback. These measurements do not isolate each optimization’s causal contribution. + +The [original allocation's final ledger](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/final-ledger.json") estimates **$71.8522** for observed rentals including elapsed storage. Its conservative total was **$73.7563**, including full storage reserves and a retained $1.50 reserve for the rejected 80 GB request. All actual rentals and owned monitoring processes from that allocation were closed; final provider queries listed no pods. These are estimates and reserves, not provider invoices. The [original rental ledger](BENCHMARK_ARCHIVES.md#tls_survey_2026-09-10 "Archived file: benchmarks/results/tls_survey_2026-09-10/collection/original-rental-closed-ledger.json") remains separately preserved; the current cumulative estimate appears above. + +## Release validation + +The September 24–25 full A40 suite passed **2,091 tests**, with one expected notebook failure, no unexpected failures and zero skips. The separate gate launcher failed to import the package before running its checks. The [September 27 installed-wheel gate](../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) then passed all **14 numerical/runtime checks and six dependency preflights**, with all 86 installed package files matching the previously built wheel byte for byte. The original failed launcher receipt remains preserved. Both sets of evidence have verified R2 backups; this operational correction changes no numerical source or benchmark qualification. + +The earlier [release-wiring validation](../benchmarks/results/tls_survey_2026-09-10/release-validation/README.md) passed all 24 paired numerical comparisons and 86 device tests on eleven fixed development inputs. This checks release wiring; it does not requalify experimental sensitivity. The fixed run completed in **177.83 seconds** within its 900-second cap, with normal child teardown and an empty GPU. It also exercised scalar/convenience, batch and permutation-FAP routing, separate backend caches, short-kernel dispatch and native graph fallback. + +The separate A40 used Python 3.11.10, NVCC 12.4.131 and all 64 pinned dependency versions, with the same 7.65-CPU quota and RAM limit. Its GPU UUID and driver differed (570.211.01 versus 570.195.03), and its temporary disk was 20 GB. These checks supply no new throughput or population-sensitivity result. Earlier host validation passed 872 tests, with 18 skips, 1,117 deselections and one existing xfail; 219 focused checks also passed from the verified wheel. All installation and test receipts, including the first failed PyCUDA build before NumPy was installed, are retained. + +## Historical measurements + +The [September 8–10 BLS study](../benchmarks/results/transit_2026-09-08/README.md), [earlier full-GTLS comparison](../benchmarks/results/tls_reference_2026-09-10/README.md), [binned sensitivity study](../benchmarks/results/tls_sensitivity_2026-09-09/README.md) and [narrow-transit audit](../benchmarks/results/tls_accuracy_2026-09-09/README.md) remain reproducible dated evidence. Their speed ratios, source snapshots and numerical failures must remain attached to their original workloads. They neither replace the collected new queue result nor qualify the new release selector. [Provenance audit](BENCHMARK_PROVENANCE.md). diff --git a/docs/figures/.gitattributes b/docs/figures/.gitattributes new file mode 100644 index 00000000..df1f2399 --- /dev/null +++ b/docs/figures/.gitattributes @@ -0,0 +1,2 @@ +# Matplotlib SVGs retain the serializer's whitespace inside path data. +*.svg -whitespace diff --git a/docs/figures/transit_benchmarks_20260910.pdf b/docs/figures/transit_benchmarks_20260910.pdf new file mode 100644 index 00000000..bef8df5e Binary files /dev/null and b/docs/figures/transit_benchmarks_20260910.pdf differ diff --git a/docs/figures/transit_benchmarks_20260910.png b/docs/figures/transit_benchmarks_20260910.png new file mode 100644 index 00000000..30a6884d Binary files /dev/null and b/docs/figures/transit_benchmarks_20260910.png differ diff --git a/docs/figures/transit_benchmarks_20260910.svg b/docs/figures/transit_benchmarks_20260910.svg new file mode 100644 index 00000000..078d8e87 --- /dev/null +++ b/docs/figures/transit_benchmarks_20260910.svg @@ -0,0 +1,1656 @@ + + + + + + + + 2026-09-10T14:35:01.510020 + image/svg+xml + + + cuvarbase benchmark tools + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 ms + + + + + + + + + + + + + 10 ms + + + + + + + + + + + + + 0.1 s + + + + + + + + + + + + + 1 s + + + + + + + + cuvarbase v1 + + + + + + cuvarbase 0.2.5 + + + + + + CPU: Astropy + + + + + + GPU: periodfind + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.5 ms + + + 6.6 ms · 4.3× + + + 68 ms · 44.0× + + + 18 ms · 11.9× + + + 200 s cadence · up to 9,736 samples · 26 days + + + BLS / TESS: one dense sector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.1 s + + + + + + + + + + + + + 1 s + + + + + + + + + + + + + 10 s + + + + + + + + cuvarbase v1 + 1 worker + + + + + + GPU: GTLS + batch: 4 workers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.16 s + + + 0.32 s · 2.0× + + + 9,736 samples · 26 days · 2,325 trial periods + + + TLS / TESS: one dense sector + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10 ms + + + + + + + + + + + + + 0.1 s + + + + + + + + + + + + + 1 s + + + + + + + + + + + + + 10 s + + + + + + + + cuvarbase v1 + + + + + + cuvarbase 0.2.5 + + + + + + CPU: periodfind + + + + + + GPU: periodfind + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 16 ms + + + 43 ms · 2.7× + + + 0.9 s · 57.1× + + + 55 ms · 3.5× + + + 30 / 10 min cadence · up to 4,295 samples · 735 days + + + BLS / TESS: two separated sectors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 s + + + + + + + + + + + + + 10 s + + + + + + + + + + + + + 100 s + + + + + + + + cuvarbase v1 + 1 worker + + + + + + GPU: GTLS + batch: 2 workers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.5 s + + + 3.7 s · 2.4× + + + 4,295 samples · 735 days · 74,616 trial periods + + + TLS / TESS: two separated sectors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.1 s + + + + + + + + + + + + + 1 s + + + + + + + + + + + + + 10 s + + + + + + + + cuvarbase v1 + + + + + + cuvarbase 0.2.5 + + + + + + CPU: periodfind + + + + + + GPU: periodfind + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 55 ms + + + 0.1 s · 1.8× + + + 1 s · 18.8× + + + 82 ms · 1.5× + + + Up to 1,317 samples · 2,744 days + + + BLS / ZTF: sparse g/r + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 10 s + + + + + + + + + + + + + 100 s + + + + + + + + cuvarbase v1 + 1 worker + + + + + + GPU: GTLS + batch: 4 workers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3.1 s + + + 4.5 s · 1.5× + + + 1,317 samples · 2,744 days · 235,266 trial periods + + + TLS / ZTF: sparse g/r + + + + + + + + + + + + + + + + + + + + + + + + Faster transit searches across TESS and ZTF cadences + + + Search time per lightcurve · lower is faster + + + Labels give batch time and the ratio to v1. Medians of 5 single / 3 batch calls; Whiskers span repetitions; logarithmic axes. + + + BLS: A40; TLS: RTX A6000. Warm APIs; input loading and grid construction excluded. CPU allocations: see report. + + + GTLS batch: fastest eligible 1/2/4-worker pool. Recovery qualifications and search/diagnostic timings: see report. + + + Post hoc report of complete configurations: original campaign gate failed after 4-worker GTLS ran out of memory on separated TESS. + + + + + + + + + + + + One lightcurve + + + + + + + + + + + Batch of 16: time per lightcurve + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/gtls_fig7_reproduction.png b/docs/gtls_fig7_reproduction.png new file mode 100644 index 00000000..e7a61150 Binary files /dev/null and b/docs/gtls_fig7_reproduction.png differ diff --git a/docs/history-cleanup-20260928/README.md b/docs/history-cleanup-20260928/README.md new file mode 100644 index 00000000..cfd5a887 --- /dev/null +++ b/docs/history-cleanup-20260928/README.md @@ -0,0 +1,73 @@ +# Development history cleanup — 28 September 2026 + +The owner requested external storage for bulk benchmark output and removal of +that output from the development history before merging PR #69. The cleanup +keeps the release's numerical implementation, failures and qualification intact. + +The affected remote refs are `release/v1.0.1`, `v1.0-fixes` and the unpublished +`v1.0.1` source tag. Their old release head was +`403c75d7425e92b8a3d112672a04177189117a61`; the old annotated tag object was +`1e2537c2fb77028fc2872f7a2ccbbb67303caa2f`. The tag is updated as part of the +owner-authorized history cleanup before any GitHub release or PyPI upload. +`master`, all previously published version tags, June `v1.0.0`, other feature +branches and the deployed `gh-pages` branch are unchanged. + +## Evidence and source identity + +All 86 package files, all tracked sdist build inputs and the prepared wheel and +sdist remain byte-identical. No GPU experiment was repeated. The original nine +TLS exactness mismatches, five unavailable timing panels and BLS repeatability +failures are preserved in the full archives and still reported in the summaries. + +Ten [evidence archives](../BENCHMARK_ARCHIVES.md) contain every original member +of the removed study directories, including original versions of retained +reports. Full cloud downloads matched their SHA256 values. The restore helper +also recovered all 506 members of the TLS survey archive downloaded from R2, +with every original member hash verified. + +The complete original histories are in private R2 at +`history-cleanup-20260928/before/`: + +| File | SHA256 | +| --- | --- | +| `remote-before.bundle` | `4264610a4028a79c76399d2c038b74937c17612e0fc90ab0be2f92a83ced93b1` | +| `local-before.bundle` | `a3331873a95d7b963870c0dc3137848eacc8b2d2ea7f4becbd9623e6dc72ad55` | + +The accompanying inventories preserve all original branch/tag values. Both +bundles record complete history and were checked with `git bundle verify`; +their full R2 read-backs matched these hashes. The local bundle also preserves +local-only development branches and the frozen validation worktree's commit. + +[commit-map.txt](commit-map.txt) maps original development commits to filtered +commits. Changes after filtering are ordinary commits on top. Original commit +IDs inside scientific receipts remain original IDs, resolvable in the archived +bundle; they are not silently replaced by current source identities. + +## What was removed from development history + +The filter removes bulk `benchmarks/results/` and `docs/validation/` artifacts, +retaining the explicitly selected reports, figures and summaries. Historical +`analysis/` snapshots are retained in the original Git bundles. The rewrite +excludes `master` and its ancestors, preserving the PR's upstream ancestry. + +Fresh clones of all ordinary heads and tags were measured before and after +filtering: Git object storage fell from approximately **296 MiB to 59 MiB**, +about **80%**. Exact final sizes and ref checks are recorded in the local +cleanup delivery. Most remaining bulk belongs to the untouched 2017 docs +branch, including its old dependency cache. This cleanup does not deploy docs. + +GitHub may retain old pull-request refs and cached objects independently of +the updated branches. These are not fetched by an ordinary clone. The measured +reduction describes reachable clone contents, not immediate server garbage +collection or destruction of historical evidence. + +## Continuing development + +Use a fresh clone after the rewritten refs are pushed. Preserve local work as +patches and apply those to the new history; do not merge an old development +branch into the cleaned branches, which would reintroduce the removed objects. +Original local-only refs remain recoverable from the local history bundle. +Use the [archive helper](../BENCHMARK_ARCHIVES.md#access-and-restoration) for raw +study evidence. Normal package and tooling tests need no archive credentials. + +Release publication and merging PR #69 remain deferred. diff --git a/docs/history-cleanup-20260928/commit-map.txt b/docs/history-cleanup-20260928/commit-map.txt new file mode 100644 index 00000000..d439ba14 --- /dev/null +++ b/docs/history-cleanup-20260928/commit-map.txt @@ -0,0 +1,537 @@ +old new +000c299c3a08102ae2a9b897a95270e08adb9b52 2dd12d4bf553dd41fe181ba715678a210adabe51 +0029d11bc66de979066a064fd1b65ca756bdfb5b 594e1adbd123c47ec17ec9538595f0ab237d72fc +007a1fe9724be362da5afd9472058e9fc5c7332e 10d33e556a7ce9aa4853139422e0ad1a7d6f42e7 +01c5249f5558a41b3450bc3fe9e5a508a8bb87df cd4097e2eed7efb4b010e0eff5635c3128d243b6 +02683885b38a891f77addac5aba297a77de30fac 8e2c6fbaf1213feaecc1dd6a0df918b4973b956b +034d8bac8398da983417778b6c603418fd82404d 87b58b4bd33a3bda7c46113d1f0a81e621b1063a +03919a6c74e188010fe4cdd2f84436dfc9df0163 e7d18040c08e06289107958a948526308c556c74 +03f9671c3e6fd9093948a8584db24d77fefdf79d 0f74fe484e7462b23c57deb83dcb23a611fa4da4 +04006701291078c89018d568c737929746ea7b73 04006701291078c89018d568c737929746ea7b73 +047cb65b25d086c9e05ca73a2ee2641fc015c281 ce1ecde52fcb40073c1f3ffde8e96d9f4562224b +05aa339a0f55902acec62a04b351bf47a90ef4e5 72477fdf8af0789d428b11d8c2418747ce3ae75b +05fd7c92d55404fe10a48a96a924341638e4a2ec 65505d75ad276e6173384cab47aa823c5f802bf0 +07cb4aafe5e702382ee6dbb407f88ff51e22e59e 5c8e0ce665e2b5f98acf5e2b32309f6ba05ffeca +07ce10e02c768d0c8e6cfd16343a7fa75f75e064 f304252e500f904ed64ced27559c6b566e229fed +082d13599d5effd8047d58def4090a989f33b423 f480eb6ed9113d9bb6d78663d04389862d6b6694 +082f1eddfbd303eb55d4b66f87b5881cdf0b9af8 e6bbb17357dfc4141bcc6673f18f45feea946a4f +08ccb887e06c5f5116b63105c6c4404c71432ced 1c55f0d65af99dde9ee9d95dd018adfa4dec9590 +096a226a6aab6ef43b75f40b806ae2f25dfb773f cd065a018ce1985ad287eaea7943cb032f6e9fb5 +09a45d91bc43c61a5ce08bfcc4bb3722383b9983 db765a1d6c850a3d293dc6bed3c20be23f6db8b9 +0aac45e4a2ee20d71509278264b89936fac231df 95f335b5a10bdae720757116c4e4082e9726c166 +0b6077bd61d045589da9d3e84eee51c410b3c3c2 422a052aaac0e7e4588213bd72eba7c74888cb42 +0d5cc65da0ffad9878cdced7e86685c332b0c8a6 3d1a99995186b51c7d0c8c037066654231ffe25d +0d724a2dbb0f460a576e3838b00fdbce64f46ffb 2b4a0d25b158734a7e7d13091ff097f290d5d118 +0dab220a7e5e62d4ab82b4651a549294d0bfbf33 cc09f33e5ac60dcfb339110df54f1522c728fbcf +0db45554fee9e1c58cc575cd309b2e6f56c3c1d4 42516b8a42972cbcd3b2086f781bdacdce696af8 +0ebaeedc316fe85ed301651e23a7360e6028c0b7 3fd1f307b2823a69a06e97a06853782ee9493b0c +0ec98b85358405fcb5cf1b808a18390429710779 0f88e68a4aefb6e952d9c3418784954cb6b13f53 +0f305a8b7aab34ee1480653b0322d9b0dfed220f 38e80f82eb15f3d1cf9a35a16787ffd93643f950 +0f74f084aec903414b25abd01d044515c7670211 5238e88328a65233cc6c95cf4ddd545770e53b97 +0f83c9f304a1558d255a157de8edf78d1bedfd98 88e3cb60d454cc6bca758cb6c16926431bc7bff8 +0fb4d93d25d10e4c206b32d27ec2781813d09f17 99c9ce30575fc63f728b72831123712fd08ba16e +1032caf029570dc4841db1c594a2cbb1654e8fd8 8d7eebfd76922946f18a90474d37d557d4ecb5b7 +10b01ec5765a2a1d5ada91a14fd69f0665a94f22 8337cec0afa9f1024ca790ca6e1187e99be67d04 +10c731ad83a8be7516c69cbeb38a64e86eac1266 49704301ebec2362d0c2c6af7168a0ac9ef87d84 +11317fb0ff1b68af05ae3f67de5f298c9a90e46b 884be76e5986a4a843dadf60fa94757e092c3d84 +1145523b8550c21526e807615373193213a4fa48 d7feda938425a14a9d69521cacc68d3f8abb263f +11941277ffa47358d1837e3650bbe75a85872fda 7d0b2cc795bddf5a7eefa7beffc95ba5464433d0 +11e9a5bd76e54bd4cda86916f02f8cca056e93f7 e9c34b0eeac049e0b4af6eea3c4c9626815b0d6b +129151063ba3e1207b2e2bf5b695b7a744bc9552 58820f8cfb7036547595fa1ccaa004a55e0d95fc +129cfa2d53c21594130ec5f8617d1ab3282665a0 df3f1b6ae391e8e862b3832b2c5cfddf1b80b37c +136b06bac382d980f61d7e9b749e352053ae0b6c 9ac446dae56ae304dcb574297ddc98226781dcc9 +15a3654777b4215f460962a8421d186120a86176 0d436c81195211d34ac10a66bbe6008252ca1220 +16a8000febb0954033accc5ca45e6aefe376a4a2 6cf6f18257e1008a7cb0f5b97095bce13e7effa8 +16a961fc1d27b1c3b5cfa691155f67016d2e1a9e c8f6940bffd4d94d29f64b3843a34fcbf608858c +17ca74b464e6bca99e910fbe7d18e04130f57866 06928dd34fff5b99816e5c2addaa13cd69ce5493 +1874941b84d98b347f3989417b456faa5a53f43a 0c917a258cd23c24932ae25a9c435236a25ef32b +1983eb59cc502be93d7a6add2363123ae7990c90 96991135443ba44143cf358100178324d4a0558b +19a754eebc0bb8e4f20cd2d1d75f1a93708870b2 ef752c2f361a050b65d611864d99f42cc3d7bdf7 +19e37cc4e28c285333f66d01e2ad8d610cabfa17 dd29876f83e56e0fbf5a555225385a95a24b8be9 +1a42a4fb3a7630c8f498932cf12638dd7d68afa8 b9d0062551a3ee25415d772d88a48b0df1e5cc0f +1a86a31c31362922c08694ec5036a4d22fba332e 6c3ce46ffbc143a33ada581ab69f7cb62b9a8c6e +1b200f4db3ab97e59c2b5ec04ac4950beaaef2d3 8095a4911d443101f3a92107ac9a6a367bb76b79 +1b96cfffe1f19da5c6c559973d22269dc061a04d 68d03a869f9a35deb430704b3490e4ed124bae0d +1bbc08d303f807e9700941c6492f571eb63b6550 b47754660fcd31cab1ab27be161ea31f95fb978e +1c687ae7c54cb3ee7ccccc2874a7073db64ea5fa f3fa3606d7d9c68464922a0d5497593130829a57 +1c78e32fd7604d5dcb27106d1afec997896ac654 2e335e086416e08bb86d1e0d6cc7b543dd3eba49 +1cc0d8a938609976045ec7f40ebe20b682ac54b1 c1331edd949505bde370d757c3f79267454f2f9d +1e94b7240115b24c6b20ee09bbbf5286b3f0c2ba 1451f41644174c066526125086de0467611c5db7 +1e9d2b04119a533ba12cc240c3188684d4e27286 9d4439e21e7a3cc21bfff85c69fe4386fb7448a3 +1ed5639a4a126ca9381d037871b4645fb6445d98 fc10b242b94153ae4a9c6b4b429e1b2c848fce62 +1f3bc3eb922a5f9e6c31e90cffb0416877480f62 eb93c5992d6073d3d56d90ad948ac56380c2fc16 +1f7269515bb63cd053d7acc4db15f9ae51263263 6b0d11f9fc15dde638b149e6476b0f1289ce60da +1f798fec0faf1662d94ce63f0e2ecf7c7f9120b2 1a532c7968518827b893ff157240893dcba83bd1 +203d9b4cbecaf2519743ec6bda2da1897024fc03 2c3fa023d7996e7d4759880a8f2dfe0151d1d219 +20b9e80252032aa29d6fedea08de4fbfa07989cd 2bf09a692bf6cfdd8cb39520aaaab69a9122c557 +21b35611bb4ae57056953ac2945819484bb9cdb3 afc236a609a038b665f0bdc02355d9d6d25c6305 +228cf56c913c421e19f409ae71984d2d21d8c2ba 2d9cec32532e76779d22f110260525b46a1cc818 +22ea5bd81f15f0f53cba939c83b21c7ef730adaf 541f7b5ca07b66948399c8bfc0b4f19a0bcf2618 +23d8caebad6faf926bae5f86debb8fef34f2df29 5bcd32fafb42666fe8ea883bef6d86a2c6276244 +245464747d592b15881a58696364658d7d77158b e4aa9a6a4221b0f394775e8b8bd5e21f38e501ee +253747a1150eb1308e2de96c45f1becb01bd19f8 91dae5eb4c050f0988c741a27fd69bd28148acf5 +254f2195c394a1853cbf23477d03ee169925265d 4dbef26bf8976a3e5a1c3e0b036345f2177c44aa +25801e6e505a2f66a58f044bd685e4c37253eac6 8aef1e1a8683b70fa93e42397041e622ff326222 +261c2b0097e7225ab9f88fc767a84d8ec9153de0 d3818a2a0fd7b6ee4294580b61b03ecb59e66b02 +263aaa04b60f80cde18b8513e6799a6387f9bb7b 4588114dc4bc3d71d8b21e18e3230bc900c50330 +26995259e3ccb6d0d3cdb3e1a2929b96e86c77bf 14b0d90c7552a085ca0d2c24c1594aac86fae579 +27752e45460f66bc8b7a70a82393e47c77edbd19 8e872b9e456d0ce0140529ccc899355a44ddac2b +27c85ad484e130cdb833e8fc203085b38a22fc5d e07c6fbd6abd7c9a0ad30dd911e1e3000eaea7ac +2a51c8688a3f6c7ad25b571bc930bbfe5763e169 6e9a274a2bf6da64ed2d72f11c27e3bcda5177dc +2b13597be40b32684b337ee26529d849bc84dba1 331cf0935f5c825158a3c29dcec08e331514d89b +2b66bab777c20a552c60535ea0d752a7abc96c8f 068f1a8ae0798d841f9a5155919fcf395e80bf37 +2b8c8cc23d031f846144ce121e2f884a9384e616 85f68972058dd75c91ebd83662d076c5dca5ee17 +2c0a8c1770f6c417562f9cb2df2beaaa91a18469 fe3082ae0e402302b8e4de7d9fd833d5deebe319 +2c25092f9c8223035f67f0574e4c53268a6d821e d313d12a9717b9d396239df110378aef03f85e70 +2ca8b6275717a8f2571876e0d159cb7da7dc646e d1134ba7ff5187fe5eed37a7d31b7fa59c78ca53 +2cbe81c6e7e029390707a96057ce5eefc825c4a7 5917461121a02cc87a0364739f2f7a186b0c7c06 +2cc1f968465a175658565c1f47e3b05c62423095 5e749cab24678d03a1dadb23182156c3941c3952 +2cd755727ed64135869db1c172c8256c86e871b7 b1da736cd0a27aa9187fc9dd0980b4b3af495776 +2d2a186e90f309e4413008f65eac9e0045b2eddb 2d2a186e90f309e4413008f65eac9e0045b2eddb +2dca8b85549424bd9ed18fcbaabd2937b7b0968e 28099cbe496d63fa1c2e5da4902426d20ab3d80e +2df8d0304649057c78ac0b0cbb58c5ece3c194dd 1bbbbe127e05744680cc49f131124dd390362563 +2e1d34bc1abaa2413a9e647291afcca8531c810c e239b6c9ce2305a3fa0119d464c6c484b9554b51 +2e31c396f39350d7903c01d347538336ee679bad 579e9a7d322e3b68addb9c6cd25cee41aeb5e509 +2f53dbe383301c603146b0236e634396df58ee08 1f8223b243e04a8069b7e48fe0ee7cd83fa325c1 +2f80f496d44a1b2b59b7b4c58487358adc0a7899 2f80f496d44a1b2b59b7b4c58487358adc0a7899 +2f90bb4142f275a99b7a3eefc448f0779e013bff 4ff828cc950b932305693218049a4c8d00951b3d +2f9736ae4cef44ea0c0cf7af25f13d1158c944e9 0acebc5dd0b7c870c9a6a4ace8f12c7d3ee2f1c1 +2f9b9fe2c7ed25b3ea84b8203cb617dc2b566b71 dd332a53f30bc9a1f6ed8b22a8ba01a86e1aebb8 +304fcac7fe261f1f23eaf1cbb07a3c430b6e4a6d 4d345e9efc96f84294d86948506ca916c031b4bc +305de577da41b2d92b608a3d066310866c2f2241 099120631661f0999eaa39ffe039ed9a1acfbb7a +3080313d0ed56d0960bb8b3b4650d64fe9cc8da2 eadb61135e62f62d9a0edb1905149ceaeddcb71b +330537ed6217d36e0ad88008c5c0d8ca2d44e17a 459377d9b9042cec1f7308817d8eaba3a3af2a5b +338737ee4ca97746104d233efcba8138e41e1830 16495678e832c33e6eb77007f32b349eb8567a4d +363a4d91f13518ec8894c3e93c62b830448eada0 94d49b9fa1c11ea774e3563535ce32f28cbe22e3 +36af32fe14bdc5a511dc3e4b70cd59f436854249 f3bc3bf8b7f52026a7be12ad1a278abb790585d3 +371c563245097128a9ec76453dfc317eb657e56c 224559f096a928f528565b37eb0de59821ce82d2 +375004e7dff5918330c19628d7f4320d23a88538 425765d0674483e0e79c8329ad5445209def319c +376378a391cde1666dc9bae30a2b20ce18b0a542 5aa8af6502656483cd300567f6b9eaaec9c872f8 +377bb71b594d1491639436b62468e3ecaf30981e e99d7af06f2084b73806406b537af210682b6c9a +38ea08b7691ba91f78bf8e8db200fe9d7518fbd3 c292fa5266ba8789c78c87be38db5a2960679418 +398cd608a00417511b7592322d3832175caf9505 4515c69b661ce41f35761980c0c937f9229e6914 +39a6dccb3c96a0587fb76eb5fe686447ffb7e820 5bc2471f5ab3094cd2963b78034e8bf2802307f1 +39d3367bf178fdd3b3d00aba7bce99f19823a76e 5b4242f6ba7d4261cf1bf3c63d0073ac4f1e288b +3a4a57613f6c28750fa654ce60919e872bf93623 670b8059e1508727cdb619522957a56d9574b999 +3ba3076b97edf53fcc57f17c3b62e63ab540dcbb d87b25ad9388427fcd44ee4a1b4cf615e7655119 +3c77b7880a2343961eacc68b9e52f8d2634e8817 495c0cfe9b934e3cc78e181d82e98ba20bf2eac4 +3d64b605efaae03ceda1af1f6426c8ffafc63e87 ab1a78b09b2da39a26d17aaa46aeb065140cb988 +3db5eff5449e93e4ece7e760684a280febdbe1f5 4fe23a80e3e77b1919664bff6ac28f4ccd817c83 +3dcb089329dec7e51b43357d5d446f9fd2e3cc17 fd6ae1e48e28090dbe48d350afce82247e712338 +3e6e0a9569b35e67008f329cdbd43358c5f4a337 7d9c8f92e0af1269a5a8ccc82bad80f7843acdb8 +3f3349d88afa727a1a43e1a061283e4f1ce7992f d644fc7f16ed1d85a55eb2b1fd5460c4eb5a8b40 +3f33e933968000bc0eab3a1e573df4ef64902f7a 5e1a2878094b0228715180b17c6ca078362a3c62 +3fa3aa9528466e48b8f36bc43aac602ef7ceef24 917389a7cfc315e16260cd46fcae276cc0d51512 +3fd2bee7e22a16a7755f0b6fc607b88b592b6255 0e3a803b4d4baea6a969cc65a4aa3a9a4fcbaa5b +3fd713aa270cd498bf8a2029d824bcc81cd96b71 610a72a0b57d2fd480036c77cc0b279468d4d996 +403c75d7425e92b8a3d112672a04177189117a61 a03eb75ea78e3cf52b4ccb7e72cb581ad35fa761 +404ec4f764db63fa1bb2be35c36749de9d746009 235c3fd06041ce862c17d901e661efb04a76c42c +4070e7abc703fbdaaa3e4cede04552cc9fbb76be ad97362039d81a55d55d2938102122a7c08f11be +4238d5393b39fa61c67b6344eaae9c4e1f6db853 5d906250d2705c98e301b60f22cd95cfb66eb27a +427a4d90864a5b1637da815675777cea87744507 f332e05efa8a9266c6fed571e565facb2b45bfda +431909f02486dc2f5dbe900f9e2112ec6e7cb3d4 81f5ca6de9c0d45af6873478db4ef535e0835d1a +435d6178799e9995b50d6c449fb812aa28fe5920 90776788c0ffa744516c1f626ea96d592af8b73b +435e9351cad8eef35c7304d31d991f1ad6a442ef 318d4b9d2a17c7d420b2487522cfdca511b52dc8 +43a2d42ed0b9736e64984eba3f15e9e37d91c88c 6456ac9178cdc90d7e65564d135668e08880a9a9 +4449ff96b9fb2e7c25af9b4cde6067a9d1415df3 733a6c41012946feb0edba6a890e00b2a057c52b +444fe0d8e21481efa0137929258a58b2c903e9dd 708a5b27a5423e41d1d347cdf028314c53fbf1b5 +44559f39cc4e46d93666cb416e88990c393c7745 25f20c2d947f6556b5a1bd83e71b0c9b8234c575 +451afcbb94b5df39e316f156b74aa9c6b3d4d3cc 51183ef59dcaf62d993d3d212c48e9a62c61dd14 +4640de4525c0a108b66a1b7a44796286d390493e 21610583bf854f7f2bb532b29d217e12252f8e25 +46459e43c2d40f8c5c84d98c8f06aababb0b677b 9a4879985181036d26f7673564d2f6a76eca582f +46f9c4edca11cd29d8d21aa663e3f480d6fb07bc 6d6856561d5f30823062fe80a210d22f19534db5 +470338789fd58e7a7d3ce3ce36a1ed0ed41497e8 da0fbcffbcbf20a076554f5d094aa937a70c8951 +4757398524aef66479b8d1ad0b20b0d95218615f f8632d19848b97031f4c36140b4b534a0ab1766c +47c8a26f772c32db3d9b2243b912ec2bc35bcf1c 20d9a9fda831b0a048fffd5a220802b6cdafae77 +47e0ae306991fdbb1f177198ddaf65c4453b18fb 1553165837101d6a8eea3647acec26507cd51af8 +47e1e22c757a5cbada78005f230f0d408de50f59 598bb9f54f9a57aa110a9bfece922864c54ef0ac +480eecee4560c7836cdfb1afbe82dfba36f65ee5 d14581efb7e181f1c30c4320de0fe82cf5161669 +4835b84c5da003073a4fe3661af03cb7ee5b8b39 8e0cd9861ab770f1b56c701b2d6df3681b33f9fc +4839ef482c5adc97f306ff26d77c55ca8771557b f7d336bca8b2f086e5957a602a4eda6dd39d27b7 +48f85d515e1a5198a432bceb341229a5ee19485f 66203c83f2ae4aea0e5b8d09a585ca7d201e6cf6 +48ff8f2bc1092e92936a01bfa0c76529b8d83ade 0bca343dce68d640d6510c0945599b7937d044f6 +490417c1a569726f7b5aa5d328c965b4f6a9d953 c4f23f65e318e9ba7f019ee61090dcf0a83d527e +496622bfaec0072313d3a0d6a118801fdacb921f 7d43286413c2664ae7fa08e36aaabc98cc39f484 +49a43c4178cae70b77d227019b1948c90ed7439c b2d533fcb4801ab24fa70ab0ae62260879b11911 +4af090ca91d059f9bb0dc8d2ed88ac6ce688391c b54204d43e96b9f05f1b41f2b508c461b276ffcc +4af1d923f3aa1b673b2c1dba98225612e95ae8e5 f6a9d1d9983fe6cc8738bb7f4e5c74523160d2f9 +4b1b22f9005552ef64096a998f92e52ac0de8a45 b756bb152a3d0ec1ef1c9c7da7c60ac156e0595d +4b220759a7d2ed2f35b35923883c1dbfbe0797c0 172261e01d2c5c82d327c0a47927e69fff0514d7 +4b7eabe8ae85b3c30d902b4883549cc5ebb88010 06ab9de2cb06f470afa7328709f067e64f8de16a +4cd57ca9b2906c78606cee5edd142042330f8258 4cd57ca9b2906c78606cee5edd142042330f8258 +4df45b36e3c75fd496dee5367270c414e82fb1f3 358599e6619bcecb3722bf5570efa31b6d943b96 +4dfc7db423528428e862315963262e01ba11092d aef8e90064dad0e635980172f95384ef296a5be2 +4e1a69c78933abe5f30d525b270faa0c6937edf9 c06e2a618618a14d3523eb1bfc724dd325de98a7 +4f82e24ca4bd04fc94ac450c577d110b3d3e21a5 ef2b2321fc9b21fd36e9401712320adc784fe803 +4fbed7244ff03e3a1b927f770ad1bdbb5241d8ba b937ecf463c38cd8f49c2bd4444ddd159e1a427e +50ecfa07c8d62809adc0fc541c8a9314e6e3aeba f8c6eab10e1cf15dcff4fa8a97db7f4b2ad941c7 +51410f9f4b80ad0fa55f66bd9621429f0b590411 c76db0f570d821491a35cf529e18f5133bf21662 +51c2cb3975110495fb2065610a097f8218f5fc7b 78f1965cf56f24290da9e318798518aeaf03f30d +51fddf0398bb6f72ad8f5bb12d887fafb60cd79a fdb847bffabc7096cede9467aa7c4104e886fa81 +529e3f65367d8768644ee65c75134de18167f62e 719882a9c3acc307f0e6e1e6cf61d5a2dbe6ca14 +53b35b7d36e1276d3c372ad8156d44257f377bd8 af1db850f95f43734840f7dd1ae1170759d7ca6a +53ee37b1677b894be9a2087612d694537fc7a75e c9997741e096d261fc1ffde0cc38cc6132f0b5f4 +54ff3cce31a564f6ada8b3222d94336820de47a4 149494513b64a9fae14ae1e280937a3a8b6703a8 +5501f6cad97316560ecf3a605c09529abb87e795 e2ac396158476760d9769152e3933f11c62bac95 +5553248d5537cb2333f28ee6ff90a2323eb7a403 f7f7ea25f93f1167a61577ed94db63d91600c3d7 +555fe97220a8fcde77694239e69d5ab15d5782d4 82f321f68e62b415755fd1e5899600ba768c82ec +55b7461058917ca8741dada8fb085c02cbbdb603 f6d8206de5b9270b3d476397f0bb7d0b45695e90 +55d28a0f09d61d8520967587af18ba5898a2c75c a4cf991bd577621b15ecad4673339dbda8ec44ec +55d4d661d266e6f4bd62cab4d4c532ba54b044e4 d5c9a99a28726778ccea84c9fea6697d2054f14a +565644fd7d005e6d0b92c5d61d1a1900cad035f2 57707e91c10ad67fd19b9290c59382467c5a7d25 +56566c9328669a45e44aefae85ba0b131e7727af e0f06656ef2688661dca20d5eac7255f56f52900 +56611131e34521d9a71a07f5ba35f0fb0de6e6b2 e121c3e9cac3bf7f33a926d26b65cdd08a94fabc +5674a328f8828a668be28eb681e945118eeb7c3e 2944c328f573ec8a47ab7b48bda464f2e5ade55b +568b821db689e4169bafdca41b61c0521bd4f7db 6be672b2b1a3b82cee27abf8932d9c798ba1c6ba +56c0d48755b8eeab1a722eb400cc9c0f17a90d79 4bec10eb01a4fdbb46ca230b8d4ed74be7934d43 +570b8b191981f2a428d94a0b8dc052c490e2c3c2 ac8b0ec5f21649a6eec5cc07ad13b98e37213c57 +5710bfd65192bfd7219e19731cff419a44ae222e 5710bfd65192bfd7219e19731cff419a44ae222e +57807053ffa1e127fc4c10454587bf79fc881dc1 56530b9ea48f619d4ae4bcf635c835b3442785df +5814095566f988480b80a6c98dd1b5100195edb9 5e49111b4fc35ba55f25e8bf93d2900e9a7acdce +5aba5bd9d00faaab512c8b5159ffe5068097197b ec72d353b37c086252cd19551fcf8f94cf67e05b +5b09b671cef63310d7a80b94193e431b0cfd41d8 71f75e87f7bc57b2ca367f69834ad41c713e9444 +5b2eeffb1165201796349052d14e5767746759bf 9c5b0f3c85ee9f7c6295159a8d9355b1bbf5c4fc +5ba2ac62ccc2c2ca88fa8c4a70f5c5640fa33f06 f40453554b69b6ff53ac64798c29f9f396e7a9aa +5babe80b417d7b91da3763f803eeeed48b70448b 71433bcd9847c22a5feeac50d2ad8cc60e7ca05e +5bac7957752772bac044247a147ab2b4956f4803 652253e64223976d2eeff1edc2a59649fb46475c +5bb6be63ed503a6e548a03855e691bd94e01fee8 0bf3156b98f6ab6807f0f90ca9ccad9701448177 +5cc8e8d3470ea3e758a83e92b17359b5325fd501 7e3019271b51bd1b6d59f39a48f8ffa16197ac1a +5e04aaf82ac3524524063776a38c1e6641428d94 000c416ea11930be155683ea25d98cc355946a78 +5e90a166e6ca400069364b616b9792d23148228c ee4abddc7000e6f979ae6ac6e5f5a1ed8c707382 +5ee3a886655d0fc30b1a804d59cba1045efdf461 eb33015410c6e278a5c5a5feb0775ad7f0f739c8 +5f61aa4f5fe8c0f5be29f1c5e9169a35c7c2f783 d4cafe2eb735e1f6c8a2babb5ef8b2907c7220ed +5fab5c4d1e28d035aed55875bd47359c8a32449c bf5436e72f2b1aa33eda67c594427555c148dac4 +602beaa592d0b91b8e2dee196e5fb76d56b4e499 a663edc2583b40df9a3b537585869dcabab4b078 +603f47b1f33d560f5cd9d60af229bf400eab6b78 462a48366d37846f8e066bc0351fa21c4f09db85 +604465af96dfe5774c63653bed07e198f05290e7 c8a93a600243b6399c253aa6e0a0aca1e6be881b +60b40091f027d552a4f161ec80795096db1d555a 63bfcf0ce4fd404d034c3cf67ff131232c7034c8 +6171add680555775e5839afef7175840db20db8d dfea493475e8a774851a8714e2c155296c1fc16a +61bd6b9e29ada43b88cb0bf4c960f141c5b8897f 61bd6b9e29ada43b88cb0bf4c960f141c5b8897f +620d9c695c33e00bf355a935e9c4ed226ca62076 e132f59aa80c108a6598b05919b792827bc2c249 +62ce387e1e0a64b9f37a5dea14458c3f2a04c84e a2f6373b8fd956063197cd3d1b772434a087886f +62e246300d5d4fbaa6dc68cdf73bdc12e99eaf89 acc4af2bca9d24582ad1e3e8b231bfc35169c4b3 +6313bb44a323d16ae1bd1bf4425e043bafcdd3f1 d718188bb4d38e0f16ce2ff9fb5511422881393c +6403e0bcd45f0e2a01aefc90d3c7a95a94352251 f681f9fa77a75410f32238fdcf6d328d742e8b0e +648b290794a781e2e9a57da2e4168c122688208c 4aa24ee2c7ff178d8fcb8583c63ecd19aa25979f +64ff2d22c84f33734148fafe374ce73a6e54bf98 b28c7e987c37d7bf69ca7b8c4022d965f0d33ef9 +65d58564f7079a90fdb670aa59901d3d88ee63de b1cbafa7d2a6061fae27f4f8d380fa22b133de22 +65d982efa9a3c59d9f92031cf477ae4cf8fcd56b e5f5dd06ba1a09843177890f8bf6517f385798e1 +65f0c8bad070cb9ae4b5e3d2608dc17c771725b5 e779112f952109a6fb3be08b3d18c2602c383cf1 +664b41d15ebf54bce6af9c0df89d081057f77370 e654c6a49095a9428929a193e7fda9f0484304af +664d0dcc1087747853afe5394b2498604b9b654b 2aeb31c23a126c1c45756febae4703ea8ad06033 +6652060ba27d8d3d08b363265fadf1ec9039f884 6652060ba27d8d3d08b363265fadf1ec9039f884 +665dbbdd95b4f32195e861ed7f3e60bb4c6331e1 e6c26ba617d6d32c312a81027562d4641cfaac47 +66739b4cb81ff01b9e2cea7c263a8ae7a67fe7b3 a15d2ef9cd64ff0a5a7eb8a7b0b2538922420447 +67a02dcfc4773d5915a371558438420b78534a1e b7c9cbf513212a452982fc12f5444f2266730b60 +67f956a711bf8714d4be2e5c2f815e76ac8c25cb e88741d5e64f4c8e3dbdc336bd4dd138f9df711b +6832060a50a9f68ecc735707fc0ce7763658626e 2ad95ec212a6d4463c7ecfe75e8a936db0494116 +688da8d2d78d0479db9a0be49a0496db05448297 865bedeec66cdc854e7d91add6a8df056e35355f +68dda4728933e1b7eebd6997c0577ee369354e9a 50483f2b1f377c79c778019889bad06374627e96 +69266149780c7737af9619734156306f7e90ac06 59a499d8ee22c07adb19c5dc4757ba3970eae53b +699bf0f0910bfbdfb8d1274798fb8dc5724cedcd 2e3a2c3a1912e89fd2a2a5f1a378e52783838d85 +69f86d852b0613e5a1f4b8626ec393cde3160e74 95d847331ea63848ba8f933f5403da17575a1f88 +6a01439119f692f9f5c4e2e965d081db15816bb2 6b56c47665932918b020a94649c9db04574ab1c5 +6a7f3da6eacad36adb715ea3642a14cedfd093ad b6e4bcc5bb9561979ecd3bad13e7c3d50420c12d +6a94d6924e274205a3cfe3acc5e392ad3968fd66 8a6de5c8ba1c1afb37b37a117b263f822ad493e2 +6b1187c04fe5b7e54fe009196bcb421c618f8be3 b2d4ba2d6bad54928de1120e5c617d4708abbca6 +6c74091ac956d29fcef580887580eef1d41edbdd 8fef05eae01528ba68c0faf2e1a3812fd28ae627 +6c8d96d372bd2c254c4f9b3d4c8a882e9e9f8a59 b8da1db78fb7a18c369aa23e5192127918c25863 +6ced75d6d75bfaafa39b78c557fcba86f4651d92 b834d4e90fcda7dfc40b8649176af7514922f8e9 +6df6bfbfb811e514409bd2adc108f65baf454e67 132e4246c806061f17956d0476d8aaaedc721d9f +6e4fd33b60fa4074879cede639b5a7d33644e4e3 bdab67b0e99080800eb3cbaab51c566fbd1ad08f +6e9c1271e42f21d01d101e73356ce2897dbf3a56 deef87494634a895b0a00a28c06ae6965655f8b5 +6f604f960c51dfc4a021e8ade076ff99f319712e 09fd819c140cda2571fe971be7ffe32352fda1b5 +6faf70f6ebbc06fa58054a1d01d0b601cae6b5e8 f13d6fd6f957d1de1efeed82fcaaa2c259ff73f1 +70c813c5925d03809d9b53e773c6753d297f3d50 504db5c3b6898b67ad1f9d2e368a432b79b5cefd +71085d68c9bab6d8dfd07e2b373d3b7e892540f0 290ade55ad00cff084ac2b527225bd588fe3adbc +71637b3d8c4ab2e6478d6be1c562d75d0a529fbc fcb72134bf04e51da3cc46e67eeb3a9baf4bd687 +71d863ad5e3734591039cc328020d69c3f9d0790 b8d4028e158505af2ab1e4767aa9dfe72ab12dff +71f092beb60f61691bcf60243ed77dfe716ca0a0 ce957a7b669cc217e6284472cff201a05dbb0f47 +72002a733153cb63048a65b1924a392def4c1e03 082c8a7049fc3f9d5fcd83c06b08414820136ba5 +72ae0296f9436fd2bf8da234235a214d55ffef1c 85a5ed7c002b7c7322410050c05bbcb1dde97307 +72f36631256f98c4233533f9d5d9de777e1d4ff9 d0f7a8ce3db9f5716c61dfc5ad2950a07d1227dd +7369c8bdc1db0c4449a64efb6b6bb6ab72fc6892 b4394995ed54052de2696d09486f03ad8acc6145 +73ae121ad8f28f1b9d670b817cec8a72ad96c327 fe9724a42f9a0f145bd6ee3c3f03d25e378db6d4 +73b593d1861bdaaad0af79af820ce32242f8662e c60369c6ddb99c0dcfabba914d48dbfe0809a881 +73ed12c2d8952f44a70700e98302ba5c09f4cee3 e02d8f4d3e88e58e749d6542180da6188e6e09e0 +74a2237c733c02214e9fd76cf0ff8f89a000855d e97fb916bdb02b9117297252bd3db8e864572a26 +74efd239942db830214bd5eb19ff359ce9bffa00 7204d920bad3db890fe52361225e1918619a551f +7573b7c9ec5e5712951f414ce6be117696700a28 746abc12f257362984577a61b163cb92aa54defc +757819630c5767c7b97b9a7aa4a05293c6ed0f9c 4e0d414b325cda1b5d11a1f76b846544ba31b1a3 +75e708a0de9c0fd5d45f48560aefcfc8a771b1a8 592dd29d5d58621c83360c8a8b4d4d993afe6298 +75fe24af1664d0e6c4f784733588717af526cbc0 24b45dbb3efc02c0c06ff195ca0776f63e34963c +7647d3ee27d5e2da0d9483ce2a20f2cf787abd98 f3ef3649f8c415fa24f38eb4496fd96483124168 +7656d55d056ff60c9460e9a43c2525855205aa3a 89424770c8b23a526c1cbe5ad2ac4c5f59df3678 +76e6afa899c83ef2a59a3d10b13635fa38436144 6e0a8402a984201ca38d8624753b4359063e861b +77b4333892e46c219f98053a21a9c5ec6e5ab35d ace583b4c9a277ee52a02aa81ba1e0b5c6f48604 +77e32b981509353913e301fc10d3680e84b2b7fd 1b5bbdc6c62f13b057133b430b25b4ad632ebd54 +77fa0a18d8d80ddb798b0dc3a2130883ce551f66 a76a7358b908c61a94fe49bfcdeadb9fb0a16cf2 +78e7379622482f76bc9e48692142cb7578ca2d1f dd80c3561e741c7a0c532367f6c3665f7cc98df3 +7af4ff792e3657863aa1565a8e52d33bcecf075a 9986ae32883763e60d5858a10d2ba5ef2a499a31 +7b8f30d366638645b6e1ed426e874a9c2be28d93 945d5f0e5d4135214255115d8b1a2f20dba74a71 +7c1b58417616fa59602331132dc7035868c40027 105b6f40ce16aadb1d6e59a865b3ae4bb6f55019 +7c92281504598f2f805a821c65fafa8dc20ca66c 1a1b76255ae2a1e15e4ffe68b541fbeb745dd097 +7c955601f079db033651417eb0042d111999915f c21a93c9f74c73a5aed1c30374ccb4dfdfbabe80 +7ceffbc1e7b520767d2d19db4752d69891aae8c3 aea8f7fb74b84dcbb90a8555d1b2d79582b93365 +7d25faa27d990aae07e7a7434ee946dca3764f71 eefaeb1a67e4208fbbeaf120f53634641963718a +7d4fd2650a86d7e3537d5e41ee39c0e39982aea8 7d4fd2650a86d7e3537d5e41ee39c0e39982aea8 +7d55ea25fab1f009bc04d883595f9b91d4f77254 89ad23bf0ca234e1c2e0ae6d122c4456fcf5f3d7 +7d6f0b53656d2c886c8210f4d410e53364107e19 639bd8883a33426703f651ab5a177c6fa334ca0e +7d761e84fcbaf3497c3e1f37d1f4e0c62938f59e 80c166b989d53db7ffbf9af114d2dacd80cbec08 +7e5bbfa4fbfc0d3c1033df7a9a4ddcc5fab897b3 cd5fe5ebde7fa64b7e36dc3569330c205662f517 +7e7a9bfe24cf6cbbd07ab3e10078993ee5edec92 d6fd851803cba09149d41debbd08ecd54c431c5d +7eaa55579b15d77afe1b614077eeb6941b1d5b6e 7eaa55579b15d77afe1b614077eeb6941b1d5b6e +7f424697ea923d934308173abd813d577333b862 3d888df72b129e7e2e094bde4364e8f2d883752e +7f5301a2fae724dd49d06d53b951affe39ae1147 f4de96db541c79d9b0ece99c48e68a1b13b29206 +7fea01d37e335ff51649592be60a251d24a10cdd 63f8019784fb8c5ff69003ca9a6c1adafdb7b123 +818acb26579e1c1f8846105f912b0d34c36eb5c9 4ebf6810020e4c5aec94ba60861f4283531488b8 +81e0b907827cec536275d7886e0a4d7594430596 579d4e521d757bd31f2ad379d6e9a8257396bfd6 +83d5356c3555c1c04f4656aceadc4a5667ddb371 a31e0dbec5745ad5fc1302630eb5b974cea3056b +84cfb36238a8bdc96d497ad6069c7539184768a6 c9ca3c4eb1182ec17e5648da30b01a2a2a01c052 +84fd21209ddb07f3d2b4a4e722a470d0c6898832 fc2d7fcbf55750f3e59cddbf13d5ee8efd25d4d1 +85f2f4f2fbe75f19c91c3c1835f01f8c259f7eeb 56a71d225868d68dd1e264b6aefb7ece62871f40 +89d548185baadec8ac01aaec5b959873906cf58f ff326c215c90cdaadecaac26727998d2021e50fd +8a08568314d0def3c171d12c24c20b7363bc413f 91b6631c09268145ea5a6bd3f09111cfbcb357ad +8ae11e856fa916d4566368ce7395bee0dce19802 0966eeb5799c2891c992cf5c7b56d0c666dd8752 +8aea12e598b325a8d9ac99e971d0e5e12b693769 5c7a83ce41440a8e59ac23fed495d20fc565be44 +8b432007eb765bfd6971e7d587935992bd65843d d425caa231f004dc88f3f8adc12e85b7baf47759 +8b5d066454462014b2344634720a0c246684f7cb 0420dcdbbb7527ed7caad1d1d6421c3b8f87b2a5 +8b64b98ba38851d99ce79569ba5f4bbfe7bc624b f240e541778367037a07522e6f88cea2591a9204 +8baf1de0aa687767316724c71478254c2ebea2fb 76f96aee1f0127991a0ec54bd89c8a739c8146b9 +8bd434048619097aa4ddc1498169b1917c703760 4a03f46e4c64bc83eea9aeb860d1718df23d1a37 +8c3651aeea81567f539ec54707ed2cbe34f37580 32411f65e95a30486c8b34194bf7859213314e99 +8c60d1d2d68c0bf9fb1e3eb8809b3496c8e337d3 f50062fd46af911abf381e234b2d1f32f8c28d1f +8c68437506c7220ff934b19021c2ba29a0bff12b c566add812479b7cf8845c0d9e604d6d0a37dd7c +8c6e005106d6253d58e7cb8b79c203fabfa0f8f7 1944bbb7cba5181f52e1f2e7f3541dc552490c9f +8c88d52b6ce637275e4c496e9f97d8e1e1a503de e28c5cf45f2914f80ba35dfd275468084f1ae5ac +8ca20e4700ed2e69485e6933cb7943c9346e9ff3 71f4da383876bd20e9ff3cb41d81dada554628d1 +8d5a1aa53b0e9bf5ee613fb84682650adfe80f21 979093ebe99070feba000ddd9afd24d0dcf4251c +8e5102be876878959b929e4e19fdc90050ede344 803fdbfd24acb3d70ee8a6cf1a845bd3709689a1 +8e51ff1f42d0500f72d6249131aeb0f3e61126df 9dc0b17c5690dbc8ad037177444d6b9fcdac68f3 +8e77fc478b3dd1cb2bdad115773607267ee70808 e4767c7b27ae951385edcb030de5987e2b460c06 +8e7fe3e13ac0c75dfcca0ba7313dccafb1a093a6 1a2e2348ee1cd75558db83df8fb4b09bf5c2277b +8ecce0bc72e297cb1fe81ee97e0816b2e40b383b fd279762e7a1fde58838f3f034b4bd12187dc452 +8ed2246ce3e0369da5eb5a880a9f127ac8244a8b 2a9f9e964100217bcc320bc269081a64997910dc +8f7591a469770067823095ed5efdbeaee6f308aa f34c6b4d5aad6cbe42f4667bf08a3e7e4b08330d +8f856de586365427595cef6e277378316cab004b 8d30db3f2531e2c1f30083be1111fa16b5919db0 +8fe68510995c7fe435122fcb97aa302ea365f92f e059bdc7bc3752d94c3e874e89d5f3d0f180c9c2 +8fec9aef0a46609d831d4a0bf5bc44b380efdcc2 39901b122d7ed67c05e361253c95fb5b57955b0f +90bac8dec6577bc44ee20d9b733355ff080e9d3f 22e4403524a3e7cb6f5465afb16b9efed392f111 +91055821166f3b1bafe0f1c6bc66d644526bb644 91055821166f3b1bafe0f1c6bc66d644526bb644 +918f77846ad83033b1b86c7e77deb3d262f2b06a 0261ee0feff7eeea1f569dc17891b6353948a49b +91ab1ef2df1547b88f233eaad9643dcbf34631be c81c9d1e982c1021ac0bf23535992dfa4b288064 +91c81db8544899c6860c5de2eaea6a8d4922e065 c2fa53f2f98ef1e451d69d6821a7d04435b1d173 +92297867e1986297779a45c1bcc4df225098cd27 ab8fb53291633c7d727002dc0e255c81b3415bf4 +92e987975002bf821f200c01c5ec2d7dcacab424 e358ba9aefbdf941f6356acb97e4d8543a178c49 +93013606c0acc1600794b90642906e0c73f4e9a3 93013606c0acc1600794b90642906e0c73f4e9a3 +9329ac18b0e72487567e5671e394f06237eaf564 5503a3d4f10cfd5b4a8972251ea02f343394a828 +9350dc19330670599b7a1410a08e5d3c05188f65 972dbb5c7199c3f4317a2b5ab2942b09bf645b22 +937518e4036a3e33e9ab6ff0d2338be5a33bab8b 62c2b1bbdb012d0ac6b4dd37812b3e26b57e959a +93a91b3c7bab0d579c0893102f642ff8258868d3 1e563e3d9a88b9b13a0c9380f0f5bcaf7b63de05 +954f03744388da374ad901d9650c7c879b22f56c 3c44cf0c4c5af2c0421f4f65cf426d3f3eadd4d8 +95aeb1609f906f60dbce2338b9e51d228eb80280 adf2518e3366e5114b9f4ce892d598f731d672ca +95c48ec2151f9450e05efdf192a7b6301ef1a336 e3fe4ad79611b51c93a22eca8b4154f13f040f21 +95ca72b241f7f406373d236ed0239987226df227 5676d1f15781c2b8fab00332404de870cffdc33a +9613ae02efa6f473314a3d4222d8614d4d6839b6 21161a96c36488d30a3831f1b7429461a46aa51a +96b6f7bbd999e5fd535e3478eb035756b40279be eef6f06a1f6a3571748dd252fd20e49202840335 +96f02e2be91fbc1918e80ba33ddec7128c75e3c4 cced3307b134a2d3f0b30457fb349c916b801e75 +97673e4a928a64b598a6abc4d9ccc4ab4ca86f98 53cabc0504a6f1aa07fcf6a33b7c65eddfcedee0 +97c14b39a848a0015c2c82709f959280c2db31b6 6268df5b63328e7bb8b41a5dfa45ec6ae836ead4 +98f5868f37ec4fa80e3fb84c385c7da7e9ac06a4 d51878032776ec59a3c28525cfecd05ec4d13292 +9963f3eb2f1c65770a1a8bed56c6110cb56e9be8 0cea8a539c102792a26ea4f0f0a6769b87ce1af4 +99b48704b09fccf227ba196522cfb2c58dd3197a ed706da5caba3a4b53b9446d8ab83e1d888a99e3 +9ac2935dc3a6d9e1dbc3b062dfc9779cafa6b897 7df81427e37f37f821146b8e4576afeea00cfcfa +9bdb40967f89afeb49fd77929c909c5ad60d1522 137e45a1e73f81628ad5fc6e2727bac0b34ecdd3 +9df0afbe319da019852b1a660b4f2b1438f21f36 ad7681a057256a291759bd23a41e43176a6fd56d +9e291e44f3ee7ce9139f9e87ca71b6a9a38f7feb 2e81fe092c3e21b9eb3ecc62a18bdd3f258a5dbd +9e351e75c431ee787e0b58aa828999ae4c82d2f9 32b43c1eda698eb9e6d61964ca25f332c006158f +9e3b9251f152796fc7aa4ccf2d81169ad9d33970 9c766977f0a004cdf29286627ae919903dd7d279 +9ea90cd454e4e7032529d373ef472a74ef2a3ea1 d45ccc2c9fdc3a88bc0aba36cd357e568bf579b8 +9ed0d9d191e58c2395f1f5234ba6e6aede710b14 88c4d58305882c167988ec2aa13d3aabe6df9514 +9ef330645bb8eb4b66b0ba303a8e9e39cef77ac2 38984d6369bb283e6a571554fa3c06d07ff24fb6 +9f1540e2e02305e46d826c901594ac432a46f0fc a7d09605ce4434563e034c6a456197ee3c2ee629 +9fb7b1e103d076a5d97e25d7aea2ba38eb521c78 0491885bbeaedbb9804abd2c1d99ab50e6e6576b +9fecd78817eea57e8d8b10ffc43c1bfdbafb7313 616d1385c412329712105b4d01c87d049151ccb4 +a0516e56d6467cded6868afb7cbe2b95dae531f1 3fba9fc07f04166a11a26b4eaa95c843fb68486e +a0533aa26bb28b98d4048d79b4c4bf5ae0892052 184005139b320195a03a3b004605e8a81eaf8c01 +a0f67692c2f690fe03b628b703306ddd4be02944 a20ce606849bb21dd7cdbd0ba31109689b233c6e +a11f4450585a7d02beb786cde618809d05d3de5d 91cc34a7e50d098fc15180bf526261a81627c2d5 +a16fb1be1d96b7112fb0bb2062b175c11348f495 a16fb1be1d96b7112fb0bb2062b175c11348f495 +a4024f70cc6093c57970bb4ce16545af97f5166b a4024f70cc6093c57970bb4ce16545af97f5166b +a480bea774f0a8f4957186f857e30c1b13607f5f 1779e6219fabefed2671be9c962b2d1aed48ab56 +a494080c85c1f962211b94da6bea99475847d1cb a494080c85c1f962211b94da6bea99475847d1cb +a4f96e72b61e90869df4318dc8d02b0c6c9241fa a3ff64fe0e789b8d036f78d788fe01d1ca1f0725 +a59baaacd50588ccbcb3252b0d28a7400f808646 648a901bdede16df6bde59ced8eee336cb0d9721 +a5dcb0d65560b72a0f6a9a2acbad9c84174d82fb 2257f0ad0ca5e6e9eedf9ad90e75461073787f12 +a641824c6a585831cc2f965149e1c2e0ef4d8e11 da100ba9c1c7d36ec19d77e570ca1e32f2fd06f1 +a6451195b26ce3fe45b730b886290711c31ea3d1 e6093dad58f2232f6753b77502bc591eb399ff6e +a6d5f5d640e8d5c9840d431a356f14bf757ac4a0 ed545da3105cc515a5a94800e8972c84095e1607 +a6ffa229100bd171a4dad32ec7e73019db859333 b9cd602c132b35046b89f9d1fd04b8d9b3c221f1 +a79bb78abd9061c38b2db44441ae9cd162a3ea75 a79bb78abd9061c38b2db44441ae9cd162a3ea75 +a7fc89ccc1a4cbf71e82a5448864d77ae667875f 7015bbb6e9af80858bd296f377b41808b5383128 +a8271a7da4e5cdcd6c420f19dc66177dab1ffc9b 43026debbd1eb7d73a195297257556b3f7c7a71e +a8b074fc452b18bd29347832004f5f6354262161 a0d5010b5c7a111ecae95f43533d1f715a40a570 +a8e48651cdabbd7cc821c76acaa7ed49f2ee489e 12fdea8e1dd5d9bd85336f30b2925213bd8ac90a +a987987ccf194b34f1f3f396078528303badb534 f627e7b86744d6324fc78f4d86a5e8defe15d5d9 +a98a5987f4b0bcfd9e46206f00e7b0f796ebccee e556534b8e4b766a9ffeb14702fc445e710db010 +aa6431ef0ea1d1cd0b968aa06d6661f89b371346 5bb53211279f9f62c52f07f210212b256906d3d3 +ab078d4c80814d1ca010c38549f90e1ee09a9fe4 fc772066e7f0ca7ae0ab95d26c146103d9be7956 +abc68d26728c868bcb3fbf6634e10605e55bef15 157bffcc4c45205fdcd6c12b85799486b95ced4a +ad60ca934cddf573442265b683c5d226313bd0cd bc73365498029ff012343d0793aa1e6c12a25205 +ae013feba123703fa3b141a1c2a3a85bdb9bcaf0 938e68753f56b2a264f4994555f53e581ec288c7 +ae0af5de0f501239a48d54991d47d2517781f696 8004be4ecf6bb2e28d9047a0e712111b319436cb +ae5246fb090d9c382b7879d0aeacd3996a4cdaa4 3c48f00f9813c6b9fb35242aa30da6d5a14eb32a +aee4d2f7566b869ebccc48343a45a172a9311a71 4223e6667f8353f47d33979853df89619992fcc3 +af7e61b1ae0a7c518d5fd70c7f97a1900f50563e 93c1059d217beb463f9235fd601b780c458ec3f3 +af87db5ccbcb5014749840ead5bcb59d79ee95ac 435d29e9b694b79690b1df107129c2a8244b7e7c +b0fd9963c7f2ff2deb7e6eb46fcb33c011ebfbd5 ca24a8057624d9a4fb0aaff59b347468e84eb860 +b13deec0bae8ddb9761ec492addc6b3a6e160171 68cc0a7e1a96d64d54ac5d6aac08e32815ea2e84 +b19644b05dada84516c4668a93377701318ee7c8 7c99ea9b1341d26aa9e29f3dc5c5a375e414a5d9 +b213fd76f1c3de24a364273c0986fc44bdd74605 58e4f535aca3fe9c175d0f02a497113e88162eac +b5a96e68568ff4bf5c98817eb203eb36cbfb89c8 7691ab294a6aa6ad9d584436aff2a70d0c13aaef +b60e01ba9d162260cd72e76e3894cdc35b496940 200c6ba1bcfd001686b95f48e6725313f56e57f7 +b612b41fb8a0102667d6241130295e4a9aa6a0fd 9815643d2e1496e6206d739b224ac86cdaa01822 +b61f2e59914d16418db9e41fffa1426f9b0be3b7 a6a8b15082ad60b50fd44feb88847e5e673123e2 +b65a09b792efe791fa885c1a42ee2cd557615110 b29a41727a2b8de51cae4338a8a245c74a8779b6 +b67d969a0cae44e7b06adbdc502dd9462c4484ff 09eaed700e63a868afc4f999391e61db0c0568bd +b870d13a3bec6b0727b123f10ae6c65ba856da89 ded69f07b9a7282ee857de1a6afee206f43bca2e +b888a66e286fb8181eafbe1657641337b2c5a28c a87d1bec7e76f84fe1e2ed528b6b66606159840a +b91c43c04d145ea7d39452d57989d622b5b2440b bd1cb331db492cfbb7169a7322ee2cfa4fc42ef3 +b924f79473771607f36ae5c7058efe2b89b844c1 3d07ae26c17ce4ca48a6c5ed0c345f2751869d81 +ba86dce6120098bcf135cfc94d4e3c50522e993b c1ca520707a0578c9e953529e71735fa11cc0e58 +baa6016a0826cc0ad1e4c476ac913c2946a909f3 3923e95d3eb2232416319151fb75010c09dce57f +bb21fc9ce3a34aa4405df8655cfd544ad4bae3c9 e7bb23f2e5c9587ffae737968ffda6feba65289a +bb5f3302fb5bc788b84a95560b7c1cae4a5ef79f e736f78a4b0a87c15bff9cb7d48e768634a59ff2 +bb64cb83e5069bfb7c972075d160469f5a35f3a0 a25a7f950484f7229b9366ebd1a78181169ccb20 +bbda7e550c5d28e95dfcfe327ddad1ea8d3a7223 690e9502e9a260ac8ab8905e626cc612555b0ae1 +bc1849d76488bab2a0b3e577870113e2e1972cd9 68cf407e8c95653f1c2e610850f208734f6e56f1 +bc2325d6401565155df77d39eae0578cfca12668 dca5de67218508834c4c2279d3a27b1402d98f20 +bccaaec7ffcc120cec21682852ca8c44224e610f a2414fb568110e8c4ead00f4b2be00959ac66438 +bd385c982e1bf5a3a17182b1e5a85d368ab21ee5 d6035e9d881b7858179d115bd685cf637aa55911 +bf37fa4010204c313ba740e6aedd9123d13fa8e6 2a0ac3c29256d9f67582f94e86a74da3908071af +c04712a1a0589b4013ba0dbf55b5eb23ba923226 680fadaceb09d4dd43bb010c1b9d708b97a92bf7 +c08d166cea6e43da562df9c06a4a35bfeafe623b 642599ef8d827c20db46842a060b6ec57cd52dae +c0e3dcfbd06c45625c4eda28bcb4e2a0bcb7b618 f36c0132e9c3b502316ec3fa32155f9c59c0e320 +c11a615c1c621d1602ca72fd6047b2fe62333e33 e8c70bcf75a726947efb8fb2b5c3de37b80fae9c +c13dbe34bb6ac21cf6eae6d9c4940771575cf599 6f467e8521b2c12782d0fa62097d881310cbce11 +c304aab4242f23e9d590eb8b754c8444ea316c3f a578d6dc4ee14ea2eef3fd76d76856a1616c3b02 +c3638c7f277213e57a3f65cc2d746749b8a7e96c 8f31bae8587869b40f257408adc972e1a83069cc +c40217225cff4d0686dc8ea603fccf7d1388657c c40217225cff4d0686dc8ea603fccf7d1388657c +c40f9bee01845e351a3a383b05853259db753ac2 2bfeceed8f993ff29851babd38225e79d4093f7e +c4b98a7d3247678c56f43e1f6679c1e2ff489796 b90580615ef67f6427070dcb32d69c8983a4f906 +c4d10ff25e063680edbd95608024bb75f482b07f 052e69ae1d822a52d6f6a86933ac59b641c5991c +c57dc9a679747400b08f146398f65ea852e8ba4b b39a5fa2ee3b7fe83389a42eba6bc3979eef53f2 +c668aef2150938cac76ce3bb2a796fbcb19ac7d3 bad3010fef98765ad81761c9a8d63744f64b5b44 +c6baf13a9df0bded9cb01dd6bcd475262e92982a bfea242bfa8577e1f5adbac2ddc1d5c670aea995 +c6d8d62ef11f4fc301b64c409aae0c50b15effe5 cfcf125e443a767a4da260c0c31abd4b104e7878 +c6ed982e61e0f7d2ca3f7a34d73573b45b564c41 8199bd5b96a258217a69a156df986376a2cd0899 +c748af0af6a0a2eda3d14c83d1342ae1e7713d13 92c102c08f7d81735184bbbc62cd3e4bae4762a5 +c77bd860e154e7df44e635c409116dfbb38b6818 5553a987267be90ed5660667c35726141923d40b +c7ed946c9d75d47d458706a9a37ddac730a074b9 09c0859075afc1f6002807c40172726483c3a7ce +c89516a425d113672d3d7564dc6cd9318bd9036c bcf4710b2c57e68c1d15b22624389e4d6d75d77b +c8f24059f012b72b3e86d2af5a63c165d7234eea fa7cf2c7a357d7ce489d7cd465941ae08b44b691 +c9366b77a78197188fa12d4155cd8a12a8bdb860 00c93d1b83037661eeaaeb75751deab55f83dd50 +c959d51f2f00e233393edc7fd0582ed4f719420b ef8c198a4dc3a28570cc3a29cd3f5c0f40b6bfcf +c95a7f7fda06a10793f020531bd17e062a6e83ac 345ac1e868bee56014cb6e249e03c3910558c673 +c964eadbaaae57416cc7f2e94f0b32683090f28f fcfa0b985da64d66c1952e31302e5df8fc6e0c05 +c9b3d723272b9d3aebab9dd9ed81b2705589a388 497b24041c26e716be6dbcafff9f08c250843633 +caa2877973ca97b5a25b5cfad0a0412699d0027b f92c67754d357a1c5401171c9058948d487772de +cbd6e0c385abb1e4dd3e39bdb4c77cc56845a249 ec279399d9df9ecc938e6954f147fecc7c298eb2 +cc5b44d3b9a3b9f5a24ebd5db112c9c5de8506b6 7d3b5375e6b049ffd788d0c5e18108a004aa17c2 +cccb293e2c98409d1e31cfac89d68fb56f58a2c0 323a895b991fdcf8b32d77ad3723c3b2645f6b77 +ccd5bc904cc4c5be2ab78cc192e2cb0b3f9443fd 847b8efbdab68b38880ff0f59a8325ce2f2ddd25 +cd8beee9cb98859262e5b37da50e61928e9956b4 d6ea7bb91158f4624887a660ad8cacf895c9c12f +ce47232df60411ea1be5fd7eddf1e256d8e5fb7e b26b37a1d736365a5289437caa85ac263a443e4a +ceae686811ae8f77cc6dd462fe1364cda3954a1b 7ea310e0accbe828479234615e011279d1096fbb +cf284c70e37bb6b5fbddc30144f34abf09439925 168ff7f715c68254f2c7238c1d4c3fc6267e010e +cfc22e113a9e44ac9d56e0a380b37c28a31dd3ec 9fa478c96800d1cbe1bbe64c69ffc5f48bc39c0e +cfccdeee711c23cd76ebe13261a95c42ad87d200 cdcf362864d655ee8a80da89cd5c93552da14811 +d0310dd772fc7eb93ceb8bb8cd367f8b695e67ab d0310dd772fc7eb93ceb8bb8cd367f8b695e67ab +d03e343d1715a2156c5ad0c5d6603df630c160d5 3117a6b829a7b0240e9b3ee3852f7d4cb35617a0 +d0cb801ce41e28f815e6e89d7990839350a029e2 d90c3087c555089dc41a7a43d3c14dd3627a8e12 +d0ceb049bf0740338b8ad8569768566c69cc9576 d0ceb049bf0740338b8ad8569768566c69cc9576 +d12d8131726f58123251412e344cb01fce5f4f68 c589043ebc2499dfb83401d2c45dcd4a8f48dc39 +d229c06d0daad61526ea29d1a407ff7944c568d9 dfcd2d685bdd922bdb207f60f00ca6b86a1b64a5 +d32aee21177a063f9c3535c6eb52ef62bc7c3546 335914b5e090347160493fdfbc7861e53b94caa8 +d332662d08a76e44f80ab9212e9d20c192e761a0 fc6893dea6c09d269f205dcba6d58f05cd08c54f +d458b7f107db40536a8ce4606790be40c064fbd3 fb290696e872ec9ed7f34d28b5bae84b8f36f4f7 +d53c372fdb0b16f06dd288fed7ac01bfa2552d3f 585d6566d58d7597df2bfb0809fbcd71dedae31e +d5e6010e23232fa51bf2336753fd1a79c911b838 8cdd2aaca9d201b83a6e889353146d0f9f8c2aa4 +d62daaa840939374c924d61f79ea98dd1f85845f 0f7917a45f599e22791074ed1292dfb8582e0919 +d6abf66bd3ea62a74a119408610099673c07edab dc69622a2f7b299e5b0b0a34aaf4fb61e5117f53 +d6c4b5f36bc8c4170dee6e3cce88a644e0506d7e 09a264bb609a3f0692053d8d33451bfd2843453d +d7e2b43d5e82e0af1be0e9bccb927edf6ac11b8b 31fdb0d05221117150a63b215457772ad586fd86 +d8c7e429879b34772f883125105ffaeb2b7268ae bbab02ba85989c8b1d7ae07761e5cb2e7ba222df +d8f3f92eb4918429d5d7fe3a80e7d5ddde33e81c 39133d4406d90f5bc397fc9b63e4df54f6ed0f75 +da967e0ce3990d4f136dc116fd71dbc7a32e2af4 8d7ff50eaaaa965649ac858330039f534b346414 +da9a3e38b0ba05554a3a2c48bdb0958c192fa544 5a1b5dce2fbf14933dcfb935c9b38b66fa735686 +db46b4964730fae11213bd47c5d3397749e96a12 e593287608065f72c26c487ca8c953bd560b126e +db944551979dc357c0ddae0baaa4be7fbc10debd 6e653f4f0a573e4a1b7caca2d922bddc51e14e4e +dc52b6aaa3fcba168f9eb68515527fbc01bbd426 d3f186917f236e848e66cbb9064651f02757412a +dc8e00a321de6157b6d9b930109951645573b27f dda106f2e2a9d2c283eb6001b3968346c47ccd84 +ddbef17988d01f09042cacc4e6b3e6928e68f336 ddbef17988d01f09042cacc4e6b3e6928e68f336 +de0037dd8d2f81cd9296fc02f4ef73478b0b8908 6700286b8e6a0fbaa49de313ebdb77d2272f2bc0 +de8b87a9741dee9e97710057ef2ed98677402bd3 dce8c0bde7802ee06a468e855f45563b6f617abb +df87ad120f9f43cd004957d94bdd7e8020b16f30 50563411b40e487dabd95519fb1b96305295d1d5 +e00b87b3c39b55c4f743a2df64a0b3a6c8edd348 f3aeeb77905c4dc1d12998c9f167e2da8f4cfb92 +e0c5ed8f12c6d4ab796025fe08eb34179b8a9ba9 c9b89f4102a6445a91961246b36f29c235d1531d +e113c37bef7578b6bf47043570678d6cb2924f03 c5205297a3ef06fbef1d46b118ee8483fafef5cf +e17c682e377f5287b07e60fa440cabe5a2960a91 be4ee5120d5c338dd7598d51fb2d24dda7b13463 +e29010ea173d990a2b7b2fc3f4cb4b851a894be1 a37cd345426717296c3a285c6f310e85f54d1483 +e2d2496166e74c8ca24efaff923c81b3819c97cd 884f67b11ab9720f668bd189e52a817d074683ef +e2f3d8429157b5620d7781853dd79f4296c50f1c 3963260b8ab770d7fa798a5917dfb84162a8b828 +e4398b9010e76f5c7e164dd85faa6335a376989e 5bfda85753377980213ad2f64afed8a606d7b1b9 +e463885c5712b77ca7b5d9e0313bc3bc47398026 5e2890db219f2d89a8631e48a6bd848c427cc733 +e53c0ddf572281c3ce59d187be5edb5607d747ea a0629ccc05efe57a651280a039a5691fa1420075 +e5b808e2349a8181f1937c321e8dc79270749105 00426e2c0236fcb5700959b4d62889cbe96126e9 +e6086591e87bfc5f43d2d7ce80b4c83d76eb063d 17228ca6cc48c3c8f76eba5f419b50901c2eadd2 +e63b120ea46ea0396ca0655808f1c45bad4951b1 d789c42f74b29dd8e77539f2856c36a0adee2a51 +e7786a484c2aae0104e30817f6e9cdb8182118d1 65b7b269f6592115a662ad8ecda77c178886a730 +e8af52ad0af00cb6c74dde42d73c9cdc341e9a74 6e1cf0cefcca6124404294c6f3116dccda777d3c +e8f127e9afe7163f215e638974d38e91d6227290 fde9ba9be72789d0510a2181ad5b2730e67c7687 +e942c9ab3867fc1c654e5bf50a6ed0610944b4a3 02c6e6629534574607a13ca776f73ebcfd31f454 +e95499342fd7a178017d80fe4b3c10f6d823397f 13893c2b5305ca80ace42139a70c60c6ef0e4e66 +e9cfaa0a72c68bb1f164eb8091f2b8b0f7e943a9 d852ef643a9c3b4308f4ba099707504e039a3575 +ea8822be65031f982f9256bd1de8b9530c18bab6 9da589b3b7957b97078311a69a3e19f03c7c4016 +ead6ed22928fedbd84568d6224797ea291f10b12 f677a156b09533bc509f19f0e2fcd7ba7185eb29 +ead7f6c2fae5e8f09b186ed04eab865f37f8794c e0c8a93e7c18a005747d7693f69bab1b129698a7 +eaf42aa9a76ed6bce2f6d39979c86b8550cb2203 fc7e275284a13eb6388f0f8c1b80ab14aca50709 +eb28e521ae4281bf203722d9fc500b3185d01d6b bd1ee3cc529fcd1bcc301ca4b886c17efd625ca7 +eb30da4cf970fec18b4647f4895669b3d6a8cb66 3ff42e9e31ede696f525978a3b4f4f9a85784731 +ec54fe32dd851943c3cb0eb61fd5757965660167 0a08f2b197e01cc92fc2d6accc64c9ed84e395d0 +ed0784b2cdbf489ca38750d845bcf553477dd1a1 2aef7dc4a18332053af64cc679f8a57aaf28e2d2 +ed8ea67e3ed5e5ff832d65ed4cdce80d8ba7b236 d6fcc3348777042da65c78f2e8e8e2d1665a4e55 +ed96347848b963f94c5a236632565ce8bd4483bd 7fb7c82f42ee6cfbd1f99e38ad74dcaa9bf618f9 +ed9eccf4672c5fdfe06b6ead56f18db9205ed904 a7110c040978790ca3b7051a536bf7e51221da92 +edb9a740dcd70de854ce7759307d58e2597c6cae bece2181a1218176d43878f8a2851618072dbbcc +ee880ff610b3755dc28a7b80adf862b0e5fab288 b5e41ab77d24ef223961420724810cfecbd1d598 +ef6c577ef43ff3270370df8e8c3df53bc2eee79e 9d1a6291af8792938f258687b44cb84448148804 +ef997b5ff80ed9c6d82853a4623fe15cb0cdae07 3c9e6f7f515780ca5bb56a17ecbbe6f97c90be5f +f05f71e0809f4e65e74e8c1c061a862049ca5914 fb32954804bfc18a71e32798c601472f1cae3504 +f09d4583d6b8d3bbd69c5937012f11b4952f0c0b 56333e20f9346172ea82c1300e8b0cc120827a33 +f0b91cc5843e8825ccd1fbf70cac8b67c9f11458 c1142806a1da746403c42e3d77a0a385340b5815 +f0dc98136ae34b34465b152be1af84faf063eb44 97284b971867584f2743505d7145ba6785c25dda +f192abd13a1aca8025cd5192ae7d0a41d372c2e3 652cdbfb32afe0b926360650a5ad5208ce472745 +f2224ced23b7b78e2d4ad730b9969d6d139eab86 26545f6f703d1675bca30431a7f2b74c8fb67a0f +f3733094fd4da40142f7f89f2216de8ef4ef632e 3f5c18cbd767eb3d895402aa692514f2e764f6bd +f3ece1a982e225f3e6ddda2250186853f39e745b 7aabd6dca103686cf56cafd429780f0506c42a48 +f4173fbca4c98457c16d1b1a98a5244a5852e270 f4173fbca4c98457c16d1b1a98a5244a5852e270 +f474d0404468930d6bf39fcd5b4b23523836bd23 feaf81a4e8ad4effc21a721bb51bf08f54e08af5 +f4a7dc0ce4821904f52dba1424dfc92f845ec3b4 2eff9f68b6b857e44219921074a2538537919193 +f5601e41a2354e450387a23eb21d7b43c935b84c 73f91f7bf97ae213e6f1f66e796684c525f7e877 +f67a9cccd0e1ff1bb0c3d2670f5ae2de5cebc657 8a69cd28894387e1a06838c315e766e28ee04613 +f69d0988463b25a4d462a7d95a68edbe3a743207 61ab5896139d96d56796e8a769cae5ca6722ff3b +f7357424e1f534e00f56f83cb8cb23d66e3d41f8 08c13cb53f1c483dd5aee6de9d963eeb920f70e0 +f7abf62aab4b94088fa8765f31318155460f3fe4 27fa99d31e3a1124f0c98009c50c372620c2fedb +f7ac7aecfe8329e55cc9437615cf96093945ad6c d8875e0ee1eea1ebcab209fac130785fa50a3364 +fa02a360672f8e374d7cf9e191cea16da2e3578c 47c49f2563a75b23e3ee18cbb2dd0aabac39ff27 +fb9e3ed03e885fc21104ca0fbebd7941def3823a 2fa16bb2b4e7fa37c30ee6ed260f082681e33f15 +fc1c0fe06e149beb0f6d5f4224b5147aba4482ae 9af867d491756123582d46cf717e318c903a0693 +fc83ea93e7d7266916867bfb048022723f583c2a 4895e164c54313863f7b337f4a86fcdf6015f9a9 +fc8c0321867ed985c6f6302024cf7f3b5489a0f8 d572201893cbec0a8f2033bb8f71c1fb04270d09 +fcfee0e808491ade6d978a8a2dabc2b08ad4e26c 3c1b5c867b5d10b966df2e673197dce016f731d6 +fd82773901f9812eec394da8b18453d829ace230 1c3d681c2ac50b31b44d8dc488d3e793de521721 +fdfd01a06175277a2e9dbeb4c15a5d82501019a9 cbc84d4ebafa1cc1526b4f2a0d1b7049fa1b9786 +fe312d426f6a18f0b00a8102c47efd55a4c2ded2 fd25ce989c72ae144b7aed7628a1db64a57448ae +fecba40b3ba0aea0b96d060666b26cfac66afa3c 3b5175e537719fbdad213d999e14d5e14853d202 +ff1ccaee1ce94bb113d7f76288cbf0ff949f12b1 1f0db051e86a5de5d40aae306e51c82302ac1c3a diff --git a/docs/requirements.txt b/docs/requirements.txt index aba6561e..8f6144fd 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,10 @@ -sphinx -astropy -astrobase -numpy -matplotlib \ No newline at end of file +# Documentation build requirements. Modern Sphinx plus the real +# dependencies autodoc needs to import cuvarbase (pycuda is mocked via +# autodoc_mock_imports in conf.py, so no CUDA stack is required to build +# the API pages). The plot-directive figures DO need a CUDA GPU to +# render; on a GPU-less builder those plots fail as warnings and the +# pages keep their source listings. +sphinx>=7,<9 +matplotlib>=3.7 +numpy>=1.22 +scipy>=1.8 diff --git a/docs/source/bls.rst b/docs/source/bls.rst index cbf82af4..55c6dbe9 100644 --- a/docs/source/bls.rst +++ b/docs/source/bls.rst @@ -24,6 +24,8 @@ Using ``cuvarbase`` BLS A shortcut: assuming orbital mechanics -------------------------------------- +The derivation below follows Seager & Mallén-Ornelas (2003) [SM03]_: their eq. (3) relates the transit duration to the orbital period for a body transiting a star of a given mean density, and eq. (4) is the Kepler's-third-law step used here. + If you assume :math:`R_p\ll R_{\star}`, :math:`M_p\ll M_{\star}`, :math:`L_p\ll L_{\star}`, and :math:`e\ll 1`, where :math:`e` is the ellipticity of the planetary orbit, :math:`L` is the luminosity, :math:`R` is the radius, and :math:`M` mass, you can eliminate a free parameter. This is because the orbital period obeys `Kepler's third law `_, @@ -84,22 +86,367 @@ The frequency spacing :math:`\delta f` needed to resolve a BLS signal with width where :math:`T` is the baseline of the observations (:math:`T = {\rm max}(t) - {\rm min}(t)`). This can be especially problematic if no assumptions are made about the nature of the signal (e.g., a Keplerian assumption). If you want to resolve a transit signal with a few observations, the minimum :math:`q` value that you would need to search is :math:`\propto 1/N` where :math:`N` is the number of observations. -For a typical Lomb-Scargle periodogram, the frequency spacing is :math:`\delta f \lesssim 1/T`, so running a BLS spectrum with an adequate frequency spacing over the same frequency range requires a factor of :math:`\mathcal{O}(N)` more trial frequencies, each of which requiring :math:`\mathcal{O}(N)` computations to estimate the best fit BLS parameters. That means that BLS scales as :math:`\mathcal{O}(N^2N_f)` while Lomb-Scargle only scales as :math:`\mathcal{O}(N_f\log N_f)` +For a typical Lomb-Scargle periodogram, the frequency spacing is :math:`\delta f \lesssim 1/T`, so running a BLS spectrum with an adequate frequency spacing over the same frequency range requires a factor of :math:`\mathcal{O}(N)` more trial frequencies, each of which requiring :math:`\mathcal{O}(N)` computations to estimate the best fit BLS parameters. That means that BLS scales as :math:`\mathcal{O}(NN_f)` in the number of trial frequencies actually searched -- a grid that is itself a factor :math:`\mathcal{O}(N)` denser than the corresponding Lomb-Scargle grid -- while Lomb-Scargle only scales as :math:`\mathcal{O}(N_f\log N_f)` However, if you can use the assumption that the transit is caused by an edge-on transit of a circularly orbiting planet, we not only eliminate a degree of freedom, but (assuming :math:`\sin{\pi q}\approx \pi q`) .. math:: - + \delta f \propto q \propto f^{2/3} -The minimum frequency you could hope to measure a transit period would be :math:`f_{\rm min} \approx 2/T`, and the maximum frequency is determined by :math:`\sin{\pi q} < 1` which implies +This duty-cycle-aware spacing :math:`\delta f \approx q(f) / (\mathrm{OS}\,T)` is the optimal transit-search grid of Ofir (2014) [O2014]_ (his eq. 4, with oversampling :math:`\mathrm{OS}`); it is implemented in :func:`cuvarbase.bls.transit_autofreq` and :func:`cuvarbase.bls_frequencies.keplerian_freq_grid`. + +The grid is defined by the recursion :math:`f_{n+1} = f_n + \delta f(f_n)` +from :math:`f_{\rm min}` up to the first point at or above +:math:`f_{\rm max}`. Both functions solve that recursion with numpy +(``method='vectorized'``, the default) rather than a Python loop with one +:math:`q` evaluation per frequency, which cost 0.2-10 s per call at survey +grid sizes -- more than the GPU search that followed. The vectorized +solver converges to a fixed point of the *same* recursion (a continuum +seed followed by defect correction), so it reproduces the grid length +exactly and every frequency to within float64 rounding +(:math:`\lesssim 10^{-15}` relative, measured over ZTF/HAT/TESS/Kepler +baselines and :math:`\rho_\star` from 0.05 to 5); the float32 grid +:func:`~cuvarbase.bls_frequencies.keplerian_freq_grid` returns, and the +float32 grid the kernels search, are bit-identical either way. Pass +``method='recursion'`` for the original scalar loop. + +The minimum frequency you could hope to measure a transit period would be :math:`f_{\rm min} \approx 2/T` (Ofir 2014, Sect. 3.1 [O2014]_), and the maximum frequency is determined by :math:`\sin{\pi q} < 1` which implies .. math:: f_{max} = 8.612~{\rm c/day}~\times \left(1 - \frac{3r}{2} + \frac{m}{2} -\dots{}\right) \sqrt{\frac{\rho_{\star}}{\rho_{\odot}}} +The leading coefficient is the surface-orbit frequency :math:`f_{\max,0} = \sqrt{G\rho_\star / 3\pi}` evaluated at solar mean density (the :math:`r, m \to 0` limit). ``cuvarbase`` uses the value ``8.6307`` c/day for this constant (see :func:`cuvarbase.bls.fmax_transit0`); the ``8.612`` here is the same derived quantity, the ~0.2% difference being the precision of the adopted :math:`G` and :math:`\rho_\odot`. It is a *derived* constant, not a literature value. + For a 10 year baseline, this translates to :math:`2.7\times 10^5` trial frequencies. The number of trial frequencies needed to perform Lomb-Scargle over this frequency range is only about :math:`3.1\times 10^4`, so 8-10 times less. However, if we were to search the *entire* range of possible :math:`q` values at each trial frequency instead of making a Keplerian assumption, we would instead require :math:`5.35\times 10^8` trial frequencies, so the Keplerian assumption reduces the number of frequencies by over 1,000. -.. [BLS] `Kovacs et al. 2002 `_ \ No newline at end of file +Sparse BLS for small datasets +------------------------------ + +For datasets with a small number of observations, the standard BLS algorithm that bins observations and searches over a grid of transit parameters can be inefficient. The "Sparse BLS" algorithm [SparseBLS]_ avoids this redundancy by directly testing all pairs of observations as potential transit boundaries. + +At each trial frequency, the observations are sorted by phase. Then, instead of searching over a grid of (phase, duration) parameters, the algorithm considers each pair of consecutive observations (i, j) as defining: + +- Transit start phase: :math:`\phi_0 = \phi_i` +- Transit duration: :math:`q = \phi_j - \phi_i` + +This approach has complexity :math:`\mathcal{O}(N_{\rm freq} \times N_{\rm data}^2)` compared to :math:`\mathcal{O}(N_{\rm freq} \times N_{\rm data} \times N_{\rm bins})` for the standard gridded approach. ``cuvarbase`` selects it for small datasets (by default :math:`N_{\rm data} < 500`) for its detection properties -- every candidate transit is tested exactly, with no binning or phase-grid loss -- not for speed: on the GPU the sparse kernel is slower than the binned fast kernel at every :math:`N_{\rm data}` (its per-frequency work grows as :math:`N_{\rm data}^2`), and it needs :math:`\mathcal{O}(N_{\rm data})` shared memory per block, which limits it to roughly 2,000 points. + +Using Sparse BLS in ``cuvarbase`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``eebls_transit`` function automatically selects between sparse BLS (for small datasets) and the GPU-accelerated standard BLS (for larger datasets): + +.. code-block:: python + + from cuvarbase.bls import eebls_transit + import numpy as np + + # Generate small dataset (e.g., 100 observations) + t = np.sort(np.random.rand(100)) * 365 # 1 year baseline + # ... (generate y, dy from your data) + + # Automatically uses sparse BLS for ndata < 500 + freqs, powers, solutions = eebls_transit( + t, y, dy, + fmin=0.1, # minimum frequency + fmax=10.0 # maximum frequency + ) + + # Or explicitly control the method: + freqs, powers, solutions = eebls_transit( + t, y, dy, + fmin=0.1, fmax=10.0, + use_sparse=True # Force sparse BLS + ) + +Both paths apply the same per-frequency Keplerian duration bounds +(``qmin_fac``/``qmax_fac`` times the fiducial ``q_transit`` value), +exactly per frequency, so results are comparable across the +``sparse_threshold`` boundary up to the two algorithms' different +candidate sets (a binned box grid vs observation pairs). For +:math:`N_{\rm data} \ge` ``sparse_threshold`` the periodogram comes +from the fast shared-memory kernel (:func:`cuvarbase.bls.eebls_gpu_fast`) +and the best-fit ``(q, phi0)`` is recovered at the ``n_solutions`` +(default 10) highest peaks; the remaining entries of ``solutions`` are +``None``. For a solution at every frequency, run the full binned +search with :func:`cuvarbase.bls.eebls_transit_gpu` or +:func:`cuvarbase.bls.eebls_gpu` (which also honour per-frequency +``qmin``/``qmax`` arrays exactly, independently of ``freq_batch_size`` +and of the free device memory; before 1.0 the standard path collapsed +them to one batch-wide window). + +The shared-memory kernels do not search a continuum of durations. +Phase is binned into :math:`n_f = \lfloor 1/q_{\rm min} \rfloor` +bins and a trial box spans :math:`m` of them, so the durations +actually searched are :math:`q = m / n_f` for +:math:`m = 1, 1 + \Delta(1), \ldots` (``dlogq`` sets the geometric +step :math:`\Delta`) up to and including +:math:`\lfloor n_f / \lfloor 1/q_{\rm max} \rfloor \rfloor`, the +widest box with :math:`q \le q_{\rm max}`. Before 1.0 the loop +stopped one rung short and never tested ``qmax`` itself -- with +``qmin=0.025``, ``qmax=0.1`` the widest box searched was ``q=0.075``, +and an on-grid ``q=0.1`` transit was recovered at ~73% of its exact +power. The geometric step can still overshoot the last rung: with the +defaults (``qmin=0.01``, ``qmax=0.5``, ``dlogq=0.3``) the ladder ends +at ``q=0.48``. Box start phases step one fine bin divided by +``noverlap``, so a box of :math:`m` bins can be misaligned by up to +:math:`1/(2 m\,{\rm noverlap})` of its width; boxes near ``qmin`` +therefore recover only part of their exact power (49-90% in the Sep +2026 audit). Raise ``noverlap`` (nearly free on the fused kernel) or +lower ``qmin`` before comparing fast-path power with an exact box fit. + +You can also use sparse BLS directly with ``sparse_bls_cpu`` (or +``sparse_bls_gpu``). By default all durations :math:`q \in (0, 0.5]` +are searched; the optional ``qmin``/``qmax`` arguments (scalar or +per-frequency arrays) restrict the candidate durations: + +.. code-block:: python + + from cuvarbase.bls import sparse_bls_cpu, q_transit + + # Define trial frequencies + freqs = np.linspace(0.1, 10.0, 1000) + + # Run sparse BLS (unconstrained durations) + powers, solutions = sparse_bls_cpu(t, y, dy, freqs) + + # ... or restrict durations to a Keplerian band + qvals = q_transit(freqs) + powers, solutions = sparse_bls_cpu(t, y, dy, freqs, + qmin=0.5 * qvals, + qmax=2.0 * qvals) + + # solutions is a list of (q, phi0) tuples for each frequency + best_idx = np.argmax(powers) + best_freq = freqs[best_idx] + best_q, best_phi0 = solutions[best_idx] + + +.. [BLS] `Kovacs et al. 2002 `_ +.. [SparseBLS] `Panahi & Zucker 2021 `_ + +Power-spectrum convention +------------------------- + +By default, all BLS functions in cuvarbase report + +.. math:: + + P(f) = 1 - \chi^2(f) / \chi^2_0 + +where :math:`\chi^2(f)` is the weighted sum of squared residuals of +the best-fit box at frequency :math:`f` and :math:`\chi^2_0` is that +of a constant (weighted-mean) model. :math:`P` is dimensionless and +lies in :math:`[0, 1]`, with 1 meaning the box model fits perfectly. + +The BLS entry points accept a ``convention=`` keyword (issue +`#17 `_) selecting +among exact transformations of this quantity: + +* ``'chi2ratio'`` (default): :math:`P` as above. +* ``'snr'``: :math:`\sqrt{\chi^2_0\,P}`, the (unsigned) + signal-to-noise ratio of the best-fit transit depth, + :math:`|\hat{\delta}|/\sigma_{\hat\delta}`. At the same + (period, duration, phase) this equals the power returned by + ``astropy.timeseries.BoxLeastSquares`` with ``objective='snr'`` + (astropy reports it signed and only keeps flux dips). +* ``'loglik'``: :math:`\chi^2_0\,P / 2`, the improvement in Gaussian + log-likelihood of the best two-level (in/out-of-transit) model over + the constant weighted-mean model. Astropy's + ``objective='likelihood'`` instead measures the improvement against + the *out-of-transit level* reference, which equals this value + divided by :math:`(1 - r)` where :math:`r` is the in-transit + fraction of the total statistical weight; for transit-like signals + (:math:`q \ll 1`) the two agree closely. + +These equivalences are verified against astropy in the test suite on +shared (period, duration, phase) solutions. Standalone conversion is +available via :func:`cuvarbase.bls.convert_bls_power`: + +.. code-block:: python + + from cuvarbase.bls import eebls_transit, convert_bls_power + + freqs, p, sols = eebls_transit(t, y, dy, convention='snr') + # ... or convert an existing chi2ratio periodogram: + p_loglik = convert_bls_power(p_chi2ratio, y, dy, 'loglik') + +Reported ``phi0`` values are transit *start* phases on the **original +input timescale**: ``phi0 = (t_start * f) mod 1`` for the times you +passed in, so the transit starts at ``t = (phi0 + n) / f`` for integer +``n``. Internally every BLS path subtracts the epoch ``floor(min(t))`` +in float64 before the float32 cast (the fold is single precision, and +absolute BJD-scale times would otherwise lose the phase entirely) and +the kernels work in phases relative to that epoch; the reported +solutions are moved back with ``(phi + epoch * f) mod 1`` in float64 +(:func:`cuvarbase.bls._rephase_solutions`). ``single_bls``, +``hone_solution`` and ``eebls_gpu_custom`` accept ``phi0``/``phi_values`` +in the same input-timescale convention and re-reference them +internally. The periodogram is therefore identical for ``t`` and +``t + 2457000.5``, while the reported ``phi0`` differs between the two +by ``(2457000.5 * f) mod 1`` -- as it must, since the phase of a +transit at frequency ``f`` depends on the zero point of the clock. + + +.. _input-validation: + +Input validation +---------------- + +Every public entry point in cuvarbase -- BLS, TLS, Lomb-Scargle, +conditional entropy, PDM, the NFFT and NUFFT-LRT -- validates its +light curve and its trial grid on the host before any GPU work +(kernel compilation included) and raises ``ValueError`` when + +* ``t``, ``y`` or ``dy`` contains a NaN or an infinity, +* any ``dy`` is zero or negative (uncertainties become + inverse-variance weights ``dy**-2``), +* ``t``, ``y`` and ``dy`` do not all have the same length, +* the light curve has fewer points than the method needs (four for + Lomb-Scargle, three for NUFFT-LRT, two elsewhere), +* the frequency grid is empty or contains a non-finite or + non-positive frequency, +* the transit-duration bounds are not ``0 < qmin <= qmax <= 1`` (the + binned kernels) or not finite (all paths). + +The error message names the array, the number of offending entries and +the first few of their indices:: + + >>> eebls_gpu_fast(t, y, dy, freqs) + ValueError: eebls_gpu_fast: t contains 1 non-finite value(s) + (NaN or inf) out of 600; first at index/indices 137. Remove or + interpolate the bad samples before searching. + +Before 1.0 these inputs were accepted silently and produced a finite +but wrong periodogram, an all-NaN spectrum, or a kernel crash that +left the process's CUDA context unusable. Because the checks run on +the host, a rejected call is *safe*: the context is untouched and the +next call in the same process succeeds. Nothing changes for valid +finite input. + +Filter your data before searching:: + + m = np.isfinite(t) & np.isfinite(y) & np.isfinite(dy) & (dy > 0) + freqs, power, sols = eebls_transit(t[m], y[m], dy[m]) + +The two helpers are public and can be reused in your own pipeline: +:func:`cuvarbase.utils.check_lightcurve` and +:func:`cuvarbase.utils.check_freqs`. + + +Data hygiene: near-zero uncertainties +------------------------------------- + +BLS is a *weighted* least-squares fit: each observation enters with +weight :math:`w_i = dy_i^{-2} / \sum_j dy_j^{-2}`. A lightcurve point +with a near-zero reported uncertainty (a common artifact of pipeline +glitches, sentinel values, or unit mistakes) therefore concentrates +essentially *all* of the statistical weight in a single observation. +The box that covers that one point's phase bin then absorbs essentially +all of the weighted variance, so :math:`\chi^2 \approx 0` for the box +model and the reported power :math:`P = 1 - \chi^2/\chi^2_0` saturates +near 1 (typically :math:`\sim 0.99` after binning) — *deterministically*, +in pure noise. Because every trial frequency has some phase bin +containing the dominant point, the result is a spuriously high, +nearly frequency-independent periodogram rather than an isolated peak. + +How to recognize it: + +- one point dominates the statistical weight: ``max(dy**-2) / sum(dy**-2)`` + is close to 1 (anything above ~0.1 deserves scrutiny); +- suspiciously high BLS power (:math:`\sim 0.99`) on data you expect to + be noise, roughly flat across trial frequencies. + +The recommended guard is an *error floor*: clip the reported +uncertainties from below at a percentile-based floor (and/or clip the +weights from above) before running BLS: + +.. code-block:: python + + import numpy as np + + # Error floor: clip dy from below at a percentile-based floor + # before computing BLS weights. + dy_floor = np.percentile(dy, 10) # or a survey-specific value + dy_safe = np.clip(dy, dy_floor, None) + + # Sanity check: no single point should dominate the total weight. + w = dy_safe ** -2 + w = w / w.sum() + if w.max() > 0.1: + raise ValueError("one point holds {:.0%} of the statistical " + "weight; check dy for near-zero values" + .format(w.max())) + + freqs, power, sols = eebls_transit(t, y, dy_safe, fmin=0.1, fmax=10.0) + +``cuvarbase`` does not apply such a floor automatically — reported +uncertainties are taken at face value — so this check belongs in your +pre-processing. + + +Precision and reproducibility +----------------------------- + +**The phase fold is float32.** Every GPU BLS kernel folds with +``mod1(t * f)`` in single precision, so the phase grid it can resolve is +quantized at :math:`\mathrm{ulp}(T f_\mathrm{max})`, where :math:`T` is +the baseline after epoch subtraction (:math:`1.95\times10^{-3}` cycles +at :math:`T f = 23{,}019`; 5000 points then take only 1977 distinct +phases). For the box edges to land where they should, the narrowest +phase step the search actually uses, + +.. math:: + + \frac{q_\mathrm{min}}{n_\mathrm{overlap}} \quad\text{(in cycles)}, + +must stay well above that ulp. When it does not, the transit's power +leaks across bin edges: measured against a float64 replica, ``q = +0.01`` boxes recover 0.968 / 0.924 / 0.901 of the exact power at +:math:`T f = 7000` / 18,250 / 58,400 (worst case 0.846), i.e. a 3-15 % +loss, and a 10-year baseline searched to 20 c/d loses 22 % (a 1-year +baseline at the same frequency loses 6 %). Keplerian ``q0`` boxes -- +what :func:`~cuvarbase.bls.eebls_transit` searches by default -- are +much wider and are not affected. If you need ``q ~ 0.01`` at +:math:`T f_\mathrm{max} \gtrsim 7000`, split the baseline into shorter +segments or restrict ``fmax``; the periodogram peak is still found, but +its height (and the depth inferred from it) is biased low. + +**Binned results depend on the time origin.** Times are epoch-subtracted +with ``floor(min(t))``, so the *fractional* part of ``min(t)`` shifts +where the phase-bin edges fall relative to the data. Binned BLS powers +move by up to ~10 % with that fraction (11 / 9 / 7.6 % at +``noverlap = 1 / 4 / 8``), occasionally moving the ``eebls_transit`` +argmax. This is discretization, not precision loss -- ``t + 2457000.5`` +and ``t + 0.5`` agree to 1e-8 -- but it means a periodogram is only +reproducible for a fixed time origin. Sparse BLS, which uses no bins, +is invariant to 5e-4. + +**Run-to-run reproducibility.** The fast shared-memory kernels +(:func:`~cuvarbase.bls.eebls_gpu_fast`, +:func:`~cuvarbase.bls.eebls_gpu_fast_optimized`) and the batch kernels +accumulate through float32 atomics, whose summation order is not fixed, +so two identical calls differ by ~1e-8 to 1e-7 in power. Compare +periodograms with a tolerance at that level, not with +``array_equal``. Sparse BLS (:func:`~cuvarbase.bls.sparse_bls_gpu`), the +PDM kernels and the default (unweighted) conditional-entropy kernels use +no such accumulation -- CE's histograms are integer atomics, whose sum +does not depend on order -- and are bitwise reproducible. Weighted CE +(``weighted=True``) deposits each point's Gaussian mass with floating- +point atomics and carries the same caveat as the fast BLS kernels. + + +References +---------- + +.. [SM03] Seager, S. & Mallén-Ornelas, G. (2003), "A Unique Solution of + Planet and Star Parameters from an Extrasolar Planet Transit Light + Curve", ApJ 585, 1038 (DOI 10.1086/346105). +.. [O2014] Ofir, A. (2014), "Optimizing the search for transiting + planets in long time series", A&A 561, A138 + (DOI 10.1051/0004-6361/201220860; arXiv:1307.7330; corrigendum + A&A 597, C2). diff --git a/docs/source/ce.rst b/docs/source/ce.rst index b610bf45..94741176 100644 --- a/docs/source/ce.rst +++ b/docs/source/ce.rst @@ -12,9 +12,54 @@ Here, where :math:`p(m, \phi)` is the density of points that fall within the bin located at phase :math:`\phi` and magnitude :math:`m` and :math:`p(\phi) = \sum_m p(m, \phi)` is the density of points that fall within the phi range. +.. note:: + + **What the returned value is.** ``cuvarbase`` returns + :math:`H(m|\phi) + \sum_m p(m) \log \Delta m_m`, where + :math:`\Delta m_m` is the width of magnitude bin :math:`m` in units of + the (normalized) magnitude range and :math:`p(m)` is the fraction of + the histogram mass in that bin -- i.e. the entropy of the magnitude + *density* rather than of the bin probabilities. With the default + ``mag_overlap=0`` every bin has :math:`\Delta m_m = 1/\mathrm{mag\_bins}` + and the offset is :math:`\log(1/5) = -1.609` for the default + ``mag_bins=5``. With ``mag_overlap > 0`` the unweighted kernels use + :math:`\Delta m_m = \min(\mathrm{mag\_overlap} + 1,\, + \mathrm{mag\_bins} - m) / \mathrm{mag\_bins}` (the top bins are + truncated at the brightest magnitude cell), whereas the weighted + kernel integrates every bin over the full window and uses the + constant :math:`(\mathrm{mag\_overlap} + 1) / \mathrm{mag\_bins}`, so + ``weighted=True`` and ``weighted=False`` spectra then differ by a + constant. With ``balanced_magbins=True`` each bin uses its own width. + In every case the offset is the same at every frequency (the + per-magnitude-bin totals do not depend on the trial frequency), so + the location of the minimum is unaffected; subtract it to recover + Graham et al.'s normalization. Lower values mean more structure: the + best frequency is the **argmin** of the periodogram. + + With ``compute_log_prob=True`` the returned quantity is instead the + Poisson log-likelihood of the phase-folded histogram under the + phase-independent null model, + :math:`\sum_{\phi, m} [N_{\phi m} \log N^{\rm exp}_{\phi m} - + N^{\rm exp}_{\phi m} - \log\Gamma(N_{\phi m} + 1)]` with + :math:`N^{\rm exp}_{\phi m} = N_\phi\, p(m)`. It is likewise + **minimized** at the true frequency. + .. plot:: plots/ce_example.py +.. note:: + + **Input validation.** Since 1.0 every entry point rejects + non-finite ``t``/``y``/``dy``, ``dy <= 0``, mismatched array + lengths, too-short light curves and non-finite or non-positive + frequency grids with a ``ValueError`` raised on the host, before + any GPU work. See :ref:`Input validation ` for + the full rules and the pre-1.0 behaviour they replace. The + conditional entropy additionally rejects a *constant* ``y``: the + magnitudes are binned over their range ``max - min``, which is + then zero (before 1.0 every point's bin index was a NaN cast to an + integer and the spectrum was flat garbage). + An example with ``cuvarbase`` ----------------------------- @@ -30,7 +75,7 @@ An example with ``cuvarbase`` dy = np.ones_like(t) # start a conditional entropy process - proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5) + proc = ce.ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5) # format your data as a list of lightcurves (t, y, dy) data = [(t, y, dy)] @@ -38,8 +83,8 @@ An example with ``cuvarbase`` # run the CE process with your data results = proc.run(data) - # finish the process (probably not necessary but ensures - # all data has been transferred) + # finish the process (necessary: the results are filled in + # asynchronously and are only complete after finish()) proc.finish() # Results is a list of [(freqs, CE), ...] for each lightcurve @@ -55,5 +100,119 @@ If you want to run CE on large datasets, you can do instead of ``run``, which will ensure that the memory limit (1 GB in this case) is not exceeded on the GPU (unless of course you have other processes running). +The frequency grid can be any 1-D numeric array (``float32``, +``float64``, integers or a Python list); to use a different grid for +each lightcurve pass a list with one grid per lightcurve. + +Reusing memory across many lightcurves +-------------------------------------- + +For many lightcurves on the same frequency grid, ``batched_run_const_nfreq`` +allocates the GPU buffers once. The lower-level equivalent is +``preallocate``, which uploads the frequency grid and binds each memory +object to one of the process streams so that ``finish()`` synchronizes +the result transfers: + +.. code-block:: python + + proc = ce.ConditionalEntropyAsyncProcess() + proc.preallocate(max_nobs=1000, freqs=freqs, nlcs=1) + for t, y, dy in lightcurves: # each with <= 1000 observations + results = proc.run([(t, y, dy)], freqs=freqs) + proc.finish() + ce_spectrum = np.copy(results[0][1]) + +Passing a different grid of the same length to ``run`` re-uploads it; a +grid of a different length raises ``ValueError``. + +The shared-memory kernels (``use_fast=True``) +--------------------------------------------- + +``use_fast=True`` gives each trial frequency its own thread block and +keeps that block's phase/magnitude histogram in shared memory. It +returns the same periodogram as the default kernels to floating-point +precision, and it has two practical advantages: + +* **No global histogram.** The default kernels accumulate into an + ``nfreq * phase_bins * mag_bins`` array in device memory -- 20 MB for + a 100,000-frequency 10 x 5 search, per lightcurve held on the GPU. + ``use_fast=True`` allocates none of it, which is often what decides + how large a batch fits. +* **Speed.** The grid is sized from the device (SM count and per-SM + occupancy) rather than from the histogram's shared-memory footprint, + so the kernels actually fill the GPU. On one NVIDIA A40 shared with + other jobs -- treat these as ratios measured in a single session, not + as portable numbers -- ``use_fast=True`` in single precision was 1.2x + faster than the default kernels at ``(ndata, nfreq) = (300, 1e5)``, + 1.9x at ``(2000, 1e5)`` and 8x at ``(10000, 1e5)``, and within noise + of them for small grids. In double precision the picture is + different: occupancy is shared-memory bound, so the fast kernels are + roughly break-even and can be up to ~1.2x *slower* around + ``ndata`` 1000-2000. + +The size of the histogram is limited by the device's shared memory per +block: ``phase_bins * mag_bins`` beyond roughly 6000 (single precision, +48 KB per block) raises ``ValueError`` rather than failing inside the +driver. ``weighted=True`` and ``balanced_magbins=True`` have no fast +kernel (see below). + +Binning details +--------------- + +* Magnitudes are normalized to :math:`[0, 1]` over the lightcurve's + range and binned into ``mag_bins`` uniform bins; the brightest point + (normalized magnitude exactly 1) belongs to the last bin. +* ``weighted=True`` spreads each point over the magnitude bins according + to the Gaussian probability mass implied by its uncertainty. A bin is + skipped only when the *whole* bin lies more than ``max_phi`` sigma + from the point (the point's own bin is always kept), so every point + retains essentially all of its mass. ``widen_mag_range=True`` pads the + normalized range by ``max_phi`` median uncertainties on each side. +* ``balanced_magbins=True`` uses ``mag_bins`` bins holding the same + number of points each, to within one (each group holds + :math:`\lfloor N/\mathrm{mag\_bins}\rfloor` or one more point). + Bin edges lie at the midpoints between adjacent + sorted groups, so the widths tile :math:`[0, 1]`; a width is floored at + :math:`10^{-6}` of the range so quantized magnitudes (bins made of a + single repeated value) cannot make the entropy :math:`-\infty`. .. [G2013] `Graham et al. 2013 `_ + +Unsupported option combinations +------------------------------- + +CE is in maintenance mode (see the module notice), and the following +option combinations are **not implemented** — they raise +``ValueError`` (from the constructor, or from ``run``/``preallocate`` +when passed as per-call keyword arguments) rather than silently +misbehaving: + +* ``use_fast=True`` with ``weighted=True`` — the fast shared-memory + kernels have no weighted variant. +* ``use_fast=True`` with ``balanced_magbins=True`` — the fast kernels + only implement uniform magnitude bins. +* ``use_fast=True`` with ``compute_log_prob=True`` — the fast kernels + compute only the conditional entropy; there is no shared-memory + log-probability kernel (before 1.0 this combination silently + returned the plain conditional entropy). +* ``balanced_magbins=True`` with ``compute_log_prob=True``. +* ``mag_overlap > 0`` with ``balanced_magbins=True`` — overlapping + magnitude bins are incompatible with the balanced-bin layout. +* ``weighted=True`` with ``balanced_magbins=True`` or + ``compute_log_prob=True``. + +``use_fast=True`` with ``use_double=True`` is supported (in single and +double precision, for any ``phase_bins``/``mag_bins``). + +When ``run`` uses an existing memory object -- ``memory=...`` or the +memory :meth:`~cuvarbase.ce.ConditionalEntropyAsyncProcess.preallocate` +created -- the kernels dispatch on *that memory's* settings, so a +per-call option keyword argument (``weighted``, ``compute_log_prob``, +``balanced_magbins``, ``mag_bins``, ...) must match the options the +memory was allocated with; a mismatch raises ``ValueError`` instead of +being silently ignored, and the memory's own option combination is +checked against the process's ``use_fast``. All of these checks run on +the host before the kernels are compiled. + +For an actively developed GPU conditional-entropy implementation, see +`periodfind `_. diff --git a/docs/source/conf.py b/docs/source/conf.py index 76232c3a..b8bdf0d9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -18,28 +18,10 @@ # import os import sys -import ctypes import io import re -cuda_dir = "/Developer/NVIDIA/CUDA-8.0/lib/" sys.path.insert(0, os.path.abspath('../..')) -sys.path.insert(0, cuda_dir) - -# Set DYLD and LD library paths -dyld_lpath = os.environ.get('DYLD_LIBRARY_PATH', '') -ld_lpath = os.environ.get('LD_LIBRARY_PATH', '') - - -def lpath_insert(p, lpath): - return '%s:%s' % (p, lpath) - -dyld_lpath = lpath_insert(cuda_dir, dyld_lpath) -ld_lpath = lpath_insert(cuda_dir, ld_lpath) - - -os.environ['DYLD_LIBRARY_PATH'] = dyld_lpath -os.environ['LD_LIBRARY_PATH'] = ld_lpath def read(path, encoding='utf-8'): @@ -82,11 +64,19 @@ def version(path): 'sphinx.ext.viewcode', 'sphinx.ext.githubpages', 'sphinx.ext.napoleon', - 'matplotlib.sphinxext.only_directives', 'matplotlib.sphinxext.plot_directive'] +# Build the API docs without CUDA hardware or drivers: cuvarbase imports +# pycuda at package-import time, so autodoc mocks the whole GPU stack. +# (batman and cufinufft are optional and already guarded in the source.) +autodoc_mock_imports = ['pycuda'] + +# The plot_directive figures require a GPU to render; when they fail on a +# GPU-less builder the pages keep the source code and lose only the image. +plot_include_source = True + # Add any paths that contain templates here, relative to this directory. -templates_path = ['.templates'] +templates_path = [] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: @@ -99,7 +89,7 @@ def version(path): # General information about the project. project = u'cuvarbase' -copyright = u'2017, John Hoffman' +copyright = u'2017-2026, John Hoffman' author = u'John Hoffman' # The version info for the project you're documenting, acts as replacement for @@ -116,7 +106,7 @@ def version(path): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. @@ -147,7 +137,7 @@ def version(path): # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['.static'] +html_static_path = [] # Custom sidebar templates, must be a dictionary that maps document names # to template names. @@ -217,6 +207,7 @@ def version(path): # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'cuvarbase', u'cuvarbase Documentation', - author, 'cuvarbase', 'One line description of project.', + author, 'cuvarbase', + 'GPU-accelerated period-finding and transit-detection algorithms.', 'Miscellaneous'), ] diff --git a/docs/source/cuvarbase.rst b/docs/source/cuvarbase.rst index f3c922bd..a944beaf 100644 --- a/docs/source/cuvarbase.rst +++ b/docs/source/cuvarbase.rst @@ -1,13 +1,6 @@ cuvarbase package ================= -Subpackages ------------ - -.. toctree:: - - cuvarbase.tests - Submodules ---------- @@ -19,6 +12,14 @@ cuvarbase\.bls module :undoc-members: :show-inheritance: +cuvarbase\.bls\_frequencies module +---------------------------------- + +.. automodule:: cuvarbase.bls_frequencies + :members: + :undoc-members: + :show-inheritance: + cuvarbase\.ce module -------------------- @@ -27,10 +28,10 @@ cuvarbase\.ce module :undoc-members: :show-inheritance: -cuvarbase\.core module ----------------------- +cuvarbase\.cufinufft\_backend module +------------------------------------ -.. automodule:: cuvarbase.core +.. automodule:: cuvarbase.cufinufft_backend :members: :undoc-members: :show-inheritance: @@ -60,6 +61,59 @@ cuvarbase\.pdm module :show-inheritance: +cuvarbase\.tls module +--------------------- + +.. automodule:: cuvarbase.tls + :members: + :undoc-members: + :show-inheritance: + +cuvarbase\.tls\_grids module +---------------------------- + +.. automodule:: cuvarbase.tls_grids + :members: + :undoc-members: + :show-inheritance: + +cuvarbase\.tls\_models module +----------------------------- + +.. automodule:: cuvarbase.tls_models + :members: + :undoc-members: + :show-inheritance: + +cuvarbase\.tls\_stats module +---------------------------- + +.. automodule:: cuvarbase.tls_stats + :members: + :undoc-members: + :show-inheritance: + +cuvarbase\.nufft\_lrt module (experimental) +--------------------------------------------- + +.. warning:: + + ``cuvarbase.nufft_lrt`` is **experimental** and outside the 1.x + API-stability promise. It is importable only by name (it is not + exported from the top-level ``cuvarbase`` namespace) and it emits an + ``EXPERIMENTAL`` ``UserWarning`` when + :class:`~cuvarbase.nufft_lrt.NUFFTLRTAsyncProcess` is first + constructed. Its September 2026 fixes were re-validated by + injection-recovery; it stays experimental because that campaign + showed its defaults and ``run()`` conventions should still change + before the API is frozen. Calibrate thresholds empirically. The + user guide, with the measured numbers, is :doc:`nufft_lrt`. + +.. automodule:: cuvarbase.nufft_lrt + :members: + :undoc-members: + :show-inheritance: + cuvarbase\.utils module ----------------------- @@ -68,11 +122,52 @@ cuvarbase\.utils module :undoc-members: :show-inheritance: +cuvarbase\.memory subpackage +---------------------------- + +.. automodule:: cuvarbase.memory.bls_memory + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: cuvarbase.memory.ce_memory + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: cuvarbase.memory.lombscargle_memory + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: cuvarbase.memory.nfft_memory + :members: + :undoc-members: + :show-inheritance: + +cuvarbase\.base subpackage +-------------------------- + +.. automodule:: cuvarbase.base.async_process + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: cuvarbase.base.context + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- +The package re-exports the classes below lazily (PEP 562); their +canonical documentation is in the module sections above, so this block +is not indexed. + .. automodule:: cuvarbase :members: :undoc-members: :show-inheritance: + :no-index: diff --git a/docs/source/cuvarbase.tests.rst b/docs/source/cuvarbase.tests.rst deleted file mode 100644 index 57287b83..00000000 --- a/docs/source/cuvarbase.tests.rst +++ /dev/null @@ -1,54 +0,0 @@ -cuvarbase\.tests package -======================== - -Submodules ----------- - -cuvarbase\.tests\.test\_bls module ----------------------------------- - -.. automodule:: cuvarbase.tests.test_bls - :members: - :undoc-members: - :show-inheritance: - -cuvarbase\.tests\.test\_ce module ---------------------------------- - -.. automodule:: cuvarbase.tests.test_ce - :members: - :undoc-members: - :show-inheritance: - -cuvarbase\.tests\.test\_lombscargle module ------------------------------------------- - -.. automodule:: cuvarbase.tests.test_lombscargle - :members: - :undoc-members: - :show-inheritance: - -cuvarbase\.tests\.test\_nfft module ------------------------------------ - -.. automodule:: cuvarbase.tests.test_nfft - :members: - :undoc-members: - :show-inheritance: - -cuvarbase\.tests\.test\_pdm module ----------------------------------- - -.. automodule:: cuvarbase.tests.test_pdm - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: cuvarbase.tests - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst index aa63dadc..3c0bf8c0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,22 +1,97 @@ -.. cuvarbase documentation master file, created by - sphinx-quickstart on Fri Sep 22 21:34:29 2017. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +cuvarbase +========= +.. image:: logo.png + :align: right + :width: 120px +**GPU-accelerated period-finding and transit-detection algorithms for +astronomical time series**, built on `PyCUDA +`_ and `CuPy +`_. cuvarbase is designed for +processing whole surveys -- millions of irregularly sampled lightcurves +-- on a single NVIDIA GPU, and its BLS has powered the TESS Quick-Look +Pipeline's planet search since Sector 59 (Kunimoto et al. 2023). -.. include:: ../../README.rst +Methods +------- +* :doc:`Box Least Squares (BLS) ` -- the production-validated box + transit search: standard, adaptive and batched multi-lightcurve GPU + paths, sparse BLS for small datasets, Keplerian frequency grids and + selectable power conventions. +* :doc:`Transit Least Squares (TLS) ` -- limb-darkened transit + templates with a GTLS-compatible observation-level search, full candidate + and harmonic refinement, and a survey batch wrapper. Thin transits use + the same default search without phase binning. +* :doc:`Generalized Lomb-Scargle ` -- NFFT-accelerated, with + multiharmonic models and Baluev false-alarm probabilities. +* :doc:`Phase Dispersion Minimization (PDM) ` -- binned and + binless variants with shared-memory kernels. +* :doc:`Conditional Entropy (CE) ` -- maintained; for an actively + developed GPU CE search see `periodfind + `_. +* The **non-equispaced FFT (NFFT)** adjoint + (:class:`cuvarbase.cunfft.NFFTAsyncProcess`) that powers the fast + Lomb-Scargle. +* :doc:`NUFFT-LRT ` -- an **experimental** likelihood-ratio + transit search for correlated noise (outside the 1.x stability + promise; see its page). + +Installation +------------ + +Install the v1 candidate from ``v1.0-fixes``. As of 10 September 2026, +PyPI still provides the older 0.2.5 release. + +.. code-block:: bash + + pip install 'cuvarbase @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' + +requires an NVIDIA GPU, the CUDA toolkit (``nvcc`` on your ``PATH``) and +Python 3.9-3.14; see :doc:`install` for the details, the optional +extras and the GPU-less install path. The source, issue tracker and +release notes are on `GitHub `_. + +Citation +-------- + +If you use cuvarbase in your research, please cite `Hoffman (2022), +ASCL record ascl:2210.030 +`_: + +.. code-block:: bibtex + + @MISC{2022ascl.soft10030H, + author = {{Hoffman}, John}, + title = "{cuvarbase: GPU-Accelerated Variability Algorithms}", + keywords = {Software}, + howpublished = {Astrophysics Source Code Library, record ascl:2210.030}, + year = 2022, + month = oct, + eid = {ascl:2210.030}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2022ascl.soft10030H}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} + } + +If you use the sparse BLS method, please also cite `Panahi & Zucker +(2021) `_; if you use TLS, `Hippke & +Heller (2019) `_. + +Contents +-------- .. toctree:: :maxdepth: 2 - :caption: Contents: whatsnew install - ce - lomb bls + tls + lomb + pdm + ce + nufft_lrt modules Indices and tables diff --git a/docs/source/lomb.rst b/docs/source/lomb.rst index 536d8271..0415ec38 100644 --- a/docs/source/lomb.rst +++ b/docs/source/lomb.rst @@ -54,7 +54,7 @@ Where SS_{\tau} &= \sum_i w_i\sin^2{\omega (t_i - \tau)}\\ - \tan{2\omega\tau} &= \frac{\sum_i w_i \sin{2\omega t_i}}{\sum_i w_i \sin{2\omega t_i}} + \tan{2\omega\tau} &= \frac{\sum_i w_i \sin{2\omega t_i}}{\sum_i w_i \cos{2\omega t_i}} For the original formulation of the Lomb-Scargle periodogram without the constant offset term. @@ -90,7 +90,149 @@ of LS without any FFT's. Estimating significance ----------------------- -See [Baluev2008]_ for more information (TODO.) +``cuvarbase`` implements the [Baluev2008]_ analytic upper bound on the +false-alarm probability of a periodogram peak, which accounts for the +effective number of independent frequencies searched without resorting +to bootstrap simulations: + +.. code-block:: python + + from cuvarbase.lombscargle import fap_baluev + + # t, dy: observation times and uncertainties + # z: the periodogram value of the peak + # fmax: the maximum frequency searched + fap = fap_baluev(t, dy, z, fmax) + +:func:`cuvarbase.lombscargle.LombScargleAsyncProcess.batched_run_const_nfreq` +applies the same bound when called with ``only_return_best_freqs=True``, +returning ``(best_freqs, best_freq_faps)``: the frequency of each +lightcurve's best peak and the false-alarm probability of that peak +(``d_K = 2 * nharmonics + 1``). Small is significant; an overwhelming +peak can underflow to exactly ``0.0``. *Changed in 1.0:* earlier +versions returned ``1 - FAP``, which rounds to exactly ``1.0`` for every +FAP below 1e-16 and so could not rank detections. Two caveats: the +bound is one-sided (an upper limit on the false-alarm probability, +tight in the interesting low-FAP regime), and it assumes uncorrelated +Gaussian noise -- correlated ("red") noise or strong aliasing can make +the true false-alarm rate higher than the bound suggests. The FAP is +exponentially sensitive to the peak power (:math:`d\ln{\rm FAP}/dP \sim +-N/2`), so for FAP-grade work on large :math:`f T` grids use +``use_double=True`` (see *Precision* below). + +Frequency grids, conventions and precision +------------------------------------------ + +**Uniform grids only.** Every GPU kernel evaluates the periodogram on +``freqs = df * (k0 + np.arange(nf))`` with an integer ``k0 >= 1`` and +``nf >= 2``; the array you pass only labels the output. ``run``, +``batched_run_const_nfreq`` and ``preallocate`` validate the grid with +:func:`cuvarbase.lombscargle.check_k0` and raise ``ValueError`` naming +the first offending point for anything else -- two concatenated +``arange`` segments, a uniform grid with points removed, ``geomspace``, +or a ``linspace`` whose start is not a multiple of its step. (Before 1.0 +only the first two points were inspected and such grids were silently +evaluated on the implied uniform grid.) Build one uniform grid per band +instead; ``cuvarbase.utils.autofrequency`` and +``run(minimum_frequency=..., maximum_frequency=...)`` produce valid +grids. Bands that start far from zero (``fmin >= fmax / 2``, say) are +fine: the NFFT grids are sized from the highest mode used. The NFFT +oversampling factor must be ``sigma >= 3`` (default 4); smaller values +alias the top of every band and are rejected. + +**Model conventions.** ``floating_mean=True`` (default) is the +generalized Lomb-Scargle of [ZK2009]_ (astropy's ``fit_mean=True``) and +is what all accuracy statements below refer to. ``floating_mean=False`` +is the classic periodogram of the data centred on the *unweighted* mean, +which differs from astropy's ``fit_mean=False, center_data=True`` for +heteroscedastic errors. ``window=True`` returns the spectral window as +the periodogram of ``y = 1`` in the classic normalization, which is +**4x** astropy's ``LombScargle(t, ones, fit_mean=False, +center_data=False)``. ``nharmonics > 1`` (the multiharmonic GLS) is +floating-mean only and is honoured on every path: the NFFT path solves +the small per-frequency system on the host from the GPU spectra, and +``use_fft=False`` / ``python_dir_sums=True`` run float64 direct sums on +the host (correct but O(N nf)). ``amplitude_prior`` is the standard +deviation of a Gaussian prior on the harmonic amplitudes (a ridge term +``1 / amplitude_prior**2``) and is applied on every path. ``dy=None`` +gives unit weights. + +See :ref:`Input validation ` for the rules every +entry point applies to ``t``, ``y``, ``dy`` and the frequency grid +before any GPU work. + +**The -1 sentinel.** A power of exactly ``-1`` is the kernels' marker +for a non-finite or negative value at that frequency. Since 1.0 every +entry point validates the light curve before any GPU work +(:func:`cuvarbase.utils.check_lightcurve`), so the inputs that used to +fill a whole periodogram with ``-1`` -- non-finite ``y`` or ``dy``, +``dy = 0``, mismatched array lengths, fewer than four observations -- +raise ``ValueError`` instead. Two degenerate cases the validator +deliberately still accepts do return ``-1`` at every frequency: a +constant (zero-variance) ``y``, and all-identical ``t``. Apart from +those, a ``-1`` in a returned periodogram is a bug report, not a valid +power. + +**Precision.** The default float32 pipeline agrees with the exact +float64 generalized Lomb-Scargle to about 1e-4 in power for +:math:`f T \lesssim 10^4` (e.g. 300 points over a year to 20 cycles/day) +and to about 1e-3 at survey scale (:math:`f T \sim 10^5`--:math:`10^6`, +ten-year baselines to 50 cycles/day); the limit is the float32 storage +of the (epoch-subtracted) times. ``use_double=True`` reaches ~1e-7 and +is recommended whenever the *value* of the power matters -- false-alarm +probabilities, amplitude estimates -- rather than the location of the +peak, which float32 recovers identically in all tests. Times are +mean-centred on the host in float64 before any cast, so absolute (BJD) +timestamps are safe. Precision is a property of the process object -- +the kernels are compiled once, at construction, in that precision -- +so ``use_double`` is **not** a per-call keyword: ``run(..., +use_double=True)`` or ``batched_run_const_nfreq(..., use_double=True)`` +on a ``LombScargleAsyncProcess()`` raises ``ValueError`` before any +device work (a value equal to the process precision is accepted and +ignored), as does a ``memory`` allocated at the other precision. +Before 1.0 the keyword silently reached the memory constructor and the +float32 kernels read the float64 buffers as float32: a wrong +periodogram with a float64 dtype. ``nharmonics=`` *is* a legitimate +per-call override. + + +Reusing device memory across calls +---------------------------------- + +Since 1.0 :func:`cuvarbase.lombscargle.LombScargleAsyncProcess.batched_run_const_nfreq` +reuses the ``LombScargleMemory`` set it built last -- pinned host +buffers, device arrays and the two cuFFT plans -- whenever the next call +asks for the same grid, precision, number of harmonics, model mode and +prior, and its buffers are long enough for the new light curves. A +survey loop that calls it once per light curve therefore pays the +allocation once instead of once per call. If ``preallocate`` was used, +that set is preferred over the cached one. + +The cached set is held on the process object for its lifetime, which is +tens of megabytes at survey ``nf``. Drop the process object, or set +``proc._batch_memory = None``, to release it. Passing any keyword that +hands the memory its own buffer or fixes its size (``t_g``, ``lsp_c``, +``nfft_mem_yw``, ``n0_buffer``, ``nf``, ``k0``, ...) opts that call out +of the cache entirely, so it allocates its own set as before. A per-call +``nharmonics=`` is part of what has to match, so it allocates and +caches a set of its own; ``use_double`` cannot be changed per call (see +*Precision* above). + +**Reproducibility.** The float32 NFFT spreads the data onto the grid +with ``atomicAdd``, whose summation order is not fixed, so two runs of +the same build on the same input need not be bitwise identical -- not +even through the same buffers. Measured on an A40 with an unchanged +build: sparse light curves on coarse grids are often bitwise stable, +but dense configurations are not, differing by up to ~6e-8 in absolute +power at ``N = 65,000``/``nf = 210,000`` and ~4e-7 at +``N = 65,000``/``nf = 30,000`` and ``N = 300``/``nf = 219,000``, i.e. +~1e-4 to ~3e-4 *relative* on powers near zero. Peak locations were +unaffected in every test. ``use_double=True`` is not bitwise stable +either -- its ``atomicAdd`` is a compare-and-swap loop with the same +order dependence -- but the jitter is at double rounding: 5 of 19 +repeats of a ``N = 300``/``nf = 1,500`` batched run differed, by at +most 6.7e-15 relative (1e-17 absolute). Compare periodograms of either +precision with a tolerance, never with ``np.array_equal``. Example: Basic @@ -99,7 +241,6 @@ Example: Basic .. plot:: :include-source: - import skcuda.fft import cuvarbase.lombscargle as gls import numpy as np import matplotlib.pyplot as plt @@ -141,7 +282,6 @@ Example: Batches of lightcurves .. plot:: :include-source: - import skcuda.fft import cuvarbase.lombscargle as gls import numpy as np import matplotlib.pyplot as plt @@ -207,4 +347,21 @@ Example: Batches of lightcurves .. [Barning1963] `Barning, F. J. M. 1963, BAN, 17, 22 `_ .. [Vanicek1969] `Vaníček, P. 1969, APSS, 4, 387 `_ .. [Scargle1982] `Scargle, J. D. 1982, ApJ, 263, 835 `_ -.. [Lomb1976] `Lomb, N. R. 1976, APSS, 39, 447 `_ \ No newline at end of file +.. [Lomb1976] `Lomb, N. R. 1976, APSS, 39, 447 `_ + +Power-spectrum convention +------------------------- + +The GPU Lomb-Scargle returns the standard normalized periodogram + +.. math:: + + P(f) = 1 - \chi^2(f) / \chi^2_0 + +(equivalently the ``normalization='standard'`` convention of +``astropy.timeseries.LombScargle``), where :math:`\chi^2(f)` is the +best-fit sinusoid's weighted residual sum and :math:`\chi^2_0` that of +the constant model. With ``floating_mean=True`` (the default) this is +the *generalized* (floating-mean) Lomb-Scargle of Zechmeister & +Kürster (2009). Values are directly comparable to astropy's defaults; +see the unit tests (``test_lombscargle.py``) which assert agreement. diff --git a/docs/source/nufft_lrt.rst b/docs/source/nufft_lrt.rst new file mode 100644 index 00000000..0957f35b --- /dev/null +++ b/docs/source/nufft_lrt.rst @@ -0,0 +1,846 @@ +NUFFT-LRT: whitened matched-filter transit detection (experimental) +******************************************************************* + +.. warning:: + + **EXPERIMENTAL.** ``cuvarbase.nufft_lrt`` is importable only by name + (it is deliberately *not* exported from the top-level ``cuvarbase`` + namespace) and emits an ``EXPERIMENTAL`` ``UserWarning`` when + :class:`~cuvarbase.nufft_lrt.NUFFTLRTAsyncProcess` is first + constructed. It *is* validated: the September 2026 correctness fixes + (below) were re-measured by the injection-recovery campaign of + 2026-09-06 (*Validation status*), and the public default path is + correct on absolute BJD timestamps and recovers random-epoch + transits. It stays experimental because that campaign also showed + that what a 1.x freeze would lock in should still change: the + default epoch grid costs 4-9 % of completeness against a finer one, + PSD whitening -- the default detector's distinguishing feature -- + gave no gain over a flat PSD, and ``run()`` returns a tuple or an + array depending on ``epochs``. So the module and its ``run()`` + signature are **outside the 1.x API-stability promise** and may + change incompatibly in a 1.x release. Its thresholds must be + calibrated empirically per dataset (*Statistical caveats*), and it + has far less operational mileage than cuvarbase's BLS and TLS. Use + it with those caveats, and quote only the measured numbers below. + +.. note:: + + **Historical TLS comparator.** Every TLS recovery and timing result on + this page comes from the 2026-09-06 campaign's phase-binned TLS engine, + retained today as ``method='binned'``. The standard TLS engine now uses + individual observations and full refinement. These tables have not been + rerun against that default; see the `current transit benchmark + `_ + for its validation and speed claims. + +What this is +============ + +A frequency-domain **likelihood-ratio / matched-filter transit search +for correlated ("red") noise**, contributed by **Jamila Taaki** +(`@xiaziyna `_). The lightcurve and each +box transit template are transformed with the GPU adjoint NFFT directly +at the observed (irregular, gappy) times over the full baseline, and the +detection statistic is the noise-whitened correlation + +.. math:: + + S = \frac{\mathrm{Re}\sum_k Y_k T_k^{*} / P(k)} + {\sqrt{\sum_k |T_k|^2 / P(k)}} + +with the noise power spectrum :math:`P(k)` either supplied or estimated +from the data (smoothed periodogram). Whitening by :math:`P(k)` is what +distinguishes it from BLS/TLS, which weight points by their individual +error bars and otherwise assume *white* noise. + +Provenance, and exactly what is implemented +=========================================== + +The method family is published in: + +1. **Taaki, Kamalabadi & Kemball (2020), AJ 159, 283** + (`arXiv:2004.14893 `_) -- joint + Bayesian transit detection + systematic-noise characterization on + Kepler long-cadence data. +2. **Taaki, Kemball & Kamalabadi (2025), AJ 170, 14** + (`arXiv:2504.18706 `_) -- the TESS + 2-min application. +3. Kay (1998/2002)-style adaptive detection under unknown noise PSDs is + the signal-processing foundation. +4. Reference NUFFT prototype: `code_nova_exoghosts + `_. + +``cuvarbase.nufft_lrt`` implements, selectable via +``run(..., detector=...)``: + +* ``'matched'`` (default) -- the stationary PSD-whitened matched filter. +* ``'marginal'`` -- **Detector A** of the 2020 paper: the joint detector + with a Gaussian prior on systematics coefficients marginalized in + closed form. Computed in the whitened frequency domain via the + Woodbury identity, so the systematics basis costs one NFFT per basis + vector per lightcurve and K-dimensional algebra per template (the + template-independent K x K algebra is computed once per search). + Supply ``systematics_basis`` (e.g. instrument cotrending vectors, or + PCA modes of a lightcurve population) and ``coeff_prior_cov`` + (+ optional ``coeff_prior_mean``), estimated from population fits as + in the paper. With ``estimate_psd=True`` (default) the PSD is + estimated from the basis-projected residual ``y - V c_ols``, not from + ``y - V mu``: the latter still contains the realized systematics, + whose power the spectral window spreads across the whole band and + which then whitens the transit away (confirmed defect, Sep 2026; + fixed). +* ``'sequential'`` -- the papers' "standard" baseline: least-squares + cotrend (with an intercept: basis columns and data are centred, so + columns need not be zero-mean) against the basis in the time domain, + then the stationary filter on the residual. + +Not implemented (deliberately): **Detector B** (joint MAP plug-in over a +depth grid) -- the 2020 paper found it comparable to Detector A and +describes it as exploratory; the closed-form marginalization supersedes +the plug-in. The papers' phase-correlation epoch pre-estimation trick +(2020, Appendix A) is also not implemented -- epochs are searched on a +grid (automatic or explicit, see *Usage*). + +**Honesty note on citing the papers:** the published validations cover +*uniformly sampled* Kepler/TESS data, and the published gains of the +joint detectors are modest (~2% detection efficiency on Kepler; 0.2% and +not statistically significant on TESS). The NUFFT / irregular-sampling +variant in this module appears in no publication -- its characterization +is the cuvarbase injection-recovery study +(``benchmarks/nufft_lrt/validate.py``; see *Validation status*). Do not +cite the papers' numbers as this module's performance. + +When is this the right tool? +============================ + +The evidence is the Sep-2026 injection-recovery re-validation of the +fixed code (*Validation status* below: 200 injections per depth, 200 +null light curves per threshold, 600-point ground-based sampling over +90 d, every arm searching the same period grid; one NVIDIA A40): + +* **Absolute (BJD-scale) timestamps and the default epoch search are + correct.** The same light curves at ``t + 2457000.5`` d give the same + statistic to 5e-8 and the same 800 detection decisions; the + ``epochs=None`` default finds the injected transit (99 % of its + detections within half a duration of the true mid-time, median error + 0.01-0.03 d). Its automatic per-cell epoch grid is coarser than the + explicit grid the harness uses for the longer durations, which costs + it 4-9 % of completeness at the transition depths (raise + ``epoch_oversample`` to buy it back, at proportional cost). +* **In white noise BLS and TLS are more complete than the whitened + filter** at the transition depths (BLS by 10-12 +- 3 % at depths + 0.003-0.004 on the same light curves; TLS similarly). Part of that is + the template grid (a 3-duration ladder and an epoch step of up to + 0.028 d against BLS's finer q ladder and P/200 phase bins), the rest + is the statistic itself. +* **In OU red noise the whitened filter is more complete than BLS by + 6-10 +- 3 %** at the transition depths (1x and 3x the white level), + and about as complete as TLS (+3 to -6 %). But **a flat-PSD matched + filter does as well (1x: +2-3 +- 3 % for whitening, not significant) + or better (3x: the flat filter wins by 6 +- 2 %)** -- the estimated + PSD partly whitens the transit away. The gain over BLS in red noise + comes from the full-baseline matched-filter form, not from the PSD + whitening, and whitening does not stabilize the false-alarm threshold + (null p95 8.6 -> 11.4 -> 14.1 from white to 3x red, as BLS's rises + 0.038 -> 0.124 -> 0.204). +* **With a shared-systematics basis the basis-aware detectors are the + only thing that works**: Detector A and the sequential cotrend + filter + recover 3/44/98/100 % of transits at depths 0.004/0.008/0.016/0.032 + where the basis-free whitened filter recovers 0/0/6/34 %, BLS + 0/0/2/16 % and TLS nothing. **Detector A equals the sequential + baseline exactly** (zero discordant decisions out of 800): after the + PSD fix it no longer trails it, but it does not beat it either. A + non-zero-mean basis changes nothing (5.5e-7). +* **Cost**: 3.4-5.7 s per search of 32 periods x 3 durations on the A40 + (~7,500 templates; 0.23 ms per template single-process) against 1.2 ms + for BLS and 9 ms for the historical binned TLS comparator. + +So, based on that evidence, **reach for NUFFT-LRT when all of these +hold:** + +1. **You have a systematics basis** (CBVs, PCA modes of a population) + and want the cotrend and the search in one statistic -- the one + regime with a decisive gain over basis-free BLS/TLS. Note that the + simpler sequential detector delivered the same completeness as + Detector A. +2. **You are scoring a bounded set of candidates**, not running a blind + survey: the cost is one adjoint NFFT *per template* (period x + duration x epoch), so ~10^3-10^5 templates is comfortable and + survey-scale grids (10^6+) are not. Typical fits: vetting/re-ranking + BLS or TLS candidates under a realistic noise model, or focused + searches around known ephemerides. Mind the period step: a box of + duration :math:`d` drifts by :math:`T\,\delta P / P` over the + baseline :math:`T` when the trial period is off by :math:`\delta P`, + so the grid needs :math:`\delta P \lesssim d P / (2T)` or an on-grid + harmonic alias (:math:`P/2`, :math:`2P`) beats the off-grid true + period. +3. **You can calibrate thresholds empirically** (see the caveats). + +**BLS** is designed for blind box searches over large period grids; +**TLS** uses limb-darkened transit templates. The validation tables +below compare recovery and cost for the specified NUFFT-LRT experiment, +including its historical binned TLS comparator. +Use the current transit benchmark for BLS/TLS release speed claims. +(Lomb-Scargle is not a transit +competitor at all -- a short-duty-cycle box leaves only a small +fraction of its power in the sinusoidal fundamental, which is why box +searches exist.) + +Statistical caveats +=================== + +* **The statistic is not N(0, 1) and is not an SNR.** Under irregular + sampling the NFFT modes are not orthogonal, so the frequency-diagonal + whitened correlation is over-dispersed *even with the true noise + PSD*: its null standard deviation is 1.8-2.7 for ground-based sampling + at the default ``nf = 2 * len(t)`` (1.81 measured for the validation + harness's sampling with the estimated PSD, 5000 draws; about 1.4 for + uniform sampling) and grows with ``nf`` (28 -> 51 at a fixed resolved + template for ``nf`` = n -> 8n). This is intrinsic to the statistic (an exact + float64 DFT reproduces it), not an NFFT accuracy or PSD-estimation + artefact. **Never apply a textbook SNR >~ 7 threshold; calibrate the + detection threshold per (sampling, ``nf``, PSD estimator) + configuration on signal-free or scrambled data**, as the validation + harness does (null-percentile calibration). Raising ``nf`` inflates + the raw value without adding information -- pick ``nf`` once and + calibrate at it. +* **Self-whitening**: with ``estimate_psd=True``, a strong transit + inflates the PSD estimate at its own harmonic frequencies and + partially suppresses itself (24-28% of the statistic at threshold in + the audit's white-noise runs). Provide ``psd=`` from a transit-free + noise model when you have one. +* **PSD convention** (for ``psd=``): ``psd[k]`` is the expected squared + modulus of the noise's *unnormalized* adjoint NFFT at mode ``k``, + + .. math:: + + P(k) = \mathrm{E}\,\Bigl|\sum_j s_j\, e^{2\pi i f_k t_j}\Bigr|^2, + \qquad f_k = \frac{k}{\max t - \min t},\quad k = 0 \ldots n_f - 1 . + + White noise of variance :math:`\sigma^2` per point has + :math:`P(k) = n\sigma^2` at every :math:`k`. ``psd = np.ones(nf)`` + therefore returns a statistic in *data units*. Bins are floored at + ``eps_floor`` (default 1e-3) times the positive median, for supplied + and estimated PSDs alike. +* **``dy`` is not used** by any detector (a ``UserWarning`` is emitted + if it is passed); the noise model is the PSD. +* **Detector A's prior is effectively wider than you specify.** The + Gram matrix :math:`G_{ij} = \langle v_i, v_j \rangle_W` is accumulated + over the ``nf`` (default ``2n``) non-orthogonal NFFT modes, which + overcounts the corresponding time-domain inner products by ~2.2-2.4x + for the samplings measured in the Sep-2026 audit, so + ``coeff_prior_cov`` behaves as though it were about that much wider. + The effect on the statistic is small, but calibrate the prior and the + detection threshold on the same footing. +* **Frequency resolution**: the default ``nf = 2 * len(t)`` gives a + maximum template frequency ``nf / T_span``. Resolving a transit of + duration :math:`d` wants ``nf`` :math:`\gtrsim` a few + :math:`\times\, T_{\rm span} / d` -- but see the first caveat before + raising ``nf``. + +Input validation +================ + +``run`` validates its inputs on the host before any GPU work (equal-length +finite ``t``/``y``, at least three observations, positive finite periods +and durations, finite epochs, a finite basis) and raises ``ValueError`` +otherwise; see :ref:`Input validation ` for the rules +shared with the other methods. + +Usage +===== + +.. code-block:: python + + import numpy as np + from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess + + proc = NUFFTLRTAsyncProcess() # sigma=4: full-band-accurate NFFT + + # Times may be absolute (BJD): floor(min(t)) is subtracted in float64 + # internally; epochs in and out are in YOUR time scale. + + # 1) focused period search with the automatic epoch grid (epochs=None): + # per (period, duration) cell, clip(ceil(2 P / duration), 8, 96) + # epochs are scanned and the max over epochs is returned together + # with the epoch that attains it -> two (nP, nD) arrays. The period + # step follows the drift criterion dP <~ dur * P / (2 T). + durations = np.array([0.12, 0.25]) + T = t.max() - t.min() + periods = np.arange(5.0, 5.6, durations.min() * 5.0 / (2 * T)) + snr, best_epoch = proc.run(t, y, periods, durations=durations) + i, j = np.unravel_index(np.argmax(snr), snr.shape) + print(periods[i], durations[j], best_epoch[i, j]) + # cost: ~2P/duration transforms per cell (max_epochs=96 caps it; raise + # it for long periods, where P/96 exceeds the duration) + + # 2) explicit epochs -> one (nP, nD, nE) array, no reduction + snr = proc.run(t, y, np.array([P]), durations=np.array([d]), + epochs=np.linspace(0, P, 40, endpoint=False)) + + # 3) Detector A (joint marginalized) with a systematics basis V (n, K) + # and a coefficient prior estimated from population fits + snr, best_epoch = proc.run(t, y, periods, durations=durations, + detector='marginal', systematics_basis=V, + coeff_prior_mean=mu_c, coeff_prior_cov=cov_c) + + # 4) known noise PSD (recommended when available; convention above) + snr, best_epoch = proc.run(t, y, periods, durations=durations, + estimate_psd=False, psd=my_psd, nf=len(my_psd)) + +Threshold calibration sketch (do this for your dataset, at the ``nf``, +sampling and PSD estimator you will search with): + +.. code-block:: python + + null_maxima = [] + for y_null in signal_free_or_scrambled_lightcurves: + snr, _ = proc.run(t, y_null, periods, durations=durations) + null_maxima.append(snr.max()) + threshold = np.percentile(null_maxima, 95) # 5% per-search FAR + +A runnable example with absolute timestamps and a transit injected at a +random epoch is ``examples/nufft_lrt_example.py``. + +Sep-2026 correctness fixes (all result-changing) +================================================ + +1. **BJD-scale times**: ``run()`` and ``compute_nufft`` cast times to + float32 before folding/gridding; absolute BJD input returned a + different statistic (corr ~0.5, wrong argmax). Times are now + epoch-subtracted in float64 first. +2. **``epochs=None``** evaluated a single phase-0 template per cell + (0/12 random-epoch transits recovered) while being documented as a + period search. It is now an automatic epoch grid with a max + reduction (see *Usage*) and returns ``(snr, best_epoch)``; the + shipped example and the old README used to show that non-search as a + detection. +3. **``detector='sequential'``** fitted the basis without an intercept: + a 1% column mean on relative flux dropped the statistic at the true + period from ~25 to ~5. The fit is now centred. +4. **``detector='marginal'``** estimated the PSD from ``y - V mu`` (see + above): SNR at the true template 2.3 vs 8.9 for the sequential + baseline; now from the basis-projected residual (8.9 vs 8.9). +5. **NFFT upper half band**: the default ``sigma = 2`` left modes + ``k >= nf/2`` aliased at O(1) (in double precision too, and + non-deterministic in float32); ``sigma = 4`` (the library's NFFT + default) makes every returned mode accurate (~4e-4 relative in + float32, ~1e-6 in float64 vs the exact adjoint DFT). +6. Also: one NFFT buffer set per ``run()`` instead of one per template + (the per-template allocation was ~90% of the campaign's GPU time), + the NFFT reuse path is zeroed and synchronized, user PSDs are floored + and length-checked, singular coefficient priors give the correct + pinned-to-mean limit (a zero variance used to become a *flat* prior) + and non-PSD priors raise. + +Validation status +================= + +**Re-validated after the Sep-2026 fixes** (Phase 4 of the 1.0 release +plan; campaign JSON, per-process logs and the full-suite log under +``benchmarks/results/nufft_lrt_validation_2026-09-06/``; harness +``benchmarks/nufft_lrt/validate.py`` at commit 2f9736a; tables rendered +by ``benchmarks/nufft_lrt/summarize.py --rst``). Measured on one +NVIDIA A40 (CUDA 12.4); the numbers are completeness fractions and +per-search costs, not absolute timings for any other GPU. + +Protocol +-------- + +600-point ground-based sampling over 90 d (nightly windows with +per-night jitter, 35 % weather loss); a shared grid of 32 log-spaced +trial periods in 2-18 d with the injected 5.3 d period *and its 2P +alias* placed on the grid; box transits of duration 0.22 d at random +epochs; formal errors sigma_white = 3e-3. Per configuration and arm, the +detection threshold is the 95th percentile of the search maximum over +200 signal-free light curves (a 5 % per-search false-alarm rate), then +200 injections per depth; a detection is a statistic above that +threshold with the best period within 1 % of P, 2P or P/2. Noise: +white; white + Ornstein-Uhlenbeck red (tau = 0.8 d) at 1x and 3x the +white level; white + 1x red + three shared systematics modes (6/3/6 +sigma_white) searched with a PCA basis and coefficient prior estimated +from a 60-light-curve population, as in Taaki et al. (2020). + +Arms: ``lrt`` = the whitened matched filter over an explicit epoch grid +(2 P / 0.12 d epochs per period, clipped to 8..96); ``lrt_auto`` = **the +public default path**, one ``run(t, y, periods, durations=...)`` call +with ``epochs=None`` and every other argument at its default; +``lrt_flat`` = PSD set to ones (no whitening); ``lrt_marg`` = Detector +A; ``lrt_seq`` = least-squares cotrend then the filter; ``bls`` = +``eebls_gpu_fast`` (q in 0.005..0.08); ``tls`` = the 2026-09-06 binned +``tls_search_batch`` implementation, scored by its un-normalized +delta-chi-squared statistic (an SDE over a +32-point spectrum is bounded by sqrt(31) and would saturate). The LRT +arms search durations {0.12, 0.21, 0.30} d; against the 0.22 d box the +nearest template recovers 97.7 % of the matched statistic when centred. +The explicit arm uses round(2 P / 0.12 d) epochs for every duration +(88 at P = 5.3 d, up to 0.030 d of misalignment); the default path's +own grid, ceil(2 P / duration), gives 89/51/36 epochs for the three +durations (up to 0.030/0.052/0.074 d), which is where its 4-9 % +deficit against the explicit arm comes from. BLS's q ladder happens to +sit closer to the injected duration (0.2385 d, P/200 phase bins), so +the comparators are slightly *better* matched to the injection than +the LRT grid is. + +Two configurations are *paired* with an existing one on identical light +curves (same random draws): ``white_bjd`` is the white-noise data on +absolute timestamps, ``t + 2457000.5`` d (2457000 d on top of the 0.5 d +every configuration carries, so each method's ``floor(min t)``-anchored +grid keeps its phase and any difference is a time-scale defect, not +grid alignment); ``red_sys_nzm`` is the systematics data searched with +the same PCA basis plus constant column offsets (0.12-0.49 of a +column's rms) and the unchanged prior. + +Resolution: a completeness cell carries the binomial error of 200 +injections (0.035 at p = 0.5) and the sampling error of its arm's +threshold from 200 null maxima (a common shift for all injections of +that arm); the tables quote both in quadrature, from a bootstrap of the +null set and a Wilson interval. Arm-vs-arm differences within a +configuration are paired on the same light curves (McNemar). + +Results +------- + +Completeness per depth (fraction of the flux) with its 1-sigma +uncertainty; ``ms/search`` is the per-search cost on the A40 under the +campaign's 8-process split (single-process LRT costs are ~2.5x lower). +The paired rows give A minus B on the same light curves. + +**White noise** + +.. list-table:: + :header-rows: 1 + + * - arm + - null p95 + - depth 0.002 + - depth 0.003 + - depth 0.004 + - depth 0.008 + - ms/search + * - LRT (explicit epoch grid) + - 8.588 + - 13 +- 3% + - 47 +- 5% + - 82 +- 3% + - 99 +- 1% + - 5737 + * - LRT, default path (epochs=None) + - 8.719 + - 10 +- 3% + - 42 +- 5% + - 74 +- 4% + - 99 +- 1% + - 4491 + * - BLS (eebls_gpu_fast) + - 0.038 + - 13 +- 3% + - 60 +- 4% + - 91 +- 2% + - 100 +- 0% + - 1.55 + * - TLS (tls_search_batch, delta-chi2) + - 4.662 + - 16 +- 3% + - 65 +- 4% + - 90 +- 2% + - 100 +- 1% + - 11.4 + +Paired differences A - B on the same light curves: + +.. list-table:: + :header-rows: 1 + + * - A - B + - depth 0.002 + - depth 0.003 + - depth 0.004 + - depth 0.008 + * - lrt - bls + - +0 +- 2% + - -12 +- 3% + - -10 +- 2% + - -1 +- 1% + * - lrt_auto - lrt + - -4 +- 1% + - -5 +- 3% + - -8 +- 2% + - +0 +- 1% + * - lrt - tls + - -4 +- 2% + - -18 +- 3% + - -9 +- 3% + - -0 +- 1% + +**Red noise, sigma_red = sigma_white** + +.. list-table:: + :header-rows: 1 + + * - arm + - null p95 + - depth 0.004 + - depth 0.006 + - depth 0.008 + - depth 0.016 + - ms/search + * - LRT (explicit epoch grid) + - 11.364 + - 4 +- 2% + - 25 +- 5% + - 56 +- 5% + - 100 +- 1% + - 4383 + * - LRT, default path (epochs=None) + - 11.186 + - 4 +- 2% + - 24 +- 4% + - 52 +- 4% + - 99 +- 1% + - 3412 + * - LRT, flat PSD + - 1.779 + - 4 +- 2% + - 22 +- 4% + - 54 +- 5% + - 100 +- 1% + - 4377 + * - BLS (eebls_gpu_fast) + - 0.124 + - 2 +- 1% + - 17 +- 3% + - 47 +- 5% + - 100 +- 0% + - 1.22 + * - TLS (tls_search_batch, delta-chi2) + - 12.283 + - 4 +- 1% + - 22 +- 3% + - 62 +- 4% + - 100 +- 0% + - 8.56 + +Paired differences A - B on the same light curves: + +.. list-table:: + :header-rows: 1 + + * - A - B + - depth 0.004 + - depth 0.006 + - depth 0.008 + - depth 0.016 + * - lrt - bls + - +2 +- 2% + - +8 +- 3% + - +10 +- 3% + - -0 +- 0% + * - lrt_auto - lrt + - +0 +- 1% + - -1 +- 2% + - -4 +- 2% + - -0 +- 0% + * - lrt - lrt_flat + - -0 +- 1% + - +2 +- 2% + - +3 +- 3% + - +0 +- 0% + * - lrt - tls + - -0 +- 2% + - +3 +- 3% + - -5 +- 3% + - -0 +- 0% + +**Red noise, sigma_red = 3 sigma_white** + +.. list-table:: + :header-rows: 1 + + * - arm + - null p95 + - depth 0.008 + - depth 0.016 + - depth 0.024 + - depth 0.032 + - ms/search + * - LRT (explicit epoch grid) + - 14.121 + - 0 +- 0% + - 12 +- 4% + - 57 +- 6% + - 89 +- 3% + - 4383 + * - LRT, default path (epochs=None) + - 13.972 + - 0 +- 0% + - 10 +- 3% + - 48 +- 5% + - 83 +- 4% + - 3413 + * - LRT, flat PSD + - 5.097 + - 0 +- 1% + - 18 +- 5% + - 63 +- 6% + - 90 +- 3% + - 4376 + * - BLS (eebls_gpu_fast) + - 0.204 + - 0 +- 1% + - 7 +- 2% + - 48 +- 4% + - 88 +- 2% + - 1.2 + * - TLS (tls_search_batch, delta-chi2) + - 34.507 + - 0 +- 1% + - 17 +- 3% + - 62 +- 4% + - 93 +- 2% + - 8.55 + +Paired differences A - B on the same light curves: + +.. list-table:: + :header-rows: 1 + + * - A - B + - depth 0.008 + - depth 0.016 + - depth 0.024 + - depth 0.032 + * - lrt - bls + - -0 +- 0% + - +6 +- 2% + - +10 +- 3% + - +1 +- 2% + * - lrt_auto - lrt + - +0 +- 0% + - -2 +- 1% + - -9 +- 2% + - -6 +- 2% + * - lrt - lrt_flat + - -0 +- 0% + - -6 +- 2% + - -6 +- 3% + - -0 +- 1% + * - lrt - tls + - -0 +- 0% + - -4 +- 2% + - -6 +- 3% + - -4 +- 1% + +**Red noise + shared systematics (PCA basis + population prior)** + +.. list-table:: + :header-rows: 1 + + * - arm + - null p95 + - depth 0.004 + - depth 0.008 + - depth 0.016 + - depth 0.032 + - ms/search + * - LRT (explicit epoch grid) + - 11.965 + - 0 +- 0% + - 0 +- 0% + - 6 +- 2% + - 34 +- 4% + - 5735 + * - LRT, default path (epochs=None) + - 11.616 + - 0 +- 0% + - 0 +- 0% + - 5 +- 2% + - 34 +- 4% + - 4482 + * - LRT Detector A (marginal) + - 12.104 + - 3 +- 1% + - 44 +- 5% + - 98 +- 1% + - 100 +- 0% + - 5524 + * - LRT sequential cotrend + - 12.220 + - 3 +- 2% + - 43 +- 5% + - 98 +- 1% + - 100 +- 0% + - 5412 + * - BLS (eebls_gpu_fast) + - 0.188 + - 0 +- 0% + - 0 +- 0% + - 2 +- 1% + - 16 +- 3% + - 1.51 + * - TLS (tls_search_batch, delta-chi2) + - 114.971 + - 0 +- 0% + - 0 +- 0% + - 0 +- 0% + - 0 +- 0% + - 11.4 + +Paired differences A - B on the same light curves: + +.. list-table:: + :header-rows: 1 + + * - A - B + - depth 0.004 + - depth 0.008 + - depth 0.016 + - depth 0.032 + * - lrt - bls + - +0 +- 0% + - +0 +- 0% + - +3 +- 1% + - +17 +- 3% + * - lrt_auto - lrt + - +0 +- 0% + - +0 +- 0% + - -0 +- 0% + - +0 +- 2% + * - lrt_marg - lrt_seq + - +0 +- 0% + - +0 +- 0% + - +0 +- 0% + - +0 +- 0% + * - lrt_seq - bls + - +3 +- 1% + - +43 +- 5% + - +95 +- 7% + - +84 +- 6% + * - lrt - tls + - +0 +- 0% + - +0 +- 0% + - +6 +- 2% + - +34 +- 4% + +**white vs white_bjd** (same light curves) + +.. list-table:: + :header-rows: 1 + + * - arm + - searches + - max rel. diff of the statistic + - best period differs + - detection differs + * - LRT (explicit epoch grid) + - 1000 + - 5.2e-08 + - 3 / 800 + - 0 / 800 + * - LRT, default path (epochs=None) + - 1000 + - 3.5e-08 + - 2 / 800 + - 0 / 800 + * - BLS (eebls_gpu_fast) + - 1000 + - 1.3e-06 + - 0 / 800 + - 0 / 800 + * - TLS (tls_search_batch, delta-chi2) + - 1000 + - 1.4e-07 + - 0 / 800 + - 0 / 800 + +**red_sys vs red_sys_nzm** (same light curves) + +.. list-table:: + :header-rows: 1 + + * - arm + - searches + - max rel. diff of the statistic + - best period differs + - detection differs + * - LRT Detector A (marginal) + - 1000 + - 5.5e-07 + - 3 / 800 + - 0 / 800 + * - LRT sequential cotrend + - 1000 + - 8.4e-08 + - 2 / 800 + - 0 / 800 + +Epoch recovery (arms that return a best epoch): per configuration, +99-100 % of the explicit-grid and default-path detections lie within +half a duration of the injected mid-time (the smallest per-depth cell +is 94 %, 45 of 48), with median errors of 0.01-0.03 d -- the grid +resolution. + +Null calibration of the single-template statistic on white noise: the +campaign's 200 draws give mean 0.348, std 1.579; 5000 draws from the +same seed give mean 0.030 +- 0.026, std 1.808 (the statistic is exactly +odd in the data, so its null mean is zero by construction; 1.81 is the +calibration constant of this sampling, the same value the pre-fix +campaign measured, and independent of ``sigma``). + +What the numbers say +-------------------- + +* The two configurations that exercise the Sep-2026 fixes on the public + default path pass exactly: BJD-scale times reproduce the relative-time + results to float32 rounding for every method, and the non-zero-mean + basis reproduces the zero-mean results for both basis-aware detectors. + ``epochs=None`` is a working epoch search. +* What a *default* call delivers, against BLS on the same light curves: + -17 +- 3 % at depths 0.003 and 0.004 in white noise, +7 +- 3 % and + +5 +- 4 % at 1x red (depths 0.006/0.008), +4/+1/-5 +- 2-3 % at 3x red + (0.016/0.024/0.032), i.e. indistinguishable from BLS there; against + TLS -23 +- 4 % (white, 0.003) and -7/-15/-10 % at 3x red. The + explicit-grid numbers in the bullets above are the method's; these + are the default's. +* The systematics-basis gain is a gain over *basis-free* BLS and TLS: + no cotrend-then-BLS/TLS comparator was run, so the campaign does not + show that the LRT detectors beat cotrending first and searching with + BLS or TLS afterwards. +* Against the explicit epoch grid, the default path loses 4-9 % of + completeness at the transition depths (paired, 2-4 sigma) because its + per-cell grid, ``ceil(2 P / duration)`` epochs, is coarser for the + longer durations (51 and 36 epochs at P = 5.3 d for 0.21 and 0.30 d, + against 89); it is a resolution setting, not a defect. +* White noise: BLS and TLS beat the whitened filter by 10-12 +- 3 % at + depths 0.003-0.004. Red noise: the whitened filter beats BLS by 6-10 + +- 3 % at the transition depths and is within +3/-6 % of TLS; the + flat-PSD filter is as good (1x) or better (3x, +6 +- 2 %). Shared + systematics: only the basis-aware detectors work (98 % vs <= 6 % at + depth 0.016), and Detector A equals the sequential baseline exactly. +* Compared with the pre-fix campaign (60 injections, explicit epochs + only, ``sigma = 2``, the `archived pre-fix campaign `_): the + qualitative picture in white and red noise is unchanged (no + whitening gain over a flat PSD; thresholds rise with red noise), the + Detector A row now measures the detector instead of the PSD defect, + and the default path and BJD-scale times are measured for the first + time. + +Citation +======== + +If you use this module, please cite Taaki, Kamalabadi & Kemball (2020, +AJ 159, 283) for the method, Taaki, Kemball & Kamalabadi (2025, AJ 170, +14) for the space-photometry application, the reference prototype +(``code_nova_exoghosts``), and cuvarbase itself (see the landing page). + +API reference +============= + +The canonical API entry is on the :doc:`API page `; it is +repeated here for convenience. + +.. automodule:: cuvarbase.nufft_lrt + :members: + :undoc-members: + :show-inheritance: + :no-index: diff --git a/docs/source/pdm.rst b/docs/source/pdm.rst new file mode 100644 index 00000000..52171f02 --- /dev/null +++ b/docs/source/pdm.rst @@ -0,0 +1,231 @@ +Phase Dispersion Minimization +============================= + +Phase dispersion minimization [S1978]_ phase-folds the data at each trial +frequency and measures how "dispersed" the folded lightcurve is. If the +trial frequency is close to the true frequency of a stationary signal, the +folded data trace out a coherent curve and the scatter around that curve is +small; at an unrelated frequency the fold looks like noise and the scatter +is comparable to the total variance of the data. + +Classically [S1978]_, PDM bins the folded data into :math:`M` phase bins +and computes + +.. math:: + \Theta(f) = \frac{s^2(f)}{\sigma^2} + = \frac{\sum_i \left(y_i - m_i(f)\right)^2 / (N - M)} + {\sum_i \left(y_i - \bar{y}\right)^2 / (N - 1)}, + +where :math:`m_i(f)` is the mean of the bin that observation :math:`i` +falls in at trial frequency :math:`f`, :math:`N` is the number of +observations and :math:`M` the number of occupied bins. +:math:`\Theta \approx 1` for noise and :math:`\Theta \ll 1` near the true +frequency. + +.. note:: + + **Input validation.** Since 1.0 every entry point rejects + non-finite ``t``/``y``/``dy``, ``dy <= 0``, mismatched array + lengths, too-short light curves and non-finite or non-positive + frequency grids with a ``ValueError`` raised on the host, before + any GPU work. See :ref:`Input validation ` for + the full rules and the pre-1.0 behaviour they replace. PDM + additionally rejects a *constant* ``y``: the statistic divides by + the variance of ``y``, which is then zero (before 1.0 the spectrum + was all NaN). + +The statistic ``cuvarbase`` computes +------------------------------------ + +The kernels return the *peak-finding* sum-of-squares ratio + +.. math:: + P(f) = 1 - \frac{\sum_i w_i \left(y_i - m_i(f)\right)^2} + {\sum_i w_i \left(y_i - \bar{y}\right)^2}, + \qquad \bar{y} = \sum_i w_i y_i, + +with weights :math:`w_i \propto 1/\sigma_i^2` normalized to +:math:`\sum_i w_i = 1` and :math:`m_i(f)` the model of the folded +lightcurve at the phase of observation :math:`i` (a bin mean, an +interpolation between bin means, or a local mean, depending on the +variant; see below). The best candidate frequencies appear as **maxima** +of the returned power array, consistent with the other periodograms in +this package. + +:math:`P(f)` is **not** :math:`1 - \Theta(f)`: the degrees-of-freedom +factors :math:`N - M` and :math:`N - 1` are not applied (for uniform +weights, :math:`1 - P(f) = \frac{N - M}{N - 1}\,\Theta(f)`). Keep the +consequences in mind: + +* **Noise floor.** For pure noise :math:`\Theta \approx 1`, but the + expected value of :math:`P` is :math:`(M - 1)/(N - 1)` (exact for + ``binned_step`` with Gaussian noise and uniform weights; + ``binned_linterp`` behaves similarly): up to 0.47 for :math:`N = 20` + observations in 10 bins, about 0.18 for :math:`N = 50` and 0.01 for + :math:`N = 1000`. Judge a peak against this floor, not against zero. +* **Comparability.** Values are only comparable between runs with the + same ``nbins`` (or ``dphi``) and the same :math:`N`; more bins raise + the whole periodogram. +* **Gappy data.** :math:`M` counts *occupied* bins, so with incomplete + phase coverage the floor varies along the periodogram and, for small + :math:`N`, the ranking of candidate peaks can differ from that of + :math:`\Theta`. +* **Binless kinds.** ``binless_tophat`` and ``binless_gauss`` use the + same ratio with :math:`m_i(f)` the kernel-weighted local mean (which + includes the point itself); their noise floor depends on ``dphi`` and + :math:`N`. + +To recover Stellingwerf's :math:`\Theta` for a binned kind, rescale +:math:`1 - P(f)` by :math:`(N - 1)/(N - M(f))` with :math:`M(f)` counted +on the host (the kernels do not return it); ``cuvarbase`` does not do +this for you. + +To our knowledge this is the only GPU implementation of PDM currently +available. As of v1.0 it has fast kernels for all variants, unit tests, +and this documentation; if you find problems, please open an issue. + +PDM variants +------------ + +The ``kind`` argument of :func:`PDMAsyncProcess.run` selects the dispersion +model: + +* ``binned_step`` — classic Stellingwerf PDM: the model is the (weighted) + mean in each of ``nbins`` phase bins. +* ``binned_linterp`` (default) — like ``binned_step``, but the model is + linearly interpolated between bin centers (a "PDM2"-style refinement + that reduces binning artifacts). +* ``binless_tophat`` — no binning; each point is compared against the + weighted mean of all points within a phase distance ``dphi`` of it + (``dphi`` is the **half-width** of the tophat window, in cycles). +* ``binless_gauss`` — like ``binless_tophat``, but every point enters the + local mean with a Gaussian weight in phase distance; ``dphi`` is the + **standard deviation** of that Gaussian, in cycles. + +Each variant also has a ``*_fast`` version (``binned_linterp_fast``, +``binned_step_fast``, ``binless_tophat_fast``, ``binless_gauss_fast``) +that computes the same statistic with the lightcurve staged through +shared memory (and, for ``binned_step_fast``, a one-pass sum-of-squares +formulation). They are numerically equivalent to the reference kernels up +to single-precision round-off (differences at the :math:`\sim 10^{-6}` +level) but are **not** guaranteed to be faster: the v1.0 audit measured +0.7-2.0x relative to the reference kernels on an Ada-generation GPU +(binned kinds 1.0-1.6x, ``binless_tophat_fast`` about 0.75x, +``binless_gauss_fast`` 1.3-2.0x). They may be faster on some GPUs; +benchmark both kinds on your hardware and data before choosing. + +An example with ``cuvarbase`` +----------------------------- + +.. code-block:: python + + import numpy as np + from cuvarbase.pdm import PDMAsyncProcess + + # make some fake data + t = np.sort(365 * np.random.rand(300)) + y = 12 + 0.1 * np.cos(2 * np.pi * t / 5.0) + y += 0.1 * np.random.randn(len(t)) + dy = 0.1 * np.ones_like(y) + + # start a PDM process + proc = PDMAsyncProcess() + + # format your data as a list of (t, y, err) lightcurves + data = [(t, y, dy)] + + # run PDM; a frequency grid is generated automatically + # if ``freqs`` is not given + results = proc.run(data, kind='binned_linterp', nbins=20) + proc.finish() + + # results is a list of (freqs, power) tuples, one per lightcurve + freqs, power = results[0] + best_freq = freqs[np.argmax(power)] + print(1.0 / best_freq) # ~5.0 + +You can supply your own frequency grid (or one per lightcurve), and any +keyword arguments accepted by :func:`cuvarbase.utils.autofrequency` +(``samples_per_peak``, ``nyquist_factor``, ``minimum_frequency``, +``maximum_frequency``) are forwarded when the grid is generated +automatically: + +.. code-block:: python + + freqs = np.linspace(0.01, 10.0, 100000) + results = proc.run(data, freqs=freqs, kind='binless_gauss_fast', + dphi=0.05) + +Numerical notes +--------------- + +* **Single-precision phase folding.** Times, weights and frequencies are + transferred to the GPU as ``float32`` after ``t`` and ``y`` have been + mean-centered in float64 on the host (absolute BJD-scale times are + therefore safe), and the phase + :math:`\phi_i = t_i f - \lfloor t_i f \rfloor` is evaluated in + ``float32``. There is no double-precision option. The resulting phase + error is of order :math:`\epsilon_\phi \approx 3 \times 10^{-8}\, + T f_{\max}` cycles for a baseline :math:`T` (the largest :math:`|t|` + after centering is of order :math:`T/2` (up to :math:`T` for very + uneven sampling), and float32 resolves :math:`t f` to + about :math:`2^{-24}` relative) and has to stay small compared with the + bin width :math:`1/\mathrm{nbins}` (or ``dphi``). As a rule of thumb + keep :math:`T f_{\max}\, \mathrm{nbins} \lesssim 10^{5}` (phase error + below 0.3% of a bin). Measured against a float64 fold of the same + statistic (``binned_step``, 500 points): :math:`T = 365` d, + :math:`f_{\max} = 20\ \mathrm{d}^{-1}`, 10 bins: largest deviation + :math:`3 \times 10^{-3}`, peak unchanged; :math:`T = 3650` d, + :math:`f_{\max} = 50\ \mathrm{d}^{-1}`, 10 bins: + :math:`1 \times 10^{-2}`, peak unchanged; the same with 50 bins: + :math:`4 \times 10^{-2}` and the peak frequency moved. In that regime + reduce ``nbins``, restrict ``maximum_frequency``, or split the + baseline. +* Apart from the phase resolution, the kernels agree with a float64 + evaluation of the same statistic to float32 round-off; the remaining + differences come from points that land on the other side of a bin edge, + which can move individual values by up to a few :math:`10^{-2}` at + single frequencies for gappy data with many bins. +* Results are bitwise reproducible from run to run, and the + multi-lightcurve ``run()``, ``batched_run_const_nfreq()`` and + ``large_run()`` paths are bit-identical to single-lightcurve ``run()`` + calls. + +API notes +--------- + +* ``run(data, freqs=None, kind='binned_linterp', nbins=10, dphi=0.05)`` + takes ``data`` as a list of ``(t, y, err)`` tuples. Observation + uncertainties ``err`` are converted to normalized inverse-variance + weights internally, and ``t`` and ``y`` are mean-centered before + transfer to the GPU. +* ``nbins`` controls the number of phase bins for the ``binned_*`` + variants; ``dphi`` (in cycles) is the tophat half-width or the Gaussian + standard deviation for the ``binless_*`` variants (see above). +* ``run`` keeps the device buffers it allocates and reuses them on the + next call that asks for the same shapes (same number of lightcurves, + same ``len(t)`` and ``len(freqs)`` for each), re-uploading the + frequency grid only when it changed. Loops over many short + lightcurves on a fixed grid -- including every chunk of + ``batched_run_const_nfreq`` and ``large_run`` -- therefore pay for + the allocation once instead of once per call; peak device memory is + unchanged, and each call still returns its own result array, so + results kept from an earlier ``run`` are never overwritten. Passing + your own ``gpu_data``/``pow_cpus`` from :meth:`allocate` bypasses the + cache, as before. +* The legacy input format ``[(t, y, w, freqs), ...]`` (weights and + frequencies packed into the data tuples) is still accepted for + backward compatibility but is **deprecated** and emits a + ``DeprecationWarning``; it returns bare power arrays instead of + ``(freqs, power)`` tuples. The weights ``w`` may have any scale (raw + :math:`1/\sigma^2`, all ones, ...): they are normalized to sum to one + internally, exactly like the weights derived from ``err``. Like + ``err`` they must be finite and strictly positive: a zero weight, + which some pre-1.0 pipelines used to mask a point while keeping the + array shapes, is rejected since 1.0 (the binned kernels skipped such + points, but ``binless_tophat`` divided 0/0 when a masked point's + window held no other point, and the ``(t, y, err)`` format cannot + express a masked point either) -- drop masked points from the arrays + instead. + +.. [S1978] `Stellingwerf 1978 `_ diff --git a/docs/source/plots/benchmarks.py b/docs/source/plots/benchmarks.py deleted file mode 100755 index a9e15ee9..00000000 --- a/docs/source/plots/benchmarks.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/python - -from __future__ import print_function - -import sys -import numpy as np -from time import time -import copy -import matplotlib -matplotlib.use('Agg') -import matplotlib.pyplot as plt -import pycuda.autoinit -import pycuda.driver as cuda - -import cuvarbase.bls as bls -import cuvarbase.ce as ce -import cuvarbase.lombscargle as ls -from astrobase.periodbase.kbls import _bls_runner as astrobase_bls -from astropy.timeseries import LombScargle as AstropyLombScargle -from tqdm import tqdm - - -def get_freqs(baseline=5 * 365., fmin=None, - fmax=(24 * 60.) / 30., samples_per_peak=5): - - df = 1. / baseline / samples_per_peak - if fmin is None: - fmin = 2./baseline - - nf = int(np.ceil((fmax - fmin) / df)) - - return fmin + df * np.arange(nf) - - -def data(ndata, baseline=5 * 365.): - t = baseline * np.sort(np.random.rand(ndata)) - y = np.cos(2 * np.pi * t) - dy = 0.1 * np.ones_like(t) - - y += dy * np.random.randn(len(t)) - - return t, y, dy - -def profile(func): - def profiled_func(*args, **kwargs): - cuda.start_profiler() - func(*args, **kwargs) - cuda.stop_profiler() - #pycuda.autoinit.context.detach() - sys.exit() - return profiled_func - -def function_timer(func, nreps=3): - def timed_func(*args, **kwargs): - dts = [] - for n in range(nreps): - t0 = time() - func(*args, **kwargs) - dt = time() - t0 - dts.append(dt) - return min(dts) - - return timed_func - - -eebls_gpu = function_timer(bls.eebls_gpu) -eebls_transit_gpu = function_timer(bls.eebls_transit_gpu) -eebls_gpu_fast = function_timer(bls.eebls_gpu_fast) -astrobase_bls = function_timer(astrobase_bls) - -_eebls_defaults = dict(qmin_fac=0.5, qmax_fac=2.0, dlogq=0.25, - samples_per_peak=4, noverlap=2) - - -def profile_cuvarbase_ce(t, y, dy, freqs, **kwargs): - - proc = ce.ConditionalEntropyAsyncProcess(**kwargs) - proc.preallocate(len(t), freqs, **kwargs) - run = profile(proc.run) - - run([(t, y, None)], freqs=freqs, **kwargs) - - return True - -def time_cuvarbase_ce_run(t, y, dy, freqs, **kwargs): - proc = ce.ConditionalEntropyAsyncProcess(**kwargs) - proc.preallocate(len(t), freqs, **kwargs) - run = function_timer(proc.run) - - return run([(t, y, None)], freqs=freqs, **kwargs) - - -def time_cuvarbase_bls(t, y, dy, freqs, qmin=1e-2, qmax=0.5, - memory=None, pre_transfer=False, transit=False, - use_fast=True, **kwargs): - - kw = copy.deepcopy(_eebls_defaults) - kw.update(kwargs) - kw['use_fast'] = use_fast - - if memory is None and not transit: - memory = bls.BLSMemory.fromdata(t, y, dy, freqs=freqs, - transfer=pre_transfer, - qmin=qmin, qmax=qmax) - - if not transit and use_fast: - return eebls_gpu_fast(t, y, dy, freqs, memory=memory, - qmin=qmin, qmax=qmax, - transfer_to_device=(not pre_transfer), - **kw) - if not transit: - return eebls_gpu(t, y, dy, freqs, qmin=qmin, qmax=qmax, - **kw) - - qvals = kwargs.get('qvals', None) - if freqs is None: - freqs, qvals = bls.transit_autofreq(t, **kw) - elif qvals is None: - qvals = bls.q_transit(freqs, **kw) - - return eebls_transit_gpu(t, y, dy, freqs=freqs, qvals=qvals, **kw) - - -def time_astrobase_bls(t, y, dy, freqs, qmin=1e-2, qmax=0.5, - **kwargs): - - nfreqs = len(freqs) - minfreq = min(freqs) - stepsize = freqs[1] - freqs[0] - nphasebins = int(np.ceil(1./qmin)) - - args = (t, y) - args += (nfreqs, minfreq, stepsize, nphasebins, qmin, qmax) - return astrobase_bls(*args) - - -def subset_data(t, y, dy, ndata): - inds = np.arange(1, len(t) - 1) - np.random.shuffle(inds) - - subinds = np.concatenate(([0], np.argsort(t[inds[:ndata-2]]), - [len(t) - 1])) - return (arr[subinds] for arr in (t, y, dy)) - - -def time_group(task_dict, group_func, values): - times = {} - for name in task_dict.keys(): - print(name) - dts = [] - for v in tqdm(values): - dts.append((v, group_func(task_dict[name], v))) - times[name] = dts - return times - -n0 = 1000 -ndatas = np.floor(np.logspace(1, 4.5, num=8)).astype(np.int) -#nblocks = np.arange(1, 25) -#nblocks = np.concatenate((nblocks, np.arange(nblocks[-1], 3000, 50))) -nblocks = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 100, 200, 500, 1000, 2000, 5000] -freq_batch_sizes = [1, 5, 10, 50, 100, 500, 1000, 2000, 5000] - -t, y, dy = data(max(ndatas), baseline=10. * 365) -freqs_t, qvals_t = bls.transit_autofreq(t, fmin=0.01, **_eebls_defaults) -t0, y0, dy0 = subset_data(t, y, dy, n0) - -qmin = min(qvals_t) -qmax = max(qvals_t) -freqs = get_freqs(baseline=(max(t) - min(t)), samples_per_peak=4, fmin=0.01) - -print(qmin, qmax, len(freqs_t), len(freqs)) -# profile_cuvarbase_ce(t0, y0, dy0, freqs=freqs, use_fast=True, force_nblocks=200) - - -tasks = { - 'BLS: cuvarbase (0.2.0)': lambda T, Y, DY, FREQS=freqs, - force_nblocks=1000, **kwargs: - time_cuvarbase_bls(T, Y, DY, FREQS, use_fast=True, - force_nblocks=force_nblocks, **kwargs), - - 'BLS: cuvarbase (0.2.0) -- transit': lambda T, Y, DY, FREQS=freqs, - force_nblocks=1000, **kwargs: - time_cuvarbase_bls(T, Y, DY, None, use_fast=True, - force_nblocks=force_nblocks, transit=True, - **kwargs), - - 'BLS: cuvarbase (0.1.9)': lambda T, Y, DY, FREQS=freqs, **kwargs: - time_cuvarbase_bls(T, Y, DY, FREQS, use_fast=False, **kwargs), - - 'BLS: astrobase': lambda T, Y, DY, FREQS=freqs, **kwargs: - time_astrobase_bls(T, Y, DY, FREQS, **kwargs), - - 'CE: cuvarbase (0.1.9) 25-2-10-1': lambda T, Y, DY, FREQS=freqs, - use_fast=False, phase_bins=25, phase_overlap=2, mag_bins=10, - mag_overlap=1, use_double=False, **kwargs: - time_cuvarbase_ce_run(T, Y, DY, FREQS, use_fast=use_fast, **kwargs), - - 'CE: cuvarbase (0.2.0) 25-2-10-1': lambda T, Y, DY, FREQS=freqs, - use_fast=True, phase_bins=25, phase_overlap=2, mag_bins=10, - mag_overlap=1, use_double=False, **kwargs: - time_cuvarbase_ce_run(T, Y, DY, FREQS, use_fast=use_fast, **kwargs) - - -} - - - -tasks_nblocks = {name: tasks[name] for name in ['BLS: cuvarbase (0.2.0)', - 'CE: cuvarbase (0.2.0) ' - '25-2-10-1']} - - -def nblock_group_func(func, nblock): - return func(t0, y0, dy0, freqs, force_nblocks=nblock) - - -def ndata_group_func(func, ndata): - T, Y, DY = subset_data(t, y, dy, ndata) - return func(T, Y, DY, freqs) - - -def freq_batch_size_group_func(func, fbs): - return func(t0, y0, dy0, freqs, freq_batch_size=fbs) - - -groups = { - 'N observations': (tasks, ndata_group_func, ndatas), - 'Grid size': (tasks_nblocks, nblock_group_func, nblocks), - 'Frequencies per kernel call': (tasks_nblocks, - freq_batch_size_group_func, - freq_batch_sizes) -} - -dev = pycuda.autoinit.device -attrs = dev.get_attributes() -device_name = dev.name() - -print(device_name) -#print(len(freqs)) -#for attr in attrs.keys(): -# print("{attr}: {value}".format(attr=attr, value=attrs[attr])) - -group_times = {} -for group in groups.keys(): - print("="*len(group)) - print(group) - print("="*len(group)) - group_times[group] = time_group(*groups[group]) - -for group in group_times: - times = group_times[group] - - f, ax = plt.subplots() - for taskname in sorted(list(times.keys())): - values, dts = zip(*times[taskname]) - ax.plot(values, dts, label=taskname) - - f.suptitle(device_name) - ax.set_xlabel(group) - ax.legend(loc='best') - ax.set_yscale('log') - ax.set_xscale('log') - - device_name.replace(' ', '_') - group.replace(' ', '_') - fname = '{dev}-{group}.png'.format(dev=device_name.replace(' ', '_'), - group=group.replace(' ', '_')) - - f.savefig(fname) - - # plt.show() diff --git a/docs/source/plots/bls_example.py b/docs/source/plots/bls_example.py index ebd89a4a..259f90c3 100644 --- a/docs/source/plots/bls_example.py +++ b/docs/source/plots/bls_example.py @@ -30,7 +30,7 @@ def data(ndata=100, baseline=1, freq=10, sigma=1., **kwargs): def plot_bls_model(ax, y0, delta, q, phi0, **kwargs): - phi_plot = np.linspace(0, 1, 50./q) + phi_plot = np.linspace(0, 1, int(50. / q)) y_plot = transit_model(phi_plot, 1., y0=y0, delta=delta, q=q, phi0=phi0) diff --git a/docs/source/plots/bls_example_transit.py b/docs/source/plots/bls_example_transit.py index 9022a943..4e690b49 100644 --- a/docs/source/plots/bls_example_transit.py +++ b/docs/source/plots/bls_example_transit.py @@ -30,7 +30,7 @@ def data(ndata=100, baseline=1, freq=10, sigma=1., **kwargs): def plot_bls_model(ax, y0, delta, q, phi0, **kwargs): - phi_plot = np.linspace(0, 1, 50./q) + phi_plot = np.linspace(0, 1, int(50. / q)) y_plot = transit_model(phi_plot, 1., y0=y0, delta=delta, q=q, phi0=phi0) @@ -92,8 +92,8 @@ def ybar(mask): # The min/max frequencies as a fraction # of their autoset values - fmin_fac=1.0, - fmax_fac=1.5, + fmin_frac=1.0, + fmax_frac=1.5, # oversampling factor; frequency spacing # is multiplied by 1/samples_per_peak diff --git a/docs/source/plots/bls_transit_diagram.py b/docs/source/plots/bls_transit_diagram.py index 23a1c29d..979d8675 100644 --- a/docs/source/plots/bls_transit_diagram.py +++ b/docs/source/plots/bls_transit_diagram.py @@ -1,6 +1,5 @@ import matplotlib.pyplot as plt import numpy as np -import cuvarbase.bls as bls def transit_model(phi0, q, delta, q1=0.): @@ -23,7 +22,7 @@ def plot_bls_sol(t, y, dy, freq, q, phi0): w = np.power(dy, -2) w /= sum(w) - phi_plot = np.linspace(0, 1, 50./q) + phi_plot = np.linspace(0, 1, int(50. / q)) phi = (t * freq) phi -= np.floor(phi) diff --git a/docs/source/plots/ce_example.py b/docs/source/plots/ce_example.py index 726fb4dd..3ec68127 100644 --- a/docs/source/plots/ce_example.py +++ b/docs/source/plots/ce_example.py @@ -45,15 +45,15 @@ def plot_ce_bins(ax, t, y, dy, freq, ce_proc): phi = phase(t, freq) # Bin the data - phi_bins = np.floor(phi * ce_proc.phase_bins).astype(np.int) + phi_bins = np.floor(phi * ce_proc.phase_bins).astype(int) yi = ce_proc.mag_bins * (y - y0)/yrange - mag_bins = np.floor(yi).astype(np.int) + mag_bins = np.floor(yi).astype(int) bins = [[sum((phi_bins == i) & (mag_bins == j)) for j in range(ce_proc.mag_bins)] for i in range(ce_proc.phase_bins)] - bins = np.array(bins).astype(np.float) + bins = np.array(bins).astype(float) # Convert to N(bin) / Ntotal bins /= np.sum(bins.ravel()) diff --git a/docs/source/tls.rst b/docs/source/tls.rst new file mode 100644 index 00000000..224ffd80 --- /dev/null +++ b/docs/source/tls.rst @@ -0,0 +1,203 @@ +Transit Least Squares (TLS) +=========================== + +The standard ``cuvarbase.tls`` search evaluates individual observations using +the numerical search implemented by public GTLS. It retains the transit-shaped +template, broad duration domain, sample-window trials, depth estimation, +spectrum ranking, and full candidate/harmonic refinement. It does not phase-bin +observations, and a GPU memory limit does not reduce the searched durations. + +The implementation removes repeated calculation and large intermediate +residual arrays. See the `current benchmark and numerical validation +`_ +for the measured speed and the tested regimes. + +Installation +------------ + +For CUDA 12, install the v1 candidate with the TLS extra. PyPI 0.2.5 does not +contain TLS; these measurements use the ``v1.0-fixes`` branch: + +.. code-block:: bash + + pip install 'cuvarbase[tls] @ git+https://github.com/johnh2o2/cuvarbase@v1.0-fixes' + +It supplies CuPy 13 and ``batman-package``; this TLS extra supports Python +3.9–3.13. The current GPU validation uses Python 3.11 and CuPy 13.6. For another CUDA runtime, install +its matching CuPy wheel and ``batman-package`` separately. Install only one +CuPy distribution in an environment; see the `CuPy installation guide +`_. PyCUDA and CuPy use the same device's +primary context; select the device with ``CUDA_DEVICE`` before the first call. +The default TLS engine requires batman and does not silently substitute a +box-shaped or analytic template when that dependency is missing. + +Single light curves +------------------- + +.. code-block:: python + + from cuvarbase.tls import tls_search + + result = tls_search(t, flux, flux_error, + R_star=1.0, M_star=1.0, + period_min=0.5, period_max=15.0) + print(result['period'], result['SDE'], result['T0']) + +Times and periods are in days. Input arrays must be finite, aligned and contain +at least three observations with positive uncertainties and a positive time +span. Flux must be positive and normalized to an out-of-transit baseline of one; +the search does not fit a free baseline. Normalize each passband before combining +multiband observations. There is no separate per-band depth or baseline model. + +A float64 time-origin shift preserves relative, negative and absolute BJD times +without dropping observations. ``T0`` is restored to the input time system and +is the first mid-transit at or after ``min(t)``. ``t0_phase`` is its fold phase +relative to ``floor(min(t))``. + +The automatic Ofir period grid uses ``R_star`` and ``M_star`` in solar units, +``n_transits_min=2`` and ``oversampling_factor=3``. An explicit ``periods`` array +retains float64 precision; returned arrays retain its original order. +``tls_search_gpu`` and ``tls_transit`` use the same default policy. + +Small requested grids remain small: unlike pinned GTLS, cuvarbase does not +silently replace a grid of fewer than 100 periods with default solar-host +bounds. Automatic grids require ``0.01 <= R_star <= 10000`` and +``0.01 <= M_star <= 1000``; out-of-range values raise ``ValueError`` rather +than being clamped. Explicit periods accept other finite positive stellar +values. These input-policy differences do not change a search supplied with +the same valid period array. + +Thin transits and the automatic duration domain +----------------------------------------------- + +Omitting duration controls selects the broad native GTLS duration grid. There +is no phase-bin cap and no default half-central-duration cutoff. Narrow +transits therefore do not require a separate accuracy preset. + +The numerical search remains a GTLS-style sample-window/template search. It is +not an exposure-integrated physical fit to arbitrary irregular sampling. The +usual limits shared with GTLS remain: a transit must be sampled, lie inside the +period/template domain, and have sufficient signal relative to noise. The +validation compares implementations on the same data and search domain; it +cannot promise that either algorithm detects every physical transit. + +Optional controls change the scientific search: + +* ``duration_grid_step=1.1`` sets the native duration-grid spacing. +* Scalar or aligned ``qmin`` and ``qmax`` explicitly replace the duration domain. + They mean nominal duration/period. Their bounds are honored per period, + including during refinement, independently of GPU workspace chunks. +* ``duration_window='keplerian'`` explicitly selects the older stellar-duration + prior, with default factors 0.5 and 2.0. Use it only when that prior is intended. +* ``n_durations`` with an explicit window sets a minimum geometric density; + additional admissible integer sample widths can be shared across periods. +* ``u``, ``limb_dark`` and ``transit_template`` configure the native template. + Template choices are ``'default'``, ``'grazing'`` and ``'box'``. + +Surveys +------- + +.. code-block:: python + + from cuvarbase.tls import tls_search_batch + + results = tls_search_batch(lightcurves, + period_min=0.5, period_max=15.0) + +Each light curve is a ``(t, flux, flux_error)`` tuple. The standard batch wrapper +processes curves sequentially while parallelizing each period search on the +GPU and reusing bounded scan plans. The longest input baseline determines the +shared automatic grid. Pass ``periods`` to fix it explicitly. +``return_arrays=True`` includes every period spectrum. ``work_chunk=256`` sets +an upper limit on periods in a physical workspace; it changes allocation and +runtime while retaining the duration and epoch trial policies. The native +floating-point scans do not guarantee bitwise repeatability on every input; +the `numerical accuracy guide +`_ +records that shared limit. + +The estimated workspace budget is the smaller of 512 MiB and one quarter +of free device memory. If one period's full preparation exceeds that budget, +the call raises ``MemoryError``; reducing ``work_chunk`` cannot solve that +single-period case. Memory limits never silently narrow the duration search. + +Results and significance +------------------------ + +Results include ``period``, ``period_uncertainty``, ``T0``, ``t0_phase``, +``duration``, ``depth``, ``chi2_min``, ``SDE``, ``SDE_raw``, ``SNR`` and +``n_transits``. ``search_configuration`` records the numerical policy. + +SDE follows the native GTLS arithmetic and full refinement policy. Invalid +masked/nonfinite candidates are excluded before ranking; this corrects a native +host-mask defect without changing the template or its resolution. The coarse +scan uses its epoch stride; the top-ranked candidates and harmonics are searched +at every sample start and their residuals enter the final detection spectrum. +``full=False`` explicitly selects GTLS's fast-mode detection policy. cuvarbase +additionally fits the selected winner to supply its parameter-result contract; +public GTLS fast mode returns only the coarse periodogram. + +The default SDE median window derives from ``30 * oversampling_factor`` and +must have an integer width. For other fractional factors, supply an explicit +positive integer ``sde_kernel_size``; even widths are increased by one. Such +an override changes the detection statistic, so use the same setting for +observed and null searches. + +SNR retains cuvarbase's definition, the square root of the nonnegative +improvement in chi-squared over the fixed baseline, in the supplied uncertainty +units. It is different from GTLS's historically reported SNR formula. Compare +detection decisions using the full period spectrum and a common detection rule, +not equality of differently defined scalar SNR fields. + +SDE summarizes the full detection spectrum; ``period``, ``T0`` and ``SNR`` +describe the selected full-stage fit. Native harmonic selection can make +these correspond to different peaks. + +Per-period duration, depth and epoch arrays describe nominal sample-window +search diagnostics. On an irregular cadence, the native final duration/T0 +estimator can differ from these nominal values. ``parameter_valid_periods`` +distinguishes fitted windows from skipped/gated numerical sentinels; +``n_masked_periods`` counts periods excluded by the spectrum mask. +A degenerate spectrum returns SDE=SNR=0, NaN fitted parameters and an ``error``. +If only the final physical-duration estimator fails, the selected period and +spectrum remain available with ``parameter_error`` and NaN duration/T0. + +SDE is not a universal false-alarm probability. An optional white-noise +permutation calibration runs the same complete search on every null: + +.. code-block:: python + + results = tls_search_batch(lightcurves, periods=periods, + fap_null_draws=200, fap_seed=1) + results[0]['FAP'] + results[0]['SDE_null'] + +The add-one estimator is ``(1 + n_exceed) / (1 + fap_null_draws)``. Permuting the +flux/error pairs preserves sampling and the marginal error distribution but +destroys correlated noise. This is not a systematics model. FAP controls are +available only on the batch wrapper. + +Older engines +------------- + +``method='binned'`` explicitly selects the earlier phase-binned batch engine. +It accepts ``nbins``, ``block_size``, ``t0_oversample``, ``n_durations``, +``refine_top_k`` and ``refine_oversample``. Its coarse statistic approximates +the observation-level search; its sensitivity limits and historical large +speed ratios apply only to that engine. See the `binned-engine audit +`_. + +``method='legacy'`` on the single-curve wrapper selects the old shared-memory +per-observation kernel, including its light-curve-length limit and low-level +memory/stream controls. The deprecated ``use_fast`` keyword selects these older +engines: True means binned and False means legacy. + +References +---------- + +* Hippke & Heller (2019), A&A 623, A39, `Transit Least Squares + `_. +* Ofir (2014), A&A 561, A138, `Optimizing the search for transiting planets + `_. +* `Pinned GTLS implementation + `_. diff --git a/docs/validation/README.md b/docs/validation/README.md new file mode 100644 index 00000000..becdf8e7 --- /dev/null +++ b/docs/validation/README.md @@ -0,0 +1,25 @@ +# v1.0 release validation + +Complete logs and machine output are preserved in the +[verified evidence archives](../BENCHMARK_ARCHIVES.md). Links below identify +the corresponding study; its inventory retains each original file and checksum. + +The current candidate is **1.0.1**, prepared without publication. The expanded September 24–25 A40 suite passed **2,091 tests**, with one expected notebook failure and zero skips. The [September 27 installed-wheel gate](../../benchmarks/results/tls_survey_2026-09-10/release-gate-20260927/README.md) passed all 14 numerical/runtime checks and six dependency preflights after correcting the package-installation setup. The initial failed launcher receipt remains preserved. [Release preparation and source comparison](../RELEASE_PREPARATION.md) bind the final versioned artifacts to those tested package sources. + +The new observation-level TLS engine has its own [10 September validation](tls-default-20260910/README.md): **265 TLS tests passed on an A40**, plus installed-wheel checks. The [independent numerical study](../../benchmarks/results/tls_reference_2026-09-10/README.md) validates its search outputs and supplies the timing comparison. + +The checks below ran on 6 September 2026 against frozen source `1032caf029570dc4841db1c594a2cbb1654e8fd8`. They establish correctness and packaging checks for that source, separately from the [performance benchmark](../TRANSIT_BENCHMARKS.md). Later implementation and documentation changes are not covered by these original full-suite counts. + +| Check | Outcome | Evidence | +|---|---|---| +| Full source suite | 1,785 passed, 1 expected failure; no skips or failures | [Source test log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/suite_full.log") | +| Additional release checks | 14/14 passed | [Release gate log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/release_gate.log") | +| Clean GPU Sphinx build | Passed with warnings treated as errors | [Build log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/docs_build.log"), [figure log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/docs_figures.log") | +| Wheel and source distribution | Build and strict metadata checks passed | [Build log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/build.log"), [Twine log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/twine_check.log") | +| Installed wheel suite | 1,773 passed, 11 source-only skips; no failures | [Wheel test log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/wheel_pyargs.log") | +| Installed source-distribution suite | 1,773 passed, 11 source-only skips; no failures | [Source-distribution test log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/sdist_pyargs.log") | +| Import without PyCUDA | Wheel and source distribution passed | [Wheel smoke log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/wheel_smoke.log"), [source-distribution smoke log](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/sdist_smoke.log") | + +The expected failure covers the PDM notebook's known non-raw TeX label strings. [Environment details](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/env_record.txt") and [source provenance](../BENCHMARK_ARCHIVES.md#validation-v100 "Archived file: docs/validation/v1.0.0/source_provenance_final.log") accompany the logs. The full original execution record, including release orchestration, is available in [Git history](https://github.com/johnh2o2/cuvarbase/tree/f0dc981/analysis/v1.0-release-gate-20260906). + +To run current checks, see [developer tools](../../tools/README.md). diff --git a/docs/validation/release-prepared-20260927/.gitignore b/docs/validation/release-prepared-20260927/.gitignore new file mode 100644 index 00000000..64ad07c4 --- /dev/null +++ b/docs/validation/release-prepared-20260927/.gitignore @@ -0,0 +1,6 @@ +* +!.gitignore +!.gitattributes +!*.md +!checks.json +!package-verification.json diff --git a/docs/validation/release-prepared-20260927/checks.json b/docs/validation/release-prepared-20260927/checks.json new file mode 100644 index 00000000..315aa054 --- /dev/null +++ b/docs/validation/release-prepared-20260927/checks.json @@ -0,0 +1,108 @@ +{ + "checked_utc": "2026-09-28T02:57:14.296880+00:00", + "version": "1.0.1", + "host_suite": { + "collected": 2008, + "passed": 1141, + "skipped": 866, + "xfailed": 1, + "failed": 0 + }, + "release_tools_with_batman": { + "collected": 211, + "passed": 211, + "skipped": 0, + "xfailed": 0, + "failed": 0 + }, + "installed_artifacts": { + "checked_utc": "2026-09-28T02:53:44.765587+00:00", + "checks": [ + { + "artifact": "cuvarbase-1.0.1-py3-none-any.whl", + "status": "installed_and_smoke_checked", + "package_path_verified": true, + "dependencies": "inherited existing host dependency environment; package installed into fresh venv", + "version": "1.0.1" + }, + { + "artifact": "cuvarbase-1.0.1.tar.gz", + "status": "installed_and_smoke_checked", + "package_path_verified": true, + "dependencies": "inherited existing host dependency environment; package installed into fresh venv", + "version": "1.0.1" + } + ] + }, + "fatal_lint_passed": true, + "sphinx": { + "unexpected_warnings": 0, + "expected_gpu_plot_warnings": 5 + }, + "workflow_yaml_parsed": true, + "preservation": { + "files_checked_for_current_credential_values": 2473, + "credential_files_excluded": true, + "timing_rows_unchanged": 16, + "original_exactness_evidence_unchanged": true, + "exactness_receipt_sha256": "1931a7a9f7ab7c34f7e882406926da7e44b446c7d8c7fa4b57120dde4fe4c9eb" + }, + "prior_gpu_suite": { + "passed": 2091, + "xfailed": 1, + "skipped": 0, + "failed": 0 + }, + "prior_gpu_gate": { + "numerical_runtime_checks_passed": 14, + "dependency_preflights_passed": 6 + }, + "version_only_package_change": true, + "gpu_experiments_repeated": false, + "new_rentals": false, + "evidence_files": { + "host-suite.xml": { + "bytes": 494714, + "sha256": "7334e2eeab840b5751b016f59bb62e550cf272d84a200976ddc5addd9725c188" + }, + "host-suite.log": { + "bytes": 37417, + "sha256": "64b817a0a978d460a16d9af524186db7383d5db0d999f3d3f4280209a1464f17" + }, + "release-tools-with-batman.xml": { + "bytes": 32875, + "sha256": "59d9a61c9572889a5f4b969e0ec97f42cc5e4581b0fa2cc7292e89af83097af8" + }, + "release-tools-with-batman.log": { + "bytes": 379, + "sha256": "6dd9d7d6248fd93d21c8c55492a9ce03236919b3bbe0aadc272381452905f7e8" + }, + "wheel-installed-smoke.log": { + "bytes": 1231, + "sha256": "2eb7208a5089661c5ba446ffd1b5a80093d7ea7dbc528302e80731870657a266" + }, + "sdist-installed-smoke.log": { + "bytes": 1883, + "sha256": "474ea321b21ad0fe5c6c3b3ac9b9f54d3e0fab1f7a9935dbb9b68d2b34c7a2fe" + }, + "twine-check.log": { + "bytes": 530, + "sha256": "429938d5e7c3145bbd9f6487f1015376ac6e6fa08f18e02f3143d141694d2047" + } + }, + "current_release_local_links": { + "checked": 106, + "missing": [] + }, + "git_evidence_bytes": { + "staged_files_verified": 566, + "all_staged_bytes_identical_to_working_files": true, + "frozen_study_line_endings_preserved": true + }, + "advisory_whitespace": { + "path": "cuvarbase/tls_reference_experimental_math.py", + "issue": "Existing final blank line in the GPU-validated source", + "retained_for_source_byte_identity": true, + "fatal_lint_passed": true + } +} diff --git a/docs/validation/release-prepared-20260927/package-verification.json b/docs/validation/release-prepared-20260927/package-verification.json new file mode 100644 index 00000000..9bfba998 --- /dev/null +++ b/docs/validation/release-prepared-20260927/package-verification.json @@ -0,0 +1,113 @@ +{ + "checked_utc": "2026-09-28T02:53:42.902370+00:00", + "version": "1.0.1", + "status": "package_sources_verified_against_gpu_validated_candidate", + "gpu_validated_version": "1.0.0", + "gpu_validated_wheel_sha256": "6c01fcf8530af19a34cef5a08711c81b69d6f6cb4605dc6a5dca29bdb5bad797", + "package_files": 86, + "byte_identical_package_files": 85, + "version_only_changed_file": "cuvarbase/__init__.py", + "version_only_change_verified_exactly": true, + "working_tree_matches_wheel": true, + "sdist_matches_wheel": true, + "metadata_readme_verified": true, + "twine_strict_passed": true, + "artifacts": { + "cuvarbase-1.0.1-py3-none-any.whl": { + "bytes": 542763, + "sha256": "a00dd3d30be3665754200054e930b0142e01889a1aef76089154bdfa71560646" + }, + "cuvarbase-1.0.1.tar.gz": { + "bytes": 533468, + "sha256": "e1b2b59319d8a430359ef9bda1bd4124fd63313875b32b8c6002860491742611" + } + }, + "package_file_sha256": { + "cuvarbase/__init__.py": "7def76024d82dc3af71c9c1f5e45507f07678fec37ce0341f8615ace14fc8193", + "cuvarbase/_cufft.py": "aecc1ed0d5787e90eca6600b67d928990d6723998a81021ea4d64c00f02ede05", + "cuvarbase/bls.py": "e09104d989f17ceee0d46ee66976cdeeb1167901ed84fb5578bcd7dd1d459978", + "cuvarbase/bls_frequencies.py": "523e57aa62fb450f02704463263f891590f45b98444983b0ad352659e5fcbace", + "cuvarbase/ce.py": "9c2ac18753ab63714d9f75cfb7bbb3f9c52525015561af25532db9a7aefa86b8", + "cuvarbase/core.py": "97d198f5a6a187919894a1cb046bc3d245d8b143e2faaedfa8f44b51cd975be8", + "cuvarbase/cufinufft_backend.py": "9ac11d33fd47f208478e282264f23f32ea3fca84c6d9f5090712d204c62b7689", + "cuvarbase/cunfft.py": "ce4f9fdd05961badaa5b4b37f11b715cd3d700018512fd1db58a311ee9f246b7", + "cuvarbase/lombscargle.py": "b5aeceed05dcaea6454df261d322e92e3b637ab499dad6c50c49116f7ef4e213", + "cuvarbase/nufft_lrt.py": "e928da1338d51eca2416fb8d9a80a9f752c7746d98eaf6d632ed42c97c34ff01", + "cuvarbase/pdm.py": "c690b4440fe27a5bdd2bee439fccb4c03c7e445279601977969746b9d0e0c8f6", + "cuvarbase/tls.py": "41c9d62044fee6cfcc7a3f038d22912d9f90cda58967c768ce238c774e69364c", + "cuvarbase/tls_grids.py": "56398357bc4258d52c560170eb01cb204f547d2331a3202648225081ed86f1b7", + "cuvarbase/tls_models.py": "553c90b7abc9511d24589d75abbdb93383541d67b47afa02aef4d12a7fad1623", + "cuvarbase/tls_reference.py": "a38d5a9b83b02825374ba6603d3c7b4106a10c939f98c9fb8004c09ce41100e2", + "cuvarbase/tls_reference_experimental.py": "fc96bb437be27a1d11a920e3b144cb5ac530233bc338c7d9a968b4eba8cb3953", + "cuvarbase/tls_reference_experimental_math.py": "a9f2306e8a17988992bbb04bac0793a53b09a9acbf6f13e80bff6e50eef4c4b5", + "cuvarbase/tls_reference_frontend.py": "36887769aef3395842888126be2d6f0de842865d06d08bfa899f8614ce359877", + "cuvarbase/tls_reference_math.py": "27e5a575e6279e64ce02fd17c40d64e9885f9c285d3145e13e2bee5ac94afe13", + "cuvarbase/tls_reference_prefix.py": "3fcdaf0acd1fb5ff5f00637872ff2785b943b0996369ffeb2f3f3b7e431035fb", + "cuvarbase/tls_reference_short_prefix.py": "6f3c6e2c4c100b2ba834b8a75c65d3c742e7675494ce7ddfbaa0c10c2ce9f5f0", + "cuvarbase/tls_stats.py": "53fba5f00950175c742713fc021b5174cd5f86d8fc82e13bf14056aeb8b83ef5", + "cuvarbase/utils.py": "404a0b29f2da4c7cf7f9fba5f738e7069b61c0fa93c7e2f23de86bd226cb379f", + "cuvarbase/base/__init__.py": "26df6507e65a9e023768da85d2dcadcce00255d697b9655b6cde6519b436a562", + "cuvarbase/base/async_process.py": "974b1170ef180e946e4d6d6ae2761f88e56676d177c23f813a49fbd9eebbefb0", + "cuvarbase/base/context.py": "2d8430e079b3032c4f9c1197fc4ff7cb4ebd04eaa05892eaf2684ba14883b0ab", + "cuvarbase/kernels/bls.cu": "400326db8eb64361d89bd696c02d0bb02e35e307715cee936dfc2bcc592d1004", + "cuvarbase/kernels/bls_batch.cu": "76d786fc6cc93feaa8a56e83d4cb7d7149b315c17f738fd89ca3badb95b68622", + "cuvarbase/kernels/bls_common.cuh": "f3e6ed36d840cc9d57265efc32cec82dcade0f7a9f33c6b27160e38fbdf340b9", + "cuvarbase/kernels/bls_optimized.cu": "3b583bac9a4a6222207ae13760145a15f5faa8cfa4b9068e4835102450d11f19", + "cuvarbase/kernels/ce.cu": "2840a0a20e4236dbb7386f990082ce8401950a3b30efd69ed661d289ffb6230d", + "cuvarbase/kernels/cunfft.cu": "beeeb467ec3584a735335df0cea5d82394b0e5fa2968632a07fe183925dcd5d8", + "cuvarbase/kernels/lomb.cu": "db5724f20c905ecacb9633d5c5f35cdd4c622a9a67b6e7f0aadadd5b048e94a9", + "cuvarbase/kernels/nufft_lrt.cu": "3e10e3e45d9f0db4fb45f0ce0f109616ba0a9c2103ea053ba9fb20503067130c", + "cuvarbase/kernels/pdm.cu": "64c69c6e3e116a16ec3c6c25c56cd891b2900d61387caa2f3446e437647c6f3b", + "cuvarbase/kernels/sparse_bls.cu": "e0b9c01655b7bcde0a93acb1fbe2c5514ce12263f0a9bbf35cdee430dfb4185f", + "cuvarbase/kernels/tls.cu": "bf0935db29b039e3a90a60de4696a9a49805d49142294a1805aa70cbe6963582", + "cuvarbase/kernels/tls_fast.cu": "c52ea297efc597ad25899be33a975d5aebd1f15df9be864e538267b0c956e3d0", + "cuvarbase/kernels/tls_reference.cu": "b060dbd2063b03d3295f8469ecb4293a1118131d41a1c55fa0399d0a24fc7d97", + "cuvarbase/kernels/tls_reference_experimental.cu": "0725f64bb424334abef574c7d482e946377738488e7a84cf320b731a019bc7a0", + "cuvarbase/kernels/tls_reference_prepare.cu": "ab4df4b72aba4d39b93071755978a3706df98b99092546364357873f0c930b9c", + "cuvarbase/kernels/tls_reference_short_prefix.cu": "e23b02f31f8da36077ae66c5d2c827cb06b2c5fab471bebfb8c7686b2691cc7a", + "cuvarbase/memory/__init__.py": "1427d5bb6daad54a1359a7cac3638c6ac2973e5d5a70c9667a379d94c5e1a07f", + "cuvarbase/memory/_host.py": "1be037b705b12bee28cff8e955cad735b5be2875740417e4b6405af91d9b92dd", + "cuvarbase/memory/bls_memory.py": "3c92b95169c5fca7eb4ffca94c70b6d31cd07a7116115369a45fa937b6865942", + "cuvarbase/memory/ce_memory.py": "0f8d05f0901aecb4011264af55c5ad82c1c1eb1783cb249ef9723ca610ce63b3", + "cuvarbase/memory/lombscargle_memory.py": "5920c6a69ed0f7d3446533eccc512c5339fc515a636609c46c26b47f3f6158f0", + "cuvarbase/memory/nfft_memory.py": "7cb9b2ec8cfca7c6711bc13f454dfd4b36229454e5a7846e9e162055e64d5906", + "cuvarbase/tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "cuvarbase/tests/_tls_reference_goldens.py": "658c70f4f079ff1799b13ebf6930100ef4b4fa8e620af879ea07e073f483f787", + "cuvarbase/tests/conftest.py": "c9629e46dbcc192c09bf7945ec1936b217821ff423ac00c79a1b4f4cf0d8e400", + "cuvarbase/tests/test_api_freeze.py": "4ea02a10540b581816736bfe4bfb4f3b096158972cf9a250eb65d9d5abc7f45c", + "cuvarbase/tests/test_bls.py": "738bf1510139ff2d1e0abe54761d239c48e4f7324ca657fe102ade4f0e0880eb", + "cuvarbase/tests/test_bls_frequencies.py": "dcb4c34135a9aed5bb20cffc53dd37d25310f86aebfe96321e967c9c3992307d", + "cuvarbase/tests/test_ce.py": "a61650dfe6b4d13fd59916bae53f28a1069eb05e5a3177381f7da6c63c9e79e1", + "cuvarbase/tests/test_error_hygiene.py": "1ba4552682e8a439293324f48cc5a215c38969c9cb10d5c4ec94e427e5c6b811", + "cuvarbase/tests/test_examples_compile.py": "6481c34c56ad299185fdb78730da8e88b986bc18fe54343d9da7706781aa73d9", + "cuvarbase/tests/test_host_array.py": "09ddb2edd50393beeac019e4e3568ae9273806bec4022bee64ac752860a1b266", + "cuvarbase/tests/test_input_validation.py": "89a3fb14445717b51100f9daa1235bebbbabc848400aa34583f9018655454565", + "cuvarbase/tests/test_keplerian_relations.py": "de30209a61494e16b0501e0257b89b9a6f8f79386c42fdbfe089b7f0d9a566c6", + "cuvarbase/tests/test_kernel_cache.py": "eef6fcc2290361f1ea214195e58e0c277333e5b09a41c7a9300f9349322f6a7f", + "cuvarbase/tests/test_kernel_drift.py": "02a0fc7176767619c0e23552459aa503893aed55b53246a6c837ec874dd983d8", + "cuvarbase/tests/test_kernel_inventory.py": "3392a560a13469b7c434e55d6b141efdea576d636f3d657aabbebd8a6ef45189", + "cuvarbase/tests/test_lazy_imports.py": "c4ad9fe1ea781a716550e079a09a9c584bed3604ada8a63e6f32098be4a754c0", + "cuvarbase/tests/test_lombscargle.py": "2b6d1c5ae90b7a4e2595b554334b0a538e72f1d62e12de079e2ef57c0757b5c5", + "cuvarbase/tests/test_mhgls_hybrid.py": "661b8742a00addfdc8682ecdbc948358cea81e263c6cb2421b3679e5ab6f604d", + "cuvarbase/tests/test_nfft.py": "058233f185e358080e91f8e7ee46bdfc4e8a83d3eac213b97322a1e9f737074c", + "cuvarbase/tests/test_nfft_m.py": "4a06fad23dfcc9edce8f92ce1e83caf3d29139ecd2983a0df30707ad4261bcd4", + "cuvarbase/tests/test_nufft_lrt.py": "c4c1d43eda2722d78fc03662b1fa0c20f5d2b15a947a78dc518aa7b94b1800a2", + "cuvarbase/tests/test_nufft_lrt_algorithm.py": "f10079e8e5eb64d841e7d30ac6e9449ea91aa1eb300d58fb11d0fa6fa6cdc3c6", + "cuvarbase/tests/test_nufft_lrt_import.py": "f299c43b8d1444398c3e3b6937f5df969a13207dd0ab00cf5e7486412c48a67a", + "cuvarbase/tests/test_nufft_lrt_pipeline.py": "cda15939669fc2d7b81e836ef269fb7212103e94c261321524ec0152af9fb026", + "cuvarbase/tests/test_pdm.py": "e781f2e80f78428afb938defc7fe29ddbe0b734c52416fb0582249e0cc2c8b6d", + "cuvarbase/tests/test_pdm_batch.py": "f615e3a5e4c1b446ab0754a613d07c116d46db40f52a19d82fbfacefba1a3133", + "cuvarbase/tests/test_readme_consistency.py": "78d3b90af0a2d9818b9a1bd7946885ee07e24cc3d9be33038b6b1b8e54abd7a6", + "cuvarbase/tests/test_readme_examples.py": "bd56646ecf339ce0a640a193440488715ef5f82162a7a6123cd8d765542462cc", + "cuvarbase/tests/test_tls_basic.py": "18d7a74ee7416771f78761f92108f7e4855a2740021f2dee482eb3350df5f920", + "cuvarbase/tests/test_tls_execution_isolation.py": "a627cc40788447a2c3652f06240551e8990d1025bb549c3f2394c0cd22bdceab", + "cuvarbase/tests/test_tls_fast.py": "2c5b206332424855266c44f38f55c6805e8426604eb9a155da660c02ed95a31a", + "cuvarbase/tests/test_tls_golden.py": "645757105d27666e96203d49d3b08765f469c73cbc9254649294537bc778d0cc", + "cuvarbase/tests/test_tls_reference_frontend.py": "51777594ecd7854975f4b57526b78f829376d2156f8b87b4fbfb01526f40aa0b", + "cuvarbase/tests/test_tls_reference_math.py": "036ecdb02d4a3f61ff74b885854bc1ec00ca966d7b5951045fd6124094b980d6", + "cuvarbase/tests/test_tls_reference_prefix.py": "41443fa7250fbe17f93bc1ca7fd1c755e1fc1b0fc05b671d8ea986d22b21db82", + "cuvarbase/tests/test_tls_reference_short_prefix.py": "fd2843a293c722fc5bb383d3bf0be57ccf997884a3cc43ba7a0797280340966e", + "cuvarbase/tests/test_tls_t0_oversample.py": "fb144497eef4738bf29b6dd7a5769d4a4dda20ea7cf7ac16760d4a53142711a3", + "cuvarbase/tests/test_utils.py": "d5500cf30874926280a54dc69b4f05da5495ed3b58c789285ff2cce5e9d25d7b" + } +} diff --git a/docs/validation/tls-default-20260910/.gitattributes b/docs/validation/tls-default-20260910/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/docs/validation/tls-default-20260910/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/docs/validation/tls-default-20260910/.gitignore b/docs/validation/tls-default-20260910/.gitignore new file mode 100644 index 00000000..ad810a68 --- /dev/null +++ b/docs/validation/tls-default-20260910/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!.gitattributes +!*.md diff --git a/docs/validation/tls-default-20260910/README.md b/docs/validation/tls-default-20260910/README.md new file mode 100644 index 00000000..513b0e6c --- /dev/null +++ b/docs/validation/tls-default-20260910/README.md @@ -0,0 +1,36 @@ +# Standard TLS implementation checks — 10 September 2026 + +All **265 TLS tests passed on an NVIDIA A40**, with zero failures or skips, +against the frozen production source used for the independent +[GTLS comparison](../../../benchmarks/results/tls_reference_2026-09-10/README.md). +These are correctness tests, separate from recovery and speed measurements. + +- [GPU receipt](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/receipt.json"): exact command, source hashes before and after, + dependency versions, device identity, timestamps and artifact hashes. +- [GPU test output](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/tests.log") and [JUnit results](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/tests.xml"). +- [Distribution receipt](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/distribution.json"): wheel/sdist hashes, TLS extra + dependencies and byte-for-byte checks of the new installed modules and + kernels against the GPU-tested source. +- [Installed-wheel tests](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/wheel-tests.log"): 87 passing host-side TLS math, + frontend and kernel-inventory tests, run outside the source checkout. +- [Current CPU suite](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/cpu-suite.json") and [output](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/cpu-suite.log"): 1,215 passed, + 851 environment-dependent skips and one expected failure, including the + TLS benchmark harness tests. Runtime and GPU TLS test sources are unchanged; + one README consistency test now reflects the candidate installation. +- [Documentation build](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/docs-build.json"): HTML builds successfully; the five + expected GPU plot warnings on this CPU host are retained in the + [build log](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/docs-build.log") and [warning log](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/docs-warnings.log"). +- [BLS source continuity](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/bls-source-continuity.json"): the measured BLS code + and its local dependencies are unchanged, apart from one documentation link. + The nine September 8 timing records therefore describe the same BLS + implementation; their original workload and hardware qualifications remain. + +The GPU environment used Python 3.11.10, CUDA 12.4, CuPy 13.6.0, +PyCUDA 2025.1.2, NumPy 2.2.6, SciPy 1.15.3 and batman-package 2.5.3. +The installed-wheel host tests used the separately recorded +[CPU environment](../../BENCHMARK_ARCHIVES.md#validation-tls-default-20260910 "Archived file: docs/validation/tls-default-20260910/host-environment.json"). +Wheel and sdist hashes identify local build artifacts, not a PyPI publication. + +The earlier [full release suite](../README.md) is dated evidence for its +recorded source. Its test count is not combined with these results to claim +that the expanded current full suite ran on a GPU. diff --git a/docs/validation/v1.0.0/.gitattributes b/docs/validation/v1.0.0/.gitattributes new file mode 100644 index 00000000..cebd76b3 --- /dev/null +++ b/docs/validation/v1.0.0/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the original validation log bytes. +* -text diff --git a/docs/validation/v1.0.0/.gitignore b/docs/validation/v1.0.0/.gitignore new file mode 100644 index 00000000..ad810a68 --- /dev/null +++ b/docs/validation/v1.0.0/.gitignore @@ -0,0 +1,4 @@ +* +!.gitignore +!.gitattributes +!*.md diff --git a/examples/nufft_lrt_example.py b/examples/nufft_lrt_example.py new file mode 100644 index 00000000..be2f860a --- /dev/null +++ b/examples/nufft_lrt_example.py @@ -0,0 +1,123 @@ +""" +Example usage of the NUFFT-based Likelihood Ratio Test for transit detection. + +Demonstrates ``NUFFTLRTAsyncProcess`` on gappy ground-based sampling with +absolute (BJD-scale) timestamps and a transit injected at a random epoch: +the default ``epochs=None`` scans an automatic epoch grid per (period, +duration) cell and returns the maximum statistic together with the epoch +that attains it. Note the statistic is a whitened correlation, not an +N(0, 1) SNR -- a detection threshold has to be calibrated on signal-free +data (sketch at the end); see the NUFFT-LRT page of the documentation +(docs/source/nufft_lrt.rst). + +The period grid matters: a box of duration ``d`` at period ``P`` drifts +by ``T * dP / P`` over a baseline ``T`` when the trial period is off by +``dP``, so the grid step must be ``dP <~ d * P / (2 T)`` or the true +period falls between grid points and a harmonic alias (P/2, 2P) that +happens to sit on the grid wins. That makes a blind search over a wide +period range expensive (one NFFT per template); the intended use is a +focused search around candidate periods, as here. +""" +import numpy as np +from cuvarbase.nufft_lrt import NUFFTLRTAsyncProcess + + +def ground_based_times(rng, baseline=90.0, n=600): + """Nightly visibility windows with weather losses.""" + nights = np.arange(int(baseline)) + nights = nights[rng.rand(len(nights)) > 0.35] + per_night = max(1, int(round(n / max(len(nights), 1)))) + t = (nights[:, None] + 0.25 * rng.rand(len(nights), per_night)).ravel() + return np.sort(t[:n]) + + +def generate_transit_lightcurve(rng, t, period, epoch, duration, depth, + noise_level=0.003): + """Relative flux with a box transit and white noise.""" + phase = np.fmod(t - epoch, period) / period + phase[phase < 0] += 1.0 + phase[phase > 0.5] -= 1.0 + y = np.ones_like(t) + y[np.abs(phase) <= duration / (2.0 * period)] -= depth + return y + noise_level * rng.randn(len(t)) + + +def example_basic_usage(): + """Focused period search with the automatic epoch grid""" + print("=" * 60) + print("NUFFT LRT Example: focused search with the automatic epoch grid") + print("=" * 60) + + rng = np.random.RandomState(42) + bjd0 = 2457000.0 # absolute timestamps are fine + t = bjd0 + ground_based_times(rng) + baseline = t.max() - t.min() + + true_period = 5.3 + true_duration = 0.22 + true_epoch = bjd0 + rng.uniform(0, true_period) # random phase + depth = 0.01 # 1% transit depth + + y = generate_transit_lightcurve(rng, t, true_period, true_epoch, + true_duration, depth) + + print(f"\n{len(t)} observations over {baseline:.0f} d, " + f"BJD {t.min():.1f} .. {t.max():.1f}") + print(f"True period: {true_period:.2f} d, duration: {true_duration:.2f} d, " + f"depth: {depth:.4f}, epoch: BJD {true_epoch:.3f}") + + proc = NUFFTLRTAsyncProcess() + + # Period step from the drift criterion dP <~ d P / (2 T) for the + # shortest duration searched; a candidate near 5.3 d is assumed + # (e.g. from a BLS pass) and refined over +-5%. + durations = np.array([0.12, 0.25]) + p_lo, p_hi = 0.95 * true_period, 1.05 * true_period + dp = durations.min() * p_lo / (2.0 * baseline) + periods = np.arange(p_lo, p_hi, dp) + + print(f"\nSearching {len(periods)} periods in [{p_lo:.2f}, {p_hi:.2f}] d " + f"(step {dp:.4f} d) x {len(durations)} durations") + print("(automatic epoch grid: up to 96 epochs per cell) ...") + snr, best_epoch = proc.run(t, y, periods, durations=durations) + + i, j = np.unravel_index(np.argmax(snr), snr.shape) + found_epoch = best_epoch[i, j] + dphase = ((found_epoch - true_epoch) / true_period + 0.5) % 1.0 - 0.5 + i_true = int(np.argmin(np.abs(periods - true_period))) + j_true = int(np.argmin(np.abs(durations - true_duration))) + + print("\nBest cell:") + print(f" Period: {periods[i]:.4f} d (true: {true_period:.4f}; " + f"nearest grid point {periods[i_true]:.4f})") + print(f" Duration: {durations[j]:.2f} d (true: {true_duration:.2f})") + print(f" Epoch: BJD {found_epoch:.3f} " + f"(true, mod P: {dphase * true_period:+.3f} d away)") + print(f" Statistic: {snr[i, j]:.2f} (whitened correlation, not an " + f"N(0,1) SNR -- calibrate a threshold, see below)") + print(f" Statistic at the true cell: {snr[i_true, j_true]:.2f}") + + # Threshold calibration sketch: the 95th percentile of the search + # maximum over signal-free light curves is the 5% per-search + # false-alarm threshold for THIS sampling, grid and PSD estimator. + # (A real calibration uses >= 60 draws; 3 here keep the example short.) + null_max = [] + for k in range(3): + y_null = 1.0 + 0.003 * rng.randn(len(t)) + s_null, _ = proc.run(t, y_null, periods, durations=durations) + null_max.append(s_null.max()) + print(f"\nSearch maximum on 3 signal-free draws: " + f"{np.round(null_max, 2)} (detection: {snr[i, j]:.2f})") + + print("\nExample completed successfully!") + + +if __name__ == '__main__': + print("\nNUFFT-based Likelihood Ratio Test for Transit Detection") + print("========================================================\n") + print("Whitened matched filter for box transits in correlated noise") + print("(Taaki, Kamalabadi & Kemball 2020; Taaki, Kemball & Kamalabadi 2025).") + print("Reference implementation:") + print("https://github.com/star-skelly/code_nova_exoghosts/blob/main/nufft_detector.py\n") + + example_basic_usage() diff --git a/examples/tls_example.py b/examples/tls_example.py new file mode 100644 index 00000000..e7972a86 --- /dev/null +++ b/examples/tls_example.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Example: GPU-Accelerated Transit Least Squares + +This script demonstrates how to use cuvarbase's GPU-accelerated TLS +implementation to detect planetary transits in photometric time series. + +Requirements: +- PyCUDA +- NumPy +- CuPy and batman-package (install cuvarbase[tls] for CUDA 12) +- matplotlib (for the example plot) +""" + +import numpy as np +import matplotlib.pyplot as plt + +# Check if we can import TLS modules +try: + from cuvarbase import tls_grids, tls_models, tls + import cupy # Standard TLS dependency; this does not initialize a GPU. + TLS_AVAILABLE = True +except ImportError as e: + print(f"Warning: Could not import TLS modules: {e}") + TLS_AVAILABLE = False + +# Batman supplies both the search template and the synthetic signal. +try: + import batman + BATMAN_AVAILABLE = True +except ImportError: + BATMAN_AVAILABLE = False + TLS_AVAILABLE = False + print("Standard TLS requires batman-package; install cuvarbase[tls].") + + +def generate_synthetic_transit(period=10.0, depth=0.01, duration=0.1, + t0=0.0, ndata=1000, noise_level=0.001, + T_span=100.0): + """ + Generate synthetic light curve with transit. + + Parameters + ---------- + period : float + Orbital period (days) + depth : float + Transit depth (fractional) + duration : float + Transit duration (days) + t0 : float + Mid-transit time (days) + ndata : int + Number of data points + noise_level : float + Gaussian noise level + T_span : float + Total observation span (days) + + Returns + ------- + t, y, dy : ndarray + Time, flux, and uncertainties + """ + # Generate time series + t = np.sort(np.random.uniform(0, T_span, ndata)) + + # Start with flat light curve + y = np.ones(ndata) + + if not BATMAN_AVAILABLE: + raise ImportError('This example requires batman-package (cuvarbase[tls])') + # Use Batman for realistic transit + params = batman.TransitParams() + params.t0 = t0 + params.per = period + params.rp = np.sqrt(depth) # Radius ratio + params.a = 15.0 # Semi-major axis + params.inc = 90.0 # Edge-on + params.ecc = 0.0 + params.w = 90.0 + params.limb_dark = "quadratic" + params.u = [0.4804, 0.1867] + + m = batman.TransitModel(params, t) + y = m.light_curve(params) + + # Add noise + noise = np.random.normal(0, noise_level, ndata) + y += noise + + # Uncertainties + dy = np.ones(ndata) * noise_level + + return t, y, dy + + +def run_tls_example(use_gpu=True): + """ + Run TLS example on synthetic data. + + Parameters + ---------- + use_gpu : bool + Use GPU implementation (default: True) + """ + if not TLS_AVAILABLE: + print("TLS modules not available. Cannot run example.") + return + + print("=" * 60) + print("GPU-Accelerated Transit Least Squares Example") + print("=" * 60) + + # Generate synthetic data + print("\n1. Generating synthetic transit...") + period_true = 12.5 # days + depth_true = 0.008 # 0.8% depth + duration_true = 0.12 # days + + t, y, dy = generate_synthetic_transit( + period=period_true, + depth=depth_true, + duration=duration_true, + ndata=800, + noise_level=0.0005, + T_span=100.0 + ) + + print(f" Data points: {len(t)}") + print(f" Time span: {np.max(t) - np.min(t):.1f} days") + print(f" True period: {period_true:.2f} days") + print(f" True depth: {depth_true:.4f} ({depth_true*1e6:.0f} ppm)") + print(f" True duration: {duration_true:.3f} days") + + # Generate period grid + print("\n2. Generating period grid...") + periods = tls_grids.period_grid_ofir( + t, R_star=1.0, M_star=1.0, + oversampling_factor=3, + period_min=8.0, + period_max=20.0 + ) + print(f" Testing {len(periods)} periods from {periods[0]:.2f} to {periods[-1]:.2f} days") + + # Run TLS search + print("\n3. Running TLS search...") + if use_gpu: + try: + results = tls.tls_search_gpu( + t, y, dy, + periods=periods, + R_star=1.0, + M_star=1.0 + ) + print(" ✓ GPU search completed") + except Exception as e: + print(f" ✗ GPU search failed: {e}") + print(" Tip: Use a CUDA-capable GPU and install cuvarbase[tls] for CUDA 12") + return + else: + print(" CPU implementation not yet available") + return + + # Display results + print("\n4. Results:") + print(f" Best period: {results['period']:.4f} ± {results['period_uncertainty']:.4f} days") + print(f" Best depth: {results['depth']:.6f} ({results['depth']*1e6:.1f} ppm)") + print(f" Best duration: {results['duration']:.4f} days") + print(f" Best T0: {results['T0']:.4f} (days; first mid-transit at or " + f"after min(t) = {t.min():.3f}; phase {results['t0_phase']:.4f} " + f"relative to floor(min(t)))") + print(f" Number of transits: {results['n_transits']}") + print(f"\n Statistics:") + print(f" SDE: {results['SDE']:.2f} (reference-package definition: " + f"SR = chi2_min/chi2)") + print(f" SNR: {results['SNR']:.2f} (sqrt(chi2_0 - chi2_min))") + # No 'FAP' is returned: the SDE has no fixed false-alarm calibration + # (its null distribution depends on the grid and baseline). For an + # empirical FAP run the null bootstrap on the batch entry point: + # tls.tls_search_batch([(t, y, dy)], periods=periods, + # fap_null_draws=200, fap_seed=1)[0]['FAP'] + + # Compare to truth + period_error = np.abs(results['period'] - period_true) + depth_error = np.abs(results['depth'] - depth_true) + duration_error = np.abs(results['duration'] - duration_true) + + print(f"\n Recovery accuracy:") + print(f" Period error: {period_error:.4f} days ({period_error/period_true*100:.1f}%)") + print(f" Depth error: {depth_error:.6f} ({depth_error/depth_true*100:.1f}%)") + print(f" Duration error: {duration_error:.4f} days ({duration_error/duration_true*100:.1f}%)") + + # Plot results + print("\n5. Creating plots...") + fig, axes = plt.subplots(2, 2, figsize=(12, 10)) + + # Plot 1: Periodogram + ax = axes[0, 0] + ax.plot(results['periods'], results['power'], 'b-', linewidth=0.5) + ax.axvline(period_true, color='r', linestyle='--', label='True period') + ax.axvline(results['period'], color='g', linestyle='--', label='Best period') + ax.set_xlabel('Period (days)') + ax.set_ylabel('Power (detrended SR)') + ax.set_title('TLS Periodogram') + ax.legend() + ax.grid(True, alpha=0.3) + + # Plot 2: Chi-squared + ax = axes[0, 1] + ax.plot(results['periods'], results['chi2'], 'b-', linewidth=0.5) + ax.axvline(period_true, color='r', linestyle='--', label='True period') + ax.axvline(results['period'], color='g', linestyle='--', label='Best period') + ax.set_xlabel('Period (days)') + ax.set_ylabel('Chi-squared') + ax.set_title('Chi-squared vs Period') + ax.legend() + ax.grid(True, alpha=0.3) + + # Plot 3: Phase-folded light curve at best period. T0 is an absolute + # mid-transit time, so folding relative to it puts the transit at + # phase 0 (plotted in [-0.5, 0.5) for a centred transit). + ax = axes[1, 0] + phases = ((t - results['T0']) / results['period'] + 0.5) % 1.0 - 0.5 + ax.plot(phases, y, 'k.', alpha=0.3, markersize=2) + # Plot best-fit model + model_phases = np.linspace(-0.5, 0.5, 1000) + model_flux = np.ones(1000) + duration_phase = results['duration'] / results['period'] + in_transit = np.abs(model_phases) < duration_phase / 2 + model_flux[in_transit] = 1 - results['depth'] + ax.plot(model_phases, model_flux, 'r-', linewidth=2, label='Best-fit model') + ax.set_xlabel('Phase relative to T0') + ax.set_ylabel('Relative Flux') + ax.set_title(f'Phase-Folded at P={results["period"]:.4f} days') + ax.legend() + ax.grid(True, alpha=0.3) + + # Plot 4: Raw light curve + ax = axes[1, 1] + ax.plot(t, y, 'k.', alpha=0.5, markersize=1) + ax.set_xlabel('Time (days)') + ax.set_ylabel('Relative Flux') + ax.set_title('Raw Light Curve') + ax.grid(True, alpha=0.3) + + plt.tight_layout() + plt.savefig('tls_example_results.png', dpi=150, bbox_inches='tight') + print(" ✓ Plot saved to 'tls_example_results.png'") + + print("\n" + "=" * 60) + print("Example complete!") + print("=" * 60) + + +if __name__ == '__main__': + import sys + + # Check for --no-gpu flag + use_gpu = '--no-gpu' not in sys.argv + + if use_gpu and not TLS_AVAILABLE: + print("Error: TLS modules not available.") + print("Make sure you're in the cuvarbase directory or have installed it.") + sys.exit(1) + + try: + run_tls_example(use_gpu=use_gpu) + except KeyboardInterrupt: + print("\nInterrupted by user") + sys.exit(0) + except Exception as e: + print(f"\nError running example: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/notebooks/Conditional entropy.ipynb b/notebooks/Conditional entropy.ipynb deleted file mode 100644 index 21d19257..00000000 --- a/notebooks/Conditional entropy.ipynb +++ /dev/null @@ -1,33 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Conditional Entropy period finder\n", - "\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 2", - "language": "python", - "name": "python2" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 2 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.13" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/notebooks/Lomb Scargle.ipynb b/notebooks/Lomb Scargle.ipynb index 1e8a83c6..988b7c47 100644 --- a/notebooks/Lomb Scargle.ipynb +++ b/notebooks/Lomb Scargle.ipynb @@ -197,21 +197,20 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 2", + "display_name": "Python 3", "language": "python", - "name": "python2" + "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.13" + "pygments_lexer": "ipython3" } }, "nbformat": 4, diff --git a/notebooks/Phase Dispersion Minimization.ipynb b/notebooks/Phase Dispersion Minimization.ipynb index 5d38aa36..64ebb98c 100644 --- a/notebooks/Phase Dispersion Minimization.ipynb +++ b/notebooks/Phase Dispersion Minimization.ipynb @@ -8,31 +8,36 @@ "\n", "The [PDM](https://ui.adsabs.harvard.edu/abs/1978ApJ...224..953S/abstract) is well suited to the case of nonsinusoidal time variation covered by only a few irregularly spaced observations.\n", "\n", - "Given the magnitudes $\\mathbf{x}$ and the observation times $\\mathbf{t}$, the variance of $\\mathbf{x}$\n", + "Given the magnitudes $\\mathbf{x}$ and the observation times $\\mathbf{t}$, Stellingwerf (1978) defines the variance of $\\mathbf{x}$ as\n", "$$\n", - "\\sigma = \\frac{\\sum(x_i - \\bar{x})^2}{N-1}\n", + "\\sigma^2 = \\frac{\\sum_i (x_i - \\bar{x})^2}{N-1},\n", "$$\n", - "where $\\bar{x}$ is the mean and N is the number or data points.\n", - "\n", - "For any subset of the sample, the variance $s^2$ is exactly as in the previous equation. Suppose we have chosen M distinct subsets, having variance $s_j^2 (j=1,...,M)$ and containg $n_j$ data points, the overall variance is then given by\n", + "where $\\bar{x}$ is the mean and $N$ is the number of data points.\n", "\n", + "For any subset of the sample, the variance $s^2$ is defined in the same way. Suppose we have chosen $M$ distinct subsets, having variances $s_j^2$ ($j = 1, \\ldots, M$) and containing $n_j$ data points; the pooled variance is then\n", "$$\n", - "s^2 = \\frac{\\sum(n_j - 1)s_j^2}{\\sum n_j-M}.\n", + "s^2 = \\frac{\\sum_j (n_j - 1)\\, s_j^2}{\\sum_j n_j - M}.\n", "$$\n", "\n", - "The full phase interval (0, 1) is divided into fixed bins. PDM method minimize the variance of the data with respect to the mean light curve in phase space. For a given trial period P, the phase vector\n", + "The full phase interval $(0, 1)$ is divided into fixed bins. PDM minimizes the variance of the data with respect to the mean light curve in phase space. For a given trial period $P$, the phase vector is\n", "$$\n", "\\Phi_i = t_i/P - [t_i/P].\n", "$$\n", "\n", - "The variance of these samples gives a measure of the scatter around the mean light curve, where the mean is defined for each bin as a function of $\\Phi$. The PDM statistics is defined as\n", + "The variance of these samples gives a measure of the scatter around the mean light curve, where the mean is defined for each bin as a function of $\\Phi$. The PDM statistic is\n", "$$\n", "\\Theta = \\frac{s^2}{\\sigma^2}.\n", "$$\n", "\n", - "If P is not a true period, then $s^2 \\approx \\sigma^2$ and $\\theta \\approx 1$, whereas if P is a correct period, $\\theta$ will reach a local minimum compared with neighboring periods, hopefully near zero. Thus, we wish to minimize $\\theta$.\n", + "If $P$ is not a true period, then $s^2 \\approx \\sigma^2$ and $\\Theta \\approx 1$, whereas if $P$ is a correct period, $\\Theta$ will reach a local minimum compared with neighboring periods, hopefully near zero. Thus, we wish to minimize $\\Theta$.\n", "\n", - "The cuvarbase PDM implementation calculates the value of $1 - \\Theta$, thus __best period can be found by maximizing the statistics__.\n", + "**What `cuvarbase` computes.** The kernels return the weighted sum-of-squares ratio\n", + "$$\n", + "P(f) = 1 - \\frac{\\sum_i w_i\\,(x_i - m_i)^2}{\\sum_i w_i\\,(x_i - \\bar{x})^2},\n", + "$$\n", + "where $m_i$ is the value of the folded model at the phase of point $i$ (the mean of its bin for `binned_step`, the interpolation between neighbouring bin means for `binned_linterp`, or a local mean for the binless kinds described below) and the weights $w_i \\propto 1/\\sigma_i^2$ are computed from the observational errors and normalized to sum to one. The __best period is therefore found by maximizing__ $P(f)$.\n", + "\n", + "Note that $P(f)$ is *not* $1 - \\Theta$: the degrees-of-freedom factors $N - M$ and $N - 1$ are not applied (for uniform weights, $1 - P = \\frac{N-M}{N-1}\\,\\Theta$). Consequently, for pure noise the expected value of $P$ is $(M-1)/(N-1)$ rather than $0$ -- up to $0.47$ for $N = 20$ points in 10 bins, $0.18$ for $N = 50$, $0.01$ for $N = 1000$ -- so a peak has to be judged against that floor; values are only comparable between runs with the same `nbins` (or `dphi`) and the same $N$; and for gappy data $M$, the number of *occupied* bins, varies with the trial frequency.\n", "\n", "The original PDM technique has been updated [(PDM2)](http://www.stellingwerf.com/rfs-bin/index.cgi?action=PageView&id=29) to solve some issues. The bin variance calculation is equivalent to a curve fit with step functions across each bin, which can introduce errors in the result if the underlying curve is non-symmetric. This can be eliminated by replacing the step function by a linear fit drawn between bin means.\n", "\n", @@ -49,7 +54,9 @@ "$$\n", "where the prior term, $m_{prior,i}$, is the mean of $m_i$ if $m_i$ is within the boxcar smoothing window. The best-fits periods have the largest $\\chi^2$ value.\n", "\n", - "To even supress the alising, a Gaussian window smooting can be applied instead of a boxcar window.\n", + "In this notation the `cuvarbase` binless kinds return $P(f) = 1 - 1/\\chi^2$ -- the same weighted sum-of-squares ratio as above, with the smoothed curve as the model -- so the best periods are again *maxima* of the returned power. The `dphi` argument is the half-width of the boxcar window ($p = 2\\,$`dphi`) for `binless_tophat` and the standard deviation of the Gaussian window for `binless_gauss`, both in units of phase.\n", + "\n", + "To further suppress the aliasing, a Gaussian window smoothing can be applied instead of a boxcar window.\n", "\n", "The following phase curve shows an example of calculating the mean light curve using a boxcar (left) and a Gaussian (right) window.\n", "\n", @@ -66,35 +73,16 @@ }, { "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/jupyter-abodi/GitHub/cuvarbase/cuvarbase/pdm.py:152: UserWarning: PDM is experimental at this point. Use with great caution.\n", - " warnings.warn(\"PDM is experimental at this point. \"\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:46:39.969227Z", + "start_time": "2026-04-01T17:46:39.466625Z" } - ], + }, "source": [ "import numpy as np\n", "import matplotlib.pyplot as plt\n", - "from cuvarbase.pdm import PDMAsyncProcess\n", - "from cuvarbase.utils import weights, autofrequency\n", + "from cuvarbase import PDMAsyncProcess\n", "\n", "def data(seed=100, sigma=0.1, ndata=250, f=10):\n", "\n", @@ -118,15 +106,6 @@ "# Generate synthetics data\n", "t, y, err = data(seed=seed, ndata=ndata, f=f0, sigma=sigma)\n", "\n", - "# Calculate weights from observational errors\n", - "w = weights(err)\n", - "\n", - "# Generate frequency grid\n", - "freqs = autofrequency(t,nyquist_factor=1,\n", - " samples_per_peak=50,\n", - " minimum_frequency=0,\n", - " maximum_frequency=maxfreq)\n", - "\n", "# Select PDM and set corresponding parameter\n", "kind = 'binned_step'\n", "nbins = 10\n", @@ -135,102 +114,68 @@ "pdm_proc = PDMAsyncProcess()\n", "\n", "# Run PDM\n", - "results = pdm_proc.run([(t, y, w, freqs)], kind=kind, nbins=nbins)\n", + "results = pdm_proc.run([(t, y, err)], kind=kind, nbins=nbins,\n", + "\t\t\t\t\t nyquist_factor=1,\n", + "\t\t\t\t\t samples_per_peak=50,\n", + "\t\t\t\t\t maximum_frequency=maxfreq)\n", "\n", "# Finish process\n", "pdm_proc.finish()\n", "\n", "# Parse the results\n", - "pow_gpu = results[0]\n", + "freqs = results[0][0]\n", + "pow_gpu = results[0][1]\n", "\n", "# Plot\n", "f, (axlc, axlsp) = plt.subplots(1, 2, figsize=(20, 5))\n", "axlc.scatter((t * f0) % 1.0, y, c='k', s=1)\n", "axlc.set_xlabel('Phase')\n", "axlc.set_ylabel('Mag.')\n", + "axlc.set_facecolor(\"white\")\n", "\n", "axlsp.plot(freqs, pow_gpu, alpha=1, label='Binned PDM')\n", "axlsp.axvline(f0, ls=':', color='r',zorder=0)\n", "axlsp.set_xlabel('Freq.')\n", "axlsp.set_ylabel('$1-\\Theta(f)$')\n", "axlsp.set_ylim(0,1)\n", + "axlsp.set_facecolor(\"white\")\n", "axlsp.legend(loc='best')\n", "plt.show()" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAABmAAAAHFCAYAAADlizaSAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xl8G/WdP/7XaGzHcVAOGW8IEORQoBTKkkDSBbUN7Zelhi5daLdf0Wjbci2sAu62Jf22otofbA+B2G/NUupgFdoC21YUbbscPcAtXfoNrcwRCBAgQIDY5CCJI+VQovgaze8P5TOeGY0uW6f1evYxxR7N8ZEdz4w+78/7/ZEAqCAiIiIiIiIiIiIiIqKSsVW7AURERERERERERERERDMNAzBEREREREREREREREQlxgAMERERERERERERERFRiTEAQ0REREREREREREREVGIMwBAREREREREREREREZUYAzBEREREREREREREREQlxgAMERERERERERERERFRiTEAQ0REREREREREREREVGIMwBAREREREREREREREZUYAzBEREREREREREREREQlxgAMERERERHRDPHRj34Ujz76KLZv3w5VVXHJJZfk3ee8887D888/j5GREWzevBmXX355BVpKRERERDTzMQBDREREREQ0Q8yZMwcvvfQSrr/++oK27+zsxG9/+1s8+eSTWLp0Ke644w786Ec/wic+8Ykyt5SIiIiIaOaTAKjVbgQRERERERGVlqqquPTSS/HII49k3SYYDOLv/u7vcMYZZ2jrHnjgAcyfPx8XXXRRJZpJRERERDRjNVW7AfXg2GOPRSKRqHYziIiIiIgqwm63Y8eOHdVuBlXAueeeiyeeeMKwrr+/H3fccUfWfVpaWjBr1izDOofDgXg8Xo4mEhERERHVpEI+NzEAk8exxx6L7du3V7sZREREREQVddxxxzEI0wCOOeYY7Nq1y7Bu165dmDdvHlpbWzEyMpKxz4033oh/+7d/q1ALiYiIiIhqV77PTZwDJg9mvhARERFRI+JzMGVz6623Yu7cudpy3HHHVbtJRERERI1BVYHx8fSicmaRWpDvcxMzYIiIiIiIiBrUzp07sXDhQsO6hQsXYv/+/ZbZLwAwNjaGsbGxSjSPiIiIiPSSSeCoo9JfHzwIzJlT3fZQXsyAISIiIiIialADAwM4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rsmcfwC488478e///u+49tprsWfPnqnYVcqAZM//wYMHsW7dOqxfv17dfvOb3+CFF17A+vXrcerUqancfUrRZH7+X375ZaxatQqSJKmXnX322Whvb2f4kmcmc/6tVmtY+AYAsiyrt6Xpjf/+IzI22X9P0/Rz4sQJdHR0hL0WbDYbLr30Ur4WppEHH3wQH//4x/GhD30Ira2tYd/bs2cPxsfHw14DZ599NqqqqvgamKZMJhOKiop47meA559/PmI95PXXX8fPfvYzrF+/Hn/961/5GphhSktLcdZZZ6GjoyNv3gMUbtnbqqurldHRUeUzn/mMsnbtWqW5uVlxuVzK/PnzFQDKo48+qtxzzz3q9Tdt2qSMj48rX/nKV5Q1a9Yod999tzI2Nqacd955WT8Wbpk//1/72tcUr9er/O3f/q1SWVmpbqWlpVk/Fm6ZP//67ZFHHlGeeuqprB8Ht6k5/0uWLFEGBweV//7v/1ZWr16tXHfddUpnZ6fyjW98I+vHwi3z5//uu+9WBgcHlZtuuklZvny5ctVVVylHjhxRnnjiiawfC7fkt9LSUuXCCy9ULrzwQkVRFOXLX/6ycuGFFypLly5VACj33HOP8uijj6rXX758uTI8PKx873vfU9asWaPcdtttis/nU6655pqsHws3btne4r2fcps+W7z3zq997WuKy+VSPvKRjyjr1q1TnnrqKeXYsWNKUVFR1vedW+rbtm3blP7+fuWyyy4L+79wcXGxep2HHnpIaW1tVa644gplw4YNyssvv6y8/PLLWd93bqlv99xzj/KBD3xAqaqqUtatW6fcc889iizLylVXXcVzP0O3F154Qbn//vvVr/kamN7b97//feWyyy5TqqqqlE2bNinPPvus0t3drcybNy9fzn/Wd2DGb1/60peU1tZWxev1Kq+88orynve8R/3eCy+8oDzyyCNh17/xxhuVgwcPKl6vV9m/f7/y4Q9/OOvHwG1qzv+JEycUI3fffXfWj4Nb5s+/fmMAk/9bsuf/ve99r7J7925ldHRUOXr0qPL1r39dMZlMWT8Obpk//2azWfmP//gP5ciRI4rH41Ha2tqUpqYmpaysLOvHwS357fLLLzf8fS7O+SOPPKK88MILEbd54403FK/Xqxw9elS59dZbs34c3Ljlyhbr/ZTb9NnivXcCUL71rW8pHR0dyujoqPLcc88pq1evzvp+c0vPFo3292FRUZHS1NSk9PX1KcPDw8ovf/lLpbKyMuv7zi317f/+7/+UEydOKF6vV+nq6lKee+45NXzhuZ+Zmz6A4Wtgem87duxQzpw5o3i9XuXUqVPKjh07lJUrV+bN+ZdCfyEiIiIiIiIiIiIiIqI04QwYIiIiIiIiIiIiIiKiNGMAQ0RERERERERERERElGYMYIiIiIiIiIiIiIiIiNKMAQwREREREREREREREVGaMYAhIiIiIiIiIiIiIiJKMwYwREREREREREREREREacYAhoiIiIiIiIiIiIiIKM0YwBAREREREREREREREaUZAxgiIpoyt956K/r7+7O9G0RERERERERERBnHAIaIiNLqkUcegaIoUBQFY2NjOHLkCP793/8dZrM527tGRERERESUc7T/h9JuZ511VrZ3jYiIUmTJ9g4QEdH08/TTT+Ozn/0sioqKcN1112Hbtm3w+Xzo6OjI9q4RERERERHlHPF/KK2enp6wrwsKCuDz+aZyt4iIKEWsgCEiorQbGxtDV1cXTp48iebmZuzatQsf/ehH1e9fc801ePfdd+F2u/H0009jwYIF6vc2btyIZ599Fj09PRgYGMCf/vQnXHTRRWH3f/fdd6OtrQ1erxdnzpzBAw88oH6vsLAQ3//+93H69GkMDw/jlVdeweWXX575gyYiIiIiIpok8X8o7fb888/jwQcfxP3334+enh7s3LkTAHDeeefhD3/4A9xuNzo7O/HYY4+hvLxcvS+r1YpHH30Ubrcb7e3t+MpXvoIXXngB999/f7YOj4hoxmIAQ0REGTc6OorCwkIAwf8MfPWrX8WnP/1pXHbZZVi2bBn+67/+S72uzWbDo48+ive///1473vfiyNHjuAPf/gDZs2aBQD4xCc+gX/913/FP/7jP2L16tW44YYbsH//fvX2TU1N2LRpE26++WZccMEF+PnPf45nnnkGq1atmtqDJiIiIiIiStGtt96K8fFxvO9970NtbS3Kysrwxz/+EXv37sXGjRtx7bXXorKyEi0tLeptvv/97+Pyyy/Hxz72MVxzzTW44oorsGHDhiweBRHRzKZw48aNGzdu6doeeeQR5amnnlK/vvLKK5XR0VHlP//zP5Vbb71VURRFWblypfr92267Teno6Ih6f5IkKYODg8rf/M3fKACUf/3Xf1UOHjyoWCyWiOsuXbpU8fl8ysKFC8Muf+6555SGhoasPzfcuHHjxo0bN27cuHHjpt8eeeQRxefzKW63W91aWlqUF154QdmzZ0/Ydevr65Vnnnkm7LLFixcriqIoq1evVkpLSxWv16vceOON6vfnzp2rjIyMKPfff3/Wj5UbN27cZtrGGTBERJR2119/PdxuNwoKCmAymbB9+3Z885vfxN/93d9hZGQEx48fV6/b0dGB+fPnq1/Pnz8f3/3ud3HFFVdg/vz5MJvNsFqtWLZsGQDg5z//Ob785S/j+PHjeOaZZ/CHP/wBv/3tbyHLMs4//3xYLBYcPnw4bH+KiorQ19c3NQdPRERERESUpBdeeAG33Xab+vXIyAh27NiBPXv2hF3vwgsvxAc/+EG43e6I+zjrrLNQUlKCoqIivPrqq+rl/f39OHToUOZ2noiIomIAQ0REaSf+8zA+Po729nbIsqx+Tz80UlEUmEwTHTEfffRRlJeX41/+5V/Q1taGsbEx7N69W21hdvr0aaxZswZXXXUVrr76ajz00EO48847cfnll2PWrFnw+/24+OKLwx4TAIaHhzN4xERERERERJM3MjKCY8eOGV6uNWvWLPz2t7/FXXfdFXHdjo4Otl4mIsoxDGCIiCjtov3nIRHve9/7cPvtt+Ppp58GACxZsgQVFRVh1/F6vfjd736H3/3ud9i2bRsOHTqE888/H3v37oXFYsH8+fPx0ksvpXwcREREREREueSNN97AJz7xCbS2tkZ86AwAjh07hvHxcVx66aU4deoUAGDOnDk4++yz8ec//3mqd5eIaMYzxb8KERHR1Dly5Ag+/elPY+3atXjPe96Dn/3sZ/B4POr3b731Vnzuc5/DeeedhxUrVuBTn/oUPB4P2tracOTIETz++ON47LHH8PGPfxzLly/HJZdcgrq6Olx33XVZPCoiIiIiIqLUbdu2DXa7HTt27MDGjRuxcuVKXHPNNfjxj38Mk8mEkZER/OhHP8L3v/99fPCDH8R5552Hn/zkJwgEAmH3c8899+DRRx/N0lEQEc0cDGCIiCin/MM//APmzp2LN954Az/96U/x3//93+ju7la/PzAwgC984Qt4+eWX8dZbb+Gqq67CRz7yEbhcLgDAZz/7WTz22GPYunUrDh06hF/96le45JJLcPLkyWwdEhERERERUVp0dHTgfe97H8xmM5599lns378fP/jBDzAwMKCGLHfeeSf+8pe/4Le//S127dqFl156KWKWzMKFC9U5m0RElDkSACXbO0FERERERERERESZ8cILL2Dfvn3413/912zvChHRjMIKGCIiIiIiIiIiIiIiojRjAENERERERERERERERJRmbEFGRERERERERERERESUZqyAISIiIiIiIiIiIiIiSjMGMERERERERERERERERGnGAIaIiIiIiIiIiIiIiCjNGMAQERERERERERERERGlGQMYIiIiIiIiIiIiIiKiNGMAQ0RERERERERERERElGYMYIiIiIiIiIiIiIiIiNKMAQwREREREREREREREVGa/X+SEv2kj/zEiwAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 12 }, { "cell_type": "markdown", "metadata": {}, "source": [ - "### Other kind of PDM methods can be run similarly " + "### Other kinds of PDM can be run similarly\n", + "\n", + "All kinds return the same $P(f)$ statistic described above. The binless kinds take `dphi` (the half-width of the boxcar window for `binless_tophat`, the standard deviation of the Gaussian window for `binless_gauss`, both in units of phase). Note that the binless cells below define `dphi = 0.05` but do not pass it to `run()`; the default is also 0.05, so the results are unaffected. Their $y$-axes are labelled $\\chi^2$ following Plavchan's notation, although the plotted quantity is $P(f)$." ] }, { "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/jupyter-abodi/GitHub/cuvarbase/cuvarbase/pdm.py:152: UserWarning: PDM is experimental at this point. Use with great caution.\n", - " warnings.warn(\"PDM is experimental at this point. \"\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/jupyter-abodi/GitHub/cuvarbase/cuvarbase/pdm.py:152: UserWarning: PDM is experimental at this point. Use with great caution.\n", - " warnings.warn(\"PDM is experimental at this point. \"\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/jupyter-abodi/GitHub/cuvarbase/cuvarbase/pdm.py:152: UserWarning: PDM is experimental at this point. Use with great caution.\n", - " warnings.warn(\"PDM is experimental at this point. \"\n" - ] - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-01T17:47:14.764372Z", + "start_time": "2026-04-01T17:47:11.965514Z" } - ], + }, "source": [ "# -------------------- PDM2 --------------------\n", "# Select PDM and set corresponding parameter\n", @@ -241,25 +186,31 @@ "pdm_proc = PDMAsyncProcess()\n", "\n", "# Run PDM\n", - "results = pdm_proc.run([(t, y, w, freqs)], kind=kind, nbins=nbins)\n", + "results = pdm_proc.run([(t, y, err)], kind=kind, nbins=nbins,\n", + "\t\t\t\t\t nyquist_factor=1,\n", + "\t\t\t\t\t samples_per_peak=50,\n", + "\t\t\t\t\t maximum_frequency=maxfreq)\n", "\n", "# Finish process\n", "pdm_proc.finish()\n", "\n", "# Parse the results\n", - "pow_gpu = results[0]\n", + "freqs = results[0][0]\n", + "pow_gpu = results[0][1]\n", "\n", "# Plot\n", "f, (axlc, axlsp) = plt.subplots(1, 2, figsize=(20, 5))\n", "axlc.scatter((t * f0) % 1.0, y, c='k', s=1)\n", "axlc.set_xlabel('Phase')\n", "axlc.set_ylabel('Mag.')\n", + "axlc.set_facecolor(\"white\")\n", "\n", "axlsp.plot(freqs, pow_gpu, alpha=1, label='Binned PDM2')\n", "axlsp.axvline(f0, ls=':', color='r',zorder=0)\n", "axlsp.set_xlabel('Freq.')\n", "axlsp.set_ylabel('$1-\\Theta(f)$')\n", "axlsp.set_ylim(0,1)\n", + "axlsp.set_facecolor(\"white\")\n", "axlsp.legend(loc='best')\n", "plt.show()\n", "\n", @@ -272,25 +223,31 @@ "pdm_proc = PDMAsyncProcess()\n", "\n", "# Run PDM\n", - "results = pdm_proc.run([(t, y, w, freqs)], kind=kind, dphi=dphi)\n", + "results = pdm_proc.run([(t, y, err)], kind=kind, nbins=nbins,\n", + "\t\t\t\t\t nyquist_factor=1,\n", + "\t\t\t\t\t samples_per_peak=50,\n", + "\t\t\t\t\t maximum_frequency=maxfreq)\n", "\n", "# Finish process\n", "pdm_proc.finish()\n", "\n", "# Parse the results\n", - "pow_gpu = results[0]\n", + "freqs = results[0][0]\n", + "pow_gpu = results[0][1]\n", "\n", "# Plot\n", "f, (axlc, axlsp) = plt.subplots(1, 2, figsize=(20, 5))\n", "axlc.scatter((t * f0) % 1.0, y, c='k', s=1)\n", "axlc.set_xlabel('Phase')\n", "axlc.set_ylabel('Mag.')\n", + "axlc.set_facecolor(\"white\")\n", "\n", "axlsp.plot(freqs, pow_gpu, alpha=1, label='Binless Boxcar PDM')\n", "axlsp.axvline(f0, ls=':', color='r',zorder=0)\n", "axlsp.set_xlabel('Freq.')\n", "axlsp.set_ylabel('$\\chi^2$')\n", "axlsp.set_ylim(0,1)\n", + "axlsp.set_facecolor(\"white\")\n", "axlsp.legend(loc='best')\n", "plt.show()\n", "\n", @@ -303,28 +260,76 @@ "pdm_proc = PDMAsyncProcess()\n", "\n", "# Run PDM\n", - "results = pdm_proc.run([(t, y, w, freqs)], kind=kind, dphi=dphi)\n", + "results = pdm_proc.run([(t, y, err)], kind=kind, nbins=nbins,\n", + "\t\t\t\t\t nyquist_factor=1,\n", + "\t\t\t\t\t samples_per_peak=50,\n", + "\t\t\t\t\t maximum_frequency=maxfreq)\n", "\n", "# Finish process\n", "pdm_proc.finish()\n", "\n", "# Parse the results\n", - "pow_gpu = results[0]\n", + "freqs = results[0][0]\n", + "pow_gpu = results[0][1]\n", "\n", "# Plot\n", "f, (axlc, axlsp) = plt.subplots(1, 2, figsize=(20, 5))\n", "axlc.scatter((t * f0) % 1.0, y, c='k', s=1)\n", "axlc.set_xlabel('Phase')\n", "axlc.set_ylabel('Mag.')\n", + "axlc.set_facecolor(\"white\")\n", "\n", "axlsp.plot(freqs, pow_gpu, alpha=1, label='Binless Gauss PDM')\n", "axlsp.axvline(f0, ls=':', color='r',zorder=0)\n", "axlsp.set_xlabel('Freq.')\n", "axlsp.set_ylabel('$\\chi^2$')\n", "axlsp.set_ylim(0,1)\n", + "axlsp.set_facecolor(\"white\")\n", "axlsp.legend(loc='best')\n", "plt.show()" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAABmAAAAHFCAYAAADlizaSAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xt8HHW9P/7X7CRpmrK9bIhtwXZTBEGUQwutB1YtejgaVDyA+l3sHpWLB9yWeBTqVxf3HDxeFha/BlFTuooKeAl0v/oV8Eaw54en6gahpUCAFgttAm1pm+72su3mOju/Pzafyczs7C3Za/J6nscck93Z2U8SOjP7eX/e77cEQAUREREREREREREREREVja3SAyAiIiIiIiIiIiIiIppuGIAhIiIiIiIiIiIiIiIqMgZgiIiIiIiIiIiIiIiIiowBGCIiIiIiIiIiIiIioiJjAIaIiIiIiIiIiIiIiKjIGIAhIiIiIiIiIiIiIiIqMgZgiIiIiIiIiIiIiIiIiowBGCIiIiIiIiIiIiIioiJjAIaIiIiIiIiIiIiIiKjIGIAhIiIiIiIiIiIiIiIqMgZgiIiIiIiIpon3vOc9ePTRR7Fv3z6oqoorrrgi52suueQSbNu2DUNDQ9i1axeuueaaMoyUiIiIiGj6YwCGiIiIiIhompgzZw6ee+453HTTTXnt39rait/97nd44oknsHz5ctx999340Y9+hA984AMlHikRERER0fQnAVArPQgiIiIiIiIqLlVVceWVV+KRRx7JuE8wGMSHP/xhnHfeedpjDz74IObPn48PfvCD5RgmEREREdG0VVfpAdSC0047DfF4vNLDICIiIiIqC7vdjv3791d6GFQGF198MTZv3mx4rLu7G3fffXfG1zQ0NGDWrFmGxxwOB2KxWCmGSERERERUlfL53MQATA6nnXYa9u3bV+lhEBERERGV1emnn84gzAywaNEiHDx40PDYwYMHMW/ePDQ2NmJoaCjtNbfeeiv+67/+q0wjJCIiIiKqXrk+N7EHTA7MfCEiIiKimYj3wZTJHXfcgblz52rb6aefXukhEREREc0MqgqMjqY2lZ1FqkGuz03MgCEiIiIiIpqhDhw4gIULFxoeW7hwIY4dO2aZ/QIAIyMjGBkZKcfwiIiIiEgvkQBOOSX19YkTwJw5lR0P5cQMGCIiIiIiohmqp6cHl156qeGx97///ejp6anQiIiIiIiIpg8GYIiIiIiIiKaJOXPm4Pzzz8f5558PAFi2bBnOP/98LFmyBABw++2344EHHtD2D4VCOOOMM3DnnXfi7LPPxtq1a+F2u/Gd73ynIuMnIiIioiyamoAjR1JbU1OlR0N5YAkyIiIiIiKiaWLlypX405/+pH0vAin3338/rrvuOixevBhLly7Vnu/r68OHP/xhfOc738HnP/957N27F//2b/+Gxx9/vNxDJyIiIqJcJAmYP7/So6ACSADYrScLu92O48ePV3oYRERERERlNXfu3JwNJYmA/D4zqaqKsbExKIpSplFRLZNlGXV1dZAkqdJDISIiIsoq1+cmZsAQERERERFRyYyMjOD111/HwYMHoSgKVJVrACkzSZIgyzIWLlyIJUuWoKGhodJDIiIiqh4jI0jefjv6FrVg4aevxZymOZUeEeXAAAwRERERERGVRDKZxI4dO/Diiy/isccew5EjR5BMJis9LKpiNpsNCxYswGWXXYYTJ07gvPPOg83G9rVEREQAgNFRPNO3C1s+fhmc+/fgY2e+o9IjohwYgCEiIiIiIqKSGBkZwfHjxxEOh7F3795KD4dqxBtvvIHjx49j/fr1GBkZQWNjY6WHREREVB3q6vDi9Z8CAPQPncBYMok6LlSoavzrEBERERERUcmoqorR0dFKD4NqzOjoKMvVERERmc2aheSiRdq3R0eGKjgYygcDMEREREREREREREREVS6pqjg2Oqx9f1z3NVUnBmCIiIiIiIiIiIiIiKrcydERJHUZokNjYxUcDeWDARgiIiIiIiKiSVi8eDG2bt2Kt771rZUeCi688EJs3boVp5xySqWHQkRERCUydCJu+H5QYQCm2tVVegBERERERERE1earX/0qPvKRj2jfHz16FC+99BK+973v4ZVXXgEAHDx4EG1tbTh69GiFRlmYRx99FKeddhoAYHBwEP39/bjvvvvw3//93wCAG2+8ETfeeCMAYGxsDCdOnMDu3bvxxBNP4Je//KWhl88PfvADXHjhhfj+97+PBx54wPA+d999N9797nfjhz/8IX74wx9ClmWsW7cO73rXu3D66afjxIkTeOqpp/D9738fhw8fLtNPT0REVPsGk4rxe4V99qodM2CIiIiIiIiILPz1r39FW1sb2trasG7dOiiKgrvvvlt7PplM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pJYRo/AcVLUYgJlGCp2ECAaDUBQFsizD7XbnnUGTbULEbrcjkUhgeHgYiUQCdrsdANLGlU/GjnmfXIEQsb/L5UJzczMGBgbgcDhyruQFUr+7eDxuGTCqhVIlREREVvT3BlMNVuizVURgRX9ckYkqFkOIffTBGDP9NdZ8nTdntFjhIgkia3fddRduuOEGfPrTn8Y555yDjRs3Ys6cObjvvvsAAA888ABuv/12bf/f/OY3WLt2La6++mq0trbin//5n/GNb3wDv/nNb9ICM0RUPQwZMCx/VVZinlOSJNgwvvqcfwMiKiFRYsxWXw/8/OepbdYsrQQZA/HVreKNIqt5s9vtle7jk7dCm8qWosGrOOaaNWvSGuROtdltvuMV71fIe9ZCQ14iIqJC6a9v+q+trte5rrP614uG9w6HQ/takqSc1/81a9aosiyrK1euTHuvydyXmF9TLc3rp4tczSS5Vfd20003qX19ferQ0JD65JNPqu985zu155544gn1vvvu076XZVm97bbb1F27dqmJRELt7+9XOzs71Xnz5uX1XrX0mYloOvnvfbvVjt4etaO3R/2fN/oqPZwZJXLwdbWjt0f9495X1V/37VQ7envU56MHKz0sIprGuve+onb09qh/O7TX8PjA4Em1o7dHveelpys0Msrjc1PlPxxU81ZLHyaqbdLBanLH/FwpJkzWrFmjSpKkNjU15X28avvdERERFcPGjRu1IIlVcESWZRVID9Dker1VAMbqteZrq3g/q/2LgQsqiosBGG75brX0mYloOtmsC8D8aT8DMOX01wOvqR29PermfbvVh8cDMM9FD1R6WEQ0jf3+9V1qR2+P+vTAPsPjsaGE2tHbo3a++FSFRka5PjexBNk0UuqmsubSJblKmfj9fvT396O7uztnfxd93fhcpVHy6QWjqipaWlrSfheZXsuGvERENB3p+65FIhHtWidKf+lLkFqV3NT3fdO/PhAIwOl0IhAIIBAI5OzV4vF4UFdXh9NPPx2yLGPlypUZy5JNpVTaZMuGspcMERHVItGHBGD5q3ITv20JEhtgE1FZiHOMTQXw9NOpTVG0cxB7wFS3iq+YquZtpq3mypYJYl5VmmuVqX51bK732bhxo2EVbrZ9M63QFdk2DodDbWpqUpuamlSHw2H4WYpVpo0ZM0REVCumcs0S2S5W19RC6DNf8i0l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thDvrlqetLzmn7s78OZAH64Iv/cnNToKLAmXAuvuTjkAM1Xu8TH86dQxjAX9uHrJSixNsY/OWDhI0ucbRXVJOS5duBhKMAh/MIiFRTYUmwugiCAOD7px2ONGocmERUUlKLUUYHFRiW65tmgj/gn4An4ssBbFlAvMBgZgZmjp0qVZ/WOCiIiIiCgbli1bhq6uxJ+Yp9yzZMkSdHV1YcOGDXj++efVx7/zne/gyiuvxLveFVu2aHx8HLfccgsefvhh9bFbb70Vd999NxYvXhyz/t13342vf/3rGRk/EREREVE+SfZ3U2odouaxrq4uLFu2LGuf/pOfJsvmGCh7eP3nN17/+Y3Xf37j9Z/fcuH62+12Bl8ornvvvRff+973Ih5zOBxwuxOX98iUXPiZoezh9SfeA/Mbr//8xus/v+XC9U/l7yYGYFKQC398ejwevpHMY7z+8xuv//zG6z+/8frPb9m8/rzv8teZM2cQCARQVVUV8XhVVRV6enp0t+np6ZnS+hMTE5iYiCwRkgv3DN8z5zdef+I9ML/x+s9vvP7zW67/3ZT9ImlERERERESUFn6/H6+88go2bdqkPmYwGLBp0ybs2bNHd5s9e/ZErA8A11xzTdz1iYiIiIgoNcyAISIiIiIimkO+973v4aGHHsLLL7+MF198EV/84hdRXFyMBx98EADw0EMP4dSpU/ja174GAPjBD36Ap59+Gl/+8pfxhz/8ATfffDPWr1+Pz33uc9k8DSIiIiKivMcATI4bHx/H17/+dYyPj2d7KJQFvP7zG6///MbrP7/x+s9vvP40U+3t7aisrMQ3v/lNLF68GHv37sV1112H06dPAwBWrFiBYDCorr9nzx7U1dXh29/+Nu655x4cPnwYN9xwA958881sncKU8GdmfuP1J94D8xuv//zG6z+/5cv1NwAQ2R4EERERERERERERERHRXMIeMERERERERERERERERGnGAAwREREREREREREREVGaMQBDRERERERERERERESUZgzAEBERERERERERERERpRkDMDngtttuw7FjxzA2Nobnn38el19+ecL1b7zxRuzfvx9jY2P461//ive///2zNFLKhKlc/8985jN45pln4Ha74Xa78eSTTya9Xyi3TfXnX7rpppsghMAjjzyS4RFSJk31+peVlaG1tRVdXV3w+Xw4ePAgfwfksale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f/M3eNe73oXDhw/jscceQ0lJCQDgYx/7GL70pS/hn/7pn3DOOefghhtuwL59+9TtW1tbsWHDBtx88814xzvegV//+tf44x//iNWrV8/uSRMREREREc3QLbfcgomJCbz73e9GfX09ysrK8H//93947bXXsH79elx33XWoqqpCe3u7us13v/tdXHnllfjwhz+Ma6+9Fu9973tx6aWXZvEsiIjmN8GFCxcuXLika3nwwQfFI488on6/adMmMTY2Jv793/9d3HLLLUIIIVatWqU+f+utt4ru7u64+zMYDGJoaEj87d/+rQAgvvSlL4kDBw4Is9kcs+7y5cuF3+8XS5YsiXj8ySefFM3NzVl/bbhw4cKFCxcuXLhw4cIlennwwQeF3+8XHo9HXdrb28Xu3bvFK6+8ErFuU1OT+OMf/xjx2LJly4QQQpxzzjmiuLhY+Hw+ceONN6rPL1iwQIyOjor77rsv6+fKhQsXLvNtYQ8YIiJKuw9+8IPweDywWCwwGo3YsWMHvv71r+Pv/u7vMDo6iqNHj6rrdnd3Y9GiRer3ixYtwre//W28973vxaJFi2AymWCz2bBixQoAwK9//Wt88YtfxNGjR/HHP/4Rjz32GB599FEoioKLLroIZrMZhw4dihiP1WpFf3//7Jw8ERERERHRFO3evRu33nqr+v3o6Ch27tyJV155JWK9iy++GFdddRU8Hk/MPs4++2wUFRXBarXihRdeUB8fGBjAwYMHMzd4IiKKiwEYIiJKO/nHw8TEBLq6uqAoivpcdNNIIQSMxsmKmA899BAqKirwz//8z+js7MT4+Dj27NmjljA7efIk1qxZg6uvvhrXXHMNHnjgAdxxxx248sorUVJSgkAggMsuuyzimAAwMjKSwTMmIiIiIiKavtHRURw5ckT3ca2SkhI8+uijuOuuu2LW7e7uZullIqIcwwAMERGlXbw/HlLx7ne/G7fddhsef/xxAMBZZ52FysrKiHV8Ph9+//vf4/e//z22bduGgwcP4qKLLsJrr70Gs9mMRYsW4dlnn53xeRAREREREeWSV199FR/72MfQ0dER86EzADhy5AgmJiZwxRVX4MSJEwCA8vJynHvuuXj66adne7hERPOeMfkqREREs+fw4cP45Cc/ibVr1+Kd73wnfvnLX8Lr9arP33LLLfj0pz+NCy64ACtXrsQnPvEJeL1edHZ24vDhw/jFL36Bn/3sZ/jIRz6CmpoaXH755WhsbMQHPvCBLJ4VERERERHRzG3btg0OhwM7d+7E+vXrsWrVKlx77bX4yU9+AqPRiNHRUfz3f/83vvvd7+Kqq67CBRdcgJ/+9KcIBoMR+7nnnnvw0EMPZeksiIjmDwZgiIgop/zjP/4jFixYgFdffRU///nP8Z//+Z84ffq0+vzg4CA++9nP4rnnnsNf//pXXH311bj++uvhdrsBAP/wD/+An/3sZ9i6dSsOHjyI3/72t7j88stx/PjxbJ0SERERERFRWnR3d+Pd7343TCYTnnjiCezbtw/f//73MTg4qAZZ7rjjDvzlL3/Bo48+iqeeegrPPvtsTC+ZJUuWqH02iYgocwwARLYHQURERERERERERJmxe/du7N27F1/60peyPRQionmFGTBERERERERERERERERpxgAMERERERERERERERFRmrEEGRERERERERERERERUZoxA4aIiIiIiIiIiIiIiCjNGIAhIiIiIiIiIiIiIiJKMwZgiIiIiIiIiIiIiIiI0owBGCIiIiIiIiIiIiIiojRjAIaIiIiIiIiIiIiIiCjNGIAhIiIiIiIiIiIiIiJKMwZgiIiIiIiIiIiIiIiI0owBGCIiIiIiIiIiIiIiojT7/wEg/glx+LCeRgAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAABmAAAAHFCAYAAADlizaSAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3Xt8HHW9P/7X7iRtmrKl3RBb4JRNERUvHChQlPWcoqdivKCAeha6R1FAYIs5hwP161lcRUEX4vlaBL8pXeV+1EX26A9QUSMc4RTZIBTaUq5yS4BCS8jSdmmybTI7vz82n9nPzM7sJdnbJK+nj5Fkd3bms5t0ZvJ+z/v9cQHQQERERERERERERERERFXjbvQAiIiIiIiIiIiIiIiIZhomYIiIiIiIiIiIiIiIiKqMCRgiIiIiIiIiIiIiIqIqYwKGiIiIiIiIiIiIiIioypiAISIiIiIiIiIiIiIiqjImYIiIiIiIiIiIiIiIiKqMCRgiIiIiIiIiIiIiIqIqYwKGiIiIiIiIiIiIiIioypiAISIiIiIiIiIiIiIiqjImYIiIiIiIiIiIiIiIiKqMCRgiIiIiIqIZ4h//8R/xm9/8Btu3b4emaTj11FNLvuakk07Co48+ikwmg+eeew5f/vKX6zBSIiIiIqKZjwkYIiIiIiKiGWL+/PnYunUrvva1r5W1fldXF+6++27cd999OOaYY3DNNdfghhtuwMc//vEaj5SIiIiIaOZzAdAaPQgiIiIiIiKqLk3TcNppp+Guu+6yXae3txef/vSncdRRR+mP3XbbbVi4cCE++clP1mOYREREREQzVkujB+AEhxxyCNLpdKOHQURERERUFx6PB6+99lqjh0F1cOKJJ+Lee+81PNbf349rrrnG9jVz5szB3LlzDY95vV6kUqlaDJGIiIiIqCmV83cTEzAlHHLIIdi+fXujh0FEREREVFeHHnookzCzwJIlS7Bz507DYzt37sSBBx6ItrY2ZDKZgtdceuml+O53v1unERIRERERNa9SfzdxDpgSWPlCRERERLMRr4PJzlVXXYUFCxboy6GHHtroIRERERHNDpoGjI/nFo0zizSDUn83sQKGiIiIiIholtqxYwcWL15seGzx4sXYvXu3ZfULAOzfvx/79++vx/CIiIiISDY6ChxwQO7rt98G5s9v7HioJFbAEBERERERzVIDAwNYtWqV4bGTTz4ZAwMDDRoREREREdHMwQQMERERERHRDDF//nwcffTROProowEAy5Ytw9FHH42lS5cCAK688krceuut+vqxWAyHH344fvCDH+A973kP1qxZg0AggB/96EcNGT8RERERFdHeDrz1Vm5pb2/0aKgMbEFGREREREQ0Qxx//PG4//779e9FIuWWW27B2WefjYMPPhiHHXaY/vzg4CA+/elP40c/+hEuuugivPrqq/jqV7+KP/3pT/UeOhERERGV4nIBCxc2ehRUARcAztZThMfjwZ49exo9DCIiIiKiulqwYEHJCSWJgPL+ZtI0DRMTE1BVtU6jIqofRVHQ0tICl8vV6KEQEdEsoGkadozthXduG+YqrK9otFJ/N/EnRERERERERDWzf/9+vPLKK9i5cydUVYWm8R5AmjlcLhcURcHixYuxdOlSzJkzp9FDIiKimWz/fjz+y5/hf459P945/0Ccuuy9jR4RlcAEDBEREREREdVENpvF008/jSeffBJ//OMf8dZbbyGbzTZ6WERV43a7sWjRInziE5/A22+/jaOOOgpuN6fbnS3eGNsLT+tczGtheI2I6mR8HI+3twIAXti7G2o2C4XnnabGMwQRERERERHVxP79+7Fnzx4kEgm8+uqrjR4OUU28/vrr2LNnD9auXYv9+/ejra2t0UOiOhjJjOHnL2xDm9KCNUcexxZ0RFQfLS3IvuMd+re7x/fBO3deAwdEpTA9RkRERERERDWjaRrGx8cbPQyimhofH2d7vVlmOLMXAJBRJ7B3gsc4IqoPbc4c7OpYpH+/Z/++Bo6GysEEDBERERERERERUQXGpXaK+7NqA0dCRLPJ2xPjUKWE/5g60cDRUDmYgCEiIiIiIiIiIqrAmJqvetmnMgFDRPWRUY0Vd2MqK/CaHRMwRERERERERFNw8MEHY9OmTXj3u99d9mvOP/98/OIXv6jhqIioHvZLSRdWwBBRvWTeftvw/dgEK2CaXUujB0BERERERETUbL7zne/gM5/5jP79rl278NRTT+HHP/4xnn/+eQDAzp070d3djV27djVolJU55ZRT8N3vflf/fnR0FENDQ7jppptw3333NW5gU3TcccfhJz/5if79yMgItm7dimuvvRbbt28HAPzmN7/BIYccAgDIZDJIpVJ48skn8etf/xqbNm3SX3vwwQfjt7/9LVRVxSmnnILh4WH9uY6ODtx9991oaWnBZz7zGbz++ut1eofUzOQWQPvYAoiI6iRjSvhmePxpeqyAISIiIiIiIrLw4IMPoru7G93d3bjwwguhqiquueYa/flsNouRkRGoDmo/9Pbbb+vv6V/+5V8wMDCAq666Cj6fr9FDs9XSUvze0c997nPo7u5GOBzG4Ycfjquvvhpudz7csWHDBnR3d+Pzn/88vvOd7yCdTuO6667DOeecU7Ct4eFhfPrTnzY8Zk7IEAHAhJafA0ZOxhAR1dI+RTF8PzbBFmTNjgkYB4jFYujq6kIsFmv0UIiIiIhmHF5rEdWfMmdOQ5ZKjY+PY2RkBCMjI/jb3/6GW265BUuWLMHChQsBFLYgO+6447Bp0yasWLEC//Vf/4W//OUvuPHGG0smN0499VT893//Nx588EH86le/whe+8AX9uZaWFnzjG9/AH//4Rzz44IP47W9/i6985Sv68+effz5+97vfIZlM4g9/+AO+/vWvF92Xpmn6e3rllVewYcMGaJqGI444Ql/H4/Hg8ssvx5///Gf85S9/wbXXXoulS5cCABYuXIg//vGPOPvss/X1//7v/x4DAwNYsWIFAMDlcuGss87CHXfcgWQyid/97neGZMe//uu/4te//jX+8pe/4M4770QoFIIiBZREm7ZTTz0Vd911F5LJZNH3lEqlMDIygs2bN+P666/HO9/5Tn28QK7SZ2RkBDt37sTmzZtx5ZVX4oYbbsAFF1xQ8LP53e9+Z6h8AoDPfvaz+N3vfld0DI2gaRp++/LfkHjpKWSZAKg7OemiSskYIqJaymi5mz5ck9+PsQKm6bEFmQP09vZiaGgIvb29CIVC095eLBZDb28vwuFwVbZHRERE5GTTvdaa7rWV/HoxHl6n0UymzJmDM356dUP2ffv5l0Ddv39Kr503bx4+9alP4eWXX8bu3buLrnvhhRfimmuuwVtvvYVLL70Ul112Gc4991zLdT/xiU8gFArhP//zP/Hss8/iPe95DyKRCMbGxnD33XfjzDPPxMqVKxEOh7Fjxw4sXrwYS5YsAQCsWrUKwWAQ3/zmN/HCCy/goIMOwrve9a6y35Pb7darPZ555hn98e9+97tYunQpLrnkEuzduxf/+q//imuvvRb//M//jF27duF73/sefvjDH+Khhx7C0NAQrrjiCiQSCTzyyCMAgJ6eHpx22mm4+uqrsWXLFhx00EHo6urStz86OorLL78cw8PDOOKIIxCJRDA6Oor/+q//0tdZunQp/umf/gnf+MY3Kqow2rdvH4DSVTO//OUv8dWvfhUnnXSSYb8bN27E5z//eRx99NHYunUrjj76aHg8HjzwwAM477zzyh5HPby5bwzP7UkBANLj+3DgnLYGj2h2UVkBQ0QNIFqOLZgzF7v378M+B1XhzlZMwDhAOBw2/FE+XdVO6BARERE52XSvtaZ7bSW/HgCv04iayD/8wz9g48aNAID29nYMDw/j3//936GVCLZed911eOyxxwAAt956K6699lrMmTMH+y2SPxdccAGuueYafQ6W1157DYcffjg+97nP4e6778aSJUvw8ssvY8uWLQCAHTt2YOvWrQCAJUuWYGRkBH/961+hqip27tyJJ598sujYPB6P/p7mzp2LiYkJRKNRfc6UpUuX4qSTTsI555yDxx9/HADw7W9/G3fffTc+8pGP4H/+53/w4IMP4o477sD3v/99PPXUUxgbG0NfX5/+OZ155pn4z//8T9x9990AgO3bt+tjBoAbb7xR//r111/Hz3/+c3z84x83JEJaW1vxne98p6L5dTo6OvClL30JO3fuxNDQUNF19+zZg1QqhYMPPtjw+MTEBP7whz/g1FNPxdatW3HqqafiD3/4AyaacJJjue0MEwD1p2blBAwrYIioPvZNXksseG0ndh+0EONZJmCaHRMwDhAKhar6B3i1EzpERERETjbda61i11blVMeYX8/rNJrp1P37cfv5lzRs35V49NFHcdVVVwEAFixYgC984Qv48Y9/jC9/+cvYsWOH7euee+45/es333wTALBo0SLs3LnTsF5bWxuWLl2Kb3/724hEIvrjiqLg7bffBgD89re/xfr16/HrX/8aAwMDeOCBB/DXv/4VAHDvvfdi9erVuOuuuzAwMIAHH3wQDzzwQNGKkbfffhtf/OIX9f1/8IMfxKWXXordu3fjgQcewLJlyzAxMYEnnnhCf83u3bsxNDSEZcuW6Y9de+21uP322/Gxj30MX/ziFzE+nksGLFu2DHPnztWrYaycfPLJOPPMM3HooYeivb0diqJg7969hnVef/31spMvv//97+FyuTBv3jw8++yz+MY3vlFWwsTlclkm0+666y7cdNNNWL9+PVatWoVzzjnH0CKt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qYg7z9n2h4iqTdVys0spJWpUcwl58Roei5rJlBIwRx11lOXygQ98AEcccQTmzJlT7XECAC655BJcf/31uOWWW/D0008jFAphdHQU55xzjuX6F110Ef74xz/ihz/8IZ555hlcdtlleOyxx9DT01OT8RHVQiWBRKtKFvM25DkLREJFzF/Q19enV8r4/X59DgQgF2j0eDz6HeBWlTW1vCudiEgolQA2J2imc2wKhUJYv349fD4frrvuurLm2iIiInKaqU4crtl8Pd1tVWN75e7ROAeM8wJWTp3Dxsnk9nU6JsGIqEYK2h6OjgLvf39uGR01rKtMVslktame2akWppSA2bJlCzZv3lywbNmyBc888wx2796NW265BXPnzq3aQFtbW3Hcccfh3nvv1R/TNA333nsvTjzxRMvXnHjiiYb1AaC/v992fQCYM2cOPB6PYSGqB7sAYSWBPatKFvM25DkLREKlv7+/YFtiIupkMqk/VmnQk4ioFkq1aDQfq6wSMh0dHejo6CgrKVPp/oiIiJymKkHjaW6j3oHrfAsvODKDoScBDC3IHPQGHCw7+TG7LSpg+BMgomorSMBoGvDUU7nFdO4U67AFWXOZUgLm9NNPx3PPPYfzzz8fxxxzDI455hicf/75ePbZZxEMBnHuuefin/7pn/D973+/agM96KCD0NLSgp07dxoe37lzJ5YsWWL5miVLllS0PgBceuml2LNnj75s3759+oMnKoNd8qKSwJ48ebQcKJS3YfU1gIJ9m/cbi8XQ29uLcDjMICQR1dRUKlbk15gTJlYJmVQqVTCny1THUa05u4ho5mpra8MhhxxS8Pj73ve+BoyGqJAcuC+/AVl1K2CKbbsW8i28XE7Mv0gt1Dj/SL1ZVcC4GPQkohoR7cREdQva2oD77sstbW2GdUUChi3ImsuUEjCRSAQXXXQRbrrpJjzxxBN44okncNNNN+Hiiy/G2rVrEY/H8a//+q84/fTTqz3emrvqqquwYMECfTn00EMbPSSaJeySF5UE9uzWlR+3+joajcLn88Hv9xcEMIHc3AZr167F0NAQIpFIwX5FkBIAg5BENG1TqaaTX2NOnFglZLxeL7xeL/x+v201jNhmJBIpaOMYDAbZcpGIyvL5z38ezz33HO6++25s3boVJ5xwgv7cz372swaOjChvqmGaalatFLQgq/UcMNLXTkzB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ddVV2LFjh+k+O3bs0N0eAK655hrL2xMRERERUWJYAUNERERERDSD3HXXXbj//vvx17/+Fc8//zw+97nPoaqqCvfddx8A4P7778fx48fxla98BQDwgx/8AH/+85/xhS98AX/4wx9www03YP369finf/qnfD4MIiIiIqKixwCmwE1OTuKrX/0qJicn830qlAd8/Wc3vv6zG1//2Y2v/+zG15/S1dXVhfr6enz961/H4sWL8dJLL+Haa6/FqVOnAAArVqxAOBzWbr9jxw5s2rQJ3/zmN3HnnXdi//79uO666/Dqq6/m6yEkhT8zsxtff+J7YHbj6z+78fWf3Yrl9XcAUPN9EkRERERERERERERERDMJZ8AQERERERERERERERFlGAMYIiIiIiIiIiIiIiKiDGMAQ0RERERERERERERElGEMYIiIiIiIiIiIiIiIiDKMAUwBuOWWW3D48GFMTEzg2WefxaWXXmp7+w9/+MPYs2cPJiYm8Morr+Bd73pXjs6UsiGZ1/9Tn/oUnnrqKQwODmJwcBCPP/543PcLFbZkf/6F66+/Hqqq4qGHHsryGVI2Jfv619TUoL29Hb29vQgEAti3bx//DChiyb7+n/3sZ7F37174/X4cOXIEd911F8rLy3N0tpRJb3vb2/Db3/4Wx48fh6qq+MAHPhB3nyuuuAI7d+5EIBDA/v37cdNNN+XgTImKQ6p/n6Liksjvzq997Wvo7e2F3+/H448/jtWrV+fhTCkbmpub8fzzz2N0dBQnT57EQw89hLPPPlt3m/LycrS3t+P06dPw+Xz47//+byxcuDBPZ0yZ1NTUhJdffhkjIyMYGRmB1+vFtddeq13P1352uf3226GqKr73ve9pl/E9MLPdcccdUFVVt+3Zs0e7vhhef5Vb/rbGxkY1EAio//AP/6Cec8456v/7f/9PHRwcVOvr601vf/nll6vBYFD94he/qK5bt079+te/rk5OTqrnnXde3h8Lt+y//j//+c/Vm2++Wb3gggvUtWvXqj/5yU/UoaEh9Ywzzsj7Y+GW/ddfbA0NDerRo0fVP//5z+pDDz2U98fBLTevf2lpqfr888+rv//971WPx6M2NDSoGzZsUN/4xjfm/bFwy/7rf+ONN6oTExPqjTfeqDY0NKjXXHONevz4cXXLli15fyzckt+uvfZa9Rvf+IZ63XXXqaqqqh/4wAdsb79y5Up1bGxM/e53v6uuW7dO/cxnPqMGg0H1ne98Z94fCzdu+d5S/fsUt+Lb4v3u/NKXvqQODQ2p73//+9U3vOEN6m9+8xv14MGDanl5ed7PnVv62yOPPKLedNNN6rnnnqu+8Y1vVH//+9+r3d3dqtvt1m5zzz33qD09Perb3/529eKLL1a9Xq/69NNP5/3cuaW/vfe971Xf9a53qatXr1bXrFmjfvOb31QnJyfVc889l6/9LNvWr1+vHjp0SH3ppZfU733ve9rlfA/M7O2OO+5Qd+/erS5atEjb6urqiun1z/sJzOrt2WefVe+++27te4fDoR47dky9/fbbTW//4IMPqr/73e90l+3YsUO999578/5YuGX/9TduTqdTHRkZUT/2sY/l/bFwy83r73Q61aefflr95Cc/qd53330MYIp4S/b1/+d//mf1wIEDaklJSd7PnVvuX/+7775bfeKJJ3SXffe731X/8pe/5P2xcEtvSySAaWtrU3fv3q27bNu2beojjzyS9/Pnxi3fW7p/n+ZWnJvZ787e3l711ltv1b6fO3euOjExoV5//fV5P19umd8WLFigqqqqvu1tb9Ne78nJSfVDH/qQdpu1a9eqqqqql112Wd7Pl1vmt4GBAfWTn/wkX/tZtFVVVan79u1Tr7rqKvXJJ5/UAhi+B2b+dscdd6i7du0yva4YXn+2IMuj0tJSXHLJJXjiiSe0y1RVxRNPPIHLL7/cdJ/LL79cd3sA2L59u+XtqXCl8vobud1ulJaWYnBwMFunSVmS6uv/7//+7zh16hR+8pOf5OI0KUtSef3f//73Y8eOHdi6dSv6+vqwe/dufPnLX4bTyT/Ki00qr7/X68Ull1yitdU588wz8e53vxsPP/xwTs6Z8ot//yMyl4m/T9PMcOaZZ2LJkiW698Lo6Ciee+45vhdmqJqaGgDQ/i18ySWXoKysTPce2LdvH3p6evgemGGcTieuv/56VFVVYceOHXztZ5GtW7fiD3/4A/74xz/qLud7YHZYs2YNjh8/joMHD+LnP/85li9fDqA4Xv+SfJ/AbLZgwQKUlJTg5MmTustPnjyJdevWme6zePFi09svXrw4a+dJ2ZHK62/07W9/G729vTGLMlT4Unn93/KWt+Af//EfceGFF+bgDCmbUnn9V61ahXe84x34xS9+gXe/+91YvXo17rnnHpSWluLrX/96Lk6bMiSV13/btm1YsGABnn76aTgcDpSWluLee+/Ft771rVycMuWZ1d//ampqUFFRgUAgkKczI8qvTPx9mmYG8e9h/lt5dnA4HPj+97+Pp59+Gq+++iqAyHtgcnISIyMjutvyPTBznH/++dixYwcqKiowNjaGD37wg9izZw8uvPBCvvazwPXXX4+LL77YdM4bf/5nvueeew7/8A//gH379mHJkiW444478Je//AXnn39+Ubz+DGCIitTtt9+OG264AVdeeSUmJyfzfTqUZXPmzMHPfvYzfPrTn8bAwEC+T4fywOl04tSpU/inf/onhMNhvPjii1i6dCluu+02BjCzwBVXXIGvfOUruOWWW/Dcc89h9erV+MEPfoB//dd/xTe/+c18nx4RERFRzmzduhXnn38+3vrWt+b7VCiH9u3bhwsvvBA1NTX48Ic/jPvvvx9XXHFFvk+LcmDZsmX4wQ9+gGuuuYbrX7PUo48+qn29e/duPPfcc+jp6UFjYyMmJibyeGaJYQCTR6dPn0YoFMKiRYt0ly9atAh9fX2m+/T19SV1eypcqbz+wq233orm5mZcffXV2L17dzZPk7Ik2df/rLPOwplnnonf/e532mWi9VQwGMTatWtx6NCh7J40ZUwqP/8nTpxAMBhEOBzWLtuzZw+WLFmC0tJSBIPBrJ4zZU4qr/83vvEN/OxnP8OPf/xjAMDf/vY3VFVV4Yc//CFaW1uhqmrWz5vyx+rvfyMjI6x+oVktnb9P08wiXm/ja79o0SK89NJLeToryoa7774b733ve7FhwwYcP35cu7yvrw/l5eWoqanRfQqavw9mjmAwiIMHDwIAXnzxRVx66aX47Gc/i1/+8pd87We4Sy65BIsWLcKLL76oXVZSUoINGzZg8+bN2LhxI98Ds8zIyAhef/11rF69Go8//njBv/5sHJ9HwWAQO3fuxFVXXaVd5nA4cNVVV2HHjh2m++zYsUN3ewC45pprLG9PhSuV1x8AbrvtNvzbv/0brr32WuzcuTMXp0pZkOzrv3fvXpx//vm48MILte23v/0tnnzySVx44YU4evRoLk+f0pTKz/8zzzyD1atXw+FwaJedffbZ6O3tZfhSZFJ5/d1uty58AwBFUbR9aWbj3/+IzKX692maeQ4fPowTJ07o3gvV1dW47LLL+F6YQe6++2588IMfxDve8Q50d3frrtu5cyempqZ074Gzzz4bDQ0NfA/MUE6nE+Xl5XztZ4E//vGPMeshL7zwAn7xi1/gwgsvxF//+le+B2aZqqoqnHXWWThx4kTR/A5QueVva2xsVCcmJtSPf/zj6rp169SOjg51cHBQXbhwoQpAvf/++9U777xTu/3ll1+uTk1NqV/4whfUtWvXqnfccYc6OTmpnnfeeXl/LNyy//p/6UtfUgOBgPp3f/d36qJFi7Stqqoq74+FW/Zff+N23333qQ899FDeHwe33Lz+y5YtU0dGRtT//M//VNesWaO++93vVvv6+tSvfOUreX8s3LL/+t9xxx3qyMiIev3116srV65Ur776anX//v3qgw8+mPfHwi35raqqSr3gggvUCy64QFVVVf3c5z6nXnDBBery5ctVAOqdd96p3n///drtV65cqY6Njanf/va31bVr16o333yzGgwG1Xe+8515fyzcuOV7i/f7lNvM2eL97vzSl76kDg4Oqu973/vU888/X33ooYfUgwcPquXl5Xk/d27pb1u3blWHhobUDRs26P4tXFFRod3mnnvuUbu7u9Urr7xSvfjii9VnnnlGfeaZZ/J+7tzS3+688071bW97m9rQ0KCef/756p133qkqiqJeffXVfO1n6fbkk0+q3/ve97Tv+R6Y2dt3vvMddcOGDWpDQ4N6+eWXq4899ph66tQpdcGCBcXy+uf9BGb99pnPfEbt7u5WA4GA+uyzz6pvetObtOuefPJJ9b777tPd/sMf/rC6d+9eNRAIqLt371bf9a535f0xcMvN63/48GHVzB133JH3x8Et+6+/cWMAU/xbsq//m9/8ZnXHjh3qxMSEeuDAAfXLX/6y6nQ68/44uGX/9Xe5XOq///u/q/v371f9fr/a09Ojtre3qzU1NXl/HNyS36644grTP8/Fa37fffepTz75ZMw+L774ohoIBNQDBw6oN910U94fBzduhbLZ/T7lNnO2eL87Aahf+9rX1BMnTqgTExPq448/rq5Zsybv580tM5sV+c/D8vJytb29XR0YGFDHxsbUX//61+qiRYvyfu7c0t/+67/+Sz18+LAaCATUkydPqo8//rgWvvC1n52bMYDhe2Bmb9u2bVOPHz+uBgIB9ejRo+q2bdvUVatWFc3r75j+goiIiIiIiIiIiIiIiDKEM2CIiIiIiIiIiIiIiIgyjAEMERERERERERERERFRhjGAISIiIiIiIiIiIiIiyjAGMERERERERERERERERBnGAIaIiIiIiIiIiIiIiCjDGMAQERERERERERERERFlGAMYIiIiIiIiIiIiIiKiDGMAQ0RERERERERERERElGEMYIiIKGduuukmDA0N5fs0iIiIiIiIiIiIso4BDBERZdR9990HVVWhqiomJyexf/9+/Nu//RtcLle+T42IiIiIiKjgyP+Gkrezzjor36dGRERpKsn3CRAR0czzyCOP4BOf+ATKy8vx7ne/G1u3bkUwGMSJEyfyfWpEREREREQFR/wbStbf36/7vrS0FMFgMJenRUREaWIFDBERZdzk5CROnjyJI0eOoKOjA0888QTe//73a9e/853vxGuvvQafz4dHHnkEixcv1q5bv349HnvsMfT392N4eBh/+tOfcNFFF+mOf8cdd6CnpweBQADHjx/HD37wA+26srIyfOc738GxY8cwNjaGZ599FldccUX2HzQREREREVGKxL+h5O2Pf/wj7r77bnzve99Df38/tm/fDgA477zz8PDDD8Pn86Gvrw8PPPAA6urqtGO53W7cf//98Pl86O3txRe+8AU8+eST+N73vpevh0dENGsxgCEioqybmJhAWVkZgMg/Br74xS/iYx/7GDZs2IAVK1bgu9/9rnbb6upq3H///XjrW9+KN7/5zdi/fz8efvhhzJkzBwDwoQ99CJ///Ofxz//8z1izZg2uu+467N69W9u/vb0dl19+OW644Qa88Y1vxK9+9Ss8+uijWL16dW4fNBERERERUZpuuukmTE1N4S1veQuamppQU1OD//u//8OuXbuwfv16XHvttVi0aBG6urq0fb7zne/giiuuwAc+8AG8853vxJVXXomLL744j4+CiGh2U7lx48aNG7dMbffdd5/60EMPad9fddVV6sTEhPof//Ef6k033aSqqqquWrVKu/7mm29WT5w4YXk8h8OhjoyMqO95z3tUAOrnP/95de/evWpJSUnMbZcvX64Gg0F1yZIlussff/xxtbW1Ne/PDTdu3Lhx48aNGzdu3LgZt/vuu08NBoOqz+fTtq6uLvXJJ59Ud+7cqbttS0uL+uijj+ouW7p0qaqqqrpmzRq1qqpKDQQC6oc//GHt+vnz56vj4+Pq9773vbw/Vm7cuHGbbRtnwBARUca9973vhc/nQ2lpKZxOJzo7O/HVr34Vf//3f4/x8XEcOnRIu+2JEyewcOFC7fuFCxfim9/8Jq688kosXLgQLpcLbrcbK1asAAD86le/wuc+9zkcOnQIjz76KB5++GH87ne/g6IoeMMb3oCSkhK8/vrruvMpLy/HwMBAbh48ERERERFRkp588kncfPPN2vfj4+PYtm0bdu7cqbvdBRdcgLe//e3w+XwxxzjrrLNQWVmJ8vJyPPfcc9rlQ0ND2LdvX/ZOnoiILDGAISKijBP/eJiamkJvby8URdGuMw6NVFUVTme0I+b999+Puro6fPazn0VPTw8mJyexY8cOrYXZsWPHsHbtWlx99dW45pprcM899+C2227DFVdcgTlz5iAUCuGSSy7R3ScAjI2NZfERExERERERpW58fBwHDx40vVw2Z84c/O53v8Ptt98ec9sTJ06w9TIRUYFhAENERBln9Y+HRLzlLW/BLbfcgkceeQQAsGzZMtTX1+tuEwgE8Pvf/x6///3vsXXrVuzbtw9veMMbsGvXLpSUlGDhwoV4+umn034cREREREREheTFF1/Ehz70IXR3d8d86AwADh48iKmpKVx22WU4evQoAGDevHk4++yz8ec//znXp0tENOs549+EiIgod/bv34+PfexjWLduHd70pjfhF7/4Bfx+v3b9TTfdhE9+8pM477zzcOaZZ+KjH/0o/H4/enp6sH//fvz85z/HAw88gA9+8INYuXIlLr30UjQ3N+Pd7353Hh8VERERERFR+rZu3Yra2lps27YN69evx6pVq/DOd74TP/nJT+B0OjE+Po4f//jH+M53voO3v/3tOO+88/DTn/4U4XBYd5w777wT999/f54eBRHR7MEAhoiICso//uM/Yv78+XjxxRfxs5/9DP/5n/+JU6dOadcPDw/j05/+NJ555hm88soruPrqq/G+970Pg4ODAIBPfOITeOCBB7Blyxbs27cPv/nNb3DppZfiyJEj+XpIREREREREGXHixAm85S1vgcvlwmOPPYbdu3fj+9//PoaHh7WQ5bbbbsNf/vIX/O53v8MTTzyBp59+OmaWzJIlS7Q5m0RElD0OAGq+T4KIiIiIiIiIiIiy48knn8RLL72Ez3/+8/k+FSKiWYUVMERERERERERERERERBnGAIaIiIiIiIiIiIiIiCjD2IKMiIiIiIiIiIiIiIgow1gBQ0RERERERERERERElGEMYIiIiIiIiIiIiIiIiDKMAQwREREREREREREREVGGMYAhIiIiIiIiIiIiIiLKMAYwREREREREREREREREGcYAhoiIiIiIiIiIiIiIKMMYwBAREREREREREREREWUYAxgiIiIiIiIiIiIiIqIM+/8BbRvnPjsUNJMAAAAASUVORK5CYII=" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAABmAAAAHFCAYAAADlizaSAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs3X+cG3WdP/DXZHbb7ZaUkmVtoZYs+APQ86DQIkSvnF/URUURz2+guRNBDkxhT07qnak58fwRCJ5b0dvSKCJwymKjnqByusJXvKJZfrQUKNBCge6WtrTdbmibdpt2dzLfP7Kf2c8kk1+7+TWb19PHyDaZTD67285M3u/P+/1RAOggIiIiIiIiIiIiIiKisnHUegBERERERERERERERETTDRMwREREREREREREREREZcYEDBERERERERERERERUZkxAUNERERERERERERERFRmTMAQERERERERERERERGVGRMwREREREREREREREREZcYEDBERERERERERERERUZkxAUNERERERERERERERFRmTMAQERERERERERERERGVGRMwREREREREREREREREZcYEDBERERER0TTxN3/zN/j1r3+NnTt3Qtd1XHrppQVfc+GFF2LDhg1IJpPYunUrPvvZz1ZhpERERERE0x8TMERERERERNPE7Nmz8eyzz+KGG24oav+Ojg489NBDePTRR3H22Wfj9ttvx49+9CN8+MMfrvBIiYiIiIimPwWAXutBEBERERERUXnpuo5PfvKTePDBB3PuEw6H8bGPfQzvec97jMfuv/9+zJ07Fx/5yEeqMUwiIiIiommrqdYDsIOTTz4ZiUSi1sMgIiIiIqoKp9OJXbt21XoYVAUXXHABHnnkEdNjfX19uP3223O+ZsaMGZg5c6bpMZfLhXg8XokhEhERERHVpWI+NzEBU8DJJ5+MnTt31noYRERERERVtWDBAiZhGsD8+fOxZ88e02N79uzB8ccfj5aWFiSTyazXrFy5Ev/+7/9epRESEREREdWvQp+buAZMAax8ISIiIqJGxPtgyuXWW2/FnDlzjG3BggW1HhIRERFRY9B1YHQ0velcWaQeFPrcxAoYIiIiIiKiBrV7927MmzfP9Ni8efNw4MABy+oXADh27BiOHTtWjeERERERkWxkBDjuuPTXhw4Bs2fXdjxUECtgiIiIiIiIGlR/fz8uuugi02Mf+tCH0N/fX6MRERERERFNH0zAEBERERERTROzZ8/GWWedhbPOOgsAcOqpp+Kss87CwoULAQC33HIL7r33XmP/SCSC0047DbfddhtOP/10LF++HF6vF9/97ndrMn4iIiIiyqO1FXjzzfTW2lrr0VAR2IKMiIiIiIhomli8eDH+9Kc/GX8WiZR77rkHV199NU466SSccsopxvMDAwP42Mc+hu9+97u48cYbsWPHDvzjP/4j/vCHP1R76ERERERUiKIAc+fWehRUAgUAV+vJw+l04uDBg7UeBhERERFRVc2ZM6fggpJEQHGfmXRdx9jYGDRNq9KoiCqjubkZqqrWehhERERUJwp9bmIFDBEREREREVXMsWPH8Prrr2PPnj3QNA26zjmAZE+KomD27Nl45zvfiePEAshERETVdOwYxsK3YsvCk/HW/3sF5h7nrPWIqAAmYIiIiIiIiKgiUqkUNm/ejBdeeAG///3v8eabbyKVStV6WESToqoqLrzwQgDAWWedxUoYIiKqvtFRPLFvF574VCdOfmMbrnjHX9d6RFQAEzBERERERERUEceOHcPBgwcRjUaxY8eOWg+HaMr+93//F+eddx5GR0eZgCEiouprasKWyz8NANh1dASjKQ3NDl6P6pmj1gMgIiIiIiKi6UvXdYyOjtZ6GERlwTZ6RERUUzNnYqzNZfxx/7FkDQdDxWAChoiIiIiIiIiIiIiozqV0HSNjExNbEqPHajgaKgYTMEREREREREREREREdS6pjUGuw0yOjdVsLFQcJmCIiIiIiIiIJuGkk07C+vXr8c53vrPo11x33XW47777KjgqIiIimq4OJw6a/nxEYwKm3jXVegBERERERERE9eZrX/saPv7xjxt/3r9/P1588UV8//vfxyuvvAIA2LNnDzo7O7F///4ajbJ0TU1NWLZsGTo7O+F2u6FpGnbt2oXHHnsMP//5z7Fv375aD9GS/PsYHR3F7t278dBDD+Huu++Gpmk499xz8YMf/AAAkEqlMDIygp07d+KJJ57Afffdh+HhYeNY1113Ha677jrEYjF84QtfML3PZz7zGdx4443YsGEDPv/5z1fvGyQiIirCiGZeV+/IGNfZq3dMwBARERERERFZ+Mtf/oJvfOMbAIC2tjYsX74ct99+Oy655BIA6UC/HNivd83Nzejp6cE73vEO/OAHP8Czzz6LN998EwsWLEBnZycuv/xyrF69utbDzEn8Ppqbm/G+970PX/7ylzE2NoZ77rnH2OdTn/oUDh8+jNmzZ+OMM87AlVdeiU984hP4/Oc/j1dffdXYb2hoCIsXL8Zb3vIW7N2713j8E5/4BN54441qfltERERFO+wwN7RiBUz9YwsyG4hEIujo6EAkEqn1UIiIiIimHd5rEVWfOmNGTbZSjY6OYnh4GMPDw3j55Zdxzz33YP78+Zg7dy6A7BZk5557LtavX48lS5bgv/7rv/DnP/8Zd911F9xud973ufTSS/Hzn/8cf/nLX/CLX/wCn/70p43nmpqa8K//+q/4/e9/j7/85S/4zW9+g6uuusp4/rrrrsNvf/tbxGIx/O53v8OXvvSlnO/j8/lw9tlnY/ny5Vi7di22bNmCPXv24Omnn8att95qSr5ccMEF+NGPfoRHH30UjzzyCL773e9iwYIFxvPiez3uuOOMx975zndi/fr1OOmkkwAA8+fPx6pVq/DHP/4Rjz32GNauXYv3ve99AACn04lvfvObePjhh/HnP/8Z//3f/22qOMr3+9i9ezd++ctf4sknn8TSpUtN+8TjcQwPD2P79u34wx/+gGuuuQb79+/HypUrs/Z7/PHHjWQaAPz1X/815s6diz//+c95x0FERFQrx/SU6c9HNFbA1DtWwNhAOBzG4OAgwuEw/H7/lI8XiUQQDocRCATKcjwiIiIiO5vqvdZU763k14vx8D6NpjN1xgxc/sNVNXnvtdfdBO3YsUm9dtasWfjoRz+K7du348CBA3n3vf7663H77bfjzTffxMqVK3HzzTfjmmuusdz34osvht/vx7e//W289NJLOP300xEMBnHkyBE89NBDuOKKK7B06VIEAgHs3r0b8+bNw/z58wEAF110EXw+H77yla/g1VdfxYknnoh3vOMdOcfV2dmJJ554Ai+99FJR3+99992HrVu3orW1FX6/H9/5znfg8/mg63rB1wPAl7/8ZTQ3N+Paa69FMpnEqaeeipGREQDA8uXLcdppp+ELX/gC9u/fj4ULF2LmzJlFHVc4evQojj/++IL7/PKXv8SKFStwwgkn4M033zSe+/Wvf40vfOEL+PGPfwwgXf3yu9/9rqQxEBERVdOxlAYAaHY4MJpK4cgYK2DqHStgbCAQCMDtdhsfyqdKDjIQERERNbqp3mtN9d5Kfj3v04jqy/vf/36sW7cO69atw2OPPYalS5di5cqVBRMQd9xxB55++mls27YN9957L8466yzMyFGB8/nPfx633347Hn30UezatQuPPvoo7r//fnzqU58CkK4i2b59O5555hns3r0bzz77LPr6+oznhoeH8cQTT2DPnj144YUX8MADD+Qc1ymnnILBwUHTY//xH/9hfI933XWX8fgf//hHPProo9ixYwdefvllfP3rX8c73vEOnHbaacX86IzxPfvss3j11Vexc+dO/PnPf8bGjRuN51566SVs3rwZb7zxBp588kk89thjRR/7vPPOw/nnn4+nnnqq4L4DAwMAgJNPPtn0+GOPPYbZs2dj0aJFaGlpwQc/+EH8+te/LnoMRERE1XZ0fCKJ8+AhABMJGapfrICxAb/fX9YZkIFAwDTLkoiIiKiRTfVeK9+9VTHVMZmv530aTXfasWNYe91NNXvvUmzYsAG33norAGDOnDn49Kc/je9///v47Gc/i927d+d83datW42vxaL2J5xwAvbs2WPar6WlBQsXLsRXv/pVBINB43FVVXHoUDqw8pvf/AarV6/GL3/5S/T39+Oxxx7DE088AQB45JFHsGzZMjz44IPo7+/HX/7yFzz22GPQtOKDMeFwGLNmzcIVV1yBRYsWGY8vXLgQfr8f7373uzF37lw4xnvOz58/37SWSj4/+9nPsHLlSpx//vl44okn8Mc//hGvvPIKAOAXv/gFvv3tb+P000/HE088gT/96U947rnn8h5PJMSamprgcDjw+9//Hj/84Q8LjkNRFADISpxpmobf/e53+PjHP44FCxZg+/btxviIinHw2FHMampCs0Ot9VCIqEEcG0u3HDvu+RcQP38JEzA2wARMAyp3QoeIiIiokeW7tyqmvVnm63mfRo1gsm3Aqu3IkSPYsWOH8edvfetb+NOf/oTLLrsMa9asyfm6MakdiAj6OxzZDShaW1uN4z7//POm51KpdI/3l156CZdeeik8Hg/OO+88hMNhPPnkk/jyl7+MPXv24O/+7u9w3nnn4b3vfS8CgQA+85nP4LrrrrNMwrz++utZ69EMDw8DAA4ePGh6/Lvf/S7eeOMNhEIhDA0NweFwIBqNoqmpyTQ+kdwAYDwnPPjgg3j88cfx/ve/H+9973tx9dVX4/bbb8fatWsRi8VwySWX4H3vex/e+9734o477sDPf/5zfO9738v1YzUSYqOjo9i3b1/RiaZTTz0VALBr166s537961/jnnvuwdve9jZWv1BJ3jyaxD1bn0FbSys+87b3mP4tEBFVyjGk7ytmO+cAAEa1VL7dqQ6wBRkRERERUYWUu5UsEdWWrutIpVIlr1WSSzwex969e7FgwQLs2LHDtMnJgsOHD+Phhx9GKBTCypUrcdFFF2HOnHTg5ejRo3jsscfwne98B5///Odx1lln4e1vf7vl+/X19eG9730vTj/99LzjOv7449HR0YG77roLTz31FAYGBuB0Ok37iLVUTjzxROOxd77znVnH2rNnD375y1/iX//1X/HTn/4Un/zkJ43n9u/fj4ceegg333wzVq1ahcsuuyzvuERCbM+ePUUnX2bOnInLLrsMGzZswP79+7Oef+211/Daa6/hbW97G37/+98XdUwiANibPAwdwL7kCA6PcRFsIqoOMYXluAs/kP4zK2DqHitgiIiIiIgqhJXHRPbW3NyMtrY2AIDT6YTX60Vra2tJa5UU8oMf/AD/8i//gkOHDqG/vx/Nzc1417vehTlz5uC+++7D3//932Pfvn3YsmULdF3HBz/4Qezbtw+JRAKXXHIJVFXF888/j2QyiY985CNIJpM526P19vbi/e9/P+644w7ceeed2LhxIxKJBE455RR4PB6jquXgwYPYv38/PvWpT2Hfvn2YP38+/umf/sl0rNdffx27d+/GddddhzvuuAOnnHIK/uEf/sG0z0033YRYLIbt27fD6XRi8eLF2LZtG4D02jdbtmzBq6++ihkzZuD973+/sVbLVLhcLsycOROtra0488wzceWVV2Lu3Ln4l3/5l5yv8fv9aGpqMtq+ERXjmJQEPKppOK65hoMhooYhEi7HNafXlhvTU0jpOhyswqtbTMDYjOgj7vF4EIvF8vYTJyIiIiJ7KWbNGCKqnve9733GgveHDh3C4OAgAoEANmzYULb3ePDBB5FMJnHllVfixhtvxJEjR/DKK6/g/vvvB5CufrnyyiuxcOFCpFIpvPDCC7jxxhuh6zoSiQSuuuoqfPGLX4TD4cArr7yCL37xizhw4IDlex07dgzLly+Hz+fDxz/+cXR1dUFRFOzatQuxWAy9vb0A0pU+X/nKV/ClL30Ja9euxeDgIL7zne+Y1lvRNA3BYBCBQAD3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tr1792L58uWwWCwIhUJ5PWfKnblc/2984xv46U9/iv/+7/8GAPztb39DdXU1vv/976OjowOyLOf9vKl4kv3338TEBKtfaEHL5r+naX4R11t77ZcuXYpXX321SGdF+XDvvffigx/8IDZv3owTJ04otw8ODsJqtaKuri7uU9D8eTB/hEIhHDp0CADw8ssv48ILL8S//Mu/4Be/+AWv/Tx3/vnnY+nSpXj55ZeV28xmMzZv3oytW7diy5YtfA8sMBMTE3jzzTexdu1aPP744yV//dk4vohCoRB2796Nyy+/XLnNYDDg8ssvx65du3T32bVrV9z9AeDKK69Men8qXXO5/gBw66234t///d9x1VVXYffu3YU4VcqD2V7/ffv24ZxzzsHGjRuV7be//S2efPJJbNy4EceOHSvk6VOW5vL9/+yzz2Lt2rUwGAzKbWeddRYGBgYYvpSZuVx/m80WF74BgCRJyr40v/G//4j0zfW/p2n+OXLkCE6ePBn3XrDb7bjooov4XphH7r33XnzkIx/Be9/7XvT19cV9bffu3QgGg3HvgbPOOgtNTU18D8xTRqMRVquV134B+NOf/pSwHvLiiy/i5z//OTZu3IiXXnqJ74EFprq6GmeeeSZOnjxZNj8DZG7F25qbm2Wfzyd/4hOfkDds2CB3d3fLbrdbXrJkiQxAfvDBB+U777xTuf/FF18sB4NB+Ytf/KK8fv16+Y477pADgYB89tlnF/25cMv/9b/ttttkv98v//3f/728dOlSZauuri76c+GW/+uv3R544AH54YcfLvrz4FaY63/aaafJExMT8n/913/J69atkz/wgQ/Ig4OD8le+8pWiPxdu+b/+d9xxhzwxMSFfd9118urVq+UrrrhCPnDggPzQQw8V/blwm/1WXV0tn3vuufK5554ry7Isf+ELX5DPPfdc+fTTT5cByHfeeaf84IMPKvdfvXq1PDU1JX/rW9+S169fL990001yKBSS3/e+9xX9uXDjVuwt3c9TbvNnS/ez87bbbpPdbrd89dVXy+ecc4788MMPy4cOHZKtVmvRz51b9tv27dvlsbExefPmzXH/Fq6srFTuc99998l9fX3ypZdeKp933nnys88+Kz/77LNFP3du2W933nmn/J73vEduamqSzznnHPnOO++UJUmSr7jiCl77Bbo9+eST8j333KP8me+B+b19+9vfljdv3iw3NTXJF198sfzYY4/Jp06dkhcvXlwu17/oJ7Dgt8997nNyX1+f7Pf75eeee05+xzveoXztySeflB944IG4+1977bXyvn37ZL/fL+/Zs0d+//vfX/TnwK0w1//IkSOynjvuuKPoz4Nb/q+/dmMAU/7bbK//O9/5TnnXrl2yz+eTDx48KH/5y1+WjUZj0Z8Ht/xff5PJJP/Hf/yHfODAAdnr9cr9/f1yV1eXXFdXV/TnwW322yWXXKL797m45g888ID85JNPJuzz8ssvy36/Xz548KB84403Fv15cONWKluqn6fc5s+W7mcnAPlrX/uafPLkSdnn88mPP/64vG7duqKfN7fcbMmo/z60Wq1yV1eXPDo6Kk9NTcm/+tWv5KVLlxb93Lllv/3whz+Ujxw5Ivv9fnloaEh+/PHHlfCF135hbtoAhu+B+b3t2LFDPnHihOz3++Vjx47JO3bskNesWVM2198w8xsiIiIiIiIiIiIiIiLKEc6AISIiIiIiIiIiIiIiyjEGMERERERERERERERERDnGAIaIiIiIiIiIiIiIiCjHGMAQERERERERERERERHlGAMYIiIiIiIiIiIiIiKiHGMAQ0RERERERERERERElGMMYIiIiIiIiIiIiIiIiHKMAQwREREREREREREREVGOMYAhIqKCufHGGzE2Nlbs0yAiIiIiIiIiIso7BjBERJRTDzzwAGRZhizLCAQCOHDgAP793/8dJpOp2KdGRERERERUctT/hlJvZ555ZrFPjYiIsmQu9gkQEdH88+ijj+KTn/wkrFYrPvCBD2D79u0IhUI4efJksU+NiIiIiIio5Ih/Q6kNDw/H/dlisSAUChXytIiIKEusgCEiopwLBAIYGhrC0aNH0d3djSeeeAIf+tCHlK+/733vwxtvvAGPx4NHH30Uy5YtU752wQUX4LHHHsPw8DDGx8fx5z//GZs2bYo7/h133IH+/n74/X6cOHEC3/ve95SvVVRU4Nvf/jaOHz+OqakpPPfcc7jkkkvy/6SJiIiIiIjmSPwbSr396U9/wr333ot77rkHw8PD2LlzJwDg7LPPxiOPPAKPx4PBwUH85Cc/QUNDg3Ism82GBx98EB6PBwMDA/jiF7+IJ598Evfcc0+xnh4R0YLFAIaIiPLO5/OhoqICQPQfA1/60pfw8Y9/HJs3b8aqVavwne98R7mv3W7Hgw8+iHe/+9145zvfiQMHDuCRRx5BTU0NAOCjH/0o/vVf/xX//M//jHXr1uGaa67Bnj17lP27urpw8cUX4/rrr8fb3/52/PKXv8Qf//hHrF27trBPmoiIiIiIKEs33ngjgsEg3vWud6G1tRV1dXX43//9X7zyyiu44IILcNVVV2Hp0qXo7e1V9vn2t7+NSy65BB/+8Ifxvve9D5deeinOO++8Ij4LIqKFTebGjRs3btxytT3wwAPyww8/rPz58ssvl30+n/yf//mf8o033ijLsiyvWbNG+fpNN90knzx5MunxDAaDPDExIf/d3/2dDED+13/9V3nfvn2y2WxOuO/pp58uh0Ihefny5XG3P/7443JHR0fRXxtu3Lhx48aNGzdu3Lhx024PPPCAHAqFZI/Ho2y9vb3yk08+Ke/evTvuvu3t7fIf//jHuNtWrlwpy7Isr1u3Tq6urpb9fr987bXXKl9ftGiRPD09Ld9zzz1Ff67cuHHjttA2zoAhIqKc++AHPwiPxwOLxQKj0Yienh589atfxf/5P/8H09PTOHz4sHLfkydPYsmSJcqflyxZgm9+85u49NJLsWTJEphMJthsNqxatQoA8Mtf/hJf+MIXcPjwYfzxj3/EI488gt/97neQJAlve9vbYDab8eabb8adj9VqxejoaGGePBERERER0Sw9+eSTuOmmm5Q/T09PY8eOHdi9e3fc/c4991xcdtll8Hg8Ccc488wzUVVVBavViueff165fWxsDPv378/fyRMRUVIMYIiIKOfEPx6CwSAGBgYgSZLyNe3QSFmWYTTGOmI++OCDaGhowL/8y7+gv78fgUAAu3btUlqYHT9+HOvXr8cVV1yBK6+8Evfddx9uvfVWXHLJJaipqUE4HMb5558f95gAMDU1lcdnTERERERENHfT09M4dOiQ7u1qNTU1+N3vfofbb7894b4nT55k62UiohLDAIaIiHIu2T8eMvGud70LN998Mx599FEAwGmnnYbGxsa4+/j9fvz+97/H73//e2zfvh379+/H2972Nrzyyiswm81YsmQJnnnmmayfBxERERERUSl5+eWX8dGPfhR9fX0JHzoDgEOHDiEYDOKiiy7CsWPHAAD19fU466yz8NRTTxX6dImIFjxj+rsQEREVzoEDB/Dxj38cGzZswDve8Q78/Oc/h9frVb5+44034lOf+hTOPvtsnHHGGfiHf/gHeL1e9Pf348CBA/jZz36Gn/zkJ/jIRz6C1atX48ILL0RbWxs+8IEPFPFZERERERERZW/79u1wOBzYsWMHLrjgAqxZswbve9/78KMf/QhGoxHT09P47//+b3z729/GZZddhrPPPhs//vGPEYlE4o5z55134sEHHyzSsyAiWjgYwBARUUn59Kc/jUWLFuHll1/GT3/6U/zXf/0XTp06pXx9fHwcn/3sZ/Hss8/ir3/9K6644gpcffXVcLvdAIBPfvKT+MlPfoJt27Zh//79+PWvf40LL7wQR48eLdZTIiIiIiIiyomTJ0/iXe96F0wmEx577DHs2bMH3/3udzE+Pq6ELLfeeiv+8pe/4He/+x2eeOIJPPPMMwmzZJYvX67M2SQiovwxAJCLfRJERERERERERESUH08++SReffVV/Ou//muxT4WIaEFhBQwREREREREREREREVGOMYAhIiIiIiIiIiIiIiLKMbYgIyIiIiIiIiIiIiIiyjFWwBAREREREREREREREeUYAxgiIiIiIiIiIiIiIqIcYwBDRERERERERERERESUYwxgiIiIiIiIiIiIiIiIcowBDBERERERERERERERUY4xgCEiIiIiIiIiIiIiIsoxBjBEREREREREREREREQ5xgCGiIiIiIiIiIiIiIgox/5/+n4ZSskZ6DQAAAAASUVORK5CYII=" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 13 }, { "cell_type": "code", diff --git a/publish_docs.sh b/publish_docs.sh deleted file mode 100644 index 9f08f1eb..00000000 --- a/publish_docs.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash -# -# A hack-ish way to automate the document publishing process for -# github pages. -# -# This won't work if you're not @johnh2o2 on Github. -# -# To build docs locally -# --------------------- -# Just ``cd docs && make html``. Then open docs/build/html/index.html. -set -x - -DOC_BRANCH=master -NEEDED="cuvarbase docs/Makefile docs/source README.rst INSTALL.rst CHANGELOG.rst" - -# We need to grab hidden files with mv... -shopt -s dotglob nullglob - -# Create gh-pages branch if one doesn't already exist. -HAS_GH_BRANCH=`git branch | grep gh-pages` -if [ "$HAS_GH_BRANCH" == "" ]; then - echo "Did not detect gh-pages branch. Creating now." - git checkout -b gh-pages || exit 1 -else - git checkout gh-pages || exit 1 -fi - -# update -git pull origin gh-pages - -# clean out -git rm -rf . - -# checkout the files we need for the documentation -git checkout $DOC_BRANCH $NEEDED -git reset HEAD - -# make docs -cd docs -make html || exit 1 -cd .. - -# move content to parent directory -mv docs/build/html/* ./ - -# remove unneeded files -rm -rf $NEEDED docs - -# update the repo -git add --all -git commit -m "Updating docs" -git push -u origin gh-pages - -# go home -git checkout $DOC_BRANCH diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..8ca31ce3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "cuvarbase" +dynamic = ["version"] +description = "Period-finding and variability on the GPU" +readme = {file = "README.md", content-type = "text/markdown"} +requires-python = ">=3.9" +license = "GPL-3.0-only" +license-files = ["LICENSE.txt"] +authors = [ + {name = "John Hoffman", email = "johnh2o2@gmail.com"} +] +keywords = ["astronomy", "GPU", "CUDA", "period-finding", "time-series"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Science/Research", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: C", + "Programming Language :: C++", +] +# numpy/scipy floors: the oldest releases that install on every supported +# interpreter (1.17/1.3 have no Python 3.9 wheels and were tested nowhere); +# 1.22/1.8 pass the CPU suite on 3.9. +dependencies = [ + "numpy>=1.22", + "scipy>=1.8", + "pycuda>=2017.1.1,!=2024.1.2", +] + +[project.optional-dependencies] +tls = [ + "cupy-cuda12x>=13.6,<14", + "batman-package>=2.5", +] +# Test extra: what `pytest --pyargs cuvarbase` needs beyond the runtime +# deps. matplotlib is not needed (every plotting import sits behind +# plot=False); batman-package and transitleastsquares exercise the +# limb-darkened TLS templates and the TLS reference comparisons. +test = [ + "pytest", + "nfft", + "astropy", + "batman-package", + "transitleastsquares", +] +cufinufft = [ + "cufinufft>=2.2", +] +# Mirrors docs/requirements.txt (the numpy/scipy floors come from the +# runtime dependencies above). +docs = [ + "sphinx>=7,<9", + "matplotlib>=3.7", +] + +[project.urls] +Homepage = "https://github.com/johnh2o2/cuvarbase" +Documentation = "https://johnh2o2.github.io/cuvarbase/" +Repository = "https://github.com/johnh2o2/cuvarbase" +"Bug Tracker" = "https://github.com/johnh2o2/cuvarbase/issues" + +[tool.setuptools.packages.find] +include = ["cuvarbase*"] + +[tool.setuptools.package-data] +cuvarbase = ["kernels/*.cu", "kernels/*.cuh"] + +[tool.setuptools.dynamic] +version = {attr = "cuvarbase.__version__"} + +[tool.pytest.ini_options] +testpaths = ["cuvarbase/tests"] +addopts = "-rs --strict-markers" +markers = [ + "gpu: needs a CUDA device", +] +# Only warnings the library emits on purpose are silenced here; everything +# else (DeprecationWarning, RuntimeWarning, ...) stays visible in the +# summary so a new one is noticed. +filterwarnings = [ + # tls_models: informational, fires once at import when the optional + # batman-package is absent (the analytic template is used instead) + "ignore:batman package not available:UserWarning", + # nufft_lrt: the deliberate EXPERIMENTAL notice (re-validation pending) + "ignore:.*nufft.?lrt.*EXPERIMENTAL:UserWarning", +] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 0eabe997..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,9 +0,0 @@ --e . -future -numpy >= 1.6 -scipy -pycuda >= 2017.1.1, != 2024.1.2 -scikit-cuda -pytest -nfft -astropy \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 11283e0a..00000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -future -numpy >= 1.6 -scipy -pycuda >= 2017.1.1, != 2024.1.2 -scikit-cuda diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d662cb96..00000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[bdist_wheel] -universal=1 - -[aliases] -test=pytest diff --git a/setup.py b/setup.py deleted file mode 100644 index b2c9ecfc..00000000 --- a/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python - -import io -import os -import re - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - - -def read(path, encoding='utf-8'): - path = os.path.join(os.path.dirname(__file__), path) - with io.open(path, encoding=encoding) as fp: - return fp.read() - - -def version(path): - """Obtain the packge version from a python file e.g. pkg/__init__.py - - See . - """ - version_file = read(path) - version_match = re.search(r"""^__version__ = ['"]([^'"]*)['"]""", - version_file, re.M) - if version_match: - return version_match.group(1) - raise RuntimeError("Unable to find version string.") - - -VERSION = version('cuvarbase/__init__.py') - -setup(name='cuvarbase', - version=VERSION, - description="Period-finding and variability on the GPU", - author='John Hoffman', - author_email='johnh2o2@gmail.com', - packages=['cuvarbase', - 'cuvarbase.tests'], - package_data={'cuvarbase': ['kernels/*cu']}, - url='https://github.com/johnh2o2/cuvarbase', - setup_requires=['pytest-runner', 'future'], - install_requires=['future', - 'numpy>=1.6', - 'scipy', - 'pycuda>=2017.1.1,!=2024.1.2', - 'scikit-cuda'], - tests_require=['pytest', - 'future', - 'nfft', - 'matplotlib', - 'astropy'], - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Natural Language :: English', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: C', - 'Programming Language :: C++']) diff --git a/test_python_versions.sh b/test_python_versions.sh deleted file mode 100644 index c5d7292f..00000000 --- a/test_python_versions.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# -# Very rough script for testing cuvarbase compatibility across python -# versions -# -# (c) John Hoffman -# -# Run this from the top-level cuvarbase directory - - -# Print everything you do. -set -x - -# Decide which python version to test -PYTHON_VERSION=2.7 - -# Put your cuda installation directory here -export CUDA_ROOT=/usr/local/cuda - -######################################################################## -CONDA_ENVIRONMENT_NAME=cuvar -CUVARBASE_DIR=$PWD - -# Export the library paths -export LD_LIBRARY_PATH="${CUDA_ROOT}/lib:${LD_LIBRARY_PATH}" -export DYLD_LIBRARY_PATH="${CUDA_ROOT}/lib:${DYLD_LIBRARY_PATH}" -export PATH="${CUDA_ROOT}/bin:${PATH}" - -# Erase the testing conda environment if it already exists -test_str=`conda info --envs | grep ${CONDA_ENVIRONMENT_NAME}` -if [ "$test_str" != "" ]; then - echo "removing conda environment ${CONDA_ENVIRONMENT_NAME}" - conda remove -y --name ${CONDA_ENVIRONMENT_NAME} --all -fi - -# Create the conda environment for testing with the right Python version -conda create -y -n $CONDA_ENVIRONMENT_NAME python=$PYTHON_VERSION numpy - -# Activate the conda environment -source activate $CONDA_ENVIRONMENT_NAME - -cd $CUVARBASE_DIR - -# Install from the present directory, ignoring caches -pip install --no-cache-dir -e . - -# test -python setup.py test - -# (optionally) clean up conda environment -#source deactivate -#conda remove -y --name $CONDA_ENVIRONMENT_NAME --all diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..1f94a16f --- /dev/null +++ b/tools/README.md @@ -0,0 +1,9 @@ +# Developer checks + +Run these commands from the repository root. Install the package and test dependencies in the environment being checked. + +- `python -m pytest`: CPU tests run without CUDA; device tests require a CUDA GPU. +- `python tools/check_release_gate.py`: additional numerical release checks, run on a CUDA device after the full suite. +- `python tools/ci_wheel_smoke.py`: installed-package smoke check used by CI. Run it in a fresh environment containing the built wheel or sdist; it removes the working directory from the import path. + +GPU checks can run on any suitable local or rented CUDA device. Resource provisioning and personal SSH configuration are outside these tools. The [release validation record](../docs/validation/README.md) contains the measured checks for the frozen v1 source. Reproducible performance experiments live under [benchmarks/](../benchmarks/README.md). diff --git a/tools/benchmark_archive.py b/tools/benchmark_archive.py new file mode 100644 index 00000000..105eab58 --- /dev/null +++ b/tools/benchmark_archive.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Restore original benchmark evidence after verifying its archive and members.""" +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import tarfile +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +MANIFESTS = ROOT / 'benchmarks/archives' + + +def sha256(path): + value = hashlib.sha256() + with Path(path).open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + value.update(block) + return value.hexdigest() + + +def load_manifest(study, directory=MANIFESTS): + if not re.fullmatch(r'[A-Za-z0-9_.-]+', study): + raise ValueError('Invalid archive identifier') + record = json.loads((directory / (study + '.json')).read_text()) + if record.get('schema') != 1 or record.get('id') != study: + raise ValueError('Unsupported archive manifest') + files = {} + for name, size, digest, kept in record['files']: + path = PurePosixPath(name) + if (path.is_absolute() or '..' in path.parts or '\\' in name + or not name.startswith(('benchmarks/results/', 'docs/validation/'))): + raise ValueError('Unsafe member path: ' + name) + if name in files or not isinstance(size, int) or size < 0: + raise ValueError('Invalid or duplicate inventory entry: ' + name) + if not re.fullmatch(r'[0-9a-f]{64}', digest) or not isinstance(kept, bool): + raise ValueError('Invalid member identity: ' + name) + files[name] = (size, digest, kept) + return record, files + + +def check_archive(path, record): + if (Path(path).stat().st_size != record['archive']['bytes'] + or sha256(path) != record['archive']['sha256']): + raise ValueError('Archive checksum or size differs from the committed manifest') + + +def safe_destination(destination, name): + target = destination / name + for path in (target, *target.parents): + if path == destination: + break + if path.is_symlink(): + raise ValueError('Restore path contains a symlink: ' + str(path)) + return target + + +def restore(archive, record, files, destination, full=False): + """Verify every member before installing any missing files; never overwrite.""" + check_archive(archive, record) + destination = Path(destination).resolve() + destination.mkdir(parents=True, exist_ok=True) + selected = {name: value for name, value in files.items() if full or not value[2]} + for name, (_, digest, _) in selected.items(): + target = safe_destination(destination, name) + if target.exists() and (not target.is_file() or sha256(target) != digest): + raise ValueError('Existing file differs; use an empty destination: ' + name) + installed = 0 + with tempfile.TemporaryDirectory(prefix='.archive-restore-', dir=destination) as temporary: + stage = Path(temporary) + seen = set() + with tarfile.open(archive, 'r:gz') as stream: + for member in stream: + if member.name not in files or member.name in seen or not member.isfile(): + raise ValueError('Unexpected, duplicate or non-regular archive member') + seen.add(member.name) + size, expected, _ = files[member.name] + if member.size != size: + raise ValueError('Member size differs: ' + member.name) + digest = hashlib.sha256() + output = None + if member.name in selected: + staged = stage / member.name + staged.parent.mkdir(parents=True, exist_ok=True) + output = staged.open('wb') + try: + with stream.extractfile(member) as source: + for block in iter(lambda: source.read(1024 * 1024), b''): + digest.update(block) + if output is not None: + output.write(block) + finally: + if output is not None: + output.close() + if digest.hexdigest() != expected: + raise ValueError('Member checksum differs: ' + member.name) + if output is not None: + staged.chmod(0o755 if member.mode & 0o111 else 0o644) + if seen != set(files): + raise ValueError('Archive omits inventory members') + for name, (_, digest, _) in selected.items(): + target = safe_destination(destination, name) + if target.exists(): + if not target.is_file() or sha256(target) != digest: + raise ValueError('Restore destination changed: ' + name) + continue + target.parent.mkdir(parents=True, exist_ok=True) + # Atomic no-clobber installation on the same filesystem. + os.link(stage / name, target) + installed += 1 + return installed + + +def fetch(record, remote, cache): + """Download through the caller's configured rclone remote; no credentials stored.""" + cache = Path(cache) + cache.mkdir(parents=True, exist_ok=True) + target = cache / (record['archive']['sha256'] + '.tar.gz') + if target.exists(): + check_archive(target, record) + return target + if not remote: + raise ValueError('Supply --archive FILE or --remote NAME:BUCKET; see docs/BENCHMARK_ARCHIVES.md') + fd, temporary = tempfile.mkstemp(prefix='download-', suffix='.part', dir=cache) + os.close(fd) + try: + subprocess.run(['rclone', 'copyto', remote.rstrip('/') + '/' + record['archive']['key'], + temporary, '--contimeout', '15s', '--timeout', '60s'], check=True) + check_archive(temporary, record) + os.replace(temporary, target) + finally: + Path(temporary).unlink(missing_ok=True) + return target + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + sub.add_parser('list', help='List preserved studies and archive sizes') + command = sub.add_parser('restore', help='Verify and restore original evidence') + command.add_argument('study') + command.add_argument('--archive', type=Path, help='Previously downloaded archive') + command.add_argument('--remote', default=os.environ.get('CUVARBASE_ARCHIVE_REMOTE'), + help='Configured rclone remote and bucket, e.g. archive:cuvarbase') + command.add_argument('--cache', type=Path, default=ROOT / '.benchmark-archives/downloads') + command.add_argument('--destination', type=Path, default=ROOT) + command.add_argument('--full', action='store_true', + help='Restore original reports too; use an empty destination') + args = parser.parse_args() + if args.command == 'list': + for path in sorted(MANIFESTS.glob('*.json')): + record, files = load_manifest(path.stem) + print('%-40s %7.2f MiB %4d files' % + (path.stem, record['archive']['bytes'] / 1024 ** 2, len(files))) + return + record, files = load_manifest(args.study) + archive = args.archive or fetch(record, args.remote, args.cache) + installed = restore(archive, record, files, args.destination, args.full) + print('Verified %d original members; restored %d missing files.' % (len(files), installed)) + + +if __name__ == '__main__': + main() diff --git a/tools/check_release_gate.py b/tools/check_release_gate.py new file mode 100755 index 00000000..9129a8dd --- /dev/null +++ b/tools/check_release_gate.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python +"""v1.0.0 release-gate checks that go beyond the pytest suite. + +Run on a GPU machine: + + python tools/check_release_gate.py + +Checks: + 0. preflight -- every dependency the zero-skip suite run needs + (pycuda, batman, transitleastsquares, nfft, astropy, cufinufft) + imports; the gate FAILS if any is missing, so a "0 skipped" suite + run is actually possible on this environment + 1. reduction_max equivalence — eebls_gpu_fast with use_optimized=True + (bls_optimized.cu) agrees with the standard kernel (validates the + s >= 32 reduction fix end-to-end) + 2. kernel-cache timing — second call of eebls_gpu_fast / + eebls_gpu_fast_optimized skips compilation + 3. lomb_scargle_simple + batched_run_const_nfreq — validates the + weights fix and the PR #59 memory_requirement (cufftEstimate1d) path + 4. CE compute_log_prob smoke + guard checks + 5. PDM: new (t, y, err) API vs deprecated path; fast vs reference kernels + +Exits nonzero if any check fails. +""" +import sys +import time + +import numpy as np + +FAILURES = [] + + +def check(name, ok, detail=""): + status = "PASS" if ok else "FAIL" + print("[%s] %s%s" % (status, name, (" — " + detail) if detail else "")) + if not ok: + FAILURES.append(name) + + +def fake_transit(n=300, baseline=365.0, freq=1.0 / 2.5, q=0.05, depth=0.05, + sigma=0.01, seed=42): + rand = np.random.RandomState(seed) + t = np.sort(baseline * rand.rand(n)) + phase = (t * freq) % 1.0 + y = np.zeros_like(t) + y[phase < q] -= depth + y += sigma * rand.randn(n) + dy = sigma * np.ones_like(y) + return (t.astype(np.float32), y.astype(np.float32), + dy.astype(np.float32)) + + +def fake_sine(n=300, baseline=365.0, freq=1.0 / 5.0, sigma=0.1, seed=7): + rand = np.random.RandomState(seed) + t = np.sort(baseline * rand.rand(n)) + y = 12 + 0.1 * np.cos(2 * np.pi * freq * t) + sigma * rand.randn(n) + dy = sigma * np.ones_like(y) + return t, y, dy + + +def ce_numpy_reference(t, y, freqs, phase_bins=10, mag_bins=5): + """Plain-numpy Graham et al. (2013) conditional entropy (unweighted, + no bin overlap) for gating the default CE kernel.""" + yi = np.digitize(y, np.linspace(y.min(), y.max(), mag_bins + 1)[1:-1]) + out = np.zeros(len(freqs)) + n = len(t) + for k, f in enumerate(freqs): + phi = (t * f) % 1.0 + pi = np.minimum((phi * phase_bins).astype(int), phase_bins - 1) + hist, _, _ = np.histogram2d(pi, yi, bins=[phase_bins, mag_bins], + range=[[0, phase_bins], [0, mag_bins]]) + p = hist / n + p_phi = p.sum(axis=1, keepdims=True) + with np.errstate(divide='ignore', invalid='ignore'): + term = p * np.log(p_phi / p) + out[k] = np.nansum(np.where(p > 0, term, 0.0)) + return out + + +# Every optional dependency a zero-skip run of cuvarbase/tests needs: +# module name -> pip distribution name. +PREFLIGHT_MODULES = [ + ('pycuda', 'pycuda'), + ('batman', 'batman-package'), + ('transitleastsquares', 'transitleastsquares'), + ('nfft', 'nfft'), + ('astropy', 'astropy'), + ('cufinufft', 'cufinufft'), +] + + +def preflight(): + """Import every dependency the zero-skip suite needs and print its + version; a missing one fails the gate (it would silently turn into + pytest skips otherwise).""" + import importlib + ok = True + for module, dist in PREFLIGHT_MODULES: + try: + mod = importlib.import_module(module) + except Exception as e: + check("preflight: import %s" % module, False, + "%s: %s (pip install %s)" % (type(e).__name__, e, dist)) + ok = False + continue + version = getattr(mod, '__version__', None) + if version is None: + try: + from importlib.metadata import version as _v + version = _v(dist) + except Exception: + version = '?' + check("preflight: import %s" % module, True, + "version %s" % version) + return ok + + +def main(): + # --- 0. preflight ------------------------------------------------- + if not preflight(): + print() + print("RELEASE GATE: preflight FAILED -- install the missing " + "dependencies above; a zero-skip suite run is not possible " + "without them") + return 1 + + from cuvarbase.bls import eebls_gpu_fast, eebls_gpu_fast_optimized + + t, y, dy = fake_transit() + f_inj = 1.0 / 2.5 + freqs = np.linspace(0.05, 1.0, 5000).astype(np.float32) + + # --- 1. reduction_max equivalence --------------------------------- + t0 = time.time() + p_std = eebls_gpu_fast(t, y, dy, freqs) + t_std_first = time.time() - t0 + + t0 = time.time() + p_opt = eebls_gpu_fast_optimized(t, y, dy, freqs) + t_opt_first = time.time() - t0 + + corr = np.corrcoef(p_std, p_opt)[0, 1] + denom = max(np.max(np.abs(p_std)), 1e-30) + max_rel = np.max(np.abs(p_std - p_opt)) / denom + same_peak = np.argmax(p_std) == np.argmax(p_opt) + check("reduction_max equivalence (standard vs optimized kernel)", + corr > 0.9999 and same_peak, + "corr=%.6f max_rel_diff=%.2e argmax %s (std=%d opt=%d)" + % (corr, max_rel, "same" if same_peak else "DIFFERS", + np.argmax(p_std), np.argmax(p_opt))) + + f_best = freqs[np.argmax(p_std)] + check("BLS recovers injected transit", + abs(f_best - f_inj) < 0.01, + "best=%.4f injected=%.4f" % (f_best, f_inj)) + + # --- 2. kernel-cache timing --------------------------------------- + t0 = time.time() + eebls_gpu_fast(t, y, dy, freqs) + t_std_second = time.time() - t0 + + t0 = time.time() + eebls_gpu_fast_optimized(t, y, dy, freqs) + t_opt_second = time.time() - t0 + + check("kernel cache: eebls_gpu_fast 2nd call faster", + t_std_second < t_std_first / 2, + "first=%.0fms second=%.0fms" % (1e3 * t_std_first, + 1e3 * t_std_second)) + check("kernel cache: eebls_gpu_fast_optimized 2nd call faster", + t_opt_second < t_opt_first / 2, + "first=%.0fms second=%.0fms" % (1e3 * t_opt_first, + 1e3 * t_opt_second)) + + # --- 3. Lomb-Scargle ---------------------------------------------- + from cuvarbase.lombscargle import (lomb_scargle_simple, + LombScargleAsyncProcess) + + ts, ys, dys = fake_sine() + f_sine = 1.0 / 5.0 + ls_freqs, ls_power = lomb_scargle_simple(ts, ys, dys, + samples_per_peak=10) + f_ls = ls_freqs[np.argmax(ls_power)] + check("lomb_scargle_simple recovers injected frequency", + abs(f_ls - f_sine) / f_sine < 0.01, + "best=%.4f injected=%.4f" % (f_ls, f_sine)) + + # batched_run_const_nfreq exercises memory_requirement, which now + # calls cufft.cufft.cufftEstimate1d (PR #59) — would crash if that + # API path were wrong. + proc = LombScargleAsyncProcess() + batch = [fake_sine(seed=s) for s in (1, 2, 3)] + results = proc.batched_run_const_nfreq(batch, batch_size=3, + samples_per_peak=5) + proc.finish() + ok = (len(results) == 3 and + all(np.all(np.isfinite(p)) for _, p in results)) + check("batched_run_const_nfreq (memory_requirement/cufftEstimate1d)", + ok, "%d result sets, all finite" % len(results)) + + # --- 4. Conditional entropy ---------------------------------------- + # NOTE: CE recovery is checked on a strong sinusoid, not the transit. + # For the q=0.05 transit above, the CE global minimum legitimately + # lands on the 2*f harmonic (folding a transit at 2f superimposes the + # dip on itself), so transit argmin-recovery is not a valid CE gate. + # Kernel correctness is instead gated by correlation against a plain + # numpy conditional-entropy reference on identical (normalized) data. + from cuvarbase.ce import ConditionalEntropyAsyncProcess + + ce_freqs = np.linspace(0.05, 1.0, 2000) + rand = np.random.RandomState(13) + tc = np.sort(365.0 * rand.rand(300)) + yc = 12 + 0.5 * np.cos(2 * np.pi * 0.2 * tc) + 0.05 * rand.randn(300) + dyc = 0.05 * np.ones_like(yc) + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5) + r = proc.run([(tc, yc, dyc)], freqs=ce_freqs) + proc.finish() + fr, cper = r[0] + f_ce = fr[np.argmin(cper)] + check("CE recovers strong sinusoid frequency", + abs(f_ce - 0.2) < 0.01, + "best=%.4f injected=%.4f" % (f_ce, 0.2)) + + # GPU vs numpy reference on the transit data (run() mean-subtracts + # t and y first, so the reference uses the same normalization). + # The kernel's statistic is an offset/scaled variant of the textbook + # CE, so gate on shared global minimum plus rank correlation rather + # than numerical agreement. + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5) + r = proc.run([(t, y, dy)], freqs=ce_freqs) + proc.finish() + fr, cper = r[0] + ref = ce_numpy_reference(t - np.mean(t), y - np.mean(y), ce_freqs, + phase_bins=10, mag_bins=5) + ce_corr = np.corrcoef(ref, cper)[0, 1] + same_min = np.argmin(ref) == np.argmin(cper) + check("CE periodogram matches numpy reference", + ce_corr > 0.9 and same_min, + "corr=%.4f argmin %s (ref=%.4f gpu=%.4f)" + % (ce_corr, "same" if same_min else "DIFFERS", + ce_freqs[np.argmin(ref)], fr[np.argmin(cper)])) + + proc = ConditionalEntropyAsyncProcess(phase_bins=10, mag_bins=5, + compute_log_prob=True) + r = proc.run([(t, y, dy)], freqs=ce_freqs) + proc.finish() + fr, logp = r[0] + check("CE compute_log_prob=True returns finite periodogram", + bool(np.all(np.isfinite(logp))), + "min=%.3g max=%.3g" % (np.min(logp), np.max(logp))) + + try: + ConditionalEntropyAsyncProcess(weighted=True, use_fast=True) + check("CE rejects use_fast + weighted", False, "no exception") + except Exception as e: + check("CE rejects use_fast + weighted", True, type(e).__name__) + + # --- 5. PDM --------------------------------------------------------- + import warnings + from cuvarbase.pdm import PDMAsyncProcess + from cuvarbase.utils import weights as make_weights + + pdm_freqs = np.linspace(0.05, 1.0, 2000).astype(np.float32) + pdm_freqs += 0.5 * (pdm_freqs[1] - pdm_freqs[0]) + + proc = PDMAsyncProcess() + res_new = proc.run([(ts, ys, dys)], freqs=pdm_freqs, + kind='binned_linterp', nbins=20) + proc.finish() + frqs_new, p_new = res_new[0] + + proc = PDMAsyncProcess() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + res_dep = proc.run([(ts, ys, make_weights(dys), pdm_freqs)], + kind='binned_linterp', nbins=20) + proc.finish() + p_dep = res_dep[0] + + corr = np.corrcoef(p_new, p_dep)[0, 1] + check("PDM new (t,y,err) API matches deprecated path", + corr > 0.999, "corr=%.6f" % corr) + + proc = PDMAsyncProcess() + res_fast = proc.run([(ts, ys, dys)], freqs=pdm_freqs, + kind='binned_linterp_fast', nbins=20) + proc.finish() + _, p_fast = res_fast[0] + corr = np.corrcoef(p_new, p_fast)[0, 1] + check("PDM fast kernel matches reference kernel", + corr > 0.999, "corr=%.6f" % corr) + + f_pdm = frqs_new[np.argmax(p_new)] + check("PDM recovers injected frequency", + abs(f_pdm - f_sine) / f_sine < 0.01, + "best=%.4f injected=%.4f" % (f_pdm, f_sine)) + + try: + proc = PDMAsyncProcess() + proc.run([(ts, ys, dys)], freqs=pdm_freqs, block_size=512) + check("PDM rejects block_size > 256", False, "no exception") + except ValueError as e: + check("PDM rejects block_size > 256", True, "ValueError") + + # -------------------------------------------------------------------- + print() + if FAILURES: + print("RELEASE GATE: %d FAILURE(S): %s" + % (len(FAILURES), ", ".join(FAILURES))) + return 1 + print("RELEASE GATE: ALL CHECKS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/check_repository_artifacts.py b/tools/check_repository_artifacts.py new file mode 100644 index 00000000..5424e8f9 --- /dev/null +++ b/tools/check_repository_artifacts.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Keep generated evidence out of Git while retaining explicit review summaries.""" +import json +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] + + +def check(root=ROOT): + allowed = set() + for manifest in (root / 'benchmarks/archives').glob('*.json'): + record = json.loads(manifest.read_text()) + allowed.update(row[0] for row in record['files'] if row[3]) + files = subprocess.check_output(['git', 'ls-files', '-z'], cwd=root).decode().split('\0') + errors = [] + for name in filter(None, files): + path = root / name + if not path.is_file(): + continue + if path.stat().st_size > 1024 * 1024: + errors.append(name + ': tracked file exceeds 1 MiB; archive bulk evidence') + if name.startswith(('benchmarks/results/', 'docs/validation/')): + if (name not in allowed and path.suffix not in {'.md', '.rst'} + and path.name not in {'.gitignore', '.gitattributes'}): + errors.append(name + ': raw evidence belongs in the archive') + return errors + + +if __name__ == '__main__': + problems = check() + if problems: + raise SystemExit('\n'.join(problems)) + print('Tracked files contain only the selected benchmark reports and small artifacts.') diff --git a/tools/ci_wheel_smoke.py b/tools/ci_wheel_smoke.py new file mode 100644 index 00000000..2a2f5c23 --- /dev/null +++ b/tools/ci_wheel_smoke.py @@ -0,0 +1,113 @@ +"""CI packaging smoke test for the *installed* wheel (or sdist). + +Run from a clean environment where cuvarbase was installed from the built +artifact (pip install --no-deps dist/*.whl), so pycuda is genuinely absent. +Three things are checked: + +1. ``import cuvarbase`` requires neither pycuda nor a CUDA context (the + primary context is created lazily on first GPU use, not at import). +2. With pycuda stubbed, every submodule in ``cuvarbase._SUBMODULES`` and + the shipped ``cuvarbase.tests`` package import -- catching + missing-subpackage bugs that source-tree testing hides (e.g. the v1.0 + wheel that omitted cuvarbase.base/cuvarbase.memory entirely). +3. Every kernel stem the package hands to ``find_kernel('...')`` resolves + to a packaged ``kernels/.cu`` file, every ``//{INCLUDE x}`` + directive resolves, and the packaged inventory matches the loaders + (a shared .cuh left out of package-data, or an orphan kernel, fails + here). +""" +import glob +import importlib +import os +import re +import sys +import types + +# Make sure we import the installed package, not the source tree. +sys.path = [p for p in sys.path if os.path.abspath(p) != os.getcwd()] + +# --- Part 1: GPU-less, pycuda-less import --------------------------------- +# pycuda is not installed in this venv, so a successful import proves the +# package top-level does not import it (no eager CUDA context). +import cuvarbase # noqa: E402 +assert 'pycuda' not in sys.modules, \ + "import cuvarbase pulled in pycuda -- the CUDA context is no longer " \ + "supposed to be created at import time" +pkg_dir = os.path.dirname(os.path.abspath(cuvarbase.__file__)) +# The package must come from the venv's site-packages, not from the +# source checkout this script lives in (a plain substring test against +# the cwd misfires whenever the venv happens to sit below the cwd). +_repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +assert not pkg_dir.startswith(_repo_root + os.sep), \ + "cuvarbase imported from the source checkout %s, not the installed " \ + "package" % _repo_root +print('GPU-less import OK:', cuvarbase.__version__, 'from', pkg_dir) + +# --- Part 2: stubbed-pycuda deep import of every submodule ----------------- +for name in ['pycuda', 'pycuda.autoprimaryctx', 'pycuda.autoinit', + 'pycuda.driver', 'pycuda.gpuarray', 'pycuda.compiler', + 'pycuda.tools']: + sys.modules[name] = types.ModuleType(name) +sys.modules['pycuda.compiler'].SourceModule = object +sys.modules['pycuda.tools'].context_dependent_memoize = lambda f: f +sys.modules['pycuda.tools'].mark_cuda_test = lambda f: f + +submodules = sorted(cuvarbase._SUBMODULES) +assert submodules, "cuvarbase._SUBMODULES is empty" +for name in submodules: + importlib.import_module('cuvarbase.' + name) +print('submodules import OK (%d): %s' % (len(submodules), ', '.join(submodules))) + +import cuvarbase.tests # noqa: E402 +tests_dir = os.path.dirname(os.path.abspath(cuvarbase.tests.__file__)) +n_tests = len(glob.glob(os.path.join(tests_dir, 'test_*.py'))) +assert n_tests > 0, "cuvarbase.tests ships no test_*.py modules" +print('cuvarbase.tests OK (%d test modules)' % n_tests) + +from cuvarbase.base import GPUAsyncProcess, ensure_context # noqa: E402, F401 +from cuvarbase.memory import BLSBatchMemory # noqa: E402, F401 +import cuvarbase.utils # noqa: E402 + +# --- Part 3: packaged kernels match the loaders --------------------------- +find_kernel_literal = re.compile( + r"""find_kernel\(\s*['"]([A-Za-z0-9_]+)['"]\s*\)""") +sources = [p for p in glob.glob(os.path.join(pkg_dir, '*.py')) + + glob.glob(os.path.join(pkg_dir, '*', '*.py')) + if os.sep + 'tests' + os.sep not in p] +literal_stems = set() +source_text = {} +for path in sources: + with open(path, 'r') as f: + source_text[path] = f.read() + literal_stems.update(find_kernel_literal.findall(source_text[path])) +assert literal_stems, "no find_kernel('...') literals in the installed package" + +kernel_dir = os.path.join(pkg_dir, 'kernels') +packaged = sorted(os.path.splitext(os.path.basename(p))[0] + for p in glob.glob(os.path.join(kernel_dir, '*.cu'))) +assert packaged, "no kernels/*.cu packaged under %s" % kernel_dir + +missing = [s for s in sorted(literal_stems) + if not os.path.isfile(cuvarbase.utils.find_kernel(s))] +assert not missing, "kernel file(s) missing from the package: %r" % missing + +# Stems that bls.py/tls.py bind to a local before calling find_kernel are +# not find_kernel('...') literals; they must still be quoted somewhere. +orphans = [s for s in packaged + if not any(re.search(r"""['"]%s['"]""" % re.escape(s), txt) + for txt in source_text.values())] +assert not orphans, "packaged kernel(s) no loader references: %r" % orphans + +for path in glob.glob(os.path.join(kernel_dir, '*.cu')): + with open(path, 'r') as f: + for target in cuvarbase.utils._INCLUDE_RE.findall(f.read()): + assert os.path.isfile(os.path.join(kernel_dir, target)), \ + "%s includes %s, missing from the package" % ( + os.path.basename(path), target) +headers = sorted(os.path.basename(p) + for p in glob.glob(os.path.join(kernel_dir, '*.cuh'))) +assert 'bls_common.cuh' in headers, "bls_common.cuh missing from the package" + +print('kernels OK: %d .cu (%s), %d .cuh (%s)' % ( + len(packaged), ', '.join(packaged), len(headers), ', '.join(headers))) +print('wheel import OK:', cuvarbase.__version__) diff --git a/tools/test_benchmark_archive.py b/tools/test_benchmark_archive.py new file mode 100644 index 00000000..b8e79ece --- /dev/null +++ b/tools/test_benchmark_archive.py @@ -0,0 +1,142 @@ +"""Evidence restoration must preserve failures and refuse corrupt or unsafe input.""" +import hashlib +import io +import json +from pathlib import Path +import shutil +import subprocess +import tarfile + +import pytest + +from tools.benchmark_archive import fetch, load_manifest, restore, sha256 +from tools.check_repository_artifacts import check + + +def fixture(tmp_path, extra=None, wrong_member_hash=False): + archive = tmp_path / 'study.tar.gz' + original = {'benchmarks/results/study/failure.json': b'{"passed":false}\n', + 'benchmarks/results/study/README.md': b'original report\n'} + with tarfile.open(archive, 'w:gz') as stream: + for name, value in original.items(): + member = tarfile.TarInfo(name) + member.size = len(value) + stream.addfile(member, io.BytesIO(value)) + if extra: + member = tarfile.TarInfo(extra) + member.type = tarfile.SYMTYPE + member.linkname = '/tmp/outside' + stream.addfile(member) + files = [[name, len(value), hashlib.sha256(value).hexdigest(), name.endswith('.md')] + for name, value in original.items()] + if wrong_member_hash: + files[-1][2] = '0' * 64 + record = dict(schema=1, id='study', archive=dict(bytes=archive.stat().st_size, + sha256=sha256(archive)), files=files) + (tmp_path / 'study.json').write_text(json.dumps(record)) + record, inventory = load_manifest('study', tmp_path) + return archive, record, inventory + + +def test_restore_preserves_failure_and_updated_tracked_report(tmp_path): + archive, record, inventory = fixture(tmp_path) + dest = tmp_path / 'checkout' + report = dest / 'benchmarks/results/study/README.md' + report.parent.mkdir(parents=True) + report.write_text('Current report with archive links\n') + assert restore(archive, record, inventory, dest) == 1 + assert json.loads((report.parent / 'failure.json').read_text()) == {'passed': False} + assert report.read_text() == 'Current report with archive links\n' + assert restore(archive, record, inventory, dest) == 0 + complete = tmp_path / 'complete' + assert restore(archive, record, inventory, complete, full=True) == 2 + assert (complete / report.relative_to(dest)).read_text() == 'original report\n' + + +@pytest.mark.parametrize('damage', ['archive', 'member', 'symlink', 'traversal']) +def test_corrupt_or_unsafe_archive_installs_nothing(tmp_path, damage): + extra = {'symlink': 'benchmarks/results/study/link', 'traversal': '../outside'}.get(damage) + archive, record, inventory = fixture(tmp_path, extra, damage == 'member') + if damage == 'archive': + with archive.open('ab') as stream: + stream.write(b'corruption') + dest = tmp_path / 'checkout' + with pytest.raises(ValueError): + restore(archive, record, inventory, dest) + assert not (dest / 'benchmarks').exists() + + +def test_destination_conflict_is_not_overwritten(tmp_path): + archive, record, inventory = fixture(tmp_path) + dest = tmp_path / 'checkout' + conflict = dest / 'benchmarks/results/study/failure.json' + conflict.parent.mkdir(parents=True) + conflict.write_text('unrelated existing evidence') + with pytest.raises(ValueError, match='Existing file differs'): + restore(archive, record, inventory, dest) + assert conflict.read_text() == 'unrelated existing evidence' + + +def test_restore_refuses_a_symlink_destination(tmp_path): + archive, record, inventory = fixture(tmp_path) + dest = tmp_path / 'checkout' + dest.mkdir() + outside = tmp_path / 'outside' + outside.mkdir() + (dest / 'benchmarks').symlink_to(outside, target_is_directory=True) + with pytest.raises(ValueError, match='symlink'): + restore(archive, record, inventory, dest) + assert list(outside.iterdir()) == [] + + +def test_manifest_cannot_escape_destination(tmp_path): + _, record, _ = fixture(tmp_path) + record['files'][0][0] = 'benchmarks/results/../../../outside' + (tmp_path / 'study.json').write_text(json.dumps(record)) + with pytest.raises(ValueError, match='Unsafe member path'): + load_manifest('study', tmp_path) + + +def test_fetch_verifies_download_and_reuses_verified_cache(tmp_path, monkeypatch): + archive, record, _ = fixture(tmp_path) + record['archive']['key'] = 'evidence/study.tar.gz' + calls = [] + + def copy(command, **kwargs): + calls.append(command) + shutil.copyfile(archive, command[3]) + + monkeypatch.setattr(subprocess, 'run', copy) + cache = tmp_path / 'cache' + first = fetch(record, 'archive:bucket', cache) + assert sha256(first) == record['archive']['sha256'] + assert fetch(record, 'archive:bucket', cache) == first + assert len(calls) == 1 and calls[0][2] == 'archive:bucket/evidence/study.tar.gz' + + +def test_failed_download_is_not_installed_in_cache(tmp_path, monkeypatch): + _, record, _ = fixture(tmp_path) + record['archive']['key'] = 'evidence/study.tar.gz' + + def corrupt(command, **kwargs): + Path(command[3]).write_bytes(b'incomplete transfer') + + monkeypatch.setattr(subprocess, 'run', corrupt) + cache = tmp_path / 'cache' + with pytest.raises(ValueError, match='Archive checksum'): + fetch(record, 'archive:bucket', cache) + assert list(cache.iterdir()) == [] + + +def test_repository_guard_rejects_accidental_raw_evidence_and_large_files(tmp_path): + subprocess.run(['git', 'init', '-q', str(tmp_path)], check=True) + evidence = tmp_path / 'benchmarks/results/study' + evidence.mkdir(parents=True) + (evidence / 'README.md').write_text('Small report\n') + (evidence / 'raw.json').write_text('{"result":1}\n') + (tmp_path / 'large.bin').write_bytes(b'x' * (1024 * 1024 + 1)) + subprocess.run(['git', '-C', str(tmp_path), 'add', '.'], check=True) + problems = check(tmp_path) + assert len(problems) == 2 + assert any('raw.json' in problem for problem in problems) + assert any('large.bin' in problem for problem in problems) diff --git a/tools/test_watch_jobs.py b/tools/test_watch_jobs.py new file mode 100644 index 00000000..e8ba5ddb --- /dev/null +++ b/tools/test_watch_jobs.py @@ -0,0 +1,223 @@ +"""Operational failures must wake the owner without losing completed evidence.""" +import json +import os +import signal +import subprocess +import sys +import time +from types import SimpleNamespace + +import pytest + +from tools.watch_jobs import assess, deliver, read, repair_services, service_alive +from tools.watch_monitor import check +from tools import watch_jobs + + +def preserved_success(): + return dict(campaign=dict(status='complete', finished_epoch=50, + steps=[dict(label='release-gate', exit_code=0)]), + required_steps=['release-gate'], require_backup=True, + termination=dict(provider_absence_verified=True), + verification=dict(status='archive_and_all_members_verified'), + backup=dict(cloud_readback_verified=True)) + + +def test_archived_failed_release_never_becomes_success(): + snapshot = preserved_success() + snapshot.update(finalizer=dict(status='complete'), completion=dict( + cloud_readback_verified=True, release_validation=dict(status='failed', gate_exit_code=1, + suite_counts=dict(failures=0, errors=0, skipped=0, xfailed=1)))) + outcome, issues = assess(snapshot, 100) + assert outcome == 'collected_with_failures' + assert 'release_validation_failed' in dict(issues) + assert 'suite_incomplete' not in dict(issues) + + +@pytest.mark.parametrize('missing', ['backup', 'verification', 'termination', 'step']) +def test_success_requires_validation_preservation_shutdown_and_backup(missing): + snapshot = preserved_success() + if missing == 'step': + snapshot['campaign']['steps'] = [] + else: + snapshot.pop(missing) + assert assess(snapshot, 1000)[0] != 'ready_for_review' + assert assess(preserved_success(), 1000)[0] == 'ready_for_review' + + +def test_nonzero_substep_is_actionable_before_campaign_exits(): + snapshot = dict(campaign=dict(status='running', stages=[dict(name='gpu-validation', exit_code=1)])) + outcome, issues = assess(snapshot, 100) + assert outcome == 'needs_attention' + assert 'step_failed:gpu-validation' in dict(issues) + + +def test_crash_silent_stall_and_lost_connection_are_distinct(): + base = dict(campaign=dict(status='running'), controller_alive=True, + remote_activity_epoch=990, stall_seconds=100) + assert not assess(base, 1000)[1] + for changed, expected in [({'controller_alive': False}, 'controller_dead'), + ({'remote_activity_epoch': 500}, 'stalled'), + ({'connection_failures': 3}, 'connection_lost')]: + assert expected in dict(assess(dict(base, **changed), 1000)[1]) + + +def test_unavailable_panels_remain_actionable_after_collection(): + snapshot = preserved_success() + snapshot['completion'] = dict(available_panels=11, planned_panels=16) + outcome, issues = assess(snapshot, 100) + assert outcome == 'collected_with_failures' + assert 'panels_unavailable' in dict(issues) + + +def test_failed_delivery_is_retried_and_success_survives_monitor_restart(tmp_path): + event = dict(id='incident', job='release', path=str(tmp_path), message='A check failed.') + config = dict(codex='/codex', thread_id='same-thread', repository=str(tmp_path), + _path=str(tmp_path/'config.json')) + calls = [] + + def queue(*args, **kwargs): + calls.append(args[0]) + return SimpleNamespace(returncode=1 if len(calls) == 1 else 0, + stdout='' if len(calls) == 1 else 'Queued message accepted for thread same-thread.') + + deliver(event, config, tmp_path, 100, runner=queue) + assert not event.get('queued') + deliver(event, config, tmp_path, 110, runner=queue) + assert len(calls) == 1 + deliver(event, config, tmp_path, 161, runner=queue) + assert event['queued'] and event['attempts'] == 2 + restored = json.loads(json.dumps(event)) + deliver(restored, config, tmp_path, 10000, runner=queue) + assert len(calls) == 2 + assert calls[1][calls[1].index('--thread')+1] == 'same-thread' + + +def test_timed_out_queue_does_not_lose_notification(tmp_path): + event = dict(id='incident', job='release', path=str(tmp_path), message='A check failed.') + config = dict(codex='/codex', thread_id='same-thread', repository=str(tmp_path), + _path=str(tmp_path/'config.json')) + + def timeout(*args, **kwargs): + raise subprocess.TimeoutExpired('codex', 30) + + deliver(event, config, tmp_path, 100, runner=timeout) + assert not event.get('queued') + assert event['retry_after'] > 100 + + +def test_independent_watchdog_retries_one_incident_and_recovers(tmp_path): + config = dict(state_directory=str(tmp_path), codex='/codex', thread_id='existing-thread', + repository='/project', heartbeat_grace_seconds=180) + path = tmp_path/'config.json' + path.write_text(json.dumps(config)) + calls = [] + + def queue(*args, **kwargs): + calls.append(args[0]) + return SimpleNamespace(returncode=1 if len(calls) == 1 else 0, + stdout='' if len(calls) == 1 else 'Queued message accepted.') + + first = check(path, queue, now=1000) + assert not first['healthy'] and not first['incident'].get('queued') + recovered_delivery = check(path, queue, now=1061) + assert recovered_delivery['incident']['queued'] + check(path, queue, now=1200) + assert len(calls) == 2 + (tmp_path/'heartbeat.json').write_text(json.dumps(dict(last_success_epoch=1200))) + healthy = check(path, queue, now=1210) + assert healthy['healthy'] and 'incident' not in healthy + assert healthy['last_incident']['id'] == first['incident']['id'] + + +def test_new_failure_after_recovery_gets_a_new_event(tmp_path, monkeypatch): + job = tmp_path/'job' + job.mkdir() + state = tmp_path/'state' + state.mkdir() + config = dict(jobs=[dict(id='a', path=str(job), require_backup=False)]) + monkeypatch.setattr(watch_jobs, 'notification', lambda message: True) + monkeypatch.setattr(watch_jobs, 'deliver', lambda *args: None) + failed = dict(status='running', steps=[dict(label='step', exit_code=1)]) + path = job/'live-campaign-state.json' + path.write_text(json.dumps(failed)) + watch_jobs.tick(config, state) + watch_jobs.tick(config, state) + assert len(read(state/'state.json')['events']) == 1 + path.write_text(json.dumps(dict(status='running'))) + watch_jobs.tick(config, state) + path.write_text(json.dumps(failed)) + watch_jobs.tick(config, state) + assert len(read(state/'state.json')['events']) == 2 + + +def test_review_cannot_silence_an_unverified_backup(tmp_path, monkeypatch): + job = tmp_path/'job' + job.mkdir() + (job/'termination.json').write_text(json.dumps(dict(provider_absence_verified=True))) + (job/'collection-verification.json').write_text(json.dumps( + dict(status='archive_and_all_members_verified'))) + config = tmp_path/'config.json' + config.write_text(json.dumps(dict(state_directory=str(tmp_path/'state'), jobs=[ + dict(id='a', path=str(job), require_backup=True)]))) + monkeypatch.setattr(sys, 'argv', ['watch_jobs.py', '--config', str(config), + 'review', '--job', 'a', '--outcome', 'done']) + with pytest.raises(ValueError, match='cloud preservation'): + watch_jobs.main() + + +def test_guard_and_collector_restart_without_resetting_rental(tmp_path, monkeypatch): + # Process identity must survive a narrow ps display without restarting + # a healthy collector alongside the deliberately stopped guard. + monkeypatch.setenv('COLUMNS', '40') + ops = tmp_path/'ops' + ops.mkdir() + script = '''from pathlib import Path +import json, os, sys, time +root = Path(__file__).resolve().parents[1] +role = 'guard' if len(sys.argv) > 1 else 'monitor' +(root/(role+'-ready.json')).write_text(json.dumps(dict(pid=os.getpid()))) +time.sleep(30) +''' + for name in ['rental.py', 'monitor.py']: + (ops/name).write_text(script) + rental = dict(deadline_epoch=12345, cap_usd=1.5, id='existing-owned-rental') + (tmp_path/'rental.json').write_text(json.dumps(rental)) + memory = {} + pids = set() + + def wait_for_guard(previous=None): + deadline = time.monotonic()+3 + while time.monotonic() < deadline: + guard = read(tmp_path/'guard-ready.json', {}).get('pid') + monitor = read(tmp_path/'monitor-ready.json', {}).get('pid') + if guard and monitor and guard != previous: + pids.update([guard, monitor]) + return guard + time.sleep(.02) + pytest.fail('Recovery processes did not start') + + try: + notices = repair_services(tmp_path, memory, 100) + assert len(notices) == 2 + pid = wait_for_guard() + assert service_alive(pid, ops/'rental.py') + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic()+3 + while service_alive(pid, ops/'rental.py') and time.monotonic() < deadline: + time.sleep(.02) + notices = repair_services(tmp_path, memory, 110) + replacement = wait_for_guard(pid) + assert replacement != pid + assert [name for name, _ in notices] == ['service_restarted:guard'] + assert read(tmp_path/'rental.json') == rental + finally: + for role in ['guard', 'monitor']: + receipt = read(tmp_path/(role+'-supervisor-restart.json'), {}) + if receipt.get('pid'): + pids.add(receipt['pid']) + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass diff --git a/tools/watch_jobs.py b/tools/watch_jobs.py new file mode 100644 index 00000000..b44d049f --- /dev/null +++ b/tools/watch_jobs.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""Watch registered cuvarbase jobs and queue actionable follow-ups in their chat. + +Run once per minute with launchd. State and delivery receipts live outside the +repository. Polling uses no model calls; only new incidents or completed work +queue a Codex turn. No benchmark retries or new rentals are performed here. +""" +import argparse +import datetime +import fcntl +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys +import time + +RUNNING = {'running', 'waiting_for_diagnostics', 'waiting_for_verified_collection'} +FAILED = {'failed', 'error', 'interrupted', 'complete_with_failures'} + + +def write(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix('.tmp') + with temporary.open('w') as stream: + json.dump(value, stream, indent=2) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + + +def read(path, default=None): + return json.loads(path.read_text()) if path.exists() else default + + +def assess(snapshot, now): + """Keep scientific/validation success separate from collection success.""" + campaign = snapshot.get('campaign') or {} + issues = [] + if campaign.get('status') in FAILED: + issues.append(('campaign_failed', 'The job finished with failures.')) + for row in campaign.get('stages', []) + campaign.get('steps', []): + name = row.get('name', row.get('label', 'unnamed')) + if row.get('status') in FAILED or row.get('exit_code') not in (None, 0): + issues.append(('step_failed:'+name, 'A required step failed: '+name)) + completion = snapshot.get('completion') or {} + validation = completion.get('release_validation') or {} + if validation.get('status') in FAILED or validation.get('gate_exit_code') not in (None, 0): + issues.append(('release_validation_failed', 'The release validation did not pass.')) + counts = validation.get('suite_counts') or {} + if any(counts.get(k, 0) for k in ('failures', 'errors', 'skipped')): + issues.append(('suite_incomplete', 'GPU tests failed, errored, or skipped required coverage.')) + if completion.get('available_panels', 0) < completion.get('planned_panels', 0): + issues.append(('panels_unavailable', 'Some planned timing panels did not qualify; retain their failures.')) + finalizer = snapshot.get('finalizer') or {} + if finalizer.get('status') == 'failed': + issues.append(('finalization_failed', 'Reporting or cloud preservation failed.')) + if snapshot.get('connection_failures', 0) >= 3: + issues.append(('connection_lost', 'Three successive remote checks failed.')) + if campaign.get('status') in RUNNING: + if snapshot.get('controller_alive') is False: + issues.append(('controller_dead', 'The controller exited while its receipt still says running.')) + heartbeat = snapshot.get('remote_activity_epoch') + if heartbeat and now-heartbeat > snapshot.get('stall_seconds', 900): + issues.append(('stalled', 'No remote log or checkpoint activity within the declared interval.')) + for row in campaign.get('steps', []) + campaign.get('stages', []): + name = row.get('label', row.get('name', 'unnamed')) + limit = snapshot.get('step_limits', {}).get(name) + if limit and 'exit_code' not in row and now-row.get('started_epoch', now) > limit: + issues.append(('step_overdue:'+name, 'A running step exceeded its time allowance: '+name)) + terminated = (snapshot.get('termination') or {}).get('provider_absence_verified') is True + verified = (snapshot.get('verification') or {}).get('status') == 'archive_and_all_members_verified' + if terminated and not verified: + issues.append(('evidence_missing', 'The rental ended before verified local collection.')) + if campaign and campaign.get('status') not in RUNNING: + finished = campaign.get('finished_epoch', now) + if not (terminated and verified) and now-finished > 300: + issues.append(('collection_overdue', 'A terminal job still needs verified collection and shutdown.')) + if campaign.get('status') == 'complete': + steps = {r.get('label', r.get('name')): r for r in campaign.get('steps', []) + campaign.get('stages', [])} + if any(steps.get(name, {}).get('exit_code') != 0 for name in snapshot.get('required_steps', [])): + issues.append(('required_steps_missing', 'The completion receipt omits required passing steps.')) + backed_up = completion.get('cloud_readback_verified') is True or ( + snapshot.get('backup') or {}).get('cloud_readback_verified') is True + if terminated and verified: + if issues: + outcome = 'collected_with_failures' + elif snapshot.get('require_backup') and not backed_up: + outcome = 'awaiting_backup' + elif campaign.get('status') == 'complete': + outcome = 'ready_for_review' + else: + outcome = 'outcome_unknown' + issues.append(('results_ready', 'Results are preserved and the rental is off; review and finish remaining work.')) + else: + outcome = 'needs_attention' if issues else 'running' + return outcome, issues + + +def module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +def service_alive(pid, script): + if not isinstance(pid, int) or pid <= 0: + return False + # Linux ps can truncate a captured command to the terminal width, hiding + # the script path and making a live service appear absent. Two -w flags + # request the full command on both Linux and macOS. + value = subprocess.run(['/bin/ps', '-ww', '-p', str(pid), '-o', 'command='], + capture_output=True, text=True, timeout=5) + return value.returncode == 0 and str(script) in shlex.split(value.stdout.strip()) + + +def repair_services(root, memory, now): + """Restart only the pre-existing bounded guard/collector, at most 3/hour.""" + if (root/'termination.json').exists() or not (root/'rental.json').exists(): + return [] + notices = [] + for role, script, args in [('guard', root/'ops/rental.py', ['guard']), + ('monitor', root/'ops/monitor.py', [])]: + ready = read(root/(role+'-ready.json'), {}) + if not script.exists() or service_alive(ready.get('pid'), script): + continue + recent = [v for v in memory.setdefault('restarts', {}).get(role, []) if now-v < 3600] + if len(recent) >= 3: + notices.append(('service_unhealthy:'+role, 'The '+role+' needs review after repeated exits.')) + continue + with (root/(role+'.log')).open('a') as log: + process = subprocess.Popen([sys.executable, str(script), *args], stdin=subprocess.DEVNULL, + stdout=log, stderr=log, start_new_session=True) + recent.append(now) + memory['restarts'][role] = recent + # The service writes its own ready receipt; retain the recovery separately. + write(root/(role+'-supervisor-restart.json'), dict(pid=process.pid, epoch=now)) + if role == 'guard' and Path('/usr/bin/caffeinate').exists(): + wake = subprocess.Popen(['/usr/bin/caffeinate', '-i', '-w', str(process.pid)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + write(root/'supervisor-wake.json', dict(pid=wake.pid, waits_for=process.pid)) + notices.append(('service_restarted:'+role, 'Restarted the existing '+role+' without resetting its deadline.')) + return notices + + +def probe(job, root): + observer = module(root/'ops/monitor.py', 'job_observer') + script = """from pathlib import Path +import json, time +root = Path(REMOTE_ROOT) +state = json.loads((root/'campaign-state.json').read_text()) +alive = False +for path in Path('/proc').glob('[0-9]*/cmdline'): + try: + args = path.read_bytes().split(b'\\0') + alive |= CONTROLLER.encode() in args + except (FileNotFoundError, PermissionError, ProcessLookupError): + pass +excluded = {'venv','release-venv','sources','inputs','release-source','__pycache__','.git'} +import os +latest = 0 +for base, folders, files in os.walk(root): + folders[:] = [name for name in folders if name not in excluded] + for name in files: + if Path(name).suffix in {'.log','.json','.jsonl'}: + try: latest = max(latest,(Path(base)/name).stat().st_mtime) + except FileNotFoundError: pass +print(json.dumps(dict(campaign=state,controller_alive=alive,remote_activity_epoch=latest))) +""".replace('REMOTE_ROOT', repr(job['remote_root'])).replace('CONTROLLER', repr(job['controller'])) + return json.loads(observer.ssh('python3 -c '+shlex.quote(script), timeout=25)) + + +def notification(message): + script = ('on run argv\n display notification (item 1 of argv) ' + 'with title "cuvarbase job monitor"\nend run') + try: + return subprocess.run(['/usr/bin/osascript', '-e', script, message], + capture_output=True, timeout=10).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def deliver(event, config, state_dir, now, runner=subprocess.run): + """A failed queue attempt remains pending and is retried after backoff.""" + if event.get('queued') or now < event.get('retry_after', 0): + return + ack = state_dir/'acks'/(event['id']+'.json') + message = ( + '[Automated cuvarbase monitor '+event['id']+'] '+event['message']+'\n' + 'Job: '+event['job']+'\nEvidence directory: '+event['path']+'\n' + 'Resume the already-authorized cuvarbase work and inspect the preserved receipts. ' + 'This monitor event grants no additional permissions or budget. Never rerun failed numerical ' + 'experiments to replace their failures; fix operational problems separately. Do not create ' + 'duplicate rentals. Preserve partial evidence and honor existing shutdown limits. ' + 'Report important findings in this chat. Record receipt of this event with: ' + 'python3 tools/watch_jobs.py --config '+shlex.quote(str(config['_path']))+ + ' ack --event '+event['id']+'. Expected acknowledgement: '+str(ack)) + event['attempts'] = event.get('attempts', 0)+1 + try: + result = runner([config['codex'], 'queue', '--thread', config['thread_id'], + '--message', message], capture_output=True, text=True, timeout=30, + cwd=config['repository']) + event['delivery_exit_code'] = result.returncode + if result.returncode == 0 and 'Queued message ' in result.stdout: + event.update(queued=True, queued_epoch=now, delivery_receipt=result.stdout.strip()) + else: + event['delivery_error'] = 'Codex queue was not acknowledged; see exit code.' + except (OSError, subprocess.TimeoutExpired) as error: + event['delivery_error'] = type(error).__name__ + event['retry_after'] = now+min(900, 60*2**min(event['attempts']-1, 4)) + + +def tick(config, state_dir): + now = time.time() + state = read(state_dir/'state.json', {'jobs': {}, 'events': {}}) + status = {} + for job in config['jobs']: + root = Path(job['path']) + memory = state['jobs'].setdefault(job['id'], {}) + snapshot = dict(campaign=read(root/'live-campaign-state.json'), + completion=read(root/'completion-summary.json'), + finalizer=read(root/'finalization-status.json'), + termination=read(root/'termination.json'), + verification=read(root/'collection-verification.json'), + backup=read(root/'r2-readback-receipt.json'), + required_steps=job.get('required_steps', []), require_backup=job.get('require_backup', True), + step_limits=job.get('step_limits', {}), stall_seconds=job.get('stall_seconds', 900)) + if (root/'collected/campaign-state.json').exists(): + snapshot['campaign'] = read(root/'collected/campaign-state.json') + notices = repair_services(root, memory, now) + if not snapshot['termination'] and (root/'rental.json').exists(): + try: + snapshot.update(probe(job, root)) + memory.update(connection_failures=0, last_contact_epoch=now) + write(state_dir/'checkpoints'/(job['id']+'.json'), snapshot['campaign']) + except Exception as error: + memory['connection_failures'] = memory.get('connection_failures', 0)+1 + memory['last_probe_error_type'] = type(error).__name__ + snapshot['connection_failures'] = memory.get('connection_failures', 0) + outcome, issues = assess(snapshot, now) + issues.extend(notices) + reviewed = read(state_dir/'reviews'/(job['id']+'.json')) + if reviewed and snapshot['termination'] and snapshot['verification']: + issues = [] + outcome = 'reviewed: '+reviewed['outcome'] + if issues: + code = '|'.join(sorted({code for code, _ in issues})) + message = ' '.join(message for _, message in issues) + if memory.get('active_issue_codes') != code: + memory['incident_sequence'] = memory.get('incident_sequence', 0)+1 + memory['current_event_id'] = None + memory['active_issue_codes'] = code + key = job['id']+':'+code+':'+str(memory['incident_sequence']) + identifier = memory.get('current_event_id') or hashlib.sha256(key.encode()).hexdigest()[:20] + memory['current_event_id'] = identifier + if identifier not in state['events']: + state['events'][identifier] = dict(id=identifier, code=code, job=job['id'], + path=str(root), message=message, detected_epoch=now, queued=False) + write(state_dir/'state.json', state) + state['events'][identifier]['desktop_notification_requested'] = notification(message) + else: + memory['active_issue_codes'] = None + status[job['id']] = dict(outcome=outcome, issues=[code for code, _ in issues], + last_contact_epoch=memory.get('last_contact_epoch'), evidence=str(root)) + for event in state['events'].values(): + if (state_dir/'acks'/(event['id']+'.json')).exists(): + event['acknowledged'] = True + deliver(event, config, state_dir, now) + if (event.get('queued') and not event.get('acknowledged') and + now-event['queued_epoch'] > 600 and not event.get('unacknowledged_alert')): + event['unacknowledged_alert'] = notification( + 'A cuvarbase follow-up is queued but not acknowledged. Check whether Codex is running or rate-limited.') + write(state_dir/'state.json', state) + state['last_poll_epoch'] = now + state['jobs_status'] = status + write(state_dir/'state.json', state) + pending = [event['id'] for event in state['events'].values() if not event.get('queued')] + write(state_dir/'status.json', dict(checked_utc=datetime.datetime.now(datetime.timezone.utc).isoformat(), + jobs=status, pending_deliveries=pending, + queued_events=sum(e.get('queued', False) for e in state['events'].values()))) + + +def daemon(config, state_dir): + """Run from Codex's project-access context; launchd watches its heartbeat.""" + with (state_dir/'daemon.lock').open('a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + heartbeat = dict(pid=os.getpid(), started_epoch=time.time(), last_success_epoch=None) + while True: + heartbeat['last_attempt_epoch'] = time.time() + write(state_dir/'heartbeat.json', heartbeat) + try: + result = subprocess.run([sys.executable, str(Path(__file__).resolve()), + '--config', config['_path'], 'poll'], timeout=110) + heartbeat['last_exit_code'] = result.returncode + if result.returncode == 0: + heartbeat['last_success_epoch'] = time.time() + except subprocess.TimeoutExpired: + heartbeat['last_exit_code'] = 'poll_timeout' + write(state_dir/'heartbeat.json', heartbeat) + time.sleep(60) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', type=Path, required=True) + commands = parser.add_subparsers(dest='command', required=True) + commands.add_parser('poll') + commands.add_parser('daemon') + commands.add_parser('status') + ack = commands.add_parser('ack') + ack.add_argument('--event', required=True) + review = commands.add_parser('review') + review.add_argument('--job', required=True) + review.add_argument('--outcome', required=True) + args = parser.parse_args() + config = read(args.config) + config['_path'] = str(args.config.resolve()) + state_dir = Path(config['state_directory']) + state_dir.mkdir(parents=True, exist_ok=True) + if args.command == 'daemon': + daemon(config, state_dir) + return + with (state_dir/'poll.lock').open('a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + if args.command == 'poll': + try: + tick(config, state_dir) + except Exception as error: + fault = read(state_dir/'monitor-error.json', dict(id='monitor-internal-error', + job='monitor', path=str(state_dir), queued=False, + message='The job monitor itself needs attention. Inspect its launchd error log.')) + fault['error_type'] = type(error).__name__ + if not fault.get('queued'): + notification(fault['message']) + deliver(fault, config, state_dir, time.time()) + write(state_dir/'monitor-error.json', fault) + raise + elif args.command == 'status': + print(json.dumps(read(state_dir/'status.json', {}), indent=2)) + elif args.command == 'ack': + state = read(state_dir/'state.json', {}) + fault = read(state_dir/'monitor-error.json', {}) + if args.event not in state.get('events', {}) and args.event != fault.get('id'): + raise ValueError('Unknown monitor event') + write(state_dir/'acks'/(args.event+'.json'), dict(acknowledged_epoch=time.time())) + else: + job = next(j for j in config['jobs'] if j['id'] == args.job) + root = Path(job['path']) + if not (read(root/'termination.json', {}).get('provider_absence_verified') and + read(root/'collection-verification.json', {}).get('status') == 'archive_and_all_members_verified'): + raise ValueError('Preserve the job and verify shutdown before closing its review') + backup = read(root/'r2-readback-receipt.json', {}) + completion = read(root/'completion-summary.json', {}) + if job.get('require_backup', True) and not ( + backup.get('cloud_readback_verified') or completion.get('cloud_readback_verified')): + raise ValueError('Verify cloud preservation before closing its review') + write(state_dir/'reviews'/(args.job+'.json'), dict(outcome=args.outcome, reviewed_epoch=time.time())) + + +if __name__ == '__main__': + main() diff --git a/tools/watch_monitor.py b/tools/watch_monitor.py new file mode 100644 index 00000000..d5500827 --- /dev/null +++ b/tools/watch_monitor.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Independent launchd heartbeat watchdog, installed outside Documents. + +It reads only its own Application Support state and queues recovery into the +existing Codex conversation. It never attempts to bypass macOS project access. +""" +import argparse +import hashlib +import json +from pathlib import Path +import shlex +import subprocess +import time + + +def read(path, default): + try: + return json.loads(path.read_text()) + except (FileNotFoundError, ValueError): + return default + + +def write(path, value): + temporary = path.with_suffix('.tmp') + temporary.write_text(json.dumps(value, indent=2)+'\n') + temporary.replace(path) + + +def check(config_path, runner=subprocess.run, now=None): + now = time.time() if now is None else now + config = read(config_path, {}) + root = Path(config['state_directory']) + heartbeat = read(root/'heartbeat.json', {}) + state = read(root/'watchdog.json', {}) + successful = heartbeat.get('last_success_epoch') or 0 + healthy = now-successful < config.get('heartbeat_grace_seconds', 180) + state.update(checked_epoch=now, healthy=healthy, last_success_epoch=successful) + if healthy: + if state.get('incident'): + state['last_incident'] = dict(state.pop('incident'), recovered_epoch=now) + else: + incident = state.setdefault('incident', dict( + id=hashlib.sha256(str(now).encode()).hexdigest()[:20], detected_epoch=now)) + if not incident.get('queued') and now >= incident.get('retry_after', 0): + message = ( + '[Automated cuvarbase monitor recovery '+incident['id']+'] ' + 'The persistent job observer has no recent successful heartbeat. ' + 'Inspect '+str(root/'watchdog.json')+' and '+str(root/'observer.log')+'. ' + 'This is the user-requested monitoring watchdog, not a new authorization. ' + 'If the observer is absent or stuck, restore it from this Codex context using ' + 'python3 '+shlex.quote(config['repository']+'/tools/watch_jobs.py')+ + ' --config '+shlex.quote(str(config_path))+ + ' daemon, detached with output in observer.log. Check the daemon lock and PID ' + 'before replacing a live process. Then inspect job status and continue authorized ' + 'work within existing budgets. Do not create duplicate rentals or repeat failed ' + 'scientific experiments. Record acknowledgement in '+str(root/'watchdog-ack.json')+'.') + incident['attempts'] = incident.get('attempts', 0)+1 + try: + result = runner([config['codex'], 'queue', '--thread', config['thread_id'], + '--message', message], cwd=root, capture_output=True, + text=True, timeout=30) + incident['exit_code'] = result.returncode + if result.returncode == 0 and 'Queued message ' in result.stdout: + incident.update(queued=True, queued_epoch=now, receipt=result.stdout.strip()) + except (OSError, subprocess.TimeoutExpired) as error: + incident['error_type'] = type(error).__name__ + incident['retry_after'] = now+min(900, 60*2**min(incident['attempts']-1, 4)) + write(root/'watchdog.json', state) + return state + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--config', type=Path, required=True) + check(parser.parse_args().config)