diff --git a/.gitattributes b/.gitattributes index bd6c7be67da881cf7ebf22b4c719020a180f90b0..4d6ccd9730379ddd8e1d98b989f5e8681252060c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -36,3 +36,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text assets/adam_atom.ico filter=lfs diff=lfs merge=lfs -text assets/adam_atom.png filter=lfs diff=lfs merge=lfs -text docs/screenshots/command-center.png filter=lfs diff=lfs merge=lfs -text +docs/field-guide/adam-map.png filter=lfs diff=lfs merge=lfs -text diff --git a/ADAM-source-2026-10-01.zip b/ADAM-source-2026-10-01.zip new file mode 100644 index 0000000000000000000000000000000000000000..eda3f97af48df6ee690f2d33f4ad165dd91af44b --- /dev/null +++ b/ADAM-source-2026-10-01.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0e60f14332f6b6c5445871e81133162df75d98f3113f8c9c43d6b2dfd8a5d29 +size 1726930 diff --git a/ADAM.spec b/ADAM.spec index ab1b3c70ef77f9f8c7f6af9589290529bc0110f6..241ba0e8572b0eb072b3cd8a3f31e6f524c70185 100644 --- a/ADAM.spec +++ b/ADAM.spec @@ -4,6 +4,7 @@ from PyInstaller.utils.hooks import collect_submodules hiddenimports = ( collect_submodules("adam.tools") + + collect_submodules("adam.model_plugins_builtin") + collect_submodules("transformers.models.dinov2") + ["transformers", "torch", "PIL"] ) diff --git a/README.md b/README.md index b91e56a48f6ee02d6dd15ba2e8cb597c7100a936..8c70eeaffb0ac9b036cd35dac621188415c30871 100644 --- a/README.md +++ b/README.md @@ -1,394 +1,591 @@ ---- -license: mit -tags: -- desktop-application -- ai-tools -- dataset-management -- lora-training -- windows ---- - -# ADAM — AI Development and Automation Manager - -ADAM is a local, safety-first desktop hub for orchestrating AI project tools. -It includes registered dataset, DDPM, and SDXL LoRA workflows with background -planning, approval gates, progress reporting, and persistent asset history. - -![ADAM command center](docs/screenshots/command-center.png) - -Dataset preparation, captioning, and preview placeholders remain clearly marked -as demo tools. The connected Dataset Collector, DDPM trainer, and Local SDXL -LoRA Trainer use real adapters and never fall back to simulated training. - -Existing program folders can be connected from **Settings → Tool folders**. -ADAM stores only the path and scans for likely entry points; it does not copy or -modify the external project. Folder assignments can also be pasted into chat: - -```text -DDPM Trainer: D:\AI\DDPM -Flow Matching Trainer: D:\AI\FlowMatchImageGenerator -``` - -On a new computer, install `requirements.txt` in your chosen Python environment -before running `Launch ADAM.bat`. The launcher checks desktop dependencies and -does not install packages automatically or depend on the developer's personal -trainer folders. Install each optional trainer's dependencies according to that -tool's setup instructions before using its ADAM workflow. - -Remote access is disabled by default. Devices with an access token can browse -datasets, edit captions/review marks and submit work. Only enable it for trusted -devices. The desktop **Allow remote job controls and approval changes** setting -also permits remote confirmation, stopping and retrying jobs. A remote browser -can enable training auto-approval only after that desktop permission is granted; -it can always turn auto-approval off. Saved token changes and disabled access -take effect for new requests without restarting the server. - -Use private Tailscale access for connections beyond a trusted local network; -the built-in HTTP listener does not provide transport encryption by itself. -Phone URLs and QR codes contain the access token and should be treated as -credentials. Remote commands require JSON, have bounded request sizes and -connection counts, and reject cross-site browser submissions. These controls -do not sandbox installed Python plugins or connected trainers: install only -code you trust. - -Detection does not automatically authorize training. A real training adapter -remains gated until its dataset, model name, run settings, and output location -are explicit. - -## Training agents - -![ADAM model creation settings](docs/screenshots/create-model.png) - -ADAM's training lifecycle is divided into four explainable responsibilities: - -- **EVE** reviews dataset membership and leaves uncertain images for the user. -- **ORION** reviews planned epochs, batch size, resolution, image exposures, and - estimated optimizer steps. He can require approval but never silently changes - the requested settings. In the Model Creation Assistant, **ORION: apply a - starting recipe** fills a conservative, editable draft from the image count - and selected resolution before a plan is built. -- **ATLAS** watches active training for non-finite loss, sustained critical GPU - temperature, critically low disk space, stalls, and large runtime overruns. - Critical conditions pause the trainer process tree so the user can inspect it. -- **NOVA** examines available post-training previews and samples for unreadable - files and exact-looking duplicate collapse. Her report explicitly separates - technical sample health from subjective or subject-quality review. - -ORION, ATLAS, and NOVA reports are stored with each durable job record and are -shown in Current Plan, Active Job, and Jobs / History respectively. ATLAS's -default thresholds can be overridden in `config/settings.json` with the -`atlas_*` settings defined in `adam/config.py`. - -Every new job passes through the shared preflight and ORION review before its -queue state is chosen. Desktop plans, Remote prompts, and Remote training forms -use the same review. Remote training auto-approval still applies to ordinary -plans, but an ORION warning leaves the job awaiting explicit approval. Reviewing -a plan does not change the requested training settings. - -## Real image collection - -When a valid Dataset Collector folder is connected, the `dataset_collector` -registry entry uses ADAM's real visible-browser adapter. After plan approval it: - -- opens Bing Images in a normal visible Chrome window; -- waits when consent/CAPTCHA/human-verification text is detected; -- resumes automatically after the user resolves the page; -- downloads valid images at least 256×256; -- removes exact duplicate downloads; -- writes a matching `.txt` caption beside every image; and -- records URLs, captions, sources, and dimensions in `metadata.csv`. - -No CAPTCHA or website restriction is bypassed. Closing Chrome or stopping the -job ends collection safely. A new timestamped dataset folder is used rather -than overwriting an existing collection. - -ADAM keeps an incomplete DDPM request in conversation memory. A follow-up such -as `dataset folder Mario, model name Mario V2, epoch count 100, output D:\Runs` -fills the pending fields and validates named datasets against the connected -collector. It will not start if the dataset cannot be found. - -## Showcase videos - -The **Showcase Video** workspace creates a finished MP4 directly from completed -DDPM and Flow Matching models. Select and reorder the models, choose 12–24 -images per model, a 3-, 4-, or 5-second image duration, shared steps and aspect -ratio, provider-compatible samplers, seed, and 720p or 1080p output. ADAM runs -the image batches sequentially and then renders a request-list interface that -tracks the active model, image number, trainer, steps, sampler, and aspect ratio. -LoRA models are intentionally excluded from this streamlined workflow. - -When Ollama is reachable, messages that are not workflow commands receive a -short conversational answer. Ollama may explain or plan, but it still cannot -bypass the registry or confirmation gates. - -## Web search in Chat Mode - -Chat Mode can give local Ollama current web context without an API key. Enable -it in **Settings → Planning model**, then ask naturally, for example: - -```text -Search the web for Dandy's World character ideas. -What are the latest Ollama release notes? -Look up a reference for a cyberpunk city character. -``` - -ADAM sends only that search query to Bing's public results feed, reads the -result titles and snippets, -and passes up to five titles, snippets, and links to Ollama. It does not open -the result pages, download anything, or let web content run tools. Results are -untrusted reference material, so ADAM is instructed to cite the links and flag -uncertainty. Disable the setting to keep Chat Mode fully local. - -When you explicitly ask ADAM to **read**, **open**, or **research** result links, -it can read up to three public HTML/text pages and give Ollama short extracts. -For example: `Search the web for Undertale character ideas and read the most -relevant links.` Direct links can be read with `Read https://example.com/ and -summarize it.` Private/local addresses, non-web protocols, oversized pages, -downloads, and more than three pages are blocked. This control can be disabled -in Settings. - -Planning runs away from the interface thread, and conversational Ollama output -is streamed into the chat. ADAM validates training commands against a strict -schema and each registered trainer's declared capabilities before offering a -job. - -In **Settings → Planning model**, **Chat response length** sets the maximum -number of generated tokens for a Chat Mode reply. Higher values allow longer -research summaries but use more time and GPU memory. The default is 1,024, -which gives Qwen3 enough room to reason and still produce a visible response. - -ADAM stores friendly dataset/model names, paths, trainer types, epochs, and -resume checkpoints in `data/assets.json`. Requests such as: - -```text -From the Mario dataset, train it on a DDPM for 300 epochs. -With the Mario dataset, train it on a LoRA for 100 epochs. -Continue the Mario model from the DDPM for 50 epochs. -``` - -are resolved to real paths before approval. Continuation is offered only when a -compatible checkpoint exists. New DDPM runs retain the latest resume checkpoint. - -## Run - -```powershell -python main.py -``` - -On Windows, you can also double-click `Launch ADAM.bat`. - -The app requires Python 3.10+ and PySide6. Optional integrations use `psutil` -for system information and `pynvml` for NVIDIA GPU information. - -```powershell -python -m pip install -r requirements.txt -``` - -Try: - -- Click **Create a model…** in Trainer Mode for the guided Model Creation Assistant. -- `Adam, train a LoRA of Hatsune Miku` -- `Adam, collect a dataset of liminal spaces` -- `Adam, generate previews` -- `Adam, check GPU status` -- `From the Mario dataset, train it on a DDPM for 300 epochs` -- `With the Mario dataset, train it on a LoRA for 100 epochs` - -Training and large collection plans are never started until you approve the -plan. All actions are recorded in `logs/adam.log`, while project artifacts live -under `data/projects/`. - -The Model Creation Assistant can start from a built-in Character LoRA, Style -LoRA, DDPM, or Flow Matching preset. It can create a dataset or select a -registered one, recommends starting values, and saves personal presets. The -result still goes through ADAM's normal validated planner and approval gate. -Use **+ Add model** to build a multi-model training batch. Each wide model tab -keeps its own dataset, trainer, name, and settings; the minus button removes an -unwanted model, and tabs can be dragged to change the run order. ADAM validates -all models, presents one combined approval plan, and runs them sequentially so -only one training workflow uses the GPU at a time. A failed step stops the batch -before a later model starts. -Before approval, ADAM adds checks for connected tools, dataset contents, the -LoRA base model, and output-drive free space. Completed dataset and training -jobs also include a suggested next step. - -### Model Batch Builder - -Use **Create model batch…** to paste one requested subject per line. ADAM turns -the list into editable model tabs, removes duplicate names, and lets the current -trainer recipe be applied to any multi-selection of models. The batch is saved -as a draft so it can be closed and resumed later. - -For a review-first workflow, choose **Collect missing datasets first**. This -queues only sequential dataset collection and leaves training in the saved -draft. After collection, reopen the draft, use **Find collected datasets**, and -review each dataset in Training Studio. **Exclude rejected** moves rejected -images out of the training folder into a recoverable quarantine, and **Restore -excluded** reverses it. **Keep all images** marks the whole selected dataset as -accepted in one action, after which individual bad images can still be rejected. -Training remains locked until each model is explicitly -marked as reviewed and ready. If every linked dataset is acceptable as-is, -**Approve all datasets** marks the entire batch ready after one confirmation; -it does not inspect individual images or apply pending rejection decisions. - -Completed Flow Matching models can be selected in **Fine-tune**. ADAM uses the -saved Flow model folder as the continuation source, locks the continuation to -the model's original resolution, and writes the fine-tuned result to a new -output folder. This continues the saved weights while starting a fresh optimizer -and learning-rate schedule; it does not overwrite the original model. - -## Training Studio - -The **Training Studio** turns completed work into a reviewable experiment loop: - -- **Datasets** provides an image gallery, keep/reject decisions, caption editing, - exact duplicate detection, and visually similar duplicate candidates. -- **Experiments** compares job settings and outcomes, opens outputs, marks a - preferred model, and converts successful settings into reusable recipes. -- **Checkpoint Lab** browses model checkpoints and output images, records - consistent prompt/seed evaluations, and sends preview requests through the - normal approval-aware planner. -- **Recipes** preserves training starting points and can import or export - portable JSON recipe files. - -### EVE AI Dataset Review - -In Training Studio → Datasets, **EVE AI Review…** performs a local reference- -guided visual review. Add one or more good reference images and optional bad -references, then choose Keep and Reject confidence thresholds. EVE uses a small -DINOv2 vision model to divide the selected dataset into **Keep**, **Reject**, and -**Uncertain** galleries with confidence scores. The model is downloaded once on -first use and subsequent analysis stays local. - -Nothing is applied automatically. Inspect both sides, double-click images for a -full view, and move selected results between the three groups before choosing -**Apply EVE review**. EVE's decisions remain ordinary Training Studio review -marks: they can be manually changed, and rejected files are not moved until -**Exclude rejected** is selected. The latest proposal is also saved under -`data/eve_reviews/` for auditing. Use **Select all in current group** (or -Ctrl/Shift selection) to move many images at once; EVE transfers only the -chosen thumbnails so manual sorting stays responsive on large datasets. - -Training panels show elapsed time, a progress-based ETA, recent logs, and a -loss sparkline when the connected trainer reports `loss`. Preflight summaries -include clearly labelled workload, duration, VRAM, and disk estimates. These -estimates are planning hints rather than hardware guarantees. - -Create a Model also supports live training previews with a configurable -epoch interval, prompt, and reproducible seed for each model tab. While a -training job is active, its newest 256×256 preview appears in the right sidebar -with the source epoch and next scheduled preview. The full-size trainer output -can be opened from the card. Built-in adapters may publish previews directly; -registered DDPM, Flow, LoRA, APVD, MaskGit, and other trainers can also -participate by writing conventionally named `preview`, `sample`, or `epoch` -images beneath their declared output folder. - -## Generations - -The **Generations** workspace runs compatible registered image generators -without opening their separate desktop interfaces. The connected DDPM and Flow -Matching projects can generate from completed models with a reproducible seed, -sampler or ODE method, step count, image count, and aspect ratio. Generation -work uses the normal ADAM job queue, progress reporting, cancellation, and -logging. - -Every completed batch is stored under `data/generations/` with its images and a -`generation.json` sidecar. The history gallery can open an image or batch folder -and restore the exact settings for another run. DDPM creative notes are stored -with a batch for organization; they are not presented as text conditioning for -an unconditional DDPM model. - -Generation history opens as automatic model folders. Each folder uses the -registered model name and a recent generated image as its cover. Double-clicking -a folder filters history to that model and selects its provider and model in the -generation controls, so the next batch is generated into the same existing model -directory. This view does not move or rewrite older generation files. - -**Generation Cycle…** selects multiple compatible completed models and queues -one generation step per model. Choose images per model, a shared prompt or -creative note, starting seed, slideshow duration, looping, fullscreen playback, -and an optional model/trainer label. When the cycle finishes, ADAM opens the -results as a local slideshow while preserving every ordinary generation record -in history. - -If ADAM discovers a job interrupted by an unexpected shutdown, it offers to -open Jobs & History. The previous record remains intact and can be retried as a -new approval-gated job. Job logs can also be exported for troubleshooting. - -## Connect an existing tool - -ADAM supports importable Python functions and command-line Python scripts. -For a no-code setup, open **Settings → External Tools → Add external tool**. -Choose the program folder, select its training entry script and important -configuration files, then review ADAM's static compatibility and safety report. -The report covers: - -- detected command-line options and required inputs; -- likely dataset formats; -- output and checkpoint behavior; -- progress reporting; -- resume-training support; and -- potentially risky operations visible in the selected entry script. - -The 1–10 rating measures how clearly the script fits ADAM's safe command-line -contract. It is not a guarantee that third-party code is harmless. ADAM does -not execute a script while scanning it, external tools cannot replace built-in -registry entries, and every external-tool run requires explicit approval. - -After registration, a tool can be planned with a request such as: - -```text -Run APVD Model Trainer with dataset=D:\DreamData, epochs=20, output=D:\APVD\output -``` - -ADAM will ask for any required inputs that were omitted before it offers the -approval plan. - -For manual registry configuration, edit the relevant item in -`config/tools.json`: - -```json -{ - "backend": { - "type": "python", - "module": "my_tools.lora", - "function": "train" - }, - "demo": false -} -``` - -The function receives a `ToolContext` as its first argument and keyword -arguments from the approved plan. This keeps training code in one place: your -existing GUI and ADAM can both call the same backend. - -For scripts: - -```json -{ - "backend": { - "type": "script", - "path": "D:/AI/LoRATrainer/train.py" - }, - "demo": false -} -``` - -ADAM invokes scripts directly with the current Python interpreter, captures -stdout/stderr, and never drives another GUI with mouse clicks. - -## Safety model - -- Plans are shown before execution. -- Long, destructive, or high-volume work requires confirmation. -- Unregistered tools cannot be invoked. -- External paths and arguments are validated before execution. -- The LLM may propose a plan, but only registered tools can execute it. -- Pause, resume, and cancel controls are available for active jobs. -- Every tool action and state transition is logged. - -## Tests - -```powershell -python -m pytest -q -``` +--- +license: mit +tags: +- desktop-application +- ai-tools +- dataset-management +- lora-training +- windows +- ai-workflow +- experiment-tracking +- model-plugins +- video-lora +--- + +# ADAM — AI Development and Automation Manager + +ADAM is a local desktop command center for AI experiments: describe an idea, +prepare and review a dataset, approve a training plan, generate samples, and +use saved experiment history to decide what to try next. + +It began as a **Jarvis-inspired assistant for AI workflows** and has grown into +a Windows/PySide6 application with registered tools, model plugins, background +jobs, live previews, and optional local Ollama chat and Remote access. + +**Source release: 1 October 2026 · 322 tests passed across 33 modules.** This repository contains the desktop +application and its source code. Model weights, personal datasets, saved jobs, +and connected external trainer projects are supplied separately by the user. + +![ADAM workflow and architecture map](docs/field-guide/adam-map.png) + +**[Explore the interactive ADAM field guide](https://huggingface.co/spaces/SyntheticMDProductions/ADAM-Field-Guide)** +— click the workflow stages, reviewers and model architectures, then explore +four animated explanations of image construction. You can also +[download the standalone HTML guide](https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/docs/field-guide/adam-field-guide.html?download=true) +and [the visual map](docs/field-guide/adam-map.png). +The animations explain mechanisms; they are not model inference or quality benchmarks. + +## What ADAM includes + +| Workspace | What it does | +| --- | --- | +| Command center | Local Ollama chat, validated workflow planning, guided model creation, and sequential model batches | +| Training Studio / Dataset Lab | Dataset browsing, captions, keep/reject decisions, EVE proposals, recoverable exclusions, checkpoint review, and recipes | +| Jobs / History | Approval, scheduling, progress, previews, pause/cancel, retry, and persistent records | +| Generations / Showcase Video | Reproducible image batches, saved settings, generation history, and DDPM/Flow showcase MP4s | +| Video LoRA | Wan 2.1 T2V 1.3B clip preparation, character-reference suggestions, reviewed caption drafts, training, and video generation | +| Model Intelligence / Experiments | Training and generation evidence, run comparison, and follow-up experiment suggestions | +| Oasis player | Playable inference for compatible action-conditioned world models | +| Tools / Remote / System | Declared plugin settings, connected tool folders, optional authenticated device access, and hardware telemetry | + +| Model family | Integration | +| --- | --- | +| DDPM, regular Flow Matching, SDXL LoRA | Adapters for separately connected local trainers and generators | +| PixelRow | Experimental built-in model that generates top to bottom, one row at a time | +| INRFlow | Experimental built-in coordinate-to-RGB flow model without a pretrained image compressor | +| Neural Cellular Automata | Included experimental custom plugin that learns image growth from a living seed | +| Oasis | Experimental connected action world model with temporal latent and temporal pixel-flow workflows | +| Wan video LoRA | Dedicated workspace and adapter for a connected LoRAVideoTrainer/Musubi environment | + +See [release notes](docs/releases/2026-10-01.md) for changes since the September 5 release, +and [Oasis](docs/oasis_integration.md) / [INRFlow](docs/inrflow_integration.md) +for integration details. Experimental architectures are intended for local +exploration; this release makes no benchmark-quality claims. + +## Quick start + +[Download the complete source ZIP](https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/ADAM-source-2026-10-01.zip?download=true), +extract it, or clone this repository, then run: + +```powershell +python -m pip install -r requirements.txt +python main.py +``` + +Use Python 3.10+ on Windows. You can also double-click `Launch ADAM.bat` after +installing the requirements. Choose a CUDA-compatible PyTorch build for GPU +training according to your hardware. Optional trainer projects keep their own +dependencies and model weights; connect them in **Settings → Tool folders**. +Ollama is optional for local chat and model-assisted planning. Chrome is needed +for the visible image collector; FFmpeg is needed for video collection and splitting. + +The release starts with empty tool connections and Remote access disabled. +Application state is saved locally under `data/`, `logs/`, and `config/settings.json`. + +![ADAM command center](docs/screenshots/command-center.png) + +Dataset preparation, captioning, and preview placeholders remain clearly marked +as demo tools. The connected Dataset Collector, DDPM trainer, and Local SDXL +LoRA Trainer use real adapters and never fall back to simulated training. + +Existing program folders can be connected from **Settings → Tool folders**. +ADAM stores only the path and scans for likely entry points; it does not copy or +modify the external project. Folder assignments can also be pasted into chat: + +```text +DDPM Trainer: D:\AI\DDPM +Flow Matching Trainer: D:\AI\FlowMatchImageGenerator +``` + +On a new computer, install `requirements.txt` in your chosen Python environment +before running `Launch ADAM.bat`. The launcher checks desktop dependencies and +does not install packages automatically or depend on the developer's personal +trainer folders. Install each optional trainer's dependencies according to that +tool's setup instructions before using its ADAM workflow. + +Remote access is disabled by default. Devices with an access token can browse +datasets, edit captions/review marks and submit work. Only enable it for trusted +devices. The desktop **Allow remote job controls and approval changes** setting +also permits remote confirmation, stopping and retrying jobs. A remote browser +can enable training auto-approval only after that desktop permission is granted; +it can always turn auto-approval off. Saved token changes and disabled access +take effect for new requests without restarting the server. + +Use private Tailscale access for connections beyond a trusted local network; +the built-in HTTP listener does not provide transport encryption by itself. +Phone URLs and QR codes contain the access token and should be treated as +credentials. Remote commands require JSON, have bounded request sizes and +connection counts, and reject cross-site browser submissions. These controls +do not sandbox installed Python plugins or connected trainers: install only +code you trust. + +Detection does not automatically authorize training. A real training adapter +remains gated until its dataset, model name, run settings, and output location +are explicit. + +## Training agents + +![ADAM model creation settings](docs/screenshots/create-model.png) + +ADAM's training lifecycle is divided into four explainable responsibilities: + +- **EVE** reviews dataset membership and leaves uncertain images for the user. +- **ORION** reviews planned epochs, batch size, resolution, image exposures, and + estimated optimizer steps. He can require approval but never silently changes + the requested settings. In the Model Creation Assistant, **ORION: apply a + starting recipe** fills a conservative, editable draft from the image count + and selected resolution before a plan is built. +- **ATLAS** watches active training for non-finite loss, sustained critical GPU + temperature, critically low disk space, stalls, and large runtime overruns. + Critical conditions pause the trainer process tree so the user can inspect it. +- **NOVA** examines available post-training previews and samples for unreadable + files and exact-looking duplicate collapse. Her report explicitly separates + technical sample health from subjective or subject-quality review. + +ORION, ATLAS, and NOVA reports are stored with each durable job record and are +shown in Current Plan, Active Job, and Jobs / History respectively. ATLAS's +default thresholds can be overridden in `config/settings.json` with the +`atlas_*` settings defined in `adam/config.py`. + +Every new job passes through the shared preflight and ORION review before its +queue state is chosen. Desktop plans, Remote prompts, and Remote training forms +use the same review. Remote training auto-approval still applies to ordinary +plans, but an ORION warning leaves the job awaiting explicit approval. Reviewing +a plan does not change the requested training settings. + +## Real image collection + +When a valid Dataset Collector folder is connected, the `dataset_collector` +registry entry uses ADAM's real visible-browser adapter. After plan approval it: + +- opens Bing Images in a normal visible Chrome window; +- waits when consent/CAPTCHA/human-verification text is detected; +- resumes automatically after the user resolves the page; +- downloads valid images at least 256×256; +- removes exact duplicate downloads; +- writes a matching `.txt` caption beside every image; and +- records URLs, captions, sources, and dimensions in `metadata.csv`. + +No CAPTCHA or website restriction is bypassed. Closing Chrome or stopping the +job ends collection safely. A new timestamped dataset folder is used rather +than overwriting an existing collection. + +ADAM keeps an incomplete DDPM request in conversation memory. A follow-up such +as `dataset folder Mario, model name Mario V2, epoch count 100, output D:\Runs` +fills the pending fields and validates named datasets against the connected +collector. It will not start if the dataset cannot be found. + +## Showcase videos + +The **Showcase Video** workspace creates a finished MP4 directly from completed +DDPM and Flow Matching models. Select and reorder the models, choose 12–24 +images per model, a 3-, 4-, or 5-second image duration, shared steps and aspect +ratio, provider-compatible samplers, seed, and 720p or 1080p output. ADAM runs +the image batches sequentially and then renders a request-list interface that +tracks the active model, image number, trainer, steps, sampler, and aspect ratio. +LoRA models are intentionally excluded from this streamlined workflow. + +When Ollama is reachable, messages that are not workflow commands receive a +conversational answer. In Chat Mode, attach an image with the **+** button and +a vision-capable Ollama model can describe it, suggest a caption, or answer +questions about visible details. Images stay on the local Ollama connection. + +In Trainer Mode, the planning model can propose a registered ADAM action when +the request is not recognized by the built-in planner. ADAM validates the +proposed tool and every setting against its registry, and confirmation gates +still apply. This option can be disabled in **Settings → Safety & Notifications**. + +## Web search in Chat Mode + +Chat Mode can give local Ollama current web context without an API key. Enable +it in **Settings → Planning model**, then ask naturally, for example: + +```text +Search the web for Dandy's World character ideas. +What are the latest Ollama release notes? +Look up a reference for a cyberpunk city character. +``` + +ADAM sends only that search query to Bing's public results feed, reads the +result titles and snippets, +and passes up to five titles, snippets, and links to Ollama. It does not open +the result pages, download anything, or let web content run tools. Results are +untrusted reference material, so ADAM is instructed to cite the links and flag +uncertainty. Disable the setting to keep Chat Mode fully local. + +When you explicitly ask ADAM to **read**, **open**, or **research** result links, +it can read up to three public HTML/text pages and give Ollama short extracts. +For example: `Search the web for Undertale character ideas and read the most +relevant links.` Direct links can be read with `Read https://example.com/ and +summarize it.` Private/local addresses, non-web protocols, oversized pages, +downloads, and more than three pages are blocked. This control can be disabled +in Settings. + +Planning runs away from the interface thread, and conversational Ollama output +is streamed into the chat. ADAM validates training commands against a strict +schema and each registered trainer's declared capabilities before offering a +job. + +In **Settings → Planning model**, choose an automatic, short, balanced, or +detailed response style. Automatic uses a smaller response for simple questions +and makes more room for image reviews, explanations, and planning. **Maximum +response length** remains a hard limit for response time and GPU memory; the +default is 1,024 tokens. + +ADAM stores friendly dataset/model names, paths, trainer types, epochs, and +resume checkpoints in `data/assets.json`. Requests such as: + +```text +From the Mario dataset, train it on a DDPM for 300 epochs. +With the Mario dataset, train it on a LoRA for 100 epochs. +Continue the Mario model from the DDPM for 50 epochs. +``` + +are resolved to real paths before approval. Continuation is offered only when a +compatible checkpoint exists. New DDPM runs retain the latest resume checkpoint. + +## Run + +```powershell +python main.py +``` + +On Windows, you can also double-click `Launch ADAM.bat`. + +The app requires Python 3.10+ and PySide6. Optional integrations use `psutil` +for system information and `pynvml` for NVIDIA GPU information. + +```powershell +python -m pip install -r requirements.txt +``` + +Try: + +- Click **Create a model…** in Trainer Mode for the guided Model Creation Assistant. +- `Adam, train a LoRA of Hatsune Miku` +- `Adam, collect a dataset of liminal spaces` +- `Adam, generate previews` +- `Adam, check GPU status` +- `From the Mario dataset, train it on a DDPM for 300 epochs` +- `With the Mario dataset, train it on a LoRA for 100 epochs` + +Training and large collection plans are never started until you approve the +plan. All actions are recorded in `logs/adam.log`, while project artifacts live +under `data/projects/`. + +The Model Creation Assistant can start from a built-in Character LoRA, Style +LoRA, DDPM, Flow Matching, or experimental PixelRow preset. It can create a dataset or select a +registered one, recommends starting values, and saves personal presets. The +result still goes through ADAM's normal validated planner and approval gate. +Use **+ Add model** to build a multi-model training batch. Each wide model tab +keeps its own dataset, trainer, name, and settings; the minus button removes an +unwanted model, and tabs can be dragged to change the run order. ADAM validates +all models, presents one combined approval plan, and runs them sequentially so +only one training workflow uses the GPU at a time. A failed step stops the batch +before a later model starts. +Before approval, ADAM adds checks for connected tools, dataset contents, the +LoRA base model, and output-drive free space. Completed dataset and training +jobs also include a suggested next step. + +### Model Batch Builder + +Use **Create model batch…** to paste one requested subject per line. ADAM turns +the list into editable model tabs, removes duplicate names, and lets the current +trainer recipe be applied to any multi-selection of models. The batch is saved +as a draft so it can be closed and resumed later. + +For a review-first workflow, choose **Collect missing datasets first**. This +queues only sequential dataset collection and leaves training in the saved +draft. After collection, reopen the draft, use **Find collected datasets**, and +review each dataset in Training Studio. **Exclude rejected** moves rejected +images out of the training folder into a recoverable quarantine, and **Restore +excluded** reverses it. **Keep all images** marks the whole selected dataset as +accepted in one action, after which individual bad images can still be rejected. +Training remains locked until each model is explicitly +marked as reviewed and ready. If every linked dataset is acceptable as-is, +**Approve all datasets** marks the entire batch ready after one confirmation; +it does not inspect individual images or apply pending rejection decisions. + +Completed Flow Matching models can be selected in **Fine-tune**. ADAM uses the +saved Flow model folder as the continuation source, locks the continuation to +the model's original resolution, and writes the fine-tuned result to a new +output folder. This continues the saved weights while starting a fresh optimizer +and learning-rate schedule; it does not overwrite the original model. + +## PixelRow + +PixelRow is ADAM's experimental top-to-bottom image architecture. It trains on +ordinary image folders and predicts one complete quantized RGB row from all +previous rows, without a diffusion noise schedule. Start with 64×64 images for +the first experiment; 128×128 is available but trains more slowly. + +PixelRow generation uses one step per image row. The Generations page labels +these as **Rows** and offers creativity, top-color-choice, and row-frame +settings. Enabling **Save row-build frames** writes a PNG sequence beneath the +generation folder, making the construction process ready for a video or visual +comparison. Seeds reproduce both the finished image and its intermediate rows. + +## Wan Video LoRA + +Open **Video LoRA** in the sidebar to use the connected LoRAVideoTrainer from +inside ADAM. This workspace is separate from the image-model creation assistant +and image Generations page. It targets **Wan 2.1 T2V 1.3B** only. + +Connect **Settings → Tool folders → Wan Video LoRA Trainer** to your existing +LoRAVideoTrainer folder. An existing `external_loravideotrainer` connection is +recognized automatically. ADAM runs its `.venv/Scripts/python.exe` and installed +Musubi Tuner; use that project's setup instructions for its CUDA dependencies +and base weights. ADAM does not install or replace the trainer environment. + +1. In **Dataset**, choose a folder of short video clips with matching `.txt` + captions, import clips, or use **Split long video**. Splitting creates a new + folder of evenly spaced 49-frame clips at 12 FPS, preserving the source. + Select clips for looping preview and caption editing. Captions save on + selection/tab changes; include the exact trigger word from Training. + **Characters & recognition** maintains a local character library with one or + more reference images per character. It samples several frames from each + clip and uses local DINOv2 visual similarity to suggest zero or multiple + characters. Review the suggestions and uncheck false matches before applying + trigger words to captions; existing action descriptions are preserved. + Recognition suggestions are not applied automatically, and similarity + scores are only a review aid. Character references and the library are stored + under `data/video_characters/`. + To draft action captions, select one or more clips and choose **AI draft + captions**. ADAM samples six ordered frames per clip and asks the configured + Ollama model to describe visible actions and changes without guessing who is + present, in English. It retries once if the model returns CJK text. Review and + edit each draft as soon as it finishes while the next selected clip is being + processed. ADAM trims notes, alternate summaries, and repeated commentary to + one short caption sentence. Check the captions to keep, then save; + ADAM adds the active training trigger automatically. Failed clips remain + unchanged, and unchecked drafts are not written. +2. In **Training**, enter a unique run name and review epochs, trigger, frame + buckets, training resolution, rank/alpha, learning rate and memory swapping. + **Review full training pipeline** creates a job awaiting approval in + **Jobs / History**. After approval it validates clips, caches video latents, + caches captions, then trains. Failures stop subsequent stages. +3. In **Generate**, choose a compatible checkpoint and set prompt, strength, + landscape/portrait format, seconds, FPS, steps, seed and block swapping. + ADAM converts duration to Wan's `4N+1` frame count and displays the actual + duration. Start with **Fast preview preset** before trying longer clips. +4. **Videos / takes** lists new MP4s with prompt/seed/settings and existing + samples from the connected trainer. Double-click to play a video. + +Training outputs default to `data/video_models//`, including an +isolated cache, dataset configuration, logs and model metadata. Existing output +folders cannot be overwritten. **Continue weights** loads a Wan adapter into a +new run with a fresh optimizer and schedule; its rank and alpha come from the +checkpoint. It does not restore a full interrupted optimizer state. Cancellation +keeps previously written checkpoints, but does not force a new checkpoint. + +Generations are stored in `data/video_generations/`, with an MP4 and +`generation.json` recording the actual seed, model, prompt, settings and output +dimensions. Existing Wan checkpoints in LoRAVideoTrainer's `output/` are indexed +separately from SDXL. The first visit imports compatible local trainer settings; +subsequent changes are saved in ADAM's own configuration. The original trainer's +storyboard editor remains available through that application. + +All work uses ADAM's shared job queue, logs and pause/cancel controls. Training +keeps ORION review and ATLAS supervision, with video-specific workload notes. +NOVA requests video samples instead of applying image-preview quality checks. +Chat requests mentioning Wan or video LoRA direct you to the dedicated workspace. + +## Training Studio + +The **Training Studio** turns completed work into a reviewable experiment loop: + +- **Datasets** provides an image gallery, keep/reject decisions, caption editing, + exact duplicate detection, and visually similar duplicate candidates. +- **Experiments** compares job settings and outcomes, opens outputs, marks a + preferred model, and converts successful settings into reusable recipes. +- **Checkpoint Lab** browses model checkpoints and output images, records + consistent prompt/seed evaluations, and sends preview requests through the + normal approval-aware planner. +- **Recipes** preserves training starting points and can import or export + portable JSON recipe files. + +### EVE AI Dataset Review + +In Training Studio → Datasets, **EVE AI Review…** performs a local reference- +guided visual review. Add one or more good reference images and optional bad +references, then choose Keep and Reject confidence thresholds. EVE uses a small +DINOv2 vision model to divide the selected dataset into **Keep**, **Reject**, and +**Uncertain** galleries with confidence scores. The model is downloaded once on +first use and subsequent analysis stays local. + +Nothing is applied automatically. Inspect both sides, double-click images for a +full view, and move selected results between the three groups before choosing +**Apply EVE review**. EVE's decisions remain ordinary Training Studio review +marks: they can be manually changed, and rejected files are not moved until +**Exclude rejected** is selected. The latest proposal is also saved under +`data/eve_reviews/` for auditing. Use **Select all in current group** (or +Ctrl/Shift selection) to move many images at once; EVE transfers only the +chosen thumbnails so manual sorting stays responsive on large datasets. + +Training panels show elapsed time, a progress-based ETA, recent logs, and a +loss sparkline when the connected trainer reports `loss`. Preflight summaries +include clearly labelled workload, duration, VRAM, and disk estimates. These +estimates are planning hints rather than hardware guarantees. + +Create a Model also supports live training previews with a configurable +epoch interval, prompt, and reproducible seed for each model tab. While a +training job is active, its newest 256×256 preview appears in the right sidebar +with the source epoch and next scheduled preview. The full-size trainer output +can be opened from the card. Built-in adapters may publish previews directly; +registered DDPM, Flow, LoRA, APVD, MaskGit, and other trainers can also +participate by writing conventionally named `preview`, `sample`, or `epoch` +images beneath their declared output folder. + +## INRFlow + +ADAM includes an experimental, lightweight **INRFlow** trainer and generator as +a separate built-in model architecture. It follows the paper's ambient-space +design: an image is represented as coordinate-to-RGB pairs, spatial context +latents summarize the current noisy field, and a point decoder predicts the +flow velocity for independently sampled pixel queries. Training therefore uses +continuous flow matching directly on RGB values without a VAE or another +pretrained image compressor. + +The implementation is intentionally scaled for local experiments rather than +the much larger published configurations. The 64px default is the recommended +starting point on an 8–12 GB GPU. Pixel-query subsampling lowers training memory; +batch size, query count, width, and resolution can be reduced further. Models +save ordinary checkpoints, EMA weights, resumable optimizer state, metadata, +and optional training previews beneath +`data/model_plugin_outputs/inrflow/`. + +Completed INRFlow models appear in Generations next to regular Flow Matching, +with reproducible seeds, Euler or Heun integration, live ODE previews, Smart +Generation, and square resolution-flexible queries. A different output +resolution is coordinate-field extrapolation, so native resolution is the fair +default for model comparisons. Creative notes are metadata because the current +backend is unconditional. + +This is an independent ADAM-sized implementation informed by the +[INRFlow paper](https://arxiv.org/abs/2412.03791) and +[Apple's reference repository](https://github.com/apple/ml-inrflow), not a copy +of the published training setup or a claim of reproducing its reported model +scale. + +## Generations + +The **Generations** workspace runs compatible registered image generators +without opening their separate desktop interfaces. DDPM, regular Flow Matching, +INRFlow, and PixelRow can generate from completed models with reproducible +settings; PixelRow uses rows, while both flow backends use ODE sampling steps. +Generation work uses the normal ADAM job queue, progress reporting, +cancellation, and logging. + +Every completed batch is stored under `data/generations/` with its images and a +`generation.json` sidecar. The history gallery can open an image or batch folder +and restore the exact settings for another run. DDPM creative notes are stored +with a batch for organization; they are not presented as text conditioning for +an unconditional DDPM model. + +The workspace groups controls into **Model & Prompt**, **Image Settings**, and +**Advanced Settings**. Dimensions, presets, image count, and seed are available +in Image Settings; sampling controls are expanded by default. Collapsing Advanced +Settings preserves its values. Custom LoRA dimensions and reference images are +remembered when reopening the page. + +Generation history opens with image cards for each generator and a preview of +the latest batch. Click a generator, then a model, to browse its images and select +that model for generation. **Recent Output**, **All Generations**, and **Favorites** +provide alternate history views. The thumbnail strip selects the image shown +alongside its metadata and reuse/save actions. In **Compare**, pin one image as +a reference and select another thumbnail to view them together. Long metadata +values are available in tooltips. These views do not move or rewrite older +generation files. + +**Generation Cycle…** selects multiple compatible completed models and queues +one generation step per model. Choose images per model, a shared prompt or +creative note, starting seed, slideshow duration, looping, fullscreen playback, +and an optional model/trainer label. When the cycle finishes, ADAM opens the +results as a local slideshow while preserving every ordinary generation record +in history. + +If ADAM discovers a job interrupted by an unexpected shutdown, it offers to +open Jobs & History. The previous record remains intact and can be retried as a +new approval-gated job. Job logs can also be exported for troubleshooting. + +## Connect an existing tool + +ADAM supports importable Python functions and command-line Python scripts. +For a no-code setup, open **Settings → External Tools → Add external tool**. +Choose the program folder, select its training entry script and important +configuration files, then review ADAM's static compatibility and safety report. +The report covers: + +- detected command-line options and required inputs; +- likely dataset formats; +- output and checkpoint behavior; +- progress reporting; +- resume-training support; and +- potentially risky operations visible in the selected entry script. + +The 1–10 rating measures how clearly the script fits ADAM's safe command-line +contract. It is not a guarantee that third-party code is harmless. ADAM does +not execute a script while scanning it, external tools cannot replace built-in +registry entries, and every external-tool run requires explicit approval. + +After registration, a tool can be planned with a request such as: + +```text +Run APVD Model Trainer with dataset=D:\DreamData, epochs=20, output=D:\APVD\output +``` + +ADAM will ask for any required inputs that were omitted before it offers the +approval plan. + +For manual registry configuration, edit the relevant item in +`config/tools.json`: + +```json +{ + "backend": { + "type": "python", + "module": "my_tools.lora", + "function": "train" + }, + "demo": false +} +``` + +The function receives a `ToolContext` as its first argument and keyword +arguments from the approved plan. This keeps training code in one place: your +existing GUI and ADAM can both call the same backend. + +For scripts: + +```json +{ + "backend": { + "type": "script", + "path": "D:/AI/LoRATrainer/train.py" + }, + "demo": false +} +``` + +ADAM invokes scripts directly with the current Python interpreter, captures +stdout/stderr, and never drives another GUI with mouse clicks. + +## Safety model + +- Plans are shown before execution. +- Long, destructive, or high-volume work requires confirmation. +- Unregistered tools cannot be invoked. +- External paths and arguments are validated before execution. +- The LLM may propose a plan, but only registered tools can execute it. +- Pause, resume, and cancel controls are available for active jobs. +- Every tool action and state transition is logged. + +## Tests + +```powershell +python scripts/run_tests.py +` + +The runner executes every test module in its own process, keeping Qt application +lifetimes isolated. A single module can also be run with +python -m pytest tests/test_agents.py -q.`` diff --git a/SHA256SUMS.txt b/SHA256SUMS.txt new file mode 100644 index 0000000000000000000000000000000000000000..c31a2fa599e4376b74279773c920d8851546b22a --- /dev/null +++ b/SHA256SUMS.txt @@ -0,0 +1 @@ +f0e60f14332f6b6c5445871e81133162df75d98f3113f8c9c43d6b2dfd8a5d29 ADAM-source-2026-10-01.zip diff --git a/adam/assets.py b/adam/assets.py index cd07b48c73a02c6dc57d3fe59831a13c35a9f882..f29aa22673cd6980183d3ea407814170f989fa2e 100644 --- a/adam/assets.py +++ b/adam/assets.py @@ -26,6 +26,20 @@ def _friendly_name(value: str, fallback: str) -> str: return text +def _is_lora_training_checkpoint(path: Path) -> bool: + """Return whether a LoRA weight is an intermediate training snapshot. + + The LoRA trainer writes both the finished adapter and periodic weights such + as ``name_epoch_0050.safetensors``. The latter are useful for recovery, + but are not independently selectable models in ADAM's model library. + """ + name = path.stem.casefold() + return bool(re.search( + r"(?:^|[_\- ])(?:checkpoint(?:[_\- ]?(?:epoch|e|step))?|epoch|e|step)[_\- ]?\d+(?:[_\- ]|$)", + name, + )) + + @dataclass(slots=True) class Asset: id: str @@ -167,12 +181,30 @@ class AssetRegistry: matches.append(item) return exact or matches - def discover(self, config: Any) -> None: + def discover(self, config: Any, *, persist: bool = True) -> None: + # Models are stored by their output folder (or the model file itself). + # Keep the registry in step with the filesystem so removing an old + # output cannot leave a ghost model that makes name matching ambiguous. + self.assets = [ + item + for item in self.assets + if item.kind != "model" or ( + item.path.strip() and Path(item.path).expanduser().exists() + ) + # Old ADAM versions registered LoRA epoch snapshots. Prune those + # stale records as well as skipping them during new discovery. + and not ( + item.trainer == "lora" + and _is_lora_training_checkpoint(Path(item.path)) + ) + ] folders = config.get("tool_folders", {}) if not isinstance(folders, dict): return folders = dict(folders) app_root = self.path.parent.parent + from adam.video_lora import discover_assets as discover_video_assets + discover_video_assets(self, app_root, config) if not folders.get("oasis_trainer"): try: external = json.loads((app_root / "config" / "external_tools.json").read_text(encoding="utf-8")) @@ -186,7 +218,11 @@ class AssetRegistry: external_lora_root = app_root / "LoRAModelsHere" if external_lora_root.is_dir(): for path in external_lora_root.rglob("*.safetensors"): - if path.is_file() and "_comfy" not in path.stem.casefold(): + if ( + path.is_file() + and "_comfy" not in path.stem.casefold() + and not _is_lora_training_checkpoint(path) + ): self.register( kind="model", name=path.stem.removesuffix("_cancelled"), @@ -230,6 +266,37 @@ class AssetRegistry: root = Path(str(folders.get(folder_name, ""))) / output_name if not root.is_dir(): continue + # LoRA Trainer versions do not all agree on their output layout. + # Some write ``output//.safetensors`` while others add + # a second folder below the run. Register the actual weight file + # in either layout so the generator can load it directly. + if trainer == "lora": + for checkpoint_path in root.rglob("*.safetensors"): + if ( + not checkpoint_path.is_file() + or "_comfy" in checkpoint_path.stem.casefold() + or _is_lora_training_checkpoint(checkpoint_path) + ): + continue + trigger_word = "" + for metadata_path in (checkpoint_path.parent / "model_info.json", checkpoint_path.parent.parent / "model_info.json"): + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + trigger_word = str(metadata.get("trigger_word") or "") + if trigger_word: + break + except (OSError, ValueError, TypeError, json.JSONDecodeError): + continue + self.register( + kind="model", + name=checkpoint_path.stem.removesuffix("_cancelled"), + path=str(checkpoint_path), + trainer="lora", + checkpoint=str(checkpoint_path), + metadata={"trigger_word": trigger_word or checkpoint_path.stem.removesuffix("_cancelled")}, + persist=False, + ) + continue for folder in root.iterdir(): if not folder.is_dir(): continue @@ -250,22 +317,6 @@ class AssetRegistry: if p.name.rsplit("-", 1)[-1].isdigit() else -1, ) - elif trainer == "lora": - trigger_word = "" - checkpoints = sorted( - ( - path for path in folder.glob("*.safetensors") - if "_comfy" not in path.stem.casefold() - ), - key=lambda p: p.stat().st_mtime, - ) - if checkpoints: - name = checkpoints[-1].stem.removesuffix("_cancelled") - try: - metadata = json.loads((folder / "model_info.json").read_text(encoding="utf-8")) - trigger_word = str(metadata.get("trigger_word") or "") - except (OSError, ValueError, TypeError, json.JSONDecodeError): - trigger_word = "" elif trainer == "flow": checkpoints = [] try: @@ -333,10 +384,11 @@ class AssetRegistry: and item.metadata.get("dataset_location_id") not in valid_location_ids ) ] - dataset_registry.discover_into_assets(self, persist=False) + dataset_registry.discover_into_assets(self, persist=False, update_cache=persist) except Exception: pass - self.save() + if persist: + self.save() def _flow_dataset_paths(self) -> dict[str, str]: """Recover source datasets for Flow models created by ADAM in older runs.""" diff --git a/adam/auto_training.py b/adam/auto_training.py new file mode 100644 index 0000000000000000000000000000000000000000..e4a6039dfba8c7de0008cd5905be64459bde37da --- /dev/null +++ b/adam/auto_training.py @@ -0,0 +1,109 @@ +"""Intent-level AUTO policies for ADAM training requests. + +Natural-language parsing belongs in the planner. This module deliberately does +not inspect prompt wording: it turns an already-selected trainer, profile, and +dataset size into transparent, reproducible training settings. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from adam.model_profiles import ModelProfile +from adam.models import SystemSnapshot +from adam.recommendations import SettingsRecommendation, recommend_for_profile + + +class TrainingProfile(str, Enum): + TEST = "test" + BALANCED = "balanced" + QUALITY = "quality" + OVERNIGHT = "overnight" + + +@dataclass(frozen=True, slots=True) +class AutoTrainingPlan: + """Resolved, explainable settings for one training run.""" + + profile: TrainingProfile + dataset_target: int + epochs: int + settings: dict[str, Any] + summary: str + reasons: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + +def profile_from_request(request: str) -> TrainingProfile: + """Map only stable, user-facing intent modifiers to a named policy.""" + lowered = request.casefold() + if any(word in lowered for word in ("overnight", "all night", "long run")): + return TrainingProfile.OVERNIGHT + if any(phrase in lowered for phrase in ("high quality", "best quality", "really good", "quality")): + return TrainingProfile.QUALITY + if any(word in lowered for word in ("quick", "quickly", "test", "small", "smoke test")): + return TrainingProfile.TEST + return TrainingProfile.BALANCED + + +def dataset_target_for(trainer: str, profile: TrainingProfile) -> int: + """Choose a collection target, never a random count. + + These are conservative collection targets. The later recommendation is + calculated from the actual usable count when an existing dataset is known. + """ + targets = { + "lora": {TrainingProfile.TEST: 40, TrainingProfile.BALANCED: 150, TrainingProfile.QUALITY: 300, TrainingProfile.OVERNIGHT: 500}, + "ddpm": {TrainingProfile.TEST: 100, TrainingProfile.BALANCED: 400, TrainingProfile.QUALITY: 800, TrainingProfile.OVERNIGHT: 1_200}, + "flow": {TrainingProfile.TEST: 100, TrainingProfile.BALANCED: 400, TrainingProfile.QUALITY: 800, TrainingProfile.OVERNIGHT: 1_200}, + "inrflow": {TrainingProfile.TEST: 80, TrainingProfile.BALANCED: 300, TrainingProfile.QUALITY: 600, TrainingProfile.OVERNIGHT: 900}, + } + return targets.get(trainer, targets["ddpm"])[profile] + + +def resolve_auto_training( + profile: ModelProfile, + *, + trainer: str, + policy: TrainingProfile, + dataset_items: int, + snapshot: SystemSnapshot | None = None, +) -> AutoTrainingPlan: + """Resolve a named policy through ADAM's existing exposure-aware recommender.""" + recommendation: SettingsRecommendation = recommend_for_profile( + profile, + dataset_items=max(10, dataset_items), + snapshot=snapshot, + ) + epoch_multiplier = { + TrainingProfile.TEST: 0.20, + TrainingProfile.BALANCED: 1.00, + TrainingProfile.QUALITY: 1.35, + TrainingProfile.OVERNIGHT: 1.80, + }[policy] + # Preserve safe bounds from the recommendation; profiles express their own + # architecture-specific baseline rather than sharing a global epoch range. + minimum = 3 if policy is TrainingProfile.TEST else 10 + maximum = 1_000 if policy is TrainingProfile.OVERNIGHT else 600 + epochs = max(minimum, min(maximum, round(recommendation.epochs * epoch_multiplier))) + settings = dict(recommendation.settings) + if "save_every" in settings: + settings["save_every"] = max(1, min(int(settings["save_every"]), max(1, epochs // 4))) + if "preview_every" in settings: + settings["preview_every"] = max(1, min(int(settings["preview_every"]), max(1, epochs // 5))) + target = dataset_target_for(trainer, policy) + summary = ( + f"{policy.value.title()} AUTO policy: target {target:,} source images; " + f"{epochs:,} epoch budget based on {max(10, dataset_items):,} expected usable items." + ) + return AutoTrainingPlan( + profile=policy, + dataset_target=target, + epochs=epochs, + settings=settings, + summary=summary, + reasons=tuple(recommendation.reasons), + warnings=tuple(recommendation.warnings), + ) diff --git a/adam/cnn_reviewer.py b/adam/cnn_reviewer.py new file mode 100644 index 0000000000000000000000000000000000000000..1b21936eadd6b604ca4d0c5c73e53f36d8ba5e73 --- /dev/null +++ b/adam/cnn_reviewer.py @@ -0,0 +1,172 @@ +"""A small, local CNN that learns which gameplay frames are worth reviewing. + +This is intentionally independent from Oasis. Its job is to prioritize and +quality-check collected frames; it never supplies inputs to an Oasis checkpoint +or changes an Oasis model. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} +Progress = Callable[[str], None] + + +@dataclass(slots=True) +class ReviewerTrainingResult: + checkpoint: str + kept_examples: int + rejected_examples: int + epochs: int + + +@dataclass(slots=True) +class FrameScore: + path: str + keep_probability: float + suggestion: str + + +def image_paths(folder: str | Path, *, limit: int = 5_000) -> list[Path]: + root = Path(folder).expanduser() + if not root.is_dir(): + return [] + return [ + path for path in sorted(root.rglob("*")) + if path.is_file() and path.suffix.casefold() in IMAGE_SUFFIXES + ][:limit] + + +def reviewer_checkpoint(root: str | Path, dataset_folder: str | Path) -> Path: + """Return an ADAM-owned checkpoint path, separate from dataset and Oasis.""" + import hashlib + + dataset = str(Path(dataset_folder).expanduser().resolve()).encode("utf-8") + identifier = hashlib.sha1(dataset).hexdigest()[:12] + return Path(root).expanduser().resolve() / "data" / "cnn_reviewers" / f"{identifier}.pt" + + +def _torch(): + try: + import torch + from torch import nn + except ImportError as exc: # pragma: no cover - controlled by application install + raise RuntimeError("CNN Reviewer needs PyTorch. Install the ADAM requirements first.") from exc + return torch, nn + + +def make_reviewer_model(): + """Build a deliberately small binary CNN for local frame triage.""" + _torch_module, nn = _torch() + return nn.Sequential( + nn.Conv2d(3, 16, kernel_size=5, stride=2, padding=2), nn.ReLU(), + nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1), nn.ReLU(), + nn.Conv2d(32, 48, kernel_size=3, stride=2, padding=1), nn.ReLU(), + nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(48, 1), + ) + + +def _load_image(path: Path): + torch, _nn = _torch() + from PIL import Image + + with Image.open(path) as image: + image = image.convert("RGB").resize((128, 72)) + # No torchvision transform is required, which keeps this feature portable. + pixels = image.get_flattened_data() if hasattr(image, "get_flattened_data") else image.getdata() + values = torch.tensor(list(pixels), dtype=torch.float32) + return values.reshape(72, 128, 3).permute(2, 0, 1).div_(255.0) + + +def _labeled_paths(decisions: dict[str, str], *, per_class_limit: int = 250) -> tuple[list[Path], list[float]]: + grouped: dict[str, list[Path]] = {"keep": [], "reject": []} + for raw_path, decision in decisions.items(): + if decision not in grouped: + continue + path = Path(raw_path).expanduser() + if path.is_file() and path.suffix.casefold() in IMAGE_SUFFIXES: + grouped[decision].append(path) + kept = sorted(grouped["keep"])[:per_class_limit] + rejected = sorted(grouped["reject"])[:per_class_limit] + return kept + rejected, [1.0] * len(kept) + [0.0] * len(rejected) + + +def train_reviewer( + root: str | Path, + dataset_folder: str | Path, + decisions: dict[str, str], + *, + epochs: int = 8, + progress: Progress | None = None, +) -> ReviewerTrainingResult: + """Train a frame-quality CNN from explicit Keep and Reject review decisions.""" + torch, nn = _torch() + paths, labels = _labeled_paths(decisions) + keep_count = int(sum(labels)) + reject_count = len(labels) - keep_count + if min(keep_count, reject_count) < 8: + raise ValueError("Review at least 8 Keep and 8 Reject frames before training the CNN reviewer.") + if progress: + progress(f"Loading {len(paths)} reviewed frame(s)…") + images = [] + valid_labels = [] + for path, label in zip(paths, labels): + try: + images.append(_load_image(path)) + valid_labels.append(label) + except Exception: + continue + keep_count = int(sum(valid_labels)) + reject_count = len(valid_labels) - keep_count + if min(keep_count, reject_count) < 8: + raise ValueError("Some reviewed images could not be read; at least 8 valid Keep and Reject frames are needed.") + torch.manual_seed(7) + inputs = torch.stack(images) + targets = torch.tensor(valid_labels, dtype=torch.float32).unsqueeze(1) + model = make_reviewer_model() + optimizer = torch.optim.Adam(model.parameters(), lr=0.001) + loss_fn = nn.BCEWithLogitsLoss() + model.train() + for epoch in range(max(1, min(int(epochs), 50))): + order = torch.randperm(len(inputs)) + loss_value = 0.0 + batches = 0 + for start in range(0, len(order), 16): + batch = order[start:start + 16] + optimizer.zero_grad() + loss = loss_fn(model(inputs[batch]), targets[batch]) + loss.backward() + optimizer.step() + loss_value += float(loss.detach()) + batches += 1 + if progress: + progress(f"CNN reviewer epoch {epoch + 1}/{epochs} · loss {loss_value / max(1, batches):.3f}") + checkpoint = reviewer_checkpoint(root, dataset_folder) + checkpoint.parent.mkdir(parents=True, exist_ok=True) + torch.save({"state_dict": model.state_dict(), "image_size": [128, 72]}, checkpoint) + return ReviewerTrainingResult(str(checkpoint), keep_count, reject_count, epochs) + + +def score_frames(checkpoint: str | Path, folder: str | Path, *, limit: int = 5_000, progress: Progress | None = None) -> list[FrameScore]: + """Return review suggestions without modifying the dataset or its decisions.""" + torch, _nn = _torch() + saved = torch.load(Path(checkpoint), map_location="cpu", weights_only=True) + model = make_reviewer_model() + model.load_state_dict(saved["state_dict"]) + model.eval() + paths = image_paths(folder, limit=limit) + scores: list[FrameScore] = [] + with torch.no_grad(): + for index, path in enumerate(paths, 1): + try: + probability = float(torch.sigmoid(model(_load_image(path).unsqueeze(0))).item()) + except Exception: + continue + suggestion = "keep" if probability >= 0.70 else "reject" if probability <= 0.30 else "review" + scores.append(FrameScore(str(path.resolve()), probability, suggestion)) + if progress and (index % 100 == 0 or index == len(paths)): + progress(f"CNN reviewer scored {index}/{len(paths)} frame(s)…") + return scores diff --git a/adam/commands.py b/adam/commands.py index 50e42f4234e0844fb389a04b2d5895079a9d98f6..85a75de9b5dc9e6516477e62de56ae83ee08cfbc 100644 --- a/adam/commands.py +++ b/adam/commands.py @@ -83,7 +83,7 @@ class TrainingCommand: "resolution", "batch_size", "learning_rate", "gradient_accumulation_steps", "dataloader_num_workers", "mixed_precision", "save_every", "preview_steps", "training_intensity", "preview_enabled", "preview_every", "preview_prompt", - "preview_seed", + "preview_seed", "training_aspect_ratio", "resize_mode", }, "flow": { "resolution", "batch_size", "learning_rate", "gradient_accumulation", diff --git a/adam/config.py b/adam/config.py index 5c05541fdcd9e6ec6a2d233041c81d366531fb61..41b1915410a6fa2378749cffcd55dd3e7ab81079 100644 --- a/adam/config.py +++ b/adam/config.py @@ -11,6 +11,8 @@ DEFAULT_SETTINGS: dict[str, Any] = { "ollama_url": "http://localhost:11434", "ollama_model": "qwen2.5:1.5b", "ollama_chat_max_tokens": 1024, + "ollama_chat_response_length": "automatic", + "ollama_proposed_actions": True, "web_search_enabled": True, "web_link_reading_enabled": True, "command_center_mode": "trainer", @@ -41,6 +43,7 @@ DEFAULT_SETTINGS: dict[str, Any] = { "ddpm_trainer": "", "flow_trainer": "", "oasis_trainer": "", + "wan_video_trainer": "", "preview_generator": "", }, } diff --git a/adam/dataset_registry.py b/adam/dataset_registry.py index e995a6dbd5b6a3160efd9cb7619777646b4c9d69..f21f0fdd21e50d660cee495056e790cbba19c6cc 100644 --- a/adam/dataset_registry.py +++ b/adam/dataset_registry.py @@ -210,9 +210,9 @@ class DatasetRegistry: ) return locations - def discover_into_assets(self, assets: "AssetRegistry", *, persist: bool = False) -> list["Asset"]: + def discover_into_assets(self, assets: "AssetRegistry", *, persist: bool = False, update_cache: bool = True) -> list["Asset"]: discovered: list[Asset] = [] - records = self.discover(asset_registry=assets, refresh_missing=False) + records = self.discover(asset_registry=assets, refresh_missing=False, persist=update_cache) for record in records: if not record.exists: continue @@ -236,6 +236,7 @@ class DatasetRegistry: *, asset_registry: "AssetRegistry | None" = None, refresh_missing: bool = True, + persist: bool = True, ) -> list[DatasetRecord]: self.load() changed = False @@ -271,7 +272,7 @@ class DatasetRegistry: record.exists = Path(record.path).is_dir() if refresh_missing and record.exists and self._needs_refresh(record): self.refresh_async(record.path, source=record.source, location_id=record.location_id) - if changed: + if changed and persist: self.save() return self.sorted_records() diff --git a/adam/generations.py b/adam/generations.py index 9c3f51b08623e75bfe5409f4ed87a8c120b45213..8d7722f93ea45190924c7c8d57101f4a63749433 100644 --- a/adam/generations.py +++ b/adam/generations.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import re +from html import unescape from dataclasses import dataclass from pathlib import Path from typing import Any @@ -13,6 +14,220 @@ from adam.registry import ToolRegistry, ToolSpec IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} +@dataclass(frozen=True, slots=True) +class ImportedLoRAMetadata: + """Portable LoRA-generation settings copied from an image or another app.""" + + prompt: str + negative_prompt: str + seed: int + steps: int + cfg_scale: float | None + base_model_path: str + lora_path: str + lora_strength: float | None + sampler: str + width: int | None = None + height: int | None = None + sampler_note: str = "" + + +def parse_lora_generation_metadata(text: str) -> ImportedLoRAMetadata: + """Parse pasted LoRA metadata without requiring an intermediate JSON file. + + Browser copy/paste sometimes HTML-escapes JSON (for example `` `` for a + space), so decode those entities before loading it. This intentionally + accepts only the small, reproducible LoRA schema ADAM understands. + """ + source = str(text or "").strip() + # A few sites escape twice when metadata is copied out of a code block. + for _ in range(2): + decoded = unescape(source) + if decoded == source: + break + source = decoded + try: + payload = json.loads(source) + except (TypeError, ValueError, json.JSONDecodeError): + # Some viewers copy a display block rather than strict JSON, omitting + # braces, commas, or quotes around values. Recover its named fields. + fields: dict[str, Any] = {} + for key in ("prompt", "negative_prompt", "seed", "steps", "cfg_scale", "model", "sampler"): + match = re.search(rf'["\']?{key}["\']?\s*:\s*(?:["\']([^"\']*)["\']|([^\r\n]+))', source, re.I) + if match: + fields[key] = (match.group(1) if match.group(1) is not None else match.group(2)).strip().rstrip(",").strip() + lora_path_match = re.search(r'["\']?path["\']?\s*:\s*["\']?([^"\',\r\n}\]]+)', source, re.I) + strength_match = re.search(r'["\']?strength["\']?\s*:\s*([^,\r\n}\]]+)', source, re.I) + if lora_path_match: + fields["loras"] = [{ + "path": lora_path_match.group(1).strip(), + "strength": strength_match.group(1).strip() if strength_match else None, + }] + payload = fields + if not isinstance(payload, dict): + raise ValueError("Metadata must be a JSON object.") + + # Source apps vary between `width`/`height` and `Width`/`Height`. + payload = {str(key).casefold(): value for key, value in payload.items()} + + def text_value(key: str, *, required: bool = False) -> str: + value = payload.get(key, "") + if value is None: + value = "" + if not isinstance(value, str): + raise ValueError(f"{key.replace('_', ' ').title()} must be text.") + value = value.strip() + if required and not value: + raise ValueError(f"Metadata is missing {key.replace('_', ' ')}.") + return value + + loras = payload.get("loras") + if not isinstance(loras, list): + raise ValueError("Metadata needs a 'loras' list (use [] when no LoRA was used).") + if loras and not isinstance(loras[0], dict): + raise ValueError("The first LoRA entry must be an object.") + lora_path = str(loras[0].get("path", "")).strip() if loras else "" + if loras and not lora_path: + raise ValueError("The first LoRA entry needs a path.") + try: + seed = int(payload.get("seed", 0)) + steps = int(payload.get("steps", 30)) + except (TypeError, ValueError) as exc: + raise ValueError("Seed and steps must be whole numbers.") from exc + # Automatic1111-style metadata commonly uses -1 for a fresh random seed. + # ADAM uses 0 for the same behavior in generation plans. + if seed == -1: + seed = 0 + if not 0 <= seed <= 2_147_483_647 or steps < 1: + raise ValueError("Seed or steps is outside ADAM's supported range.") + cfg_value = payload.get("cfg_scale") + try: + cfg_scale = float(cfg_value) if cfg_value is not None else None + except (TypeError, ValueError) as exc: + raise ValueError("CFG scale must be a number.") from exc + strength_value = loras[0].get("strength") if loras else None + try: + lora_strength = float(strength_value) if strength_value is not None else None + except (TypeError, ValueError) as exc: + raise ValueError("LoRA strength must be a number.") from exc + def dimension(key: str) -> int | None: + value = payload.get(key) + if value is None or value == "": + return None + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{key.title()} must be a whole number.") from exc + if not 256 <= result <= 2048: + raise ValueError(f"{key.title()} must be between 256 and 2048 pixels.") + return result + + sampler = text_value("sampler") or "DPM++ 2M" + supported = {"DPM++ 2M", "DPM++ 2M Karras", "DPM++ 2M SDE", "DPM++ 2M SDE Karras", "DPM++ SDE", "DPM++ SDE Karras", "Euler", "Euler a", "Heun", "LMS", "DDIM"} + sampler_note = "" + if sampler not in supported: + normalized = sampler.casefold() + replacement = "DPM++ SDE" if "sde" in normalized else "DPM++ 2M" if "2m" in normalized else "" + if not replacement: + raise ValueError(f"Sampler '{sampler}' is not supported by ADAM's LoRA generator.") + sampler_note = f"Sampler '{sampler}' was mapped to '{replacement}' because the connected generator does not support it." + sampler = replacement + return ImportedLoRAMetadata( + prompt=text_value("prompt", required=True), + negative_prompt=text_value("negative_prompt"), + seed=seed, + steps=steps, + cfg_scale=cfg_scale, + base_model_path=text_value("model", required=True), + lora_path=lora_path, + lora_strength=lora_strength, + sampler=sampler, + width=dimension("width"), + height=dimension("height"), + sampler_note=sampler_note, + ) + + +def parse_pasted_lora_metadata_request(text: str) -> "ChatGenerationRequest | None": + """Recognize copied LoRA metadata as an unambiguous prompt-box command.""" + source = str(text or "") + if not re.search(r'["\']?prompt["\']?\s*:', source, re.I) or not re.search( + r'["\']?(?:loras|negative_prompt|sampler)["\']?\s*:', source, re.I + ): + return None + try: + metadata = parse_lora_generation_metadata(source) + except ValueError: + return None + return ChatGenerationRequest( + prompt=metadata.prompt, + subject=Path(metadata.lora_path).stem if metadata.lora_path else "", + provider_hint="lora", + model_query=Path(metadata.lora_path).stem if metadata.lora_path else "", + base_model_query=Path(metadata.base_model_path).stem, + negative_prompt=metadata.negative_prompt, + steps=metadata.steps, + sampler=metadata.sampler, + seed=metadata.seed, + cfg_scale=metadata.cfg_scale, + lora_strength=metadata.lora_strength, + width=metadata.width, + height=metadata.height, + has_positive_prompt=True, + is_pasted_metadata=True, + metadata_model_path=metadata.lora_path, + metadata_base_model_path=metadata.base_model_path, + ) + + +def parse_plain_generation_metadata(text: str) -> "ChatGenerationRequest | None": + """Read the common CivitAI/A1111 and PixAI copied-text metadata layouts.""" + source = str(text or "").replace("\r\n", "\n").strip() + if not source: + return None + civitai = re.search(r"\bNegative\s+prompt\s*:", source, re.I) + pixai = re.search(r"\b(?:Sampling\s+Steps|Original\s+Prompt)\b", source, re.I) + if not civitai and not pixai: + return None + prompt = "" + negative = "" + if civitai: + prompt = source[:civitai.start()].strip(" ,\n") + tail = source[civitai.end():] + settings = re.search(r"\b(?:Steps|Size)\s*:", tail, re.I) + negative = tail[:settings.start()].strip(" ,\n") if settings else tail.strip(" ,\n") + else: + original = re.search(r"\bOriginal\s+Prompt\s*\n+(.+?)(?=\n+\s*Size\s*\n)", source, re.I | re.S) + prompt = (original.group(1) if original else source.split("\n\n", 1)[0]).strip(" ,\n") + negative_match = re.search(r"\n\s*Negative\s*\n+(.+?)(?=\n\s*(?:Prompt\s+Helper|#|$))", source, re.I | re.S) + negative = negative_match.group(1).strip(" ,\n") if negative_match else "" + + def number(pattern: str, kind): + match = re.search(pattern, source, re.I) + return kind(match.group(1)) if match else None + steps = number(r"\b(?:Sampling\s+)?Steps\s*:?\s*(\d+)", int) + cfg = number(r"\bCFG\s*(?:Scale)?\s*:?\s*(\d+(?:\.\d+)?)", float) + seed = number(r"\bSeed\s*:?\s*(-?\d+)", int) + if seed == -1: + seed = 0 + size = re.search(r"\bSize\s*:?\s*(\d+)\s*[x×]\s*(\d+)", source, re.I) + sampler_match = re.search(r"\b(?:Sampling\s+Method|Sampler)\s*:?\s*([^\n,]+)", source, re.I) + sampler = sampler_match.group(1).strip() if sampler_match else "" + if sampler: + folded = sampler.casefold() + sampler = next((name for name in ("DPM++ 2M SDE Karras", "DPM++ 2M SDE", "DPM++ 2M Karras", "DPM++ SDE Karras", "DPM++ SDE", "DPM++ 2M") if name.casefold() in folded), sampler) + loras = re.findall(r"]+)(?::([\d.]+))?>", prompt, re.I) + if loras: + prompt = re.sub(r"\s*]+>", "", prompt, flags=re.I).strip(" ,") + return ChatGenerationRequest( + prompt=prompt, provider_hint="lora", model_query=loras[0][0].strip() if len(loras) == 1 else "", + negative_prompt=negative, steps=steps, seed=seed, sampler=sampler, cfg_scale=cfg, + lora_strength=float(loras[0][1]) if len(loras) == 1 and loras[0][1] else None, + width=int(size.group(1)) if size else None, height=int(size.group(2)) if size else None, + has_positive_prompt=True, is_pasted_metadata=True, + ) + + @dataclass(frozen=True, slots=True) class ChatGenerationRequest: """Generation settings recognized from a Command Center message.""" @@ -34,6 +249,11 @@ class ChatGenerationRequest: reference_strength: int | None = None reference_image: str = "" has_positive_prompt: bool = False + is_pasted_metadata: bool = False + metadata_model_path: str = "" + metadata_base_model_path: str = "" + width: int | None = None + height: int | None = None _QUOTED = r'["\u201c\u201d]([^"\u201c\u201d]+)["\u201c\u201d]' @@ -47,7 +267,9 @@ def generation_model_match_score(query: str, model_name: str) -> int: """Score whether conversational subject text clearly names a saved model.""" def words(value: str) -> list[str]: value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", value) - ignored = {"a", "an", "the", "of", "image", "picture", "model", "ddpm", "flow", "matching", "lora"} + value = re.sub(r"\bpixel\s+row\b", " ", value, flags=re.I) + value = re.sub(r"\binr\s*flow\b", " ", value, flags=re.I) + ignored = {"a", "an", "the", "of", "image", "picture", "model", "ddpm", "flow", "matching", "lora", "inr"} return [word for word in re.findall(r"[a-z0-9]+", value.casefold()) if word not in ignored] query_words = words(query) @@ -73,21 +295,43 @@ def parse_chat_generation_request(text: str) -> ChatGenerationRequest | None: This intentionally requires both a creation verb and the word image/picture so ordinary planning requests continue through the regular Command Center planner. """ + pasted_metadata = parse_pasted_lora_metadata_request(text) + if pasted_metadata is not None: + return pasted_metadata + plain_metadata = parse_plain_generation_metadata(text) + if plain_metadata is not None: + return plain_metadata request = " ".join(text.strip().split()) - if not request or not re.search(r"\b(generate|create|make)\b", request, re.I): - return None - if not re.search(r"\b(image|images|picture|pictures)\b", request, re.I): + # Do not treat any request that happens to contain both words as an image + # generation command. Dataset requests commonly say things such as + # "image mode" and "generate captions"; those must continue to the + # regular planner (and, in particular, the video dataset collector). + # Require the creation verb to directly introduce the image noun instead. + generation_command = re.compile( + r"\b(?:generate|create|make)\s+" + r"(?:(?:an?|the|\d+)\s+)?" + r"(?:[\"\u201c\u201d]?(?:ddpm|ddim|inr\s*flow|flow(?:\s+matching)?|pixel\s*row|lora)[\"\u201c\u201d]?\s+)?" + r"(?:images?|pictures?)\b", + re.I, + ) + if not request or not generation_command.search(request): return None provider_hint = "" provider_match = re.search( - r"\b(ddpm|ddim|flow(?:\s+matching)?|lora)\b[\"\u201c\u201d]?(?=\s+(?:image|picture))", + r"\b(ddpm|ddim|inr\s*flow|flow(?:\s+matching)?|pixel\s*row|lora)\b[\"\u201c\u201d]?(?=\s+(?:image|picture))", request, re.I, ) if provider_match: hint = provider_match.group(1).casefold() - provider_hint = "ddpm" if hint in {"ddpm", "ddim"} else "flow" if hint.startswith("flow") else "lora" + provider_hint = ( + "ddpm" if hint in {"ddpm", "ddim"} + else "inrflow" if hint.replace(" ", "") == "inrflow" + else "flow" if hint.startswith("flow") + else "pixelrow" if hint.replace(" ", "") == "pixelrow" + else "lora" + ) # Support natural phrasing such as "Generate an image of LoRA OrangeCat". lora_subject_match = re.search( rf"\b(?:image|picture)s?\s+of\s+(?:a\s+)?LoRA\s+{_QUOTED}", @@ -123,10 +367,14 @@ def parse_chat_generation_request(text: str) -> ChatGenerationRequest | None: # In promptless commands, a provider suffix is usually part of the saved # model name (for example, "Minecraft Flow"), not prompt prose. if not provider_hint and subject: - if re.search(r"\bflow(?:\s+match(?:ing)?)?\s*$", subject, re.I): + if re.search(r"\binr\s*flow\s*$", subject, re.I): + provider_hint = "inrflow" + elif re.search(r"\bflow(?:\s+match(?:ing)?)?\s*$", subject, re.I): provider_hint = "flow" elif re.search(r"\bddpm\s*$", subject, re.I): provider_hint = "ddpm" + elif re.search(r"\bpixel\s*row\s*$", subject, re.I): + provider_hint = "pixelrow" positive_match = re.search( rf"\bpositive\s+prompt(?:\s+of|\s*=|\s*:)?\s*{_QUOTED}", @@ -286,6 +534,66 @@ class GenerationModelFolder: latest_at: str +@dataclass(frozen=True, slots=True) +class GenerationProviderFolder: + """A generator-centered view over the on-disk generation folders. + + Output is stored as ``generations///...``. This view + intentionally exposes that first directory level in the UI, keeping all + images made by one generator together without moving any user files. + """ + + key: str + provider_id: str + provider_name: str + records: tuple[GenerationRecord, ...] + model_count: int + image_count: int + latest_at: str + + +def generation_provider_key(record: GenerationRecord) -> str: + """Return the stable key for the generator directory containing a batch.""" + provider_id = str(record.provider_id or "").strip() + if not provider_id: + # Metadata written by older versions may not have a provider id. Its + # parent is still the generator directory in the current file layout. + provider_id = record.folder.parent.name + return provider_id.casefold() + + +def group_generation_providers( + records: list[GenerationRecord], +) -> list[GenerationProviderFolder]: + """Build newest-first generator folders from existing generation records.""" + grouped: dict[str, list[GenerationRecord]] = {} + for record in records: + grouped.setdefault(generation_provider_key(record), []).append(record) + folders: list[GenerationProviderFolder] = [] + for key, provider_records in grouped.items(): + newest_first = sorted( + provider_records, + key=lambda item: item.created_at or item.folder.name, + reverse=True, + ) + latest = newest_first[0] + provider_id = str(latest.provider_id or latest.folder.parent.name) + provider_name = str(latest.provider_name or provider_id) + folders.append( + GenerationProviderFolder( + key=key, + provider_id=provider_id, + provider_name=provider_name, + records=tuple(newest_first), + model_count=len({generation_model_key(record) for record in newest_first}), + image_count=sum(len(record.images) for record in newest_first), + latest_at=latest.created_at, + ) + ) + folders.sort(key=lambda item: (item.latest_at, item.provider_name.casefold()), reverse=True) + return folders + + def generation_model_key(record: GenerationRecord) -> str: """Keep renamed or duplicated display names separated by model identity.""" raw_path = str(record.model_path or "").strip() @@ -345,14 +653,26 @@ def load_generation_history(root: Path, *, limit: int = 200) -> list[GenerationR history_root = root.resolve() / "data" / "generations" if not history_root.is_dir(): return [] - records = [ - record - for metadata_path in history_root.rglob("generation*.json") - for record in [GenerationRecord.from_metadata(metadata_path)] - if record is not None - ] + # History can grow into thousands of image batches. Sort inexpensive file + # metadata first, then decode only the newest records requested by the UI. + # This keeps a page refresh responsive without moving or rewriting history. + try: + metadata_paths = sorted( + history_root.rglob("generation*.json"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + except OSError: + metadata_paths = list(history_root.rglob("generation*.json")) + records = [] + for metadata_path in metadata_paths: + record = GenerationRecord.from_metadata(metadata_path) + if record is not None: + records.append(record) + if len(records) >= max(1, int(limit)): + break records.sort(key=lambda item: item.created_at or item.folder.name, reverse=True) - return records[: max(1, int(limit))] + return records def build_generation_plan( diff --git a/adam/intelligence.py b/adam/intelligence.py new file mode 100644 index 0000000000000000000000000000000000000000..e6748120bed8c98ebcfea2e729d4a312f2c95452 --- /dev/null +++ b/adam/intelligence.py @@ -0,0 +1,200 @@ +"""Evidence-led summaries for ADAM's saved training and generation history. + +This module intentionally stays independent of the Qt interface so its advice can +be tested and reused by a future remote surface. It does not assess a model's +absolute quality: it identifies useful follow-up experiments from the evidence +ADAM has recorded locally. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from adam.experiment_tracker import ExperimentRun +from adam.generations import GenerationRecord +from adam.image_preferences import PreferenceProfile + + +@dataclass(frozen=True, slots=True) +class ModelIntelligence: + key: str + model_name: str + architecture: str + runs: tuple[ExperimentRun, ...] + generations: tuple[GenerationRecord, ...] + rated_images: int + positive_ratings: int + rejected_images: int + state: str + diagnosis: str + recommendation: str + recommended_epochs: int + + +def _normalized(value: str) -> str: + return " ".join(str(value or "").casefold().split()) + + +def _paths_match(left: str, right: str, cache: dict[str, Path] | None = None) -> bool: + if not left or not right: + return False + try: + cache = cache if cache is not None else {} + for value in (left, right): + if value not in cache: + cache[value] = Path(value).expanduser().resolve() + left_path, right_path = cache[left], cache[right] + return left_path == right_path or left_path in right_path.parents or right_path in left_path.parents + except OSError: + return _normalized(left) == _normalized(right) + + +def _generation_matches(run: ExperimentRun, record: GenerationRecord, cache: dict[str, Path] | None = None) -> bool: + if _paths_match(run.output_folder, record.model_path, cache): + return True + if any(_paths_match(path, record.model_path, cache) for path in run.checkpoint_paths): + return True + return _normalized(run.model_name) == _normalized(record.model_name) + + +def _ratings(records: tuple[GenerationRecord, ...], root: Path | None = None) -> tuple[int, int, int]: + rated = positive = rejected = 0 + seen: set[str] = set() + preferences_by_model: dict[tuple[str, str], PreferenceProfile] = {} + image_keys: dict[Path, str] = {} + for record in records: + preferences = None + if root: + key = (record.provider_id, record.model_path) + if key not in preferences_by_model: + preferences_by_model[key] = PreferenceProfile(root, record.provider_id, record.model_name, record.model_path) + preferences = preferences_by_model[key] + for image in record.images: + if image not in image_keys: + image_keys[image] = str(image.expanduser().resolve()) + image_key = image_keys[image] + if image_key in seen: + continue + seen.add(image_key) + evaluation = record.image_evaluations.get(image_key, {}) + saved_rating = preferences.rating_for(image) if preferences else None + rating = saved_rating.rating if saved_rating else str(evaluation.get("rating", "")).casefold() + # Preference ratings are persisted separately today. The evaluator + # score still counts as review evidence when it is available. + score = evaluation.get("score") + if rating or isinstance(score, (int, float)): + rated += 1 + if rating in {"favorite", "keep"} or (isinstance(score, (int, float)) and score >= 0.70): + positive += 1 + if rating == "reject" or (isinstance(score, (int, float)) and score <= 0.35): + rejected += 1 + return rated, positive, rejected + + +def _diagnose( + runs: tuple[ExperimentRun, ...], generations: tuple[GenerationRecord, ...], rated: int, positive: int, rejected: int, +) -> tuple[str, str, str, int]: + latest = runs[0] + finished = [run for run in runs if run.status.casefold() == "finished"] + epoch_budget = max(1, latest.epochs) + previous = runs[1] if len(runs) > 1 else None + + if not finished: + return ( + "Needs a completed run", + "ADAM has not recorded a finished training run for this model yet, so it cannot judge training behavior.", + "Finish one run and generate a small, fixed-prompt test batch before changing several settings at once.", + epoch_budget, + ) + if rated == 0: + return ( + "Needs visual review", + "Training history exists, but there are no scored test generations linked to this model. Loss alone cannot tell ADAM which checkpoint you prefer.", + "Generate 4–8 images with one repeatable prompt and seed, then rate the results in Generations before starting a follow-up.", + epoch_budget, + ) + if rejected > positive and rated >= 3: + return ( + "Review data or settings", + f"{rejected} of {rated} reviewed generated images were rejected or scored low. More epochs by themselves are unlikely to be the best first change.", + "Review dataset variety, captions, and the fixed-prompt gallery. Keep the epoch count similar for the next controlled test, changing only one training setting.", + epoch_budget, + ) + if previous and latest.final_loss is not None and previous.final_loss is not None: + loss_change = latest.final_loss - previous.final_loss + if abs(loss_change) <= max(0.0001, abs(previous.final_loss) * 0.03): + return ( + "Likely plateau", + "The last two recorded losses changed very little. That is a plateau signal, not proof that the model has stopped improving visually.", + "Run a shorter follow-up (about 25% fewer epochs) with a lower learning rate or improved data; compare it using the same evaluation prompt and seed.", + max(1, round(epoch_budget * 0.75)), + ) + if positive >= max(2, rejected * 2): + return ( + "Promising", + f"{positive} reviewed generated images look positive versus {rejected} rejected. The model has enough signal for a focused continuation test.", + "Preserve this run as a baseline. Try a modest continuation of about 25% more epochs, then compare the same prompt-and-seed gallery before committing further.", + max(epoch_budget + 1, round(epoch_budget * 1.25)), + ) + return ( + "Gather one more comparison", + "ADAM has mixed review evidence. A single outcome can be affected by prompt choice, seed, or dataset coverage.", + "Make another small fixed-prompt generation batch, rate it, and change only one setting in the next run so the result is interpretable.", + epoch_budget, + ) + + +def build_model_intelligence( + runs: list[ExperimentRun], generations: list[GenerationRecord], *, root: Path | None = None, +) -> list[ModelIntelligence]: + """Group local records into newest-first, model-centered intelligence cards.""" + grouped: dict[tuple[str, str], list[ExperimentRun]] = {} + for run in runs: + key = (_normalized(run.model_name), _normalized(run.model_architecture)) + if key[0]: + grouped.setdefault(key, []).append(run) + + profiles: list[ModelIntelligence] = [] + path_cache: dict[str, Path] = {} + for (name_key, architecture_key), raw_runs in grouped.items(): + model_runs = tuple(sorted(raw_runs, key=lambda run: run.timestamp, reverse=True)) + model_generations = tuple( + record for record in generations + if any(_generation_matches(run, record, path_cache) for run in model_runs) + ) + rated, positive, rejected = _ratings(model_generations, root) + state, diagnosis, recommendation, epochs = _diagnose( + model_runs, model_generations, rated, positive, rejected + ) + profiles.append(ModelIntelligence( + key=f"{architecture_key}:{name_key}", + model_name=model_runs[0].model_name, + architecture=model_runs[0].model_architecture, + runs=model_runs, + generations=model_generations, + rated_images=rated, + positive_ratings=positive, + rejected_images=rejected, + state=state, + diagnosis=diagnosis, + recommendation=recommendation, + recommended_epochs=epochs, + )) + return sorted(profiles, key=lambda profile: profile.runs[0].timestamp, reverse=True) + + +def recommended_training_request(profile: ModelIntelligence) -> str: + """Create an approval-aware follow-up request using the latest run as a baseline.""" + run = profile.runs[0] + options = { + key: value for key, value in run.settings.items() + if key not in {"dataset_dir", "model_name", "epochs", "output_dir", "resume_from"} + } + return ( + f"From the {run.dataset_name or run.dataset_path} dataset, train a " + f"{run.model_architecture.upper()} model for {profile.recommended_epochs} epochs. " + f"Name the model {run.model_name} Follow-up. " + "[ADAM_TRAINING_OPTIONS:" + json.dumps(options, sort_keys=True) + "] " + "[ADAM_TRAINER:" + run.model_architecture + "]" + ) diff --git a/adam/job_manager.py b/adam/job_manager.py index 8dff72d1ea6d8a071704f657fd79ff56de289c07..b00d59a7383aa9e3ffb217d777efac3cc022eebc 100644 --- a/adam/job_manager.py +++ b/adam/job_manager.py @@ -73,7 +73,7 @@ class JobWorker(QThread): preview_state = {"epoch": 0, "path": ""} last_progress_emit = {"time": 0.0, "overall": -1, "message": ""} - progress_samples: list[dict[str, float]] = [] + progress_samples: list[dict[str, Any]] = [] def on_progress(percent: int, message: str, step_index: int = index, **details: Any) -> None: overall = int(((step_index + percent / 100) / total_steps) * 100) @@ -175,7 +175,7 @@ class JobWorker(QThread): @staticmethod def _estimate_step_eta( details: dict[str, Any], - samples: list[dict[str, float]], + samples: list[dict[str, Any]], now: float, ) -> dict[str, Any]: """Estimate remaining runtime from real step cadence instead of percent alone.""" @@ -184,6 +184,8 @@ class JobWorker(QThread): unit = str(details.get("unit", "step") or "step") epoch = _safe_int(details.get("epoch")) total_epochs = _safe_int(details.get("total_epochs")) + if details.get("reset_eta"): + samples.clear() if (not current or not total) and epoch and total_epochs: current, total, unit = epoch, total_epochs, "epoch" payload: dict[str, Any] = { @@ -194,9 +196,18 @@ class JobWorker(QThread): if not current or not total or current >= total: return payload last = samples[-1] if samples else None + if last and ( + int(last.get("total", total)) != total + or str(last.get("unit", unit)) != unit + or current < last["current"] + ): + # A different tqdm operation or a restarted counter needs a fresh + # cadence; carrying the old rate creates wildly incorrect ETAs. + samples.clear() + last = None if last and current <= last["current"]: return payload - samples.append({"time": now, "current": float(current)}) + samples.append({"time": now, "current": float(current), "total": float(total), "unit": unit}) del samples[:-25] if len(samples) < 2: return payload @@ -380,6 +391,10 @@ class JobManager(QObject): step = job.plan.steps[job.current_step] if step.tool_id != "ddpm_trainer": raise ValueError("Safe epoch-boundary adjustment currently supports DDPM training.") + if step.arguments.get("progressive_stages"): + raise ValueError( + "Change batch settings before starting a progressive run; each stage manages its own saved handoff." + ) allowed = {"batch_size", "training_intensity", "gradient_accumulation_steps"} cleaned = {key: int(value) for key, value in updates.items() if key in allowed} if not cleaned or not 1 <= cleaned.get("batch_size", 1) <= 64 \ diff --git a/adam/model_plugins.py b/adam/model_plugins.py index ac477fff3e91a069e3e811e6db3dd7abf914b03b..e8582180fa942a897ade8a886937c20980d01ba8 100644 --- a/adam/model_plugins.py +++ b/adam/model_plugins.py @@ -237,7 +237,9 @@ class ModelPluginRegistry: "demo": False, } defaults.update(tool) - defaults["arguments"] = list(defaults.get("arguments") or [*core_arguments, *list(schema)]) + defaults["arguments"] = list(dict.fromkeys( + defaults.get("arguments") or [*core_arguments, *list(schema)] + )) defaults["required_arguments"] = list(defaults.get("required_arguments") or []) return defaults diff --git a/adam/model_plugins_builtin/ddpm/manifest.py b/adam/model_plugins_builtin/ddpm/manifest.py index 92e333ea342718b4a552753af032b3a418cef2d1..a48e357484ae37c43391bf307a1be537ea66225f 100644 --- a/adam/model_plugins_builtin/ddpm/manifest.py +++ b/adam/model_plugins_builtin/ddpm/manifest.py @@ -16,7 +16,9 @@ MODEL_INFO = { } TRAINING_SETTINGS = { - "resolution": {"label": "Resolution", "type": "choice", "options": [64, 128, 256, 384, 512], "default": 128, "group": "Basic"}, + "resolution": {"label": "Longest edge", "type": "choice", "options": [64, 128, 256, 384, 512], "default": 128, "group": "Basic", "description": "The longest side of the native training canvas."}, + "training_aspect_ratio": {"label": "Training aspect ratio", "type": "choice", "options": ["Dataset (Auto)", "1:1 (Square)", "16:9 (Widescreen)", "9:16 (Portrait)", "4:3 (Classic)", "3:4 (Portrait Classic)", "3:2 (Photo)", "2:3 (Portrait Photo)"], "default": "Dataset (Auto)", "group": "Basic", "description": "Dataset Auto uses the median source-image aspect ratio; 256 with 16:9 creates a 256x144 model."}, + "resize_mode": {"label": "Image fitting", "type": "choice", "options": ["fit", "fill", "stretch"], "default": "fit", "group": "Dataset", "description": "fit preserves the entire image and edge-pads only when needed; fill crops; stretch changes proportions."}, "batch_size": {"label": "Batch size", "type": "int", "default": 1, "min": 1, "max": 64, "group": "Basic"}, "learning_rate": {"label": "Learning rate", "type": "float", "default": 0.0001, "min": 0.0000001, "max": 0.1, "decimals": 7, "step": 0.00005, "group": "Optimization"}, "gradient_accumulation_steps": {"label": "Gradient accumulation", "type": "int", "default": 1, "min": 1, "max": 64, "group": "Optimization"}, diff --git a/adam/model_plugins_builtin/flow_matching/manifest.py b/adam/model_plugins_builtin/flow_matching/manifest.py index 9df1fe4f165c44fc9b07da8bbaefcbd358c685c4..2dedc4a3fee891df279c4000d81af486f3d2eb2e 100644 --- a/adam/model_plugins_builtin/flow_matching/manifest.py +++ b/adam/model_plugins_builtin/flow_matching/manifest.py @@ -37,6 +37,8 @@ GENERATION_SETTINGS = { "steps": {"label": "ODE steps", "type": "int", "default": 20, "min": 1, "max": 200, "group": "Generation"}, "sampler": {"label": "Method", "type": "choice", "options": ["Heun", "Euler"], "default": "Heun", "group": "Generation"}, "aspect_ratio": {"label": "Aspect ratio", "type": "choice", "options": ["1:1 (Square)", "4:3 (Landscape)", "3:4 (Portrait)", "3:2 (Landscape)", "2:3 (Portrait)", "16:9 (Widescreen)", "9:16 (Vertical)"], "default": "1:1 (Square)", "group": "Generation"}, + "width": {"label": "Width", "type": "int", "default": 0, "min": 0, "max": 2048, "group": "Generation"}, + "height": {"label": "Height", "type": "int", "default": 0, "min": 0, "max": 2048, "group": "Generation"}, "seed": {"label": "Seed", "type": "int", "default": 0, "min": 0, "max": 2147483647, "group": "Generation"}, "preview_interval": {"label": "Steps per preview", "type": "int", "default": 0, "min": 0, "max": 500, "group": "Preview"}, "smart_generation": {"label": "Smart Generation", "type": "bool", "default": False, "group": "Smart Generation"}, diff --git a/adam/model_plugins_builtin/inrflow/__init__.py b/adam/model_plugins_builtin/inrflow/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..044466320d174f7147cca634437740c175e53a41 --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/__init__.py @@ -0,0 +1,2 @@ +"""ADAM-sized INRFlow image model plugin.""" + diff --git a/adam/model_plugins_builtin/inrflow/common.py b/adam/model_plugins_builtin/inrflow/common.py new file mode 100644 index 0000000000000000000000000000000000000000..e03743bfb24c63008309fd6dc831f0c6f4b7b6cc --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/common.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import torch +from PIL import Image + +from adam.executor import ToolExecutionError + +from .model import MODEL_FORMAT_VERSION, INRFlowConfig, INRFlowModel + + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} +FINAL_CHECKPOINT_NAME = "inrflow_model.pt" +CONFIG_NAME = "inrflow_config.json" + + +def safe_model_name(value: str) -> str: + name = re.sub(r"\s+", " ", value.strip()) + if not name or len(name) > 96 or any(character in name for character in '<>:"/\\|?*\x00'): + raise ToolExecutionError( + "Choose a short INRFlow model name without reserved filename characters." + ) + return name + + +def ensure_below(path: Path, root: Path, label: str) -> Path: + resolved = path.expanduser().resolve() + try: + resolved.relative_to(root.expanduser().resolve()) + except ValueError as exc: + raise ToolExecutionError(f"{label} must stay inside {root.resolve()}.") from exc + return resolved + + +def image_files(folder: Path) -> list[Path]: + try: + return sorted( + path + for path in folder.rglob("*") + if path.is_file() and path.suffix.casefold() in IMAGE_EXTENSIONS + ) + except OSError: + return [] + + +def resolve_checkpoint(path: Path) -> Path: + candidate = path.expanduser().resolve() + if candidate.is_dir(): + candidate = candidate / FINAL_CHECKPOINT_NAME + if not candidate.is_file(): + raise ToolExecutionError( + "The selected INRFlow checkpoint does not exist or is incomplete." + ) + return candidate + + +def load_checkpoint( + path: Path, + device: torch.device, + *, + prefer_ema: bool = True, +) -> tuple[INRFlowModel, dict[str, Any]]: + checkpoint_path = resolve_checkpoint(path) + try: + payload = torch.load(checkpoint_path, map_location=device, weights_only=True) + except (OSError, RuntimeError, ValueError, TypeError) as exc: + raise ToolExecutionError(f"Could not load the INRFlow checkpoint: {exc}") from exc + if not isinstance(payload, dict) or "model_state" not in payload or "config" not in payload: + raise ToolExecutionError("The selected file is not a valid INRFlow checkpoint.") + if int(payload.get("format_version", 0)) != MODEL_FORMAT_VERSION: + raise ToolExecutionError("This INRFlow checkpoint uses an unsupported format version.") + try: + config = INRFlowConfig.from_dict(dict(payload["config"])) + model = INRFlowModel(config).to(device) + state = payload.get("ema_state") if prefer_ema else None + model.load_state_dict(state if isinstance(state, dict) else payload["model_state"], strict=True) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + raise ToolExecutionError(f"The INRFlow checkpoint is incompatible: {exc}") from exc + return model, payload + + +def save_image(image: torch.Tensor, path: Path) -> None: + pixels = ( + image.detach() + .float() + .cpu() + .clamp(-1.0, 1.0) + .add(1.0) + .mul(127.5) + .round() + .to(torch.uint8) + .numpy() + ) + path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(pixels, mode="RGB").save(path, format="PNG") + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") + temporary.replace(path) diff --git a/adam/model_plugins_builtin/inrflow/generator.py b/adam/model_plugins_builtin/inrflow/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..08ab814afafae9b8f45361b354bc4a9b9d64259d --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/generator.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import torch + +from adam.executor import ToolExecutionError +from adam.generations import generation_metadata_path, generation_output_folder +from adam.image_preferences import GenerationPreferenceEvaluator, PreferenceProfile + +from .common import ensure_below, load_checkpoint, safe_model_name, save_image, write_json +from .model import sample_image + + +def generate( + context, + model_name: str, + model_path: str, + prompt: str, + image_count: int, + steps: int, + seed: int, + sampler: str, + aspect_ratio: str, + output_resolution: str = "Native", + noise_scale: float = 1.0, + query_chunk_size: int = 1024, + preview_interval: int = 5, + smart_generation: bool = False, + smart_wanted_results: int = 8, + smart_max_candidates: int = 32, + smart_min_score: float = 0.7, + smart_mode: str = "threshold", + smart_keep_rejected: bool = True, +) -> dict[str, Any]: + """Generate images by integrating the learned ambient-space velocity field.""" + name = safe_model_name(model_name) + model_root = ( + context.root.resolve() / "data" / "model_plugin_outputs" / "inrflow" + ).resolve() + selected = ensure_below(Path(model_path), model_root, "INRFlow model") + if not selected.exists(): + raise ToolExecutionError("The selected INRFlow model no longer exists.") + count = int(image_count) + step_count = int(steps) + if not 1 <= count <= 48: + raise ToolExecutionError("INRFlow image count must be between 1 and 48.") + if not 2 <= step_count <= 200: + raise ToolExecutionError("INRFlow ODE steps must be between 2 and 200.") + method = sampler.strip().title() + if method not in {"Euler", "Heun"}: + raise ToolExecutionError("INRFlow supports the Euler and Heun ODE methods.") + if aspect_ratio != "1:1 (Coordinate Field)": + raise ToolExecutionError("INRFlow currently generates square coordinate fields.") + if not 0.1 <= float(noise_scale) <= 2.0: + raise ToolExecutionError("INRFlow starting noise scale must be between 0.1 and 2.0.") + if int(query_chunk_size) not in {256, 512, 1024, 2048, 4096}: + raise ToolExecutionError("Choose a supported INRFlow query chunk size.") + if not 0 <= int(preview_interval) <= step_count: + raise ToolExecutionError("Preview interval must be between 0 and the ODE step count.") + if len(prompt) > 500: + raise ToolExecutionError("The INRFlow creative note must be 500 characters or shorter.") + + smart_enabled = bool(smart_generation) + wanted_results = int(smart_wanted_results or count) + max_candidates = int(smart_max_candidates or count) + threshold = float(smart_min_score) + top_n_mode = str(smart_mode).casefold() == "top_n" + if smart_enabled: + if not 1 <= wanted_results <= 48: + raise ToolExecutionError("Wanted Smart Generation results must be between 1 and 48.") + if not wanted_results <= max_candidates <= 256: + raise ToolExecutionError( + "Maximum Smart Generation candidates must be between wanted results and 256." + ) + if not 0.0 <= threshold <= 1.0: + raise ToolExecutionError("Minimum Smart Generation score must be between 0 and 1.") + if str(smart_mode).casefold() not in {"threshold", "top_n"}: + raise ToolExecutionError("Smart Generation mode must be threshold or top_n.") + count = wanted_results + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model, checkpoint = load_checkpoint(selected, device, prefer_ema=True) + if str(output_resolution) == "Native": + resolution = model.config.resolution + else: + try: + resolution = int(output_resolution) + except (TypeError, ValueError) as exc: + raise ToolExecutionError("Choose Native or a supported INRFlow output resolution.") from exc + if resolution not in {32, 64, 128, 256}: + raise ToolExecutionError("INRFlow output resolution must be 32, 64, 128, or 256.") + if resolution % model.config.patch_size: + raise ToolExecutionError( + "That output resolution is not divisible by this model's spatial latent patch." + ) + if resolution != model.config.resolution: + context.log( + f"Querying the learned coordinate field at {resolution}px; it was trained at " + f"{model.config.resolution}px, so this is resolution extrapolation." + ) + + generated_total = max_candidates if smart_enabled else count + base_seed = int(seed) + if base_seed <= 0: + base_seed = random.SystemRandom().randint( + 1, 2_147_483_647 - generated_total + ) + if base_seed + generated_total - 1 > 2_147_483_647: + raise ToolExecutionError("The INRFlow seed is too large for this image count.") + + output = generation_output_folder(context.root, context.tool.id, name) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + image_paths: list[str] = [] + selected_paths: list[str] = [] + image_evaluations: dict[str, dict[str, object]] = {} + profile = ( + PreferenceProfile(context.root, context.tool.id, name, str(selected)) + if smart_enabled + else None + ) + evaluator = GenerationPreferenceEvaluator(context.root) if smart_enabled else None + context.log( + f"Loaded {name}: trained at {model.config.resolution}px, generating at " + f"{resolution}px with {method} on {device}." + ) + context.log( + "INRFlow is unconditional; the creative note is saved with the result but is not a text prompt." + ) + + try: + for image_index in range(generated_total): + context.checkpoint() + current_seed = base_seed + image_index + generator = torch.Generator(device=device) + generator.manual_seed(current_seed) + live_path = output / ".live" / context.job_id / f"image_{image_index + 1:03d}.png" + + def on_step(done: int, total: int, image: torch.Tensor) -> None: + context.checkpoint() + overall = (image_index + done / max(1, total)) / generated_total + context.progress( + max(1, min(99, round(overall * 100))), + f"Image {image_index + 1} of {generated_total} · ODE step {done} of {total}", + current=done, + total=total, + image_index=image_index, + image_count=generated_total, + unit="step", + ) + if int(preview_interval) > 0 and ( + done % int(preview_interval) == 0 or done == total + ): + save_image(image, live_path) + context.preview( + live_path, + kind="generation", + current=done, + total=total, + image_index=image_index, + image_count=generated_total, + seed=current_seed, + steps=step_count, + ) + + image = sample_image( + model, + resolution=resolution, + steps=step_count, + method=method, + noise_scale=float(noise_scale), + query_chunk_size=int(query_chunk_size), + generator=generator, + step_callback=on_step, + ) + destination = output / ( + f"{timestamp}_{context.job_id}_INRFlow_{method}_seed_{current_seed}_" + f"{resolution}px.png" + ) + save_image(image, destination) + image_paths.append(str(destination)) + if smart_enabled and profile is not None and evaluator is not None: + score = evaluator.score( + profile, + [destination], + keep_threshold=threshold, + reject_threshold=profile.reject_threshold, + )[0] + image_evaluations[str(destination.resolve())] = { + "score": score.score, + "confidence": score.confidence, + "category": score.category, + "reason": score.reason, + } + if not top_n_mode and score.score is not None and score.score >= threshold: + selected_paths.append(str(destination)) + if len(selected_paths) >= wanted_results: + break + except torch.cuda.OutOfMemoryError as exc: + if device.type == "cuda": + torch.cuda.empty_cache() + raise ToolExecutionError( + "INRFlow ran out of VRAM while generating. Lower output resolution or query chunk size." + ) from exc + finally: + if evaluator is not None: + evaluator.vision.unload() + + if smart_enabled and top_n_mode: + ranked = sorted( + image_paths, + key=lambda path: float( + image_evaluations.get(str(Path(path).resolve()), {}).get("score") or -1.0 + ), + reverse=True, + ) + selected_paths = ranked[:wanted_results] + if smart_enabled: + chosen = set(selected_paths) + ordered_images = [*selected_paths, *[path for path in image_paths if path not in chosen]] + saved_images = ordered_images if bool(smart_keep_rejected) else selected_paths + else: + ordered_images = image_paths + saved_images = image_paths + + metadata = { + "version": 1, + "provider_id": context.tool.id, + "provider_name": context.tool.name, + "model_name": name, + "model_path": str(selected), + "model_type": "inrflow", + "architecture": "inrflow_ambient_space", + "prompt": prompt.strip(), + "prompt_behavior": "label_only", + "seed": base_seed, + "image_seeds": [base_seed + index for index in range(len(image_paths))], + "image_count": len(saved_images), + "steps": step_count, + "sampler": method, + "aspect_ratio": aspect_ratio, + "training_resolution": model.config.resolution, + "output_resolution": resolution, + "noise_scale": float(noise_scale), + "query_chunk_size": int(query_chunk_size), + "preview_interval": int(preview_interval), + "images": saved_images, + "image_evaluations": image_evaluations, + "checkpoint_epoch": int(checkpoint.get("completed_epochs", 0) or 0), + "used_ema_weights": isinstance(checkpoint.get("ema_state"), dict), + "uses_pretrained_compressor": False, + "smart_generation": { + "enabled": smart_enabled, + "mode": str(smart_mode), + "wanted_results": wanted_results if smart_enabled else count, + "maximum_candidates": max_candidates if smart_enabled else count, + "minimum_score": threshold, + "selected_count": len(selected_paths) if smart_enabled else count, + "candidate_count": len(image_paths), + "profile_id": profile.id if profile else "", + "keep_rejected_candidates": bool(smart_keep_rejected), + }, + "created_at": datetime.now(timezone.utc).isoformat(), + } + write_json(generation_metadata_path(output, timestamp, context.job_id), metadata) + live_folder = output / ".live" / context.job_id + if live_folder.is_dir(): + for path in live_folder.glob("*.png"): + try: + path.unlink() + except OSError: + pass + try: + live_folder.rmdir() + live_folder.parent.rmdir() + except OSError: + pass + + if smart_enabled: + context.progress( + 100, + f"Smart Generation selected {len(selected_paths)} of {wanted_results} requested " + f"image(s) from {len(image_paths)} candidate(s)", + ) + else: + context.progress(100, f"Generated {count} INRFlow image(s)") + return { + "output_folder": str(output), + "assets": [ + { + "kind": "generation", + "name": f"{name} · {timestamp}", + "path": str(output), + "trainer": "inrflow", + "metadata": { + "resolution": resolution, + "steps": step_count, + "sampler": method, + }, + } + ], + } diff --git a/adam/model_plugins_builtin/inrflow/manifest.py b/adam/model_plugins_builtin/inrflow/manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..ea99051b6d1df55f3a24a6afa207bd2837a5e4a6 --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/manifest.py @@ -0,0 +1,394 @@ +PLUGIN_ID = "inrflow" + +MODEL_INFO = { + "name": "INRFlow (Ambient Space)", + "version": "0.1", + "category": "Image Generation", + "description": ( + "ADAM-sized INRFlow: coordinate-to-RGB flow matching directly in pixel space, " + "with spatial context latents and no pretrained image compressor." + ), + "architecture": "inrflow_ambient_space", + "status": "experimental", + "output_type": "image", + "capabilities": [ + "fresh_training", + "resume_training", + "image_generation", + "smart_generation", + "live_preview", + "resolution_flexible_generation", + ], + "input_formats": ["image folder"], + "output_formats": ["INRFlow checkpoint", "INRFlow metadata", "PNG preview"], + "hardware": {"recommended_vram_gb": 8, "recommended_system_ram_gb": 16}, + "vram_behavior": { + "scales_with": [ + "resolution", "batch_size", "hidden_size", "depth", + "query_points", "sampling_steps", + ], + "estimate": ( + "Designed for 64px experiments on 8–12 GB GPUs. At 128px, reduce batch " + "size and query points before shrinking the model." + ), + }, + "method_reference": "https://arxiv.org/abs/2412.03791", + "reference_implementation": "https://github.com/apple/ml-inrflow", +} + +TRAINING_SETTINGS = { + "resolution": { + "label": "Training resolution", + "type": "choice", + "options": [32, 64, 128, 256], + "default": 64, + "group": "Basic", + "description": "Start at 64px for an architecture comparison on an RTX 3060.", + }, + "resize_mode": { + "label": "Image fitting", + "type": "choice", + "options": ["fill", "fit", "stretch"], + "default": "fill", + "group": "Dataset", + }, + "horizontal_flip": { + "label": "Random horizontal flip", + "type": "bool", + "default": True, + "group": "Dataset", + }, + "batch_size": { + "label": "Batch size", + "type": "int", + "default": 4, + "min": 1, + "max": 32, + "group": "Basic", + }, + "learning_rate": { + "label": "Learning rate", + "type": "float", + "default": 0.0001, + "min": 0.0000001, + "max": 0.1, + "decimals": 7, + "step": 0.00005, + "group": "Optimization", + }, + "weight_decay": { + "label": "Weight decay", + "type": "float", + "default": 0.0, + "min": 0.0, + "max": 1.0, + "decimals": 5, + "step": 0.001, + "group": "Optimization", + "advanced": True, + }, + "gradient_accumulation_steps": { + "label": "Gradient accumulation", + "type": "int", + "default": 1, + "min": 1, + "max": 64, + "group": "Optimization", + }, + "workers": { + "label": "Loader workers", + "type": "int", + "default": 0, + "min": 0, + "max": 16, + "group": "Dataset", + }, + "mixed_precision": { + "label": "Precision", + "type": "choice", + "options": ["fp16", "bf16", "no"], + "default": "fp16", + "group": "Optimization", + }, + "patch_size": { + "label": "Spatial latent patch", + "type": "choice", + "options": [4, 8, 16], + "default": 8, + "group": "INRFlow", + "description": "Each spatial context latent attends to the coordinate-value pairs in one patch.", + }, + "hidden_size": { + "label": "Transformer width", + "type": "choice", + "options": [128, 192, 256, 384], + "default": 256, + "group": "INRFlow", + "advanced": True, + }, + "depth": { + "label": "Transformer layers", + "type": "choice", + "options": [2, 4, 6, 8], + "default": 4, + "group": "INRFlow", + "advanced": True, + }, + "num_heads": { + "label": "Attention heads", + "type": "choice", + "options": [4, 8], + "default": 8, + "group": "INRFlow", + "advanced": True, + }, + "decoder_layers": { + "label": "Point decoder layers", + "type": "choice", + "options": [1, 2], + "default": 1, + "group": "INRFlow", + "advanced": True, + }, + "fourier_frequencies": { + "label": "Coordinate frequencies", + "type": "choice", + "options": [4, 6, 8, 10], + "default": 8, + "group": "INRFlow", + "advanced": True, + }, + "query_points": { + "label": "Pixel queries per image", + "type": "choice", + "options": [256, 512, 1024, 2048, 4096], + "default": 1024, + "group": "INRFlow", + "description": "Point-wise subsampling is a defining INRFlow training advantage.", + }, + "time_sampling": { + "label": "Flow-time sampling", + "type": "choice", + "options": ["logit_normal", "uniform"], + "default": "logit_normal", + "group": "INRFlow", + "advanced": True, + }, + "ema_decay": { + "label": "EMA decay", + "type": "float", + "default": 0.999, + "min": 0.9, + "max": 0.99999, + "decimals": 5, + "step": 0.0001, + "group": "Optimization", + "advanced": True, + }, + "save_every": { + "label": "Save every", + "type": "int", + "default": 10, + "min": 1, + "max": 1000, + "group": "Checkpoints", + }, + "preview_enabled": { + "label": "Generate previews while training", + "type": "bool", + "default": True, + "group": "Preview", + }, + "preview_every": { + "label": "Preview interval", + "type": "int", + "default": 5, + "min": 1, + "max": 100000, + "group": "Preview", + }, + "preview_steps": { + "label": "Preview flow steps", + "type": "int", + "default": 20, + "min": 2, + "max": 200, + "group": "Preview", + }, + "preview_prompt": { + "label": "Preview note", + "type": "text", + "default": "", + "group": "Preview", + }, + "preview_seed": { + "label": "Preview seed", + "type": "int", + "default": 123456789, + "min": 0, + "max": 2147483647, + "group": "Preview", + }, +} + +GENERATION_SETTINGS = { + "prompt": { + "label": "Creative note", + "type": "multiline_text", + "default": "", + "group": "Generation", + }, + "image_count": { + "label": "Images", + "type": "int", + "default": 1, + "min": 1, + "max": 48, + "group": "Generation", + }, + "steps": { + "label": "ODE steps", + "type": "int", + "default": 50, + "min": 2, + "max": 200, + "group": "Generation", + }, + "sampler": { + "label": "ODE method", + "type": "choice", + "options": ["Euler", "Heun"], + "default": "Euler", + "group": "Generation", + }, + "aspect_ratio": { + "label": "Aspect ratio", + "type": "choice", + "options": ["1:1 (Coordinate Field)"], + "default": "1:1 (Coordinate Field)", + "group": "Generation", + }, + "seed": { + "label": "Seed", + "type": "int", + "default": 0, + "min": 0, + "max": 2147483647, + "group": "Generation", + }, + "output_resolution": { + "label": "Output resolution", + "type": "choice", + "options": ["Native", "32", "64", "128", "256"], + "default": "Native", + "group": "Coordinate Field", + "description": "INRFlow can query the learned coordinate field at a different resolution.", + }, + "noise_scale": { + "label": "Starting noise scale", + "type": "float", + "default": 1.0, + "min": 0.1, + "max": 2.0, + "decimals": 2, + "step": 0.05, + "group": "Generation", + }, + "query_chunk_size": { + "label": "Query chunk size", + "type": "choice", + "options": [256, 512, 1024, 2048, 4096], + "default": 1024, + "group": "Advanced", + "advanced": True, + "description": "Reduce this if resolution-flexible generation runs out of VRAM.", + }, + "preview_interval": { + "label": "Steps per live preview", + "type": "int", + "default": 5, + "min": 0, + "max": 200, + "group": "Preview", + }, + "smart_generation": { + "label": "Smart Generation", + "type": "bool", + "default": False, + "group": "Smart Generation", + }, + "smart_wanted_results": { + "label": "Wanted results", + "type": "int", + "default": 8, + "min": 1, + "max": 48, + "group": "Smart Generation", + }, + "smart_max_candidates": { + "label": "Maximum candidates", + "type": "int", + "default": 32, + "min": 1, + "max": 256, + "group": "Smart Generation", + }, + "smart_min_score": { + "label": "Minimum score", + "type": "float", + "default": 0.7, + "min": 0, + "max": 1, + "group": "Smart Generation", + }, + "smart_mode": { + "label": "Selection mode", + "type": "choice", + "options": ["threshold", "top_n"], + "default": "threshold", + "group": "Smart Generation", + }, + "smart_keep_rejected": { + "label": "Keep rejected candidates", + "type": "bool", + "default": True, + "group": "Smart Generation", + }, +} + +TRAINING_TOOL = { + "id": "inrflow_trainer", + "name": "INRFlow Trainer", + "description": "Trains coordinate-to-RGB flow matching directly in ambient image space.", + "capabilities": ["fresh_training", "resume_training", "progress", "pause", "cancel", "live_preview"], + "backend": { + "type": "python", + "module": "adam.model_plugins_builtin.inrflow.trainer", + "function": "train", + }, +} + +GENERATION_TOOL = { + "id": "inrflow_generator", + "name": "INRFlow Generator", + "description": "Integrates an INRFlow coordinate field from Gaussian noise to an image.", + "model_trainers": ["inrflow"], + "capabilities": [ + "image_generation", "smart_generation", "seed", "ode_method", "batch", + "resolution_flexible_generation", "live_preview", "progress", "cancel", + ], + "generation_options": { + "samplers": ["Euler", "Heun"], + "aspect_ratios": ["1:1 (Coordinate Field)"], + "step_min": 2, + "step_max": 200, + "step_default": 50, + "preview_step_default": 5, + }, + "backend": { + "type": "python", + "module": "adam.model_plugins_builtin.inrflow.generator", + "function": "generate", + }, +} + diff --git a/adam/model_plugins_builtin/inrflow/model.py b/adam/model_plugins_builtin/inrflow/model.py new file mode 100644 index 0000000000000000000000000000000000000000..f77cc8cc2ccf444d0ff8c4094a6ba308f42dcfbf --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/model.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import math +from dataclasses import asdict, dataclass +from typing import Any, Callable + +import torch +from torch import Tensor, nn +from torch.nn import functional as F + + +MODEL_FORMAT_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class INRFlowConfig: + resolution: int = 64 + patch_size: int = 8 + hidden_size: int = 256 + depth: int = 4 + num_heads: int = 8 + decoder_layers: int = 1 + fourier_frequencies: int = 8 + + def __post_init__(self) -> None: + if self.resolution not in {32, 64, 128, 256}: + raise ValueError("INRFlow resolution must be 32, 64, 128, or 256.") + if self.patch_size not in {4, 8, 16} or self.resolution % self.patch_size: + raise ValueError("INRFlow patch size must be 4, 8, or 16 and divide the resolution.") + if not 64 <= self.hidden_size <= 768: + raise ValueError("INRFlow transformer width must be between 64 and 768.") + if not 1 <= self.depth <= 12 or not 1 <= self.decoder_layers <= 4: + raise ValueError("INRFlow transformer depth is outside the supported range.") + if self.num_heads not in {2, 4, 8, 16} or self.hidden_size % self.num_heads: + raise ValueError("INRFlow width must be divisible by its attention-head count.") + if not 2 <= self.fourier_frequencies <= 16: + raise ValueError("INRFlow coordinate frequencies must be between 2 and 16.") + + def to_dict(self) -> dict[str, int]: + return asdict(self) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "INRFlowConfig": + return cls( + resolution=int(payload.get("resolution", 64)), + patch_size=int(payload.get("patch_size", 8)), + hidden_size=int(payload.get("hidden_size", 256)), + depth=int(payload.get("depth", 4)), + num_heads=int(payload.get("num_heads", 8)), + decoder_layers=int(payload.get("decoder_layers", 1)), + fourier_frequencies=int(payload.get("fourier_frequencies", 8)), + ) + + +def coordinate_grid( + height: int, + width: int, + *, + device: torch.device | str | None = None, +) -> Tensor: + """Return normalized x/y coordinates as a flattened Nx2 field.""" + y = torch.linspace(0.0, 1.0, height, device=device) + x = torch.linspace(0.0, 1.0, width, device=device) + yy, xx = torch.meshgrid(y, x, indexing="ij") + return torch.stack((xx, yy), dim=-1).reshape(height * width, 2) + + +class FourierCoordinates(nn.Module): + def __init__(self, frequencies: int) -> None: + super().__init__() + bands = torch.pow(2.0, torch.arange(frequencies, dtype=torch.float32)) * math.pi + self.register_buffer("bands", bands, persistent=False) + self.output_size = 2 + 4 * frequencies + + def forward(self, coordinates: Tensor) -> Tensor: + phases = coordinates.unsqueeze(-1) * self.bands + return torch.cat( + (coordinates, phases.sin().flatten(-2), phases.cos().flatten(-2)), dim=-1 + ) + + +class TimeEmbedding(nn.Module): + def __init__(self, hidden_size: int, frequency_size: int = 64) -> None: + super().__init__() + self.frequency_size = frequency_size + self.mlp = nn.Sequential( + nn.Linear(frequency_size, hidden_size), + nn.SiLU(), + nn.Linear(hidden_size, hidden_size), + ) + + def forward(self, time: Tensor) -> Tensor: + half = self.frequency_size // 2 + frequencies = torch.exp( + -math.log(10_000.0) + * torch.arange(half, device=time.device, dtype=torch.float32) + / max(1, half) + ) + phases = time.float().unsqueeze(1) * frequencies.unsqueeze(0) + embedding = torch.cat((phases.cos(), phases.sin()), dim=1) + return self.mlp(embedding) + + +class PatchContextEncoder(nn.Module): + """Cross-attend one spatial latent to nearby coordinate/value pairs.""" + + def __init__(self, config: INRFlowConfig, coordinates: FourierCoordinates) -> None: + super().__init__() + hidden = config.hidden_size + self.patch_size = config.patch_size + self.coordinates = coordinates + self.point_projection = nn.Sequential( + nn.Linear(coordinates.output_size + 3, hidden), + nn.LayerNorm(hidden), + nn.SiLU(), + ) + self.center_projection = nn.Linear(coordinates.output_size, hidden) + self.latent_seed = nn.Parameter(torch.randn(1, 1, hidden) * 0.02) + self.attention = nn.MultiheadAttention( + hidden, config.num_heads, batch_first=True + ) + self.norm1 = nn.LayerNorm(hidden) + self.norm2 = nn.LayerNorm(hidden) + self.mlp = nn.Sequential( + nn.Linear(hidden, hidden * 2), nn.GELU(), nn.Linear(hidden * 2, hidden) + ) + + @staticmethod + def _patchify(values: Tensor, height: int, width: int, patch: int) -> Tensor: + batch, points, channels = values.shape + if points != height * width or height % patch or width % patch: + raise ValueError("INRFlow context field does not match its patch grid.") + return values.reshape( + batch, height // patch, patch, width // patch, patch, channels + ).permute(0, 1, 3, 2, 4, 5).reshape( + batch, (height // patch) * (width // patch), patch * patch, channels + ) + + def forward( + self, + context_coordinates: Tensor, + context_values: Tensor, + *, + height: int, + width: int, + ) -> tuple[Tensor, Tensor]: + batch = context_values.shape[0] + encoded_coordinates = self.coordinates(context_coordinates) + point_features = self.point_projection( + torch.cat((encoded_coordinates, context_values), dim=-1) + ) + point_patches = self._patchify( + point_features, height, width, self.patch_size + ) + coordinate_patches = self._patchify( + context_coordinates, height, width, self.patch_size + ) + centers = coordinate_patches.mean(dim=2) + latent_queries = self.latent_seed + self.center_projection( + self.coordinates(centers) + ) + latent_count = point_patches.shape[1] + queries = latent_queries.reshape(batch * latent_count, 1, -1) + points = point_patches.reshape( + batch * latent_count, self.patch_size * self.patch_size, -1 + ) + attended, _weights = self.attention( + queries, points, points, need_weights=False + ) + latents = self.norm1(queries + attended) + latents = latents + self.mlp(self.norm2(latents)) + return latents.reshape(batch, latent_count, -1), centers + + +def _modulate(value: Tensor, shift: Tensor, scale: Tensor) -> Tensor: + return value * (1.0 + scale.unsqueeze(1)) + shift.unsqueeze(1) + + +class TimeConditionedBlock(nn.Module): + def __init__(self, hidden_size: int, heads: int) -> None: + super().__init__() + self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False) + self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False) + self.attention = nn.MultiheadAttention(hidden_size, heads, batch_first=True) + self.mlp = nn.Sequential( + nn.Linear(hidden_size, hidden_size * 4), + nn.GELU(approximate="tanh"), + nn.Linear(hidden_size * 4, hidden_size), + ) + self.modulation = nn.Sequential(nn.SiLU(), nn.Linear(hidden_size, hidden_size * 4)) + nn.init.zeros_(self.modulation[-1].weight) + nn.init.zeros_(self.modulation[-1].bias) + + def forward(self, latents: Tensor, time_embedding: Tensor) -> Tensor: + shift1, scale1, shift2, scale2 = self.modulation(time_embedding).chunk(4, dim=-1) + attended, _weights = self.attention( + _modulate(self.norm1(latents), shift1, scale1), + _modulate(self.norm1(latents), shift1, scale1), + _modulate(self.norm1(latents), shift1, scale1), + need_weights=False, + ) + latents = latents + attended + return latents + self.mlp(_modulate(self.norm2(latents), shift2, scale2)) + + +class QueryDecoderBlock(nn.Module): + def __init__(self, hidden_size: int, heads: int) -> None: + super().__init__() + self.query_norm = nn.LayerNorm(hidden_size) + self.latent_norm = nn.LayerNorm(hidden_size) + self.attention = nn.MultiheadAttention(hidden_size, heads, batch_first=True) + self.output_norm = nn.LayerNorm(hidden_size) + self.mlp = nn.Sequential( + nn.Linear(hidden_size, hidden_size * 2), + nn.GELU(approximate="tanh"), + nn.Linear(hidden_size * 2, hidden_size), + ) + + def forward(self, queries: Tensor, latents: Tensor) -> Tensor: + attended, _weights = self.attention( + self.query_norm(queries), + self.latent_norm(latents), + self.latent_norm(latents), + need_weights=False, + ) + queries = queries + attended + return queries + self.mlp(self.output_norm(queries)) + + +class INRFlowModel(nn.Module): + """Coordinate-query flow model following INRFlow's ambient-space structure.""" + + def __init__(self, config: INRFlowConfig) -> None: + super().__init__() + self.config = config + self.coordinate_embedding = FourierCoordinates(config.fourier_frequencies) + self.time_embedding = TimeEmbedding(config.hidden_size) + self.context_encoder = PatchContextEncoder(config, self.coordinate_embedding) + self.latent_coordinate_projection = nn.Linear( + self.coordinate_embedding.output_size, config.hidden_size + ) + self.trunk = nn.ModuleList([ + TimeConditionedBlock(config.hidden_size, config.num_heads) + for _ in range(config.depth) + ]) + self.query_projection = nn.Sequential( + nn.Linear(self.coordinate_embedding.output_size + 3, config.hidden_size), + nn.LayerNorm(config.hidden_size), + nn.SiLU(), + ) + self.decoder = nn.ModuleList([ + QueryDecoderBlock(config.hidden_size, config.num_heads) + for _ in range(config.decoder_layers) + ]) + self.output = nn.Sequential( + nn.LayerNorm(config.hidden_size), nn.Linear(config.hidden_size, 3) + ) + nn.init.zeros_(self.output[-1].weight) + nn.init.zeros_(self.output[-1].bias) + + def encode_context( + self, + context_coordinates: Tensor, + context_values: Tensor, + time: Tensor, + *, + height: int, + width: int, + ) -> tuple[Tensor, Tensor]: + latents, centers = self.context_encoder( + context_coordinates, context_values, height=height, width=width + ) + time_embedding = self.time_embedding(time) + latents = latents + self.latent_coordinate_projection( + self.coordinate_embedding(centers) + ) + for block in self.trunk: + latents = block(latents, time_embedding) + return latents, time_embedding + + def decode_queries( + self, + latents: Tensor, + time_embedding: Tensor, + query_coordinates: Tensor, + query_values: Tensor, + ) -> Tensor: + queries = self.query_projection(torch.cat(( + self.coordinate_embedding(query_coordinates), query_values + ), dim=-1)) + queries = queries + time_embedding.unsqueeze(1) + for block in self.decoder: + queries = block(queries, latents) + return self.output(queries) + + def forward( + self, + context_coordinates: Tensor, + context_values: Tensor, + time: Tensor, + query_coordinates: Tensor, + query_values: Tensor, + *, + height: int, + width: int, + ) -> Tensor: + latents, time_embedding = self.encode_context( + context_coordinates, context_values, time, height=height, width=width + ) + return self.decode_queries( + latents, time_embedding, query_coordinates, query_values + ) + + @torch.inference_mode() + def velocity_field( + self, + coordinates: Tensor, + values: Tensor, + time: Tensor, + *, + height: int, + width: int, + query_chunk_size: int = 1024, + ) -> Tensor: + latents, time_embedding = self.encode_context( + coordinates, values, time, height=height, width=width + ) + outputs = [] + for start in range(0, coordinates.shape[1], query_chunk_size): + stop = min(coordinates.shape[1], start + query_chunk_size) + outputs.append(self.decode_queries( + latents, + time_embedding, + coordinates[:, start:stop], + values[:, start:stop], + )) + return torch.cat(outputs, dim=1) + + +@torch.inference_mode() +def sample_image( + model: INRFlowModel, + *, + resolution: int, + steps: int, + method: str, + noise_scale: float, + query_chunk_size: int, + generator: torch.Generator, + step_callback: Callable[[int, int, Tensor], None] | None = None, +) -> Tensor: + """Integrate the learned velocity from Gaussian noise (t=0) to data (t=1).""" + if resolution % model.config.patch_size: + raise ValueError("Output resolution must be divisible by the trained patch size.") + model.eval() + device = next(model.parameters()).device + coordinates = coordinate_grid(resolution, resolution, device=device).unsqueeze(0) + values = torch.randn( + 1, resolution * resolution, 3, device=device, generator=generator + ) * float(noise_scale) + times = torch.linspace(0.0, 1.0, int(steps) + 1, device=device) + for index in range(int(steps)): + time = times[index].expand(1) + next_time = times[index + 1].expand(1) + delta = times[index + 1] - times[index] + with torch.autocast( + device_type=device.type, + dtype=torch.float16, + enabled=device.type == "cuda", + ): + first = model.velocity_field( + coordinates, + values, + time, + height=resolution, + width=resolution, + query_chunk_size=query_chunk_size, + ) + if method == "Heun": + predicted = values + delta * first + second = model.velocity_field( + coordinates, + predicted, + next_time, + height=resolution, + width=resolution, + query_chunk_size=query_chunk_size, + ) + if method == "Heun": + values = values + delta * 0.5 * (first + second) + else: + values = values + delta * first + if step_callback is not None: + image = values[0].reshape(resolution, resolution, 3).clamp(-1, 1) + step_callback(index + 1, int(steps), image) + return values[0].reshape(resolution, resolution, 3).clamp(-1, 1) + + +def parameter_count(model: nn.Module) -> int: + return sum(parameter.numel() for parameter in model.parameters()) diff --git a/adam/model_plugins_builtin/inrflow/trainer.py b/adam/model_plugins_builtin/inrflow/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..cd63a5e4155bf5cac8d669b1643fca75708ac2a0 --- /dev/null +++ b/adam/model_plugins_builtin/inrflow/trainer.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import copy +import random +from contextlib import nullcontext +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import torch +from PIL import Image, ImageOps, ImageStat +from torch.utils.data import DataLoader, Dataset + +from adam.executor import ToolExecutionError + +from .common import ( + CONFIG_NAME, + FINAL_CHECKPOINT_NAME, + ensure_below, + image_files, + load_checkpoint, + resolve_checkpoint, + safe_model_name, + save_image, + write_json, +) +from .model import ( + MODEL_FORMAT_VERSION, + INRFlowConfig, + INRFlowModel, + coordinate_grid, + parameter_count, + sample_image, +) + + +class INRFlowImageDataset(Dataset[torch.Tensor]): + def __init__( + self, + paths: list[Path], + *, + resolution: int, + resize_mode: str, + horizontal_flip: bool, + ) -> None: + self.paths = paths + self.resolution = resolution + self.resize_mode = resize_mode + self.horizontal_flip = horizontal_flip + + def __len__(self) -> int: + return len(self.paths) + + def __getitem__(self, index: int) -> torch.Tensor: + path = self.paths[index] + try: + with Image.open(path) as opened: + image = opened.convert("RGB") + size = (self.resolution, self.resolution) + if self.resize_mode == "fill": + image = ImageOps.fit(image, size, method=Image.Resampling.LANCZOS) + elif self.resize_mode == "fit": + mean = tuple( + int(value) for value in ImageStat.Stat(image.resize((1, 1))).mean + ) + image = ImageOps.pad( + image, size, method=Image.Resampling.LANCZOS, color=mean + ) + else: + image = image.resize(size, Image.Resampling.LANCZOS) + if self.horizontal_flip and random.random() < 0.5: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + buffer = bytearray(image.tobytes()) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Could not read training image {path.name}: {exc}") from exc + pixels = torch.frombuffer(buffer, dtype=torch.uint8).reshape( + self.resolution, self.resolution, 3 + ) + return pixels.float().div(127.5).sub(1.0).permute(2, 0, 1) + + +def _checkpoint_payload( + model: INRFlowModel, + ema_model: INRFlowModel, + optimizer: torch.optim.Optimizer, + *, + model_name: str, + dataset_dir: Path, + completed_epochs: int, + global_step: int, + training_settings: dict[str, Any], +) -> dict[str, Any]: + return { + "format_version": MODEL_FORMAT_VERSION, + "architecture": "inrflow_ambient_space", + "method": "conditionally_independent_continuous_flow_matching", + "model_name": model_name, + "config": model.config.to_dict(), + "model_state": model.state_dict(), + "ema_state": ema_model.state_dict(), + "optimizer_state": optimizer.state_dict(), + "completed_epochs": int(completed_epochs), + "global_step": int(global_step), + "dataset_dir": str(dataset_dir), + "training_settings": training_settings, + "saved_at": datetime.now(timezone.utc).isoformat(), + } + + +def _save_checkpoint(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + torch.save(payload, temporary) + temporary.replace(path) + + +@torch.no_grad() +def _update_ema(ema_model: INRFlowModel, model: INRFlowModel, decay: float) -> None: + source = model.state_dict() + for name, value in ema_model.state_dict().items(): + incoming = source[name] + if value.is_floating_point(): + value.mul_(decay).add_(incoming, alpha=1.0 - decay) + else: + value.copy_(incoming) + + +def _preview( + context, + model: INRFlowModel, + output: Path, + *, + epoch: int, + next_epoch: int, + steps: int, + seed: int, + prompt: str, +) -> None: + device = next(model.parameters()).device + generator = torch.Generator(device=device) + generator.manual_seed(int(seed)) + image = sample_image( + model, + resolution=model.config.resolution, + steps=int(steps), + method="Euler", + noise_scale=1.0, + query_chunk_size=1024, + generator=generator, + step_callback=lambda _done, _total, _image: context.checkpoint(), + ) + destination = output / "previews" / f"preview_epoch_{epoch:06d}.png" + save_image(image, destination) + context.preview( + destination, + epoch=epoch, + next_epoch=next_epoch, + prompt=prompt, + seed=seed, + steps=int(steps), + ) + + +def train( + context, + dataset_dir: str, + model_name: str, + epochs: int, + output_dir: str, + resume_from: str = "", + resolution: int = 64, + resize_mode: str = "fill", + horizontal_flip: bool = True, + batch_size: int = 4, + learning_rate: float = 0.0001, + weight_decay: float = 0.0, + gradient_accumulation_steps: int = 1, + workers: int = 0, + mixed_precision: str = "fp16", + patch_size: int = 8, + hidden_size: int = 256, + depth: int = 4, + num_heads: int = 8, + decoder_layers: int = 1, + fourier_frequencies: int = 8, + query_points: int = 1024, + time_sampling: str = "logit_normal", + ema_decay: float = 0.999, + save_every: int = 10, + preview_enabled: bool = True, + preview_every: int = 5, + preview_steps: int = 20, + preview_prompt: str = "", + preview_seed: int = 123456789, +) -> dict[str, Any]: + """Train an ADAM-sized INRFlow model directly on RGB coordinate fields.""" + name = safe_model_name(model_name) + dataset = Path(dataset_dir).expanduser().resolve() + if not dataset.is_dir(): + raise ToolExecutionError("The selected INRFlow dataset folder no longer exists.") + training_dataset = dataset + accepted_frames = dataset / "frames" + frame_paths = image_files(accepted_frames) if accepted_frames.is_dir() else [] + if frame_paths: + training_dataset, paths = accepted_frames, frame_paths + else: + paths = image_files(dataset) + if len(paths) < 2: + raise ToolExecutionError( + "INRFlow needs at least two readable image files before training can start." + ) + + output_root = ( + context.root.resolve() / "data" / "model_plugin_outputs" / "inrflow" + ).resolve() + output = ensure_below(Path(output_dir), output_root, "INRFlow output") + if output.exists() and not output.is_dir(): + raise ToolExecutionError("The INRFlow output path must be a folder.") + if output.exists() and any(output.iterdir()): + raise ToolExecutionError( + "The INRFlow output folder is not empty. Choose a new model output." + ) + output.mkdir(parents=True, exist_ok=True) + + if resize_mode not in {"fill", "fit", "stretch"}: + raise ToolExecutionError("INRFlow image fitting must be fill, fit, or stretch.") + if not 1 <= int(epochs) <= 100_000: + raise ToolExecutionError("INRFlow epochs must be between 1 and 100000.") + if not 1 <= int(batch_size) <= 32 or not 1 <= int(gradient_accumulation_steps) <= 64: + raise ToolExecutionError("INRFlow batch size or gradient accumulation is invalid.") + if not 1e-7 <= float(learning_rate) <= 0.1 or not 0.0 <= float(weight_decay) <= 1.0: + raise ToolExecutionError("INRFlow learning rate or weight decay is invalid.") + if not 0 <= int(workers) <= 16 or mixed_precision not in {"fp16", "bf16", "no"}: + raise ToolExecutionError("INRFlow loader workers or precision is invalid.") + if int(query_points) < 1 or int(query_points) > int(resolution) ** 2: + raise ToolExecutionError("Pixel queries cannot exceed the number of training pixels.") + if time_sampling not in {"logit_normal", "uniform"}: + raise ToolExecutionError("INRFlow time sampling must be logit_normal or uniform.") + if not 0.9 <= float(ema_decay) <= 0.99999: + raise ToolExecutionError("INRFlow EMA decay must be between 0.9 and 0.99999.") + if not 1 <= int(save_every) <= 1000 or not 1 <= int(preview_every) <= 100_000: + raise ToolExecutionError("INRFlow save and preview intervals must be positive.") + if not 2 <= int(preview_steps) <= 200: + raise ToolExecutionError("INRFlow preview steps must be between 2 and 200.") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + resume_payload: dict[str, Any] | None = None + if resume_from.strip(): + resume_path = ensure_below( + resolve_checkpoint(Path(resume_from)), output_root, "INRFlow resume checkpoint" + ) + model, resume_payload = load_checkpoint(resume_path, device, prefer_ema=False) + config = model.config + context.log( + "Continuing with the checkpoint architecture: " + f"{config.resolution}px, width {config.hidden_size}, depth {config.depth}." + ) + else: + try: + config = INRFlowConfig( + resolution=int(resolution), + patch_size=int(patch_size), + hidden_size=int(hidden_size), + depth=int(depth), + num_heads=int(num_heads), + decoder_layers=int(decoder_layers), + fourier_frequencies=int(fourier_frequencies), + ) + except ValueError as exc: + raise ToolExecutionError(str(exc)) from exc + model = INRFlowModel(config).to(device) + + if int(query_points) > config.resolution ** 2: + raise ToolExecutionError( + "Pixel queries cannot exceed the resumed model's training resolution." + ) + ema_model = copy.deepcopy(model).to(device).eval() + if resume_payload is not None and isinstance(resume_payload.get("ema_state"), dict): + try: + ema_model.load_state_dict(resume_payload["ema_state"], strict=True) + except RuntimeError: + context.log("The previous EMA weights were incompatible; EMA restarted from the model.") + + dataset_object = INRFlowImageDataset( + paths, + resolution=config.resolution, + resize_mode=resize_mode, + horizontal_flip=bool(horizontal_flip), + ) + loader = DataLoader( + dataset_object, + batch_size=int(batch_size), + shuffle=True, + num_workers=int(workers), + pin_memory=device.type == "cuda", + drop_last=False, + ) + optimizer = torch.optim.AdamW( + model.parameters(), + lr=float(learning_rate), + betas=(0.9, 0.95), + weight_decay=float(weight_decay), + ) + start_epoch = 0 + global_step = 0 + if resume_payload is not None: + start_epoch = int(resume_payload.get("completed_epochs", 0) or 0) + global_step = int(resume_payload.get("global_step", 0) or 0) + if isinstance(resume_payload.get("optimizer_state"), dict): + try: + optimizer.load_state_dict(resume_payload["optimizer_state"]) + for group in optimizer.param_groups: + group["lr"] = float(learning_rate) + group["weight_decay"] = float(weight_decay) + except (ValueError, RuntimeError): + context.log("The old optimizer state was incompatible; using a fresh optimizer.") + + use_fp16 = mixed_precision == "fp16" and device.type == "cuda" + use_bf16 = ( + mixed_precision == "bf16" + and device.type == "cuda" + and bool(getattr(torch.cuda, "is_bf16_supported", lambda: False)()) + ) + if mixed_precision != "no" and not (use_fp16 or use_bf16): + context.log(f"{mixed_precision.upper()} is unavailable here; INRFlow will use full precision.") + autocast_dtype = torch.bfloat16 if use_bf16 else torch.float16 + try: + scaler = torch.amp.GradScaler("cuda", enabled=use_fp16) + except (AttributeError, TypeError): + scaler = torch.cuda.amp.GradScaler(enabled=use_fp16) + + accumulation = int(gradient_accumulation_steps) + requested_epochs = int(epochs) + final_epoch = start_epoch + requested_epochs + batches_per_epoch = max(1, len(loader)) + total_batches = requested_epochs * batches_per_epoch + coordinates = coordinate_grid(config.resolution, config.resolution, device=device) + settings = { + "resolution": config.resolution, + "resize_mode": resize_mode, + "horizontal_flip": bool(horizontal_flip), + "batch_size": int(batch_size), + "learning_rate": float(learning_rate), + "weight_decay": float(weight_decay), + "gradient_accumulation_steps": accumulation, + "workers": int(workers), + "mixed_precision": mixed_precision, + "query_points": int(query_points), + "time_sampling": time_sampling, + "ema_decay": float(ema_decay), + **config.to_dict(), + } + write_json( + output / CONFIG_NAME, + { + "format_version": MODEL_FORMAT_VERSION, + "model_type": "inrflow", + "model_name": name, + **config.to_dict(), + }, + ) + context.log( + f"Training INRFlow on {len(paths)} images from {training_dataset} at " + f"{config.resolution}x{config.resolution}, {parameter_count(model):,} parameters, " + f"batch {batch_size}, device {device}." + ) + context.log( + "Images stay in RGB coordinate space: no VAE or other pretrained image compressor is used." + ) + optimizer.zero_grad(set_to_none=True) + processed_batches = 0 + last_loss = 0.0 + try: + for epoch in range(start_epoch + 1, final_epoch + 1): + model.train() + epoch_loss = 0.0 + for batch_index, images in enumerate(loader, 1): + context.checkpoint() + images = images.to(device, non_blocking=device.type == "cuda") + batch = images.shape[0] + clean = images.permute(0, 2, 3, 1).reshape(batch, -1, 3) + noise = torch.randn_like(clean) + if time_sampling == "logit_normal": + time = torch.sigmoid(torch.randn(batch, device=device)) + else: + time = torch.rand(batch, device=device) + mixed = (1.0 - time[:, None, None]) * noise + time[:, None, None] * clean + target = clean - noise + sample_count = min(int(query_points), clean.shape[1]) + indices = torch.randperm(clean.shape[1], device=device)[:sample_count] + all_coordinates = coordinates.unsqueeze(0).expand(batch, -1, -1) + amp = ( + torch.autocast( + device_type=device.type, + dtype=autocast_dtype, + enabled=use_fp16 or use_bf16, + ) + if device.type in {"cuda", "cpu"} + else nullcontext() + ) + with amp: + velocity = model( + all_coordinates, + mixed, + time, + all_coordinates[:, indices], + mixed[:, indices], + height=config.resolution, + width=config.resolution, + ) + loss = torch.nn.functional.mse_loss(velocity, target[:, indices]) + scaled_loss = loss / accumulation + scaler.scale(scaled_loss).backward() + if batch_index % accumulation == 0 or batch_index == batches_per_epoch: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), 2.0) + scaler.step(optimizer) + scaler.update() + optimizer.zero_grad(set_to_none=True) + global_step += 1 + _update_ema(ema_model, model, float(ema_decay)) + last_loss = float(loss.detach().item()) + epoch_loss += last_loss + processed_batches += 1 + percent = max(1, min(99, round(processed_batches * 100 / total_batches))) + context.progress( + percent, + f"Epoch {epoch} of {final_epoch} · flow loss {last_loss:.4f}", + epoch=epoch, + total_epochs=final_epoch, + current_step=processed_batches, + total_steps=total_batches, + unit="batch", + loss=last_loss, + ) + + payload = _checkpoint_payload( + model, + ema_model, + optimizer, + model_name=name, + dataset_dir=dataset, + completed_epochs=epoch, + global_step=global_step, + training_settings=settings, + ) + if epoch % int(save_every) == 0: + _save_checkpoint(output / "checkpoints" / f"epoch_{epoch:06d}.pt", payload) + if bool(preview_enabled) and epoch % int(preview_every) == 0: + _preview( + context, + ema_model, + output, + epoch=epoch, + next_epoch=min(final_epoch, epoch + int(preview_every)), + steps=int(preview_steps), + seed=int(preview_seed), + prompt=preview_prompt, + ) + context.log(f"Finished epoch {epoch}; average loss {epoch_loss / batches_per_epoch:.4f}.") + except torch.cuda.OutOfMemoryError as exc: + if device.type == "cuda": + torch.cuda.empty_cache() + raise ToolExecutionError( + "INRFlow ran out of VRAM. Reduce batch size, then pixel queries, resolution, or model width." + ) from exc + + final_payload = _checkpoint_payload( + model, + ema_model, + optimizer, + model_name=name, + dataset_dir=dataset, + completed_epochs=final_epoch, + global_step=global_step, + training_settings=settings, + ) + final_checkpoint = output / FINAL_CHECKPOINT_NAME + _save_checkpoint(final_checkpoint, final_payload) + write_json( + output / "training_metadata.json", + { + "format_version": MODEL_FORMAT_VERSION, + "model_type": "inrflow", + "architecture": "inrflow_ambient_space", + "method": "conditionally_independent_continuous_flow_matching", + "model_name": name, + "dataset_dir": str(dataset), + "image_count": len(paths), + "completed_epochs": final_epoch, + "epochs_this_run": requested_epochs, + "global_step": global_step, + "final_loss": last_loss, + "parameter_count": parameter_count(model), + "checkpoint": str(final_checkpoint), + "uses_pretrained_compressor": False, + "method_reference": "https://arxiv.org/abs/2412.03791", + "settings": settings, + "finished_at": datetime.now(timezone.utc).isoformat(), + }, + ) + context.progress(100, "INRFlow training completed") + return { + "output_folder": str(output), + "model_name": name, + "assets": [ + { + "kind": "model", + "name": name, + "path": str(output), + "trainer": "inrflow", + "dataset_path": str(dataset), + "checkpoint": str(final_checkpoint), + "epochs": final_epoch, + "metadata": { + "architecture": "inrflow_ambient_space", + "resolution": config.resolution, + "parameter_count": parameter_count(model), + "uses_pretrained_compressor": False, + }, + } + ], + } diff --git a/adam/model_plugins_builtin/oasis/manifest.py b/adam/model_plugins_builtin/oasis/manifest.py index 63ae2dc7a1672ca7bcde69a907974a25165144c6..6e0c9108779f8c6ce2778180c0504e7c886baa4b 100644 --- a/adam/model_plugins_builtin/oasis/manifest.py +++ b/adam/model_plugins_builtin/oasis/manifest.py @@ -2,35 +2,40 @@ PLUGIN_ID = "oasis" MODEL_INFO = { "name": "Oasis Action World Model", - "version": "1.0", + "version": "1.1", "category": "Playable World Models", "description": "Action-conditioned playable world model trainer for gameplay frame sequences.", - "architecture": "action_conditioned_rectified_flow_video", + "architecture": "action_conditioned_rectified_flow_video | action_conditioned_latent_vae_flow_video | action_conditioned_temporal_latent_flow | action_conditioned_temporal_pixel_flow", "status": "experimental", "output_type": "playable_world", "capabilities": ["fresh_training", "resume_training", "playable_inference", "live_preview"], "input_formats": ["Oasis action dataset folder", "semicolon-separated Oasis dataset folders"], - "output_formats": ["action_flow_model_info.json", "diffusers unet folder", "png preview"], + "output_formats": ["action_flow_model_info.json", "diffusers unet folder", "VAE folder for latent models", "png preview"], "hardware": {"recommended_vram_gb": 12, "recommended_system_ram_gb": 32}, "vram_behavior": { - "scales_with": ["resolution", "batch_size", "sequence_context"], + "scales_with": ["resolution", "batch_size", "history frames"], "estimate": "High; 256x144 with batch 2 is the conservative RTX 3060 starting point.", }, } TRAINING_SETTINGS = { - "resolution": {"label": "Resolution", "type": "choice", "options": ["128x72", "256x144", "384x216", "512x288"], "default": "256x144", "group": "Basic"}, + "model_engine": {"label": "Model engine", "type": "choice", "options": ["pixel_flow", "vae_cpu_lite", "temporal_latent", "temporal_pixel_flow"], "option_labels": {"pixel_flow": "Pixel Flow (RGB)", "vae_cpu_lite": "VAE CPU Lite", "temporal_latent": "Temporal Latent (VAE + history)", "temporal_pixel_flow": "Temporal Pixel Flow"}, "default": "temporal_latent", "group": "Basic", "description": "Temporal Pixel Flow keeps recent-frame and timed-input conditioning while predicting full RGB frames without a VAE. It needs more VRAM than Temporal Latent."}, + "resolution": {"label": "Resolution", "type": "choice", "options": ["256x144", "512x288"], "default": "256x144", "group": "Basic", "description": "Must be 16:9 with both dimensions divisible by 16."}, + "frame_gap": {"label": "Frame gap", "type": "int", "default": 1, "min": 1, "max": 60, "group": "Basic", "description": "How many recorded frames one generated game frame spans. Use 1 for a 12–15 FPS recording when you want the most responsive native AI FPS."}, "batch_size": {"label": "Batch size", "type": "int", "default": 2, "min": 1, "max": 16, "group": "Basic"}, "learning_rate": {"label": "Learning rate", "type": "float", "default": 0.00002, "min": 0.0000001, "max": 0.01, "decimals": 7, "step": 0.00001, "group": "Optimization"}, "workers": {"label": "Loader workers", "type": "int", "default": 2, "min": 0, "max": 8, "group": "Dataset"}, - "mixed_precision": {"label": "Precision", "type": "choice", "options": ["fp32", "fp16", "no"], "default": "fp32", "group": "Optimization"}, + "mixed_precision": {"label": "Precision", "type": "choice", "options": ["fp16", "no"], "default": "fp16", "group": "Optimization", "description": "The connected Oasis trainer supports FP16 or full precision (no AMP)."}, "gradient_accumulation": {"label": "Gradient accumulation", "type": "int", "default": 1, "min": 1, "max": 16, "group": "Optimization"}, - "frame_gap": {"label": "Prediction horizon", "type": "int", "default": 3, "min": 1, "max": 60, "group": "Sequence"}, - "sequence_context": {"label": "Context length", "type": "int", "default": 1, "min": 1, "max": 32, "group": "Sequence"}, + "sequence_context": {"label": "Legacy context length", "type": "int", "default": 1, "min": 1, "max": 32, "group": "Sequence", "advanced": True, "description": "Only used by the older pixel-flow engines."}, + "context_frames": {"label": "History frames", "type": "choice", "options": [1, 4, 8], "default": 4, "group": "Sequence", "description": "How much recent visual and input history the temporal model uses."}, + "rollout_frames": {"label": "Future frames", "type": "choice", "options": [1, 3], "default": 3, "group": "Sequence", "description": "How many future frames each temporal training example predicts."}, + "vae_epochs": {"label": "VAE warm-up epochs", "type": "int", "default": 5, "min": 1, "max": 100, "group": "Sequence", "description": "Initial epochs used to learn the compact visual representation for latent engines. Not used by pixel_flow or temporal_pixel_flow."}, "action_aggregation": {"label": "Action aggregation", "type": "choice", "options": ["window", "mean", "last"], "default": "window", "group": "Sequence"}, "validation_split": {"label": "Validation split", "type": "float", "default": 0.1, "min": 0.01, "max": 0.5, "decimals": 3, "step": 0.01, "group": "Dataset"}, "validation_batches": {"label": "Validation batches", "type": "int", "default": 8, "min": 0, "max": 128, "group": "Dataset"}, "save_every": {"label": "Save every", "type": "int", "default": 5, "min": 1, "max": 1000, "group": "Checkpoints"}, + "best_checkpoint_min_improvement": {"label": "Best-checkpoint improvement (%)", "type": "float", "default": 0.1, "min": 0.0, "max": 20.0, "decimals": 2, "step": 0.1, "group": "Checkpoints", "advanced": True, "description": "Save a separate best-validation checkpoint only when held-out quality improves by this percentage. ADAM uses that checkpoint automatically when playing the completed model."}, "preview_enabled": {"label": "Generate previews while training", "type": "bool", "default": True, "group": "Preview"}, "preview_every": {"label": "Preview interval", "type": "int", "default": 5, "min": 1, "max": 100000, "group": "Preview"}, "preview_steps": {"label": "Preview steps", "type": "int", "default": 1, "min": 1, "max": 50, "group": "Preview"}, @@ -43,8 +48,18 @@ TRAINING_SETTINGS = { "neutral_action_dropout": {"label": "Neutral action dropout", "type": "float", "default": 0.15, "min": 0.0, "max": 0.9, "decimals": 3, "step": 0.05, "group": "Advanced", "advanced": True}, "action_contrast_weight": {"label": "Action contrast weight", "type": "float", "default": 0.35, "min": 0.0, "max": 5.0, "decimals": 3, "step": 0.05, "group": "Advanced", "advanced": True}, "action_contrast_margin": {"label": "Action contrast margin", "type": "float", "default": 0.02, "min": 0.0, "max": 1.0, "decimals": 4, "step": 0.005, "group": "Advanced", "advanced": True}, + "contrast_every": {"label": "Contrast every N batches", "type": "int", "default": 4, "min": 1, "max": 128, "group": "Advanced", "advanced": True}, + "contrast_samples": {"label": "Contrast samples", "type": "int", "default": 2, "min": 2, "max": 32, "group": "Advanced", "advanced": True}, + "chunk_size": {"label": "Transitions per training chunk", "type": "int", "default": 0, "min": 0, "max": 1000000, "group": "Dataset", "description": "0 uses every transition each epoch. A balanced chunk bounds large-dataset training time."}, + "chunk_mode": {"label": "Chunk selection", "type": "choice", "options": ["balanced", "random", "sequential"], "default": "balanced", "group": "Dataset"}, + "chunk_offset": {"label": "Chunk offset", "type": "int", "default": 0, "min": 0, "max": 100000000, "group": "Dataset", "advanced": True}, + "replay_older_percent": {"label": "Older-data replay (%)", "type": "float", "default": 50.0, "min": 0.0, "max": 500.0, "decimals": 1, "step": 25.0, "group": "Dataset", "advanced": True}, + "include_older_data": {"label": "Mix selected older datasets", "type": "bool", "default": True, "group": "Dataset", "advanced": True}, + "recovery_minutes": {"label": "Emergency recovery every minutes", "type": "int", "default": 30, "min": 0, "max": 240, "group": "Checkpoints", "advanced": True}, + "benchmark_batches": {"label": "Speed-test batches", "type": "int", "default": 8, "min": 1, "max": 100, "group": "Advanced", "advanced": True}, + "tf32": {"label": "Use TF32 acceleration", "type": "bool", "default": True, "group": "Optimization", "advanced": True}, "gradient_checkpointing": {"label": "Gradient checkpointing", "type": "bool", "default": False, "group": "Advanced", "advanced": True}, - "balance_actions": {"label": "Balance rare actions", "type": "bool", "default": False, "group": "Advanced", "advanced": True}, + "balance_actions": {"label": "Balance rare actions", "type": "bool", "default": True, "group": "Advanced", "advanced": True}, } GENERATION_SETTINGS = { diff --git a/adam/model_plugins_builtin/pixelrow/__init__.py b/adam/model_plugins_builtin/pixelrow/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..81db9a7cc840eb0689401b111e1aafe9563978f3 --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/__init__.py @@ -0,0 +1,2 @@ +"""PixelRow autoregressive image model plugin.""" + diff --git a/adam/model_plugins_builtin/pixelrow/common.py b/adam/model_plugins_builtin/pixelrow/common.py new file mode 100644 index 0000000000000000000000000000000000000000..2a0f8ad50c65f8f7896b8fb86afa1a576344aad3 --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/common.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import torch +from PIL import Image + +from adam.executor import ToolExecutionError + +from .model import MODEL_FORMAT_VERSION, PixelRowConfig, PixelRowModel, canvas_to_uint8 + + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} +FINAL_CHECKPOINT_NAME = "pixelrow_model.pt" +CONFIG_NAME = "pixelrow_config.json" + + +def safe_model_name(value: str) -> str: + name = re.sub(r"\s+", " ", value.strip()) + if not name or len(name) > 96 or any(character in name for character in '<>:"/\\|?*\x00'): + raise ToolExecutionError("Choose a short PixelRow model name without reserved filename characters.") + return name + + +def ensure_below(path: Path, root: Path, label: str) -> Path: + resolved = path.expanduser().resolve() + try: + resolved.relative_to(root.expanduser().resolve()) + except ValueError as exc: + raise ToolExecutionError(f"{label} must stay inside {root.resolve()}.") from exc + return resolved + + +def image_files(folder: Path) -> list[Path]: + try: + return sorted( + path for path in folder.rglob("*") + if path.is_file() and path.suffix.casefold() in IMAGE_EXTENSIONS + ) + except OSError: + return [] + + +def resolve_checkpoint(path: Path) -> Path: + candidate = path.expanduser().resolve() + if candidate.is_dir(): + candidate = candidate / FINAL_CHECKPOINT_NAME + if not candidate.is_file(): + raise ToolExecutionError("The selected PixelRow checkpoint does not exist or is incomplete.") + return candidate + + +def load_checkpoint(path: Path, device: torch.device) -> tuple[PixelRowModel, dict[str, Any]]: + checkpoint_path = resolve_checkpoint(path) + try: + payload = torch.load(checkpoint_path, map_location=device, weights_only=False) + except (OSError, RuntimeError, ValueError, TypeError) as exc: + raise ToolExecutionError(f"Could not load the PixelRow checkpoint: {exc}") from exc + if not isinstance(payload, dict) or "model_state" not in payload or "config" not in payload: + raise ToolExecutionError("The selected file is not a valid PixelRow checkpoint.") + if int(payload.get("format_version", 0)) != MODEL_FORMAT_VERSION: + raise ToolExecutionError("This PixelRow checkpoint uses an unsupported format version.") + try: + config = PixelRowConfig.from_dict(dict(payload["config"])) + model = PixelRowModel(config).to(device) + model.load_state_dict(payload["model_state"], strict=True) + except (KeyError, TypeError, ValueError, RuntimeError) as exc: + raise ToolExecutionError(f"The PixelRow checkpoint is incompatible: {exc}") from exc + return model, payload + + +def save_canvas(canvas: torch.Tensor, path: Path, completed_rows: int | None = None) -> None: + pixels = canvas_to_uint8(canvas, completed_rows=completed_rows).numpy() + path.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(pixels, mode="RGB").save(path, format="PNG") + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") + temporary.replace(path) + diff --git a/adam/model_plugins_builtin/pixelrow/generator.py b/adam/model_plugins_builtin/pixelrow/generator.py new file mode 100644 index 0000000000000000000000000000000000000000..f389bca37a298e045f99e1202ad5e03af6f1bbd9 --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/generator.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import torch + +from adam.executor import ToolExecutionError +from adam.generations import generation_metadata_path, generation_output_folder + +from .common import ensure_below, load_checkpoint, safe_model_name, save_canvas, write_json + + +def generate( + context, + model_name: str, + model_path: str, + prompt: str, + image_count: int, + steps: int, + seed: int, + sampler: str, + aspect_ratio: str, + temperature: float = 0.85, + top_k: int = 4, + save_progress_frames: bool = True, + frame_interval: int = 2, + preview_interval: int = 4, +) -> dict[str, Any]: + """Generate images one complete RGB row at a time.""" + name = safe_model_name(model_name) + model_root = (context.root.resolve() / "data" / "model_plugin_outputs" / "pixelrow").resolve() + selected = ensure_below(Path(model_path), model_root, "PixelRow model") + if not selected.exists(): + raise ToolExecutionError("The selected PixelRow model no longer exists.") + if not 1 <= int(image_count) <= 48: + raise ToolExecutionError("PixelRow image count must be between 1 and 48.") + if not 1 <= int(steps) <= 128: + raise ToolExecutionError("PixelRow rows to generate must be between 1 and 128.") + if sampler != "Categorical": + raise ToolExecutionError("PixelRow currently supports categorical row sampling.") + if aspect_ratio != "1:1 (Native)": + raise ToolExecutionError("PixelRow currently generates at its native square resolution.") + if not 0.05 <= float(temperature) <= 3.0: + raise ToolExecutionError("PixelRow creativity must be between 0.05 and 3.0.") + if not 1 <= int(top_k) <= 64: + raise ToolExecutionError("PixelRow top color choices must be between 1 and 64.") + if int(frame_interval) not in {1, 2, 4, 8, 16}: + raise ToolExecutionError("PixelRow frame interval must be 1, 2, 4, 8, or 16 rows.") + if not 0 <= int(preview_interval) <= 128: + raise ToolExecutionError("PixelRow live preview interval must be between 0 and 128 rows.") + if len(prompt) > 500: + raise ToolExecutionError("The PixelRow creative note must be 500 characters or shorter.") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model, checkpoint = load_checkpoint(selected, device) + height = model.config.resolution + rows_to_generate = min(int(steps), height) + effective_top_k = min(int(top_k), model.config.color_bins) + if int(steps) > height: + context.log( + f"This model is {height}px tall, so PixelRow will stop after its {height} native rows." + ) + if effective_top_k != int(top_k): + context.log( + f"This model has {model.config.color_bins} color levels; top color choices was capped to that value." + ) + + count = int(image_count) + base_seed = int(seed) + if base_seed <= 0: + base_seed = random.SystemRandom().randint(1, 2_147_483_647 - count) + if base_seed + count - 1 > 2_147_483_647: + raise ToolExecutionError("The PixelRow seed is too large for this image count.") + + output = generation_output_folder(context.root, context.tool.id, name) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + image_paths: list[str] = [] + frame_folders: list[str] = [] + context.log( + f"Loaded {name}: {height}x{height}, {model.config.color_bins} color levels, device {device}." + ) + context.log("PixelRow uses the creative note as metadata; generation is unconditional.") + + try: + for image_index in range(count): + context.checkpoint() + current_seed = base_seed + image_index + torch_generator = torch.Generator(device=device) + torch_generator.manual_seed(current_seed) + frame_folder = output / "row_progress" / f"{timestamp}_{context.job_id}_seed_{current_seed}" + last_frame_path: Path | None = None + + def row_ready(completed_rows: int, canvas: torch.Tensor) -> None: + nonlocal last_frame_path + context.checkpoint() + overall = ((image_index * rows_to_generate) + completed_rows) / max(1, count * rows_to_generate) + context.progress( + max(1, min(99, round(overall * 100))), + f"Image {image_index + 1} of {count} · row {completed_rows} of {rows_to_generate}", + current=completed_rows, + total=rows_to_generate, + image_index=image_index, + image_count=count, + unit="row", + ) + save_frame = bool(save_progress_frames) and ( + completed_rows % int(frame_interval) == 0 or completed_rows == rows_to_generate + ) + publish_preview = int(preview_interval) > 0 and ( + completed_rows % int(preview_interval) == 0 or completed_rows == rows_to_generate + ) + if save_frame: + last_frame_path = frame_folder / f"row_{completed_rows:04d}.png" + save_canvas(canvas, last_frame_path, completed_rows=completed_rows) + if publish_preview: + preview_path = last_frame_path + if preview_path is None or not preview_path.is_file(): + preview_path = output / ".live" / context.job_id / f"image_{image_index + 1:03d}.png" + save_canvas(canvas, preview_path, completed_rows=completed_rows) + context.preview( + preview_path, + kind="generation", + current=completed_rows, + total=rows_to_generate, + image_index=image_index, + image_count=count, + seed=current_seed, + steps=rows_to_generate, + ) + + canvas = model.generate( + rows=rows_to_generate, + temperature=float(temperature), + top_k=effective_top_k, + generator=torch_generator, + row_callback=row_ready, + ) + destination = output / ( + f"{timestamp}_{context.job_id}_PixelRow_seed_{current_seed}_rows_{rows_to_generate}.png" + ) + save_canvas(canvas, destination, completed_rows=rows_to_generate) + image_paths.append(str(destination)) + if bool(save_progress_frames): + frame_folders.append(str(frame_folder)) + except torch.cuda.OutOfMemoryError as exc: + if device.type == "cuda": + torch.cuda.empty_cache() + raise ToolExecutionError( + "PixelRow ran out of VRAM while generating. Generate fewer images in one batch." + ) from exc + + created_at = datetime.now(timezone.utc).isoformat() + metadata = { + "version": 1, + "provider_id": context.tool.id, + "provider_name": context.tool.name, + "model_name": name, + "model_path": str(selected), + "model_type": "pixelrow", + "prompt": prompt.strip(), + "prompt_behavior": "label_only", + "seed": base_seed, + "image_seeds": [base_seed + index for index in range(count)], + "image_count": count, + "steps": rows_to_generate, + "sampler": sampler, + "aspect_ratio": aspect_ratio, + "resolution": height, + "temperature": float(temperature), + "top_k": effective_top_k, + "save_progress_frames": bool(save_progress_frames), + "frame_interval": int(frame_interval), + "row_progress_folders": frame_folders, + "preview_interval": int(preview_interval), + "images": image_paths, + "checkpoint_epoch": int(checkpoint.get("completed_epochs", 0) or 0), + "created_at": created_at, + } + write_json(generation_metadata_path(output, timestamp, context.job_id), metadata) + # Leave only durable showcase frames; the live-preview file is an implementation detail. + live_folder = output / ".live" / context.job_id + if live_folder.is_dir(): + for path in live_folder.glob("*.png"): + try: + path.unlink() + except OSError: + pass + try: + live_folder.rmdir() + except OSError: + pass + try: + live_folder.parent.rmdir() + except OSError: + pass + context.progress(100, f"Generated {count} PixelRow image(s)") + return { + "output_folder": str(output), + "assets": [{ + "kind": "generation", + "name": f"{name} · {timestamp}", + "path": str(output), + "trainer": "pixelrow", + "metadata": { + "row_progress_folders": frame_folders, + "rows_generated": rows_to_generate, + }, + }], + } diff --git a/adam/model_plugins_builtin/pixelrow/manifest.py b/adam/model_plugins_builtin/pixelrow/manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..a160643229fce3ef64b843b2055cce9561a5d0e9 --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/manifest.py @@ -0,0 +1,301 @@ +PLUGIN_ID = "pixelrow" + +MODEL_INFO = { + "name": "PixelRow", + "version": "0.1", + "category": "Image Generation", + "description": ( + "Experimental autoregressive image model that constructs pictures from " + "top to bottom, predicting one complete RGB row at a time." + ), + "architecture": "autoregressive_rows", + "status": "experimental", + "output_type": "image", + "capabilities": [ + "fresh_training", + "resume_training", + "image_generation", + "live_preview", + "row_progress_frames", + ], + "input_formats": ["image folder"], + "output_formats": ["PixelRow checkpoint", "PNG image", "PNG row progress frames"], + "hardware": {"recommended_vram_gb": 6, "recommended_system_ram_gb": 16}, + "vram_behavior": { + "scales_with": ["resolution", "batch_size", "color_bins", "hidden_size"], + "estimate": "Moderate at 64px; reduce batch size first when training at 128px.", + }, +} + +TRAINING_SETTINGS = { + "resolution": { + "label": "Resolution", + "type": "choice", + "options": [32, 64, 128], + "default": 64, + "group": "Basic", + "description": "PixelRow currently learns square images. Start at 64px for the first car experiment.", + }, + "resize_mode": { + "label": "Image fitting", + "type": "choice", + "options": ["fill", "fit", "stretch"], + "default": "fill", + "group": "Dataset", + "description": "Fill preserves proportions and center-crops; fit pads; stretch changes proportions.", + }, + "horizontal_flip": { + "label": "Random horizontal flip", + "type": "bool", + "default": True, + "group": "Dataset", + }, + "batch_size": { + "label": "Batch size", + "type": "int", + "default": 8, + "min": 1, + "max": 64, + "group": "Basic", + }, + "learning_rate": { + "label": "Learning rate", + "type": "float", + "default": 0.0002, + "min": 0.0000001, + "max": 0.1, + "decimals": 7, + "step": 0.00005, + "group": "Optimization", + }, + "gradient_accumulation_steps": { + "label": "Gradient accumulation", + "type": "int", + "default": 1, + "min": 1, + "max": 64, + "group": "Optimization", + }, + "workers": { + "label": "Loader workers", + "type": "int", + "default": 0, + "min": 0, + "max": 16, + "group": "Dataset", + "description": "Zero is the safest choice for the Windows desktop app.", + }, + "mixed_precision": { + "label": "Precision", + "type": "choice", + "options": ["fp16", "no"], + "default": "fp16", + "group": "Optimization", + }, + "hidden_size": { + "label": "Spatial memory channels", + "type": "choice", + "options": [64, 128, 192, 256], + "default": 128, + "group": "PixelRow", + "advanced": True, + "description": "Column-aware memory carried from completed rows into the next-row prediction.", + }, + "recurrent_layers": { + "label": "Sequence layers", + "type": "int", + "default": 2, + "min": 1, + "max": 4, + "group": "PixelRow", + "advanced": True, + }, + "row_channels": { + "label": "Row feature channels", + "type": "choice", + "options": [32, 64, 96, 128], + "default": 64, + "group": "PixelRow", + "advanced": True, + }, + "color_bins": { + "label": "Color levels per channel", + "type": "choice", + "options": [16, 32, 64], + "default": 32, + "group": "PixelRow", + "advanced": True, + "description": "PixelRow predicts a color category instead of averaging raw RGB values.", + }, + "edge_loss_weight": { + "label": "Line-detail strength", + "type": "float", + "default": 0.05, + "min": 0.0, + "max": 1.0, + "decimals": 3, + "step": 0.01, + "group": "PixelRow", + "advanced": True, + "description": "Encourages horizontal and vertical color boundaries to match the training images.", + }, + "save_every": { + "label": "Save every", + "type": "int", + "default": 10, + "min": 1, + "max": 1000, + "group": "Checkpoints", + }, + "preview_enabled": { + "label": "Generate previews while training", + "type": "bool", + "default": True, + "group": "Preview", + }, + "preview_every": { + "label": "Preview interval", + "type": "int", + "default": 5, + "min": 1, + "max": 100000, + "group": "Preview", + }, + "preview_prompt": { + "label": "Preview note", + "type": "text", + "default": "", + "group": "Preview", + }, + "preview_seed": { + "label": "Preview seed", + "type": "int", + "default": 123456789, + "min": 0, + "max": 2147483647, + "group": "Preview", + }, +} + +GENERATION_SETTINGS = { + "prompt": { + "label": "Creative note", + "type": "multiline_text", + "default": "", + "group": "Generation", + }, + "image_count": { + "label": "Images", + "type": "int", + "default": 1, + "min": 1, + "max": 48, + "group": "Generation", + }, + "steps": { + "label": "Rows to generate", + "type": "int", + "default": 128, + "min": 1, + "max": 128, + "group": "Generation", + "description": "Values above the trained image height automatically produce the complete image.", + }, + "sampler": { + "label": "Row sampling", + "type": "choice", + "options": ["Categorical"], + "default": "Categorical", + "group": "Generation", + }, + "aspect_ratio": { + "label": "Aspect ratio", + "type": "choice", + "options": ["1:1 (Native)"], + "default": "1:1 (Native)", + "group": "Generation", + }, + "seed": { + "label": "Seed", + "type": "int", + "default": 0, + "min": 0, + "max": 2147483647, + "group": "Generation", + }, + "temperature": { + "label": "Creativity", + "type": "float", + "default": 0.85, + "min": 0.05, + "max": 3.0, + "decimals": 2, + "step": 0.05, + "group": "PixelRow", + }, + "top_k": { + "label": "Top color choices", + "type": "int", + "default": 4, + "min": 1, + "max": 64, + "group": "PixelRow", + "description": "Smaller values are more conservative; larger values add variation.", + }, + "save_progress_frames": { + "label": "Save row-build frames", + "type": "bool", + "default": True, + "group": "Row Showcase", + }, + "frame_interval": { + "label": "Save every N rows", + "type": "choice", + "options": [1, 2, 4, 8, 16], + "default": 2, + "group": "Row Showcase", + }, + "preview_interval": { + "label": "Rows per live preview", + "type": "int", + "default": 4, + "min": 0, + "max": 128, + "group": "Preview", + }, +} + +TRAINING_TOOL = { + "id": "pixelrow_trainer", + "name": "PixelRow Trainer", + "description": "Trains an experimental model to construct images one RGB row at a time.", + "capabilities": ["fresh_training", "resume_training", "progress", "pause", "cancel", "live_preview"], + "backend": { + "type": "python", + "module": "adam.model_plugins_builtin.pixelrow.trainer", + "function": "train", + }, +} + +GENERATION_TOOL = { + "id": "pixelrow_generator", + "name": "PixelRow Generator", + "description": "Builds images from top to bottom and can save the visible row-by-row process.", + "model_trainers": ["pixelrow"], + "capabilities": ["image_generation", "seed", "batch", "row_progress_frames", "live_preview", "progress", "cancel"], + "generation_options": { + "samplers": ["Categorical"], + "aspect_ratios": ["1:1 (Native)"], + "step_min": 1, + "step_max": 128, + "step_default": 128, + "step_label": "Rows", + "preview_step_label": "Rows / preview", + "preview_step_default": 4, + }, + "backend": { + "type": "python", + "module": "adam.model_plugins_builtin.pixelrow.generator", + "function": "generate", + }, +} diff --git a/adam/model_plugins_builtin/pixelrow/model.py b/adam/model_plugins_builtin/pixelrow/model.py new file mode 100644 index 0000000000000000000000000000000000000000..795958ac889bb22e86c97f1968331714d27a7f7b --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/model.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Callable + +import torch +from torch import Tensor, nn +from torch.nn import functional as F + + +MODEL_FORMAT_VERSION = 1 + + +@dataclass(frozen=True, slots=True) +class PixelRowConfig: + resolution: int = 64 + hidden_size: int = 128 + recurrent_layers: int = 2 + row_channels: int = 64 + color_bins: int = 32 + + def __post_init__(self) -> None: + if self.resolution not in {32, 64, 128}: + raise ValueError("PixelRow resolution must be 32, 64, or 128.") + if not 64 <= self.hidden_size <= 1024: + raise ValueError("PixelRow hidden size must be between 64 and 1024.") + if not 1 <= self.recurrent_layers <= 4: + raise ValueError("PixelRow recurrent layers must be between 1 and 4.") + if not 16 <= self.row_channels <= 256: + raise ValueError("PixelRow row channels must be between 16 and 256.") + if self.color_bins not in {16, 32, 64}: + raise ValueError("PixelRow color bins must be 16, 32, or 64.") + + def to_dict(self) -> dict[str, int]: + return asdict(self) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "PixelRowConfig": + return cls( + resolution=int(payload.get("resolution", 64)), + hidden_size=int(payload.get("hidden_size", 128)), + recurrent_layers=int(payload.get("recurrent_layers", 2)), + row_channels=int(payload.get("row_channels", 64)), + color_bins=int(payload.get("color_bins", 32)), + ) + + +class ResidualRowBlock(nn.Module): + def __init__(self, channels: int) -> None: + super().__init__() + groups = max(1, min(8, channels // 8)) + self.norm = nn.GroupNorm(groups, channels) + self.conv1 = nn.Conv1d(channels, channels, 5, padding=2) + self.conv2 = nn.Conv1d(channels, channels, 3, padding=1) + + def forward(self, value: Tensor) -> Tensor: + residual = value + value = self.conv1(F.gelu(self.norm(value))) + value = self.conv2(F.gelu(value)) + return value + residual + + +class PixelRowModel(nn.Module): + """A row-level autoregressive model with categorical RGB outputs. + + During training the GRU sees only the rows above each target row. The row + decoder predicts all pixels in the next row together, so generation takes + exactly one autoregressive decision per image row. + """ + + def __init__(self, config: PixelRowConfig) -> None: + super().__init__() + self.config = config + self.row_encoder = nn.Sequential( + nn.Conv1d(3, config.hidden_size, 5, padding=2), + nn.GELU(), + nn.Conv1d(config.hidden_size, config.hidden_size, 5, padding=2), + nn.GELU(), + ) + self.start_embedding = nn.Parameter( + torch.zeros(1, 1, config.hidden_size, config.resolution) + ) + self.row_position = nn.Embedding(config.resolution, config.hidden_size) + self.sequence = nn.GRU( + input_size=config.hidden_size, + hidden_size=config.hidden_size, + num_layers=config.recurrent_layers, + batch_first=True, + dropout=0.1 if config.recurrent_layers > 1 else 0.0, + ) + self.hidden_to_row = nn.Conv1d(config.hidden_size, config.row_channels, 1) + self.column_features = nn.Parameter( + torch.randn(1, config.row_channels, config.resolution) * 0.02 + ) + self.row_decoder = nn.Sequential( + ResidualRowBlock(config.row_channels), + ResidualRowBlock(config.row_channels), + nn.GroupNorm(max(1, min(8, config.row_channels // 8)), config.row_channels), + nn.GELU(), + nn.Conv1d(config.row_channels, 3 * config.color_bins, 1), + ) + nn.init.normal_(self.start_embedding, std=0.02) + + def encode_rows(self, rows: Tensor) -> Tensor: + """Encode BxHx3xW normalized RGB rows into BxHxCxW features.""" + batch, height, channels, width = rows.shape + if channels != 3 or width != self.config.resolution: + raise ValueError("PixelRow input rows do not match the model configuration.") + encoded = self.row_encoder(rows.reshape(batch * height, channels, width)) + return encoded.reshape(batch, height, self.config.hidden_size, width) + + def decode_hidden(self, hidden: Tensor) -> Tensor: + """Decode BxHxCxW states to BxHx3xBinsxW logits.""" + batch, height, channels, width = hidden.shape + features = self.hidden_to_row(hidden.reshape(batch * height, channels, width)) + features = features + self.column_features + logits = self.row_decoder(features) + return logits.reshape( + batch, + height, + 3, + self.config.color_bins, + self.config.resolution, + ) + + def forward(self, target_rows: Tensor) -> Tensor: + """Teacher-force the image while preserving strict top-to-bottom causality.""" + batch, height, channels, width = target_rows.shape + if height != self.config.resolution or channels != 3 or width != self.config.resolution: + raise ValueError("PixelRow expects square BxHx3xW tensors at its trained resolution.") + encoded = self.encode_rows(target_rows) + inputs = torch.cat( + (self.start_embedding.expand(batch, -1, -1, -1), encoded[:, :-1]), dim=1 + ) + positions = self.row_position(torch.arange(height, device=target_rows.device)) + inputs = inputs + positions.view(1, height, self.config.hidden_size, 1) + # Each column gets a recurrent sequence, while the row encoder and + # decoder exchange local horizontal context through 1D convolutions. + column_sequences = inputs.permute(0, 3, 1, 2).reshape( + batch * width, height, self.config.hidden_size + ) + sequence_output, _state = self.sequence(column_sequences) + spatial_output = sequence_output.reshape( + batch, width, height, self.config.hidden_size + ).permute(0, 2, 3, 1).contiguous() + return self.decode_hidden(spatial_output) + + def loss(self, images: Tensor, *, edge_loss_weight: float = 0.0) -> tuple[Tensor, dict[str, float]]: + targets = quantize_images(images, self.config.color_bins) + normalized = dequantize_images(targets, self.config.color_bins) + logits = self(normalized) + categorical = F.cross_entropy( + logits.permute(0, 1, 2, 4, 3).reshape(-1, self.config.color_bins), + targets.reshape(-1), + ) + edge_loss = categorical.new_zeros(()) + if edge_loss_weight > 0: + levels = torch.linspace(-1.0, 1.0, self.config.color_bins, device=images.device) + expected = (logits.softmax(dim=3) * levels.view(1, 1, 1, -1, 1)).sum(dim=3) + horizontal = F.l1_loss(expected[..., 1:] - expected[..., :-1], normalized[..., 1:] - normalized[..., :-1]) + vertical = F.l1_loss(expected[:, 1:] - expected[:, :-1], normalized[:, 1:] - normalized[:, :-1]) + edge_loss = (horizontal + vertical) * 0.5 + total = categorical + float(edge_loss_weight) * edge_loss + return total, { + "categorical": float(categorical.detach().item()), + "edge": float(edge_loss.detach().item()), + } + + @torch.inference_mode() + def generate( + self, + *, + rows: int | None = None, + temperature: float = 1.0, + top_k: int = 8, + generator: torch.Generator | None = None, + row_callback: Callable[[int, Tensor], None] | None = None, + ) -> Tensor: + """Generate one image and optionally report its partially completed canvas.""" + self.eval() + total_rows = min(max(1, int(rows or self.config.resolution)), self.config.resolution) + temperature = max(0.05, float(temperature)) + top_k = min(max(1, int(top_k)), self.config.color_bins) + device = next(self.parameters()).device + canvas = torch.zeros(1, self.config.resolution, 3, self.config.resolution, device=device) + recurrent_state: Tensor | None = None + previous_embedding: Tensor | None = None + for row_index in range(total_rows): + if previous_embedding is None: + step_input = self.start_embedding[:, 0] + else: + step_input = previous_embedding + position = self.row_position(torch.tensor([row_index], device=device)).unsqueeze(-1) + column_input = (step_input + position).permute(0, 2, 1).reshape( + self.config.resolution, 1, self.config.hidden_size + ) + column_output, recurrent_state = self.sequence(column_input, recurrent_state) + spatial_output = column_output.reshape( + 1, self.config.resolution, 1, self.config.hidden_size + ).permute(0, 2, 3, 1).contiguous() + logits = self.decode_hidden(spatial_output)[:, 0] / temperature + if top_k < self.config.color_bins: + best_values, best_indices = torch.topk(logits, top_k, dim=2) + probabilities = best_values.softmax(dim=2) + sampled_offset = torch.multinomial( + probabilities.permute(0, 1, 3, 2).reshape(-1, top_k), + 1, + generator=generator, + ).reshape(1, 3, self.config.resolution) + sampled = best_indices.permute(0, 1, 3, 2).gather( + 3, sampled_offset.unsqueeze(-1) + ).squeeze(-1) + else: + probabilities = logits.softmax(dim=2) + sampled = torch.multinomial( + probabilities.permute(0, 1, 3, 2).reshape(-1, self.config.color_bins), + 1, + generator=generator, + ).reshape(1, 3, self.config.resolution) + normalized_row = dequantize_images(sampled, self.config.color_bins) + canvas[:, row_index] = normalized_row + previous_embedding = self.row_encoder(normalized_row) + if row_callback is not None: + row_callback(row_index + 1, canvas[0].detach()) + return canvas[0] + + +def quantize_images(images: Tensor, color_bins: int) -> Tensor: + """Convert normalized RGB values in [-1, 1] to categorical color levels.""" + return ((images.clamp(-1, 1) + 1.0) * 0.5 * (color_bins - 1)).round().long() + + +def dequantize_images(indices: Tensor, color_bins: int) -> Tensor: + """Convert categorical color levels back to normalized RGB values.""" + return indices.float() * (2.0 / (color_bins - 1)) - 1.0 + + +def canvas_to_uint8(canvas: Tensor, completed_rows: int | None = None) -> Tensor: + """Convert Hx3xW normalized rows to a display-ready HxWx3 byte tensor.""" + image = ((canvas.detach().float().cpu().clamp(-1, 1) + 1.0) * 127.5).round().byte() + image = image.permute(0, 2, 1).contiguous() + if completed_rows is not None and completed_rows < image.shape[0]: + image[completed_rows:] = 32 + return image diff --git a/adam/model_plugins_builtin/pixelrow/trainer.py b/adam/model_plugins_builtin/pixelrow/trainer.py new file mode 100644 index 0000000000000000000000000000000000000000..08d817e0fab8463aeb29a470e219be36d77933ba --- /dev/null +++ b/adam/model_plugins_builtin/pixelrow/trainer.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import random +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import torch +from PIL import Image, ImageOps, ImageStat +from torch.utils.data import DataLoader, Dataset + +from adam.executor import ToolExecutionError + +from .common import ( + CONFIG_NAME, + FINAL_CHECKPOINT_NAME, + ensure_below, + image_files, + load_checkpoint, + resolve_checkpoint, + safe_model_name, + save_canvas, + write_json, +) +from .model import MODEL_FORMAT_VERSION, PixelRowConfig, PixelRowModel + + +class PixelRowImageDataset(Dataset[torch.Tensor]): + def __init__( + self, + paths: list[Path], + *, + resolution: int, + resize_mode: str, + horizontal_flip: bool, + ) -> None: + self.paths = paths + self.resolution = resolution + self.resize_mode = resize_mode + self.horizontal_flip = horizontal_flip + + def __len__(self) -> int: + return len(self.paths) + + def __getitem__(self, index: int) -> torch.Tensor: + path = self.paths[index] + try: + with Image.open(path) as opened: + image = opened.convert("RGB") + if self.resize_mode == "fill": + image = ImageOps.fit( + image, + (self.resolution, self.resolution), + method=Image.Resampling.LANCZOS, + ) + elif self.resize_mode == "fit": + mean = tuple(int(value) for value in ImageStat.Stat(image.resize((1, 1))).mean) + image = ImageOps.pad( + image, + (self.resolution, self.resolution), + method=Image.Resampling.LANCZOS, + color=mean, + ) + else: + image = image.resize( + (self.resolution, self.resolution), Image.Resampling.LANCZOS + ) + if self.horizontal_flip and random.random() < 0.5: + image = image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + buffer = bytearray(image.tobytes()) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Could not read training image {path.name}: {exc}") from exc + pixels = torch.frombuffer(buffer, dtype=torch.uint8).reshape( + self.resolution, self.resolution, 3 + ) + # Model layout is H rows x RGB channels x W columns. + return pixels.permute(0, 2, 1).float().div(127.5).sub(1.0) + + +def _checkpoint_payload( + model: PixelRowModel, + optimizer: torch.optim.Optimizer, + *, + model_name: str, + dataset_dir: Path, + completed_epochs: int, + global_step: int, + training_settings: dict[str, Any], +) -> dict[str, Any]: + return { + "format_version": MODEL_FORMAT_VERSION, + "architecture": "autoregressive_rows", + "model_name": model_name, + "config": model.config.to_dict(), + "model_state": model.state_dict(), + "optimizer_state": optimizer.state_dict(), + "completed_epochs": int(completed_epochs), + "global_step": int(global_step), + "dataset_dir": str(dataset_dir), + "training_settings": training_settings, + "saved_at": datetime.now(timezone.utc).isoformat(), + } + + +def _save_checkpoint(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + torch.save(payload, temporary) + temporary.replace(path) + + +def _preview( + context, + model: PixelRowModel, + output: Path, + *, + epoch: int, + next_epoch: int, + seed: int, + prompt: str, +) -> None: + device = next(model.parameters()).device + generator = torch.Generator(device=device) + generator.manual_seed(int(seed)) + canvas = model.generate( + rows=model.config.resolution, + temperature=0.85, + top_k=min(4, model.config.color_bins), + generator=generator, + ) + destination = output / "previews" / f"preview_epoch_{epoch:06d}.png" + save_canvas(canvas, destination, completed_rows=model.config.resolution) + context.preview( + destination, + epoch=epoch, + next_epoch=next_epoch, + prompt=prompt, + seed=seed, + steps=model.config.resolution, + ) + + +def train( + context, + dataset_dir: str, + model_name: str, + epochs: int, + output_dir: str, + resume_from: str = "", + resolution: int = 64, + resize_mode: str = "fill", + horizontal_flip: bool = True, + batch_size: int = 8, + learning_rate: float = 0.0002, + gradient_accumulation_steps: int = 1, + workers: int = 0, + mixed_precision: str = "fp16", + hidden_size: int = 128, + recurrent_layers: int = 2, + row_channels: int = 64, + color_bins: int = 32, + edge_loss_weight: float = 0.05, + save_every: int = 10, + preview_enabled: bool = True, + preview_every: int = 5, + preview_prompt: str = "", + preview_seed: int = 123456789, +) -> dict[str, Any]: + """Train PixelRow inside ADAM's managed plugin-output area.""" + name = safe_model_name(model_name) + dataset = Path(dataset_dir).expanduser().resolve() + if not dataset.is_dir(): + raise ToolExecutionError("The selected PixelRow dataset folder no longer exists.") + training_dataset = dataset + accepted_frames = dataset / "frames" + frame_paths = image_files(accepted_frames) if accepted_frames.is_dir() else [] + if frame_paths: + training_dataset = accepted_frames + paths = frame_paths + else: + paths = image_files(dataset) + if len(paths) < 2: + raise ToolExecutionError("PixelRow needs at least two readable image files before training can start.") + + output_root = (context.root.resolve() / "data" / "model_plugin_outputs" / "pixelrow").resolve() + output = ensure_below(Path(output_dir), output_root, "PixelRow output") + if output.exists() and not output.is_dir(): + raise ToolExecutionError("The PixelRow output path must be a folder.") + if output.exists() and any(output.iterdir()): + raise ToolExecutionError("The PixelRow output folder is not empty. Choose a new model output.") + output.mkdir(parents=True, exist_ok=True) + + if resize_mode not in {"fill", "fit", "stretch"}: + raise ToolExecutionError("PixelRow image fitting must be fill, fit, or stretch.") + if not 1 <= int(epochs) <= 100_000: + raise ToolExecutionError("PixelRow epochs must be between 1 and 100000.") + if not 1 <= int(batch_size) <= 64 or not 1 <= int(gradient_accumulation_steps) <= 64: + raise ToolExecutionError("PixelRow batch size and gradient accumulation must be between 1 and 64.") + if not 1e-7 <= float(learning_rate) <= 0.1: + raise ToolExecutionError("PixelRow learning rate must be between 0.0000001 and 0.1.") + if not 0 <= int(workers) <= 16 or mixed_precision not in {"fp16", "no"}: + raise ToolExecutionError("PixelRow loader workers or precision is outside the supported range.") + if not 0.0 <= float(edge_loss_weight) <= 1.0: + raise ToolExecutionError("PixelRow line-detail strength must be between 0 and 1.") + if not 1 <= int(save_every) <= 1000 or not 1 <= int(preview_every) <= 100_000: + raise ToolExecutionError("PixelRow save and preview intervals must be positive.") + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + resume_payload: dict[str, Any] | None = None + if resume_from.strip(): + resume_path = ensure_below( + resolve_checkpoint(Path(resume_from)), output_root, "PixelRow resume checkpoint" + ) + model, resume_payload = load_checkpoint(resume_path, device) + config = model.config + context.log( + "Continuing with the checkpoint architecture: " + f"{config.resolution}px, width {config.hidden_size}, {config.color_bins} color levels." + ) + else: + try: + config = PixelRowConfig( + resolution=int(resolution), + hidden_size=int(hidden_size), + recurrent_layers=int(recurrent_layers), + row_channels=int(row_channels), + color_bins=int(color_bins), + ) + except ValueError as exc: + raise ToolExecutionError(str(exc)) from exc + model = PixelRowModel(config).to(device) + + dataset_object = PixelRowImageDataset( + paths, + resolution=config.resolution, + resize_mode=resize_mode, + horizontal_flip=bool(horizontal_flip), + ) + loader = DataLoader( + dataset_object, + batch_size=int(batch_size), + shuffle=True, + num_workers=int(workers), + pin_memory=device.type == "cuda", + drop_last=False, + ) + optimizer = torch.optim.AdamW(model.parameters(), lr=float(learning_rate), betas=(0.9, 0.95)) + start_epoch = 0 + global_step = 0 + if resume_payload is not None: + start_epoch = int(resume_payload.get("completed_epochs", 0) or 0) + global_step = int(resume_payload.get("global_step", 0) or 0) + optimizer_state = resume_payload.get("optimizer_state") + if isinstance(optimizer_state, dict): + try: + optimizer.load_state_dict(optimizer_state) + for group in optimizer.param_groups: + group["lr"] = float(learning_rate) + except (ValueError, RuntimeError): + context.log("The previous optimizer state was incompatible; continuing with a fresh optimizer.") + + use_fp16 = mixed_precision == "fp16" and device.type == "cuda" + if mixed_precision == "fp16" and not use_fp16: + context.log("FP16 requires CUDA; PixelRow will train in full precision on this device.") + try: + scaler = torch.amp.GradScaler("cuda", enabled=use_fp16) + except (AttributeError, TypeError): # PyTorch 2.2 compatibility. + scaler = torch.cuda.amp.GradScaler(enabled=use_fp16) + accumulation = int(gradient_accumulation_steps) + requested_epochs = int(epochs) + final_epoch = start_epoch + requested_epochs + batches_per_epoch = max(1, len(loader)) + total_batches = requested_epochs * batches_per_epoch + settings = { + "resolution": config.resolution, + "resize_mode": resize_mode, + "horizontal_flip": bool(horizontal_flip), + "batch_size": int(batch_size), + "learning_rate": float(learning_rate), + "gradient_accumulation_steps": accumulation, + "workers": int(workers), + "mixed_precision": mixed_precision, + "hidden_size": config.hidden_size, + "recurrent_layers": config.recurrent_layers, + "row_channels": config.row_channels, + "color_bins": config.color_bins, + "edge_loss_weight": float(edge_loss_weight), + } + write_json(output / CONFIG_NAME, { + "format_version": MODEL_FORMAT_VERSION, + "model_type": "pixelrow", + "model_name": name, + **config.to_dict(), + }) + context.log( + f"Training PixelRow on {len(paths)} images from {training_dataset} at " + f"{config.resolution}x{config.resolution}, " + f"batch {batch_size}, learning rate {learning_rate}, device {device}." + ) + optimizer.zero_grad(set_to_none=True) + processed_batches = 0 + last_loss = 0.0 + try: + for epoch in range(start_epoch + 1, final_epoch + 1): + model.train() + epoch_loss = 0.0 + for batch_index, images in enumerate(loader, 1): + context.checkpoint() + images = images.to(device, non_blocking=device.type == "cuda") + with torch.autocast(device_type=device.type, dtype=torch.float16, enabled=use_fp16): + loss, parts = model.loss(images, edge_loss_weight=float(edge_loss_weight)) + scaled_loss = loss / accumulation + scaler.scale(scaled_loss).backward() + if batch_index % accumulation == 0 or batch_index == batches_per_epoch: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + scaler.step(optimizer) + scaler.update() + optimizer.zero_grad(set_to_none=True) + global_step += 1 + last_loss = float(loss.detach().item()) + epoch_loss += last_loss + processed_batches += 1 + percent = max(1, min(99, round(processed_batches * 100 / total_batches))) + context.progress( + percent, + f"Epoch {epoch} of {final_epoch} · loss {last_loss:.4f}", + epoch=epoch, + total_epochs=final_epoch, + current_step=processed_batches, + total_steps=total_batches, + unit="batch", + loss=last_loss, + categorical_loss=parts["categorical"], + edge_loss=parts["edge"], + ) + + payload = _checkpoint_payload( + model, + optimizer, + model_name=name, + dataset_dir=dataset, + completed_epochs=epoch, + global_step=global_step, + training_settings=settings, + ) + if epoch % int(save_every) == 0: + _save_checkpoint(output / "checkpoints" / f"epoch_{epoch:06d}.pt", payload) + if bool(preview_enabled) and epoch % int(preview_every) == 0: + _preview( + context, + model, + output, + epoch=epoch, + next_epoch=min(final_epoch, epoch + int(preview_every)), + seed=int(preview_seed), + prompt=preview_prompt, + ) + context.log(f"Finished epoch {epoch}; average loss {epoch_loss / batches_per_epoch:.4f}.") + except torch.cuda.OutOfMemoryError as exc: + if device.type == "cuda": + torch.cuda.empty_cache() + raise ToolExecutionError( + "PixelRow ran out of VRAM. Reduce batch size first, then sequence width or resolution." + ) from exc + + final_payload = _checkpoint_payload( + model, + optimizer, + model_name=name, + dataset_dir=dataset, + completed_epochs=final_epoch, + global_step=global_step, + training_settings=settings, + ) + final_checkpoint = output / FINAL_CHECKPOINT_NAME + _save_checkpoint(final_checkpoint, final_payload) + write_json(output / "training_metadata.json", { + "format_version": MODEL_FORMAT_VERSION, + "model_type": "pixelrow", + "architecture": "autoregressive_rows", + "model_name": name, + "dataset_dir": str(dataset), + "image_count": len(paths), + "completed_epochs": final_epoch, + "epochs_this_run": requested_epochs, + "global_step": global_step, + "final_loss": last_loss, + "checkpoint": str(final_checkpoint), + "settings": settings, + "finished_at": datetime.now(timezone.utc).isoformat(), + }) + context.progress(100, "PixelRow training completed") + return { + "output_folder": str(output), + "model_name": name, + "assets": [{ + "kind": "model", + "name": name, + "path": str(output), + "trainer": "pixelrow", + "dataset_path": str(dataset), + "checkpoint": str(final_checkpoint), + "epochs": final_epoch, + "metadata": { + "architecture": "autoregressive_rows", + "resolution": config.resolution, + "color_bins": config.color_bins, + }, + }], + } diff --git a/adam/model_plugins_builtin/sdxl_lora/manifest.py b/adam/model_plugins_builtin/sdxl_lora/manifest.py index 69cb5c15073c434705fbead9b6a0c86dfe70a0c4..37fbe9a43110d959ba8d19e59283f4175d56ad89 100644 --- a/adam/model_plugins_builtin/sdxl_lora/manifest.py +++ b/adam/model_plugins_builtin/sdxl_lora/manifest.py @@ -45,7 +45,7 @@ GENERATION_SETTINGS = { "image_count": {"label": "Images", "type": "int", "default": 1, "min": 1, "max": 48, "group": "Generation"}, "steps": {"label": "Steps", "type": "int", "default": 30, "min": 1, "max": 150, "group": "Generation"}, "cfg_scale": {"label": "CFG scale", "type": "float", "default": 7.0, "min": 0.1, "max": 30.0, "decimals": 2, "step": 0.5, "group": "Generation"}, - "sampler": {"label": "Sampler", "type": "choice", "options": ["DPM++ 2M", "DPM++ SDE", "Euler", "Euler a", "DDIM"], "default": "DPM++ 2M", "group": "Generation"}, + "sampler": {"label": "Sampler", "type": "choice", "options": ["DPM++ 2M", "DPM++ 2M Karras", "DPM++ 2M SDE", "DPM++ 2M SDE Karras", "DPM++ SDE", "DPM++ SDE Karras", "Euler", "Euler a", "Heun", "LMS", "DDIM"], "default": "DPM++ 2M", "group": "Generation"}, "aspect_ratio": {"label": "Aspect ratio", "type": "choice", "options": ["1:1 (Square)", "4:3 (Landscape)", "3:4 (Portrait)", "3:2 (Landscape)", "2:3 (Portrait)", "16:9 (Widescreen)", "9:16 (Vertical)"], "default": "1:1 (Square)", "group": "Generation"}, "width": {"label": "Width", "type": "int", "default": 1024, "min": 256, "max": 2048, "group": "Generation"}, "height": {"label": "Height", "type": "int", "default": 1024, "min": 256, "max": 2048, "group": "Generation"}, diff --git a/adam/model_plugins_builtin/wan_video/__init__.py b/adam/model_plugins_builtin/wan_video/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a67a2983985a4e90587e03bd2d23672d7f31b51d --- /dev/null +++ b/adam/model_plugins_builtin/wan_video/__init__.py @@ -0,0 +1 @@ +"""Wan 2.1 video LoRA integration.""" diff --git a/adam/model_plugins_builtin/wan_video/manifest.py b/adam/model_plugins_builtin/wan_video/manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..3436d190a4914151b601de546cce41cf2d252750 --- /dev/null +++ b/adam/model_plugins_builtin/wan_video/manifest.py @@ -0,0 +1,50 @@ +PLUGIN_ID = "wan_video" + +MODEL_INFO = { + "name": "Wan Video LoRA", "version": "1.0", "category": "Video Generation", + "description": "Train Wan 2.1 T2V 1.3B LoRAs on captioned clips and generate MP4 videos with the connected LoRAVideoTrainer.", + "architecture": "wan21_t2v_1_3b_lora", "status": "experimental", "output_type": "video", + "workspace": "video_lora", + "input_formats": ["captioned video folder"], "output_formats": ["safetensors", "mp4"], + "hardware": {"recommended_vram_gb": 12, "recommended_system_ram_gb": 32}, + "capabilities": ["fresh_training", "resume_training", "video_generation"], +} + +TRAINING_SETTINGS = { + "trigger_word": {"label": "Trigger word", "type": "text", "default": "subject_token", "required": True, "group": "Dataset"}, + "resolution": {"label": "Training resolution", "type": "choice", "options": ["448x256", "256x448"], "default": "448x256", "group": "Dataset"}, + "target_frames": {"label": "Frame buckets", "type": "choice", "options": ["25", "49", "25,49"], "default": "25,49", "group": "Dataset", "description": "A clip must have at least the shortest selected frame count. Longer clips can supply multiple buckets."}, + "batch_size": {"label": "Batch size", "type": "int", "default": 1, "min": 1, "max": 4, "group": "Training"}, + "learning_rate": {"label": "Learning rate", "type": "float", "default": 0.0001, "min": 0.0000001, "max": 0.01, "decimals": 7, "group": "Training"}, + "rank": {"label": "LoRA rank", "type": "int", "default": 16, "min": 1, "max": 128, "group": "Training"}, + "alpha": {"label": "LoRA alpha", "type": "int", "default": 16, "min": 1, "max": 128, "group": "Training"}, + "blocks_to_swap": {"label": "Training blocks to swap", "type": "int", "default": 20, "min": 0, "max": 29, "group": "Memory", "description": "More swapping reduces GPU memory use and increases CPU transfer time."}, + "save_every": {"label": "Save every N epochs", "type": "int", "default": 2, "min": 1, "max": 1000, "group": "Checkpoints"}, + "seed": {"label": "Training seed", "type": "int", "default": 42, "min": 0, "max": 2147483647, "group": "Advanced", "advanced": True}, +} + +GENERATION_SETTINGS = { + "prompt": {"label": "Prompt", "type": "multiline_text", "default": "", "required": True, "group": "Prompt"}, + "format": {"label": "Video format", "type": "choice", "options": ["Landscape 832x480", "Portrait 480x832"], "default": "Landscape 832x480", "group": "Video"}, + "duration": {"label": "Requested seconds", "type": "float", "default": 2.0, "min": 2.0, "max": 15.0, "decimals": 2, "step": 0.5, "group": "Video"}, + "fps": {"label": "Playback FPS", "type": "int", "default": 12, "min": 4, "max": 60, "group": "Video"}, + "steps": {"label": "Inference steps", "type": "int", "default": 20, "min": 1, "max": 100, "group": "Generation"}, + "lora_strength": {"label": "LoRA strength", "type": "float", "default": 0.8, "min": 0.0, "max": 2.0, "decimals": 2, "step": 0.05, "group": "Generation"}, + "seed": {"label": "Seed", "type": "int", "default": 1701, "min": 0, "max": 2147483647, "group": "Generation"}, + "randomize_seed": {"label": "Randomize seed", "type": "bool", "default": False, "group": "Generation"}, + "blocks_to_swap": {"label": "Generation blocks to swap", "type": "int", "default": 24, "min": 0, "max": 29, "group": "Memory"}, + "experimental_speed": {"label": "Experimental TF32 speed mode", "type": "bool", "default": False, "group": "Advanced", "advanced": True}, +} + +TRAINING_TOOL = { + "id": "wan_video_trainer", "name": "Wan Video LoRA Trainer", + "capabilities": ["fresh_training", "resume_training", "progress", "pause", "cancel"], + "backend": {"type": "python", "module": "adam.tools.wan_video_adapter", "function": "train"}, +} +GENERATION_TOOL = { + "id": "wan_video_generator", "name": "Wan Video Generator", "model_trainers": ["wan_video"], + "arguments": ["model_name", "model_path", *GENERATION_SETTINGS], + "required_arguments": ["model_path", "prompt"], + "capabilities": ["video_generation", "progress", "pause", "cancel"], + "backend": {"type": "python", "module": "adam.tools.wan_video_adapter", "function": "generate"}, +} diff --git a/adam/nova.py b/adam/nova.py index 0c23a80ec5c4562457ad97d300680ca13d70acec..90beb9a73f1dfcb5e37aa4fe096dc570b56a4c4a 100644 --- a/adam/nova.py +++ b/adam/nova.py @@ -36,6 +36,8 @@ def _candidate_images(job: Job, limit: int = 64) -> list[Path]: def evaluate_job_output(job: Job) -> dict[str, Any]: """Evaluate technical sample health without claiming to judge artistic quality.""" + if any(step.tool_id == "wan_video_trainer" for step in job.plan.steps): + return {"agent": "NOVA", "status": "NEEDS VIDEO SAMPLES", "summary": "Wan training saved adapter weights. Generate a fixed-seed video in Video LoRA and review motion, subject consistency and flicker.", "sample_count": 0} if not any(step.tool_id.endswith("_trainer") for step in job.plan.steps): return {} paths = _candidate_images(job) diff --git a/adam/oasis_dataset.py b/adam/oasis_dataset.py index f7e8910e76d20516a489c8ccc6f5b52aa262e155..c9762b2c54c3c1b6a494a4d2c3432bb0116c808f 100644 --- a/adam/oasis_dataset.py +++ b/adam/oasis_dataset.py @@ -24,6 +24,7 @@ DERIVED_ACTIONS = { SUPPORTED_ACTIONS = BINARY_ACTIONS | CONTINUOUS_ACTIONS | DERIVED_ACTIONS REQUIRED_CANONICAL_ACTIONS = {"w", "a", "s", "d", "jump"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} +RECORDED_ACTIONS = BINARY_ACTIONS | CONTINUOUS_ACTIONS METADATA_FIELDS = { "session_id", "session_started_at", "frame_index", "filename", "timestamp_seconds", "camera_encoding", @@ -39,7 +40,11 @@ class OasisDatasetReport: valid_transitions: int = 0 sessions: int = 0 resolution: str = "" + capture_fps: float | None = None + recommended_frame_gap: int | None = None + native_ai_fps: float | None = None action_counts: dict[str, int] = field(default_factory=dict) + idle_rows: int = 0 errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) @@ -48,6 +53,61 @@ class OasisDatasetReport: return not self.errors +def _read_dataset_info(directory: Path) -> dict[str, Any]: + """Read optional recorder metadata without making it a dataset requirement.""" + try: + value = json.loads((directory / "dataset_info.json").read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def _recommended_frame_gap(capture_fps: float, _camera_encoding: str) -> int: + """Choose a playable horizon that targets about 12 genuine AI frames per second. + + This intentionally differs from the external trainer's older movement-focused + heuristic. A 12–15 FPS recording should train at gap 1, rather than being + slowed to a 3–5 FPS playable world before GPU speed is even considered. + """ + target_ai_fps = 12.0 + return max(1, min(12, round(capture_fps / target_ai_fps))) + + +def oasis_pace( + value: str | list[str] | tuple[str, ...], *, frame_gap: int, +) -> dict[str, float | int | None]: + """Return portable pacing information derived from connected recorder metadata. + + A model only makes one genuine frame for every prediction horizon. Display + interpolation can look smoother, but cannot make controls more responsive. + """ + rates: list[float] = [] + camera_encodings: list[str] = [] + for directory in dataset_directories(value): + info = _read_dataset_info(directory) + try: + rate = float(info.get("capture_fps")) + except (TypeError, ValueError): + continue + if rate > 0: + rates.append(rate) + camera_encodings.append(str(info.get("camera_encoding", "legacy_pixels"))) + if not rates or len({round(rate, 6) for rate in rates}) != 1: + return {"capture_fps": None, "recommended_frame_gap": None, "native_ai_fps": None} + capture_fps = rates[0] + camera_encoding = ( + "relative_degrees_v1" + if "relative_degrees_v1" in camera_encodings + else camera_encodings[0] + ) + gap = max(1, int(frame_gap)) + return { + "capture_fps": capture_fps, + "recommended_frame_gap": _recommended_frame_gap(capture_fps, camera_encoding), + "native_ai_fps": capture_fps / gap, + } + + def dataset_directories(value: str | list[str] | tuple[str, ...]) -> list[Path]: entries = value if isinstance(value, (list, tuple)) else str(value or "").split(";") directories: list[Path] = [] @@ -66,15 +126,22 @@ def _numeric_frame_index(path: Path) -> int | None: return int(match.group(1)) if match else None -def validate_oasis_dataset(value: str | list[str] | tuple[str, ...], *, frame_gap: int = 1) -> OasisDatasetReport: +def validate_oasis_dataset( + value: str | list[str] | tuple[str, ...], *, frame_gap: int = 1, + verify_images: bool = True, +) -> OasisDatasetReport: report = OasisDatasetReport() frame_gap = max(1, int(frame_gap)) + pace = oasis_pace(value, frame_gap=frame_gap) + report.capture_fps = pace["capture_fps"] # type: ignore[assignment] + report.recommended_frame_gap = pace["recommended_frame_gap"] # type: ignore[assignment] + report.native_ai_fps = pace["native_ai_fps"] # type: ignore[assignment] directories = dataset_directories(value) if not directories: report.errors.append("Select at least one Oasis action dataset folder.") return report seen_resolution: tuple[int, int] | None = None - action_counts = {name: 0 for name in sorted(REQUIRED_CANONICAL_ACTIONS | CONTINUOUS_ACTIONS)} + action_counts = {name: 0 for name in sorted(RECORDED_ACTIONS)} transition_total = 0 session_ids: set[str] = set() @@ -145,19 +212,20 @@ def validate_oasis_dataset(value: str | list[str] | tuple[str, ...], *, frame_ga f"{actions_path.name} line {line_number} points to missing frame {filename}; skipping row." ) continue - try: - with Image.open(frame_path) as image: - image.verify() - with Image.open(frame_path) as image: - size = image.size - except Exception as exc: - report.errors.append(f"Broken image file {frame_path.name}: {exc}") - continue - if seen_resolution is None: - seen_resolution = size - report.resolution = f"{size[0]}x{size[1]}" - elif size != seen_resolution: - report.errors.append(f"Inconsistent frame resolution: {frame_path.name} is {size[0]}x{size[1]}, expected {seen_resolution[0]}x{seen_resolution[1]}.") + if verify_images: + try: + with Image.open(frame_path) as image: + image.verify() + with Image.open(frame_path) as image: + size = image.size + except Exception as exc: + report.errors.append(f"Broken image file {frame_path.name}: {exc}") + continue + if seen_resolution is None: + seen_resolution = size + report.resolution = f"{size[0]}x{size[1]}" + elif size != seen_resolution: + report.errors.append(f"Inconsistent frame resolution: {frame_path.name} is {size[0]}x{size[1]}, expected {seen_resolution[0]}x{seen_resolution[1]}.") unexpected = sorted(set(row) - SUPPORTED_ACTIONS - METADATA_FIELDS) if unexpected: report.errors.append(f"{actions_path.name} line {line_number} contains unsupported action field(s): {', '.join(unexpected[:6])}.") @@ -176,6 +244,7 @@ def validate_oasis_dataset(value: str | list[str] | tuple[str, ...], *, frame_ga report.errors.append(f"{actions_path.name} repeats frame_index {frame_index} in session {session_id}.") continue seen_keys.add(key) + action_active = False for name in action_counts: try: value = float(row.get(name, 0)) @@ -184,6 +253,9 @@ def validate_oasis_dataset(value: str | list[str] | tuple[str, ...], *, frame_ga value = 0.0 if abs(value) > (0.5 if name in BINARY_ACTIONS else 0.02): action_counts[name] += 1 + action_active = True + if not action_active: + report.idle_rows += 1 row["_session_id"] = session_id rows_by_session.setdefault(session_id, []).append(row) session_ids.add(f"{resolved}:{session_id}") @@ -213,3 +285,10 @@ def validate_oasis_dataset(value: str | list[str] | tuple[str, ...], *, frame_ga if report.valid_rows and not any(action_counts.values()): report.errors.append("No non-idle action labels were found. Record idle plus at least one active control.") return report + + +def inspect_oasis_dataset( + value: str | list[str] | tuple[str, ...], *, frame_gap: int = 1, +) -> OasisDatasetReport: + """Quickly inspect training-relevant labels and pacing without decoding images.""" + return validate_oasis_dataset(value, frame_gap=frame_gap, verify_images=False) diff --git a/adam/oasis_player.py b/adam/oasis_player.py new file mode 100644 index 0000000000000000000000000000000000000000..fa911f264fac7629582d9b50cd387ebab0dbfedb --- /dev/null +++ b/adam/oasis_player.py @@ -0,0 +1,112 @@ +"""Small, UI-independent helpers for ADAM's native Oasis Player page.""" + +from __future__ import annotations + +import json +import random +from pathlib import Path + + +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} +ACTION_MODEL_TYPES = { + "action_conditioned_rectified_flow_video", + "action_conditioned_latent_vae_flow_video", + "action_conditioned_temporal_latent_flow", + "action_conditioned_temporal_pixel_flow", +} +LATENT_MODEL_TYPES = { + "action_conditioned_latent_vae_flow_video", + "action_conditioned_temporal_latent_flow", +} + + +def is_action_model(path: str | Path) -> bool: + """Return whether *path* is a complete Oasis action-world-model folder.""" + folder = Path(path) + try: + info = json.loads((folder / "action_flow_model_info.json").read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return False + weights = ( + folder / "unet" / "diffusion_pytorch_model.safetensors", + folder / "unet" / "diffusion_pytorch_model.bin", + ) + model_type = info.get("model_type") + has_unet = (folder / "unet" / "config.json").is_file() and any(candidate.is_file() for candidate in weights) + has_vae = (folder / "vae" / "config.json").is_file() and (folder / "vae" / "pytorch_model.bin").is_file() + return model_type in ACTION_MODEL_TYPES and has_unet and (model_type not in LATENT_MODEL_TYPES or has_vae) + + +def model_info(path: str | Path) -> dict: + """Read compatible model metadata, raising a useful error for the UI.""" + folder = Path(path) + if not is_action_model(folder): + raise ValueError("Choose a complete Oasis action model folder.") + return json.loads((folder / "action_flow_model_info.json").read_text(encoding="utf-8")) + + +def frame_size(info: dict) -> tuple[int, int]: + """Return the trained (width, height), including legacy checkpoint metadata.""" + if info.get("width") and info.get("height"): + return int(info["width"]), int(info["height"]) + value = str(info.get("resolution", "256x144")).lower().replace("×", "x") + if "x" in value: + width, height = (int(part.strip()) for part in value.split("x", 1)) + return width, height + width = int(value) + return width, round(width * 9 / 16) + + +def discover_models(oasis_root: str | Path, assets=()) -> list[tuple[str, Path]]: + """Find local Oasis models without copying, moving, or modifying them.""" + found: dict[Path, str] = {} + root = Path(oasis_root) + library = root / "output_action_flow_models" + if library.is_dir(): + for folder in library.iterdir(): + if folder.is_dir() and is_action_model(folder): + found[folder.resolve()] = folder.name + for asset in assets: + if getattr(asset, "kind", "") != "model" or getattr(asset, "trainer", "") != "oasis": + continue + folder = Path(getattr(asset, "path", "")) + if is_action_model(folder): + found[folder.resolve()] = getattr(asset, "name", "") or folder.name + return sorted(((name, path) for path, name in found.items()), key=lambda item: item[0].casefold()) + + +def capture_path(root: str | Path, prefix: str = "oasis_frame") -> Path: + """Choose an ADAM-owned, collision-resistant PNG destination.""" + from datetime import datetime + from uuid import uuid4 + + folder = Path(root) / "data" / "oasis_captures" + folder.mkdir(parents=True, exist_ok=True) + return folder / f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{uuid4().hex[:6]}.png" + + +def random_roblox_dataset_frame(oasis_root: str | Path, chooser=None) -> Path: + """Pick one image from a deeply nested Roblox Dataset without assuming its layout. + + Oasis recordings often live in several named recording folders, each with a + ``frames`` folder. Reservoir sampling avoids holding every path in memory. + ``chooser`` is injectable for deterministic tests. + """ + root = Path(oasis_root) / "OldDatasets" / "Roblox Dataset" + if not root.is_dir(): + raise FileNotFoundError("The connected Oasis Trainer has no OldDatasets/Roblox Dataset folder.") + pick = None + count = 0 + chooser = chooser or random.randrange + try: + for path in root.rglob("*"): + if not path.is_file() or path.suffix.casefold() not in IMAGE_EXTENSIONS: + continue + count += 1 + if chooser(count) == 0: + pick = path + except OSError as exc: + raise OSError(f"ADAM could not read the Roblox Dataset: {exc}") from exc + if pick is None: + raise FileNotFoundError("No PNG, JPG, WEBP, or BMP frames were found in the Roblox Dataset.") + return pick diff --git a/adam/ollama.py b/adam/ollama.py index a82e3402c130ce860cb71a72dc5dfad861ea689b..dbee79c170ec5c3ed3e54e8aa54f0e4df982c1f2 100644 --- a/adam/ollama.py +++ b/adam/ollama.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import base64 import urllib.error import urllib.request +from pathlib import Path from typing import Any, Callable @@ -17,22 +19,56 @@ class OllamaClient: model: str, timeout: float = 2.5, chat_max_tokens: int | None = None, + chat_response_length: str = "automatic", ) -> None: self.base_url = base_url.rstrip("/") self.model = model self.timeout = timeout self.chat_max_tokens = chat_max_tokens + self.chat_response_length = str(chat_response_length or "automatic").casefold() def _chat_system(self, system: str) -> str: """Return the application system prompt unchanged.""" return system - def _num_predict(self, default: int, *, chat: bool = True) -> int: + def _num_predict(self, default: int, *, chat: bool = True, prompt: str = "", image_count: int = 0) -> int: """Qwen3's reasoning commonly needs more than a short-chat token budget.""" if chat and self.chat_max_tokens is not None: - return self.chat_max_tokens + limit = max(64, int(self.chat_max_tokens)) + choices = {"short": 256, "balanced": 512, "detailed": limit} + selected = choices.get(self.chat_response_length) + if selected is not None: + return min(limit, selected) + text = prompt.casefold() + if image_count: + budget = 384 + elif any(word in text for word in ("compare", "explain", "research", "plan", "review", "why", "how")): + budget = 768 + elif any(word in text for word in ("summarize", "details", "ideas", "examples")): + budget = 512 + else: + budget = 256 + return min(limit, max(128, budget)) return 1024 if self.model.casefold().startswith("qwen3") else default + @staticmethod + def prepare_image(path: str | Path, *, maximum_side: int = 1536) -> str: + """Return a compact PNG attachment without changing the user's source image.""" + source = Path(path).expanduser() + if not source.is_file(): + raise OllamaError("The attached image no longer exists.") + try: + from PIL import Image, ImageOps + with Image.open(source) as opened: + image = ImageOps.exif_transpose(opened).convert("RGB") + image.thumbnail((maximum_side, maximum_side), Image.Resampling.LANCZOS) + from io import BytesIO + buffer = BytesIO() + image.save(buffer, format="PNG", optimize=True) + except (OSError, ValueError) as exc: + raise OllamaError(f"ADAM could not read {source.name} as an image.") from exc + return base64.b64encode(buffer.getvalue()).decode("ascii") + def is_available(self, timeout: float = 0.35) -> bool: request = urllib.request.Request(f"{self.base_url}/api/tags", method="GET") try: @@ -64,8 +100,10 @@ class OllamaClient: raise OllamaError("Ollama returned an unsupported plan shape.") return parsed - def generate_text(self, system: str, prompt: str) -> str: - response = self._generate(system, prompt, json_format=False).strip() + def generate_text( + self, system: str, prompt: str, *, image_paths: list[str | Path] | None = None, + ) -> str: + response = self._generate(system, prompt, json_format=False, image_paths=image_paths).strip() if not response: raise OllamaError("Ollama returned an empty response.") return response @@ -75,14 +113,21 @@ class OllamaClient: system: str, prompt: str, on_chunk: Callable[[str], None], + image_paths: list[str | Path] | None = None, ) -> str: + images = [self.prepare_image(path) for path in (image_paths or [])] payload: dict[str, Any] = { "model": self.model, "system": self._chat_system(system), "prompt": prompt, + # Free GPU memory for image generation and other local workloads as + # soon as this one-shot text request finishes. + "keep_alive": 0, "stream": True, - "options": {"temperature": 0.35, "num_predict": self._num_predict(180)}, + "options": {"temperature": 0.35, "num_predict": self._num_predict(180, prompt=prompt, image_count=len(images))}, } + if images: + payload["images"] = images request = urllib.request.Request( f"{self.base_url}/api/generate", data=json.dumps(payload).encode("utf-8"), @@ -109,19 +154,23 @@ class OllamaClient: raise OllamaError("Ollama returned an empty response.") return result - def _generate(self, system: str, prompt: str, *, json_format: bool) -> str: + def _generate(self, system: str, prompt: str, *, json_format: bool, image_paths: list[str | Path] | None = None) -> str: + images = [self.prepare_image(path) for path in (image_paths or [])] payload: dict[str, Any] = { "model": self.model, "system": self._chat_system(system), "prompt": prompt, + "keep_alive": 0, "stream": False, "options": { "temperature": 0.1 if json_format else 0.35, - "num_predict": self._num_predict(300 if json_format else 180, chat=not json_format), + "num_predict": self._num_predict(300 if json_format else 180, chat=not json_format, prompt=prompt, image_count=len(images)), }, } if json_format: payload["format"] = "json" + if images: + payload["images"] = images body = json.dumps( payload ).encode("utf-8") diff --git a/adam/orion.py b/adam/orion.py index 6a372356260164dc00f79d38f3864c231c38970f..a58fac886974ba9f9413982717421cde6887162a 100644 --- a/adam/orion.py +++ b/adam/orion.py @@ -138,6 +138,91 @@ def review_training_plan(plan: Any) -> dict[str, Any]: for step in training_steps: args = step.arguments + if step.tool_id == "wan_video_trainer": + from adam.video_lora import clips_in + count = len(clips_in(Path(str(args.get("dataset_dir", ""))))) + epochs = int(args.get("epochs", 1)) + findings.append({"level": "warning" if epochs > 100 or count < 20 else "ready", "message": ( + f"Wan video: {count} clips, {epochs} epochs, frame buckets {args.get('target_frames', '25,49')}. " + "Each clip can contribute multiple frame buckets. Inspect motion and captions; video runtime requires a measured run." + )}) + continue + if step.tool_id == "oasis_trainer": + from adam.oasis_dataset import inspect_oasis_dataset, oasis_pace + + gap = max(1, int(args.get("frame_gap", 1) or 1)) + pace = oasis_pace(args.get("dataset_dir", ""), frame_gap=gap) + recommended_gap = pace["recommended_frame_gap"] + capture_fps = pace["capture_fps"] + native_fps = pace["native_ai_fps"] + if isinstance(capture_fps, (int, float)) and isinstance(native_fps, (int, float)): + message = ( + f"{step.title or 'Oasis'}: {float(capture_fps):g} FPS capture with " + f"prediction gap {gap} trains at a native pace of {float(native_fps):g} AI FPS." + ) + if isinstance(recommended_gap, int) and recommended_gap != gap: + message += f" Dataset metadata recommends gap {recommended_gap} for responsive control." + findings.append({"level": "warning", "message": message}) + else: + findings.append({"level": "ready", "message": message}) + report = inspect_oasis_dataset(args.get("dataset_dir", ""), frame_gap=gap) + if report.ok and report.valid_transitions: + batch = max(1, int(args.get("batch_size", 1) or 1)) + accumulation = max(1, int(args.get("gradient_accumulation", 1) or 1)) + requested_chunk = max(0, int(args.get("chunk_size", 0) or 0)) + transitions_per_epoch = min(report.valid_transitions, requested_chunk) if requested_chunk else report.valid_transitions + steps_per_epoch = math.ceil(transitions_per_epoch / batch / accumulation) + epochs = max(1, int(args.get("epochs", 1) or 1)) + optimizer_steps = steps_per_epoch * epochs + total_steps += optimizer_steps + label = step.title or "Oasis" + findings.append({ + "level": "ready", + "message": ( + f"{label}: {report.valid_transitions:,} valid transitions; " + f"{transitions_per_epoch:,} used per epoch; about " + f"{steps_per_epoch:,} optimizer steps per epoch." + ), + }) + if report.valid_transitions >= 7_500 and not requested_chunk: + findings.append({ + "level": "warning", + "message": ( + f"{label}: every epoch uses all {report.valid_transitions:,} transitions. " + "Use a balanced 5,000-transition chunk or explicitly confirm the longer run." + ), + }) + if optimizer_steps >= 100_000: + findings.append({ + "level": "warning", + "message": ( + f"{label}: this plan schedules about {optimizer_steps:,} optimizer steps. " + "Run the short benchmark and inspect rollout previews before committing." + ), + }) + idle_ratio = report.idle_rows / max(1, report.valid_rows) + if idle_ratio < 0.05: + findings.append({ + "level": "warning", + "message": ( + f"{label}: only {idle_ratio:.1%} of labelled frames are idle. " + "Record more no-input gameplay to improve stable pauses." + ), + }) + rare_threshold = max(10, math.ceil(report.valid_rows * 0.01)) + rare_controls = [ + name for name, count in report.action_counts.items() + if 0 < count < rare_threshold + ] + if rare_controls and not bool(args.get("balance_actions", False)): + findings.append({ + "level": "warning", + "message": ( + "Rare controls are present (" + ", ".join(rare_controls[:5]) + + "); turn on Balance rare actions or record more examples." + ), + }) + continue dataset_key = str(Path(str(args.get("dataset_dir", ""))).expanduser()) images = dataset_image_count(args.get("dataset_dir")) or projected_counts.get(dataset_key, 0) epochs = max(1, int(args.get("epochs", 1) or 1)) diff --git a/adam/planner.py b/adam/planner.py index cb6989b724a9b5924c6cc7acb688af28058b9260..46a45913714861c179b9ccd2bf2f496d4d83b870 100644 --- a/adam/planner.py +++ b/adam/planner.py @@ -7,9 +7,12 @@ from pathlib import Path from typing import Any from collections.abc import Callable +from adam.auto_training import profile_from_request, resolve_auto_training from adam.assets import Asset, AssetRegistry from adam.commands import CommandValidationError, TrainingCommand from adam.config import ConfigManager +from adam.dataset_lab import scan_dataset +from adam.model_profiles import ModelProfileRegistry from adam.models import ExecutionPlan, PlanStep from adam.ollama import OllamaClient, OllamaError from adam.registry import RegistryError, ToolRegistry @@ -47,6 +50,7 @@ def _trainer_label(trainer: str) -> str: return { "ddpm": "DDPM", "flow": "Flow Matching", + "inrflow": "INRFlow", "lora": "LoRA", "oasis": "Oasis Action World Model", }.get(trainer, trainer.replace("_", " ").title()) @@ -99,10 +103,18 @@ class Planner: if not request: raise PlanningError("Tell ADAM what you want to accomplish.") + if re.search(r"\b(?:wan(?:\s*2[.]1)?|video\s+lora|lora\s+video)\b", request, re.I): + self.last_mode = "Video LoRA workspace" + return ExecutionPlan(request=request, summary="Open Video LoRA in the sidebar to select captioned clips, review a Wan training pipeline, continue weights, or generate an MP4. Wan settings and checkpoints are separate from SDXL LoRA.", steps=[], project_name="Video LoRA") + if self.pending_request and self._looks_like_pending_details(request): return self._continue_pending_request(request) self.assets.discover(self.config) + auto_training = self._auto_training_plan(request) + if auto_training: + self.last_mode = "Intent-based AUTO training" + return auto_training external = self._external_tool_plan(request) if external: self.last_mode = "Validated external tool" @@ -130,7 +142,7 @@ class Planner: project_name="Conversation", ) - if self.config.get("provider") == "ollama": + if self.config.get("provider") == "ollama" and self.config.get("ollama_proposed_actions", True): try: generated = self._ollama_plan(request) self.last_mode = "Ollama + registry validation" @@ -164,6 +176,7 @@ class Planner: request: str, history: list[dict[str, str]] | None = None, stream_callback: Callable[[str], None] | None = None, + image_paths: list[str | Path] | None = None, ) -> str: """Answer conversationally without creating or executing a workflow.""" request = request.strip() @@ -174,6 +187,7 @@ class Planner: self.config.get("ollama_model"), timeout=45.0, chat_max_tokens=int(self.config.get("ollama_chat_max_tokens", 1024)), + chat_response_length=str(self.config.get("ollama_chat_response_length", "automatic")), ) if self.config.get("provider") != "ollama": raise PlanningError( @@ -199,10 +213,17 @@ class Planner: "about AI datasets, captions, LoRA, DDPM, Flow Matching, model training, previews, " "and the workflows registered in ADAM. This is Chat Mode: you cannot run tools, " "change files, start jobs, or claim that work occurred. If the user asks you to " - "perform an action, explain that they should switch to Trainer Mode. Never invent " - "job results or capabilities. Registered read-only capability summary:\n" + "perform an action, explain that ADAM must create a validated plan before anything " + "can happen. Never invent job results, capability status, or a running/completed job. " + "Registered read-only capability summary:\n" + json.dumps(capabilities, ensure_ascii=False) ) + if image_paths: + system += ( + " The user attached image(s). Describe only visible evidence, distinguish " + "uncertainty from facts, and offer an editable caption when useful. Do not " + "claim the image was added to a dataset or used for training." + ) recent = (history or [])[-10:] transcript = "\n".join( f"{'User' if item.get('role') == 'user' else 'ADAM'}: " @@ -233,9 +254,9 @@ class Planner: ) try: response = ( - client.generate_text_stream(system, prompt, stream_callback) + client.generate_text_stream(system, prompt, stream_callback, image_paths=image_paths) if stream_callback - else client.generate_text(system, prompt) + else client.generate_text(system, prompt, image_paths=image_paths) ) except OllamaError as exc: raise PlanningError(f"Ollama could not answer: {exc}") from exc @@ -582,6 +603,7 @@ class Planner: ) or ( "lora" if re.search(r"\blora\b", lowered) else "ddpm" if re.search(r"\bddpm\b", lowered) + else "inrflow" if re.search(r"\binr\s*flow\b", lowered) else "flow" if re.search(r"\bflow(?:\s+matching)?\b", lowered) else "oasis" if re.search( r"\b(oasis|action[- ]conditioned|playable\s+ai\s+games?|world\s+models?|gameplay[- ]frame|wasd|w/a/s/d)\b", @@ -601,7 +623,7 @@ class Planner: model_query = "" resume_match = re.search( r"\b(?:fine[- ]?tune|retrain|continue|resume)\s+(?:the\s+)?(.+?)" - r"(?:\s+model)?\s+(?:from|on|with)\s+(?:the\s+)?(?:ddpm|lora|oasis)\b", + r"(?:\s+model)?\s+(?:from|on|with)\s+(?:the\s+)?(?:ddpm|lora|oasis|inr\s*flow|flow)\b", request, re.I, ) @@ -619,7 +641,7 @@ class Planner: model_query = _clean_subject(match.group(1)) if match else "" natural_resume = re.search( r"\b(?:fine[- ]?tune|retrain|continue|resume)\s+(?:the\s+)?(.+?)\s+" - r"from\s+(?:my|our|the)\s+(?:ddpm|lora)\s+model\b", + r"from\s+(?:my|our|the)\s+(?:ddpm|lora|inr\s*flow|flow)\s+model\b", request, re.I, ) @@ -627,7 +649,7 @@ class Planner: model_query = _clean_subject(natural_resume.group(1)) model_of_resume = re.search( r"\b(?:fine[- ]?tune|retrain|continue|resume)\s+(?:the\s+)?" - r"(?:ddpm\s+|lora\s+)?model\s+of\s+(.+?)(?:\s+for\b|,|$)", + r"(?:(?:ddpm|lora|inr\s*flow|flow)\s+)?model\s+of\s+(.+?)(?:\s+for\b|,|$)", request, re.I, ) @@ -636,7 +658,7 @@ class Planner: # Natural phrasing such as "fine-tune Hatsune Miku from our DDPM model" # should search for "Hatsune Miku", not the whole explanatory clause. model_query = re.sub( - r"\s+from\s+(?:my|our|the)?\s*(?:ddpm|lora)\s+model\s*$", + r"\s+from\s+(?:my|our|the)?\s*(?:ddpm|lora|inr\s*flow|flow)\s+model\s*$", "", model_query, flags=re.I, @@ -710,7 +732,18 @@ class Planner: steps=[], project_name="Resume training", ) - resumed_model_name = _friendly_model_name(model) + requested_output_name = self._fine_tune_output_name(request, fine_tune_payload) + resumed_model_name = _clean_subject(requested_output_name) if requested_output_name else _friendly_model_name(model) + continuation_output = self._training_output( + trainer, f"{resumed_model_name} Fine Tune" + ) + if not continuation_output: + return ExecutionPlan( + request=request, + summary=f"The {_trainer_label(trainer)} trainer folder is not connected.", + steps=[], + project_name="Resume training", + ) command = TrainingCommand.from_dict( { "action": "resume_training", @@ -718,10 +751,9 @@ class Planner: "dataset": dataset.path, "model_name": resumed_model_name, "epochs": epochs, - "output": ( - str(self._training_output(trainer, f"{resumed_model_name} Fine Tune") or model.path) - if trainer == "flow" else model.path - ), + # A continuation is always a new model branch. Never send a + # resumed run back to the selected source model's folder. + "output": str(continuation_output), # The DDPM adapter can safely branch from a complete pipeline when # its exact Accelerate checkpoint has been cleaned up. "resume_from": model.checkpoint or model.path, @@ -779,6 +811,141 @@ class Planner: raise PlanningError(str(exc)) from exc return self._plan_training_command(request, command) + def _auto_training_plan(self, request: str) -> ExecutionPlan | None: + """Plan a short, clear training request through a named AUTO policy. + + This is intentionally narrow: a trainer and a subject must both be + explicit. Ambiguous requests continue through the existing planner, + which can ask a focused follow-up instead of guessing an architecture. + """ + lowered = request.casefold() + if not re.search(r"\b(train|make|create|build|test)\b", lowered): + return None + # Fully specified legacy requests retain their established planner path. + # AUTO is for omitted decisions, not a replacement for explicit control. + if re.search(r"\b\d{1,5}\s*epochs?\b", request, re.I): + return None + match = re.search( + r"^\s*(?:(?:can|could|will|would)\s+you\s+(?:please\s+)?)?(?:quickly\s+)?" + r"(?:train|make|create|build|test)\s+(?:me\s+)?(?:a|an|the)?\s*" + r"(?:(?:quick|high\s+quality|best\s+quality|really\s+good|quality)\s+)?" + r"(ddpm|lora|inr\s*flow|flow(?:\s+matching)?)(?:\s+model)?\s+" + r"(?:on|of|for|using|with)\s+(?:an?\s+)?(?:dataset\s+(?:of|for)\s+)?(.+?)\s*$", + request, + re.I, + ) + if not match: + return None + trainer_token, raw_subject = match.groups() + trainer = ( + "inrflow" if trainer_token.casefold().replace(" ", "") == "inrflow" + else "flow" if trainer_token.casefold().startswith("flow") + else trainer_token.casefold() + ) + # Remove only trailing presentation words/settings; the subject itself + # remains ordinary natural language and never becomes a hidden command. + subject = re.sub( + r"\s+(?:images?|pictures?|screenshots?)(?:\s+(?:for|with|overnight|quickly)\b.*)?$|" + r"\s+for\s+\d{1,5}\s+epochs?\b.*$", + "", + raw_subject, + flags=re.I, + ) + subject = _clean_subject(subject) + if subject == "new subject": + return None + profile = ModelProfileRegistry(self.registry.model_plugins).get(trainer) + if profile is None: + return None + + policy = profile_from_request(request) + options = self._training_options_from_request(request) + epoch_match = re.search(r"\b(\d{1,5})\s*epochs?\b", request, re.I) + count_match = re.search(r"\b(\d{1,6})\s+(?:images?|pictures?)\b", request, re.I) + existing_dataset = self._asset_dataset(subject) + expected_items = ( + scan_dataset(existing_dataset.path, limit=1).image_count + if existing_dataset else 0 + ) + auto = resolve_auto_training( + profile, + trainer=trainer, + policy=policy, + dataset_items=expected_items or (int(count_match.group(1)) if count_match else 400), + ) + epochs = int(epoch_match.group(1)) if epoch_match else auto.epochs + image_count = int(count_match.group(1)) if count_match else auto.dataset_target + # Explicit structured/manual settings override AUTO; AUTO supplies every + # remaining supported setting, making the final plan reproducible. + training_options = {**auto.settings, **options} + model_name = self._model_name_from_request(request) or subject + + if existing_dataset: + output = self._training_output(trainer, model_name) + if not output: + return None + try: + command = TrainingCommand.from_dict({ + "action": "train", "trainer": trainer, "dataset": existing_dataset.path, + "model_name": model_name, "epochs": epochs, "output": str(output), + "base_model": ( + str(training_options.get("base_model") or self._lora_base_model()) + if trainer == "lora" else "" + ), + "training_options": training_options, + }) + except CommandValidationError as exc: + raise PlanningError(str(exc)) from exc + plan = self._plan_training_command(request, command) + plan.summary += f" {auto.summary}" + return plan + + collector_root = self._configured_tool_folder("dataset_collector") + output = self._training_output(trainer, model_name) + if not collector_root or not output: + return None + project = _project_name(subject, "Dataset") + dataset_dir = (Path(collector_root) / "Datasets" / project).resolve() + if dataset_dir.exists(): + dataset_dir = dataset_dir.with_name( + f"{dataset_dir.name} {datetime.now().strftime('%Y%m%d_%H%M%S')}" + ) + arguments: dict[str, Any] = { + "dataset_dir": str(dataset_dir), "model_name": model_name, + "epochs": epochs, "output_dir": str(output), **training_options, + } + if trainer == "lora": + base_model = str(training_options.get("base_model") or self._lora_base_model()) + if not base_model or not Path(base_model).is_file(): + return ExecutionPlan( + request=request, + summary="Choose a valid SDXL base model in the LoRA settings before AUTO training.", + steps=[], project_name="LoRA training", + ) + arguments["base_model"] = base_model + arguments["trigger_word"] = str(training_options.get("trigger_word") or model_name) + return ExecutionPlan( + request=request, + summary=( + f"AUTO plan: collect up to {image_count:,} images for {subject}, then train " + f"{model_name} with {_trainer_label(trainer)} for {epochs:,} epochs. {auto.summary}" + ), + steps=[ + PlanStep("dataset_collector", "Collect dataset", "Collect a reviewable dataset for the requested subject.", { + "subject": subject, "image_count": max(10, min(image_count, 100_000)), + "collection_mode": _collection_mode(request), "project_name": project, + "output_dir": str(dataset_dir), + }), + PlanStep(f"{trainer}_trainer", f"Train {_trainer_label(trainer)} model", "Train using resolved AUTO settings.", arguments), + ], + requires_confirmation=True, + confirmation_reason=( + "This plan downloads a dataset and starts real GPU training. " + "The resolved AUTO settings are included in the training step." + ), + project_name=model_name[:64], + ) + @staticmethod def _fine_tune_payload(request: str) -> dict[str, Any]: match = re.search(r"\[ADAM_FINE_TUNE:(\{.*\})\]\s*$", request, re.S) @@ -792,6 +959,20 @@ class Planner: raise PlanningError("Fine-tune settings must be an object.") return payload + @staticmethod + def _fine_tune_output_name(request: str, payload: dict[str, Any]) -> str: + """Return an explicit result name without confusing it with the source model.""" + requested = str(payload.get("output_model_name", "")).strip() + if requested: + return requested + match = re.search( + r"\b(?:name|call)\s+(?:the\s+)?(?:fine[- ]?tuned\s+)?" + r"(?:model\s+)?(?:as|to)\s+[\"\u201c]?([^\"\u201d.,]+)", + request, + re.I, + ) + return _clean_subject(match.group(1)) if match else "" + def _dataset_for_model(self, model: Asset) -> Asset | None: if model.dataset_id: linked = next( @@ -827,14 +1008,15 @@ class Planner: if dataset_dir.exists(): dataset_dir = dataset_dir.with_name(f"{dataset_dir.name} {datetime.now().strftime('%Y%m%d_%H%M%S')}") image_count = max(10, min(int(payload.get("image_count", 60)), 5000)) - model_name = _friendly_model_name(model) + requested_output_name = self._fine_tune_output_name(request, payload) + model_name = _clean_subject(requested_output_name) if requested_output_name else _friendly_model_name(model) + continuation_output = self._training_output(trainer, f"{model_name} Fine Tune") + if not continuation_output: + return ExecutionPlan(request=request, summary=f"The {_trainer_label(trainer)} trainer folder is not connected.", steps=[], project_name="Resume training") arguments: dict[str, Any] = { "dataset_dir": str(dataset_dir), "model_name": model_name, "epochs": epochs, - "output_dir": ( - str(self._training_output(trainer, f"{model_name} Fine Tune") or model.path) - if trainer == "flow" else model.path - ), + "output_dir": str(continuation_output), "resume_from": model.checkpoint or model.path, **training_options, } if trainer == "lora": @@ -969,13 +1151,44 @@ class Planner: return existing if matches: return matches - return self.assets.find("model", raw_query, trainer=trainer) + matches = self.assets.find("model", raw_query, trainer=trainer) + # Asset discovery and an in-memory registration can legitimately refer + # to the same saved model. One physical path is one continuation + # candidate, not an ambiguity the user has to resolve. + unique: list[Asset] = [] + seen_paths: set[str] = set() + # Prefer a model folder over a checkpoint file inside that same folder. + # Discovery can register both representations of one saved run. + ordered = sorted(matches, key=lambda item: (not Path(item.path).is_dir(), len(str(item.path)))) + for asset in ordered: + try: + resolved = Path(asset.path).expanduser().resolve() + key = str(resolved).casefold() + except OSError: + resolved = Path(asset.path) + key = str(asset.path).casefold() + nested_in_known_model = any( + key.startswith(parent + "\\") or key.startswith(parent + "/") + for parent in seen_paths + ) + if key not in seen_paths and not nested_in_known_model: + seen_paths.add(key) + unique.append(asset) + return unique def _plan_training_command( self, request: str, command: TrainingCommand, ) -> ExecutionPlan: + if command.trainer == "wan_video": + from adam.video_lora import training_plan + try: + return training_plan(self.root, command.dataset, command.model_name, command.epochs, + command.training_options or {}, command.resume_from, + "" if command.output == "default output" else command.output) + except ValueError as exc: + raise PlanningError(str(exc)) from exc tool_id = f"{command.trainer}_trainer" spec = self.registry.get(tool_id) capability = ( @@ -1028,6 +1241,23 @@ class Planner: ) from exc if command.resume_from and not Path(command.resume_from).exists(): raise PlanningError("The validated resume checkpoint does not exist.") + if command.resume_from: + resume_path = Path(command.resume_from).expanduser().resolve() + # A checkpoint may live below its model folder, so protect both + # ancestors and descendants rather than checking simple equality. + try: + output_path.relative_to(resume_path) + overlaps_resume = True + except ValueError: + try: + resume_path.relative_to(output_path) + overlaps_resume = True + except ValueError: + overlaps_resume = False + if overlaps_resume: + raise PlanningError( + "Continuation output must be a new folder, separate from the source model." + ) arguments: dict[str, Any] = { "dataset_dir": dataset_argument, "model_name": command.model_name, @@ -1413,6 +1643,9 @@ class Planner: self.config.get("ollama_model"), timeout=30.0, chat_max_tokens=int(self.config.get("ollama_chat_max_tokens", 1024)), + chat_response_length=str( + self.config.get("ollama_chat_response_length", "automatic") + ), ) if re.search(r"\bollama\b.*\b(working|online|reachable|running)\b", request, re.I): return ( @@ -1893,7 +2126,7 @@ class Planner: catalog = self.registry.safe_llm_catalog() system = ( - "You are ADAM's planning component. You only plan; you never execute. " + "You are ADAM's proposed-action component. You only propose plans; you never execute. " "Return strict JSON with summary, project_name, requires_confirmation, " "confirmation_reason, and steps. Each step has tool_id, title, " "description, and arguments. Use only listed tool IDs and only their " diff --git a/adam/progressive_training.py b/adam/progressive_training.py new file mode 100644 index 0000000000000000000000000000000000000000..a448e1147bfe2f81781b292485c001b75d0dee1d --- /dev/null +++ b/adam/progressive_training.py @@ -0,0 +1,103 @@ +"""Resolution-curriculum helpers shared by the DDPM and Flow adapters. + +The adapters deliberately use conservative batch caps instead of trying an +out-of-memory probe in a real training job. A user can still turn the policy +off and enter every batch setting manually. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from adam.executor import ToolExecutionError + + +@dataclass(frozen=True, slots=True) +class ResolutionStage: + resolution: int + epochs: int + + +_BATCH_CAPS = { + "ddpm": {64: 16, 128: 12, 256: 4, 384: 2, 512: 1}, + "flow": {64: 12, 128: 8, 256: 4, 384: 2, 512: 1}, +} + + +def parse_stages(value: Any, *, trainer: str, total_epochs: int) -> list[ResolutionStage]: + """Validate a JSON-friendly progressive-resolution schedule. + + Stages are intentionally a list of small dictionaries so plans remain easy + to inspect and edit in saved job JSON. + """ + if not isinstance(value, list) or len(value) < 2: + raise ToolExecutionError("Progressive training needs at least two resolution stages.") + multiple = 16 if trainer == "flow" else 8 + stages: list[ResolutionStage] = [] + previous = 0 + for raw in value: + if not isinstance(raw, dict): + raise ToolExecutionError("Each progressive stage must include resolution and epochs.") + try: + resolution = int(raw.get("resolution", 0)) + epochs = int(raw.get("epochs", 0)) + except (TypeError, ValueError) as exc: + raise ToolExecutionError("Progressive stage resolution and epochs must be whole numbers.") from exc + if not 64 <= resolution <= 512 or resolution % multiple: + raise ToolExecutionError( + f"{trainer.upper()} progressive resolutions must be 64–512 and divisible by {multiple}." + ) + if resolution <= previous: + raise ToolExecutionError("Progressive stages must increase from lower to higher resolution.") + if epochs < 1: + raise ToolExecutionError("Each progressive stage needs at least one epoch.") + stages.append(ResolutionStage(resolution, epochs)) + previous = resolution + if sum(stage.epochs for stage in stages) != int(total_epochs): + raise ToolExecutionError( + f"Progressive stage epochs total {sum(stage.epochs for stage in stages):,}, " + f"but training length is {int(total_epochs):,}." + ) + return stages + + +def suggested_stages(final_resolution: int, total_epochs: int) -> list[ResolutionStage]: + """Return an editable low-to-high schedule that always matches the budget.""" + resolutions = [size for size in (64, 128, 256, 384, 512) if size <= int(final_resolution)] + if len(resolutions) < 2: + return [ResolutionStage(int(final_resolution), int(total_epochs))] + if int(total_epochs) < len(resolutions): + resolutions = resolutions[-int(total_epochs):] + return [ResolutionStage(resolution, 1) for resolution in resolutions] + # Front-load inexpensive structure learning while reserving final-resolution + # refinement. Normalizing lets the same policy work for any epoch budget. + weights = [0.60, 0.20, 0.10, 0.06, 0.04][-len(resolutions):] + allocation = [max(1, round(total_epochs * weight / sum(weights))) for weight in weights] + difference = int(total_epochs) - sum(allocation) + allocation[0] += difference + return [ResolutionStage(resolution, epochs) for resolution, epochs in zip(resolutions, allocation)] + + +def stage_batch_settings( + *, trainer: str, stage_resolution: int, final_resolution: int, + final_batch_size: int, base_accumulation: int, auto_batch: bool, +) -> tuple[int, int]: + """Choose a conservative physical batch and matching accumulation count.""" + requested_batch = max(1, min(64, int(final_batch_size))) + requested_accumulation = max(1, min(64, int(base_accumulation))) + if not auto_batch: + return requested_batch, requested_accumulation + caps = _BATCH_CAPS[trainer] + cap = caps[min(caps, key=lambda size: abs(size - int(stage_resolution)))] + # Scale from the user's final-stage batch according to image area, then + # enforce a trainer-specific safe cap. This never raises the 512px batch. + scaled = round(requested_batch * (int(final_resolution) / int(stage_resolution)) ** 2) + batch_size = max(1, min(cap, 64, scaled)) + effective_batch = requested_batch * requested_accumulation + accumulation = max(1, min(64, -(-effective_batch // batch_size))) + return batch_size, accumulation + + +def stage_summary(stages: list[ResolutionStage]) -> str: + return ", ".join(f"{stage.resolution}px × {stage.epochs}" for stage in stages) diff --git a/adam/recommendations.py b/adam/recommendations.py index dca9bf954b5892a34495bb30443d607b8a067914..eb603393da1914df8a75b2348f177ad406c9d0ea 100644 --- a/adam/recommendations.py +++ b/adam/recommendations.py @@ -74,6 +74,7 @@ def recommend_for_profile( profile: ModelProfile, *, dataset_items: int, + dataset_path: str = "", resolution: int | str | None = None, snapshot: SystemSnapshot | None = None, base_model_gb: float = 0.0, @@ -85,6 +86,7 @@ def recommend_for_profile( else: resolution = int(raw_resolution) reasons: list[str] = [] + warnings: list[str] = [] vram_total = snapshot.vram_total_gb if snapshot and snapshot.vram_total_gb else None available_vram = ( max(0.0, snapshot.vram_total_gb - snapshot.vram_used_gb) @@ -92,12 +94,18 @@ def recommend_for_profile( else vram_total ) architecture = profile.architecture.casefold() - target_exposures = 80_000 if profile.id == "lora" else 180_000 if "diffusion" in architecture else 120_000 - max_epochs = 220 if profile.id == "lora" else 600 if "diffusion" in architecture else 300 - epochs = max(10 if profile.id == "lora" else 25, min(max_epochs, round(target_exposures / images))) - reasons.append( - f"Epochs target roughly {target_exposures:,} image exposures, then clamp to the profile's safe range." - ) + if profile.id == "oasis": + # Oasis learns labelled transitions rather than independent images. A real + # dataset inspection below replaces this fallback whenever one is selected. + epochs = 20 + reasons.append("Oasis starts from a transition-step budget, not an image-exposure target.") + else: + target_exposures = 80_000 if profile.id == "lora" else 180_000 if "diffusion" in architecture else 120_000 + max_epochs = 220 if profile.id == "lora" else 600 if "diffusion" in architecture else 300 + epochs = max(10 if profile.id == "lora" else 25, min(max_epochs, round(target_exposures / images))) + reasons.append( + f"Epochs target roughly {target_exposures:,} image exposures, then clamp to the profile's safe range." + ) batch_defaults = { 64: 16, @@ -110,6 +118,8 @@ def recommend_for_profile( } if profile.id == "flow": batch_defaults.update({64: 12, 128: 8, 256: 4}) + if profile.id == "inrflow": + batch_defaults.update({64: 4, 128: 2, 256: 1}) if profile.id == "oasis": batch_defaults.update({128: 4, 256: 2, 384: 1, 512: 1}) if profile.id == "lora": @@ -136,7 +146,7 @@ def recommend_for_profile( settings["learning_rate"] = _clamp_to_schema( profile, "learning_rate", - 0.00002 if profile.id == "oasis" else 0.0001 if profile.id in {"ddpm", "lora"} else 0.0002, + 0.00002 if profile.id == "oasis" else 0.0001 if profile.id in {"ddpm", "lora", "inrflow"} else 0.0002, ) workers = max(1, min(8, (os.cpu_count() or 4) // 2)) for key in ("dataloader_num_workers", "workers"): @@ -145,22 +155,103 @@ def recommend_for_profile( for key, value in { "gradient_accumulation_steps": 1, "gradient_accumulation": 1, - "mixed_precision": "fp32" if profile.id == "oasis" else "fp16", + "mixed_precision": "fp16", "save_every": max(5, min(25, max(1, epochs // 10))), "preview_every": max(5, min(50, max(1, epochs // 10))), "training_intensity": 100, "gradient_checkpointing": resolution >= 384 or (available_vram is not None and available_vram < 8), "rank": 16, "alpha": 16, - "frame_gap": 3, + "frame_gap": 1, "sequence_context": 1, "preview_steps": 1 if profile.id == "oasis" else 50 if profile.id == "ddpm" else 10, }.items(): if key in profile.training: settings[key] = _clamp_to_schema(profile, key, value) + if profile.id == "inrflow" and "query_points" in profile.training: + settings["query_points"] = _clamp_to_schema( + profile, "query_points", min(1024, resolution * resolution) + ) + reasons.append( + "INRFlow starts with at most 1,024 decoded pixel queries per image to keep training memory practical." + ) + + if profile.id == "oasis" and dataset_path: + from adam.oasis_dataset import dataset_directories, inspect_oasis_dataset, oasis_pace + + pace = oasis_pace(dataset_path, frame_gap=int(settings.get("frame_gap", 1))) + recommended_gap = pace["recommended_frame_gap"] + capture_fps = pace["capture_fps"] + if isinstance(recommended_gap, int) and isinstance(capture_fps, (int, float)): + settings["frame_gap"] = _clamp_to_schema(profile, "frame_gap", recommended_gap) + native_fps = float(capture_fps) / int(settings["frame_gap"]) + reasons.append( + f"The dataset records at {float(capture_fps):g} FPS, so prediction gap " + f"{settings['frame_gap']} gives a native trained pace of {native_fps:g} AI FPS." + ) + report = inspect_oasis_dataset(dataset_path, frame_gap=int(settings.get("frame_gap", 1))) + if report.ok and report.valid_transitions: + transition_count = report.valid_transitions + # Large datasets need bounded epochs and a rotating, balanced sample. + # This keeps the recommendation in tens of thousands of updates rather + # than silently turning 10K captured frames into a multi-day run. + chunk_size = 5_000 if transition_count >= 7_500 else 0 + transitions_per_epoch = min(transition_count, chunk_size) if chunk_size else transition_count + optimizer_steps_per_epoch = math.ceil( + transitions_per_epoch + / max(1, int(settings.get("batch_size", batch_size))) + / max(1, int(settings.get("gradient_accumulation", 1))) + ) + target_updates = 50_000 if transition_count >= 7_500 else 30_000 + epochs = max(5, min(45, math.ceil(target_updates / max(1, optimizer_steps_per_epoch)))) + for key, value in { + "chunk_size": chunk_size, + "chunk_mode": "balanced", + "chunk_offset": 0, + "balance_actions": True, + "tf32": True, + "contrast_every": 4, + "contrast_samples": 2, + "recovery_minutes": 30, + "save_every": max(5, min(10, max(1, epochs // 4))), + "preview_every": max(2, min(10, max(1, epochs // 5))), + }.items(): + if key in profile.training: + settings[key] = _clamp_to_schema(profile, key, value) + if len(dataset_directories(dataset_path)) > 1: + for key, value in {"include_older_data": True, "replay_older_percent": 50.0}.items(): + if key in profile.training: + settings[key] = _clamp_to_schema(profile, key, value) + reasons.append( + f"{transition_count:,} valid transitions use " + f"{transitions_per_epoch:,} transition(s) per epoch, about " + f"{optimizer_steps_per_epoch:,} optimizer steps per epoch, and a " + f"{target_updates:,}-step initial budget." + ) + if chunk_size: + reasons.append( + "A balanced 5,000-transition chunk keeps rare controls represented; " + "increase the chunk offset on a later continuation to rotate the sample." + ) + idle_ratio = report.idle_rows / max(1, report.valid_rows) + if idle_ratio < 0.05: + warnings.append( + f"Only {idle_ratio:.1%} of labelled frames are idle. Record more no-input gameplay " + "so the world can stay stable when the player releases controls." + ) + rare_threshold = max(10, math.ceil(report.valid_rows * 0.01)) + rare_controls = [ + name for name, count in report.action_counts.items() + if 0 < count < rare_threshold + ] + if rare_controls: + warnings.append( + "Rare recorded controls: " + ", ".join(rare_controls[:5]) + + ". Action balancing is enabled, but more examples are still safer." + ) + estimated = estimate_vram_gb(profile, resolution, int(settings.get("batch_size", batch_size)), base_model_gb) - warnings: list[str] = [] if available_vram is not None and estimated > available_vram * 0.9: warnings.append( f"Estimated VRAM need is about {estimated:.1f} GB, above the conservative {available_vram * 0.9:.1f} GB working limit." @@ -178,11 +269,18 @@ def recommend_for_profile( memory_note = ( f" using about {available_vram:.1f} GB available VRAM" if available_vram is not None else " without detected VRAM" ) - summary = ( - f"Recommended {epochs:,} epochs for {images:,} item(s), " - f"batch {settings.get('batch_size', batch_size)} at {resolution}px{memory_note}. " - "Treat this as a starting recipe, not a guarantee." - ) + if profile.id == "oasis": + summary = ( + f"Recommended {epochs:,} Oasis epochs, batch {settings.get('batch_size', batch_size)}, " + f"prediction gap {settings.get('frame_gap', 1)}{memory_note}. " + "The recipe uses labelled transitions and is a starting point, not a guarantee." + ) + else: + summary = ( + f"Recommended {epochs:,} epochs for {images:,} item(s), " + f"batch {settings.get('batch_size', batch_size)} at {resolution}px{memory_note}. " + "Treat this as a starting recipe, not a guarantee." + ) return SettingsRecommendation( profile_id=profile.id, epochs=epochs, diff --git a/adam/remote_access.py b/adam/remote_access.py index e69230a29c5452147a6340307cdc9fc4b280d5b0..ec0aad57c1fbf3522be5f30d36e6bf29378c26b8 100644 --- a/adam/remote_access.py +++ b/adam/remote_access.py @@ -24,6 +24,7 @@ from adam.generations import ( load_generation_history, parse_chat_generation_request, ) +from adam.assets import Asset from adam.remote_dispatcher import RemoteCommandDispatcher from adam.remote_media import OpaqueIdCodec, RemoteMediaStore from adam.remote_v1 import RemoteV1Service @@ -2413,9 +2414,11 @@ class RemoteAccessService: preferred_id = { "ddpm": "ddpm_generator", "flow": "flow_generator", + "inrflow": "inrflow_generator", + "pixelrow": "pixelrow_generator", "lora": "lora_generator", }.get(parsed.provider_hint, "") - if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow"}: + if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow", "inrflow", "pixelrow"}: preferred_id = "lora_generator" preferred_tool = next((item for item in tools if item.id == preferred_id), None) if parsed.provider_hint and preferred_tool is None: @@ -2443,7 +2446,23 @@ class RemoteAccessService: key=lambda item: item[0], reverse=True, ) - model = scored[0][1] if scored and scored[0][0] > 0 else None + model = next( + ( + asset for asset in candidates + if parsed.metadata_model_path + and Path(asset.path).resolve() == Path(parsed.metadata_model_path).expanduser().resolve() + ), + None, + ) + if model is None and parsed.metadata_model_path: + direct_path = Path(parsed.metadata_model_path).expanduser() + if direct_path.is_file() and direct_path.suffix.casefold() == ".safetensors": + model = Asset( + id="pasted-metadata", kind="model", name=direct_path.stem, + path=str(direct_path.resolve()), trainer="lora", + ) + if model is None: + model = scored[0][1] if scored and scored[0][0] > 0 else None if model is None and not model_query and len(candidates) == 1: model = candidates[0] if model is None and plain_model_search: @@ -2534,8 +2553,8 @@ class RemoteAccessService: extra_arguments = { "negative_prompt": parsed.negative_prompt or str(saved_generation.get("negative_prompt", "")), "base_model_path": base_model_path, - "width": 0, - "height": 0, + "width": parsed.width or 0, + "height": parsed.height or 0, "cfg_scale": parsed.cfg_scale if parsed.cfg_scale is not None else float(saved_generation.get("cfg_scale", 0) or 0), "lora_strength": 0.0 if base_only else ( parsed.lora_strength if parsed.lora_strength is not None else float(saved_generation.get("lora_strength", 0) or 0) @@ -2568,6 +2587,8 @@ class RemoteAccessService: for asset in getattr(self.planner.assets, "assets", []) if asset.kind == "base_model" and Path(asset.path).exists() ] + if parsed.metadata_base_model_path and Path(parsed.metadata_base_model_path).expanduser().is_file(): + return str(Path(parsed.metadata_base_model_path).expanduser().resolve()) if parsed.base_model_query: scored_bases = sorted( ( diff --git a/adam/remote_dashboard.py b/adam/remote_dashboard.py index 1ea04dbfa5efd64c1da1cc3a50fc88d093e7cf28..9ee2b94e94295272ede5ba45a50ed09f56fdb884 100644 --- a/adam/remote_dashboard.py +++ b/adam/remote_dashboard.py @@ -22,7 +22,8 @@ def remote_dashboard_app_html() -> str:
Active Job
Checking ADAM...
Time Left -Finish -
Live Preview
Waiting for a preview.
-
Prompt ADAM
+
Prompt ADAM
+
Image Generation
Choose the model directly instead of describing a LoRA in a prompt.
Latest Generation
@@ -62,16 +63,18 @@ def remote_dashboard_app_html() -> str:

       
     
-  
-
-  
Jobs
+ + +

Generate Image

+ +
Jobs
Remote Control
Remembered Locations
System
\ No newline at end of file diff --git a/docs/field-guide/adam-map.png b/docs/field-guide/adam-map.png new file mode 100644 index 0000000000000000000000000000000000000000..712987fcb866c2f30572307fd4ec3a4a7a3ab4b3 --- /dev/null +++ b/docs/field-guide/adam-map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:188d675c7cdec20179b1d10b6e2856b6e54b42f5bdcdd1d8cd088004b0c87048 +size 298122 diff --git a/docs/field-guide/adam-map.svg b/docs/field-guide/adam-map.svg new file mode 100644 index 0000000000000000000000000000000000000000..a87153af8dab437482fba7d45a71e0ce8cb5c1f8 --- /dev/null +++ b/docs/field-guide/adam-map.svg @@ -0,0 +1 @@ +ADAM: an AI experiment command centerSource-based map of ADAM workflows, lifecycle reviewers, shared execution services, and model architectures.PROJECT FIELD GUIDE / 01 OCT 2026ADAMA local AI experiment command center.From an idea to a reviewed dataset, a trained model, and evidence for the next run.LOCALDesktop workspaceREVIEWEDTraining approval + reviewEXTENSIBLEDeclared model pluginsREPEATABLESaved jobs + experiments01 / THE EXPERIMENT LOOP01Idea → planChat or guided modelcreation02Collect + reviewImages, clips, captions,decisions03Review + approvePreflight and workloadchecks04TrainOne shared sequential jobqueue05GenerateSeeds, previews,image/video history06Learn + repeatCompare runs and saverecipesEVIDENCE FEEDS THE NEXT RUNEVEDataset reviewerLocal visual similarity; uncertainimages stay reviewable.ORIONBefore trainingChecks workload and settings; warningscan require approval.ATLASDuring trainingWatches loss, heat and disk; criticalconditions pause work.NOVAAfter trainingChecks sample health; artistic qualitystill needs your eye.02 / ONE ENGINE, MULTIPLE WORKSPACESDESKTOP + REMOTEShared execution pathPlannerTool registryJob managerTool executorDURABLE MEMORYJobs + logs / Datasets + models / Experiment database / Generation settings + reviews03 / DIFFERENT WAYS TO MAKE AN IMAGEDDPM + FlowNoise → imageDenoising or an integrated flow.PixelRowRows → imageBuilds the canvas top to bottom.INRFlowCoordinates → RGBPixel-space flow, no VAE.Neural Cellular AutomataSeed → growthLearns local cellular update rules.ALSO IN THE LABSDXL LoRA · Wan video LoRA · Oasis action world model + playerSource inspection: adam/ + models/; animations in the companion guide explain mechanisms, not model quality. \ No newline at end of file diff --git a/docs/inrflow_integration.md b/docs/inrflow_integration.md new file mode 100644 index 0000000000000000000000000000000000000000..624a391ba847109059c20c31a805a7ff5556296e --- /dev/null +++ b/docs/inrflow_integration.md @@ -0,0 +1,47 @@ +# INRFlow integration + +INRFlow is implemented as a self-contained built-in ADAM model plugin. It uses +the existing manifest-driven Training and Generations interfaces, job queue, +checkpoint catalog, progress reporting, pause/cancel behavior, live previews, +and asset registry. No separate workspace or UI redesign is required. + +## What is implemented + +- RGB coordinate-value fields in ambient image space; there is no VAE or other + modality-specific compressor. +- A patch-local cross-attention encoder that turns coordinate/RGB pairs into + spatial context latents. +- A time-conditioned transformer trunk for global context. +- A query decoder that predicts one RGB velocity per requested coordinate-value + pair. +- Conditional independent continuous flow matching from Gaussian noise at + `t=0` to image data at `t=1`, with uniform or logit-normal time sampling. +- Random point-query subsampling during training. +- EMA model weights, periodic and final checkpoints, optimizer-state resume, + deterministic preview seeds, and training metadata. +- Euler and Heun generation, live ODE previews, query chunking, Smart + Generation, and square resolution-flexible coordinate queries. + +## Comparison with regular Flow Matching + +For a useful architecture comparison, train both backends from the same dataset +at the same native resolution, use the same number of optimizer updates, and +generate with the same seed and Euler/Heun step count. Keep INRFlow's output at +Native for the primary comparison. Its alternative output resolutions test the +continuous coordinate representation, but they are not an apples-to-apples +quality comparison because they extrapolate away from the training grid. + +ADAM's default INRFlow is deliberately much smaller than the reference research +models so it can be explored on a consumer GPU. It preserves the defining model +structure and loss, but it should not be treated as a reproduction of published +quality or scaling results. + +## Boundaries + +MaskGIT remains out of scope. PixelRow is a separate autoregressive-row plugin, +and Glow is not implemented. None of those architectures share INRFlow's +checkpoint format. + +Method reference: [INRFlow: Flow Matching for INRs in Ambient +Space](https://arxiv.org/abs/2412.03791). Reference implementation: +[apple/ml-inrflow](https://github.com/apple/ml-inrflow). diff --git a/docs/oasis_integration.md b/docs/oasis_integration.md index fe5d9a565a2a398ad100cfc842eb3b04f3aa945d..08343316039889e296048598c6b28f2dad93dc5f 100644 --- a/docs/oasis_integration.md +++ b/docs/oasis_integration.md @@ -4,6 +4,20 @@ ADAM treats Oasis as a first-class trainer named `Oasis Action World Model`. It uses the existing `Oasis-Game-Trainer/roblox_action_flow_app.py` worker and keeps training and playable inference in separate processes. +Choose **temporal_latent** for new recordings. It learns a timed transition +from several recent frames and the inputs that happened between them. That lets +it distinguish, for example, a stationary car from a car still coasting after +the accelerator was released, or a player standing still from one falling with +no keys held. The player has a **Temporal: advance during no-input** switch: +turn it on to see learned idle dynamics, or turn it off to advance only when +you provide an input. + +Choose **temporal_pixel_flow** when you want the same timed frame and input +history while predicting full RGB frames directly. It does not use a VAE, so it +avoids the latent codec's spatial compression and uses more GPU memory. It is a +separate checkpoint format; it cannot continue a Temporal Latent or Pixel Flow +checkpoint. Start with batch size 1 if full RGB training runs out of memory. + ## Dataset Layout Use one or more recorder folders. Multiple folders can be supplied with @@ -29,6 +43,12 @@ missing frames, invalid labels, duplicate or gapped frame indexes, broken images, inconsistent resolutions, empty sequences, and missing action columns. It does not guess missing labels. +For temporal models, use a current v5 recorder dataset. It contains timestamps, +input intervals, episode IDs, and discontinuity markers so ADAM never trains +across a reset, teleport, pause screen, or missing frame. Keep no-input frames: +they are the evidence needed to learn coasting, gravity, and other motion that +continues after an action ends. + ## Minecraft Beta-Style Recording Notes Record your own gameplay. Start with `256x144` at 8-12 FPS for a first pipeline @@ -46,7 +66,7 @@ coverage and synchronized frame/action timestamps. ## RTX 3060 12 GB Starting Point -Use `256x144`, batch size `2`, mixed precision `fp32`, gradient accumulation -`1`, prediction horizon `3`, loader workers `2`, save every `5` epochs, preview -every `5` epochs, and 5-10 epochs for a pipeline test. Try `fp16` only after -FP32 is stable. +Use `temporal_latent`, `256x144`, batch size `2`, mixed precision `no`, history +frames `4`, future frames `3`, VAE warm-up `5`, gradient accumulation `1`, +loader workers `2`, save every `5` epochs, preview every `5` epochs, and 5-10 +epochs for a pipeline test. Try `fp16` only after full precision is stable. diff --git a/docs/releases/2026-10-01-manifest.json b/docs/releases/2026-10-01-manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..718bf29a19ff795fac0cb77e4f52e9665f6bb5a9 --- /dev/null +++ b/docs/releases/2026-10-01-manifest.json @@ -0,0 +1,937 @@ +{ + "release": "2026-10-01", + "previous_public_commit": "c61c43556ee3e9d5834f20f8e5acfcce65596b4e", + "validation": { + "tests": 322, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "files": [ + { + "path": ".gitattributes", + "bytes": 1740, + "sha256": "152ddb6753987ed9b2e23125c0c87abed95a33dcdd502f05f0a0e13d5bce1a65" + }, + { + "path": ".gitignore", + "bytes": 266, + "sha256": "bdf50144bff848f41a9367f0e19842d46540993cac06b72c80f594051abe9f63" + }, + { + "path": "ADAM.spec", + "bytes": 1672, + "sha256": "0e1baf0577735ea976080780b01d83da1370dece1325c9c0ccbd545d5f3bd5fd" + }, + { + "path": "Build ADAM.exe.ps1", + "bytes": 1089, + "sha256": "8ebd9a938fcc49d2d5e76fe8344da75fe7c6e56a5300749f5fe65acd9a5d4a47" + }, + { + "path": "LICENSE", + "bytes": 1079, + "sha256": "7349a619cdca91bbce2b58a13c279370211c2809897e2195f3e28a9e0faeea55" + }, + { + "path": "Launch ADAM.bat", + "bytes": 692, + "sha256": "186082c214b47acedc086b47cc25d3d0f70687bec9c1ad5a17b057e7e33c3063" + }, + { + "path": "README.md", + "bytes": 31169, + "sha256": "2543fcba73ed2fb942eb121bc54570f8acb5690b73d947768d7ec010e54bea43" + }, + { + "path": "adam/__init__.py", + "bytes": 78, + "sha256": "24c6ccecdbd1bc20453bff7ea55fa52435ea27bc5b59db2f2b2d26f9830f90fb" + }, + { + "path": "adam/app.py", + "bytes": 1615, + "sha256": "446ac3e2737f2fb3906a421347feb5bac1346fb599744acba487f2ba85043f4e" + }, + { + "path": "adam/assets.py", + "bytes": 18235, + "sha256": "3f77bfeb2e00943bdca610b6fbfa074ff4ae5571fb741a5d0aa24dc2b58cf13d" + }, + { + "path": "adam/atlas.py", + "bytes": 5314, + "sha256": "b7033b7e546912a8034238659cfee5db92037e46fd7afa7eca1269f07ff9e986" + }, + { + "path": "adam/auto_training.py", + "bytes": 4371, + "sha256": "9565d4de32b1103f0fc3990475d0b5fc6188e09fbbc95bd00dfad4b4168cf2d4" + }, + { + "path": "adam/cnn_reviewer.py", + "bytes": 6722, + "sha256": "728d345f3b7594c37720cbaa670f25be9b26c9ac97cd782e1739dc15a65581e5" + }, + { + "path": "adam/commands.py", + "bytes": 6971, + "sha256": "c190ceb8ff66a6b5772730253f0d525e13f3d8bf86ae02f0c1357c55e0403bf1" + }, + { + "path": "adam/config.py", + "bytes": 2685, + "sha256": "d8e8afced98b026fbf776164aa50cd2a9a7a6f32f0859b43539fd001274eecdb" + }, + { + "path": "adam/dataset_lab.py", + "bytes": 4481, + "sha256": "c6a0d5c183f0db1e3300acfa082a391bba080198ed3711957517f1312c028c86" + }, + { + "path": "adam/dataset_registry.py", + "bytes": 19280, + "sha256": "75921a8ae29ea4dcd96866f5e7a51d2e6fdf08e84e06ee06fdb8b92bc35a74b5" + }, + { + "path": "adam/eve.py", + "bytes": 7076, + "sha256": "34e14f717e29a2c566d0277ad76ff75fa6121272e9659ac3fe7f1da7135d4a35" + }, + { + "path": "adam/executor.py", + "bytes": 11143, + "sha256": "ea221d0e936842b82dfd89765611418ff7ea9a337d0a1a5574961ec8bc4ca5f7" + }, + { + "path": "adam/experiment_tracker.py", + "bytes": 16093, + "sha256": "594550e930806aa39819491d8e5be7c2831a713b08203d62d159a61cd8aeabf0" + }, + { + "path": "adam/external_tools.py", + "bytes": 12762, + "sha256": "816782e8e66f7d2877833471c4a28386e9c7a316f1fd4c7ef5436294b2a5f0e2" + }, + { + "path": "adam/generation_previews.py", + "bytes": 1839, + "sha256": "2ffc6b8f7e324980e65d5ed55ae43c6a1f944455e80cdf0f294546d04aa3fbae" + }, + { + "path": "adam/generations.py", + "bytes": 31392, + "sha256": "70fa42689950c75aab40494a82facf32b075397b89993a368b2d831572106f1c" + }, + { + "path": "adam/image_preferences.py", + "bytes": 11876, + "sha256": "e6976168a6ca606fc92e7399dd6eb9ae3f8ec88a2fd6437daa826de0658a61a8" + }, + { + "path": "adam/intelligence.py", + "bytes": 9310, + "sha256": "47bc28cdcb45840b1e8da84c4434b33709cb997283f581eb5fb82c39d7437356" + }, + { + "path": "adam/job_manager.py", + "bytes": 38532, + "sha256": "005ff914787f78a0caa50849775f989be77f222c8ceab7dbda15ba0737484c8a" + }, + { + "path": "adam/logging_setup.py", + "bytes": 1327, + "sha256": "2b5864cfa452cceb1813b24dc196999442655fde42b9068d281fa0f8c9734594" + }, + { + "path": "adam/model_inspector/__init__.py", + "bytes": 376, + "sha256": "f914d08cb02ab4b309e293bcef086ec46f8dbc810f4b4e3cdf9aa52e09aec3f5" + }, + { + "path": "adam/model_inspector/base.py", + "bytes": 3876, + "sha256": "7ec700f666a708280a554880b1bb5bdd6143abc6b34f99960f94c5779509536b" + }, + { + "path": "adam/model_inspector/comparison.py", + "bytes": 6896, + "sha256": "0a5b7fd9c6e0de497002c330f3b6b20f017a15ed2eb2d25cc436f8fad2628a86" + }, + { + "path": "adam/model_inspector/ddpm.py", + "bytes": 1267, + "sha256": "b7ac6d7732b32bc4371586f5150443f765ed11472914f81f7923364cd33c30f7" + }, + { + "path": "adam/model_inspector/detector.py", + "bytes": 1409, + "sha256": "bf12e293becacd25fa6dce24004898ffbed1aa73c2d7e191d3c7ae293df56679" + }, + { + "path": "adam/model_inspector/flow_matching.py", + "bytes": 1000, + "sha256": "c9853e55ef5dec64140a28199ec3a86ba21941f002f79560ffffa77687a6f194" + }, + { + "path": "adam/model_inspector/generic.py", + "bytes": 14755, + "sha256": "0094ea881ce098654185b51a4463f3cb13c8fc86223cc35a5ef4dde3d1c39ace" + }, + { + "path": "adam/model_inspector/lora.py", + "bytes": 889, + "sha256": "1d00831ae8284ec6bd68bec4f0fda7b19024b3c8b97f4017cf5dc183e0bafe70" + }, + { + "path": "adam/model_inspector/maskgit.py", + "bytes": 1049, + "sha256": "dff394ebd7bb27f5e1e160e1aa65706e98ea2d88e133a2fd3a77eecdfc25fefb" + }, + { + "path": "adam/model_inspector/statistics.py", + "bytes": 5435, + "sha256": "29f69ed55018e2913f2028b3d191db7ef9d60011766bcbe5a7dd765e6e1987fa" + }, + { + "path": "adam/model_plugin_backend.py", + "bytes": 1571, + "sha256": "15756a8cffa6ce513470eded9954acac3450c2182ab5c171fd0e2f6aad6753d3" + }, + { + "path": "adam/model_plugins.py", + "bytes": 19256, + "sha256": "3e05b3942b751a4106763e961c789cae79f92132c99fad0193f8a3ed73de0364" + }, + { + "path": "adam/model_plugins_builtin/__init__.py", + "bytes": 57, + "sha256": "1c63b7da991a5d11f63b1a4b708357fcd6d8cf18aea4bf1fe359cc00b0ed0976" + }, + { + "path": "adam/model_plugins_builtin/ddpm/__init__.py", + "bytes": 25, + "sha256": "a02c48d2f352e13e0ef566d1eb9c0f2c80c32fecfceba645e0588bd52454a6dc" + }, + { + "path": "adam/model_plugins_builtin/ddpm/manifest.py", + "bytes": 5757, + "sha256": "d100e659b719879571096839ccb8466d38319ab4f22cd4d35fc646db6f9d6a23" + }, + { + "path": "adam/model_plugins_builtin/flow_matching/__init__.py", + "bytes": 34, + "sha256": "c795d0bea7e73166108a65a5de4c106da5bf6f6e493784645d08ca757d2714a0" + }, + { + "path": "adam/model_plugins_builtin/flow_matching/manifest.py", + "bytes": 4529, + "sha256": "3bd47cbac3e2d7d1d2b4171eeae26999cadddf9ce4d0874ac6102b1d2b3ad2f8" + }, + { + "path": "adam/model_plugins_builtin/inrflow/__init__.py", + "bytes": 46, + "sha256": "fa687e6e831fd9ed97f96bc5e365db10fbbd5fae5efc86af575deef97f4537fe" + }, + { + "path": "adam/model_plugins_builtin/inrflow/common.py", + "bytes": 3570, + "sha256": "a479e559c8089f770eb02f2a8568994e9e68c42e1616921570deb23f07feb5e7" + }, + { + "path": "adam/model_plugins_builtin/inrflow/generator.py", + "bytes": 12643, + "sha256": "32a6241254620a0f8033cb2cc742e78201df999d43459f3a5cbef1608627cfd8" + }, + { + "path": "adam/model_plugins_builtin/inrflow/manifest.py", + "bytes": 10721, + "sha256": "edccfc8e9dee1cda26f3ba2e8d50f70494ca7335c004b0523a376e06d4a71ad8" + }, + { + "path": "adam/model_plugins_builtin/inrflow/model.py", + "bytes": 15158, + "sha256": "86148578072a37a534abda7bde7d750de5f0e3d3c169db7804f69ae7f7bd3c29" + }, + { + "path": "adam/model_plugins_builtin/inrflow/trainer.py", + "bytes": 20091, + "sha256": "6522bb75302834ebf35dd24c35c044d007e0917e59e80cd92b0e7c0ec749081b" + }, + { + "path": "adam/model_plugins_builtin/model_template/__init__.py", + "bytes": 38, + "sha256": "91a87c9c9228ef24f89c2e67de8467d4507eee07724bb532e37cbbfcf370621d" + }, + { + "path": "adam/model_plugins_builtin/model_template/manifest.py", + "bytes": 1188, + "sha256": "fd20be1377585aefe07b305022af17a250800e45af307710a2a27f4e6f0d6705" + }, + { + "path": "adam/model_plugins_builtin/oasis/__init__.py", + "bytes": 51, + "sha256": "7921caa7aad9cb7efd49b131158c6c041d045326e64a291b0281b327f84d4721" + }, + { + "path": "adam/model_plugins_builtin/oasis/manifest.py", + "bytes": 9561, + "sha256": "0a38b760938c05085ea000a9e7bad9e7af45f2648ff677db1ea4769cbd6dc466" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/__init__.py", + "bytes": 51, + "sha256": "3acf37387e72f0740560722bffb046f10cb797ba203455af9a96c7633cfd220e" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/common.py", + "bytes": 3310, + "sha256": "18ea3b1ba01cebcecfcaa4616a643f589505bd9ad92a659c1018c59ac1b7e605" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/generator.py", + "bytes": 9050, + "sha256": "9222cca16dfe2de179a70478bda90b1d80255dce11fbf4bfab006a5fd870d8e2" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/manifest.py", + "bytes": 8441, + "sha256": "c530806c6a62c2d3598f46e5af6a1a0151590bba47f443869399d106e371c68a" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/model.py", + "bytes": 11107, + "sha256": "449ff62e8007c02f517dacdc1525f0a44e44504a90ca3d29aca3d8016e184605" + }, + { + "path": "adam/model_plugins_builtin/pixelrow/trainer.py", + "bytes": 15892, + "sha256": "e0e610d5998a84e7a2c1eb9e141aeba9563190b488d34654d3e331b4fb1448ef" + }, + { + "path": "adam/model_plugins_builtin/sdxl_lora/__init__.py", + "bytes": 30, + "sha256": "cf4e232fe394cc4513fd71ec6e1f3c54ff09749aa6ac96da86411163b97b3569" + }, + { + "path": "adam/model_plugins_builtin/sdxl_lora/manifest.py", + "bytes": 5617, + "sha256": "f96bb74129f3f4de96a5b197759a393a15ba6c4043bfce17ce2cc6153e7fa2f2" + }, + { + "path": "adam/model_plugins_builtin/wan_video/__init__.py", + "bytes": 38, + "sha256": "242c491e2bffb5bfb9d94ee6bdaa50fedc68e2845084db4c5cb8d96f1cbc99cb" + }, + { + "path": "adam/model_plugins_builtin/wan_video/manifest.py", + "bytes": 4180, + "sha256": "6070a0ac8467a97867dd15a900fa7da4707e1c1ed9b985b4d91644833916bff3" + }, + { + "path": "adam/model_profiles.py", + "bytes": 3440, + "sha256": "6029c93cab0031a6e9ead8071df162413286a8e5c198d54a0bbe8bb1d0f88484" + }, + { + "path": "adam/models.py", + "bytes": 3952, + "sha256": "e2990c78a10b41dbe9b893ccc319b853122df8b76e19392db88a21ec8ddf40b8" + }, + { + "path": "adam/monitoring.py", + "bytes": 2943, + "sha256": "6d9a58c389709bf071ee636bb4b8459080d37121c2c0b0dc866d2b7af8a5bae9" + }, + { + "path": "adam/nova.py", + "bytes": 3403, + "sha256": "a3e4630fc745b2b2b6cb3e078fc0bf9cdfaa804193dae45fb41384c232954726" + }, + { + "path": "adam/oasis_dataset.py", + "bytes": 13359, + "sha256": "26b800fc8c0f2cd396391f2b48e727708466d095bc89d76dbdc56f2da9e1721e" + }, + { + "path": "adam/oasis_player.py", + "bytes": 4661, + "sha256": "b5c8c76baf360fc19e3dbc04fedb3544597ebc692a2b7fe4d17d8c4d3ae90a91" + }, + { + "path": "adam/ollama.py", + "bytes": 8285, + "sha256": "e42ee130845176bfeb8ff3238cd7c84ed6c7ca02726eff7a62a69e5da37112fa" + }, + { + "path": "adam/orion.py", + "bytes": 13619, + "sha256": "9435eea90f3d67ef70d38d87a3783e821f42791147ec9c4bf60d3c17df33084a" + }, + { + "path": "adam/planner.py", + "bytes": 107817, + "sha256": "fc830b875104ae3b1df3ad243a89c09a22ac9dc29a92b5504c1ac9fdece9b087" + }, + { + "path": "adam/process_control.py", + "bytes": 1679, + "sha256": "00b77d451d7de5276828ee773899d9762170e0893efa01add8f8f37cf896c240" + }, + { + "path": "adam/progressive_training.py", + "bytes": 4702, + "sha256": "bfb723c7201f625384e86aa903039b5eaafc81c3f4c6aa359f78ace6644ea044" + }, + { + "path": "adam/recommendations.py", + "bytes": 13627, + "sha256": "61a0697761afe3d1fa6acad813b0cc517c80a48c0a0bad7f1484820496f0a578" + }, + { + "path": "adam/registry.py", + "bytes": 8385, + "sha256": "e95dcd24b6bf1faca8fe999292ee021a4ace77984c9958d07aec692e67ef1250" + }, + { + "path": "adam/remote_access.py", + "bytes": 117057, + "sha256": "fc3466dc39f067d3a9c73de16a4e38dab3d402e250d0e1f10e4467d5a8431e61" + }, + { + "path": "adam/remote_api.py", + "bytes": 4210, + "sha256": "91062692c61da84521a43050702fe292f96bbcac388e359d7be5c19b797c5aaf" + }, + { + "path": "adam/remote_dashboard.py", + "bytes": 39111, + "sha256": "6f904b9530a247f9ab3ef37ab2064e901210e8243c9d877faf9f4c1bbacc6e12" + }, + { + "path": "adam/remote_dispatcher.py", + "bytes": 4204, + "sha256": "301081cd474235826c0aebe22590dac7be4281e2bce92498adcd383193a15c31" + }, + { + "path": "adam/remote_media.py", + "bytes": 4435, + "sha256": "f8b3be3c549b51416875439da168c09e4e11ef251f4d8d505cdf67ead52f69a0" + }, + { + "path": "adam/remote_v1.py", + "bytes": 37585, + "sha256": "2d659292f657b218915a9c9555b1a844fc948d953f8eda134fb4d7b0b721ac56" + }, + { + "path": "adam/showcase.py", + "bytes": 13646, + "sha256": "bfa6e66ced79bd98a7050f72496030d2d9458fcbfb826a83316489cf0147a2c4" + }, + { + "path": "adam/studio.py", + "bytes": 13367, + "sha256": "23bd8a2853a05532321ef2c3b6b35bb0f2a3458b518a7a86e43000b1e6fe0dcc" + }, + { + "path": "adam/tool_folders.py", + "bytes": 6025, + "sha256": "85ee368448e55a78e098516393fade92b37a8e41f979df578ae3f6336411c16a" + }, + { + "path": "adam/tools/__init__.py", + "bytes": 75, + "sha256": "432fa9911ef90f04ecfdcffeceaa4fa6e501eaa967d773b50541ba6e602c7f85" + }, + { + "path": "adam/tools/ddpm_adapter.py", + "bytes": 27106, + "sha256": "fbd8b43bfba5bb7dba948aeee449cf0a69b8cbafba75b088e41df91ff10fdc14" + }, + { + "path": "adam/tools/ddpm_generator.py", + "bytes": 14410, + "sha256": "53a7470f9203ed53f82303ba277a883925802157306b749038d2091cb0589dfc" + }, + { + "path": "adam/tools/demo_backends.py", + "bytes": 8785, + "sha256": "a6bb209ea9725b1620666604608cc261ca3cf824019c144b2b46811ed1a617b7" + }, + { + "path": "adam/tools/flow_adapter.py", + "bytes": 15064, + "sha256": "c5cc4c3c403286bc13f9e4c4ab6c98efbc65655ffed1a4ddb80d7ae18d05a277" + }, + { + "path": "adam/tools/flow_generator.py", + "bytes": 14073, + "sha256": "0c079b73275ac51b1f09784be57127375b66213884fb576f5eaa4d20aa35f3e4" + }, + { + "path": "adam/tools/lora_adapter.py", + "bytes": 14050, + "sha256": "c3b75b4c1403e892c73ec7a16fb4ca9200bdb5ed0f4f8062e1d98161ec04e67a" + }, + { + "path": "adam/tools/lora_generator.py", + "bytes": 13927, + "sha256": "f81812f8455fae687b389efb1837e956fcdd53d6c58827fba73146470477b5c9" + }, + { + "path": "adam/tools/oasis_adapter.py", + "bytes": 20298, + "sha256": "b4a8e94a8ca10df7f86c9b693e39b9d131db209acc2e19ae57042b30a83581b3" + }, + { + "path": "adam/tools/real_dataset_collector.py", + "bytes": 14188, + "sha256": "6c4aa0a3757ba46d603e830773f1830c93edae98ed164016f2d1de64f4b53114" + }, + { + "path": "adam/tools/wan_video_adapter.py", + "bytes": 15936, + "sha256": "a1aade6a38e845f27f6105467b71cac172b401ac5c4f118a94990df2fcaf8c22" + }, + { + "path": "adam/tools/youtube_video_collector.py", + "bytes": 25942, + "sha256": "1c8c396e4f28d108693c01974d9f3d0cc384812283779cac1928b689da702d49" + }, + { + "path": "adam/training_assistant.py", + "bytes": 23260, + "sha256": "885b5610b703233637a63c540a54ac188864658501dd3132be9259df167cb97a" + }, + { + "path": "adam/transcript_dataset.py", + "bytes": 4908, + "sha256": "040ca9ce87cf62e0a62855c86a0cdc4dad44e366317cb807a4e52b6fc4ac9cae" + }, + { + "path": "adam/ui/__init__.py", + "bytes": 41, + "sha256": "071ce54c591671192aae9f6d66c46473e8ad098e9df7f5351122cde5e49e349f" + }, + { + "path": "adam/ui/asset_refresh.py", + "bytes": 2844, + "sha256": "f8d2b07c2d075906ad9d604240538b406636561c223cccdbf34f31e26781fb45" + }, + { + "path": "adam/ui/background.py", + "bytes": 2417, + "sha256": "6313e9c0a07fc6519eb8f4ef9087e23013ae0bec1031c45c6cfc0d5610a50a4a" + }, + { + "path": "adam/ui/generation_widgets.py", + "bytes": 6851, + "sha256": "8b43590938941e85f29eeb08a555230603e940559d6c108045e2856240923d9e" + }, + { + "path": "adam/ui/generations.py", + "bytes": 109926, + "sha256": "6680cb237a0e240fd9e8f212105495e784264f52df03e358eaf9cd00cf377707" + }, + { + "path": "adam/ui/main_window.py", + "bytes": 316082, + "sha256": "a9a1a101e9a05b6844bd9e10c48178daee90ab499aadda327557568681f5037c" + }, + { + "path": "adam/ui/model_intelligence.py", + "bytes": 14276, + "sha256": "1fcb7e993b5d96ce7b2cd6731383181da7e5e75662f67a8d6e452276eae5a373" + }, + { + "path": "adam/ui/oasis_player.py", + "bytes": 33914, + "sha256": "7658ae4dd9c5336dad03ad0465190519658a3c68131ac2321877a8a7c9b137d7" + }, + { + "path": "adam/ui/settings_ui.py", + "bytes": 11486, + "sha256": "0880f80632fe8f2ba99bdc4ef975dffe33ab030ec099470399c8cdb33075d828" + }, + { + "path": "adam/ui/showcase.py", + "bytes": 11989, + "sha256": "e5cf1ac7917ca46bffdfb933a7d815b13175fb51fbf2c06b57b88701d36afc58" + }, + { + "path": "adam/ui/studio.py", + "bytes": 75536, + "sha256": "4ef135f94756e018ca6c37e51c9a7a1d61ca3c5dab85d0249b60f662ef1c5343" + }, + { + "path": "adam/ui/theme.py", + "bytes": 7633, + "sha256": "dc6d39abebc60f8e522c2ee639344f6aa82618ba241757756d2a5220b3c6f58a" + }, + { + "path": "adam/ui/video_lora.py", + "bytes": 63839, + "sha256": "86f9abe2b79a1eafcc0a5a9eaca6de774b0be66e3816b7d29d14af965bd769a9" + }, + { + "path": "adam/ui/widgets.py", + "bytes": 39926, + "sha256": "5c87f2d4c531702b4ff2a697b7d7c3a35496fccf17524ee88b342537a17b5b0e" + }, + { + "path": "adam/video_captioning.py", + "bytes": 10120, + "sha256": "2e08e9e5128378fc51ddfa10054504d76cadc398ce595cd431e693afd04a41a8" + }, + { + "path": "adam/video_characters.py", + "bytes": 8570, + "sha256": "ee6f35da3fec7a67e1506241d626dc43386ab0d9e34bc28d312e4e7e2c58e07b" + }, + { + "path": "adam/video_lora.py", + "bytes": 13498, + "sha256": "925b345785d64d4a6027ee04e1d3f34042f61e29dfc2b950299eb43bcaf6a621" + }, + { + "path": "adam/web_search.py", + "bytes": 9848, + "sha256": "7794fff177155eaa029764808a1d824cf447afd03d416e55a2335f9cd9b1f07b" + }, + { + "path": "assets/adam_atom.ico", + "bytes": 137941, + "sha256": "b76b80bf4b20600fc71cf89363b0660c6849ab025e9584e67ad60ade5d3c246b" + }, + { + "path": "assets/adam_atom.png", + "bytes": 507770, + "sha256": "089ad61b0852c1057d88973e15c5cb04dd47012c997bbecb6b88775226bb414f" + }, + { + "path": "config/external_tools.json", + "bytes": 21, + "sha256": "8b58f6b913fdfee5afbc5b8192a652c67bef9f92579b4025171b9a56b9de7fdf" + }, + { + "path": "config/settings.json", + "bytes": 1188, + "sha256": "6cbca26bdfe6336fb62a45461ea9aceb2749630f80019ed471fe63bb0ee2e45c" + }, + { + "path": "config/tools.json", + "bytes": 13372, + "sha256": "a32c5a04d693f408f8ab9bc4083a90dca4d890ef45b91d90a8d11a3e7144bd29" + }, + { + "path": "docs/field-guide/adam-field-guide.html", + "bytes": 30506, + "sha256": "58e51bb315dbc571b7f125a69a2bc271417b79bd78ca943066573a42af07f696" + }, + { + "path": "docs/field-guide/adam-map.png", + "bytes": 298122, + "sha256": "188d675c7cdec20179b1d10b6e2856b6e54b42f5bdcdd1d8cd088004b0c87048" + }, + { + "path": "docs/field-guide/adam-map.svg", + "bytes": 14993, + "sha256": "9a84c1a4ccb6f4d984713634dbff4a27e369dd1ab08cdb50b5e73c5a0e1717dc" + }, + { + "path": "docs/inrflow_integration.md", + "bytes": 2332, + "sha256": "83d2997ac4fa6501fa876190fca529cbf19f3150fec39011ddaa300f4aa599ff" + }, + { + "path": "docs/oasis_integration.md", + "bytes": 3313, + "sha256": "f53dad36f45ecab6a215be9193534485a7ad5d8cdb8ffe9e12d33a18898253ef" + }, + { + "path": "docs/releases/2026-10-01-test-results.json", + "bytes": 6448, + "sha256": "2221d775ef288c2f2b2b7d9eb0558acf725b5f066bbbf175e8a7d89a6596048d" + }, + { + "path": "docs/releases/2026-10-01-validation.md", + "bytes": 1494, + "sha256": "0d3dd1e0e7e4f18ee300de36dbb4397b3791d7a3c4914f1f76e07b832b521652" + }, + { + "path": "docs/releases/2026-10-01.md", + "bytes": 2407, + "sha256": "df8a029f6f7048df866490ed4fb9c9ff09dbc746f104ac43ab113a214c521f83" + }, + { + "path": "docs/screenshots/command-center.png", + "bytes": 141942, + "sha256": "cd50fe5c0726b73dd7471522c6ce3ed405428a8a203ab75cf989173fbe309c41" + }, + { + "path": "docs/screenshots/create-model.png", + "bytes": 50301, + "sha256": "e5833dbd01a7e13d70074083d2aa580e4a037cb6aeb3e56d1ecf341585704951" + }, + { + "path": "docs/youtube_video_collector_plan.md", + "bytes": 4916, + "sha256": "0211df34e59946fa05a0d9695f1e5c25d16bab09f1d673dc266312c88c40719c" + }, + { + "path": "main.py", + "bytes": 3298, + "sha256": "4a50f95b65dd002250155984a60646af7a642d97cb4985691bbb8ec3adcc757f" + }, + { + "path": "models/__init__.py", + "bytes": 43, + "sha256": "0c0622448e5b152777ffa997fc029ef4ee60ae87f4fa8cf310844b9680a4a101" + }, + { + "path": "models/model_template/__init__.py", + "bytes": 58, + "sha256": "6bdf74a5462db135f8b3bd7bb99fa00cc959041aab0072d0f8fc783602aedfd0" + }, + { + "path": "models/model_template/generator.py", + "bytes": 411, + "sha256": "f90d4f07556593cbc669cf373f79c276f41bbfb4459d9c09813c319ec90fbadd" + }, + { + "path": "models/model_template/manifest.py", + "bytes": 1668, + "sha256": "7aa59ad2c342ee035167fa5f6f593f9cd93782952c3bbf1cbb80ce80fcdf325c" + }, + { + "path": "models/model_template/model.py", + "bytes": 287, + "sha256": "1aa6d7a985769d192d30eeba97388c3f4e21400c51d5a2d497d92f622cb36bb1" + }, + { + "path": "models/model_template/trainer.py", + "bytes": 472, + "sha256": "5227a4deb053f38295a159532229b1bbdc19a935992a1cec64e1d6a0cfcf9482" + }, + { + "path": "models/neural_cellular_automata/__init__.py", + "bytes": 54, + "sha256": "39bc4bdca681f9fc71d27e609bd683fe8165f7bdf7eb696526f87e6f5042ccb4" + }, + { + "path": "models/neural_cellular_automata/config.py", + "bytes": 3031, + "sha256": "20ccccb5385e94bc00028ba3b39fe500a959d4c67385ea57cb2c7ca77ecc8900" + }, + { + "path": "models/neural_cellular_automata/generator.py", + "bytes": 8097, + "sha256": "448765e412a03d111e7fe5667cf61363e4f404ff28259757c0d81b0bb1340049" + }, + { + "path": "models/neural_cellular_automata/image_display.py", + "bytes": 1394, + "sha256": "2306793d01a9d9bf686485b1b8825692aea8d6b74ae9de7b964b38d17a5cb96f" + }, + { + "path": "models/neural_cellular_automata/image_preprocessing.py", + "bytes": 1652, + "sha256": "139926cb8e3312a8f8f49185de2f9277e22e7ad0ba2275e60c2445d1c2f86552" + }, + { + "path": "models/neural_cellular_automata/manifest.py", + "bytes": 4621, + "sha256": "71975069f77ac6b8e5982cb9b0b75bdf34e20e4100b91b3771104584373016b7" + }, + { + "path": "models/neural_cellular_automata/model.py", + "bytes": 2476, + "sha256": "7eda0e721dc834ff3da8f30f2f6b05b672649b794a1217de48d91c1e5b4390f6" + }, + { + "path": "models/neural_cellular_automata/trainer.py", + "bytes": 12638, + "sha256": "16ccc037fa92a15fa7a49dd1b967e54d243eab1e2c4e85389b02ba4bdeb76bb6" + }, + { + "path": "pytest.ini", + "bytes": 101, + "sha256": "b882d5ae722daa32004e7dac1ad7856ab28caa2a78b41d349c5bb77e1fc888e8" + }, + { + "path": "requirements.txt", + "bytes": 241, + "sha256": "25c79a6b333e639fd5e2f169c7d5fbf80c1ebab139e8dd3ac13f0fbd0e02f7f3" + }, + { + "path": "scripts/run_tests.py", + "bytes": 1199, + "sha256": "bf9204d5696d40cc6614be5c43e1f4d4dd324797ff995638f91d3d7c4e3b6239" + }, + { + "path": "tests/test_adam_manager_foundation.py", + "bytes": 25292, + "sha256": "ee2ea12e9e7b6732a75dd84d7e4f0ae4a94625353e2232fdd1455f13d9bdd9ca" + }, + { + "path": "tests/test_agents.py", + "bytes": 7598, + "sha256": "a7c624620a1876f70170a962a8991d51482da914fe977253da3bb3c46e4c7369" + }, + { + "path": "tests/test_assets_commands.py", + "bytes": 4665, + "sha256": "cc1a2e6b58c32306090b16470fd5313903e53b1a5a826b5654567f37164ea468" + }, + { + "path": "tests/test_cnn_reviewer.py", + "bytes": 1742, + "sha256": "d6cdd7c56d5cde211845421aecbef819a030564e5178f0f51e5f10f8492f7526" + }, + { + "path": "tests/test_ddpm_adapter.py", + "bytes": 5806, + "sha256": "d232a110c6e0bbb81279b271d6785fc9908fc039a5b00b8c02bbcd08f4db271b" + }, + { + "path": "tests/test_eve.py", + "bytes": 1772, + "sha256": "620da062810c61fed1847e56a20277648626a751e5bf957356ef718210cc45f6" + }, + { + "path": "tests/test_experiment_tracker.py", + "bytes": 3861, + "sha256": "eaca1f0c08cd731b8b12efac3975c3fe986d725295c918b44f65edc528638e6b" + }, + { + "path": "tests/test_external_tools.py", + "bytes": 4308, + "sha256": "99f137090f613f32cb36a7581c2ad17d917005f210e9f153a1743666fcfcab98" + }, + { + "path": "tests/test_generation_workspace.py", + "bytes": 11252, + "sha256": "f8c0ed7a4d1f7cf9c0fc1082e6799f60c49b01cd22811e8412512aa28d20ae77" + }, + { + "path": "tests/test_generations.py", + "bytes": 39051, + "sha256": "6a92b0e69524b6f39f78bbbafbc122d7accb276778fb8b895ff52d81ff9fee54" + }, + { + "path": "tests/test_image_preferences.py", + "bytes": 3110, + "sha256": "59d7972d645db6079f9bd7cb4f0822b6af32c217df109a314bdbe6fc907d72db" + }, + { + "path": "tests/test_inrflow.py", + "bytes": 7111, + "sha256": "34ba0ec5e57bd1fe4084d53ba71357dbe8335a5360b2b45f438d38b194261a67" + }, + { + "path": "tests/test_intelligence.py", + "bytes": 2297, + "sha256": "6801aa246e2f589f104ceb0908b4cd8ac6d9ba0fdde41cb2581be6bf9a917a6c" + }, + { + "path": "tests/test_job_manager.py", + "bytes": 13817, + "sha256": "bcf6f823d630e7845339a40dc2d7dbdcdde2c86f9d7843b8245ca65977a6b367" + }, + { + "path": "tests/test_model_inspector.py", + "bytes": 2342, + "sha256": "f0256369fd68a44ac8ced7a6992f60285bb5efc97ddadd1bfba2d1d954e53f17" + }, + { + "path": "tests/test_model_plugins.py", + "bytes": 2972, + "sha256": "67c2196b2fbd199dab5dd418f2b4dc34bfe2d0b8507f5c7cf4f6ec6b37c72de3" + }, + { + "path": "tests/test_models.py", + "bytes": 2244, + "sha256": "730c5b7c167217ec173a535696bbd6534cd72723e064a5b3e1775cdbddd3a4b3" + }, + { + "path": "tests/test_oasis_integration.py", + "bytes": 21853, + "sha256": "188328e8379ebb48121209ec0d5f166c011fdf0a14cfe20e14fa9b0dfe19472f" + }, + { + "path": "tests/test_oasis_player.py", + "bytes": 2899, + "sha256": "7e7fc075251979ee1617c1322a7e1d3fc96cd43d80b7c23648533f9bcd50c274" + }, + { + "path": "tests/test_ollama.py", + "bytes": 2429, + "sha256": "0d58774076e9313d2052b6058fcd18c9e2f6d346e9dae6be5683dcfe4943a276" + }, + { + "path": "tests/test_pixelrow.py", + "bytes": 6512, + "sha256": "955c8ac4902b1ee9ebe22d26f35b91318f207c98d5bc9e2dddd0057dad6b7007" + }, + { + "path": "tests/test_planner.py", + "bytes": 22940, + "sha256": "c1fc42220948f162202a12ff1b0291120ac00c6410c226a56f5c3fd6f3dee235" + }, + { + "path": "tests/test_progressive_training.py", + "bytes": 3351, + "sha256": "97338cd861898f64d4d0146cd3b017e7175d4b63b3a17e92dc78b079ee7958ad" + }, + { + "path": "tests/test_registry_executor.py", + "bytes": 4088, + "sha256": "2536965dd109a148fbcde523735216b4c8370c0ae8fd1a493b7e3d172261ebb9" + }, + { + "path": "tests/test_remote_hardening.py", + "bytes": 6345, + "sha256": "fac439b22f45f9abd19c156ded472136059ebe6d417681512794b16015816e89" + }, + { + "path": "tests/test_remote_phase3a.py", + "bytes": 21331, + "sha256": "58169dd2ed1ad66e9d3642f4c4b86b4b40fd53edfe9c60cf59db8a393ac74c8f" + }, + { + "path": "tests/test_studio.py", + "bytes": 4124, + "sha256": "76f67978eecf0f3278cc9c44329f8874a3f9de63a97d6dcd534ffbe1847e3f19" + }, + { + "path": "tests/test_tool_folders.py", + "bytes": 1172, + "sha256": "abc4d4f1f1a731883bd7992acf976f1effe6d329c61f5000dacb40f0c15d3fdf" + }, + { + "path": "tests/test_training_assistant.py", + "bytes": 7910, + "sha256": "607119a54eec114a9ca9e60677575e6bd18dab1610fc2047cea7c886b2ae9c32" + }, + { + "path": "tests/test_ui_loading.py", + "bytes": 8271, + "sha256": "8f494c6df51807247de2b2e57734f90c6c4cb03cc981804adb88310d8bb51d77" + }, + { + "path": "tests/test_wan_video.py", + "bytes": 18523, + "sha256": "39c66d5a08c0a2ed0da092ce7a66c22920b1d13f8bb059f528b26d17c77ca3ac" + }, + { + "path": "tests/test_web_search.py", + "bytes": 1554, + "sha256": "0e934d46e2ee431f627f55653a3bd35cd5c4b0759ab3996b6d2b33609d87c31d" + }, + { + "path": "tests/test_youtube_video_collector.py", + "bytes": 7242, + "sha256": "769f0869faad599ac12f4a13608741183b979c05946372f426e961fd4a34ade5" + } + ] +} diff --git a/docs/releases/2026-10-01-test-results.json b/docs/releases/2026-10-01-test-results.json new file mode 100644 index 0000000000000000000000000000000000000000..536c00b4709ae5c97e28b235f96972a5e3a2793d --- /dev/null +++ b/docs/releases/2026-10-01-test-results.json @@ -0,0 +1,314 @@ +{ + "environment": { + "os": "Windows", + "python": "3.10", + "qt_platform": "offscreen", + "execution": "one process per module" + }, + "totals": { + "tests": 322, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "modules": [ + { + "module": "test_adam_manager_foundation.py", + "exit_code": 0, + "seconds": 4.56, + "tests": 18, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_agents.py", + "exit_code": 0, + "seconds": 3.0, + "tests": 13, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_assets_commands.py", + "exit_code": 0, + "seconds": 1.77, + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_cnn_reviewer.py", + "exit_code": 0, + "seconds": 8.45, + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_ddpm_adapter.py", + "exit_code": 0, + "seconds": 1.97, + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_eve.py", + "exit_code": 0, + "seconds": 1.61, + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_experiment_tracker.py", + "exit_code": 0, + "seconds": 1.84, + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_external_tools.py", + "exit_code": 0, + "seconds": 2.19, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_generation_workspace.py", + "exit_code": 0, + "seconds": 11.81, + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_generations.py", + "exit_code": 0, + "seconds": 8.67, + "tests": 41, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_image_preferences.py", + "exit_code": 0, + "seconds": 1.59, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_inrflow.py", + "exit_code": 0, + "seconds": 13.8, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_intelligence.py", + "exit_code": 0, + "seconds": 1.64, + "tests": 2, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_job_manager.py", + "exit_code": 0, + "seconds": 3.11, + "tests": 16, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_model_inspector.py", + "exit_code": 0, + "seconds": 5.69, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_model_plugins.py", + "exit_code": 0, + "seconds": 1.5, + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_models.py", + "exit_code": 0, + "seconds": 1.5, + "tests": 2, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_oasis_integration.py", + "exit_code": 0, + "seconds": 13.02, + "tests": 19, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_oasis_player.py", + "exit_code": 0, + "seconds": 1.49, + "tests": 6, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_ollama.py", + "exit_code": 0, + "seconds": 1.59, + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_pixelrow.py", + "exit_code": 0, + "seconds": 12.36, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_planner.py", + "exit_code": 0, + "seconds": 4.66, + "tests": 31, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_progressive_training.py", + "exit_code": 0, + "seconds": 1.45, + "tests": 4, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_registry_executor.py", + "exit_code": 0, + "seconds": 1.5, + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_remote_hardening.py", + "exit_code": 0, + "seconds": 7.45, + "tests": 17, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_remote_phase3a.py", + "exit_code": 0, + "seconds": 3.86, + "tests": 18, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_studio.py", + "exit_code": 0, + "seconds": 1.42, + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_tool_folders.py", + "exit_code": 0, + "seconds": 1.39, + "tests": 2, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_training_assistant.py", + "exit_code": 0, + "seconds": 1.42, + "tests": 17, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_ui_loading.py", + "exit_code": 0, + "seconds": 2.27, + "tests": 9, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_wan_video.py", + "exit_code": 0, + "seconds": 3.351, + "tests": 32, + "failures": 0, + "errors": 0, + "skipped": 0, + "note": "Passed after waiting for asynchronous model discovery." + }, + { + "module": "test_web_search.py", + "exit_code": 0, + "seconds": 1.34, + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "module": "test_youtube_video_collector.py", + "exit_code": 0, + "seconds": 2.06, + "tests": 6, + "failures": 0, + "errors": 0, + "skipped": 0 + } + ] +} diff --git a/docs/releases/2026-10-01-validation.md b/docs/releases/2026-10-01-validation.md new file mode 100644 index 0000000000000000000000000000000000000000..af6683bd83b0ea85df613f73643098385a539dae --- /dev/null +++ b/docs/releases/2026-10-01-validation.md @@ -0,0 +1,24 @@ +# Release validation — 1 October 2026 + +- **322 test cases passed across all 33 test modules**, with no failures, errors + or skipped cases in the final module results. +- Tests were run against the staged release in separate processes on Windows + with Python 3.10, using offscreen Qt and two CPU math threads. Running all Qt + tests in one process encountered an application-lifetime crash; the supplied + `python scripts/run_tests.py` runner isolates each module. +- Two sampler assertions were updated to retain the now-supported + `DPM++ 2M SDE Karras` choice. The Wan desktop smoke check now waits for + asynchronous asset discovery before checking the model list. +- The clean release desktop and model-creation assistant opened successfully; + fresh screenshots were captured without personal connections or history. +- The public HTML guide was checked in a browser: all 18 component selections, + four distinct mechanism illustrations, canvas rendering and pause control + passed. +- All staged Python files parsed successfully; local documentation and image + links were checked. The release file list includes only source, public + resources, documentation, tests and clean configuration. + +The tests include small synthetic model checks. No user dataset training, +external trainer installation, CUDA benchmark, or packaged Windows executable +was validated for this source release. Browser collection and Ollama availability +still depend on the user's own setup. diff --git a/docs/releases/2026-10-01.md b/docs/releases/2026-10-01.md new file mode 100644 index 0000000000000000000000000000000000000000..c4bab5eeb1f48f682a7bae7bed54e8fe2e5bcc7f --- /dev/null +++ b/docs/releases/2026-10-01.md @@ -0,0 +1,41 @@ +# ADAM source release — 1 October 2026 + +This release updates the public September 5 snapshot with the current desktop +source, registered tools, model plugins, integration documentation and tests. + +## Added since the previous public snapshot + +- **PixelRow:** built-in experimental causal row generation with checkpoints, + continuation, reproducible previews and optional row-build frame sequences. +- **INRFlow:** built-in experimental coordinate-to-RGB ambient-space flow, + resumable training, EMA weights, Euler/Heun sampling and live ODE previews. +- **Wan Video LoRA:** a dedicated captioned-clip workflow, reviewed character + suggestions and Ollama action captions, staged caching/training, compatible + checkpoint continuation and recorded MP4 generations. +- **Oasis player:** playable inference and updated temporal latent / temporal + pixel-flow integration for action-labelled gameplay recordings. +- **Model Intelligence:** an evidence-led bridge between model, experiment and + generation history, with suggested follow-up experiments. +- **Training and generation improvements:** intent-level AUTO policies, + progressive training, aspect-ratio-aware settings, model-folder browsing, + background reads and asset refresh helpers. +- **Public visual guide:** an image map for the README, a standalone HTML + companion, and a static Hugging Face Space with clickable explanations and + four conceptual animations. +- **Fresh release configuration:** empty external tools and tool folders, + blank Remote credentials and disabled Remote access. Direct image-collection + and model-inspection dependencies are explicitly listed in requirements. + +## Release scope + +This is a source release, not a prebuilt Windows executable. External trainer +repositories, environments, base weights and datasets are not bundled. Generic +dataset preparation, caption-generation and preview placeholders remain demos; +the dedicated real adapters and experimental model implementations are separate. +No real training or GPU benchmark is claimed as part of release validation. + +The final validation passed 322 cases across 33 isolated test modules. +Sampler-import and asynchronous-asset test expectations were refreshed, and +`python scripts/run_tests.py` runs the complete suite with module isolation. + +Validation results are recorded in `docs/releases/2026-10-01-validation.md`. diff --git a/docs/screenshots/command-center.png b/docs/screenshots/command-center.png index 695999b92fa99c5de2cefc23ef298daaf9423654..9b0ef78151b396ee0c509b8cb8894732c95bf98b 100644 --- a/docs/screenshots/command-center.png +++ b/docs/screenshots/command-center.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3e92b6b7189532e72b99ec16702721ed239177530836ecd5ce9b3f1389ffc21 -size 151984 +oid sha256:cd50fe5c0726b73dd7471522c6ce3ed405428a8a203ab75cf989173fbe309c41 +size 141942 diff --git a/docs/screenshots/create-model.png b/docs/screenshots/create-model.png index 575858555fddf82ccd37cd6f17e88534e306770c..b84990cc36f4b18bf280e4ef62ea043217cb1b13 100644 Binary files a/docs/screenshots/create-model.png and b/docs/screenshots/create-model.png differ diff --git a/main.py b/main.py index 4eb652734e9a29d7f6adf1b4e2bc0ff1170e9081..f1435f8b0f49faacdfcc871e2a59a4f36c2f22a2 100644 --- a/main.py +++ b/main.py @@ -63,6 +63,9 @@ def parse_args() -> argparse.Namespace: def main() -> int: + # LoRA generation uses spawned CUDA workers on Windows. This is a no-op + # for the source launcher and lets the packaged desktop app spawn them too. + multiprocessing.freeze_support() args = parse_args() project_root, resource_root = application_paths() prepare_portable_config(project_root, resource_root) diff --git a/requirements.txt b/requirements.txt index c8425a2ed0a0f4821c1efc212ddfc67b10d9e105..44c8f8a429088604f3840a209d8b79ba358f41d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,8 @@ qrcode[pil]>=7.4 torch>=2.2 torchvision>=0.17 transformers>=4.45 +diffusers>=0.29 +numpy>=1.24 +requests>=2.31 +selenium>=4.15 +safetensors>=0.4 diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 0000000000000000000000000000000000000000..3c59a56f3e2c61b09fd6a4603dc39b33f8753c60 --- /dev/null +++ b/scripts/run_tests.py @@ -0,0 +1,38 @@ +"""Run ADAM's test modules in isolated processes for Qt application lifetimes.""" +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + modules = sorted((root / "tests").glob("test_*.py")) + if not modules: + print("No test modules were found.") + return 1 + environment = os.environ.copy() + environment.setdefault("QT_QPA_PLATFORM", "offscreen") + environment.setdefault("OMP_NUM_THREADS", "2") + environment.setdefault("MKL_NUM_THREADS", "2") + failed: list[str] = [] + for module in modules: + print(f"\nRunning {module.name}", flush=True) + result = subprocess.run( + [sys.executable, "-m", "pytest", str(module), "-q", *sys.argv[1:]], + cwd=root, + env=environment, + check=False, + ) + if result.returncode: + failed.append(module.name) + print(f"\n{len(modules) - len(failed)}/{len(modules)} test modules passed.") + if failed: + print("Failed modules: " + ", ".join(failed)) + return int(bool(failed)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_assets_commands.py b/tests/test_assets_commands.py index 9d104698640a4a9efb17147557fb38132dd008e0..425ba98f66bf0501c4368cb8d6d1e67f152be8fc 100644 --- a/tests/test_assets_commands.py +++ b/tests/test_assets_commands.py @@ -29,6 +29,54 @@ def test_asset_registry_prefers_exact_friendly_name(tmp_path: Path) -> None: assert [item.name for item in registry.find("dataset", "Mario")] == ["Mario"] +def test_asset_discovery_removes_models_whose_paths_were_deleted(tmp_path: Path) -> None: + model = tmp_path / "LoRA output" / "Crystal_Biter" + model.mkdir(parents=True) + registry = AssetRegistry(tmp_path) + registry.register(kind="model", name="Crystal_Biter", path=str(model), trainer="lora") + + model.rmdir() + registry.discover(FakeConfig({"tool_folders": {}})) + + assert registry.find("model", "Crystal_Biter", trainer="lora") == [] + + +def test_lora_discovery_registers_weights_in_nested_trainer_output(tmp_path: Path) -> None: + trainer = tmp_path / "LoRATrainer" + weight = trainer / "output" / "Named run" / "adapter" / "My_LoRA.safetensors" + weight.parent.mkdir(parents=True) + weight.write_bytes(b"weights") + (weight.parent.parent / "model_info.json").write_text( + '{"trigger_word": "my_lora"}', encoding="utf-8" + ) + + registry = AssetRegistry(tmp_path) + registry.discover(FakeConfig({"tool_folders": {"lora_trainer": str(trainer)}})) + + model = registry.find("model", "My LoRA", trainer="lora")[0] + assert Path(model.path) == weight.resolve() + assert model.metadata == {"trigger_word": "my_lora"} + + +def test_lora_discovery_excludes_intermediate_epoch_checkpoints(tmp_path: Path) -> None: + trainer = tmp_path / "LoRATrainer" + output = trainer / "output" / "Named run" + output.mkdir(parents=True) + (output / "My_LoRA.safetensors").write_bytes(b"final") + (output / "My_LoRA_epoch_0050.safetensors").write_bytes(b"checkpoint") + (output / "checkpoint-e50_s100.safetensors").write_bytes(b"checkpoint") + + registry = AssetRegistry(tmp_path) + # Simulate an index written by an older ADAM release. + registry.register( + kind="model", name="My_LoRA_epoch_0050", + path=str(output / "My_LoRA_epoch_0050.safetensors"), trainer="lora", + ) + registry.discover(FakeConfig({"tool_folders": {"lora_trainer": str(trainer)}})) + + assert [item.name for item in registry.assets if item.trainer == "lora"] == ["My_LoRA"] + + def test_training_command_rejects_uncontrolled_fields() -> None: with pytest.raises(CommandValidationError, match="Unsupported command fields"): TrainingCommand.from_dict( diff --git a/tests/test_cnn_reviewer.py b/tests/test_cnn_reviewer.py new file mode 100644 index 0000000000000000000000000000000000000000..d2546b0a9e80954f8da2874f5ce99a8ba1735023 --- /dev/null +++ b/tests/test_cnn_reviewer.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from adam.cnn_reviewer import _labeled_paths, reviewer_checkpoint, score_frames, train_reviewer + + +def test_reviewer_checkpoint_is_adam_owned_and_dataset_specific(tmp_path: Path) -> None: + first = reviewer_checkpoint(tmp_path, tmp_path / "dataset one") + second = reviewer_checkpoint(tmp_path, tmp_path / "dataset two") + + assert first.parent == tmp_path / "data" / "cnn_reviewers" + assert first != second + assert first.suffix == ".pt" + + +def test_reviewer_uses_only_readable_image_keep_reject_labels(tmp_path: Path) -> None: + kept = tmp_path / "keep.png"; kept.write_bytes(b"not decoded here") + rejected = tmp_path / "reject.jpg"; rejected.write_bytes(b"not decoded here") + missing = tmp_path / "missing.png" + + paths, labels = _labeled_paths({str(kept): "keep", str(rejected): "reject", str(missing): "keep", "x": "unreviewed"}) + + assert paths == [kept, rejected] + assert labels == [1.0, 0.0] + + +def test_reviewer_trains_and_scores_without_touching_decisions(tmp_path: Path) -> None: + pytest.importorskip("torch") + from PIL import Image + + decisions = {} + for index in range(8): + path = tmp_path / f"keep_{index}.png" + Image.new("RGB", (32, 32), (20, 220, 30)).save(path) + decisions[str(path)] = "keep" + for index in range(8): + path = tmp_path / f"reject_{index}.png" + Image.new("RGB", (32, 32), (220, 20, 30)).save(path) + decisions[str(path)] = "reject" + + result = train_reviewer(tmp_path, tmp_path, decisions, epochs=1) + scores = score_frames(result.checkpoint, tmp_path) + + assert Path(result.checkpoint).is_file() + assert len(scores) == 16 + assert decisions[str(tmp_path / "keep_0.png")] == "keep" diff --git a/tests/test_ddpm_adapter.py b/tests/test_ddpm_adapter.py index 917660ea34903e6ddd99eb13db62b8c1c97af734..0a77c075b5628ca51f18869a38ab6f166e912f86 100644 --- a/tests/test_ddpm_adapter.py +++ b/tests/test_ddpm_adapter.py @@ -66,7 +66,7 @@ def test_resolution_change_branches_from_pipeline_instead_of_resuming_checkpoint dataset_dir=str(dataset), model_name="Anime", epochs=5, - output_dir=str(model), + output_dir=str(trainer_root / "output" / "Anime V2"), resume_from=str(checkpoint), resolution=256, ) @@ -76,4 +76,88 @@ def test_resolution_change_branches_from_pipeline_instead_of_resuming_checkpoint assert str(model.resolve()) in command assert "--resume_from_checkpoint" not in command assert result["output_folder"] != str(model.resolve()) - assert any("Changing DDPM resolution from 64px to 256px" in line for line in logs) + assert any("Changing DDPM canvas from 64x64 to 256x256" in line for line in logs) + + +def test_widescreen_training_uses_native_256_by_144_canvas(tmp_path: Path, monkeypatch) -> None: + trainer_root = tmp_path / "DDPM" + output = trainer_root / "output" / "Wide" + dataset = tmp_path / "dataset" + dataset.mkdir(parents=True) + trainer_root.mkdir(parents=True) + (trainer_root / "train.py").write_text("# fake trainer", encoding="utf-8") + for index in range(2): + (dataset / f"{index}.png").write_bytes(b"image") + + ConfigManager(tmp_path).update({"tool_folders": {"ddpm_trainer": str(trainer_root)}}) + monkeypatch.setattr(ddpm_adapter.importlib.util, "find_spec", lambda _name: object()) + commands: list[list[str]] = [] + + class FakeProcess: + stdout = io.StringIO("PROGRESS_JSON:{\"event\":\"done\"}\n") + returncode = 0 + + def poll(self): + return 0 + + monkeypatch.setattr( + ddpm_adapter.subprocess, + "Popen", + lambda command, **_kwargs: commands.append(command) or FakeProcess(), + ) + + ddpm_adapter.train_ddpm( + _context(tmp_path, []), + dataset_dir=str(dataset), + model_name="Wide", + epochs=1, + output_dir=str(output), + resolution=256, + training_aspect_ratio="16:9 (Widescreen)", + resize_mode="fit", + ) + + command = commands[0] + assert command[command.index("--resolution_width") + 1] == "256" + assert command[command.index("--resolution_height") + 1] == "144" + assert command[command.index("--resize_mode") + 1] == "fit" + + +def test_video_dataset_uses_nested_accepted_frames_only(tmp_path: Path, monkeypatch) -> None: + trainer_root = tmp_path / "DDPM" + output = trainer_root / "output" / "Video" + dataset = tmp_path / "Video Dataset" + accepted = dataset / "frames" / "video_0001" + rejected = dataset / "rejected" / "video_0001" + accepted.mkdir(parents=True) + rejected.mkdir(parents=True) + trainer_root.mkdir(parents=True) + (trainer_root / "train.py").write_text("# fake trainer", encoding="utf-8") + for index in range(2): + (accepted / f"accepted_{index}.png").write_bytes(b"image") + (rejected / "rejected_0.png").write_bytes(b"image") + + ConfigManager(tmp_path).update({"tool_folders": {"ddpm_trainer": str(trainer_root)}}) + monkeypatch.setattr(ddpm_adapter.importlib.util, "find_spec", lambda _name: object()) + commands: list[list[str]] = [] + + class FakeProcess: + stdout = io.StringIO("PROGRESS_JSON:{\"event\":\"done\"}\n") + returncode = 0 + + def poll(self): + return 0 + + monkeypatch.setattr( + ddpm_adapter.subprocess, + "Popen", + lambda command, **_kwargs: commands.append(command) or FakeProcess(), + ) + + ddpm_adapter.train_ddpm( + _context(tmp_path, []), dataset_dir=str(dataset), model_name="Video", + epochs=1, output_dir=str(output), + ) + + command = commands[0] + assert command[command.index("--train_data_dir") + 1] == str((dataset / "frames").resolve()) diff --git a/tests/test_generation_workspace.py b/tests/test_generation_workspace.py new file mode 100644 index 0000000000000000000000000000000000000000..cbc4465293a6c5c6186c5a55de0c3d5c99f2c588 --- /dev/null +++ b/tests/test_generation_workspace.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import json +import os +import shutil +import time +from pathlib import Path +from types import SimpleNamespace + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QObject, QPoint, QPointF, QThreadPool, Qt, Signal +from PySide6.QtGui import QColor, QFontDatabase, QImage, QLinearGradient, QPainter, QPolygonF +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest + +from adam.assets import AssetRegistry +from adam.config import ConfigManager +from adam.generations import GenerationRecord +from adam.registry import ToolRegistry +from adam.ui.asset_refresh import AssetRefresh +from adam.ui.generations import GenerationsPage +from adam.ui.theme import APP_STYLESHEET + + +def pump(app, condition=lambda: True): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + app.processEvents() + if condition(): + app.processEvents() + return + time.sleep(0.005) + assert condition() + + +@pytest.fixture(scope="module") +def app(): + instance = QApplication.instance() or QApplication([]) + # Windows' offscreen Qt backend does not discover system fonts itself. + for name in ("segoeui.ttf", "segoeuib.ttf", "seguisb.ttf", "seguisym.ttf"): + font = Path("C:/Windows/Fonts") / name + if font.exists(): + QFontDatabase.addApplicationFont(str(font)) + instance.setStyleSheet(APP_STYLESHEET) + return instance + + +def landscape(path: Path, hue: int): + """Synthetic review artwork; never reads the user's generation history.""" + image = QImage(640, 800, QImage.Format_RGB32) + painter = QPainter(image) + gradient = QLinearGradient(0, 0, 640, 800) + gradient.setColorAt(0, QColor.fromHsv(hue, 170, 130)) + gradient.setColorAt(1, QColor.fromHsv((hue + 70) % 360, 130, 250)) + painter.fillRect(image.rect(), gradient) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor("#ffe3a1")) + painter.drawEllipse(390, 140, 120, 120) + for offset, color in ((0, "#2e5274"), (130, "#153248"), (260, "#091b2d")): + painter.setBrush(QColor(color)) + painter.drawPolygon(QPolygonF([QPointF(0, 520 + offset), QPointF(180, 290 + offset), QPointF(380, 580 + offset), QPointF(520, 410 + offset), QPointF(640, 570 + offset), QPointF(640, 800), QPointF(0, 800)])) + painter.end() + image.save(str(path)) + + +@pytest.fixture +def workspace(app, tmp_path, monkeypatch): + class Jobs(QObject): + job_updated = Signal(object) + + def __init__(self): + super().__init__() + self.plans = [] + + def submit(self, plan): + self.plans.append(plan) + return SimpleNamespace(id="layout-review") + + config = ConfigManager(tmp_path) + shutil.copy2(Path(__file__).resolve().parents[1] / "config/tools.json", tmp_path / "config/tools.json") + assets = AssetRegistry(tmp_path) + base = tmp_path / "LandscapeXL.safetensors" + base.touch() + assets.register(kind="base_model", name="LandscapeXL", path=str(base)) + records = [] + for index, (trainer, provider, name) in enumerate((("lora", "lora_generator", "Mountain Light"), ("ddpm", "ddpm_generator", "Forest Study"), ("flow", "flow_generator", "Evening Coast"))): + model = tmp_path / ("a_very_long_model_directory_name_" * 3) / (name + (".safetensors" if trainer == "lora" else "")) + model.parent.mkdir(parents=True, exist_ok=True) + if trainer == "lora": + model.touch() + else: + model.mkdir() + if trainer == "ddpm": + (model / "model_index.json").write_text("{}") + else: + (model / "flow_model_info.json").write_text("{}") + (model / "unet").mkdir() + (model / "unet/config.json").write_text("{}") + assets.register(kind="model", name=name, trainer=trainer, path=str(model)) + paths = [] + for number in range(6): + image = tmp_path / f"image-{index}-{number}.png" + landscape(image, 200 + index * 25 + number * 4) + paths.append(str(image)) + metadata = tmp_path / f"generation-{index}.json" + payload = dict(provider_id=provider, provider_name={"lora": "LoRA Generator", "ddpm": "DDPM Generator", "flow": "Flow Matching Generator"}[trainer], + model_name=name, model_path=str(model), images=paths, prompt="A quiet mountain valley at dusk, soft light and distant peaks", + negative_prompt="blur, watermark", base_model_path=str(base), seed=3210 + index * 10, steps=27, + sampler="DPM++ 2M" if trainer == "lora" else "DDIM" if trainer == "ddpm" else "Euler", + aspect_ratio="3:4 (Portrait)" if trainer == "lora" else "1:1 (Square)", width=864, height=1152, + cfg_scale=5.0, lora_strength=0.8, denoise_strength=0.5, created_at=f"2026-09-{26-index}T14:30:00+00:00") + metadata.write_text(json.dumps(payload), encoding="utf-8") + records.append(GenerationRecord.from_metadata(metadata)) + monkeypatch.setattr(AssetRefresh, "request", lambda *args, **kwargs: None) + monkeypatch.setattr("adam.ui.generations.load_generation_history", lambda root: records) + jobs = Jobs() + page = GenerationsPage(tmp_path, ToolRegistry(tmp_path), jobs, assets, config) + page.resize(1500, 900) + page.show() + pump(app, lambda: not page._history_reader._running and page.gallery.count() == 6) + page.provider.setCurrentIndex(page.provider.findData("lora_generator")) + page.base_model.setCurrentIndex(page.base_model.findData(str(base))) + page.prompt.setPlainText(records[0].prompt) + page.negative_prompt.setPlainText("blur, watermark") + page.aspect.setCurrentText("3:4 (Portrait)") + page.steps.setValue(27) + page.cfg_scale.setValue(5.0) + pump(app) + yield page, jobs, records + page.close() + QThreadPool.globalInstance().waitForDone(3000) + page.deleteLater() + app.processEvents() + + +@pytest.mark.parametrize("width,height", [(1500, 900), (1200, 800)]) +def test_layout_keeps_controls_inside_pane_and_preview_does_not_expand_it(app, workspace, width, height): + page, _, _ = workspace + page.resize(width, height) + pump(app) + assert page.size().width() == width + assert page.size().height() == height + create = page.workspace_splitter.widget(0) + assert create.width() < width * 0.48 + assert page.workspace_splitter.widget(1).width() > create.width() + assert create.horizontalScrollBar().maximum() == 0 + for control in (page.provider, page.model, page.base_model, page.width, page.height, page.images, page.preset, page.seed, page.generate_button): + assert control.isVisible() + right = control.mapTo(create.viewport(), QPoint(control.width(), 0)).x() + assert right <= create.viewport().width(), control.objectName() + assert page.advanced_toggle.isChecked() + assert page.steps.isVisible() and page.cfg_scale.isVisible() + if os.environ.get("ADAM_RENDER_GENERATION_REVIEW"): + output = Path(__file__).resolve().parents[1] / "artifacts/generation-workspace-review" + output.mkdir(parents=True, exist_ok=True) + page.grab().save(str(output / f"generations-{width}x{height}.png")) + + +def test_dimensions_and_hidden_advanced_values_reach_generation_plan(app, workspace): + page, jobs, _ = workspace + page.width.setValue(888) + page.height.setValue(1168) + page.steps.setValue(39) + page.cfg_scale.setValue(6.5) + page.lora_strength.setValue(0.75) + page.advanced_toggle.setChecked(False) + page._generate() + arguments = jobs.plans[-1].steps[0].arguments + assert arguments["width"] == 888 and arguments["height"] == 1168 + assert arguments["steps"] == 39 and arguments["cfg_scale"] == 6.5 + assert arguments["lora_strength"] == 0.75 + saved = dict(page.config.get("generation_settings")) + page.width.setValue(1024) + page.config.update({"generation_settings": saved}) + page._restore_generation_settings() + assert page.width.value() == 888 and page.height.value() == 1168 + + +def test_history_tabs_navigation_favorites_and_comparison(app, workspace): + page, _, records = workspace + assert page.model_folders.count() == 3 + assert page._selection()[0] == records[0] + QTest.mouseClick(page.model_folders.viewport(), Qt.LeftButton, + pos=page.model_folders.visualItemRect(page.model_folders.item(0)).center()) + pump(app) + assert page.model_folders.count() == 1 + page.model_folders.setCurrentRow(0) + QTest.keyClick(page.model_folders, Qt.Key_Return) + pump(app, lambda: page.gallery.count() == 6) + assert not page.model_folders.isVisible() + page.history_tabs.setCurrentIndex(2) + pump(app, lambda: page.gallery.count() == 18) + page.gallery.setCurrentRow(7) + selected_path = page._selection()[1] + page._rate_selection("favorite") + pump(app, lambda: not page._history_reader._running) + page.history_tabs.setCurrentIndex(3) + pump(app, lambda: page.gallery.count() == 1) + assert page._selection()[1] == selected_path + page.history_tabs.setCurrentIndex(4) + pump(app, lambda: page.gallery.count() == 18) + page.gallery.setCurrentRow(0) + page._pin_comparison() + pinned = page.comparison_caption.text() + page.gallery.setCurrentRow(1) + assert page.comparison_panel.isVisible() + assert page.comparison_caption.text() == pinned + page._clear_displayed_history() + pump(app) + assert page.gallery.count() == 0 + + +@pytest.mark.parametrize("provider", ["ddpm_generator", "flow_generator"]) +def test_provider_specific_dimensions_and_smart_settings_survive_collapsing(app, workspace, provider): + page, jobs, _ = workspace + page.provider.setCurrentIndex(page.provider.findData(provider)) + ddpm = provider == "ddpm_generator" + custom = page.ddpm_custom_size if ddpm else page.flow_custom_size + width = page.ddpm_width if ddpm else page.flow_width + height = page.ddpm_height if ddpm else page.flow_height + custom.setChecked(True) + width.setValue(384) + height.setValue(512) + page.smart_enabled.setChecked(True) + page.advanced_toggle.setChecked(False) + page._generate() + arguments = jobs.plans[-1].steps[0].arguments + assert arguments["width"] == 384 and arguments["height"] == 512 + if "smart_generation" in page._current_tool().capabilities: + assert arguments["smart_generation"] is True + assert not page.lora_dimensions.isVisible() + + +def test_reusing_lora_image_restores_dimensions_and_strengths(workspace): + page, _, _ = workspace + page.width.setValue(1024) + page.height.setValue(1024) + page.cfg_scale.setValue(10.0) + page.lora_strength.setValue(1.5) + page.negative_prompt.clear() + page._reuse_settings() + assert (page.width.value(), page.height.value()) == (864, 1152) + assert page.cfg_scale.value() == 5.0 + assert page.lora_strength.value() == 0.8 + assert page.negative_prompt.toPlainText() == "blur, watermark" diff --git a/tests/test_generations.py b/tests/test_generations.py index a9ca379d6d794fcae56ea82ae0b87b948d73fac0..ac8c5c52e42dc55e3a72b12da588d1f87d1f7f50 100644 --- a/tests/test_generations.py +++ b/tests/test_generations.py @@ -12,10 +12,15 @@ from adam.generations import ( generation_output_folder, generation_model_match_score, generation_model_key, + generation_provider_key, generation_tools, group_generation_records, + group_generation_providers, load_generation_history, parse_chat_generation_request, + parse_lora_generation_metadata, + parse_pasted_lora_metadata_request, + parse_plain_generation_metadata, combine_generation_plans, ) from adam.registry import ToolRegistry @@ -103,6 +108,16 @@ def test_command_center_does_not_capture_non_image_plans() -> None: assert parse_chat_generation_request("Generate four training previews") is None +def test_command_center_does_not_capture_video_dataset_collection() -> None: + request = ( + "Collect a video dataset from https://www.youtube.com/@zackdfilms/shorts. " + "maximum 30 videos, without audio, image mode, 2 frames per second, " + "generate captions, and store everything in the Video_Dataset folder." + ) + + assert parse_chat_generation_request(request) is None + + def test_command_center_subject_matches_completed_model_name() -> None: assert generation_model_match_score("rouge the bat", "Rouge The Bat V2 MADA") > 0 assert generation_model_match_score("rouge the bat", "SpectrogramV3") == 0 @@ -143,6 +158,90 @@ def test_command_center_parses_advanced_generation_overrides() -> None: assert parsed.reference_strength == 70 +def test_pasted_lora_metadata_accepts_html_escaped_json_and_preserves_supported_karras_sampler() -> None: + metadata = parse_lora_generation_metadata( + '{ "prompt":"Adam, standing next to Hatsune Miku",' + '"negative_prompt":"bad quality","seed":2076436200,"steps":25,' + '"cfg_scale":5.0,"model":"D:\\\\Models\\\\nova.safetensors",' + '"loras":[{"path":"output\\\\Adam.safetensors","strength":1.0}],' + '"sampler":"DPM++ 2M SDE Karras"}' + ) + + assert metadata.prompt == "Adam, standing next to Hatsune Miku" + assert metadata.lora_path == "output\\Adam.safetensors" + assert metadata.base_model_path.endswith("nova.safetensors") + assert metadata.sampler == "DPM++ 2M SDE Karras" + assert metadata.sampler_note == "" + + +def test_command_center_treats_pasted_lora_metadata_as_generation_request() -> None: + parsed = parse_pasted_lora_metadata_request( + '"prompt": Adam, standing next to Hatsune Miku\n' + '"negative_prompt": bad quality\n' + '"seed": 2076436200\n"steps": 25\n"cfg_scale": 5.0\n' + '"model": D:\\Models\\novaFurryXL_illustriousV40.safetensors\n' + '"loras": [\n"path": output\\Adam_20260817\\Adam.safetensors\n"strength": 1.0\n]\n' + '"sampler": DPM++ 2M SDE Karras' + ) + + assert parsed is not None + assert parsed.is_pasted_metadata is True + assert parsed.provider_hint == "lora" + assert parsed.model_query == "Adam" + assert parsed.base_model_query == "novaFurryXL_illustriousV40" + assert parsed.sampler == "DPM++ 2M SDE Karras" + assert parsed.prompt == "Adam, standing next to Hatsune Miku" + + +def test_pasted_lora_metadata_treats_negative_one_seed_as_random() -> None: + metadata = parse_lora_generation_metadata( + '{"prompt":"test","model":"base.safetensors","loras":[{"path":"lora.safetensors"}],"seed":-1}' + ) + + assert metadata.seed == 0 + + +def test_pasted_metadata_allows_an_empty_lora_list_for_base_only_generation() -> None: + metadata = parse_lora_generation_metadata( + '{"prompt":"Ivan seals paintings","model":"base.safetensors","loras":[],"seed":42}' + ) + + assert metadata.lora_path == "" + assert metadata.lora_strength is None + + +def test_pasted_lora_metadata_accepts_case_insensitive_dimensions() -> None: + parsed = parse_pasted_lora_metadata_request( + '{"prompt":"test","model":"base.safetensors","loras":[{"path":"lora.safetensors"}],' + '"Width":1024,"Height":1024}' + ) + + assert parsed is not None + assert parsed.width == 1024 + assert parsed.height == 1024 + + +def test_plain_civitai_metadata_preserves_generation_settings() -> None: + parsed = parse_plain_generation_metadata( + 'masterpiece, temmie\n' + 'Negative prompt: worst quality, watermark Steps: 27, CFG scale: 5, Sampler: DPM++ 2M SDE, Seed: 1043750535, Size: 824x1152' + ) + assert parsed is not None + assert parsed.model_query == 'temmie_undertale_r1' + assert parsed.steps == 27 and parsed.cfg_scale == 5 + assert parsed.width == 824 and parsed.height == 1152 + + +def test_plain_pixai_metadata_uses_original_prompt_and_settings() -> None: + parsed = parse_plain_generation_metadata( + 'visible prompt\n\nOriginal Prompt\nactual prompt\n\nSize\n768 x 1280\n\nSampling Steps\n28\n\nSampling Method\nEuler a\n\nCFG Scale\n5\n\nNegative\nbad quality\n\nPrompt Helper' + ) + assert parsed is not None + assert parsed.prompt == 'actual prompt' + assert parsed.sampler == 'Euler a' + assert parsed.negative_prompt == 'bad quality' + + def test_command_center_separates_lora_from_base_model_in_natural_phrasing() -> None: parsed = parse_chat_generation_request( 'Generate an image of LoRA OrangeCat, 30 steps, Base Model "waiIllustriousSDXL"' @@ -218,6 +317,8 @@ def test_registry_declares_ddpm_image_generator(tmp_path: Path) -> None: "ddpm_generator", "flow_generator", "lora_generator", + "inrflow_generator", + "pixelrow_generator", ] @@ -435,6 +536,30 @@ def test_generation_history_groups_model_folders_with_latest_image_cover(tmp_pat assert old_mario[0] in folders[0].records[1].images +def test_generation_history_groups_generator_folders_without_moving_images(tmp_path: Path) -> None: + ddpm = generation_output_folder(tmp_path, "ddpm_generator", "Mario") + flow = generation_output_folder(tmp_path, "flow_generator", "Luigi") + + def add_batch(folder: Path, provider_id: str, provider_name: str, stamp: str) -> None: + image = folder / f"{stamp}.png" + image.write_bytes(b"image") + (folder / f"generation_{stamp}.json").write_text(json.dumps({ + "provider_id": provider_id, "provider_name": provider_name, + "model_name": folder.name, "images": [str(image)], "created_at": stamp, + }), encoding="utf-8") + + add_batch(ddpm, "ddpm_generator", "DDPM Generator", "2026-08-02") + add_batch(ddpm, "ddpm_generator", "DDPM Generator", "2026-08-01") + add_batch(flow, "flow_generator", "Flow Generator", "2026-08-03") + + folders = group_generation_providers(load_generation_history(tmp_path)) + + assert [folder.provider_id for folder in folders] == ["flow_generator", "ddpm_generator"] + assert folders[1].model_count == 1 + assert folders[1].image_count == 2 + assert generation_provider_key(folders[1].records[0]) == "ddpm_generator" + + def test_ddpm_adapter_writes_images_and_reproducibility_metadata( tmp_path: Path, monkeypatch ) -> None: @@ -750,6 +875,8 @@ def test_flow_adapter_uses_registered_model_and_tracks_each_seed( seed=700, sampler="Heun", aspect_ratio="4:3 (Landscape)", + width=320, + height=192, ) output = Path(str(result["output_folder"])) @@ -759,6 +886,9 @@ def test_flow_adapter_uses_registered_model_and_tracks_each_seed( assert [call["seed"] for call in calls] == [700, 701] assert all(call["method"] == "Heun" for call in calls) assert all(call["aspect_ratio"] == "4:3 (Landscape)" for call in calls) + assert all(call["width"] == 320 and call["height"] == 192 for call in calls) + assert metadata["width"] == 320 + assert metadata["height"] == 192 def test_flow_smart_generation_can_return_top_ranked_pool(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_inrflow.py b/tests/test_inrflow.py new file mode 100644 index 0000000000000000000000000000000000000000..c91bb4e3718acf0c9dc18ae3c43032448f06aa18 --- /dev/null +++ b/tests/test_inrflow.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import torch +from PIL import Image + +from adam.generations import parse_chat_generation_request +from adam.model_plugins import ModelPluginRegistry +from adam.model_plugins_builtin.inrflow.generator import generate +from adam.model_plugins_builtin.inrflow.model import ( + INRFlowConfig, + INRFlowModel, + coordinate_grid, + sample_image, +) +from adam.model_plugins_builtin.inrflow.trainer import train +from adam.registry import ToolRegistry + + +class FakeContext: + def __init__(self, root: Path, tool_id: str, tool_name: str) -> None: + self.root = root + self.job_id = "inrflow-test-job" + self.tool = SimpleNamespace(id=tool_id, name=tool_name) + self.logs: list[str] = [] + self.progress_updates: list[tuple[int, str, dict]] = [] + self.previews: list[dict] = [] + + def log(self, message: str) -> None: + self.logs.append(message) + + def checkpoint(self) -> None: + pass + + def progress(self, percent: int, message: str, **details) -> None: + self.progress_updates.append((percent, message, details)) + + def preview(self, path, **details) -> None: + self.previews.append({"path": str(path), **details}) + + +def test_inrflow_plugin_is_discovered_with_native_backends() -> None: + plugins = ModelPluginRegistry(Path.cwd()) + tools = ToolRegistry(Path.cwd()) + + plugin = plugins.get("inrflow") + trainer = tools.get("inrflow_trainer") + generator = tools.get("inrflow_generator") + + assert plugin.info["architecture"] == "inrflow_ambient_space" + assert plugin.info["method_reference"].endswith("2412.03791") + assert plugin.training_settings["query_points"]["default"] == 1024 + assert trainer.backend["module"].endswith("inrflow.trainer") + assert "resume_training" in trainer.capabilities + assert generator.model_trainers == ("inrflow",) + assert "resolution_flexible_generation" in generator.capabilities + assert len(generator.arguments) == len(set(generator.arguments)) + + +def test_inrflow_generation_request_selects_distinct_provider() -> None: + request = parse_chat_generation_request( + 'Generate an INRFlow image of tiny cars using model "Coordinate Cars" with 20 steps.' + ) + spaced = parse_chat_generation_request("Generate an INR Flow image of landscapes") + + assert request is not None + assert request.provider_hint == "inrflow" + assert request.model_query == "Coordinate Cars" + assert request.subject == "tiny cars" + assert request.steps == 20 + assert spaced is not None and spaced.provider_hint == "inrflow" + + +def test_inrflow_predicts_pointwise_velocity_and_seeded_samples() -> None: + config = INRFlowConfig( + resolution=32, + patch_size=8, + hidden_size=64, + depth=1, + num_heads=4, + decoder_layers=1, + fourier_frequencies=2, + ) + model = INRFlowModel(config) + coordinates = coordinate_grid(32, 32).unsqueeze(0).expand(2, -1, -1) + values = torch.randn(2, 32 * 32, 3) + prediction = model( + coordinates, + values, + torch.tensor([0.25, 0.75]), + coordinates[:, :37], + values[:, :37], + height=32, + width=32, + ) + loss = prediction.square().mean() + loss.backward() + first = sample_image( + model, + resolution=32, + steps=2, + method="Euler", + noise_scale=1.0, + query_chunk_size=256, + generator=torch.Generator().manual_seed(42), + ) + second = sample_image( + model, + resolution=32, + steps=2, + method="Euler", + noise_scale=1.0, + query_chunk_size=256, + generator=torch.Generator().manual_seed(42), + ) + + assert prediction.shape == (2, 37, 3) + assert first.shape == (32, 32, 3) + assert torch.equal(first, second) + + +def test_inrflow_training_and_generation_round_trip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(torch.cuda, "is_available", lambda: False) + dataset = tmp_path / "cars" + dataset.mkdir() + for index, color in enumerate(((220, 40, 30), (20, 80, 220))): + image = Image.new("RGB", (40, 28), (225, 225, 225)) + for x in range(8, 32): + for y in range(10, 22): + image.putpixel((x, y), color) + image.save(dataset / f"car_{index}.png") + + output = tmp_path / "data" / "model_plugin_outputs" / "inrflow" / "Coordinate Cars" + training_context = FakeContext(tmp_path, "inrflow_trainer", "INRFlow Trainer") + result = train( + training_context, + dataset_dir=str(dataset), + model_name="Coordinate Cars", + epochs=1, + output_dir=str(output), + resolution=32, + batch_size=2, + mixed_precision="no", + patch_size=8, + hidden_size=64, + depth=1, + num_heads=4, + decoder_layers=1, + fourier_frequencies=2, + query_points=64, + preview_enabled=False, + ) + + assert (output / "inrflow_model.pt").is_file() + assert (output / "inrflow_config.json").is_file() + assert (output / "training_metadata.json").is_file() + assert result["assets"][0]["trainer"] == "inrflow" + metadata = json.loads((output / "training_metadata.json").read_text(encoding="utf-8")) + assert metadata["uses_pretrained_compressor"] is False + assert metadata["settings"]["query_points"] == 64 + + continued_output = ( + tmp_path / "data" / "model_plugin_outputs" / "inrflow" / "Coordinate Cars Continued" + ) + continuation_context = FakeContext(tmp_path, "inrflow_trainer", "INRFlow Trainer") + train( + continuation_context, + dataset_dir=str(dataset), + model_name="Coordinate Cars Continued", + epochs=1, + output_dir=str(continued_output), + resume_from=str(output), + resolution=64, + batch_size=2, + mixed_precision="no", + query_points=64, + preview_enabled=False, + ) + continued_metadata = json.loads( + (continued_output / "training_metadata.json").read_text(encoding="utf-8") + ) + assert continued_metadata["completed_epochs"] == 2 + assert continued_metadata["settings"]["resolution"] == 32 + + generation_context = FakeContext(tmp_path, "inrflow_generator", "INRFlow Generator") + generated = generate( + generation_context, + model_name="Coordinate Cars", + model_path=str(output), + prompt="ambient-space comparison", + image_count=1, + steps=2, + seed=123, + sampler="Euler", + aspect_ratio="1:1 (Coordinate Field)", + output_resolution="64", + query_chunk_size=256, + preview_interval=1, + ) + + generation_folder = Path(generated["output_folder"]) + final_images = list(generation_folder.glob("*_64px.png")) + generation_metadata = list(generation_folder.glob("generation_*.json")) + assert len(final_images) == 1 + assert len(generation_metadata) == 1 + assert generation_context.previews[-1]["current"] == 2 + assert generation_context.progress_updates[-1][0] == 100 diff --git a/tests/test_intelligence.py b/tests/test_intelligence.py new file mode 100644 index 0000000000000000000000000000000000000000..d6520b7ffcab1c0f1ce52dcf7f8fee7e375a12ac --- /dev/null +++ b/tests/test_intelligence.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone +from pathlib import Path + +from adam.experiment_tracker import ExperimentRun +from adam.generations import GenerationRecord +from adam.intelligence import build_model_intelligence, recommended_training_request + + +def _run(*, model_name: str = "Atlas", loss: float | None = 0.2, epochs: int = 20) -> ExperimentRun: + return ExperimentRun( + id="EXP-1", job_id="1", timestamp=datetime.now(timezone.utc).isoformat(), + model_architecture="ddpm", model_name=model_name, trigger_word="", base_model="", + dataset_path="D:/dataset", dataset_name="dataset", dataset_item_count=24, + epochs=epochs, batch_size=2, learning_rate=0.0001, optimizer="adamw", + scheduler="", resolution=256, seed=7, status="finished", training_time_seconds=30, + final_loss=loss, output_folder="D:/models/atlas", checkpoint_paths=[], preview_images=[], + peak_vram_gb=None, hardware={}, settings={"batch_size": 2}, generation_settings={}, + ) + + +def test_intelligence_requests_visual_review_without_generation_evidence() -> None: + profile = build_model_intelligence([_run()], [])[0] + + assert profile.state == "Needs visual review" + assert profile.recommended_epochs == 20 + + +def test_intelligence_detects_plateau_and_creates_follow_up_request(tmp_path: Path) -> None: + image = tmp_path / "sample.png" + image.write_bytes(b"not decoded by this test") + newest = _run(loss=0.200, epochs=20) + older = _run(loss=0.201, epochs=20) + older.id = "EXP-0" # type: ignore[misc] + record = GenerationRecord( + metadata_path=tmp_path / "generation.json", folder=tmp_path, images=(image,), + provider_id="ddpm_generator", provider_name="DDPM", model_name="Atlas", + model_path="", prompt="test", seed=1, steps=10, sampler="ddim", aspect_ratio="1:1", + created_at=datetime.now(timezone.utc).isoformat(), smart_generation={}, image_evaluations={ + str(image.resolve()): {"score": 0.8}, + }, + ) + + profile = build_model_intelligence([newest, older], [record], root=tmp_path)[0] + + assert profile.state == "Likely plateau" + assert profile.recommended_epochs == 15 + request = recommended_training_request(profile) + assert "15 epochs" in request + assert "Atlas Follow-up" in request diff --git a/tests/test_job_manager.py b/tests/test_job_manager.py index 22d23ca3b250d4aae2ad443f04f7a95461a6ea2b..9cf6b5b5365baba101091b5f797071a0026c625e 100644 --- a/tests/test_job_manager.py +++ b/tests/test_job_manager.py @@ -79,6 +79,51 @@ def test_job_history_is_not_silently_truncated(tmp_path: Path) -> None: assert len(restored.jobs) == 125 +def test_jobs_history_rows_remain_visible_while_loading(tmp_path: Path, monkeypatch) -> None: + """A long history must not look blank while its rows are streamed into the table.""" + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from PySide6.QtTest import QTest + from adam.ui.main_window import JobsPage + + manager = JobManager(tmp_path, None, logging.getLogger("test.jobs")) # type: ignore[arg-type] + manager.jobs = [_job(index) for index in range(125)] + app = QApplication.instance() or QApplication([]) + page = JobsPage(manager) + + assert page.table.updatesEnabled() + QTest.qWait(200) + assert page.table.item(0, 0).text() == "JOB0000" + page.close() + page.deleteLater() + app.processEvents() + + +def test_jobs_page_can_approve_a_waiting_plan(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from adam.ui.main_window import JobsPage + + manager = JobManager(tmp_path, None, logging.getLogger("test.jobs")) # type: ignore[arg-type] + waiting = _job(1, JobStatus.AWAITING_CONFIRMATION) + manager.jobs = [waiting] + approved: list[str] = [] + monkeypatch.setattr(manager, "confirm", approved.append) + app = QApplication.instance() or QApplication([]) + page = JobsPage(manager) + page.show() + page.selected_job_id = waiting.id + page._show_job(waiting) + + assert page.approve_button.isVisible() + assert page.approve_button.isEnabled() + page.approve_button.click() + assert approved == [waiting.id] + page.close() + page.deleteLater() + app.processEvents() + + def test_history_cleanup_only_removes_completed_and_failed(tmp_path: Path) -> None: manager = JobManager(tmp_path, None, logging.getLogger("test.jobs")) # type: ignore[arg-type] manager.jobs = [ @@ -185,7 +230,7 @@ def test_worker_coalesces_rapid_progress_events() -> None: def test_step_eta_uses_measured_progress_cadence() -> None: - samples: list[dict[str, float]] = [] + samples: list[dict[str, object]] = [] first = JobWorker._estimate_step_eta( {"current_step": 10, "total_steps": 110, "unit": "step"}, @@ -207,6 +252,24 @@ def test_step_eta_uses_measured_progress_cadence() -> None: assert second["estimated_completion_at"] +def test_step_eta_resets_when_progress_counter_changes() -> None: + samples: list[dict[str, object]] = [] + JobWorker._estimate_step_eta( + {"current_step": 819, "total_steps": 825, "unit": "step"}, samples, 100.0 + ) + reset = JobWorker._estimate_step_eta( + {"current_step": 1, "total_steps": 160, "unit": "step"}, samples, 120.0 + ) + measured = JobWorker._estimate_step_eta( + {"current_step": 2, "total_steps": 160, "unit": "step"}, samples, 141.5 + ) + + assert "eta_seconds" not in reset + assert measured["eta_seconds"] == 3397 + assert measured["progress_current"] == 2 + assert measured["progress_total"] == 160 + + def test_active_ddpm_adjustment_is_queued_on_worker(tmp_path: Path, monkeypatch) -> None: manager = JobManager(tmp_path, None, logging.getLogger("test.jobs")) # type: ignore[arg-type] job = Job( diff --git a/tests/test_model_plugins.py b/tests/test_model_plugins.py index ecff29111b3ec60333e5b0f11886051ba9d47e92..8782f9dbcd47a798af7ce291a43e1c199b1f4961 100644 --- a/tests/test_model_plugins.py +++ b/tests/test_model_plugins.py @@ -15,7 +15,7 @@ from adam.registry import ToolRegistry def test_builtin_model_plugins_are_discovered() -> None: registry = ModelPluginRegistry(Path.cwd()) - assert {"ddpm", "flow", "lora"}.issubset(registry.plugins) + assert {"ddpm", "flow", "lora", "inrflow", "pixelrow"}.issubset(registry.plugins) assert registry.errors == [] assert registry.training_schema("ddpm")["resolution"]["type"] == "choice" assert registry.generation_schema_for_tool("lora_generator")["base_model_path"]["required"] diff --git a/tests/test_oasis_integration.py b/tests/test_oasis_integration.py index 519dcefd1e56f10d04fd1aee488f9d837c6dd4bc..56c40729536425fcfc87d59d1600e7fa431d783f 100644 --- a/tests/test_oasis_integration.py +++ b/tests/test_oasis_integration.py @@ -8,10 +8,15 @@ from PIL import Image from adam.config import ConfigManager from adam.executor import ToolContext -from adam.oasis_dataset import validate_oasis_dataset +from adam.model_profiles import ModelProfileRegistry +from adam.models import ExecutionPlan, PlanStep +from adam.orion import apply_orion_review +from adam.oasis_dataset import oasis_pace, validate_oasis_dataset from adam.planner import Planner +from adam.recommendations import recommend_for_profile from adam.registry import ToolRegistry, ToolSpec from adam.tools import oasis_adapter +from adam.training_assistant import estimate_plan def _write_oasis_dataset(root: Path, *, frames: int = 4) -> Path: @@ -47,6 +52,28 @@ def _write_oasis_dataset(root: Path, *, frames: int = 4) -> Path: return dataset +def _write_large_oasis_label_dataset(root: Path, *, frames: int = 7_501) -> Path: + """Fast metadata-only fixture for recommendation code; training preflight is stricter.""" + dataset = root / "Large Oasis Dataset" + frames_dir = dataset / "frames" + frames_dir.mkdir(parents=True) + rows = [] + for index in range(frames): + filename = f"frame_{index:08d}.png" + (frames_dir / filename).touch() + rows.append(json.dumps({ + "session_id": "session-a", "frame_index": index, "filename": filename, + "w": 1 if index == 1 else 0, "a": 0, "s": 0, "d": 0, "jump": 0, + "mouse_dx": 0.0, "mouse_dy": 0.0, "zoom": 0.0, + })) + (dataset / "actions.jsonl").write_text("\n".join(rows), encoding="utf-8") + (dataset / "dataset_info.json").write_text( + json.dumps({"capture_fps": 12, "camera_encoding": "relative_degrees_v1"}), + encoding="utf-8", + ) + return dataset + + def _write_registry(root: Path) -> None: config = root / "config" config.mkdir() @@ -73,6 +100,15 @@ def test_oasis_plugin_is_registered() -> None: assert tool.model_trainers == () assert "resume_training" in tool.capabilities assert registry.model_plugins.training_schema("oasis")["resolution"]["default"] == "256x144" + assert registry.model_plugins.training_schema("oasis")["resolution"]["options"] == ["256x144", "512x288"] + gap = registry.model_plugins.training_schema("oasis")["frame_gap"] + assert gap["default"] == 1 + assert gap["group"] == "Basic" + assert gap["label"] == "Frame gap" + temporal = registry.model_plugins.training_schema("oasis") + assert temporal["model_engine"]["default"] == "temporal_latent" + assert temporal["context_frames"]["options"] == [1, 4, 8] + assert temporal["rollout_frames"]["options"] == [1, 3] assert player.model_trainers == ("oasis",) @@ -87,6 +123,83 @@ def test_oasis_dataset_validation_accepts_legacy_actions_jsonl(tmp_path: Path) - assert report.action_counts["w"] == 1 +def test_oasis_pace_uses_capture_fps_and_camera_metadata(tmp_path: Path) -> None: + dataset = _write_oasis_dataset(tmp_path) + (dataset / "dataset_info.json").write_text( + json.dumps({"capture_fps": 12, "camera_encoding": "relative_degrees_v1"}), + encoding="utf-8", + ) + + pace = oasis_pace(str(dataset), frame_gap=3) + report = validate_oasis_dataset(str(dataset), frame_gap=3) + + assert pace == {"capture_fps": 12.0, "recommended_frame_gap": 1, "native_ai_fps": 4.0} + assert report.capture_fps == 12.0 + assert report.recommended_frame_gap == 1 + assert report.native_ai_fps == 4.0 + + +def test_oasis_pace_keeps_a_12_fps_legacy_recording_playable(tmp_path: Path) -> None: + dataset = _write_oasis_dataset(tmp_path) + (dataset / "dataset_info.json").write_text( + json.dumps({"capture_fps": 12, "camera_encoding": "legacy_pixels"}), + encoding="utf-8", + ) + + pace = oasis_pace(str(dataset), frame_gap=1) + + assert pace["recommended_frame_gap"] == 1 + assert pace["native_ai_fps"] == 12.0 + + +def test_oasis_orion_recipe_adapts_frame_gap_to_recording_rate(tmp_path: Path) -> None: + dataset = _write_oasis_dataset(tmp_path) + (dataset / "dataset_info.json").write_text( + json.dumps({"capture_fps": 12, "camera_encoding": "relative_degrees_v1"}), + encoding="utf-8", + ) + profile = ModelProfileRegistry(ToolRegistry(Path.cwd()).model_plugins).get("oasis") + assert profile is not None + + recipe = recommend_for_profile(profile, dataset_items=10_000, dataset_path=str(dataset)) + + assert recipe.settings["frame_gap"] == 1 + assert any("12 FPS" in reason and "12 AI FPS" in reason for reason in recipe.reasons) + + +def test_oasis_orion_recipe_bounds_a_large_dataset_by_optimizer_steps(tmp_path: Path) -> None: + dataset = _write_large_oasis_label_dataset(tmp_path) + profile = ModelProfileRegistry(ToolRegistry(Path.cwd()).model_plugins).get("oasis") + assert profile is not None + + recipe = recommend_for_profile(profile, dataset_items=7_501, dataset_path=str(dataset)) + + assert recipe.epochs == 20 + assert recipe.settings["chunk_size"] == 5000 + assert recipe.settings["chunk_mode"] == "balanced" + assert recipe.settings["balance_actions"] is True + assert any("50,000-step initial budget" in reason for reason in recipe.reasons) + + +def test_oasis_orion_review_uses_transition_chunk_for_its_step_estimate(tmp_path: Path) -> None: + dataset = _write_large_oasis_label_dataset(tmp_path) + plan = ExecutionPlan( + request="train oasis", summary="Train Oasis.", + steps=[PlanStep("oasis_trainer", "Train Oasis", "Train", { + "dataset_dir": str(dataset), "epochs": 20, "batch_size": 2, + "gradient_accumulation": 1, "frame_gap": 1, "chunk_size": 5000, + "balance_actions": True, + })], + ) + + report = apply_orion_review(plan) + estimates = estimate_plan(plan) + + assert report["estimated_optimizer_steps"] == 50_000 + assert "Estimated optimizer steps: about 50,000" in plan.summary + assert any("50,000 optimizer steps" in item.message for item in estimates) + + def test_oasis_dataset_validation_rejects_missing_labels(tmp_path: Path) -> None: dataset = _write_oasis_dataset(tmp_path) rows = (dataset / "actions.jsonl").read_text(encoding="utf-8").splitlines() @@ -212,6 +325,8 @@ def test_oasis_fine_tune_accepts_existing_dataset_path(tmp_path: Path) -> None: assert step.arguments["resume_from"] == str( (oasis_root / "output_action_flow_models" / "Roblox Oasis V 2.3.6").resolve() ) + assert Path(step.arguments["output_dir"]) != Path(step.arguments["resume_from"]) + assert "Fine_Tune" in Path(step.arguments["output_dir"]).name def test_oasis_fine_tune_normalizes_path_shaped_model_name(tmp_path: Path) -> None: @@ -373,6 +488,14 @@ def test_oasis_adapter_builds_worker_command_and_registers_model(tmp_path: Path, output_dir=str(output), workers=0, preview_enabled=False, + contrast_every=8, + contrast_samples=3, + chunk_size=5000, + chunk_mode="balanced", + replay_older_percent=25, + recovery_minutes=15, + benchmark_batches=12, + tf32=False, ) command = captured["command"] @@ -380,6 +503,13 @@ def test_oasis_adapter_builds_worker_command_and_registers_model(tmp_path: Path, assert "--dataset-dir" in command assert str(dataset) in command assert "--mixed-precision" in command + assert command[command.index("--contrast-every") + 1] == "8" + assert command[command.index("--contrast-samples") + 1] == "3" + assert command[command.index("--chunk-size") + 1] == "5000" + assert command[command.index("--replay-older-percent") + 1] == "25.0" + assert command[command.index("--recovery-minutes") + 1] == "15" + assert command[command.index("--best-checkpoint-min-improvement") + 1] == "0.001" + assert "--tf32" not in command assert result["assets"][0]["trainer"] == "oasis" diff --git a/tests/test_oasis_player.py b/tests/test_oasis_player.py new file mode 100644 index 0000000000000000000000000000000000000000..3eb6ba9da4416638f709c04facacf192e46cb906 --- /dev/null +++ b/tests/test_oasis_player.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from adam.oasis_player import capture_path, discover_models, frame_size, is_action_model, model_info, random_roblox_dataset_frame + + +def _model(root: Path, name: str = "Playable") -> Path: + folder = root / "output_action_flow_models" / name + (folder / "unet").mkdir(parents=True) + (folder / "unet" / "config.json").write_text("{}", encoding="utf-8") + (folder / "unet" / "diffusion_pytorch_model.safetensors").write_bytes(b"weights") + (folder / "action_flow_model_info.json").write_text(json.dumps({ + "model_type": "action_conditioned_rectified_flow_video", "resolution": "256x144", + }), encoding="utf-8") + return folder + + +def _temporal_model(root: Path, name: str = "Temporal") -> Path: + folder = _model(root, name) + (folder / "vae").mkdir() + (folder / "vae" / "config.json").write_text("{}", encoding="utf-8") + (folder / "vae" / "pytorch_model.bin").write_bytes(b"weights") + (folder / "action_flow_model_info.json").write_text(json.dumps({ + "model_type": "action_conditioned_temporal_latent_flow", "resolution": "256x144", + }), encoding="utf-8") + return folder + + +def test_native_player_discovers_only_complete_models(tmp_path: Path) -> None: + playable = _model(tmp_path) + incomplete = tmp_path / "output_action_flow_models" / "Incomplete" + incomplete.mkdir() + + assert is_action_model(playable) + assert not is_action_model(incomplete) + assert discover_models(tmp_path) == [("Playable", playable)] + + +def test_native_player_reads_legacy_square_resolution_as_16_by_9(tmp_path: Path) -> None: + playable = _model(tmp_path) + payload = model_info(playable) + assert frame_size(payload) == (256, 144) + + +def test_native_player_accepts_complete_temporal_model(tmp_path: Path) -> None: + assert is_action_model(_temporal_model(tmp_path)) + + +def test_native_player_rejects_temporal_model_without_vae(tmp_path: Path) -> None: + temporal = _model(tmp_path) + (temporal / "action_flow_model_info.json").write_text(json.dumps({ + "model_type": "action_conditioned_temporal_latent_flow", "resolution": "256x144", + }), encoding="utf-8") + assert not is_action_model(temporal) + + +def test_player_captures_are_adam_owned_and_unique(tmp_path: Path) -> None: + first = capture_path(tmp_path) + second = capture_path(tmp_path) + assert first.parent == tmp_path / "data" / "oasis_captures" + assert first != second + + +def test_player_finds_a_frame_through_nested_roblox_dataset_folders(tmp_path: Path) -> None: + frame = tmp_path / "OldDatasets" / "Roblox Dataset" / "recording one" / "frames" / "frame_0001.png" + frame.parent.mkdir(parents=True) + frame.write_bytes(b"image") + (frame.parent / "notes.txt").write_text("not a frame", encoding="utf-8") + + assert random_roblox_dataset_frame(tmp_path, chooser=lambda _count: 0) == frame diff --git a/tests/test_ollama.py b/tests/test_ollama.py index 8e34dcc385d20058536cdd285875d34ede666dc2..4e02fc3d697622bfebd80d839b5c6bc64c9cfced 100644 --- a/tests/test_ollama.py +++ b/tests/test_ollama.py @@ -1,3 +1,8 @@ +import json +from pathlib import Path + +from PIL import Image + from adam.ollama import OllamaClient @@ -15,8 +20,47 @@ def test_other_models_keep_their_original_system_prompt() -> None: assert client._num_predict(180) == 180 -def test_configured_chat_limit_overrides_the_model_default() -> None: +def test_automatic_chat_budget_stays_under_the_configured_limit() -> None: client = OllamaClient("http://localhost:11434", "qwen3:4b", chat_max_tokens=1536) - assert client._num_predict(180) == 1536 + assert client._num_predict(180, prompt="hello") == 256 + assert client._num_predict(180, prompt="Explain how this training plan works") == 768 + assert client._num_predict(180, prompt="Describe this image", image_count=1) == 384 assert client._num_predict(300, chat=False) == 1024 + + +def test_explicit_detailed_mode_uses_the_configured_limit() -> None: + client = OllamaClient( + "http://localhost:11434", "qwen3:4b", chat_max_tokens=1536, + chat_response_length="detailed", + ) + + assert client._num_predict(180, prompt="hello") == 1536 + + +def test_streaming_vision_request_attaches_a_resized_image(tmp_path: Path, monkeypatch) -> None: + image = tmp_path / "reference.png" + Image.new("RGB", (2400, 1200), (24, 48, 72)).save(image) + captured = {} + + class Response: + status = 200 + + def __enter__(self): return self + def __exit__(self, *_args): return None + def __iter__(self): + return iter([b'{"response":"A clear blue image.","done":true}\n']) + + def open_request(request, timeout): + captured["payload"] = json.loads(request.data.decode("utf-8")) + captured["timeout"] = timeout + return Response() + + monkeypatch.setattr("adam.ollama.urllib.request.urlopen", open_request) + chunks = [] + client = OllamaClient("http://localhost:11434", "qwen3-vl:4b", chat_max_tokens=1024) + + assert client.generate_text_stream("You are ADAM.", "Describe this image", chunks.append, [image]) == "A clear blue image." + assert chunks == ["A clear blue image."] + assert captured["payload"]["options"]["num_predict"] == 384 + assert len(captured["payload"]["images"]) == 1 diff --git a/tests/test_pixelrow.py b/tests/test_pixelrow.py new file mode 100644 index 0000000000000000000000000000000000000000..27d7e03f48c635d68e5bae364428b64b2d44e95c --- /dev/null +++ b/tests/test_pixelrow.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import torch +from PIL import Image + +from adam.generations import parse_chat_generation_request +from adam.model_plugins import ModelPluginRegistry +from adam.model_plugins_builtin.pixelrow.generator import generate +from adam.model_plugins_builtin.pixelrow.model import PixelRowConfig, PixelRowModel +from adam.model_plugins_builtin.pixelrow.trainer import train +from adam.registry import ToolRegistry + + +class FakeContext: + def __init__(self, root: Path, tool_id: str, tool_name: str) -> None: + self.root = root + self.job_id = "test-job" + self.tool = SimpleNamespace(id=tool_id, name=tool_name) + self.logs: list[str] = [] + self.progress_updates: list[tuple[int, str, dict]] = [] + self.previews: list[dict] = [] + + def log(self, message: str) -> None: + self.logs.append(message) + + def checkpoint(self) -> None: + pass + + def progress(self, percent: int, message: str, **details) -> None: + self.progress_updates.append((percent, message, details)) + + def preview(self, path, **details) -> None: + self.previews.append({"path": str(path), **details}) + + +def test_pixelrow_plugin_is_discovered_with_native_backends() -> None: + plugins = ModelPluginRegistry(Path.cwd()) + tools = ToolRegistry(Path.cwd()) + + plugin = plugins.get("pixelrow") + trainer = tools.get("pixelrow_trainer") + generator = tools.get("pixelrow_generator") + + assert plugin.info["architecture"] == "autoregressive_rows" + assert plugin.training_settings["resolution"]["default"] == 64 + assert plugin.generation_settings["save_progress_frames"]["default"] is True + assert trainer.backend["module"].endswith("pixelrow.trainer") + assert "resume_training" in trainer.capabilities + assert generator.model_trainers == ("pixelrow",) + assert len(generator.arguments) == len(set(generator.arguments)) + + +def test_pixelrow_generation_request_selects_the_provider() -> None: + request = parse_chat_generation_request( + 'Generate a PixelRow image of cars using model "Tiny Cars" with 64 steps.' + ) + + assert request is not None + assert request.provider_hint == "pixelrow" + assert request.model_query == "Tiny Cars" + assert request.subject == "cars" + assert request.steps == 64 + + +def test_pixelrow_model_is_row_causal_and_seeded() -> None: + config = PixelRowConfig( + resolution=32, + hidden_size=64, + recurrent_layers=1, + row_channels=16, + color_bins=16, + ) + model = PixelRowModel(config) + images = torch.rand(2, 32, 3, 32) * 2 - 1 + + logits = model(images) + changed_future = images.clone() + changed_future[:, 10:] = torch.rand_like(changed_future[:, 10:]) * 2 - 1 + changed_logits = model(changed_future) + loss, parts = model.loss(images, edge_loss_weight=0.05) + first = model.generate(rows=4, top_k=4, generator=torch.Generator().manual_seed(7)) + second = model.generate(rows=4, top_k=4, generator=torch.Generator().manual_seed(7)) + + assert logits.shape == (2, 32, 3, 16, 32) + assert torch.equal(logits[:, :10], changed_logits[:, :10]) + assert loss.isfinite() + assert parts["categorical"] > 0 + assert first.shape == (32, 3, 32) + assert torch.equal(first, second) + assert torch.count_nonzero(first[4:]) == 0 + + +def test_pixelrow_training_and_generation_round_trip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(torch.cuda, "is_available", lambda: False) + dataset = tmp_path / "cars" + dataset.mkdir() + for index, color in enumerate(((220, 40, 30), (20, 80, 220))): + image = Image.new("RGB", (40, 28), (225, 225, 225)) + for x in range(8, 32): + for y in range(10, 22): + image.putpixel((x, y), color) + image.save(dataset / f"car_{index}.png") + + output = tmp_path / "data" / "model_plugin_outputs" / "pixelrow" / "Tiny Cars" + training_context = FakeContext(tmp_path, "pixelrow_trainer", "PixelRow Trainer") + result = train( + training_context, + dataset_dir=str(dataset), + model_name="Tiny Cars", + epochs=1, + output_dir=str(output), + resolution=32, + batch_size=2, + hidden_size=64, + recurrent_layers=1, + row_channels=16, + color_bins=16, + edge_loss_weight=0.0, + mixed_precision="no", + preview_enabled=False, + ) + + assert (output / "pixelrow_model.pt").is_file() + assert (output / "pixelrow_config.json").is_file() + assert (output / "training_metadata.json").is_file() + assert result["assets"][0]["trainer"] == "pixelrow" + assert training_context.progress_updates[-1][0] == 100 + + continued_output = ( + tmp_path / "data" / "model_plugin_outputs" / "pixelrow" / "Tiny Cars Continued" + ) + continuation_context = FakeContext(tmp_path, "pixelrow_trainer", "PixelRow Trainer") + train( + continuation_context, + dataset_dir=str(dataset), + model_name="Tiny Cars Continued", + epochs=1, + output_dir=str(continued_output), + resume_from=str(output), + resolution=64, + batch_size=2, + edge_loss_weight=0.0, + mixed_precision="no", + preview_enabled=False, + ) + continued_metadata = json.loads( + (continued_output / "training_metadata.json").read_text(encoding="utf-8") + ) + assert continued_metadata["completed_epochs"] == 2 + assert continued_metadata["settings"]["resolution"] == 32 + + generation_context = FakeContext(tmp_path, "pixelrow_generator", "PixelRow Generator") + generated = generate( + generation_context, + model_name="Tiny Cars", + model_path=str(output), + prompt="car experiment", + image_count=1, + steps=3, + seed=123, + sampler="Categorical", + aspect_ratio="1:1 (Native)", + top_k=4, + save_progress_frames=True, + frame_interval=1, + preview_interval=1, + ) + + generation_folder = Path(generated["output_folder"]) + final_images = list(generation_folder.glob("*_rows_3.png")) + progress_frames = list(generation_folder.glob("row_progress/*/row_*.png")) + metadata = list(generation_folder.glob("generation_*.json")) + assert len(final_images) == 1 + assert len(progress_frames) == 3 + assert len(metadata) == 1 + assert generation_context.previews[-1]["current"] == 3 + assert generation_context.progress_updates[-1][0] == 100 diff --git a/tests/test_planner.py b/tests/test_planner.py index dd5def56665978defa451a4a025ff7feabe7a5af..5fb51ff23bb66c75f6753117f723bf6332fa39c6 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -46,6 +46,51 @@ def make_planner() -> Planner: return Planner(root, ToolRegistry(root), config) +def test_chat_forwards_image_attachments_to_the_vision_model(monkeypatch) -> None: + received: dict[str, object] = {} + + class VisionClient: + def __init__(self, *args, **kwargs) -> None: + received["settings"] = kwargs + + def is_available(self, timeout: float) -> bool: + return True + + def generate_text(self, system: str, prompt: str, *, image_paths=None) -> str: + received["system"] = system + received["prompt"] = prompt + received["images"] = image_paths + return "The image shows a settings panel." + + monkeypatch.setattr("adam.planner.OllamaClient", VisionClient) + planner = make_planner() + planner.config.settings["provider"] = "ollama" + image = planner.root / "reference.png" + image.touch() + + response = planner.chat("What is in this image?", image_paths=[image]) + + assert response == "The image shows a settings panel." + assert received["images"] == [image] + assert "attached image" in str(received["system"]).casefold() + assert received["settings"] == {"timeout": 45.0, "chat_max_tokens": 1024, "chat_response_length": "automatic"} + + +def test_disabled_proposed_actions_does_not_call_the_planning_model(monkeypatch) -> None: + planner = make_planner() + planner.config.settings.update({"provider": "ollama", "ollama_proposed_actions": False}) + + def fail_if_called(request: str): + raise AssertionError("The planning model must remain disabled.") + + monkeypatch.setattr(planner, "_ollama_plan", fail_if_called) + monkeypatch.setattr(planner, "_conversation_response", lambda request: "Conversation fallback") + + plan = planner.plan("Arrange a creative automation sequence") + + assert plan.summary == "Conversation fallback" + + def test_lora_request_builds_real_confirmed_pipeline() -> None: plan = make_planner().plan("Adam, train a LoRA of Hatsune Miku for 100 epochs.") @@ -60,6 +105,51 @@ def test_lora_request_builds_real_confirmed_pipeline() -> None: assert plan.steps[1].arguments["dataset_dir"] == plan.steps[0].arguments["output_dir"] +def test_short_training_request_uses_balanced_auto_policy() -> None: + plan = make_planner().plan("Train a DDPM on Roblox images") + + assert plan.requires_confirmation is True + assert [step.tool_id for step in plan.steps] == ["dataset_collector", "ddpm_trainer"] + assert plan.steps[0].arguments["subject"] == "Roblox" + assert plan.steps[0].arguments["image_count"] == 400 + assert plan.steps[1].arguments["epochs"] > 0 + assert plan.steps[1].arguments["resolution"] == 128 + assert "Balanced AUTO policy" in plan.summary + + +def test_conversational_ddpm_command_builds_a_real_auto_plan() -> None: + plan = make_planner().plan( + "Can you train a DDPM model on a cozy cafe in Paris at sunset?" + ) + + assert plan.requires_confirmation is True + assert [step.tool_id for step in plan.steps] == ["dataset_collector", "ddpm_trainer"] + assert plan.steps[0].arguments["subject"] == "cozy cafe in Paris at sunset" + + +def test_ddpm_command_accepts_a_subject_reference_expanded_from_chat() -> None: + plan = make_planner().plan( + "Train a DDPM with Astronauts Exploring a Floating City in Jupiter's Atmosphere" + ) + + assert [step.tool_id for step in plan.steps] == ["dataset_collector", "ddpm_trainer"] + assert plan.steps[0].arguments["subject"] == "Astronauts Exploring a Floating City in Jupiter's Atmosphere" + + +def test_short_training_request_honors_test_profile_modifier() -> None: + plan = make_planner().plan("Quickly test a DDPM on Roblox images") + + assert plan.steps[0].arguments["image_count"] == 100 + assert "Test AUTO policy" in plan.summary + + +def test_short_training_request_accepts_quality_modifier() -> None: + plan = make_planner().plan("Train a high quality DDPM on Roblox images") + + assert plan.steps[0].arguments["image_count"] == 800 + assert "Quality AUTO policy" in plan.summary + + def test_dataset_count_and_subject_are_extracted() -> None: plan = make_planner().plan( "Adam, collect a dataset of 300 images for liminal spaces." @@ -219,6 +309,21 @@ def test_flow_request_with_windows_dataset_path_creates_standard_flow_plan(tmp_p assert plan.steps[0].arguments["resolution"] == 128 +def test_inrflow_request_uses_its_own_builtin_trainer() -> None: + planner = make_planner() + plan = planner.plan( + "From the Mario dataset, train an INRFlow model for 12 epochs. " + "Name the model Mario Coordinates. " + '[ADAM_TRAINING_OPTIONS:{"resolution":64,"batch_size":2,"query_points":512}]' + ) + + assert plan.requires_confirmation is True + assert [step.tool_id for step in plan.steps] == ["inrflow_trainer"] + assert plan.steps[0].arguments["model_name"] == "Mario Coordinates" + assert plan.steps[0].arguments["query_points"] == 512 + assert "model_plugin_outputs" in plan.steps[0].arguments["output_dir"] + + def test_flow_model_can_be_fine_tuned_from_its_saved_model_folder(tmp_path: Path) -> None: (tmp_path / "config").mkdir() shutil.copy2(ROOT / "config" / "tools.json", tmp_path / "config" / "tools.json") @@ -375,7 +480,10 @@ def test_ddpm_model_can_resume_from_registered_checkpoint(tmp_path: Path) -> Non assert [step.tool_id for step in plan.steps] == ["ddpm_trainer"] assert plan.steps[0].arguments["epochs"] == 20 assert plan.steps[0].arguments["resume_from"] == str(checkpoint.resolve()) - assert plan.steps[0].arguments["output_dir"] == str(checkpoint.parent.resolve()) + output = Path(plan.steps[0].arguments["output_dir"]) + assert output != checkpoint.parent.resolve() + assert output.parent == checkpoint.parent.parent + assert "Fine_Tune" in output.name def test_fine_tune_model_name_stops_before_epoch_phrase(tmp_path: Path) -> None: @@ -397,3 +505,38 @@ def test_fine_tune_model_name_stops_before_epoch_phrase(tmp_path: Path) -> None: assert [step.tool_id for step in plan.steps] == ["ddpm_trainer"] assert plan.steps[0].arguments["model_name"] == "DanTDM" assert plan.steps[0].arguments["epochs"] == 300 + + +def test_fine_tune_uses_the_requested_output_model_name(tmp_path: Path) -> None: + (tmp_path / "config").mkdir() + shutil.copy2(ROOT / "config" / "tools.json", tmp_path / "config" / "tools.json") + dataset = tmp_path / "datasets" / "Crystal Biter" + model = tmp_path / "LoRA" / "output" / "Crystal_Biter" + checkpoint = model / "Crystal_Biter.safetensors" + dataset.mkdir(parents=True) + checkpoint.parent.mkdir(parents=True) + checkpoint.write_bytes(b"checkpoint") + base_model = tmp_path / "base.safetensors" + base_model.write_bytes(b"base") + config = ConfigManager(tmp_path) + config.settings["provider"] = "manual" + config.settings["tool_folders"] = {"lora_trainer": str(tmp_path / "LoRA")} + planner = Planner(tmp_path, ToolRegistry(tmp_path), config) + registered_dataset = planner.assets.register(kind="dataset", name="Crystal Biter", path=str(dataset)) + planner.assets.register(kind="model", name="Crystal_Biter", path=str(model), trainer="lora", dataset_id=registered_dataset.id, checkpoint=str(checkpoint)) + + plan = planner.plan( + 'Fine-tune Crystal_Biter for 20 epochs with LoRA. ' + '[ADAM_FINE_TUNE:{"model_name":"Crystal_Biter","output_model_name":"Crystal_Biter_V2",' + '"trainer":"lora","epochs":20,"dataset_mode":"original","dataset_name":"",' + f'"new_subject":"","image_count":60,"training_options":{{"base_model":"{base_model.as_posix()}"}}}}]' + ) + + assert plan.steps[0].arguments["model_name"] == "Crystal_Biter_V2" + + +def test_plain_language_fine_tune_output_name_is_preserved() -> None: + assert Planner._fine_tune_output_name( + "Fine-tune Crystal_Biter for 20 epochs and name the fine-tuned model as Crystal_Biter_V2.", + {}, + ) == "Crystal_Biter_V2" diff --git a/tests/test_progressive_training.py b/tests/test_progressive_training.py new file mode 100644 index 0000000000000000000000000000000000000000..7cb2c3fe8f2cebf0786ba16ba6348cd0609e7a59 --- /dev/null +++ b/tests/test_progressive_training.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from adam.executor import ToolContext, ToolExecutionError +from adam.progressive_training import parse_stages, stage_batch_settings, suggested_stages +from adam.registry import ToolSpec +from adam.tools import ddpm_adapter + + +def _context(root): + import threading + + running = threading.Event() + running.set() + return ToolContext( + root=root, job_id="PROGRESSIVE", tool=ToolSpec("ddpm_trainer", "DDPM", "", "Training", "train_ddpm"), + cancel_event=threading.Event(), run_event=running, + progress_callback=lambda *_args, **_kwargs: None, log_callback=lambda _message: None, + ) + + +def test_progressive_stages_require_an_increasing_complete_budget() -> None: + stages = parse_stages( + [{"resolution": 64, "epochs": 30}, {"resolution": 128, "epochs": 10}], + trainer="ddpm", total_epochs=40, + ) + assert [(stage.resolution, stage.epochs) for stage in stages] == [(64, 30), (128, 10)] + + with pytest.raises(ToolExecutionError, match="total"): + parse_stages( + [{"resolution": 64, "epochs": 30}, {"resolution": 128, "epochs": 10}], + trainer="ddpm", total_epochs=41, + ) + with pytest.raises(ToolExecutionError, match="increase"): + parse_stages( + [{"resolution": 128, "epochs": 10}, {"resolution": 64, "epochs": 10}], + trainer="ddpm", total_epochs=20, + ) + + +def test_auto_stage_batch_never_keeps_an_unsafe_final_batch() -> None: + batch, accumulation = stage_batch_settings( + trainer="flow", stage_resolution=512, final_resolution=512, + final_batch_size=12, base_accumulation=1, auto_batch=True, + ) + assert (batch, accumulation) == (1, 12) + + low_batch, low_accumulation = stage_batch_settings( + trainer="flow", stage_resolution=64, final_resolution=512, + final_batch_size=1, base_accumulation=12, auto_batch=True, + ) + assert low_batch == 12 + assert low_accumulation == 1 + + +def test_suggested_schedule_uses_the_entire_epoch_budget() -> None: + stages = suggested_stages(512, 485) + assert stages[0].resolution == 64 + assert stages[-1].resolution == 512 + assert sum(stage.epochs for stage in stages) == 485 + + +def test_ddpm_curriculum_hands_weights_forward_and_publishes_only_final_model(tmp_path, monkeypatch) -> None: + calls: list[tuple[int, str, str]] = [] + + def fake_stage(_context, _dataset, _name, stage_epochs, stage_output, resume_from, *args): + path = __import__("pathlib").Path(stage_output) + path.mkdir(parents=True) + (path / "model_index.json").write_text("{}", encoding="utf-8") + calls.append((stage_epochs, str(path), str(resume_from))) + return {} + + monkeypatch.setattr(ddpm_adapter, "_train_ddpm_stage", fake_stage) + final_output = tmp_path / "output" / "Example" + result = ddpm_adapter.train_ddpm( + _context(tmp_path), dataset_dir=str(tmp_path), model_name="Example", epochs=5, + output_dir=str(final_output), resolution=128, batch_size=1, + progressive_stages=[{"resolution": 64, "epochs": 3}, {"resolution": 128, "epochs": 2}], + ) + + assert len(calls) == 2 + assert calls[0][2] == "" + assert calls[1][2] == calls[0][1] + assert (final_output / "model_index.json").is_file() + assert result["output_folder"] == str(final_output) diff --git a/tests/test_remote_phase3a.py b/tests/test_remote_phase3a.py index 4d55c7a91389ed18c6e3cdbe9772dd1438c777c5..25d1985fcd4bbb60ade044ef9bffd29a2d6fbcfd 100644 --- a/tests/test_remote_phase3a.py +++ b/tests/test_remote_phase3a.py @@ -308,6 +308,24 @@ def test_remote_v1_models_include_lora_trigger_word_without_paths(tmp_path: Path assert payload["models"][0]["checkpoint_name"] == "adam.safetensors" +def test_remote_schemas_include_discovered_loras_and_base_models(tmp_path: Path) -> None: + loras = tmp_path / "LoRAModelsHere" + bases = tmp_path / "LoRA StableDiffusionModels Here" + loras.mkdir() + bases.mkdir() + (loras / "Selected_LoRA.safetensors").write_bytes(b"weights") + (loras / "Selected_LoRA_epoch_0050.safetensors").write_bytes(b"checkpoint") + (bases / "SDXL_Base.safetensors").write_bytes(b"base") + + api = _remote_v1(tmp_path) + training = api.training_schema() + generation = api.generation_schema() + + assert [item["name"] for item in training["base_models"]] == ["SDXL_Base"] + assert [item["name"] for item in generation["base_models"]] == ["SDXL_Base"] + assert [item["name"] for item in generation["models"] if item["trainer"] == "lora"] == ["Selected_LoRA"] + + def test_structured_generation_queues_existing_generation_plan(tmp_path: Path) -> None: model = tmp_path / "ddpm" / "Model" model.mkdir(parents=True) @@ -398,7 +416,9 @@ def test_lora_adapter_passes_explicit_trigger_word_to_native_payload(tmp_path: P " output_dir: Path\n" " resume_checkpoint: Path | None = None\n" " trigger_word: str = ''\n" - " epochs: int = 1\n", + " epochs: int = 1\n" + " preview_prompt: str = ''\n" + " preview_interval_epochs: int = 5\n", encoding="utf-8", ) (backend / "diffusers_sdxl_lora_backend.py").write_text( @@ -406,7 +426,7 @@ def test_lora_adapter_passes_explicit_trigger_word_to_native_payload(tmp_path: P "class DiffusersSDXLLoRABackend:\n" " def train(self, config, control, progress):\n" " config.output_dir.mkdir(parents=True, exist_ok=True)\n" - " (config.output_dir / 'payload.json').write_text(json.dumps({'trigger_word': config.trigger_word, 'epochs': config.epochs}), encoding='utf-8')\n" + " (config.output_dir / 'payload.json').write_text(json.dumps({'trigger_word': config.trigger_word, 'epochs': config.epochs, 'preview_prompt': config.preview_prompt, 'preview_interval_epochs': config.preview_interval_epochs}), encoding='utf-8')\n" " final = config.output_dir / 'final.safetensors'\n" " final.write_bytes(b'weights')\n" " return final\n", @@ -439,10 +459,14 @@ def test_lora_adapter_passes_explicit_trigger_word_to_native_payload(tmp_path: P epochs=1, output_dir=str(trainer / "output" / "Adam_OC_LoRA_v2"), base_model=str(base), + preview_prompt="a different subject in a studio", + preview_every=3, ) payload = json.loads((Path(result["output_folder"]) / "payload.json").read_text(encoding="utf-8")) assert payload["trigger_word"] == "adam_oc" + assert payload["preview_prompt"] == "a different subject in a studio" + assert payload["preview_interval_epochs"] == 3 assert result["trigger_word"] == "adam_oc" assert result["assets"][0]["metadata"]["trigger_word"] == "adam_oc" diff --git a/tests/test_training_assistant.py b/tests/test_training_assistant.py index 108c67625aec7aeedd17fb4740a1b7af0b20cf14..6f779f2868c7283dc44a8ddf4d8e822685739acf 100644 --- a/tests/test_training_assistant.py +++ b/tests/test_training_assistant.py @@ -126,6 +126,14 @@ def test_custom_presets_extend_built_in_presets() -> None: assert presets["My Quick Run"]["epochs"] == 5 +def test_oasis_large_dataset_preset_uses_a_bounded_balanced_chunk() -> None: + preset = presets_from_config(FakeConfig())["Oasis Large Dataset +10K"] + + assert preset["epochs"] == 45 + assert preset["training_options"]["chunk_size"] == 5000 + assert preset["training_options"]["frame_gap"] == 1 + + def test_preflight_is_saved_in_plan_summary(tmp_path: Path) -> None: trainer = tmp_path / "trainer" dataset = tmp_path / "dataset" diff --git a/tests/test_ui_loading.py b/tests/test_ui_loading.py new file mode 100644 index 0000000000000000000000000000000000000000..7a7f3a0c8295a2acc78bfa1607ca86a4dc031cb6 --- /dev/null +++ b/tests/test_ui_loading.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import os +import shutil +import threading +import time +from copy import deepcopy +from dataclasses import asdict +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QCoreApplication, QThreadPool, QTimer, QObject, Signal +from PySide6.QtWidgets import QApplication, QStackedWidget, QWidget + +from adam.assets import AssetRegistry +from adam.config import ConfigManager +from adam.experiment_tracker import ExperimentStore +from adam.ui.asset_refresh import AssetRefresh +from adam.ui.background import BackgroundRead +from adam.ui.model_intelligence import ModelIntelligencePage +from adam.ui.main_window import ( + expand_subject_reference, + is_explicit_action_request, + is_pending_job_confirmation_request, + subject_idea_from_chat, +) + + +@pytest.fixture(scope="module") +def app(): + instance = QCoreApplication.instance() + if instance is not None and not isinstance(instance, QApplication): + pytest.skip("Run UI loading tests separately from tests that create QCoreApplication.") + return instance or QApplication([]) + + +def pump(app, condition, timeout=5): + deadline = time.monotonic() + timeout + while not condition() and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.005) + assert condition() + + +def test_background_read_keeps_navigation_responsive_and_delivers_on_ui_thread(app): + stack = QStackedWidget() + stack.addWidget(QWidget()) + stack.addWidget(QWidget()) + reader = BackgroundRead(stack) + gate = threading.Event() + started = threading.Event() + results = [] + reader.ready.connect(lambda value: results.append((value, threading.get_ident()))) + + def slow_read(): + started.set() + assert gate.wait(3) + return "loaded" + + try: + reader.request(slow_read) + pump(app, started.is_set) + QTimer.singleShot(0, lambda: stack.setCurrentIndex(1)) + pump(app, lambda: stack.currentIndex() == 1) + assert results == [] + finally: + gate.set() + pump(app, lambda: bool(results)) + assert results == [("loaded", threading.get_ident())] + + +def test_chat_action_requests_are_routed_to_the_validated_planner() -> None: + assert is_explicit_action_request("Can you train a DDPM on cozy cafe images?") + assert is_explicit_action_request("Yes, start this job now") + assert not is_explicit_action_request("What subject should we train today?") + assert not is_explicit_action_request("Explain how DDPM training works") + assert is_pending_job_confirmation_request("Yes, start this job now") + assert not is_pending_job_confirmation_request("Can you train a DDPM on cozy cafe images?") + + +def test_subject_reference_is_expanded_from_the_prior_chat_idea() -> None: + reply = """🎯 Subject Idea: +> “Astronauts Exploring a Floating City in Jupiter's Atmosphere” +""" + assert subject_idea_from_chat(reply) == "Astronauts Exploring a Floating City in Jupiter's Atmosphere" + request, subject = expand_subject_reference("Train a DDPM with that subject you mentioned", reply) + assert subject == "Astronauts Exploring a Floating City in Jupiter's Atmosphere" + assert request == "Train a DDPM with Astronauts Exploring a Floating City in Jupiter's Atmosphere" + + +def test_refresh_coalesces_requests_and_does_not_publish_stale_data(app): + owner = QWidget() + reader = BackgroundRead(owner) + gate = threading.Event() + results = [] + reader.ready.connect(results.append) + try: + reader.request(lambda: gate.wait(3) and "old") + reader.request(lambda: "superseded", force=True) + reader.request(lambda: "latest", force=True) + finally: + gate.set() + pump(app, lambda: bool(results)) + assert results == ["latest"] + reader.request(lambda: "unnecessary repeat") + app.processEvents() + assert not reader._running + assert results == ["latest"] + + +def test_failed_read_can_be_retried(app): + owner = QWidget() + reader = BackgroundRead(owner) + errors, results = [], [] + reader.failed.connect(errors.append) + reader.ready.connect(results.append) + + def fail(): + raise OSError("disconnected") + + reader.request(fail) + pump(app, lambda: bool(errors)) + reader.request(lambda: "recovered") + pump(app, lambda: bool(results)) + assert errors == ["disconnected"] + assert results == ["recovered"] + + +def test_asset_merge_preserves_changes_made_during_discovery(app, tmp_path): + assets = AssetRegistry(tmp_path) + original = assets.register(kind="model", name="Original", path=str(tmp_path / "original")) + removed = assets.register(kind="model", name="Remove me", path=str(tmp_path / "removed")) + baseline = {item.id: asdict(item) for item in assets.assets} + discovered = deepcopy(assets.assets) + original.name = "User edited name" + assets.assets.remove(removed) + new = assets.register(kind="model", name="Just trained", path=str(tmp_path / "new")) + service = AssetRefresh(assets, ConfigManager(tmp_path)) + service._merge((baseline, discovered)) + assert {item.name for item in assets.assets} == {"User edited name", "Just trained"} + assert assets.assets[0] is original + assert new in assets.assets + + +def test_background_discovery_does_not_write_shared_state(app, tmp_path, monkeypatch): + assets = AssetRegistry(tmp_path) + config = ConfigManager(tmp_path) + writes = [] + monkeypatch.setattr(AssetRegistry, "save", lambda self: writes.append(threading.get_ident())) + monkeypatch.setattr("adam.dataset_registry.DatasetRegistry.save", lambda self: writes.append(threading.get_ident())) + assets.discover(config, persist=False) + assert writes == [] + + +def test_intelligence_load_does_not_block_other_tabs(app, tmp_path, monkeypatch): + gate = threading.Event() + started = threading.Event() + + def history(*args, **kwargs): + started.set() + assert gate.wait(3) + return [] + + monkeypatch.setattr("adam.ui.model_intelligence.load_generation_history", history) + page = ModelIntelligencePage(tmp_path, ExperimentStore(tmp_path)) + stack = QStackedWidget() + stack.addWidget(page) + stack.addWidget(QWidget()) + try: + page.refresh() + pump(app, started.is_set) + QTimer.singleShot(0, lambda: stack.setCurrentIndex(1)) + pump(app, lambda: stack.currentIndex() == 1) + assert page._reader._running + finally: + gate.set() + pump(app, lambda: not page._reader._running) + assert page.metric_models.text() == "0" + QThreadPool.globalInstance().waitForDone(3000) + + +def test_generation_refresh_keeps_edits_and_folder_navigation_reuses_history(app, tmp_path, monkeypatch): + from adam.registry import ToolRegistry + from adam.ui.generations import GenerationsPage + + class Jobs(QObject): + job_updated = Signal(object) + + config = ConfigManager(tmp_path) + shutil.copy2(Path(__file__).resolve().parents[1] / "config/tools.json", tmp_path / "config/tools.json") + assets = AssetRegistry(tmp_path) + monkeypatch.setattr(AssetRefresh, "request", lambda *args, **kwargs: None) + calls = [] + + def history(*args, **kwargs): + calls.append(1) + return [] + + monkeypatch.setattr("adam.ui.generations.load_generation_history", history) + jobs = Jobs() + page = GenerationsPage(tmp_path, ToolRegistry(tmp_path), jobs, assets, config) + pump(app, lambda: not page._history_reader._running) + page.provider.setCurrentIndex(page.provider.findData("ddpm_generator")) + page.steps.setValue(37) + page.seed.setText("123456") # In-progress edit has not emitted editingFinished. + page.prompt.setPlainText("Keep my current prompt") + model = tmp_path / "model" + model.mkdir() + (model / "model_index.json").write_text("{}") + assets.register(kind="model", name="New model", path=str(model), trainer="ddpm") + page.refresh() + assert page.steps.value() == 37 + assert page.seed.text() == "123456" + assert page.prompt.toPlainText() == "Keep my current prompt" + assert page.model.currentData() == str(model) + assert page.generate_button.isEnabled() + page._show_provider_folders() + page._go_back_in_history() + app.processEvents() + assert calls == [1] diff --git a/tests/test_wan_video.py b/tests/test_wan_video.py new file mode 100644 index 0000000000000000000000000000000000000000..992a6c8fbef0ff51bf1b6d882d3b0045a29cc41f --- /dev/null +++ b/tests/test_wan_video.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import io +import json +import struct +import sys +import threading +import time +from pathlib import Path + +import pytest + +from adam.assets import AssetRegistry +from adam.config import ConfigManager +from adam.executor import ToolCancelled, ToolContext, ToolExecutionError +from adam.models import ExecutionPlan, PlanStep +from adam.registry import ToolRegistry, ToolSpec +from adam.tools import wan_video_adapter as adapter +from adam.video_lora import ( + MODEL_FILES, checkpoint_info, dataset_errors, discover_assets, frames_for_seconds, + generation_plan, settings_for, trainer_root, training_plan, +) + + +def checkpoint(path: Path, *, base="wan2.1_t2v_1.3B_bf16.safetensors") -> Path: + header = json.dumps({"__metadata__": {"ss_network_module": "networks.lora_wan", "ss_sd_model_name": base, + "ss_epoch": "2", "ss_network_dim": "16"}, + "lora_unet_blocks_0.lora_down.weight": {"dtype": "F32", "shape": [1, 1], "data_offsets": [0, 4]}}).encode() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(struct.pack("= 0 + assert str(archived.resolve()) in ConfigManager(root).get("video_lora_settings")["dataset_history"] + page.close() + page.deleteLater() + app.processEvents() + + +def test_desktop_caption_shortcut_and_batch_captioning(workspace, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QObject, Signal, Qt + from PySide6.QtTest import QTest + from adam.ui.video_lora import VideoLoraPage + + class Jobs(QObject): + job_updated = Signal(object) + active_job = None + + root, _, dataset = workspace + second = dataset / "second.mp4" + second.write_bytes(b"video") + app = QApplication.instance() or QApplication([]) + page = VideoLoraPage(root, ToolRegistry(root), Jobs(), AssetRegistry(root), ConfigManager(root)) + page.show() + app.processEvents() + page.dataset_folder.setText(str(dataset)) + page._refresh_clips() + page.clips.setCurrentRow(0) + page.clips.item(1).setSelected(True) + page.caption.setPlainText("subject_token smiles at the camera") + + page._apply_caption_to_selected() + + assert (dataset / "clip.txt").read_text(encoding="utf-8").strip() == "subject_token smiles at the camera" + assert (dataset / "second.txt").read_text(encoding="utf-8").strip() == "subject_token smiles at the camera" + page.clips.setCurrentRow(0) + page.caption.setPlainText("subject_token waves") + page.caption.document().setModified(True) + page.caption.setFocus() + QTest.keyClick(page.caption, Qt.Key_Return, Qt.ShiftModifier) + app.processEvents() + assert page.clips.currentRow() == 1 + assert (dataset / "clip.txt").read_text(encoding="utf-8").strip() == "subject_token waves" + page.close() + page.deleteLater() + app.processEvents()