Files
craft-ai-integrations/tools/mutation/mutation_native.py
T
Jeremy 1a02afd6be feat(client): offer SAM 2.1 on Burn as an optional engine after rten (#91)
* refactor(engine-rten): let another engine reuse the SAM 2.1 request handling

Loaded, Backend, Prediction, read_prompt, prompt_points and best_proposal become
public. Visibility only: the click and box mapping, the picture kept between
requests, the empty-mask answer and the size checks then live in one place for
every SAM 2.1 engine.

* feat(engine-burn): crate shell and the script that generates the SAM 2.1 architecture

tools/burn/generate_sam21.py checks the two published ONNX files against the
pinned size and SHA-256, runs the pinned burn-onnx 0.22.0 (an example of
craft-ai-burn-setup), applies the five asserted fixes, drops the generated
loaders that panic on a bad file, splits by submodule so every file is under
700 lines, formats and writes. --check proves the tree is what the script
produces. REVISION must equal the saved-weights cache revision. Cargo.lock
additions are Burn with wgpu and ndarray (generated).

* feat(burn-setup): prepare SAM 2.1 weights for Burn once, on this computer

Brings in craft-ai-burn-setup (feature `conversion`): checks the two published
ONNX files by size and SHA-256, converts them to Burn weights with the pinned
burn-onnx 0.22.0, and keeps one checked, locked, shared copy. Nothing is hosted
or downloaded. Mutation group bp (10 cases). Cargo.lock additions are the
optional burn-onnx tree (generated).

* feat(engine-burn): GENERATED SAM 2.1 tiny architecture (30 files)

Output of tools/burn/generate_sam21.py, revision sam21-tiny-burn-0.22.1-onnx-0.22.0-v1,
from the published encoder.onnx and decoder.onnx with burn-onnx 0.22.0 and its five
asserted fixes. Do not review line by line; rerun the script with --check instead.
Longest file 699 lines. Marked linguist-generated.

* feat(engine-burn): SAM 2.1 runner for image.segment on Burn

Sam21Runner (engine burn, model sam21) over the generated architecture. The request
handling, picture kept between requests, empty-mask answer and click/box mapping are
the shared Loaded from craft-ai-engine-rten, so only the model run lives here.
Weights load through BurnpackStore and come back as errors; every call into the
generated code is guarded against panics. Graphics card when one answers, else the
processor. An .onnx file is answered with 'prepare it first', not a guess.
16 unit tests on a fake backend: click and box mapping, empty mask, short mask,
a failing model, shared conformance run with Cancel, damaged and truncated files.

* test(engine-burn): live comparison with rten, and the shared revision check

The ignored live test runs the real SAM 2.1 files through rten (published ONNX) and
Burn (weights prepared on this computer) on the astronaut sample: one click and a box,
best-mask IoU, timings, and a truncated prepared file refused. The revision test fails
when the generator, the compiled architecture and the saved-weights cache key disagree.

* test+docs(engine-burn): mutation group be, weight-loading tests and the Burn engine guide

Group be: 15 cases, one target each, all caught (saved-weights marker, load completeness,
panic guard, answer length, best proposal, file names, declared card makers, the
revision shared by script, architecture and cache key, the catalogue route and the
.bpk binding rule). fully_applied is split out so the load checks can be tested without
the store's own refusals. Group nf's onnx case follows the rewritten extension check.
docs/BURN-ENGINE.md: how to regenerate, the five fixes, the revision bump rule, limits.

* feat(engine-burn): CRAFT_AI_BURN_DEVICE=cpu forces the processor, and the processor path is measured

* feat(engine-burn): prepare the saved copy when loading from the downloaded ONNX files

The host step: given the downloaded encoder.onnx with decoder.onnx beside it, the runner calls
craft-ai-burn-setup (exact source bytes checked, reused when already saved) and loads from the
burn-prepared folder beside the model folder. Behind the default 'prepare' feature; mutation
group be gains two cases (17/17); docs say what is measured.

* feat(client): optional burn feature and a catalogue route for SAM 2.1 on Burn

craft-ai-client gains the cargo feature burn (ProviderFactory::standard() then knows the
engine). SAM 2.1's recipe lists a burn NativeRoute after rten, with the same two published
ONNX files (one download for both, nothing converted is hosted). rten stays first and
the default. The binding accepts the prepared .bpk file. Engines now list rten, burn,
candle; the tests that assumed the Burn slot had no files, or that no built-in engine
exists whatever cargo features are on, say what they mean. burn_engine.rs runs two apps
through the client with the real prepared files (ignored).

* fix(burn-setup): sweep the scratch folders a killed run left, and keep craft-ai-features a dev dependency

CodeRabbit on #89: tempdir_in(cache) folders hold the two source copies (about 130 MB) and were
removed only by Drop, so a killed app leaked them for good. They now carry the cache key and a role
in their names, and after the key's lock is taken the same key's leftovers are removed (other keys'
folders, the lock file and links are left alone). Red first: leftovers_of_a_killed_run_are_swept_for_this_key_only.
Mutation cases burn-killed-run-leftovers-kept and burn-sweep-takes-other-keys, both caught.
craft-ai-features is only used by a unit test, so it moves to dev-dependencies.

* fix(engine-burn): review findings on #90

CodeRabbit: the generator now refuses an unknown option (a mistyped --chek used to select the run that
rewrites src/model; red first: tools/burn/test_generate_sam21.py failed to import parse_args); the
preparation live test needs the prepare feature and is ignored by default; the refusal test no longer
leaves burn-prepared/<key>.lock in the shared system temp folder (the model folder is nested one level
inside the test's own folder; confirmed the old layout leaked it on this PC); docs, STATUS and the
mutation README no longer describe the client feature and catalogue route, which arrive in the next
PR, as available.

* docs(client): say the Burn route and client feature exist, and what the status line really covers

CodeRabbit on #91: the STATUS row said the host step was not built. Given the downloaded
encoder.onnx the runner prepares the saved copy itself on first load; the row now says that, names
the limits (no progress or Cancel during that load, no app run through the shared binding, no live
run on macOS or Linux) and LOCAL-BURN-PREPARATION.md no longer calls engine registration separate
work. Also restores the client feature and catalogue route in the docs and rustdoc that the #90
review fix had marked as planned, and the three mutation-README claims for them.

* fix(burn-setup): sweep only the scratch folders, not a renamed copy of the key's folder

CodeRabbit on #89 (second round): sweep_stale removed every folder starting with <key>., so a damaged
copy a person renamed to <key>.backup would be deleted on the next preparation. It now matches only
<key>.work., <key>.conv. and <key>.out., the names the scratch folders are made with (one constant
for both). Red first: the sweep test gained a <key>.backup folder that must survive. Mutation group
bp 13/13 caught, including the new case burn-sweep-takes-a-renamed-copy.

* test(burn): the generator's usage errors must exit with code 1

CodeRabbit on #90 (second round): the parse_args tests asserted only SystemExit, which a SystemExit(0)
would also satisfy. They now assert the code. Checked against a mutant that exits 0: 6 failures.

* fix(engine-burn): a damaged saved copy says it is damaged and where it lives

CodeRabbit on #90 (third round) pointed out that a saved copy with a bad manifest or weights fails
the same way on every retry, behind the message 'could not be read or written. Try again.' It
proposed deleting the copy. The rule that a derived copy is refused, never repaired or deleted for
the person, stays (#89 and its tests pin it); the dead end was the wording. craft-ai-burn-setup now
answers every unusable saved copy with DAMAGED_COPY (remove it, prepare again, no 'try again'), and
the Burn engine adds where it lives: the burn-prepared folder beside the model folder.
Tests written first (they did not compile without DAMAGED_COPY and explain): a damaged copy of three
kinds gets that answer and is left untouched; the engine explains it and passes other errors on.
Mutation: bp 2 new cases and be 1 new case, all caught.

* fix(burn-setup): a missing or unreadable saved weight is a damaged copy too

CodeRabbit on #90 (fourth round): hash_file(...)? on a saved encoder.bpk or decoder.bpk that is gone
returned the generic 'try again' error before the damaged-copy branch could run. It now maps to
DAMAGED_COPY. Red first: the damaged-copy test gained 'missing encoder' and 'missing decoder' cases and
failed with the old message. New mutation case burn-missing-saved-weight-answered-with-try-again; the
saved-hash case was re-aimed at the new line. bp 16/16 caught.

* fix(burn-setup): a manifest that cannot be opened or read is a damaged copy too

CodeRabbit on #91 (round 5): File::open and read_to_end on an existing manifest returned the generic
'try again' error, so the engine gave no deletion guidance. Both now map to DAMAGED_COPY. Red first:
a_manifest_that_cannot_be_opened_is_a_damaged_copy (Windows: another handle holds it exclusively;
Unix: mode 0, ending quietly where permissions are not enforced) failed with the old message. The
Unix variant was not run here. New mutation case burn-unopenable-manifest-answered-with-try-again;
bp 17/17 caught (Windows).

* test(burn-setup): write the Unix permission as octal (clippy::non_octal_unix_permissions, CI only)

---------

Co-authored-by: scratch <s@s>
2026-10-22 23:28:33 +01:00

31 lines
4.7 KiB
Python

"""Deliberate-bug cases for group `nf`: planning and offering a built-in runtime's own files.
Loaded by mutation_check.py. Each case breaks one rule and names the test that should notice.
Format: (name, file, old, new, package, test target, test filter).
"""
F = 'crates/ai-features/src/'
FE = 'craft-ai-features'
NF = [
('native-plan-ignores-usable', F + 'feature.rs', '.find(|r| r.usable && r.native.iter().any(|n| n.provider == provider))', '.find(|r| r.native.iter().any(|n| n.provider == provider))', FE, 'native_setup', 'cannot_run_yet'),
('engine-recipe-ignores-the-engine', F + 'feature.rs', '.find(|r| r.usable && r.native.iter().any(|n| n.provider == provider))', '.find(|r| r.usable)', FE, 'engine_recipe', 'each_engine_plans_from'),
('native-plan-takes-the-first-recipe', F + 'plan.rs', 'let recipe = feature.engine_recipe(provider);', 'let recipe = feature.first_usable_recipe();', FE, 'engine_recipe', 'each_engine_plans_from'),
('first-usable-recipe-takes-the-first', F + 'feature.rs', 'self.locals.iter().find(|r| r.usable)', 'self.locals.first()', FE, 'engine_recipe', 'plain_built_in_offer_skips'),
('native-offer-takes-the-first-recipe', F + 'setup.rs', 'Some(provider) => feature.engine_recipe(provider)?,', 'Some(_) => feature.locals.first()?,', FE, 'setup', 'unusable_first_recipe'),
('plain-built-in-offer-takes-the-first-recipe', F + 'setup.rs', 'None => feature.first_usable_recipe()?,', 'None => feature.locals.first()?,', FE, 'engine_recipe', 'plain_built_in_offer_skips'),
('native-option-from-every-recipe', F + 'feature.rs', 'if !out.contains(&route.provider.as_str()) {', 'if true {', FE, 'native_setup', 'first_usable_recipe'),
('native-option-from-the-first-recipe-only', F + 'options.rs', '.engine_recipe(provider)\n .and_then(|r| r.native.iter().find(|n| n.provider == provider))', '.first_usable_recipe()\n .and_then(|r| r.native.iter().find(|n| n.provider == provider))', FE, 'engine_recipe', 'each_engine_plans_from'),
('native-plan-takes-a-route-without-files', F + 'plan.rs', '.find(|n| n.provider == provider && !n.models.is_empty())\n }) else {\n return out;\n };\n fill_files(&mut out, &route.models, inventory);\n check_disk', '.find(|n| n.provider == provider)\n }) else {\n return out;\n };\n fill_files(&mut out, &route.models, inventory);\n check_disk', FE, 'native_setup', 'unfinished_or_unknown'),
('native-plan-takes-any-provider', F + 'plan.rs', '.find(|n| n.provider == provider && !n.models.is_empty())\n }) else {\n return out;\n };\n fill_files(&mut out, &route.models, inventory);\n check_disk', '.find(|n| !n.models.is_empty())\n }) else {\n return out;\n };\n fill_files(&mut out, &route.models, inventory);\n check_disk', FE, 'native_setup', 'unfinished_or_unknown'),
('native-plan-skips-the-disk-check', F + 'plan.rs', ' fill_files(&mut out, &route.models, inventory);\n check_disk(&mut out, inventory);', ' fill_files(&mut out, &route.models, inventory);', FE, 'native_setup', 'free_space'),
('native-offer-ignores-the-provider', F + 'setup.rs', 'selected_provider.is_none_or(|provider| n.provider == provider)', 'true', FE, 'setup', 'selected_provider'),
('built-in-offer-compares-comfyui-files', F + 'setup.rs', 'let compared = if let Some(native) = native {', 'let compared = if let Some(native) = native.filter(|_| false) {', FE, 'setup', 'needs_only_the_model'),
('option-offered-for-a-route-without-files', F + 'options.rs', ' .filter(|n| !n.models.is_empty())\n else {', ' else {', FE, 'native_setup', 'not_unbuilt_burn'),
('native-options-before-later-locals', F + 'options.rs', 'locals.extend(natives);', 'locals.splice(0..0, natives);', FE, 'native_setup', 'follow_every_local'),
('built-in-offer-uses-the-comfyui-terms', F + 'setup.rs', 'agreement: agreement_for(route),', 'agreement: agreement(),', FE, 'setup', 'agrees_to_its_own'),
('native-option-loses-its-model-id', F + 'options.rs', 'model: native.model.clone(),', 'model: String::new(),', FE, 'native_setup', 'candle_with_its_own_size'),
('onnx-is-not-weights', F + 'recipe.rs', ' name.ends_with(".safetensors") || name.ends_with(".onnx")', ' name.ends_with(".safetensors")', FE, 'native_onnx', 'an_onnx_file'),
('onnx-name-is-case-sensitive', F + 'recipe.rs', 'let name = m.filename.to_ascii_lowercase();', 'let name = m.filename.clone();', FE, 'native_onnx', 'an_onnx_file'),
('onnx-path-refused-when-binding', 'crates/ai-client/src/local.rs', '["safetensors", "onnx", "bpk"]', '["safetensors", "bpk"]', 'craft-ai-client', 'local_native', 'the_native_route_is_registered'),
]