Skip to content

Phase 1 / Kinesis / Skeleton foundations - #79

Closed
guysenpai wants to merge 16 commits into
mainfrom
phase-1/kinesis/skeleton-foundations
Closed

guysenpai wants to merge 16 commits into
mainfrom
phase-1/kinesis/skeleton-foundations

Conversation

@guysenpai

Copy link
Copy Markdown
Contributor

Opens the Kinesis module and delivers the bottom of its dependency chain: a skeleton asset, its hierarchy, its bind pose, forward kinematics, and the one addressing type every later bone reference must pass through. Brief: briefs/m1.2.0-skeleton-foundations.md.

The layout ruling, and it was wrong twice before it was right

PoseBuffer freezes with AnimationModule and is snapshotted by rollback, so its layout is cheap to choose now and a save-format migration to change later. Three documents asserted one and none had measured one.

The bench decided it — and two instrument defects had to be found first, the second reversing the verdict the first had already corrected:

  1. The AoS body was written scalar against a vector SoA body, so it measured code and not layout. Equalising it moved a ratio from 0.936× to 1.006× at identical checksums.
  2. Memory order and element representation still varied together. A fourth layout — same interleaving, @Vector members — separated them and reversed the ruling.

Verdict: interleaved, with @Vector-backed elements. It wins the blend at every bone count (0.895–0.911× against AoS) and ties forward kinematics, where the sign flips between runs and no verdict is registered. The corpus's argument for SoA was vectorisation; the only form that vectorises loses the blend by a factor of 1.8. The corpus's literal AoS — one Tier 0 Transform per bone — is the slowest viable form, by ~10 % on the blend and 6–7 % on the kinematics.

What else the milestone establishes

  • Mat4 descends into foundation/math as an alias, preserving the type identity classifyField matches on. Its C twin had no assertion at all — related by a doc comment, zero use sites — and now has a field-by-field one.
  • Loading refuses a malformed hierarchy rather than repairing it. The topological order is an asset invariant checked at load, which makes a cycle unrepresentable rather than detected. error.NoRoot was written and removed: no input can reach it, and an error nobody can provoke is an assertion.
  • resolveBone is the one resolution path. A role the profile does not map resolves to absence, never to a neighbouring bone. Two bones of one name are refused at load, so a name never addresses two.
  • A counter-proof corpus pins the addressing type — a bare string, a bare index and a bare ordinal must not compile in a bone-reference position — with both halves shown to redden.

Method, which is most of the value

Eleven counter-factuals were run; the ones that found something outnumber the ones that confirmed something. Two instrument defects in the bench. A rotation-order error that left the whole suite green, because the acceptance chain rotates about one axis and its leaf carries the identity. A resolver indexing the mapping by ordinal that answered a neighbouring bone while every positive test stayed green.

An adversarial review then raised twenty-one findings, nineteen real, closed in this branch: three defects in shipped behaviour, eight guards that could not fail, and — sharpest — a guarantee affirmed in a comment the code did not give, committed in the act of closing an instance of that same class.

Measurements

Test floor 2290 → 2357 (2355 on windows-2025), re-derived FROM THE SUITE at every gate and confirmed independently by the dead-test closure each time. Four corners green locally (Debug/ReleaseSafe × f32/f64), lint, fmt, zig build clean, bone-ref-counterproof green, kinesis-skeleton-demo printing the hand-computed pose.

Two points for arbitration

  • RD-1 — the pose element is a Kinesis-owned type, not the Tier 0 Transform. Two readings of one frozen sentence, 10 % apart. Reversible in one edit.
  • RD-2b — the layout bench is not built on src/foundation/simd/, which the Scope names: that harness may not import foundation/math, and a pose bench needs Vec3, Quat and a Transform.

🤖 Generated with Claude Code

guysenpai and others added 16 commits September 13, 2026 13:30
The frozen section as produced by Claude.ai, with the eight specs of the
reading list ticked off in the living section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Mat4(T)` is declared in `src/foundation/math/mat4.zig` and `rtti` names it
by alias, which preserves the type IDENTITY `classifyField` matches on. Its
storage is `extern struct { m: [16]T }` and not `Mat3`'s `cols: [N]Vec`,
because it carries a frozen C twin that a `@Vector` layout cannot serve.

The twin had no assertion at all — `WeldMat4` has zero use sites and was tied
to `rtti.Mat4` by a doc comment — so a field-by-field comparison lands with a
witness that it can return false.

`affineInverse` dotted the column where the formula wants the row; a diagonal
fixture cannot tell the two apart, the rotated non-uniform one did.

The pose layout is decided on the bench, not on intuition: SoA-3 wins blend
AND forward kinematics at 32/64/128 bones over three runs. The per-channel
form, the only one that vectorises, loses the blend by 75% — which refutes
the stated argument for SoA while confirming SoA. An instrument defect was
corrected first: the AoS body was written scalar against a vector SoA body,
and equalising it moved the 32-bone ratio from 0.936x to 1.006x at identical
checksums.

Test floor 2290 -> 2300 / 2298, re-derived from the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three layouts differed in two things at once — memory order AND element
representation — so the gap could belong to either. `AoS-vec` holds both
fixed but the order: same 48 bytes, same alignment, `@Vector` members.

It reverses the verdict. Interleaved beats split on blend 12 times out of 12
across four runs and ties it on FK, so SoA-3 is dominated. What the earlier
reading had credited to the split was the `[3]f32` element: the corpus's own
AoS candidate, one Tier 0 `Transform` per bone, is the slowest viable form by
about 10% on both operations, an axis neither document named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`src/interfaces/AnimationModule.zig` declares the types its own signatures
name and attests its non-freeze with a test that fails the day a protocol
version appears. The assert block covers what an implementation owes today
and grows with it: asserting an entry nothing can satisfy forces a body that
returns a plausible answer, which hides worse than an absence.

`init` reaches `world` and `persistent_allocator` and nothing else, registers
the `Skeleton` component there, and registers no system — a system with no
body to run is a mechanism nothing executes.

Poses live outside the ECS and the component carries a handle. Instance slots
are never recycled: an id is a position, so a reused slot would make a stale
handle address a different skeleton and read as working. Counter-factual run:
recycling reddens exactly that test and no other.

Test floor 2300 -> 2315 / 2313.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Loading refuses a malformed hierarchy rather than repairing one: a loader
that reroots an orphan or clamps an index makes every downstream invariant
untestable. The topological order is checked here, which makes a cycle
unrepresentable rather than detected — a cycle must contain a forward
reference and a forward reference is what this refuses, so the two are one
check and one code.

`error.NoRoot` was written and removed: the test for it failed, and reading
why showed no input reaches it. Bone zero has no index below it, so it is the
root or the hierarchy was already refused. An error nobody can provoke is an
assertion, and it is now one, with the argument at the site.

The inverse bind pose is stored, not derived. Deriving would force the bind
pose and the rest pose to coincide; they legitimately differ, which is why
glTF ships both.

Test floor 2315 -> 2329 / 2327.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pass produces MODEL space — each bone relative to the skeleton root — and
never world: the entity transform enters at the consumers, and baking it here
would make the skin matrices and the socket reads both wrong in a way that
looks right at the origin.

Rig and instance are two stores, so one hierarchy backs many poses. A fresh
instance stands in its bind pose: a skeleton at the identity is a heap of
bones at the origin and reads as a sampling bug.

The single pass is asserted twice and neither is timing — the topological
invariant read off the loaded rig, and the fixed point, since a second pass
would move something if a parent were evaluated after its child.

A coverage hole found by counter-factual: reversing the rotation operands
left the suite green, because the acceptance chain rotates about one axis and
its leaf carries the identity, and quaternions commute in both cases. Closed
with two non-parallel rotations, a sequential-application oracle, and a
discrimination guard.

Test floor 2329 -> 2339 / 2337.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The attack was written first and it caught the plausible implementation: a
profile mapping twenty-two of twenty-three roles, the gap in the middle. A
resolver indexing the mapping array by ordinal answers foot_l's bone for the
unmapped calf_l — a neighbour, which is the failure the invariant names — and
the positive test stays green under it, because a resolver right for
twenty-two roles passes every ordinary test.

Two bones of one name are refused at load. Otherwise a name addresses two
bones and the resolver has to pick, which is acting on possibly the wrong one.
It caught a real duplicate on its first run.

The counter-proof corpus pins the addressing type: a bare string, a bare index
and a bare ordinal must not compile in a reference position, while both
legitimate variants must. Both halves were shown to redden — and the first
attempt to show it was a bad counter-factual that moved the guard one level in
instead of removing it.

Test floor 2339 -> 2348 / 2346.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bindModelPose` had no caller and no test, duplicated what `instantiate` does
inline, and carried an unstated aliasing contract — it passed one buffer as
both input and output of the kinematics pass.

`resolveRole` and `resolveName` were public beside a header claiming
`resolveBone` is the one path. Two public entries that map a role or a name
are two more paths, in the module's own surface, at the place the invariant
is written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deviations, blockers, the closing self-audit and the final measurements.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in shipped behaviour. `Mat4.approxEql` compared NaN EQUAL —
`@abs(x-y) > tol` is false for NaN — so four assertions could not fail on a
total failure of the inverse. `rig()` returned a pointer the next `loadRig`
frees, which the shipped scenario holds across its whole `main`. Neither new
process entry installed the float environment, and the bench is the one whose
published numbers decided the layout.

Eight guards that could not fail, each verified by mutation. The two "single
linear pass" tests passed against a kinematics reduced to a copy; the
`id == len` boundary was never probed; three refusals of `parse` were
unreachable from `parse`; the C-twin's negative witness differed in width, so
the field loop it exists for never ran.

`fromTrs`'s third column was pinned by nothing, and the first repair was blind
to two columns for a different reason — a quarter turn makes their diagonal
terms exactly zero. What ships is a half turn plus a generic rotation checked
against `rotateVec3` plus a guard that refuses a fixture with a near-zero
basis term. All nine terms now detected.

And the class this milestone had already closed twice, committed while closing
it: `resolveName` stayed public under a header saying it was not.

Scope: the module now publishes a resource and registers one `fixed_update`
system, driven through a real `dispatchFrame`; the seven unimplemented
interface entries are present and refuse; `parse` validates its float payload.

Test floor 2348 -> 2357 / 2355.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Thirteen cells green. The windows ReleaseSafe f64 cell cleared its
55-minute budget by 45 seconds, which is the cost this brief predicted for a
gate touching a depended-upon Tier 0 header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same two cells took 49m40 and 54m15 cold, then 4m28 and 4m26 warm — a
factor of 11 to 12 on a branch whose only intervening change is one line of
journal. The 54 minutes were compilation, not test execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by external review after this milestone's own adversarial pass had
closed. `compose` built a model-space `BoneTransform` from two others, and
that composition is exact for the position and WRONG for the scale: it
multiplies the two scales componentwise, which is the right answer only
when no rotation between the two bones reorients the scale axes.

MEASURED before the fix, on a root scaled `(3, 1, 1)` with a quarter-turned
child: the pass put the grandchild at `(0.0000, 3.0000, 0.0000)` where the
matrix product puts it at `(0.0000, 1.0000, 0.0000)`. Wrong BY THE SCALE
FACTOR, finite and plausible, on a rig no artist would call unusual.

What let it through is written at `skeleton.zig`'s header: a comment on that
composition documented an approximation about SHEAR — genuinely
inexpressible in TRS — and a documented approximation on one quantity does
not cover an error on another. It reads as an argument, which is this
repository's costliest defect class.

`ModelPose` is declared on the interface, `compose` is deleted,
`forwardKinematics` composes `Mat4` and the local poses stay TRS — a clip
samples translation, rotation and scale, and nothing is gained by widening
what it writes. `pose.zig` gains `allocModel`/`freeModel`; `root.zig`,
the tests, the demo and the layout bench follow.

The pin is a test named for the defect: an accumulated scale must not
survive a rotated child. Its oracle is the matrix product, computed by
walking each bone up to the root — not the pass's own arithmetic, which
would agree with itself whatever it did.

AND THE BENCH'S FK ARM STOPPED DISCRIMINATING, which is reported rather
than quietly re-run: it measured a TRS output, and against `[]Mat4` the
ratios straddle 1.0 (0.870-1.153) with the sign flipping between bone
counts inside one run, matrix composition dominating the read cost. The
layout verdict stands on the BLEND arm, unchanged and stable across three
runs. Recorded in the bench header and in `bench/results/pose_layout.md`,
where the FK row is marked superseded rather than left to read as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by external review after this milestone's own adversarial pass had
closed. `validatePayload` refused a stored rotation whose length missed one
by more than `unit_rotation_tolerance` and did nothing to the ones it
admitted — so a quaternion legitimately inside the band stayed off-unit,
the matrix built from it stretched, and the stretch COMPOUNDED down the
hierarchy onto a consumer that had been told the payload was validated.

The tolerance is NOT tightened. It exists to refuse the absurd — a null
quaternion, an arbitrary one — and it keeps that job; what it never was is
a guarantee of unit length. The two run in order, and the order is
load-bearing: a null norm NORMALISED is a NaN, so the refusal must come
first. Both halves are pinned, and the counter-factual that inverts them
reddens two tests with `expected error.NonUnitRotation, found
error.NonFiniteTransform` — the NaN arriving exactly where the argument
says it would.

THE FIRST ATTACK WRITTEN FOR THIS MEASURED ALMOST NOTHING AND IS THE
REASON THE FIX IS WHERE IT IS. A 128-deep chain at half the tolerance
passed at 1.000321, so rather than escalate the fixture blindly the
exposure was PROBED: drift is governed by the rotation ANGLE and not by
the norm error alone. At the band's edge a 1.2 rad per-bone rotation
reaches 8.5 % stretch over 128 bones (5.7 % about (1,1,1)); the same error
at 0.1 rad reaches 0.06 %. The first attack used 0.1 rad, which is why it
proved the opposite of what it claimed. Tightening the band would not have
closed this.

The attack ships at the measured worst case, and it asserts BOTH halves:
the stored rotation is unit AFTER LOAD — the direct property, which does
not depend on a chain length — and the FK tip is unit-length to 1e-3 after
128 compositions. Its non-vacuity is established in the same execution: the
same chain rebuilt from the unnormalised quaternion is required to stretch
past 1.05, so the assertions above it are about the normalisation and not
about a fixture that could never have drifted.

Suite floor re-derived FROM THE SUITE: 2357 -> 2359 on macOS, 2355 -> 2357
on windows, the closure agreeing independently at 2359.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by external review after this milestone's own adversarial pass had
closed. `rig()` returned a `Rig` BY VALUE, and that copy carries the store's
own slice handles: `borrowed.parents[1] = 127` wrote into the hierarchy the
loader had validated, and `borrowed.deinit(gpa)` freed the module's memory
while the store went on holding the same slices. Both compiled. Both were
silent.

THE PREVIOUS REMEDY IS WHAT PRODUCED THIS ONE, and it is the finding's real
content. `rig()` first returned a `*const Rig`, which dangled the moment
another rig was loaded, and the repair changed the SHAPE of the return —
pointer to value — which closed the dangle and left ownership exactly where
it was. A remedy that changes the form of a return moves the defect instead
of closing it; what was needed was a second TYPE.

So: two types, not one. `Rig` stays the owner — mutable slices, `deinit`,
internal to the store, and its doc now says that is what it is. `RigView`
is what leaves: every slice `[]const`, no destructor, and no
`profile_mappings` at all, that field being the backing storage `deinit`
frees and having no meaning to a reader. `resolveBone` takes the view too —
resolution is a READ and has no business receiving a destructor.

The counter-proof is a NON-COMPILATION, in the corpus that already drives
one: `case-view-write` fails with `cannot assign to constant` and
`case-view-deinit` with `no field or member function named 'deinit' in
'skeleton.RigView'`. The first mark is generic in isolation, so it is paired
with its own file name — a mark that any constant assignment anywhere would
satisfy discriminates nothing. The control compiles in the same execution
and now READS through the view: bone count, bone name, parents, bind pose,
inverse bind and the profile. Without that half, two refusals would prove
the type useless rather than tight.

AND THE HARNESS IS SHOWN TO FIRE. With `rig()` restored to returning a
`Rig`, both new cases COMPILE and the corpus reddens: `process exited with
code 0 (expected exited with code 1)`, twice. The two lines this commit
makes impossible were legal code on the branch that was about to be tagged.

The corpus keeps its addressing name for a second family rather than
growing a rename: the path is prescribed by the brief's frozen section, and
a cosmetic divergence from it is still a divergence. What is NOT claimed is
that no code can name the owning record — Zig has no module-private
declaration. What is checkable, and checked, is that the owning type never
appears in an entry's return type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hotfix was reviewed GO MERGE with one objection: quaternion norm is
multiplicative, so |q1*...*qn| = (1+e)^n independent of angle, therefore
"drift is governed by the angle" is false and the two fixtures must have had
norm errors 100x apart.

MEASURED, both objects in one execution, delta measured after construction
rather than declared, over 128 bones. The quaternion-product norm is FLAT to
five figures across angles 0.1 to 3.0 — 1.066054 to 1.066059 — while the
matrix chain at the same delta runs 1.000641 to 1.289770. The reviewer's
theorem is exact (Euler's four-square identity, verified over 200000 random
pairs against this repo's own Quat.mul at 7.6e-16) and their arithmetic is
right at every step. It governs Quat.mul, which src/modules/kinesis/ calls
ZERO times. forwardKinematics composes Mat4, and Mat4.fromTrs writes an
unscaled 1 on each diagonal term while every off-diagonal term carries the
full |q|^2 — so M(k*u) = k^2*R(u) + (1-k^2)*I, an affine mix with the
identity, verified elementwise to 1 ULP. The decisive cell is theta = 0:
delta fully present, stretch exactly 1.000000, where a law independent of
angle demands 1.066.

BUT THE OBJECTION WAS RIGHT ABOUT THE SENTENCE IT ATTACKED. "Tightening the
band would not have closed it" is FALSE — a non-sequitur, since the drift is
the product n*delta*(1-cos theta) and a product closes by shrinking any
factor; 100x tighter takes the 128-bone stretch from 8.5% to 0.08%. What is
true and stronger is that the band is defeated by DEPTH: max_bones is 4096,
and at the band's own edge such a chain stretches 13.6x at 1.2 rad and 3583x
at pi. Both false sentences are deleted here, not annotated.

AND THE FIXTURE WAS ADMISSIBLE BY ONE ULP AND BY LUCK — a second defect the
objection's wrong number nevertheless landed on. k = sqrt(1 + tolerance)
names a length the band REFUSES: 1 + 1e-3 is unrepresentable in f32 and
rounds up to 8389 ULP where the predicate admits 8388. It loaded only because
five roundings happened to land one ULP low at that angle; the identical
construction is refused at 15.2% of angles in (0,3], non-monotonically —
1.80 rad refused between an accepted 1.50 and an accepted 2.00.

Repaired, and the attack is STRONGER: 7550 ULP under the 8388 ceiling against
a 2 ULP construction spread, 0% of angles refused, 3.0 rad instead of 1.2,
unnormalised stretch 1.257397 instead of 1.085067, floor 1.05 -> 1.2. The 0.9
factor is written as a rule (factor <= 1 - N*2^-23/tol) and not as a number,
because it stops working at a tighter tolerance. rotationIsUnitEnough is
extracted from validatePayload as the ONE declarant of the band arithmetic,
whose summation order decides the last ULP. The in-test assertion is
TWO-SIDED, since "inside the band" alone is satisfied by a unit quaternion
under which the test is vacuous — counter-factual: forcing k = 1.0 aborts it.

Mat4.fromTrs now declares the unit precondition its twins Mat3.fromQuat and
Quat.rotateVec3 both declare and it did not, with the consequence rather than
the bare word: the mix is anisotropic, so no scale factor describes it.

Reported and NOT fixed here, verified on the source in both directions:
body_manager.setRotation stores a rotation raw, reachable through the FROZEN
setBodyTransform and through sync_in.zig's per-tick ECS seam, while addBody
normalises and integration.zig skips exactly the .gameplay bodies syncIn
writes to. Forge, frozen surface, needs an M1.D number and an owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@guysenpai

Copy link
Copy Markdown
Contributor Author

Content is on main via #80 (e9371a9). Closing without merge.

@guysenpai guysenpai closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant