Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions analysis/horizon_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ def main():
help="OOS rows date (YYYY-MM-DD). Default: latest.",
)
parser.add_argument(
"--bucket", default=cfg.RESEARCH_BUCKET,
help=f"S3 bucket. Default: {cfg.RESEARCH_BUCKET}",
"--bucket", default=cfg.S3_BUCKET,
help=f"S3 bucket. Default: {cfg.S3_BUCKET}",
)
parser.add_argument(
"--bootstrap-iter", type=int, default=1000,
Expand Down
6 changes: 3 additions & 3 deletions analysis/triple_barrier_cutover_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ def run_gate(
``DATE_CONVENTIONS.md``.

Args:
bucket: S3 bucket. Defaults to ``cfg.RESEARCH_BUCKET``.
bucket: S3 bucket. Defaults to ``cfg.S3_BUCKET``.
n_days: trailing prediction-history window. Default 42.
horizon_days: forward-realized window. Default 21 (matches
``cfg.FORWARD_DAYS``).
Expand All @@ -248,7 +248,7 @@ def run_gate(
``window_days``, ``horizon_days``, ``n_pairs_loaded``,
``n_realized_filled``, ``s3_key`` (when ``write_to_s3=True``).
"""
bucket = bucket or cfg.RESEARCH_BUCKET
bucket = bucket or cfg.S3_BUCKET
dual = now_dual()
if run_id is None:
run_id = new_eval_run_id()
Expand Down Expand Up @@ -344,7 +344,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--bucket", default=None,
help=f"S3 bucket. Default: cfg.RESEARCH_BUCKET ({cfg.RESEARCH_BUCKET}).",
help=f"S3 bucket. Default: cfg.S3_BUCKET ({cfg.S3_BUCKET}).",
)
parser.add_argument(
"--window", type=int, default=DEFAULT_WINDOW_DAYS,
Expand Down
4 changes: 2 additions & 2 deletions analysis/variant_cutover_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,8 +500,8 @@ def main():
"""CLI entry point for offline gate evaluation."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--bucket", default=cfg.RESEARCH_BUCKET,
help=f"S3 bucket. Default: {cfg.RESEARCH_BUCKET}",
"--bucket", default=cfg.S3_BUCKET,
help=f"S3 bucket. Default: {cfg.S3_BUCKET}",
)
parser.add_argument(
"--baseline", required=True,
Expand Down
106 changes: 106 additions & 0 deletions tests/test_analysis_clis_invokable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Regression test pinning that every ``analysis/*`` CLI invokes ``--help``
without an ``AttributeError`` on a stale ``cfg.<MISSING>`` reference at
argparse default-setup time.

Caught 2026-05-28 (operator audit while scoping L2878 / the regime-
conditioning rebuild): three diagnostic CLIs that gate Stages 1d / 2d /
3 cutover decisions — ``variant_cutover_gate``, ``horizon_battery``,
``triple_barrier_cutover_runner`` — were ALL referencing
``cfg.RESEARCH_BUCKET``, an attribute that has never been defined in
``config.py`` (canonical name is ``cfg.S3_BUCKET``). The stale symbol
was introduced 2026-05-10 by PR #117 (variant gate substrate) and
copied into the two sibling CLIs since. The CLIs died at import time
during the argparse default lookup before any user-facing message.

Programmatic API was unaffected — the bug only surfaced when an
operator actually ran the CLI for the first time. Latent for 18 days.

This test closes the bug CLASS at PR time: every analysis-CLI module
with a ``main()`` entry point is invoked via ``python -m`` with
``--help`` and the test asserts exit code 0 + non-empty stdout.
``argparse``'s ``--help`` exits with code 0; a missing-attr error
surfaces as a non-zero exit code + stderr trace.

Composes with the lib-version-bump-check chokepoint pattern (mirror
test ``test_version_bump_workflow.py`` in alpha-engine-lib #82) —
when a class of mistake recurs in more than one file, lift the
chokepoint to a test that fails at PR time. See
[[feedback_lift_invariants_to_chokepoint_after_second_recurrence]].
"""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parent.parent

# Every analysis CLI with a ``main()`` entry. New CLIs added under
# ``analysis/`` MUST be appended here (or excluded via a comment if
# explicitly internal-only). The walker below would auto-discover them
# but the explicit list also serves as documentation.
ANALYSIS_CLIS = [
"analysis.variant_cutover_gate",
"analysis.horizon_battery",
"analysis.triple_barrier_cutover_runner",
"analysis.compare_modes",
]


@pytest.mark.parametrize("module", ANALYSIS_CLIS)
def test_analysis_cli_help_works(module: str) -> None:
"""Pin ``python -m <module> --help`` exit 0 + non-empty usage line.

Catches stale ``cfg.<MISSING_ATTR>`` references in argparse
defaults — the failure mode of the original 2026-05-28 incident.
"""
result = subprocess.run(
[sys.executable, "-m", module, "--help"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"`python -m {module} --help` exited {result.returncode}.\n"
f"stderr:\n{result.stderr}\n"
f"stdout:\n{result.stdout}"
)
assert result.stdout.strip(), (
f"`python -m {module} --help` produced empty stdout — argparse "
f"should always print a usage line."
)
assert "usage:" in result.stdout.lower(), (
f"`python -m {module} --help` stdout missing 'usage:' line "
f"(argparse default formatter).\nstdout: {result.stdout[:500]}"
)


def test_analysis_cli_list_covers_every_module_with_main() -> None:
"""Walk ``analysis/*.py`` looking for any module that defines a
top-level ``main()`` function; assert it appears in
``ANALYSIS_CLIS`` above.

Catches the case where someone adds a new analysis CLI but forgets
to register it in the parametrize list above, leaving the new CLI
un-smoke-tested.
"""
analysis_dir = REPO_ROOT / "analysis"
discovered: list[str] = []
for py_path in sorted(analysis_dir.glob("*.py")):
if py_path.name.startswith("_"):
continue
source = py_path.read_text()
if "\ndef main(" not in source and not source.startswith("def main("):
continue
mod_name = f"analysis.{py_path.stem}"
discovered.append(mod_name)

missing = sorted(set(discovered) - set(ANALYSIS_CLIS))
assert not missing, (
"Newly-discovered analysis CLIs missing from ANALYSIS_CLIS: "
f"{missing}. Append them to the list above (or comment why "
"they're excluded from the help-smoke gate)."
)
Loading