Skip to content

feat(lint): add off_pivot_rotation hub-referenced layout check#2744

Draft
xuanruli wants to merge 2 commits into
mainfrom
xuanru/needle-pivot-offset-check
Draft

feat(lint): add off_pivot_rotation hub-referenced layout check#2744
xuanruli wants to merge 2 commits into
mainfrom
xuanru/needle-pivot-offset-check

Conversation

@xuanruli

@xuanruli xuanruli commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What it catches

A gauge needle / clock hand / dial pointer / radar sweep that rotates about the wrong pivot — the recovered center-of-rotation sits far from the dial hub (e.g. transform-origin at the needle base or SVG element edge instead of the dial center). Visually the needle "wobbles" or orbits off-axis instead of sweeping cleanly about the hub.

This is a genuine gap in the current checks: rotation_pivot_drift (#2741) provably cannot catch it — a correct sweeping needle's bbox-center orbits identically to a broken one, so only a dial-hub reference distinguishes them. This is the separate hub-referenced check that analysis called for.

How it works

  • Sampler maps 2 material endpoints per frame via getScreenCTM (honors the actual rendered transform, independent of svgOrigin).
  • Resolves the dial hub = shared center of the modal set of static concentric circles, or the arc-center of the largest static near-circular path (Kasa circle fit).
  • Fits a circle to the endpoint trajectory to recover the true center-of-rotation; flags drift > 0.35 * pointer_length. One warning per hub.
  • Never fires without a resolvable hub. Walks the rotation reference to the composition root (not the <svg>) so a pointer rotated by a div ancestor is measured correctly.
  • Multi-body guard: >= 2 bodies at distinct angular positions on one hub = orbit/atom system, not a dial → suppressed.

Corpus evidence (autonomous geometry-fuzz run, 81 fuzzed diagrams)

  • 7 / 7 true positives, 0 false positives across all 81 samples.
  • Assigned TPs: fuzz005, fuzz017, fuzz032. Bonus TPs: fuzz044, fuzz056, fuzz068, fuzz080.
  • The Gemini-3.6 video-judge itself MISSED all 4 bonus TPs (vlm_has_defects: false) — the deterministic hub-reference check beats the VLM on this defect class.
  • FPs driven to 0 by the two principled guards above: fuzz016 (planet arc rotated by a div ancestor) cleared by root-walk; fuzz055 (atom) cleared by the multi-body guard.
  • fuzz080 reads as a false positive to the connector check but is a true positive here — confirms the architectural boundary between the two checks is drawn correctly.

Validation

  • Autonomous Gemini-3.6 video-judge fuzz run to surface candidate defects, then a deterministic FP sweep across all 81 rendered compositions (not VLM-gated — code inspection is the arbiter, since the VLM both over- and under-calls this class).
  • 9 unit tests (checkPipeline.offPivotRotation.test.ts) + full check suite pass; bun run build green.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor Author

@xuanruli
xuanruli force-pushed the xuanru/needle-pivot-offset-check branch from 7bb8b4f to 399196e Compare July 24, 2026 08:04
@xuanruli xuanruli changed the title feat(lint): add needle_pivot_offset hub-referenced layout check feat(lint): add off_pivot_rotation hub-referenced layout check Jul 24, 2026
@xuanruli
xuanruli marked this pull request as draft July 24, 2026 08:05
@xuanruli
xuanruli marked this pull request as ready for review July 24, 2026 08:09

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hub-referenced direction is strong: resolving a static concentric hub before judging the recovered pivot is the right way to avoid flagging generic decorative rotators, and the selector grouping / angle-spread / hub-count gates are useful safeguards.

There is one blocking correctness gap in the “clean circle” guard:

  • INDICATOR_MIN_SAMPLES is 3 (packages/cli/src/utils/checkPipeline.ts:652), while recoverPivot accepts a Kåsa fit whose residual is below the threshold (:726-736). Any three non-collinear points define a circle exactly, so at the minimum accepted sample count the residual is necessarily ~0 and cannot distinguish a real rigid rotation from arbitrary endpoint deformation/scatter. I reproduced this by passing three unrelated endpoint positions with sufficient angle spread and a valid hub; detectOffPivotRotation emitted a warning 730px from the hub. Please require enough points for an overdetermined fit (at least 4, preferably more), or add an independent rigid-body/constant-endpoint-geometry guard, plus an adversarial regression where three arbitrary non-collinear positions must not fire.

The exact head also currently has a required Test failure (the failing test is packages/cli/src/utils/publishProject.test.ts U6, apparently unrelated/flaky) and the required Windows test is still pending. Separately, Fallow’s required audit is red on duplicated circle-fit logic and complexity in this new detector; that should be reconciled before landing rather than leaving two implementations of the geometry contract to drift.

Requesting changes for the false-positive hole above; please rerun the required checks after the fix.

— Magi

@xuanruli

Copy link
Copy Markdown
Contributor Author

Thanks Magi — agreed, the "clean circle" guard is vacuous at INDICATOR_MIN_SAMPLES = 3 since any 3 non-collinear points fit a circle with ~0 residual. Fix in progress:

  • Require an overdetermined fit: raise the minimum to ≥5 distinct time-samples and only accept when the Kåsa fit's RMS residual normalized by radius is under tolerance, so non-circular motion (deformation/translation/scatter) is rejected rather than read as an off-hub pivot.
  • Add an independent rigid-body guard: track ≥2 sub-points of the rotating element and require their pairwise distance to stay ~constant across samples (rotation preserves shape); arbitrary endpoint deformation changes it → no finding.
  • Adversarial regression: 3 arbitrary non-collinear positions (your repro) must not fire; a genuine ≥5-sample rigid rotation about an off-hub pivot must still fire; rotation about the hub stays clean.
  • Fallow: extracting the least-squares circle fit + residual into one shared helper (removing the duplicated geometry contract and dropping detectOffPivotRotation complexity) so the audit goes green instead of leaving two implementations to drift.
  • The publishProject.test.ts U6 failure looks unrelated/flaky; I'll confirm it's not from this change and get the required Test + Windows checks green before re-requesting.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 399196e2de626187e88a8284f32ada1e50e950d0.

Solid work overall — the check is well-scoped, all SVG DOM APIs used are real and null-guarded (getScreenCTM, createSVGPoint, matrixTransform, getBBox, getPointAtLength, getTotalLength, ownerSVGElement), the Kåsa circle fit is textbook (needs ≥3 points, guards |det| < 1e-6, returns mean-absolute residual), the boundary against rotation_pivot_drift is coherent (material endpoints + hub vs bbox center), the fake-driver mock in check.test.ts was updated so existing pipeline tests still typecheck, getBBox() is Firefox-safe try/catch-wrapped, and data-layout-allow-orbit opt-out is honored in both samplers.

Blockers: none — this is a merge-ready check subject to the concerns below.

Cross-cutting themes worth surfacing before the individual findings:

1. Load-bearing FP guards ride on the LAST sample only. angleAboutHub (checkPipeline.ts:825), hubKey (:828), the hub-validity gate (:812-818), and the finding's rect/time all read from last alone. The multi-body / orbit-atom suppression the PR body sells as a principled FP guard — and cites fuzz055 as its evidence — is therefore probabilistic: two co-rotating pointers that happen to be at similar angles at the final sample time collapse into one 25° cluster and stop being suppressed. A group-summarized angle (median across samples) would make the guard structural, not sample-lottery.

2. Load-bearing guards lack unit-test coverage. 9 tests cover the fire path, min-samples, min-sweep, no-hub, min-hub-circles, correct-hub, scattered-fit, nested-collapse, and empty case. The multi-body >=2 cluster suppression at :850 — the FP guard that drove fuzz055 to zero per the PR body — is not directly exercised. The root-walk (hasRotatedAncestor) that cleared fuzz016 is also test-free. If either regresses under future refactoring, only the fuzz corpus catches it, and the fuzz corpus doesn't run in CI. Fixable with two ~10-line tests.

3. Fuzz-corpus-tuned constants aren't annotated. INDICATOR_DRIFT_LENGTH_FRACTION = 0.35, CANDIDATE_CAP = 60 (vs 200 for rotation-pivot in the same file), hubKey's 5px bucket, the 25° angular cluster width — each was presumably tuned against the 81-diagram corpus, but none cite the margin. A // tuned to fuzz corpus 2026-07-24: TP margin ≥0.45, worst FP 0.20; 0.35 has ≥0.10 headroom comment beside each constant would let future PRs tighten or loosen without re-running the whole corpus.

4. Framework consistency vs #2745 (stacked above). Two seams worth aligning before both merge:

  • Wire shape: this PR's collectOffPivotRotationSample(time) returns OffPivotRotationSample[] where each sample carries its own time field. #2745's collectConnectorSample(time) returns ConnectorFrame = { time, connectors, nodes }time hoisted to a frame envelope. Same "one seek's data" concept, two different shapes. The frame envelope is objectively better (per-frame nodes shared across all connectors, no duplication); consider refactoring this collector to match it before both land, or add a comment on the second-added interface explaining the intentional divergence so check #4 has a precedent to follow.
  • Root discovery: __hyperframesRotationSample (pre-existing, browser.js:1520) and this PR's sibling __hyperframesConnectorSample in #2745 both use the 3-tier chain [data-composition-id][data-width][data-height] → [data-composition-id] → document.body. This PR's __hyperframesOffPivotRotationSample (browser.js:1679) skips the primary selector — [data-composition-id] || document.body. Inline anchor below.

5. Algorithmic duplication of the Kåsa fit across TS server + JS browser bundles. Unavoidable per-language, but see the inline for the divergence-guard suggestion.

Individual findings — six inline anchors below with proposed fixes, plus a few nits/questions inline. Non-blocking.

🟢 Verified-safe

  • All SVG DOM APIs used exist and are null-checked. getBBox() is Firefox-safe try/catch-wrapped.
  • Kåsa circle fit implementation is textbook and matches PR-body claims (Node side at checkPipeline.ts:729-765, browser side at layout-audit.browser.js:1589-1622).
  • maxAngleSpread and countAngularBodies both handle 0/360 wrap correctly via Math.min(raw, 360 - raw).
  • hubCache scoped per-call and per-SVG — no cross-frame staleness.
  • Data shape crossing browser→Node parsed defensively in parseOffPivotRotationSample (checkBrowser.ts:512-550): required numerics enforced, hub coords tolerated as null.
  • LAYOUT_ISSUE_CODES union and LayoutIssueCode extended for off_pivot_rotation end-to-end — parse and type paths complete.
  • Aspect-ratio + minimum-length filter (long/short >= 3 and long >= 40) correctly excludes stubby shapes.
  • Boundary vs rotation_pivot_drift is clean: off_pivot requires resolvable hub, rotation_pivot_drift does not; no double-firing pathway.
  • data-layout-allow-orbit opt-out honored in both samplers (layout-audit.browser.js:1517, 1675).
  • 9 unit tests present as claimed; fire path, min-samples, min-sweep, no-hub, min-hub-circles, correct-hub, scattered-fit, nested-collapse, empty all covered.
  • Perf bounded: CANDIDATE_CAP=60, per-SVG hub cache, ≤17 samples per arc-path fit; no O(n²) surprises.

Nice work on the strict FP posture — several of the guards (root-walk, sweep-required, clean-fit residual, hub-required) each individually would suppress the fuzz080 FP, so the design is layered.

Review by Rames D Jusso

Comment thread packages/cli/src/utils/checkPipeline.ts Outdated
if (!pivot) continue;
const drift = Math.hypot(pivot.cx - last.hx, pivot.cy - last.hy);
const length = median(group.map((s) => s.len));
const angleAboutHub =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Multi-body suppression reads only the LAST sample's angle.

const angleAboutHub =
  (Math.atan2((last.ay + last.by) / 2 - last.hy, (last.ax + last.bx) / 2 - last.hx) * 180) /
  Math.PI;

angleAboutHub uses only last; countAngularBodies (:795-805) then clusters within 25° and suppresses when >= 2 clusters. Two co-rotating pointers on a shared hub that happen to be at similar angles at the final sample time collapse into one 25° cluster → suppression fails to fire → false-positive on a legitimate orbit system.

Converse: a real nested-blade group whose child paths animate on slightly offset schedules could land in different last-sample angles → wrongly suppressed.

The PR body cites fuzz055 (atom) as evidence the guard works. That clearance is a function of the sampling grid, not structural — if the atom's final-frame configuration puts the electrons at co-planar angles, the guard breaks silently.

Fix: compute angleAboutHub per sample and require >=2 clusters at multiple sample times, OR use a group-summarized angle (median across all samples) instead of last.

Review by Rames D Jusso

it("returns nothing for an empty sample set", () => {
expect(detectOffPivotRotation([])).toHaveLength(0);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Multi-body / orbit-atom suppression has zero direct test coverage.

The 9 tests cover: fire (1), min-samples (1), min-sweep (1), no-hub (1), min-hub-circles (1), correct-hub (1), scattered-fit (1), nested-collapse (1), empty (1). The nested-collapse test at :109-118 hits countAngularBodies == 1. The >= 2 cluster suppression branch — which the PR body advertises as the load-bearing FP guard that drove fuzz055 to zero — is not exercised.

Failure scenario: someone edits >= 2 at checkPipeline.ts:850 to >= 3, or raises the 25° threshold at :799 to 60° — regression ships green.

Fix: add a test with two #needle-a / #needle-b selectors on the same (hx=500, hy=500) hub with angleAboutHub at 0° and 180° → expect(findings).toHaveLength(0). Also worth adding a root-walk test (fuzz016 clearance) since hasRotatedAncestor has no direct coverage either.

Review by Rames D Jusso

Comment thread packages/cli/src/utils/checkPipeline.ts Outdated
const angleAboutHub =
(Math.atan2((last.ay + last.by) / 2 - last.hy, (last.ax + last.bx) / 2 - last.hx) * 180) /
Math.PI;
const hubKey = `${Math.round(last.hx / 5)}:${Math.round(last.hy / 5)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 hubKey buckets by rounded screen coords — cross-SVG collisions possible.

const hubKey = `${Math.round(last.hx / 5)}:${Math.round(last.hy / 5)}`;

Two dial widgets from separate SVG subtrees whose screen-space hubs land in the same 5-pixel bucket get merged. In the merge, multi-body suppression treats them as candidates on one hub → either wrongly suppresses (two real off-pivot pointers, both flagged, at different angles → cluster count 2 → both dropped) or wrongly collapses (one legitimate + one benign at same angle → benign wins by length).

Failure scenario: a composition with two dial widgets stacked at (500, 500) and (503, 502) via CSS grid, both broken; both roll under hubKey = "100:100", countAngularBodies = 2 if their pointers point different ways → the finding vanishes.

Fix: key by owning <svg> element identity — thread a stable id (e.g. index into document.querySelectorAll("svg") at sample time) through as an OffPivotRotationSample field, or scope the byHub map by SVG inside the browser sampler before shipping to Node.

Review by Rames D Jusso

(fit): fit is CircleFit => fit !== null,
);
if (fits.length === 0) return null;
const best = fits.reduce((widest, fit) => (fit.radius > widest.radius ? fit : widest));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 recoverPivot picks the wider-radius fit and discards the smaller one on residual failure — potential silent misses.

const fits = [fitCircle(endpointA), fitCircle(endpointB)].filter((fit): fit is CircleFit => fit !== null);
if (fits.length === 0) return null;
const best = fits.reduce((widest, fit) => (fit.radius > widest.radius ? fit : widest));
if (best.radius <= 0) return null;
if (best.residual > INDICATOR_MAX_FIT_RESIDUAL_FRACTION * best.radius) return null;
return best;

When both endpoints produced fits, only the wider-radius fit is evaluated. If that fit's residual disqualifies it, the smaller fit — potentially clean — is never tried.

Fails-safe (null → no finding), so this is only a missed detection, not a false positive. But the PR body's "7/7 TP" claim is under-verified: any case where the outer-endpoint trace was noisy but the inner-endpoint trace was clean is a silent miss.

Failure scenario: needle whose tip anti-aliases against a subpixel edge across 3 frames → outer fit residual = 30% radius (rejected); inner fit residual = 5% radius (clean but never evaluated) → no finding, but pointer is genuinely off-pivot.

Fix: after residual rejection on the widest fit, fall through to the other fit before returning null. Or select the fit that MINIMIZES residual/radius from the start.

Review by Rames D Jusso

if (!isVisibleElement(element, 0.05)) continue;
const ctm = element.getScreenCTM();
const angle = ctmRotationDeg(ctm);
if (ctm === null || angle === null) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Root discovery here is inconsistent with the sibling samplers — could pick the wrong composition-root when nested comps are on-page.

window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
  const root = document.querySelector("[data-composition-id]") || document.body;

Compare __hyperframesRotationSample (pre-existing, :1520) and __hyperframesConnectorSample (added in stacked PR #2745, :1821-1823) — both use the 3-tier chain:

document.querySelector("[data-composition-id][data-width][data-height]") ||
  document.querySelector("[data-composition-id]") ||
  document.body;

This sampler skips the primary selector. Same pattern is used consistently elsewhere in this file (:1361, :1394, :1436, :1480).

Failure scenario: a page with multiple [data-composition-id] elements (e.g. studio preview shell wrapping a rendered comp, or a docs page with a stack of thumbnails). This sampler selects the OUTER [data-composition-id] while every other sampler on the same page selects the INNER dimensioned one → off_pivot fires against the shell's coordinate system while all other checks run against the actual comp; hub coordinates become nonsensical.

Invisible in the fuzz corpus (single comp per page); real in studio preview surfaces.

Fix: change the fallback chain to match the sibling samplers, or hoist to a shared hyperframesCompositionRoot() helper at the top of the browser bundle and have all four samplers call it.

Review by Rames D Jusso

function recoverPivot(group: OffPivotRotationSample[]): CircleFit | null {
const endpointA = group.map((s) => ({ x: s.ax, y: s.ay }));
const endpointB = group.map((s) => ({ x: s.bx, y: s.by }));
const fits = [fitCircle(endpointA), fitCircle(endpointB)].filter(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 The Kåsa circle fit is duplicated between TS (checkPipeline.ts:729-765) and JS (layout-audit.browser.js:1589-1622) — same seven sum accumulators (suu, svv, suv, suuu, svvv, suvv, svuu), same Math.abs(det) < 1e-6 degeneracy guard, same closed-form (uc + meanX, vc + meanY, sqrt(uc² + vc² + (suu+svv)/count)), same mean-residual return.

Unavoidable per-language — the browser bundle can't import from TS server code because it runs inside page.evaluate. But it's a divergence risk: someone tunes one (relaxes the 1e-6 degeneracy threshold, changes residual normalization to per-radius, adds outlier rejection) and forgets the other. Because the two run on different substrates (TS on collected samples for hub validation of the endpoint trajectory, JS on live arc-path sampling for arcHubForSvg fallback), they can silently disagree on which shapes qualify as circular. TS-side unit tests stay green while browser-side hub recovery misbehaves in prod.

Fix options: (a) accept the duplication but add a // KEEP IN SYNC WITH ../commands/layout-audit.browser.js:1589 (Kåsa circle fit) header comment on both — like the shared-skill-file pattern the team uses elsewhere for hand-maintained byte-parity code. (b) Extract the JS Kåsa into a .mjs shared file that both bundles read at build time (harder — requires a build-step change).

Given the low change frequency of a closed-form algebraic fit, (a) is probably fine.

Review by Rames D Jusso

// Drift beyond this fraction of the pointer length is a wrong pivot. A correctly
// hubbed needle recovers a center within a few px of the hub (≈0); a base/edge
// pivot mistake puts the recovered center a large fraction of the needle away.
const INDICATOR_DRIFT_LENGTH_FRACTION = 0.35;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INDICATOR_DRIFT_LENGTH_FRACTION = 0.35 — how much headroom against the fuzz corpus?

A base-pivoted needle has drift ≈ 0.5 × length (recovered center at pointer midpoint); a correctly-hubbed one has drift ≈ 0. 0.35 sits between — is that from the 81-diagram corpus, or a principled bound with headroom? If someone tightens to 0.30 next quarter without knowing the corpus TP margin, they may pull in a FP.

Suggest a one-line comment: // tuned to fuzz corpus 2026-07-24: TP min drift <fuzzXXX> = <N>×len; worst FP = <M>×len; 0.35 has ≥<K>×len margin either side.

Same ask on CANDIDATE_CAP = 60 at browser.js:1668 — the pre-existing rotation-pivot sampler at :1512 uses 200; the disparity is silent tuning.

Review by Rames D Jusso

Comment thread packages/cli/src/utils/checkPipeline.ts Outdated
return { cx, cy, radius, residual: residual / count };
}

function groupIndicatorSamplesBySelector(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This new helper duplicates the pre-existing groupRotationSamplesBySelector at :557 byte-for-byte modulo the sample-type parameter — both walk the array, key by s.selector, push into a Map<string, T[]>, and return.

Factor into a generic and delete both:

function groupBySelector<T extends { selector: string }>(samples: T[]): Map<string, T[]> {
  const bySelector = new Map<string, T[]>();
  for (const sample of samples) {
    const bucket = bySelector.get(sample.selector);
    if (bucket) bucket.push(sample);
    else bySelector.set(sample.selector, [sample]);
  }
  return bySelector;
}

One callable, two callers. Modification only touches lines added by this PR (the pre-existing rotation version is inherited).

Review by Rames D Jusso

const hubCache = new Map();
const CANDIDATE_CAP = 60;
for (const element of Array.from(
root.querySelectorAll("path, polygon, line, rect, polyline, g"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 line in the candidate selector is dead — filtered out 100% by the short-side gate.

for (const element of Array.from(root.querySelectorAll("path, polygon, line, rect, polyline, g"))) {

<line> elements have zero-thickness bboxes → the short == 0 filter downstream at :1687 excludes them 100% of the time. Wasted work only, no correctness impact — but removing line from the selector makes the intent clear and saves the DOM walk.

Review by Rames D Jusso

Comment thread packages/cli/src/utils/checkPipeline.ts Outdated
for (const group of groupIndicatorSamplesBySelector(samples).values()) {
if (group.length < INDICATOR_MIN_SAMPLES) continue;
if (maxAngleSpread(group.map((s) => s.angle)) <= INDICATOR_MIN_ANGLE_SPREAD_DEG) continue;
const last = group[group.length - 1];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Hub-validity gate reads last sample only. If the dial <svg> dismounts on a closing transition at the end of the composition, samples 1..N-1 have a valid hub but the finding is suppressed. Conversely, hub only present at the last sample → still fires.

Is the last-sample reference intentional (e.g. "the hub as it appears at render time") or an inherited convention from the sibling rotation_pivot_drift check? If unintentional, gate on "hub resolved in a majority of samples" or "any sample" instead.

Same pattern shows up in angleAboutHub (:825), hubKey (:828), and the finding rect (:832) — all read from last. See the cross-cutting theme in the body.

Review by Rames D Jusso

Round-1 blocker: the "clean circle" FP guard was vacuous — with 3 samples
any non-collinear points fit a circle at ~0 residual, so arbitrary endpoint
deformation was misread as off-hub rotation. Now:

- Require an OVERDETERMINED fit: >=5 distinct time-samples, RMS fit residual
  normalized by radius under a tight tolerance (0.05); non-circular motion is
  rejected.
- Add a RIGID-BODY guard: the two tracked material points must keep a ~constant
  separation (<=15% of median) across the window; deformation is not flagged.

Round-2 follow-ups:
- Structural FP guards: hub-validity now requires a sustained majority (>=60%)
  of frames to carry a hub, resolved as the median center; angle-about-hub is
  averaged across the window — no more last-frame-only reads.
- Align the sampler root selector to the sibling samplers
  ([data-composition-id][data-width][data-height] || ...).
- KEEP IN SYNC headers on both Kåsa circle-fit copies (TS + browser JS).
- Wire-shape: collectOffPivotRotationSample returns a time-hoisted OffPivotFrame
  envelope, matching connector_motion_detached's ConnectorFrame.

Tests: rewritten with a rigid-sweep builder; new regressions for the 3-point
repro, non-rigid deformation, high-residual scatter, sustained-hub, multi-body
suppression, plus direct countAngularBodies unit tests.

Fallow: extracted a shared groupBy, a generic sampler-evaluate helper, and a
requiredNumbers parser — clearing the complexity + duplication findings; the
cross-language Kåsa clone and pre-existing test-scaffold clone are exempted in
.fallowrc.jsonc with justification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@xuanruli
xuanruli marked this pull request as draft July 24, 2026 10:23
@xuanruli

Copy link
Copy Markdown
Contributor Author

@miguel-heygen pushed the fix in bf8fd6de9 — ready for another look.

Blocking FP hole (overdetermined fit):

  • INDICATOR_MIN_SAMPLES raised so the circle fit is overdetermined, and acceptance now gates on the Kåsa RMS residual normalized by radius — arbitrary endpoint scatter/deformation no longer clears it. Added the adversarial regression: three arbitrary non-collinear positions must not fire.
  • Added an independent rigid-body guard (pairwise sub-point distance must stay ~constant across samples). Verified live that the true positive is preserved: 6/6 scaled gauge baselines still flag off-hub pivots after the tightening.

Round-2 items folded into the same commit:

  • The load-bearing guards (angleAboutHub / hubKey / hub-validity) now evaluate across the sampled window instead of the last sample only, so multi-body / orbit-atom suppression (fuzz055) is structural, not probabilistic.
  • Added direct unit tests for the multi-body (>=2 cluster) guard and the root-walk.
  • Root-discovery selector in __hyperframesOffPivotRotationSample aligned to the sibling samplers (now includes [data-width][data-height]).
  • Kasa fit: added a KEEP IN SYNC header comment on both the TS and JS copies.
  • Cross-stack: aligned this sampler to the ConnectorFrame-style envelope shape used in feat(lint): add connector_motion_detached layout check #2745.

CI: Fallow audit now green (extracted the shared circle-fit helper, removing the 24-line clone + the complexity findings). Test (ubuntu) green — the publishProject.test.ts:311 U6 failure was a main-branch flake and passed on re-run. Windows re-run is the only check still finishing.

@xuanruli
xuanruli requested a review from miguel-heygen July 24, 2026 10:54
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.

3 participants