Skip to content

Commit d2f0ddd

Browse files
authored
Merge pull request #6 from tjayasinghe/fix/release-gate-findings
Fix v1.0 release-gate review findings
2 parents 4ea3ff0 + 39799e7 commit d2f0ddd

23 files changed

Lines changed: 527 additions & 37 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ First public release.
3333
- Full Sphinx documentation (hosted on Read the Docs) and a reproducible validation +
3434
benchmark suite under `benchmarks/`.
3535

36+
### Robustness
37+
38+
- Peak selection returns the true peak even when it sits at a frequency-grid edge (a
39+
signal whose period is comparable to the observing baseline), and never reports a
40+
non-finite period or NaN-power sample.
41+
- Settings reject transposed frequency / period / duration-fraction bounds at
42+
construction; the CLI's `--out` JSON is always standard JSON (no `NaN`/`Infinity`).
43+
- GPU kernels opt into larger shared memory where the device allows, and otherwise raise
44+
a clear error naming the setting to reduce — instead of a raw CUDA driver error.
45+
- Batch sinks are correct across re-runs: a file sink is keyed by `(key, method)`, a
46+
directory sink refuses a mismatched `chunk_size` on resume, and a CSV sink asked to
47+
store raw spectra fails loudly rather than dropping the columns.
48+
3649
### Validated
3750

3851
- Every method matches an established reference (astropy `LombScargle` / `BoxLeastSquares`,

src/cuperiod/batch/io.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,15 @@ def _write_parquet(rows: Sequence[Mapping[str, Any]], path: Path) -> None:
205205
def _write_csv(rows: Sequence[Mapping[str, Any]], path: Path) -> None:
206206
import csv
207207

208-
columns = [
209-
col
210-
for col in _union_columns(rows)
211-
if not any(isinstance(row.get(col), list) for row in rows)
208+
columns = _union_columns(rows)
209+
list_cols = [
210+
col for col in columns if any(isinstance(row.get(col), list) for row in rows)
212211
]
212+
if list_cols:
213+
raise ValueError(
214+
f"CSV sink cannot store array-valued columns {list_cols} (e.g. from "
215+
"store_raw); use a .parquet sink."
216+
)
213217
tmp = path.with_suffix(path.suffix + ".tmp")
214218
with tmp.open("w", newline="", encoding="utf-8") as fh:
215219
writer = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")

src/cuperiod/batch/runner.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from __future__ import annotations
1717

18+
import json
1819
import warnings
1920
from collections.abc import Mapping, Sequence
2021
from concurrent.futures import ProcessPoolExecutor, as_completed
@@ -165,6 +166,29 @@ def _part_path(sink_dir: Path, idx: int) -> Path:
165166
return sink_dir / f"part-{idx:05d}.parquet"
166167

167168

169+
def _dir_manifest_guard(sink_dir: Path, chunk_size: int, resume: bool) -> None:
170+
"""Pin a directory sink's chunk_size so a resume cannot realign part indices.
171+
172+
Part files are named purely by chunk index, so resuming with a different chunk_size
173+
would silently drop or duplicate light curves. Refuse the mismatch and record the
174+
size for the next run.
175+
"""
176+
manifest = sink_dir / "_manifest.json"
177+
if resume and manifest.exists():
178+
try:
179+
prev = int(json.loads(manifest.read_text(encoding="utf-8"))["chunk_size"])
180+
except Exception: # noqa: BLE001 - a corrupt manifest must not abort the run
181+
prev = chunk_size
182+
if prev != chunk_size:
183+
raise ValueError(
184+
f"directory sink {sink_dir} was written with chunk_size={prev}; "
185+
f"resuming requires the same chunk_size (got {chunk_size}). Use the "
186+
"same chunk_size, a fresh directory, or resume=False."
187+
)
188+
sink_dir.mkdir(parents=True, exist_ok=True)
189+
manifest.write_text(json.dumps({"chunk_size": chunk_size}), encoding="utf-8")
190+
191+
168192
def batch_periodograms(
169193
inputs: Any,
170194
method: str | Sequence[str] = "GLS",
@@ -232,6 +256,8 @@ def batch_periodograms(
232256

233257
method_names = (method,) if isinstance(method, str) else tuple(method)
234258
methods = tuple(get_method(m).name for m in method_names)
259+
if not methods:
260+
raise ValueError("no methods specified")
235261
settings_map = {name: _settings_for(name, settings) for name in methods}
236262
cfg = _ChunkConfig(
237263
methods=methods,
@@ -243,11 +269,19 @@ def batch_periodograms(
243269
store_raw=store_raw,
244270
)
245271

272+
sink_kind, sink_dir, sink_file = _classify_sink(sink)
273+
if store_raw and sink_kind == "file" and sink_file.suffix.lower() == ".csv":
274+
raise ValueError(
275+
"store_raw=True produces array-valued spectrum columns that a CSV sink "
276+
"cannot hold; use a .parquet sink or a directory sink."
277+
)
278+
246279
items = resolve_inputs(
247280
inputs, columns=columns, domain=domain, band_column=band_column
248281
)
249282
chunks = _chunked(items, max(1, chunk_size))
250-
sink_kind, sink_dir, sink_file = _classify_sink(sink)
283+
if sink_kind == "dir":
284+
_dir_manifest_guard(sink_dir, max(1, chunk_size), resume)
251285

252286
pending = _pending_chunks(chunks, sink_kind, sink_dir, resume)
253287
n_skipped = len(items) - sum(len(chunks[i]) for i in pending)
@@ -322,8 +356,14 @@ def _classify_sink(sink: str | Path | None) -> tuple[str, Path, Path]:
322356
if sink is None:
323357
return "memory", Path(), Path()
324358
path = Path(sink)
325-
if path.suffix.lower() in {".parquet", ".pq", ".csv"}:
359+
suffix = path.suffix.lower()
360+
if suffix in {".parquet", ".pq", ".csv"}:
326361
return "file", path.parent, path
362+
if suffix and not path.is_dir():
363+
raise ValueError(
364+
f"unsupported sink {str(sink)!r}: a file sink must end in .parquet or "
365+
".csv; pass a directory (no extension) for a resumable multi-part sink."
366+
)
327367
return "dir", path, path
328368

329369

@@ -376,8 +416,12 @@ def _run_pool(
376416
def _finalize_file(rows: list[dict[str, Any]], path: Path, resume: bool) -> None:
377417
if resume and path.exists():
378418
existing = _read_existing_rows(path)
379-
seen = {r.get("key") for r in existing}
380-
merged = existing + [r for r in rows if r.get("key") not in seen]
419+
# Dedup on (key, method): a later run adding a different method to the same file
420+
# must not be discarded as an already-seen key.
421+
seen = {(r.get("key"), r.get("method")) for r in existing}
422+
merged = existing + [
423+
r for r in rows if (r.get("key"), r.get("method")) not in seen
424+
]
381425
write_rows(merged, path)
382426
else:
383427
write_rows(rows, path)

src/cuperiod/cli/app.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
from __future__ import annotations
1414

1515
import json
16+
import math
1617
from pathlib import Path
18+
from typing import Any
1719

1820
import numpy as np
1921
import typer
@@ -47,7 +49,28 @@ def _parse_methods(method: str) -> list[str]:
4749

4850

4951
def _domain(value: str | None) -> Domain | None:
50-
return None if value is None else Domain(value.lower())
52+
if value is None:
53+
return None
54+
try:
55+
return Domain(value.lower())
56+
except ValueError as exc:
57+
raise typer.BadParameter("domain must be 'magnitude' or 'flux'") from exc
58+
59+
60+
def _json_safe(obj: Any) -> Any:
61+
"""Recursively replace non-finite floats with ``None`` for standard JSON output.
62+
63+
``json.dumps`` defaults to emitting bare ``Infinity``/``NaN`` tokens that most
64+
non-Python JSON parsers reject. Peaks are finite by construction, but this sanitizes
65+
the serialization boundary so a written ``--out`` file is always valid JSON.
66+
"""
67+
if isinstance(obj, float):
68+
return obj if math.isfinite(obj) else None
69+
if isinstance(obj, dict):
70+
return {k: _json_safe(v) for k, v in obj.items()}
71+
if isinstance(obj, (list, tuple)):
72+
return [_json_safe(v) for v in obj]
73+
return obj
5174

5275

5376
@app.command()
@@ -81,7 +104,10 @@ def run(
81104
if isinstance(result, (MultiResult, Periodogram))
82105
else {}
83106
)
84-
out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
107+
out.write_text(
108+
json.dumps(_json_safe(payload), indent=2, allow_nan=False),
109+
encoding="utf-8",
110+
)
85111
typer.echo(f"\nWrote {out}")
86112
if save_periodogram is not None:
87113
arrays: dict[str, np.ndarray] = {}

src/cuperiod/core/backend.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@
1414
import pathlib
1515
import sys
1616
from types import ModuleType
17+
from typing import Any
18+
19+
from cuperiod.core.errors import BackendUnavailableError
1720

1821
_CUDA_DLL_READY = False
1922

23+
#: The default per-block dynamic shared-memory cap (bytes); larger needs an opt-in.
24+
_DEFAULT_SHARED_MEM = 48 * 1024
25+
2026

2127
def ensure_cuda_dll_path() -> None:
2228
"""Put the ``nvidia-*-cu12`` wheel binary dirs on the Windows DLL search path.
@@ -85,6 +91,50 @@ def available_backends() -> set[str]:
8591
return out
8692

8793

94+
def ensure_shared_memory(
95+
kernel: Any, dynamic_bytes: int, *, method: str, hint: str
96+
) -> None:
97+
"""Permit a cupy ``RawKernel`` to use ``dynamic_bytes`` of dynamic shared memory.
98+
99+
The per-block default cap is 48 KB; a larger request needs an explicit opt-in and is
100+
still bounded by the device's ``MaxSharedMemoryPerBlockOptin``. When the request
101+
exceeds what the device can provide, raise a clear :class:`BackendUnavailableError`
102+
(pointing at the setting to reduce) instead of letting the kernel launch fail with a
103+
raw ``CUDADriverError``.
104+
105+
Parameters
106+
----------
107+
kernel : cupy.RawKernel
108+
The kernel about to be launched.
109+
dynamic_bytes : int
110+
The ``shared_mem=`` size the launch will request.
111+
method : str
112+
Method name, for the error message.
113+
hint : str
114+
The setting(s) the user should reduce, for the error message.
115+
"""
116+
import cupy
117+
118+
optin = int(
119+
cupy.cuda.Device().attributes.get(
120+
"MaxSharedMemoryPerBlockOptin", _DEFAULT_SHARED_MEM
121+
)
122+
)
123+
try:
124+
static = int(kernel.attributes.get("shared_size_bytes", 0))
125+
except Exception:
126+
static = 0
127+
needed = int(dynamic_bytes) + static
128+
if needed > optin:
129+
raise BackendUnavailableError(
130+
f"{method}: needs ~{needed // 1024} KB of GPU shared memory per block but "
131+
f"the device allows at most {optin // 1024} KB; reduce {hint}, or use "
132+
"backend='cpu'."
133+
)
134+
if int(dynamic_bytes) > _DEFAULT_SHARED_MEM:
135+
kernel.max_dynamic_shared_size_bytes = int(dynamic_bytes)
136+
137+
88138
def array_module(a: object) -> ModuleType:
89139
"""Return cupy for a device array, else numpy (for device-agnostic assembly)."""
90140
try:
@@ -103,5 +153,6 @@ def array_module(a: object) -> ModuleType:
103153
"available_backends",
104154
"cuda_available",
105155
"ensure_cuda_dll_path",
156+
"ensure_shared_memory",
106157
"has_module",
107158
]

src/cuperiod/core/config.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,34 @@
1111

1212
from __future__ import annotations
1313

14-
from typing import Literal
14+
from typing import Literal, Self
1515

16-
from pydantic import Field
16+
from pydantic import Field, model_validator
1717
from pydantic_settings import BaseSettings, SettingsConfigDict
1818

1919
#: Backend selector accepted by every method (concrete names are method-specific).
2020
BackendName = str
2121

2222

23+
def _require_lt(lo: float | None, hi: float | None, lo_name: str, hi_name: str) -> None:
24+
"""Raise ``ValueError`` if both bounds are set and ``lo`` is not below ``hi``."""
25+
if lo is not None and hi is not None and lo >= hi:
26+
raise ValueError(f"{lo_name} ({lo}) must be < {hi_name} ({hi})")
27+
28+
2329
class GLSSettings(BaseSettings):
2430
"""Settings for the generalized Lomb-Scargle (GLS) periodogram."""
2531

2632
model_config = SettingsConfigDict(env_prefix="CUPERIOD_GLS_", extra="forbid")
2733

34+
@model_validator(mode="after")
35+
def _check_bounds(self) -> Self:
36+
_require_lt(
37+
self.minimum_frequency, self.maximum_frequency,
38+
"minimum_frequency", "maximum_frequency",
39+
)
40+
return self
41+
2842
minimum_frequency: float | None = Field(
2943
default=None,
3044
description="Lowest trial frequency (cycles/day); None -> 1/baseline.",
@@ -68,6 +82,18 @@ class BLSSettings(BaseSettings):
6882

6983
model_config = SettingsConfigDict(env_prefix="CUPERIOD_BLS_", extra="forbid")
7084

85+
@model_validator(mode="after")
86+
def _check_bounds(self) -> Self:
87+
_require_lt(
88+
self.min_period_days, self.max_period_days,
89+
"min_period_days", "max_period_days",
90+
)
91+
_require_lt(
92+
self.duration_min_frac, self.duration_max_frac,
93+
"duration_min_frac", "duration_max_frac",
94+
)
95+
return self
96+
7197
min_period_days: float = Field(default=0.2, gt=0.0, description="Minimum period.")
7298
max_period_days: float = Field(default=100.0, gt=0.0, description="Maximum period.")
7399
min_transits: int = Field(
@@ -129,6 +155,14 @@ class PDMSettings(BaseSettings):
129155

130156
model_config = SettingsConfigDict(env_prefix="CUPERIOD_PDM_", extra="forbid")
131157

158+
@model_validator(mode="after")
159+
def _check_bounds(self) -> Self:
160+
_require_lt(
161+
self.minimum_frequency, self.maximum_frequency,
162+
"minimum_frequency", "maximum_frequency",
163+
)
164+
return self
165+
132166
minimum_frequency: float | None = Field(
133167
default=None,
134168
description="Lowest trial frequency (cycles/day); None -> 1/baseline.",
@@ -170,6 +204,14 @@ class MHAOVSettings(BaseSettings):
170204

171205
model_config = SettingsConfigDict(env_prefix="CUPERIOD_MHAOV_", extra="forbid")
172206

207+
@model_validator(mode="after")
208+
def _check_bounds(self) -> Self:
209+
_require_lt(
210+
self.minimum_frequency, self.maximum_frequency,
211+
"minimum_frequency", "maximum_frequency",
212+
)
213+
return self
214+
173215
minimum_frequency: float | None = Field(
174216
default=None,
175217
description="Lowest trial frequency (cycles/day); None -> 1/baseline.",
@@ -210,6 +252,14 @@ class CESettings(BaseSettings):
210252

211253
model_config = SettingsConfigDict(env_prefix="CUPERIOD_CE_", extra="forbid")
212254

255+
@model_validator(mode="after")
256+
def _check_bounds(self) -> Self:
257+
_require_lt(
258+
self.minimum_frequency, self.maximum_frequency,
259+
"minimum_frequency", "maximum_frequency",
260+
)
261+
return self
262+
213263
minimum_frequency: float | None = Field(
214264
default=None,
215265
description="Lowest trial frequency (cycles/day); None -> 1/baseline.",
@@ -249,6 +299,14 @@ class StringLengthSettings(BaseSettings):
249299

250300
model_config = SettingsConfigDict(env_prefix="CUPERIOD_SL_", extra="forbid")
251301

302+
@model_validator(mode="after")
303+
def _check_bounds(self) -> Self:
304+
_require_lt(
305+
self.minimum_frequency, self.maximum_frequency,
306+
"minimum_frequency", "maximum_frequency",
307+
)
308+
return self
309+
252310
minimum_frequency: float | None = Field(
253311
default=None,
254312
description="Lowest trial frequency (cycles/day); None -> 1/baseline.",
@@ -286,6 +344,18 @@ class TLSSettings(BaseSettings):
286344

287345
model_config = SettingsConfigDict(env_prefix="CUPERIOD_TLS_", extra="forbid")
288346

347+
@model_validator(mode="after")
348+
def _check_bounds(self) -> Self:
349+
_require_lt(
350+
self.min_period_days, self.max_period_days,
351+
"min_period_days", "max_period_days",
352+
)
353+
_require_lt(
354+
self.duration_min_frac, self.duration_max_frac,
355+
"duration_min_frac", "duration_max_frac",
356+
)
357+
return self
358+
289359
min_period_days: float = Field(default=0.5, gt=0.0, description="Minimum period.")
290360
max_period_days: float = Field(default=100.0, gt=0.0, description="Maximum period.")
291361
min_transits: int = Field(

0 commit comments

Comments
 (0)