diff --git a/.github/workflows/hqiv-arena.yml b/.github/workflows/hqiv-arena.yml index 87057f6..539bc29 100644 --- a/.github/workflows/hqiv-arena.yml +++ b/.github/workflows/hqiv-arena.yml @@ -140,6 +140,9 @@ jobs: path: _lean_artifacts continue-on-error: true + - name: Protected mirror source integrity (AST gate) + run: python3 scripts/check_arena_source_integrity.py --verbose + - name: Run Lean ↔ Python Alignment Gate id: align run: | @@ -247,7 +250,7 @@ jobs: # On branches we compare against it; on main we will update it after merge. run: | curl -fsSL -o arena/baseline.json \ - "https://raw.githubusercontent.com/disregardfiat/pyhqiv/main/arena/baseline.json" || \ + "https://raw.githubusercontent.com/HQIV/pyhqiv/main/arena/baseline.json" || \ echo '{"overall_score": 1000, "metrics": []}' > arena/baseline.json mkdir -p arena @@ -296,7 +299,7 @@ jobs: Full results artifact: `arena-score-${{ github.run_id }}` → `arena_results.json` *Sigma everywhere*: broad error reduction across observables is rewarded. Single-metric gaming is discouraged. - See [CONTRIBUTING.md](https://github.com/disregardfiat/pyhqiv/blob/main/CONTRIBUTING.md#hqiv-arena) for details. + See [CONTRIBUTING.md](https://github.com/HQIV/pyhqiv/blob/main/CONTRIBUTING.md#hqiv-arena) for details. """ pr = os.environ.get("PR_NUMBER") or "${{ github.event.number }}" if pr and pr != "null": diff --git a/CHANGELOG.md b/CHANGELOG.md index a057d09..f8351e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,4 +28,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - (None in this release.) -[0.4.0]: https://github.com/disregardfiat/pyhqiv/releases/tag/v0.4.0 +[0.4.0]: https://github.com/HQIV/pyhqiv/releases/tag/v0.4.0 diff --git a/CITATION.cff b/CITATION.cff index c11100d..1e20751 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,10 +7,10 @@ authors: orcid: "" date-released: 2026-02-27 doi: 10.5281/zenodo.18794889 -url: "https://github.com/disregardfiat/pyhqiv" +url: "https://github.com/HQIV/pyhqiv" version: "0.3.0" license: MIT -repository-code: "https://github.com/disregardfiat/pyhqiv" +repository-code: "https://github.com/HQIV/pyhqiv" preferred-citation: type: article title: "Horizon-Quantized Informational Vacuum (HQIV): A Unified Framework from Causal Horizon Monogamy and Discrete Null-Lattice Combinatorics" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca9ac2a..e30f34e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,8 +26,8 @@ All development happens on branches: - See the workflow in hqiv-lean and the lean job in pyhqiv's arena workflow. 2. **Lean ↔ Python Alignment Gate** (hard) - - `python scripts/validate_hqiv_alignment.py` - - Must pass 100%. Uses Lean-exported `witnesses.json` + functional mirror checks in pyhqiv (lightcone, metric, so8 generators, etc.). + - `python scripts/check_arena_source_integrity.py` — AST gate on `lightcone.py` / `metric.py` (no import creep, no literal-return cheats in Ω_k mirrors). + - `python scripts/validate_hqiv_alignment.py` — must pass 100%. Uses Lean-exported `witnesses.json` + functional mirror checks in pyhqiv (lightcone, metric, so8 generators, etc.). - If you changed a Lean definition that affects a numerical value, update the export and the py mirror (or the alignment will fail). 3. **Python Test Gate** (hard) diff --git a/README.md b/README.md index 0b866ff..eac711b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # pyhqiv — Horizon-Quantized Informational Vacuum (HQIV) Calculator [![PyPI version](https://badge.fury.io/py/pyhqiv.svg)](https://badge.fury.io/py/pyhqiv) -[![CI](https://github.com/disregardfiat/pyhqiv/actions/workflows/ci.yml/badge.svg)](https://github.com/disregardfiat/pyhqiv/actions/workflows/ci.yml) +[![CI](https://github.com/HQIV/pyhqiv/actions/workflows/ci.yml/badge.svg)](https://github.com/HQIV/pyhqiv/actions/workflows/ci.yml) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18794889.svg)](https://doi.org/10.5281/zenodo.18794889) -> **⚠️ Experimental status.** All features in this package are experimental. APIs and numerical results may change. Public contribution and feedback are greatly appreciated — please open issues or pull requests on [GitHub](https://github.com/disregardfiat/pyhqiv). +> **⚠️ Experimental status.** All features in this package are experimental. APIs and numerical results may change. Public contribution and feedback are greatly appreciated — please open issues or pull requests on [GitHub](https://github.com/HQIV/pyhqiv). **pyhqiv** is the clean, first-principles Python calculator for the HQIV framework (discrete null-lattice combinatorics + horizon monogamy + octonionic carriers). It exactly mirrors the Lean formalization in [HQIV/hqiv-lean](https://github.com/HQIV/hqiv-lean) and the paper series in `HQIV_LEAN/papers/`. @@ -41,7 +41,7 @@ pip install pyhqiv From source (editable, for development/arena): ```bash -git clone https://github.com/disregardfiat/pyhqiv.git && cd pyhqiv +git clone https://github.com/HQIV/pyhqiv.git && cd pyhqiv pip install -e ".[dev]" ``` diff --git a/pyproject.toml b/pyproject.toml index 1acc2ad..13ab8a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,9 @@ dev = ["pytest>=7", "pytest-cov>=4", "matplotlib>=3.5"] all = ["pyhqiv[ase,mda,qutip,jax,pyvista]"] [project.urls] -Documentation = "https://github.com/disregardfiat/pyhqiv" -Repository = "https://github.com/disregardfiat/pyhqiv" -"Bug Tracker" = "https://github.com/disregardfiat/pyhqiv/issues" +Documentation = "https://github.com/HQIV/pyhqiv" +Repository = "https://github.com/HQIV/pyhqiv" +"Bug Tracker" = "https://github.com/HQIV/pyhqiv/issues" [project.scripts] hqiv-arena = "pyhqiv.arena_cli:main" diff --git a/scripts/check_arena_source_integrity.py b/scripts/check_arena_source_integrity.py new file mode 100644 index 0000000..82e5c77 --- /dev/null +++ b/scripts/check_arena_source_integrity.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Static integrity gate for HQIV Arena Lean-mirror Python modules. + +Complements scripts/validate_hqiv_alignment.py by rejecting import creep and +literal-return shortcuts in lightcone.py / metric.py before numeric alignment runs. +""" + +from __future__ import annotations + +import argparse +import ast +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +REPO_ROOT = Path(__file__).resolve().parents[1] + +ALLOWED_RETURN_FLOATS = frozenset({0.0, 1.0}) +ALLOWED_MODULE_BINDINGS = frozenset({"ALPHA_EXACT", "__all__"}) + +IMPORT_ALLOWLIST: dict[str, frozenset[str | None]] = { + "src/pyhqiv/lightcone.py": frozenset({None, "fractions", "math", "__future__"}), + "src/pyhqiv/metric.py": frozenset({None, "dataclasses", "math", "__future__", "pyhqiv"}), +} + +REQUIRED_CALLEES: dict[str, dict[str, frozenset[str]]] = { + "src/pyhqiv/lightcone.py": { + "omega_k_at_horizon": frozenset({"curvature_integral", "x_over_theta_from_horizons"}), + "omega_k_partial": frozenset({"omega_k_at_horizon", "reference_m"}), + "reference_m": frozenset({"qcd_shell", "lattice_step_count"}), + "curvature_norm_combinatorial": frozenset( + {"cube_directions", "octonion_imaginary_dim", "unit_cube_half_diagonal"} + ), + }, + "src/pyhqiv/metric.py": { + "gamma_hqiv": frozenset({"alpha"}), + }, +} + +PROTECTED_PATHS = tuple(IMPORT_ALLOWLIST.keys()) + + +@dataclass +class Violation: + file: str + rule: str + detail: str + + +def _import_roots(tree: ast.Module) -> set[str | None]: + roots: set[str | None] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + roots.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom): + if node.module is None: + roots.add(None) + else: + roots.add(node.module.split(".")[0]) + return roots + + +def _calls_in_function(func: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + names: set[str] = set() + for node in ast.walk(func): + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name): + names.add(node.func.id) + elif isinstance(node.func, ast.Attribute): + names.add(node.func.attr) + return names + + +def _bad_literal_returns(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[float]: + bad: list[float] = [] + for node in ast.walk(func): + if not isinstance(node, ast.Return) or node.value is None: + continue + val = node.value + if isinstance(val, ast.Constant) and isinstance(val.value, (int, float)): + f = float(val.value) + if f not in ALLOWED_RETURN_FLOATS: + bad.append(f) + return bad + + +def _module_float_assignments(tree: ast.Module) -> list[str]: + bad: list[str] = [] + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + name = target.id + if name in ALLOWED_MODULE_BINDINGS: + continue + if isinstance(node.value, ast.Constant) and isinstance( + node.value.value, float + ): + bad.append(name) + return bad + + +def _function_defs(tree: ast.Module) -> dict[str, ast.FunctionDef]: + return {n.name: n for n in tree.body if isinstance(n, ast.FunctionDef)} + + +def check_file(rel_path: str) -> list[Violation]: + path = REPO_ROOT / rel_path + if not path.exists(): + return [Violation(rel_path, "missing_file", str(path))] + + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + violations: list[Violation] = [] + + allowed_imports = IMPORT_ALLOWLIST[rel_path] + extra = _import_roots(tree) - allowed_imports + if extra: + violations.append( + Violation( + rel_path, + "imports", + f"disallowed import roots: {sorted(extra)}", + ) + ) + + for name in _module_float_assignments(tree): + violations.append(Violation(rel_path, "module_float", f"module-level float: {name}")) + + funcs = _function_defs(tree) + for func_name, must_call in REQUIRED_CALLEES.get(rel_path, {}).items(): + func = funcs.get(func_name) + if func is None: + violations.append( + Violation(rel_path, "missing_function", f"expected {func_name}") + ) + continue + missing = must_call - _calls_in_function(func) + if missing: + violations.append( + Violation( + rel_path, + "structural_calls", + f"{func_name} must call {sorted(missing)}", + ) + ) + bad_returns = _bad_literal_returns(func) + if bad_returns: + violations.append( + Violation( + rel_path, + "literal_return", + f"{func_name} forbidden literal return(s): {bad_returns}", + ) + ) + if func_name == "hqvm_lapse": + for node in ast.walk(func): + if isinstance(node, ast.Return) and isinstance(node.value, ast.Constant): + if isinstance(node.value.value, (int, float)): + violations.append( + Violation( + rel_path, + "literal_return", + f"{func_name} must be 1 + Phi + phi*t, not a constant", + ) + ) + + return violations + + +def run_checks(paths: Iterable[str] | None = None) -> list[Violation]: + all_v: list[Violation] = [] + for rel in list(paths) if paths else list(PROTECTED_PATHS): + all_v.extend(check_file(rel)) + return all_v + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="HQIV Arena protected-source integrity gate") + p.add_argument("--verbose", "-v", action="store_true") + p.add_argument("paths", nargs="*", help="Optional module paths under repo root") + args = p.parse_args(argv) + + violations = run_checks(args.paths or None) + if args.verbose or violations: + print("=== HQIV Arena source integrity ===") + for v in violations: + print(f"[FAIL] {v.file} ({v.rule}): {v.detail}") + if not violations: + print("All protected modules passed.") + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/pyhqiv/arena/SKILL.md b/src/pyhqiv/arena/SKILL.md index e0bb3a9..7189b5b 100644 --- a/src/pyhqiv/arena/SKILL.md +++ b/src/pyhqiv/arena/SKILL.md @@ -1,6 +1,6 @@ --- name: hqiv-arena -description: "Use when helping a solver or coding agent use the hqiv-arena CLI for the HQIV physics improvement benchmark (HQIV/hqiv-lean + pyhqiv): login, config, benchmark, clone, setup, run, submit, note, submissions, sync, reset, version, update, and install-skill. Explains the GitHub-native workflow, repo context (Lean + Python alignment), API/PAT keys, dirty-worktree safety, and how local scoring feeds the public leaderboard at disregardfiat.tech/#arena." +description: "Use when helping a solver or coding agent use the hqiv-arena CLI for the HQIV physics improvement benchmark (HQIV/hqiv-lean + HQIV/pyhqiv): login, config, benchmark, clone, setup, run, submit, note, submissions, sync, reset, version, update, and install-skill. Explains dual auth (hqiv_ Arena API key + GitHub PAT/gh for PRs), repo context, dirty-worktree safety, and how local scoring feeds the public leaderboard at disregardfiat.tech/#arena." --- # HQIV Arena CLI Usage @@ -9,32 +9,27 @@ Use this skill to operate the `hqiv-arena` solver CLI from a terminal for improv The CLI is configured for the HQIV "fixed benchmark" (improving formal + numerical physics results across hqiv-lean and pyhqiv while keeping Lean ↔ Python alignment and increasing scores under the "sigma everywhere" rules). -## Setup & GitHub Login / API Key (PAT) +## Setup & authentication (two credentials) -The HQIV Arena is GitHub-native. You authenticate with a GitHub Personal Access Token (PAT) that has `repo` scope (for pushing branches and opening PRs). The token is stored locally. +1. **Arena API key (`hqiv_…`)** — from [disregardfiat.tech/#arena](https://disregardfiat.tech/#arena) (Sign in with GitHub). Used for provisional leaderboard entries via the public Arena API. No GitHub PAT required for this step. ```bash -hqiv-arena login +hqiv-arena login hqiv_YourKeyFromTheSite +export HQIV_ARENA_API_URL=https://disregardfiat.tech/api/v1 # optional override ``` -This prints instructions and a link: - -- Visit https://github.com/settings/tokens/new -- Give it a name like "HQIV Arena" -- Select the `repo` scope (full control of private repos is not needed; public repo is sufficient for HQIV) -- Generate the token (classic PAT) -- Paste it: `hqiv-arena login ghp_YourTokenHere` - -You can also pass it directly: +2. **GitHub PAT (`ghp_…`)** — for `hqiv-arena submit` to push branches and open PRs on `HQIV/pyhqiv` (authoritative CI scoring). Or use `gh auth login` instead of storing a PAT. ```bash hqiv-arena login ghp_YourTokenHere ``` -Environment overrides (useful for agents): +Run `login` twice to store both keys in `~/.config/hqiv-arena/config.json`. + +Environment overrides (agents): -- `HQIV_ARENA_TOKEN`: the GitHub PAT -- `HQIV_ARENA_API_URL`: (future) if a custom arena API is used +- `HQIV_ARENA_TOKEN`: `hqiv_…` **or** GitHub PAT (prefix selects behavior) +- `HQIV_ARENA_API_URL`: Arena API base (default `https://disregardfiat.tech/api/v1`) Check config: diff --git a/src/pyhqiv/arena_cli.py b/src/pyhqiv/arena_cli.py index 6749142..cfc5ef4 100644 --- a/src/pyhqiv/arena_cli.py +++ b/src/pyhqiv/arena_cli.py @@ -73,7 +73,8 @@ def _auto_insert_src() -> None: # and use the client_id here. For now we guide users to PATs (simpler + no app approval). GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c7e8e4a2c" # placeholder; real one would be set -DEFAULT_API_BASE = "https://api.github.com" +DEFAULT_GITHUB_API = "https://api.github.com" +DEFAULT_ARENA_API = "https://disregardfiat.tech/api/v1" CONFIG_ENV_TOKEN = "HQIV_ARENA_TOKEN" CONFIG_ENV_API = "HQIV_ARENA_API_URL" @@ -83,22 +84,48 @@ def _auto_insert_src() -> None: # The two repos that make up the HQIV benchmark workspace HQIV_LEAN_REPO = "https://github.com/HQIV/hqiv-lean.git" -PYHQIV_REPO = "https://github.com/disregardfiat/pyhqiv.git" +PYHQIV_REPO = "https://github.com/HQIV/pyhqiv.git" +PYHQIV_GITHUB_WEB = "https://github.com/HQIV/pyhqiv" @dataclass class Config: - token: Optional[str] = None - api_base_url: str = DEFAULT_API_BASE + arena_api_key: Optional[str] = None + github_token: Optional[str] = None + arena_api_url: str = DEFAULT_ARENA_API + github_api_base: str = DEFAULT_GITHUB_API def to_dict(self) -> Dict[str, Any]: - return {"token": self.token, "apiBaseUrl": self.api_base_url} + return { + "arenaApiKey": self.arena_api_key, + "githubToken": self.github_token, + "arenaApiUrl": self.arena_api_url, + "githubApiBase": self.github_api_base, + } @classmethod def from_dict(cls, d: Dict[str, Any]) -> "Config": + legacy = d.get("token") + arena_key = d.get("arenaApiKey") + gh_token = d.get("githubToken") + if legacy and not arena_key and not gh_token: + if str(legacy).startswith("hqiv_"): + arena_key = legacy + else: + gh_token = legacy + arena_url = d.get("arenaApiUrl", DEFAULT_ARENA_API) + gh_api = d.get("githubApiBase", DEFAULT_GITHUB_API) + legacy_api = d.get("apiBaseUrl") + if legacy_api: + if "disregardfiat.tech" in str(legacy_api) or str(legacy_api).rstrip("/").endswith("/api/v1"): + arena_url = legacy_api + else: + gh_api = legacy_api return cls( - token=d.get("token"), - api_base_url=d.get("apiBaseUrl", DEFAULT_API_BASE), + arena_api_key=arena_key, + github_token=gh_token, + arena_api_url=arena_url, + github_api_base=gh_api, ) @@ -127,12 +154,66 @@ def write_config(cfg: Config) -> None: p.chmod(0o600) -def get_effective_token(cfg: Config) -> Optional[str]: - return os.environ.get(CONFIG_ENV_TOKEN) or cfg.token +def _env_token() -> Optional[str]: + return os.environ.get(CONFIG_ENV_TOKEN) + + +def is_arena_api_key(token: str) -> bool: + return token.startswith("hqiv_") + + +def is_github_pat(token: str) -> bool: + return token.startswith(("ghp_", "github_pat_", "gho_", "ghu_", "ghs_", "ghr_")) + + +def get_arena_api_key(cfg: Config) -> Optional[str]: + t = _env_token() + if t and is_arena_api_key(t): + return t + return cfg.arena_api_key + + +def get_github_token(cfg: Config) -> Optional[str]: + t = _env_token() + if t and not is_arena_api_key(t): + return t + return cfg.github_token + + +def get_arena_api_url(cfg: Config) -> str: + return (os.environ.get(CONFIG_ENV_API) or cfg.arena_api_url).rstrip("/") + +def get_github_api_base(cfg: Config) -> str: + return cfg.github_api_base.rstrip("/") -def get_effective_api_base(cfg: Config) -> str: - return os.environ.get(CONFIG_ENV_API) or cfg.api_base_url + +def arena_api_request( + cfg: Config, + method: str, + path: str, + body: Optional[Dict[str, Any]] = None, + *, + api_key: Optional[str] = None, +) -> Dict[str, Any]: + key = api_key or get_arena_api_key(cfg) + if not key: + raise ValueError("no Arena API key (hqiv_…); sign in at https://disregardfiat.tech/#arena") + url = f"{get_arena_api_url(cfg)}{path}" + headers = { + "Authorization": f"Bearer {key}", + "Accept": "application/json", + "Content-Type": "application/json", + } + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Arena API HTTP {e.code}: {detail}") from e def current_skill_install_targets() -> Dict[str, Path]: @@ -242,53 +323,66 @@ def ensure_clean_worktree(cwd: Optional[Path] = None, force: bool = False) -> No pass # not a git repo or other; proceed with caution -# --- Login (GitHub PAT + optional device flow guidance) ---------------------- +# --- Login (Arena API key and/or GitHub PAT) --------------------------------- def do_login(token: Optional[str], api: Optional[str]) -> None: cfg = read_config() if api: - cfg.api_base_url = api + if "github.com" in api or "api.github" in api: + cfg.github_api_base = api.rstrip("/") + else: + cfg.arena_api_url = api.rstrip("/") if not token: - print("HQIV Arena uses a GitHub Personal Access Token (PAT) with 'repo' scope.") - print("This lets the CLI push branches and open PRs on your behalf.") + print("HQIV Arena supports two credentials (you can run login twice to store both):") print() - print("1. Go to: https://github.com/settings/tokens/new") - print("2. Name it 'HQIV Arena', select the 'repo' scope.") - print("3. Generate token and paste it below.") + print(" A) Arena API key (hqiv_…) — from https://disregardfiat.tech/#arena") + print(" Sign in with GitHub on the site; copy the one-time hqiv_ key.") + print(" Used for provisional leaderboard entries via POST /api/v1/submissions.") print() - print("Alternatively, if you have the GitHub CLI installed and logged in:") - print(" gh auth login") - print(" hqiv-arena login # will try to use your gh token") + print(" B) GitHub PAT (ghp_… / github_pat_…) with 'repo' scope — for push + PR.") + print(" Or use: gh auth login (then submit can use gh without storing a PAT).") print() - token = input("Paste GitHub token (ghp_... or github_pat_...): ").strip() + token = input("Paste token (hqiv_… or GitHub PAT): ").strip() if not token: print("No token provided.", file=sys.stderr) sys.exit(1) token = token.strip() - cfg.token = token - # Verify very lightly (we don't want to require extra scopes for /user if possible) - try: - # Use gh if present and authed, else direct - if has_gh() and gh_auth_status(): - print("Using existing gh authentication where possible.") - else: - # Simple validation: hit a public-ish endpoint with the token - req = urllib.request.Request( - f"{get_effective_api_base(cfg)}/user", - headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - user = json.loads(resp.read()) - print(f"Token validated for GitHub user: {user.get('login')}") - except Exception as e: - print(f"Warning: could not fully validate token against GitHub ({e}). Storing anyway.") + if is_arena_api_key(token): + cfg.arena_api_key = token + try: + me = arena_api_request(cfg, "GET", "/me", api_key=token) + who = me.get("github") or me.get("label") or me.get("id") + print(f"Arena API key validated ({who}).") + except Exception as e: + print(f"Warning: could not validate Arena API key ({e}). Storing anyway.") + elif is_github_pat(token) or token: + cfg.github_token = token + try: + if has_gh() and gh_auth_status(): + print("GitHub CLI is authenticated; PAT also stored for API fallback.") + else: + req = urllib.request.Request( + f"{get_github_api_base(cfg)}/user", + headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + user = json.loads(resp.read()) + print(f"GitHub token validated for user: {user.get('login')}") + except Exception as e: + print(f"Warning: could not fully validate GitHub token ({e}). Storing anyway.") + else: + print("Unrecognized token prefix. Expected hqiv_… or ghp_… / github_pat_…", file=sys.stderr) + sys.exit(1) write_config(cfg) - print(f"Logged in. Config saved to {config_path()}") + print(f"Config saved to {config_path()}") # --- Clone ------------------------------------------------------------------- @@ -397,6 +491,15 @@ def do_run(cwd: Optional[Path] = None) -> None: env = os.environ.copy() env["PYTHONPATH"] = str(py_root / "src") + os.pathsep + env.get("PYTHONPATH", "") + integrity_script = py_root / "scripts" / "check_arena_source_integrity.py" + if integrity_script.exists(): + print("\n-- Stage: Source integrity (mirror modules) --") + try: + run([sys.executable, str(integrity_script), "--verbose"], cwd=py_root, check=True) + except subprocess.CalledProcessError: + print("Source integrity gate FAILED.", file=sys.stderr) + sys.exit(1) + # 1. Alignment (fast gate) print("\n-- Stage: Alignment --") align_script = py_root / "scripts" / "validate_hqiv_alignment.py" @@ -435,7 +538,43 @@ def do_run(cwd: Optional[Path] = None) -> None: # --- Submit ------------------------------------------------------------------ -def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: Optional[Path] = None) -> None: +def _git_sha(cwd: Path) -> Optional[str]: + try: + return run(["git", "rev-parse", "HEAD"], cwd=cwd, capture=True).stdout.strip() + except Exception: + return None + + +def post_arena_api_submission( + cfg: Config, + *, + note: str, + model: str, + claimed_score: Optional[float], + sigma_weighted: Optional[float], + git_ref: Optional[str], +) -> Optional[Dict[str, Any]]: + if not get_arena_api_key(cfg): + return None + body: Dict[str, Any] = {"note": note, "model": model} + if claimed_score is not None: + body["claimed_score"] = claimed_score + if sigma_weighted is not None: + body["sigma_weighted"] = sigma_weighted + if git_ref: + body["git_ref"] = git_ref + return arena_api_request(cfg, "POST", "/submissions", body) + + +def do_submit( + note_file: str, + model: str, + claimed_score: Optional[float], + sigma_weighted: Optional[float] = None, + pr_only: bool = False, + api_only: bool = False, + cwd: Optional[Path] = None, +) -> None: cwd = cwd or Path.cwd() if not is_hqiv_workspace(cwd): print("Warning: not obviously inside an HQIV arena workspace. Continuing anyway.") @@ -457,9 +596,43 @@ def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: O sys.exit(1) cfg = read_config() - token = get_effective_token(cfg) - if not token and not has_gh(): - print("No GitHub token configured and no `gh` CLI found. Run `hqiv-arena login` first.", file=sys.stderr) + git_ref = _git_sha(cwd) + + if not pr_only: + api_result = post_arena_api_submission( + cfg, + note=note, + model=model, + claimed_score=claimed_score, + sigma_weighted=sigma_weighted, + git_ref=git_ref, + ) + if api_result: + print("Arena API submission recorded (provisional leaderboard).") + print(api_result.get("message", "")) + preview = api_result.get("leaderboard_preview") or {} + if preview.get("current_best"): + print(f" current_best score: {preview['current_best'].get('score')}") + elif api_only: + print("No Arena API key. Sign in at https://disregardfiat.tech/#arena", file=sys.stderr) + sys.exit(1) + + if api_only: + return + + github_token = get_github_token(cfg) + if not github_token and not has_gh(): + print( + "No GitHub PAT or `gh` CLI for PR workflow. Recorded Arena API submission only.\n" + "For authoritative CI scoring, run: hqiv-arena login ghp_… (or gh auth login)", + file=sys.stderr, + ) + if pr_only: + sys.exit(1) + return + + if pr_only and not github_token and not has_gh(): + print("PR workflow requires GitHub PAT or gh auth.", file=sys.stderr) sys.exit(1) # Create a branch if on main @@ -512,8 +685,8 @@ def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: O print(f"gh pr create had issues, falling back to API: {e}") # Fallback: direct GitHub API PR creation (requires token with repo scope) - if not token: - print("Cannot create PR: no token and gh not available / failed.", file=sys.stderr) + if not github_token: + print("Cannot create PR: no GitHub token and gh not available / failed.", file=sys.stderr) sys.exit(1) # We need the repo name and head ref @@ -529,7 +702,7 @@ def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: O ref = f"{owner}:{head}" # Create PR via API - url = f"{get_effective_api_base(cfg)}/repos/{owner}/{repo}/pulls" + url = f"{get_github_api_base(cfg)}/repos/{owner}/{repo}/pulls" payload = { "title": pr_title, "head": head if "/" not in head else ref.split(":", 1)[1], @@ -541,7 +714,7 @@ def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: O url, data=data, headers={ - "Authorization": f"token {token}", + "Authorization": f"token {github_token}", "Accept": "application/vnd.github+json", "Content-Type": "application/json", }, @@ -559,6 +732,24 @@ def do_submit(note_file: str, model: str, claimed_score: Optional[float], cwd: O def do_submissions(all_public: bool = False, cwd: Optional[Path] = None) -> None: cwd = cwd or Path.cwd() + cfg = read_config() + if get_arena_api_key(cfg): + try: + path = "/submissions?all=1" if all_public else "/submissions" + data = arena_api_request(cfg, "GET", path) + subs = data.get("submissions") or [] + if subs: + print("=== Arena API submissions ===") + for s in subs[:20]: + print( + f" {s.get('created_at', '')} {s.get('author', '?')} " + f"model={s.get('model', '?')} score={s.get('claimed_score')}" + ) + else: + print("No Arena API submissions yet.") + except Exception as e: + print(f"Arena API submissions list failed: {e}") + if has_gh(): label = "hqiv-arena" if not all_public else None args = ["gh", "pr", "list", "--limit", "20", "--json", "number,title,author,headRefName,createdAt,url"] @@ -569,7 +760,7 @@ def do_submissions(all_public: bool = False, cwd: Optional[Path] = None) -> None return print("Install `gh` (GitHub CLI) for nice submission listing, or implement custom listing here.") - print("For now, visit https://github.com/HQIV/hqiv-lean/pulls and https://github.com/disregardfiat/pyhqiv/pulls") + print(f"For now, visit https://github.com/HQIV/hqiv-lean/pulls and {PYHQIV_GITHUB_WEB}/pulls") def do_note(ref: str, cwd: Optional[Path] = None) -> None: @@ -639,14 +830,22 @@ def main(argv: Optional[list[str]] = None) -> int: sub = parser.add_subparsers(dest="cmd", required=True) # login - p = sub.add_parser("login", help="Store GitHub PAT for Arena operations") - p.add_argument("token", nargs="?", help="GitHub PAT (ghp_... or github_pat_...)") - p.add_argument("--api", help="Override API base (rarely needed)") + p = sub.add_parser("login", help="Store Arena API key (hqiv_…) and/or GitHub PAT") + p.add_argument("token", nargs="?", help="hqiv_… from disregardfiat.tech/#arena or ghp_… PAT") + p.add_argument( + "--api", + help="Override Arena API URL (https://disregardfiat.tech/api/v1) or GitHub API base", + ) p.set_defaults(func=lambda a: do_login(a.token, a.api)) # config p = sub.add_parser("config", help="Show current configuration") - p.set_defaults(func=lambda a: print(json.dumps(read_config().to_dict(), indent=2))) + p.set_defaults( + func=lambda a: print( + json.dumps(read_config().to_dict(), indent=2) + + "\n# HQIV_ARENA_TOKEN / HQIV_ARENA_API_URL override file values" + ) + ) # benchmark p = sub.add_parser("benchmark", help="Show the fixed HQIV benchmark") @@ -670,7 +869,19 @@ def main(argv: Optional[list[str]] = None) -> int: p.add_argument("--note-file", required=True, help="Markdown file with detailed progress note") p.add_argument("--model", required=True, help="Model or agent used (e.g. 'Claude 4 Opus')") p.add_argument("--claimed-score", type=float, help="Optional local score you observed") - p.set_defaults(func=lambda a: do_submit(a.note_file, a.model, a.claimed_score)) + p.add_argument("--sigma-weighted", type=float, help="Optional weighted sigma from local run") + p.add_argument("--api-only", action="store_true", help="Only POST to disregardfiat Arena API") + p.add_argument("--pr-only", action="store_true", help="Skip Arena API; only open GitHub PR") + p.set_defaults( + func=lambda a: do_submit( + a.note_file, + a.model, + a.claimed_score, + a.sigma_weighted, + a.pr_only, + a.api_only, + ) + ) # submissions p = sub.add_parser("submissions", help="List recent submissions / PRs") diff --git a/tests/test_arena_source_integrity.py b/tests/test_arena_source_integrity.py new file mode 100644 index 0000000..83e8733 --- /dev/null +++ b/tests/test_arena_source_integrity.py @@ -0,0 +1,27 @@ +"""Tests for the static Arena source-integrity gate.""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + + +def test_integrity_script_passes_on_main_tree(): + script = Path(__file__).resolve().parents[1] / "scripts" / "check_arena_source_integrity.py" + proc = subprocess.run( + [sys.executable, str(script), "--verbose"], + cwd=script.parents[1], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +def test_catches_literal_return_cheat(): + from scripts.check_arena_source_integrity import _bad_literal_returns + + tree = ast.parse("def f():\n return 0.42\n") + func = tree.body[0] + assert _bad_literal_returns(func) == [0.42] diff --git a/tests/test_hqiv_arena.py b/tests/test_hqiv_arena.py index f88a6e2..6581828 100644 --- a/tests/test_hqiv_arena.py +++ b/tests/test_hqiv_arena.py @@ -56,6 +56,21 @@ def test_arena_badges_award_logic(): assert "sigma-improver" not in b2 # regression present +def test_source_integrity_script_passes(): + import subprocess + import sys + from pathlib import Path + + script = Path(__file__).resolve().parents[1] / "scripts" / "check_arena_source_integrity.py" + proc = subprocess.run( + [sys.executable, str(script)], + cwd=script.parents[1], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + def test_alignment_script_runs_as_module(): # Just import + basic structure; the full gate is exercised in CI and manual runs import runpy