diff --git a/astrid/packs/_core/skill/SKILL.md b/astrid/packs/_core/skill/SKILL.md index f77e3b8..f208f55 100644 --- a/astrid/packs/_core/skill/SKILL.md +++ b/astrid/packs/_core/skill/SKILL.md @@ -284,6 +284,7 @@ Before rendering an iteration video, run `python3 -m astrid.packs.builtin.iterat | `builtin.render` | Render a hype timeline to hype.mp4 through the Remotion compositor. | | `builtin.scene_describe` | Caption each detected scene with a vision model for downstream selection. | | `builtin.scenes` | Detect source-video scene boundaries with ffmpeg-driven analysis. | +| `builtin.script_pipeline` | Generate short scripts through rough attempts, synthesis, style pass, and optional judging. | | `builtin.search_loras` | Search Hugging Face Hub for LoRAs associated with a base model. | | `builtin.shots` | Slice scenes into shot windows for downstream pool building. | | `builtin.spatial_audio_page` | Build a static page that mixes Foley tracks anchored to spatial rectangles via Web Audio. | @@ -307,12 +308,6 @@ Before rendering an iteration video, run `python3 -m astrid.packs.builtin.iterat | `external.vibecomfy.validate` | Validate a VibeComfy / ComfyUI workflow JSON without executing it. | | `iteration.assemble` | Adapt prepared iteration data into canonical iteration artifacts and render-ready hype inputs. | | `iteration.prepare` | Collect thread provenance, quality scores, and candidate runs into iteration prepare artifacts. | -| `seinfeld.aitoolkit_stage` | Generate ai-toolkit job config from manifest + vocabulary; upload to pod; start AI Toolkit UI on :8675. | -| `seinfeld.aitoolkit_train` | Kick off ai-toolkit training on a pod and mirror remote logs locally. | -| `seinfeld.lora_eval_grid` | Run baseline LTX + per-checkpoint inference samples, download MP4s, write static index.html viewer. | -| `seinfeld.lora_register` | Pure-local: copy chosen .safetensors into registered/ and write registered_lora.json. | -| `seinfeld.repo_setup` | Idempotent git submodule add + checkout of ostris/ai-toolkit for config-schema reference. | -| `seinfeld.script_pipeline` | Generate Seinfeld-style short scene scripts through ideation, synthesis, and voice passes. | | `upload.youtube` | Upload a finished video to YouTube via the shared banodoco-social Zapier integration. | ### Orchestrators @@ -329,8 +324,6 @@ Before rendering an iteration video, run `python3 -m astrid.packs.builtin.iterat | `builtin.thumbnail_maker` | Plan source evidence and thumbnail generation candidates for a video/query pair. | | `builtin.training_run` | Run a generic LoRA training job from a prepared dataset manifest. | | `builtin.vary_grid` | Iterative grid editor: take an existing grid image and emit a new grid of variations via fal. | -| `seinfeld.dataset_build` | Bucket-fill loop that builds the Seinfeld LoRA training set from YouTube. | -| `seinfeld.lora_train` | Train an LTX 2.3 LoRA on the Seinfeld dataset via ai-toolkit on RunPod. | ### Elements diff --git a/astrid/packs/builtin/dataset_build/schemas/ai-toolkit-adapter-manifest.schema.json b/astrid/packs/builtin/dataset_build/schemas/ai-toolkit-adapter-manifest.schema.json index ad7986a..b3f3e8a 100644 --- a/astrid/packs/builtin/dataset_build/schemas/ai-toolkit-adapter-manifest.schema.json +++ b/astrid/packs/builtin/dataset_build/schemas/ai-toolkit-adapter-manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://astrid.builtin-training/schemas/ai-toolkit-adapter-manifest.schema.json", "title": "AI-Toolkit Adapter Manifest", - "description": "The flat 'clips' shape expected by seinfeld.lora_train preflight and ai-toolkit staging. This is what the 'ai-toolkit-ltx' manifest adapter exports from the canonical manifest.", + "description": "The flat 'clips' shape expected by builtin.training_run preflight and ai-toolkit staging. This is what the 'ai-toolkit-ltx' manifest adapter exports from the canonical manifest.", "type": "object", "required": ["clips"], "additionalProperties": false, diff --git a/astrid/packs/builtin/script_pipeline/STAGE.md b/astrid/packs/builtin/script_pipeline/STAGE.md new file mode 100644 index 0000000..daa6cd9 --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/STAGE.md @@ -0,0 +1,24 @@ +# builtin.script_pipeline + +Preset-driven creative-writing executor. It preserves the original three-pass +shape while moving topic-specific prompt rules into config: + +1. Generate rough attempts in parallel. +2. Synthesize one structured draft from the rough attempts. +3. Apply a voice/style pass. +4. Optionally judge multiple final candidates and select the winner. + +Use fake mode for no-network smoke tests: + +```bash +python3 -m astrid executors run builtin.script_pipeline -- \ + --preset seinfeld \ + --produces-dir runs/script-pipeline/produces \ + --fake \ + --candidates 2 \ + --rough-attempts 3 \ + --select-best +``` + +Live DeepSeek runs read provider/model data from the preset and require the +configured API-key environment variable, defaulting to `DEEPSEEK_API_KEY`. diff --git a/astrid/packs/builtin/script_pipeline/__init__.py b/astrid/packs/builtin/script_pipeline/__init__.py new file mode 100644 index 0000000..7d7e771 --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/__init__.py @@ -0,0 +1,3 @@ +"""Generic creative-writing script pipeline executor.""" + +__all__ = ["run"] diff --git a/astrid/packs/builtin/script_pipeline/executor.yaml b/astrid/packs/builtin/script_pipeline/executor.yaml new file mode 100644 index 0000000..fe7dbdd --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/executor.yaml @@ -0,0 +1,35 @@ +{ + "id": "builtin.script_pipeline", + "name": "Script Pipeline", + "kind": "built_in", + "version": "0.1.0", + "short_description": "Generate short scripts through rough attempts, synthesis, style pass, and optional judging.", + "description": "Preset-driven creative-writing pipeline. Runs parallel rough attempts, synthesizes a draft, applies a voice/style pass, optionally judges multiple candidates, and writes candidate markdown, selected markdown, and manifest.json. DeepSeek is the initial live provider; --fake mode is available for no-network smoke tests.", + "keywords": ["script", "writing", "deepseek", "creative", "preset"], + "inputs": [ + {"name": "preset", "type": "string", "required": false, "description": "Built-in preset name or path to YAML/JSON preset."}, + {"name": "prompt", "type": "string", "required": false, "description": "Scene brief override."}, + {"name": "candidates", "type": "integer", "required": false, "description": "Complete pipeline candidates to generate."}, + {"name": "rough_attempts", "type": "integer", "required": false, "description": "Parallel rough attempts per candidate."}, + {"name": "fake", "type": "boolean", "required": false, "description": "Use deterministic no-network provider outputs."} + ], + "outputs": [ + {"name": "selected_scene", "type": "file", "path_template": "{out}/produces/selected_scene.md"}, + {"name": "manifest", "type": "file", "path_template": "{out}/produces/manifest.json"} + ], + "command": { + "argv": [ + "{python_exec}", + "-m", + "astrid.packs.builtin.script_pipeline.run", + "--produces-dir", + "{out}/produces" + ] + }, + "cache": {"mode": "none"}, + "isolation": {"mode": "subprocess", "network": true}, + "metadata": { + "runtime_module": "astrid.packs.builtin.script_pipeline.run", + "runtime_file": "run.py" + } +} diff --git a/astrid/packs/builtin/script_pipeline/presets/always_sunny.yaml b/astrid/packs/builtin/script_pipeline/presets/always_sunny.yaml new file mode 100644 index 0000000..17450cc --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/presets/always_sunny.yaml @@ -0,0 +1,81 @@ +schema_version: 1 +id: always_sunny +title: Always Sunny Script Pipeline +provider: + name: deepseek + model: deepseek-v4-pro + endpoint: https://api.deepseek.com/v1/chat/completions + api_key_env: DEEPSEEK_API_KEY + timeout_seconds: 320 +defaults: + candidates: 1 + rough_attempts: 5 + select_best: false + rough_temperature: 1.8 + synth_temperature: 1.1 + voice_temperature: 1.0 + judge_temperature: 0.2 + max_tokens: 8192 + judge_max_tokens: 1024 +prompt: >- + Write a very short It's Always Sunny in Philadelphia-style bar scene, about + 30 seconds of screen time. The gang tries to turn an ordinary minor + inconvenience into a selfish business scheme. Keep it to 10-14 short lines, + mostly overlapping argument. No laugh tags and no studio-audience rhythm. +prompts: + rough_system: >- + You are a dark farce comedy writer. You write fast, selfish, argumentative + bar scenes where every character believes they are the only rational person + in the room. + synth_system: >- + You are a structure editor for chaotic ensemble comedy. Thread multiple + rough attempts into one escalating bar argument with a bad plan, a worse + justification, and a quick reversal. + synth_template: | + Below are {rough_attempts} attempts at a short ensemble bar scene. + + Original brief: + {prompt} + + Build one coherent short scene from the strongest material. + + Rules for this pass: + - Center the scene on a selfish plan that should obviously fail. + - Keep the setting grounded in a grimy neighborhood bar or a back office. + - Make the characters interrupt, accuse, reframe, and escalate. + - The comedy should come from bad incentives, denial, and group delusion. + - Do not add laugh tags, applause tags, or sitcom audience beats. + - End on a hard turn or an immediately worse idea. + - Output only the script. + + ATTEMPTS: + + {attempts_blob} + voice_system: | + You are a voice pass editor for a chaotic bar ensemble comedy. Keep the structure and line count, but sharpen each line toward selfish motive, defensive denial, or a bad-faith argument. + + VOICE RULES: + + DENNIS is controlled, grandiose, and chillingly image-obsessed. He should sound like he is managing a brand crisis around himself. + + DEE is combative, insecure, and desperate to be credited. She attacks status and fairness. + + MAC is overconfident, physical, and invents rules that flatter him. + + CHARLIE is literal, grimy, and strangely practical in the wrong direction. + + FRANK is blunt, venal, and excited when the plan becomes worse. + + Do not add laugh tags, applause tags, or audience cues. Output only the corrected script. + voice_template: | + Here is the draft scene. Rewrite only lines that violate the voice rules. Preserve the structure, order, and approximate line count. + + DRAFT: + + {draft_scene} + judge_system: >- + You are judging Always Sunny-style short bar scenes. Pick the strongest + candidate for selfish motive, ensemble argument, escalating bad logic, + grimy specificity, and absence of laugh tags or studio-audience rhythm. + Return strict JSON only: + {"winner": <1-based index>, "reason": ""}. diff --git a/astrid/packs/builtin/script_pipeline/presets/seinfeld.yaml b/astrid/packs/builtin/script_pipeline/presets/seinfeld.yaml new file mode 100644 index 0000000..05f3dad --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/presets/seinfeld.yaml @@ -0,0 +1,84 @@ +schema_version: 1 +id: seinfeld +title: Seinfeld Script Pipeline +provider: + name: deepseek + model: deepseek-v4-pro + endpoint: https://api.deepseek.com/v1/chat/completions + api_key_env: DEEPSEEK_API_KEY + timeout_seconds: 320 +defaults: + candidates: 1 + rough_attempts: 5 + select_best: false + rough_temperature: 2.0 + synth_temperature: 1.0 + voice_temperature: 1.0 + judge_temperature: 0.2 + max_tokens: 8192 + judge_max_tokens: 1024 +prompt: >- + Write a VERY SHORT Seinfeld scene - about 30 seconds of screen time. Keep it + to 8-12 short lines of dialogue total. Kramer bursts in EXTREMELY excited + about open source AI. George is skeptical / dismissive. Jerry is confused - + keeps interrupting with 'Who?' / 'What?' because none of the names or terms + register. End on a button (one clean closing line). Format as a script: + NAME: line. No stage directions beyond the opening. +prompts: + rough_system: >- + You are a comedy writer with deep knowledge of Seinfeld's voice and rhythm. + You write tight, fast scenes - no fat. + synth_system: >- + You are a comedy writer with a deep ear for Seinfeld's rhythm. Your one job + in this pass is STRUCTURE - turning multiple rough attempts into a single + coherent scene. Don't worry about polishing character voices yet; that's a + later pass. Just thread the best material. + synth_template: | + Below are {rough_attempts} attempts at a short scene. + + Original brief: + {prompt} + + Pick the strongest weird ideas, specific name-jokes, and character-true beats from across the attempts, and weave them into ONE coherent ~12-line scene. Loose threading - connected enough to flow, not so neat it reads as a sketch. + + Rules for this pass: + - Preserve the brief's requested wackiness level: funny, wacky, somewhat grounded in reality, and also over the top. + - Choose one concrete, playable modern-life problem or object as the scene engine. + - Keep the absurdity practical: Kramer's scheme can be ridiculous, but it should involve specific objects, errands, apartments, neighbors, dating, food, money, etiquette, or daily inconvenience. + - Open with a one-line stage direction: location + what each character is doing. + - Kramer bursts in early. + - Include the "puffy shirt" callback if any attempt has it. + - Add a small physical beat inline somewhere mid-scene. + - End on an action or exit line, not a clever metaphor. + - No laugh tags this pass. Just dialogue. + - Output only the script. No commentary, no headers. + + ATTEMPTS: + + {attempts_blob} + voice_system: | + You are a script doctor for Seinfeld. You receive a structurally-correct draft scene and your ONLY job is to fix lines that violate character voice. You do NOT restructure, reorder, add, or remove lines. You replace individual lines that sound wrong with versions that sound right. After the voice pass, you insert laugh tags. + + CHARACTER VOICES - concrete rules: + + GEORGE doesn't construct cute analogies. He panics, catastrophizes, and complains about specific people. His comedy comes from his neuroses leaking out, not from clever metaphors. + + JERRY's voice is flat and declarative, not literary. He repeats himself slightly. + + KRAMER is concrete-absurd, not ideological or abstract. He physicalizes everything. + + LAUGH TAGS - after the voice fixes, insert at most 5 tags total, only on the biggest beats. Forms: [LAUGHTER], [BIG LAUGHTER], [LAUGHTER AND APPLAUSE]. Plus [APPLAUSE] on Kramer's entrance and [END SCENE] at the end. Do not tag every line. + + Output only the corrected script. No commentary. + voice_template: | + Here is the draft scene. Find lines that violate the character-voice rules and rewrite only those lines in place. Leave any line that already sounds right untouched. Do not change the structure, the order, or the count of lines. Then insert the laugh tags per the rules above. + + DRAFT: + + {draft_scene} + judge_system: >- + You are judging generated Seinfeld-style short scene scripts. Pick the + single strongest candidate for concrete Kramer physical absurdity, Jerry's + flat confusion, George's neurotic specificity, coherent escalation, and + sparse laugh tags. Return strict JSON only: + {"winner": <1-based index>, "reason": ""}. diff --git a/astrid/packs/builtin/script_pipeline/run.py b/astrid/packs/builtin/script_pipeline/run.py new file mode 100644 index 0000000..5aaad9e --- /dev/null +++ b/astrid/packs/builtin/script_pipeline/run.py @@ -0,0 +1,556 @@ +#!/usr/bin/env python3 +"""Preset-driven script pipeline executor.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +try: + import yaml +except ImportError: # pragma: no cover - optional dependency + yaml = None + + +PACKAGE_ROOT = Path(__file__).resolve().parent +PRESETS_DIR = PACKAGE_ROOT / "presets" +DEFAULT_PRESET = "seinfeld" + + +@dataclass(frozen=True) +class ProviderConfig: + name: str + model: str + endpoint: str + api_key_env: str + timeout_seconds: int + + +@dataclass(frozen=True) +class PipelineConfig: + preset_id: str + title: str + provider: ProviderConfig + prompt: str + prompts: dict[str, str] + defaults: dict[str, Any] + source_path: Path + + +@dataclass(frozen=True) +class Candidate: + index: int + work_dir: Path + md_path: Path + final_scene: str + draft_scene: str + attempts_blob: str + + +class ChatClient(Protocol): + def complete( + self, + messages: list[dict[str, str]], + *, + temperature: float, + max_tokens: int, + phase: str, + candidate_index: int | None = None, + attempt_index: int | None = None, + ) -> str: + """Return assistant text for one chat completion.""" + + +class DeepSeekClient: + def __init__(self, provider: ProviderConfig, api_key: str) -> None: + self.provider = provider + self.api_key = api_key + + def complete( + self, + messages: list[dict[str, str]], + *, + temperature: float, + max_tokens: int, + phase: str, + candidate_index: int | None = None, + attempt_index: int | None = None, + ) -> str: + body = { + "model": self.provider.model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + } + last_error: Exception | None = None + for attempt in range(1, 4): + request = Request( + self.provider.endpoint, + data=json.dumps(body).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=self.provider.timeout_seconds) as response: + payload = response.read().decode("utf-8") + data = json.loads(payload) + if "error" in data: + raise RuntimeError(f"{self.provider.name} API error: {data['error']}") + return str(data["choices"][0]["message"]["content"]) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + if 400 <= exc.code < 500 and exc.code != 429: + raise RuntimeError(f"{self.provider.name} HTTP {exc.code}: {detail}") from exc + last_error = RuntimeError(f"{self.provider.name} HTTP {exc.code}: {detail}") + except (URLError, TimeoutError) as exc: + last_error = RuntimeError(f"{self.provider.name} request failed: {exc}") + if attempt < 3: + wait_seconds = 2 ** attempt + print( + f"{self.provider.name} call failed; retrying in {wait_seconds}s " + f"({attempt}/3): {last_error}", + file=sys.stderr, + ) + time.sleep(wait_seconds) + raise RuntimeError(str(last_error)) + + +class FakeScriptClient: + def complete( + self, + messages: list[dict[str, str]], + *, + temperature: float, + max_tokens: int, + phase: str, + candidate_index: int | None = None, + attempt_index: int | None = None, + ) -> str: + idx = candidate_index or 1 + attempt = attempt_index or 1 + if phase == "rough": + return ( + "INT. ROOM - DAY\n" + f"CHARACTER A: Fake rough attempt {attempt} for candidate {idx} has a very specific problem.\n" + "CHARACTER B: I need the problem stated plainly.\n" + "CHARACTER C: Plain statements are how plans fall apart." + ) + if phase == "synth": + return ( + "INT. ROOM - DAY\n" + f"CHARACTER A: Candidate {idx} is turning the hallway into a help desk.\n" + "CHARACTER B: The hallway has tickets now?\n" + "CHARACTER C: I can't be triaged in a hallway." + ) + if phase == "voice": + return ( + "INT. ROOM - DAY\n" + f"CHARACTER A: Candidate {idx} is turning the hallway into a help desk!\n" + "CHARACTER B: The hallway has tickets now?\n" + "CHARACTER C: I can't be triaged in a hallway.\n" + "END." + ) + if phase == "judge": + return json.dumps({"winner": 1, "reason": "Fake judge selected the first deterministic candidate."}) + return messages[-1]["content"] + + +def load_pipeline_config(preset: str | Path | None, config_path: Path | None = None) -> PipelineConfig: + source_path = _resolve_config_path(config_path or preset or DEFAULT_PRESET) + raw = _load_mapping(source_path) + provider_raw = _mapping(raw.get("provider"), "provider") + prompts = _mapping(raw.get("prompts"), "prompts") + defaults = dict(_mapping(raw.get("defaults", {}), "defaults")) + provider = ProviderConfig( + name=_required_str(provider_raw, "name", "provider.name"), + model=_required_str(provider_raw, "model", "provider.model"), + endpoint=_required_str(provider_raw, "endpoint", "provider.endpoint"), + api_key_env=str(provider_raw.get("api_key_env") or "DEEPSEEK_API_KEY"), + timeout_seconds=int(provider_raw.get("timeout_seconds") or 320), + ) + return PipelineConfig( + preset_id=str(raw.get("id") or source_path.stem), + title=str(raw.get("title") or source_path.stem), + provider=provider, + prompt=_required_str(raw, "prompt", "prompt"), + prompts={str(key): str(value) for key, value in prompts.items()}, + defaults=defaults, + source_path=source_path, + ) + + +def build_chat_client(config: PipelineConfig, *, fake: bool, env: dict[str, str] | None = None) -> ChatClient: + if fake: + return FakeScriptClient() + if config.provider.name != "deepseek": + raise RuntimeError(f"unsupported script provider: {config.provider.name}") + active_env = env if env is not None else os.environ + api_key = active_env.get(config.provider.api_key_env) + if not api_key: + raise RuntimeError(f"{config.provider.api_key_env} is required") + return DeepSeekClient(config.provider, api_key) + + +def run_pipeline( + *, + config: PipelineConfig, + client: ChatClient, + produces_dir: Path, + prompt: str, + candidates_count: int, + rough_attempts: int, + select_best: bool, + max_workers: int, +) -> dict[str, Any]: + produces_dir.mkdir(parents=True, exist_ok=True) + candidates: list[Candidate] = [] + with ThreadPoolExecutor(max_workers=max(1, min(max_workers, candidates_count))) as pool: + futures = [ + pool.submit( + run_candidate, + config=config, + client=client, + index=index, + produces_dir=produces_dir, + prompt=prompt, + rough_attempts=rough_attempts, + ) + for index in range(1, candidates_count + 1) + ] + for future in as_completed(futures): + candidates.append(future.result()) + candidates.sort(key=lambda candidate: candidate.index) + + if select_best: + winner_index, judge_reason = judge_best(config, client, candidates) + else: + winner_index = candidates[0].index + judge_reason = "Selection skipped; defaulted to first candidate." + selected = next(candidate for candidate in candidates if candidate.index == winner_index) + selected_path = write_selected_scene(produces_dir, selected, winner_index, judge_reason) + manifest = write_manifest( + produces_dir, + config=config, + prompt=prompt, + rough_attempts=rough_attempts, + candidates=candidates, + selected_index=winner_index, + selected_path=selected_path, + judge_reason=judge_reason, + ) + return manifest + + +def run_candidate( + *, + config: PipelineConfig, + client: ChatClient, + index: int, + produces_dir: Path, + prompt: str, + rough_attempts: int, +) -> Candidate: + run_id = f"{time.strftime('%Y%m%d_%H%M%S')}_{index:02d}_{os.getpid()}_{uuid.uuid4().hex[:8]}" + work_dir = produces_dir / "work" / run_id + work_dir.mkdir(parents=True, exist_ok=False) + + def rough(attempt_index: int) -> str: + content = client.complete( + [{"role": "system", "content": config.prompts["rough_system"]}, {"role": "user", "content": prompt}], + temperature=_float_default(config, "rough_temperature", 2.0), + max_tokens=_int_default(config, "max_tokens", 8192), + phase="rough", + candidate_index=index, + attempt_index=attempt_index, + ).strip() + write_text(work_dir / f"rough_{attempt_index:02d}.txt", content) + return content + + with ThreadPoolExecutor(max_workers=rough_attempts) as pool: + rough_scenes = list(pool.map(rough, range(1, rough_attempts + 1))) + + attempts_blob = format_attempts_blob(rough_scenes) + synth_prompt = render_template( + config.prompts["synth_template"], + prompt=prompt, + rough_attempts=rough_attempts, + attempts_blob=attempts_blob, + ) + draft_scene = client.complete( + [{"role": "system", "content": config.prompts["synth_system"]}, {"role": "user", "content": synth_prompt}], + temperature=_float_default(config, "synth_temperature", 1.0), + max_tokens=_int_default(config, "max_tokens", 8192), + phase="synth", + candidate_index=index, + ).strip() + write_text(work_dir / "draft_scene.txt", draft_scene) + + voice_prompt = render_template(config.prompts["voice_template"], draft_scene=draft_scene) + final_scene = client.complete( + [{"role": "system", "content": config.prompts["voice_system"]}, {"role": "user", "content": voice_prompt}], + temperature=_float_default(config, "voice_temperature", 1.0), + max_tokens=_int_default(config, "max_tokens", 8192), + phase="voice", + candidate_index=index, + ).strip() + md_path = write_candidate_markdown( + produces_dir, + config=config, + candidate_index=index, + run_id=run_id, + rough_attempts=rough_attempts, + final_scene=final_scene, + draft_scene=draft_scene, + attempts_blob=attempts_blob, + ) + return Candidate(index, work_dir, md_path, final_scene, draft_scene, attempts_blob) + + +def judge_best(config: PipelineConfig, client: ChatClient, candidates: list[Candidate]) -> tuple[int, str]: + if len(candidates) == 1: + return candidates[0].index, "Only one candidate was generated." + blob = "\n\n---\n\n".join(f"## Candidate {candidate.index}\n\n{candidate.final_scene}" for candidate in candidates) + content = client.complete( + [{"role": "system", "content": config.prompts["judge_system"]}, {"role": "user", "content": blob}], + temperature=_float_default(config, "judge_temperature", 0.2), + max_tokens=_int_default(config, "judge_max_tokens", 1024), + phase="judge", + ).strip() + try: + payload = json.loads(content) + winner = int(payload["winner"]) + reason = str(payload["reason"]) + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + raise RuntimeError(f"judge returned invalid JSON: {content}") from exc + if winner not in {candidate.index for candidate in candidates}: + raise RuntimeError(f"judge selected unknown candidate {winner}") + return winner, reason + + +def write_candidate_markdown( + produces_dir: Path, + *, + config: PipelineConfig, + candidate_index: int, + run_id: str, + rough_attempts: int, + final_scene: str, + draft_scene: str, + attempts_blob: str, +) -> Path: + md_path = produces_dir / "candidates" / f"candidate_{candidate_index:02d}_{run_id}.md" + markdown = f"""# {config.title} - candidate {candidate_index} + +*Pipeline:* +1. Rough ideation - {rough_attempts} attempts +2. Synthesis - structure pass +3. Voice/style pass + +--- + +## Final scene + +{final_scene} + +--- + +## Draft before voice/style pass + +{draft_scene} + +--- + +## Source attempts + +{attempts_blob} +""" + write_text(md_path, markdown) + return md_path + + +def write_selected_scene(produces_dir: Path, selected: Candidate, winner_index: int, judge_reason: str) -> Path: + selected_md = selected.md_path.read_text(encoding="utf-8") + selected_md += f"\n---\n\n## Selection\n\nWinner: candidate {winner_index}\n\n{judge_reason}\n" + selected_path = produces_dir / "selected_scene.md" + write_text(selected_path, selected_md) + return selected_path + + +def write_manifest( + produces_dir: Path, + *, + config: PipelineConfig, + prompt: str, + rough_attempts: int, + candidates: list[Candidate], + selected_index: int, + selected_path: Path, + judge_reason: str, +) -> dict[str, Any]: + manifest = { + "schema_version": 1, + "preset": config.preset_id, + "preset_path": str(config.source_path), + "provider": { + "name": config.provider.name, + "model": config.provider.model, + }, + "prompt": prompt, + "rough_attempts": rough_attempts, + "candidates": [ + { + "index": candidate.index, + "markdown": str(candidate.md_path), + "work_dir": str(candidate.work_dir), + } + for candidate in candidates + ], + "selected_index": selected_index, + "selected_scene": str(selected_path), + "judge_reason": judge_reason, + } + write_text(produces_dir / "manifest.json", json.dumps(manifest, indent=2) + "\n") + return manifest + + +def format_attempts_blob(scenes: list[str]) -> str: + return "\n\n---\n\n".join(f"### Attempt {index + 1}\n\n{scene.strip()}" for index, scene in enumerate(scenes)) + + +def render_template(template: str, **values: Any) -> str: + return template.format(**values) + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def load_prompt(args: argparse.Namespace, config: PipelineConfig) -> str: + if args.prompt_file: + return Path(args.prompt_file).read_text(encoding="utf-8").strip() + return str(args.prompt or config.prompt) + + +def _load_env_file(path: Path | None) -> None: + if path is None or not path.exists(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#") and "=" in stripped: + key, value = stripped.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a preset-driven script pipeline.") + parser.add_argument("--produces-dir", type=Path, required=True) + parser.add_argument("--preset", default=DEFAULT_PRESET, help="Built-in preset name or path to YAML/JSON.") + parser.add_argument("--config", type=Path, help="Explicit YAML/JSON preset config path.") + parser.add_argument("--prompt", help="Scene brief override.") + parser.add_argument("--prompt-file", type=Path, help="Read scene brief from a text file.") + parser.add_argument("--candidates", type=int, help="Complete pipeline candidates to generate.") + parser.add_argument("--rough-attempts", type=int, help="Rough attempts per candidate.") + parser.add_argument("--select-best", action="store_true", help="Run judge pass when multiple candidates exist.") + parser.add_argument("--fake", action="store_true", help="Use deterministic no-network responses.") + parser.add_argument("--max-workers", type=int, default=5, help="Maximum concurrent complete candidates.") + parser.add_argument("--env-file", type=Path, default=Path.home() / ".hermes" / ".env") + parser.add_argument("--open-result", action="store_true", help="Open selected_scene.md after writing it.") + parser.add_argument("--json", action="store_true", help="Print manifest JSON.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config = load_pipeline_config(args.preset, args.config) + candidates_count = int(args.candidates or config.defaults.get("candidates") or 1) + rough_attempts = int(args.rough_attempts or config.defaults.get("rough_attempts") or 1) + if candidates_count < 1: + raise SystemExit("--candidates must be >= 1") + if rough_attempts < 1: + raise SystemExit("--rough-attempts must be >= 1") + if args.max_workers < 1: + raise SystemExit("--max-workers must be >= 1") + + _load_env_file(args.env_file) + client = build_chat_client(config, fake=bool(args.fake)) + prompt = load_prompt(args, config) + select_best = bool(args.select_best or config.defaults.get("select_best")) + manifest = run_pipeline( + config=config, + client=client, + produces_dir=args.produces_dir, + prompt=prompt, + candidates_count=candidates_count, + rough_attempts=rough_attempts, + select_best=select_best, + max_workers=args.max_workers, + ) + if args.json: + print(json.dumps(manifest, indent=2)) + else: + print(f"selected: {manifest['selected_scene']}") + if args.open_result: + subprocess.run(["open", str(manifest["selected_scene"])], check=False) + return 0 + + +def _resolve_config_path(value: str | Path) -> Path: + path = Path(value).expanduser() + if path.suffix.lower() in {".yaml", ".yml", ".json"} or path.parent != Path("."): + return path.resolve() + return (PRESETS_DIR / f"{path}.yaml").resolve() + + +def _load_mapping(path: Path) -> dict[str, Any]: + text = path.read_text(encoding="utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + if yaml is None: + raise RuntimeError("PyYAML is required to parse script pipeline presets") + loaded = yaml.safe_load(text) + else: + loaded = json.loads(text) + if not isinstance(loaded, dict): + raise RuntimeError(f"script pipeline config must be an object: {path}") + return loaded + + +def _mapping(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise RuntimeError(f"{path} must be an object") + return value + + +def _required_str(values: dict[str, Any], key: str, path: str) -> str: + value = values.get(key) + if not isinstance(value, str) or not value: + raise RuntimeError(f"{path} is required") + return value + + +def _int_default(config: PipelineConfig, key: str, fallback: int) -> int: + value = config.defaults.get(key, fallback) + return int(value) + + +def _float_default(config: PipelineConfig, key: str, fallback: float) -> float: + value = config.defaults.get(key, fallback) + return float(value) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/astrid/packs/builtin/training_run/STAGE.md b/astrid/packs/builtin/training_run/STAGE.md index 250d043..20df6c7 100644 --- a/astrid/packs/builtin/training_run/STAGE.md +++ b/astrid/packs/builtin/training_run/STAGE.md @@ -50,9 +50,9 @@ python3 -m astrid.packs.builtin.training_run.run \ ## Seinfeld Example `examples/configs/training/seinfeld-training.yaml` carries the current Seinfeld -LoRA defaults as config for `builtin.training_run`. The existing -`seinfeld.lora_train` path is intentionally preserved for M3; removing or -migrating that pack-specific path is deferred to a later milestone. +LoRA defaults as config for `builtin.training_run`. Historical Seinfeld pack +notes are archived under `docs/examples/seinfeld/`; active runs should use this +built-in orchestrator and explicit example config. ## Live Run diff --git a/astrid/packs/builtin/training_run/__init__.py b/astrid/packs/builtin/training_run/__init__.py index 56e39e8..837cf49 100644 --- a/astrid/packs/builtin/training_run/__init__.py +++ b/astrid/packs/builtin/training_run/__init__.py @@ -1,3 +1,13 @@ """Support modules for the generic ``builtin.training_run`` orchestrator.""" -__all__ = ["ai_toolkit", "compute_backends", "config", "manifest", "manifest_input", "run", "state", "trainer_adapters"] +__all__ = [ + "ai_toolkit", + "compute_backends", + "config", + "defaults", + "manifest", + "manifest_input", + "run", + "state", + "trainer_adapters", +] diff --git a/astrid/packs/builtin/training_run/defaults.py b/astrid/packs/builtin/training_run/defaults.py new file mode 100644 index 0000000..7570b6f --- /dev/null +++ b/astrid/packs/builtin/training_run/defaults.py @@ -0,0 +1,33 @@ +"""Reusable defaults for built-in LTX training runs.""" + +from __future__ import annotations + +RUNPOD_LTX_DEFAULTS = { + "image": "ostris/aitoolkit:latest", + "ports": "8675/http,22/tcp", + "gpu_type": "NVIDIA RTX 6000 Ada Generation", + "container_disk_gb": 200, + "max_runtime_seconds": 43200, +} + +AI_TOOLKIT_LTX_DEFAULTS = { + "resolution": 512, + "resolution_buckets": [512, 768], + "num_frames": 121, + "fps": 24, + "learning_rate": 2.0e-5, + "steps_default": 2000, + "steps_smoke": 100, + "rank": 32, + "save_every": 250, + "sample_every": 250, + "batch_size": 1, + "gradient_accumulation_steps": 4, + "seed_default": 42, + "base_model_default": "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors", +} + +__all__ = [ + "AI_TOOLKIT_LTX_DEFAULTS", + "RUNPOD_LTX_DEFAULTS", +] diff --git a/astrid/packs/builtin/visual_understand/STAGE.md b/astrid/packs/builtin/visual_understand/STAGE.md index 4efcdc4..08e0627 100644 --- a/astrid/packs/builtin/visual_understand/STAGE.md +++ b/astrid/packs/builtin/visual_understand/STAGE.md @@ -38,7 +38,7 @@ schema, the name defaults to `"response"` and strict defaults to true. - **VLM bucket-judge / caption with locked vocab.** Generate a schema whose fields are enums over your vocabulary file; the model can't emit - out-of-vocab tokens. This is how `seinfeld.dataset_build` enforces + out-of-vocab tokens. This is how dataset-build configs can enforce caption-template adherence without a project-specific VLM wrapper. - **One-off image questions.** Skip `--response-schema` and use the free-text mode. diff --git a/astrid/packs/seinfeld/__init__.py b/astrid/packs/seinfeld/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/ai_toolkit/upstream b/astrid/packs/seinfeld/ai_toolkit/upstream deleted file mode 160000 index f38de2a..0000000 --- a/astrid/packs/seinfeld/ai_toolkit/upstream +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f38de2a2fedfafa4bf298806d1efcabb4a357cbc diff --git a/astrid/packs/seinfeld/aitoolkit_stage/__init__.py b/astrid/packs/seinfeld/aitoolkit_stage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/aitoolkit_stage/executor.yaml b/astrid/packs/seinfeld/aitoolkit_stage/executor.yaml deleted file mode 100644 index a1e9652..0000000 --- a/astrid/packs/seinfeld/aitoolkit_stage/executor.yaml +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "seinfeld.aitoolkit_stage", - "name": "Seinfeld AI Toolkit Stage", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Generate ai-toolkit job config from manifest + vocabulary; upload to pod; start AI Toolkit UI on :8675.", - "description": "Reads provisional.manifest.json + vocabulary.yaml, substitutes hivemind-validated LTX 2.3 defaults into config_template.yaml, writes a bootstrap.sh, and (unless --dry-run) ships config, bootstrap, and a manifest-filtered copy of the dataset to a live RunPod pod via external.runpod.exec, then starts the AI Toolkit UI on port 8675.", - "keywords": ["seinfeld", "ai-toolkit", "lora", "stage", "ltx"], - "inputs": [ - {"name": "pod_handle", "type": "file", "description": "Path to pod_handle.json from external.runpod.provision (omit for --dry-run)."}, - {"name": "manifest", "type": "file", "description": "provisional.manifest.json"}, - {"name": "vocabulary", "type": "file", "description": "vocabulary.yaml"}, - {"name": "dataset_remote_path", "type": "string", "description": "Remote pod folder for uploaded clip/caption dataset copies (default /workspace/dataset)."} - ], - "outputs": [ - {"name": "staged_config", "type": "file", "path_template": "{out}/produces/staged_config.yaml"}, - {"name": "bootstrap", "type": "file", "path_template": "{out}/produces/bootstrap.sh"}, - {"name": "ui_url", "type": "file", "path_template": "{out}/produces/ui_url.txt"}, - {"name": "dataset_upload", "type": "file", "path_template": "{out}/produces/dataset_upload.json"} - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.aitoolkit_stage.run", - "--manifest", "{manifest}", - "--vocabulary", "{vocabulary}", - "--produces-dir", "{out}/produces" - ] - }, - "cache": {"mode": "none"}, - "isolation": {"mode": "subprocess", "network": true}, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.aitoolkit_stage.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/aitoolkit_stage/run.py b/astrid/packs/seinfeld/aitoolkit_stage/run.py deleted file mode 100644 index 8a98e72..0000000 --- a/astrid/packs/seinfeld/aitoolkit_stage/run.py +++ /dev/null @@ -1,437 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.aitoolkit_stage — generate ai-toolkit config, bootstrap.sh, and (live) stage onto a pod.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -import sys -from pathlib import Path - -try: - import yaml -except ImportError: - yaml = None # type: ignore[assignment] - -HIVEMIND_DEFAULTS = { - "resolution": 512, - "resolution_buckets": [512, 768], - "num_frames": 121, - "fps": 24, - "lr": 2.0e-5, - "steps_default": 2000, - "steps_smoke": 100, - "rank": 32, - "save_every": 250, - "sample_every": 250, - "batch_size": 1, - "grad_accum": 4, - "seed_default": 42, - "trigger_word": "seinfeld scene", - "base_model_default": "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors", -} - -TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "lora_train" / "config_template.yaml" -REPO_ROOT = Path(__file__).resolve().parents[4] - - -def _load_yaml(path: Path) -> dict: - if yaml is None: - raise RuntimeError("PyYAML required (pip install pyyaml)") - with path.open("r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - - -def _dump_yaml(obj: dict, path: Path) -> None: - with path.open("w", encoding="utf-8") as f: - yaml.safe_dump(obj, f, sort_keys=False, default_flow_style=False) - - -def _build_sample_prompts(vocab: dict, n: int = 4) -> list[str]: - """Build a small list of inference prompts from the vocabulary.""" - scenes = list((vocab.get("scenes") or {}).items()) - chars = list((vocab.get("characters") or {}).keys()) - shots = list((vocab.get("shot_types") or {}).keys()) - prompts: list[str] = [] - for i in range(n): - scene_id = scenes[i % max(len(scenes), 1)][0] if scenes else "jerrys_apt" - char = chars[i % max(len(chars), 1)] if chars else "jerry" - shot = shots[i % max(len(shots), 1)] if shots else "medium" - prompts.append( - f"{HIVEMIND_DEFAULTS['trigger_word']}, A {shot} shot in {scene_id}. " - f"{char.capitalize()} talking. Seinfeld sitcom style, 90s NBC lighting." - ) - return prompts - - -def build_config( - *, - manifest: dict, - vocabulary: dict, - smoke: bool, - steps: int | None, - seed: int, - base_model: str, - run_name: str, - dataset_dir: str, - output_dir: str, -) -> dict: - if yaml is None: - raise RuntimeError("PyYAML required") - template_text = TEMPLATE_PATH.read_text(encoding="utf-8") - cfg = yaml.safe_load(template_text) - - final_steps = steps if steps is not None else ( - HIVEMIND_DEFAULTS["steps_smoke"] if smoke else HIVEMIND_DEFAULTS["steps_default"] - ) - prompts = _build_sample_prompts(vocabulary, n=3 if smoke else 4) - - process = cfg["config"]["process"][0] - process["training_folder"] = output_dir - process["trigger_word"] = HIVEMIND_DEFAULTS["trigger_word"] - process["network"]["linear"] = HIVEMIND_DEFAULTS["rank"] - process["network"]["linear_alpha"] = HIVEMIND_DEFAULTS["rank"] - process["save"]["save_every"] = HIVEMIND_DEFAULTS["save_every"] - process["datasets"][0]["folder_path"] = dataset_dir - process["datasets"][0]["num_frames"] = HIVEMIND_DEFAULTS["num_frames"] - process["datasets"][0]["fps"] = HIVEMIND_DEFAULTS["fps"] - process["datasets"][0]["resolution"] = list(HIVEMIND_DEFAULTS["resolution_buckets"]) - process["datasets"][0]["bucketing"] = True - process["datasets"][0]["cache_latents_to_disk"] = True - process["train"]["batch_size"] = HIVEMIND_DEFAULTS["batch_size"] - process["train"]["steps"] = final_steps - process["train"]["gradient_accumulation_steps"] = HIVEMIND_DEFAULTS["grad_accum"] - process["train"]["lr"] = HIVEMIND_DEFAULTS["lr"] - process["train"]["seed"] = seed - # Baseline/in-loop LTX video sampling has repeatedly killed otherwise healthy - # RunPod training runs. Train first; generate review samples as a separate - # post-training step after checkpoints are durable. - process["train"]["skip_first_sample"] = True - process["train"]["disable_sampling"] = True - process["model"]["name_or_path"] = base_model - process["model"]["is_ltx"] = True - process["sample"]["sample_every"] = HIVEMIND_DEFAULTS["sample_every"] - process["sample"]["width"] = HIVEMIND_DEFAULTS["resolution"] - process["sample"]["height"] = 768 - process["sample"]["num_frames"] = HIVEMIND_DEFAULTS["num_frames"] - process["sample"]["fps"] = HIVEMIND_DEFAULTS["fps"] - process["sample"]["seed"] = seed - process["sample"]["prompts"] = prompts - cfg["config"]["name"] = run_name - cfg.setdefault("meta", {})["name"] = run_name - return cfg - - -BOOTSTRAP_TEMPLATE = """#!/usr/bin/env bash -set -euo pipefail - -# AI Toolkit bootstrap - runs on the RunPod pod. -WORKSPACE=/workspace -TOOLKIT_ROOT=/app/ai-toolkit -UI_ROOT="$TOOLKIT_ROOT/ui" -UI_PORT=8675 - -mkdir -p "$WORKSPACE" -cd "$WORKSPACE" - -if [ -f /etc/rp_environment ]; then - set -a - # RunPod's image startup writes platform env here for later SSH sessions. - # shellcheck disable=SC1091 - source /etc/rp_environment - set +a -fi - -echo "Checking CUDA visibility..." -nvidia-smi - -if [ ! -d "$TOOLKIT_ROOT" ]; then - echo "ERROR: AI Toolkit root not found at $TOOLKIT_ROOT" >&2 - exit 2 -fi - -if [ ! -f "$TOOLKIT_ROOT/run.py" ]; then - echo "ERROR: AI Toolkit training entrypoint missing: $TOOLKIT_ROOT/run.py" >&2 - exit 2 -fi - -hf_token_value="${HF_TOKEN:-}" -if [ -z "$hf_token_value" ] && [ -r /proc/1/environ ]; then - hf_token_value="$(tr '\0' '\n' &2 - exit 5 -fi -umask 077 -if [ -f "$TOOLKIT_ROOT/.env" ]; then - grep -v '^HF_TOKEN=' "$TOOLKIT_ROOT/.env" > "$TOOLKIT_ROOT/.env.tmp" || true -else - : > "$TOOLKIT_ROOT/.env.tmp" -fi -printf 'HF_TOKEN=%s\n' "$hf_token_value" >> "$TOOLKIT_ROOT/.env.tmp" -mv "$TOOLKIT_ROOT/.env.tmp" "$TOOLKIT_ROOT/.env" -export HF_TOKEN="$hf_token_value" -unset hf_token_value - -if [ ! -f "$WORKSPACE/config.yaml" ]; then - echo "ERROR: expected config at $WORKSPACE/config.yaml" >&2 - exit 3 -fi - -# Dataset upload runs as the next stage exec call, after this bootstrap script. - -ui_up=0 -if command -v curl >/dev/null 2>&1; then - if curl -fsS "http://127.0.0.1:${UI_PORT}" >/dev/null 2>&1; then - ui_up=1 - fi -fi - -if [ "$ui_up" -eq 1 ]; then - echo "AI Toolkit UI already running on :${UI_PORT}" -else - if [ ! -d "$UI_ROOT" ]; then - echo "ERROR: AI Toolkit UI root not found at $UI_ROOT" >&2 - exit 4 - fi - echo "Starting AI Toolkit UI on :${UI_PORT}..." - cd "$UI_ROOT" - nohup npm run start >"$WORKSPACE/ui.log" 2>&1 & - echo $! >"$WORKSPACE/ui.pid" - for _ in $(seq 1 30); do - if curl -fsS "http://127.0.0.1:${UI_PORT}" >/dev/null 2>&1; then - echo "AI Toolkit UI started on :${UI_PORT} (pid=$(cat "$WORKSPACE/ui.pid"))" - exit 0 - fi - sleep 2 - done - echo "ERROR: AI Toolkit UI did not become ready on :${UI_PORT}" >&2 - exit 4 -fi -""" - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Stage ai-toolkit config + bootstrap onto a RunPod pod.") - p.add_argument("--manifest", type=Path, required=True) - p.add_argument("--vocabulary", type=Path, required=True) - p.add_argument("--produces-dir", type=Path, required=True) - p.add_argument("--pod-handle", type=Path, default=None, help="pod_handle.json from external.runpod.provision") - p.add_argument( - "--dataset-dir", - default=None, - help="Config dataset folder override. Defaults to --dataset-remote-path.", - ) - p.add_argument( - "--dataset-remote-path", - default="/workspace/dataset", - help="Remote pod folder where manifest clips + captions are uploaded.", - ) - p.add_argument("--output-dir", default="/workspace/output") - p.add_argument("--run-name", default="seinfeld-scene-v1") - p.add_argument("--base-model", default=HIVEMIND_DEFAULTS["base_model_default"]) - p.add_argument("--steps", type=int, default=None) - p.add_argument("--seed", type=int, default=HIVEMIND_DEFAULTS["seed_default"]) - p.add_argument("--smoke", action="store_true") - p.add_argument("--dry-run", action="store_true") - return p - - -def _resolve_manifest_path(path_value: str, manifest_dir: Path) -> Path: - path = Path(path_value) - if path.is_absolute(): - return path - repo_path = (REPO_ROOT / path).resolve() - if repo_path.exists(): - return repo_path - return (manifest_dir / path).resolve() - - -def _safe_path_part(value: str) -> str: - return "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in value) or "clips" - - -def _dataset_entries(manifest_path: Path, smoke: bool) -> list[tuple[str, Path, Path, str]]: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - manifest_dir = manifest_path.parent - clips = manifest.get("clips") or [] - if smoke: - clips = clips[:5] - - entries: list[tuple[str, Path, Path, str]] = [] - for idx, clip in enumerate(clips): - clip_file = clip.get("clip_file") or clip.get("path") - clip_id = clip.get("clip_id") or clip.get("id") or ( - Path(clip_file).stem if clip_file else f"clip_{idx:03d}" - ) - if not clip_file: - raise ValueError(f"manifest clip {idx} is missing clip_file") - - clip_path = _resolve_manifest_path(str(clip_file), manifest_dir) - caption_value = clip.get("caption_file") - caption_path = ( - _resolve_manifest_path(str(caption_value), manifest_dir) - if caption_value - else clip_path.with_name(f"{clip_id}.caption.json") - ) - if not clip_path.is_file(): - raise FileNotFoundError(f"clip_file missing: {clip_path}") - if not caption_path.is_file(): - raise FileNotFoundError(f"caption_file missing: {caption_path}") - - bucket = _safe_path_part(str(clip.get("bucket") or clip_path.parent.name or "clips")) - entries.append((str(clip_id), clip_path, caption_path, bucket)) - return entries - - -def _upload_dataset(args: argparse.Namespace, produces: Path, pod_handle: dict) -> int: - """Copy manifest clips into a staging farm and upload them to the pod.""" - try: - entries = _dataset_entries(args.manifest, args.smoke) - except (OSError, ValueError, json.JSONDecodeError) as exc: - print(f"aitoolkit_stage: dataset staging failed: {exc}", file=sys.stderr) - return 3 - - dataset_staging = produces / "_dataset_staging" - if dataset_staging.exists(): - shutil.rmtree(dataset_staging) - dataset_staging.mkdir(parents=True, exist_ok=True) - - for clip_id, clip_path, caption_path, bucket in entries: - bucket_dir = dataset_staging / bucket - bucket_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(clip_path, bucket_dir / f"{clip_id}{clip_path.suffix}") - shutil.copy2(caption_path, bucket_dir / f"{clip_id}.caption.json") - - file_count = len(entries) * 2 - result = { - "status": "dry_run" if args.dry_run else "staged", - "strategy": "copy_farm", - "clips": len(entries), - "files": file_count, - "local_root": str(dataset_staging.resolve()), - "remote_root": args.dataset_remote_path, - "pod_id": pod_handle.get("pod_id") or pod_handle.get("id"), - } - (produces / "dataset_upload.json").write_text( - json.dumps(result, indent=2) + "\n", encoding="utf-8" - ) - - if args.dry_run: - print( - "aitoolkit_stage: dry-run would upload " - f"{len(entries)} clips + captions from {dataset_staging} " - f"to {args.dataset_remote_path}" - ) - return 0 - - exec_produces = produces / "_dataset_exec_produces" - exec_produces.mkdir(parents=True, exist_ok=True) - exec_argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "exec", - "--produces-dir", str(exec_produces), - "--pod-handle", str(args.pod_handle), - "--local-root", str(dataset_staging), - "--remote-root", args.dataset_remote_path, - "--upload-mode", "sftp_walk", - "--remote-script", f"echo dataset_staged {len(entries)} clips", - ] - rv = subprocess.run(exec_argv, cwd=REPO_ROOT) - if rv.returncode != 0: - print(f"aitoolkit_stage: dataset upload failed rc={rv.returncode}", file=sys.stderr) - return rv.returncode - print(f"aitoolkit_stage: uploaded {len(entries)} dataset clips to {args.dataset_remote_path}") - return 0 - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - if args.dataset_dir is None: - args.dataset_dir = args.dataset_remote_path - else: - args.dataset_remote_path = args.dataset_dir - produces = args.produces_dir - produces.mkdir(parents=True, exist_ok=True) - - if yaml is None: - print("ERROR: PyYAML not installed", file=sys.stderr) - return 3 - - manifest = json.loads(args.manifest.read_text(encoding="utf-8")) - vocabulary = _load_yaml(args.vocabulary) - - cfg = build_config( - manifest=manifest, - vocabulary=vocabulary, - smoke=args.smoke, - steps=args.steps, - seed=args.seed, - base_model=args.base_model, - run_name=args.run_name, - dataset_dir=args.dataset_dir, - output_dir=args.output_dir, - ) - - staged_path = produces / "staged_config.yaml" - bootstrap_path = produces / "bootstrap.sh" - _dump_yaml(cfg, staged_path) - bootstrap_path.write_text(BOOTSTRAP_TEMPLATE, encoding="utf-8") - bootstrap_path.chmod(0o755) - - if args.dry_run or not args.pod_handle: - if args.dry_run: - rc = _upload_dataset(args, produces, {}) - if rc != 0: - return rc - ui_url = "" - (produces / "ui_url.txt").write_text(ui_url + "\n", encoding="utf-8") - result = { - "status": "dry_run" if args.dry_run else "staged_local_only", - "staged_config": str(staged_path.resolve()), - "bootstrap": str(bootstrap_path.resolve()), - } - (produces / "stage_result.json").write_text( - json.dumps(result, indent=2) + "\n", encoding="utf-8" - ) - print(f"aitoolkit_stage: wrote {staged_path}") - return 0 - - # Live mode: ship config + bootstrap to pod, run bootstrap, derive UI URL. - pod_handle = json.loads(args.pod_handle.read_text(encoding="utf-8")) - pod_id = pod_handle.get("pod_id") or pod_handle.get("id") - ui_url = f"https://{pod_id}-8675.proxy.runpod.net" if pod_id else "" - (produces / "ui_url.txt").write_text(ui_url + "\n", encoding="utf-8") - print(f"aitoolkit_stage: AI Toolkit UI URL → {ui_url}") - - # Delegate file shipping + bootstrap exec to external.runpod.exec. - # external.runpod.exec interface: --local-root + --remote-root - # + --remote-script + required --produces-dir. - upload_dir = produces / "_upload_staging" - upload_dir.mkdir(parents=True, exist_ok=True) - shutil.copy(staged_path, upload_dir / "config.yaml") - shutil.copy(bootstrap_path, upload_dir / "bootstrap.sh") - exec_produces = produces / "_exec_produces" - exec_produces.mkdir(parents=True, exist_ok=True) - exec_argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "exec", - "--produces-dir", str(exec_produces), - "--pod-handle", str(args.pod_handle), - "--local-root", str(upload_dir), - "--remote-root", "/workspace", - "--remote-script", "bash /workspace/bootstrap.sh", - ] - rv = subprocess.run(exec_argv, cwd=REPO_ROOT) - if rv.returncode != 0: - print(f"aitoolkit_stage: external.runpod.exec failed rc={rv.returncode}", file=sys.stderr) - return rv.returncode - rv_dataset = _upload_dataset(args, produces, pod_handle) - if rv_dataset != 0: - return rv_dataset - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/aitoolkit_train/__init__.py b/astrid/packs/seinfeld/aitoolkit_train/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/aitoolkit_train/executor.yaml b/astrid/packs/seinfeld/aitoolkit_train/executor.yaml deleted file mode 100644 index 5458ce7..0000000 --- a/astrid/packs/seinfeld/aitoolkit_train/executor.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "seinfeld.aitoolkit_train", - "name": "Seinfeld AI Toolkit Train", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Kick off ai-toolkit training on a pod and mirror remote logs locally.", - "description": "Starts `ai-toolkit run /workspace/config.yaml` on a live RunPod pod via external.runpod.exec, polls /workspace/output for checkpoints, mirrors the remote training log into /training.log, and emits checkpoint_manifest.json. On detected crashes (CUDA OOM / NaN / RuntimeError) writes training.failure.log, marks status=failed, returns non-zero.", - "keywords": ["seinfeld", "ai-toolkit", "lora", "train"], - "inputs": [ - {"name": "pod_handle", "type": "file"}, - {"name": "config_path", "type": "string", "description": "Path on pod to the staged config.yaml (default /workspace/config.yaml)."} - ], - "outputs": [ - {"name": "checkpoint_manifest", "type": "file", "path_template": "{out}/produces/checkpoint_manifest.json"}, - {"name": "training_log", "type": "file", "path_template": "{out}/produces/training.log"} - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.aitoolkit_train.run", - "--pod-handle", "{pod_handle}", - "--produces-dir", "{out}/produces" - ] - }, - "cache": {"mode": "none"}, - "isolation": {"mode": "subprocess", "network": true}, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.aitoolkit_train.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/aitoolkit_train/run.py b/astrid/packs/seinfeld/aitoolkit_train/run.py deleted file mode 100644 index 1f7677c..0000000 --- a/astrid/packs/seinfeld/aitoolkit_train/run.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.aitoolkit_train — start training on pod, mirror logs, emit checkpoint manifest.""" - -from __future__ import annotations - -import argparse -import json -import re -import shlex -import subprocess -import sys -from pathlib import Path - -FAILURE_PATTERNS = [ - "CUDA out of memory", - "NaN detected", - "RuntimeError", - "torch.cuda.OutOfMemoryError", -] -CHECKPOINT_RE = re.compile(r"step[_-]?(\d+).*\.safetensors$", re.IGNORECASE) - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Run ai-toolkit training on a pod and mirror logs.") - p.add_argument("--pod-handle", type=Path, required=True) - p.add_argument("--produces-dir", type=Path, required=True) - p.add_argument("--config-path", default="/workspace/config.yaml") - p.add_argument("--output-dir", default="/workspace/output") - p.add_argument("--remote-log", default="/workspace/training.log") - p.add_argument( - "--timeout", - type=int, - default=28800, - help="RunPod exec wait timeout in seconds. Default 8h; LTX LoRA runs can exceed 4h.", - ) - p.add_argument("--dry-run", action="store_true") - return p - - -def _scan_failures(log_text: str) -> str | None: - for pat in FAILURE_PATTERNS: - if pat in log_text: - return pat - return None - - -def _parse_checkpoint_listing(listing: str, output_dir: str) -> list[dict]: - out: list[dict] = [] - for raw in listing.splitlines(): - line = raw.strip() - if not line.endswith(".safetensors"): - continue - m = CHECKPOINT_RE.search(line) - step = int(m.group(1)) if m else -1 - name = line.split()[-1] - out.append({"step": step, "remote_path": f"{output_dir}/{name}"}) - out.sort(key=lambda c: c["step"]) - return out - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - produces = args.produces_dir - produces.mkdir(parents=True, exist_ok=True) - training_log = produces / "training.log" - manifest_path = produces / "checkpoint_manifest.json" - - if args.dry_run: - manifest_path.write_text( - json.dumps({"status": "dry_run", "checkpoints": []}, indent=2) + "\n", - encoding="utf-8", - ) - training_log.write_text("(dry-run)\n", encoding="utf-8") - return 0 - - repo_root = Path(__file__).resolve().parents[4] - - # Kick off training (blocking — ai-toolkit streams its log to stdout, which we capture). - train_inner = ( - "set -o pipefail; " - "cd /app/ai-toolkit && " - f"python3 run.py {shlex.quote(args.config_path)} 2>&1 | tee {shlex.quote(args.remote_log)}" - ) - train_cmd = f"bash -lc {shlex.quote(train_inner)}" - exec_produces = produces / "_exec_train" - exec_produces.mkdir(parents=True, exist_ok=True) - # Use an empty staging dir as --local-root so cmd_exec doesn't upload the cwd - # (training only needs to run a remote command; config + dataset are already on the pod). - empty_local_root = produces / "_empty_local_root" - empty_local_root.mkdir(parents=True, exist_ok=True) - exec_argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "exec", - "--produces-dir", str(exec_produces), - "--pod-handle", str(args.pod_handle), - "--local-root", str(empty_local_root), - "--timeout", str(args.timeout), - "--remote-script", train_cmd, - ] - rv = subprocess.run(exec_argv, cwd=repo_root) - - # Mirror remote stdout into local training.log from exec_result.json. - result_json = exec_produces / "exec_result.json" - result_data = {} - if result_json.exists(): - try: - result_data = json.loads(result_json.read_text(encoding="utf-8")) - except json.JSONDecodeError: - pass - try: - remote_rc = int(result_data.get("returncode", -1)) - except (TypeError, ValueError): - remote_rc = -1 - training_log.write_text( - (result_data.get("stdout", "") or "") + (result_data.get("stderr", "") or ""), - encoding="utf-8", - ) - log_text = training_log.read_text(encoding="utf-8") if training_log.exists() else "" - failure = _scan_failures(log_text) - - if failure or remote_rc != 0 or rv.returncode != 0: - tail = "\n".join(log_text.splitlines()[-200:]) - (produces / "training.failure.log").write_text(tail, encoding="utf-8") - manifest_path.write_text( - json.dumps( - { - "status": "failed", - "reason": failure or f"remote_exit_{remote_rc}_wrapper_{rv.returncode}", - "checkpoints": [], - }, - indent=2, - ) + "\n", - encoding="utf-8", - ) - print( - f"aitoolkit_train: FAILED (remote_rc={remote_rc} wrapper_rc={rv.returncode} pattern={failure})", - file=sys.stderr, - ) - return remote_rc or rv.returncode or 4 - - # Enumerate checkpoints on the pod. - list_produces = produces / "_exec_list" - list_produces.mkdir(parents=True, exist_ok=True) - list_argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "exec", - "--produces-dir", str(list_produces), - "--pod-handle", str(args.pod_handle), - "--local-root", str(empty_local_root), - "--remote-script", f"ls -1 {args.output_dir}/*.safetensors 2>/dev/null || true", - ] - subprocess.run(list_argv, cwd=repo_root) - list_result = list_produces / "exec_result.json" - listing = "" - if list_result.exists(): - try: - listing = json.loads(list_result.read_text(encoding="utf-8")).get("stdout", "") - except Exception: - pass - checkpoints = _parse_checkpoint_listing(listing, args.output_dir) - manifest_path.write_text( - json.dumps({"status": "ok", "checkpoints": checkpoints}, indent=2) + "\n", - encoding="utf-8", - ) - print(f"aitoolkit_train: {len(checkpoints)} checkpoint(s) → {manifest_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/dataset_build/__init__.py b/astrid/packs/seinfeld/dataset_build/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/dataset_build/orchestrator.yaml b/astrid/packs/seinfeld/dataset_build/orchestrator.yaml deleted file mode 100644 index 7afca75..0000000 --- a/astrid/packs/seinfeld/dataset_build/orchestrator.yaml +++ /dev/null @@ -1,34 +0,0 @@ -{ - "id": "seinfeld.dataset_build", - "name": "Seinfeld Dataset Build", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Bucket-fill loop that builds the Seinfeld LoRA training set from YouTube.", - "description": "Reads the locked-vocabulary criteria element, searches YouTube, downloads video, segments scenes, VLM-judges each clip into a vocabulary bucket, VLM-captions it against the locked template, human-reviews a sample, exports the training manifest. Runs until per-bucket targets are met.", - "keywords": ["seinfeld", "dataset", "lora", "training", "youtube", "vlm", "captioning"], - "runtime": { - "kind": "command", - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.dataset_build.run", - "{orchestrator_args}" - ] - } - }, - "child_executors": [ - "builtin.youtube_audio", - "builtin.scenes", - "builtin.visual_understand", - "builtin.video_understand" - ], - "child_orchestrators": [], - "cache": { - "mode": "none" - }, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.dataset_build.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/dataset_build/run.py b/astrid/packs/seinfeld/dataset_build/run.py deleted file mode 100644 index f2d7a3d..0000000 --- a/astrid/packs/seinfeld/dataset_build/run.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -"""Seinfeld dataset_build orchestrator — bucket-fill loop. - -Searches YouTube, downloads candidate videos, segments scenes, judges each -scene against the locked vocabulary, captions the accepted ones, and writes -a training manifest. -""" - -from __future__ import annotations - -import argparse -import json -import os -import random -import subprocess -import sys -import time -from pathlib import Path -from typing import Any - -# Per-bucket search queries — multiple per bucket for variety. -BUCKET_QUERIES: dict[str, list[str]] = { - "jerrys_apt": [ - "seinfeld jerry kramer apartment scene HD", - "seinfeld jerry george apartment conversation", - "seinfeld kramer enters jerry apartment", - "seinfeld jerry apartment full scene", - "seinfeld elaine in jerry's apartment", - ], - "monks_diner": [ - "seinfeld monks diner george elaine full scene", - "seinfeld jerry george monks coffee shop", - "seinfeld elaine monks diner scene", - "seinfeld monks restaurant booth scene", - "seinfeld kramer monks diner", - ], -} - -# Per-bucket query hints for the VLM judge. -BUCKET_DESCRIPTIONS = { - "jerrys_apt": "Jerry's apartment = exposed brick, beige kitchen cabinets, kitchen island, big window in living room.", - "monks_diner": "Monk's diner = restaurant booth with formica table and vinyl seats, diner counter visible behind, large hanging lamps.", -} - - -def _run(cmd: list[str], env: dict | None = None, timeout: int = 600) -> subprocess.CompletedProcess: - return subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout) - - -def _pyenv_env() -> dict: - return {**os.environ, "PYENV_VERSION": "3.11.11"} - - -def yt_search(query: str, n: int, log) -> list[dict]: - env = _pyenv_env() - proc = _run( - ["yt-dlp", "--skip-download", "--flat-playlist", - "--print", "%(id)s|%(title)s|%(duration)s", - f"ytsearch{n}:{query}"], - env=env, - ) - out = [] - for line in proc.stdout.splitlines(): - if "|" not in line: - continue - parts = line.split("|", 2) - if len(parts) < 3: - continue - vid, title, dur = parts - try: - dur_s = float(dur) - except ValueError: - dur_s = 0.0 - out.append({"id": vid, "title": title, "duration": dur_s, - "url": f"https://www.youtube.com/watch?v={vid}"}) - log(f" search '{query}' → {len(out)} results") - return out - - -def yt_download(url: str, out_no_ext: Path, log) -> Path | None: - env = _pyenv_env() - proc = _run([ - "python3", "-m", "astrid.packs.builtin.youtube_audio.run", - "--url", url, "--mode", "video", - "--out", str(out_no_ext), - ], env=env, timeout=900) - target = out_no_ext.with_suffix(".mp4") - if proc.returncode != 0 or not target.exists(): - log(f" download FAILED: {proc.stderr[-200:].strip()}") - return None - return target - - -def detect_scenes(video: Path, out_json: Path, log) -> list[dict]: - env = _pyenv_env() - proc = _run([ - "python3", "-m", "astrid.packs.builtin.scenes.run", - "--video", str(video), "--out", str(out_json), - ], env=env, timeout=600) - if proc.returncode != 0 or not out_json.exists(): - log(f" scenes FAILED: {proc.stderr[-200:].strip()}") - return [] - scenes = json.loads(out_json.read_text()) - return scenes if isinstance(scenes, list) else [] - - -def vlm_call(video: Path, at_s: float, query: str, schema_path: Path, mode: str, out_json: Path, log) -> dict | None: - env = _pyenv_env() - proc = _run([ - "python3", "-m", "astrid.packs.builtin.visual_understand.run", - "--video", str(video), "--at", f"{at_s:.2f}", - "--query", query, - "--response-schema", str(schema_path), - "--env-file", ".env.local", - "--mode", mode, - "--out", str(out_json), - ], env=env, timeout=180) - if proc.returncode != 0 or not out_json.exists(): - log(f" VLM FAILED: {proc.stderr[-200:].strip()}") - return None - try: - r = json.loads(out_json.read_text())["results"][0] - if r["status"] != "ok": - log(f" VLM error: {r.get('error', '')[:200]}") - return None - return json.loads(r["answer"]) - except Exception as exc: - log(f" VLM parse error: {exc}") - return None - - -def cut_clip(source: Path, start: float, end: float, out: Path, log) -> bool: - out.parent.mkdir(parents=True, exist_ok=True) - duration = end - start - proc = _run([ - "ffmpeg", "-y", "-ss", f"{start:.2f}", "-i", str(source), - "-t", f"{duration:.2f}", - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", - "-c:a", "aac", "-b:a", "128k", - "-movflags", "+faststart", str(out), - ]) - if proc.returncode != 0 or not out.exists(): - log(f" ffmpeg cut FAILED: {proc.stderr[-200:].strip()}") - return False - return True - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--vocabulary", type=Path, - default=Path("astrid/packs/seinfeld/vocabulary.yaml")) - p.add_argument("--schemas-dir", type=Path, - default=Path("astrid/packs/seinfeld/schemas")) - p.add_argument("--target", type=int, default=15, - help="Target accepted clips per bucket.") - p.add_argument("--buckets", nargs="+", default=["jerrys_apt", "monks_diner"], - help="Bucket ids to fill.") - p.add_argument("--candidates-per-search", type=int, default=3, - help="YouTube results pulled per search query.") - p.add_argument("--max-scenes-per-video", type=int, default=10, - help="Cap per-video scene judgements (longest scenes first).") - p.add_argument("--min-scene-s", type=float, default=2.5) - p.add_argument("--max-scene-s", type=float, default=15.0) - p.add_argument("--judge-mode", default="fast") - p.add_argument("--caption-mode", default="best") - p.add_argument("--out", type=Path, required=True) - p.add_argument("--dry-run", action="store_true") - return p - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - out: Path = args.out - out.mkdir(parents=True, exist_ok=True) - state_path = out / "state.json" - log_path = out / "build.log" - - state: dict[str, Any] - if state_path.exists(): - state = json.loads(state_path.read_text()) - else: - state = { - "buckets": {b: {"accepted": 0, "clips": []} for b in args.buckets}, - "processed_video_ids": [], - "started_at": time.time(), - } - for b in args.buckets: - state["buckets"].setdefault(b, {"accepted": 0, "clips": []}) - - def save_state(): - state_path.write_text(json.dumps(state, indent=2)) - - def log(msg: str): - line = f"[{time.strftime('%H:%M:%S')}] {msg}" - print(line, flush=True) - with log_path.open("a") as f: - f.write(line + "\n") - - if args.dry_run: - log("DRY RUN — would search and process candidates per bucket:") - for b in args.buckets: - log(f" {b}: target={args.target}, queries={BUCKET_QUERIES.get(b, [])}") - return 0 - - judge_schema = args.schemas_dir / "bucket_judge.json" - caption_schema = args.schemas_dir / "caption.json" - for p in (judge_schema, caption_schema): - if not p.is_file(): - log(f"FATAL: missing schema {p}") - return 2 - - candidates_root = out / "candidates" - candidates_root.mkdir(exist_ok=True) - accepted_root = out / "accepted" - accepted_root.mkdir(exist_ok=True) - - # Build the candidate URL queue (deduped, shuffled across buckets). - queue: list[tuple[str, dict]] = [] # (bucket, candidate) - for bucket in args.buckets: - if state["buckets"][bucket]["accepted"] >= args.target: - log(f"bucket {bucket} already at target") - continue - for query in BUCKET_QUERIES.get(bucket, []): - for cand in yt_search(query, args.candidates_per_search, log): - if cand["id"] in state["processed_video_ids"]: - continue - queue.append((bucket, cand)) - # Deduplicate by video id while preserving bucket-of-first-see. - seen_ids: set[str] = set() - deduped: list[tuple[str, dict]] = [] - for bucket, cand in queue: - if cand["id"] in seen_ids: - continue - seen_ids.add(cand["id"]) - deduped.append((bucket, cand)) - random.seed(42) - random.shuffle(deduped) - log(f"queue: {len(deduped)} unique candidate videos across {len(args.buckets)} buckets") - - for bucket, cand in deduped: - if all(state["buckets"][b]["accepted"] >= args.target for b in args.buckets): - log("all buckets full — stopping") - break - if state["buckets"][bucket]["accepted"] >= args.target and not any( - state["buckets"][b]["accepted"] < args.target for b in args.buckets - ): - continue - - vid = cand["id"] - log(f"=== video {vid} ({cand['duration']:.0f}s) primary_bucket={bucket} ===") - log(f" title: {cand['title']!r}") - if cand["duration"] > 0 and cand["duration"] > 1800: - log(" skip: > 30 min (likely compilation, too expensive)") - state["processed_video_ids"].append(vid) - save_state() - continue - vid_dir = candidates_root / vid - vid_dir.mkdir(exist_ok=True) - video_path = yt_download(cand["url"], vid_dir / "source", log) - if video_path is None: - state["processed_video_ids"].append(vid) - save_state() - continue - - scenes_path = vid_dir / "scenes.json" - scenes = detect_scenes(video_path, scenes_path, log) - log(f" {len(scenes)} scenes detected") - - usable = [s for s in scenes if args.min_scene_s <= s["duration"] <= args.max_scene_s] - usable.sort(key=lambda s: -s["duration"]) - usable = usable[: args.max_scenes_per_video] - log(f" {len(usable)} usable scenes after duration filter") - - for scene in usable: - # Only judge if any bucket still has room. - if all(state["buckets"][b]["accepted"] >= args.target for b in args.buckets): - break - - mid = (scene["start"] + scene["end"]) / 2 - sidx = scene["index"] - judge_out = vid_dir / f"scene-{sidx:02d}.judge.json" - descriptions = "; ".join(f"{k}: {v}" for k, v in BUCKET_DESCRIPTIONS.items()) - judge_query = ( - f"Classify this Seinfeld frame strictly per the schema. " - f"Bucket descriptions: {descriptions}. " - "Characters: Jerry mid-30s short dark hair; George short stocky balding glasses; " - "Elaine curly dark hair; Kramer tall lanky wild hair. Be conservative — " - "if uncertain, set accept=false and bucket=null. " - "Reject talking-head, title cards, credits, ad cards, or non-show footage." - ) - j = vlm_call(video_path, mid, judge_query, judge_schema, args.judge_mode, judge_out, log) - if j is None: - continue - log(f" scene {sidx} ({scene['duration']:.1f}s @ {mid:.1f}s): " - f"accept={j['accept']} bucket={j['bucket']} chars={j['characters_visible']} " - f"conf={j['confidence']:.2f}") - - if not j["accept"]: - continue - target_bucket = j["bucket"] - if target_bucket not in args.buckets: - continue - if state["buckets"][target_bucket]["accepted"] >= args.target: - log(f" bucket {target_bucket} already full — skipping") - continue - - # Cut clip - clip_id = f"{vid}-s{sidx:02d}" - clip_path = accepted_root / target_bucket / f"{clip_id}.mp4" - if not cut_clip(video_path, scene["start"], scene["end"], clip_path, log): - continue - - # Caption (best mode — accepted only) - caption_out = accepted_root / target_bucket / f"{clip_id}.caption.json" - caption_query = ( - f"Caption this Seinfeld clip frame for the locked-template schema. " - f"Scene is {target_bucket}. Pick characters from those visible and the closest outfit " - f"token for each. Don't invent new tokens. Assemble the final caption string per template." - ) - cap = vlm_call(video_path, mid, caption_query, caption_schema, args.caption_mode, caption_out, log) - if cap is None: - clip_path.unlink(missing_ok=True) - continue - log(f" CAPTION: {cap['caption']}") - - state["buckets"][target_bucket]["accepted"] += 1 - state["buckets"][target_bucket]["clips"].append({ - "clip_id": clip_id, - "video_id": vid, - "scene_index": sidx, - "source_url": cand["url"], - "start_s": scene["start"], - "end_s": scene["end"], - "duration_s": scene["duration"], - "clip_file": str(clip_path), - "caption_file": str(caption_out), - "caption": cap["caption"], - "judge_confidence": j["confidence"], - }) - save_state() - log(f" --> bucket {target_bucket} now {state['buckets'][target_bucket]['accepted']}/{args.target}") - - state["processed_video_ids"].append(vid) - save_state() - log(f" progress: " + " | ".join( - f"{b}={state['buckets'][b]['accepted']}/{args.target}" for b in args.buckets - )) - - # Write manifest. - manifest = { - "vocabulary": str(args.vocabulary), - "buckets": {b: state["buckets"][b]["clips"] for b in args.buckets}, - "stats": {b: state["buckets"][b]["accepted"] for b in args.buckets}, - "completed_at": time.time(), - } - (out / "manifest.json").write_text(json.dumps(manifest, indent=2)) - log(f"\nDONE. manifest at {out / 'manifest.json'}") - log(f"final: " + " | ".join( - f"{b}={state['buckets'][b]['accepted']}/{args.target}" for b in args.buckets - )) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/lora_eval_grid/__init__.py b/astrid/packs/seinfeld/lora_eval_grid/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/lora_eval_grid/executor.yaml b/astrid/packs/seinfeld/lora_eval_grid/executor.yaml deleted file mode 100644 index f032ff9..0000000 --- a/astrid/packs/seinfeld/lora_eval_grid/executor.yaml +++ /dev/null @@ -1,34 +0,0 @@ -{ - "id": "seinfeld.lora_eval_grid", - "name": "Seinfeld LoRA Eval Grid", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Run baseline LTX + per-checkpoint inference samples, download MP4s, write static index.html viewer.", - "description": "Builds 3-6 prompts from the vocabulary, runs baseline LTX (no LoRA) plus inference for each checkpoint listed in checkpoint_manifest.json on the live pod via external.runpod.exec, downloads MP4s into eval_grid/{baseline,}/, emits eval_grid/index.html. --smoke produces 3 baseline-only samples.", - "keywords": ["seinfeld", "lora", "eval", "grid", "ltx"], - "inputs": [ - {"name": "pod_handle", "type": "file"}, - {"name": "checkpoint_manifest", "type": "file"}, - {"name": "vocabulary", "type": "file"} - ], - "outputs": [ - {"name": "eval_grid_index", "type": "file", "path_template": "{out}/produces/eval_grid/index.html"} - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.lora_eval_grid.run", - "--pod-handle", "{pod_handle}", - "--checkpoint-manifest", "{checkpoint_manifest}", - "--vocabulary", "{vocabulary}", - "--produces-dir", "{out}/produces" - ] - }, - "cache": {"mode": "none"}, - "isolation": {"mode": "subprocess", "network": true}, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.lora_eval_grid.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/lora_eval_grid/run.py b/astrid/packs/seinfeld/lora_eval_grid/run.py deleted file mode 100644 index 82fb36b..0000000 --- a/astrid/packs/seinfeld/lora_eval_grid/run.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.lora_eval_grid — baseline + per-checkpoint inference + index.html viewer.""" - -from __future__ import annotations - -import argparse -import html -import json -import subprocess -import sys -from pathlib import Path - -try: - import yaml -except ImportError: - yaml = None # type: ignore[assignment] - -TRIGGER = "seinfeld scene" - - -def _build_prompts(vocab: dict, smoke: bool) -> list[str]: - scenes = list((vocab.get("scenes") or {}).keys()) - chars = list((vocab.get("characters") or {}).keys()) - shots = list((vocab.get("shot_types") or {}).keys()) or ["medium"] - out: list[str] = [] - n = 3 if smoke else 6 - for i in range(n): - scene = scenes[i % max(len(scenes), 1)] if scenes else "jerrys_apt" - char = chars[i % max(len(chars), 1)] if chars else "jerry" - shot = shots[i % len(shots)] - out.append( - f"{TRIGGER}, A {shot} shot in {scene}. {char.capitalize()} talking. " - f"Seinfeld sitcom style, 90s NBC lighting, multi-cam look." - ) - return out - - -def _render_index_html(prompts: list[str], buckets: list[str], grid_dir: Path) -> str: - rows: list[str] = [] - for pi, prompt in enumerate(prompts): - cells: list[str] = [] - for b in buckets: - mp4 = f"{b}/prompt_{pi:02d}.mp4" - local_mp4 = grid_dir / mp4 - if local_mp4.exists(): - body = f'' - else: - body = '
missing local asset
' - cells.append(f'
{html.escape(b)}
{body}') - rows.append( - f"{html.escape(prompt)}{''.join(cells)}" - ) - head_cells = "prompt" + "".join(f"{html.escape(b)}" for b in buckets) - return ( - "" - "Seinfeld LoRA Eval Grid" - "" - "

Seinfeld LoRA Eval Grid

" - f"{head_cells}" - f"{''.join(rows)}
\n" - ) - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Run baseline + per-checkpoint samples and build a grid.") - p.add_argument("--pod-handle", type=Path, required=True) - p.add_argument("--checkpoint-manifest", type=Path, required=True) - p.add_argument("--vocabulary", type=Path, required=True) - p.add_argument("--produces-dir", type=Path, required=True) - p.add_argument("--smoke", action="store_true") - p.add_argument("--dry-run", action="store_true") - return p - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - produces = args.produces_dir - grid_dir = produces / "eval_grid" - grid_dir.mkdir(parents=True, exist_ok=True) - - if yaml is None: - print("ERROR: PyYAML required", file=sys.stderr) - return 3 - - with args.vocabulary.open("r", encoding="utf-8") as f: - vocab = yaml.safe_load(f) or {} - prompts = _build_prompts(vocab, smoke=args.smoke) - (grid_dir / "prompts.json").write_text( - json.dumps(prompts, indent=2) + "\n", encoding="utf-8" - ) - - manifest = json.loads(args.checkpoint_manifest.read_text(encoding="utf-8")) - checkpoints = manifest.get("checkpoints", []) if isinstance(manifest, dict) else [] - - buckets = ["baseline"] - if not args.smoke: - buckets += [f"step_{c['step']}" for c in checkpoints] - - for b in buckets: - (grid_dir / b).mkdir(parents=True, exist_ok=True) - - if args.dry_run: - (grid_dir / "index.html").write_text( - _render_index_html(prompts, buckets, grid_dir), encoding="utf-8" - ) - return 0 - - repo_root = Path(__file__).resolve().parents[4] - # Prevent runpod exec from uploading the repository cwd for each eval sample. - empty_local_root = grid_dir / "_empty_local_root" - empty_local_root.mkdir(parents=True, exist_ok=True) - - # Run inference on the pod. The inference command is a placeholder; ai-toolkit - # provides a `run.py` infer mode and an HTTP API — verify exact entrypoint at impl time. - for bucket in buckets: - ckpt_arg = "" if bucket == "baseline" else f"--lora /workspace/output/{bucket}.safetensors" - for i, prompt in enumerate(prompts): - remote_mp4 = f"/workspace/eval/{bucket}/prompt_{i:02d}.mp4" - cmd = ( - f"mkdir -p /workspace/eval/{bucket}; " - f"python3 /workspace/ai-toolkit/infer.py {ckpt_arg} " - f"--prompt {json.dumps(prompt)} --out {remote_mp4} || true" - ) - eval_produces = grid_dir / "_exec" / bucket / f"prompt_{i:02d}" - eval_produces.mkdir(parents=True, exist_ok=True) - subprocess.run( - [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "exec", - "--produces-dir", str(eval_produces), - "--pod-handle", str(args.pod_handle), - "--local-root", str(empty_local_root), - "--remote-script", cmd, - ], - cwd=repo_root, - ) - pull_dir = grid_dir / bucket - pull_dir.mkdir(parents=True, exist_ok=True) - pull_produces = grid_dir / "_pull" / bucket / f"prompt_{i:02d}" - pull_produces.mkdir(parents=True, exist_ok=True) - pull = subprocess.run( - [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "pull", - "--produces-dir", str(pull_produces), - "--pod-handle", str(args.pod_handle), - "--remote-path", remote_mp4, - "--local-dir", str(pull_dir), - ], - cwd=repo_root, - ) - local_mp4 = pull_dir / Path(remote_mp4).name - if pull.returncode != 0 or not local_mp4.exists(): - print(f"ERROR: eval sample was not pulled locally: {local_mp4}", file=sys.stderr) - return 4 - - (grid_dir / "index.html").write_text( - _render_index_html(prompts, buckets, grid_dir), encoding="utf-8" - ) - print(f"lora_eval_grid: wrote {grid_dir / 'index.html'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/lora_register/__init__.py b/astrid/packs/seinfeld/lora_register/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/lora_register/executor.yaml b/astrid/packs/seinfeld/lora_register/executor.yaml deleted file mode 100644 index dac86d4..0000000 --- a/astrid/packs/seinfeld/lora_register/executor.yaml +++ /dev/null @@ -1,36 +0,0 @@ -{ - "id": "seinfeld.lora_register", - "name": "Seinfeld LoRA Register", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Pure-local: copy chosen .safetensors into registered/ and write registered_lora.json.", - "description": "Reads chosen_checkpoint.json (from human gate / resume), copies the chosen LoRA file from the downloaded eval/training artifact dir into /registered/.safetensors, and writes registered_lora.json with the 8 required fields (lora_id, checkpoint_step, lora_file, config_used, base_model, vocabulary_hash, trained_at, human_pick_notes).", - "keywords": ["seinfeld", "lora", "register"], - "inputs": [ - {"name": "chosen_checkpoint", "type": "file"}, - {"name": "lora_source", "type": "file", "description": "Local path to the downloaded .safetensors (the chosen checkpoint)."}, - {"name": "staged_config", "type": "file"}, - {"name": "vocabulary", "type": "file"} - ], - "outputs": [ - {"name": "registered_lora", "type": "file", "path_template": "{out}/produces/registered_lora.json"} - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.lora_register.run", - "--chosen-checkpoint", "{chosen_checkpoint}", - "--lora-source", "{lora_source}", - "--staged-config", "{staged_config}", - "--vocabulary", "{vocabulary}", - "--produces-dir", "{out}/produces" - ] - }, - "cache": {"mode": "none"}, - "isolation": {"mode": "subprocess", "network": false}, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.lora_register.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/lora_register/run.py b/astrid/packs/seinfeld/lora_register/run.py deleted file mode 100644 index 8ac139c..0000000 --- a/astrid/packs/seinfeld/lora_register/run.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.lora_register — copy chosen LoRA and emit registered_lora.json.""" - -from __future__ import annotations - -import argparse -import datetime as _dt -import hashlib -import json -import shutil -import sys -from pathlib import Path - - -def _sha256(path: Path) -> str: - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1 << 20), b""): - h.update(chunk) - return h.hexdigest() - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Register chosen seinfeld LoRA.") - p.add_argument("--chosen-checkpoint", type=Path, required=True) - p.add_argument("--lora-source", type=Path, required=True) - p.add_argument("--staged-config", type=Path, required=True) - p.add_argument("--vocabulary", type=Path, required=True) - p.add_argument("--produces-dir", type=Path, required=True) - p.add_argument("--base-model", default="ltx-2.3") - p.add_argument("--lora-id", default="seinfeld-scene-v1") - return p - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - produces = args.produces_dir - registered_dir = produces / "registered" - registered_dir.mkdir(parents=True, exist_ok=True) - - chosen = json.loads(args.chosen_checkpoint.read_text(encoding="utf-8")) - step = int(chosen.get("step", chosen.get("checkpoint_step", -1))) - notes = str(chosen.get("notes", chosen.get("human_pick_notes", ""))) - - if not args.lora_source.exists(): - print(f"ERROR: lora source not found: {args.lora_source}", file=sys.stderr) - return 2 - - dst = registered_dir / f"{args.lora_id}.safetensors" - shutil.copy2(args.lora_source, dst) - - record = { - "lora_id": args.lora_id, - "checkpoint_step": step, - "lora_file": str(dst.resolve()), - "config_used": str(args.staged_config.resolve()), - "base_model": args.base_model, - "vocabulary_hash": _sha256(args.vocabulary), - "trained_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - "human_pick_notes": notes, - } - out_path = produces / "registered_lora.json" - out_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8") - print(f"lora_register: wrote {out_path}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/lora_train/__init__.py b/astrid/packs/seinfeld/lora_train/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/astrid/packs/seinfeld/lora_train/orchestrator.yaml b/astrid/packs/seinfeld/lora_train/orchestrator.yaml deleted file mode 100644 index 9594fab..0000000 --- a/astrid/packs/seinfeld/lora_train/orchestrator.yaml +++ /dev/null @@ -1,38 +0,0 @@ -{ - "id": "seinfeld.lora_train", - "name": "Seinfeld LoRA Train", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Train an LTX 2.3 LoRA on the Seinfeld dataset via ai-toolkit on RunPod.", - "description": "Provisions a RunPod GPU, stages the dataset_build manifest + an ai-toolkit (ostris) config, runs training, generates an eval sample grid, gates on human checkpoint selection (exit 0 + PAUSED status in last_run.json), then `resume --pick ` tears down the pod and registers the chosen LoRA.", - "keywords": ["seinfeld", "lora", "training", "ai-toolkit", "runpod", "ltx"], - "runtime": { - "kind": "command", - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.lora_train.run", - "{orchestrator_args}" - ] - } - }, - "child_executors": [ - "seinfeld.repo_setup", - "external.runpod.provision", - "seinfeld.aitoolkit_stage", - "seinfeld.aitoolkit_train", - "seinfeld.lora_eval_grid", - "external.runpod.teardown", - "seinfeld.lora_register", - "external.runpod.exec" - ], - "child_orchestrators": [], - "cache": { - "mode": "none" - }, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.lora_train.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/lora_train/run.py b/astrid/packs/seinfeld/lora_train/run.py deleted file mode 100644 index 94d7a84..0000000 --- a/astrid/packs/seinfeld/lora_train/run.py +++ /dev/null @@ -1,484 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.lora_train — orchestrator: provision → stage → train → eval → human gate → resume → teardown → register. - -Subcommands: - default (no subcommand): run pipeline up through human gate, exit 0 with last_run.json status=PAUSED. - resume: read last_run.json + --pick , write chosen_checkpoint.json, teardown pod, register LoRA. - -See ./STAGE.md for the full step list. -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -from pathlib import Path - -try: - import yaml -except ImportError: - yaml = None # type: ignore[assignment] - -DEFAULT_IMAGE = "ostris/aitoolkit:latest" -DEFAULT_PORTS = "8675/http,22/tcp" -DEFAULT_STORAGE = "seinfeld-dataset" -DEFAULT_GPU = "NVIDIA RTX 6000 Ada Generation" -DEFAULT_CONTAINER_DISK_GB = 200 -DEFAULT_MAX_RUNTIME = 43200 # 12h ceiling -DEFAULT_BASE_MODEL = "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors" -PACK_ROOT = Path(__file__).resolve().parents[1] -REPO_ROOT = Path(__file__).resolve().parents[4] - - -def _abs(p: str | Path) -> str: - return str(Path(p).resolve()) - - -def _run(argv: list[str], cwd: Path | None = None) -> int: - print(f"$ {' '.join(argv)}", flush=True) - return subprocess.run(argv, cwd=cwd or REPO_ROOT).returncode - - -def _preflight(args: argparse.Namespace) -> int: - if yaml is None: - print("ERROR: PyYAML required (pip install pyyaml)", file=sys.stderr) - return 3 - manifest_path = Path(args.manifest) - vocab_path = Path(args.vocabulary) - if not manifest_path.exists(): - print(f"ERROR: manifest not found: {manifest_path}", file=sys.stderr) - return 3 - if not vocab_path.exists(): - print(f"ERROR: vocabulary not found: {vocab_path}", file=sys.stderr) - return 3 - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except Exception as exc: - print(f"ERROR: manifest is not valid JSON: {exc}", file=sys.stderr) - return 3 - try: - yaml.safe_load(vocab_path.read_text(encoding="utf-8")) - except Exception as exc: - print(f"ERROR: vocabulary YAML parse failed: {exc}", file=sys.stderr) - return 3 - - clips = manifest.get("clips") or [] - if args.smoke and len(clips) < 5: - print(f"ERROR: --smoke requires ≥5 clips; found {len(clips)}", file=sys.stderr) - return 3 - if not clips: - print("ERROR: manifest has no clips", file=sys.stderr) - return 3 - - manifest_dir = manifest_path.parent - missing: list[str] = [] - for clip in clips: - clip_file = clip.get("clip_file") or clip.get("path") - clip_id = clip.get("clip_id") or (Path(clip_file).stem if clip_file else None) - if not clip_file or not clip_id: - missing.append(f"") - continue - cf = Path(clip_file) - if not cf.is_absolute(): - # Manifest stores repo-root-relative paths (per dataset_build/run.py). - cf_repo = (REPO_ROOT / cf).resolve() - cf_mdir = (manifest_dir / cf).resolve() - cf = cf_repo if cf_repo.exists() else cf_mdir - if not cf.exists(): - missing.append(f"clip_file missing: {cf}") - continue - caption = cf.parent / f"{clip_id}.caption.json" - if not caption.exists(): - missing.append(f"caption sidecar missing: {caption}") - if missing: - print("ERROR: preflight failed:", file=sys.stderr) - for m in missing[:10]: - print(f" - {m}", file=sys.stderr) - if len(missing) > 10: - print(f" ... and {len(missing) - 10} more", file=sys.stderr) - return 3 - - if not os.environ.get("RUNPOD_API_KEY") and not args.dry_run: - print( - "ERROR: RUNPOD_API_KEY is not set. Source one of:\n" - " source $PWD/.env.local\n" - " source $PWD/.env\n" - " source /Users/peteromalley/Documents/reigh-workspace/runpod-lifecycle/.env\n" - " source ~/.config/astrid/.env", - file=sys.stderr, - ) - return 3 - return 0 - - -def _invoke_repo_setup(out: Path) -> int: - produces = out / "repo_setup" - produces.mkdir(parents=True, exist_ok=True) - return _run([ - sys.executable, "-m", "astrid.packs.seinfeld.repo_setup.run", - "--produces-dir", _abs(produces), - ]) - - -def _provision(args: argparse.Namespace, out: Path) -> tuple[int, Path | None]: - produces = out / "provision" - produces.mkdir(parents=True, exist_ok=True) - argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "provision", - "--produces-dir", _abs(produces), - "--image", args.image, - "--ports", args.ports, - "--gpu-type", args.gpu, - "--container-disk-gb", str(args.container_disk_gb), - "--max-runtime-seconds", str(args.max_runtime_seconds), - ] - if args.storage_name: - argv.extend(["--storage-name", args.storage_name]) - rc = _run(argv) - handle = produces / "pod_handle.json" - return rc, (handle if handle.exists() else None) - - -def _stage(args: argparse.Namespace, out: Path, pod_handle: Path) -> int: - produces = out / "stage" - produces.mkdir(parents=True, exist_ok=True) - argv = [ - sys.executable, "-m", "astrid.packs.seinfeld.aitoolkit_stage.run", - "--manifest", _abs(args.manifest), - "--vocabulary", _abs(args.vocabulary), - "--produces-dir", _abs(produces), - "--pod-handle", _abs(pod_handle), - "--base-model", args.base_model_name, - "--seed", str(args.seed), - "--dataset-remote-path", args.dataset_remote_path, - ] - if args.steps is not None: - argv += ["--steps", str(args.steps)] - if args.smoke: - argv.append("--smoke") - rc = _run(argv) - ui_url = produces / "ui_url.txt" - if ui_url.exists(): - url = ui_url.read_text(encoding="utf-8").strip() - if url: - print(f"\n========== AI Toolkit UI ==========\n{url}\n===================================\n", flush=True) - return rc - - -def _train(args: argparse.Namespace, out: Path, pod_handle: Path) -> int: - produces = out / "train" - produces.mkdir(parents=True, exist_ok=True) - argv = [ - sys.executable, "-m", "astrid.packs.seinfeld.aitoolkit_train.run", - "--pod-handle", _abs(pod_handle), - "--produces-dir", _abs(produces), - ] - return _run(argv) - - -def _samples_collage( - args: argparse.Namespace, out: Path, pod_handle: Path, staged_config: Path, -) -> int: - """Pull training-time sample mp4s from the pod, optionally caption with video_understand, - build the per-step / per-prompt HTML grid the human gate uses to pick a checkpoint.""" - produces = out / "samples_collage" - produces.mkdir(parents=True, exist_ok=True) - remote_output = f"/workspace/output/{args.lora_id}" - argv = [ - sys.executable, "-m", "astrid.packs.seinfeld.samples_collage.run", - "--pod-handle", _abs(pod_handle), - "--remote-output-dir", remote_output, - "--out", _abs(produces), - "--staged-config", _abs(staged_config), - "--produces-dir", _abs(produces), - ] - if not args.skip_understand: - argv.append("--understand") - argv.extend(["--understand-mode", args.understand_mode]) - return _run(argv) - - -def _eval_grid( - args: argparse.Namespace, out: Path, pod_handle: Path, - checkpoint_manifest: Path, staged_config: Path, -) -> int: - produces = out / "eval_grid" - produces.mkdir(parents=True, exist_ok=True) - argv = [ - sys.executable, "-m", "astrid.packs.seinfeld.lora_eval_grid.run", - "--pod-handle", _abs(pod_handle), - "--checkpoint-manifest", _abs(checkpoint_manifest), - "--vocabulary", _abs(args.vocabulary), - "--produces-dir", _abs(produces), - ] - if args.smoke: - argv.append("--smoke") - return _run(argv) - - -def _teardown(out: Path, pod_handle: Path) -> int: - produces = out / "teardown" - produces.mkdir(parents=True, exist_ok=True) - return _run([ - sys.executable, "-m", "astrid.packs.external.runpod.run", "teardown", - "--produces-dir", _abs(produces), - "--pod-handle", _abs(pod_handle), - ]) - - -def _register(args: argparse.Namespace, out: Path, chosen: Path, lora_source: Path, staged_config: Path) -> int: - produces = out / "register" - produces.mkdir(parents=True, exist_ok=True) - return _run([ - sys.executable, "-m", "astrid.packs.seinfeld.lora_register.run", - "--chosen-checkpoint", _abs(chosen), - "--lora-source", _abs(lora_source), - "--staged-config", _abs(staged_config), - "--vocabulary", _abs(args.vocabulary), - "--produces-dir", _abs(produces), - "--base-model", args.base_model_name, - "--lora-id", args.lora_id, - ]) - - -def _write_last_run(out: Path, payload: dict) -> None: - (out / "last_run.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - description="Train a Seinfeld LoRA on LTX 2.3 via ai-toolkit on RunPod." - ) - sub = p.add_subparsers(dest="subcommand") - - def _add_common(sp: argparse.ArgumentParser) -> None: - sp.add_argument("--out", required=True, type=Path) - - # Default run subcommand (also runnable as bare flags for orchestrator framework) - p.add_argument("--manifest") - p.add_argument("--vocabulary") - p.add_argument("--base-model-name", dest="base_model_name", default=DEFAULT_BASE_MODEL) - p.add_argument("--lora-id", default="seinfeld-scene-v1") - p.add_argument("--steps", type=int, default=None) - p.add_argument("--seed", type=int, default=42) - p.add_argument("--gpu", default=DEFAULT_GPU) - p.add_argument("--image", default=DEFAULT_IMAGE) - p.add_argument("--ports", default=DEFAULT_PORTS) - p.add_argument("--storage-name", default=DEFAULT_STORAGE) - p.add_argument("--container-disk-gb", type=int, default=DEFAULT_CONTAINER_DISK_GB) - p.add_argument("--max-runtime-seconds", type=int, default=DEFAULT_MAX_RUNTIME) - p.add_argument("--dataset-remote-path", default="/workspace/dataset") - p.add_argument("--skip-understand", action="store_true", - help="Skip video_understand calls on each sample (still pulls + collages).") - p.add_argument("--understand-mode", default="fast", choices=["fast", "best"], - help="video_understand model: fast=Gemini Flash, best=Gemini Pro.") - p.add_argument("--smoke", action="store_true") - p.add_argument("--dry-run", action="store_true") - p.add_argument("--produces-dir", dest="produces_dir", type=Path, default=None) - p.add_argument("--out", type=Path, default=None) - - # resume - sp_resume = sub.add_parser("resume", help="Resume from PAUSED state: pick a checkpoint, teardown, register.") - sp_resume.add_argument("--out", required=True, type=Path) - sp_resume.add_argument("--pick", type=int, required=True, help="Checkpoint step to register.") - sp_resume.add_argument("--notes", default="", help="Human pick notes.") - sp_resume.add_argument("--skip-teardown", action="store_true") - - return p - - -def _resolve_out(args: argparse.Namespace) -> Path: - out = args.out or args.produces_dir - if not out: - print("ERROR: --out (or --produces-dir) is required", file=sys.stderr) - sys.exit(2) - out = Path(out) - out.mkdir(parents=True, exist_ok=True) - return out - - -def cmd_run(args: argparse.Namespace) -> int: - if not args.manifest or not args.vocabulary: - print("ERROR: --manifest and --vocabulary are required", file=sys.stderr) - return 2 - out = _resolve_out(args) - - rc = _preflight(args) - if rc != 0: - return rc - - if args.dry_run: - # Run stage in dry-run only — no pod work. - produces = out / "stage" - produces.mkdir(parents=True, exist_ok=True) - stage_argv = [ - sys.executable, "-m", "astrid.packs.seinfeld.aitoolkit_stage.run", - "--manifest", _abs(args.manifest), - "--vocabulary", _abs(args.vocabulary), - "--produces-dir", _abs(produces), - "--base-model", args.base_model_name, - "--seed", str(args.seed), - "--dataset-remote-path", args.dataset_remote_path, - "--dry-run", - ] - if args.steps is not None: - stage_argv += ["--steps", str(args.steps)] - if args.smoke: - stage_argv.append("--smoke") - rc = _run(stage_argv) - if rc != 0: - return rc - _write_last_run(out, { - "status": "DRY_RUN", - "image": args.image, - "ports": args.ports, - "gpu": args.gpu, - "smoke": args.smoke, - "staged_config": _abs(produces / "staged_config.yaml"), - }) - print(f"lora_train: dry-run complete → {out / 'last_run.json'}") - return 0 - - rc = _invoke_repo_setup(out) - if rc != 0: - print(f"ERROR: seinfeld.repo_setup failed (rc={rc})", file=sys.stderr) - return rc - - rc, pod_handle = _provision(args, out) - if rc != 0 or pod_handle is None: - print(f"ERROR: provision failed (rc={rc})", file=sys.stderr) - return rc or 4 - - try: - rc = _stage(args, out, pod_handle) - if rc != 0: - return rc - staged_config = (out / "stage" / "staged_config.yaml").resolve() - - rc = _train(args, out, pod_handle) - if rc != 0: - return rc - checkpoint_manifest = (out / "train" / "checkpoint_manifest.json").resolve() - - # Pull training-time samples + (optionally) caption each with video_understand. - # Best-effort — collage failures should not block the human gate. - rc_collage = _samples_collage(args, out, pod_handle, staged_config) - collage_index = (out / "samples_collage" / "index.html").resolve() - if rc_collage != 0: - print(f"WARN: samples_collage rc={rc_collage} (continuing anyway)", file=sys.stderr) - - rc = _eval_grid(args, out, pod_handle, checkpoint_manifest, staged_config) - if rc != 0: - return rc - eval_index = (out / "eval_grid" / "index.html").resolve() - except Exception: - # Best-effort teardown on unexpected failure. - _teardown(out, pod_handle) - raise - - _write_last_run(out, { - "status": "PAUSED", - "pod_handle": _abs(pod_handle), - "staged_config": str(staged_config), - "checkpoint_manifest": str(checkpoint_manifest), - "samples_collage_index": str(collage_index) if collage_index.exists() else None, - "eval_grid_index": str(eval_index), - "vocabulary": _abs(args.vocabulary), - "base_model_name": args.base_model_name, - "lora_id": args.lora_id, - "out": _abs(out), - }) - print( - "\n========== HUMAN GATE ==========\n" - f"Training samples (per-step × per-prompt with auto-captions): {collage_index}\n" - f"Eval grid (inference clips on candidate checkpoints): {eval_index}\n" - f"\nWhen ready, run:\n" - f" python3 -m astrid.packs.seinfeld.lora_train.run resume " - f"--out {out} --pick --notes ''\n" - "================================\n", - flush=True, - ) - return 0 - - -def cmd_resume(args: argparse.Namespace) -> int: - out = Path(args.out).resolve() - state_path = out / "last_run.json" - if not state_path.exists(): - print(f"ERROR: no last_run.json at {state_path}", file=sys.stderr) - return 2 - state = json.loads(state_path.read_text(encoding="utf-8")) - if state.get("status") != "PAUSED": - print(f"ERROR: last_run.json status is {state.get('status')!r}, expected PAUSED", file=sys.stderr) - return 2 - - cm_path = Path(state["checkpoint_manifest"]) - cm = json.loads(cm_path.read_text(encoding="utf-8")) - match = next((c for c in cm.get("checkpoints", []) if int(c.get("step", -1)) == args.pick), None) - if not match: - print(f"ERROR: step {args.pick} not in checkpoint_manifest", file=sys.stderr) - return 2 - - chosen = { - "step": args.pick, - "remote_path": match["remote_path"], - "notes": args.notes, - } - chosen_path = out / "chosen_checkpoint.json" - chosen_path.write_text(json.dumps(chosen, indent=2) + "\n", encoding="utf-8") - - pod_handle = Path(state["pod_handle"]) - # Pull the chosen .safetensors off the pod into /register-src/ before teardown. - register_src = out / "register-src" - register_src.mkdir(parents=True, exist_ok=True) - local_lora = register_src / Path(match["remote_path"]).name - pull_argv = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "pull", - "--pod-handle", _abs(pod_handle), - "--produces-dir", _abs(register_src), - "--remote-path", match["remote_path"], - "--local-dir", _abs(register_src), - ] - rc = subprocess.run(pull_argv, cwd=REPO_ROOT).returncode - if rc != 0 or not local_lora.exists(): - print( - f"ERROR: could not pull checkpoint from pod before teardown: {match['remote_path']} -> {local_lora}", - file=sys.stderr, - ) - if not local_lora.exists(): - print("ERROR: lora source missing; aborting before teardown to preserve pod.", file=sys.stderr) - return 5 - - if not args.skip_teardown: - rc = _teardown(out, pod_handle) - if rc != 0: - print(f"WARNING: teardown rc={rc} — continuing to register", file=sys.stderr) - - # Reconstruct an args-like namespace for _register. - reg_args = argparse.Namespace( - vocabulary=state["vocabulary"], - base_model_name=state.get("base_model_name", DEFAULT_BASE_MODEL), - lora_id=state.get("lora_id", "seinfeld-scene-v1"), - ) - rc = _register(reg_args, out, chosen_path, local_lora, Path(state["staged_config"])) - if rc != 0: - return rc - - _write_last_run(out, {**state, "status": "REGISTERED", "chosen_checkpoint": _abs(chosen_path)}) - print(f"lora_train: registered → {out / 'register' / 'registered_lora.json'}") - return 0 - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - if args.subcommand == "resume": - return cmd_resume(args) - return cmd_run(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/pack.yaml b/astrid/packs/seinfeld/pack.yaml deleted file mode 100644 index 8aefeef..0000000 --- a/astrid/packs/seinfeld/pack.yaml +++ /dev/null @@ -1,3 +0,0 @@ -id: seinfeld -name: Seinfeld Scene Generator -version: 0.1.0 diff --git a/astrid/packs/seinfeld/repo_setup/__init__.py b/astrid/packs/seinfeld/repo_setup/__init__.py deleted file mode 100644 index 662b632..0000000 --- a/astrid/packs/seinfeld/repo_setup/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# seinfeld.repo_setup — idempotent ai-toolkit submodule initializer \ No newline at end of file diff --git a/astrid/packs/seinfeld/repo_setup/executor.yaml b/astrid/packs/seinfeld/repo_setup/executor.yaml deleted file mode 100644 index 9476ce8..0000000 --- a/astrid/packs/seinfeld/repo_setup/executor.yaml +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "seinfeld.repo_setup", - "name": "Seinfeld Repo Setup", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Idempotent git submodule add + checkout of ostris/ai-toolkit for config-schema reference.", - "description": "Ensures astrid/packs/seinfeld/ai_toolkit/upstream/ is checked out at a pinned SHA. If the submodule already exists, emits status=already_initialized. Otherwise runs git submodule add and git checkout.", - "keywords": ["seinfeld", "ai-toolkit", "submodule", "init", "setup"], - "inputs": [], - "outputs": [ - { - "name": "result", - "type": "file", - "path_template": "{out}/produces/setup_result.json" - } - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.repo_setup.run", - "--produces-dir", - "{out}/produces" - ] - }, - "cache": { - "mode": "none" - }, - "isolation": { - "mode": "subprocess", - "network": true - }, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.repo_setup.run", - "runtime_file": "run.py" - } -} \ No newline at end of file diff --git a/astrid/packs/seinfeld/repo_setup/run.py b/astrid/packs/seinfeld/repo_setup/run.py deleted file mode 100644 index 7905744..0000000 --- a/astrid/packs/seinfeld/repo_setup/run.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.repo_setup — idempotent ai-toolkit submodule initializer. - -If ``astrid/packs/seinfeld/ai_toolkit/upstream/.git`` already exists, -exits 0 with ``{status: "already_initialized"}``. Otherwise runs -``git submodule add`` + ``git checkout `` and exits 0. -""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -from pathlib import Path - -# Pinned SHA — single source of truth. HEAD of ostris/ai-toolkit main -# as of 2026-05-12; confirmed LTX 2.3 support. -PINNED_SHA = "f38de2a2fedfafa4bf298806d1efcabb4a357cbc" - -SUBMODULE_URL = "https://github.com/ostris/ai-toolkit.git" -SUBMODULE_PATH = "astrid/packs/seinfeld/ai_toolkit/upstream" - - -def _write_json(path: Path, payload: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - - -def _repo_root() -> Path: - """Return the git worktree root by walking up from this file.""" - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, text=True, cwd=Path(__file__).resolve().parent, - ) - if result.returncode != 0: - raise RuntimeError( - f"git rev-parse failed — not inside a git working tree? " - f"stderr: {result.stderr.strip()}" - ) - return Path(result.stdout.strip()) - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser( - description="Idempotent ai-toolkit submodule initializer." - ) - parser.add_argument( - "--produces-dir", type=Path, required=True, - help="Produces output directory (framework provides via {out}/produces).", - ) - args = parser.parse_args(argv) - produces_dir: Path = args.produces_dir - produces_dir.mkdir(parents=True, exist_ok=True) - - root = _repo_root() - submodule_path = root / SUBMODULE_PATH - - # Idempotency: already initialized? - if (submodule_path / ".git").exists(): - result = {"status": "already_initialized", "sha": PINNED_SHA} - _write_json(produces_dir / "setup_result.json", result) - print(f"repo_setup: ai-toolkit submodule already initialized at {submodule_path}") - return 0 - - # ── git submodule add ────────────────────────────────────────── - print(f"repo_setup: adding submodule {SUBMODULE_URL} → {SUBMODULE_PATH}") - result = subprocess.run( - ["git", "submodule", "add", SUBMODULE_URL, SUBMODULE_PATH], - capture_output=True, text=True, cwd=root, - ) - if result.returncode != 0: - print( - f"ERROR: git submodule add failed:\n{result.stderr}", - file=sys.stderr, - ) - return 1 - - # ── git checkout pinned SHA ──────────────────────────────────── - print(f"repo_setup: checking out pinned SHA {PINNED_SHA}") - result = subprocess.run( - ["git", "checkout", PINNED_SHA], - capture_output=True, text=True, cwd=submodule_path, - ) - if result.returncode != 0: - print( - f"ERROR: git checkout {PINNED_SHA} failed:\n{result.stderr}", - file=sys.stderr, - ) - return 2 - - # ── git add .gitmodules + submodule ──────────────────────────── - subprocess.run( - ["git", "add", ".gitmodules", SUBMODULE_PATH], - capture_output=True, text=True, cwd=root, - ) - - submodule_result = {"status": "initialized", "sha": PINNED_SHA} - _write_json(produces_dir / "setup_result.json", submodule_result) - print(f"repo_setup: ai-toolkit initialized at {submodule_path} @ {PINNED_SHA}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file diff --git a/astrid/packs/seinfeld/samples_collage/run.py b/astrid/packs/seinfeld/samples_collage/run.py deleted file mode 100644 index a05de7f..0000000 --- a/astrid/packs/seinfeld/samples_collage/run.py +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env python3 -"""Download ai-toolkit training samples + optionally run video_understand, build an HTML collage. - -This is a `lora_train` orchestrator step: it runs after training finishes, -between `aitoolkit_train` and `lora_eval_grid` / `human_gate`. Goals: - -- scp /workspace/output//samples/ from the pod into the local run dir. -- (optional, --understand) call `astrid.packs.builtin.video_understand.run` on each mp4 - with a custom prompt-alignment query against the training prompt for that column. -- Generate index.html: rows = checkpoint step, cols = training prompts, cells = video + - prompt text + auto-summary + scores. - -The HTML is what the human gate operator looks at when picking a checkpoint. -""" - -from __future__ import annotations - -import argparse -import html -import json -import subprocess -import sys -import textwrap -from pathlib import Path - -STEP_RE = re.compile(r"step[_-]?0*(\d+)", re.IGNORECASE) -PROMPT_RE = re.compile(r"prompt[_-]?(\d+)", re.IGNORECASE) -REPO_ROOT = Path(__file__).resolve().parents[4] - -# Query used when --understand is set. Lightweight JSON rubric tailored to LoRA QA. -UNDERSTAND_QUERY_TEMPLATE = textwrap.dedent( - """\ - This 4-second video is a sample generated by a Seinfeld scene LoRA being trained on - top of LTX 2.3. The training prompt was: "{prompt}" - - Return compact JSON with: - - summary: one short sentence describing what is actually in the video - - prompt_match_score: 0-10 (does the generation match the prompt?) - - prompt_match_notes: specifically what matches and what doesn't - - seinfeld_aesthetic: 0-10 (does the look match 90s sitcom Seinfeld?) - - characters_visible: short list of any recognizable characters (or "none") - - location_visible: best guess at location depicted (or "unknown") - - artifacts: any visible glitches, distortion, or anatomy problems - """ -) -UNDERSTAND_RESPONSE_SCHEMA = { - "type": "object", - "additionalProperties": False, - "properties": { - "summary": {"type": "string"}, - "prompt_match_score": {"type": "number"}, - "prompt_match_notes": {"type": "string"}, - "seinfeld_aesthetic": {"type": "number"}, - "characters_visible": {"type": "string"}, - "location_visible": {"type": "string"}, - "artifacts": {"type": "string"}, - }, - "required": [ - "summary", - "prompt_match_score", - "prompt_match_notes", - "seinfeld_aesthetic", - ], -} - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser() - p.add_argument("--pod-handle", type=Path, required=True) - p.add_argument( - "--remote-output-dir", - required=True, - help="Pod path containing samples/ subdir (e.g. /workspace/output/seinfeld-scene-v1).", - ) - p.add_argument("--out", type=Path, required=True, help="Local dir to hold downloaded files + HTML.") - p.add_argument( - "--staged-config", - type=Path, - default=None, - help="staged_config.yaml path — used to read prompt list for column labels.", - ) - p.add_argument( - "--ssh-key", - default="/tmp/pod_key/id", - help="Local private key file. Defaults to debug path.", - ) - p.add_argument( - "--understand", - action="store_true", - help="Run video_understand on each sample to caption + score prompt-alignment.", - ) - p.add_argument( - "--understand-mode", - default="fast", - choices=["fast", "best"], - help="video_understand model mode (fast=Flash, best=Pro).", - ) - p.add_argument("--open", action="store_true", help="Open index.html in a browser when done.") - p.add_argument("--produces-dir", type=Path, default=None, help="Orchestrator produces dir.") - return p - - -def _parse_mp4_name(name: str) -> tuple[int, int | None, str]: - step_m = STEP_RE.search(name) - step = int(step_m.group(1)) if step_m else -1 - prompt_m = PROMPT_RE.search(name) - prompt_idx = int(prompt_m.group(1)) if prompt_m else None - slug = name.rsplit(".", 1)[0] - return step, prompt_idx, slug - - -def _load_prompts(staged_config: Path | None) -> list[str]: - if not staged_config or not staged_config.is_file(): - return [] - try: - import yaml - cfg = yaml.safe_load(staged_config.read_text(encoding="utf-8")) - return list(cfg["config"]["process"][0]["sample"]["prompts"]) - except Exception as exc: - print(f"WARN: could not load prompts from {staged_config}: {exc}", file=sys.stderr) - return [] - - -def _understand_one(mp4: Path, prompt: str, mode: str, out_json: Path) -> dict | None: - """Call astrid.packs.builtin.video_understand.run on a single mp4.""" - query = UNDERSTAND_QUERY_TEMPLATE.format(prompt=prompt) - cmd = [ - sys.executable, "-m", "astrid.packs.builtin.video_understand.run", - "--video", str(mp4), - "--query", query, - "--mode", mode, - "--out", str(out_json), - "--out-dir", str(out_json.parent / "_vu_windows"), - "--max-chunks", "1", - "--chunk-sec", "30", - ] - rv = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True) - if rv.returncode != 0: - print(f"WARN: video_understand failed for {mp4.name}: rc={rv.returncode}", file=sys.stderr) - print(rv.stderr[-500:], file=sys.stderr) - return None - if out_json.is_file(): - try: - data = json.loads(out_json.read_text(encoding="utf-8")) - # Strip the wrapping schema if video_understand returned its standard envelope. - if isinstance(data, dict) and "results" in data: - results = data.get("results") or [] - if results and isinstance(results, list): - first = results[0] - if isinstance(first, dict) and "response" in first: - try: - return json.loads(first["response"]) - except Exception: - return first.get("response") - return data - except Exception: - return None - return None - - -def _format_understanding(uj: dict | None) -> str: - if not uj: - return '
(no understanding)
' - score = uj.get("prompt_match_score") - aesthetic = uj.get("seinfeld_aesthetic") - summary = uj.get("summary", "") - notes = uj.get("prompt_match_notes", "") - characters = uj.get("characters_visible", "") - location = uj.get("location_visible", "") - artifacts = uj.get("artifacts", "") - parts = [ - f'
match {score}/10 · look {aesthetic}/10
', - f'
{html.escape(summary)}
', - ] - if notes: - parts.append(f'
notes: {html.escape(notes)}
') - meta = [] - if characters: - meta.append(f"chars: {html.escape(str(characters))}") - if location: - meta.append(f"where: {html.escape(str(location))}") - if artifacts: - meta.append(f"artifacts: {html.escape(str(artifacts))}") - if meta: - parts.append(f'
{" · ".join(meta)}
') - return "".join(parts) - - -def _build_html(samples_dir: Path, prompts: list[str], understanding: dict[Path, dict | None]) -> str: - mp4s = sorted(samples_dir.glob("**/*.mp4")) - if not mp4s: - return "

No samples found.

" - - by_step: dict[int, list[tuple[int | None, str, Path]]] = {} - for mp4 in mp4s: - step, pidx, slug = _parse_mp4_name(mp4.name) - by_step.setdefault(step, []).append((pidx, slug, mp4)) - - steps = sorted(by_step.keys()) - n_cols = max((len(v) for v in by_step.values()), default=1) - if prompts: - n_cols = max(n_cols, len(prompts)) - - rows_html = [] - for step in steps: - cells = sorted(by_step[step], key=lambda t: (t[0] if t[0] is not None else 99, t[2])) - while len(cells) < n_cols: - cells.append((None, "", None)) - tds = [] - for pidx, slug, path in cells: - if path is None: - tds.append("—") - continue - rel = path.relative_to(samples_dir.parent) - uj = understanding.get(path) - tds.append( - f'' - f'' - f'
' - f'{html.escape(slug)}
' - f'{_format_understanding(uj)}' - f'' - ) - rows_html.append( - f'' - f'step
{step if step >= 0 else "?"}' - + "".join(tds) + "" - ) - - headers = [] - for c in range(n_cols): - head = f"prompt {c}" - if prompts and c < len(prompts): - head += f"
{html.escape(prompts[c])}" - headers.append(f"{head}") - - return f""" -Seinfeld LoRA — sample collage - - -

Seinfeld scene-LoRA — sample collage

-

Rows = checkpoint step. Columns = training prompts (verbatim from staged config). -Each cell shows the generated sample + video_understand auto-caption + prompt-alignment score. -Look for: scene identity, character recognizability, Seinfeld-aesthetic acquisition, over-fit -(generations on novel prompts looking inappropriately Seinfeld-styled).

- -{''.join(headers)} -{''.join(rows_html)} -
- -""" - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - produces = args.produces_dir or args.out - produces.mkdir(parents=True, exist_ok=True) - - out = args.out - out.mkdir(parents=True, exist_ok=True) - samples_local = out / "samples" - - remote = args.remote_output_dir.rstrip("/") + "/samples/." - pull_produces = produces / "_sample_pull" - pull_produces.mkdir(parents=True, exist_ok=True) - pull_cmd = [ - sys.executable, "-m", "astrid.packs.external.runpod.run", "pull", - "--produces-dir", str(pull_produces), - "--pod-handle", str(args.pod_handle), - "--remote-path", remote, - "--local-dir", str(samples_local), - ] - if args.ssh_key: - pull_cmd.extend(["--ssh-key", args.ssh_key]) - rv = subprocess.run(pull_cmd, cwd=REPO_ROOT) - if rv.returncode != 0 or not list(samples_local.glob("**/*.mp4")): - print(f"ERROR: samples download failed: rc={rv.returncode}", file=sys.stderr) - # Still write an HTML so the orchestrator's downstream step doesn't error on a missing file. - (out / "index.html").write_text( - f"

samples_collage

artifact pull failed: rc={rv.returncode}

", - encoding="utf-8", - ) - return 2 - - prompts = _load_prompts(args.staged_config) - - # Pair each mp4 with its prompt (by prompt index in filename) and optionally run video_understand. - understanding: dict[Path, dict | None] = {} - if args.understand: - understand_dir = out / "understand" - understand_dir.mkdir(parents=True, exist_ok=True) - mp4s = sorted(samples_local.glob("**/*.mp4")) - print(f"samples_collage: running video_understand on {len(mp4s)} sample(s)...", file=sys.stderr) - for mp4 in mp4s: - step, pidx, slug = _parse_mp4_name(mp4.name) - prompt_text = prompts[pidx] if (prompts and pidx is not None and pidx < len(prompts)) else "" - out_json = understand_dir / f"{slug}.json" - uj = _understand_one(mp4, prompt_text, args.understand_mode, out_json) - understanding[mp4] = uj - - html_text = _build_html(samples_local, prompts, understanding) - index_path = out / "index.html" - index_path.write_text(html_text, encoding="utf-8") - print(f"samples_collage: wrote {index_path}") - - # Manifest for the orchestrator - manifest = { - "status": "ok", - "samples_dir": str(samples_local), - "index_html": str(index_path), - "sample_count": len(list(samples_local.glob("**/*.mp4"))), - "understood": args.understand, - "prompts": prompts, - } - (produces / "samples_collage_manifest.json").write_text( - json.dumps(manifest, indent=2) + "\n", encoding="utf-8" - ) - - if args.open: - subprocess.run(["open", str(index_path)]) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/astrid/packs/seinfeld/script_pipeline/__init__.py b/astrid/packs/seinfeld/script_pipeline/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/astrid/packs/seinfeld/script_pipeline/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/astrid/packs/seinfeld/script_pipeline/executor.yaml b/astrid/packs/seinfeld/script_pipeline/executor.yaml deleted file mode 100644 index b642da3..0000000 --- a/astrid/packs/seinfeld/script_pipeline/executor.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{ - "id": "seinfeld.script_pipeline", - "name": "Seinfeld Script Pipeline", - "kind": "built_in", - "version": "0.1.0", - "short_description": "Generate Seinfeld-style short scene scripts through ideation, synthesis, and voice passes.", - "description": "Runs a three-phase DeepSeek script pipeline: parallel rough scene ideation, structural synthesis, then Seinfeld voice and laugh-tag pass. Can generate multiple final candidates concurrently and write a selected-best markdown result.", - "keywords": ["seinfeld", "script", "comedy", "deepseek", "scene"], - "inputs": [ - {"name": "prompt", "type": "string", "required": false, "description": "Scene brief. Defaults to Kramer excited about open source AI when using the module CLI."}, - {"name": "candidates", "type": "integer", "required": false, "description": "Number of complete pipeline candidates to generate when using the module CLI."} - ], - "outputs": [ - {"name": "selected_scene", "type": "file", "path_template": "{out}/produces/selected_scene.md"}, - {"name": "manifest", "type": "file", "path_template": "{out}/produces/manifest.json"} - ], - "command": { - "argv": [ - "{python_exec}", - "-m", - "astrid.packs.seinfeld.script_pipeline.run", - "--produces-dir", - "{out}/produces" - ] - }, - "cache": {"mode": "none"}, - "isolation": {"mode": "subprocess", "network": true}, - "metadata": { - "runtime_module": "astrid.packs.seinfeld.script_pipeline.run", - "runtime_file": "run.py" - } -} diff --git a/astrid/packs/seinfeld/script_pipeline/run.py b/astrid/packs/seinfeld/script_pipeline/run.py deleted file mode 100644 index 1c93b43..0000000 --- a/astrid/packs/seinfeld/script_pipeline/run.py +++ /dev/null @@ -1,379 +0,0 @@ -#!/usr/bin/env python3 -"""seinfeld.script_pipeline - generate short Seinfeld-style script scenes.""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -import uuid -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass -from pathlib import Path -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -DEFAULT_SYSTEM_BASE = ( - "You are a comedy writer with deep knowledge of Seinfeld's voice and " - "rhythm. You write tight, fast scenes - no fat." -) - -DEFAULT_PROMPT = ( - "Write a VERY SHORT Seinfeld scene - about 30 seconds of screen time. " - "Keep it to 8-12 short lines of dialogue total. " - "Kramer bursts in EXTREMELY excited about open source AI. " - "George is skeptical / dismissive. " - "Jerry is confused - keeps interrupting with 'Who?' / 'What?' because " - "none of the names or terms register. " - "End on a button (one clean closing line). " - "Format as a script: NAME: line. No stage directions beyond the opening." -) - -SYSTEM_SYNTH = ( - "You are a comedy writer with a deep ear for Seinfeld's rhythm. " - "Your one job in this pass is STRUCTURE - turning multiple rough attempts " - "into a single coherent scene. Don't worry about polishing character " - "voices yet; that's a later pass. Just thread the best material." -) - -SYSTEM_VOICE = """You are a script doctor for Seinfeld. You receive a structurally-correct draft scene and your ONLY job is to fix lines that violate character voice. You do NOT restructure, reorder, add, or remove lines. You replace individual lines that sound wrong with versions that sound right. After the voice pass, you insert laugh tags. - -CHARACTER VOICES - concrete rules: - -GEORGE doesn't construct cute analogies. He panics, catastrophizes, and complains about specific people (his mother, his boss, an ex). His comedy comes from his neuroses leaking out, not from clever metaphors. - WRONG: "Open source - what is that, a food bank for algorithms?" - WRONG: "They share models like it's potluck for the singularity." - RIGHT: "Free? Nothing's free. My mother gave me free advice for thirty years and look at me." - -JERRY's voice is FLAT and declarative, not literary. He repeats himself slightly. He doesn't do poetic phrasings or clever buttons. - WRONG: "I barely tolerate them at standard pitch." - WRONG: "The one thing I asked the universe to forget." - WRONG: "My fridge is open source, and it only knows to chill." - RIGHT: "I don't want my thoughts fine-tuned. My thoughts are fine. Actually they're not, but I'm not adjusting them." - -KRAMER is CONCRETE-ABSURD, not ideological or abstract. He physicalizes everything. He doesn't talk about "liberty" or "democratization" - he talks about specific objects in his apartment. - WRONG: "It's democratizing intelligence!" - WRONG: "It's about liberty! Digital liberty!" - WRONG: "It'll tell you your soup is too salty in iambic pentameter." - RIGHT: "I got a llama in my closet writing my Christmas cards." - RIGHT: "It's running on the Roomba, Jerry - the Roomba!" - -LAUGH TAGS - after the voice fixes, insert at most 5 tags total, ONLY on the biggest beats. Forms: [LAUGHTER], [BIG LAUGHTER], [LAUGHTER AND APPLAUSE]. Plus [APPLAUSE] on Kramer's entrance and [END SCENE] at the end. Do NOT tag every line. - -Output ONLY the corrected script. No commentary.""" - -SYSTEM_JUDGE = """You are judging generated Seinfeld-style short scene scripts. Pick the single strongest candidate for: -- concrete Kramer physical absurdity over abstract tech talk -- Jerry's flat confusion and simple closing line -- George's neurotic specificity -- coherent escalation in about 30 seconds -- sparse laugh tags on actual beats - -Return strict JSON only: {"winner": <1-based index>, "reason": ""}.""" - - -@dataclass(frozen=True) -class Candidate: - index: int - work_dir: Path - md_path: Path - final_scene: str - draft_scene: str - attempts_blob: str - - -def _load_env_file(path: Path) -> None: - if not path.exists(): - return - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line and not line.startswith("#") and "=" in line: - key, value = line.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) - - -def _call_deepseek(api_key: str, messages: list[dict], temperature: float, max_tokens: int = 8192) -> dict: - body = { - "model": "deepseek-v4-pro", - "messages": messages, - "max_tokens": max_tokens, - "temperature": temperature, - } - last_error: Exception | None = None - for attempt in range(1, 4): - request = Request( - "https://api.deepseek.com/v1/chat/completions", - data=json.dumps(body).encode("utf-8"), - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - method="POST", - ) - try: - with urlopen(request, timeout=320) as response: - payload = response.read().decode("utf-8") - break - except HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - if 400 <= exc.code < 500 and exc.code != 429: - raise RuntimeError(f"DeepSeek HTTP {exc.code}: {detail}") from exc - last_error = RuntimeError(f"DeepSeek HTTP {exc.code}: {detail}") - except URLError as exc: - last_error = RuntimeError(f"DeepSeek request failed: {exc}") - if attempt < 3: - wait_seconds = 2 ** attempt - print(f"DeepSeek call failed; retrying in {wait_seconds}s ({attempt}/3): {last_error}", file=sys.stderr) - time.sleep(wait_seconds) - else: - raise RuntimeError(str(last_error)) - data = json.loads(payload) - if "error" in data: - raise RuntimeError(f"DeepSeek API error: {data['error']}") - return data - - -def _write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _build_synth_prompt(prompt: str, attempts_blob: str) -> str: - return f"""Below are 5 attempts at a 30-second Seinfeld scene. - -Original brief: -{prompt} - -Pick the strongest weird ideas, specific name-jokes, and character-true beats from across the attempts, and weave them into ONE coherent ~12-line scene. Loose threading - connected enough to flow, not so neat it reads as a sketch. - -Rules for this pass: -- Preserve the brief's requested wackiness level: funny, wacky, somewhat grounded in reality, and also over the top. Favor Seinfeld-style comic escalation over surreal sci-fi, random escalation, or pure tech-name jokes. -- Choose one concrete, playable modern-life problem or object as the scene engine. The final scene should feel like it could physically happen in Jerry's apartment today. -- Keep the absurdity practical: Kramer's scheme can be ridiculous, but it should involve specific objects, errands, apartments, neighbors, dating, food, money, etiquette, or daily inconvenience. -- Open with a one-line stage direction: location + what each character is doing. -- Kramer bursts in early. -- Include the "puffy shirt" callback if any attempt has it. -- Add a small physical beat (eating cereal, opening fridge, etc.) inline somewhere mid-scene. -- End on an action or exit line (Kramer leaving, Jerry walking away) - NOT a clever metaphor. -- NO laugh tags this pass. Just dialogue. -- Output ONLY the script. No commentary, no headers. - -ATTEMPTS: - -{attempts_blob} -""" - - -def _build_voice_prompt(draft_scene: str) -> str: - return f"""Here is the draft scene. Find lines that violate the character-voice rules and rewrite ONLY those lines in place. Leave any line that already sounds right untouched. Do not change the structure, the order, or the count of lines. Then insert the laugh tags per the rules above. - -DRAFT: - -{draft_scene} -""" - - -def _run_candidate( - *, - index: int, - api_key: str, - produces_dir: Path, - prompt: str, - rough_attempts: int, -) -> Candidate: - stamp = time.strftime("%Y%m%d_%H%M%S") - run_id = f"{stamp}_{index:02d}_{os.getpid()}_{uuid.uuid4().hex[:8]}" - work_dir = produces_dir / "work" / run_id - work_dir.mkdir(parents=True, exist_ok=False) - - def base_run(attempt_index: int) -> str: - print(f"candidate {index}: starting rough attempt {attempt_index}...", file=sys.stderr) - data = _call_deepseek( - api_key, - [ - {"role": "system", "content": DEFAULT_SYSTEM_BASE}, - {"role": "user", "content": prompt}, - ], - temperature=2.0, - ) - content = data["choices"][0]["message"]["content"] - _write_text(work_dir / f"scene_{attempt_index}.txt", content) - print(f"candidate {index}: done rough attempt {attempt_index}", file=sys.stderr) - return content - - print(f"candidate {index}: generating {rough_attempts} rough scenes...", file=sys.stderr) - with ThreadPoolExecutor(max_workers=rough_attempts) as pool: - scenes = list(pool.map(base_run, range(1, rough_attempts + 1))) - - attempts_blob = "\n\n---\n\n".join( - f"### Attempt {attempt_index + 1}\n\n{scene.strip()}" - for attempt_index, scene in enumerate(scenes) - ) - print(f"candidate {index}: synthesizing structure...", file=sys.stderr) - data = _call_deepseek( - api_key, - [ - {"role": "system", "content": SYSTEM_SYNTH}, - {"role": "user", "content": _build_synth_prompt(prompt, attempts_blob)}, - ], - temperature=1.0, - ) - draft_scene = data["choices"][0]["message"]["content"].strip() - _write_text(work_dir / "draft_scene.txt", draft_scene) - - print(f"candidate {index}: applying voice + laugh pass...", file=sys.stderr) - data = _call_deepseek( - api_key, - [ - {"role": "system", "content": SYSTEM_VOICE}, - {"role": "user", "content": _build_voice_prompt(draft_scene)}, - ], - temperature=1.0, - ) - final_scene = data["choices"][0]["message"]["content"].strip() - - md_path = produces_dir / "candidates" / f"candidate_{index:02d}_{run_id}.md" - md = f"""# Seinfeld Script Pipeline - candidate {index} - -*3-phase pipeline:* -1. *Ideation - {rough_attempts}x DeepSeek V4 Pro at temp 2.0* -2. *Synthesis - 1x DeepSeek at temp 1.0, structure only* -3. *Voice + laugh tags - 1x DeepSeek at temp 1.0, character-voice doctor* - ---- - -## Final scene (after voice pass) - -{final_scene} - ---- - -## Phase 2 draft (before voice pass) - -{draft_scene} - ---- - -## Source attempts (phase 1) - -{attempts_blob} -""" - _write_text(md_path, md) - print(md_path) - return Candidate(index, work_dir, md_path, final_scene, draft_scene, attempts_blob) - - -def _judge_best(api_key: str, candidates: list[Candidate]) -> tuple[int, str]: - if len(candidates) == 1: - return candidates[0].index, "Only one candidate was generated." - blob = "\n\n---\n\n".join( - f"## Candidate {candidate.index}\n\n{candidate.final_scene}" - for candidate in candidates - ) - data = _call_deepseek( - api_key, - [ - {"role": "system", "content": SYSTEM_JUDGE}, - {"role": "user", "content": blob}, - ], - temperature=0.2, - max_tokens=1024, - ) - content = data["choices"][0]["message"]["content"].strip() - try: - payload = json.loads(content) - winner = int(payload["winner"]) - reason = str(payload["reason"]) - except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: - raise RuntimeError(f"Judge returned invalid JSON: {content}") from exc - if winner not in {candidate.index for candidate in candidates}: - raise RuntimeError(f"Judge selected unknown candidate {winner}") - return winner, reason - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Generate Seinfeld-style short scene scripts.") - parser.add_argument("--produces-dir", type=Path, required=True) - parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Scene brief.") - parser.add_argument("--prompt-file", type=Path, help="Read scene brief from a text file.") - parser.add_argument("--candidates", type=int, default=1, help="Complete pipeline candidates to generate.") - parser.add_argument("--rough-attempts", type=int, default=5, help="Rough attempts per candidate.") - parser.add_argument("--select-best", action="store_true", help="Use a judge pass to select the best candidate.") - parser.add_argument("--open-result", action="store_true", help="Open selected_scene.md after writing it.") - parser.add_argument("--env-file", type=Path, default=Path.home() / ".hermes" / ".env") - return parser - - -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) - if args.candidates < 1: - raise SystemExit("--candidates must be >= 1") - if args.rough_attempts < 1: - raise SystemExit("--rough-attempts must be >= 1") - - _load_env_file(args.env_file) - api_key = os.environ.get("DEEPSEEK_API_KEY") - if not api_key: - raise SystemExit("DEEPSEEK_API_KEY is required") - - prompt = args.prompt_file.read_text(encoding="utf-8").strip() if args.prompt_file else args.prompt - produces_dir: Path = args.produces_dir - produces_dir.mkdir(parents=True, exist_ok=True) - - candidates: list[Candidate] = [] - max_workers = min(args.candidates, 5) - with ThreadPoolExecutor(max_workers=max_workers) as pool: - futures = [ - pool.submit( - _run_candidate, - index=index, - api_key=api_key, - produces_dir=produces_dir, - prompt=prompt, - rough_attempts=args.rough_attempts, - ) - for index in range(1, args.candidates + 1) - ] - for future in as_completed(futures): - candidates.append(future.result()) - candidates.sort(key=lambda candidate: candidate.index) - - if args.select_best or len(candidates) == 1: - winner_index, judge_reason = _judge_best(api_key, candidates) - else: - winner_index = candidates[0].index - judge_reason = "Selection skipped; defaulted to first candidate." - selected = next(candidate for candidate in candidates if candidate.index == winner_index) - - selected_md = selected.md_path.read_text(encoding="utf-8") - selected_md += f"\n---\n\n## Selection\n\nWinner: candidate {winner_index}\n\n{judge_reason}\n" - selected_path = produces_dir / "selected_scene.md" - _write_text(selected_path, selected_md) - - manifest = { - "prompt": prompt, - "candidates": [ - { - "index": candidate.index, - "markdown": str(candidate.md_path), - "work_dir": str(candidate.work_dir), - } - for candidate in candidates - ], - "selected_index": winner_index, - "selected_scene": str(selected_path), - "judge_reason": judge_reason, - } - _write_text(produces_dir / "manifest.json", json.dumps(manifest, indent=2) + "\n") - print(f"selected: {selected_path}") - - if args.open_result: - subprocess.run(["open", str(selected_path)], check=False) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/builtin-dataset-build.md b/docs/builtin-dataset-build.md index c773282..3301d3b 100644 --- a/docs/builtin-dataset-build.md +++ b/docs/builtin-dataset-build.md @@ -7,7 +7,7 @@ Run a no-network fixture smoke path: ```bash -python3 -m astrid.packs.builtin.dataset_build.run \ +python3 -m astrid orchestrators run builtin.dataset_build -- \ --config fixtures/builtin-training/dataset-config.json \ --out runs/builtin-training-fixture \ --review-decisions fixtures/builtin-training/review-decisions.json @@ -17,14 +17,14 @@ Run the Seinfeld example after replacing source URLs and providing the required ```bash OPENAI_API_KEY=... \ -python3 -m astrid.packs.builtin.dataset_build.run \ +python3 -m astrid orchestrators run builtin.dataset_build -- \ --config examples/configs/dataset/seinfeld-dataset.yaml \ --out runs/seinfeld-dataset ``` For unattended runs, pass `--review-decisions `. Without that flag, the orchestrator starts `builtin.human_review` with the generic review UI and waits for submit. -## Migration From `seinfeld.dataset_build` +## Migration From The Historical Seinfeld Dataset Builder Use `examples/configs/dataset/seinfeld-dataset.yaml` as the migration template. Show-specific behavior now belongs in config: @@ -37,6 +37,11 @@ Use `examples/configs/dataset/seinfeld-dataset.yaml` as the migration template. M1 reproduces the prototype's generic VLM bucket-judge and caption flow. It does not implement the M2b top-up loop, and there is intentionally no Seinfeld compatibility shim in built-in code. Continue using explicit config if a show-specific dataset needs different buckets, prompts, rights policy, or budgets. +Historical Seinfeld stage notes now live under `docs/examples/seinfeld/` as +archive-only reference material. Active workflows should use +`builtin.dataset_build`, `builtin.training_run`, and `builtin.script_pipeline` +with example configs or presets rather than direct pack-module execution. + ## Outputs The run writes only inside the requested `--out` directory: diff --git a/astrid/packs/seinfeld/CAPTIONING.md b/docs/examples/seinfeld/CAPTIONING.md similarity index 100% rename from astrid/packs/seinfeld/CAPTIONING.md rename to docs/examples/seinfeld/CAPTIONING.md diff --git a/astrid/packs/seinfeld/DATASET_QUALITY.md b/docs/examples/seinfeld/DATASET_QUALITY.md similarity index 100% rename from astrid/packs/seinfeld/DATASET_QUALITY.md rename to docs/examples/seinfeld/DATASET_QUALITY.md diff --git a/docs/examples/seinfeld/README.md b/docs/examples/seinfeld/README.md new file mode 100644 index 0000000..e35c7f1 --- /dev/null +++ b/docs/examples/seinfeld/README.md @@ -0,0 +1,63 @@ +# Historical Seinfeld Pack Archive + +This directory preserves the useful Seinfeld prototype material before the +`astrid/packs/seinfeld/` pack is deleted in Milestone 4. These files are +historical examples and reference material only. They are not registered +executors or orchestrators, and they should not be used as compatibility shims. + +## Canonical Built-In Commands + +Use the built-in dataset pipeline with the Seinfeld example config: + +```bash +python3 -m astrid start builtin.dataset_build --project +python3 -m astrid orchestrators run builtin.dataset_build -- --config examples/configs/dataset/seinfeld-dataset.yaml +``` + +Use the built-in training pipeline with the Seinfeld training config: + +```bash +python3 -m astrid orchestrators run builtin.training_run -- --config examples/configs/training/seinfeld-training.yaml --dry-run +python3 -m astrid orchestrators run builtin.training_run -- --config examples/configs/training/seinfeld-training.yaml --confirm-spend +``` + +Use the built-in script pipeline with the Seinfeld preset: + +```bash +python3 -m astrid executors inspect builtin.script_pipeline --json +python3 -m astrid.packs.builtin.script_pipeline.run --preset seinfeld --fake --produces-dir runs/seinfeld-script/produces +python3 -m astrid.packs.builtin.script_pipeline.run --preset seinfeld --produces-dir runs/seinfeld-script/produces +``` + +The direct module command above is useful when no Astrid session is bound. In a +bound session, prefer the executor gateway: + +```bash +python3 -m astrid executors run builtin.script_pipeline -- --preset seinfeld --produces-dir runs/seinfeld-script/produces +``` + +## Archived Contents + +- `TRAINING_PLAN.md`, `DATASET_QUALITY.md`, `CAPTIONING.md`, and + `RUNPOD_TRAINING_LAUNCHER_BRIEF.md`: historical prototype planning and + quality notes. +- `vocabulary.yaml` and `vocab_compile.py`: locked vocabulary reference and + the old schema compiler. +- `schemas/`: historical structured VLM schemas used by the prototype. +- `dataset_build/`: old dataset-build stage notes, review UI fixture, review + schema, and sprint brief. +- `aitoolkit_stage/`, `aitoolkit_train/`, `lora_eval_grid/`, + `lora_register/`, `lora_train/`, `repo_setup/`, and `script_pipeline/`: + old stage notes and training template references. + +## Migration Notes + +- `seinfeld.dataset_build` becomes `builtin.dataset_build` plus + `examples/configs/dataset/seinfeld-dataset.yaml`. +- `seinfeld.lora_train` becomes `builtin.training_run` plus + `examples/configs/training/seinfeld-training.yaml`. +- `seinfeld.script_pipeline` becomes `builtin.script_pipeline` plus the + `seinfeld` preset in `astrid/packs/builtin/script_pipeline/presets/`. +- Historical docs may still mention deleted `seinfeld.*` ids because they + preserve prototype context. Active docs and examples should point at the + built-in commands above. diff --git a/astrid/packs/seinfeld/RUNPOD_TRAINING_LAUNCHER_BRIEF.md b/docs/examples/seinfeld/RUNPOD_TRAINING_LAUNCHER_BRIEF.md similarity index 100% rename from astrid/packs/seinfeld/RUNPOD_TRAINING_LAUNCHER_BRIEF.md rename to docs/examples/seinfeld/RUNPOD_TRAINING_LAUNCHER_BRIEF.md diff --git a/astrid/packs/seinfeld/TRAINING_PLAN.md b/docs/examples/seinfeld/TRAINING_PLAN.md similarity index 100% rename from astrid/packs/seinfeld/TRAINING_PLAN.md rename to docs/examples/seinfeld/TRAINING_PLAN.md diff --git a/astrid/packs/seinfeld/aitoolkit_stage/OSTRIS_IMAGE_NOTES.md b/docs/examples/seinfeld/aitoolkit_stage/OSTRIS_IMAGE_NOTES.md similarity index 100% rename from astrid/packs/seinfeld/aitoolkit_stage/OSTRIS_IMAGE_NOTES.md rename to docs/examples/seinfeld/aitoolkit_stage/OSTRIS_IMAGE_NOTES.md diff --git a/astrid/packs/seinfeld/aitoolkit_stage/STAGE.md b/docs/examples/seinfeld/aitoolkit_stage/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/aitoolkit_stage/STAGE.md rename to docs/examples/seinfeld/aitoolkit_stage/STAGE.md diff --git a/astrid/packs/seinfeld/aitoolkit_train/STAGE.md b/docs/examples/seinfeld/aitoolkit_train/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/aitoolkit_train/STAGE.md rename to docs/examples/seinfeld/aitoolkit_train/STAGE.md diff --git a/astrid/packs/seinfeld/dataset_build/STAGE.md b/docs/examples/seinfeld/dataset_build/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/dataset_build/STAGE.md rename to docs/examples/seinfeld/dataset_build/STAGE.md diff --git a/astrid/packs/seinfeld/dataset_build/review.html b/docs/examples/seinfeld/dataset_build/review.html similarity index 100% rename from astrid/packs/seinfeld/dataset_build/review.html rename to docs/examples/seinfeld/dataset_build/review.html diff --git a/astrid/packs/seinfeld/dataset_build/review.schema.json b/docs/examples/seinfeld/dataset_build/review.schema.json similarity index 100% rename from astrid/packs/seinfeld/dataset_build/review.schema.json rename to docs/examples/seinfeld/dataset_build/review.schema.json diff --git a/astrid/packs/seinfeld/dataset_build/sprint-brief.md b/docs/examples/seinfeld/dataset_build/sprint-brief.md similarity index 100% rename from astrid/packs/seinfeld/dataset_build/sprint-brief.md rename to docs/examples/seinfeld/dataset_build/sprint-brief.md diff --git a/astrid/packs/seinfeld/lora_eval_grid/STAGE.md b/docs/examples/seinfeld/lora_eval_grid/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/lora_eval_grid/STAGE.md rename to docs/examples/seinfeld/lora_eval_grid/STAGE.md diff --git a/astrid/packs/seinfeld/lora_register/STAGE.md b/docs/examples/seinfeld/lora_register/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/lora_register/STAGE.md rename to docs/examples/seinfeld/lora_register/STAGE.md diff --git a/astrid/packs/seinfeld/lora_train/STAGE.md b/docs/examples/seinfeld/lora_train/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/lora_train/STAGE.md rename to docs/examples/seinfeld/lora_train/STAGE.md diff --git a/astrid/packs/seinfeld/lora_train/config_template.yaml b/docs/examples/seinfeld/lora_train/config_template.yaml similarity index 100% rename from astrid/packs/seinfeld/lora_train/config_template.yaml rename to docs/examples/seinfeld/lora_train/config_template.yaml diff --git a/astrid/packs/seinfeld/repo_setup/STAGE.md b/docs/examples/seinfeld/repo_setup/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/repo_setup/STAGE.md rename to docs/examples/seinfeld/repo_setup/STAGE.md diff --git a/astrid/packs/seinfeld/schemas/bucket_judge.json b/docs/examples/seinfeld/schemas/bucket_judge.json similarity index 100% rename from astrid/packs/seinfeld/schemas/bucket_judge.json rename to docs/examples/seinfeld/schemas/bucket_judge.json diff --git a/astrid/packs/seinfeld/schemas/caption.json b/docs/examples/seinfeld/schemas/caption.json similarity index 100% rename from astrid/packs/seinfeld/schemas/caption.json rename to docs/examples/seinfeld/schemas/caption.json diff --git a/astrid/packs/seinfeld/schemas/scene_verify.json b/docs/examples/seinfeld/schemas/scene_verify.json similarity index 100% rename from astrid/packs/seinfeld/schemas/scene_verify.json rename to docs/examples/seinfeld/schemas/scene_verify.json diff --git a/astrid/packs/seinfeld/script_pipeline/STAGE.md b/docs/examples/seinfeld/script_pipeline/STAGE.md similarity index 100% rename from astrid/packs/seinfeld/script_pipeline/STAGE.md rename to docs/examples/seinfeld/script_pipeline/STAGE.md diff --git a/astrid/packs/seinfeld/vocab_compile.py b/docs/examples/seinfeld/vocab_compile.py similarity index 100% rename from astrid/packs/seinfeld/vocab_compile.py rename to docs/examples/seinfeld/vocab_compile.py diff --git a/astrid/packs/seinfeld/vocabulary.yaml b/docs/examples/seinfeld/vocabulary.yaml similarity index 100% rename from astrid/packs/seinfeld/vocabulary.yaml rename to docs/examples/seinfeld/vocabulary.yaml diff --git a/docs/examples/training-workflow.md b/docs/examples/training-workflow.md new file mode 100644 index 0000000..b122993 --- /dev/null +++ b/docs/examples/training-workflow.md @@ -0,0 +1,125 @@ +# Built-In Dataset And Training Workflow + +This workflow is the canonical path for building a reviewed video dataset and +training an LTX LoRA with the built-in tools. The Seinfeld material under +`docs/examples/seinfeld/` is historical archive content; it documents the old +prototype but does not define registered tools. + +## 1. Build A Dataset + +Start from a strict dataset config. The checked-in examples are lightweight +templates with placeholder licensed sources and ignored `runs/` outputs. + +```bash +python3 -m astrid orchestrators run builtin.dataset_build -- \ + --config examples/configs/dataset/seinfeld-dataset.yaml \ + --out runs/seinfeld-dataset +``` + +For a CI or fixture-style run, pass review decisions explicitly: + +```bash +python3 -m astrid orchestrators run builtin.dataset_build -- \ + --config examples/configs/dataset/seinfeld-dataset.yaml \ + --out runs/seinfeld-dataset \ + --review-decisions fixtures/builtin-training/review-decisions.json +``` + +The dataset run writes `review_data.json`, `review_state.json`, +`final.manifest.json`, and `ai-toolkit-ltx.manifest.json` under the output +directory. + +## 2. Review And Finalize + +Without `--review-decisions`, `builtin.dataset_build` starts the generic human +review UI and waits for submit. Review accepts, rejects, or edits captions, and +accepted captions are propagated to sibling caption sidecars before manifest +export. The training workflow should consume the finalized +`ai-toolkit-ltx.manifest.json`, not provisional review data. + +Existing local runs such as `runs/seinfeld-dataset` are useful only when they +already contain finalized review artifacts. If a run only has provisional +outputs, rerun the dataset builder or provide review decisions before training. + +## 3. Dry-Run Training + +Run a dry-run before any live spend. It validates the training config, checks +declared secrets, normalizes the manifest into the training run directory, +builds the ai-toolkit config, writes `planned_cost.json`, and performs no +network, GPU, or RunPod calls. + +```bash +python3 -m astrid orchestrators run builtin.training_run -- \ + --config examples/configs/training/seinfeld-training.yaml \ + --dry-run +``` + +To use an existing finalized dataset run explicitly: + +```bash +python3 -m astrid orchestrators run builtin.training_run -- \ + --config examples/configs/training/seinfeld-training.yaml \ + --manifest runs/seinfeld-dataset/ai-toolkit-ltx.manifest.json \ + --out runs/seinfeld-lora \ + --dry-run +``` + +## 4. Live Training + +Live training fails closed if declared secrets are missing. Review the dry-run +artifacts and spend cap first, then confirm spend for the live run. + +```bash +RUNPOD_API_KEY=... HF_TOKEN=... \ +python3 -m astrid orchestrators run builtin.training_run -- \ + --config examples/configs/training/seinfeld-training.yaml \ + --confirm-spend +``` + +The run provisions compute, stages the normalized manifest and ai-toolkit +config, starts training, pulls review samples, and pauses at the checkpoint +review gate. + +## 5. Review Checkpoints + +Open the generated review page from the training run output, compare samples, +and choose a checkpoint label, step, basename, or remote path from +`checkpoints/checkpoint_manifest.json`. The paused state keeps the pod teardown +guard so the operator can resume safely. + +## 6. Resume And Register + +Resume with the chosen checkpoint. Registration pulls the selected +`.safetensors` file, writes `registered/registered_lora.json`, then tears down +the pod unless `--skip-teardown` is supplied intentionally. + +```bash +python3 -m astrid orchestrators run builtin.training_run -- \ + resume \ + --out runs/seinfeld-lora \ + --pick final \ + --notes "best checkpoint" +``` + +Use `--dry-run` with `resume` to inspect persisted state without mutating +remote resources: + +```bash +python3 -m astrid orchestrators run builtin.training_run -- \ + resume \ + --out runs/seinfeld-lora \ + --dry-run \ + --json +``` + +## 7. Script Presets + +For script generation, use the built-in script pipeline with a preset. The +Seinfeld and Always Sunny styles are data under +`astrid/packs/builtin/script_pipeline/presets/`. + +```bash +python3 -m astrid executors run builtin.script_pipeline -- \ + --preset seinfeld \ + --produces-dir runs/seinfeld-script/produces +``` diff --git a/docs/megaplan/epics/builtin-training/contracts/fixtures/training-run-config.seinfeld.json b/docs/megaplan/epics/builtin-training/contracts/fixtures/training-run-config.seinfeld.json index 21875db..f61fd44 100644 --- a/docs/megaplan/epics/builtin-training/contracts/fixtures/training-run-config.seinfeld.json +++ b/docs/megaplan/epics/builtin-training/contracts/fixtures/training-run-config.seinfeld.json @@ -2,7 +2,7 @@ "schema_version": 1, "trainer_id": "ai-toolkit-ltx", "manifest_path": "runs/seinfeld-dataset/ai-toolkit-ltx.manifest.json", - "vocabulary_path": "astrid/packs/seinfeld/vocabulary.yaml", + "vocabulary_path": "docs/examples/seinfeld/vocabulary.yaml", "compute": { "backend": "runpod", "gpu_type": "NVIDIA RTX 6000 Ada Generation", diff --git a/docs/megaplan/epics/builtin-training/contracts/schemas/ai-toolkit-adapter-manifest.schema.json b/docs/megaplan/epics/builtin-training/contracts/schemas/ai-toolkit-adapter-manifest.schema.json index ad7986a..b3f3e8a 100644 --- a/docs/megaplan/epics/builtin-training/contracts/schemas/ai-toolkit-adapter-manifest.schema.json +++ b/docs/megaplan/epics/builtin-training/contracts/schemas/ai-toolkit-adapter-manifest.schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://astrid.builtin-training/schemas/ai-toolkit-adapter-manifest.schema.json", "title": "AI-Toolkit Adapter Manifest", - "description": "The flat 'clips' shape expected by seinfeld.lora_train preflight and ai-toolkit staging. This is what the 'ai-toolkit-ltx' manifest adapter exports from the canonical manifest.", + "description": "The flat 'clips' shape expected by builtin.training_run preflight and ai-toolkit staging. This is what the 'ai-toolkit-ltx' manifest adapter exports from the canonical manifest.", "type": "object", "required": ["clips"], "additionalProperties": false, diff --git a/examples/configs/dataset/always-sunny-dataset.yaml b/examples/configs/dataset/always-sunny-dataset.yaml new file mode 100644 index 0000000..802ed49 --- /dev/null +++ b/examples/configs/dataset/always-sunny-dataset.yaml @@ -0,0 +1,80 @@ +schema_version: 1 +media_type: video +dataset_id: always-sunny-chaos-v1 +description: Generic builtin.dataset_build config for an Always Sunny-style LTX training dataset. + +sources: + - provider: youtube + config: + source_urls: + - "https://www.youtube.com/watch?v=REPLACE_WITH_LICENSED_SOURCE" + max_results_per_query: 1 + download_format: mp4 + +buckets: + bar_argument_escalation: + target_count: 30 + description: Bar or back-office scenes where a selfish plan escalates through fast ensemble argument. + search_queries: + - "Always Sunny bar argument scene" + +clip_config: + min_duration_s: 2.0 + max_duration_s: 7.0 + max_scenes_per_source: 20 + +caption: + provider: visual_understand + prompt_template: >- + Write a concise training caption for this chaotic ensemble bar-comedy clip. + Include the dingy setting, visible character grouping, camera framing, + argument energy, selfish scheme, and physical blocking. Do not mention + filenames, episode titles, or review labels. + +filters: + duration: + enabled: true + min_s: 2.0 + max_s: 7.0 + +review: + enabled: true + reject_reasons: + - watermark + - wrong_scene + - wrong_character + - bad_motion + - low_quality + - wrong_content + - rights_concern + - other + +manifest: + adapter: ai-toolkit-ltx + +budgets: + max_api_calls: 250 + max_estimated_cost_usd: 15.0 + providers: + caption.visual_understand: + max_calls: 125 + rate_limit_per_minute: 30 + bucket_judge.visual_understand: + max_calls: 125 + rate_limit_per_minute: 30 + +output: + run_dir: runs/always-sunny-dataset + +extensions: + bucket_judge: + enabled: true + provider: visual_understand + buckets: + - bar_argument_escalation + prompt_template: >- + Classify this clip into the configured bucket: {buckets}. Accept only + clean ensemble bar-comedy footage with fast argument escalation and + grounded blocking. Reject commercials, interviews, title cards, + watermarked uploads, compilations with heavy edits, and off-topic clips. + Return strict JSON with accept, bucket, reason, and score. diff --git a/examples/configs/training/always-sunny-training.yaml b/examples/configs/training/always-sunny-training.yaml new file mode 100644 index 0000000..9b3d057 --- /dev/null +++ b/examples/configs/training/always-sunny-training.yaml @@ -0,0 +1,48 @@ +schema_version: 1 +trainer_id: ai-toolkit-ltx +manifest_path: runs/always-sunny-dataset/ai-toolkit-ltx.manifest.json +compute: + backend: runpod + gpu_type: NVIDIA RTX 6000 Ada Generation + container_disk_gb: 200 + max_runtime_seconds: 43200 + max_gpu_hours: 12 + max_runpod_spend_usd: 15 + require_spend_confirmation: true + storage_name: always-sunny-dataset + image: ostris/aitoolkit:latest + ports: 8675/http,22/tcp +secrets: + required_env: + - RUNPOD_API_KEY + - HF_TOKEN +base_model: Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors +lora_config: + lora_id: always-sunny-chaos-v1 + trigger_word: paddy chaos scene + prompt_text: chaotic ensemble bar comedy scene with handheld-feeling blocking, fast selfish argument, and dingy practical lighting. + rank: 32 + alpha: 32 + steps: 2000 + learning_rate: 0.00002 + seed: 43 + width: 512 + height: 768 + num_frames: 121 + fps: 24 + batch_size: 1 + gradient_accumulation_steps: 4 + save_every: 250 + sample_every: 250 +checkpoint: + enabled: true + sample_prompts: + - paddy chaos scene, a tense bar argument around a terrible business idea + - paddy chaos scene, a cramped back-office argument with selfish escalation + review_labels: + - ensemble argument + - grimy bar staging + - caption alignment + - motion stability +output: + run_dir: runs/always-sunny-lora diff --git a/examples/configs/training/seinfeld-training.yaml b/examples/configs/training/seinfeld-training.yaml index a770f3e..c55965f 100644 --- a/examples/configs/training/seinfeld-training.yaml +++ b/examples/configs/training/seinfeld-training.yaml @@ -1,7 +1,7 @@ schema_version: 1 trainer_id: ai-toolkit-ltx manifest_path: runs/seinfeld-dataset/ai-toolkit-ltx.manifest.json -vocabulary_path: astrid/packs/seinfeld/vocabulary.yaml +vocabulary_path: ../../../docs/examples/seinfeld/vocabulary.yaml compute: backend: runpod gpu_type: NVIDIA RTX 6000 Ada Generation diff --git a/tests/packs/builtin/dataset_build/test_examples_docs.py b/tests/packs/builtin/dataset_build/test_examples_docs.py index 3b9e997..26406d4 100644 --- a/tests/packs/builtin/dataset_build/test_examples_docs.py +++ b/tests/packs/builtin/dataset_build/test_examples_docs.py @@ -8,9 +8,12 @@ ROOT = Path(__file__).resolve().parents[4] EXAMPLE_CONFIG = ROOT / "examples" / "configs" / "dataset" / "seinfeld-dataset.yaml" +ALWAYS_SUNNY_CONFIG = ROOT / "examples" / "configs" / "dataset" / "always-sunny-dataset.yaml" MIGRATION_DOC = ROOT / "docs" / "builtin-dataset-build.md" +TRAINING_WORKFLOW_DOC = ROOT / "docs" / "examples" / "training-workflow.md" BUILTIN_PACKAGE = ROOT / "astrid" / "packs" / "builtin" / "dataset_build" ORCHESTRATOR = BUILTIN_PACKAGE / "orchestrator.yaml" +SEINFELD_ARCHIVE = ROOT / "docs" / "examples" / "seinfeld" def test_seinfeld_example_is_strict_generic_dataset_config() -> None: @@ -23,6 +26,21 @@ def test_seinfeld_example_is_strict_generic_dataset_config() -> None: assert "apartment_group_dialogue" in parsed.data["buckets"] +def test_always_sunny_example_is_distinct_strict_generic_dataset_config() -> None: + seinfeld = load_dataset_config(EXAMPLE_CONFIG).data + always_sunny = load_dataset_config(ALWAYS_SUNNY_CONFIG).data + + assert always_sunny["media_type"] == "video" + assert always_sunny["manifest"]["adapter"] == "ai-toolkit-ltx" + assert list(always_sunny["buckets"]) == ["bar_argument_escalation"] + assert len(always_sunny["buckets"]) == 1 + assert "bar-comedy" in always_sunny["caption"]["prompt_template"] + assert always_sunny["caption"]["prompt_template"] != seinfeld["caption"]["prompt_template"] + assert always_sunny["output"]["run_dir"].endswith("runs/always-sunny-dataset") + assert "REPLACE_WITH_LICENSED_SOURCE" in always_sunny["sources"][0]["config"]["source_urls"][0] + assert "astrid/packs/seinfeld" not in ALWAYS_SUNNY_CONFIG.read_text(encoding="utf-8") + + def test_migration_docs_explain_m1_scope_and_no_compatibility_shim() -> None: text = MIGRATION_DOC.read_text(encoding="utf-8") @@ -30,6 +48,50 @@ def test_migration_docs_explain_m1_scope_and_no_compatibility_shim() -> None: assert "does not implement the M2b top-up loop" in text assert "no Seinfeld compatibility shim" in text assert "examples/configs/dataset/seinfeld-dataset.yaml" in text + assert "python3 -m astrid orchestrators run builtin.dataset_build --" in text + assert "python3 -m astrid.packs." + "seinfeld" not in text + + +def test_training_workflow_doc_uses_canonical_builtin_commands() -> None: + text = TRAINING_WORKFLOW_DOC.read_text(encoding="utf-8") + + assert "python3 -m astrid orchestrators run builtin.dataset_build --" in text + assert "python3 -m astrid orchestrators run builtin.training_run --" in text + assert "python3 -m astrid executors run builtin.script_pipeline --" in text + assert "review_state.json" in text + assert "ai-toolkit-ltx.manifest.json" in text + assert "checkpoints/checkpoint_manifest.json" in text + assert "registered/registered_lora.json" in text + assert "runs/seinfeld-dataset" in text + assert "docs/examples/seinfeld/" in text + assert "python3 -m astrid.packs." + "seinfeld" not in text + + +def test_historical_seinfeld_archive_contains_migration_materials() -> None: + expected = [ + "README.md", + "TRAINING_PLAN.md", + "DATASET_QUALITY.md", + "CAPTIONING.md", + "RUNPOD_TRAINING_LAUNCHER_BRIEF.md", + "vocabulary.yaml", + "vocab_compile.py", + "schemas/bucket_judge.json", + "schemas/caption.json", + "schemas/scene_verify.json", + "dataset_build/sprint-brief.md", + "dataset_build/review.schema.json", + "dataset_build/review.html", + "lora_train/config_template.yaml", + ] + missing = [path for path in expected if not (SEINFELD_ARCHIVE / path).is_file()] + assert missing == [] + + readme = (SEINFELD_ARCHIVE / "README.md").read_text(encoding="utf-8") + assert "builtin.dataset_build" in readme + assert "builtin.training_run" in readme + assert "builtin.script_pipeline" in readme + assert "compatibility shims" in readme def test_builtin_dataset_build_code_has_no_seinfeld_literals() -> None: diff --git a/tests/packs/builtin/script_pipeline/test_script_pipeline.py b/tests/packs/builtin/script_pipeline/test_script_pipeline.py new file mode 100644 index 0000000..68b8166 --- /dev/null +++ b/tests/packs/builtin/script_pipeline/test_script_pipeline.py @@ -0,0 +1,122 @@ +"""builtin.script_pipeline fake-mode coverage.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +from astrid.packs.builtin.script_pipeline import run as script_run + + +def test_fake_mode_writes_candidates_selected_and_manifest(tmp_path: Path) -> None: + produces = tmp_path / "produces" + + rc = script_run.main( + [ + "--produces-dir", + str(produces), + "--preset", + "seinfeld", + "--fake", + "--candidates", + "2", + "--rough-attempts", + "3", + "--select-best", + "--json", + ] + ) + + assert rc == 0 + selected = produces / "selected_scene.md" + manifest_path = produces / "manifest.json" + assert selected.is_file() + assert manifest_path.is_file() + candidate_paths = sorted((produces / "candidates").glob("candidate_*.md")) + assert len(candidate_paths) == 2 + work_dirs = sorted((produces / "work").glob("*")) + assert len(work_dirs) == 2 + assert all(len(list(path.glob("rough_*.txt"))) == 3 for path in work_dirs) + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["schema_version"] == 1 + assert manifest["preset"] == "seinfeld" + assert manifest["provider"] == {"name": "deepseek", "model": "deepseek-v4-pro"} + assert manifest["rough_attempts"] == 3 + assert manifest["selected_index"] == 1 + assert len(manifest["candidates"]) == 2 + assert "Fake judge selected" in manifest["judge_reason"] + assert "Candidate 1" in selected.read_text(encoding="utf-8") + + +def test_config_loading_keeps_provider_model_in_preset_data(tmp_path: Path) -> None: + preset = yaml.safe_load((script_run.PRESETS_DIR / "seinfeld.yaml").read_text(encoding="utf-8")) + preset["id"] = "custom" + preset["provider"]["model"] = "deepseek-custom" + preset["defaults"]["candidates"] = 1 + preset["defaults"]["rough_attempts"] = 1 + path = tmp_path / "custom.yaml" + path.write_text(yaml.safe_dump(preset, sort_keys=False), encoding="utf-8") + + config = script_run.load_pipeline_config(path) + assert config.preset_id == "custom" + assert config.provider.name == "deepseek" + assert config.provider.model == "deepseek-custom" + + manifest = script_run.run_pipeline( + config=config, + client=script_run.FakeScriptClient(), + produces_dir=tmp_path / "out", + prompt="custom prompt", + candidates_count=1, + rough_attempts=1, + select_best=False, + max_workers=1, + ) + assert manifest["provider"]["model"] == "deepseek-custom" + assert Path(manifest["selected_scene"]).is_file() + + +def test_style_rules_live_in_presets_and_always_sunny_is_distinct() -> None: + run_text = Path(script_run.__file__).read_text(encoding="utf-8") + for forbidden in ("KRAMER", "JERRY", "GEORGE", "LAUGHTER", "APPLAUSE", "laugh tags"): + assert forbidden.lower() not in run_text.lower() + + seinfeld = script_run.load_pipeline_config("seinfeld") + always_sunny = script_run.load_pipeline_config("always_sunny") + + assert "Seinfeld" in seinfeld.title + assert "Always Sunny" in always_sunny.title + assert "laugh tags" in seinfeld.prompts["voice_system"].lower() + assert "do not add laugh tags" in always_sunny.prompts["voice_system"].lower() + assert "bar" in always_sunny.prompt.lower() + assert "Kramer" in seinfeld.prompt + assert "Kramer" not in always_sunny.prompt + assert "selfish" in always_sunny.prompts["judge_system"].lower() + assert always_sunny.prompts["judge_system"] != seinfeld.prompts["judge_system"] + + +def test_always_sunny_fake_mode_uses_preset_metadata(tmp_path: Path) -> None: + produces = tmp_path / "always-sunny" + + rc = script_run.main( + [ + "--produces-dir", + str(produces), + "--preset", + "always_sunny", + "--fake", + "--candidates", + "1", + "--rough-attempts", + "1", + ] + ) + + assert rc == 0 + manifest = json.loads((produces / "manifest.json").read_text(encoding="utf-8")) + assert manifest["preset"] == "always_sunny" + assert manifest["provider"] == {"name": "deepseek", "model": "deepseek-v4-pro"} + assert Path(manifest["selected_scene"]).is_file() diff --git a/tests/packs/builtin/test_training_run_seinfeld_parity.py b/tests/packs/builtin/test_training_run_seinfeld_parity.py index 6a46446..deef22c 100644 --- a/tests/packs/builtin/test_training_run_seinfeld_parity.py +++ b/tests/packs/builtin/test_training_run_seinfeld_parity.py @@ -10,10 +10,9 @@ import yaml from astrid.packs.builtin.dataset_build.interfaces import ArtifactPullResult, CostEstimate, ProviderCapabilities, RemoteExecResult, RunPodHandle +from astrid.packs.builtin.training_run.defaults import AI_TOOLKIT_LTX_DEFAULTS, RUNPOD_LTX_DEFAULTS import astrid.packs.builtin.training_run.run as training_run_module from astrid.packs.builtin.training_run.run import main as training_run_main -from astrid.packs.seinfeld.aitoolkit_stage.run import HIVEMIND_DEFAULTS -from astrid.packs.seinfeld.lora_train import run as legacy_lora_train REPO_ROOT = Path(__file__).resolve().parents[3] EXAMPLE_CONFIG = REPO_ROOT / "examples" / "configs" / "training" / "seinfeld-training.yaml" @@ -75,7 +74,7 @@ def provision(self, config: dict[str, Any]) -> RunPodHandle: self.provision_calls.append(dict(config)) handle_path = self.tmp_path / "pod_handle.json" handle_path.write_text(json.dumps({"pod_id": "pod-seinfeld"}) + "\n", encoding="utf-8") - return RunPodHandle("pod-seinfeld", legacy_lora_train.DEFAULT_GPU, metadata={"handle_path": str(handle_path)}) + return RunPodHandle("pod-seinfeld", RUNPOD_LTX_DEFAULTS["gpu_type"], metadata={"handle_path": str(handle_path)}) def teardown(self, handle: RunPodHandle) -> None: self.teardown_calls.append(handle) @@ -123,28 +122,28 @@ def test_seinfeld_example_config_preserves_legacy_defaults_without_wrapper() -> compute = payload["compute"] lora = payload["lora_config"] - assert compute["image"] == legacy_lora_train.DEFAULT_IMAGE - assert compute["ports"] == legacy_lora_train.DEFAULT_PORTS - assert compute["storage_name"] == legacy_lora_train.DEFAULT_STORAGE - assert compute["gpu_type"] == legacy_lora_train.DEFAULT_GPU - assert compute["container_disk_gb"] == legacy_lora_train.DEFAULT_CONTAINER_DISK_GB - assert compute["max_runtime_seconds"] == legacy_lora_train.DEFAULT_MAX_RUNTIME - assert payload["base_model"] == legacy_lora_train.DEFAULT_BASE_MODEL + assert compute["image"] == RUNPOD_LTX_DEFAULTS["image"] + assert compute["ports"] == RUNPOD_LTX_DEFAULTS["ports"] + assert compute["storage_name"] == "seinfeld-dataset" + assert compute["gpu_type"] == RUNPOD_LTX_DEFAULTS["gpu_type"] + assert compute["container_disk_gb"] == RUNPOD_LTX_DEFAULTS["container_disk_gb"] + assert compute["max_runtime_seconds"] == RUNPOD_LTX_DEFAULTS["max_runtime_seconds"] + assert payload["base_model"] == AI_TOOLKIT_LTX_DEFAULTS["base_model_default"] assert lora["lora_id"] == "seinfeld-scene-v1" - assert lora["trigger_word"] == HIVEMIND_DEFAULTS["trigger_word"] - assert lora["rank"] == HIVEMIND_DEFAULTS["rank"] - assert lora["alpha"] == HIVEMIND_DEFAULTS["rank"] - assert lora["steps"] == HIVEMIND_DEFAULTS["steps_default"] - assert lora["learning_rate"] == HIVEMIND_DEFAULTS["lr"] - assert lora["seed"] == HIVEMIND_DEFAULTS["seed_default"] - assert lora["width"] == HIVEMIND_DEFAULTS["resolution"] + assert lora["trigger_word"] == "seinfeld scene" + assert lora["rank"] == AI_TOOLKIT_LTX_DEFAULTS["rank"] + assert lora["alpha"] == AI_TOOLKIT_LTX_DEFAULTS["rank"] + assert lora["steps"] == AI_TOOLKIT_LTX_DEFAULTS["steps_default"] + assert lora["learning_rate"] == AI_TOOLKIT_LTX_DEFAULTS["learning_rate"] + assert lora["seed"] == AI_TOOLKIT_LTX_DEFAULTS["seed_default"] + assert lora["width"] == AI_TOOLKIT_LTX_DEFAULTS["resolution"] assert lora["height"] == 768 - assert lora["num_frames"] == HIVEMIND_DEFAULTS["num_frames"] - assert lora["fps"] == HIVEMIND_DEFAULTS["fps"] - assert lora["batch_size"] == HIVEMIND_DEFAULTS["batch_size"] - assert lora["gradient_accumulation_steps"] == HIVEMIND_DEFAULTS["grad_accum"] - assert lora["save_every"] == HIVEMIND_DEFAULTS["save_every"] - assert lora["sample_every"] == HIVEMIND_DEFAULTS["sample_every"] + assert lora["num_frames"] == AI_TOOLKIT_LTX_DEFAULTS["num_frames"] + assert lora["fps"] == AI_TOOLKIT_LTX_DEFAULTS["fps"] + assert lora["batch_size"] == AI_TOOLKIT_LTX_DEFAULTS["batch_size"] + assert lora["gradient_accumulation_steps"] == AI_TOOLKIT_LTX_DEFAULTS["gradient_accumulation_steps"] + assert lora["save_every"] == AI_TOOLKIT_LTX_DEFAULTS["save_every"] + assert lora["sample_every"] == AI_TOOLKIT_LTX_DEFAULTS["sample_every"] assert not (REPO_ROOT / "astrid" / "packs" / "seinfeld" / "training_run").exists() assert not (REPO_ROOT / "astrid" / "packs" / "seinfeld" / "builtin_training_run").exists() @@ -171,25 +170,29 @@ def test_seinfeld_config_dry_run_emits_equivalent_ai_toolkit_config(tmp_path: Pa process = generated["config"]["process"][0] payload = _example_payload() - assert process["trigger_word"] == HIVEMIND_DEFAULTS["trigger_word"] - assert process["network"] == {"type": "lora", "linear": HIVEMIND_DEFAULTS["rank"], "linear_alpha": HIVEMIND_DEFAULTS["rank"]} - assert process["save"]["save_every"] == HIVEMIND_DEFAULTS["save_every"] + assert process["trigger_word"] == "seinfeld scene" + assert process["network"] == { + "type": "lora", + "linear": AI_TOOLKIT_LTX_DEFAULTS["rank"], + "linear_alpha": AI_TOOLKIT_LTX_DEFAULTS["rank"], + } + assert process["save"]["save_every"] == AI_TOOLKIT_LTX_DEFAULTS["save_every"] assert process["datasets"][0]["folder_path"] == "/workspace/dataset" - assert process["datasets"][0]["resolution"] == [HIVEMIND_DEFAULTS["resolution"], 768] - assert process["datasets"][0]["num_frames"] == HIVEMIND_DEFAULTS["num_frames"] - assert process["datasets"][0]["fps"] == HIVEMIND_DEFAULTS["fps"] - assert process["train"]["batch_size"] == HIVEMIND_DEFAULTS["batch_size"] - assert process["train"]["steps"] == HIVEMIND_DEFAULTS["steps_default"] - assert process["train"]["gradient_accumulation_steps"] == HIVEMIND_DEFAULTS["grad_accum"] - assert process["train"]["lr"] == HIVEMIND_DEFAULTS["lr"] - assert process["train"]["seed"] == HIVEMIND_DEFAULTS["seed_default"] - assert process["model"]["name_or_path"] == legacy_lora_train.DEFAULT_BASE_MODEL + assert process["datasets"][0]["resolution"] == [AI_TOOLKIT_LTX_DEFAULTS["resolution"], 768] + assert process["datasets"][0]["num_frames"] == AI_TOOLKIT_LTX_DEFAULTS["num_frames"] + assert process["datasets"][0]["fps"] == AI_TOOLKIT_LTX_DEFAULTS["fps"] + assert process["train"]["batch_size"] == AI_TOOLKIT_LTX_DEFAULTS["batch_size"] + assert process["train"]["steps"] == AI_TOOLKIT_LTX_DEFAULTS["steps_default"] + assert process["train"]["gradient_accumulation_steps"] == AI_TOOLKIT_LTX_DEFAULTS["gradient_accumulation_steps"] + assert process["train"]["lr"] == AI_TOOLKIT_LTX_DEFAULTS["learning_rate"] + assert process["train"]["seed"] == AI_TOOLKIT_LTX_DEFAULTS["seed_default"] + assert process["model"]["name_or_path"] == AI_TOOLKIT_LTX_DEFAULTS["base_model_default"] assert process["model"]["is_ltx"] is True - assert process["sample"]["sample_every"] == HIVEMIND_DEFAULTS["sample_every"] - assert process["sample"]["width"] == HIVEMIND_DEFAULTS["resolution"] + assert process["sample"]["sample_every"] == AI_TOOLKIT_LTX_DEFAULTS["sample_every"] + assert process["sample"]["width"] == AI_TOOLKIT_LTX_DEFAULTS["resolution"] assert process["sample"]["height"] == 768 - assert process["sample"]["num_frames"] == HIVEMIND_DEFAULTS["num_frames"] - assert process["sample"]["fps"] == HIVEMIND_DEFAULTS["fps"] + assert process["sample"]["num_frames"] == AI_TOOLKIT_LTX_DEFAULTS["num_frames"] + assert process["sample"]["fps"] == AI_TOOLKIT_LTX_DEFAULTS["fps"] assert process["sample"]["prompts"] == payload["checkpoint"]["sample_prompts"] assert generated["meta"]["name"] == "seinfeld-scene-v1" assert generated["meta"]["review_labels"] == payload["checkpoint"]["review_labels"] diff --git a/tests/packs/builtin/training_run/test_ai_toolkit_support.py b/tests/packs/builtin/training_run/test_ai_toolkit_support.py new file mode 100644 index 0000000..0537f0f --- /dev/null +++ b/tests/packs/builtin/training_run/test_ai_toolkit_support.py @@ -0,0 +1,156 @@ +"""Built-in ai-toolkit support migrated from the retired Seinfeld pack tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from astrid.packs.builtin.dataset_build.interfaces import ArtifactPullResult, RemoteExecResult, RunPodHandle +from astrid.packs.builtin.training_run.ai_toolkit.register import register_checkpoint +from astrid.packs.builtin.training_run.ai_toolkit.stage import preflight_stage_inputs, stage_training_inputs + + +class RecordingStageRemote: + def __init__(self, *, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.calls: list[dict[str, object]] = [] + + def exec(self, handle: RunPodHandle, command: list[str], config: dict[str, object]) -> RemoteExecResult: + self.calls.append({"handle": handle, "command": list(command), "config": dict(config)}) + return RemoteExecResult(exit_code=self.exit_code, stdout="ok\n", stderr="failed\n" if self.exit_code else "", command=command) + + +class RecordingRegisterRemote: + def __init__(self, *, create_checkpoint: bool = True) -> None: + self.create_checkpoint = create_checkpoint + self.calls: list[dict[str, object]] = [] + + def pull_artifacts( + self, + handle: RunPodHandle, + remote_paths: list[str], + local_dir: Path, + config: dict[str, object], + ) -> ArtifactPullResult: + self.calls.append({"handle": handle, "remote_paths": list(remote_paths), "local_dir": local_dir, "config": dict(config)}) + local_paths: list[Path] = [] + if self.create_checkpoint: + local_dir.mkdir(parents=True, exist_ok=True) + for remote_path in remote_paths: + local_path = local_dir / Path(remote_path).name + local_path.write_bytes(b"safetensors") + local_paths.append(local_path) + return ArtifactPullResult(local_paths=local_paths, remote_paths=list(remote_paths), metadata={"strategy": "test"}) + + +def test_stage_preflight_rejects_missing_manifest_or_config(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.json" + trainer_config = tmp_path / "config.yaml" + manifest.write_text("{}\n", encoding="utf-8") + + with pytest.raises(FileNotFoundError, match="config.yaml"): + preflight_stage_inputs(manifest_path=manifest, trainer_config_path=trainer_config) + + +def test_stage_training_inputs_records_upload_contract(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.json" + trainer_config = tmp_path / "trainer" / "config.yaml" + manifest.write_text("{}\n", encoding="utf-8") + trainer_config.parent.mkdir(parents=True) + trainer_config.write_text("name: test\n", encoding="utf-8") + remote = RecordingStageRemote() + handle = RunPodHandle("pod-123", "NVIDIA RTX 6000 Ada Generation") + + result = stage_training_inputs( + remote, + handle, + manifest_path=manifest, + trainer_config_path=trainer_config, + local_root=tmp_path / "run", + remote_root="/workspace/custom", + upload_mode="sftp_walk", + excludes=["*.tmp", "cache"], + produces_dir=tmp_path / "produces", + timeout=120, + ) + + assert result.remote_root == "/workspace/custom" + assert result.local_root == (tmp_path / "run").resolve() + assert result.exec_result.exit_code == 0 + assert len(remote.calls) == 1 + config = remote.calls[0]["config"] + assert config["local_root"] == (tmp_path / "run").resolve() + assert config["remote_root"] == "/workspace/custom" + assert config["upload_mode"] == "sftp_walk" + assert config["excludes"] == "*.tmp,cache" + assert config["timeout"] == 120 + assert "mkdir -p /workspace/custom/dataset /workspace/custom/output" in str(config["remote_script"]) + + +def test_stage_training_inputs_raises_on_remote_failure(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.json" + trainer_config = tmp_path / "config.yaml" + manifest.write_text("{}\n", encoding="utf-8") + trainer_config.write_text("name: test\n", encoding="utf-8") + + with pytest.raises(RuntimeError, match="ai-toolkit staging failed"): + stage_training_inputs( + RecordingStageRemote(exit_code=1), + RunPodHandle("pod-123", "NVIDIA RTX 6000 Ada Generation"), + manifest_path=manifest, + trainer_config_path=trainer_config, + local_root=tmp_path / "run", + remote_root="/workspace", + ) + + +def test_register_checkpoint_pulls_copies_and_writes_metadata(tmp_path: Path) -> None: + remote = RecordingRegisterRemote() + handle = RunPodHandle("pod-123", "NVIDIA RTX 6000 Ada Generation") + + result = register_checkpoint( + remote, + handle, + checkpoint_remote_path="/workspace/output/demo-final.safetensors", + local_dir=tmp_path / "pulled", + registry_dir=tmp_path / "registered", + lora_id="demo-lora", + metadata={"notes": "best identity", "checkpoint": {"step": 1500}}, + produces_dir=tmp_path / "produces", + ) + + assert remote.calls[0]["remote_paths"] == ["/workspace/output/demo-final.safetensors"] + assert result.pulled_checkpoint_path.is_file() + assert result.registered_lora_path == (tmp_path / "registered" / "demo-final.safetensors").resolve() + assert result.registered_lora_path.is_file() + payload = json.loads(result.metadata_path.read_text(encoding="utf-8")) + assert payload["schema_version"] == 1 + assert payload["lora_id"] == "demo-lora" + assert payload["source_checkpoint_remote_path"] == "/workspace/output/demo-final.safetensors" + assert payload["metadata"]["notes"] == "best identity" + assert payload["metadata"]["checkpoint"]["step"] == 1500 + + +def test_register_checkpoint_rejects_non_safetensors_and_missing_pull(tmp_path: Path) -> None: + handle = RunPodHandle("pod-123", "NVIDIA RTX 6000 Ada Generation") + with pytest.raises(ValueError, match="safetensors"): + register_checkpoint( + RecordingRegisterRemote(), + handle, + checkpoint_remote_path="/workspace/output/demo-final.ckpt", + local_dir=tmp_path / "pulled", + registry_dir=tmp_path / "registered", + lora_id="demo-lora", + ) + + with pytest.raises(FileNotFoundError, match="pulled checkpoint is missing"): + register_checkpoint( + RecordingRegisterRemote(create_checkpoint=False), + handle, + checkpoint_remote_path="/workspace/output/demo-final.safetensors", + local_dir=tmp_path / "missing", + registry_dir=tmp_path / "registered", + lora_id="demo-lora", + ) diff --git a/tests/packs/builtin/training_run/test_config_manifest_trainer.py b/tests/packs/builtin/training_run/test_config_manifest_trainer.py index 82b501c..41f8f0c 100644 --- a/tests/packs/builtin/training_run/test_config_manifest_trainer.py +++ b/tests/packs/builtin/training_run/test_config_manifest_trainer.py @@ -65,6 +65,15 @@ def test_manifest_normalization_accepts_valid_input_and_fails_on_missing_files( with pytest.raises(TrainingManifestError, match="clip_file missing"): normalize_ai_toolkit_manifest(missing_manifest, tmp_path / "bad-run") + missing_caption_manifest = tmp_path / "missing-caption.json" + missing_caption_manifest.write_text( + json.dumps({"clips": [{"clip_id": "clip_003", "clip_file": str(clip)}]}), + encoding="utf-8", + ) + caption.unlink() + with pytest.raises(TrainingManifestError, match="inferred caption sidecar missing"): + normalize_ai_toolkit_manifest(missing_caption_manifest, tmp_path / "bad-caption-run") + def test_trainer_adapter_generates_config_and_generic_python_omits_seinfeld_literals( tmp_path: Path, diff --git a/tests/packs/builtin/training_run/test_defaults.py b/tests/packs/builtin/training_run/test_defaults.py new file mode 100644 index 0000000..95839cf --- /dev/null +++ b/tests/packs/builtin/training_run/test_defaults.py @@ -0,0 +1,51 @@ +"""Built-in training-run defaults.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from astrid.packs.builtin.training_run.defaults import AI_TOOLKIT_LTX_DEFAULTS, RUNPOD_LTX_DEFAULTS +from astrid.packs.builtin.training_run.config import load_training_run_config + + +ROOT = Path(__file__).resolve().parents[4] +EXAMPLE_CONFIG = ROOT / "examples" / "configs" / "training" / "seinfeld-training.yaml" +ALWAYS_SUNNY_CONFIG = ROOT / "examples" / "configs" / "training" / "always-sunny-training.yaml" + + +def test_seinfeld_training_example_uses_builtin_defaults_and_archived_vocabulary_path() -> None: + payload = yaml.safe_load(EXAMPLE_CONFIG.read_text(encoding="utf-8")) + compute = payload["compute"] + lora = payload["lora_config"] + + assert payload["vocabulary_path"] == "../../../docs/examples/seinfeld/vocabulary.yaml" + assert "astrid/packs/seinfeld" not in payload["vocabulary_path"] + assert compute["image"] == RUNPOD_LTX_DEFAULTS["image"] + assert compute["ports"] == RUNPOD_LTX_DEFAULTS["ports"] + assert compute["storage_name"] == "seinfeld-dataset" + assert compute["gpu_type"] == RUNPOD_LTX_DEFAULTS["gpu_type"] + assert payload["base_model"] == AI_TOOLKIT_LTX_DEFAULTS["base_model_default"] + assert lora["lora_id"] == "seinfeld-scene-v1" + assert lora["trigger_word"] == "seinfeld scene" + assert lora["rank"] == AI_TOOLKIT_LTX_DEFAULTS["rank"] + assert lora["steps"] == AI_TOOLKIT_LTX_DEFAULTS["steps_default"] + + +def test_always_sunny_training_example_is_distinct_and_dry_run_safe() -> None: + payload = yaml.safe_load(ALWAYS_SUNNY_CONFIG.read_text(encoding="utf-8")) + parsed = load_training_run_config(ALWAYS_SUNNY_CONFIG) + text = ALWAYS_SUNNY_CONFIG.read_text(encoding="utf-8") + + assert parsed.data["trainer_id"] == "ai-toolkit-ltx" + assert parsed.data["output"]["run_dir"].endswith("runs/always-sunny-lora") + assert "astrid/packs/seinfeld" not in text + assert "OPENAI_API_KEY" not in text + assert "RUNPOD_API_KEY" in payload["secrets"]["required_env"] + assert "HF_TOKEN" in payload["secrets"]["required_env"] + assert payload["compute"]["storage_name"] == "always-sunny-dataset" + assert payload["lora_config"]["lora_id"] == "always-sunny-chaos-v1" + assert payload["lora_config"]["trigger_word"] == "paddy chaos scene" + assert payload["lora_config"]["seed"] != yaml.safe_load(EXAMPLE_CONFIG.read_text(encoding="utf-8"))["lora_config"]["seed"] + assert payload["checkpoint"]["sample_prompts"] != yaml.safe_load(EXAMPLE_CONFIG.read_text(encoding="utf-8"))["checkpoint"]["sample_prompts"] diff --git a/tests/packs/seinfeld/__init__.py b/tests/packs/seinfeld/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/packs/seinfeld/_fixtures.py b/tests/packs/seinfeld/_fixtures.py deleted file mode 100644 index e5b4dea..0000000 --- a/tests/packs/seinfeld/_fixtures.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Shared fixtures for seinfeld pack tests.""" - -from __future__ import annotations - -import json -from pathlib import Path - -VOCAB_YAML = """\ -version: 0.0.0-draft -scenes: - jerrys_apt: "Jerry's apartment" - monks_diner: "Monk's coffee shop" -characters: - jerry: {description: "Mid-30s male"} - george: {description: "Short stocky"} -shot_types: - wide: "Wide framing" - medium: "Medium two-shot" -""" - - -def make_dataset(root: Path, n_clips: int = 6) -> Path: - """Create a fake dataset with n clips + caption sidecars. - - Returns the manifest path. - """ - clips_dir = root / "clips" - clips_dir.mkdir(parents=True, exist_ok=True) - clips = [] - for i in range(n_clips): - clip_id = f"clip_{i:03d}" - clip_file = clips_dir / f"{clip_id}.mp4" - clip_file.write_bytes(b"\x00") # placeholder - cap = clips_dir / f"{clip_id}.caption.json" - cap.write_text( - json.dumps({"caption": "seinfeld scene, jerry talking in jerrys_apt"}) + "\n", - encoding="utf-8", - ) - clips.append({"clip_id": clip_id, "clip_file": str(clip_file)}) - manifest = root / "provisional.manifest.json" - manifest.write_text(json.dumps({"clips": clips}) + "\n", encoding="utf-8") - return manifest - - -def make_vocab(root: Path) -> Path: - vp = root / "vocabulary.yaml" - vp.write_text(VOCAB_YAML, encoding="utf-8") - return vp diff --git a/tests/packs/seinfeld/test_aitoolkit_stage_dryrun.py b/tests/packs/seinfeld/test_aitoolkit_stage_dryrun.py deleted file mode 100644 index 38590eb..0000000 --- a/tests/packs/seinfeld/test_aitoolkit_stage_dryrun.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Dry-run produces a parseable YAML with hivemind keys.""" - -from __future__ import annotations - -from pathlib import Path - -import yaml - -from astrid.packs.seinfeld.aitoolkit_stage import run as stage_run - -from ._fixtures import make_dataset, make_vocab - - -def test_dry_run_writes_parseable_yaml_with_hivemind_keys(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - produces = tmp_path / "produces" - rc = stage_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--produces-dir", str(produces), - "--dry-run", - ]) - assert rc == 0 - - staged = produces / "staged_config.yaml" - bootstrap = produces / "bootstrap.sh" - assert staged.is_file() - assert bootstrap.is_file() - - cfg = yaml.safe_load(staged.read_text(encoding="utf-8")) - proc = cfg["config"]["process"][0] - - assert proc["trigger_word"] == "seinfeld scene" - assert proc["network"]["linear"] == 32 - assert proc["network"]["linear_alpha"] == 32 - assert proc["save"]["save_every"] == 250 - assert proc["sample"]["sample_every"] == 250 - assert proc["datasets"][0]["resolution"] == stage_run.HIVEMIND_DEFAULTS["resolution_buckets"] - assert proc["datasets"][0]["num_frames"] == stage_run.HIVEMIND_DEFAULTS["num_frames"] - assert proc["datasets"][0]["fps"] == stage_run.HIVEMIND_DEFAULTS["fps"] - assert proc["datasets"][0]["bucketing"] is True - assert proc["train"]["steps"] == 2000 - assert proc["train"]["batch_size"] == 1 - assert proc["train"]["gradient_accumulation_steps"] == 4 - assert proc["train"]["lr"] == 2.0e-5 - assert proc["train"]["seed"] == 42 - assert proc["model"]["arch"] == "ltx2.3" - assert proc["model"]["quantize"] is True - assert proc["model"]["name_or_path"] == "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors" - assert proc["sample"]["width"] == stage_run.HIVEMIND_DEFAULTS["resolution"] - assert proc["sample"]["height"] == 768 - assert len(proc["sample"]["prompts"]) >= 3 - - bootstrap_text = bootstrap.read_text(encoding="utf-8") - assert "/proc/1/environ" in bootstrap_text - assert '$TOOLKIT_ROOT/.env' in bootstrap_text - assert "hf_test_token" not in bootstrap_text - - -def test_dry_run_smoke_overrides_steps_to_100(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - produces = tmp_path / "produces" - rc = stage_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--produces-dir", str(produces), - "--dry-run", - "--smoke", - ]) - assert rc == 0 - cfg = yaml.safe_load((produces / "staged_config.yaml").read_text(encoding="utf-8")) - assert cfg["config"]["process"][0]["train"]["steps"] == 100 diff --git a/tests/packs/seinfeld/test_aitoolkit_stage_upload.py b/tests/packs/seinfeld/test_aitoolkit_stage_upload.py deleted file mode 100644 index 5cf6a56..0000000 --- a/tests/packs/seinfeld/test_aitoolkit_stage_upload.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Dataset upload staging for seinfeld.aitoolkit_stage.""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - -import yaml - -from astrid.packs.seinfeld.aitoolkit_stage import run as stage_run - -from ._fixtures import make_dataset, make_vocab - - -def _arg_value(argv: list[str], flag: str) -> str: - return argv[argv.index(flag) + 1] - - -def test_live_stage_uploads_smoke_dataset_after_bootstrap( - tmp_path: Path, - monkeypatch, -) -> None: - manifest = make_dataset(tmp_path, n_clips=6) - vocab = make_vocab(tmp_path) - produces = tmp_path / "produces" - pod_handle = tmp_path / "pod_handle.json" - pod_handle.write_text(json.dumps({"pod_id": "pod-123"}) + "\n", encoding="utf-8") - - calls: list[list[str]] = [] - - def fake_run(argv: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: - calls.append(argv) - assert cwd == stage_run.REPO_ROOT - return subprocess.CompletedProcess(argv, 0) - - monkeypatch.setattr(stage_run.subprocess, "run", fake_run) - - rc = stage_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--produces-dir", str(produces), - "--pod-handle", str(pod_handle), - "--dataset-remote-path", "/workspace/custom-dataset", - "--smoke", - ]) - - assert rc == 0 - assert len(calls) == 2 - - bootstrap_call, dataset_call = calls - assert bootstrap_call[:4] == [ - sys.executable, - "-m", - "astrid.packs.external.runpod.run", - "exec", - ] - assert _arg_value(bootstrap_call, "--remote-root") == "/workspace" - assert _arg_value(bootstrap_call, "--remote-script") == "bash /workspace/bootstrap.sh" - - assert dataset_call[:4] == bootstrap_call[:4] - assert _arg_value(dataset_call, "--remote-root") == "/workspace/custom-dataset" - assert _arg_value(dataset_call, "--upload-mode") == "sftp_walk" - assert _arg_value(dataset_call, "--pod-handle") == str(pod_handle) - assert Path(_arg_value(dataset_call, "--local-root")).name == "_dataset_staging" - assert _arg_value(dataset_call, "--remote-script") == "echo dataset_staged 5 clips" - - staged_files = list((produces / "_dataset_staging" / "clips").iterdir()) - assert len([p for p in staged_files if p.suffix == ".mp4"]) == 5 - assert len([p for p in staged_files if p.name.endswith(".caption.json")]) == 5 - - upload = json.loads((produces / "dataset_upload.json").read_text(encoding="utf-8")) - assert upload["strategy"] == "copy_farm" - assert upload["clips"] == 5 - assert upload["remote_root"] == "/workspace/custom-dataset" - - cfg = yaml.safe_load((produces / "staged_config.yaml").read_text(encoding="utf-8")) - assert cfg["config"]["process"][0]["datasets"][0]["folder_path"] == "/workspace/custom-dataset" diff --git a/tests/packs/seinfeld/test_lora_eval_grid_artifacts.py b/tests/packs/seinfeld/test_lora_eval_grid_artifacts.py deleted file mode 100644 index c81117e..0000000 --- a/tests/packs/seinfeld/test_lora_eval_grid_artifacts.py +++ /dev/null @@ -1,77 +0,0 @@ -"""lora_eval_grid downloads local MP4 assets before writing a successful grid.""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -from astrid.packs.seinfeld.lora_eval_grid import run as eval_run - -from ._fixtures import make_vocab - - -def test_eval_grid_uses_runpod_pull_and_html_references_only_local_assets( - tmp_path: Path, - monkeypatch, -) -> None: - vocab = make_vocab(tmp_path) - pod_handle = tmp_path / "pod_handle.json" - pod_handle.write_text(json.dumps({"pod_id": "pod-1", "ssh": "root@1.2.3.4 -p 2222"}) + "\n", encoding="utf-8") - checkpoint_manifest = tmp_path / "checkpoint_manifest.json" - checkpoint_manifest.write_text( - json.dumps({"status": "ok", "checkpoints": [{"step": 500, "remote_path": "/workspace/output/step_500.safetensors"}]}) + "\n", - encoding="utf-8", - ) - produces = tmp_path / "produces" - calls: list[list[str]] = [] - - def fake_run(argv: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: - calls.append(argv) - assert cwd == Path(__file__).resolve().parents[3] - if "pull" in argv: - remote = argv[argv.index("--remote-path") + 1] - local_dir = Path(argv[argv.index("--local-dir") + 1]) - local_dir.mkdir(parents=True, exist_ok=True) - (local_dir / Path(remote).name).write_bytes(b"mp4") - return subprocess.CompletedProcess(argv, 0) - - monkeypatch.setattr(eval_run.subprocess, "run", fake_run) - - rc = eval_run.main([ - "--pod-handle", str(pod_handle), - "--checkpoint-manifest", str(checkpoint_manifest), - "--vocabulary", str(vocab), - "--produces-dir", str(produces), - "--smoke", - ]) - - assert rc == 0 - assert any("pull" in call for call in calls) - html = (produces / "eval_grid" / "index.html").read_text(encoding="utf-8") - assert "/workspace" not in html - assert "baseline/prompt_00.mp4" in html - assert (produces / "eval_grid" / "baseline" / "prompt_00.mp4").exists() - - -def test_eval_grid_fails_when_pull_does_not_create_local_asset(tmp_path: Path, monkeypatch) -> None: - vocab = make_vocab(tmp_path) - pod_handle = tmp_path / "pod_handle.json" - pod_handle.write_text(json.dumps({"pod_id": "pod-1", "ssh": "root@1.2.3.4 -p 2222"}) + "\n", encoding="utf-8") - checkpoint_manifest = tmp_path / "checkpoint_manifest.json" - checkpoint_manifest.write_text(json.dumps({"status": "ok", "checkpoints": []}) + "\n", encoding="utf-8") - - def fake_run(argv: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess: - return subprocess.CompletedProcess(argv, 0) - - monkeypatch.setattr(eval_run.subprocess, "run", fake_run) - - rc = eval_run.main([ - "--pod-handle", str(pod_handle), - "--checkpoint-manifest", str(checkpoint_manifest), - "--vocabulary", str(vocab), - "--produces-dir", str(tmp_path / "produces"), - "--smoke", - ]) - - assert rc == 4 diff --git a/tests/packs/seinfeld/test_lora_register.py b/tests/packs/seinfeld/test_lora_register.py deleted file mode 100644 index 34a3f2c..0000000 --- a/tests/packs/seinfeld/test_lora_register.py +++ /dev/null @@ -1,48 +0,0 @@ -"""lora_register writes registered_lora.json with the required schema.""" - -from __future__ import annotations - -import json -from pathlib import Path - -from astrid.packs.seinfeld.lora_register import run as reg_run - -from ._fixtures import make_vocab - - -REQUIRED_KEYS = { - "lora_id", "checkpoint_step", "lora_file", "config_used", - "base_model", "vocabulary_hash", "trained_at", "human_pick_notes", -} - - -def test_register_writes_full_schema(tmp_path: Path) -> None: - vocab = make_vocab(tmp_path) - lora_src = tmp_path / "step_1500.safetensors" - lora_src.write_bytes(b"\x00\x01\x02") - staged_config = tmp_path / "staged_config.yaml" - staged_config.write_text("name: fake\n") - - chosen = tmp_path / "chosen.json" - chosen.write_text(json.dumps({"step": 1500, "notes": "best identity"})) - - produces = tmp_path / "produces" - rc = reg_run.main([ - "--chosen-checkpoint", str(chosen), - "--lora-source", str(lora_src), - "--staged-config", str(staged_config), - "--vocabulary", str(vocab), - "--produces-dir", str(produces), - "--base-model", "ltx-2.3", - "--lora-id", "seinfeld-scene-v1", - ]) - assert rc == 0 - - record = json.loads((produces / "registered_lora.json").read_text(encoding="utf-8")) - assert REQUIRED_KEYS.issubset(record.keys()) - assert record["checkpoint_step"] == 1500 - assert record["base_model"] == "ltx-2.3" - assert record["lora_id"] == "seinfeld-scene-v1" - assert record["human_pick_notes"] == "best identity" - assert len(record["vocabulary_hash"]) == 64 # SHA-256 hex - assert (produces / "registered" / "seinfeld-scene-v1.safetensors").is_file() diff --git a/tests/packs/seinfeld/test_lora_train_pause.py b/tests/packs/seinfeld/test_lora_train_pause.py deleted file mode 100644 index e544695..0000000 --- a/tests/packs/seinfeld/test_lora_train_pause.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Post-eval pipeline writes last_run.json with status=PAUSED and exits 0.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from astrid.packs.seinfeld.lora_train import run as lora_run - -from ._fixtures import make_dataset, make_vocab - - -def test_pause_after_eval_exits_zero_with_paused_status( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - out = tmp_path / "out" - - monkeypatch.setenv("RUNPOD_API_KEY", "test-key-rpa_dummy") - - # Stub repo_setup invocation - monkeypatch.setattr(lora_run, "_invoke_repo_setup", lambda out_dir: 0) - - # Stub provision: create a fake pod_handle.json - def fake_provision(args, out_dir): - prov = out_dir / "provision" - prov.mkdir(parents=True, exist_ok=True) - handle = prov / "pod_handle.json" - handle.write_text(json.dumps({"pod_id": "fake-pod"}) + "\n") - return 0, handle - - monkeypatch.setattr(lora_run, "_provision", fake_provision) - - def fake_stage(args, out_dir, pod_handle): - stage = out_dir / "stage" - stage.mkdir(parents=True, exist_ok=True) - (stage / "staged_config.yaml").write_text("name: fake\n") - (stage / "ui_url.txt").write_text("https://fake-pod-8675.proxy.runpod.net\n") - return 0 - - monkeypatch.setattr(lora_run, "_stage", fake_stage) - - def fake_train(args, out_dir, pod_handle): - train = out_dir / "train" - train.mkdir(parents=True, exist_ok=True) - (train / "checkpoint_manifest.json").write_text(json.dumps({ - "checkpoints": [ - {"step": 500, "remote_path": "/workspace/output/step_500.safetensors"}, - {"step": 1000, "remote_path": "/workspace/output/step_1000.safetensors"}, - ], - "status": "ok", - })) - return 0 - - monkeypatch.setattr(lora_run, "_train", fake_train) - - def fake_eval(args, out_dir, pod_handle, cm, sc): - eg = out_dir / "eval_grid" - eg.mkdir(parents=True, exist_ok=True) - (eg / "index.html").write_text("") - return 0 - - monkeypatch.setattr(lora_run, "_eval_grid", fake_eval) - - rc = lora_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--out", str(out), - ]) - assert rc == 0 - - last = json.loads((out / "last_run.json").read_text(encoding="utf-8")) - assert last["status"] == "PAUSED" - for key in ("pod_handle", "staged_config", "checkpoint_manifest", "eval_grid_index", "vocabulary", "out"): - assert key in last - # Absolute paths - assert Path(last[key]).is_absolute() diff --git a/tests/packs/seinfeld/test_lora_train_preflight.py b/tests/packs/seinfeld/test_lora_train_preflight.py deleted file mode 100644 index 2085517..0000000 --- a/tests/packs/seinfeld/test_lora_train_preflight.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Preflight fails on missing caption sidecar / clip file.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import yaml - -from astrid.packs.seinfeld.lora_train import run as lora_run - -from ._fixtures import make_dataset, make_vocab - - -def _run_dryrun(tmp_path: Path, manifest: Path, vocab: Path) -> int: - out = tmp_path / "out" - return lora_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--out", str(out), - "--dry-run", - ]) - - -def test_preflight_fails_on_missing_caption_sidecar(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - # Delete one caption sidecar - (tmp_path / "clips" / "clip_000.caption.json").unlink() - rc = _run_dryrun(tmp_path, manifest, vocab) - assert rc != 0 - - -def test_preflight_fails_on_missing_clip_file(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - (tmp_path / "clips" / "clip_001.mp4").unlink() - rc = _run_dryrun(tmp_path, manifest, vocab) - assert rc != 0 - - -def test_preflight_passes_with_intact_dataset(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - rc = _run_dryrun(tmp_path, manifest, vocab) - assert rc == 0 - cfg = yaml.safe_load((tmp_path / "out" / "stage" / "staged_config.yaml").read_text(encoding="utf-8")) - assert cfg["config"]["process"][0]["model"]["name_or_path"] == "Lightricks/LTX-2.3/ltx-2.3-22b-dev.safetensors" diff --git a/tests/packs/seinfeld/test_orchestrator_image_arg.py b/tests/packs/seinfeld/test_orchestrator_image_arg.py deleted file mode 100644 index dc725c9..0000000 --- a/tests/packs/seinfeld/test_orchestrator_image_arg.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Default --image is ostris/aitoolkit:latest and lands in last_run.json on dry-run.""" - -from __future__ import annotations - -import json -from pathlib import Path - -from astrid.packs.seinfeld.lora_train import run as lora_run - -from ._fixtures import make_dataset, make_vocab - - -def test_default_image_is_pinned_in_dry_run_last_run(tmp_path: Path) -> None: - manifest = make_dataset(tmp_path) - vocab = make_vocab(tmp_path) - out = tmp_path / "out" - rc = lora_run.main([ - "--manifest", str(manifest), - "--vocabulary", str(vocab), - "--out", str(out), - "--dry-run", - ]) - assert rc == 0 - state = json.loads((out / "last_run.json").read_text(encoding="utf-8")) - assert state["image"] == "ostris/aitoolkit:latest" - assert lora_run.DEFAULT_IMAGE == "ostris/aitoolkit:latest" diff --git a/tests/packs/seinfeld/test_repo_setup_idempotent.py b/tests/packs/seinfeld/test_repo_setup_idempotent.py deleted file mode 100644 index 9fb78bf..0000000 --- a/tests/packs/seinfeld/test_repo_setup_idempotent.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Second invocation of repo_setup is a no-op when submodule .git exists.""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest - -from astrid.packs.seinfeld.repo_setup import run as setup_run - - -def test_idempotent_second_invocation_no_op( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - # Pretend the repo root is tmp_path and the submodule is already initialised. - fake_root = tmp_path - submodule = fake_root / setup_run.SUBMODULE_PATH - (submodule / ".git").mkdir(parents=True) - - monkeypatch.setattr(setup_run, "_repo_root", lambda: fake_root) - - # If git is actually invoked, the test will fail because we don't want it - # to touch a real repo. Make subprocess.run a guard. - called: list[list[str]] = [] - orig_run = subprocess.run - - def guard(*args, **kwargs): # type: ignore[no-untyped-def] - argv = args[0] if args else kwargs.get("args") - called.append(list(argv) if argv else []) - raise AssertionError(f"subprocess.run unexpectedly called: {argv}") - - monkeypatch.setattr(setup_run.subprocess, "run", guard) - - produces = tmp_path / "produces" - rc = setup_run.main(["--produces-dir", str(produces)]) - assert rc == 0 - assert called == [], f"git was invoked: {called}" - - result = json.loads((produces / "setup_result.json").read_text(encoding="utf-8")) - assert result["status"] == "already_initialized" - assert result["sha"] == setup_run.PINNED_SHA diff --git a/tests/test_packs_shipped_ids.py b/tests/test_packs_shipped_ids.py index 715d9bc..2a54c23 100644 --- a/tests/test_packs_shipped_ids.py +++ b/tests/test_packs_shipped_ids.py @@ -2,7 +2,12 @@ from __future__ import annotations +import json +import os +import subprocess +import sys import unittest +from pathlib import Path from astrid.core.executor.registry import load_default_registry as load_executor_registry from astrid.core.orchestrator.registry import load_default_registry as load_orchestrator_registry @@ -64,6 +69,28 @@ def test_known_non_builtin_ids_resolve_to_their_packs(self) -> None: f"executor_root for {executor_id} did not land under packs/{pack}/", ) + def test_cli_lists_do_not_register_seinfeld_pack_ids(self) -> None: + commands = [ + ([sys.executable, "-m", "astrid", "executors", "list", "--json"], "executors"), + ([sys.executable, "-m", "astrid", "orchestrators", "list", "--json"], "orchestrators"), + ] + for argv, key in commands: + with self.subTest(command=" ".join(argv)): + result = subprocess.run( + argv, + cwd=Path(__file__).resolve().parents[1], + env=os.environ.copy(), + text=True, + capture_output=True, + check=True, + ) + payload = json.loads(result.stdout) + ids = [item["id"] for item in payload[key]] + self.assertFalse( + any(identifier.startswith("seinfeld.") for identifier in ids), + f"unexpected Seinfeld ids from {' '.join(argv)}: {ids}", + ) + if __name__ == "__main__": unittest.main()