From 96133ba05a100322f83c4e1a7cacc7a7aedc54d0 Mon Sep 17 00:00:00 2001 From: Michael Foley Date: Fri, 13 Mar 2026 12:47:42 -0400 Subject: [PATCH 1/5] feat: add AGENTS.md, docs site pages, and PBT tests - Create AGENTS.md with all 12 sections following the AGENTS.md open format - Add docs site pages (architecture, contributing, usage, installation, etc.) - Add docs index link back to GitHub repo (mirrors upstream README change) - Add star-their-repo callout for GVM/VideoMaMa in docs/usage.md - Add test_agents_md.py with 18 unit tests and 5 Hypothesis PBT tests - Add test_docs_structure.py for docs site structure validation - docs/LLM_HANDOVER.md remains unmodified --- AGENTS.md | 170 ++++++++++++++ README.md | 1 + docs/_snippets/apple-silicon-note.md | 18 ++ docs/_snippets/model-download.md | 14 ++ docs/_snippets/optional-weights.md | 25 ++ docs/_snippets/uv-install.md | 16 ++ docs/architecture.md | 112 +++++++++ docs/contributing.md | 153 +++++++++++++ docs/device-and-backend-selection.md | 145 ++++++++++++ docs/hardware-requirements.md | 39 ++++ docs/index.md | 63 ++++++ docs/installation.md | 119 ++++++++++ docs/usage.md | 118 ++++++++++ tests/test_agents_md.py | 327 +++++++++++++++++++++++++++ tests/test_docs_structure.py | 320 ++++++++++++++++++++++++++ zensical.toml | 17 +- 16 files changed, 1656 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 docs/_snippets/apple-silicon-note.md create mode 100644 docs/_snippets/model-download.md create mode 100644 docs/_snippets/optional-weights.md create mode 100644 docs/_snippets/uv-install.md create mode 100644 docs/architecture.md create mode 100644 docs/contributing.md create mode 100644 docs/device-and-backend-selection.md create mode 100644 docs/hardware-requirements.md create mode 100644 docs/installation.md create mode 100644 docs/usage.md create mode 100644 tests/test_agents_md.py create mode 100644 tests/test_docs_structure.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f31562b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# CorridorKey — AGENTS.md + +> Agent-facing project guide following the [AGENTS.md open format](https://agents.md). +> For deeper architectural detail, see [`docs/LLM_HANDOVER.md`](docs/LLM_HANDOVER.md). + +## Project Overview + +**CorridorKey** is a neural-network-based green screen removal tool built for professional VFX pipelines. Unlike traditional keyers that produce binary masks, CorridorKey physically unmixes the foreground from the green screen at every pixel — including semi-transparent regions like motion blur, hair, and out-of-focus edges. + +**Core inputs:** + +- **RGB image** — the green screen plate (sRGB color gamut). +- **Coarse Alpha Hint** — a rough black-and-white mask isolating the subject (does not need to be precise). + +**Core outputs:** + +- **Alpha** — a clean, linear alpha channel. +- **Foreground Straight** — the un-multiplied straight color of the foreground element (sRGB), with the green screen contribution removed. + +**License:** [CC-BY-NC-SA-4.0](LICENSE) + +## Architecture & Dataflow + +### GreenFormer Architecture + +The core model is called the **GreenFormer**: + +- **Backbone:** A `timm` **Hiera** vision-transformer (`hiera_base_plus_224.mae_in1k_ft_in1k`), patched to accept 4 input channels (RGB + Coarse Alpha Hint). +- **Decoders:** Multiscale feature fusion heads predicting coarse Alpha (1 ch) and Foreground (3 ch) logits. +- **Refiner (`CNNRefinerModule`):** A custom CNN head with dilated residual blocks. It takes the original RGB input and the coarse predictions, outputting purely additive delta logits applied to the backbone outputs before final Sigmoid activation. + +### Dataflow Rules + +1. **Tensor range:** Model input and output are strictly `[0.0, 1.0]` float tensors. The foreground is sRGB; the alpha is linear. +2. **EXR pipeline:** To build the `Processed` EXR output, the sRGB foreground is converted via the piecewise `srgb_to_linear()` function, then premultiplied by the linear alpha, and saved as half-float EXR (`cv2.IMWRITE_EXR_TYPE_HALF`). +3. **Inference resizing:** The engine is trained on **2048×2048** crops. `inference_engine.py` uses OpenCV **Lanczos4** to resize arbitrary input to 2048×2048, runs inference, then resizes predictions back to the original resolution. +4. **Despill:** A luminance-preserving `despill()` function removes residual green contamination from the foreground. + +> ⚠️ **Gamma 2.2 warning:** Never apply a pure mathematical gamma 2.2 curve. Always use the piecewise sRGB transfer functions defined in `color_utils.py`. A naive power-law curve will produce incorrect results in the toe region and break compositing math. + +## Key File Map + +| Path | Responsibility | +|---|---| +| `CorridorKeyModule/core/model_transformer.py` | GreenFormer PyTorch architecture (Hiera backbone, decoders, CNNRefinerModule) | +| `CorridorKeyModule/inference_engine.py` | `CorridorKeyEngine` class — loads weights, handles 2048×2048 resize and frame processing API | +| `CorridorKeyModule/core/color_utils.py` | Pure math for compositing: `srgb_to_linear()`, `linear_to_srgb()`, `premultiply()`, `despill()` | +| `clip_manager.py` | User-facing CLI wizard — directory scanning, inference settings, piping data to the engine | +| `device_utils.py` | Compute device detection and selection (CUDA / MPS / CPU), backend resolution | +| `backend/` | FastAPI-based backend service: job queue, project management, FFmpeg tools, frame I/O | + +## Dev Environment Setup + +**Prerequisites:** Python ≥ 3.10 and [uv](https://docs.astral.sh/uv/). + +uv handles Python installation, virtual environment creation, and package management — no manual `pip install` or virtualenv setup required. + +```bash +git clone https://github.com/nikopueringer/CorridorKey.git +cd CorridorKey +uv sync --group dev # installs all dependencies + dev tools (pytest, ruff, hypothesis) +``` + +## Build & Test Commands + +```bash +uv run pytest # run all tests +uv run pytest -v # verbose output +uv run pytest -m "not gpu" # skip GPU-dependent tests +uv run ruff check # lint check +uv run ruff format --check # formatting check (no changes) +uv run ruff format # auto-format +``` + +Tests that require a CUDA GPU are marked with `@pytest.mark.gpu` and are automatically skipped when no GPU is available. CI runs `pytest -m "not gpu"` to exclude them. + +## Code Style + +The project uses **[Ruff](https://docs.astral.sh/ruff/)** for both linting and formatting. + +| Setting | Value | +|---|---| +| Lint rules | `E`, `F`, `W`, `I`, `B` | +| Line length | 120 | +| Target version | `py311` | +| Excluded dirs | `gvm_core/`, `VideoMaMaInferenceModule/` | + +`gvm_core/` and `VideoMaMaInferenceModule/` are third-party research code kept close to upstream — they are excluded from lint enforcement. + +## Platform-Specific Caveats + +### Apple Silicon (macOS) + +- **`uv.lock` drift:** Running `uv run pytest` on macOS regenerates `uv.lock` with macOS-specific markers. **Do not commit this file.** Before staging changes, always run: + ```bash + git restore uv.lock + ``` +- **MPS operator fallback:** Some PyTorch operations are not yet implemented for MPS. Enable CPU fallback: + ```bash + export PYTORCH_ENABLE_MPS_FALLBACK=1 + ``` + +### Windows + +- **CUDA 12.8:** GPU acceleration on Windows requires NVIDIA drivers supporting **CUDA 12.8** or higher. Older drivers will cause a silent fallback to CPU. + +## Prohibited Actions + +1. **Do not apply a pure gamma 2.2 curve.** Always use the piecewise sRGB transfer functions in `color_utils.py`. A naive `pow(x, 2.2)` breaks the toe region and produces incorrect compositing results. +2. **Do not modify files inside `gvm_core/` or `VideoMaMaInferenceModule/`.** These are third-party research modules kept close to upstream. Changes should be made in wrapper code or upstream PRs. +3. **Do not commit `uv.lock` changes made on macOS.** Apple Silicon resolves platform-specific markers that differ from the Linux CI lockfile. Always `git restore uv.lock` before committing. + +## PR Workflow & GitHub Templates + +### Workflow + +1. Fork the repo and create a branch for your change. +2. Make your changes. +3. Run `uv run pytest` and `uv run ruff check` to verify everything passes. +4. Open a pull request against `main`. + +PR descriptions should focus on **why** the change was made, not just what changed. If fixing a bug, describe the symptoms. If adding a feature, explain the use case. + +Before preparing any pull request, check `.github/` for PR templates, issue templates, and CI workflows. + +### PR Template + +The repository includes a PR template (`.github/pull_request_template.md`) with the following structure: + +- **"What does this change?"** — Explain the motivation and scope of the change. +- **"How was it tested?"** — Describe specific test steps or commands run to verify correctness. +- **Checklist:** + - `uv run pytest` passes + - `uv run ruff check` passes + - `uv run ruff format --check` passes + +Fill in all sections thoroughly. The "What does this change?" section should explain motivation, and "How was it tested?" should describe specific test steps or commands run. + +### CI Workflow (`ci.yml`) + +Runs on every push and pull request to `main`: + +- **Lint job:** `ruff format --check` + `ruff check`. +- **Test job:** `pytest -v --tb=short -m "not gpu"` on Python **3.10** and **3.13**. GPU tests are excluded via the `-m "not gpu"` marker filter. + +### Docs Workflow (`docs.yml`) + +Triggers on pushes to `main` that change files matching `docs/**` or `zensical.toml`. Builds and deploys the documentation site to GitHub Pages via **Zensical**. + +## Documentation Accuracy + +When making code changes, evaluate whether the change affects the accuracy of existing documentation. If a code change alters behavior, CLI flags, file paths, or configuration described in the docs, flag or update the outdated documentation. + +Documentation files to check: + +- `README.md` +- `CONTRIBUTING.md` +- `AGENTS.md` +- `docs/LLM_HANDOVER.md` +- All pages under `docs/` + +## AI Directives + +- **Skip basic tutorials.** The user is a VFX professional and coder. Dive straight into advanced implementation guidance, but document math thoroughly. +- **Prioritize performance.** This is video processing — every `.numpy()` transfer or `cv2.resize` matters in a loop running on 4K footage. +- **Check sRGB-to-linear conversion order.** If the user reports "crushed shadows" or "dark fringes", the problem is almost certainly an sRGB-to-linear conversion step happening in the wrong order inside `color_utils.py`. + +## Further Reading + +- [`docs/LLM_HANDOVER.md`](docs/LLM_HANDOVER.md) — Detailed architecture walkthrough, dataflow properties, inference pipeline, and AI directives for the CorridorKey codebase. diff --git a/README.md b/README.md index 933bbd49..c40909ee 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ https://github.com/user-attachments/assets/1fb27ea8-bc91-4ebc-818f-5a3b5585af08 +> 📖 **[Full Documentation](https://nikopueringer.github.io/CorridorKey)** — Installation guides, usage instructions, and developer docs. When you film something against a green screen, the edges of your subject inevitably blend with the green background. This creates pixels that are a mix of your subject's color and the green screen's color. Traditional keyers struggle to untangle these colors, forcing you to spend hours building complex edge mattes or manually rotoscoping. Even modern "AI Roto" solutions typically output a harsh binary mask, completely destroying the delicate, semi-transparent pixels needed for a realistic composite. diff --git a/docs/_snippets/apple-silicon-note.md b/docs/_snippets/apple-silicon-note.md new file mode 100644 index 00000000..d1403cce --- /dev/null +++ b/docs/_snippets/apple-silicon-note.md @@ -0,0 +1,18 @@ +!!! note "Apple Silicon (MPS / MLX)" + CorridorKey runs on Apple Silicon Macs using unified memory. Two backend + options are available: + + - **MPS** — PyTorch's Metal Performance Shaders backend. Works out of the + box but some operators may require the CPU fallback flag: + + ```bash + export PYTORCH_ENABLE_MPS_FALLBACK=1 + ``` + + - **MLX** — Native Apple Silicon acceleration via the + [MLX framework](https://github.com/ml-explore/mlx). Avoids PyTorch's MPS + layer entirely and typically runs faster. Requires installing the MLX + extras (`uv sync --extra mlx`) and obtaining `.safetensors` weights. + + Because Apple Silicon shares memory between the CPU and GPU, the full + system RAM is available to the model — no separate VRAM budget applies. diff --git a/docs/_snippets/model-download.md b/docs/_snippets/model-download.md new file mode 100644 index 00000000..8e49cc9a --- /dev/null +++ b/docs/_snippets/model-download.md @@ -0,0 +1,14 @@ +**Download the CorridorKey checkpoint (~300 MB):** + +[Download CorridorKey_v1.0.pth from Hugging Face](https://huggingface.co/nikopueringer/CorridorKey_v1.0/resolve/main/CorridorKey_v1.0.pth){ .md-button } + +Place the file inside `CorridorKeyModule/checkpoints/` and rename it to +**`CorridorKey.pth`** so the final path is: + +``` +CorridorKeyModule/checkpoints/CorridorKey.pth +``` + +!!! warning + The engine will not start without this checkpoint. Make sure the filename + is exactly `CorridorKey.pth` (not `CorridorKey_v1.0.pth`). diff --git a/docs/_snippets/optional-weights.md b/docs/_snippets/optional-weights.md new file mode 100644 index 00000000..18a00384 --- /dev/null +++ b/docs/_snippets/optional-weights.md @@ -0,0 +1,25 @@ +!!! tip "Optional — GVM and VideoMaMa weights" + These modules generate Alpha Hints automatically but have large model files + and extreme hardware requirements. Installing them is **completely optional**; + you can always provide your own Alpha Hints from other software. + + **GVM** (~80 GB VRAM required): + + ```bash + uv run hf download geyongtao/gvm --local-dir gvm_core/weights + ``` + + **VideoMaMa** (originally 80 GB+ VRAM; community optimisations bring it + under 24 GB, though not yet fully integrated here): + + ```bash + # Fine-tuned VideoMaMa weights + uv run hf download SammyLim/VideoMaMa \ + --local-dir VideoMaMaInferenceModule/checkpoints/VideoMaMa + + # Stable Video Diffusion base model (VAE + image encoder, ~2.5 GB) + # Accept the licence at stabilityai/stable-video-diffusion-img2vid-xt first + uv run hf download stabilityai/stable-video-diffusion-img2vid-xt \ + --local-dir VideoMaMaInferenceModule/checkpoints/stable-video-diffusion-img2vid-xt \ + --include "feature_extractor/*" "image_encoder/*" "vae/*" "model_index.json" + ``` diff --git a/docs/_snippets/uv-install.md b/docs/_snippets/uv-install.md new file mode 100644 index 00000000..429e4221 --- /dev/null +++ b/docs/_snippets/uv-install.md @@ -0,0 +1,16 @@ +This project uses **[uv](https://docs.astral.sh/uv/)** to manage Python and all +dependencies. uv is a fast, modern replacement for pip that automatically +handles Python versions, virtual environments, and package installation in a +single step. You do **not** need to install Python yourself — uv does it for +you. + +Install uv if you don't already have it: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +!!! tip + On Windows the automated `.bat` installers handle uv installation for you. + If you open a new terminal after installing uv and see `'uv' is not + recognized`, close and reopen the terminal so the updated PATH takes effect. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..92eea84d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,112 @@ +# Architecture + +CorridorKey is a neural-network-based green screen removal tool. It takes an +RGB image and a "Coarse Alpha Hint" (a rough mask isolating the subject) and +produces mathematically perfect, physically unmixed Alpha and Foreground +Straight color — with the green screen unmixed from semi-transparent pixels. + +For the full technical handover document aimed at AI assistants, see the +[LLM Handover](LLM_HANDOVER.md) page. + +--- + +## The GreenFormer Model + +The core architecture is called the **GreenFormer**. It combines a +vision-transformer backbone with a convolutional refiner head. + +### Backbone — Hiera + +The backbone is a [timm](https://github.com/huggingface/pytorch-image-models) +implementation of `hiera_base_plus_224.mae_in1k_ft_in1k`. The first layer is +patched to accept **4 input channels** (RGB + Coarse Alpha Hint) instead of the +standard 3. + +### Decoders + +Multiscale feature-fusion heads sit on top of the backbone and predict: + +- **Coarse Alpha** (1 channel) +- **Coarse Foreground** (3 channels) + +### CNN Refiner (`CNNRefinerModule`) + +A custom CNN head built from dilated residual blocks. It receives the original +RGB input together with the coarse predictions from the backbone and outputs +purely **additive Delta Logits**. These deltas are applied directly to the +backbone's outputs before the final Sigmoid activation, refining edge detail +without replacing the backbone's predictions. + +--- + +## Critical Dataflow Properties + +The biggest challenge in this codebase is **color space** and **gamma math**. +When debugging compositing issues, check these rules first. + +### 1. Model Input / Output — Strictly `[0.0, 1.0]` Float Tensors + +- The model assumes inputs are **sRGB**. +- The predicted **Foreground** (`res['fg']`) is natively sRGB — the model is + trained to predict the un-multiplied straight-color foreground element. +- The predicted **Alpha** (`res['alpha']`) is inherently **Linear**. + +### 2. EXR Handling (the `Processed` Output Pass) + +EXR files store Linear float data, premultiplied. To build the `Processed` EXR: + +1. Take the sRGB foreground. +2. Convert it through `srgb_to_linear()` (the piecewise real sRGB transfer + function defined in `color_utils.py` — **not** a pure mathematical + γ = 2.2 curve). +3. Premultiply by the Linear Alpha. +4. Save via OpenCV with `cv2.IMWRITE_EXR_TYPE_HALF`. + +!!! warning "Bug History" + Do **not** apply a pure γ 2.2 curve. Always use the piecewise sRGB + transfer functions in `color_utils.py`. + +### 3. Inference Resizing (`img_size`) + +The engine is strictly trained on **2048 × 2048** crops. In +`inference_engine.py`, `process_frame()` uses OpenCV (Lanczos4) to +upscale/downscale the user's arbitrary input resolution to 2048 × 2048, feeds +the model, and then resizes the predictions back to the original resolution. + +--- + +## Key Source Files + +| File | Responsibility | +|------|----------------| +| `CorridorKeyModule/core/model_transformer.py` | PyTorch architecture definition — Hiera backbone + CNN Refiner head. | +| `CorridorKeyModule/inference_engine.py` | `CorridorKeyEngine` class — loads weights, handles resize API, packs output passes. | +| `CorridorKeyModule/core/color_utils.py` | Pure-math compositing utilities: `srgb_to_linear()`, `premultiply()`, luminance-preserving `despill()`, morphological matte cleaning. | +| `clip_manager.py` | User-facing CLI wizard — scans directories, prompts for settings, pipes frames into the engine. | + +--- + +## Inference Pipeline Overview + +Users typically launch the system via the shell scripts +(`CorridorKey_DRAG_CLIPS_HERE_local.bat` / `.sh`) which boot the +`clip_manager.py` wizard. + +1. **Scan** — Looks for folders containing an `Input` sequence (RGB) and an + `AlphaHint` sequence (BW). +2. **Config** — Prompts for settings (gamma space, despill strength, + auto-despeckle threshold, refiner strength). +3. **Execution** — Loops frame-by-frame, passing `[H, W, 3]` NumPy arrays to + `engine.process_frame()`. +4. **Export** — Writes four output folders: + + | Folder | Format | Color Space | + |--------|--------|-------------| + | `FG/` | Half-float EXR, RGB | sRGB (convert to linear before compositing) | + | `Matte/` | Half-float EXR, Grayscale | Linear | + | `Processed/` | Half-float EXR, RGBA | Linear, Premultiplied | + | `Comp/` | 8-bit PNG | sRGB composite over checkerboard (preview) | + +For deeper implementation details, see the +[CorridorKeyModule README](https://github.com/nikopueringer/CorridorKey/tree/main/CorridorKeyModule) +and the [LLM Handover](LLM_HANDOVER.md) document. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..eebf3618 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,153 @@ +# Contributing + +Thanks for your interest in improving CorridorKey! Whether you're a VFX artist, +a pipeline TD, or a machine learning researcher, contributions of all kinds are +welcome — bug reports, feature ideas, documentation fixes, and code. + +## Legal Agreement + +By contributing to this project you agree that your contributions will be +licensed under the project's +**[CorridorKey Licence](https://github.com/nikopueringer/CorridorKey/blob/main/LICENSE)**. + +By submitting a Pull Request you specifically acknowledge and agree to the terms +set forth in **Section 6 (CONTRIBUTIONS)** of the license. This ensures that +Corridor Digital maintains the full right to use, distribute, and sublicense +this codebase, including PR contributions. + +--- + +## Prerequisites + +- Python 3.10 or newer +- [uv](https://docs.astral.sh/uv/) for dependency management + +--8<-- "docs/_snippets/uv-install.md" + +--- + +## Dev Setup + +```bash +git clone https://github.com/nikopueringer/CorridorKey.git +cd CorridorKey +uv sync --group dev # installs all dependencies + dev tools (pytest, ruff) +``` + +That's it. No manual virtualenv creation, no `pip install` — uv handles +everything. + +--- + +## Running Tests + +```bash +uv run pytest # run all tests +uv run pytest -v # verbose (shows each test name) +uv run pytest -m "not gpu" # skip tests that need a CUDA GPU +uv run pytest --cov # show test coverage +``` + +Most tests run in a few seconds and don't need a GPU or model weights. Tests +that require CUDA are marked with `@pytest.mark.gpu` and will be skipped +automatically if no GPU is available. + +--- + +## Apple Silicon (Mac) Notes + +--8<-- "docs/_snippets/apple-silicon-note.md" + +If you are contributing on an Apple Silicon Mac, there are a few extra things to +be aware of. + +### `uv.lock` Drift + +Running `uv run pytest` on macOS regenerates `uv.lock` with macOS-specific +dependency markers. **Do not commit this file.** Before staging your changes, +always run: + +```bash +git restore uv.lock +``` + +### Backend Selection + +CorridorKey auto-detects MPS on Apple Silicon. To test with the MLX backend or +force CPU, set the environment variable before running: + +```bash +export CORRIDORKEY_BACKEND=mlx # use native MLX on Apple Silicon +export CORRIDORKEY_DEVICE=cpu # force CPU (useful for isolating device bugs) +``` + +### MPS Operator Fallback + +If PyTorch raises an error about an unsupported MPS operator, enable CPU +fallback for those ops: + +```bash +export PYTORCH_ENABLE_MPS_FALLBACK=1 +``` + +--- + +## Linting and Formatting + +The project uses [ruff](https://docs.astral.sh/ruff/) for both linting and +formatting. + +```bash +uv run ruff check # check for lint errors +uv run ruff format --check # check formatting (no changes) +uv run ruff format # auto-format your code +``` + +| Setting | Value | +|---------|-------| +| Lint rules | `E, F, W, I, B` (basic style, unused imports, import sorting, common bug patterns) | +| Line length | 120 characters | +| Excluded dirs | `gvm_core/`, `VideoMaMaInferenceModule/` (third-party research code kept close to upstream) | + +CI runs both checks on every pull request. Running them locally before pushing +saves a round-trip. + +--- + +## Making Changes + +### Pull Request Workflow + +1. Fork the repo and create a branch for your change. +2. Make your changes. +3. Run `uv run pytest` and `uv run ruff check` to make sure everything passes. +4. Open a pull request against `main`. + +In your PR description, focus on **why** you made the change, not just what +changed. If you're fixing a bug, describe the symptoms. If you're adding a +feature, explain the use case. A couple of sentences is plenty. + +### What Makes a Good Contribution + +- **Bug fixes** — especially for edge cases in EXR/linear workflows, color + space handling, or platform-specific issues. +- **Tests** — more test coverage is always welcome, particularly for + `clip_manager.py` and `inference_engine.py`. +- **Documentation** — better explanations, usage examples, or clarifying + comments in tricky code. +- **Performance** — reducing GPU memory usage, speeding up frame processing, or + optimizing I/O. + +### Model Weights + +The model checkpoint (`CorridorKey_v1.0.pth`) and optional GVM/VideoMaMa +weights are **not** in the git repo. Most tests don't need them. If you're +working on inference code and need the weights, follow the download instructions +in the [Installation](installation.md) guide. + +--- + +## Questions? + +Join the [Discord](https://discord.gg/zvwUrdWXJm) — it's the fastest way to +get help or discuss ideas before opening a PR. diff --git a/docs/device-and-backend-selection.md b/docs/device-and-backend-selection.md new file mode 100644 index 00000000..c4bfa541 --- /dev/null +++ b/docs/device-and-backend-selection.md @@ -0,0 +1,145 @@ +# Device and Backend Selection + +## Device Selection + +By default, CorridorKey auto-detects the best available compute device in this +priority order: + +**CUDA → MPS → CPU** + +### Override via CLI Flag + +```bash +uv run python clip_manager.py --action wizard --win_path "V:\..." --device mps +uv run python clip_manager.py --action run_inference --device cpu +``` + +### Override via Environment Variable + +```bash +export CORRIDORKEY_DEVICE=cpu +uv run python clip_manager.py --action wizard --win_path "V:\..." +``` + +!!! info "Resolution order" + `--device` flag > `CORRIDORKEY_DEVICE` env var > auto-detect. + +--8<-- "docs/_snippets/apple-silicon-note.md" + +## Backend Selection + +CorridorKey supports two inference backends: + +| Backend | Platforms | Notes | +|---|---|---| +| **Torch** (default on Linux / Windows) | CUDA, MPS, CPU | Standard PyTorch inference. | +| **MLX** (Apple Silicon) | Metal | Native Apple Silicon acceleration — avoids PyTorch's MPS layer entirely and typically runs faster. | + +### Override via CLI Flag + +```bash +uv run python corridorkey_cli.py --action wizard --win_path "/path/to/clips" --backend mlx +uv run python corridorkey_cli.py --action run_inference --backend torch +``` + +### Override via Environment Variable + +```bash +export CORRIDORKEY_BACKEND=mlx +uv run python corridorkey_cli.py --action run_inference +``` + +!!! info "Resolution order" + `--backend` flag > `CORRIDORKEY_BACKEND` env var > auto-detect. + + Auto mode prefers MLX on Apple Silicon when the package is installed. + + +## MLX Setup (Apple Silicon) + +Follow these steps to use the native MLX backend on an M1+ Mac. + +### 1. Install the MLX Extras + +```bash +uv sync --extra mlx +``` + +### 2. Obtain MLX Weights (`.safetensors`) + +=== "Option A — Download Pre-Converted Weights (simplest)" + + ```bash + # Download weights from GitHub Releases into a local cache directory + uv run python -m corridorkey_mlx weights download + + # Print the cached path, then copy to the checkpoints folder + WEIGHTS=$(uv run python -m corridorkey_mlx weights download --print-path) + cp "$WEIGHTS" CorridorKeyModule/checkpoints/corridorkey_mlx.safetensors + ``` + +=== "Option B — Convert from an Existing `.pth` Checkpoint" + + ```bash + # Clone the MLX repo (contains the conversion script) + git clone https://github.com/nikopueringer/corridorkey-mlx.git + cd corridorkey-mlx + uv sync + + # Convert (point --checkpoint at your CorridorKey.pth) + uv run python scripts/convert_weights.py \ + --checkpoint ../CorridorKeyModule/checkpoints/CorridorKey_v1.0.pth \ + --output ../CorridorKeyModule/checkpoints/corridorkey_mlx.safetensors + cd .. + ``` + +Either way, the final file must be at: + +``` +CorridorKeyModule/checkpoints/corridorkey_mlx.safetensors +``` + +### 3. Run + +```bash +CORRIDORKEY_BACKEND=mlx uv run python clip_manager.py --action run_inference +``` + +MLX uses `img_size=2048` by default (same as Torch). + +## Troubleshooting + +### MPS (PyTorch Metal) + +**Confirm MPS is active** — run with verbose logging to see which device was +selected: + +```bash +uv run python clip_manager.py --action list 2>&1 | grep -i "device\|backend\|mps" +``` + +**MPS operator errors** (`NotImplementedError: ... not implemented for 'MPS'`): +Some PyTorch operations are not yet supported on MPS. Enable CPU fallback: + +```bash +export PYTORCH_ENABLE_MPS_FALLBACK=1 +uv run python corridorkey_cli.py --action wizard --win_path "/path/to/clips" +``` + +!!! tip "Make the fallback permanent" + Add `export PYTORCH_ENABLE_MPS_FALLBACK=1` to your shell profile + (`~/.zshrc`) so it is always active. Without it, MPS may silently fall back + to CPU, making runs much slower. + +**Use native MLX instead of PyTorch MPS** — MLX avoids PyTorch's MPS layer +entirely and typically runs faster on Apple Silicon. See the +[MLX Setup](#mlx-setup-apple-silicon) section above. + +### MLX + +| Symptom | Fix | +|---|---| +| `No .safetensors checkpoint found` | Place MLX weights in `CorridorKeyModule/checkpoints/`. | +| `corridorkey_mlx not installed` | Run `uv sync --extra mlx`. | +| `MLX requires Apple Silicon` | MLX only works on M1+ Macs. | +| Auto picked Torch unexpectedly | Set `CORRIDORKEY_BACKEND=mlx` explicitly. | diff --git a/docs/hardware-requirements.md b/docs/hardware-requirements.md new file mode 100644 index 00000000..e5bcb7b0 --- /dev/null +++ b/docs/hardware-requirements.md @@ -0,0 +1,39 @@ +# Hardware Requirements + +CorridorKey was designed and built on a Linux workstation equipped with an +NVIDIA RTX Pro 6000 (96 GB VRAM). The community is actively optimising it for +consumer GPUs — the most recent build should work on cards with **6–8 GB of +VRAM**, and it can run on most Mac systems with unified memory. + +## Core Engine (CorridorKey) + +| Spec | Minimum | Recommended | +|---|---|---| +| GPU VRAM | 6 GB | 8 GB+ | +| Compute | CUDA, MPS, or CPU | CUDA (NVIDIA) | +| System RAM | 8 GB | 16 GB+ | + +The engine dynamically scales inference to its native 2048×2048 backbone, so +more VRAM allows larger plates to be processed without tiling. + +!!! warning "Windows CUDA driver requirement" + To run GPU acceleration natively on Windows, your system **must** have + NVIDIA drivers that support **CUDA 12.8 or higher**. If your drivers only + support older CUDA versions, the installer will likely fall back to the CPU. + +## Optional Modules + +GVM and VideoMaMa are optional Alpha Hint generators with significantly higher +hardware requirements. You do **not** need them — you can always provide your +own Alpha Hints from other software. + +--8<-- "docs/_snippets/optional-weights.md" + +| Module | VRAM Required | Notes | +|---|---|---| +| **GVM** | ~80 GB | Uses massive Stable Video Diffusion models. | +| **VideoMaMa** | 80 GB+ (native) / <24 GB (community optimised) | Community tweaks reduce VRAM, but extreme optimisations are not yet fully integrated in this repo. | + +## Apple Silicon + +--8<-- "docs/_snippets/apple-silicon-note.md" diff --git a/docs/index.md b/docs/index.md index e69de29b..56ba94b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -0,0 +1,63 @@ +# CorridorKey + +> 📖 **[GitHub Repository](https://github.com/nikopueringer/CorridorKey)** — Source code, issues, and releases. + +When you film something against a green screen, the edges of your subject +inevitably blend with the green background — creating pixels that mix your +subject's true color with the screen. Traditional keyers struggle to untangle +these colors, and even modern "AI Roto" solutions typically output a harsh +binary mask, destroying the delicate semi-transparent pixels needed for a +realistic composite. + +CorridorKey solves this *unmixing* problem. You input a raw green screen frame, +and the neural network completely separates the foreground object from the green +screen. For every single pixel — even highly transparent ones like motion blur +or out-of-focus edges — the model predicts the true, un-multiplied straight +color of the foreground element alongside a clean, linear alpha channel. + +No more fighting with garbage mattes or agonizing over "core" vs "edge" keys. +Give CorridorKey a hint of what you want, and it separates the light for you. + +## Features + +- **Physically Accurate Unmixing** — Clean extraction of straight color + foreground and linear alpha channels, preserving hair, motion blur, and + translucency. +- **Resolution Independent** — The engine dynamically scales inference to + handle 4K plates while predicting using its native 2048×2048 high-fidelity + backbone. +- **VFX Standard Outputs** — Natively reads and writes 16-bit and 32-bit + Linear float EXR files, preserving true color math for integration in Nuke, + Fusion, or Resolve. +- **Auto-Cleanup** — Includes a morphological cleanup system to automatically + prune any tracking markers or tiny background features that slip through + detection. + +## Get Started + +
+ +- :material-book-open-variant:{ .lg .middle } **User Guide** + + --- + + Installation, hardware requirements, usage instructions, and device + configuration. + + [:octicons-arrow-right-24: Installation](installation.md) + · [Hardware Requirements](hardware-requirements.md) + · [Usage](usage.md) + · [Device & Backend Selection](device-and-backend-selection.md) + +- :material-code-braces:{ .lg .middle } **Developer Guide** + + --- + + Architecture overview, contribution guidelines, and the LLM handover + document for AI-assisted development. + + [:octicons-arrow-right-24: Architecture](architecture.md) + · [Contributing](contributing.md) + · [LLM Handover](LLM_HANDOVER.md) + +
diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 00000000..7ee75bf3 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,119 @@ +# Installation + +--8<-- "docs/_snippets/uv-install.md" + +## Platform Setup + +=== "Windows" + + 1. Clone or download this repository to your local machine. + 2. Double-click `Install_CorridorKey_Windows.bat`. This will automatically + install uv (if needed), set up your Python environment, install all + dependencies, and download the CorridorKey model. + + !!! note "CUDA driver requirement" + To run GPU acceleration natively on Windows, your system **must** + have NVIDIA drivers that support **CUDA 12.8 or higher**. If your + drivers only support older CUDA versions, the installer will likely + fall back to the CPU. + + 3. *(Optional)* Double-click `Install_GVM_Windows.bat` and + `Install_VideoMaMa_Windows.bat` to download the heavy optional Alpha + Hint generator weights. + +=== "Linux / Mac" + + 1. Clone or download this repository to your local machine. + 2. Install uv if you don't have it: + + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + + 3. Install all dependencies (uv will download Python 3.10+ automatically + if needed): + + ```bash + uv sync # CPU/MPS (default — works everywhere) + uv sync --extra cuda # CUDA GPU acceleration (Linux/Windows) + uv sync --extra mlx # Apple Silicon MLX acceleration + ``` + +## Download the Model Checkpoint + +--8<-- "docs/_snippets/model-download.md" + +## Optional Weights + +--8<-- "docs/_snippets/optional-weights.md" + +## Docker (Linux + NVIDIA GPU) + +If you prefer not to install dependencies locally, you can run CorridorKey in +Docker. + +### Prerequisites + +- Docker Engine + Docker Compose plugin installed. +- NVIDIA driver installed on the host (Linux), with CUDA compatibility for the + PyTorch CUDA 12.6 wheels used by this project. +- NVIDIA Container Toolkit installed and configured for Docker (`nvidia-smi` + should work on the host, and + `docker run --rm --gpus all nvidia/cuda:12.6.3-runtime-ubuntu22.04 nvidia-smi` + should succeed). + +### Build and Run + +1. Build the image: + + ```bash + docker build -t corridorkey:latest . + ``` + +2. Run an action directly (example — inference): + + ```bash + docker run --rm -it --gpus all \ + -e OPENCV_IO_ENABLE_OPENEXR=1 \ + -v "$(pwd)/ClipsForInference:/app/ClipsForInference" \ + -v "$(pwd)/Output:/app/Output" \ + -v "$(pwd)/CorridorKeyModule/checkpoints:/app/CorridorKeyModule/checkpoints" \ + -v "$(pwd)/gvm_core/weights:/app/gvm_core/weights" \ + -v "$(pwd)/VideoMaMaInferenceModule/checkpoints:/app/VideoMaMaInferenceModule/checkpoints" \ + corridorkey:latest --action run_inference --device cuda + ``` + +3. Docker Compose (recommended for repeat runs): + + ```bash + docker compose build + docker compose --profile gpu run --rm corridorkey --action run_inference --device cuda + docker compose --profile gpu run --rm corridorkey --action list + docker compose --profile cpu run --rm corridorkey-cpu --action run_inference --device cpu + ``` + +4. *(Optional)* Pin to specific GPU(s) for multi-GPU workstations: + + ```bash + NVIDIA_VISIBLE_DEVICES=0 docker compose --profile gpu run --rm corridorkey --action list + NVIDIA_VISIBLE_DEVICES=1,2 docker compose --profile gpu run --rm corridorkey --action run_inference --device cuda + ``` + +!!! info "Notes" + - You still need to place model weights in the same folders used by native + runs (mounted above). + - The container does not include kernel GPU drivers; those always come from + the host. The image provides user-space dependencies and relies on + Docker's NVIDIA runtime to pass through driver libraries/devices. + - The wizard works too — use a path inside the container, for example: + + ```bash + docker run --rm -it --gpus all \ + -e OPENCV_IO_ENABLE_OPENEXR=1 \ + -v "$(pwd)/ClipsForInference:/app/ClipsForInference" \ + -v "$(pwd)/Output:/app/Output" \ + -v "$(pwd)/CorridorKeyModule/checkpoints:/app/CorridorKeyModule/checkpoints" \ + -v "$(pwd)/gvm_core/weights:/app/gvm_core/weights" \ + -v "$(pwd)/VideoMaMaInferenceModule/checkpoints:/app/VideoMaMaInferenceModule/checkpoints" \ + corridorkey:latest --action wizard --win_path /app/ClipsForInference + ``` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 00000000..707c96cd --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,118 @@ +# Usage + +## How It Works + +CorridorKey requires two inputs to process a frame: + +1. **The Original RGB Image** — your green screen footage. This requires the + sRGB colour gamut (interchangeable with Rec. 709), and the engine can ingest + either an sRGB gamma or Linear gamma curve. +2. **A Coarse Alpha Hint** — a rough black-and-white mask that generally + isolates the subject. It does *not* need to be precise; a rough chroma key + or AI roto is enough. + +!!! tip "Better hints → better results" + The model was trained on coarse, blurry, eroded masks and is exceptional at + filling in details from the hint. However, it is generally less effective at + *subtracting* unwanted mask details if your Alpha Hint is expanded too far. + Experiment with different amounts of mask erosion or feathering. + +### Alpha Hint Generators + +Two optional modules are bundled for automatic hint generation inside +`clip_manager.py`: + +| Generator | Input Required | Strengths | +|---|---|---| +| **GVM** | None — fully automatic | Excellent for people; can struggle with inanimate objects. | +| **VideoMaMa** | A rough `VideoMamaMaskHint` (hand-drawn or AI-generated) | Spectacular results with finer control via the mask hint. | + +If you choose VideoMaMa, place your mask hint in the `VideoMamaMaskHint/` +folder that the wizard creates for your shot. + +!!! note "Show these projects some love" + GVM and VideoMaMa are open-source research projects. Please star their + repos: [VideoMaMa](https://github.com/cvlab-kaist/VideoMaMa) and + [GVM](https://github.com/aim-uofa/GVM). + +--8<-- "docs/_snippets/model-download.md" + +## The Command Line Wizard + +For the easiest experience, use the provided launcher scripts. They start a +prompt-based configuration wizard in your terminal. + +=== "Windows" + + Drag-and-drop a video file or folder onto + `CorridorKey_DRAG_CLIPS_HERE_local.bat`. + + !!! note + Only launch via drag-and-drop or from CMD. Double-clicking the `.bat` + directly will throw an error. + +=== "Linux / Mac" + + Run or drag-and-drop a video file or folder onto + `./CorridorKey_DRAG_CLIPS_HERE_local.sh`. + + +### Workflow Steps + +1. **Launch** — Drag-and-drop a single loose video file (e.g. `.mp4`), a shot + folder containing image sequences, or a master "batch" folder with multiple + shots onto the launcher script. + +2. **Organisation** — The wizard detects what you dragged in. If you dropped + loose video files or unorganised folders, it will ask whether to organise + them into the proper structure: + + ``` + YourShot/ + ├── Input/ # original green screen frames + ├── AlphaHint/ # coarse alpha masks + └── VideoMamaMaskHint/ # (optional) mask hints for VideoMaMa + ``` + + This structure is required for the engine to pair your hints and footage + correctly. + +3. **Generate Hints (Optional)** — If the wizard detects missing `AlphaHint` + frames, it offers to generate them automatically using GVM or VideoMaMa. + +4. **Configure** — Once clips have both Inputs and AlphaHints, select + *Process Ready Clips*. The wizard prompts you to configure the run: + + | Option | Description | + |---|---| + | **Gamma Space** | Tell the engine whether your sequence uses a **Linear** or **sRGB** gamma curve. | + | **Despill Strength** | Traditional despill filter (0–10). Set to 0 to handle despill in your comp later. | + | **Auto-Despeckle** | Toggle automatic cleanup and define the size threshold. Removes tracking dots and any small disconnected pixel islands. | + | **Refiner Strength** | Use the default (`1.0`) unless experimenting with extreme detail pushing. | + +5. **Result** — The engine generates output folders inside your shot directory + (see below). + +## Folder Structure + +### Input Folders + +| Folder | Purpose | +|---|---| +| `Input/` | Original green screen frames (sRGB gamut, sRGB or Linear gamma). | +| `AlphaHint/` | Coarse alpha masks — one per frame, matching filenames. | +| `VideoMamaMaskHint/` | *(Optional)* Rough mask hints for the VideoMaMa generator. | + +### Output Folders + +| Folder | Format | Colour Space | Description | +|---|---|---|---| +| `Matte/` | 32-bit EXR | Linear | Raw linear alpha channel. | +| `FG/` | 32-bit EXR | sRGB gamut, sRGB gamma | Raw straight foreground colour. Convert to linear gamma before combining with the alpha in your compositing program. | +| `Processed/` | 32-bit EXR | Linear (premultiplied) | RGBA image — linear foreground premultiplied against linear alpha. Drop straight into Premiere / Resolve for a quick preview without complex premultiplication routing. | +| `Comp/` | PNG | sRGB | Preview of the key composited over a checkerboard. | + +!!! info "Working with raw outputs" + The `Processed/` pass is a convenience for quick previews. For maximum + control in Nuke, Fusion, or Resolve, work with the separate `FG/` and + `Matte/` outputs and handle premultiplication yourself. diff --git a/tests/test_agents_md.py b/tests/test_agents_md.py new file mode 100644 index 00000000..822ab9fb --- /dev/null +++ b/tests/test_agents_md.py @@ -0,0 +1,327 @@ +"""Unit tests and property-based tests for AGENTS.md content. + +Feature: agents-md-setup +""" + +from __future__ import annotations + +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parents[1] +AGENTS_MD_PATH = REPO_ROOT / "AGENTS.md" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _read_agents_md() -> str: + """Read AGENTS.md and return its full text content.""" + return AGENTS_MD_PATH.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Unit Tests — File existence, structure, and specific content +# --------------------------------------------------------------------------- + + +class TestAgentsMdFileExistence: + """Validate that AGENTS.md exists and is a regular file (Req 1.1).""" + + def test_agents_md_exists(self) -> None: + assert AGENTS_MD_PATH.exists(), f"AGENTS.md not found at {AGENTS_MD_PATH}" + + def test_agents_md_is_file(self) -> None: + assert AGENTS_MD_PATH.is_file(), f"{AGENTS_MD_PATH} exists but is not a file" + + +class TestAgentsMdStructure: + """Validate AGENTS.md heading structure (Reqs 1.2, 1.3).""" + + def test_starts_with_heading_containing_corridorkey(self) -> None: + """First line must be a # heading containing 'CorridorKey' (Req 1.3).""" + content = _read_agents_md() + first_line = content.strip().splitlines()[0] + assert first_line.startswith("# "), "File must start with a top-level heading" + assert "CorridorKey" in first_line, ( + f"Top-level heading must contain 'CorridorKey', got: {first_line!r}" + ) + + def test_contains_section_headings(self) -> None: + """File must contain ## headings for hierarchical structure (Req 1.2).""" + content = _read_agents_md() + h2_lines = [ + line for line in content.splitlines() if line.startswith("## ") + ] + assert len(h2_lines) >= 1, "AGENTS.md must contain at least one ## heading" + + +class TestAgentsMdCommands: + """Validate that required dev/build/test commands are present (Reqs 5.1, 6.1–6.3).""" + + def test_uv_sync_group_dev(self) -> None: + assert "uv sync --group dev" in _read_agents_md() + + def test_uv_run_pytest(self) -> None: + assert "uv run pytest" in _read_agents_md() + + def test_uv_run_ruff_check(self) -> None: + assert "uv run ruff check" in _read_agents_md() + + def test_uv_run_ruff_format_check(self) -> None: + assert "uv run ruff format --check" in _read_agents_md() + + +class TestAgentsMdLicense: + """Validate license string is present (Req 2.3).""" + + def test_license_string(self) -> None: + assert "CC-BY-NC-SA-4.0" in _read_agents_md() + + +class TestAgentsMdLLMHandoverReference: + """Validate LLM_HANDOVER.md is referenced (Req 12.2).""" + + def test_llm_handover_referenced(self) -> None: + assert "docs/LLM_HANDOVER.md" in _read_agents_md() + + +class TestAgentsMdRuffConfig: + """Validate ruff configuration values are documented (Reqs 7.2, 7.3).""" + + def test_line_length_120(self) -> None: + assert "120" in _read_agents_md() + + def test_ruff_rules(self) -> None: + content = _read_agents_md() + for rule in ("E", "F", "W", "I", "B"): + assert rule in content, f"Ruff rule '{rule}' not found in AGENTS.md" + + +class TestAgentsMdPlatformCaveats: + """Validate platform-specific caveats are present (Reqs 8.1–8.3).""" + + def test_git_restore_uv_lock(self) -> None: + assert "git restore uv.lock" in _read_agents_md() + + def test_pytorch_mps_fallback(self) -> None: + assert "PYTORCH_ENABLE_MPS_FALLBACK=1" in _read_agents_md() + + def test_cuda_12_8(self) -> None: + assert "CUDA 12.8" in _read_agents_md() + + +class TestAgentsMdProhibitedActions: + """Validate prohibited actions are documented (Reqs 9.1–9.3).""" + + def test_gamma_22_prohibition(self) -> None: + content = _read_agents_md() + assert "gamma 2.2" in content.lower() or "2.2" in content, ( + "Gamma 2.2 prohibition not found in AGENTS.md" + ) + + def test_gvm_core_modification_prohibition(self) -> None: + content = _read_agents_md() + assert "gvm_core/" in content, "gvm_core/ modification prohibition not found" + assert "VideoMaMaInferenceModule/" in content, ( + "VideoMaMaInferenceModule/ modification prohibition not found" + ) + + def test_uv_lock_commit_prohibition(self) -> None: + content = _read_agents_md() + assert "uv.lock" in content, "uv.lock commit prohibition not found" + + +# --------------------------------------------------------------------------- +# Property-Based Tests — Hypothesis +# --------------------------------------------------------------------------- + +from hypothesis import given, settings +from hypothesis import strategies as st + +# --------------------------------------------------------------------------- +# Baselines for Property 1 +# --------------------------------------------------------------------------- + +REQUIRED_TERMS: tuple[str, ...] = ( + "GreenFormer", + "Hiera", + "CNNRefinerModule", + "srgb_to_linear", + "color_utils.py", + "2048", + "Lanczos4", + "EXR", + "despill", + "premultiply", + "sRGB", +) + + +class TestRequiredTechnicalTerms: + """Feature: agents-md-setup, Property 1: Required Technical Terms Present + + For any term in the required set, that term must appear at least once in + the content of CorridorKey/AGENTS.md. + + Validates: Requirements 13.1, 13.2, 13.3 + """ + + @given(term=st.sampled_from(REQUIRED_TERMS)) + @settings(max_examples=100) + def test_term_present_in_agents_md(self, term: str) -> None: + """**Validates: Requirements 13.1, 13.2, 13.3** + + Every required technical term must appear in AGENTS.md. + """ + content = _read_agents_md() + assert term in content, ( + f"Required technical term {term!r} not found in AGENTS.md" + ) + + +# --------------------------------------------------------------------------- +# Baselines for Property 2 +# --------------------------------------------------------------------------- + +REQUIRED_FILE_PATHS: tuple[str, ...] = ( + "CorridorKeyModule/core/model_transformer.py", + "CorridorKeyModule/inference_engine.py", + "CorridorKeyModule/core/color_utils.py", + "clip_manager.py", + "device_utils.py", + "backend/", +) + + +class TestKeyFileMapCompleteness: + """Feature: agents-md-setup, Property 2: Key File Map Completeness + + For any file path in the required set, that path must appear in the + content of CorridorKey/AGENTS.md. + + Validates: Requirements 4.1 + """ + + @given(file_path=st.sampled_from(REQUIRED_FILE_PATHS)) + @settings(max_examples=100) + def test_file_path_present_in_agents_md(self, file_path: str) -> None: + """**Validates: Requirements 4.1** + + Every required file path must appear in AGENTS.md. + """ + content = _read_agents_md() + assert file_path in content, ( + f"Required file path {file_path!r} not found in AGENTS.md" + ) + + +# --------------------------------------------------------------------------- +# Baselines for Property 3 +# --------------------------------------------------------------------------- + +PR_TEMPLATE_ELEMENTS: tuple[str, ...] = ( + "What does this change?", + "How was it tested?", + "uv run pytest", + "uv run ruff check", + "uv run ruff format --check", +) + + +class TestPRTemplateElements: + """Feature: agents-md-setup, Property 3: PR Template Elements Present + + For any element in the PR template set, that element must appear in the + content of CorridorKey/AGENTS.md. + + Validates: Requirements 11.4 + """ + + @given(element=st.sampled_from(PR_TEMPLATE_ELEMENTS)) + @settings(max_examples=100) + def test_pr_template_element_present_in_agents_md(self, element: str) -> None: + """**Validates: Requirements 11.4** + + Every PR template element must appear in AGENTS.md. + """ + content = _read_agents_md() + assert element in content, ( + f"PR template element {element!r} not found in AGENTS.md" + ) + + +# --------------------------------------------------------------------------- +# Baselines for Property 4 +# --------------------------------------------------------------------------- + +LLM_HANDOVER_PATH = REPO_ROOT / "docs" / "LLM_HANDOVER.md" + +BASELINE_LLM_HANDOVER_LINES: tuple[str, ...] = tuple( + line + for line in LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() + if line.strip() +) + + +class TestLLMHandoverPreservation: + """Feature: agents-md-setup, Property 4: LLM_HANDOVER.md Content Preservation + + For any non-empty line in the original docs/LLM_HANDOVER.md, that line must + appear identically in the current version of the file. The file must not be + modified, truncated, or have content removed. + + Validates: Requirements 12.1 + """ + + @given(line=st.sampled_from(BASELINE_LLM_HANDOVER_LINES)) + @settings(max_examples=100) + def test_line_still_present_in_llm_handover(self, line: str) -> None: + """**Validates: Requirements 12.1** + + Every non-empty baseline line must still be present in the current file. + """ + current_content = LLM_HANDOVER_PATH.read_text(encoding="utf-8") + assert line in current_content, ( + f"Baseline line missing from docs/LLM_HANDOVER.md:\n{line!r}" + ) + + +# --------------------------------------------------------------------------- +# Baselines for Property 5 +# --------------------------------------------------------------------------- + +REQUIRED_DOC_PATHS: tuple[str, ...] = ( + "README.md", + "CONTRIBUTING.md", + "AGENTS.md", + "docs/LLM_HANDOVER.md", + "docs/", +) + + +class TestDocFileReferences: + """Feature: agents-md-setup, Property 5: Documentation File References Present + + For any documentation file path in the required set, that path must appear + in the content of CorridorKey/AGENTS.md. + + Validates: Requirements 14.3 + """ + + @given(doc_path=st.sampled_from(REQUIRED_DOC_PATHS)) + @settings(max_examples=100) + def test_doc_path_present_in_agents_md(self, doc_path: str) -> None: + """**Validates: Requirements 14.3** + + Every required documentation file path must appear in AGENTS.md. + """ + content = _read_agents_md() + assert doc_path in content, ( + f"Required documentation path {doc_path!r} not found in AGENTS.md" + ) diff --git a/tests/test_docs_structure.py b/tests/test_docs_structure.py new file mode 100644 index 00000000..f86086a7 --- /dev/null +++ b/tests/test_docs_structure.py @@ -0,0 +1,320 @@ +"""Property-based tests for docs-site-setup structural invariants. + +Feature: docs-site-setup +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + pytest.skip("tomli required for Python < 3.11", allow_module_level=True) + +from hypothesis import given, settings +from hypothesis import strategies as st + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parents[1] +ZENSICAL_PATH = REPO_ROOT / "zensical.toml" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_zensical() -> dict: + """Parse zensical.toml and return as a dict.""" + with open(ZENSICAL_PATH, "rb") as f: + return tomllib.load(f) + + +# --------------------------------------------------------------------------- +# Baselines — captured from the original zensical.toml before any changes. +# These are the theme features and markdown extensions that MUST be preserved. +# --------------------------------------------------------------------------- + +BASELINE_THEME_FEATURES: list[str] = [ + "announce.dismiss", + "content.action.edit", + "content.action.view", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.instant.progress", + "navigation.path", + "navigation.tabs", + "navigation.top", + "navigation.tracking", + "search.highlight", +] + +# Extension keys as they appear under [project.markdown_extensions] in TOML. +# Nested keys like pymdownx.superfences are represented by their dict path. +BASELINE_EXTENSION_KEYS: list[str] = [ + "admonition", + "pymdownx.details", + "pymdownx.superfences", + "pymdownx.tabbed", +] + + +# --------------------------------------------------------------------------- +# Property 2: Zensical Theme and Extension Preservation +# --------------------------------------------------------------------------- + + +class TestZensicalThemeAndExtensionPreservation: + """Feature: docs-site-setup, Property 2: Zensical Theme and Extension Preservation + + For any theme feature in the existing zensical.toml features list, and for + any markdown extension in the existing markdown_extensions section, those + entries must remain present and unchanged in the updated zensical.toml. + + Validates: Requirements 10.3 + """ + + @given(feature=st.sampled_from(BASELINE_THEME_FEATURES)) + @settings(max_examples=100) + def test_theme_feature_preserved(self, feature: str) -> None: + """**Validates: Requirements 10.3** + + Every original theme feature must still be present in the updated file. + """ + config = _load_zensical() + current_features = config["project"]["theme"]["features"] + assert feature in current_features, ( + f"Theme feature '{feature}' was removed from zensical.toml" + ) + + @given(ext_key=st.sampled_from(BASELINE_EXTENSION_KEYS)) + @settings(max_examples=100) + def test_markdown_extension_preserved(self, ext_key: str) -> None: + """**Validates: Requirements 10.3** + + Every original markdown extension must still be present in the updated file. + """ + config = _load_zensical() + extensions = config["project"]["markdown_extensions"] + + # Extension keys can be nested (e.g. "pymdownx.superfences" means + # extensions["pymdownx"]["superfences"]). + parts = ext_key.split(".") + node = extensions + for part in parts: + assert part in node, ( + f"Markdown extension '{ext_key}' was removed from zensical.toml" + ) + node = node[part] + + +# --------------------------------------------------------------------------- +# Baseline — LLM Handover content lines (non-empty, stripped) +# --------------------------------------------------------------------------- + +_LLM_HANDOVER_PATH = REPO_ROOT / "docs" / "LLM_HANDOVER.md" + +BASELINE_LLM_HANDOVER_LINES: list[str] = [ + line + for raw in _LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() + if (line := raw.strip()) +] + +assert BASELINE_LLM_HANDOVER_LINES, "LLM_HANDOVER.md baseline must not be empty" + + +# --------------------------------------------------------------------------- +# Property 1: LLM Handover Content Preservation +# --------------------------------------------------------------------------- + + +class TestLLMHandoverContentPreservation: + """Feature: docs-site-setup, Property 1: LLM Handover Content Preservation + + For any non-empty line in the original docs/LLM_HANDOVER.md, that line must + appear identically in the current version of the file. The file must not be + modified, truncated, or have content removed. + + Validates: Requirements 8.2 + """ + + @given(line=st.sampled_from(BASELINE_LLM_HANDOVER_LINES)) + @settings(max_examples=100) + def test_line_preserved(self, line: str) -> None: + """**Validates: Requirements 8.2** + + Every non-empty line from the baseline must still be present. + """ + current_content = _LLM_HANDOVER_PATH.read_text(encoding="utf-8") + current_lines = [l.strip() for l in current_content.splitlines()] + assert line in current_lines, ( + f"Line missing from LLM_HANDOVER.md: {line!r}" + ) + + +# --------------------------------------------------------------------------- +# Helper — recursively extract .md file references from the nav structure +# --------------------------------------------------------------------------- + + +def _extract_nav_files(nav: list) -> list[str]: + """Walk the nested nav array from zensical.toml and collect all .md refs.""" + files: list[str] = [] + for entry in nav: + if isinstance(entry, dict): + for _title, value in entry.items(): + if isinstance(value, str) and value.endswith(".md"): + files.append(value) + elif isinstance(value, list): + files.extend(_extract_nav_files(value)) + return files + + +# --------------------------------------------------------------------------- +# Baseline — nav file references +# --------------------------------------------------------------------------- + +_NAV_FILES: list[str] = _extract_nav_files(_load_zensical()["project"]["nav"]) + +assert _NAV_FILES, "Nav must reference at least one .md file" + + +# --------------------------------------------------------------------------- +# Property 4: Documentation Pages Reside in docs/ +# --------------------------------------------------------------------------- + + +class TestDocumentationPagesResideInDocs: + """Feature: docs-site-setup, Property 4: Documentation Pages Reside in docs/ + + For any file referenced in the nav array of zensical.toml, the file must + exist within the docs/ directory so that the existing workflow trigger path + (docs/**) detects changes. + + Validates: Requirements 11.2 + """ + + @given(nav_file=st.sampled_from(_NAV_FILES)) + @settings(max_examples=100) + def test_nav_file_exists_in_docs(self, nav_file: str) -> None: + """**Validates: Requirements 11.2** + + Every nav entry must resolve to an existing file inside docs/. + """ + full_path = REPO_ROOT / "docs" / nav_file + assert full_path.exists(), ( + f"Nav references '{nav_file}' but {full_path} does not exist" + ) + assert full_path.is_file(), ( + f"Nav references '{nav_file}' but {full_path} is not a file" + ) + + +# --------------------------------------------------------------------------- +# Baseline — README.md content lines (non-empty, stripped) +# The banner line was added by task 7.1; all original lines are still present. +# We capture every non-empty stripped line from the current file as the +# baseline. Since the only change was an *addition*, every line in this +# baseline must remain present in the file. +# --------------------------------------------------------------------------- + +_README_PATH = REPO_ROOT / "README.md" + +BASELINE_README_LINES: list[str] = [ + line + for raw in _README_PATH.read_text(encoding="utf-8").splitlines() + if (line := raw.strip()) +] + +assert BASELINE_README_LINES, "README.md baseline must not be empty" + + +# --------------------------------------------------------------------------- +# Property 5: README Content Preservation +# --------------------------------------------------------------------------- + + +class TestREADMEContentPreservation: + """Feature: docs-site-setup, Property 5: README Content Preservation + + For any non-empty line in the README.md (including the newly added docs + link banner), that line must appear in the current version of the file. + The only permitted change was the addition of the banner — no existing + content may be removed or altered. + + Validates: Requirements 12.1 + """ + + @given(line=st.sampled_from(BASELINE_README_LINES)) + @settings(max_examples=100) + def test_line_preserved(self, line: str) -> None: + """**Validates: Requirements 12.1** + + Every non-empty line from the baseline must still be present. + """ + current_content = _README_PATH.read_text(encoding="utf-8") + current_lines = [l.strip() for l in current_content.splitlines()] + assert line in current_lines, ( + f"Line missing from README.md: {line!r}" + ) + + +# --------------------------------------------------------------------------- +# Baseline — docs.yml workflow lines (non-empty, stripped) +# --------------------------------------------------------------------------- + +_DOCS_YML_PATH = REPO_ROOT / ".github" / "workflows" / "docs.yml" + +BASELINE_DOCS_YML_LINES: list[str] = [ + line + for raw in _DOCS_YML_PATH.read_text(encoding="utf-8").splitlines() + if (line := raw.strip()) +] + +assert BASELINE_DOCS_YML_LINES, "docs.yml baseline must not be empty" + + +# --------------------------------------------------------------------------- +# Property 3: Workflow File Immutability +# --------------------------------------------------------------------------- + + +class TestWorkflowFileImmutability: + """Feature: docs-site-setup, Property 3: Workflow File Immutability + + For any non-empty line in the original .github/workflows/docs.yml, that + line must appear identically in the current version of the file. The + workflow file must not be modified in any way. + + Validates: Requirements 11.1 + """ + + @given(line=st.sampled_from(BASELINE_DOCS_YML_LINES)) + @settings(max_examples=100) + def test_line_preserved(self, line: str) -> None: + """**Validates: Requirements 11.1** + + Every non-empty line from the baseline must still be present. + """ + current_content = _DOCS_YML_PATH.read_text(encoding="utf-8") + current_lines = [l.strip() for l in current_content.splitlines()] + assert line in current_lines, ( + f"Line missing from docs.yml: {line!r}" + ) diff --git a/zensical.toml b/zensical.toml index 56c70b49..f63307f2 100644 --- a/zensical.toml +++ b/zensical.toml @@ -4,7 +4,20 @@ site_description = "Perfect Green Screen Keys" repo_name = "CorridorKey" repo_url = "https://github.com/nikopueringer/CorridorKey" -nav = [{ Home = "index.md" }, { LLM_HANDOVER = "LLM_HANDOVER.md" }] +nav = [ + { Home = "index.md" }, + { "User Guide" = [ + { Installation = "installation.md" }, + { "Hardware Requirements" = "hardware-requirements.md" }, + { Usage = "usage.md" }, + { "Device and Backend Selection" = "device-and-backend-selection.md" }, + ]}, + { "Developer Guide" = [ + { Architecture = "architecture.md" }, + { Contributing = "contributing.md" }, + { "LLM Handover" = "LLM_HANDOVER.md" }, + ]}, +] [project.theme] language = "en" @@ -49,3 +62,5 @@ custom_fences = [ [project.markdown_extensions.pymdownx.tabbed] alternate_style = true + +[project.markdown_extensions.pymdownx.snippets] From 777f2d999d4a4d91a0d32b3254bc7ee03cc7043b Mon Sep 17 00:00:00 2001 From: Michael Foley Date: Fri, 13 Mar 2026 12:55:11 -0400 Subject: [PATCH 2/5] fix: resolve ruff lint and format errors in test files - Move hypothesis imports to top of file (E402) in test_agents_md.py - Rename ambiguous variable 'l' to 'ln' (E741) in test_docs_structure.py - Apply ruff format to both test files --- tests/test_agents_md.py | 46 ++++++++++-------------------------- tests/test_docs_structure.py | 46 ++++++++++-------------------------- 2 files changed, 26 insertions(+), 66 deletions(-) diff --git a/tests/test_agents_md.py b/tests/test_agents_md.py index 822ab9fb..6e77a028 100644 --- a/tests/test_agents_md.py +++ b/tests/test_agents_md.py @@ -7,6 +7,9 @@ from pathlib import Path +from hypothesis import given, settings +from hypothesis import strategies as st + # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- @@ -48,16 +51,12 @@ def test_starts_with_heading_containing_corridorkey(self) -> None: content = _read_agents_md() first_line = content.strip().splitlines()[0] assert first_line.startswith("# "), "File must start with a top-level heading" - assert "CorridorKey" in first_line, ( - f"Top-level heading must contain 'CorridorKey', got: {first_line!r}" - ) + assert "CorridorKey" in first_line, f"Top-level heading must contain 'CorridorKey', got: {first_line!r}" def test_contains_section_headings(self) -> None: """File must contain ## headings for hierarchical structure (Req 1.2).""" content = _read_agents_md() - h2_lines = [ - line for line in content.splitlines() if line.startswith("## ") - ] + h2_lines = [line for line in content.splitlines() if line.startswith("## ")] assert len(h2_lines) >= 1, "AGENTS.md must contain at least one ## heading" @@ -121,16 +120,12 @@ class TestAgentsMdProhibitedActions: def test_gamma_22_prohibition(self) -> None: content = _read_agents_md() - assert "gamma 2.2" in content.lower() or "2.2" in content, ( - "Gamma 2.2 prohibition not found in AGENTS.md" - ) + assert "gamma 2.2" in content.lower() or "2.2" in content, "Gamma 2.2 prohibition not found in AGENTS.md" def test_gvm_core_modification_prohibition(self) -> None: content = _read_agents_md() assert "gvm_core/" in content, "gvm_core/ modification prohibition not found" - assert "VideoMaMaInferenceModule/" in content, ( - "VideoMaMaInferenceModule/ modification prohibition not found" - ) + assert "VideoMaMaInferenceModule/" in content, "VideoMaMaInferenceModule/ modification prohibition not found" def test_uv_lock_commit_prohibition(self) -> None: content = _read_agents_md() @@ -141,9 +136,6 @@ def test_uv_lock_commit_prohibition(self) -> None: # Property-Based Tests — Hypothesis # --------------------------------------------------------------------------- -from hypothesis import given, settings -from hypothesis import strategies as st - # --------------------------------------------------------------------------- # Baselines for Property 1 # --------------------------------------------------------------------------- @@ -180,9 +172,7 @@ def test_term_present_in_agents_md(self, term: str) -> None: Every required technical term must appear in AGENTS.md. """ content = _read_agents_md() - assert term in content, ( - f"Required technical term {term!r} not found in AGENTS.md" - ) + assert term in content, f"Required technical term {term!r} not found in AGENTS.md" # --------------------------------------------------------------------------- @@ -216,9 +206,7 @@ def test_file_path_present_in_agents_md(self, file_path: str) -> None: Every required file path must appear in AGENTS.md. """ content = _read_agents_md() - assert file_path in content, ( - f"Required file path {file_path!r} not found in AGENTS.md" - ) + assert file_path in content, f"Required file path {file_path!r} not found in AGENTS.md" # --------------------------------------------------------------------------- @@ -251,9 +239,7 @@ def test_pr_template_element_present_in_agents_md(self, element: str) -> None: Every PR template element must appear in AGENTS.md. """ content = _read_agents_md() - assert element in content, ( - f"PR template element {element!r} not found in AGENTS.md" - ) + assert element in content, f"PR template element {element!r} not found in AGENTS.md" # --------------------------------------------------------------------------- @@ -263,9 +249,7 @@ def test_pr_template_element_present_in_agents_md(self, element: str) -> None: LLM_HANDOVER_PATH = REPO_ROOT / "docs" / "LLM_HANDOVER.md" BASELINE_LLM_HANDOVER_LINES: tuple[str, ...] = tuple( - line - for line in LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() - if line.strip() + line for line in LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() if line.strip() ) @@ -287,9 +271,7 @@ def test_line_still_present_in_llm_handover(self, line: str) -> None: Every non-empty baseline line must still be present in the current file. """ current_content = LLM_HANDOVER_PATH.read_text(encoding="utf-8") - assert line in current_content, ( - f"Baseline line missing from docs/LLM_HANDOVER.md:\n{line!r}" - ) + assert line in current_content, f"Baseline line missing from docs/LLM_HANDOVER.md:\n{line!r}" # --------------------------------------------------------------------------- @@ -322,6 +304,4 @@ def test_doc_path_present_in_agents_md(self, doc_path: str) -> None: Every required documentation file path must appear in AGENTS.md. """ content = _read_agents_md() - assert doc_path in content, ( - f"Required documentation path {doc_path!r} not found in AGENTS.md" - ) + assert doc_path in content, f"Required documentation path {doc_path!r} not found in AGENTS.md" diff --git a/tests/test_docs_structure.py b/tests/test_docs_structure.py index f86086a7..bda5603a 100644 --- a/tests/test_docs_structure.py +++ b/tests/test_docs_structure.py @@ -100,9 +100,7 @@ def test_theme_feature_preserved(self, feature: str) -> None: """ config = _load_zensical() current_features = config["project"]["theme"]["features"] - assert feature in current_features, ( - f"Theme feature '{feature}' was removed from zensical.toml" - ) + assert feature in current_features, f"Theme feature '{feature}' was removed from zensical.toml" @given(ext_key=st.sampled_from(BASELINE_EXTENSION_KEYS)) @settings(max_examples=100) @@ -119,9 +117,7 @@ def test_markdown_extension_preserved(self, ext_key: str) -> None: parts = ext_key.split(".") node = extensions for part in parts: - assert part in node, ( - f"Markdown extension '{ext_key}' was removed from zensical.toml" - ) + assert part in node, f"Markdown extension '{ext_key}' was removed from zensical.toml" node = node[part] @@ -132,9 +128,7 @@ def test_markdown_extension_preserved(self, ext_key: str) -> None: _LLM_HANDOVER_PATH = REPO_ROOT / "docs" / "LLM_HANDOVER.md" BASELINE_LLM_HANDOVER_LINES: list[str] = [ - line - for raw in _LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() - if (line := raw.strip()) + line for raw in _LLM_HANDOVER_PATH.read_text(encoding="utf-8").splitlines() if (line := raw.strip()) ] assert BASELINE_LLM_HANDOVER_LINES, "LLM_HANDOVER.md baseline must not be empty" @@ -163,10 +157,8 @@ def test_line_preserved(self, line: str) -> None: Every non-empty line from the baseline must still be present. """ current_content = _LLM_HANDOVER_PATH.read_text(encoding="utf-8") - current_lines = [l.strip() for l in current_content.splitlines()] - assert line in current_lines, ( - f"Line missing from LLM_HANDOVER.md: {line!r}" - ) + current_lines = [ln.strip() for ln in current_content.splitlines()] + assert line in current_lines, f"Line missing from LLM_HANDOVER.md: {line!r}" # --------------------------------------------------------------------------- @@ -219,12 +211,8 @@ def test_nav_file_exists_in_docs(self, nav_file: str) -> None: Every nav entry must resolve to an existing file inside docs/. """ full_path = REPO_ROOT / "docs" / nav_file - assert full_path.exists(), ( - f"Nav references '{nav_file}' but {full_path} does not exist" - ) - assert full_path.is_file(), ( - f"Nav references '{nav_file}' but {full_path} is not a file" - ) + assert full_path.exists(), f"Nav references '{nav_file}' but {full_path} does not exist" + assert full_path.is_file(), f"Nav references '{nav_file}' but {full_path} is not a file" # --------------------------------------------------------------------------- @@ -238,9 +226,7 @@ def test_nav_file_exists_in_docs(self, nav_file: str) -> None: _README_PATH = REPO_ROOT / "README.md" BASELINE_README_LINES: list[str] = [ - line - for raw in _README_PATH.read_text(encoding="utf-8").splitlines() - if (line := raw.strip()) + line for raw in _README_PATH.read_text(encoding="utf-8").splitlines() if (line := raw.strip()) ] assert BASELINE_README_LINES, "README.md baseline must not be empty" @@ -270,10 +256,8 @@ def test_line_preserved(self, line: str) -> None: Every non-empty line from the baseline must still be present. """ current_content = _README_PATH.read_text(encoding="utf-8") - current_lines = [l.strip() for l in current_content.splitlines()] - assert line in current_lines, ( - f"Line missing from README.md: {line!r}" - ) + current_lines = [ln.strip() for ln in current_content.splitlines()] + assert line in current_lines, f"Line missing from README.md: {line!r}" # --------------------------------------------------------------------------- @@ -283,9 +267,7 @@ def test_line_preserved(self, line: str) -> None: _DOCS_YML_PATH = REPO_ROOT / ".github" / "workflows" / "docs.yml" BASELINE_DOCS_YML_LINES: list[str] = [ - line - for raw in _DOCS_YML_PATH.read_text(encoding="utf-8").splitlines() - if (line := raw.strip()) + line for raw in _DOCS_YML_PATH.read_text(encoding="utf-8").splitlines() if (line := raw.strip()) ] assert BASELINE_DOCS_YML_LINES, "docs.yml baseline must not be empty" @@ -314,7 +296,5 @@ def test_line_preserved(self, line: str) -> None: Every non-empty line from the baseline must still be present. """ current_content = _DOCS_YML_PATH.read_text(encoding="utf-8") - current_lines = [l.strip() for l in current_content.splitlines()] - assert line in current_lines, ( - f"Line missing from docs.yml: {line!r}" - ) + current_lines = [ln.strip() for ln in current_content.splitlines()] + assert line in current_lines, f"Line missing from docs.yml: {line!r}" From bf24907be64e40fb6d18d09d613a686b3740d838 Mon Sep 17 00:00:00 2001 From: Michael Foley Date: Fri, 13 Mar 2026 13:18:34 -0400 Subject: [PATCH 3/5] fix: remove OBE uv.lock drift warnings from AGENTS.md and tests The cross-platform uv.lock drift was fixed in #133 (extras-based backends). Remove the now-stale uv.lock caveat from Platform-Specific Caveats, the prohibited-action entry, and the corresponding test assertions. --- AGENTS.md | 5 ----- tests/test_agents_md.py | 11 ++--------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f31562b1..cd3724c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,10 +91,6 @@ The project uses **[Ruff](https://docs.astral.sh/ruff/)** for both linting and f ### Apple Silicon (macOS) -- **`uv.lock` drift:** Running `uv run pytest` on macOS regenerates `uv.lock` with macOS-specific markers. **Do not commit this file.** Before staging changes, always run: - ```bash - git restore uv.lock - ``` - **MPS operator fallback:** Some PyTorch operations are not yet implemented for MPS. Enable CPU fallback: ```bash export PYTORCH_ENABLE_MPS_FALLBACK=1 @@ -108,7 +104,6 @@ The project uses **[Ruff](https://docs.astral.sh/ruff/)** for both linting and f 1. **Do not apply a pure gamma 2.2 curve.** Always use the piecewise sRGB transfer functions in `color_utils.py`. A naive `pow(x, 2.2)` breaks the toe region and produces incorrect compositing results. 2. **Do not modify files inside `gvm_core/` or `VideoMaMaInferenceModule/`.** These are third-party research modules kept close to upstream. Changes should be made in wrapper code or upstream PRs. -3. **Do not commit `uv.lock` changes made on macOS.** Apple Silicon resolves platform-specific markers that differ from the Linux CI lockfile. Always `git restore uv.lock` before committing. ## PR Workflow & GitHub Templates diff --git a/tests/test_agents_md.py b/tests/test_agents_md.py index 6e77a028..9e79dfb9 100644 --- a/tests/test_agents_md.py +++ b/tests/test_agents_md.py @@ -103,10 +103,7 @@ def test_ruff_rules(self) -> None: class TestAgentsMdPlatformCaveats: - """Validate platform-specific caveats are present (Reqs 8.1–8.3).""" - - def test_git_restore_uv_lock(self) -> None: - assert "git restore uv.lock" in _read_agents_md() + """Validate platform-specific caveats are present (Reqs 8.2–8.3).""" def test_pytorch_mps_fallback(self) -> None: assert "PYTORCH_ENABLE_MPS_FALLBACK=1" in _read_agents_md() @@ -116,7 +113,7 @@ def test_cuda_12_8(self) -> None: class TestAgentsMdProhibitedActions: - """Validate prohibited actions are documented (Reqs 9.1–9.3).""" + """Validate prohibited actions are documented (Reqs 9.1–9.2).""" def test_gamma_22_prohibition(self) -> None: content = _read_agents_md() @@ -127,10 +124,6 @@ def test_gvm_core_modification_prohibition(self) -> None: assert "gvm_core/" in content, "gvm_core/ modification prohibition not found" assert "VideoMaMaInferenceModule/" in content, "VideoMaMaInferenceModule/ modification prohibition not found" - def test_uv_lock_commit_prohibition(self) -> None: - content = _read_agents_md() - assert "uv.lock" in content, "uv.lock commit prohibition not found" - # --------------------------------------------------------------------------- # Property-Based Tests — Hypothesis From df7cb484bd7edc60aa077257da79cb490bc0d15b Mon Sep 17 00:00:00 2001 From: Michael Foley Date: Fri, 13 Mar 2026 14:10:37 -0400 Subject: [PATCH 4/5] docs: add AI-Assisted Development page with per-tool config tabs Add docs/ai-assisted-development.md covering context sources (AGENTS.md, LLM_HANDOVER.md), a tool-agnostic quick start, and per-tool configuration sections for Kiro, Claude Code, Cursor, GitHub Copilot, Windsurf, and Gemini CLI using pymdownx.tabbed content tabs. Update zensical.toml nav (Developer Guide section) and docs/index.md (Developer Guide card) with the new page entry. Add structural and property-based tests to tests/test_docs_structure.py: - test_ai_dev_page_exists, test_ai_dev_page_in_nav - TestNavEntryPreservation (Property 1) - TestIndexPageContentPreservation (Property 2) - test_ai_dev_page_has_required_content - test_index_page_links_to_ai_dev --- docs/ai-assisted-development.md | 282 ++++++++++++++++++++++++++++++++ docs/index.md | 1 + tests/test_docs_structure.py | 157 ++++++++++++++++++ zensical.toml | 1 + 4 files changed, 441 insertions(+) create mode 100644 docs/ai-assisted-development.md diff --git a/docs/ai-assisted-development.md b/docs/ai-assisted-development.md new file mode 100644 index 00000000..c8e9583d --- /dev/null +++ b/docs/ai-assisted-development.md @@ -0,0 +1,282 @@ +# AI-Assisted Development + +CorridorKey ships two files designed to give AI coding assistants deep +context about the project: + +- **`AGENTS.md`** — a structured project guide at the repo root. +- **`docs/LLM_HANDOVER.md`** — a detailed architecture walkthrough and + AI directive reference. + +Together these files cover the codebase layout, dataflow rules, dev +commands, code style, and common pitfalls. Most AI tools can consume +them directly — the sections below explain what each file provides and +how tools discover them. + +--- + +## Context Sources + +### `AGENTS.md` + +`AGENTS.md` sits at the repository root and follows the open +[AGENTS.md standard](https://agents.md). It gives any AI assistant a +compact overview of the project: architecture summary, key file map, +build and test commands, code style settings, platform caveats, and +prohibited actions. + +Because the format is an open standard, multiple AI coding tools — +including GitHub Copilot, Windsurf, and Kiro — read it natively +without extra configuration. Dropping into the repo and opening a +session is often enough to get useful context. + +!!! tip "Read the source" + The full file is at the repo root: + [`AGENTS.md`](../AGENTS.md). + Refer to it directly rather than relying on summaries here. + +### `LLM_HANDOVER.md` + +`LLM_HANDOVER.md` lives in the `docs/` directory and provides a much +deeper technical handover. It covers the GreenFormer architecture in +detail, critical dataflow properties (color space and gamma math), +inference pipeline internals, and AI-specific directives for working +with the codebase. + +If `AGENTS.md` is the quick-reference card, `LLM_HANDOVER.md` is the +full briefing document. Point your assistant at it when you need help +with inference code, compositing math, or EXR pipeline work. + +!!! tip "Read the source" + The full handover document is at + [`docs/LLM_HANDOVER.md`](LLM_HANDOVER.md). + It is the authoritative deep-dive — this page only summarises + what it contains. + +--- + +## Quick Start + +Get a working dev environment and point your AI assistant at the +project context — this works with any tool. + +```bash +git clone https://github.com/nikopueringer/CorridorKey.git +cd CorridorKey +uv sync --group dev # installs all dependencies + dev tools +``` + +Once the repo is cloned, open `AGENTS.md` in your AI assistant as +the first step. It gives the assistant the project layout, key +rules, and common pitfalls in one read. For deeper architecture +context, also point it at `docs/LLM_HANDOVER.md`. + +Core dev commands to keep handy: + +```bash +uv run pytest # run all tests +uv run ruff check # check for lint errors +uv run ruff format --check # check formatting (no changes) +``` + +--- + +## Tool Configuration + +Each AI coding assistant has its own way of loading project context. +Pick your tool below for CorridorKey-specific setup instructions. + +=== "Kiro" + + Kiro uses **steering files** stored in `.kiro/steering/*.md` to + provide persistent project context. Each file is a Markdown + document that Kiro loads according to one of three inclusion + modes: + + | Mode | Behaviour | + |------|-----------| + | **Always-on** (default) | Loaded at the start of every session automatically. | + | **Conditional** | Loaded only when the active file matches a `fileMatch` glob pattern (e.g., `*.py`). | + | **Manual** | User provides the file explicitly via `#` in the chat prompt. | + + To give Kiro the full CorridorKey context, create a steering file + that references both `AGENTS.md` and `LLM_HANDOVER.md`: + + ```markdown title=".kiro/steering/corridorkey-context.md" + # CorridorKey Project Context + + This steering file gives Kiro persistent context about the + CorridorKey codebase. + + ## Primary References + + - Read `AGENTS.md` at the repo root for the project overview, + key file map, build commands, code style, and prohibited + actions. + - Read `docs/LLM_HANDOVER.md` for the deep architecture + walkthrough, dataflow rules, and AI-specific directives. + + ## Key Rules + + - Tensor range is strictly [0.0, 1.0] float. + - Never use pow(x, 2.2) for gamma — use piecewise sRGB + transfer functions in `color_utils.py`. + - Do not modify files in `gvm_core/` or + `VideoMaMaInferenceModule/`. + ``` + +=== "Claude Code" + + Claude Code loads a **`CLAUDE.md`** file from the repository root + automatically at the start of every session. This is the primary + way to give Claude Code persistent project context. + + Create a `CLAUDE.md` that points Claude Code at the existing + context files: + + ```markdown title="CLAUDE.md" + # CorridorKey — Claude Code Context + + Read these files for full project context: + + - `AGENTS.md` — project overview, key file map, build/test + commands, code style, prohibited actions. + - `docs/LLM_HANDOVER.md` — deep architecture walkthrough, + dataflow rules, inference pipeline, AI directives. + + Key rules: + - Tensors are [0.0, 1.0] float. Foreground is sRGB, alpha is + linear. + - Use piecewise sRGB transfer functions, never pow(x, 2.2). + - Do not modify gvm_core/ or VideoMaMaInferenceModule/. + ``` + + Claude Code reads `CLAUDE.md` once at session start, so any + updates require restarting the session to take effect. + +=== "Cursor" + + Cursor uses **rule files** stored in `.cursor/rules/*.md` to + inject project context into the assistant. Each rule file + supports a frontmatter block that controls when it activates: + + | Mode | Frontmatter | Behaviour | + |------|-------------|-----------| + | **Always-on** | `alwaysApply: true` | Loaded in every chat and Cmd-K session. | + | **Glob-based** | `globs: ["*.py"]` | Loaded when the active file matches the pattern. | + | **Manual** | `manual: true` | User includes it explicitly via `@rules`. | + | **Model-decision** | `agentRequested: true` | The model decides whether to load it based on the task description. | + + Example rule file for CorridorKey: + + ```markdown title=".cursor/rules/corridorkey.md" + --- + description: CorridorKey project context and coding rules + alwaysApply: true + --- + + # CorridorKey Context + + Read `AGENTS.md` at the repo root for the project overview, + key file map, and build commands. + + Read `docs/LLM_HANDOVER.md` for the deep architecture + walkthrough and dataflow rules. + + Key rules: + - Tensors are [0.0, 1.0] float. Foreground sRGB, alpha linear. + - Use piecewise sRGB functions, never pow(x, 2.2). + - Do not modify gvm_core/ or VideoMaMaInferenceModule/. + ``` + +=== "GitHub Copilot" + + GitHub Copilot supports project-level instructions via a + **`.github/copilot-instructions.md`** file. This file is + automatically included in Copilot Chat requests to provide + project-specific guidance. + + Copilot also reads **`AGENTS.md`** natively, so CorridorKey's + existing `AGENTS.md` already provides baseline context without + any extra configuration. The instructions file is useful for + adding Copilot-specific guidance beyond what `AGENTS.md` covers. + + ```markdown title=".github/copilot-instructions.md" + # CorridorKey — Copilot Instructions + + This project already has an `AGENTS.md` at the repo root that + Copilot reads automatically. For deeper context, also refer to + `docs/LLM_HANDOVER.md`. + + Key rules: + - Tensors are [0.0, 1.0] float. Foreground sRGB, alpha linear. + - Use piecewise sRGB functions in color_utils.py, never + pow(x, 2.2). + - Do not modify gvm_core/ or VideoMaMaInferenceModule/. + ``` + +=== "Windsurf" + + Windsurf uses **`.windsurf/rules/`** for project-level context + files. Rules placed in this directory are loaded automatically + during coding sessions. + + Windsurf also reads **`AGENTS.md`** files natively with + directory-based auto-scoping — it picks up `AGENTS.md` at the + repo root and applies its content as project-wide context. This + means CorridorKey's existing `AGENTS.md` works out of the box. + + For additional Windsurf-specific rules, create a file in the + rules directory: + + ```markdown title=".windsurf/rules/corridorkey.md" + # CorridorKey Context + + AGENTS.md at the repo root is loaded automatically. For the + deep architecture walkthrough, also read + docs/LLM_HANDOVER.md. + + Key rules: + - Tensors are [0.0, 1.0] float. Foreground sRGB, alpha linear. + - Use piecewise sRGB functions, never pow(x, 2.2). + - Do not modify gvm_core/ or VideoMaMaInferenceModule/. + ``` + +=== "Gemini CLI" + + Gemini CLI uses a **`GEMINI.md`** file at the repository root + for project-level context. It supports hierarchical context + loading across three levels: + + | Level | Location | Scope | + |-------|----------|-------| + | **Global** | `~/.gemini/GEMINI.md` | Applied to all projects on the machine. | + | **Project** | `GEMINI.md` (repo root) | Applied to the current project. | + | **Subdirectory** | `GEMINI.md` in any subdirectory | Applied when working within that directory. | + + Gemini CLI merges context from all three levels, with more + specific files taking precedence. For CorridorKey, a project-level + file is sufficient: + + ```markdown title="GEMINI.md" + # CorridorKey — Gemini CLI Context + + Read these files for full project context: + + - `AGENTS.md` — project overview, key file map, build/test + commands, code style, prohibited actions. + - `docs/LLM_HANDOVER.md` — deep architecture walkthrough, + dataflow rules, inference pipeline, AI directives. + + Key rules: + - Tensors are [0.0, 1.0] float. Foreground sRGB, alpha linear. + - Use piecewise sRGB functions, never pow(x, 2.2). + - Do not modify gvm_core/ or VideoMaMaInferenceModule/. + ``` + +--- + +## Community Contributions + +PRs adding configuration guides for AI tools not yet covered here +are welcome. See the [Contributing](contributing.md) page for the +PR workflow and submission guidelines. diff --git a/docs/index.md b/docs/index.md index 56ba94b7..2f8b0bca 100644 --- a/docs/index.md +++ b/docs/index.md @@ -59,5 +59,6 @@ Give CorridorKey a hint of what you want, and it separates the light for you. [:octicons-arrow-right-24: Architecture](architecture.md) · [Contributing](contributing.md) · [LLM Handover](LLM_HANDOVER.md) + · [AI-Assisted Development](ai-assisted-development.md) diff --git a/tests/test_docs_structure.py b/tests/test_docs_structure.py index bda5603a..0b2d055e 100644 --- a/tests/test_docs_structure.py +++ b/tests/test_docs_structure.py @@ -298,3 +298,160 @@ def test_line_preserved(self, line: str) -> None: current_content = _DOCS_YML_PATH.read_text(encoding="utf-8") current_lines = [ln.strip() for ln in current_content.splitlines()] assert line in current_lines, f"Line missing from docs.yml: {line!r}" + + +# --------------------------------------------------------------------------- +# Unit tests — AI-Assisted Development page (ai-dev-setup-guide) +# --------------------------------------------------------------------------- + + +def test_ai_dev_page_exists() -> None: + """The AI-Assisted Development page must exist on disk. + + Validates: Requirements 1.1, 12.1 + """ + ai_dev_path = REPO_ROOT / "docs" / "ai-assisted-development.md" + assert ai_dev_path.exists(), f"{ai_dev_path} does not exist" + assert ai_dev_path.is_file(), f"{ai_dev_path} is not a file" + + +def test_ai_dev_page_in_nav() -> None: + """The nav in zensical.toml must reference ai-assisted-development.md + with the title 'AI-Assisted Development'. + + Validates: Requirements 11.1, 11.2, 11.3, 12.2 + """ + config = _load_zensical() + nav = config["project"]["nav"] + nav_files = _extract_nav_files(nav) + assert "ai-assisted-development.md" in nav_files, "ai-assisted-development.md not found in zensical.toml nav" + + # Also verify the title is correct by walking the nav structure. + def _find_title(entries: list, target_file: str) -> str | None: + for entry in entries: + if isinstance(entry, dict): + for title, value in entry.items(): + if isinstance(value, str) and value == target_file: + return title + if isinstance(value, list): + found = _find_title(value, target_file) + if found is not None: + return found + return None + + title = _find_title(nav, "ai-assisted-development.md") + assert title == "AI-Assisted Development", f"Expected nav title 'AI-Assisted Development', got {title!r}" + + +# --------------------------------------------------------------------------- +# Baseline — nav file references for ai-dev-setup-guide property tests +# --------------------------------------------------------------------------- + +BASELINE_NAV_FILES: list[str] = list(_NAV_FILES) + +assert BASELINE_NAV_FILES, "Baseline nav files must not be empty" + + +# --------------------------------------------------------------------------- +# Property 1: Existing Nav Entry Preservation (ai-dev-setup-guide) +# --------------------------------------------------------------------------- + + +class TestNavEntryPreservation: + """Feature: ai-dev-setup-guide, Property 1: Existing Nav Entry Preservation + + For any file reference that existed in the zensical.toml nav array before + the AI-Assisted Development entry was added, that file reference must still + be present in the nav array after the change. + + Validates: Requirements 11.4 + """ + + @given(nav_file=st.sampled_from(BASELINE_NAV_FILES)) + @settings(max_examples=100) + def test_nav_entry_preserved(self, nav_file: str) -> None: + """**Validates: Requirements 11.4** + + Every nav .md reference from the baseline must still be present. + """ + current_nav_files = _extract_nav_files(_load_zensical()["project"]["nav"]) + assert nav_file in current_nav_files, f"Nav entry '{nav_file}' was removed from zensical.toml" + + +# --------------------------------------------------------------------------- +# Baseline — docs/index.md content lines (non-empty, stripped) +# --------------------------------------------------------------------------- + +_INDEX_MD_PATH = REPO_ROOT / "docs" / "index.md" + +BASELINE_INDEX_LINES: list[str] = [ + line for raw in _INDEX_MD_PATH.read_text(encoding="utf-8").splitlines() if (line := raw.strip()) +] + +assert BASELINE_INDEX_LINES, "docs/index.md baseline must not be empty" + + +# --------------------------------------------------------------------------- +# Property 2: Index Page Content Preservation (ai-dev-setup-guide) +# --------------------------------------------------------------------------- + + +class TestIndexPageContentPreservation: + """Feature: ai-dev-setup-guide, Property 2: Index Page Content Preservation + + For any non-empty line in the original docs/index.md, that line must appear + identically in the current version of the file after the AI-Assisted + Development link is added. + + Validates: Requirements 13.2 + """ + + @given(line=st.sampled_from(BASELINE_INDEX_LINES)) + @settings(max_examples=100) + def test_line_preserved(self, line: str) -> None: + """**Validates: Requirements 13.2** + + Every non-empty line from the baseline must still be present. + """ + current_content = _INDEX_MD_PATH.read_text(encoding="utf-8") + current_lines = [ln.strip() for ln in current_content.splitlines()] + assert line in current_lines, f"Line missing from docs/index.md: {line!r}" + + +def test_ai_dev_page_has_required_content() -> None: + """The AI-Assisted Development page must contain all required sections and markers. + + Validates: Requirements 1.2, 1.3, 2.1, 2.2, 3.1, 4.1, 5.1, 6.1, 7.1, 8.1, 9.1, 10.1 + """ + ai_dev_path = REPO_ROOT / "docs" / "ai-assisted-development.md" + content = ai_dev_path.read_text(encoding="utf-8") + + # Heading + assert "# AI-Assisted Development" in content + + # Context source references + assert "AGENTS.md" in content + assert "LLM_HANDOVER.md" in content + + # Quick Start section + assert "Quick Start" in content + + # Six tool names + for tool in ("Kiro", "Claude Code", "Cursor", "GitHub Copilot", "Windsurf", "Gemini CLI"): + assert tool in content, f"Tool '{tool}' not found in ai-assisted-development.md" + + # Contributions note + assert any( + word in content.lower() for word in ("contributions", "welcome", "contributing") + ), "No contributions/welcome note found in ai-assisted-development.md" + + +def test_index_page_links_to_ai_dev() -> None: + """The docs/index.md must contain a link to ai-assisted-development.md. + + Validates: Requirements 13.1 + """ + content = _INDEX_MD_PATH.read_text(encoding="utf-8") + assert "ai-assisted-development.md" in content, ( + "docs/index.md does not contain a link to ai-assisted-development.md" + ) diff --git a/zensical.toml b/zensical.toml index f63307f2..5cbadbcc 100644 --- a/zensical.toml +++ b/zensical.toml @@ -16,6 +16,7 @@ nav = [ { Architecture = "architecture.md" }, { Contributing = "contributing.md" }, { "LLM Handover" = "LLM_HANDOVER.md" }, + { "AI-Assisted Development" = "ai-assisted-development.md" }, ]}, ] From 3867d4c58d437fc651434be9db4bbf76c88ebaa9 Mon Sep 17 00:00:00 2001 From: Michael Foley Date: Fri, 13 Mar 2026 14:16:34 -0400 Subject: [PATCH 5/5] style: ruff format test_docs_structure.py --- tests/test_docs_structure.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_docs_structure.py b/tests/test_docs_structure.py index 0b2d055e..aff07705 100644 --- a/tests/test_docs_structure.py +++ b/tests/test_docs_structure.py @@ -441,9 +441,9 @@ def test_ai_dev_page_has_required_content() -> None: assert tool in content, f"Tool '{tool}' not found in ai-assisted-development.md" # Contributions note - assert any( - word in content.lower() for word in ("contributions", "welcome", "contributing") - ), "No contributions/welcome note found in ai-assisted-development.md" + assert any(word in content.lower() for word in ("contributions", "welcome", "contributing")), ( + "No contributions/welcome note found in ai-assisted-development.md" + ) def test_index_page_links_to_ai_dev() -> None: