Static lifecycle and performance analysis for Manim scenes
Project description
Qual
Static analysis for Manim Community scenes — catch definite runtime errors, silent mis-rendering, performance multipliers, and non-determinism before you render.
English | 日本語
qual is a standalone static analyzer for Manim Community 0.20
projects, written in Rust. It parses your Python source and checks it against
a curated, versioned model of Manim's semantics — it never imports or
executes Manim or your code. Instead of pattern-matching API names, it runs
a lifecycle abstract interpreter that models what Scene.play actually does
(argument compilation, auto-add, introducers/removers, updaters) and a
symbolic cost model that knows which code runs once and which code runs every
frame.
Example
scenes/demo.py:
from manim import *
class TrackerDemo(Scene):
def construct(self):
title = Text("Tracking x", font_size=0)
square = Square()
tracker = ValueTracker(0)
label = always_redraw(lambda: MathTex(f"x={tracker.get_value():.2f}"))
self.add(title, square, label)
self.play(square.shift(RIGHT))
square.add_updater(lambda m: m.rotate(0.05))
self.play(tracker.animate.set_value(8), run_time=8)
self.wait(0)
$ qual check . --format concise
scenes/demo.py:6:46: MLR115 error `Text(font_size=0)` is not positive; text sizing requires font_size > 0
scenes/demo.py:9:39: MLP226 warning Each invocation constructs a `MathTex` and performs a cache-key lookup, and this f-string key varies per frame: every rendered frame can mint a distinct Text/TeX cache key and disk asset (`K_resource ≈ F`). Across the 1 play(s) where this callback provably executes it may create at least ~480 distinct keys.
scenes/demo.py:11:19: MLC102 error `square.shift(...)` mutates the mobject immediately and returns the mobject itself, not an Animation; use `.animate` (e.g. `square.animate.shift(...)`) inside `Scene.play()`.
scenes/demo.py:12:38: MLD301 warning Updater lambda applies `rotate` with a fixed step every frame but declares no `dt` parameter; the motion speed depends on the profile frame rate
scenes/demo.py:14:9: MLC112 warning This `wait()` renders a single frozen frame: nothing makes it dynamic, and the updater registered at line 9 reads frame-varying state without a `dt` parameter, so its visual change never renders during the wait. Pass `frozen_frame=False`, or declare a `dt` parameter on the updater.
scenes/demo.py:14:19: MLC104 error Use a positive `duration`: the literal `0` is non-positive and playing it aborts the render.
Two of these would crash the render (MLC102, MLC104), one renders an
invisible title (MLR115), one silently changes speed with the frame rate
(MLD301), one freezes a wait that the author expected to animate
(MLC112), and one launches the external TeX compiler for a fresh cache
key on every rendered frame (MLP226) — and the linter can bound that
cost: run_time=8 at 60 FPS is provably at least ~480 distinct keys.
What it checks
Rules come in four families:
- MLC — lifecycle / correctness. Definite runtime errors and lifecycle
mistakes that render the wrong picture: non-Animation arguments to
Scene.play(MLC102),MoveToTargetwithoutgenerate_target()on any path (MLC107),Restorewithoutsave_state()(MLC120), two animations writing the same channel of the same mobject in one play (MLC108), aScene.remove(child)undone by re-adding the surviving parent (MLC115). - MLR — rendering. Code that renders, but not what you meant: a Python
escape corrupting a TeX command in a non-raw
MathTexliteral (MLR103), asset paths that fail Manim's exact runtime search (MLR104), Pango markup passed to plainText(MLR124),Transform(mob, mob)(MLR113). - MLP — performance. Cost multipliers with machine-readable evidence:
Text/MathTex/SVGMobjectconstruction inside an updater oralways_redraw(MLP201), frame-varying TeX cache keys that mint one disk asset per frame (MLP226), scene graphs growing every frame (MLP204),TracedPathwithoutdissipating_time(MLP220). - MLD — determinism / portability. Renders that differ between machines,
frame rates, or renderers: fixed per-frame steps without
dtscaling (MLD301), unseeded global randomness in frame callbacks (MLD302), case-only asset path mismatches on case-sensitive targets (MLD305).
Semantic depth, not name matching
The analyzer's core principle (DESIGN §1): never warn on an API name alone.
FadeOut(mob) is fine on a mobject that was never added — play's preparation
auto-adds it and the remover deletes it afterwards. So the pipeline builds
real facts first:
- a lifecycle abstract interpreter: intra-function CFG, interprocedural
helper summaries, per-Scene MRO composition with
super()dispatch, allocation-site identity, scene membership/order/updater tracking, and play-group semantics; - a symbolic cost model: hot-context propagation (updaters,
always_redraw, stop conditions, interpolate overrides) and frame-count intervals derived only from literal durations — the cost report above saysduration 8 s -> frames ~480becauserun_time=8at 60 FPS is provable, and printsunknownotherwise, never a fabricated number.
Every diagnostic separates severity (error/warning/info) from
confidence (certain/high/medium/low), and state-dependent rules
fire only on definite, all-paths evidence. When a value cannot be resolved
statically, it degrades to Unknown and the linter stays silent rather
than guessing — a deliberate design stance carried through every rule.
Installation
For a published release, choose any of these entry points. The PyPI package installs the native Rust executable; it does not import Manim or require a Python runtime after installation.
# Python tooling
uv tool install qual-manim
# or: pipx install qual-manim
# Rust tooling (builds from source; Rust 1.85+)
cargo install qual --locked
Standalone installers and checksummed archives for Linux, macOS, and Windows are attached to each GitHub Release:
# macOS / Linux
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/Poietra/qual/releases/latest/download/qual-installer.sh | sh
Until the first registry release, or to install the current checkout, build from source:
git clone https://github.com/Poietra/qual.git
cd qual
cargo install --path .
No Python, Manim, or LaTeX installation is needed: the analyzer parses source and consults versioned knowledge profiles, and never imports or executes Manim or the code it analyzes.
Quickstart
qual check . # analyze; rich in a terminal, concise when piped
qual check . --format rich # force source frames and colour
qual check . --format concise # one line per diagnostic
qual check scenes --format full # explanations + evidence
qual check . --format json # schemas/diagnostics-v1.json
qual check . --format sarif # SARIF 2.1.0
qual check . --format github # GitHub Actions annotations
qual explain MLC102 # full documentation for a rule
qual rules # every rule ID, phase, and status
qual config # resolved effective configuration
qual cost scenes/demo.py # per-scene cost breakdown
qual coverage . # what the analysis could not resolve
qual static-facts . > facts.json # StaticFacts v0 semantic projection
qual change-impact --before old --after new > impact.json
qual source-bridge . --request patch.json > candidates.json
Exit codes: 0 — no reported diagnostic reaches fail-level; 1 — at
least one does; 2 — command-line, configuration, or internal error.
Output formats
Without --format, check picks its output from where it is writing.
Attached to a terminal it prints rich: a banner per finding, the offending
source line with the span underlined, the explanation, and a summary.
✖ MLC104 scene.py:10:42 ───────────────────────────────────────────────
Use a positive `run_time`: the literal `0` is non-positive and playing
it aborts the render.
8 │ group = AnimationGroup()
9 │ self.add(title, eq)
> 10 │ self.play(Write(title), run_time=0)
│ ^
11 │ self.wait()
ℹ Manim validates durations when a play executes, not when an animation
is constructed …
✖ 2 errors ⚠ 1 warning in 1 file
Redirected to a file or a pipe it prints concise — one stable line per
diagnostic, with no escape sequences — so scripts and CI keep the format
they parse today. Pass --format to override the choice in either
direction.
Colour follows --color auto|always|never. auto styles only a terminal,
NO_COLOR (any value) disables styling, and --color always styles even
when redirected. Only rich is ever styled. COLUMNS sets the width the
banners and wrapping use.
Useful check options: --select / --ignore, --min-confidence,
--fail-level, --profile, --renderer, --fps,
--resolution WIDTHxHEIGHT, --color, --statistics, --analysis-summary (the
coverage report below, printed to stderr after the diagnostics; stdout
and the exit code are untouched), and the baseline/fix options
described below. --no-cache forces a full analysis without reading,
writing, or creating cache state. --select also narrows the analysis itself: fact layers
no selected rule needs (the lifecycle interpreter, the symbolic cost
model) are skipped, so a narrow select is faster than a full run. The
reported diagnostics are identical either way — rules superseding a
selected rule still run so a narrow select never resurrects a superseded
diagnostic.
--format full prints the explanation and machine-readable evidence under
each diagnostic:
scenes/demo.py:9:39: MLP226 warning Each invocation constructs a `MathTex` and performs a cache-key lookup, ...
A frame-varying key defeats the `MathTex` cache: instead of one shaping/compile job reused every frame,
each frame pays construction plus a cache miss, and for TeX classes each distinct key also launches the
external TeX compiler and `dvisvgm`, leaving one disk asset per key. ...
evidence.distinct_resource_keys: {"lower":480,"upper":null}
evidence.execution: {"plays":[{"certainty":"proven","kind":"play","location":"scenes/demo.py:13:9"},{"certainty":"maybe","kind":"play","location":"scenes/demo.py:11:9"}],"unresolved_entries":false}
evidence.frames: {"lower":480,"upper":null}
evidence.invocation_context: "frame-callback"
evidence.multiplicity: ["frames"]
evidence.state_path: ["construct","always_redraw:9"]
applies to profiles: production
Analysis cache
Normal check runs keep a disposable SQLite cache at
.qual-cache/cache-v2.sqlite3. An identical second run validates
filesystem dependencies and reuses the whole-project diagnostic JSON without
starting the frontend. After a source edit, cache v2 still parses and indexes
the complete project, then divides project files into weak dependency
components using resolved imports, calls, base classes, and module-name
collisions. Unchanged components reuse JSON method summaries and filtered
diagnostics; only changed components rerun summaries, Scene lifecycle, and
cost analysis. ASTs and analyzed code are never serialized or executed.
Keys cover the analyzer build, resolved semantic configuration, Manim
knowledge profile, complete source layout, and relevant source bytes. Literal
asset candidates and case-sensitive directory walks are stamped per entry,
so asset changes invalidate the affected component. The SQLite WAL database
supports concurrent cold writers and retains the 16 most recently used
project snapshots plus 256 component snapshots. It is never required for
correctness: corruption rebuilds with a warning and other failures continue
with analysis. --no-cache disables all cache filesystem activity. --fix,
baselines, and --analysis-summary deliberately run a complete analysis
because they need live source or index state after diagnostics are produced.
Add .qual-cache/ to a project's ignore file. Older
cache-v1.sqlite3 files are unused and may be removed.
Cold runs parallelize independent summary components, Scene lifecycle runs, and rules using a bounded worker pool. Recursive summary fixpoints and the frontend/project index remain ordered and sequential. Output is collected and stably sorted, with a test proving byte-identical JSON at one and four workers.
Configuration
Configuration lives in [tool.qual] in pyproject.toml, found by
walking up from the checked path. Render profiles are
[[tool.qual.profile]] entries:
[tool.qual]
manim-version = "0.20"
target-python = "3.11"
select = ["MLC", "MLR", "MLP", "MLD"]
ignore = []
min-confidence = "high"
fail-level = "warning"
default-profile = "production"
knowledge-profile = "upstream_0_20"
respect-manim-cfg = true
exclude = [".venv/**", "media/**"]
per-file-ignores = { "tests/fixtures/**" = ["MLP", "MLD"] }
[[tool.qual.profile]]
name = "production"
renderer = "cairo"
platform = "linux"
pixel-width = 1920
pixel-height = 1080
frame-rate = 60
assets-dir = "."
allowed-fonts = ["Noto Sans", "Noto Sans CJK JP"]
Precedence, highest first:
CLI > selected profile > pyproject base > manim.cfg > builtin defaults
When respect-manim-cfg is enabled (the default), a manim.cfg supplies
resolution/fps/renderer defaults below the pyproject settings. Unknown keys,
unknown rule selectors, duplicate profile names, and unknown profile
references are configuration errors (exit 2). --profile all analyzes every
defined profile and merges same-evidence diagnostics, listing the affected
profiles per diagnostic.
Configuration is validated honestly (exit 2 on violation):
- A declared
manim-versionmust fall inside the Manim range supported by the configured knowledge profile (e.g.upstream_0_20supports>=0.20,<0.21); when absent, nothing is validated. target-pythonmust beMAJOR.MINORbetween 3.6 and 3.12. The upper bound is the Python grammar the bundled parser (rustpython-parser 0.4) implements; the lower bound is the floor below which syntax gating can no longer be guaranteed (older targets are refused with exit 2 instead of being silently unenforced). The grammar is fixed (nofeature_versionpinning), so parsing itself never changes; instead a post-parse gate over the AST, the token stream, and f-string text reports every construct newer than the target asMLC000:async/awaitsyntax outsideasync def(3.7),:=, positional-only/, and f-string self-documenting=(3.8), relaxed decorators and parenthesized context managers withas(3.9),match(3.10),except*and PEP 646*unpacking in subscripts (3.11),typealiases, PEP 695 type parameters, and PEP 701 f-string expressions (3.12). A file the gate passes silently is guaranteed parseable by the target's own parser. The gated file is still fully analyzed, and a--fixthat would introduce such syntax is rolled back. Seequal explain MLC000for the full coverage table.- A frame rate that is zero, negative, or non-finite, and a resolution
with a zero dimension, are rejected wherever they come from (
--fps/--resolution, a profile, ormanim.cfg). stub-pathsis not implemented yet; a non-empty list is rejected instead of being silently ignored.
qual config prints the resolved configuration plus an
enforcement section stating which settings are enforced and which are
informational.
Using the optimized fork profile
Projects rendering with the locally patched Manim fork (profile
local_0_20_1_4d25c031) can tell qual so and unlock the
fork-specific analysis layer:
[tool.qual]
knowledge-profile = "local_0_20_1_4d25c031"
default-profile = "production"
[[tool.qual.profile]]
name = "production"
renderer = "cairo"
platform = "linux"
cairo-fork-workers = 4
cairo-static-layers = true
This enables, on top of everything the upstream profile provides:
- A "fork fast paths" section in
qual cost: per play, whether the fork-per-play Cairo pipeline (cairo-fork-workers), the static-layer retention path (cairo-static-layers), and packed interpolation apply — with the exact blocker and its source span when they do not (e.g. a Scene updater), including the renderer-wide monotonic disable chain after the first serial play. The section never advises removing a feature; it explains the render-path consequence. MLP214: flags four or more distinct TeX compile keys constructed serially before a scene's first play and cites the fork's precompile APIs (MathTex.precompile,tex_to_svg_file_async).MLP217: flags frame-varyinguse_svg_cache=Truekeys in hot callbacks that grow the fork's declared process-global SVG cache every frame.MLP225(opt-in via--select MLP225): emits the cost report's fast-path blocker explanations as per-play diagnostics.
Under upstream_0_20 all of the above is inert: the cost report carries no
fork section and the three rules never fire, even when selected.
Suppressions
self.play(square.shift(RIGHT)) # qual: ignore[MLC102] # same statement
# qual: ignore[MLP201] # next statement
label = always_redraw(...)
# qual: file-ignore[MLP] # whole file; must appear in the file header
Suppressions target whole statements, not single lines: an end-of-line
comment (or a standalone comment directly above) covers the entire
statement, including continuation lines of a multi-line call, so a
diagnostic anchored anywhere inside the statement is suppressed. For
compound statements (def, for, if, with, ...) the suppression
covers only the header up to its colon — one comment can never silence an
entire suite.
An unknown rule ID inside an inline suppression does not suppress anything; it is reported as a dedicated warning:
scene.py:8:41: MLC001 warning unknown rule ID in suppression: MLC999
For whole directories, use per-file-ignores in pyproject.toml (see
above).
Gradual adoption: baselines
Adopt the linter on an existing project without fixing everything first:
qual check . --write-baseline .qual-baseline.json # record today's findings
qual check . --baseline .qual-baseline.json # report only new findings
Baseline fingerprints (schemas/baseline-v1.json) contain no line
numbers — they are built from rule ID, relative path, qualified scene name,
and a surrounding token hash — so inserting unrelated lines elsewhere in a
file does not invalidate entries. The scene field records the qualified
enclosing Scene class (empty outside any scene), so identical findings in
different scenes get distinct fingerprints. Written files carry a
scene_attribution: "attributed" provenance marker: their empty scene
means literally "outside any Scene" and matches exactly. Baselines written
before scene attribution (no marker) are still read, and only there an
empty scene matches as a wildcard. A corrupt or wrong-schema baseline
file exits 2 with a clear message.
Autofix
qual check . --fix # apply SAFE fixes only
qual check . --fix --unsafe-fixes # also apply UNSAFE fixes
Safe and unsafe fixes are strictly separated: --fix alone applies only
edits that preserve behavior (e.g. MLC127 removes a duplicate child from
one add()/VGroup() call, MLR104 corrects a case-only asset path).
Unsafe fixes can change runtime semantics (e.g. rewriting
play(mob.shift(...)) to play(mob.animate.shift(...)) for MLC102) and
require the explicit extra flag. Every fixed file is re-parsed for
validation; a file whose fix does not survive re-parsing is rolled back.
$ qual check . --fix
scene.py:8:40: MLC127 info Remove the duplicate `square` from this `VGroup(...)` call: Manim warns and ignores repeated children of a single add.
fixed 1 issue(s) in 1 file(s)
Cost command
qual cost prints the symbolic cost breakdown per scene — play list
with frame intervals, hot contexts with provenance and the plays where the
callback provably executes, per-frame constructions, and resource-key
growth. Unknown durations are printed as unknown, never as fabricated
numbers:
$ qual cost scenes/demo.py
profiles: production (cairo, 1920x1080, 60 fps)
scene scenes.demo.TrackerDemo (scenes/demo.py)
plays:
scenes/demo.py:11:9 play duration unknown -> frames per-frame
scenes/demo.py:13:9 play duration 8 s -> frames ~480
scenes/demo.py:14:9 wait duration 0 s -> frames ~0
hot contexts:
scenes/demo.py:9:31 entry always_redraw; path construct -> always_redraw:9; factors frames; proven execution plays: scenes/demo.py:13:9
scenes/demo.py:12:28 entry updater; path construct -> updater:12; factors frames; proven execution plays: scenes/demo.py:13:9
per-frame constructions:
scenes/demo.py:9:39 MathTex construction x at least ~480 invocations across 1 proven play(s)
resource-key growth:
scenes/demo.py:9:39 MathTex distinct cache keys: at least ~480 across 1 proven play(s) (f-string key varies per frame)
Under the local fork knowledge profile the report gains a per-scene
"fork fast paths" section (see below). For example, with
cairo-fork-workers = 4 and cairo-static-layers = true in the profile:
$ qual cost scene.py
...
fork fast paths (profile production, knowledge local_0_20_1_4d25c031):
fork-per-play (cairo_fork_workers 4):
scene.py:9:9 play #1: no static blocker found (fork-eligible pending the runtime audit)
scene.py:10:9 play #2: no static blocker found (fork-eligible pending the runtime audit)
static layers (cairo_static_layers on):
scene.py:9:9 play #1: no static blocker found
scene.py:10:9 play #2: no static blocker found
packed interpolation:
scene.py:9:9 play #1: canonical per-member interpolation because the animation type FadeIn is outside the audited allowlist at scene.py:9:19 (blocker unsupported_animation_type); an updater-bearing mobject is in the scene family (updater registered here) at scene.py:8:9 (blocker updater_bearing_family)
scene.py:10:9 play #2: canonical per-member interpolation because an updater-bearing mobject is in the scene family (updater registered here) at scene.py:8:9 (blocker updater_bearing_family)
evidence: measured packed interpolation on the calibration machine, 300 members / 60 frames: 130.658 -> 33.004 ms/play, steady state 2.0761 -> 0.1890 ms/frame (docs/research/perf-evidence.md)
note: the features named above can be correct expression; this section explains the render-path consequence and never advises removing them
Analysis coverage
The analyzer's conservative silences are correct but invisible: a clean
run does not tell you whether there were no problems or whether half the
project could not be analyzed. qual coverage (and
qual check --analysis-summary, which prints the same report to
stderr without touching stdout or the exit code) surfaces everything the
analysis could not resolve:
For a file with a star import from an unresolvable module, a relative
import escaping the project tree, a match statement above
target-python = "3.9", and a play wrapped in an unresolved helper call:
$ qual coverage .
analysis coverage (knowledge profile upstream_0_20, target-python 3.9)
scene.py
constructs above target-python (MLC000): 1
star imports from unresolved modules: 1
unresolved relative imports: 1
calls with no resolved target: 1 of 6 (mystery x1)
scene scene.Demo (scene.py)
plays with unknown duration: 1 of 2
.animate builders with unknown target: 0 of 0
project
files parsed: 1 of 1
calls resolved: 5 of 6
play durations known: 1 of 2
scene constructors resolved: 1 of 1
constructs above target-python (MLC000): 1
unresolved imports: 2 (1 star, 1 relative)
manim APIs not in the knowledge profile: 0
helper calls summarized, not inlined: 0
top unresolved calls: mystery x1
analysis confidence: 1/1 files parsed, 5/6 calls resolved, 1/2 play durations known, 1/1 scene constructors resolved (counts of analyzed facts, not estimates)
Every number is a count of computed facts; the only ratios are plain
resolved / total count pairs. --format json emits the same data as a
stable machine-readable document with top-level keys
knowledge_profile, target_python, files[] (path, parsed,
gated_constructs, unresolved_star_imports,
unresolved_relative_imports, calls, unresolved_calls,
unresolved_call_names, apis_not_in_profile), scenes[] (name,
path, constructor_state_unknown, plays,
plays_with_unknown_duration, builders,
builders_with_unknown_target), and project (the totals plus
top_unresolved_call_names and helper_inline_fallbacks — the helper
call sites where inlining fell back to an effect summary, recorded
project-wide and deduplicated across scenes sharing a helper chain, so
the count appears on project only). Output is deterministic and
byte-stable for identical inputs.
CI integration
GitHub Actions annotations directly on the PR diff:
name: qual
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install --path . --locked
working-directory: qual # path to your qual checkout
- run: qual check . --format github
Or upload SARIF so findings appear in the GitHub code-scanning UI:
- run: qual check . --format sarif > qual.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: qual.sarif
Pre-execution admission checks
Because qual never imports or executes the code it reads, a service
that renders user-supplied scenes can run it before spending sandbox,
CPU, and GPU time — rejecting scenes that provably fail at render, and
flagging ones whose cost model predicts a blow-up.
Untrusted input needs the whole contract, not just the rules:
- Limits are enforced, not assumed. Sources over 4 MiB, nesting deeper
than 96, or prefix-operator runs longer than 64 are refused as
MLC000before parsing, so hostile input cannot exhaust the stack (see MLC000). Everything else remains the caller's budget: impose your own wall-clock and memory bounds on the process. - Start in observe mode. Record findings alongside the real render outcome instead of blocking on them; promote a rule to blocking only once its prediction has been checked against what rendering actually did.
- Block on
certainonly. Correct source can carry error-severity findings on purpose — a test assertingVGroup(3.0)raises is the canonical case. Findings measured on a real corpus are in docs/research/corpus-evidence.md.
# Observe: record everything, never fail the request.
qual check "$SCENE_DIR" --format json --fail-level error > findings.json || true
# Block: refuse only what the analyzer is certain about.
qual check "$SCENE_DIR" --format json \
--min-confidence certain --fail-level error
Exit code 1 means the threshold was met; exit code 2 is a usage or configuration error and must never be treated as a rejected scene.
Rule catalog
The catalog contains 92 rule IDs across four families; all 92 are implemented:
| Family | Implemented | Reserved |
|---|---|---|
| MLC lifecycle / correctness | 31 | 0 |
| MLR rendering | 27 | 0 |
| MLP performance | 27 | 0 |
| MLD determinism / portability | 7 | 0 |
One implemented rule is opt-in: MLP225 has default_enabled: false and
never joins a normal check run; only an exact --select MLP225 under the
local fork profile evaluates it.
The full index with per-rule status, severity, and confidence is in
docs/rules/README.md; each implemented rule has a
documentation page there, also available via qual explain <ID>.
Architecture
Python sources
|
SourceManager ............ encoding (PEP 263), newlines, Unicode columns
|
knowledge profile ........ versioned Manim 0.20 semantics (no import, ever)
|
frontend ................. imports/aliases, project index, qualified call facts
|
semantic ................. lifecycle abstract interpreter -> LifecycleFacts
|
cost ..................... hot contexts, frame intervals -> CostFacts
|
rules .................... MLC / MLR / MLP / MLD over the fact layers
|
suppressions, supersedes, baseline
|
output ................... concise | full | json | sarif | github, fixes, cost report
docs/architecture.md walks a new contributor
through this pipeline: what each fact layer provides, where it lives, the
knowledge-profile system, and how one diagnostic flows end to end.
DESIGN.md is the authoritative specification for the semantic
model, the rule catalog, and every public contract. JSON output follows
schemas/diagnostics-v1.json; baselines
follow schemas/baseline-v1.json. The
Poietra/fast-manim semantic bridge emitted by qual static-facts is
specified by
StaticFacts v0 and its
JSON Schema. It publishes snapshot-scoped
Scene/object/play/animation/updater IDs, encoding-aware source anchors,
reason-carrying unknowns, renderer risks, and coverage frontiers without
exposing analyzer handles. It reports blockers but never grants permission to
skip or fork rendering. Output is deterministic and byte-stable for the same
input.
The cache-independent
SemanticDependencyGraph v0
is the shared fact layer for cache component partitioning and conservative
source-change impact. It retains anchored Unknown frontiers instead of
guessing dynamic dependency edges.
ChangeImpact v0 compares two source
snapshots and emits schema-validated, reason-carrying Scene/play/object impact
candidates, including relations deleted from the target tree.
SourceBridge v0 generates hash-guarded
local patch candidates, validates them against an in-memory reanalysis, and
reports match | ambiguous | missing without writing project files.
Known limitations
- Target version. The shipped knowledge profile covers Manim Community 0.20 only. Other versions have no profile yet.
- Asset checks probe the linting machine.
MLR104resolves literal asset paths with Manim's own runtime search, on the machine running the lint. For absolute paths outside the project tree that is evidence about the lint host, not necessarily the render host (e.g. CI linting a repo rendered elsewhere); those diagnostics carryenvironment_dependent: trueas evidence. Case-only mismatches are reported only against case-sensitive target platforms (linux); when every affected profile targets windows/macos, the declared renders resolve the file as written and the linter stays silent. - Source encodings. PEP 263 declarations resolve through WHATWG labels
plus a CPython codec-alias table (
latin-1,cp932,koi8_r, ...). A rare Python codec the linter cannot represent is skipped with an explicitMLC000"not supported by qual" notice — never a claim that the target Python could not decode the file. - Durations come from literals only. A play whose duration rests on
Manim's defaults (
self.play(m.animate.shift(RIGHT))with norun_timeanywhere,self.wait()) is reported as unknown — frame counts use per-frame wording instead of numbers (conservative: missing, never fabricated). A literal play-levelrun_timedecides the whole-play duration exactly — including plays inside Scene helpers, per call site, even when the call sites pass different (or untracked) mobjects to a.animatebuilder on the parameter — and a non-literalrun_time(or a**kwargssplat) honestly widens constructor literals it overrides. - Summary-derived plays are conservative. When helper inlining falls
back to an effect summary (recursion, an unresolvable call — counted as
helper calls summarized, not inlinedin the coverage report), the helper's plays still materialize, but asMaybe-certainty records with open repetitions: literal-duration checks such asMLC104still fire there, while every caller-state-dependent judgment stays degraded. TracedPath's constructor-registered updater is cost-only. The updaterTracedPathregisters on itself at construction is modeled for cost purposes (MLP220spans, hot-context entry for a traced lambda), but it is not a lifecycle updater registration: aTracedPathalone does not make a defaultwait()dynamic in the lifecycle model, and a bound-methodtraced_point_funcbody is not analyzed as a hot context.- Deliberately conservative silences. Some detections are narrower than
their catalog prose and stay silent rather than guess:
MLR106sees NaN/inf only in literal form, not throughfloat("nan")calls;MLD301proves FPS dependence only for updaters that lack adtparameter (a declared-but-unuseddtis not flagged);MLC113/MLC124recognize their documented call shapes only;MLR102needs the interpreter to prove the played bare builder's target unchanged;MLR105validates a verified Pango subset (a bare&is allowed);MLD304implements only the ThreeDScene fixed-object cleanup divergence.qual explain <RULE>states each rule's exact scope. - Not yet implemented. Threshold calibration against rendered baselines; a nightly render-comparison CI.
Development
cargo fmt --check
cargo build
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings
All four gates must pass.
Knowledge-profile maintenance: the sync_manim_knowledge binary statically
reads a Manim checkout, generates reviewable profile candidates, and checks
the shipped profiles for drift (exit 1 on contradictions) — see
src/knowledge/profiles/README.md.
Provenance is split: upstream_0_20 describes the clean upstream base
commit 4d25c031 (read via git archive, never the working tree), and the
local_0_20_1_4d25c031 overlay carries what the sibling fork's working
tree adds on top:
# working tree (fork) — informational against upstream
cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --diff
# clean upstream base — must be contradiction-free
cargo run --features dev-tools --bin sync_manim_knowledge -- --manim-root ../manim --manim-ref 4d25c031 --diff
cargo test --test knowledge_drift -- --ignored # layer-9 drift gate (both)
Release quality gates (DESIGN §11.4)
Three additional gates guard releases:
# Labeled corpus gate — runs automatically inside `cargo test`.
# tests/corpus/manifest-v1.json pins sha256 + exact expected diagnostics
# (true positives and false-positive guards) for every corpus case,
# including pinned real-Manim example_scenes snapshots and the
# adversarial review probes.
cargo test --test corpus_gate
# Benchmark gate — explicit, release build, quiet machine.
# Cold ≤ 2 s / warm hit ≤ 0.5 s / one-of-20 incremental ≤ 0.5 s /
# peak RSS < 300 MiB over the pinned 10k-LOC fixture
# (tests/corpus/benchmark_10kloc); thresholds assert only on the machine
# matching benchmarks/reference-machine.json, informational elsewhere.
# The gate proves cold is a miss, warm a validated hit, and incremental a partial hit.
# Three-run median on the reference machine (2026-07-20): cold 0.422 s,
# warm 0.006 s, incremental 0.171 s, peak RSS 246.5 MiB — all within budget.
cargo test --release --test benchmark_gate -- --ignored benchmark
# Knowledge drift gate — needs the sibling Manim checkout; in CI it runs
# on schedule/dispatch against a shallow clone of the pinned base commit.
cargo test --test knowledge_drift -- --ignored
Corpus cases are never re-recorded mechanically: a mismatch means re-adjudication under the labeling protocol in CONTRIBUTING.md.
See CONTRIBUTING.md for the
repository layout, the step-by-step guide to adding a rule, and the
invariants every change must keep, and
docs/architecture.md for the pipeline and
fact-layer overview. DESIGN.md is authoritative; changes to
public contracts must update it, its schema tests, and the rule docs
together.
License
MIT.
Dependency licenses, and one consequence worth knowing before you ship a
prebuilt binary — the Python parser pulls in an LGPL-3.0-only big-integer
crate, which Rust links statically — are documented in
THIRD-PARTY-LICENSES.md. Installing from source
(cargo install) is unaffected.
Prebuilt releases include the LGPL/GPL texts, exact locked source, and the relinking instructions in every distribution format. The release gate refuses to publish when that material is missing.
qual is an independent project. Manim Community is not affiliated
with it and does not endorse it.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file qual_manim-0.3.0.tar.gz.
File metadata
- Download URL: qual_manim-0.3.0.tar.gz
- Upload date:
- Size: 823.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2224c07923c0e6c0bca2d221676385498a3c09fdaf07e1c8565d32d0813fc415
|
|
| MD5 |
fd1d43756f2f28017ed927a4c8105461
|
|
| BLAKE2b-256 |
a803c73a3492fd46dc0c3161cf5834c90352e72d5db8dca9f79f630bb1facfec
|
File details
Details for the file qual_manim-0.3.0-py3-none-win_amd64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-win_amd64.whl
- Upload date:
- Size: 5.6 MB
- Tags: Python 3, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35bdcfaa6cfb0bb484c9fa854f544347afe8acdcfeedff5b1a580e36f797d7ae
|
|
| MD5 |
0de6a05f31838e647414cce7532a8c52
|
|
| BLAKE2b-256 |
906a4364d478fd095d1d1dff3d8eee36eb3b3e26767d1728b9342355c37e087f
|
File details
Details for the file qual_manim-0.3.0-py3-none-musllinux_1_2_x86_64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-musllinux_1_2_x86_64.whl
- Upload date:
- Size: 5.7 MB
- Tags: Python 3, musllinux: musl 1.2+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b7cc7ea7cacf6ea4c6bb3d07a285967b4ae8ca58d279f05afdbe666c7244263
|
|
| MD5 |
39595b9db48c82045a2b5323f0c5c9e9
|
|
| BLAKE2b-256 |
41a37726fc961b79cb9bdc805aad5efe275902894ae8acb727799710854b94ee
|
File details
Details for the file qual_manim-0.3.0-py3-none-musllinux_1_2_aarch64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-musllinux_1_2_aarch64.whl
- Upload date:
- Size: 5.2 MB
- Tags: Python 3, musllinux: musl 1.2+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
480e85a2b92f1365d3912dc149d70839c22ae55d129006252ff5697cfc13617e
|
|
| MD5 |
aa705126a450c44245298fa47471b553
|
|
| BLAKE2b-256 |
a8156b70e53aa38a86470b35e7a4d1170d14d43496b8fcdb7bb1ea75498fca5c
|
File details
Details for the file qual_manim-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 5.6 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5ee815fa1db2ead0ea09fa68cf9e9a743749b48b5c1c4e769ef03f2ce01108ff
|
|
| MD5 |
a449282fcc35e960c4bf34fc419ec2ac
|
|
| BLAKE2b-256 |
c1f64d1b114d9f16c7e94386f8e9fa4cabe71b448e4327fd848a0a9905cb5078
|
File details
Details for the file qual_manim-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 5.2 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b3b9f0d0c2320142399cba24752afc2024718aa46562890e4851ab8cc4baa342
|
|
| MD5 |
06ca503de0a7b62e5d2cdb153907b347
|
|
| BLAKE2b-256 |
ab769d7f3a944ab14bba51c70397649c161b2c5addab143656de3b012def99bf
|
File details
Details for the file qual_manim-0.3.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 5.2 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
018b7e4a1a425815d26e86349c90417d8537c81449dbd03fb6e303deaee461e2
|
|
| MD5 |
80f2ca733c97a2146f9d892b1da47019
|
|
| BLAKE2b-256 |
5a64ddc046165075cf49b7d3059b1eff4f972edc5ff3a337e86e4243edb24684
|
File details
Details for the file qual_manim-0.3.0-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: qual_manim-0.3.0-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 5.5 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.12.0 {"installer":{"name":"uv","version":"0.12.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58f4fd2efaadda164c32ef783f228f579a40bc6b6cdebffb4382b37a6a6bc397
|
|
| MD5 |
d23f834b9ae8914423d5321d755b371f
|
|
| BLAKE2b-256 |
09b1d1694636fad14014df6fc14ceb5883ccd667d729bb28eae996cd3f98db2b
|