diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml index 8b771c1b..391c21cc 100644 --- a/.github/workflows/ci-gate.yml +++ b/.github/workflows/ci-gate.yml @@ -45,14 +45,16 @@ jobs: set -euo pipefail # The authoritative gate workflows. A PR that doesn't touch a given - # workflow's paths simply won't have a run for it (→ N/A). Lint is - # intentionally excluded (warning-only); CI Gate excludes itself. + # workflow's paths simply won't have a run for it (→ N/A). CI Gate + # excludes itself. "Lint (warning-only)" — SwiftLint/ktlint — is + # intentionally excluded and is NOT the same thing as "Python Lint", + # which is blocking and listed below. # "Repo Hygiene" is deliberately first: unlike the others it has NO # path filter, so it always runs and can never be N/A. That matters, # because the guard it carries (internal documents in this PUBLIC # repo) is most needed on exactly the docs-only PRs that trigger none # of the path-filtered workflows below. - GATES=("Repo Hygiene" "Swift CI" "Android CI" "Helper CI" "Supabase CI") + GATES=("Repo Hygiene" "Swift CI" "Android CI" "Helper CI" "Supabase CI" "Python Lint") # Give GitHub time to create the path-triggered runs for this SHA so # we don't race and call a still-uncreated run "N/A". diff --git a/.github/workflows/helper-ci.yml b/.github/workflows/helper-ci.yml index abf749f9..28544156 100644 --- a/.github/workflows/helper-ci.yml +++ b/.github/workflows/helper-ci.yml @@ -61,21 +61,21 @@ jobs: python-version: '3.12' - name: Install dev tooling - # ruff is PINNED. Unpinned, `--upgrade` installs whatever shipped since - # the last run, and a release that enables new rules turns a PR red for - # code it never touched. That is not hypothetical: main was green on - # 2026-07-23, and the next PR to trigger this workflow (2026-07-30, and - # only because helper-ci watches Protocol.swift) drew 380 errors across - # files nobody had edited — UP045 and friends. Ten minutes went into - # proving the feature branch was innocent. - # - # Raising this pin is a deliberate task: bump it, run `ruff check .`, - # and fix or `# noqa` what the new rules find, in a PR that does only - # that. Never bump it as a side effect of unrelated work. - run: python -m pip install --upgrade pip && python -m pip install pytest 'ruff==0.15.11' cryptography + run: python -m pip install --upgrade pip && python -m pip install pytest cryptography - - name: Ruff lint - run: ruff check . + # Ruff used to run here, and that was the bug. This job has + # `working-directory: helper`, so `ruff check .` only ever linted + # `helper/` — and this workflow's `paths:` filter meant a change to + # `scripts/*.py` did not start it at all. Python outside `helper/` was + # unlinted for the life of the repo (91 errors when first measured, + # 2026-08-17). + # + # Linting now lives in `.github/workflows/python-lint.yml`, which runs + # `ruff check .` from the REPO ROOT on every `**/*.py` change — a strict + # superset of what this step covered, so removing it leaves no gap in + # helper/ coverage. Do not re-add a ruff step here: two pinned ruff + # versions in two workflows is the copied-constant drift this repo has + # been bitten by before. The pin and its rationale live in python-lint.yml. - name: Pytest run: pytest -q diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml new file mode 100644 index 00000000..0c51c9b4 --- /dev/null +++ b/.github/workflows/python-lint.yml @@ -0,0 +1,86 @@ +name: Python Lint + +# Repo-wide ruff. This is a BLOCKING gate — unlike "Lint (warning-only)", which +# is SwiftLint/ktlint and deliberately advisory. `ci-gate.yml` lists this +# workflow in GATES; a failure here fails the required check. +# +# Why this workflow exists at all, given helper-ci.yml already ran ruff: +# it did not lint what it appeared to lint. Two independent defects, either one +# of which was sufficient to hide every non-helper Python file in the repo: +# +# 1. helper-ci's `paths:` filter is helper/**, three named scripts, and one +# Swift file. A change to `scripts/foo.py` matched none of them, so the +# workflow never started. +# 2. Even when it DID start, its ruff step ran under +# `working-directory: helper`, so `ruff check .` resolved to `helper/`. +# Widening the path filter alone would have fixed nothing. +# +# Cost of the gap, measured 2026-08-17: 91 ruff errors across `scripts/`, +# `CLI Pulse Bar/scripts/` and `archive/` — none of which any CI run had ever +# reported. The proximate trigger was `scripts/acquisition_reconcile.py`, added +# in PR #433 with two F541 errors that went straight to main. +# +# The path filter below is therefore `**/*.py` and nothing narrower. If you add +# a Python file anywhere in this repo, this workflow lints it. Note `**/*.py` +# also covers paths containing spaces (`CLI Pulse Bar/scripts/*.py`) — verified +# on a scratch branch, not assumed, because that directory holds ~24 of the +# repo's Python files and a glob that silently skipped it would recreate this +# exact bug. + +on: + push: + paths: + - '**/*.py' + - 'ruff.toml' + - '.github/workflows/python-lint.yml' + pull_request: + paths: + - '**/*.py' + - 'ruff.toml' + - '.github/workflows/python-lint.yml' + # Mirrors the other workflows: manual retrigger for an Actions outage + # without needing an empty commit. + workflow_dispatch: {} + +concurrency: + group: python-lint-${{ github.ref }} + cancel-in-progress: true + +jobs: + ruff: + name: ruff (repo-wide) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Install ruff + # ruff is PINNED. Unpinned, `--upgrade` installs whatever shipped since + # the last run, and a release that enables new rules turns a PR red for + # code it never touched. That is not hypothetical: main was green on + # 2026-07-23, and the next PR to trigger the old helper-ci workflow + # (2026-07-30, and only because helper-ci watches Protocol.swift) drew + # 380 errors across files nobody had edited — UP045 and friends. Ten + # minutes went into proving the feature branch was innocent. + # + # Raising this pin is a deliberate task: bump it, run `ruff check .`, + # and fix or `# noqa` what the new rules find, in a PR that does only + # that. Never bump it as a side effect of unrelated work. + # + # `ruff.toml` states `select` explicitly, so a bump changes rule + # BEHAVIOUR but not the rule SET — that makes the diff reviewable. + # Keep this pin and helper-ci.yml's Python version in step. + run: python -m pip install --upgrade pip && python -m pip install 'ruff==0.15.11' + + - name: Ruff lint (repo root) + # NO working-directory. This runs at the repo root on purpose — see the + # header comment. `ruff.toml` at the root supplies the config and the + # single `archive/` exclusion. + run: | + ruff --version + ruff check . --output-format=github diff --git a/CLI Pulse Bar/scripts/add_for_review_ios_v192.py b/CLI Pulse Bar/scripts/add_for_review_ios_v192.py index cc7fc48a..376c91f3 100644 --- a/CLI Pulse Bar/scripts/add_for_review_ios_v192.py +++ b/CLI Pulse Bar/scripts/add_for_review_ios_v192.py @@ -9,10 +9,11 @@ - Build 30 is bound, What's New / all screenshots are in place. """ from __future__ import annotations -import sys, os +import sys +import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from submit_ios_v192 import get, post, patch, APP_ID, IOS_VERSION_ID, requests, H, BASE_URL +from submit_ios_v192 import get, post, APP_ID, IOS_VERSION_ID, requests, H, BASE_URL def create_review_submission() -> str: diff --git a/CLI Pulse Bar/scripts/appstore_metadata.py b/CLI Pulse Bar/scripts/appstore_metadata.py index a0b5a824..17526b86 100644 --- a/CLI Pulse Bar/scripts/appstore_metadata.py +++ b/CLI Pulse Bar/scripts/appstore_metadata.py @@ -7,10 +7,8 @@ import jwt import time import requests -import json import hashlib import os -import sys # --- Config --- API_KEY_ID = "DMMFP6XTXX" @@ -203,7 +201,7 @@ def set_localization(version_id, locale="en-US"): } }, raise_on_error=False) if result is None: - print(f" (localization update skipped due to version state)") + print(" (localization update skipped due to version state)") # whatsNew only for updates, not first version - try separately api_patch(f"/appStoreVersionLocalizations/{loc_id}", { "data": { @@ -213,7 +211,7 @@ def set_localization(version_id, locale="en-US"): } }, raise_on_error=False) else: - print(f" Creating new localization...") + print(" Creating new localization...") loc_data["locale"] = locale r = api_post("/appStoreVersionLocalizations", { "data": { @@ -272,7 +270,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type): } }, raise_on_error=False) if r is None: - print(f" Cannot create screenshot set (version state). Skipping.") + print(" Cannot create screenshot set (version state). Skipping.") return set_id = r["data"]["id"] print(f" Created screenshot set: {set_id}") @@ -283,7 +281,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type): if existing: result = api_delete(f"/appScreenshots/{existing[0]['id']}") if result >= 400: - print(f" Cannot modify screenshots (version state). Skipping.") + print(" Cannot modify screenshots (version state). Skipping.") return for ss in existing[1:]: api_delete(f"/appScreenshots/{ss['id']}") @@ -317,14 +315,14 @@ def upload_screenshots(loc_id, screenshot_files, display_type): }, raise_on_error=False) if r is None: - print(f" Failed to reserve. Skipping remaining screenshots.") + print(" Failed to reserve. Skipping remaining screenshots.") return screenshot_id = r["data"]["id"] upload_ops = r["data"]["attributes"].get("uploadOperations", []) if not upload_ops: - print(f" No upload operations returned, skipping...") + print(" No upload operations returned, skipping...") continue # Upload parts @@ -359,7 +357,7 @@ def upload_screenshots(loc_id, screenshot_files, display_type): # 4. Set App Info (category, etc.) # ============================================================ def set_app_info(): - print(f"\n Setting app info (category)...") + print("\n Setting app info (category)...") r = api_get(f"/apps/{APP_ID}/appInfos") infos = r.get("data", []) if not infos: @@ -381,7 +379,7 @@ def set_app_info(): }, } }) - print(f" Category set to Developer Tools") + print(" Category set to Developer Tools") except Exception as e: print(f" Category update note: {e}") @@ -400,7 +398,7 @@ def set_app_info(): } } }) - print(f" App info localization updated") + print(" App info localization updated") break diff --git a/CLI Pulse Bar/scripts/compose_appstore_screenshots.py b/CLI Pulse Bar/scripts/compose_appstore_screenshots.py index de0308cb..b506f293 100755 --- a/CLI Pulse Bar/scripts/compose_appstore_screenshots.py +++ b/CLI Pulse Bar/scripts/compose_appstore_screenshots.py @@ -11,9 +11,8 @@ """ from __future__ import annotations -import os from pathlib import Path -from PIL import Image, ImageDraw, ImageFont, ImageChops +from PIL import Image, ImageDraw, ImageFont CANVAS_W, CANVAS_H = 2880, 1800 diff --git a/CLI Pulse Bar/scripts/resubmit.py b/CLI Pulse Bar/scripts/resubmit.py index 45f83005..d192a85d 100644 --- a/CLI Pulse Bar/scripts/resubmit.py +++ b/CLI Pulse Bar/scripts/resubmit.py @@ -2,7 +2,11 @@ """ CLI Pulse - Cancel review, update screenshots, select new build, resubmit. """ -import jwt, time, requests, os, hashlib, sys +import jwt +import time +import requests +import os +import hashlib API_KEY_ID = "DMMFP6XTXX" API_ISSUER = "c5671c11-49ec-47d9-bd38-5e3c1a249416" @@ -37,7 +41,7 @@ def post(path, data): try: for e in r.json().get("errors", []): print(f" {e.get('detail', e.get('title'))}") - except: + except Exception: print(f" {r.text[:300]}") return None return r.json() @@ -49,7 +53,7 @@ def patch(path, data): try: for e in r.json().get("errors", []): print(f" {e.get('detail', e.get('title'))}") - except: + except Exception: print(f" {r.text[:300]}") return None return r.json() @@ -75,9 +79,10 @@ def cancel_reviews(): if result: print(f" Canceled: {sub_id}") else: - # Try legacy API - print(f" Trying legacy appStoreVersionSubmissions...") - r2 = get(f"/appStoreVersionSubmissions") + # Try legacy API. The response is deliberately not inspected — + # the call is a probe and we proceed either way. + print(" Trying legacy appStoreVersionSubmissions...") + get("/appStoreVersionSubmissions") # Just proceed - may already be canceled @@ -113,7 +118,7 @@ def select_build(version_id, platform_label): print(f" Waiting for build processing... ({attempt+1}/12)") time.sleep(30) - print(f" WARNING: Could not find valid build 3. Proceeding with existing build.") + print(" WARNING: Could not find valid build 3. Proceeding with existing build.") return False @@ -148,7 +153,7 @@ def upload_screenshots(loc_id, files, display_type): } }) if not r: - print(f" Failed to create screenshot set") + print(" Failed to create screenshot set") return set_id = r["data"]["id"] @@ -174,7 +179,7 @@ def upload_screenshots(loc_id, files, display_type): } }) if not r: - print(f" Failed to reserve upload") + print(" Failed to reserve upload") continue ss_id = r["data"]["id"] @@ -218,7 +223,7 @@ def submit_for_review(): vr = get(f"/apps/{APP_ID}/appStoreVersions?filter[platform]={platform}") for v in vr.get("data", []): if v["attributes"]["versionString"] == "1.0.0": - post(f"/reviewSubmissionItems", { + post("/reviewSubmissionItems", { "data": { "type": "reviewSubmissionItems", "relationships": { @@ -241,9 +246,9 @@ def submit_for_review(): } }) if result: - print(f" Submitted for review!") + print(" Submitted for review!") else: - print(f" Submit failed - may need manual submission") + print(" Submit failed - may need manual submission") else: print(" Could not create review submission") @@ -341,7 +346,7 @@ def main(): } } }) - print(f" Updated privacy policy URL") + print(" Updated privacy policy URL") # Try to select latest build if mac_vid: diff --git a/CLI Pulse Bar/scripts/submit_v1_10_8.py b/CLI Pulse Bar/scripts/submit_v1_10_8.py index be7378a6..cbeddf22 100644 --- a/CLI Pulse Bar/scripts/submit_v1_10_8.py +++ b/CLI Pulse Bar/scripts/submit_v1_10_8.py @@ -154,8 +154,6 @@ def bind_build(version_id, platform_label): for b in r.get("data", []): bv = b["attributes"].get("version") proc = b["attributes"].get("processingState") - ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {}) - .get("data") or {}) states.append(f"{bv}/{proc}") if bv == TARGET_BUILD and proc == "VALID": found = b["id"] diff --git a/CLI Pulse Bar/scripts/submit_v1_11_0.py b/CLI Pulse Bar/scripts/submit_v1_11_0.py index 564a406e..956b4a1e 100644 --- a/CLI Pulse Bar/scripts/submit_v1_11_0.py +++ b/CLI Pulse Bar/scripts/submit_v1_11_0.py @@ -179,8 +179,6 @@ def bind_build(version_id, platform_label): for b in r.get("data", []): bv = b["attributes"].get("version") proc = b["attributes"].get("processingState") - ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {}) - .get("data") or {}) states.append(f"{bv}/{proc}") if bv == TARGET_BUILD and proc == "VALID": found = b["id"] diff --git a/CLI Pulse Bar/scripts/submit_v1_11_1.py b/CLI Pulse Bar/scripts/submit_v1_11_1.py index 6fd2a009..bcf44257 100644 --- a/CLI Pulse Bar/scripts/submit_v1_11_1.py +++ b/CLI Pulse Bar/scripts/submit_v1_11_1.py @@ -194,8 +194,6 @@ def bind_build(version_id, platform_label): for b in r.get("data", []): bv = b["attributes"].get("version") proc = b["attributes"].get("processingState") - ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {}) - .get("data") or {}) states.append(f"{bv}/{proc}") if bv == TARGET_BUILD and proc == "VALID": found = b["id"] diff --git a/CLI Pulse Bar/scripts/submit_v1_12_0.py b/CLI Pulse Bar/scripts/submit_v1_12_0.py index 985e99fc..1f06140b 100644 --- a/CLI Pulse Bar/scripts/submit_v1_12_0.py +++ b/CLI Pulse Bar/scripts/submit_v1_12_0.py @@ -152,8 +152,6 @@ def bind_build(version_id, platform_label): for b in r.get("data", []): bv = b["attributes"].get("version") proc = b["attributes"].get("processingState") - ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {}) - .get("data") or {}) states.append(f"{bv}/{proc}") if bv == TARGET_BUILD and proc == "VALID": found = b["id"] diff --git a/CLI Pulse Bar/scripts/submit_v1_21_0.py b/CLI Pulse Bar/scripts/submit_v1_21_0.py index 6b25ae7d..21664d7b 100644 --- a/CLI Pulse Bar/scripts/submit_v1_21_0.py +++ b/CLI Pulse Bar/scripts/submit_v1_21_0.py @@ -219,8 +219,6 @@ def bind_build(version_id, platform_label): for b in r.get("data", []): bv = b["attributes"].get("version") proc = b["attributes"].get("processingState") - ver_rel = (b.get("relationships", {}).get("preReleaseVersion", {}) - .get("data") or {}) states.append(f"{bv}/{proc}") if bv == TARGET_BUILD and proc == "VALID": found = b["id"] diff --git a/CLI Pulse Bar/scripts/submit_watch_v192.py b/CLI Pulse Bar/scripts/submit_watch_v192.py index cd372b72..cb671733 100644 --- a/CLI Pulse Bar/scripts/submit_watch_v192.py +++ b/CLI Pulse Bar/scripts/submit_watch_v192.py @@ -8,7 +8,9 @@ the set creation is rejected. """ from __future__ import annotations -import hashlib, sys, os +import hashlib +import sys +import os from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..f7c93955 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,46 @@ +# Repo-wide Ruff configuration. +# +# Until 2026-08-17 there was no config file at all, and the only `ruff check .` +# in CI ran from `working-directory: helper` inside a workflow whose `paths:` +# filter listed `helper/**`. Two independent reasons the same Python never got +# linted: the workflow did not trigger, and even when it did it only ever saw +# `helper/`. Python under `scripts/`, `CLI Pulse Bar/scripts/` and +# `backend/supabase/` was unlinted for the life of the repo — 91 errors had +# accumulated by the time anyone checked. +# +# `.github/workflows/python-lint.yml` now runs ruff from the repo root on every +# `**/*.py` change, and this file is what that run reads. + +# CI installs Python 3.12 (see python-lint.yml / helper-ci.yml). Pin the same +# version here so a lint that passes locally cannot fail on the runner for a +# syntax-version reason. +target-version = "py312" + +# `archive/` is documented in AGENTS.md under "Archived or historical". Nothing +# imports it, nothing ships it, and it carries 11 of the errors that were found +# when linting was first turned on repo-wide. Linting it would mean editing dead +# code to satisfy a gate that protects live code. +# +# This is the ONLY exclusion. Everything else that ships is linted. If you are +# about to add a second entry here, fix the code instead — an exclusion is +# permanent in practice, and this file exists because of what "we'll clean it up +# later" cost the first time. +exclude = [ + "archive", +] + +[lint] +# These four groups are exactly ruff 0.15.11's defaults, written out on purpose. +# +# Leaving `select` implicit means the rule set is whatever the installed ruff +# version happens to default to, so raising the pin can turn PRs red for code +# they never touched. That is not hypothetical — see the comment above the +# `ruff==0.15.11` pin in python-lint.yml for the 2026-07-30 incident. Stating +# the set explicitly makes a version bump a mechanical change and a rule-set +# change a deliberate, reviewable one. +# +# E4 — import placement/formatting +# E7 — statement-level style (multiple statements per line, bare except, ...) +# E9 — syntax and IO errors +# F — Pyflakes: undefined names, unused imports, unused locals +select = ["E4", "E7", "E9", "F"] diff --git a/scripts/ci_check_android_strings_parity.py b/scripts/ci_check_android_strings_parity.py index aa2c7d8e..25904266 100644 --- a/scripts/ci_check_android_strings_parity.py +++ b/scripts/ci_check_android_strings_parity.py @@ -26,7 +26,6 @@ """ from __future__ import annotations -import os import re import sys from pathlib import Path diff --git a/scripts/pet_art/pet_art_gen.py b/scripts/pet_art/pet_art_gen.py index 9d4128f0..65c2e139 100644 --- a/scripts/pet_art/pet_art_gen.py +++ b/scripts/pet_art/pet_art_gen.py @@ -7,7 +7,6 @@ frame) parameter deltas → frame-to-frame consistency. Emits SVG; the export script rasterizes to PetAssets/ PNG @1x/@2x. """ -import math STROKE = 14 VIEW = 512 @@ -63,16 +62,22 @@ def ears(c, cx, cy, spread=86, h=60, flop=None): def face(c, cx, cy, eyes='dot', mouth='smile', eye_r=12, teary=False): ex = 36 if eyes == 'closed': - c.eye(cx-ex, cy-6, eye_r, closed=True); c.eye(cx+ex, cy-6, eye_r, closed=True) + c.eye(cx-ex, cy-6, eye_r, closed=True) + c.eye(cx+ex, cy-6, eye_r, closed=True) elif eyes == 'half': - c.stroke_path(f"M{cx-ex-16} {cy-6} h32"); c.stroke_path(f"M{cx+ex-16} {cy-6} h32") + c.stroke_path(f"M{cx-ex-16} {cy-6} h32") + c.stroke_path(f"M{cx+ex-16} {cy-6} h32") elif eyes == 'wide': - c.circles.append((cx-ex, cy-6, eye_r+8, False)); c.circles.append((cx+ex, cy-6, eye_r+8, False)) - c.circles.append((cx-ex, cy-6, 5, True)); c.circles.append((cx+ex, cy-6, 5, True)) + c.circles.append((cx-ex, cy-6, eye_r+8, False)) + c.circles.append((cx+ex, cy-6, eye_r+8, False)) + c.circles.append((cx-ex, cy-6, 5, True)) + c.circles.append((cx+ex, cy-6, 5, True)) else: # dot - c.eye(cx-ex, cy-6, eye_r); c.eye(cx+ex, cy-6, eye_r) + c.eye(cx-ex, cy-6, eye_r) + c.eye(cx+ex, cy-6, eye_r) if teary: - c.stroke_path(f"M{cx-ex-6} {cy+8} q-8 20 4 24"); c.stroke_path(f"M{cx+ex+6} {cy+8} q8 20 -4 24") + c.stroke_path(f"M{cx-ex-6} {cy+8} q-8 20 4 24") + c.stroke_path(f"M{cx+ex+6} {cy+8} q8 20 -4 24") # nose c.fill_path(f"M{cx} {cy+18} l-8 10 h16 Z") my = cy+28 @@ -81,13 +86,16 @@ def face(c, cx, cy, eyes='dot', mouth='smile', eye_r=12, teary=False): elif mouth == 'flat': # unsettlingly wide flat smile (polite) c.stroke_path(f"M{cx-58} {my} q58 30 116 0") elif mouth == 'open': # effort (smash) - c.circles.append((cx, my+6, 12, False)); c.stroke_path(f"M{cx-14} {my-2} h28") + c.circles.append((cx, my+6, 12, False)) + c.stroke_path(f"M{cx-14} {my-2} h28") elif mouth == 'tiny': c.circles.append((cx, my+4, 6, False)) else: # smile - c.stroke_path(f"M{cx} {my-4} q-16 14 -30 4"); c.stroke_path(f"M{cx} {my-4} q16 14 30 4") + c.stroke_path(f"M{cx} {my-4} q-16 14 -30 4") + c.stroke_path(f"M{cx} {my-4} q16 14 30 4") # whiskers - c.stroke_path(f"M{cx-106} {cy+10} h40"); c.stroke_path(f"M{cx+66} {cy+10} h40") + c.stroke_path(f"M{cx-106} {cy+10} h40") + c.stroke_path(f"M{cx+66} {cy+10} h40") def zzz(c, x, y): @@ -97,7 +105,8 @@ def zzz(c, x, y): def emphasis(c, cx, cy): - c.stroke_path(f"M{cx-150} {cy-30} l-26 -10"); c.stroke_path(f"M{cx+150} {cy-30} l26 -10") + c.stroke_path(f"M{cx-150} {cy-30} l-26 -10") + c.stroke_path(f"M{cx+150} {cy-30} l26 -10") # ---- form drawers: (state, frame) -> Cat ---- @@ -121,16 +130,18 @@ def draw(form, state, frame): zzz(c, 372, 250) elif state == 'active': c.stroke_path("M232 224 q-10 -30 8 -48") - if frame == 1: c.stroke_path("M280 224 q10 -30 -8 -48") + if frame == 1: + c.stroke_path("M280 224 q10 -30 -8 -48") elif form == 'polite': base_cat(c, hx, hy, sleeping) if sleeping: - face(c, hx, hy, eyes='closed', mouth='flat'); zzz(c, 360, 150) + face(c, hx, hy, eyes='closed', mouth='flat') + zzz(c, 360, 150) else: - wider = 58 + (18 if frame == 1 else 0) face(c, hx, hy, eyes='dot', mouth='flat') - c.stroke_path("M226 452 q-6 -30 0 -50"); c.stroke_path("M286 452 q6 -30 0 -50") # paws together + c.stroke_path("M226 452 q-6 -30 0 -50") + c.stroke_path("M286 452 q6 -30 0 -50") # paws together if state == 'active': py = 300 if frame == 1 else 340 c.stroke_path(f"M330 400 q60 -20 44 -{440-py}") # raised wave paw @@ -139,27 +150,33 @@ def draw(form, state, frame): base_cat(c, hx, hy-6, sleeping) if sleeping: c.stroke_path("M176 452 h160") # slumped on keyboard - face(c, hx, hy+30, eyes='closed', mouth='tiny'); zzz(c, 360, 250) + face(c, hx, hy+30, eyes='closed', mouth='tiny') + zzz(c, 360, 250) else: m = 'open' if (state == 'active' and frame == 1) else 'smile' face(c, hx, hy-6, eyes='wide', mouth=m) # tiny keyboard c.stroke_path("M176 452 h160 v34 h-160 Z") - for gx in range(196, 330, 22): c.stroke_path(f"M{gx} 460 v18") + for gx in range(196, 330, 22): + c.stroke_path(f"M{gx} 460 v18") pl = 452 if frame == 0 else 470 - c.stroke_path(f"M214 430 v{pl-430}"); c.stroke_path(f"M298 430 v{(470 if frame==0 else 452)-430}") + c.stroke_path(f"M214 430 v{pl-430}") + c.stroke_path(f"M298 430 v{(470 if frame==0 else 452)-430}") if state == 'active': - c.stroke_path("M196 420 l-14 -12"); c.stroke_path("M316 420 l14 -12") + c.stroke_path("M196 420 l-14 -12") + c.stroke_path("M316 420 l14 -12") elif form == 'pop': base_cat(c, hx, hy, sleeping) if sleeping: - face(c, hx, hy, eyes='closed', mouth='tiny'); zzz(c, 360, 150) + face(c, hx, hy, eyes='closed', mouth='tiny') + zzz(c, 360, 150) else: m = 'O' if (frame == 1 or state == 'active') else 'smile' eyes = 'wide' if m == 'O' else 'dot' face(c, hx, hy, eyes=eyes, mouth=m) - if state == 'active' and frame == 0: emphasis(c, hx, hy) + if state == 'active' and frame == 0: + emphasis(c, hx, hy) elif form == 'long': # comically elongated horizontal tube; normal head on the left. @@ -167,7 +184,8 @@ def draw(form, state, frame): # coiled into a spiral (cinnamon-roll) c.stroke_path("M256 320 m-70 0 a70 70 0 1 1 140 0 a44 44 0 1 1 -88 0 a20 20 0 1 1 40 0") ears(c, 200, 268, spread=44, h=40) - face(c, 200, 300, eyes='closed', mouth='tiny'); zzz(c, 360, 250) + face(c, 200, 300, eyes='closed', mouth='tiny') + zzz(c, 360, 250) else: arch = 26 if (state == 'active' and frame == 1) else 0 top = 292 - arch // 2 @@ -179,20 +197,24 @@ def draw(form, state, frame): face(c, 150, hy2 - 2, eyes='half', mouth='smile') # four legs legs = (214, 250, 322, 358) if state != 'active' else (206, 258, 314, 366) - for lx in legs: c.stroke_path(f"M{lx} {top+68} v38") + for lx in legs: + c.stroke_path(f"M{lx} {top+68} v38") c.stroke_path(f"M398 {top+30} c44 -6 58 -34 38 -60") # tail elif form == 'huh': if sleeping: base_cat(c, hx, hy, True) - hd = Cat(); hd.circles.append((256, 300, 92, False)); ears(hd, 256, 300) + hd = Cat() + hd.circles.append((256, 300, 92, False)) + ears(hd, 256, 300) face(hd, 256, 300, eyes='closed', mouth='tiny') c.raw(f'{hd.inner()}') zzz(c, 372, 250) else: # upright body; the whole HEAD tilts (the classic "huh?"). c.stroke_path("M176 268 C150 330 150 400 176 430 C210 452 302 452 336 430 C362 400 362 330 336 268") - c.stroke_path("M214 452 q-6 -26 0 -44"); c.stroke_path("M298 452 q6 -26 0 -44") + c.stroke_path("M214 452 q-6 -26 0 -44") + c.stroke_path("M298 452 q6 -26 0 -44") c.stroke_path("M336 420 c46 8 66 -26 44 -60") tilt = 18 if frame == 0 else -18 hd = Cat() @@ -215,7 +237,8 @@ def base_cat(c, cx, cy, sleeping, tilt=0): ears(c, cx, cy, flop='right' if tilt else None) c.circles.append((cx, cy, 96, False)) # head c.stroke_path("M176 268 C150 330 150 400 176 430 C210 452 302 452 336 430 C362 400 362 330 336 268") - c.stroke_path("M214 452 q-6 -26 0 -44"); c.stroke_path("M298 452 q6 -26 0 -44") # paws + c.stroke_path("M214 452 q-6 -26 0 -44") + c.stroke_path("M298 452 q6 -26 0 -44") # paws c.stroke_path("M336 420 c46 8 66 -26 44 -60") # tail @@ -235,7 +258,8 @@ def draw_egg(state): egg_body(c) elif state == 'idle_1': # mid-wiggle: whole egg tilted ~8 degrees - e = Cat(); egg_body(e) + e = Cat() + egg_body(e) c.raw(f'{e.inner()}') elif state == 'crack1': egg_body(c) @@ -243,7 +267,8 @@ def draw_egg(state): elif state == 'crack2': egg_body(c) c.stroke_path("M206 210 l20 16 l-16 18 l22 14 l-14 18 l18 12") # crack spreads down - c.circles.append((290, 250, 11, False)); c.circles.append((290, 250, 4, True)) # peeking eye + c.circles.append((290, 250, 11, False)) + c.circles.append((290, 250, 4, True)) # peeking eye elif state == 'crack3': egg_body(c, lift=18, ear=True) # jagged separation line across the middle @@ -266,7 +291,8 @@ def draw_egg(state): FRAMES = {'idle': [0, 1], 'active': [0, 1], 'sleep': [0]} if __name__ == '__main__': - import sys, os + import sys + import os outdir = sys.argv[1] if len(sys.argv) > 1 else '.' n = 0 for form in FORMS: diff --git a/scripts/pet_golden_oracle.py b/scripts/pet_golden_oracle.py index 21246665..4fc48332 100644 --- a/scripts/pet_golden_oracle.py +++ b/scripts/pet_golden_oracle.py @@ -2,7 +2,8 @@ # Independent spec oracle for the Pulse Cat M1 ruleset. Generates # PetGoldenVectors.json with computed expectations. If the Swift PetEngine # matches this, both match the plan §1.2 spec. -import json, datetime +import json +import datetime RULESET_VERSION = 1 WEIGHT_TABLE_VERSION = 1 @@ -35,9 +36,12 @@ def window_keys(today): return [dkey(t - datetime.timedelta(days=i)) for i in range(WINDOW_DAYS-1, -1, -1)] def resolve_form(dom, tempo): - if dom == "anthropic": return "loaf" if tempo == "steady" else "polite" - if dom == "openai": return "smash" if tempo == "steady" else "pop" - if dom == "google": return "long" + if dom == "anthropic": + return "loaf" if tempo == "steady" else "polite" + if dom == "openai": + return "smash" if tempo == "steady" else "pop" + if dom == "google": + return "long" return "huh" # other / None def usable(u): @@ -60,7 +64,8 @@ def profile(days, today): fam_score = {} for k in keys: for prov, u in days.get(k,{}).items(): - if not usable(u): continue + if not usable(u): + continue fam, w = PROV.get(prov, ("other", 500_000)) fam_score[fam] = fam_score.get(fam,0) + max(0,u.get("tokens",0))*w total = sum(fam_score.values()) @@ -76,7 +81,8 @@ def profile(days, today): dom = top[0] # tempo toks_sorted = sorted(day_tokens.values(), reverse=True) - top3 = sum(toks_sorted[:BURST_TOP]); tt = sum(toks_sorted) + top3 = sum(toks_sorted[:BURST_TOP]) + tt = sum(toks_sorted) burst = tt > 0 and top3*100 >= tt*BURST_PCT tempo = "burst" if burst else "steady" form = resolve_form(dom, tempo) @@ -85,7 +91,8 @@ def profile(days, today): resolvedForm=form, eggStage=egg, activeDays=len(active)) def timing_allows(last, today): - if last is None: return True + if last is None: + return True return today >= shift(last, MIN_DAYS_BETWEEN) def evaluate(days, today, owned, last): @@ -107,8 +114,10 @@ def spread(prov, daykeys, tokens, msgs=0, conf=None): out = {} for k in daykeys: u = {"tokens": tokens} - if msgs: u["messages"] = msgs - if conf: u["confidence"] = conf + if msgs: + u["messages"] = msgs + if conf: + u["confidence"] = conf out.setdefault(k, {})[prov] = u return out @@ -121,7 +130,10 @@ def merge(*maps): T = "2026-07-11" W = window_keys(T) # 07-05 .. 07-11 -d = lambda i: W[i] # index into window, 0=oldest + + +def d(i): # index into window, 0=oldest + return W[i] cases = [] def add(name, days, today=T, owned=None, last=None): @@ -158,7 +170,8 @@ def add(name, days, today=T, owned=None, last=None): # 13 clock rollback (last hatch in the future) add("clock_rollback_no_hatch", spread("Claude", W, 20_000), last=shift(T,9)) # 14 DST spring-forward window (ending 2026-03-09), 6 steady days -Tdst = "2026-03-09"; Wd = window_keys(Tdst) +Tdst = "2026-03-09" +Wd = window_keys(Tdst) add("dst_spring_forward_loaf", spread("Claude", Wd[1:], 25_000), today=Tdst) # 6 days # 15 messages-only (0 weighted tokens) -> NOT qualified even at 3 active days add("messages_only_not_qualified", @@ -206,7 +219,8 @@ def add(name, days, today=T, owned=None, last=None): # 25 timezone travel: window includes frozen keys on both sides of a UTC-midnight # day boundary (same event bucketed to adjacent days in different TZs, per M0). # Engine profiles the frozen keys as distinct active days — no merge, no gap. -Ttrav = "2026-03-21"; Wt = window_keys(Ttrav) # ...03-15..03-21 +Ttrav = "2026-03-21" +Wt = window_keys(Ttrav) # ...03-15..03-21 add("timezone_travel_frozen_keys", merge(spread("Claude", [Wt[2],Wt[3],Wt[4],Wt[5],Wt[6]], 22_000)), today=Ttrav) # 5 steady days # 26 steering: OpenAI-heavy first half, Anthropic-heavy second half — the whole diff --git a/scripts/pet_normalize_assets.py b/scripts/pet_normalize_assets.py index 46cb19fe..35e5c688 100644 --- a/scripts/pet_normalize_assets.py +++ b/scripts/pet_normalize_assets.py @@ -16,7 +16,9 @@ Requires Pillow. IP: the AI-gen prompts must obey the §1.3 red lines (no meme names/likeness); this script only normalizes geometry, it does not create art. """ -import os, sys, re +import os +import sys +import re try: from PIL import Image, ImageDraw, ImageFilter @@ -155,7 +157,8 @@ def _selftest(): for rel, expect in cases.items(): got = parse_target(os.path.join("/in", rel), "/in") status = "ok" if got == expect else "FAIL" - if got != expect: ok = False + if got != expect: + ok = False print(f" [{status}] {rel} -> {got} (expected {expect})") print("selftest passed" if ok else "selftest FAILED") return ok