Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
364325c
Ground image-generate prompts in documentation and fail unaligned art…
cursoragent Sep 26, 2026
1df0651
Fix the scene-spec-generate alignment test to use a spoken image label.
cursoragent Sep 26, 2026
6212a13
Review generated image pixels against the documentation.
cursoragent Sep 26, 2026
287a212
Fail CI when hotspots, dead code, or mutation survivors grow (#174)
jmjava Sep 27, 2026
f65722a
Prove timestamps --engine whisper exits 1 when the provider has no sp…
jmjava Oct 7, 2026
c697cfe
Prove docgen tts exits 1 when a listed segment has no narration. (#177)
jmjava Oct 7, 2026
672f6e5
Prove docgen concat exits 1 when a segment recording is missing. (#176)
jmjava Oct 7, 2026
6610de4
Prove docgen benchmark exits 1 when a quality score falls below basel…
jmjava Oct 7, 2026
94177d5
Fail stale-helper checks when helpers are inlined (#172)
jmjava Oct 7, 2026
d9422bf
Fail timing checks when ffprobe cannot read a duration (#170)
jmjava Oct 7, 2026
e7c0a3c
Fail closed when a benchmark baseline case is missing from the curren…
jmjava Sep 27, 2026
3460370
Keep baseline-id checks without raising cyclomatic complexity.
jmjava Sep 27, 2026
3d15985
Keep the benchmark case filter out of cli.py.
jmjava Oct 7, 2026
3db0f16
Merge pull request #171 from jmjava/verify/docgen-17-baseline-ids
jmjava Oct 7, 2026
451764a
Ground image-generate prompts in documentation and fail unaligned art…
cursoragent Sep 26, 2026
f7abb4a
Fix the scene-spec-generate alignment test to use a spoken image label.
cursoragent Sep 26, 2026
370bda4
Review generated image pixels against the documentation.
cursoragent Sep 26, 2026
10f9295
Keep image-doc alignment under the complexity gates.
jmjava Oct 8, 2026
5f331c9
Merge remote image-doc alignment history with the complexity-gate fix.
jmjava Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,22 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install ruff lizard==1.24.0
- run: pip install ruff lizard==1.24.0 vulture==2.16
- run: ruff check src/ tests/
- name: Complexity proving test
run: bash scripts/test-check-complexity.sh
- name: PR-diff complexity
run: python3 scripts/check-complexity.py --base origin/main --paths src
- name: Hotspot proving test
run: bash scripts/test-check-hotspots.sh
- name: PR-diff hotspots
run: python3 scripts/check-hotspots.py --base origin/main --paths src
- name: Vulture proving test
run: bash scripts/test-check-vulture.sh
- name: Vulture baseline
run: python3 scripts/check-vulture.py src tests
- name: Mutation score proving test
run: bash scripts/test-check-mutation.sh

unit:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -93,3 +103,42 @@ jobs:
- run: pip install --no-cache-dir "."
- name: Validate scratch init bundle
run: bash scripts/ci-validate-scratch-bundle.sh

# mutmut on src/docgen/path_filters.py only. The job reads the committed
# survived ceiling and does not rewrite it.
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install system dependencies
run: |
set -euo pipefail
attempt=1
max_attempts=4
backoff=4
while [ "$attempt" -le "$max_attempts" ]; do
echo "apt attempt $attempt/$max_attempts"
if timeout 600s sudo apt-get -o Acquire::Retries=3 update \
&& timeout 600s sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg tesseract-ocr; then
exit 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "apt failed after $max_attempts attempts"
exit 1
fi
echo "apt failed, retrying in ${backoff}s..."
sleep "$backoff"
backoff=$((backoff * 2))
attempt=$((attempt + 1))
done
- run: pip install --no-cache-dir ".[dev]" "mutmut==3.8.0"
- name: Mutation score for path_filters.py
env:
PYTHONPATH: ${{ github.workspace }}/src
run: |
mutmut run --max-children 1
mutmut export-cicd-stats
python3 scripts/check-mutation-score.py
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ tests/.bin-cache/
htmlcov/
.mypy_cache/
.ruff_cache/
mutants/
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ Commands registered on the **`docgen`** CLI include:
- **`freeze`** — ``docgen freeze`` builds the **`docgen-gui`** onedir (`pip install 'docgen[packaging]'`). Optional ``--smoke`` runs the binary headless. Do not run a full freeze in routine pytest; set ``DOCGEN_FREEZE_SMOKE=1`` for the optional test.
- **`tts`** — text-to-speech for segment files (OpenAI or xAI `/v1/tts`).
- **`timestamps`** — word/segment timing (`timing.json`). Default engine **`local`** aligns the known narration text against the mp3 offline (ffmpeg silencedetect, no API); **`--engine whisper`** uses OpenAI whisper-1 or xAI `/v1/stt` when `ai.provider` is grok. Both emit the same Whisper-shaped blocks. Failed ffmpeg silencedetect raises `AlignmentError` (empty stderr is not treated as full-span speech). OpenAI whisper-1 word/segment `start`/`end` must be finite JSON numbers (bool/NaN raise `AIError`). Grok `/v1/stt` rejects empty word tokens and inverted `end < start` intervals (`AIError`). Empty ``segments.all`` raises ``TimestampError`` (same as TTS) and does not leave a stale ``timing.json`` as success.
- **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via OpenAI Images or xAI Imagine into the bundle (also runs for missing assets inside `generate-all`).
- **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via OpenAI Images or xAI Imagine into the bundle (also runs for missing assets inside `generate-all`). Default **`align_with_docs`** wraps each prompt with narration + source snippets and fails when the authored prompt shares no documented terms. After the PNG is written, **OCR** plus a **vision review** (`align_review`, default true) check the pixels against the same corpus and retry once on FAIL.
- **`manim`** — render Manim scenes declared in config.
- **`compose`** — mux narration audio with visual sources via ffmpeg. With no segment ids, uses ``segments.all`` (same as ``generate-all``), not ``segments.default``. A ``type: mixed`` row raises ``ComposeError`` if any listed source is missing (no silent subset mux).
- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`scene_assets`** (pre-render: stuck-board cadence, frame-budget overlaps, `MANIM_FONT` consistency, stale helpers / stale compiled class including hand-edited generated-region labels and `run_time` — hard fail; also a `generate-all` gate before Manim), **`av_sync`** (hard fail on `--pre-push` / `generate-all`; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related visual-sync checks (`ocr_scan`, `layout`, `freeze_ratio` — hard fail on `--pre-push` / `generate-all`). Missing tesseract fails `ocr_scan` / `av_sync` / `layout` (not skip-PASS). Missing audio or an LFS pointer fails `timing_sync` and recording media gates (`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) (not skip-PASS). Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for `type: manim` (not skip-PASS).
- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`scene_assets`** (pre-render: stuck-board cadence, frame-budget overlaps, `MANIM_FONT` consistency, stale helpers / stale compiled class including hand-edited generated-region labels and `run_time` — hard fail; also a `generate-all` gate before Manim), **`av_sync`** (hard fail on `--pre-push` / `generate-all`; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related visual-sync checks (`ocr_scan`, `layout`, `freeze_ratio` — hard fail on `--pre-push` / `generate-all`). **`scene_assets`** also fails when an image-element prompt shares no documented terms with narration/source, or when OCR (and optional vision, `validation.image_asset_alignment.review`) finds invented on-image terms. Missing tesseract fails `ocr_scan` / `av_sync` / `layout` (not skip-PASS). Missing audio or an LFS pointer fails `timing_sync` and recording media gates (`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) (not skip-PASS). Missing `*.scene.yaml` fails `story_end` / `subject_beat_coverage` for `type: manim` (not skip-PASS).
- **`lint`** — narration lint helper.
- **`narration-generate`** — LLM-assisted narration from hints and repo context; optional **`--revise --revision-notes`** for in-place edits (same contract as the wizard Revise button).
- **`scene-spec-generate`** — LLM emits declarative **`*.scene.yaml`**; enforces frame budget + **subject-beat coverage** (dwell OK; cover topic shifts; reject invented labels).
- **`scene-spec-generate`** — LLM emits declarative **`*.scene.yaml`**; enforces frame budget + **subject-beat coverage** (dwell OK; cover topic shifts; reject invented labels) and **image-prompt alignment** (image `prompt:` must use documented terms from narration/source).
- **`scene-compile`** — compile specs into **`scenes.py`** (generated regions only).
- **`yaml-generate`** — merge defaults and hint wiring into **`docgen.yaml`**.
- **`clean-bundle`** — remove regenerable outputs per policy.
Expand All @@ -91,6 +91,8 @@ Commands registered on the **`docgen`** CLI include:
- **Manim / `scenes.py` (marker blocks):** Fix generators under `src/docgen/**` (`manim_scene_support.py`, `scene_spec.py`, `scene_spec_generate.py`, `validate`, `yaml_generate`, tests). **Do not** patch generated classes inside a consumer's **`animations/scenes.py`** between **`BEGIN/END GENERATED SCENE`** markers; re-run **`scene-spec-generate`** / **`scene-compile --retime`** and **`manim`** instead. Preferred consumer order: narration → TTS → timestamps → scene-spec/compile → Manim → compose.
- **Beat sync (fail-closed):** when `timing.json` has words, every story box label must match a spoken phrase (`wait_word`); unmatched labels and leftover LLM indices are rejected. Opt out with ``pace: none``. Legacy row-level ``wait_segment`` is upgraded to ``wait_word`` and written back on ``scene-compile`` (direct ``compile_scene_class`` still rejects leftover ``wait_segment``). Fuzzy containment matching is not used. **`scene-compile` clamps FadeIn / page-fade `run_time` against the next word start** so `_TimedScene._clock` cannot race past waits (issue #66 — do not emit cascading first-board dumps). After a reveal, a **dwell** slot may play `Indicate` / `Circumscribe` when the gap to the next `wait_word` is long enough (also clamped). Long holds emit additional mid-hold pulses (`timed_wait` + emphasis) so the board does not freeze after the first Indicate. Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions FadeOut revealed boxes, not the parent `VGroup`. `scene-compile` refreshes stale `_box` / `_arrow` / `_TimedScene` helpers in `scenes.py`.
- **Subject-beat coverage:** implemented in `scene_spec.layout_density_violations` / `cluster_subject_beats`; enforced by **`scene-spec-generate`** and **`validate`** (`validation.subject_beat_coverage.enabled`, default true). Not a blind label count.
- **Image-prompt alignment:** implemented in `image_align.image_prompt_alignment_violations`; **`scene-spec-generate`** and **`image-generate`** reject prompts that share no documented terms; **`validate`** reports the same failures on **`scene_assets`** (`validation.image_prompt_alignment.enabled`, default true). `image-generate` also wraps the Images API prompt with narration/source (`image_generation.align_with_docs`, default true).
- **Image-asset alignment:** OCR + vision review of the generated PNG (`docgen.image_align`). **`image-generate`** fails closed and retries once (`image_generation.align_review`, default true). **`validate`** folds the same pixel gate into **`scene_assets`** (`validation.image_asset_alignment`, OCR on by default, vision opt-in).
- **`docgen benchmark` is required** after clock / compile / `_TimedScene` / dwell changes. Pytest string assertions are not a substitute. Do not remove the CI `benchmark` job. See **Required gate** below.
- Prefer **stable CLI / library contracts** and **documented exit codes** so CI can depend on them.
- **`narration_from_source`:** hints in config + **`docgen narration-generate`** — owner-supplied context paths, not opaque bulk edits to outputs.
Expand Down
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,23 @@ If you still need the legacy behaviour, pin a pre-removal commit
- **Image assets in Manim scenes** — a scene-spec box may be an **image
element** (`image: images/<name>.png` + `prompt:`); `docgen image-generate`
renders the prompt via OpenAI Images (default `gpt-image-1`) or xAI Imagine
(`grok-imagine-image-2.0` when `ai.provider` is `grok`). The compiled scene
shows it with the `_image` helper. `generate-all` fills in missing assets
automatically.
(`grok-imagine-image-2.0` when `ai.provider` is `grok`). By default the
authored prompt is **grounded** in the segment narration plus
`manim_scene_generation` source snippets, and prompts that share no
documented terms fail closed (`image_prompt_alignment`). After the PNG is
written, OCR rejects invented on-image labels and a vision model reviews
the pixels against the same docs (`image_asset_alignment` /
`image_generation.align_review`). The compiled scene shows the asset with
the `_image` helper. `generate-all` fills in missing assets automatically.
- **ffmpeg composition** — combine narration audio and Manim video into final
segments, with a freeze-tail guard.
- **Validation** — A/V drift, freeze ratio, OCR error scan, layout, narration lint,
Manim scene lint, **timing_sync** (stale `timing.json` vs regenerated mp3 —
hard fail), **story_end** (paced visual story finishes long before narration —
hard fail), and **av_sync** (OCR check that scene-spec label anchors appear on
screen near their spoken time — hard fail on `--pre-push` / `generate-all`).
hard fail), **scene_assets** (also fails image prompts or PNG OCR/vision
that do not match narration/source), and **av_sync** (OCR
check that scene-spec label anchors appear on screen near their spoken time
— hard fail on `--pre-push` / `generate-all`).
Missing tesseract fails `ocr_scan` / `av_sync` / `layout` instead of skip-PASS.
Missing audio or an LFS pointer fails `timing_sync` and recording media gates
(`stream_presence`, `av_drift`, `ocr_scan`, `av_sync`) instead of skip-PASS.
Expand Down Expand Up @@ -128,7 +135,11 @@ using the Cursor key:
image_generation:
model: gpt-image-1 # or dall-e-3, gpt-image-1-mini, …
size: 1536x1024
align_with_docs: true # wrap prompts with narration/source; fail invented terms
align_review: true # vision-review the PNG; retry once on FAIL
# review_model: gpt-4o
# quality: high
# style: "…" # optional override of the educational-diagram prefix
```

```bash
Expand Down Expand Up @@ -241,7 +252,7 @@ docgen --repo /path/to/your-project generate-all
| `docgen freeze [--dist DIR] [--smoke]` | PyInstaller onedir for **`docgen-gui` only** (`pip install 'docgen[packaging]'`). Not the full Manim CLI |
| `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio |
| `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription). Empty `segments.all` is `TimestampError` (stale `timing.json` is not success) |
| `docgen image-generate [--segment 01 \| --all \| --spec PATH] [--force] [--dry-run] [--model …] [--size …]` | Generate scene-spec image assets (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle |
| `docgen image-generate [--segment 01 \| --all \| --spec PATH] [--force] [--dry-run] [--model …] [--size …]` | Generate scene-spec image assets (`image:` + `prompt:` boxes) via the OpenAI Images API; grounds prompts in narration/source, then OCR + vision-reviews the PNG unless `align_with_docs` / `align_review` is false |
| `docgen manim [--scene StackDAGScene]` | Render Manim animations |
| `docgen compose [01 02 03] [--ffmpeg-timeout 900]` | Compose segments (audio + video). Omit ids to walk `segments.all` (same as `generate-all`); a mapped segment with missing audio/visuals is a hard fail |
| `docgen validate [--max-drift 2.75] [--pre-push]` | Run all validation checks |
Expand Down Expand Up @@ -344,7 +355,11 @@ timestamps:
image_generation: # scene-spec image elements (docgen image-generate)
model: gpt-image-1 # Cursor/OpenAI Images; Grok remaps gpt-image-* to Imagine
size: 1536x1024
align_with_docs: true # ground prompts in narration/source (default)
align_review: true # vision-review generated PNGs (default)
# review_model: gpt-4o
# quality: high # optional, model-specific
# style: "…" # optional Images-API prefix override

manim:
quality: 1080p30 # supports 480p15, 720p30, 1080p30, 1080p60, 1440p30, 1440p60, 2160p60
Expand All @@ -355,6 +370,12 @@ manim:
validation:
subject_beat_coverage:
enabled: true # scene-spec-generate + validate: cover narration topic beats
image_prompt_alignment:
enabled: true # image-element prompts must use documented terms
image_asset_alignment:
enabled: true # OCR generated PNGs vs narration/source
ocr: true
review: false # set true to vision-review existing assets in validate

compose:
ffmpeg_timeout_sec: 300 # can also be overridden with: docgen compose --ffmpeg-timeout N
Expand Down
12 changes: 12 additions & 0 deletions config/mutation-path-filters-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"module": "src/docgen/path_filters.py",
"killed": 11,
"survived": 0,
"total": 11,
"no_tests": 0,
"skipped": 0,
"suspicious": 0,
"timeout": 0,
"check_was_interrupted_by_user": 0,
"segfault": 0
}
8 changes: 8 additions & 0 deletions config/vulture-baseline.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# High-confidence vulture findings (vulture 2.16, min-confidence 80).
# Each line is: <count> <path> <message>
# check-vulture.py fails when a count rises or a new key appears.
# CI reads this file and must not rewrite it.
1 src/docgen/cli.py unused variable 'param'
2 src/docgen/scene_clock_harness.py unused variable 'other'
1 src/docgen/scene_spec_generate.py unused variable 'source_snippets'
20 tests/test_ai_client.py unused variable 'clear_ai_env'
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,29 @@ target-version = "py310"
# selection that fails the existing codebase; keep CI on the historical E/F gate.
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]

# One module. validate.py's generated mutant module is about 4.6MB and the
# runner was OOM-killed above 12G, so that file is not the CI target.
# path_filters.py is the module that can finish. source_paths copies the
# package so imports resolve; only_mutate keeps the run off the rest of src.
[tool.mutmut]
source_paths = ["src"]
only_mutate = ["src/docgen/path_filters.py"]
pytest_add_cli_args_test_selection = ["tests/test_path_filters.py"]
also_copy = [
".github",
".cursor",
"scripts",
"AGENTS.md",
"README.md",
"NOTICE",
"SESSION_NOTES.md",
"packaging",
"docs",
"milestones",
"third_party",
"issues",
"specs-on-hold",
"session-notes",
"config",
]
Loading
Loading