animsmith — design & requirements
Status: pre-1.0 publishing design. Intended to keep the public crate and CLI surface aligned while the project is still willing to make breaking changes. Origin: extracted from a private game project's animation pipeline (design session 2026-07-03); that project — "the incubating project" below — is the first consumer, not the scope.
1. Mission & positioning
animsmith is a linter for skeletal animation clips. It answers the question every game team answers by hand today: does this clip have game-engine-friendly characteristics? — does the loop actually close, does the walk cycle's declared speed match its root motion, do the feet slide during stance, is the rig conformant, are the quaternions sane.
The gap it fills. Nothing open-source does game-semantics clip validation:
- Khronos glTF-Validator checks spec conformance (accessor validity, NaN, quaternion norms at the container level) — it has no concept of a loop, a gait, or root motion.
- ozz-animation has a motion-extraction sample (a good root-motion measurement reference) but no lint pipeline.
- Academic metric code (foot-skate ratio, jitter, penetration) lives in ML-evaluation repos, not artist tools.
- Engine importers (Unreal Interchange + Data Validation, Unity AssetPostprocessor, Godot import sidecars) give teams a place to hang custom checks, but the checks themselves are always studio-custom and re-derived from scratch.
animsmith packages those checks as a standalone Rust library + CLI: glTF/GLB native, FBX ingested via ufbx, engine-agnostic core, machine-readable output, and a self-contained HTML report with a 3D preview.
What it is not (scope guardrails):
- Not a spec validator. Run glTF-Validator for container conformance; animsmith assumes a parseable file and judges its content.
- Not an art exporter.
convert(FBX→glTF) exists so clips can enter the lint pipeline directly from a DCC export; it promises animation and skinning fidelity, not material/shading fidelity. - A transformer for pipeline-mechanical operations only (scope
widened 2026-07-03; see Appendix A). In scope:
fixfor lossless mechanical repairs (quaternion unit normalization and hemisphere normalization), frame-range slice/trim + hold-extend, fail-closed in-place gait-anchor rotation, opt-in pruning of provably constant multi-key tracks, and format conversion including a full mesh/skin FBX→glTF path (a maintained replacement for the archived FBX2glTF). Out of scope stays artistic transformation: retargeting, motion editing, procedural animation — that is DCC work. The rule of thumb: animsmith may rewrite a clip only in ways whose correctness its own checks can verify. - Gait anchoring is an explicitly in-place operation. The core boundary
requires a movement policy; the shipped CLI and assembly switches select
only
InPlace. Before any channel is cyclically reordered, AnimSmith samples the resolved Root role (falling back to Hips) and refuses missing/non-finite evidence, horizontal accumulation above 1 cm, or yaw accumulation above 1°. Every nonconstant channel the operation would rotate must contain exactly one key at each declared whole-frame sample over[0, duration], at the exact representable f32key / fpstime and exact period endpoint. Sparse, differently framed, duplicate-time, or off-grid evidence refuses; duplicate(bone, property)channels, including constant channels, also refuse before sampling. A phase shift must bijectively permute authored values rather than synthesize values at omitted frames. The verifier samples those exact admitted f32 key times, and mutation is an integer-index permutation rather than a second floating-point resample. Constant channels need no grid because cyclic reordering cannot change them; their key times cannot influence the declared period or shift. Before allocating a pose grid, the public core boundary validates all track cardinalities, targets, finite values, all resolved metric-role indices, and the complete acyclic parents-before-children skeleton. Both declared-frame and maximum-authored-key work must independently satisfy the inclusive boundsdeclared frames × skeleton bones <= 1,000,000,declared frames × tracks <= 1,000,000, andmaximum authored keys × skeleton bones <= 1,000,000; every verifier and metric sample uses the one bounded declared grid. The 1 cm endpoint-displacement and 1° accumulated-yaw caps are applied directly, without any sampled-step allowance an interior outlier could inflate. Yaw is derived from model-space f32 quaternions as binary64 headings. At sample zero, the verifier selects the local+Z,+Y, or+Xbasis axis with the greatest finite horizontal projection, breaking exact ties in that order, and retains that one axis for the entire proof. This admits equivalent source-axis conventions without switching witnesses later to hide yaw; a selected axis that later loses its horizontal projection refuses. Full-turn crossings are counted and the first heading is subtracted from the final unwrapped heading, so the result has no segment-count-dependent summation error. Only four f32 successors at each inclusive cap cover authored endpoint translation/quaternion quantization; this gait-local rule does not widen other checks. Standalone transform output is transactional too: per-clip success lines and the write summary remain buffered until every selected clip and the artifact write succeed. This admits tightly closed cyclic pelvis/root sway while refusing authored travel and turns. Root-motion phase offsets, root-motion extraction, and trajectory-preserving cyclic rebasing remain runtime or separately designed operations; gait anchoring must not improvise them. - Not a runtime. It models how engines sample animation; it does not play games.
2. Users & use cases
- Artist inner loop —
animsmith lint export.fbxseconds after a DCC export, before any engine import or bake. Catches "the loop pops", "wrong rig", "cm instead of m" while the DCC session is still open. This is the highest-value loop: the alternative is discovering the problem after the slowest step of the pipeline. - CI gate —
animsmith lint --format jsonin CI on committed assets; stable JSON schema, exit codes, per-check severity config, baseline file for adopting teams with a dirty back catalog. - Pipeline library — engine pipelines embed
animsmith-coreand build check sets programmatically. First consumer: the incubating project's asset gate replaces ~1000 LOC of measurement Python with library calls. - PR-review artifact —
animsmith report clip.glb -o report.htmlproduces a single offline HTML file with 3D skeleton playback and metric charts; attach it to a PR or CI artifacts so a reviewer can see the seam pop the numbers describe.
3. CLI surface
animsmith lint <file...> [--config animsmith.toml] [--select ids] [--deny warn] [--format text|json]
animsmith measure <file...> --format json # measurements only, no judgment
animsmith inspect <file> # clips, durations, tracks, bones, detected rig profile
animsmith report <file> -o report.html [--clip name]
animsmith report <before> -o comparison.html --compare-after <after> --before-clip <name> --after-clip <name>
animsmith transform <file> -o <out.glb> [--clip name] [--slice START:END] [--hold-extend SECONDS] [--gait-anchor] [--drop-duplicate-loop-endpoint] [--prune-constant-tracks]
animsmith fix <file> (-o <out.glb>|--in-place|--dry-run) [--repair id[,id]]
animsmith convert <in.fbx|in.glb|in.gltf> -o <out.glb> [--material-texture-recipe recipe.toml] [--animation-only|--bake-static-mesh-transforms] [--format text|json]
animsmith assemble <recipe.toml> -o <out.glb> --evidence <out.json>
animsmith diff <A> <B> [--format text|json] # A/B: assets or one-file output-v13 measure/lint JSON
-
lint= measure + judge against config.measureis lint minus judgment — both emit the independently versioned measurement contract that other pipelines can pin. -
Exit codes:
0no failing findings and no required-unavailable engine prediction facets (warnings, notes, and ordinary nonblocking coverage gaps may remain),1at least one error-severity finding, anyrequired_prediction_unavailablefacet, pending repairs underfix --dry-run, or any producer refusal established as a fact about source asset bytes,2operator/tool error (unreadable declared file, bad config or recipe syntax, unsafe/unwritable path, or publication failure).--deny-warningspromotes warnings to errors; severity and--allowdo not suppress an emitted required-unavailable facet. -
Producer outcome authority is typed before prose exists.
convertandassemblecarryPublished,Rejected, andOperatorthrough one shared boundary. Loader, selector, transform, proof, and representability failures reached after source bytes were read are asset refusals; invocation, recipe-validation, path, file-read, temporary-file, serialization, and publication failures are operator errors. Mixed error enums (notably the material-texture recipe) are matched by variant at the call site. No exit decision parses an error string. JSON refusals use the separately immutableurn:animsmith:schema:producer-refusal:1record; existing conversion, assembly, output, and scale evidence identities remain unchanged. Text refusals are one escaped stderr diagnostic carrying the same stable kind. Every refusal precedes publication, preserving a prior assembly pair and a prior convert artifact; this does not claim that convert's direct writer can roll back an operator I/O failure. -
Stdout is reporting, not outcome authority. Every JSON, text, and Markdown stdout write, including parser-rendered help and version text, uses a fallible checked write followed by a best-effort checked stderr diagnosis. A closed pipe or other write failure never panics or replaces the outcome already established by a stdout-bearing path: shipped success paths retain
0, while finding and refusal paths retain1. Operator errors occur before stdout reporting, remain stderr-only, and retain2. This deliberately includes output-centric commands such asinspect: exit0states that inspection completed, not that an external consumer accepted every byte. Assigning a new outcome to text alone would make exit semantics depend on presentation format and would relabel real findings or completed publication as operator errors. JSON serialization failure remains an operator error because it means no truthful record could be formed; failure to deliver already-rendered bytes is only a reporting failure. Multi-part human output is attempted as one checked stream where one command can produce multiple records; in particular, all selectedfixrepair reports and all parts of one conversion summary yield at most one stdout-failure diagnostic. Parser output remains clap-owned: the checked boundary calls clap's fallible writer directly so its configured automatic/forced ANSI styling is preserved instead of being stripped while materializing a plain string. -
Human-readable command results are assembled by pure functions in the CLI's renderer module. Command dispatch keeps execution, file writes, and exit policy in
main.rsand passes structured values to the renderer; the renderer is the single owner of escaping untrusted text for terminal-safe presentation. JSON result serialization remains a separate, unchanged path. -
transform --prune-constant-tracksis an opt-in, candidate-first mechanical edit: it shares theconstant-tracktolerances (vector components1e-4, sign-invariant rotations1e-3radians), verifies the resulting sampled local TRS and model-space position/rotation, and reports every original track index it removed or retained. It runs after selected transforms and never substitutes for DCC curve cleanup or key reduction. Per-clipanimates_bonesnames protect motion evidence;[rig] required_bonesremains a skeleton-presence-only contract. -
Inputs:
.glb,.gltf(+ external buffers),.fbx(via thefbxfeature, default-on in the released binary). -
Malformation policy: structural malformation — keyframe/value count mismatch, zero-key channels, absolute or escaping external buffer URIs, non-forest node graphs (cycles or a node with two parents), or a primitive accessor whose dense or sparse byte extent its declared buffer view or resolved buffer bytes cannot satisfy, an integer
TEXCOORD_0orWEIGHTS_0accessor that omitsnormalized: true, or an animation sampler accessor whose declared element does not match its property-selected reader — is rejected at load. Forconvertandassemble, a rejection derived from readable source bytes is an asset refusal (exit 1); an unreadable primary file or a missing/unreadable declared external resource is still an operator error (exit 2). Commands outside that producer boundary retain their historical operator-error load policy. Run glTF-Validator for structural details. Recovering a non-forest graph would force an arbitrary parent choice or silently drop a cyclic subtree, so the loader rejects rather than repairs (decision recorded for #92). Likewise, substituting an empty position/index vector for a short accessor would make checks report on geometry the file did not author. Primitive shortfalls therefore use a locatedPrimitiveAccessorLayoutrefusal; inverse-bind shortfalls remain typed source evidence because that domain already models unavailable bind declarations (decision recorded for #329). Sampler input isSCALAR/FLOAT; translation and scale output areVEC3/FLOAT; rotation output retains all five glTF quaternion encodings (BYTE,UNSIGNED_BYTE,SHORT,UNSIGNED_SHORT,FLOAT) asVEC4, and morph-weight output retains those same component encodings asSCALAR. Declared lookalikes are never reinterpreted merely because their byte size happens to match the reader's element (decision recorded for #327). The same fail-closed rule keeps invariant-9 honest for integer UVs and weights: normalizedUNSIGNED_BYTE/UNSIGNED_SHORTvalues are decoded to their declared float range, andFLOATvalues pass through, but an unnormalized integer accessor is refused before decoding. Thegltfreader rescales those integers whether or not the flag is set, and the loader has no finding channel in which to disclose that unauthorized reinterpretation; allowing it would makemeasurereport derived values as authored data (decision recorded for #328). Semantic defects — NaN times or values, non-unit quaternions, hemisphere flips, seam pops — load fine and are judged by the checks; sampling is panic-free under them by construction. -
fixintentionally requires either-o/--outputor--in-placefor writes;--dry-runis the check mode — it inspects only and exits1when repairs are pending, mirroringlint. Repairs are addressed by stable ids; every repair must be safe, lossless, and idempotent. Repair taxonomy (risk-tier groups) is deliberately deferred until a repair exists that doesn't meet that bar. -
fixstays byte-surgical — a product requirement, not an accident (decision recorded for #33). It patches only the offending animation bytes in the original container and copies everything else through verbatim, so meshes, skins, materials, and textures survive a repair bit-for-bit. Folding hemisphere/norm repair into a coretransformand re-emitting through the unifiedDocumentwriter was considered and rejected: the model writer re-emits and reorders accessors, so it is not byte-identical and would rewrite bytesfixmust leave untouched. TheDocumentround-trip is the right tool forconvert/transform; in-placefixis not a round-trip. -
convertandassembleare compiled only with thefbxfeature.--no-default-featuresremains a glTF-only pure-Rust CLI with validation, transform, fix, and diff commands intact;reportis controlled separately by thereportfeature. -
convert --bake-static-mesh-transformsis an opt-in static geometry operation. It accumulates rest hierarchy transforms into positions and inverse-transpose normalized normals, then writes canonical identity-root geometry. It retains topology, UVs, and the model-supported material and embedded base-color, normal, metallic-roughness, and occlusion-texture data. It fails closed for any animation track, skin signal, uninstanced or shared mesh definition, malformed/non-finite data, singular or near-singular transform, or reflection; it neither bakes skinning nor guesses animated or reflected semantics. It conflicts with--animation-only, leaves default conversion unchanged, and is deterministic for repeated same-platform input/options. -
convert --material-texture-recipesupplies exact named BaseColor, normal, metallic-roughness, and occlusion mappings for a conversion. It conflicts with--animation-only, is compatible with static baking, and leaves the ordinary linked/embedded texture path untouched when omitted. Its versioned recipe and provenance contracts pin path containment, image processing, and encoder behavior. -
assembleis the versioned generic boundary for a skinned base plus clips spread across separate files or master timelines. It permits only explicit, mechanically verifiable operations: exact-name skeleton remap, exact mesh selection, clip slicing/endpoint/hold/gait operations, named channel removal, rest-track completion, quaternion cleanup, opt-in constant-track pruning after all other transforms while protecting effective-clipanimates_bonesnames, fail-closed exact-name node-subtree removal after animation transforms, bind-consistent skin canonicalization, material recipes, and deterministic GLB/evidence emission. Node-subtree removal refuses any surviving track, mesh-instance, skin-joint, or complete source-skin reference; it remaps the surviving hierarchy but does not reparent descendants or garbage-collect mesh, material, or texture resources. Character-assembly recipe/evidence v3 introduced that node-removal contract; v4 retains it unchanged while adding optional rest/bind scale integration. Selectors resolve by exact unique name against the post-canonicalization base; their descendant union is planned before completion, excluded from completion targets, and projected after clip processing and constant-track pruning.animates_bonesmay therefore retain a protected track and force refusal, while[rig] required_bonesremains lint presence policy rather than an assembly selector. Accepted non-empty projection clears optional source-native skeleton identity, because it cannot remain a complete account after authored deletion. Because mesh-bearing nodes are refused, removal cannot newly orphan asset resources; only the earlier explicit mesh-instance selection performs resource pruning. Evidence lists every removed node once in original parent-before-child order, distinguishes direct selectors from descendants, and retains original node and parent indices. Archive extraction, gameplay naming and acceptance policy, cache/generation policy, and publication remain with the consumer.
4. Repository & crate layout
One public repo, one cargo workspace, five published crates (plus one
publish = false dev crate, animsmith-testkit). The split is driven by
two hard constraints: the core must be consumable with zero C compilation
and minimal deps; FBX support pulls in a C build step most library
consumers must never pay for.
animsmith/
├── Cargo.toml # workspace, edition 2024
├── LICENSE-MIT / LICENSE-APACHE
├── THIRD-PARTY.md # ufbx (MIT OR PDDL-1.0), vendored viewer assets
├── crates/
│ ├── animsmith-core/ # data model, sampling/FK, metrics, diffs, checks, config, findings
│ ├── animsmith-gltf/ # glTF/GLB → core model; GLB writer for `convert`
│ ├── animsmith-fbx/ # ufbx wrapper → core model; isolates the C build
│ ├── animsmith-report/ # self-contained HTML report generation
│ ├── animsmith/ # CLI binary (features: fbx, report — default on)
│ └── animsmith-testkit/ # publish=false: fixture builders, documentation-source readers, and CLI process helpers shared by tests + the example generators
├── assets/viewer/ # viewer JS/CSS, inlined via include_str!
├── fuzz/ # cargo-fuzz targets for the untrusted-input loaders
└── testdata/ # CC0 rigs + procedurally corrupted fixtures
- animsmith-core: deps
glam(the de-facto Rust game-math crate — do not hand-roll mat4/quat as the Python did),serde,serde_json,serde_jcsfor strict bounded core-owned RFC 8785 canonical JSON contracts,sha2, andthiserror. Its JCS use remains engine-, format-, filesystem-, and network-agnostic. It owns strict versioned JSON contract validation but no file-format knowledge or filesystem/network I/O. This is what embedding pipelines link. - animsmith-gltf: the
gltfcrate with trimmed features (no image decoding); owns GLB emission viagltf-json. - animsmith-fbx:
ufbx(official bindings, v0.11.x, actively maintained; bundles the single-file C library viacc— no system deps). A separate crate rather than a feature flag so the C toolchain requirement is structurally isolated. - animsmith (CLI):
clap,serde_json,toml.--no-default-featuresyields a pure-Rust glTF-only build. - Toolchain: stable Rust, edition 2024, MSRV pinned in CI. License: MIT OR Apache-2.0. All crate names verified free on crates.io (2026-07-03).
- fuzz/: a nightly-only cargo-fuzz workspace (detached from the main
workspace) with libFuzzer targets for the three entry points that ingest
untrusted files —
animsmith_gltf::load,FixSession::apply_to_path(..., Repair::QuatFlip), andanimsmith_fbx::load. These are the executable check on invariant-1 ("untrusted input must never panic or OOM"): targets run in release mode to match the shipped CLI's panic semantics, with AddressSanitizer on. A weekly CI job (fuzz.yml) runs each target for 60s off a checked-in seed corpus; minimized crashers are committed underfuzz/seeds/as regression fixtures, each also pinned by a unit test inanimsmith-gltf's hardening suite. Continuous/long-running fuzzing (OSS-Fuzz) is deferred.
5. Core data model
Two representations of a loaded file, because checks genuinely need both:
Raw layer — what the file says. The document keeps the skeleton hierarchy
and rest pose, authored clips and tracks, optional scene assets, and source
metadata together. Mechanical checks use this layer for defects such as NaNs,
quaternion flips, key density, and constant tracks. Exact Rust types and fields
belong to the animsmith-core model rustdocs; build them with just doc or use
the package README's stable docs.rs link.
assets (meshes, skins, PBR materials, and embedded base-color, normal,
metallic-roughness, and occlusion textures) is the scene-asset half of
the document. Both the FBX and glTF loaders populate it from a single
load (there is no separate assets-carrying entry point — the two
loaders share one shape); it is empty when the input carries no scene
assets. The check catalog ignores it — checks judge animation — but it
rides the one load/write round-trip, so transform and convert
preserve geometry rather than silently dropping it, and measure
reports versioned static scene measurements from it (#16): geometry-definition
boxes in primitive coordinates, node-instance boxes in default/rest node-world
coordinates, and per-scene unions. These are not runtime-visible bounds:
animation, skin deformation, morph deformation, and runtime placement are
excluded. The output contract records unavailable static node bounds and scene
partial coverage rather than serializing non-finite values.
The glTF loader also retains metadata-only presence for secondary
JOINTS_n/WEIGHTS_n attributes. The measurement contract aggregates those
independent sides per mesh definition so unsupported or unpaired sets are
observable without changing the primary four-influence semantics. Secondary
per-vertex payloads, repair, and writer preservation remain outside the core
skinning model.
The scene-asset model keeps source mesh definitions separate from their
mesh-bearing node instances and retains declared scene roots plus the optional
default-scene index. That identity is measurement evidence: duplicate names do
not collapse, shared definitions are reported once, and nodes outside every
declared scene remain observable. The current glTF writer still normalizes an
emitted document to its existing single generated scene; preserving authored
multi-scene membership across transform/convert is not part of the static
measurement contract.
validate_document_shape is the model-owned snapshot check for the structural
invariants full-shape strict operations such as scale share: finite
parent-before-child rest topology, source identity/projection topology,
clip/track storage, and scene instance/inverse-bind references. Document
remains publicly mutable, so a
successful check is not a durable token; strict operations revalidate every
input at every public boundary. This is deliberately not universal model
validity. Measurement can retain finite instance evidence from hierarchy
branches unrelated to a malformed chain, sampling remains panic-free and
tolerant of hostile standalone tracks, and static baking validates only the
fields its static operation consumes. Scale's
negative-weight, finite-position, capability, closure, affine, parity, and
proof rules remain operation-specific.
Ingestion is triangle-list only — the target inputs are skinned game
rigs, and the model and writer carry no primitive-topology field. A
non-TRIANGLES glTF primitive (points, lines, strips, fans) is skipped
rather than misread as a triangle list; other topologies and their
retriangulation are out of scope for now.
Sampled layer — what a game runtime sees. A PoseGrid built by a
ClipSampler: uniform time grid over [0, duration] (resolution = max
channel key count, or explicit fps), glTF-spec interpolation semantics
(lerp for T/S, shortest-path slerp for R with negation on dot < 0, STEP
hold, cubic-spline Hermite), clamp at ends. For clips declared loop,
the wrap pair is (last frame, frame 0) — the seam definition every loop
check shares. FK accumulates every skeleton node's local TRS to model
space, including each root node's own transform. Metrics that need a
body-relative frame derive it from resolved roles such as hips and feet.
The metric grid is computed once per clip and shared across checks,
measurements, and reports through the lazy MetricGrids owner.
Rig profiles — checks reference semantic roles rather than bone names.
Profiles bind those roles to source-rig names; the exact public role set,
binding types, and matcher fallback belong to the animsmith-core profile
rustdocs.
Built-in profiles ship for mixamo (mixamorig:Hips…), ue-mannequin
(pelvis, foot_l…), and humanoid (humanoid_ Pelvis,
humanoid_ L Foot…), plus auto-detection that scores every profile by
resolved-role coverage and reports the winner in inspect. A check whose
required roles don't resolve records a typed coverage gap — never a false
finding. This is the single design rule that makes the tool useful outside
its birthplace: tolerance data and bone names are config; the math is not.
The runner owns selection, activation, and severity policy. Each check exposes
a cheap applicability predicate and one evaluation method. The evaluation
returns content findings separately from completed scopes and typed coverage
gaps. Severity overrides therefore cannot turn missing evidence into a false
error. gait-group, for example, can complete member-existence validation
while reporting phase coherence as a gap. severity = "off" disables a check
without hiding its applicability record. A check may also declare an opt-in
default; assigning "note", "warn", or "error" enables it while retaining the
same independent configuration record.
The built-in evidence-code declarations are the single authority for each scope or gap code's machine identity, meaning, and allowed emitting check ids. The evaluation boundary rejects a built-in code emitted by an undeclared check, and the output-reference gate derives its exact inventory, meanings, and emitters from those same declarations. Embedded checks remain open-ended: they use namespaced custom codes rather than extending a closed enum.
Checks implement one trait and emit structured findings:
#![allow(unused)] fn main() { pub trait Check { fn id(&self) -> &'static str; // "loop-seam", "quat-flip", … fn enabled_by_default(&self) -> bool; // true unless policy is opt-in fn applicability(&self, ctx: &CheckCtx) -> Applicability; fn evaluate(&self, ctx: &CheckCtx) -> CheckOutput; } pub struct Finding { pub check_id: &'static str, pub severity: Severity, // Note | Warning | Error pub clip: Option<String>, pub bone: Option<String>, pub time_s: Option<f32>, pub measured: Option<Value>, pub expected: Option<Value>, pub message: String, } }
The structured fields (not just a message string) are what make diff,
the JSON schema, and the HTML report cheap.
6. Check catalog
Tiers are shipping priority. "Prior art" = a proven implementation exists in the incubating project's pipeline to port, with real-world numbers to golden-test against.
P0 — mechanical + the two killer semantic checks (v0 core)
| id | what it checks | needs | config | prior art |
|---|---|---|---|---|
nan | NaN/Inf in key times or values | raw | — | new (trivial) |
time-monotonic | non-increasing/duplicate key times; first key ≫ 0 | raw | epsilon | new |
quat-norm | rotation keys with |q|−1 beyond tolerance | raw | eps (1e-3) | new |
quat-flip | adjacent keys with dot < 0 (long-way slerp in engines that don't neighborhood-correct) | raw | severity | new |
duration-sanity | zero/degenerate duration; channels within one clip ending at different times; frame count non-integral at declared fps | raw + meta | expected fps list, pinned duration | reference contract duration_s pin |
scale-keys | interpolation-aware temporal scale variation beyond tolerance | raw trajectory | severity | new |
non-uniform-scale | component inequality anywhere on the interpolation-aware scale trajectory | raw trajectory | severity | new |
constant-nonunit-scale | constant non-unit scale channel or single-key pin | raw trajectory | opt-in severity (off by default) | new |
constant-track | redundant multi-key representation whose interpolation-aware trajectory never moves | raw trajectory | eps | new |
frozen-bone | required bone's max angular deviation from first frame below floor | grid + roles/meta | min_rotation_deg | reference contract rotation floor + measured rotation ranges |
duplicate-loop-endpoint | strict authored-key duplicate closing endpoint in a declared loop; warning/default-on | common finite strictly increasing timeline, valid cardinality, matching endpoints, interior motion + loop declaration | — | mechanical subset of #22's endpoint classifier plus open-cycle transform |
loop-closure | maximum per-bone model-space last→first position and shortest-path rotation delta | grid + loop declaration | global max_position_delta_m / max_rotation_delta_deg; clip max_loop_position_delta_m / max_loop_rotation_delta_deg | consumer-neutral authoring-loop requirement |
loop-seam | last→first position wrap discontinuity of feet-relative-to-hips, normalized by the local neighbour per-frame step, with a stride floor so stationary clips skip | grid + Hips/feet/toe roles | max_ratio, min_stride_step_m | locomotion_metrics.py — port verbatim |
loop-seam-vel | maximum per-bone model-space difference between the in-clip velocities entering and leaving the wrap | grid + loop declaration | global max_velocity_delta_mps; clip max_loop_velocity_delta_mps | consumer-neutral authoring-loop requirement |
loop-seam-rot | maximum per-bone shortest-path model-space angular-velocity difference between the in-clip steps entering and leaving the wrap | grid + loop declaration | max_angular_velocity_delta_degps | consumer-neutral authoring-loop requirement |
root-motion-speed | horizontal root/hips displacement ÷ duration vs declared speed_mps; flags stray speed pins on non-locomotion clips | grid + Root/Hips | pinned speed + tolerance (reference gate: 15%) | reference bake |
missing-bones | declared-required animated bones absent; tracks targeting nodes outside the skeleton | raw + meta | bone/role list | reference contract animates_bones |
required-bones | declared required rig bones absent from the skeleton; static sockets and IK targets are valid | skeleton + rig config | [rig] required_bones | consumer-neutral structural rig contract |
naming | clip names vs convention pattern | meta | regex/glob | new |
units-sanity | hips rest height wildly outside human scale (the cm-vs-m export classic) | skeleton + profile | height band | new |
P1 — locomotion semantics (the first-of-kind tier)
| id | what it checks | prior art |
|---|---|---|
gait-phase / gait-group | stride-phase anchor from the fundamental-harmonic trough of the left-minus-right foot-height signal; an exactly zero-amplitude signal has no phase subject, while positive signals are governed by the configured inclusive lr_amplitude confidence floor; circular phase spread across a declared clip ring (directional-blend coherence) | reference metrics module + gait-group contract — port verbatim |
in-place | classify in-place vs root-motion (net + per-frame root displacement) and compare against the clip's declared expectation | new; trivial on the grid |
foot-slide | detect stance (foot height + near-zero vertical velocity), measure horizontal foot velocity during stance in the travel-cancelled frame | new; hardest check — ships opt-in until corpus-tuned |
bind-pose | rest pose vs first frame delta (clip authored against wrong bind); T-pose/A-pose classification; node-TRS rest disagreeing with IBM-derived rest (the disagreement is itself a finding) | reference sidecar already derives rest from IBMs |
axis-conventions | character forward/up at rest vs declared axes; root orientation drift over a loop | reference contract axis vocabulary |
key-density | keys/sec far above the clip fps (unbaked-curve bloat) or far below (starved track) | new |
P2 — corpus/cross-clip
Cross-clip skeleton & rest-pose consistency across a directory;
ground-penetration of feet/toes; mirrored-pair symmetry (walk_left vs
walk_right); additive-clip suitability; compression-noise metrics
(per-track jerk); morph-target weight ranges; SARIF output.
7. Configuration
TOML (animsmith.toml, or --config); Rust-ecosystem norm and
diff-friendly in asset repos:
[rig]
profile = "mixamo" # or "auto", or an inline role map:
# A presence-only structural contract; these do not need animation tracks.
required_bones = ["root", "weapon_socket", "ik_hand_l"]
# [rig.roles]
# hips = "humanoid_ Pelvis"
# left_foot = "humanoid_ L Foot"
[checks.loop-seam]
severity = "error" # off | note | warn | error
max_ratio = 1.5
[checks.loop-closure]
max_position_delta_m = 0.01
max_rotation_delta_deg = 1.0
[checks.loop-seam-vel]
max_velocity_delta_mps = 0.1
[checks.loop-seam-rot]
max_angular_velocity_delta_degps = 5.0
[checks.quat-flip]
severity = "warn"
[clips."run_*"] # glob; exact > glob, later entries win ties
loop = true
movement_owner_xz = "animation"
movement_owner_y = "gameplay"
movement_owner_yaw = "animation"
max_loop_position_delta_m = 0.04 # finite per-family override; global is fallback
max_loop_rotation_delta_deg = 2.0
max_loop_velocity_delta_mps = 0.2
speed_mps = { value = 3.1, tolerance = 0.25 }
fps = 30
[clips.run_forward] # exact fields overlay matching globs
max_loop_rotation_delta_deg = 0.5
[gait_groups.run-ring]
clips = ["run_forward", "run_back", "run_left", "run_right"]
max_gait_phase_spread = 0.08
min_lr_amplitude_m = 0.05
CLI flags override file config (--select, --allow, --deny).
Engine-agnosticism rule: the TOML file is merely one constructor of
a CheckSet. Embedding pipelines build check sets
programmatically through the library API and keep their own contract
formats, hashing, and tolerance semantics on their side. animsmith never
learns an embedder's contract schema.
8. Output formats
- Text (default): findings grouped per clip, measured-vs-expected on
one line, colored;
--quietfor CI summaries. - JSON (
--format json): current output v19, identified byurn:animsmith:schema:output:19. Lint emits one result per catalog check and represents selection, configuration, applicability, evaluation coverage, content findings, completed scopes, and typed gaps independently. Measure and lint share a nested, independently versioned measurement contract. The current contract inventories glTF material definitions, the five corebase_color,normal,metallic_roughness,occlusion, andemissivetexture slots, texture-to-image identity, and decoded image metadata in source order. Complete coverage is scoped to that documented core domain; extension-defined texture slots are not implied. Measurements-v17 makes per-bone loop-continuity unavailability explicit without suppressing finite sibling rows. It retains measurements-v16's inventories each mesh's primitives in source order: nullable source material, total and finite decodedPOSITIONrows, and finite-only primitive AABB and centroid. Indexed geometry counts each stored position once rather than index references. Unsupported nonempty images may carry at most 16 bytes of lowercaseleading_magic_hex; it is evidence only and never a guessed container format. The contract makes absent loader support explicit as unavailable coverage and separates a source-declared MIME type from byte-detected container and decoded image facts. This remains measurement evidence, not an image acceptance, repair, resize, transcode, color-space, writer-preservation, or recipe-authority policy. Measurements v15 adds the canonical set of surviving non-empty local TRS channels keyed by skeleton index, plus engine-neutral sampled Root/Hips trajectory evidence. Root is selected whenever resolved; Hips is only a fallback when Root is unresolved. Translation and yaw retain independent availability, with endpoint X/Z/Y displacement, sampled horizontal travel and vertical extrema, and fixed-basis net/unwrapped/travel yaw in normalized right-handed +Y-up metre space. These are shared uniform-grid regression observations, not continuous-curve extrema or transform-correctness proof. Output v10 added the registry-independent engine-prediction provenance and scoped-facet substrate described in Appendix E. Output v11 retained it and added per-role resolution outcome and match-policy provenance. Output v12 introduced bounded V2 provenance/settings/prediction evidence and catalog-allocated facet summaries; output v17 retained that evidence while advancing its nested measurements to v16 and adding V6 root-motion provenance. Output v19 retains the immutable prediction graphs against a fail-closed measurements-v16 projection while publishing the complete measurements-v18 evidence. Output-v9, measurements-v15, output-v12, and collection-output-v4 remain immutable historical contracts. Measurements v14 gives every clip fact that is not applicable to every clip (loop continuity, loop endpoint mode, frame grid, loop seam ratio, gait and its phase, and root-motion speed) a required sibling<field>_availabilitystatus ofmeasured,not_applicable, orunavailable, so a consumer can distinguish a legitimately missing subject from an applicable metric whose derivation failed and must fail closed. It also retains v13's exact raw inverse-bind matrices and affine and conditioning evidence before deriving their inverses, and v12's corrected linear-transform observations through shared f64 affine facts; v13 and earlier contracts remain immutable historical evidence.convert --format jsoninstead emits the separately versionedurn:animsmith:schema:conversion-evidence:2producer-evidence contract: requested options, written-artifact counts, optional static mesh bake entries with source/output identities and applied world transforms, and deterministic material-texture recipe provenance when requested. v1 remains historical; the current CLI emits v2 exclusively. CLI exit status derives from content severity (warnings block only with--deny-warnings) plus required-unavailable engine-prediction facets; ordinary coverage gaps remain nonblocking evidence. The current output-v19 envelope types and immutable identities live inanimsmith-coreso CLI and embedded producers serialize the same reporting contract. Static-bake evidence is also a public core type; the conversion envelope remains a CLI producer contract. - Future serializers: no game-industry standard exists for skeletal animation lint results. Keep native JSON as the source of truth, then add serializers where downstream tools expect them: SARIF for code scanning, GitLab Code Quality/CodeClimate for MR widgets, JUnit XML for CI test dashboards, and CSV/HTML for humans.
diff A B: compares measurement maps per metric with fixed public per-metric significance thresholds, independent of configured lint caps; prints deltas; exits 1 on significant movement. Primary use: "did this DCC re-export change anything that matters?"
9. HTML report (the visual preview)
animsmith report clip.glb -o report.html → one self-contained offline
HTML file (CI-artifact- and PR-attachment-friendly; no CDN, no install).
The explicit comparison spelling is a separate presentation producer: it preflights named/indexed skeleton correspondence, selected metric-grid work, and input text before either side's checks run; writes atomically only to an output distinct from both inputs; and uses one shared coordinate/path scale. Both judged poses are drawn through that one camera: side by side by default, and at the reader's option overlaid in a single pane with the after skeleton dashed over the before one at the same two selected frames. The overlay is a second reading of the embedded grids — no second copy of the pose data, no refit, and no new authority. Its phase synchronization labels both source times and never claims a retime. Findings, coverage, prediction provenance, and immutable identities remain side-specific, so a missing finding cannot read as acceptance.
Key design choice: no three.js, no <model-viewer>.
<model-viewer>can play a GLB but exposes no skeleton/per-frame API — it cannot draw bone lines, foot trails, or sync to charts. Fails the requirement outright.- three.js (~650KB inlined + GLTFLoader) works, but it would re-sample the animation in JS, and its loop/slerp behavior may disagree subtly with what the linter measured — the preview could contradict the findings it illustrates. It is also an update treadmill.
- The decisive observation: the report never needs to sample animation
in JS. The Rust side already computed the
PoseGrid— model-space joint positions for every frame the checks judged. Embed that.
The source viewer is a hand-written WebGL2 skeleton renderer:
bones as line segments, joint dots, root-motion and foot trails, orbit
camera, play/scrub transport. It renders exactly the frames the checks
measured — when loop-seam flags the wrap at frame N, the viewer scrubs
to that frame N. Determinism is the feature.
- Embedded data: pose grids as base64 Float32Array in
<script type="application/json">blocks (~60 bones × 3 floats × 300 frames ≈ 290KB base64 per clip; f16 quantization is the escape hatch if reports grow). The source GLB embedded once as a download button. - Charts: Rust-generated inline SVG — root-motion top-down path, foot
heights, L−R gait signal with the fitted fundamental, per-bone
seam-delta bars — with a small shared JS playhead syncing a cursor line
across all charts and the 3D view. A declared
gait_groupsblend ring adds one cross-clip figure per group: every member's L−R foot height on one unshifted normalized source-phase axis, each measured stride anchor marked at its own phase, and the group'smax_gait_phase_spreaddrawn as a band around their circular mean. The members are never aligned to their anchors, because that would erase the disagreement the figure is evidence for, and no member is nominated as the reference the others are judged against — the check judges the set's spread and names none. - Findings panel: each finding links to its clip + time; clicking scrubs the viewer.
- Two clips side by side: the
withcontrol (and thewith=NAMEfragment key) plays a second clip of the same document beside the selected one, as two scissored halves of the one canvas drawn through one camera fitted to both clips' bounds. The transport drives the selected clip, and every half — its pose, its chart playhead and its path dot — is placed at the frame nearest that phase in its own grid, with both source times labelled. Both viewers place their two timelines through that one shared mapping and label it with one shared disclosure sentence. Like the comparison's shared phase it is a presentation mapping between two timelines, not a retime: the source panes select the samples the checks judged and never resample authored clip time. - Illustrative local blend: an optional third pane in the single-document report interpolates dense Rust-sampled local TRS, then runs full affine FK. Its continuous weight uses T/S lerp and shortest-hemisphere quaternion nlerp in binary64; exact endpoint weights reuse the source positions. A private binary64 Rust reference supplies synthetic conformance fixtures. No authored key sampler, rest-pose duplicate, all-pairs table, core measurement or engine runtime claim is introduced. The existing shared camera/orbit/zoom remains; interior poses may extend beyond its initial source bounds. The added local authority has a 32 MiB aggregate raw-byte cap plus clip, bone and metadata limits. Aggregate excess omits all local streams; invalid clips omit their own stream. Neither changes source findings, source playback or the public infallible render API. Evidence-only embeds neither positions nor locals. The output guide owns the detailed math, limits, omission and presentation contract.
- A skinned-mesh view (vendored three.js,
--report full) is a P2 option the crate layout leaves room for; it is presentation polish, not v1.
10. FBX ingestion (animsmith-fbx + convert)
- Library: the official
ufbxRust bindings (v0.11.x, actively maintained; the same C foundation the incubating pipeline already trusted, so behavior is already trusted in the incubating pipeline). - Normalization at load via
LoadOpts: target axes = glTF convention (right-handed, +Y up, −Z forward),target_unit_meters = 1.0(FBX defaults to cm), transform-adjust space conversion (don't rewrite geometry), helper-node handling for 3ds Max geometric transforms. The core model only ever sees glTF-convention data regardless of source. - Animation extraction uses ufbx's
bake_anim— it evaluates anim stacks/layers, cubic/TCB curves, pre/post-rotation, and inherit-scale modes into resampled TRS keys (rate from the FBX TimeMode, overridable). Each anim stack (take) becomes one coreClip. This sidesteps the entire FBX-curve-semantics swamp. - Scale inventory without enablement: the same successful parse produces
a deterministic ufbx-side status for every current Appendix D.4 domain and a
documented normalized source-node/source-skin projection when every declared
skin slot can be represented. A missing cluster bone downgrades that generic
projection to unavailable instead of silently dropping the slot. It
explicitly names coordinate/inherit normalization, baked and lost curve state, generated
normals/helper nodes, cluster-derived bind provenance, four-influence
truncation/renormalization, rejected negative/non-finite/unrepresentable
influences, triangulation/welding, omitted face/edge payloads, uninstanced
mesh definitions, unsupported source data, and ufbx source identities. Only
successfully projected cluster binds count as bone-convenience overwrites.
One unreadable cluster makes its ordered bind declaration
atomically unreadable; finite prefixes are not retained under shifted slots.
Stackless authored curves, point/line faces, and attached zero-face mesh
definitions are present-but-unsupported rather than absent or rebuilt; the
zero-face definitions retain an exact count and stable source identity.
Retained normalized meshes keep one definition per stable ufbx mesh identity;
shared source geometry produces multiple node instances that reference the
same compact normalized definition. They keep their stable ufbx typed
identity so source-skin attachment joins cannot shift
when an earlier source mesh emits no primitive.
Its core capability projection remains unsupported for ordinary operations;
narrow FBX
rest-bindinstead stages and proves a private GLB under the dedicated v5 contract.whole-documentremains unsupported, and no raw FBX span or artifact-preservation claim is made. convertemits glTF 2.0 GLB: nodes + skin (computed IBMs) + one animation per clip with at least one writable track; mesh + weights when present;--animation-onlyto strip mesh. The glTF writer returns counts derived from the emitted artifact, which bothconvertandtransformuse for their summaries and to report source clips omitted because they have no writable tracks. Explicitly not an art exporter — no material fidelity promise. Bothconvertandtransformshare oneload→writeround-trip overDocument(assets included), so geometry survives a transform pass and--animation-onlyclears it uniformly across input formats (it is the only lever that drops geometry).--bake-static-mesh-transformsis the separate opt-in path for static, unskinned, singly-instanced geometry: it removes the effective rest hierarchy transform by baking positions and normals under an identity root and reports each applied transform in conversion evidence. Unsupported animated, skinned, malformed/non-finite, singular/near-singular, or reflected input is rejected before output.- FBX pitfalls double as checks when linting
.fbxdirectly: source unit ≠ 1m (warn even though we convert), Z-up source, namespace-prefixed bone names (profile matcher strips), default "Take 001" naming (feedsnaming), baked-key explosion (feedskey-density), non-uniform inherited scale.
11. Roadmap
- M0 — walking skeleton. Repo bootstrap: workspace, dual license, CI
(fmt/clippy/test on Linux/macOS/Windows), CC0 test fixtures. Core model
- sampler/FK;
animsmith-gltf;inspectandmeasure --format json; the mechanical P0 checks (nan→constant-track).
- sampler/FK;
- M1 — reference parity. Rig profiles, TOML config, per-clip
expectations; port
loop-seam,frozen-bone,root-motion-speed,gait-phase/gait-group— golden-tested against the reference implementation's verified production numbers;lintwith exit codes + stable JSON; adopt the reference project's mutation-test discipline (corrupt one field, assert the finding names exactly that field). the incubating project's measure port lands here: its sidecar tool becomes a thin wrapper overanimsmith-core+animsmith-gltf(the embedder keeps its sidecar schema and hashing);locomotion_metrics.pyand the animation half ofmeasured_sidecar.pyare deleted. - M2 — report, FBX, diff → v0.1.0 on crates.io. HTML report; FBX
ingestion +
convert;diff. The v0.1 bar for "usable by an unaffiliated team": README quickstart works on a raw Mixamo-style GLB with zero config (profile auto-detect), built-inmixamo+ue-mannequinprofiles, sampleanimsmith.toml, versioned JSON schema doc, no incubator vocabulary anywhere in the public API. - M3 — the hard semantics.
foot-slide(stance detection),in-place,bind-pose/axis-conventions, rotational/velocity loop seams,--deny-warnings, baseline/suppression file for teams adopting with a dirty back catalog.
12. Risks & open questions
- Foot-contact detection robustness (M3) is the only research-grade
item — thresholds vary with rig scale and style. Ship opt-in at
warnuntil tuned on a corpus. - Test-asset licensing: Mixamo clips cannot be redistributed. Use CC0 rigged clips (Quaternius/KayKit/self-authored) + procedurally corrupted fixtures; budget M0 time for this.
- Sampling-semantics fidelity: engines differ subtly (cubic handling, sub-frame wrap). The doc pins the model — "glTF-spec interpolation on a uniform grid, wrap = (last, 0)" — and exposes grid fps as config, accepting it is a model of runtimes, not all of them.
- ufbx 0.x churn and thin docs.rs coverage: pin exact versions,
treat the C library's docs as canonical, keep
animsmith-fbxthin and swappable. - Rest-pose truth: node TRS and inverse-bind matrices can disagree.
Rule: IBM-derived rest is authoritative when a skin exists, node TRS
otherwise, and disagreement beyond tolerance is a
bind-posefinding. - Report size on long/many clips: f16 quantization + per-clip lazy JSON blocks if it bites.
- Scope pressure toward transformation will come ("you detected the
seam pop — just fix it"). The linter-first line is the identity; only
convert,transform, and mechanical/losslessfixoperations may mutate data.
Appendix A — naming decision record
Two decisions, both 2026-07-03 (the project was renamed the same day it was built, before anything was published):
-
animlintwas chosen first, on a linter-first scope with clip transformation explicitly out of scope. Rejected then:gltf-lint(glTF is the carrier, not the domain; permanently confusable with Khronos glTF-Validator's spec-conformance role; wrong the moment FBX input landed),animkit/clipkit/clipforge(existing projects),gaitkeeper(minor collisions; overemphasizes locomotion). -
Renamed to
animsmiththe same day, when transformation became first-class: the incubating pipeline needs hemisphere normalization, frame-range slicing, hold-extend, and gait-anchor rotation sooner than later, and its archived-FBX2glTF conversion step wants a maintained replacement. The naming record had said the lint-first name "only breaks if clip transformation becomes first-class" — that fork flipped, so the name followed before the first crates.io publish made it permanent.animsmithand all sibling crate names verified free on crates.io with zero GitHub repository hits. Lint remains the flagship subcommand.
Appendix B — prior-art map (first consumer)
The measurement algorithms, config vocabulary, and testing discipline
were extracted from a private game project's asset pipeline — the
uniform-grid sampler, the local-neighbour loop-seam denominator with
its stride floor, the L−R fundamental-harmonic gait anchor, the
root-motion speed gate, the Pinned{value, tolerance} expectation
shape, the Finding/severity/exit-code conventions, and the
mutation-test style (corrupt one field, assert the finding names it)
are all faithful ports, golden-tested against that pipeline's shipped
numbers. Its measurement scripts are deleted as the project migrates
onto the animsmith library — the standing proof that the public API is
sufficient for a real bake pipeline.
Appendix C — result-contract ownership decisions
These decisions record result-contract ownership after output v3 finalization:
-
The versioned envelope belongs to
animsmith-core, not the CLI binary. The CLI still supplies its build identity and frontend policy, butMeasurementContract, the command-specificMeasureFileReportandLintFileReportrecords, command-specific envelopes, summaries, and URNs are shared library types. This lets embedded producers emit the exact schema without copying private structs or protocol strings while making an invalid measure/lint record shape unrepresentable. -
Nested findings keep their
check_id. AFindingis also a standalone embedded result and the record consumed by text, Markdown, and HTML presentations. Keeping it self-describing avoids a second wire-only finding projection and supports extracting findings without retaining their parent check record. Output v3 therefore accepts the small nested redundancy, andevaluate_checksrejects a child id that disagrees with its parent rather than serializing ambiguous ownership. -
Read-side validation is consumer-neutral.
animsmith-corevalidates output and nested measurement identities and recovers every file's display path plus complete clip and mesh measurements in source order. It does not impose a file-count rule or prescribe CLI remediation. Thedifffrontend owns its single-file policy and operator guidance; embedded consumers may accept empty or multi-file reports according to their own workflows.
Appendix D — decision record: skinned rest/bind scale canonicalization
Status (2026-08-20): implemented for self-contained glTF/GLB, the narrow
FBX rest/bind re-encode path, and character assembly recipe/evidence v7. The shared
core plan, typed ledger, exact-source writers, independent core and artifact
proofs, atomic CLI publisher, and immutable scale-evidence v4 producer are the
current contract. Character assembly v7 can opt into rest/bind scale for
glTF/GLB and inventory-complete FBX inputs with an exact root name and factor
after validating a versioned basis for the base and every separate clip;
recipe/evidence v1 through v6 remain unchanged.
Whole-document conversion supports
raw glTF POSITION morph deltas while preserving static JSON weights as
numeric values and animated weight accessor payloads byte-exactly; other morph
semantics and every rest/bind morph payload remain refused. FBX
whole-document scaling remains deferred.
D.1 Problem and two distinct operations
A skinned mesh can already have the intended physical dimensions while its
joint hierarchy carries a compensating uniform scale. For example, a root can
contribute 0.01, descendant translations can be authored 100 times larger,
and inverse-bind matrices can carry a compensating linear magnitude of 100.
Skin deformation is then visually correct, but a rigid object parented to a
joint inherits the unwanted 0.01 scale.
That case must not be confused with a document whose entire linear unit is wrong. The following are separate, explicitly selected operations:
- Whole-document linear-unit conversion changes physical size. The caller
declares a finite factor
q > 0; every represented length is converted byq. It is appropriate only when the source was authored in a different linear unit. - Rest/bind hierarchy reparameterization preserves already-correct world geometry. It removes one compensating inherited scale from a restricted skinned hierarchy, rebases local rest and animation translations, and regenerates inverse binds.
Neither operation may infer its factor or applicability from mesh bounds, character height, joint lengths, inverse-bind magnitude, filename, or an asset category. Measurements from #267 are diagnostic evidence, not conversion authority. A caller must name the operation and declare or accept the exact factor that the transform plan validates.
The declared unit-conversion transform is a finite positive scalar only; it is not an axis, rotation, translation, shear, or reflection operation. The initial reparameterization source class is finite, orientation-preserving, positive uniform rest-world scale. Non-uniform scale, shear, reflection, singular or near-singular matrices, non-finite values, and mixed effective factors in the selected domain fail before any output is written.
Classification and proof share one versioned tolerance policy and compute in
f64, narrowing only at the writer model boundary. The current policy identity
is appendix-d-v6. The three finite widened axis lengths are sorted in
ascending order before their arithmetic mean is accumulated and divided by
three. This canonical association rule makes the common factor independent of
authored axis order, including when the sum lies on an equal-axis boundary.
Relative orthogonality, equal-axis, and common-factor tolerance is 1e-5; an
axis is unequal when
abs(length - average) > 1e-5 * max(average, length), relative to the longer
of the two and to nothing else; a determinant is singular when
abs(det) <= 1e-6 * product(axis_lengths); scalar/vector comparison uses
abs_error <= 1e-6 + 1e-5 * max(abs(before), abs(after)); and shortest-path
rotation residual is at most 1e-5 radians. Every bound is inclusive: a
residual exactly equal to its bound is accepted. Exact float equality is
forbidden. The implementation records every threshold and observed maximum
residual in evidence, so noisy values such as 100.000015 can be accepted for
an explicit, reviewable reason rather than by implementation accident.
This is a pre-1.0 policy-identity cutover: the former public
ScaleTolerancePolicy::APPENDIX_D_V5 associated constant is removed rather
than retained as an alias for the new axis-order-independent meaning. Evidence
records therefore use appendix-d-v6. The historical v5 calibration and
revision history live in the
scale proof calibration notes; the recurrence v5
introduced and v6 retains is specified below as part of the current policy.
The f32-rounding term, per obligation. The scalar band above is stated
relative to the quantity being compared. For five of proof's obligations
that quantity is not the magnitude the arithmetic ran on, and a rotation can
separate the two without limit: a bound component near zero on a mesh four
thousand units across, a near-identity W * B whose translation column is the
difference of two terms of magnitude abs(W), or a world translation whose
parent chain cancelled two terms larger still. The compared number is
then small and the absolute rounding error it carries is large, so a purely
relative band derives the tolerance from the small number and the error from
the big one — and plan_scale accepts a plan whose candidate prove_scale
then refuses. Sweeping a rotating rig at a declared factor of 3190, that
happened on 86 % of rotations before this policy, with residuals up to
9.8e-4 against a 6.1e-5 band.
appendix-d-v6 therefore retains the further quantity f32_rounding_ulps = 4, and the five obligations that compare f32-rounded arithmetic use
abs_error <= 1e-6 + 1e-5 * max(abs(before), abs(after))
+ f32_rounding_ulps * magnitude * 2^-23
where magnitude is named per obligation below. The added term is absolute
in magnitude, never relative to the compared quantity, which is what makes
it incapable of loosening a comparison whose operands already are its own
magnitude: there it contributes 4 * 2^-23 = 4.77e-7 of them, twenty times
below the 1e-5 the relative band already allows. The 2^-23 is
f32::EPSILON, because every one of these residuals is the difference of two
f32 quantities even though the subtraction itself is done in f64.
-
Skinned bounds takes
magnitudeas the largest per-vertex arithmetic provenance over every contributing vertex — read off the candidate, and maxed against the source's own rebased by the conversion factor:candidate.max(q * source), for the reason given after this list. Each influence has three stages whose cancellation a later stage cannot recover:- the magnitude the
W * B * ptransform runs on, which ismax over i of sum over k of abs((W * B)_ik) * abs(p_k)withpextended by the homogeneous1thattransform_point3sums the translation column in with — the productabs(W * B) * abs(p)and not either factor alone — because the weighted sum over slots that follows can cancel those terms against each other; - the magnitude the contributing slot's
W * Bcomposition ran on, which covers the cancellation that composition performs; and - the magnitude that slot's joint world translation was itself accumulated from along its parent chain, which covers the cancellation performed one composition earlier.
For one influence, the base is the maximum of those three stages. A vertex combines those influence bases with the same binary64 weighted average of the stored non-negative binary32 weights as its skinned point:
sum(w_i * base_i) / sum(w_i). The accumulator then takes the maximum vertex base. All three stages are load-bearing, and each dominates the others by an unbounded ratio on some rig. The skinned points alone miss a joint placed far from the geometry it carries — a1000-unit local translation under a3190root puts the joint3.2e6from the origin while its vertices skin to within one unit of it, and the bound inherits1.6e-1of error from a9.7e-1extreme. Dropping the slot-composition stage makes the calibration sweep refuse correct candidates in fifty-six of its seventy-two pre-v4 cells, at up to62.6ulps of the base that remains; dropping the transform stage outright refuses in thirty-four, and narrowing it toabs(p)alone refuses in fifteen at up to47.7.Stage 1 has been wrong twice, in the same place. It first read the skinned point's magnitude and claimed that covered the transform; the correction read the vertex position
abs(p)and claimed the same. Both name one term of a product of two, and each was exact only on the rigs that happened to be built.- The skinned point coincides with the transform only while no two slots
oppose. Two slots whose composed
W * Bdiffer by a half turn send a vertex1000units out to a blended origin, so the result reads0while every term summed to produce it carried that vertex's own ulp; put the joints near the origin as well and stages 2 and 3 read1. The residual is then6.1e-5— one ulp of1000— against a1.5e-6band, a demand of503ulps, rising to65_527atabs(p) = 1e5. abs(p)coincides with the transform only whileabs(W * B)is1, and the one rig that reached the cancellation composedW * Bfrom a pure rotation, where it is1by construction. Give those same two slots a uniform scale ofkand the sum still cancels while each term carries the rounding of ak * abs(p)-magnitude transform: atk = 1024andabs(p) = 65536the residual is4.0against a0.0313band, a demand of512ulps, and the rig refuses frommin(k, abs(p)) > 8upward with the shortfall growing asmin(k, abs(p)).
Both need only that the bind pose is not the rest pose, which is ordinary content: with
W * Bthe identity on every slot there is nothing for a blend to cancel and nothing but1forabs(W * B)to be, which is why a section's worth of fixtures built on analytic inverse binds could express neither case.two_slots_whose_composed_binds_cancel_a_vertex_still_prove_its_boundspins the cancellation andtwo_slots_with_a_scaled_composition_cancel_a_vertex_and_still_prove_its_boundspins the second factor; both build their binds independently of the rest world for that reason, and the calibration sweep carries composed slots atabs(W * B) = {1e-3, 1, 1e3}so a third factor of the same kind is visible to a measurement rather than only to a reviewer.The blended point is not another stage. Shared scale-input validation refuses every finite negative primary skin weight before planning. The implementation widens the stored binary32 weights, transformed-point numerator, and denominator to binary64, divides once, and narrows the final point to binary32. This prevents a lone subnormal contribution from disappearing before division, several large finite weights from overflowing their denominator, and rounded binary32 coefficients whose sum exceeds one from overflowing an otherwise finite convex blend at
f32::MAX. Bounds residuals are compared per axis, so each blended component is already bounded by the same binary64 weighted transform operands. v3's L2 length could be up tosqrt(3)larger while naming no additional per-axis arithmetic; v4 removes it.This validation decision removes two formerly unbounded affine cases. With weights
1.0and-0.99999, a positive near-zero denominator could amplify opposed transformed points to1.997e8; with identical transformed points, both numerator and denominator could cancel while the accumulation error was still divided by the near-zero sum. No finite ULP count could cover the latter generally. The two #336 counterexample rigs remain as fixtures and pinNegativeSkinWeightat the shared planning boundary instead of forcing tolerance machinery to describe invalid input.Weighting the provenance is equally load-bearing in the other direction. A stage at magnitude
1e20with weight1e-20contributes about1, not1e20; an unweighted max would let an influence with essentially no effect buy the whole distant stage's tolerance. Separate named fixtures isolate the transform and slot-composition halves, a proof-level fixture measures the old and v4 detection floors, and the calibration has an explicit profile that gives each vertex's larger production influence base the smaller weight.The proof-level floor is an adjacent-binary32 bracket on the same candidate-only weight mutation in both policies. A frozen v3 evaluator first refuses at candidate weight bits
0x3a7a1be4(the preceding0x3a7a1be3is accepted), where the Bounds residual is9.5320709005312e13. v4 searches both directions around the authored1e-20weight; its smaller refused residual is2.4400651454925537e-6, with the lower bracket0x1e3c6f0arefused /0x1e3c6f0baccepted and the upper bracket0x1e3d5b21accepted /0x1e3d5b22refused. That is a3.906e19xrecovery in this adversarial rig. It is a measured detection floor for this fixture, not a universal precision claim, and the exact bits and residuals are pinned in the test.magnitudeis deliberately not read off the bound corner being compared. A per-axis extreme is contributed by whichever vertex happened to be furthest along that axis, so three vertices at(3000, .001, .002),(.001, 3000, .003)and(.002, .003, 3000)build a corner of magnitude2.4e-3out of vertices of magnitude3000. A corner-derived band is a million times tighter than the error the corner carries, and — read the other way, as a base for the relative term — a corner-derived tolerance admits4.2e-2on a component of magnitude5.98, which is0.71 %relative error and no longer a rounding allowance at all. - the magnitude the
-
The skin equation and the inverse binds of an unaffected instance take
magnitudeas the largest quantity any entry of the compared product was summed from:max over (i, j) of sum over k of abs(a_ik) * abs(b_kj). ForW * Bthat is6.4e3on the rig above, not the near-identity product's1.0: the residual there is1.8e-4against the1.1e-5band the product magnitude buys, a16xshortfall, and2.50ulps of the operand magnitude after it. The skin equation maxes that against the same parent-chain magnitude skinned bounds takes, becauseabs(W) * abs(B)reads the already composedWand so cannot see terms the chain that produced it already cancelled. A joint whose local offset points back along its parent's world translation leavesabs(W) * abs(B)at1.0while the residual it must admit is6.25e-2—524288ulps of that base, and0.08of the chain's. The skin equation reads that quantity off the candidate and maxes it against the source's rebased by the factor, exactly as skinned bounds does, and for the same reason. It is also notmatrix_magnitude(a) * matrix_magnitude(b), which replaces the sum overkwith a product of two independent maxima and reads7.6e6where the arithmetic ran on6.4e3— a tolerance from that would accept a matrix that is entirely wrong, and the over-acceptance calibration fixture refuses exactly that case.For an unaffected instance's binds the two operands are the effective matrix and the declared factor, and scaling a column cancels nothing, so the term is inert there: a correctly built candidate leaves the effective bind unchanged and therefore has exactly zero residual. It is stated rather than omitted so that the policy quantity means one thing across every obligation that compares
f32matrices. -
Rest translation and sampled trajectory compare a node's world translation between the two documents, and take
magnitudeas that node's parent-chain magnitude alone — the same quantity the two above max against, read off the rest pose and off the sampled pose respectively. There is no product to sum here: the comparison is between two composed world translations, and the only way one of them can be small while its error is large is the chain cancellation.The root carries zero provenance because its world is a copied local. For child
i, with parent worldW_p, local matrixL_i, and inherited provenanceC_parent, the shipped binary64 recurrence iss_i,r = sum over k in {0,1,2} f64(abs(W_p[r,k])) * f64(abs(L_i[k,3])) p_i,r = f64(abs(W_p[r,3])) m_i,r = s_i,r + min(max(p_i,r, f32::MIN_POSITIVE), s_i,r / 2^-23) m_i = max over spatial rows r of m_i,r C_i = C_parent + m_iThe first term provisions the local three-product dot. The second provisions the final addition by paying the smaller of one ulp at the carried parent's scale and the whole new contribution being absorbed. The minimum-normal floor covers subnormal product rounding without imposing a fixed charge when
s_i,r = 0. Only the three spatial output rows participate; affine row 3 is the exact homogeneous coordinate. Rest and sampled pose construction share this recurrence. Its calibration populations and revision history are recorded separately in the scale proof calibration notes. The policy-neutral matrix rewrite, residual, and provenance kernels live incrates/animsmith-core/src/scale/numeric.rs; operation selection, tolerance decisions, reference expectations, connector products, and proof-owned expected values remain outside that leaf.This scalar is not a universal componentwise forward-error proof: it does not separately propagate error in the inherited linear block before that block acts on a later translation. The shipped claim is this additive recurrence and its checked empirical envelope, not an unbounded-depth analytic guarantee. At the later fixed stages, SkinMatrix maxes the accumulated chain against the
W * Boperand magnitude and Bounds feeds that slot base through the weight-proportional blend described above. A fully analytic matrix-error propagation would be a different model and policy contract.The chain is the candidate's, not the source's and not the max of the two. Whole-document conversion scales every translation by the factor and leaves every linear part alone, so the two documents' chains are that factor apart — subject to the candidate's
f32narrowing of the factor, since the build scales byfactor as f32while the proof rebases by thef64factor, a relative difference of at most2^-24(1.49e-8atq = 0.1, whosef32is0.10000000149011612) that this very rounding term covers many times over — and the source's rounding is rebased by the same factor before it is compared, so the residual scales with the candidate's chain at either end of the factor range, and under rest/bind the two chains are equal outright. A max over the two sides is therefore never needed, and under a shrinking conversion it is strictly worse: at a factor of0.01the source chain is100xthe candidate's, so the max freezes the band at the source rig's size while the candidate it is spent on keeps shrinking, and the smallest genuinely wrong candidate either obligation refuses stops tracking the factor at all. On the cancelling-chain rig that costs89xof discriminating power at0.01and648xat1e-4.a_shrinking_conversion_holds_rest_translation_to_the_candidate_s_own_chainanda_shrinking_conversion_holds_trajectory_to_the_candidate_s_own_chainpin the tightening; the two..._holds_..._to_the_candidate_sidefixtures pin the growing direction, where the candidate's chain is also the larger.
Transform-only affine is not in this rounding class. It compares a probe point transformed through the complete expected and actual world affines as a vector L2 residual against a vector-magnitude base, so its comparison base is already the magnitude its arithmetic ran on and it takes no rounding term.
Overflow is not NaN. A skinned position that leaves the f32 range is
reported as InvalidSkinnedPrimitive { reason: "skinned_magnitude_overflow" },
separately from the "non_finite_result" reported for NaN. Both fail closed,
and neither has a fixed preflight magnitude limit: skinning accumulates a dot
product per axis, so overflow depends on orientation and operand magnitudes,
not only on the magnitude of the result.
The magnitude calculations follow the same finite-domain rule. Bounds are
compared per axis and never square a finite blended point into an L2 length
that could overflow above sqrt(f32::MAX). The parent-chain
abs(W_parent) * abs(t_local) sums are accumulated in binary64. Composition
and column operand magnitudes are summed in f32 lanes and recomputed in
binary64 whenever that sum leaves the f32 range, so an operand magnitude is
never reported as inf while the composed result remains finite. These are
current appendix-d-v6 proof and refusal semantics, not calibration
measurements.
Why the source side is rebased. Every residual above is |a - q * b| for
the candidate quantity a and the source quantity b, so the source operand
enters the comparison multiplied by the factor and its rounding is multiplied
by the factor with it: a source quantity accurate to k ulps of its own
magnitude contributes q * k ulps of that magnitude to the residual. A base
that reads the source side unrebased — which max(source, candidate) did for
skinned bounds and the skin equation — therefore states the source's error in
the wrong units by a factor of q. Under a shrinking conversion that is the
whole defect, and it is loose by exactly 1/q: the band freezes at the source
rig's size while the candidate it is spent on keeps shrinking. Measured on the
far-joint rig, the smallest inverse-bind shift the skin equation refuses goes
from 1.9e-3 under the max to 1.3e-5 at 0.01 and 1.3e-7 at 1e-4; the
smallest bounds error refused goes from 4.8e-1 to 4.8e-3 and 4.9e-5.
a_shrinking_conversion_rebases_the_skin_matrix_magnitude_by_the_factor and
a_shrinking_conversion_rebases_the_bounds_magnitude_by_the_factor pin both.
Unlike the parent chain this does not reduce to the candidate's magnitude
alone, because these two magnitudes are products: only the terms carrying a
translation are a factor apart, and both sides retain an unscaled O(1) floor
from the composition's linear block and from the exact 1.0 the homogeneous
row contributes to every chain. Where that floor dominates the source,
q * source exceeds the candidate's magnitude under a growing conversion by up
to the whole factor. Production retains the max because it is the operand
bound justified by the comparison; no smaller candidate-only or min base is
part of the policy contract.
An earlier calibration tried to classify those smaller bases as equivalent by
dividing an obligation-wide maximum residual by a separately selected maximum
slot base. Those maxima can belong to different slots: after calibration began
recording each production comparison's own residual and provenance, the
shipped SkinMatrix demand was 2.213 while that mispairing still printed
2.084. The alternative-base figures and equivalence claim were removed
rather than preserved as false mutation evidence.
Calibration evidence. The checked-in release calibration, its deterministic populations, current reference demand tables, historical policy comparisons, and machine-local cost measurements live in the separate scale proof calibration notes. Those notes measure this policy; the normative thresholds, provenance definitions, and refusal boundaries remain in this decision record.
Normative residual norm for the unit-scale postcondition. The postcondition
"unit composed scale for every affected node" is measured per axis, as
max(abs(scale_axis - 1)) over the three axes — an L-infinity norm, not an L2
norm over the three axes together. This is normative. Per-axis is what the
claim means, it is how the equal-axis check already measures, and it is the
same dimensionless relative quantity the scalar common-factor check compares,
so the input band and the postcondition are directly commensurable.
How the common-factor band relates to it. The common-factor band is the
normative input contract, and the unit-scale postcondition bound is derived
from it rather than declared independently. For an affected node i with
observed rest-world factor s_i, a domain common factor s_0, and a declared
factor s_declared, the candidate's composed scale on axis k of node i is
axis_ik / s_declared. Three independently bounded relative comparisons stand
between those two numbers, and an affected chain composes all three:
- the declared-factor match binds
s_0tos_declaredwithinc = 1e-5; - the mixed-factor check binds every other affected node's
s_itos_0within the samec; and - the equal-axis check binds each individual axis length
axis_iktos_iwithin the samec.
The third band is the easiest to lose sight of and cannot be dropped: s_i is
the average of node i's three rest-world axis lengths, while the
postcondition measures an individual axis, so an axis is permitted a further
band away from the very number the mixed-factor check compared. A rig whose
root is off-factor, whose leaf is off-common-factor, and whose leaf axes are
non-uniform within the equal-axis band loads all three at once.
Each comparison is stated relative to max of its operands, so each
contributes at most c / (1 - c) when re-expressed relative to the smaller
one, and the composed worst case is (1 - c)^-3 - 1 = 3.00006e-5. The policy
therefore sets the postcondition bound to four common-factor bands — 4e-5 —
rounded up to the next power of two, 2^-14 = 6.103515625e-5. Three of those
bands are the analytic composition above; the fourth is headroom for the f32
world-matrix composition and decomposition that produces the measured composed
scale. 2^-14 is also 512 * 2^-23, and so lies on the binary32 mantissa grid
the measurement lives on, which is what makes the inclusive "at most" above
reachable rather than vacuous for this obligation.
Three bands rounded up (2^-15 = 3.0517578125e-5) would leave that analytic
worst case only four binary32 ulps of room — 5.17e-7, or 4.34 * 2^-23.
That is a rounding artefact, not headroom, and it would make the reserved
float-headroom band above a claim rather than a fact. The fourth band buys the
claim honestly, and it does not blunt the obligation: every build defect this
postcondition exists to catch — a dropped rebase, a factor applied twice, a
stale no-op candidate — is at least 1e-3, so 6.1e-5 still leaves better
than a 16x detection margin.
No floor on the comparison base. The common-factor and mixed-factor
comparisons are abs(a - b) <= c * max(abs(a), abs(b)), with nothing else
inside the max. Flooring that base — at 1.0, at the 1e-6 absolute scalar
term, or at anything else — turns the band into an absolute tolerance below the
floor, which is a relative band of floor * c / abs(s) that widens without
limit as the declared factor shrinks. It therefore breaks the closure property
below floor * c / 2^-14; at a 1e-6 floor that is
1e-11 * 16384 = 1.6384e-7 (the crossing point is inversely proportional to
the postcondition bound, so the 2^-15 bound an earlier revision declared put
it at twice that, 3.2768e-7), and at a declared factor of 1e-9 such a band
admits 1e-2 relative error, 1000x the declared policy. The degenerate
a == b == 0 case needs no floor either: it compares 0 <= 0.
What the derivation guarantees is the closure property: any plan the
common-factor band accepts yields a candidate whose every affected node
satisfies the unit-scale postcondition — at every declared factor the policy
admits, not merely near unit magnitude — with a full band to spare
(6.1035e-5 - 3.00006e-5 = 3.1035e-5). The guarantee is stated for the single
closed affected domain this record admits — one common factor, one declared
factor, hence exactly the three composed bands above. Admitting a second
independently classified factor in one plan would compose a fourth band and
require a new policy identity, not a wider bound at this one.
Sampling budget. The policy also bounds the total sampled proof work a
document may demand, as sample_time_count * per_sample_work_units against a
budget of 4e8 work units. The per-sample cost is every pass the sampled
obligations actually make, and each is charged once per document side, since
proof walks the source and the candidate:
2 * bone_count
+ sum over affected skinned instances of
[skin_and_bounds] *
(3 * len(skin_joints)
+ 2 * (vertices over every primitive of its mesh))
Every sampled obligation poses both skeletons, hence the bone term. Only the
source bone count is measured, which is sound because proof rejects a
candidate whose bone count differs (bone_count_mismatch) before it charges
anything: the candidate document is caller-supplied, so an unchecked candidate
skeleton is proof work nothing has billed. The slot term is charged explicitly,
per instance: nothing bounds the instance count, and nothing bounds
skin_joints, which may repeat a joint, so slot work bears no relation to the
bone count and must not be folded into it. The vertex term charges each vertex
once, while the f32-rounding stage of the skinning walk runs once per non-zero
influence. A four-influence vertex therefore performs that stage four times for
the same vertex charge; the budget is the fixed released work proxy calibrated
for the documented populations, not an exact instruction counter.
sample_time_count counts key times
and cubic-segment interior times, because both are evaluated. Exceeding the
budget is a typed refusal raised before the first sample time is evaluated;
proof never silently samples a subset. The comparison is >, so the budget is
a ceiling a document may reach, matching the inclusive "at most" every other
policy quantity is stated with. The budget is part of the policy identity for
the same reason every tolerance is: it is recorded in evidence, and two
evidence records carrying the same policy id must describe the same amount of
checking.
The calibration notes record the historical timing shapes and measurements that established this charge as a linear proxy for real work. Those seconds are machine observations, not a normative wall-time guarantee.
The value has to clear real assets as well as bound bad ones: a 200-bone rig
with a 100k-vertex skinned mesh costs 201_000 units per sample time, so a
30-second clip at 30 fps costs 180_900_000. A budget that refuses that
refuses a plausible production asset, with no way for a caller to opt into the
work.
Historical implementation cost and baseline comparisons are retained in the scale proof calibration notes, not in this normative work-budget definition.
D.2 Algebra and rewrite rules
Let L_i(t) and W_i(t) be node i's local and composed world matrices, G
be a skinned primitive's geometry-bind-to-world matrix, and B_i be its
inverse bind. Valid input satisfies
W_i(rest) * B_i = G
for every joint slot. Matrices below act on column vectors.
Whole-document linear-unit conversion
For the caller-declared factor q, define U = scale(q). Coordinates change
basis as M' = U M U^-1. For a uniform U, this multiplies an affine matrix's
translation by q while leaving its dimensionless linear part unchanged.
Therefore:
- multiply every node-local rest translation by
q; - multiply every animation translation value and both glTF cubic-spline
translation tangents by
q; key times remain seconds; - multiply mesh
POSITIONvalues and morphPOSITIONdeltas byq; - conjugate every per-skin inverse bind as
B_i' = U B_i U^-1; - multiply every supported camera, light, collision, or extension length by
qthrough a field-specific handler; and - leave rotations, normals under positive uniform conversion, UVs, weights, times, morph weights, and rest/animation scale channels unchanged.
glTF LINEAR and STEP samplers have values only; they do not store tangent
elements to rewrite. “Translation tangents” throughout this record therefore
means the explicit in/out elements of a CUBICSPLINE translation sampler.
Root-motion positions and velocities are not separate stored fields: they are
recomputed from the converted translation tracks. Distances and velocities
therefore change by q; durations do not. Animation scale is dimensionless
and is never multiplied as though it were a distance.
This operation preserves topology and semantics while intentionally producing
world positions q times the source positions. It is not a repair for an
already-correct mesh with a compensating skeleton scale.
Rest/bind hierarchy reparameterization
For the initial supported class, every affected joint has a rest-world linear
part within tolerance of s R_i, where s > 0 is one common factor and R_i
is a proper rotation. The affected domain is a closed connected hierarchy:
the scaled ancestor, all selected skin joints and paths between them, and all
descendant nodes whose attachment transform would otherwise inherit s.
A strict source-node connector with no normalized bone may occur on a path
between two projected nodes when its only modeled contribution is its authored
static local rest, which must be finite and affine. Planning composes that
local in source order; the connector does not become an affected normalized
node, its authored local is preserved exactly, and it owns exact-preservation
and projected-successor bridge obligations rather than a numeric residual.
The selected root and every selected skin joint must still project to
normalized bones. An
unprojected descendant or attachment that would itself need rewriting, a
second effective factor, or an instance outside the closure still rejects the
plan.
The compiled rest/bind plan records this source topology once, keyed by raw
source-node identity. A projected row also records its normalized BoneId,
authoritative raw parent, nearest projected parent, role, and the incoming
connector tail from which the ordered span is derived. A connector topology
row records its source and parent identities; field rows state its exact
preservation, and the projected successor identifies ownership of the bridged
rewrite. Derived indexes may accelerate lookup, but they are not additional
topology authorities. This distinction matters to embedders: source-node
indices select and preserve raw structure, while BoneId identifies
normalized rewrite and residual domains.
The same global table also retains OutsideDomain rows, including every
source row for whole-document conversion, so raw parent/projection identity is
never inferred from absence.
For affected node i, define the constant basis correction
C_i = scale(1 / s_i), where the initial contract admits s_i = s inside the
domain and s_i = 1 at its parent boundary. The desired world matrix is
W_i'(t) = W_i(t) * C_i
and the corresponding local matrix is
L_i'(t) = C_parent^-1 * L_i(t) * C_i .
The same rule remains valid across a preserved static connector, but it does
not reduce to skipping that connector or blindly applying the projected
parent's scalar to the next raw local. Let H be the ordered product of
unprojected connector locals between projected parent p and projected node
i, and let L_i be i's authored local after that connector span. Since
H is preserved, the projected successor's authored local must become
L_i' = H^-1 * C_p^-1 * H * L_i * C_i .
Then W_p' * H * L_i' = W_p * H * L_i * C_i, as required. A translated
connector makes the conjugation's additive translation term observable, so a
non-identity connector fixture is required; an identity-only fixture cannot
distinguish composition from omission. This is a static-rest rule only:
SourceNodeAsset carries no raw animation channel for an unprojected row, so
support for a frontend that drops an animated connector requires a separate
source-animation inventory and rewrite contract.
Because every admitted C is a positive uniform scale, the result remains
representable as TRS. On a direct projected edge (H = I) — including the
normalized Bone local into which a loader has already folded a connector —
the node-local translation is multiplied by the parent's s_parent, rotation
is unchanged, and local scale is multiplied by the dimensionless ratio
s_parent / s_i. For a raw projected successor after a preserved connector,
the linear part receives that same ratio but its translation is instead
determined by H^-1 * C_p^-1 * H * L_i * C_i; a translated H contributes
an additive term, so the simple translation multiplier must not be applied to
that raw local. The connector product, inverse, additive term, complete
bridged translation sum, and bridged linear ratio application are evaluated
in f64, then each final source-local component is narrowed once to the
f32 model boundary. Direct projected edges retain their established f32
arithmetic and association. Planning likewise keeps each admitted connector
span widened until its projected endpoint, so a built candidate that retains
Complete source projection remains a consumable factor-one planner input even
when the preserved connector-only product exceeds f32. Translation
animation values and both cubic translation
tangents on normalized nodes receive the existing parent-basis multiplier.
Scale animation on those normalized nodes is the same local-scale component
over time, so every stored VEC3 scale element receives s_parent / s_i too:
the selected closure root receives 1 / s, a strict affected descendant
receives s / s = 1, and an unaffected node receives 1. This is a topology
rule, not a constant-track predicate: LINEAR, STEP, and every value plus
in/out tangent of CUBICSPLINE are rebased. Rotation tracks and key times
remain unchanged.
A node on a direct projected edge that declares matrix rather than TRS
members takes the same rule in component form. Writing L_i' out in glTF's
column-major order, the nine linear entries (0,1,2, 4,5,6, 8,9,10) are
multiplied by s_parent / s_i and the three translation entries
(12, 13, 14) by s_parent. Components
3, 7, 11, 15 are written back exactly as authored, which is the correct
rebase only because the §D.4 gate has already proved them exactly
(0, 0, 0, 1): the basis change multiplies 3, 7, 11 by 1 / s_i, a no-op
on an exact zero and on nothing else, and leaves 15 alone at any value, so
what the gate buys there is not the arithmetic but the premise — a matrix
is the node's whole transform, with no projective divide this record models,
only when 15 is exactly one. §D.3 case 4 records why that gate compares
exactly rather than within tolerance. A raw projected successor after a
preserved connector is governed by the full H product above, not this
component shortcut.
Mesh positions, morph deltas, and normals remain unchanged because their world geometry is already correct. Each inverse bind is regenerated from the unchanged geometry bind:
B_i' = inverse(W_i'(rest)) * G = C_i^-1 * B_i .
Consequently W_i'(t) * B_i' = W_i(t) * B_i: the complete skin palette,
not merely joint origins, is analytically preserved.
Both bone convenience inverse binds and every per-instance skin-slot array must be updated; the single bone-level value is never authority for a multi-skin document. A raw format has one bind store per skin and no per-node bind, so the frontend rewrites each affected skin's stored matrices with per-slot factors and proves through the reloaded document that every per-instance array and bone convenience value is correspondingly correct. The output deliberately changes the affected nodes' composed scale to one while preserving their world translations and orientations, animation trajectories, skinned vertices, and bounds.
Conflicting raw accessor claims reject. Unlike whole-document conversion,
whose converted uses all take the same q, rest/bind derives a typed claim for
every raw accessor use, not only a scale-bearing write. A claim records the
accessor's expected component shape and its multiplier. Translation outputs,
scale outputs, and inverse-bind slots carry their topology- or slot-derived
multiplier. Factor-one preservation claims cover mesh attributes and indices,
sampler inputs, rotation and weights outputs, and sampler outputs that no
channel selects. The latter are still sampler-referenced payloads, not unused
bytes that the raw proof may overlook.
One accessor index must have compatible component shape and one multiplier
across all of those claims. Thus a translation or scale output reached from the
closure root and from a child, from an affected and unaffected node, or an
accessor used both as mesh POSITION and a translation or scale output can
reject; so can a same-index type conflict even when both factors are one. One
inverse-bind store shared by skins whose joints straddle the closure is the
same problem. Splitting the payload is not a remedy, because it changes array
lengths and destroys the identities the proof pins.
Distinct accessor indices are checked too: if their resolved byte ranges
overlap and either claim rewrites bytes, the operation refuses rather than
silently corrupt a preserved neighbour. Direct-image bufferView consumers
are not accessors and have their own separate image-overlap refusal. Within
the source domain admitted by the common conservative capability preflight,
uses whose claims agree impose no additional rest/bind restriction however
many reach one accessor. The preflight can still refuse a
scale-bearing/dimensionless alias or overlapping raw range before this ledger
runs. A declared factor of one makes every rest/bind multiplier one, so this
ledger's factor disagreement cannot fire on a no-op; it does not broaden the
preflight's supported raw domain.
D.3 Worked synthetic cases
The implementation fixtures use literal analytic values rather than values generated by the transform under test.
- Unit skeleton. A root and child both have unit scale, the child rest
translation is
(0, 1, 0), and the IBM is rigid. Reparameterization with an expected factor of one is a deterministic no-op. Requesting any other factor rejects as a source-fact mismatch. A whole-document conversion by0.01instead produces a0.01 mchild offset and mesh while leaving scale channels at one. - Compensated inherited scale. A root has scale
0.01; its child has local translation(0, 100, 0)and local scale one. Their composed child position is(0, 1, 0), every selected joint has effective scale0.01, and the corresponding IBM linear columns have magnitude 100. The reparameterized root and child have unit composed scale, the child local translation is(0, 1, 0), the IBM is rigid, and mesh positions and the child world position stay byte-for-byte or tolerance-equivalent as defined by the proof. A transform-only child at local offset(1, 0, 0)is rebased to(0.01, 0, 0), preserving its world origin while its composed linear scale changes from0.01to one. Transforming a further off-origin point through that child's complete affine distinguishes the result from a no-op. Checking onlyjoint_translation + joint_rotation * offsetis invalid because it drops the very scale channel the fixture must prove. Unskinned geometry in the affected closure is initially rejected rather than silently resized; supporting it would require a separate geometry and instancing rule. - Separate clips. A compatible clip declares the same named parent
topology, rest orientations, helper layout, and
0.01translation basis; its child translation value and cubic tangents are rebased by0.01before remapping. A name-identical clip with a unit parent basis, different parent rotation, different helper layout, or a mixed factor fails compatibility before any keys are copied. - Rejected affines. Independent fixtures introduce
(0.01, 0.02, 0.01)scale, a shear term, a negative determinant, a zero/near-zero determinant,NaN/infinity, and one joint with an effective factor different from the common factor. Each must produce a stable typed rejection, no artifact, and no partial evidence publication. Two kinds of near-valid noise are treated differently, by design. Values that reach the tolerance-bearing affine classification — a100.000015IBM column, or a rest scale axis skewed withinequal_axis— are accepted when their measured residual is inside the declared tolerance, and that residual is recorded. Structural facts checked at the §D.4 capability gate are compared exactly: a nodematrixwhose last row is not exactly(0, 0, 0, 1)is aNonAffineNodeMatrixrefusal, somatrix[15] = 1.0000001is rejected before any tolerance is consulted. The exactness is deliberate on three grounds: it matches glTF-Validator, whoseisTrsDecomposable(lib/src/utils.dart) tests those same four components with exact!=before it applies any tolerance and reportsNODE_MATRIX_NON_TRS; composing two affine matrices produces an exactly zero bottom row in floating point (0*x + 0*y + 0*z + 1*0), so no real composition pipeline emits noise there; and the §D.2 rewrite rules rely on it — the reparameterization's node-matrixrebase writes components3, 7, 11, 15back as authored, so a toleratedmatrix[3] = 1e-12would be published unconverted where the basis change owed it1 / s_i, and a toleratedmatrix[15] = 1.0000001would be rebased as though the node were affine. Relaxing it is a change to the §D.4 contract, not a tolerance widening; §D.1's ban on exact float equality binds the comparisons that tolerance policy governs — classification and proof — not this gate.
D.4 Modeled domains and preservation coverage
The transform plan owns an explicit capability manifest. A field absent from
Document is not evidence that the source lacks that field; preflight must
inspect the raw input or a loader-supplied complete inventory before mutation.
The plan compiles every modeled container into a typed field ledger. Each row
identifies the target and component, then states either a structural semantic
rewrite kind or PreserveExact; absence is not used to mean preservation. A
row does not carry a resolved factor, expected value, or connector product:
candidate construction and proof independently derive those numbers from the
operation and validated topology. PreserveExact is typed builder write-set
ownership: the core builder does not write that field. It is not, by itself, a
bit-equality rule for normalized derived state presented to prove_scale.
Container rows carry the counts and shapes needed to describe every element
without storing one row per vertex, key, tangent, or matrix component. Candidate
construction consumes this ledger, so each authoritative modeled production
write discharges one rewrite row and every rewrite row must be discharged.
Whole-document conversion retains one compatibility-only exception: raw locals
kept beside Unavailable source coverage are still converted best-effort, as
before, but that coverage explicitly makes their identities non-authoritative,
so they produce neither ledger rows nor proof/replay claims. Factor-one rows remain
explicit where identity, aliasing, and write-set coverage depend on them.
Separate typed payload-shape rows retain document, clip, track, instance, mesh,
and primitive identity and cardinality, including empty containers, without
freezing key times or other numeric values. Exact replay therefore detects
added, removed, reordered, or retargeted payload that happens not to add a
field row or obligation.
| Domain | Current model boundary | Unit conversion | Reparameterization |
|---|---|---|---|
| Rest hierarchy | Bone::rest; source-node TRS/matrix and parent topology form a canonical typed source table | Scale translations; conjugate retained matrices; preserve topology explicitly | Rebase affected normalized/projected locals; exactly preserve typed static-connector rows; bridge only the projected successor; preserve orientations and topology explicitly |
| Translation animation | Values and cubic tangent triplets are retained for normalized nodes | Multiply values and both tangents by q | Multiply by the affected parent-basis factor; infer no raw animation support for an unprojected connector |
| Rotation and scale animation | Retained as dimensionless values | Leave unchanged | Rotation unchanged; rebase each scale VEC3 by s_parent / s_i, including cubic tangents |
| Root motion and velocity | Derived from translation tracks | Convert tracks, then recompute | Preserve sampled trajectory and derived velocity |
| Base mesh geometry | Base POSITION and normals are retained | Scale positions; normals unchanged | Leave positions and normals unchanged |
| Morphs | Not retained by the normalized core; raw glTF ownership inventories target accessor identities and weight locations | Raw glTF only: scale dense f32 POSITION deltas once per unique accessor; preserve static JSON weights as numeric values and animated weight accessor payloads byte-exactly; reject other semantics and unsafe aliases/layouts | Reject: rest/bind morph preservation is not proven |
| Skin binds | Per-instance IBMs plus a lossy bone convenience value | Conjugate every per-skin matrix | Regenerate every per-skin matrix from output joints and unchanged G |
| Cameras/lights | Node transform only; typed fields are not modeled | Reject until all length fields have handlers | Reject when attached to the affected domain until preservation is proven |
| Collision/custom data | No semantic model for extras or extensions | Reject unless a registered handler covers every length | Reject when affected unless exact preservation is proven |
| Other vertex/source data | Several attributes, non-triangle modes, and extension payloads are not writer-preserved | Reject on the normalized-model route | Reject on the normalized-model route |
| Out-of-contract node transforms | A matrix beside a TRS member, or a matrix whose last row is not exactly (0, 0, 0, 1) — compared exactly at the gate, never within tolerance (§D.3 case 4) — parses but is not glTF 2.0 | Reject: which transform the author meant is unknowable, and U M U^-1 leaves 3, 7, 11 unconverted where the basis change owes them 1 / q, while a 15 other than one is invariant under the rewrite and so would be published still asserting a projective divide (§D.2) | Reject for the same reason, with 1 / s_i for 1 / q |
| Animation targeting a matrix node | glTF 2.0 requires an animated node to use TRS; a typed reader can otherwise decompose matrix and lose that source distinction | Reject at raw preflight | Reject at raw preflight |
| Shared raw accessor payloads | Every accessor use has a component-shape and typed field disposition; preserved uses remain explicit factor-one claims | Convert once per unique accessor: every converted use takes q | Reject same-index type/factor conflicts and overlapping distinct accessors when either range is rewritten (§D.2) |
| Unreferenced accessor payloads | Retained only in the raw source container, including dense bases and sparse index/value payloads | Preserve exactly; reject when any owned byte span overlaps a rewritten accessor | Preserve exactly; reject when any owned byte span overlaps a rewritten accessor |
| Image payload aliases | An image reads a bufferView directly and never becomes an accessor | Reject when its bytes overlap a rewritten accessor | Reject when its bytes overlap a rewritten accessor |
The narrow normalized FBX bridge applies the collision/custom row at the operation boundary rather than treating every parser-observed declaration as scale-bearing. User-defined properties are explicitly discarded before the private GLB is written, and parser-known texture/video linkage is represented by bounded resource facts and dependency-closure evidence rather than a transform field. The source-aware gate may therefore admit those two known classes while retaining their inventory/evidence rows; rooted capture keeps supported texture assignments in the normalized document and private GLB. The same proof boundary admits exactly ufbx's marker, LOD-group, stereo-camera, camera-switcher, and display-layer typed lists: those records supply no hierarchy transform, skin bind, animation track, or geometry to the normalized bridge. Display layers contain only node membership and editor visibility/freeze/color state. Shader and binding-table records are material-evaluation metadata and are admitted on the same basis. BindPose is different rest/bind-domain evidence: the same-load gate admits it only when it covers every joint of each skin it touches and every converted row is finite, unambiguous, and agrees under the fixed Appendix D scalar tolerance with the converted cluster bind or node rest-world matrix the bridge already consumes. A source without a Pose remains valid; incomplete, ambiguous, non-finite, or mismatching BindPoses fail closed as distinct residual kinds. All admitted rows remain counted in the raw unmodeled-element aggregate. Every residual unmodeled typed list fails closed, and its refusal retains the exact nonzero kind counts. This remains a normalized re-encoding rather than a raw FBX object-property, marker/camera/display-layer behavior, material, or texture-assignment preservation claim. Extensions, incomplete construct/resource coverage, and every unsupported transform, geometry, bind, or animation fact still reject. The inventory-only API remains conservative because its frozen v5 aggregate cannot distinguish these discharged classes from another unmodeled source element.
The connector exception is deliberately narrower than general helper-node support. The source projection can attest an unprojected row's identity, parent, and authored local rest, but not a raw animation channel targeting that row. A frontend that omits such a channel from the normalized model has not preserved it and cannot cite the static connector rule as evidence that it is safe to rewrite.
The current glTF writer rebuilds nodes as TRS, emits only modeled triangle
attributes, creates skin/holder structures, and does not preserve arbitrary
source JSON. The FBX loader mechanically inventories every current row above,
including the unverifiable unreferenced-accessor analogue, and exposes a
documented normalized ufbx source-node/source-skin sidecar when every declared
joint slot is representable. A missing cluster bone downgrades the generic
sidecar to unavailable, and one unreadable converted bind makes its whole
ordered declaration unreadable with no retained prefix. Complete coverage here
means every adjusted/compensated ufbx node and skin has stable identity and
projected values; it does not relabel those values as raw authored FBX
transform or payload evidence. The inventory also records
that it bakes takes, normalizes coordinate and inheritance semantics, generates
some missing data, truncates/renormalizes influences, counts rejected invalid
or unrepresentable influences, triangulates, welds, and cannot prove raw
payload spans. Consequently neither current
load-Document-write route qualifies as a
preservation-proof frontend for these operations without the raw capability
preflight and explicitly bounded writer work. Unknown extensions or extras,
unsupported morph semantics or unsafe morph storage, cameras, lights, collision metadata, non-triangle primitives,
unmodeled vertex attributes, secondary influence payloads, malformed or
missing inverse binds, node transforms outside the glTF 2.0 contract, and image
payloads sharing bytes with a rewritten accessor fail closed. The FBX
inventory is evidence of why the current frontend remains refused, not an
enablement signal. A later FBX producer may enable only an operation whose
complete inventory/write/proof boundary it satisfies; it must state that FBX
curves were baked, not preserved as authored curves.
Source validity is decided once, at the preflight, and not re-derived per operation. The preflight's byte-disjointness inspection therefore ranges every accessor, whether or not a mesh, skin, or animation sampler references it, plus every image view. An unreferenced accessor contributes its dense base when it has one and the packed spans of its sparse indices and values; those bytes are preserved source rather than expendable payload. A referenced sparse accessor is still refused outright before disjointness matters, and extension-defined consumers are unreachable while every extension is itself a refusal. Every unreferenced span may sit adjacent to or alias other preserved data, but any overlap with a rewritten accessor refuses with both owners located. Each operation keeps its own guard as defence in depth, since the guard is what must hold if the gate is ever relaxed, but it shares the gate's classifier rather than re-deriving one.
The core typed ledger is also the semantic authority for both raw glTF scale
writers. Before binding raw fields, the adapter replays the plan's complete
structural inventory against the current normalized document; numeric-only
replay remains valid, while a stale payload, field, topology, or obligation
inventory refuses. One numeric-free glTF binding then projects canonical
source topology and field dispositions onto raw node members, animation
outputs, mesh payloads, and skin slots. The binding independently walks
/nodes/*/children and refuses disagreement with canonical topology before
writing bytes. Writer and artifact proof share those validated identities and
component-group names, but independently select components and derive numeric
multipliers; neither consumes the other's expected values, matrices, factor
helpers, or domain model. The core ledger never replaces raw claims over
scenes, materials, default scene, references, unknown payload, aliasing, byte
ranges, or container bytes.
D.5 Separate-clip compatibility
Exact bone-name matching is necessary but insufficient. Before transforming or remapping a separately supplied clip, the producer compares a versioned skeleton-basis record containing:
- named parent topology and target node paths;
- finite local rest matrices, rest orientations, and helper-node layout;
- declared/normalized coordinate convention;
- effective uniform scale at every translation-track target;
- selected reparameterization domain and factor; and
- loader/tool identity plus the exact input-byte digest.
Compatibility requires topology and orientation agreement within their declared rules and the analytically expected translation-basis relationship. Different rest translation bases, parent scale, parent rotation, helper layout, or effective factor reject before remapping. A digest identifies the evidence record but does not replace tolerance-aware semantic comparison.
Character-assembly recipe/evidence v4 implements the base record and
comparator; v5 composes it with canonicalization, grounding, and node removal.
V6 admits the narrow normalized/baked FBX projection while leaving both older
contracts immutable. It captures the exact base and clip bytes once, performs
the format-specific capability preflight and plan before remapping, and rebases
each compatible clip's translation values and both CUBICSPLINE translation
tangents in the source basis. Evidence pins every input digest and basis
fingerprint together with the compatibility result; v6 additionally binds the
input container, projection kind, full FBX inventory, and private staged-GLB
identity. V1 through v3 remain immutable and v3 rejects the v4 block as unknown.
V7 generalizes the core compatibility basis around selector identity derived
for each accepted plan and source document.
Indexed mode retains v6's exact format-local indices, while named mode compares
the exact resolved root and ordered selected-skin joint names plus semantic
source/helper parent paths. Thus independently captured inputs may use
different source-array indices without weakening topology, rest, orientation,
or factor checks.
D.6 Proof, evidence, publication, and rollback
The candidate is serialized before it is proved, and the proof runs once, on the document reloaded from the exact artifact bytes — never on a separately built in-memory candidate, which would prove a document the rewriter did not emit. That single pass has two layers over the same reload: the normalized-document layer, which produces the residuals below, and the artifact layer, which re-derives directly from the container bytes every claim the normalized model cannot represent. Publication carries the proof to the file by digest, not by a second proof: the bytes are staged, read back, and refused unless their SHA-256 equals that of the proved bytes. A second proof would be redundant, the proof being deterministic over byte-identical input.
The artifact layer's claims are stated over raw source bytes — preservation of every raw JSON location and every buffer byte outside the rewritten set, array and index identity, honest reporting of the rewritten locations, container framing, bounds consistency, and single-step narrowing — and presuppose a rewriter that edits the source container in place. A frontend without one, which rebuilds the file from the normalized model as an FBX writer would, cannot make these claims and is not thereby exempt from the obligation they discharge: that declared-unaffected payloads are unchanged. Such a frontend owes that obligation by the other route — a complete loader-supplied inventory of every §D.4 domain — and may enable an operation only over the domains that inventory covers.
The exact raw-JSON-preservation refusal is diagnostic evidence as well as a refusal: the current scale-evidence v4 record carries up to the first 16 deterministic differences, each as a pointer and an added/removed/changed direction, plus the omitted remainder; the full count is the retained length plus that remainder. The field is null when the exact-preservation walk did not supply locations for a refusal; it is not an alternate spelling of the capability preflight's violations.
For the glTF producers, that raw complement explicitly includes scene
membership, default-scene selection, materials, and their references: neither
scale operation owns a write location in those payloads, so the artifact layer
proves their raw JSON values exactly unchanged. The normalized core proof does
not also deep-compare assets.scenes or assets.materials; those model values
cannot replace the raw fields and references the container proof owns. A
rebuilding frontend therefore cannot cite the core proof for those domains
and must discharge them through the complete inventory route above before
enabling an operation.
The proof evaluates rest, every key time, and analytic or sufficiently bounded interior times for cubic segments. For reparameterization it must prove, within versioned tolerances:
- equal world joint translations and orientations before/after;
- equal sampled animated trajectories, root motion, and derived velocities;
- equal skinned vertex positions and bounds for analytic one-joint and multi-joint fixtures plus deterministic sampled production evidence;
- unchanged mesh/material/skin identity and declared unaffected payloads —
including that each candidate mesh instance draws the same mesh, binds the
same skin joints, hangs off the same node, and names the same source node as
its source counterpart, none of which either operation rewrites, and that a
skin outside the affected closure keeps each slot's effective inverse bind.
Identity here covers placement, not only what an instance is: the node is
what positions an unskinned prop, and the source node is what resolves the
skin whose absent bind accessor stands for the format-defined identity
default. Both are compared positionally, so the instance list must also
agree in length and order. Candidate proof also compares normalized parent
topology and source-projection coverage exactly for every operation. When
coverage is
Complete, the raw-node parent/bone map is exact too; rows underUnavailablecoverage are non-authoritative, produce no raw topology or field rows, and are not compared. Neither operation rewrites any authoritative identity. For rest/bind, the compiled ledger includes anExactUnchangedWorldRestobligation for every bone in the exact unaffected complement and proof compares each complete composed world-rest affine exactly. The discriminating case is an independent sibling or leaf outside the closure's ancestry; an unaffected ancestor was already constrained transitively by an affected descendant's world-rest residual. This is an exact semantic placement invariant, not a tolerance or a claim that equal matrices prove every stored local field was retained; exact local ownership is a separate field-ledger obligation. Whole-document conversion has no unaffected complement, but its typed global topology obligation remains in force. Before either operation builds or proves, it recompiles the supplied source and requires exact equality of the operation variant, canonical source topology, payload and instance inventory, field dispositions, and derived obligation rows. Added, removed, reordered, or retargeted positional payloads (including stable-order source skins, attachments, tracks, meshes, instances, slots, or evidence) therefore make the plan stale even when the former aggregate boolean inventory would have been unchanged. Canonical source-node and connector topology is keyed by raw source identity, so merely reordering its backing rows is not a topology change. Numeric values may still change when that exact inventory and ledger remain equal; replay does not freeze the planned source snapshot. The shared input/candidate checks, source-skin structural rows, canonical source topology and rest/bind domain derivation, world-pose and inverse-bind readers, and one-time affected-skin classification live incrates/animsmith-core/src/scale/validation.rs. Operation selection, both planners, typed field/payload/obligation ledger compilation, clip-evidence declaration, and complete numeric-value-free replay live in privatecrates/animsmith-core/src/scale/planning.rs. Planning consumes the shared validation/topology boundary and remains the single owner of the compiled plan vocabulary; validation does not depend back on planning or reconstruct a second plan vocabulary. The opaque candidate wrapper, feature-gated analytic reference builder, and writer-owned direct/connector source-local rebase arithmetic live in privatecrates/animsmith-core/src/scale/reference.rs. That module consumes the compiled plan and shared validation/numeric leaves; proof derives its own connector products, expected source locals, and scale-track boundary rather than importing the reference writer's expectations. Those proof-owned derivations, exact field discharge, sampled work and budget accounting, semantic residuals, and skin/bounds checks live in privatecrates/animsmith-core/src/scale/proof.rs. Its paired residual recorder is nested underproof::residual, so only proof can mutate a residual maximum and its comparison count; the public read-only values continue to resolve through theanimsmith_core::scalefacade. For an admitted connector span, typed obligations require every connector local to remain bit-exact and independently derive the projected successor's expected bridged source local from canonical source topology. The connector owns no normalized residual, but a writer and proof cannot agree on a wrong connector rebase by sharing the writer's product calculation. Rewritten direct rewritten source-local components compare under theappendix-d-v6scalar tolerance, rather than bit equality: a raw adapter may apply the same factor before its onef32narrowing while the normalized source view was already narrowed. Preserved source-local components retain their exact-bit obligation. A connector-bridged projected successor also remains bit-exact: that core-only connector path does not cross a raw frontend narrowing boundary, and its exact connector-projection contract is unchanged. Operation-fixed rewrite and tolerance policy fields are not source inventory. Numeric affine/factor classification is not repeated there, so proof's normalized-skeleton witness remains independent of planning's raw-projection witness; a structurally stale plan still cannot omit a newly added bone, connector, or payload from every proof walk. The unaffected-bind comparison is over each slot's effective bind, in the resolution order the model defines: per-instance array, then the bone convenience value, then the format-defined identity only when a complete attached source skin declares its inverse-bind accessorAbsent. When both sides resolve, the effective matrices are compared even if their stored representations differ. Exactly one side resolving is missing proof evidence; neither side resolving is reported as out of the proof's scope, never as proven — a document carrying an unrelated skin with no bind evidence and no licensed identity default is not an operation this record touches, and must not be refused for lacking evidence about it; - unit composed scale for every affected node, measured per axis against the derived bound of §D.1; and
- the analytically expected full world affine of a transform-only attached child: preserved origin/orientation and unit linear scale, so a no-op cannot pass.
For whole-document conversion, the corresponding length facts must differ by
exactly the declared factor within tolerance while dimensionless facts remain
equal. The raw glTF artifact proof independently walks admitted morph
POSITION accessors, requires one conversion per unique accessor (including
base/morph and cross-target aliases), compares every static JSON morph-weight
numeric value, and compares every animated weight accessor byte exactly. Both
operations prove finite output, the skin equation, deterministic artifact
bytes, and deterministic evidence bytes.
Every obligation above is derived from the evidence it needs. The typed
ledger contains a row only when the validated planned inventory carries the
payload that row reads — a transform-only attachment in the closure for a
full-affine probe, an affected translation track for key-time checks, an
affected cubic segment for interior samples, any affected track for sampled
trajectories, a skinned instance touching the closure for one shared skinned
evidence group, and a non-empty affected closure for the inseparable
rest/unit-scale group. Skin-matrix and bounds results remain separately
reported projections of that one skinned group; neither can be independently
armed, and unit scale cannot exist without the affected-rest obligation.
A row whose evidence disappears from a replayed source changes the recompiled
ledger and yields typed PlanDocumentMismatch, never a zero residual. A
counterpart or value missing inside an inventory-matched walk is typed
MissingProofEvidence. The residuals are the evidence record, so a record
stating "residual 0.0" for a claim nothing checked is false rather than
missing. This matters most for the transform-only obligation, which is the
claim ensuring a no-op cannot pass — a guarantee an empty probe loop reporting
zero would not provide.
Per-element track values, base mesh positions, and inverse binds outside the
affected closure are ordinary typed obligations rather than three special
ungated loops. Their rows are derived for every corresponding planned
container. For normalized Bone::rest, inverse binds, tracks, and meshes,
proof discharges ownership through the existing versioned residual or exact
semantic obligations, independently deriving rewritten expectations from the
operation. A normalized candidate may therefore be non-bit-identical while
remaining inside the declared tolerance; field-row ownership neither replaces
nor tightens that accepted set. Rewritten and preserved track/mesh comparisons
retain their released maxima and counts. Core bit equality applies only to
authored SourceNodeRest locals that every admitted producer copies, including
preserved connector locals. Exact structural obligations such as topology,
connector projection, and unchanged world rest own no numeric residual. Raw
byte-exact preservation of other fields — including signed zero and quaternion
sign — remains the artifact proof's PR-B responsibility. No separately mutable
boolean controls any of these walks.
Proof cost is bounded, not merely expected to be small. Source and candidate
world matrices are derived once per sample time and shared across the
trajectory, skin, and bounds obligations, and the skin and bounds obligations
share one walk over the affected instances and their vertices. The remaining
work is sample_time_count * per_sample_work_units with the per-sample cost
spelled out in §D.1 — bones, skin slots, and skinned vertices, each charged
once per document side — which the tolerance policy of §D.1 caps. A document
above that cap is refused with a
typed error naming the policy identity, the sample count, the per-sample cost,
the computed work, and the budget. It is never proved against a truncated
sample set: a bounded proof of a subset would publish evidence for claims it
did not check. Because the budget lives in the policy identity, it is not a
per-run flag, and the producer's fixed-policy rule below covers it.
The glTF/GLB producer emits immutable scale-evidence v4 containing the operation kind, declared and observed factors, tolerance policy and residuals, affected node/skin identities, raw capability manifest, input/output byte digests and counts, proof sample coverage/results, tool identity, and rejection reason. It is written as an atomic artifact/evidence publication pair. Failure leaves no new output; an existing pair is restored. The original input is retained so rollback is selecting the prior artifact, not attempting an inverse float rewrite.
Narrow FBX rest/bind path. With the ufbx feature, scale rest-bind may
accept an .fbx input only when its complete inventory proves the normalized
rest/bind subset: adjusted metre/Y-up coordinates, compensated inheritance,
complete finite bind provenance, unchanged four-slot influences, and no
unsupported scale-bearing semantic D.4 row. Same-load source facts may prove
that user-defined properties, bounded texture/video declarations, or exact
marker, LOD-group, stereo-camera, camera-switcher, and display-layer typed
lists are known, non-scale-bearing inputs to this normalized bridge. Display
layers contain only node membership and editor visibility/freeze/color state.
Shader and binding-table metadata is equally non-bearing. BindPose rows are
admitted only after the same-load converted matrices reconcile with the
cluster/node matrices the bridge consumes; no Pose is required. Other unmodeled typed lists remain
fail-closed and are reported by exact nonzero kind counts. The same-load
boundary may also classify an exhaustive set of conversion-fidelity facts as
scale-invariant: omitted authored vertex/face/edge metadata, influence
truncation/rejection/renormalization with complete effective coverage,
triangulation, and exact-bit welding. The frozen inventory continues to report
those losses and its detached projection remains conservative. Missing
effective influences, omitted point/line or mesh definitions, unsupported
skinning/deformers, incomplete binds or normals, and unclassified payload stay
fail-closed. The same-load
FbxScaleSource retains the raw facts and dependency closure, while v5 keeps
the frozen inventory projection rather than adding either sidecar to its wire
shape. The three raw-span rows are deliberately
unverifiable because FBX is not rewritten. The frontend serializes the
normalized document to a private GLB, maps the selected root and ordered skin
joint topology by exact unique normalized names, rewrites that GLB with the
existing raw glTF writer, reloads and semantically proves the exact candidate
once, read-back-digest checks it, and atomically publishes the resulting GLB
with immutable scale-evidence v5. V5 records the complete FBX inventory and
the private staged-GLB identity plus the nested raw-GLB proof; it makes no raw
FBX span, object-property, authored-curve, material, or texture-assignment
preservation claim, nor a marker/camera/display-layer behavior claim; supported
linked texture bytes are nevertheless captured before staging so admission cannot
silently remove them. Refusals name the exact incomplete coverage domain,
semantic row, inventory counter, or residual unmodeled kind.
Two observed factors, and the divergence between them. For rest/bind
reparameterization, "observed factor" names two independent witnesses of the
same quantity, measured from deliberately different state: planning classifies
the raw source projection's node-local rests composed through the raw parent
chain, and proof reads the normalized skeleton's bone rests composed through
its own parent chain. For whole-document conversion, both fields are the
declared factor by definition and their divergence is exactly zero; that
operation does not classify scale from either hierarchy.
The raw composition includes every preserved static connector local. The
normalized witness reads the corresponding folded transform through the next
projected bone; the connector itself has no normalized identity and therefore
owns no residual obligation. After semantic residuals succeed, proof's typed
field-row discharge separately checks exact connector preservation and the
analytic bridged source local at each projected successor. This preserves the
released residual-error precedence while keeping both the connector's
preserved authored components and its bridged projected successor bit-exact.
Direct rewritten raw rows outside a connector bridge use the published scalar
tolerance at the frontend narrowing boundary described in the compiled-ledger
contract above.
Strict scale-input shape validation requires the two chains to agree — under
Complete source-skeleton coverage, which this operation requires anyway, a
projection that contradicts its own skeleton is refused before either witness
is taken — but nothing reconciles the two readings: each is still composed
from its own stored state, and that independence is the reason both witnesses
exist. The record therefore carries both, plus the relative divergence
|planned - proved| / max(|planned|, |proved|) between them explicitly, so a
consumer neither mistakes one witness for the other nor has to derive the
relationship from two separate policy fields.
The expected ceiling on that divergence is the sum of two bands the policy
already declares: the common-factor band plus the postcondition unit-scale
residual (1e-5 + 2^-14 = 7.103515625e-5 under appendix-d-v6). Planning
binds its witness to the declared factor within the first band or refuses;
and for a candidate this operation built from the source under proof, that
candidate's composed root scale is the proof witness divided by the declared
factor, so the unit-scale postcondition binds the proof witness to the
declared factor within the second. The two bands are not stated the same way,
and the sum is a ceiling only up to that difference: planning's is relative to
the max of its two operands, exactly as the divergence is, while the
postcondition's is an absolute L∞ deviation from 1 on the candidate's
composed scale — so it bounds |proved − declared| as a fraction of the
declared factor rather than as a fraction of max(planned, proved).
The divergence is reported, not enforced. The second step above holds for a candidate this operation built from the source it is proved against — which proof deliberately does not require — and it costs the binary32 rounding of the rebase on the way, so the sum is the ceiling the design guarantees rather than a bound proved to the last ulp; refusing at it would refuse documents whose two witnesses each honour their own band. A divergence beyond it is not evidence that the two parent chains disagree — those are validated to agree, above — but that the state each witness reads differs: most often the raw node-local rests against the normalized bone rests, two separately stored descriptions of one rest pose. How far apart those are is a fact about the input, not a residual this proof owns. Recording both witnesses and their divergence introduces no band of its own and no new policy identity.
Migration is opt-in. assemble.canonicalize_skin in recipe v3 remains the
existing unanimated bind-geometry operation with identity source-to-metre
conversion; it does not gain rest-scale rewriting, and old recipes are not
silently reinterpreted. Recipe/evidence v4 adds the optional, explicit
rest_bind_scale operation and basis-compatibility evidence. Consumers that invert scaled
inverse binds with the rigid-only shortcut -A^T t can observe a change by
s^2; that exposes a pre-existing consumer error because the shortcut is valid
only for an orthonormal linear part, not a geometry change made by this
operation.
D.7 CLI, configuration, and public API
The CLI uses a required operation subcommand, not one flag whose meaning depends on the input:
animsmith scale whole-document INPUT.glb -o OUTPUT.glb \
--factor 0.01 --evidence OUTPUT.scale.json
animsmith scale rest-bind INPUT.glb -o OUTPUT.glb \
--source-skin-index 0 --source-root-node-index 3 \
--expected-factor 0.01 --evidence OUTPUT.scale.json
All numeric and source-identity arguments shown are required; there is no
inferred factor, implicit first skin/root, or in-place mode. Artifact and
evidence paths must be distinct from each other and the input. The narrow FBX
path additionally refuses either destination when it names a retained safe
dependency key, and refuses publication entirely when that key was rejected as
symlink-mediated or the dependency closure stopped before enumerating every
key. Initial support is self-contained glTF/GLB for both
operations, plus the narrow inventory-complete FBX rest-bind path that emits
a .glb and v5 evidence.
The command uses the fixed tolerance-policy version recorded in evidence rather
than accepting per-run tolerance flags. A future policy change requires a new
policy identity and compatibility review.
The single-document producer has no animsmith.toml key and no separate plan
file: mutation must not become an incidental effect of a lint configuration.
Assembly recipe v7 exposes the same rest/bind operation through an optional
[rest_bind_scale] block whose exact root_node_name and factor are required.
The name rejects leading or trailing whitespace instead of normalizing it.
For every captured base and clip input, that name must resolve to exactly one
normalized source node and exactly one non-empty source skin whose every joint
is that node or its descendant. The resolved per-input source indices feed the
unchanged core operation and private staging bridge; no cross-file source-array
index is inferred or reused. It accepts glTF/GLB plus the narrow FBX inventory
admitted by the
standalone rest/bind boundary, validates every base and clip basis before
applying the operation or remapping keys, and records the declared name,
resolved per-input name/indices, and normalized/baked FBX projection rather
than claiming raw FBX curve or span preservation. Recipe v6 remains immutable
with its explicit source indices, recipe v5 remains glTF-only, and recipe v3
has no such block and continues to reject it as unknown.
The public animsmith-core shape is a non-exhaustive ScaleOperation with
distinct WholeDocumentLinearUnits { factor } and
RestBindUniformScale { source_skin_index, source_root_node_index, expected_factor } variants, carried by a ScaleRequest. Pure planning returns
either an opaque, non-exhaustive ScalePlan (operation variant, affected
closure, canonical topology, typed field rows, tolerance policy, and derived
obligations) or a typed ScaleError; proof returns a
ScaleProof with one read-only ScaleProofResidual per semantic claim. Each
value carries the maximum and number of comparisons together, so a residual
nothing walked is distinguishable from a measured zero (issue #319) and an
adapter cannot combine one claim's count with another claim's maximum (issue
#323).
Planning and proof take format-neutral node, track, bind, and capability facts.
They do not accept paths, glTF/ufbx types, config parsers, or publication
policy. The format frontend owns raw inventory and exact source rewriting; the
CLI owns atomic artifact/evidence publication. Applying a plan builds a
candidate and never mutates the caller's source on failure.
animsmith-core::scale implements this contract (issue #281): the
ScaleOperation/ScaleRequest planning entry point, the typed ScalePlan
and ScaleError, the no-authority candidate handoff, and paired ScaleProof
residual values. Any change to the operation variants, required selectors, or failure
boundary is a design change rather than an implementation detail.
ScalePlan exposes its compiled ledger only through non-exhaustive read-only
field, payload-shape, topology, and obligation row views; callers cannot
use those row values to construct or mutate a plan with an incoherent
operation, topology, field inventory, or obligation set. Plan
equality includes that exact structural ledger. The former public
ScaleDomainRewrites and ScaleProofObligations boolean bags and their plan
accessors are removed in this pre-1.0 API break: operation-fixed facts and
evidence gates have one typed authority rather than independently selectable
booleans. The CLI privately projects the same five domain_rewrites booleans
from the operation variant's frozen mapping, so payload presence and
factor-one rows cannot change their meaning. Immutable scale-evidence
v1/v2/v3, ScaleProof serialization, appendix-d-v6, tolerances, calibration
semantics, and policy pins do not change. The internal ledger is not serialized
into evidence.
ScalePlan and ScaleProof each carry the observed factor beside the
declared one, as §D.6's evidence contract requires (issue #290). The declared
factor is what the build applies and what every proof expectation is stated
in; the observed factor is the rest-world uniform factor measured at the
scaled root, which the declared-factor band admits without requiring equality.
Proof re-measures it from the source it is handed rather than reading it off
the plan, so evidence does not depend on planning having recorded it. It is
reported, not checked: the input band is planning's obligation and the
postcondition derived from it in §D.1 is proof's. For a whole-document
conversion the observed factor is the declared one, because §D.1 gives that
operation's factor no measurable source counterpart at all.
animsmith-gltf owns the glTF/GLB frontend of that contract:
preflight_scale_source (issue #280) captures the raw inventory,
capability_facts / rewrite_linear_units / prove_rewritten_artifact
(issue #282) implement the whole-document conversion of §D.2 directly on the
source's own JSON and buffer bytes, and rewrite_rest_bind /
prove_rewritten_rest_bind (issue #283) implement the rest/bind
reparameterization the same way. Because that route produces a candidate
core did not build, ScaleCandidate::from_document exists so the reloaded
artifact can reach prove_scale; the type asserts nothing that prove_scale
does not independently re-derive. Core exposes no production candidate
builder: its analytic reference constructor is available only through the
non-default fixtures feature for tests and calibration.
rewrite_rest_bind is the selector-taking convenience boundary; it compiles a
plan and delegates to the same plan-taking raw adapter used by
rewrite_scale_plan. The latter validates the plan against the supplied
source before any write, so the CLI can carry one authoritative compiled plan
through rewrite and proof without admitting stale-plan replay.
GltfScaleArtifact reports which operation produced it
beside the declared factor, because the two operations rewrite different
domains and a factor alone does not distinguish them. Beyond the shared
plan/proof rejections, the frontend owns three refusals the format-neutral
layer cannot state: the disagreeing-multiplier refusal of §D.2, and two
agreement checks over the raw node hierarchy, the loader's source-node
projection, and the normalized skeleton's parent links. animsmith-core
requires the last two of those three to describe the same tree — that is the
document-shape validation of §D.6 — but the raw /nodes/*/children arrays
live in JSON that never becomes a Document field, so bringing the third
description into the comparison is only possible here.
D.8 Implementation ownership and deferred integrations
Putting all semantics in assembly was rejected: it would hide a reusable
single-document operation and preserve assembly's currently insufficient
separate-clip proof. A standalone command with unrelated duplicate math was
also rejected. The chosen end state is one shared core transform-plan and proof
layer with distinct, explicit frontends: the shipped single-document producer
and character-assembly recipe/evidence v7 integration. The existing
canonicalize_skinned_bind_pose remains the narrower unanimated bind-geometry
foundation; it is not silently widened or renamed into this contract.
Shared affine classification, distinct acceptance contracts. The core uses
one typed, symmetric f64 positive-uniform Mat3 classifier for both Appendix
D and skinned bind-pose canonicalization. Appendix D retains
appendix-d-v6's 1e-5 equal-axis/orthogonality and 1e-6
relative-determinant thresholds. The canonicalization input
source_to_meters_y_up retains its numeric 1e-4 equal-axis/orthogonality
thresholds and exact-zero determinant threshold; these are compatibility with
the existing assembly/canonicalization contract, not a second scale-policy
version.
Canonicalization does deliberately adopt the shared classifier's semantics:
axis equality is relative to the three-axis average and longer operand rather
than to the first column, derived arithmetic is f64, and singularity is
classified before non-uniformity or shear. Thus a unit basis with one axis at
1.00015 is accepted while 1.00016 is refused, and an input that is both
exactly singular and sheared now uses the existing
reflection_or_singular reason rather than non_uniform_or_sheared. The
machine-readable vocabulary is unchanged; these symmetric boundaries and
precedence replace two independent algorithms with one explicit typed policy.
The classifier runs after canonicalization's separate existing
zero-translation/fourth-column w_axis ~= Vec4::W gate and its first-axis
<= 1e-4 zero_scale reason. That zero-scale gate reads the same widened
f64 axis length as the classifier, so a finite large basis cannot overflow a
binary32 length intermediate and be mislabeled as zero scale.
Near the shear boundary, the symmetric average base and binary64 dot products
can also refuse inputs that the former first-column/binary32 check accepted.
Measurement shares facts, not the positive-only verdict. Measurements v12
derives axis lengths, their canonical ascending-order mean, determinant,
axis-length product, and XY/XZ/YZ dot products from the same crate-private
widened f64 fact record. It also uses the shared symmetric
mean/longer-operand equal-axis predicate, which removes v11's privileged-X
result and reconciles the finite
diag(1, 1, 1.000012) fixture. Measurement still asks a different question:
it keeps finite partial evidence for singular, reflected, sheared, and
non-uniform inputs; normalizes each orthogonality comparison by that axis
pair's length product; and classifies in singular, reflected, sheared,
unit/uniform, then non-uniform precedence. Calling the positive-uniform
classifier directly would discard those descriptive facts and impose the
operation's different precedence. Skin-bind summary factors are sorted before
their mean is derived and every factor is compared to that mean, so authored
joint order cannot choose consistent_uniform versus mixed_uniform or the
reported common factor. These corrected numeric semantics require the nested
measurements-v12 identity. The JSON shape remains unchanged, but the outer
identity advances to output-v6 because each immutable output schema statically
pins its nested measurement URN; measurements v11 and output-v5 remain
immutable historical evidence. In general, changing a nested identity that an
immutable outer schema references requires a new outer identity too; historical
outer schemas are never retargeted or broadened in place.
Measurements v13 makes inverse-bind derivation auditable and fail-closed. Each
per-joint bind observation retains the exact finite raw inverse-bind matrix for
its accessor slot. Before publishing an inverse, the source bottom row must be
within absolute 1e-6 of affine [0, 0, 0, 1]; this admits several binary32
round trips around one but refuses projective translation columns. The linear
3x3 reports the scale-free reciprocal infinity-norm condition number
1 / (norm_inf(A) * norm_inf(inverse(A))). Exactly singular sources and
finite sources at or below 1e-6 use distinct non_invertible and
ill_conditioned unavailable reasons. This avoids the prior scale-bearing,
exact-zero determinant decision: a uniformly small but well-conditioned matrix
is assessed by its error amplification rather than its determinant magnitude.
The new nested shape advances the immutable outer envelope to output v7;
measurements v12 and output v6 remain historical.
Measurements v14 gives every clip fact that is not applicable to every clip —
loop continuity, loop endpoint mode, frame grid, loop seam ratio, gait (and
its own phase), and root-motion speed — a required sibling
<field>_availability status of measured, not_applicable, or
unavailable. A bare optional value cannot distinguish "this clip has no
subject for the fact" (not_applicable, a legitimate absence) from "the
subject applies, but derivation failed" (unavailable, which a consumer must
fail closed on rather than silently treat as passing); those two absences now
compare distinctly under diff as well. The value field is present if and
only if its status is measured. The new nested shape advances the immutable
outer envelope to output v8; measurements v13 and output v7 remain
historical.
Measurements v15 inventories each clip's surviving non-empty local TRS channel set by skeleton index and adds normalized model-space root-trajectory regression evidence. A resolved Root always wins; Hips is a typed fallback only when Root is unresolved. The record retains independent translation and yaw availability, signed endpoint displacement, sampled horizontal travel and vertical extrema, and fixed-witness net/unwrapped/travel yaw. All trajectory path/extrema/winding values come from the shared inclusive uniform metric grid and therefore do not prove continuous-curve extrema or authored transform preservation. The new nested shape advances the immutable outer envelope to output v9; measurements v14 and output v8 remain historical.
The implementation status is:
- shipped: raw glTF/GLB capability preflight, the shared core plan/ledger and proof, preservation-safe whole-document and rest/bind exact-source writers, artifact proof, and atomic CLI publication with scale-evidence v4;
- shipped: issue #286's conservative FBX rest/bind re-encode path with the full ufbx inventory, exact staged-GLB rewrite/reload proof, v5 evidence, and atomic GLB/evidence publication; whole-document FBX scaling remains refused;
- shipped: glTF/GLB and inventory-complete normalized/baked FBX assembly integration in recipe/evidence v6, including the versioned basis fingerprint/comparator, pre-remap clip rebasing, exact input digests, compatibility evidence, and proof over the exact staged artifact;
- shipped: recipe/evidence v7 exact-name rest/bind selection, with a unique source-node and fully-governed-skin resolution for every captured input and explicit declared/resolved selector evidence (issue #459);
- shipped: issue #286-A's explicit conservative ufbx-side status for every current D.4 domain and documented normalized source-skeleton projection;
- shipped: raw glTF whole-document scaling for dense
f32morphPOSITIONdeltas with numeric-value preservation for static JSON weights and byte-exact preservation for animated weight accessors (issue #298); rest/bind morphs and a normalized core morph model remain out of scope.
The live implementation sequence is owned by tracker issue #344 and roadmap issue #165, not by a numbered future-slice list in this decision record. Work beyond this positive-uniform, fail-closed decision remains ADR-gated.
Appendix E — decision record: engine profiles
This appendix is the accepted design outcome of issue #150. It defines the consumer-model boundary required by the 0.4.0 engine-feedback milestone; it does not claim that the configuration, checks, or generators described here already ship.
E.1 Decision and ownership
An engine profile is a versioned set of externally verified importer or runtime facts. It answers how a named consumer handles a supported input. It is not any of the following:
- a rig profile, which resolves semantic roles to bones;
- project intent, such as whether a clip should be in-place, looping, or animation-driven root motion;
- a severity or artistic-quality preset;
- evidence that a particular engine import actually ran; or
- authority to rewrite an asset.
Those distinctions are structural. [rig] continues to own role resolution,
[runtime_nodes] owns the shared selection of project-declared attachment,
socket, IK, collision, and other runtime-facing nodes, [clips] owns per-clip
intent plus explicitly nested clip-scoped importer choices, [checks] owns
activation, severity, and project tolerances, engine readback owns observed
import results, and Appendix D plus each transform's own proof contract owns
rewriting. An engine profile may describe a hard importer constraint or
interpretation, but it must not silently change the math, thresholds, or
findings of an existing consumer-neutral check.
The issue's original suggestion that profiles own loop/contact thresholds is therefore rejected. An engine may expose a loop toggle or a compression setting; it does not know a project's acceptable seam, contact, or slide error. Generated guidance may propose a documented starting value, but the effective value remains explicit project policy.
E.2 Selection and built-ins
The first configuration surface is deliberately small:
[engine]
profile = "unity-generic"
profile_revision = 2
engine_version = "6000.3"
importer = "fbx-model-importer"
[engine.settings]
animation_type = "generic"
avatar_setup = "create_from_this_model"
import_animation = true
root_motion_source = "Reference/Root"
[clips."locomotion_*"]
movement_owner_xz = "animation"
movement_owner_y = "gameplay"
movement_owner_yaw = "animation"
[clips."locomotion_*".engine_settings]
root_rotation = "extract"
root_position_y = "bake"
root_position_xz = "extract"
[clips.idle]
movement_owner_xz = "gameplay"
movement_owner_y = "gameplay"
movement_owner_yaw = "gameplay"
[clips.idle.engine_settings]
root_rotation = "bake"
root_position_y = "bake"
root_position_xz = "bake"
[runtime_nodes]
selectors = ["weapon_socket", "ik_*", "collision_anchor"]
[runtime_nodes].selectors is project policy, not an importer fact. Exact
names and * globs resolve with the deterministic miss and ambiguity behavior
already established by issue #268. The existing
[checks.rest-world-scale].node_selectors field remains accepted as a
compatibility alias and is normalized into this shared selection before
evaluation. Declaring both forms is a typed configuration error rather than a
precedence rule. rest-world-scale and the selected-node facet of
engine-unit-scale consume the same resolved set; one check never reads the
other check's private settings. An absent selector field and an explicit empty
selector list both mean no declared policy, preserving the legacy check's
behavior. Only that state makes the selected-node engine facet genuinely not
applicable. If a
declared selector misses or is ambiguous, rest-world-scale retains its
existing engine-neutral coverage behavior, while an enabled, otherwise
applicable engine-unit-scale facet emits required_prediction_unavailable
and follows E.4's exit policy. Component/root and mesh-node unit-mapping rules
may still apply independently.
An absent [engine] section means today's engine-neutral behavior. There is no
generic engine profile, no auto-detection, no nearest-version match, and no
fallback profile. Family, profile revision, engine version, and importer route
are all required when [engine] is present. An unknown or unsupported tuple,
unknown setting, or setting that is invalid for that importer is a typed
configuration error. A caller that still wants ordinary engine-neutral linting
removes [engine]; it does not receive a partial prediction under a different
consumer contract.
Every profile setting descriptor declares its exact value domain, applicability,
and either document or clip scope. Document-scoped importer choices live only
under [engine.settings]. Clip-scoped importer choices live only under the
matching [clips.<selector>.engine_settings] table and resolve through the
existing clip-selector rule (exact name over matching globs, later matching
globs winning ties). For the Unity Generic FBX profile, the Motion/Root Motion
Node choice is an exact source-transform path that applies to all imported
clips and is therefore document-scoped as root_motion_source. It is
explicitly not applicable to the Unity Humanoid profile, whose importer uses
Avatar/body semantics. Rotation and position bake/extract choices remain
clip-scoped for both Unity profiles. [clips.<selector>] movement-owner fields remain project intent;
the sibling engine_settings table records the importer state that a prediction
must compare with that intent. A setting at the wrong scope is a typed
configuration error, not an override.
Each applicable V1 setting is classified as required without a verified default; values in the example are not defaults. Adding a documented default requires a later profile revision and settings-contract revision rather than a silent registry edit. Omitting a required setting is a typed configuration error; it is never guessed, treated as an implementation default, or deferred to a coverage gap. The resolved settings record therefore contains one exact document value or per-clip value for every applicable setting.
The registry contains the preserved revision-1 tuples plus the current revision-2 Unity Generic, Godot, Unreal, and Bevy tuples and the revision-3 Bevy tuple:
| family | revision | engine version | importer | AnimSmith input boundary |
|---|---|---|---|---|
unity-generic | 2 | 6000.3 | fbx-model-importer | FBX |
unity-humanoid | 1 | 6000.3 | fbx-model-importer | FBX |
unreal | 1 | 5.8 | fbx-importer | FBX |
unreal | 2 | 5.8 | fbx-importer | FBX |
godot | 1 | 4.7 | resource-importer-scene | glTF JSON, GLB, FBX |
godot | 2 | 4.7 | resource-importer-scene | glTF JSON, GLB |
bevy | 1 | 0.19.0 | gltf-asset-loader | glTF JSON, GLB |
bevy | 2 | 0.19.0 | gltf-asset-loader | glTF JSON, GLB |
bevy | 3 | 0.19.0 | gltf-asset-loader | glTF JSON, GLB |
These are exact versions, not ranges. The Godot row is AnimSmith's bounded V1
profile boundary rather than a claim that Godot cannot import other formats.
The preserved Unity revision-1 advice profiles expose document booleans
convert_units and bake_axis_conversion, plus per-clip root_rotation,
root_position_y, and root_position_xz values in the closed bake | extract
domain. Current Unity Generic revision 2 instead exposes the frozen document
values animation_type = "generic", avatar_setup = "create_from_this_model",
import_animation = true, and the exact document-scoped
root_motion_source, plus those three per-clip root controls. Only Generic
also exposes root_motion_source; it is not applicable to Humanoid. Unreal,
Godot, and Bevy revision 1 expose no V1 setting vocabulary, so every supplied
key is rejected. Bevy revision 2 has a separate closed setting vocabulary for exact
loader coordinate toggles, mesh loading, extension-handler environment,
animation feature, and animation loading. It materializes version-pinned
defaults and retains each value's explicit/default origin.
Bevy revision 3 preserves the earlier tuples and adds only the narrow
animation/channel gate-support fact. It resolves the compiled
bevy_animation feature and load_animations with explicit/default origins;
the compiled feature gate takes precedence. Its raw same-load inventory binds
animation coverage independently from each animation's channel coverage.
Extensions, other animation constructs, and positive runtime survival remain
outside this revision; the latter requires an actual Bevy readback.
The separately versioned bevy-gltf-addressability-rules:1 bundle paired with
this immutable profile owns the richer scene, skin, named-map, and target-path
projection; it does not mutate profile revisions 1--3.
The two Unity ids are import modes, not skeleton-name maps. Likewise,
unreal, godot, and bevy do not imply a mannequin, humanoid bone map, or
other rig profile. glTF is not an engine profile: its specification defines
format semantics but deliberately does not define looping, playback, import
options, or other runtime policy. glTF 2.0 compatibility remains a loader and
consumer-neutral check boundary. Bevy has separate supported-extension,
asset-addressability, and animation-target behavior.
Each built-in resolves its exact (family, profile_revision, engine_version, importer) tuple to an immutable fact-bundle identity such as
urn:animsmith:engine-profile:unity-generic:2. Its data record contains exact
engine version and importer values, accepted input formats, source
and target coordinate bases, importer/runtime facts, allowed setting
vocabulary, and primary-source references with a verification date. For every
claimed input format it must also enumerate, rather than leave implicit:
- handedness, up/forward axes, and the selected importer's axis-conversion mapping;
- each applicable clip-boundary predicate (including whole-frame constraints) or an explicit statement that none is known; and
- supported, dropped, and reinterpreted channels, extensions, and animation constructs, with an explicit unsupported or unknown state where evidence is incomplete.
Changing any such fact requires a new profile revision and normal release
notes; a historical result never silently resolves through the current
revision. [engine.settings] selects only choices the immutable importer facts
already expose; it cannot override those facts.
Facts use explicit known, unknown, and genuinely not-applicable states. The
accepted-format fact is the sole input-format authority. The facts digest is
the core InputIdentity of a versioned, fixed-field-order, length-prefixed
UTF-8 encoding of the full tuple, URN, every fact state and value (including
accepted formats), every setting descriptor/domain/scope/applicability and
default status, verification metadata, and source references sorted by stable
id. Source references bind stable fact/setting ids to one target version, URL,
and verification date; live documentation bytes are never digest input. The
settings digest uses the same encoding over the full resolved identity and
fully materialized document settings plus actual clip names/settings in lexical
order, never TOML spelling, selector patterns, or map insertion order.
Version one does not accept arbitrary engine-fact overrides in TOML. A user cannot safely make an importer support a channel or reinterpret root motion by assertion. Embedders may supply namespaced custom checks and keep their own consumer contracts, as they do today, without extending the built-in profile registry.
Crate ownership is fixed before implementation. A new format-neutral
animsmith-engine library owns the built-in fact registry, strict tuple
resolution, prediction-rule adapters, and the standalone advisory/readback
payloads. It depends on animsmith-core, but not on format libraries, TOML, a
filesystem, or engine SDKs. animsmith-core continues to own consumer-neutral
documents, measurements, checks, shared source-evidence value types, every
measure/lint envelope and URN, and a registry-independent wire projection for
profile identity, rule basis, prediction state, and shared provenance. The
engine library populates that core-owned projection; core never depends on the
engine registry or its concrete profile types. The glTF and FBX libraries
populate the common raw-source projection while retaining their format-specific
parsing details. The animsmith CLI owns strict TOML mapping and orchestration;
animsmith-report renders the resulting public contracts. This preserves
Appendix C's envelope ownership, prevents a core/engine dependency cycle, and
gives embedders the same registry and resolver as the CLI.
Format loaders bind that projection to the normalized document in an opaque,
immutable LoadedSource. Its consumer surface is deliberately limited to
document(&self), source_facts(&self), dependency_closure(&self), and
into_document(self): callers cannot mutate a document while continuing to
present its source facts or captured closure as current, and consuming the
document explicitly discards both sidecars. The facts view borrows the
canonical Document.assets.source_skeleton projection rather than copying
source-node or skin authority. Existing document-only loader entry points
remain compatibility conveniences that consume this wrapper.
The V1 projection is bound to the exact primary-file InputIdentity and an
explicit source format established by the loader; SourceInfo.path and its
free-form format label are neither identity nor fact authority. Row sets carry
independent complete, partial, or unavailable coverage. Rows retained
under partial coverage prove positive presence only; absence is established
only by complete coverage. Observation availability, provenance, and AnimSmith
loader disposition remain orthogonal and never imply target-engine support.
V1 bounds projection work to 65,536 enumerable rows, 4,096 clips/takes, 4,096
resource-reference declarations, 4,096 UTF-8 bytes per retained source string,
8 MiB of retained source strings in total, and traversal depth 128. At N+1 the
loader keeps the deterministic source-order prefix (lexical order only where a
parser exposes a map without source order), marks the affected domain partial
with projection_budget_exceeded, and stops new work in that domain. A legacy
load that succeeds does not become a failure merely because this sidecar is
partial. These are projection limits, not claims that every existing parser,
resource read, image payload, or bake allocation is globally bounded.
Raw resource facts stop at bounded declaration identity, kind, and a safely
retained source-relative spelling. They do not themselves open, normalize,
deduplicate, hash, or map referenced content. Unsafe absolute, remote,
data-payload, or escaping spellings are classified without being reproduced in
the new facts surface, its debug/serialization views, or its errors. Legacy
diagnostic Document::source.path remains outside that projection.
The separate dependency-closure v1 sidecar is exact only over that versioned
declaration domain. Format loaders construct it during the same load that
consumes the resource bytes; a post-load reopen cannot establish the identity
of bytes already parsed. Embedded/data/BIN/view-backed declarations map to the
primary identity. Safe relative declarations map through a normalized
source-relative key to one captured InputIdentity; aliases of one key are
opened and hashed once, while different keys remain distinct even when their
bytes match. The source-order reference map and key-sorted distinct-resource
table form a domain-separated canonical closure identity. Only complete raw
declaration coverage with an identity for every declaration produces a
complete closure identity. Unsupported extensions or unmodelled FBX
resource-bearing domains keep the result conservative rather than turning a
known subset into a whole-document or target-importer claim.
Each reference also records a purpose derived from its source kind: loader-essential buffers, nonessential images/textures, or target-only video/cache declarations. This is source/loader evidence, not a prediction of whether a selected engine profile will import the resource.
Closure capture has its own immutable v1 budget: at most 4,096 declarations, 1,024 distinct external keys, 4,096 UTF-8 bytes for each source spelling and normalized key, 128 path components per spelling, 8 MiB of aggregate normalization input, 64 MiB read and hashed per external resource, 256 MiB in aggregate, and one bounded non-quadratic deduplication probe per declaration. The loader checks N+1 before allocation or opening, retains the deterministic prefix and work counters, and stops the affected capture. Absolute, escaping, out-of-root, remote, malformed, oversized, and symlink-mediated locators are typed refusals and are never opened or reproduced. A trusted resource root comes from path-based loading or an explicit byte-loader API; byte loading never falls back to the process working directory. Missing loader-essential bytes remain a load/operator error, while unavailable nonessential or target-only bytes may leave typed partial closure. No captured resource is recursively parsed for more declarations. The limits bound this closure walk; they do not certify the primary parser or a concurrently mutating trusted filesystem.
E.3 Rules, checks, and precedence
Engine rules consume engine-neutral measurements plus a dedicated read-only
raw-source-facts projection; they do not recompute those facts differently per
engine. The normalized Document alone is insufficient because importer-
sensitive extensions, authored FBX curves and units, take boundaries, and
other declarations may already have been normalized or discarded. The raw
projection preserves such facts and their availability without exposing a
scale operation's private capability ledger as the general engine API.
That projection is the one shared provenance-and-coverage model for raw source evidence, not a parallel per-feature copy. Loaders reuse its identity, availability, and provenance primitives wherever scale planning needs the same source observation. A scale capability inventory and proof ledger remain operation-specific derived consumers because they additionally encode a requested rewrite's closure and proof obligations; engine rules do not create a third representation of the underlying source fact.
The format-neutral core also owns urn:animsmith:exact-source-timing:1, a
bounded same-load sidecar attached to a loaded source. It records generic exact
time units, frame periods, selected clip ranges, and typed availability without
format names, parser types, or format constants. A format adapter performs its
own projection into that vocabulary: the FBX adapter alone owns KTime constants,
integer period arithmetic, and ufbx resolved-property selection. Engine rules
consume the generic sidecar through RawSourceFactsV2 and prediction provenance
v3; neither the V2 outer identity nor the historical readers are widened.
A rule declares all of its preconditions: the full resolved profile identity,
input format and raw-source coverage, required measurement fields, rig roles
where applicable, and every explicit project intent or importer setting. A
required engine-prediction facet with missing evidence records scoped
required_prediction_unavailable rather than a passing finding, guessed
value, or generic engine-neutral coverage gap.
New engine prediction checks are grouped by stable concern rather than copied once per engine. The initial families are:
engine-clip-boundaryfor ranges, whole-frame requirements, and slicing;engine-root-motionfor importer extraction/bake interpretation;engine-track-supportfor channels, extensions, and data an importer drops or reinterprets;engine-unit-scalefor explicit unit conversion and resulting transform scale; andengine-addressabilityfor scenes, animations, targets, and runtime labels.
The current engine-root-motion slice is the exact Unity Generic revision-2 /
6000.3 / fbx-model-importer FBX tuple. Its V6 provenance binds the V5
same-load source evidence to a bounded raw transform-path inventory and the
normalized per-clip movement-owner intent. The rule is applicable only when
that exact profile is resolved and at least one axis is explicitly declared.
For each declared horizontal_xz, vertical_y, or yaw axis, it compares
movement_owner_xz, movement_owner_y, or movement_owner_yaw with
root_position_xz, root_position_y, or root_rotation. bake is
baked_into_pose; extract is stored_as_root_motion. Gameplay ownership
matches baking, animation ownership matches extraction, and a mismatch emits
an ordinary error finding bound to the available prediction facet.
The rule has one explicit lifecycle: all available axis facets are complete,
a mixture of available and required-unavailable facets is partial, and all
required-unavailable work is not_evaluated. Missing or ambiguous path
resolution, a path that does not identify the explicitly resolved Root role,
incomplete raw-path or project-intent coverage, settings overflow, duplicate
clip names, or unavailable axis-specific translation/yaw evidence emits
required_prediction_unavailable; this state is not a content finding and
cannot be suppressed by --allow. The rule never falls back to Hips, even
though the consumer-neutral root-trajectory measurement still uses Hips when
Root is unresolved. Measurement availability is required, but numeric
translation/yaw magnitude is not a threshold and does not affect routing.
Generic's root_motion_source is a closed, case-sensitive, byte-exact relative
path. / is the only separator and has no escape syntax. Empty segments,
leading/trailing or doubled separators, ./.., backslashes, Unicode control
or format characters are rejected. The bounds are 1,024 UTF-8 bytes per
segment, 4,096 bytes per path, and 256 segments. The FBX adapter projects
source identities, names, and parent chains from the same input bytes with a
raw-preserving ufbx load. The implicit ufbx root and generated geometry/scale
helpers remain rows but cannot match. Complete inventory coverage is required
to prove NoMatch; partial or unavailable coverage yields CoverageIncomplete.
This is source evidence only: AnimSmith does not execute Unity, read back an
imported asset, play runtime clips, or certify engine behavior. Repository and
CI fixtures for this slice are self-authored synthetic FBX files only.
The selected profile chooses applicable rules within those check ids. The
file-scoped provenance retains the full resolved profile and source identity;
each check retains prediction facets keyed by the existing evaluation scope,
with the exact fact, setting, policy, and measurement basis for clean and
finding-bearing work alike. Findings bind to an available facet rather than
introducing another applied-rule id. Checks do not use unity-*, unreal-*,
and similar duplicate families.
The bounded 0.4.0 production slice is the exact Bevy revision 1 / 0.19.0 /
gltf-asset-loader animation-index selector rule. With complete nonempty glTF
or GLB source-animation inventory, engine-addressability emits one available
facet per source row whose subject is Bevy's Animation{source_clip_index}
display label. A source name is only metadata: absent and duplicate names do
not change the index selector. Partial or unavailable inventory emits one
required-unavailable inventory facet and no retained-prefix predictions, so
the 4,096-row raw-source and file-facet caps compose without an N+1 facet.
The resolved-settings/provenance v1 contract separately caps actual clip rows
at 4,096. A 4,097-clip document is therefore a bounded operator error before
prediction, not a silently truncated settings inventory; #485 owns any future
overflow evidence and shared multi-rule facet-budget policy.
This predicts the canonical selector convention only. It does not claim a
successful Bevy load, runtime asset existence, animation-target survival, or
graph wiring, and the index is not stable across source-order edits. Richer
scene/name/target addressability remains a separate inventory/adapter concern.
The current additional production slice is the exact Unreal revision 1 / 5.8 /
fbx-importer whole-end-frame rule. For every retained FBX animation stack,
the FBX adapter projects ufbx's selected complete range pair and exact KTime
period into the generic exact-source-timing sidecar. engine-clip-boundary
tests only whether the absolute selected end coordinate is on that lattice.
Incomplete inventory and unavailable declarations or ranges yield scoped
required-unavailable facets; decimal FPS, inferred frame numbers, import-range
selection, resampling, compression, root motion, and all other importer policy
remain out of scope.
The corresponding 0.4.0 standalone producer is
generate addressability. Its V1 root is separate from output-v11 and contains
one engine-neutral glTF/GLB animation inventory plus a nullable exact-Bevy
adapter. animsmith-engine owns this format/profile-specific envelope and its
strict reader; core remains limited to the generic source, closure, prediction,
and evaluation contracts it consumes. The neutral identity covers the primary
input, full existing dependency closure, and bounded raw animation/channel
observations only; tool metadata and the adapter cannot change it. The exact
Bevy adapter embeds the same prediction provenance and unchanged
engine-addressability evaluation,
so it creates neither a second selector rule nor a second check lifecycle.
Absent and repeated source names remain metadata. Scene/default-scene, skin,
target path or UUID, named-map-winner, and extension-handling claims require
new raw facts and a later profile revision and are outside this V1.
Issue #488 adds those rich facts as a separate immutable producer contract,
urn:animsmith:schema:gltf-addressability:2; the historical V1 root and
readers remain unchanged. V2 preserves the V1 animation inventory and adds
bounded same-load raw glTF scene, node, skin, node-to-skin attachment,
scene-path, default-scene, named-map, and unique animation-target evidence.
Coverage is independent for every domain: complete empty coverage proves
absence, while a retained prefix or unavailable domain never does. Rows retain
source-array indices, authored names, parent/child order, scene roots, ordered
skin joints, explicit skin.skeleton, and the dependency-closure identity.
The explicit skeleton member remains source evidence only because Bevy ignores
it; V2 makes no inferred-root claim and does not claim scene-instantiated
SkinnedMesh attachment.
The optional exact-Bevy adapter is paired with the existing one
engine-addressability evaluation and reuses the exact Animation{i}
primitive, without creating a second check lifecycle. Its separately
versioned rule bundle accompanies immutable Bevy profile revision 3 and pins
tag v0.19.0, commit
c6f634ca9f406d68ba5109d921247b654cb42c10, bevy_gltf 0.19.0, locked
gltf 1.4.1, and the label, loader, node-path, target-id, feature, and root
Cargo.lock sources.
Scene{i} is emitted for every declared scene; Gltf.default_scene is only a
route to an existing scene handle, with no DefaultScene label and no
fabricated Scene0. Skin{i}/InverseBindMatrices is eager for every source
skin, including unreferenced skins, using Bevy's identity fallback for an
absent accessor. Skin{i} is created when any source node references the skin
during the all-source-node construction pass. Source indices, not collected
vector order, are identity. Named maps are separate, source-order
last-write-wins projections; skin winners follow lazy first-reference order.
Target rows are per unique source animation target node and retain their
animation/channel contributors. Authored names or GltfNode{source_index}
fallbacks form the path, excluding the scene world-root. A path/UUID is
published only with complete hierarchy, reachability, closure, feature,
settings, and collision evidence. Unreachable or multiply reachable targets,
duplicate paths, UUID collisions, disabled/missing bevy_animation, disabled
load_animations, missing target pointer width, and incomplete evidence are
typed required-unavailable states. Exact target IDs require explicitly pinned
32- or 64-bit pointer width because Bevy hashes segment lengths with the target
pointer width's little-endian encoding; the host width is never inferred.
The projection carries explicit target_coverage separate from retained
target rows. It is complete, including for an empty target domain, or is
required-unavailable when raw node/scene/path or animation/channel evidence is
incomplete, and with target_domain_truncated at the 4,096-target row
ceiling. Other new-rich-projection bound failures use
projection_bounds_exceeded. Each new scene/node/skin/attachment/path/target/
map domain is bounded at 4,096 rows, with aggregate structural references at
65,536 and dynamic projection text at 1 MiB; the sealed V1 animation inventory
and CheckEvaluation scopes retain their own bounds. Names and path segments
are capped at 1,024 UTF-8 bytes, paths at 4,096 bytes and 256 segments, and
staged reports at 256 MiB. Overflow retains an independent canonical prefix;
strict readback rejects N+1 collections and contradictory states. A bounded second parse of already captured primary bytes is permitted
within the same loader invocation; reopening the primary or dependencies is
not. V2 is prediction evidence only and never certifies runtime loading,
scene spawning, target survival, graph wiring, or playback. Required-
unavailable prediction retains exit 1; malformed tuple/config/input remains
exit 2.
The second bounded 0.4.0 standalone producer is generate import-advice.
animsmith-engine owns its independent V1 envelope, canonical identity, and
strict reader. It embeds the same exact prediction-provenance record but is
not a check evaluation: it projects fully materialized profile settings into
document/per-clip importer properties beside same-load source identity,
dependency closure, source-to-normalized clip linkage, explicit loop/movement
intent, and normalized measurement availability. Unity 6000.3 Generic and
Humanoid revision 1 can project Convert Units, Bake Axis Conversion, Generic
Root Motion Source, and the three bake/extract root controls. Frozen Unreal
5.8 and Godot 4.7 revision 1 have no setting descriptors and therefore emit a
typed refusal rather than a guessed preset. Authored frame coordinates,
sampling policy, root-motion prediction, and unit-conversion inference remain
out of scope until their own exact source/profile authorities exist.
Revision 2 of the producer is an independent immutable contract,
urn:animsmith:schema:engine-import-advice:2. It uses output-v15's V4
prediction-provenance and prediction-basis types by reference and carries one
optional native projection. available requires that projection and omits
refusal_reason when the same-load dependency closure is complete; partial or
unavailable closure requires refused, dependency_closure_incomplete, and
no projection. Unknown tuples, missing settings, and unsupported formats are
configuration errors, so neither those errors nor incomplete closure can be
mistaken for an importer preset.
The only V2 advice profiles are exact: Godot 2 / 4.7 /
resource-importer-scene accepts glTF JSON and GLB and projects
animation/fps (1..120, verified default 30) plus animation/trimming
(verified default false); Unreal 2 / 5.8 / fbx-importer accepts FBX and
requires explicit sample_rate from default_30, source_determined, or
custom_hz(1..48000). The projection retains explicit/default origin and no
other settings. It is a parameter projection from same-load evidence, not
engine execution, imported-asset readback, runtime/gameplay certification, or
project-file mutation.
[checks.<id>] remains the only authority for severity and explicit
enable/disable overrides. Existing checks such as loop-closure, in-place,
foot-slide, and root-motion-speed keep their current behavior under every
engine profile.
The shipped narrow slice is Bevy revision 3 / 0.19.0 /
gltf-asset-loader engine-track-support. It binds a bounded raw animation
inventory from the same load, with animation coverage and independent
per-animation channel coverage. A complete inventory can produce only
negative gate outcomes for rows dropped by the compiled bevy_animation
feature or by load_animations; a dropped row is not a content finding. The
compiled feature gate has precedence over the load setting. A complete-empty
inventory is not applicable. Partial or unavailable coverage emits exactly one
subjectless, unsuppressible required_prediction_unavailable inventory facet
and no retained-prefix prediction, including the N+1 boundary. If both gates
allow loading, the result is a stable required-unavailable runtime-survival
state. Extensions, other constructs, and positive runtime survival are not
modeled by this slice. V5 provenance and output-v19 are current for this Bevy
slice; V4/revision-2 and output-v15 remain preserved and readable. The Unity
Generic root-motion slice uses V6 provenance in output-v19, while output v9
and all other historical readers remain immutable.
Configuration precedence is consequently:
- loader and measurement evidence establish what the input contains;
- the resolved engine profile supplies immutable verified consumer facts;
- resolved document and per-clip importer settings state the configured
consumer behavior, while
[clips]fields state the intended use; [checks]supplies activation, severity, and tolerances; and- the runner evaluates each applicable facet or records its typed required-unavailable prediction state.
No later layer may fabricate evidence missing from an earlier one.
E.4 Reproducible output and fail-closed coverage
measure remains engine-profile-independent: selecting an engine profile or
its importer settings does not change measurements. Existing measurement
inputs from [clips] and engine-neutral check policy retain their current
semantics. lint and generated-advice outputs that use a profile record, at
minimum:
- requested family, profile revision, engine version, and importer;
- resolved immutable profile URN and engine-facts digest;
- input format, raw-source coverage, and fully materialized document and per-clip importer settings;
- measurement and outer-output schema identities consumed;
- each check id plus facet scope, exact fact/policy/measurement basis, and primary-source reference set, including clean evaluations; and
- scoped required-unavailable reasons for prediction evidence that is missing, distinct from ordinary engine-neutral coverage gaps.
Output v17 publishes this record; output v9 remains immutable historical
evidence. Under Appendix C, animsmith-core owns the outer lint envelope, its
URN, and the
registry-independent prediction/provenance
wire types. animsmith-engine constructs those wire records through a one-way
adapter without making core depend on the engine crate. The exact JSON shape is
fixed by the v10 contract and permits a consumer to distinguish
a measured fact, a profile prediction, generated advice, and an engine
readback. Text and HTML views render that model rather than maintaining separate
conclusions.
Prediction, advice, and readback contracts reuse one versioned provenance
header for input dependency-closure identity, profile tuple and facts digest,
settings digest, schema identities, and source references. The primary-file
InputIdentity remains present, but it is not sufficient closure identity for
a text glTF or any source with external dependencies. The header also carries
a deterministic source-reference-to-resource map and the exact byte count and
digest of every distinct external resource declared or consumed by the loader
or target importer, keyed by a source-relative declaration rather than a host
absolute path. It records closure coverage as complete, partial, or
unavailable. If an applicable rule, suggestion, or readback comparison depends
on a resource whose identity cannot be established, that work is unavailable
rather than silently bound to primary-file bytes alone.
The three contracts' top-level schema identities and typed payloads remain separate because their authorities differ: a shared header must not let advice or an observation deserialize as measured or predicted evidence. This centralizes common identity plumbing without coupling the contracts' independent compatibility rules.
Profile selection never turns unavailable evidence into not_applicable.
not_applicable is reserved for a rule whose subject genuinely does not exist
under the resolved profile and project intent. An engine prediction is
required when the resolved profile and project intent make its rule
applicable, the run's CheckSelection selects its check, and the check remains
enabled after [checks] configuration. An unselected check remains visible as
unselected and is not required; an explicitly disabled check remains visible
as disabled. Once a check is selected and enabled, severity does not change
whether evidence is required. A required facet with schema-valid unavailable
measurement evidence or incomplete raw-source coverage emits typed
required_prediction_unavailable and makes lint exit 1. Malformed or
non-finite present measurement data remains a contract/operator error and exit
2; it is not prediction unavailability. The facet is not rewritten as a
content finding, and existing engine-neutral coverage gaps
remain nonblocking. Advice generation refuses the affected suggestion without
changing lint status. Unknown profile, version, importer, or setting
selections fail earlier as configuration errors; they are never downgraded to
coverage. Output v10 records the requirement state so embedded
consumers can apply the same policy without parsing CLI exit behavior.
E.5 Root motion, units, and scale
Profiles keep these independent:
- the source/interchange linear unit captured from normative format semantics or an explicit declaration in the source itself;
- the target engine's native distance unit;
- the exact source-to-target distance mapping and whether the importer preserves physical dimensions;
- source and target handedness, up/forward axes, and the importer's selected axis-conversion behavior;
- the resulting component/root, mesh-node, selected runtime-facing joint, and attachment transform scales; and
- the project's movement-ownership policy.
For example, a centimetre-native engine does not imply a component scale of
100, and a metre-native format does not prove that a source hierarchy has
unit effective scale. Rules consume the coordinate-domain and selected-node
measurements designed in issues #267 and #268. They may recommend an importer
setting only when its documented behavior accounts for animation
translations, bones, sockets, attachments, IK targets, and collision anchors.
Version one has no TOML or profile override for source units. A loader records
the format-defined or source-declared unit and its provenance in the shared raw
projection; if that evidence is absent, an applicable unit prediction is
unavailable under E.4 rather than inferred from bounds, character height,
filenames, importer defaults, or a caller assertion.
They never infer units from bounds, character height, filenames, inverse-bind
magnitudes, or an observed persistent object scale.
Animation scale channels are dimensionless and never become length-bearing
merely because the target engine uses a different world unit.
The contract can therefore state, independently, that one source metre maps
to 100 target centimetre units, physical dimensions are preserved, and the
expected imported component/root, mesh-node, and selected-joint transform
scales are dimensionless 1.0. A mismatch in any one of those claims cannot
be hidden by satisfying another.
Issue #269's accepted Appendix D result is incorporated here: whole-document unit conversion and rest/bind hierarchy reparameterization are distinct operations with separate positive-uniform, closure, and proof contracts. Profile selection neither authorizes nor freezes an automatic normalization path. It may diagnose the need for, or advise a separately requested, Appendix D operation only when that operation's own source coverage and proof requirements are satisfied.
Root motion is handled the same way. The profile describes which source or
projection a configured importer uses and what a bake/extract option means.
The artifact supplies engine-neutral horizontal displacement, signed vertical
displacement plus non-collapsing vertical excursion/extrema, and yaw evidence.
[clips.<selector>] independently declares
movement_owner_xz, movement_owner_y, and movement_owner_yaw as either
"gameplay" (the entity/controller owns world motion and the importer bakes
that component into pose) or "animation" (extracted root motion owns world
motion). Issue #466 owns these engine-neutral clip-intent fields. The existing
in_place field remains a compatibility alias only for
horizontal ownership (true means gameplay, false means animation); declaring
both spellings in one selector entry is a typed configuration error. Each valid
entry is normalized before the existing field-by-field exact/glob overlay, so
an alias in one layer and the canonical field in another remain valid. A missing
axis declaration makes only the intent-dependent rule for that axis not
applicable; it is never inferred from another axis. A filename such as _RM,
small translation magnitude, or the profile alone cannot declare intent.
Conversion between root motion and in-place remains ADR-gated and is not
authorized by this profile design.
E.6 Advice, manifests, and readback
Generated import settings use a separate immutable advisory-sidecar contract. They bind the complete input dependency-closure identity, full profile identity and facts digest, selected importer settings, measurement/check identities, and every suggestion or refusal. They must say when upstream canonicalization is required instead of presenting an unsafe importer bake as equivalent. They never affect lint exit status or claim to be measured evidence or the engine's actual imported state.
The 0.4.0 generation boundary is one input document. The engine-neutral glTF inventory may publish source indices, names, targets, and the complete input dependency-closure identity. A versioned Bevy adapter may derive typed labels and supported-extension predictions from that inventory; those fields are not mislabeled as engine-version-agnostic. Cross-file clip identity, runtime sets, collection manifests, and collection-wide presets consume the file-scoped contract from issue #409 in the 0.5.0 milestone rather than inventing a second identity here.
Engine readback has another immutable contract that records the exact engine
build, project/import-settings digest, observed outputs, and warnings. Smoke
tests compare a versioned prediction with that captured readback; successful
parsing alone is not proof that an expected clip, target, or setting survived
import. Engine SDKs, editor installations, licenses, and runtime harnesses stay
outside animsmith-core. CI fixtures must be synthetic, self-authored, or
redistribution-safe. Commercial packs may supply local, one-time validation
evidence, but are never repository fixtures, workflow downloads, caches, or
uploaded artifacts.
E.7 Primary-source and version discipline
Engine behavior is mutable external state. Every built-in rule cites a primary engine or specification source, records the verified target version and date, and is rechecked when that target or rule changes. Marketplace observations and local import probes are useful counterexamples, not authority for a general engine claim.
The design was reconciled on 2026-08-20 against these current primary sources:
- Unity 6000.3 Animation Clip, Root Motion, and model-import manuals: https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html, https://docs.unity3d.com/6000.3/Documentation/Manual/RootMotion.html, and https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html;
- Unreal Engine 5.8 Animation Sequence and FBX import documentation: https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8 and https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8;
- Godot 4.7 advanced 3D import and scene-importer documentation: https://docs.godotengine.org/en/4.7/tutorials/assets_pipeline/importing_3d_scenes/advanced_import_settings.html and https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html;
- Bevy 0.19
GltfAssetLabel,AnimationTargetId, and animation API rustdoc: https://docs.rs/bevy/0.19.0/bevy/gltf/enum.GltfAssetLabel.html and https://docs.rs/bevy/0.19.0/bevy/animation/struct.AnimationTargetId.html; and - the glTF 2.0 specification: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html.
Unversioned latest documentation is not retained as profile evidence.
E.8 Implementation sequence and non-goals
The dependency order is:
- this decision record;
- coordinated engine-neutral measurement work for per-property coverage and horizontal/vertical root trajectory plus yaw (#402 and #408);
- independent per-axis clip movement-ownership intent (#466), required before root-motion prediction but not by the measurement/profile substrate;
- the dedicated raw importer-sensitive source-facts projection (#463);
- strict engine-profile registry, config, and resolution types without prediction behavior (#464);
- bounded, rooted dependency-closure capture from the raw resource domain (#475);
- reproducible output-v17 provenance for the resolved target, facts digest, required-prediction state, and per-check basis (#465 and #482);
- per-concern prediction rules, split from umbrella issue #154 into bounded engine/rule slices where their input facts differ, including the Unity Generic V6 root-motion path;
- single-document preset advice and separate glTF-inventory/Bevy-adapter generation (#155 and #156);
- prediction-versus-readback feasibility and harness decisions (#151); and
- per-engine guides generated from the accepted rules and evidence (#157).
The two measurement tickets share schema, diff, rendering, and contract surfaces, so they require one version plan and sequential implementation rather than sibling branches. They do not depend on profile selection. Bevy graph- template demand (#163) remains gated by the manifest and a demonstrated consumer; the profile design does not turn it into an implementation commitment.
This decision does not add engine dependencies, custom TOML profile definitions, automatic unit or root-motion conversion, retargeting, compression simulation, artistic thresholds, cross-file collection policy, or claims of visual, deformation, artistic, gameplay, or runtime correctness. Those require their own evidence and, where they rewrite motion, a separate accepted design and proof boundary.
Appendix F — decision record: file-scoped clip identity and collections
Issue #409 decides the boundary used when logical clips and runtime
relationships span physical source files. The decision is intentionally a
protocol decision followed by bounded implementation slices. It does not make
the existing multi-file spelling of lint collection-aware.
F.1 Separate collection authority
AnimSmith provides two independently versioned contracts:
urn:animsmith:schema:collection-manifest:1, a strict TOML declaration; andurn:animsmith:schema:collection-output:5, deterministic JSON evidence.
The historical urn:animsmith:schema:collection-output:1 and
urn:animsmith:schema:collection-output:2 contracts remain immutable. V2
derives its exact shape, budgets, and reader rules from V1, changing only the
nested ordinary output envelope identity from output v10 to output v11. The
historical urn:animsmith:schema:collection-output:3 derives from V2 and adds one
bounded dependency_closure state to every source. A complete state retains the
loader's exact DependencyClosureIdentityV1; partial and unavailable states
retain a nonempty, sorted, unique sequence from the seven closed
DependencyClosureCoverageReasonV1 values. The current collection-output:11
embeds the current output-v19 lint envelope with measurements-v18 while
preserving collection-output-v8 and its nested output-v16, and
collection-output-v4 with its nested output-v12/measurements-v15, as immutable
historical evidence. Only complete closure coverage can
establish a source, logical clip, or runtime-set member. This keeps primary
input identity, optional manifest digest pin, and complete loader-input
identity separate.
They are consumed and produced by an explicit command:
animsmith collection lint COLLECTION.toml --format json
The command name, input identity, and output identity are distinct from
ordinary animsmith lint FILE.... Existing lint and measure invocations remain
document-local and continue to emit output-v13 with measurements-v16. A file
extension, the presence of multiple inputs, or repeated embedded take names
never switches commands implicitly.
The collection manifest is the sole AnimSmith authority for:
- durable logical clip identifiers;
- physical source and take bindings;
- optional source digest pins; and
- cross-file runtime-set identity and membership.
It is not a second authority for rig roles, per-clip expectations, check
severity, or check tolerances. Those remain in an explicitly selected existing
animsmith.toml config basis and retain their ordinary per-document selector
semantics. The collection manifest may select a complete config basis for a
source; it cannot override individual config fields.
An assembly recipe is not a collection manifest. Assembly is a mutating producer that chooses a base, projects source domains, and publishes one artifact. Collection lint preserves each physical source and evaluates relationships without renaming, merging, or rewriting it. Likewise, the animation-pack evaluation manifest remains a skill-level evidence and taxonomy format. It may supply retained validation evidence, but it is not accepted as the CLI protocol.
The repository-owned structured evaluation model is a separate skill-level
authority. animation-pack-evaluation:1 remains frozen to independently
validated collection-output:2. Current evaluations use the additive
animation-pack-evaluation:2 contract and bind the exact raw
collection-output:11 digest and byte count plus its manifest identity. V2
retains every scrubbed source state, including complete dependency-closure
identity or the full typed partial/unavailable reason set, and proves exact
clip source/take and ordered runtime-set relationships against that V11 input.
The V2 validation boundary requires the explicitly selected checkout-matched
AnimSmith binary and invokes a hidden validation-only collection subcommand
that calls the one authoritative V11 strict reader before any projection.
Schema validity plus a matching raw digest is not sufficient, and there is no
second Python semantic mirror.
Clip duration and speed preserve exact measured, unavailable, or not-applicable
availability rather than collapsing the latter two categories.
Unavailable rows are mandatory typed soft failures, not grounds to shrink the
model to surviving records. A separate projection sidecar was rejected because
it would duplicate clip/set authority and create a third versioned contract;
V2 is the single report authority, while the exact hashed V11 document remains
the mechanical evidence binding. Neither model is accepted as a CLI protocol.
F.2 Logical and physical clip identity
Every collection clip has one user-declared logical id and one physical binding. Neither is sufficient alone.
The logical id is the durable public reference used by runtime sets and future sidecar schemas. V1 ids are portable, lowercase ASCII, slash-separated tokens:
com.example.pack/locomotion/walk-forward-in-place
Each token starts with [a-z0-9] and then contains only [a-z0-9._-]; empty,
. and .. tokens are invalid. An id has at least two tokens and at most 255
bytes. The namespace is a collision-avoidance convention, not an ownership or
compatibility claim. It is opaque to AnimSmith: filenames, directories, take
names, vendor metadata, and clip behavior never generate or rewrite it.
The physical binding contains:
- one manifest-local source key;
- a zero-based source-local take index; and
- the exact expected embedded take name at that index.
Index and name are both required. Name alone is ambiguous when many files or
multiple takes use Take 001; index alone would silently accept reordered or
renamed source content. The source record supplies the safe root-relative
locator, observed primary InputIdentity (SHA-256 and bytes), optional
expected SHA-256, complete or typed-incomplete dependency-closure state, and
config basis. A source rename does not change the logical id, but
the manifest must be edited explicitly and output records the changed physical
locator. A digest pin is a binding assertion, not the logical identity.
One physical (source, take_index) may have only one logical id. Two ids bound
to the same physical take are an ambiguous duplicate and invalidate the
manifest. One source may legitimately bind several distinct take indices.
Distinct source locators with equal content digests remain distinct physical
sources; content equality does not create an alias or compatibility claim.
F.3 Manifest V1 shape and composition
The normative implementation issue freezes the exact JSON Schema and Rust wire types, but V1 has this authority shape:
schema = "urn:animsmith:schema:collection-manifest:1"
schema_version = 1
collection_id = "com.example.pack"
input_root = "assets"
[[sources]]
key = "walk-forward"
path = "locomotion/walk_forward.fbx"
config = "configs/locomotion.toml"
expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
[[clips]]
id = "com.example.pack/locomotion/walk-forward-in-place"
source = "walk-forward"
take_index = 0
take_name = "Take 001"
[[runtime_sets]]
id = "com.example.pack/sets/walk-ring"
kind = "directional-blend"
members = [
"com.example.pack/locomotion/walk-forward-in-place",
"com.example.pack/locomotion/walk-left-in-place",
]
The schema/version pair, collection id, nonempty source/clip arrays, and every
field shown for clips are required. input_root, source config, and
expected_sha256 are optional. An absent config means Config::default();
there is no ambient config discovery. A selected config path applies to the
whole loaded source document. The same source cannot select different configs
for different takes.
The collection id and source keys use one token from the lowercase ASCII
logical-id grammar (no /) and are each limited to 255 bytes. Source keys are
manifest-local plumbing rather than public clip identity. Every clip and
runtime-set id must start with the exact collection id followed by /; this
makes the declared collection namespace an enforced prefix without treating it
as an ownership claim. Unknown fields, duplicate ids/keys, duplicate
source/take bindings, malformed digests, dangling source references, dangling
or repeated set members, and sets with fewer than two members are operator
errors.
Runtime-set ids use the logical-id grammar. V1 set kinds are membership vocabulary only:
gait-group;sync-group;directional-blend;speed-blend;transition-chain;mask-composition;retarget-group;paired-interaction; andmotion-database.
The closed list prevents typo-created semantics. A kind does not itself claim that its members blend, retarget, synchronize, or play correctly. V1 preserves declared member order because some relationship consumers assign coordinates or roles by declaration order. Output sorts source, clip, and set records by their stable ids, while each set retains its declared member order.
V1 intentionally carries no generic policy map and no untyped TOML payload. Check-specific coordinates, tolerances, or controller policies require a typed, versioned contract owned by that check. They must not be smuggled into collection V1 as open data merely to avoid a schema revision.
The parser and wire reader are bounded before allocation. The implementation
slice must freeze limits for manifest bytes, source rows, clip rows, runtime
sets, aggregate memberships, identifier bytes, and output bytes, with exact
N/N+1 tests and checked arithmetic. Safe path spelling reuses the immutable
dependency-closure V1 key limits: 4,096 UTF-8 bytes and 128 components.
F.4 Rooted access and immutable sources
The manifest directory is the control root. input_root, when present, is a
relative directory below it; source paths resolve below that input root. When
it is absent, source paths resolve below the manifest directory itself, never
the process working directory or another ambient root. Config paths always
resolve below the manifest directory. All declared paths use / as the
separator and are retained as UTF-8 evidence labels, never as host absolute
paths.
The CLI/frontend owns filesystem access. It reuses
DependencyResourceKeyV1 with ParserRelativePath for lexical validation and
uses a rooted resolver equivalent to the assembly/material boundaries. It
rejects, before source execution:
- empty,
.,.., absolute, drive-qualified, UNC, remote, or backslash paths; - NUL, control characters, oversized keys, and excessive components;
- a symbolic-link component or final symbolic link;
- a non-directory input root, non-regular config target, or existing non-regular source target; and
- a canonical target outside its declared root.
Case and Unicode are preserved exactly; AnimSmith does not case-fold or Unicode normalize a declaration into another identity. Lexical duplicates and aliases the host resolver exposes as one canonical target are rejected. V1 does not claim portable hard-link identity beyond what the host safely exposes.
The source is opened read-only, measured, and never renamed or rewritten. Observed identity is derived from the bytes actually analyzed. An expected digest mismatch is evidence about a readable binding, not permission to use the bytes as if they matched. Root containment and symlink refusal prevent ordinary path escapes; they do not claim to defeat a privileged actor concurrently mutating the filesystem between checks and reads. That portable TOCTOU limit is retained in the public security notes.
F.5 Existing config and document-local checks
Collection execution loads each source with exactly one selected existing
config basis. [clips."<selector>"], [gait_groups], and [sync_groups]
continue to resolve embedded clip names inside that one document. Reaching a
config through a collection does not make its selectors match logical ids and
does not reinterpret a previously valid single-document project.
Document-local gait/sync checks may run as part of the nested source analysis. Collection runtime sets are separate records keyed by logical ids. A shared name does not merge the two domains, and membership is never inferred from a document-local group, filename, directory, or evaluation taxonomy.
Core remains format- and filesystem-neutral. It may own validated bounded logical/source/take/set values and deterministic evidence types. TOML parsing, rooted access, loader dispatch, and command exit routing remain in the CLI frontend. Format loaders continue to own their raw-source and dependency closure projections.
F.6 Collection output and completeness
collection-output:1 is a historic strict envelope, not ordinary output v12. It contains:
- the manifest schema identity, observed manifest SHA-256/bytes, collection id, tool version, and frozen collection budget identity;
- one source record with its safe declared locator, observed or unavailable source identity, expected-digest result, config state/path/digest, loader state, and nested ordinary per-document result when available;
- one clip record with logical id, source reference, take-index/name witness, binding state, and exact nested measurement/check references;
- one runtime-set record with id, kind, every declared member, each resolution state, an explicit evaluation lifecycle, coverage gaps, findings, and any later check-specific measured payload; and
- deterministic totals and terminal work counters needed to prove bounds.
Nested ordinary results retain their published schema identities and strict readers. They are not copied into a nearly equivalent collection-only shape. Host absolute paths and raw host I/O messages are never emitted. A source row uses its safe declared locator; stable typed states carry failures.
Collection-output V1 additionally carries one existing ClipMeasurements
value per established logical clip, selected from a duplicate-safe
normalized-index projection only after the raw source take index and exact
authored name map to that normalized index. This additive in-memory projection
does not change measurements-v15: ordinary output remains name-keyed. When two
authored names remain equal after loader normalization, their indexed
measurements remain distinct while a name-addressed check reference is typed
unavailable; no first/last match is guessed. Runtime-set V1 completeness
depends on the binding and indexed measurement, not that optional check
reference, and its decision remains not_evaluated until a later typed set
check owns a conclusion.
The normalized name is bounded at 4,101 bytes: the 4,096-byte authored-name
maximum plus # and the largest duplicate ordinal possible under the
4,096-clip manifest cap. Available nested measurement keys retain output-v10's
4,096-byte bound.
When that derived key exceeds output-v10's immutable 4,096-byte text bound, V1
retains physical binding and indexed measurements but marks the nested document
and name-addressed check reference nested_output_unavailable, then exits 1;
it never embeds a schema-invalid ordinary envelope.
The immutable output budget permits 1 GiB per primary source, 16 GiB of
aggregate primary-source reads, and 256 MiB of serialized/readback JSON. It
reuses the manifest V1 source, clip, runtime-set, aggregate-membership, and
aggregate-work limits. Primary reads and strict output readback stop at N+1;
checked work counters retain the terminal observation needed to distinguish an
exact limit from exhaustion.
Producer serialization also writes through a 256 MiB N+1 sink before any full
envelope can be allocated, and its self-reported byte count converges over the
finite decimal widths permitted by that same cap.
After an aggregate primary-read N+1 witness, later declared sources are retained
as aggregate_exhausted without being opened, so the terminal counter cannot
advance beyond that single witness.
The manifest digest binds the exact input bytes. Reordering otherwise equivalent TOML therefore changes that input identity, while the decoded source, clip, and set record arrays remain canonically ordered and byte-identical. The contract does not erase source-manifest changes merely to make two different inputs share a full output digest.
A set may report a complete conclusion only when every declared member needed by that conclusion resolved and supplied the required evidence. Missing, rejected, mismatched, quarantined, or unmeasurable members remain visible. The evaluator must not shrink a set and report a clean result over the survivors. Member ordering in output is deterministic and every group-level measurement names its exact member basis.
Logical ids, equal source digests, common skeleton names, or membership in one set prove none of retargeting, blend, artistic, deformation, engine, gameplay, or cross-pack compatibility. Those claims require their own declared policy and evidence.
F.7 Failure and exit policy
The collection command introduces one explicit aggregate-data exception to the
ordinary lint operator-error rule. The exception is scoped to a manifest that
has already passed all control-plane validation:
- exit
2with no collection envelope for an unreadable or malformed manifest, unsupported schema identity, budget violation, duplicate/unsafe declaration, existing non-regular source target, missing/unreadable/malformed selected config, serialization failure, or other control/tool error; - exit
1with a schema-complete collection envelope when a lexically safe declared source is missing or unreadable, when readable bytes are unsupported or malformed, when a digest/take binding mismatches, or when another required collection member cannot be established; and - otherwise apply the existing lint severity and required-unavailable rules to
available nested/check evidence, with exit
0only when none requires1.
Missing/unreadable bytes and readable-but-rejected bytes have distinct typed
states even though both make a required collection incomplete. Unsafe paths and
configs are never downgraded to data availability. This preserves all declared
members for batch automation without silently changing ordinary lint FILE...,
which retains exit 2 and no output for an unreadable primary file.
An unavailable measurement inside a successfully loaded member follows the
owning check's existing coverage/required policy; collection membership alone
does not promote every nonblocking coverage gap to exit 1. A source that
cannot be loaded at all is different: the declared physical clip identity was
not established, so the collection result is incomplete and exits 1.
F.8 Retained evidence exercise
The retained, externally generated animation-pack evidence behind #409 contains the required marketplace shape: many physical files repeat one embedded take name, while meaningful logical motions and runtime sets live outside those takes. The public eight-pack rollup reports 895 individual files, 582 logical motions, and 90 runtime-set records without publishing licensed source bytes.
The mapping exercise is lossless for the foundation facts:
- retained per-file locator and digest facts map to source rows;
- retained logical-motion identities map to namespaced clip ids;
- selected source-local takes map to the index-plus-name witness;
- retained runtime-set membership maps to logical-id member lists; and
- evaluator roles, confidence, capability profiles, workflow stages, report prose, and engine observations deliberately remain outside this contract.
This shows why the evaluation manifest must not be reused wholesale. It proves the collection identity and membership need while keeping evaluation taxonomy and product conclusions at their existing skill/report authority.
The retained license-safe spike fixture is
crates/animsmith/testdata/collection-spike.
Its self-authored, motion-free glTF files cover two distinct sources with the
same bytes and embedded Take 001, one two-take source, cross-file gait and
sync sets, an explicit config basis, and duplicate-member, missing-member, and
escaping-source failures. The preservation table pins each locator, byte count,
digest, logical id, and index-plus-name witness without treating that retained
fixture as output-schema authority.
Repository tests use only synthetic/self-authored or explicitly redistribution-safe multi-file assets. Licensed pack bytes and motion-bearing derivatives remain outside source, CI, caches, logs, and public artifacts.
F.9 Implementation sequence and non-goals
The 0.5.0 order is:
- this accepted decision record and bounded issue decomposition (#409);
- strict collection-manifest V1 types, budgets, logical/physical identity validation, and rooted resolver with license-safe fixtures (#545);
- explicit
collection lint, deterministic collection-output V1, nested per-document execution, aggregate failure routing, and strict reader tests (#546); - contact-sidecar and transition-family schema spikes (#147 and #148) using the logical clip-reference vocabulary without generation code;
- truthful gait-phase statistic publication (#504), after separately choosing the existing maximum circular deviation from mean or a deliberate changed threshold statistic; and
- root-speed/stride set evidence (#411), split so raw evidence and any typed controller policy do not expand collection V1 into an open policy map.
The parser/resolver and execution/output work are separate PRs because rooted I/O review and public output/exit review have different failure surfaces. Both must land in 0.5.0; closing the ADR alone does not deliver the milestone's collection foundation.
This decision does not add collection report HTML, generated transitions, engine graph output, archive extraction, filename-based intent inference, source renaming/merging, motion rewriting, cross-pack compatibility claims, or a generic untyped runtime policy language. The strict contact-fragment sidecar producer specified in F.10 is the narrow exception; host-side merge and all runtime interpretation remain later consumers or separately accepted contracts.
F.10 Contact-fragment V1 (#147)
Issue #147 freezes the interchange contract for contact facts. In 0.6.0,
issue #152 delivers its narrow strict producer: animsmith generate contact-fragment and
animsmith collection generate-contact-fragment sample stance support and
atomically publish one source-bound fragment. The collection command reloads
its selected manifest source rather than consuming collection-output evidence.
Neither issue adds host-side sidecar merge or a runtime event system. Its
independently versioned identity is:
urn:animsmith:schema:contact-fragment:1
The fragment is an importable envelope, not a second independently edited
authority beside a host's measured sidecar. A V1 envelope contains schema,
schema_version, producer (tool and tool-version), artifact (the exact
primary source bytes' existing InputIdentity wire {sha256, bytes}),
dependency_closure_identity (the complete existing
DependencyClosureIdentityV1,
serialized through that same {sha256, bytes} wire), one clip reference, a
measured positive duration_s, and an ordered events array. The sha256
field makes both the algorithm and digest explicit; this contract does not
introduce an {algorithm, digest, bytes} shape. Both identities are mandatory.
The closure identity binds the primary source and every dependency in the
loader's complete versioned dependency domain; partial or unavailable closure
coverage refuses fragment generation. This is the identity only, not the full
dependency_closure record used by output v10. The captured closure's
primary_input must equal artifact; the producer and any consumer checking
staleness validate that relationship against the complete captured closure.
Unknown fields are rejected by the strict core reader implemented in 0.6. A mismatch in either
identity makes the fragment stale rather than authorizing a consumer to use it.
AnimSmith implements the format-neutral strict reader and bounded
canonicalization/identity seam in animsmith-core, plus the strict producer
above. The producer loads one exact direct take or reloads one exact
manifest-selected take, requires complete finite bilateral stance evidence,
and publishes the canonical fragment as a single durable sidecar. It does not
implement contact transforms, host-side merge, runtime scheduling, engine
mapping, physical-contact inference, or gameplay semantics; those consumer
boundaries remain separately deferred.
V1 deliberately requires the complete modeled closure, including declared dependencies such as textures that may not affect contact calculations. It can therefore refuse generation when an unrelated dependency is unavailable. This conservative policy avoids a second format-specific dependency-relevance authority; narrowing it requires a separately versioned contract.
The clip reference is a tagged scope rather than a filename or animation-array index:
collectioncarries the #409 logical clip id together with its physicalsource, take-index, and exact take-name witness. The witness lets a host detect a manifest edit or take reorder even when a logical id was retained.documentcarries the exact embedded clip/take name and the primary-plus- dependency-closure binding that scopes that name. If the document exposes duplicate names or cannot establish an unambiguous source-local take identity, the reference is unavailable and the fragment is refused; no first/last animation match is guessed.
An event has a stable event_id, an engine-neutral role and phase, and
either a point time or a window {start, end}. Times are normalized to the
closed interval [0, 1]; start <= end is required for a window. A point or
window is never identified by its array position. Optional confidence and
explicitly versioned extension data are non-normative additions; confidence,
when present, is finite and lies in [0, 1]. Unity, Unreal, Bevy, and
gameplay-specific event types do not belong in V1. An
event_id is unique within the clip and remains stable when the same fact is
regenerated. It is opaque to consumers, is not an engine handle, and is not
derived from array position.
V1's closed role vocabulary is left_foot, right_foot, left_hand,
right_hand, left_toe, right_toe, left_knee, right_knee, left_elbow,
right_elbow, root, prop, and body. Its closed phase vocabulary is
begin, end, and marker. A producer that needs another semantic role or
phase must propose a versioned contract extension; it must not smuggle an
engine enum or arbitrary string into V1.
Canonical bytes use the complete RFC 8785 JSON Canonicalization Scheme
(JCS), including its object-member
sorting, string escaping, and number serialization rules. Every value,
including an opaque extension payload, must be JCS-canonicalizable or the
fragment is refused; an extension does not supply a private key-ordering rule.
The reader accepts decoded IEEE-754 numbers only within the safe magnitude
[-9007199254740991, 9007199254740991], including identity byte counts and
extension-payload numbers. Lexical decimal aliases are therefore the same
decoded JCS value, not distinct V1 facts. Extension payloads are reparsed from
their bounded JCS bytes before storage, so canonical serialization followed by
strict reread preserves the validated fragment value.
Before JCS serialization, a mixed point/window event sort key is exactly
(start, kind_rank, end_key, role, phase, event_id): a point sets
start = time, kind_rank = 0, and the end sentinel end_key = null, which
sorts before every numeric window end; a window uses its declared start, has
kind_rank = 1, and sets end_key = end. Thus a
point and a window at the same start have a deterministic order, including
when their numeric endpoints are otherwise equal. Every string comparison in
that tuple, including the opaque event_id, is lexicographic by unsigned
UTF-16 code units exactly as RFC 8785 orders object property names; no Unicode
normalization is applied. Extension arrays retain
their declared order. Seconds and frame numbers, if
present for display, are derived values and never identity or comparison
coordinates. The strict core reader rejects unknown fields in the
core envelope and events, duplicate event ids, non-finite values, out-of-range
times, and ambiguous references before accepting the fragment. These rules
make a regenerated fragment reviewable without making event detection part of
this contract.
V1 is bounded before retention or canonicalization. A contact-fragment source
is at most 8,388,608 bytes of UTF-8 JSON with at most 32 nested arrays/objects,
4,096 events, and 256 extensions. Every authored string is at most 4,096 UTF-8
bytes; identifiers (including event_id, producer fields, schema ids, source
keys, and logical ids) are additionally at most 255 bytes, except the exact
64-lowercase-hex SHA-256 spelling. One extension payload is at most 262,144 JCS
bytes and 16 levels deep. The complete canonical fragment is also at most
8,388,608 JCS bytes. A contact-transform-result source and its complete
canonical result are each at most 16,777,216 bytes; it contains at most 4,096
control points and 4,096 event outcomes and embeds a contact fragment obeying
the preceding limits. Container depth is the number of object/array containers
on the path from the root to a value, inclusive: the top-level envelope object
has depth 1. An extension payload object participates in that full-envelope
count and is also depth 1 for its separate 16-level payload limit. A strict
reader accepts each exact maximum and rejects the first byte, element, string
byte, or nesting level above it while decoding, before retaining that excess
value or allocating an unbounded canonical buffer. Canonical byte limits are
enforced with a bounded JCS sink.
The published JSON Schema's maxLength limits are Unicode-code-point limits;
the reader's UTF-8 byte limits are deliberately stricter for non-ASCII text.
Extensions, when present, are an array of strict envelopes. Each envelope has
exactly schema, schema_version, and payload; schema is the extension's
versioned identity, schema_version is a positive integer, and payload is a
JSON object validated by that extension's schema. The core reader rejects
unknown envelope fields and preserves a well-formed unsupported extension as
opaque data; a consumer that requires its semantics may reject the fragment
with a typed unsupported-extension result. Core fields remain closed and an
extension cannot redefine them or add an engine event type.
A minimal collection-scoped envelope is illustrative of the normative shape (the two input identities are not second clip digests):
{
"schema": "urn:animsmith:schema:contact-fragment:1",
"schema_version": 1,
"producer": {"tool": "animsmith", "version": "0.6.0"},
"artifact": {"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "bytes": 123456},
"dependency_closure_identity": {"sha256": "1111111111111111111111111111111111111111111111111111111111111111", "bytes": 456},
"clip": {"scope": "collection", "logical_id": "com.example.pack/locomotion/walk-forward-in-place", "source": "walk-forward", "take_index": 0, "take_name": "Take 001"},
"duration_s": 1.2,
"events": [
{"event_id": "left-foot/0", "role": "left_foot", "phase": "marker", "time": 0.23, "confidence": 0.92},
{"event_id": "right-foot/0", "role": "right_foot", "phase": "begin", "window": {"start": 0.71, "end": 0.79}}
],
"extensions": [{"schema": "urn:example:contact-quality:1", "schema_version": 1, "payload": {"quality": "high"}}]
}
The standalone document clip variant replaces the collection fields with
clip_name and retains both envelope input identities. A host may deterministically
merge this fragment into its existing sidecar, but the host remains the sole
authority for that file's unrelated fields.
Trim, slice, resample, and time-warp are contract-preserving only when the
contact fragment is transformed in the same operation. The minimal transform
result has identity
urn:animsmith:schema:contact-transform-result:1 and fields schema,
schema_version, operation, input, outcome, event_outcomes, and,
only when outcome = "transformed", output; a refusal additionally has a
refusal object containing exactly a stable code and message for display.
The strict tagged operation object has exactly these structurally
representable V1 shapes:
{"kind":"trim","version":1,"interval":{"start":a,"end":b}};{"kind":"slice","version":1,"interval":{"start":a,"end":b}};{"kind":"resample","version":1,"mapping":"identity"}; and{"kind":"time_warp","version":1,"output_duration_s":d,"control_points":[{"input_time":0,"output_time":0}, ...]}.
The control-point array is ordered, finite, starts at (0, 0), ends at
(1, 1), and has strictly increasing input and output coordinates. The mapping
is piecewise linear: an exact input knot maps to its declared output knot; for
adjacent knots (x0, y0) and (x1, y1), an input t strictly between them
maps to y0 + ((t - x0) / (x1 - x0)) * (y1 - y0). Point times and both window
endpoints use that same mapping. Structural decoding retains a known V1
operation even when its finite numeric domain or ordering is invalid, so a
refusal can echo the request. An unknown kind, version, field, mapping token,
missing field, or malformed field type is a strict request/reader error and
produces no V1 transform result or event outcomes. input
has exactly
{artifact:{sha256,bytes},dependency_closure_identity:{sha256,bytes},fragment:{sha256,bytes}}
and refers to
the separately supplied input fragment; the input fragment is not duplicated
inline. A successful output has exactly
{artifact:{sha256,bytes},dependency_closure_identity:{sha256,bytes},fragment:{sha256,bytes},contact_fragment};
its
inline contact_fragment is the complete transformed
urn:animsmith:schema:contact-fragment:1 value. The output fragment
identity must equal the canonical serialized inline fragment's {sha256, bytes}, and the output artifact identity must equal the inline fragment's
artifact binding. The output dependency_closure_identity must equal the
inline fragment's closure binding and must be freshly captured for the output
artifact rather than copied from the input. Its captured closure's
primary_input must equal the output artifact. The output is absent, never a
null placeholder, on refusal.
V1 operation mappings are exact: trim and slice carry a finite retained
interval [a, b] with 0 <= a < b <= 1 and map an in-range t to
(t - a) / (b - a); resample carries the identity mapping on normalized
time; and time_warp carries the piecewise-linear control points above. A point outside
a trim/slice interval has outcome outside and is omitted only from a
successful output fragment. For a point, t < a || t > b is outside and both
endpoints are retained. A window is wholly outside exactly when
end < a || start > b, and is fully contained exactly when
a <= start && end <= b; equality at either retained endpoint is contained.
Every other window crosses a boundary and has outcome refused with code
partial_window, refusing the entire operation—no partial window is silently
clamped.
Preserving an opaque extension through an artifact transform cannot prove
that extension-owned times or event references remain valid. A transformer may
succeed only when it recognizes every extension schema/version and implements
that extension's operation-specific transform contract. Otherwise it refuses
the whole operation before event inventory with top-level code
unsupported_extension, an empty event_outcomes, and no output fragment.
The core transform context therefore carries the handler-produced extension
outputs, in exact input order and with the same schema/version at every
position. It never treats a caller-declared support set or an automatic opaque
payload copy as proof that an extension was transformed.
The inline output fragment's duration_s is exact: trim and slice use
input_duration_s * (b - a), resample preserves input_duration_s, and
time_warp requires a finite positive output_duration_s field in its
operation object, which the output fragment must equal. These duration rules
apply even when every event is outside the retained interval. If the rounded
trim/slice duration is not finite and positive, the operation refuses before
event inventory with invalid_value and an empty event_outcomes list.
Every V1 time and duration number is a finite IEEE 754 binary64 value.
Arithmetic uses round-to-nearest, ties-to-even after each operation in the
following order; fused multiply-add and extended-precision intermediates are
not permitted. Write rn(x) for that rounding. Trim/slice first computes
span = rn(b - a), then mapped = rn(rn(t - a) / span) and
output_duration_s = rn(input_duration_s * span). Between time-warp knots it
computes dx = rn(x1 - x0), alpha = rn(rn(t - x0) / dx),
dy = rn(y1 - y0), and mapped = rn(y0 + rn(alpha * dy)); an exact knot
bypasses interpolation and returns its declared output_time. Resample returns
the decoded input value unchanged. Any non-finite intermediate is
invalid_value. These binary64 results are the numbers supplied to JCS.
After input binding and fragment identity validation, event_outcomes contains
exactly one object per input event, in the fragment's canonical input order.
Each object has exactly event_id and outcome, plus value only when
outcome = "transformed" (the exact mapped point or window) and code only
when outcome = "refused". A pre-inventory binding, fragment-identity,
operation-validation, or extension-support refusal uses an empty
event_outcomes list. Global success means
outcome = "transformed", an output is present, and every event outcome is
transformed or outside; global refusal means outcome = "refused", a
typed top-level refusal is present, and output is absent. A refusal discovered
after inventory has at least one refused event outcome. partial_window means
the exact crossing predicate above; invalid_mapping means a structurally
known operation has an out-of-domain interval, invalid endpoint/order, or
non-monotonic control points; invalid_binding means the supplied fragment,
primary artifact, or dependency-closure identity does not match current input;
and invalid_value means valid finite inputs produced a non-finite arithmetic
intermediate or a rounded trim/slice duration outside the fragment's finite
positive domain. Unsupported extension transformation uses
unsupported_extension as specified above. A malformed fragment/result or
duplicate, missing, or unknown event-outcome identity is a strict reader error,
not another refusal result. The result records the operation, bindings, and
outcomes. Core implements the exact mapping and independently rederives a read
result against its separately supplied input fragment and external transform
context. That context carries the current input and output artifacts, their
complete captured closures, the expected output producer, and any
operation-specific transformed extension outputs. Core validates that each
closure's primary input is its corresponding artifact before deriving the
identity recorded in a successful result; detector policy, asset mutation, and
publication remain outside this contract.
AnimSmith owns the strict sampled stance-support contact facts and these identity/time/transform semantics. The host owns final file layout and merge, unrelated measurements and provenance, runtime scheduling, and engine-native mapping. The delivered contact-fragment producer does not perform contact transforms, infer gameplay meaning, validate foot placement, or claim engine, artistic, or gameplay correctness. Host-side merge and runtime behavior remain deferred.
F.10A Skeleton compatibility V1 (#158)
Issue #158 adds a strict structural seam for declared source/target skeleton comparison without implementing retargeting, mesh rewrite, engine-avatar generation, masking, contacts, or gameplay proof. Its immutable JSON result identity is:
urn:animsmith:schema:skeleton-compatibility:1
The public command is
animsmith skeleton compare SOURCE TARGET --correspondence FILE --format json|text.
FILE is a separate strict TOML authority with independent identity
urn:animsmith:skeleton-correspondence:1. It binds the exact source and
target primary input identities, explicit selected roots and finite selected
node-name sets on both sides, either exact-name or one-to-one explicit
correspondence, and every numeric tolerance. Unknown fields, duplicate names
or targets, malformed or stale identities, ambiguous selectors, non-finite
tolerances, oversize control input, or source/target primary inputs above the
existing 64 MiB bound are operator errors that emit no result.
The result records exact source and target primary identities, complete
dependency-closure identities when same-load capture is complete, the exact
correspondence bytes, canonical selected-skeleton provenance for each side,
deterministic row order, and separate non-scoring evidence facets for source
skin membership, inverse-bind accessors, and deformation model coverage.
Topology/rest rows are the only scoring surface in V1. compatible means
every required row was available and within tolerance; partial retains mixed
available and unavailable required evidence even when another retained field
mismatches; otherwise any required mismatch is incompatible; and
not_evaluated means no correspondence row was evaluable. Missing source/target
names and parent mismatch remain explicit typed rows rather than being
collapsed into an aggregate score.
The seam compares only the declared selector members. It does not infer a
skeleton subtree from attachments, scene reachability, or skin membership.
Parent correspondence and normalized child length walk through unselected
intermediate nodes to the nearest selected ancestor, so they describe the
declared projection and do not claim complete raw-hierarchy equality.
selected_skeleton_identity is derived provenance, not a copied authored
profile: AnimSmith serializes a canonical, name-ordered projection of each
selected node's measured structural evidence and the declared selector, then
records that exact identity. Local and rest-world transform deltas reuse the
existing normalized source-skeleton measurements contract; world-space
comparison stays unavailable when the loaded source evidence does not prove a
finite right-handed unit-orthonormal rest-world basis.
Non-pass rows and unavailable evidence sides carry a typed remediation surface and likely remedy class. This routes work to correspondence, the selected skeleton authority, selected skin/bind evidence, or a measurement boundary without guessing whether a project integrator, artist, or vendor is to blame. The human text view renders those fields together with exact identities, matching authority, tolerances, row deltas, and facet details.
F.10B Foot-cycle parameterization planner V1 (#18)
Issue #18's first post-result implementation slice adds the separate strict
urn:animsmith:schema:foot-cycle-parameterization:1 TOML declaration and pure
format-neutral map planner. The declaration binds exact manifest bytes, one
existing ordered gait-group, one explicit reference, one safe fragment path
per member, the future generation directory, and finite positive segment-slope
bounds whose inclusive interval contains identity slope 1.0. It also requires
a closed [proof] table whose finite maximum gait-phase spread and contact
boundary phase error each lie in [0, 0.5], and whose finite minimum
left/right amplitude is non-negative. These values are bound by the exact
parameterization bytes and retained in the plan, with no defaults, config
lookup, inference, or cross-member merge. The planner
accepts only canonical collection-scoped fragments with
the exact known stance-detector V1 provenance and independently measured typed
Root/Hips evidence bound to the same exact artifact, dependency closure, and
collection source/take witness. Missing, ambiguous, malformed, or non-finite root evidence
refuses. It retains signed endpoint X/Z displacement and signed accumulated
unwrapped yaw, while the unchanged admission applies binary64 horizontal
hypot(X, Z) at 0.01 m and absolute yaw at 1 degree; this pure slice does not
derive those source measurements. It partitions positive windows and markers
by side, requires one marker per physical window and strictly disjoint
same-side runs, and preserves those physical rows. A distinct same-side first
run beginning at 0 and last run ending at 1 coalesce into one logical
cyclic stance whose artificial seam edges do not enter correspondence; a full
[0,1] stance refuses. Opposite-side overlap and touch are valid. Logical
left/right counts must be equal and nonzero, with alternating onsets around the
cycle. The planner builds the true logical boundaries in deterministic cyclic
chronological order, rotates correspondence (not phases) to a true left onset,
and requires one identical boundary signature across members. It pairs
boundaries positionally and maps them to the reference's authored phases.
Simultaneous boundary groups must agree on both source and reference instants;
identical map pairs collapse before (0,0) and (1,1) are included and the
retained 4,096-knot cap is checked. It returns bounded duration-preserving
time_warp operations with exact contact input bindings. All members must use one exact detector contact
height because that policy determines the compared boundaries. The whole ring
admits at most 16,384 contact events and 32 MiB of canonical fragment bytes
before topology retention and repeated canonicalization, and marker/window
validation is ordered rather than cross-product work. Only the correspondence
topology is circularly normalized: phase zero is not rotated, so incompatible
wrap/non-wrap correspondence or any other mapping that cannot remain strictly
monotone and endpoint preserving refuses. This slice does not load or mutate
animation assets, transform fragments, derive its required typed in-place
Root/Hips evidence, serialize candidates, prove output, or publish the
collection generation.
F.10C Foot-cycle clip candidate V1 (#18)
The next independent slice is the pure format-neutral application of one
planned member map to an already selected Clip. time_warp_clip_v1 validates
track-local clip/track shape again, requires the plan's exact duration-preserving
V1 operation, and returns a cloned candidate without mutating its input. This
Clip-only seam has no skeleton input, so its host must already have bound every
track bone index to the selected, validated skeleton.
Normalized control points map source time to output time: LINEAR tracks retain
all mapped authored keys and add interior map-knot samples with the existing
runtime sampling semantics, while STEP tracks map only authored breakpoints.
Binary64 calculations may narrow to finite binary32 track times, but distinct
source or output instants that collide after narrowing refuse rather than being
silently collapsed. A map knot and an authored key that are the same instant in
that emitted binary32 domain — equal, or one representable place apart, since
nothing is representable between them — are one emitted key at the authored
time and value, carrying the control point's own output.
FootCycleClipWarpKnotV1 is the single definition of that question, and
time_warp_rows_v1 merges a track's authored keys with its knots into the key
sequence a candidate stores. A knot-bearing row carries that knot — its
control-point index and that point's two narrowed times — and no stored value,
so the independent ClipMap proof in the CLI crate consumes the rows for the
sequence and reads only the index, narrowing the control point itself rather
than spelling coincidence a second way. What the proof takes from core is which
keys exist and in what order; every number is its own, including a knot's two
times, which it narrows again from the control point the row names rather than
reading the producer's resolution of them. Tests freeze the row sequence and
perturb producer times by one binary32 place in both directions: the proof
rejects changed stored outputs and inserted-knot samples while still accepting
the independently correct candidate. A coalesced row retains its authored
value, so its source-time metadata is unused after row selection; perturbing
that metadata alone remains accepted, while resampling a changed value is
rejected. That holds for every authored key the map has a control point for, including a track's
own first and last: the span test is inclusive of one place on either side, so
which side of a rounding step the reconstructed instant lands on does not
decide the outcome. A key the knot is bit-equal to binds before a key it is
merely beside, and coalescing only ever re-times an authored key — it never
removes one. The single exception is an interior point that is the same
instant as a source endpoint, 0 or the duration: it contributes nothing,
because the exact endpoint rows define the output there and a candidate retains
those two instants as themselves. Identity maps return a structurally identical
admissible clip.
CUBICSPLINE remains deliberately conservative: one-key tracks are retained; multi-key tracks are retained only when every stored key value is bit-exact equal and all stored input/output tangents are zero. Any other cubic track refuses the whole candidate. Finite quaternion keys must also have a finite positive binary32 squared length before any runtime sampling. Fixed checked caps refuse N+1 retained name bytes, tracks, authored keys/values, generated keys, and aggregate inspection work before candidate allocation. Candidate storage has a derived public upper bound: retained cubic storage is already bounded by authored values, while LINEAR/STEP emit one value per bounded generated key. This slice does not prove that the plan belongs to the loaded artifact, transform contact fragments, serialize or reread an output, or publish a generation directory. Those bindings and atomic transaction steps remain later #18 work.
F.10D Foot-cycle source preparation V1 (#18)
The CLI crate's next private seam binds the two prior pure boundaries to exact local source bytes without adding a command. It bounded-reads the manifest and parameterization once, resolves the exact member-reachable manifest source/config subset in canonical manifest source-key order plus every parameterization contact/output locator under its explicit declaration root, and rejects symlinks, canonical collisions, and platform-observable hardlink aliases before parsing any motion source. The future output leaf must be absent under an existing symlink-free parent. Pure manifest/runtime-set binding is validated first. Per-source and aggregate byte caps then cover only the exact member-reachable source subset, retained in manifest order; unrelated manifest sources do not gate this collection-scoped operation. Every reachable source and its exact config basis are loaded. Both the normalized config semantics and the explicit config byte identity are retained for later proof without a control-file reread. Any declared digest pin is enforced, while complete dependency-closure identities, complete raw take inventories, and exact index/name-to-normalized-clip witnesses are mandatory.
Each normalized reachable document passes the core's strict structural shape
validation before it can reach sampling or candidate work. The adapter then
preflights every selected member's frames * bones pose cells and frames * tracks sampling work, including the complete invocation totals, before
constructing the first MetricGrids grid. It uses configured role resolution
and root_trajectory_metrics as the existing authority. Horizontal
admission is the binary64 hypot of retained signed X/Z endpoint components
and yaw admission is the absolute value of retained signed accumulated
unwrapped yaw. Missing, ambiguous, non-finite,
or over-threshold evidence remains typed and makes the core planner refuse. The
prepared collection retains the exact aggregate source pose-cell and
sample-evaluation totals for a later shared source/output proof budget. The
adapter cross-checks every plan's member, source artifact, closure, canonical
fragment, selected take, duration, and root binding before building the pure
clip candidate. Any later member failure discards all candidates; preparation
does no filesystem mutation.
Contact output remains intentionally unfinished at this boundary. Core exposes
one operation-aware handler that validates and reconstructs the exact closed
stance-support detector payload for time_warp; the adapter retains that
handler output together with the exact input fragment, artifact, closure, and
operation. It cannot truthfully construct the output artifact identity or
output dependency closure until a complete candidate document is serialized.
The typed continuation accepts those later exact identities and only then calls
transform_contact_fragment_v1. This slice therefore performs no
serialization, generated-artifact hashing, reread/proof, generation staging,
publication, or CLI exposure.
F.10E Bounded candidate GLB projection V1 (#18)
The next private format-edge seam is an in-memory GLB writer, not a collection producer. It first makes an allocation-free strict admission pass over the normalized document: checked row, name-byte, and traversal-work ceilings bound the modeled writer arrays, scalar binary components, validation lookups, source sidecars, and copied texture bytes before it constructs writer-owned JSON. It then counts the single shared projection with checked JSON/BIN/GLB framing arithmetic and returns an immutable receipt containing padded chunk sizes, framing size, and JSON/BIN digests. The receipt binds that emitted projection, not deliberately excluded source-only provenance. Retaining output reruns the projection and accepts only the same receipt, reserving exact output capacities before copying bytes.
The legacy path remains its established permissive conversion projection. The
new StrictFootCycleV1 policy instead fails closed on any normalized fact that
the writer cannot preserve: omitted clips/tracks, primitive or texture domains,
non-explicit skinned inverse binds, invalid primary skin slots, incompatible
skinned/unskinned mesh reuse, meshless materials, scene membership collapse,
or source-local rest and source-skin root facts without an exact normalized
representation. Strict output attaches a skinned mesh to its normalized node,
rather than applying legacy's generated-holder normalization. Non-empty
complete source material-resource sidecars refuse: V1 has no source
texture/image identity or inspection-fact projection. An exact complete-empty
resource graph is admitted only when the writer-facing material list is also
empty. Source-only names, stable
source indices, and image-inspection/provenance evidence remain deliberately
outside the output projection; they are not presented as candidate facts.
This seam does not bind a prepared collection member to its source, serialize
contact transforms, reread/prove output, stage files, publish a directory, or
add a CLI command.
F.10F Foot-cycle serialized-candidate proof V1 (#18)
The next private transaction consumes one complete prepared collection without
adding filesystem or command behavior. For every declared member it clones the
member's retained source document and replaces only the selected clip with the
prepared candidate. It validates the document, runs StrictFootCycleV1
preflight, sums exact framed GLB sizes through one checked 256 MiB candidate
ceiling, and admits the combined source-plus-output metric-grid pose-cell and
sample-evaluation work before the first candidate write. All candidate writes
finish before any readback; every exact byte vector is then reread through the
ordinary glTF loader. Any refusal drops the local vectors and returns no partial
batch.
The reread artifact identity must equal the exact retained bytes. Its dependency
closure must be complete and bound to that primary identity, with no external
resources and no reference target other than Primary; embedded BIN-buffer
references therefore remain valid self-contained evidence. Only then does the
transaction finish the prepared contact transform with the reread artifact,
closure, and independently redetected producer identity.
Proof does not treat the existing checks as its verdict. It independently
revalidates the finite strictly increasing endpoint-preserving map and exact
LINEAR/STEP/cubic candidate relationship, including the constant-value,
zero-tangent multi-key CUBICSPLINE restriction. One already-budgeted output grid
per member supplies fresh contact topology/boundaries, gait phase/amplitude,
signed Root/Hips trajectory, and per-bone loop pose plus linear/angular seam
velocity. Contact and gait use the declaration's required proof fields; root
facts are compared to retained signed source facts under the fixed 0.01 m and
1 degree V1 limits; loop facts use the exact captured source config's effective
per-clip/check/default tolerance resolution. The result retains exact artifacts,
closures, completed transforms, independently detected contacts, proof facts,
phase spread, and separate source/output/combined work totals for the later
evidence and publication transaction. Its contact proof independently repeats
the per-side marker/disjointness validation, seam coalescing, logical-count and
cyclic-onset checks, and deterministic true-boundary ordering rather than
calling planner internals. Before its circular numeric error comparison, proof also
requires the expected and independently detected (side, edge) signature in
absolute phase-zero chronological order to match, so a complete left/right
role swap remains a topology failure at every numeric error tolerance. It
defines no public evidence schema,
filesystem layout, publication, or CLI behavior.
F.10G Foot-cycle generation producer V1 (#18)
The final CLI-private transaction exposes exactly
collection transform-foot-cycle COLLECTION.toml --parameterization FOOT-CYCLE.toml --format json; the required JSON-only format has no default
and the parameterization is the sole destination authority. The glTF-only
build retains this command, while FBX admission remains feature gated. Every
successful source becomes a self-contained strict GLB.
For N members the fixed generation has checked 3N+1 cardinality and exact
declared order: each members/NNNNNN/ contains artifact.glb,
contact-fragment.json, and evidence.json, followed by the root
aggregate-evidence.json. The member evidence V1 contract is
urn:animsmith:schema:foot-cycle-member-evidence:1; it binds exact control
inputs, source artifact/config/complete closure, output artifact/complete
closure, transformed and independently redetected contacts, canonical map
identity, policy, proof facts, and deterministic relative paths. Aggregate
evidence V1 is urn:animsmith:schema:foot-cycle-aggregate-evidence:1; it binds
the ordered member summaries and member-evidence identities,
phase spread, component and exact total bytes, retained GLB bytes, and
source/output/combined proof work. Strict bounded readers and published JSON
Schemas close both records.
All GLB, fragment, member-evidence, and aggregate-evidence bytes share one 256
MiB retained/staged ceiling; each component retains its narrower contract cap.
Checked arithmetic admits the exact file count and byte total, including the
aggregate record's own converged serialized size, before the first filesystem
write. One publish_generation call stages and flushes the complete fixed set
under a sibling directory, then performs one platform no-replace directory
rename. An existing or racing destination wins unchanged. This is atomic
visibility of a new local generation, not replacement, power-loss, network
filesystem, or crash-cleanup atomicity.
Success emits the exact aggregate file bytes to stdout only after publication;
a broken stream is best-effort diagnosed and retains exit 0. Valid asset,
capability, refinement, contact-transform, or proof refusal publishes nothing,
emits one producer-refusal:1 record, and exits 1. Malformed or stale control
bindings, unsafe/aliased paths, I/O, serialization, publication, and race
failures publish nothing, keep stdout empty, and exit 2. The producer never
rereads mutable manifest, parameterization, config, or contact controls after
their exact capture and never substitutes the writer's candidate model for the
independent serialized readback proof.
F.11 Transition-family declaration V1 (#148)
Issue #148 freezes declarations for transition families without implementing transition checks, reports, inferred graph edges, or runtime generation. Its independent identity is:
urn:animsmith:schema:transition-family:1
The document-local tables are admitted by the CLI's normal strict config loader, which retains the whole-config exact input identity alongside the normalized declaration. The JSON-only document command and manifest-bound collection command now evaluate that authority. The collection form binds its exact manifest before source work and reloads only manifest-witnessed takes.
The format-neutral core now owns the strict SkeletonBasisV1 identity, both
document and collection transition-pose evaluators, and their shared result contract
urn:animsmith:schema:transition-pose-evaluation:1. It admits a mutable
loaded document's skeleton and selected T/R tracks, plans bounded endpoint
work before sampling, and returns an
immutable complete/finding/not-evaluated result. The collection adapter
reloads only manifest-witnessed sources; neither adapter adds a lint check.
The document adapter is the
JSON-only animsmith evaluate-transition-poses INPUT --format json command:
it loads one document through the normal public loader/config path, binds the
loaded exact raw document identity, and emits the core result unchanged. It
uses the exact zero-byte TOML sequence as the defined omitted-config
declaration source, so an omitted config and an explicitly empty config have
the same exact and normalized declaration identities. The collection adapter
preserves every declared family member and makes an unavailable or unusable
source a whole-family incomplete result rather than evaluating a subset.
Its scope-neutral subject_input binds the exact raw document in this
document evaluator; the collection adapter binds its manifest subject under
the same result schema. The normalized declaration scope determines
which subject meaning applies. For document scope, the public evaluator accepts
the loader's same-load DependencyClosureV1, takes subject_input only from
its primary_input, and retains its complete
subject_dependency_closure_identity. Every document member retains the same
primary identity as source_input and the same complete closure identity as
source_dependency_closure_identity. Complete coverage and a present
DependencyClosureIdentityV1 are both required before any configured family
can be complete; partial, unavailable, or identity-less coverage makes every
configured family incomplete/not_evaluated with
dependency_closure_incomplete. The no_configured_families result is the
deliberate exception because it performs no source evaluation and remains a
complete pass even when closure capture is unavailable.
Collection scope does not fabricate a dependency closure for its manifest:
subject_input is the manifest's primary identity, while each available
member independently retains its raw source_input and complete
source_dependency_closure_identity. An unavailable source may retain a null
source_input when no primary bytes were obtained. One missing or incomplete
member closure makes the entire family dependency_closure_incomplete, but
other available members keep their closure identities as positive evidence;
the family is never evaluated as a survivor subset. Thus unchanged primary
text glTF bytes cannot mask changed external animation-buffer bytes.
Before sampling, the evaluator directly verifies every declared index/name
witness, admits at most 4,096 document clips for its one-pass global
duplicate-name proof, 4,096 bones, and 8 MiB of basis-name text. A document
above the clip cap with no direct witness contradiction yields normal
input_limit family rows without scanning its full name domain. Each selected
clip first admits at most three raw flat track rows per bone (12,288 maximum),
then bounds the consumed translation/rotation domain to two channels per bone.
Scale values, shape, and duplicates remain semantically ignored, but raw scale
rows count toward that first resource admission because the loader stores all
properties in one vector. Across the invocation it inspects at most the V1
aggregate-comparison bound of 16,777,216 selected time/value elements. A
valid-witness input exceeding one of these admission limits retains every
family as incomplete/not_evaluated with input_limit;
unrelated assets and unselected clips are outside this evaluator boundary.
Before sampling it serializes the full pair-free authority, including subject
and per-member dependency-closure identities, and subtracts that from a
conservative detailed JCS reservation, producing result_limit rather than
cloning a representation that must later be discarded as over-cap.
The declaration has a tagged scope, a stable family_id, an explicit
ordered members array, a boundary (entry, exit, or both), a typed
basis, and typed named tolerances. A family has at least two members.
family_id is unique within its owner and is never inferred from filenames,
directories, embedded names, or member order. Unknown fields and duplicate
members are invalid.
Document-local family ids are one lowercase-ASCII token, 1–255 bytes, starting
with [a-z0-9] and continuing with [a-z0-9._-]. The table key is the id and
there is no duplicate family_id field. TOML must quote the key whenever it
contains punctuation or a dot (the canonical spelling quotes it always), for
example [transition_families."walk_to_run"] or
[transition_families."combat.entry.v1"]. Collection family ids retain
Appendix F's slash-qualified logical-id grammar and collection-id prefix.
The two scopes are intentionally different authorities:
documentdeclarations are placed in the existinganimsmith.tomlconfig basis under[transition_families."<family_id>"]. The table key itself is the family id; it is a reusable config declaration and carries no artifact digest. The document evaluator binds the exact primary documentInputIdentityand complete dependency-closure identity in its output, while members use exact embedded take-index/name witnesses. A repeated or loader-ambiguous take name is a strict resolution failure.collectiondeclarations use a separate declaration envelope, not an extension of collection-manifest V1 and not another path/member authority. The envelope binds the exact collection-manifestInputIdentity{sha256, bytes}, collection id, and one or more family records. Each record references a declared logical clip id plus its correspondingsource, take-index, and take-name witness.
A declaration cannot mix scopes, use a filesystem path, use an animation-array
index as identity, or import a member from another document or collection.
Missing, duplicate, cross-scope, and ambiguous members are strict resolution
failures. stale_digest is a collection-only failure: it means the envelope's
manifest InputIdentity no longer matches the declaration. A source digest
pin, when present in that manifest, is enforced by manifest resolution and a
mismatch makes the affected member unavailable; it is not a second identity
carried by this declaration. A reusable document-local config has no
stale-digest state at parse time; the document evaluator binds the exact primary
document InputIdentity plus its complete dependency-closure identity and
reports its source/take resolution in its output. A family
is not silently reduced to the members that happen to resolve, and a
collection family is not copied into a document-local config. The existing [clips], [gait_groups], and
[sync_groups] sections retain their document-local embedded-name semantics;
this declaration does not replace, reinterpret, or create a second authority
for them. The document-local table and collection envelope share the same
transition-family:1 family-record semantics; only their ownership and
placement differ.
V1's basis is explicit and engine-neutral: translation is metres in the
declared skeleton-local basis, rotation is degrees in the same basis, and time
is normalized clip time in [0, 1]. The named tolerance record is closed and
unit-bearing: translation_m, rotation_deg, and time_normalized, each a
finite non-negative value. boundary selects whether the evaluator
considers entry, exit, or both; it does not itself perform a comparison.
Additional policy fields require a separately versioned contract, not a
generic TOML map or untyped extension payload.
Both declaration placements are bounded before retention or canonicalization. The exact declaration source is at most 8,388,608 bytes and 16 nested tables/arrays, with at most 4,096 families, 4,096 members in one family, and 16,384 members across all families. Authored strings are at most 4,096 UTF-8 bytes, with the 255-byte identifier limits above applied more strictly. The normalized JSON envelope is at most 8,388,608 JCS bytes and has the same nesting limit. Depth counts each table/array or JSON object/array on the root-to-value path, inclusive; the root table or envelope object is depth
- The strict reader accepts each exact maximum and rejects the first byte, element, string byte, aggregate member, or nesting level above it while decoding, before retaining that excess value or allocating an unbounded canonical buffer. Canonical byte limits are enforced with a bounded JCS sink.
For example, a collection-owned declaration can be written as the following typed TOML-like value. The logical id and source/take witnesses must agree with the collection manifest; they are repeated here to make a stale or reordered member explicit rather than silently resolving by name:
schema = "urn:animsmith:schema:transition-family:1"
schema_version = 1
scope = "collection"
collection_id = "com.example.pack"
manifest_input_identity = { sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", bytes = 9876 }
[[families]]
family_id = "com.example.pack/transitions/walk-to-run"
boundary = "both"
[families.basis]
translation = "skeleton-local-metres"
rotation = "skeleton-local-degrees"
time = "normalized-clip"
[families.tolerances]
translation_m = 0.05
rotation_deg = 5.0
time_normalized = 0.02
[[families.members]]
logical_id = "com.example.pack/locomotion/walk-forward-in-place"
source = "walk-forward"
take_index = 0
take_name = "Take 001"
[[families.members]]
logical_id = "com.example.pack/locomotion/run-forward-in-place"
source = "run-forward"
take_index = 0
take_name = "Take 001"
The document-local placement uses the existing config basis and the same family-record fields:
[transition_families."walk_to_run"]
schema = "urn:animsmith:schema:transition-family:1"
schema_version = 1
scope = "document"
boundary = "entry"
[transition_families."walk_to_run".basis]
translation = "skeleton-local-metres"
rotation = "skeleton-local-degrees"
time = "normalized-clip"
[transition_families."walk_to_run".tolerances]
translation_m = 0.05
rotation_deg = 5.0
time_normalized = 0.02
[[transition_families."walk_to_run".members]]
take_index = 0
take_name = "Walk"
[[transition_families."walk_to_run".members]]
take_index = 1
take_name = "Run"
After strict validation, the normalized JSON representation has exactly one of the following two closed envelope shapes. The collection form is:
{
"schema": "urn:animsmith:schema:transition-family:1",
"schema_version": 1,
"scope": "collection",
"collection_id": "com.example.pack",
"manifest_input_identity": {"sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", "bytes": 9876},
"families": [{
"family_id": "com.example.pack/transitions/walk-to-run",
"boundary": "both",
"basis": {"translation": "skeleton-local-metres", "rotation": "skeleton-local-degrees", "time": "normalized-clip"},
"tolerances": {"translation_m": 0.05, "rotation_deg": 5.0, "time_normalized": 0.02},
"members": [
{"logical_id": "com.example.pack/locomotion/walk-forward-in-place", "source": "walk-forward", "take_index": 0, "take_name": "Take 001"},
{"logical_id": "com.example.pack/locomotion/run-forward-in-place", "source": "run-forward", "take_index": 0, "take_name": "Take 001"}
]
}]
}
The document form lifts the repeated table contract fields into one envelope;
the table key becomes family_id:
{
"schema": "urn:animsmith:schema:transition-family:1",
"schema_version": 1,
"scope": "document",
"families": [{
"family_id": "walk_to_run",
"boundary": "entry",
"basis": {"translation": "skeleton-local-metres", "rotation": "skeleton-local-degrees", "time": "normalized-clip"},
"tolerances": {"translation_m": 0.05, "rotation_deg": 5.0, "time_normalized": 0.02},
"members": [{"take_index": 0, "take_name": "Walk"}, {"take_index": 1, "take_name": "Run"}]
}]
}
No owner or source-input identity is injected into the document form. A V1
evaluation record binds three distinct facts: the exact declaration-source
InputIdentity (the whole config for document scope or the declaration TOML
for collection scope), the normalized declaration InputIdentity computed
over the JCS bytes above, and the evaluated document or collection-manifest
InputIdentity. The collection form additionally retains its embedded exact
manifest binding. These identities are never interchangeable.
The strict reader rejects malformed/duplicate declaration members, and the document evaluator rejects missing or ambiguous document witnesses before it emits a result. The collection adapter additionally rejects stale or cross-scope members. The collection envelope's manifest digest/bytes binding makes a manifest reorder or edit stale even when its collection id and logical ids are unchanged.
Canonical serialization first sorts decoded family declarations by their
stable family_id and preserves declared member order, then serializes that
typed JSON representation with RFC 8785 JCS. The exact source TOML
InputIdentity remains a separate binding and is never replaced by the
normalized JSON identity. The strict reader validates owner/scope, member
syntax, basis, units, finite tolerances, and canonicalization before a
declaration is accepted. The document and collection commands produce V1
findings/reports; required gameplay metadata, inferred edges, state machines,
blend trees, and runtime transition generation remain follow-up work.
F.12 Directional-speed policy/evaluation V1 (#552)
The directional-speed policy is a separate, manifest-bound declaration for directional-blend runtime sets. It is not a field in collection-manifest V1, does not revise collection-output V2, and does not infer membership from filenames, paths, engine controller types, or other host metadata. The typed contract and strict TOML reader feed the evaluator and its JSON-only CLI consumer; no new manifest or collection-output authority is introduced.
The schema identity is
urn:animsmith:schema:collection-directional-speed-policy:1, with
schema_version = 1. Its wire envelope has exactly these top-level fields:
schema, schema_version, manifest, runtime_set_id, source_basis,
diagonal_behavior, direction_tolerance_deg, mode, the mode-specific
fields, and members. manifest has exactly the collection-manifest V1
schema/version, collection_id, and input = { sha256, bytes }; that input
identity is the exact manifest identity, not a digest of normalized values.
runtime_set_id is a logical id that must bind to a manifest runtime set of
kind directional-blend.
The explicit source_basis contains x and z, each a semantic 2-D
orientation witness for the raw collection-output V3 +X/+Z endpoint
displacement. Their magnitudes are nonsemantic; the evaluator uses unit axes
for heading comparison. Every component is finite and has absolute value
at most 1,000,000. Each axis is nonzero. Perpendicularity is judged by the
normalized dot product
abs(dot(x / ||x||, z / ||z||)) <= 1e-9.
The normalized calculation uses hypot lengths and component-wise division,
so its decision is independent of axis scale and does not square tiny values
or overflow large bounded values. The 1e-9 cosine threshold is the frozen
COLLECTION_DIRECTIONAL_SPEED_POLICY_V1_MAX_AXIS_COSINE bound. It rejects
near-parallel and diagonal axes, including tiny vectors, while accepting
orthogonal subnormal-scale axes when their finite representation has a
nonzero length.
members is an ordered sequence. Each member has exactly one logical id
and one semantic coordinate = [x, z]; it may additionally carry
speed_mps only for authored mode or expected_ratio only for ratios mode.
Coordinates are finite, bounded by the same component limit, nonzero by
component value, and unique by exact finite bit identity after canonicalizing
both signed zeros to one zero key. The reader preserves declared order. A
consumer binds the policy only when the manifest identity, runtime-set id,
runtime-set kind, and complete member sequence all match exactly; missing,
extra, or reordered members are binding errors.
The closed diagonal_behavior tokens are preserve and normalize. The
existing speed_mps and expected_ratio fields are unit-input/base targets.
For a semantic coordinate c, preserve defines gain g(c) = hypot(c) and
normalize defines gain g(c) = 1. Therefore uniform expected speed is
base * g(c), authored expected speed is the member base speed_mps * g(c),
and ratios compare
expected_measured_ratio_i_to_ref = declared_expected_ratio_i * g(c_i) / g(c_ref), with the reference declaration fixed at 1.0. This gain does not
affect direction comparison. The declaration carries finite
non-negative direction_tolerance_deg, bounded inclusively by 180 degrees.
The closed speed modes are:
uniform: requires finite non-negative boundeduniform_speed_mpsandspeed_tolerance_mps; member speed and ratio fields are forbidden.authored: requires finite non-negative boundedspeed_tolerance_mpsand exactly one finite non-negative boundedspeed_mpsper member; uniform and ratio fields are forbidden.ratios: requires finite non-negative boundedratio_tolerance, areference_member, and exactly one finite non-negative boundedexpected_ratioper member. The reference member must be present and its expected ratio must be exactly1.0; speed fields are forbidden.
All scalar bounds are inclusive. Unknown fields, duplicate TOML keys,
unknown mode or diagonal tokens, missing or mode-inapplicable fields,
malformed lowercase SHA-256 identities, invalid manifest schema/version,
non-finite numbers, out-of-range components, invalid bases, invalid member
coordinates, duplicate member ids, and duplicate coordinates are strict
control errors. The source reader is capped at 8,388,608 bytes and 4,096
members; each exact limit is accepted and the first excess member is rejected
before it is retained. The reader first decodes only schema and
schema_version so unsupported declarations receive stable unsupported-schema
or unsupported-version classifications; it then performs the complete
deny-unknown-fields decode and typed invariant validation. This two-pass
lifecycle is classification, not a second policy authority.
There is no evaluator inference or fallback. The pure, format-neutral
collection-directional-speed-evaluation:1 result and typed adapter consume
already strictly decoded collection-output V3 evidence; they do not revise V3.
The immutable result binds the raw policy TOML
and evidence JSON InputIdentity values as well as the manifest, and retains
every member's coordinate, raw root-travel values, projected heading,
comparison values, pass/violation state, lifecycle, and gaps.
For complete evidence, finite endpoint X/Z displacement is normalized before
the unit source X/Z axes form its semantic heading. The declared coordinate is
also normalized; the included angle is atan2(abs(cross), dot) in degrees.
Speed uses published speed_mps, never travel. Every tolerance is inclusive,
so a finding requires strict excess. A zero endpoint, zero ratio reference, or
unrepresentable derived product/ratio yields a complete typed not-evaluated
result (zero_net_displacement, zero_reference_speed, or numeric_range)
without a subset or fabricated finding. Preserve uses hypot(coordinate) as
the expected-speed gain; normalize uses one, and the ratio reference remains
exactly one. Findings retain manifest order, direction before speed/ratio.
animsmith collection evaluate-directional-speed --policy POLICY.toml --evidence COLLECTION-OUTPUT.json --format json captures policy bytes under the 8 MiB
cap and evidence bytes under the 256 MiB cap before retaining them, invokes
the strict readers/adapter/evaluator, and serializes the result once to stdout.
Invalid, stale, wrong-kind, unreadable, malformed, or over-budget control
inputs exit 2 with no result. Incomplete/not-evaluable evidence and policy
findings emit the result and exit 1; only a complete passing policy exits 0.
There is no filesystem publication, controller generation, retiming, stride
rewriting, or universal speed rule.