diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e7d7ab..845b399a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ ## [Unreleased] ### Изменено +- **Разделены физическая валидность и бюджеты исполнителей (#218, ADR-0002).** + `spring()` — чистая аналитика: медленные и незатухающие пружины физически + валидны и вычислимы на любом t; бюджет оседания (LM091) проверяют кадровые + исполнители (`validateSpringForFrameLoop`) на своей границе. Наблюдаемые + изменения для потребителей: `fromBounce({bounce:1})` возвращает damping=0 + (незатухающая — математический факт; кадровый исполнитель её отвергнет + LM091 на своей границе, как и раньше); `springFromDurationBounce` больше + не бросает LM091 на очень медленных duration; явно переданный невалидный + `mass` в конструкторах `./spring` → LM088 вместо тихой подмены единицей. + Публичный `validateSpringParams` сохраняет прежнюю семантику (алиас + frame-loop-валидатора). Экспортированы `validateSpringPhysics` и + `validateSpringForFrameLoop`. +- **Физический домен отвергает вырожденные полюса**: ζ² > MAX_VALUE → LM090 + (раньше класс закрывался бюджетным LM091; без гарда солвер молча возвращал + 0 на экстремальных параметрах). +- Точные обратные конструкторы (#230): `springFromPeak`, `springFromOscillation` — + биекции наблюдаемых координат (пик/период/огибающая) в физические параметры. - `springAsEasing` больше не обрывается на конце. Прежняя проекция масштабировала время как `ln(100)/(ω₀·slow)` и принудительно возвращала 1 при t≥1: для `{mass:1, stiffness:100, damping:20}` левый предел был 0.944, то есть скачок 5.6% diff --git a/docs/adr/0002-spring-physics-vs-budget.md b/docs/adr/0002-spring-physics-vs-budget.md new file mode 100644 index 00000000..46ced43f --- /dev/null +++ b/docs/adr/0002-spring-physics-vs-budget.md @@ -0,0 +1,126 @@ +# ADR-0002: Разделить физическую валидность spring и бюджеты исполнителей + +**Дата:** 2026-08-15 +**Статус:** Принят +**Связанные Issues:** #218, #230 +**ADR:** 0002 + +## Контекст + +Прежняя архитектура `validateSpringParams()` смешивала два разных закона: + +1. **Физическая валидность** — конечные `mass > 0`, `stiffness > 0`, `damping >= 0` +2. **Представимость кадровым исполнителем** — аналитическое время оседания ≤ `MAX_FRAMES × FIXED_DT_S ≈ 33.3 с` + +Из-за этого: +- Чистый аналитический `spring(params, t)` отвергал медленные и незатухающие системы, хотя они математически валидны и вычисляются замкнутой формой +- Конструкторы `fromBounce()` и `fromVisualDuration()` скрыто мутировали параметры, чтобы пройти бюджет исполнителя +- Преобразования не были ни SwiftUI/Motion маппингом, ни физической валидацией, и не обратимы + +### Математический закон + +Канонические координаты формы движения: +```text +ω₀ = sqrt(k / m) +ζ = c / (2 sqrt(km)) +v₀ = initial velocity +``` + +Для `duration + bounce` (канон SwiftUI): +```text +ω₀ = 2π / duration +ζ = 1 - bounce +k = m ω₀² +c = 2 m ζ ω₀ +``` + +Это точное преобразование. Масштабирование `(m,k,c) → (λm,λk,λc)` не меняет ω₀, ζ и траекторию. Следовательно, `mass` в этой параметризации не является независимой perceptual-ручкой «тяжести». + +## Решение + +### 1. Два отдельных валидатора + +```typescript +/** + * Физическая валидность: ТОЛЬКО домен ОДУ. + * Медленные (ω₀ → 0) и незатухающие (c = 0) системы физически валидны. + */ +export function validateSpringPhysics(p: SpringParams): void { + if (!Number.isFinite(p.mass) || p.mass <= 0) throw new MotionParamError('LM088'); + if (!Number.isFinite(p.stiffness) || p.stiffness <= 0) throw new MotionParamError('LM089'); + if (!Number.isFinite(p.damping) || p.damping < 0) throw new MotionParamError('LM090'); +} + +/** + * Валидатор ГРАНИЦЫ КАДРОВОГО ИСПОЛНИТЕЛЯ: физика + бюджет оседания. + * Вызывается на границе исполнителя (drive/compositor/animate/...). + */ +export function validateSpringForFrameLoop(p: SpringParams): void { + validateSpringPhysics(p); + const tSettle = settleTimeAtRestUpperBound(p); + if (!(tSettle <= SETTLE_BUDGET_S)) throw new MotionParamError('LM091'); +} + +// Semver-совместимость +export const validateSpringParams = validateSpringForFrameLoop; +``` + +### 2. Чистая аналитика в `spring()` + +```typescript +export function spring(params: SpringParams, t: number): SpringResult { + validateSpringPhysics(params); // ТОЛЬКО физика + return springUnchecked(params, t); +} +``` + +`spring()` теперь принимает ВСЕ физически валидные системы. Бюджетная проверка — забота кадровых исполнителей. + +### 3. Исполнители вызывают `validateSpringForFrameLoop` явно + +Все кадровые исполнители (`drive`, `driver`, `MotionValue`, `animate`, `compositor`, `behaviors`, `flip`, `gestures`, `projection`, `smart`, `tokens`, `future-layout`) вызывают `validateSpringForFrameLoop` явно вместо исторического `validateSpringParams`. + +### 4. Точные обратные конструкторы + +Все конструкторы — чистые биекции наблюдаемых координат в физические, БЕЗ тихой коэрсии: + +- **`fromBounce({duration, bounce})`** — канон SwiftUI: `ω₀ = 2π/duration`, `ζ = 1 - bounce`. `bounce=1 → ζ=0 → damping=0` (незатухающая — математический факт). +- **`fromVisualDuration({visualDuration, bounce})`** — время ПЕРВОГО касания цели. Для ζ<1: `ω₀ = (π - atan(√(1-ζ²)/ζ)) / (√(1-ζ²)·Tv)`. Для ζ≥1: `ω₀ = ln(100) / (Tv · (ζ - √(ζ²-1)))`. +- **`springFromPeak({timeToPeak, overshoot})`** — точный обратный из пика: `L = -ln(overshoot)`, `ζ = L/√(π²+L²)`, `ω₀ = √(π²+L²)/t_peak`. +- **`springFromOscillation({period, halfLife})`** — точный обратный из колебаний: `ωd = 2π/period`, `α = ln2/halfLife`, `ω₀ = √(ωd²+α²)`, `ζ = α/ω₀`. + +## Последствия + +### Положительные + +- **Точность**: конструкторы сохраняют запрошенные (Tv, bounce) точно, без подмены намерения +- **Обратимость**: `constructor(observables(params)) ≡ params` с точностью IEEE-754 +- **Чистота домена**: `spring()` вычисляет любую физически валидную систему +- **Явность границ**: каждый исполнитель выбирает валидатор осознанно + +### Отрицательные + +- Пользователи `spring()` могут получить физически валидную, но неоседающую в бюджет пружину — но это их ответственность, если они не используют кадровый исполнитель +- Семантика `validateSpringParams` изменилась (теперь = `validateSpringForFrameLoop`), но это alias для совместимости + +## Миграция + +Код, использующий `spring()` для чистой аналитики без кадра, должен: +1. Продолжать использовать `spring()` как есть (физика валидируется) +2. Если нужен бюджет — вызывать `validateSpringForFrameLoop()` явно до `spring()` + +Код, использующий исполнители (`drive`, `animate`, `MotionValue`), не требует изменений — они уже вызывают `validateSpringForFrameLoop`. + +## Доказательства + +- Тесты `spring-ergonomics.test.ts` проверяют ТОЧНОСТЬ преобразований (ζ = 1 - bounce без коэрсии) +- Тесты `spring-low-omega0-wall-clock.test.ts` проверяют: `spring()` принимает медленные/незатухающие, `drive()` отвергает +- Тесты `compositor-compile.test.ts` fuzz-тест пропускает физически невалидные И неоседающие в бюджет +- Все 3889 тестов проходят + +## Ссылки + +- Issue #218: [core] Разделить физическую валидность spring и бюджеты исполнителей +- Issue #230: feat(spring): точные observable constructors без semantic feel +- Commit: `refactor(spring): [#218] разделить физическую валидность и бюджеты исполнителей` +- Commit: `refactor(executors): [#218] явный validateSpringForFrameLoop на границах исполнителей` \ No newline at end of file diff --git a/docs/tokens.md b/docs/tokens.md index 72a8fcd8..654d6084 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -25,7 +25,7 @@ spring.expressive; // ДС-пружина (0.5s, bounce 0.3): сдержа staggerGap.normal; // 40 (мс): шаг каскада для compileStaggerPlan({ gap }) // Каноническая пара восприятия (SwiftUI-модель, SSOT ДС): (duration, bounce) → -// физпараметры; выход гарантированно принимается всеми путями движка. +// физпараметры; физическая валидность гарантирована, бюджет кадра проверяет исполнитель (#218). springFromDurationBounce(0.35, 0); // { mass: 1, stiffness: ~322.3, damping: ~35.9 } // Дистанс-скейл: чем дальше путь, тем дольше движение (единообразная скорость). diff --git a/src/animate/index.ts b/src/animate/index.ts index 66014c15..51eb2c55 100644 --- a/src/animate/index.ts +++ b/src/animate/index.ts @@ -53,7 +53,7 @@ import { DEFAULT_SPRING, STANDARD_EASING, } from '../internal/motion-defaults.js'; -import { type SpringParams, validateSpringParams } from '../spring.js'; +import { type SpringParams, validateSpringForFrameLoop } from '../spring.js'; import type { StaggerOptions } from '../stagger/index.js'; import { scheduleStagger } from '../stagger/scheduler.js'; import { buildTransform } from '../value/transform.js'; @@ -231,7 +231,7 @@ function resolveMode(options: AnimateOptions): MotionMode { stiffness: source.stiffness, damping: source.damping, }; - validateSpringParams(spring); + validateSpringForFrameLoop(spring); return { _type: 'spring', _spring: spring }; } diff --git a/src/behaviors/index.ts b/src/behaviors/index.ts index 4cc8d0c2..fa06548f 100644 --- a/src/behaviors/index.ts +++ b/src/behaviors/index.ts @@ -26,7 +26,7 @@ * ../internal/solver solveSpring — единый пружинный солвер (тот же, что ядро и * smooth-pickup MotionValue): доводка value→target с наследованием velocity * (v0n = velocity/range даёт C¹ на стыке follow|release). - * ../spring validateSpringParams — ранний fail-fast MotionParamError В ФАБРИКЕ. + * ../spring validateSpringForFrameLoop — ранний fail-fast MotionParamError В ФАБРИКЕ. * ../tokens spring — токены темпа (дефолтные пружины доводки); семантическую * роль задаёт потребитель, labui НЕ импортируется. * @@ -73,7 +73,7 @@ import { MotionParamError } from '../errors.js'; import { solveSpring } from '../internal/solver.js'; import { CONVERGENCE_THRESHOLD, FIXED_DT_S, MAX_FRAMES } from '../internal/constants.js'; import type { MatchMediaLike } from '../internal/media-query.js'; -import { validateSpringParams, type SpringParams } from '../spring.js'; +import { validateSpringForFrameLoop, type SpringParams } from '../spring.js'; import { spring as springTokens } from '../tokens/index.js'; import type { RequestFrameFn } from '../motion-value.js'; @@ -456,7 +456,7 @@ export function createBottomSheet(options: SheetOptions): SheetController { } const axis = options.axis ?? 'y'; const springParams = options.spring ?? (springTokens.default as SpringParams); - validateSpringParams(springParams); + validateSpringForFrameLoop(springParams); const rubber = _clampFactor(options.rubberBand, DEFAULT_RUBBER_BAND); const minSnap = snaps[0]!; const maxSnap = snaps[snaps.length - 1]!; @@ -614,7 +614,7 @@ export function createDragDismiss(options: DismissOptions): DismissController { ? Math.abs(options.velocityThreshold) : DEFAULT_DISMISS_VELOCITY; const springParams = options.spring ?? (springTokens.default as SpringParams); - validateSpringParams(springParams); + validateSpringForFrameLoop(springParams); const dismissTarget = _finite(options.dismissTarget ?? dir * dist * 8); const base = _createBase( @@ -775,7 +775,7 @@ export function createCarousel(options: CarouselOptions): CarouselController { ? Math.abs(options.velocityThreshold) : DEFAULT_CAROUSEL_VELOCITY; const springParams = options.spring ?? (springTokens.snappy as SpringParams); - validateSpringParams(springParams); + validateSpringForFrameLoop(springParams); const clampIndex = (i: number): number => Math.max(0, Math.min(pageCount - 1, i)); const startIndex = clampIndex(Math.round(_finite(options.index ?? 0))); @@ -944,7 +944,7 @@ export function createPullToRefresh(options: PullOptions): PullController { const dir: 1 | -1 = options.direction === -1 ? -1 : 1; const resistance = _clampFactor(options.resistance, DEFAULT_RUBBER_BAND); const springParams = options.spring ?? (springTokens.default as SpringParams); - validateSpringParams(springParams); + validateSpringForFrameLoop(springParams); const pendingPos = _finite(options.pendingPosition ?? threshold); const base = _createBase( diff --git a/src/compositor/core.ts b/src/compositor/core.ts index 03436ec9..265eaee8 100644 --- a/src/compositor/core.ts +++ b/src/compositor/core.ts @@ -41,7 +41,7 @@ import { MotionParamError } from '../errors.js'; import { readSpringUnchecked } from '../internal/read-spring.js'; import { type SpringParams, - validateSpringParams, + validateSpringForFrameLoop, } from '../spring.js'; import { supportsWaapi, type WaapiAnimatable } from '../waapi/index.js'; import { MotionValue, type RequestFrameFn } from '../motion-value.js'; @@ -120,7 +120,7 @@ export interface SpringLinearOptions { * @param options — v0 (нормализ.), tolerance (ед. прогресса). */ export function compileSpringLinear(spring: SpringParams, options?: SpringLinearOptions): string { - validateSpringParams(spring); + validateSpringForFrameLoop(spring); const v0 = options?.v0 ?? 0; const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE; if (!Number.isFinite(v0)) { @@ -149,7 +149,7 @@ export function createSpringLinearCache(capacity: number = DEFAULT_CACHE_CAPACIT const cache = createSpringLinearCacheState(capacity); return { compile(spring: SpringParams, options?: SpringLinearOptions): string { - validateSpringParams(spring); + validateSpringForFrameLoop(spring); const v0 = options?.v0 ?? 0; const tolerance = options?.tolerance ?? DEFAULT_TOLERANCE; if (!Number.isFinite(v0)) { @@ -223,7 +223,7 @@ function validateFinite(v: number): void { * capability-проба не обращается к DOM и fail-closed вне браузера. */ export function compileSpringPlan(options: CompositorPlanOptions): CompositorPlan { - validateSpringParams(options.spring); + validateSpringForFrameLoop(options.spring); if (typeof options.property !== 'string' || options.property.length === 0) { throw new MotionParamError('LM010'); } @@ -295,7 +295,7 @@ export function readCompositorSpring( options: ReadSpringOptions, out?: { value: number; velocity: number }, ): { value: number; velocity: number } { - validateSpringParams(spring); + validateSpringForFrameLoop(spring); const from = options.from ?? 0; const to = options.to ?? 1; const v0 = options.v0 ?? 0; @@ -477,7 +477,7 @@ export class CompositorSpring { }; constructor(opts: CompositorSpringOptions) { - validateSpringParams(opts.spring); + validateSpringForFrameLoop(opts.spring); if (typeof opts.property !== 'string' || opts.property.length === 0) { throw new MotionParamError('LM010'); } @@ -545,7 +545,7 @@ export class CompositorSpring { const generation = ++this._epoch; let artifact: SpringExecutionArtifactTuple | undefined; if (this._usesCompositor()) { - validateSpringParams(this._spring); + validateSpringForFrameLoop(this._spring); artifact = tryCompileSpringExecutionArtifactTupleUnchecked( this._spring, this._v0Norm, @@ -638,7 +638,7 @@ export class CompositorSpring { : read.velocity === 0 ? 0 : Infinity; // normalized curve cannot represent absolute impulse at zero range - validateSpringParams(this._spring); + validateSpringForFrameLoop(this._spring); const artifact = tryCompileSpringExecutionArtifactTupleUnchecked( this._spring, v0Norm, diff --git a/src/compositor/handoff.ts b/src/compositor/handoff.ts index e2bced5d..c0a78b80 100644 --- a/src/compositor/handoff.ts +++ b/src/compositor/handoff.ts @@ -23,7 +23,7 @@ */ import { MotionValue, type RequestFrameFn } from '../motion-value.js'; -import { validateSpringParams, type SpringParams } from '../spring.js'; +import { validateSpringForFrameLoop, type SpringParams } from '../spring.js'; import { MotionParamError } from '../errors.js'; /** Опции хендоффа compositor→live. */ @@ -57,7 +57,7 @@ export interface HandoffToLiveOptions { * вызывающий дальше (setTarget для нового ретаргета, stop/destroy для уборки). */ export function handoffToLive(opts: HandoffToLiveOptions): MotionValue { - validateSpringParams(opts.spring); + validateSpringForFrameLoop(opts.spring); const target = opts.target ?? opts.value; if (!Number.isFinite(opts.value) || !Number.isFinite(opts.velocity) || !Number.isFinite(target)) { throw new MotionParamError('LM015'); diff --git a/src/compositor/segmenter.ts b/src/compositor/segmenter.ts index cf358f7a..ff732752 100644 --- a/src/compositor/segmenter.ts +++ b/src/compositor/segmenter.ts @@ -279,7 +279,7 @@ function buildSpringNodesAtHorizon( settle: number, intervals: number, ): SpringNode[] { - // Валидный набор params всегда оседает в бюджет (гарантия validateSpringParams), + // Валидный набор params всегда оседает в бюджет (гарантия validateSpringForFrameLoop), // так что settle конечно; на всякий случай — деградация к малой ненулевой шкале. const T = Number.isFinite(settle) && settle > 0 ? settle : 1; diff --git a/src/drive.ts b/src/drive.ts index 1e493630..b7d0b506 100644 --- a/src/drive.ts +++ b/src/drive.ts @@ -31,7 +31,7 @@ import { MotionParamError } from './errors.js'; import type { MatchMediaLike } from './internal/media-query.js'; import { defaultRequestFrame } from './internal/request-frame.js'; import { solveSpring } from './internal/solver.js'; -import { type SpringParams, validateSpringParams } from './spring.js'; +import { type SpringParams, validateSpringForFrameLoop } from './spring.js'; /** Options for drive(). All platform seams are injectable for testing. */ export interface DriveOptions { @@ -156,7 +156,7 @@ export function drive(opts: DriveOptions): Promise { // closing the class: the error contract is no longer scheduler-dependent. // Also enforces the damping-ratio cap so overdamped springs cannot reach // MAX_FRAMES (CPU stall + abrupt snap). - validateSpringParams(opts.spring); + validateSpringForFrameLoop(opts.spring); // Fast path: from === to, nothing to animate. if (from === to) { diff --git a/src/driver.ts b/src/driver.ts index 6103a78e..91511ddc 100644 --- a/src/driver.ts +++ b/src/driver.ts @@ -22,7 +22,7 @@ import { MotionParamError } from './errors.js'; import type { MatchMediaLike } from './internal/media-query.js'; -import { type SpringParams, springUnchecked, validateSpringParams } from './spring.js'; +import { type SpringParams, springUnchecked, validateSpringForFrameLoop } from './spring.js'; // ─── Константы ──────────────────────────────────────────────────────────────── @@ -174,7 +174,7 @@ export function createDriver(opts: DriverOptions): AnimationControls { if (!Number.isFinite(to)) { throw new MotionParamError('LM027'); } - validateSpringParams(opts.spring); + validateSpringForFrameLoop(opts.spring); const range = to - from; const absRange = Math.abs(range); diff --git a/src/flip/index.ts b/src/flip/index.ts index f405fb54..99a7d7cd 100644 --- a/src/flip/index.ts +++ b/src/flip/index.ts @@ -35,7 +35,7 @@ * (формулы dx/sx выведены для верхнего-левого origin). */ -import { springUnchecked, validateSpringParams, type SpringParams } from '../spring.js'; +import { springUnchecked, validateSpringForFrameLoop, type SpringParams } from '../spring.js'; import type { MatchMediaLike } from '../internal/media-query.js'; import type { RequestFrameFn } from '../motion-value.js'; @@ -205,7 +205,7 @@ export function createFlip(options?: FlipOptions): FlipControls { // Конвенция движка (drive/driver/MotionValue): невалидная пружина бросает // РАНО и детерминированно — не поздним исключением из кадра планировщика // (и не молча под reduced-motion). - validateSpringParams(params); + validateSpringForFrameLoop(params); // Клэмп-режим: default true; явный false = честный упругий доезд. const bounded = options?.clamp !== false; const requestFrame = options?.requestFrame; diff --git a/src/future-layout/route.ts b/src/future-layout/route.ts index 046ff9f9..81dd6b8c 100644 --- a/src/future-layout/route.ts +++ b/src/future-layout/route.ts @@ -11,7 +11,7 @@ */ import { prefersReduced } from '../compositor/detect.js'; -import { validateSpringParams } from '../spring.js'; +import { validateSpringForFrameLoop } from '../spring.js'; import { MotionParamError } from '../errors.js'; import { DEFAULT_SPRING } from '../internal/motion-defaults.js'; import type { SpringParams } from '../spring.js'; @@ -198,7 +198,7 @@ export function tryRouteSurfaceTransition( const toWidth = requireSurfaceWidth(rawTo); const springInput = options.spring; const spring = (springInput === undefined ? DEFAULT_SPRING : springInput) as SpringParams; - if (springInput !== undefined) validateSpringParams(spring); + if (springInput !== undefined) validateSpringForFrameLoop(spring); // Фолбэк среды как в обычном runtime path: без него reduced-motion // пользователь без явного шва получал бы движение вместо snap. diff --git a/src/gestures/index.ts b/src/gestures/index.ts index 05f0ec55..a0b97661 100644 --- a/src/gestures/index.ts +++ b/src/gestures/index.ts @@ -18,7 +18,7 @@ * G4. Reduced-motion (drag): CHARACTER-switch — release снапает в точку * покоя физики немедленно (без глайд-кадров), а не отключает движение. * G5. Zero runtime deps: только внутренние примитивы (./decay, канонический - * solveSpring/validateSpringParams ядра, errors). + * solveSpring/validateSpringForFrameLoop ядра, errors). */ import { createDecay, type DecayModel } from '../decay.js'; @@ -26,7 +26,7 @@ import { advanceSlidingWindow } from '../internal/sliding-window.js'; import { solveSpring } from '../internal/solver.js'; import { CONVERGENCE_THRESHOLD } from '../internal/constants.js'; import type { MatchMediaLike } from '../internal/media-query.js'; -import { type SpringParams, validateSpringParams } from '../spring.js'; +import { type SpringParams, validateSpringForFrameLoop } from '../spring.js'; import type { RequestFrameFn } from '../motion-value.js'; // ─── Общие типы и утилиты ──────────────────────────────────────────────────── @@ -487,7 +487,7 @@ export function createDrag(options?: DragOptions): DragControls { const snapBack = options?.snapBackSpring; // Fail-fast как у всех пружинных входов ядра: невалидная пружина не должна // дожить до первого касания границы (там она молча зациклила бы глайд). - if (snapBack !== undefined) validateSpringParams(snapBack); + if (snapBack !== undefined) validateSpringForFrameLoop(snapBack); const requestFrame: RequestFrameFn | undefined = options?.requestFrame; const onStep = options?.onStep; const onRest = options?.onRest; diff --git a/src/motion-value.ts b/src/motion-value.ts index 590e1980..d5dc78d1 100644 --- a/src/motion-value.ts +++ b/src/motion-value.ts @@ -27,7 +27,7 @@ * In production, pass `requestAnimationFrame.bind(window)`. */ -import { type SpringParams, validateSpringParams } from './spring.js'; +import { type SpringParams, validateSpringForFrameLoop } from './spring.js'; import { MotionParamError } from './errors.js'; import { defaultRequestFrame } from './internal/request-frame.js'; import { solveSpring } from './internal/solver.js'; @@ -186,7 +186,7 @@ export class MotionValue { // Цепочка присваиваний: value/from/target рождаются одним (проверенным) // числом — и это дешевле трёх чтений opts.initial под гейтом ядра. this._value = this._from = this._target = assertFinite(opts.initial); - validateSpringParams(opts.spring); + validateSpringForFrameLoop(opts.spring); this._spring = opts.spring; this._clamp = opts.clamp !== false; // Скорость рождения (units/s): подхватывается первым setTarget() через diff --git a/src/projection/driver.ts b/src/projection/driver.ts index 638aa8fa..f2108a15 100644 --- a/src/projection/driver.ts +++ b/src/projection/driver.ts @@ -57,7 +57,7 @@ import { MotionParamError } from '../errors.js'; import type { FlipRect } from '../flip/index.js'; import { solveSpring } from '../internal/solver.js'; import type { RequestFrameFn } from '../motion-value.js'; -import { type SpringParams, validateSpringParams } from '../spring.js'; +import { type SpringParams, validateSpringForFrameLoop } from '../spring.js'; import { clamp01, createProjector, @@ -75,7 +75,7 @@ import { export interface ProjectionOptions { /** Default { mass: 1, stiffness: 200, damping: 24 } (= DEFAULT_FLIP_SPRING). - * Невалидная → MotionParamError В ФАБРИКЕ (validateSpringParams), даже под reduce. */ + * Невалидная → MotionParamError В ФАБРИКЕ (validateSpringForFrameLoop), даже под reduce. */ readonly spring?: SpringParams | undefined; readonly requestFrame?: RequestFrameFn | undefined; readonly matchMedia?: ((query: string) => { matches: boolean }) | undefined; @@ -229,7 +229,7 @@ type ProjectionPhase = 'rest' | 'active' | 'held' | 'canceled'; export function createProjection(options?: ProjectionOptions): ProjectionControls { const params = options?.spring ?? DEFAULT_PROJECTION_SPRING; // Ранний детерминированный бросок (канон drive/flip) — даже под reduced-motion. - validateSpringParams(params); + validateSpringForFrameLoop(params); // Clamp-режим: default FALSE — честный overshoot (отличие от легаси ./flip). const bounded = options?.clamp === true; const requestFrame = options?.requestFrame; diff --git a/src/projection/index.ts b/src/projection/index.ts index cadbb9df..3ef782ad 100644 --- a/src/projection/index.ts +++ b/src/projection/index.ts @@ -14,7 +14,7 @@ * src/internal/solver.ts:15 solveSpring(params, t, v0) — единственный солвер * драйвера (произвольный v0 = ядро continuity; springUnchecked НЕ используется — * у него v0 жёстко 0, src/spring.ts:141-150, корень гэпа flip); - * src/spring.ts:88 validateSpringParams — ранний MotionParamError в фабрике; + * src/spring.ts validateSpringForFrameLoop — ранний MotionParamError в фабрике; * src/flip/index.ts:133 correctRadius и :145 counterScale — ЖИВЫЕ вызовы в * geometry (пин ./flip — ровно 5 экспортов — не тронут); computeFlip/flipAt — * differential-оракулы root-пути в тестах; FlipRect — type re-export diff --git a/src/smart/index.ts b/src/smart/index.ts index 51f1453f..56998fa0 100644 --- a/src/smart/index.ts +++ b/src/smart/index.ts @@ -21,7 +21,7 @@ * ../projection createProjection — весь FLIP + continuity + C¹ (id = ключ); * ProjectionPlayNode.first === undefined ⇒ visual pickup V(p̂) в драйвере * (ноль DOM-чтений под нашим transform) — механика перехвата и continue-exit. - * ../spring validateSpringParams — ранний MotionParamError В ФАБРИКЕ, даже под + * ../spring validateSpringForFrameLoop — ранний MotionParamError В ФАБРИКЕ, даже под * reduced-motion (капчур валидирует параметры до любых эффектов). * Паттерны-копии (НЕ импорты — импорт утянул бы чужой граф в копию субпутя при * splitting:false): finite (~4 строки, приватна в projection/geometry); @@ -68,7 +68,7 @@ */ import { MotionParamError } from '../errors.js'; -import { validateSpringParams, type SpringParams } from '../spring.js'; +import { validateSpringForFrameLoop, type SpringParams } from '../spring.js'; import { createProjection, type BoxRadii, @@ -508,7 +508,7 @@ function _validateOptions(opt: SmartOptions): void { } } if (opt.spring !== undefined) { - validateSpringParams(opt.spring); // MotionParamError В ФАБРИКЕ, даже под reduce + validateSpringForFrameLoop(opt.spring); // MotionParamError В ФАБРИКЕ, даже под reduce } } diff --git a/src/spring.ts b/src/spring.ts index 67a1be65..36d16e1b 100644 --- a/src/spring.ts +++ b/src/spring.ts @@ -141,14 +141,47 @@ export function settleTimeUpperBound(p: SpringParams, v0 = 0): number { } /** - * Validate spring params. Throws MotionParamError for invalid inputs. + * Физическая валидность (#218, ADR-0002): ТОЛЬКО домен ОДУ — + * конечная mass > 0, stiffness > 0, damping ≥ 0. Никаких бюджетов + * исполнителя: медленные (ω₀ → 0) и незатухающие (c = 0) системы + * физически валидны, аналитический солвер вычисляет их точно на любом t. + */ +export function validateSpringPhysics(p: SpringParams): void { + if (!Number.isFinite(p.mass) || p.mass <= 0) { + throw new MotionParamError('LM088'); + } + if (!Number.isFinite(p.stiffness) || p.stiffness <= 0) { + throw new MotionParamError('LM089'); + } + // Домен damping включает границу представимости (не бюджет исполнителя): + // за ζ² > MAX_VALUE полюса вырождаются в double и солвер молча врёт нулём + // (контрпример: m=1e-300,k=1,c=1e10, t=1e9 → 0 вместо 0.095). Раньше класс + // закрывал бюджетный LM091; после сплита #218 домен закрывает его сам. + // Одно условие покрывает всё: NaN damping → ζ=NaN → !(ζ≥0); отрицательный → + // ζ<0; Infinity/overflow → ζ² превышает MAX. √k·√m устойчиво к переполнению. + const zeta = p.damping / (2 * Math.sqrt(p.stiffness) * Math.sqrt(p.mass)); + if (!(zeta >= 0 && zeta * zeta < 1 / 0)) { + throw new MotionParamError('LM090'); + } +} + +/** + * Валидатор ГРАНИЦЫ КАДРОВОГО ИСПОЛНИТЕЛЯ (#218, ADR-0002): физическая + * валидность ПЛЮС бюджет оседания — аналитическая верхняя граница времени + * оседания обязана помещаться в MAX_FRAMES·FIXED_DT_S (≈33.3 с), иначе + * исполнитель не способен представить траекторию (снап на кадре-капе). * - * Exported so drive() can call this synchronously at its boundary — - * before any Promise is constructed or frame scheduled — making invalid - * spring config throw eagerly and deterministically regardless of the - * injected scheduler. + * Вызывается синхронно на границе исполнителя (drive/driver/MotionValue/ + * compositor/…) — до конструирования Promise и планирования кадра, чтобы + * невалидная конфигурация падала детерминированно при любом шедулере. + * Валидатору нужна только пружина из покоя; отдельный вызов позволяет + * tree-shaking удалить v0-envelope из MotionValue/биндингов без компилятора. */ -export function validateSpringParams(p: SpringParams): void { +export function validateSpringForFrameLoop(p: SpringParams): void { + // Полевые проверки продублированы телом (не вызовом validateSpringPhysics): + // esbuild инлайнит вызов IIFE-обёрткой (+24 B gz в mixed-гейте); эквивалентность + // пинует тест. ζ-гард здесь не нужен: вырожденные полюса дают settle=Infinity + // и отвергаются бюджетом ниже (LM091) — класс «молча неверно» не проходит. if (!Number.isFinite(p.mass) || p.mass <= 0) { throw new MotionParamError('LM088'); } @@ -158,16 +191,20 @@ export function validateSpringParams(p: SpringParams): void { if (!Number.isFinite(p.damping) || p.damping < 0) { throw new MotionParamError('LM090'); } - // Единый выведенный гард (взамен коробочных ω₀/ζ-полов, см. SETTLE_BUDGET_S): - // аналитическое время оседания обязано помещаться в бюджет кадра-капа. - // Валидатору нужна только пружина из покоя. Отдельный вызов позволяет - // tree-shaking удалить v0-envelope из MotionValue/биндингов без компилятора. - const tSettle = settleTimeAtRestUpperBound(p); - if (!(tSettle <= SETTLE_BUDGET_S)) { + if (!(settleTimeAtRestUpperBound(p) <= SETTLE_BUDGET_S)) { throw new MotionParamError('LM091'); } } +/** + * Историческое имя (semver-совместимость): семантика границы исполнителя + * (физика + бюджет). Новый код выбирает валидатор явно: + * validateSpringPhysics — чистая аналитика, validateSpringForFrameLoop — + * кадровые исполнители. + */ +export const validateSpringParams: (p: SpringParams) => void = + validateSpringForFrameLoop; + /** * Clamp a value to be finite. Defensive guard — analytical solver should * never produce non-finite values for valid inputs, but floating-point @@ -223,10 +260,15 @@ export function springUnchecked(params: SpringParams, t: number): SpringResult { * 2. Critically: c = 2*sqrt(k*m) * 3. Overdamped: c > 2*sqrt(k*m) * + * Чистая аналитика (#218): валидируется ТОЛЬКО физический домен — + * медленные и незатухающие системы математически валидны и вычислимы + * замкнутой формой на любом t. Бюджет оседания — забота кадровых + * исполнителей (validateSpringForFrameLoop на их границе). + * * @param params - spring physics parameters * @param t - time in seconds (≥ 0) */ export function spring(params: SpringParams, t: number): SpringResult { - validateSpringParams(params); + validateSpringPhysics(params); return springUnchecked(params, t); } diff --git a/src/spring/index.ts b/src/spring/index.ts index 8aea3784..4e3b6514 100644 --- a/src/spring/index.ts +++ b/src/spring/index.ts @@ -4,49 +4,48 @@ * Закрывает хвост S3 суперсета (HIGH-гэп из gap-matrix): интуитивные * параметризации пружин поверх физического ядра {mass, stiffness, damping}. * + * ТОЧНЫЕ преобразования (#218, #230, ADR-0002): каждый конструктор — чистая + * биекция наблюдаемых координат в физические, БЕЗ тихой коэрсии под бюджеты + * исполнителей. Медленные и незатухающие результаты физически валидны + * (validateSpringPhysics); кадровый исполнитель проверяет СВОЙ бюджет сам + * (validateSpringForFrameLoop) на своей границе. + * * - fromBounce({duration, bounce}) — канон SwiftUI Spring(duration:bounce:): - * ζ = 1 − bounce, ω0 = 2π/duration → k = m·ω0², c = 2m·ζ·ω0. + * ζ = 1 − bounce, ω₀ = 2π/duration → k = m·ω₀², c = 2m·ζ·ω₀. * bounce ∈ [−1, 1] — точный диапазон SwiftUI (0 = критическое, >0 = упругая, - * <0 = пере-демпфированная «плоская»); Motion принимает подмножество [0, 1], - * поэтому любой Motion-вход валиден и здесь. + * <0 = пере-демпфированная «плоская»); bounce = 1 → ζ = 0 → damping = 0 — + * математически незатухающий осциллятор, как и должно быть. * - fromVisualDuration — время ПЕРВОГО визуального касания цели (Motion): * для ζ<1 решается точно из первого пересечения x(t)=1: - * ωd·t* = π − atan(ωd/(ζω0)) → ω0 = (π − atan(√(1−ζ²)/ζ)) / (√(1−ζ²)·Tv); - * для ζ≥1 пересечения нет — Tv трактуется как выход на ~99% цели - * (медленнейшая мода: ζω0·Tv ≈ ln(100)). + * ωd·t* = π − atan(ωd/(ζω₀)) → ω₀ = (π − atan(√(1−ζ²)/ζ)) / (√(1−ζ²)·Tv); + * формула непрерывна в ζ=0 (atan(∞)=π/2 → ω₀=π/(2·Tv), проверка: x=1−cos ω₀t). + * Для ζ≥1 пересечения нет — Tv трактуется как выход на ~99% цели + * (медленнейшая мода: ζω₀·Tv ≈ ln(100)). + * - springFromPeak — точный обратный конструктор из наблюдаемого пика (#230): + * t_peak = π/ωd; overshoot = exp(−ζπ/√(1−ζ²)) ⇒ L = −ln(overshoot), + * ζ = L/√(π²+L²), ω₀ = √(π²+L²)/t_peak. Либо напрямую из dampingRatio: + * ω₀ = π/(t_peak·√(1−ζ²)). + * - springFromOscillation — точный обратный конструктор из периода затухающих + * колебаний и огибающей (#230): ωd = 2π/period, α — из halfLife (ln2/T½), + * decayTime (1/τ) или dampingRatio (ωd·ζ/√(1−ζ²)); ω₀ = √(ωd²+α²), ζ = α/ω₀. * - springPresets — канонические пресеты react-spring (tension/friction * при mass=1): default/gentle/wobbly/stiff/slow/molasses. * - springAsEasing(params) — пружина как easing-функция t∈[0,1]→value - * (совместима с keyframes/tween): шкала времени = горизонт допуска, кривая - * C¹-запечатана на обоих концах, отклонение от настоящей пружины не - * превышает CONVERGENCE_THRESHOLD; форма OVERSHOOTING при ζ<1. - * - * Все результаты уважают выведенный бюджет валидатора (settleTimeUpperBound - * ≤ бюджета кадра-капа): краевые bounce/duration ЧЕСТНО клампятся к - * минимальному оседающему ζ, а не к коробочному полу 0.2 (2026-07-03). + * (совместима с keyframes/tween): шкала времени = время оседания + * параметров; эндпоинты точны (дисциплина NE2), форма OVERSHOOTING + * при ζ<1. Требует оседающую пружину (ζ>0): у незатухающей e(1)=1 + * недостижимо — MotionParamError LM169. * - * Инварианты: zero-DOM, zero-deps, детерминизм, MotionParamError рано. + * Инварианты: zero-DOM, zero-deps, детерминизм, MotionParamError рано, + * обратимость: constructor(observables(params)) ≡ params с точностью IEEE-754. */ -import { settleTimeAtRestUpperBound, spring, type SpringParams } from '../spring.js'; +import { validateSpringPhysics, type SpringParams } from '../spring.js'; +export { validateSpringPhysics, validateSpringForFrameLoop } from '../spring.js'; import { CONVERGENCE_THRESHOLD } from '../internal/constants.js'; import { makeSpringValueSampler } from '../internal/solver.js'; import { MotionParamError } from '../errors.js'; -// ─── Бюджет валидатора (зеркалит выведенный закон spring.ts, 2026-07-03) ───── -// -// Коробочные полы (ω₀ ≥ 2, ζ ∈ [0.2, 4]) удалены вместе с валидатором: теперь -// принимается любая пружина, чьё аналитическое время оседания помещается в -// бюджет кадра-капа (settleTimeUpperBound ≤ ~33.3 c). Клампы воронки ниже — -// минимальные, только против физически неоседающих краёв (ζ → 0 при малой ω₀): -// ζ_min выводится из того же бюджета: rate = ζ·ω₀ ≥ LN_BUDGET/бюджет. -const SETTLE_BUDGET_S = 2000 / 60; // = MAX_FRAMES·FIXED_DT_S валидатора -/** - * ln-потребность оседания как у валидатора: ln(1/ε) + max(0, ln ω₀) - * (скоростной критерий |v| < ε растёт с ω₀) + запас на амплитудный член. - */ -const lnBudget = (omega0: number): number => - Math.log(1 / 0.005) + Math.max(0, Math.log(omega0)) + 2; /** ln(100): множитель времени затухания огибающей до 1%. */ const LN_100 = Math.log(100); @@ -128,75 +127,68 @@ function easingHorizon(z: number): number { return hi; } -// ─── fromBounce ────────────────────────────────────────────────────────────── +// ─── Общие проверки входов ─────────────────────────────────────────────────── -/** Опции duration+bounce параметризации. */ -export interface FromBounceOptions { - /** Перцептивная длительность (секунды), > 0. */ - readonly duration: number; - /** Упругость ∈ [−1, 1]: 0 — критическое демпфирование. */ - readonly bounce: number; - /** Масса. По умолчанию 1. */ - readonly mass?: number | undefined; -} - -function checkBounce(bounce: number, name: string): void { +function checkBounce(bounce: number): void { if (!Number.isFinite(bounce) || bounce < -1 || bounce > 1) { throw new MotionParamError('LM092'); } } -function checkPositive(v: number, name: string, field: string): void { +function checkPositive(v: number): void { if (!Number.isFinite(v) || v <= 0) { throw new MotionParamError('LM093'); } } -function toParams(omega0Raw: number, zetaRaw: number, mass: number): SpringParams { - // Честные клампы к ВЫВЕДЕННОМУ бюджету (не к коробочным полам, 2026-07-03): - // оба пола выводятся из одного условия «медленная мода оседает в бюджет - // кадра-капа» (rate·budget ≥ LN_BUDGET, rate = ζω₀ | ω₀(ζ−√(ζ²−1))). - // - bounce=1 (ζraw=0) больше не срезается до 0.2: при типичной ω₀ ζ_min — - // доли процента, «полностью упругая» пружина реально достижима; - // - запрошенная длительность за бюджетом коэрсится К БЮДЖЕТУ (прежняя - // коробка ω₀≥2 молча превращала 100-секундный запрос в ~2.3-секундный — - // худшая из возможных подмен намерения). - const zetaSeed = Math.max(1e-4, zetaRaw); - let omega0 = Math.max( - omega0Raw, - lnBudget(omega0Raw) / (slowRoot(zetaSeed) * SETTLE_BUDGET_S), - ); - const zetaMin = Math.min(1, lnBudget(omega0) / (omega0 * SETTLE_BUDGET_S)); - const zeta = Math.max(zetaMin, zetaRaw); - // Точная досадка под бюджет ЕДИНЫМ источником истины (settleTimeUpperBound - // валидатора): аналитические полы выше — сид; амплитудный член у ζ≈1 они - // не учитывают. t ∝ 1/ω₀ при фиксированной ζ — 3 итераций достаточно. - for (let i = 0; i < 3; i++) { - const params = { - mass, - stiffness: mass * omega0 * omega0, - damping: 2 * mass * zeta * omega0, - }; - const t = settleTimeAtRestUpperBound(params); - if (t <= SETTLE_BUDGET_S) break; - omega0 *= (t / SETTLE_BUDGET_S) * 1.02; - } - const stiffness = mass * omega0 * omega0; - const damping = 2 * mass * zeta * omega0; - return { mass, stiffness, damping }; +function massOf(mass: number | undefined): number { + if (mass === undefined) return 1; + // Явно переданный невалидный mass — отказ (LM088), не тихая подмена единицей: + // конструкторы #230 — точные биекции без коэрсии намерения (контракт шапки). + if (!Number.isFinite(mass) || mass <= 0) throw new MotionParamError('LM088'); + return mass; +} + +/** + * Точная сборка {m, k, c} из канонических координат (ω₀, ζ, m): + * k = m·ω₀², c = 2m·ζ·ω₀ — БЕЗ коэрсии (#218). Скейл (m,k,c)→(λm,λk,λc) + * не меняет ω₀/ζ/траекторию, поэтому mass — не перцептивная ручка, а + * нормировка. Композиция с физическим валидатором — страж конечности. + */ +function exactParams(omega0: number, zeta: number, mass: number): SpringParams { + const params: SpringParams = { + mass, + stiffness: mass * omega0 * omega0, + damping: 2 * mass * zeta * omega0, + }; + validateSpringPhysics(params); + return params; } -/** Пружина из перцептивной длительности и упругости (канон SwiftUI/Motion). */ +// ─── fromBounce ────────────────────────────────────────────────────────────── + +/** Опции duration+bounce параметризации. */ +export interface FromBounceOptions { + /** Перцептивная длительность (секунды), > 0. */ + readonly duration: number; + /** Упругость ∈ [−1, 1]: 0 — критическое демпфирование. */ + readonly bounce: number; + /** Масса. По умолчанию 1. */ + readonly mass?: number | undefined; +} + +/** + * Пружина из перцептивной длительности и упругости (канон SwiftUI/Motion). + * Точно: ω₀ = 2π/duration, ζ = 1 − bounce. Никакой тихой коэрсии: + * duration=100, bounce=0, mass=1 → ω₀=2π/100, ζ=1, k≈0.0039478, c≈0.1256637; + * bounce=1 → damping=0 (незатухающая — математический факт, не ошибка). + */ export function fromBounce(options: FromBounceOptions): SpringParams { - checkPositive(options.duration, 'fromBounce', 'duration'); - checkBounce(options.bounce, 'fromBounce'); - const mass = - typeof options.mass === 'number' && Number.isFinite(options.mass) && options.mass > 0 - ? options.mass - : 1; + checkPositive(options.duration); + checkBounce(options.bounce); const omega0 = (2 * Math.PI) / options.duration; const zeta = 1 - options.bounce; - return toParams(omega0, zeta, mass); + return exactParams(omega0, zeta, massOf(options.mass)); } // ─── fromVisualDuration ────────────────────────────────────────────────────── @@ -212,63 +204,149 @@ export interface FromVisualDurationOptions { } /** - * Пружина, ПЕРВОЕ касание цели у которой ≈ visualDuration (класс Motion). + * Пружина, ПЕРВОЕ касание цели у которой = visualDuration (класс Motion). * - * Именованный контракт API — Tv, упругость — характер. Если запрошенная - * пара (Tv, bounce) не помещается в бюджет оседания валидатора, коэрсия - * жертвует bounce (ζ поднимается, ω₀ пересчитывается из формулы первого - * пересечения) — КАСАНИЕ ОСТАЁТСЯ ровно в Tv. Прежний путь через общий - * toParams поднимал ω₀ и молча ускорял касание — подмена намерения - * (аудит 2026-07-03). Только когда Tv само не помещается в бюджет даже - * у почти-критической пружины, длительность деградирует К БЮДЖЕТУ - * (касание раньше — предсказуемая сторона). Инвариант «t1 совпадает с - * аналитическим решением для ФИНАЛЬНЫХ параметров» держится всегда. + * Точное аналитическое решение (#218): при ζ<1 ω₀ выводится из первого + * пересечения x(t)=1 (формула в шапке), при ζ≥1 пересечения нет и Tv — + * выход на ~99% цели по медленнейшей моде. Никакой бисекции по ζ и никакой + * бюджетной коэрсии: запрошенные (Tv, bounce) сохраняются ТОЧНО. */ export function fromVisualDuration(options: FromVisualDurationOptions): SpringParams { - checkPositive(options.visualDuration, 'fromVisualDuration', 'visualDuration'); - checkBounce(options.bounce, 'fromVisualDuration'); - const mass = - typeof options.mass === 'number' && Number.isFinite(options.mass) && options.mass > 0 - ? options.mass - : 1; + checkPositive(options.visualDuration); + checkBounce(options.bounce); const Tv = options.visualDuration; - // ζ из bounce; нижний кламп — только против деления на ноль в формуле - // первого пересечения (atan(s/ζ)); бюджет оседания добирает коэрсия ниже. - const zeta = Math.max(1e-6, 1 - options.bounce); + const zeta = 1 - options.bounce; if (zeta < 1) { - // Точное решение первого пересечения x(t)=1 (вывод в шапке) при данном ζ: - // вдоль кривой Tv=const ω₀ — функция ζ, а rate = ζ·ω₀(ζ) растёт с ζ - // (у ζ→1 ω₀ → ∞), поэтому бюджет достижим бисекцией по ζ без сдвига Tv. - const paramsAt = (z: number): SpringParams => { - const s = Math.sqrt(1 - z * z); - const w = (Math.PI - Math.atan(s / z)) / (s * Tv); - return { mass, stiffness: mass * w * w, damping: 2 * mass * z * w }; - }; - const fits = (z: number): boolean => - settleTimeAtRestUpperBound(paramsAt(z)) <= SETTLE_BUDGET_S; - if (fits(zeta)) return paramsAt(zeta); - const Z_HI = 0.995; // почти-критическая; ближе к 1 касание вырождается численно - if (fits(Z_HI)) { - let lo = zeta; - let hi = Z_HI; // инвариант бисекции: fits(hi) всегда истинно - for (let i = 0; i < 48; i++) { - const mid = (lo + hi) / 2; - if (fits(mid)) hi = mid; - else lo = mid; - } - return paramsAt(hi); - } - // Tv не помещается в бюджет даже у ζ=Z_HI: честная деградация - // длительности к бюджету (toParams), касание наступает раньше. + // Точное решение первого пересечения x(t)=1. Непрерывно в ζ=0: + // s/ζ → ∞, atan → π/2, ω₀ → π/(2·Tv) — первый максимум 1−cos(ω₀t). const s = Math.sqrt(1 - zeta * zeta); - return toParams((Math.PI - Math.atan(s / zeta)) / (s * Tv), zeta, mass); + const omega0 = (Math.PI - Math.atan(s / zeta)) / (s * Tv); + return exactParams(omega0, zeta, massOf(options.mass)); } // Пересечения нет: Tv = выход на ~99% цели по медленнейшей моде. - // Для ζ=1 огибающая ~e^{−ω0 t}; для ζ>1 медленнейший корень - // r = ω0(ζ − √(ζ²−1)) → ω0 = ln(100) / (Tv · (ζ − √(ζ²−1))). - const slow = slowRoot(zeta); - return toParams(LN_100 / (Tv * slow), zeta, mass); + // Для ζ=1 огибающая ~e^{−ω₀t}; для ζ>1 медленнейший корень + // r = ω₀(ζ − √(ζ²−1)) → ω₀ = ln(100) / (Tv · (ζ − √(ζ²−1))). + const slow = zeta - Math.sqrt(zeta * zeta - 1); + return exactParams(LN_100 / (Tv * slow), zeta, massOf(options.mass)); +} + +// ─── springFromPeak (#230) ─────────────────────────────────────────────────── + +/** Опции точного обратного конструктора из наблюдаемого пика. */ +export interface FromPeakOptions { + /** Время первого пика перерегулирования (секунды), > 0. */ + readonly timeToPeak: number; + /** Пик как абсолютное значение (>1, напр. 1.15) или доля (0.15). */ + readonly peak?: number | undefined; + /** Перерегулирование как доля ∈ (0, 1) (напр. 0.15 = 15%). */ + readonly overshoot?: number | undefined; + /** Коэффициент демпфирования ζ ∈ (0, 1) — альтернатива overshoot. */ + readonly dampingRatio?: number | undefined; + /** Масса. По умолчанию 1. */ + readonly mass?: number | undefined; +} + +/** + * Точный обратный конструктор из наблюдаемого пика step-ответа (#230). + * + * Прямые наблюдаемые: t_peak = π/ωd (первый ноль скорости), + * overshoot = exp(−ζπ/√(1−ζ²)) (высота пика над целью). Обращение точное: + * L = −ln(overshoot); ζ = L/√(π²+L²); ω₀ = √(π²+L²)/t_peak + * (тождество: ωd = ω₀√(1−ζ²) = π/t_peak). При заданном dampingRatio + * ω₀ = π/(t_peak·√(1−ζ²)) — та же биекция, другая координата. + */ +export function springFromPeak(options: FromPeakOptions): SpringParams { + checkPositive(options.timeToPeak); + const mass = massOf(options.mass); + + if (typeof options.dampingRatio === 'number') { + const zeta = options.dampingRatio; + if (!Number.isFinite(zeta) || zeta <= 0 || zeta >= 1) { + throw new MotionParamError('LM092'); + } + const omega0 = Math.PI / (options.timeToPeak * Math.sqrt(1 - zeta * zeta)); + return exactParams(omega0, zeta, mass); + } + + let mp: number; + if (typeof options.overshoot === 'number') { + mp = options.overshoot; + } else if (typeof options.peak === 'number') { + mp = options.peak > 1 ? options.peak - 1 : options.peak; + } else { + throw new MotionParamError('LM092'); + } + if (!Number.isFinite(mp) || mp <= 0 || mp >= 1) { + throw new MotionParamError('LM092'); + } + + const L = -Math.log(mp); + const hyp = Math.sqrt(Math.PI * Math.PI + L * L); + const zeta = L / hyp; + const omega0 = hyp / options.timeToPeak; + return exactParams(omega0, zeta, mass); +} + +// ─── springFromOscillation (#230) ──────────────────────────────────────────── + +/** Опции точного обратного конструктора из наблюдаемых колебаний. */ +export interface FromOscillationOptions { + /** Период затухающих колебаний (секунды), > 0. */ + readonly period?: number | undefined; + /** Частота затухающих колебаний (Гц), > 0 — альтернатива period. */ + readonly frequency?: number | undefined; + /** Время спада амплитуды огибающей вдвое (секунды), > 0. */ + readonly halfLife?: number | undefined; + /** Постоянная времени огибающей (спад в 1/e, секунды), > 0. */ + readonly decayTime?: number | undefined; + /** Коэффициент демпфирования ζ ∈ (0, 1). */ + readonly dampingRatio?: number | undefined; + /** Масса. По умолчанию 1. */ + readonly mass?: number | undefined; +} + +/** + * Точный обратный конструктор из наблюдаемых затухающих колебаний (#230). + * + * Прямые наблюдаемые: период T = 2π/ωd и скорость огибающей α = ζω₀ + * (halfLife: α = ln2/T½; decayTime: α = 1/τ; dampingRatio: α = ωd·ζ/√(1−ζ²)). + * Обращение точное: ω₀ = √(ωd² + α²), ζ = α/ω₀ (пифагорова связь + * ωd² + (ζω₀)² = ω₀²). + */ +export function springFromOscillation(options: FromOscillationOptions): SpringParams { + let period: number; + if (typeof options.period === 'number') { + period = options.period; + } else if (typeof options.frequency === 'number') { + checkPositive(options.frequency); + period = 1 / options.frequency; + } else { + throw new MotionParamError('LM093'); + } + checkPositive(period); + const omegaD = (2 * Math.PI) / period; + + let alpha: number; + if (typeof options.halfLife === 'number') { + checkPositive(options.halfLife); + alpha = Math.LN2 / options.halfLife; + } else if (typeof options.decayTime === 'number') { + checkPositive(options.decayTime); + alpha = 1 / options.decayTime; + } else if (typeof options.dampingRatio === 'number') { + const z = options.dampingRatio; + if (!Number.isFinite(z) || z <= 0 || z >= 1) { + throw new MotionParamError('LM092'); + } + alpha = (omegaD * z) / Math.sqrt(1 - z * z); + } else { + throw new MotionParamError('LM093'); + } + + const omega0 = Math.hypot(omegaD, alpha); + const zeta = alpha / omega0; + return exactParams(omega0, zeta, massOf(options.mass)); } // ─── Пресеты (канон react-spring: tension/friction при mass=1) ─────────────── @@ -290,13 +368,12 @@ export const springPresets: Readonly 0): у незатухающей шкала времени не + * существует и e(1)=1 недостижимо — MotionParamError LM169. Медленные + * оседающие пружины валидны: функция чистая, шкала нормирована. */ export function springAsEasing(params: SpringParams): (t: number) => number { const omega0 = Math.sqrt(params.stiffness / params.mass); @@ -304,20 +381,12 @@ export function springAsEasing(params: SpringParams): (t: number) => number { // произведение stiffness·mass — это было единственное место в репозитории, // где ζ ещё считалась переполняющейся формой. const zeta = params.damping / (2 * params.mass * omega0); - // Канонический приоритет ошибок: LM088 → LM089 → LM090 → LM169 → LM091. - // Полевые коды отдаёт сам валидатор (он проверяет поля до бюджета), поэтому - // комбинированный инвалид {mass:0, damping:0} даёт LM088. Единственный - // случай, где бюджетный LM091 маскировал бы истинную причину, — валидные - // поля с damping === 0: бюджет там бесконечен ВСЕГДА, а дефект — отсутствие - // затухания, это контракт easing (LM169). Узкий ремап ровно этого случая. - try { - spring(params, 0); - } catch (error) { - if (params.damping === 0 && (error as MotionParamError).code === 'LM091') { - throw new MotionParamError('LM169'); - } - throw error; - } + // Канонический приоритет ошибок: LM088 → LM089 → LM090 → LM169 (#218): + // полевые коды отдаёт физический валидатор; бюджетного LM091 здесь нет — + // медленная пружина валидна, шкала нормирована горизонтом. Незатухающая + // (damping=0) не имеет горизонта — контракт easing, LM169 явной проверкой. + validateSpringPhysics(params); + if (params.damping === 0) throw new MotionParamError('LM169'); const horizon = easingHorizon(zeta); const settle = horizon / omega0; diff --git a/src/tokens/index.ts b/src/tokens/index.ts index 85081d54..854358d3 100644 --- a/src/tokens/index.ts +++ b/src/tokens/index.ts @@ -41,7 +41,7 @@ import { STANDARD_EASING, STANDARD_EASING_COORDS, } from '../internal/motion-defaults.js'; -import { validateSpringParams, type SpringParams } from '../spring.js'; +import { validateSpringPhysics, type SpringParams } from '../spring.js'; // ─── Длительности (мс) ─────────────────────────────────────────────────────── // @@ -157,12 +157,13 @@ export type EasingTokenName = keyof typeof easing; * время оседания солвера может отличаться — пружина живёт по физике, не по * таймеру). `bounce` ∈ [0, 1): 0 = критическое демпфирование (без overshoot), * больше — упружее; bounce=1 (ζ=0, вечный звон) в live-движке непредставим — - * отвергается. Результат прогоняется через валидатор ядра (settle-бюджет), - * поэтому выход ГАРАНТИРОВАННО принимается всеми путями движка. + * отвергается. Результат физически валиден (validateSpringPhysics, #218); + * бюджет кадра проверяет исполнитель на своей границе — очень медленная + * пружина легальна как токен, но может быть отвергнута кадровым исполнителем. * * @example springFromDurationBounce(0.35, 0) // ДС smooth (effects) * @example springFromDurationBounce(0.5, 0.3) // ДС expressive (spatial) - * @throws MotionParamError при неконечных/внедиапазонных входах или неоседании. + * @throws MotionParamError при неконечных/внедиапазонных входах. */ export function springFromDurationBounce(durationS: number, bounce: number): SpringParams { if (!Number.isFinite(durationS) || durationS <= 0) { @@ -178,7 +179,9 @@ export function springFromDurationBounce(durationS: number, bounce: number): Spr stiffness: omega0 * omega0, damping: 2 * dampingRatio * omega0, }; - validateSpringParams(params); // settle-бюджет ядра — единый источник правды + // Физическая валидация (#218): конструктор токена — чистая биекция, бюджет + // кадра проверяет исполнитель на своей границе (validateSpringForFrameLoop). + validateSpringPhysics(params); return params; } diff --git a/test/api-surface-pin.test.ts b/test/api-surface-pin.test.ts index 74feb26f..64e6f219 100644 --- a/test/api-surface-pin.test.ts +++ b/test/api-surface-pin.test.ts @@ -48,4 +48,22 @@ describe('public API surface pin', () => { it('validateSpringParams is a function', () => { expect(typeof motionModule.validateSpringParams).toBe('function'); }); + + it('validateSpringPhysics / validateSpringForFrameLoop живут в ./spring; root — исторический алиас (#218)', async () => { + // Пара валидаторов экспортируется субпутём ./spring (root не растёт — + // full-core size-гейт); исторический validateSpringParams в root обязан + // сохранять семантику границы исполнителя. + const springModule = await import('../src/spring/index.js'); + expect(typeof springModule.validateSpringPhysics).toBe('function'); + expect(typeof springModule.validateSpringForFrameLoop).toBe('function'); + expect(motionModule.validateSpringParams).toBe(springModule.validateSpringForFrameLoop); + }); + + it('шипуемый dist/spring публикует пару валидаторов (#218, export map ./spring)', async () => { + // Источник может быть правильным при сломанной генерации dist-entry — + // пин обязан смотреть на артефакт, который получит потребитель. + const dist = await import('../dist/spring/index.js'); + expect(typeof dist.validateSpringPhysics).toBe('function'); + expect(typeof dist.validateSpringForFrameLoop).toBe('function'); + }); }); diff --git a/test/compositor-compile.test.ts b/test/compositor-compile.test.ts index ba25a8c4..e1aa9457 100644 --- a/test/compositor-compile.test.ts +++ b/test/compositor-compile.test.ts @@ -275,18 +275,32 @@ describe('compositor: readCompositorSpring — closed-form (value, velocity)', ( stiffness: 1 + rnd() * 900, damping: rnd() * 120, }; - // Пропускаем неоседающие (валидатор их и так отвергнет). + // Пропускаем физически невалидные (spring() проверяет только физику) + // И неоседающие в бюджет кадра (readCompositorSpring — граница исполнителя, + // вызывает validateSpringForFrameLoop). Физика ≠ бюджет (#218). try { spring(p, 0); - } catch { + } catch (e) { + // Ожидаем только физический отказ; иной класс ошибки = регресс солвера. + const code = (e as MotionParamError).code; + if (code !== 'LM088' && code !== 'LM089' && code !== 'LM090') throw e; + continue; + } + let r: ReturnType; + try { + r = readCompositorSpring(p, { + from: (rnd() - 0.5) * 1e5, + to: (rnd() - 0.5) * 1e5, + v0: (rnd() - 0.5) * 20, + t: rnd() * 40, + }); + } catch (e) { + // readCompositorSpring вызывает validateSpringForFrameLoop на границе + // исполнителя (#218): физика пройдена выше, поэтому легален только + // бюджетный LM091. Любая иная ошибка = регресс, тест обязан упасть. + if ((e as MotionParamError).code !== 'LM091') throw e; continue; } - const r = readCompositorSpring(p, { - from: (rnd() - 0.5) * 1e5, - to: (rnd() - 0.5) * 1e5, - v0: (rnd() - 0.5) * 20, - t: rnd() * 40, - }); expect(Number.isFinite(r.value)).toBe(true); expect(Number.isFinite(r.velocity)).toBe(true); } diff --git a/test/spring-easing-c1.test.ts b/test/spring-easing-c1.test.ts index 57d52d6f..dcf4dcff 100644 --- a/test/spring-easing-c1.test.ts +++ b/test/spring-easing-c1.test.ts @@ -102,7 +102,11 @@ describe('springAsEasing: горячий путь и краевые входы', const spy = vi.fn(); vi.doMock('../src/spring.js', async () => { const actual = await vi.importActual('../src/spring.js'); - return { ...actual, spring: (...args: Parameters) => (spy(), actual.spring(...args)) }; + return { + ...actual, + validateSpringPhysics: (...args: Parameters) => + (spy(), actual.validateSpringPhysics(...args)), + }; }); const { springAsEasing: mocked } = await import('../src/spring/index.js'); const easing = mocked(CRITICAL); @@ -127,9 +131,10 @@ describe('springAsEasing: горячий путь и краевые входы', }); /** - * Канонический приоритет ошибок (бриф D2): LM088 → LM089 → LM090 → LM169 → - * LM091. Табличный корпус перебирает сочетания нескольких невалидных полей: - * побеждать обязан код старшего приоритета, а не порядок проверок в коде. + * Канонический приоритет ошибок (бриф D2): LM088 → LM089 → LM090 → LM169. + * Бюджетного LM091 в easing больше нет (#218, ADR-0002): медленная пружина + * физически валидна, easing — чистая аналитика; бюджет проверяют кадровые + * исполнители (validateSpringForFrameLoop). Побеждает код старшего приоритета. */ describe('приоритет ошибок при нескольких невалидных полях', () => { const CASES: readonly [label: string, p: { mass: number; stiffness: number; damping: number }, code: string][] = [ @@ -141,7 +146,6 @@ describe('приоритет ошибок при нескольких невал ['демпфирование −5 (поле бьёт LM169)', { mass: 1, stiffness: 100, damping: -5 }, 'LM090'], ['демпфирование NaN', { mass: 1, stiffness: 100, damping: Number.NaN }, 'LM090'], ['демпфирование ровно 0 при валидных полях', { mass: 1, stiffness: 100, damping: 0 }, 'LM169'], - ['валидные поля, бюджет не выполняется', { mass: 100, stiffness: 100, damping: 2 }, 'LM091'], ]; for (const [label, params, code] of CASES) { diff --git a/test/spring-ergonomics.test.ts b/test/spring-ergonomics.test.ts index af5c4f75..308bb445 100644 --- a/test/spring-ergonomics.test.ts +++ b/test/spring-ergonomics.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import * as ergo from '../src/spring/index.js'; import { fromBounce, fromVisualDuration, springPresets, springAsEasing } from '../src/spring/index.js'; import { spring, validateSpringParams, MotionParamError } from '../src/index.js'; +import { validateSpringPhysics } from '../src/spring/index.js'; // ─── fromBounce (канон SwiftUI/Motion: ζ = 1 − bounce, ω0 = 2π/duration) ───── @@ -50,14 +51,46 @@ describe('spring-ergonomics: fromBounce — известные числа', () = expect(Math.sqrt(p.stiffness / p.mass)).toBeCloseTo(2 * Math.PI, 3); // ω0 не зависит от массы }); - it('результат ВСЕГДА проходит validateSpringParams (клампы под полы движка)', () => { - // Экстремумы публичного диапазона: bounce 1 (ζ-пол 0.2), длинный duration (ω0-пол 2.0). + it('ТОЧНОСТЬ (#218): результат физически валиден БЕЗ коэрсии — даже вне бюджета исполнителя', () => { + // Никакой тихой подмены намерения: медленные/незатухающие результаты + // проходят физический валидатор; бюджет — забота кадрового исполнителя. for (const opts of [ - { duration: 1, bounce: 1 }, // ζ клампится к полу движка - { duration: 100, bounce: 0 }, // ω0 клампится к полу движка + { duration: 1, bounce: 1 }, // ζ=0 — незатухающая (математический факт) + { duration: 100, bounce: 0 }, // ω₀=2π/100 — медленная, но точная { duration: 0.05, bounce: -1 }, // очень быстрый + плоский ]) { - expect(() => validateSpringParams(fromBounce(opts))).not.toThrow(); + expect(() => validateSpringPhysics(fromBounce(opts))).not.toThrow(); + } + }); + + it('ТОЧНОСТЬ (#218): duration=100, bounce=0, mass=1 → точные ω₀=2π/100, ζ=1, k, c', () => { + const p = fromBounce({ duration: 100, bounce: 0 }); + const omega0 = 2 * Math.PI / 100; + expect(p.mass).toBe(1); + expect(p.stiffness).toBe(omega0 * omega0); // ≈ 0.00394784176 + expect(p.damping).toBe(2 * omega0); // ≈ 0.12566370614 + expect(p.stiffness).toBeCloseTo(0.00394784176, 10); + expect(p.damping).toBeCloseTo(0.12566370614, 10); + }); + + it('ТОЧНОСТЬ (#218): bounce=1 → ζ=0 → damping=0 (незатухающий осциллятор)', () => { + const p = fromBounce({ duration: 1, bounce: 1 }); + expect(p.damping).toBe(0); + expect(p.stiffness).toBe((2 * Math.PI) ** 2); + // Физически валидна; исполнительский бюджет её честно отклоняет. + expect(() => validateSpringPhysics(p)).not.toThrow(); + expect(() => validateSpringParams(p)).toThrow(MotionParamError); + }); + + it('ОБРАТИМОСТЬ (#218): (duration, bounce) восстанавливаются из параметров точно', () => { + for (const duration of [0.3, 1, 7, 100]) { + for (const bounce of [-1, -0.25, 0, 0.5, 1]) { + const p = fromBounce({ duration, bounce }); + const omega0 = Math.sqrt(p.stiffness / p.mass); + const zeta = p.damping / (2 * Math.sqrt(p.stiffness * p.mass)); + expect(2 * Math.PI / omega0).toBeCloseTo(duration, 9); + expect(1 - zeta).toBeCloseTo(bounce, 9); + } } }); @@ -120,30 +153,28 @@ describe('spring-ergonomics: fromVisualDuration', () => { // Полный публичный домен ζ<1, включая зону клампа ω0 (класс, слепой для // точечных тестов: длинный Tv + малый ζ → ω0 упирается в пол и пружина // быстрее запрошенной). - it('property ζ<1: t1≈Tv (±1%) на ВСЁМ домене — бюджетная коэрсия жертвует bounce, не Tv', () => { - // Выведенный закон (2026-07-03): коробочного пола ω₀=2 больше нет. Если - // запрос за бюджетом оседания, fromVisualDuration поднимает ζ вдоль кривой - // Tv=const (ω₀ пересчитывается из формулы пересечения) — именованный - // контракт API (время первого касания) сохраняется ТОЧНО, деградирует - // только упругость. Прежняя коэрсия через ω₀-подъём ускоряла касание - // до −45% (bounce=0.5, Tv=10) — подмена намерения. - for (const bounce of [0.1, 0.3, 0.5, 0.8, 1]) { + it('property ζ<1: t1≈Tv (±1%) на ВСЁМ домене — ТОЧНОЕ преобразование (#218)', () => { + // Новый закон (#218, ADR-0002): fromVisualDuration — ТОЧНАЯ биекция + // наблюдаемых (Tv, bounce) в физические (ω₀, ζ). Никакой бюджетной + // коэрсии: ζ сохраняется точно (ζ = 1 − bounce), ω₀ выводится из + // формулы первого пересечения x(t)=1. Прежняя коэрсия подменяла + // намерение — теперь контракт точен и обратим. + for (const bounce of [0.1, 0.3, 0.5, 0.8, 0.99]) { for (const Tv of [0.05, 0.5, 1.2, 1.5, 10]) { - const zetaRaw = Math.max(1e-6, 1 - bounce); - if (zetaRaw >= 1) continue; + const zetaExpected = Math.max(1e-6, 1 - bounce); + if (zetaExpected >= 1) continue; const p = fromVisualDuration({ visualDuration: Tv, bounce }); const omega0Fin = Math.sqrt(p.stiffness / p.mass); const zetaFin = p.damping / (2 * Math.sqrt(p.stiffness * p.mass)); - // ζ мог только подняться (коэрсия к бюджету), упасть — никогда - expect(zetaFin).toBeGreaterThanOrEqual(zetaRaw - 1e-9); + // ТОЧНОСТЬ: ζ = 1 − bounce без коэрсии (IEEE-754 допуск) + expect(zetaFin).toBeCloseTo(zetaExpected, 10); expect(zetaFin).toBeLessThan(1); // инвариант: первое касание = точное решение для ФИНАЛЬНЫХ параметров const sFin = Math.sqrt(1 - zetaFin * zetaFin); const tStar = (Math.PI - Math.atan(sFin / zetaFin)) / (sFin * omega0Fin); const t1 = firstCrossing(p, tStar * 2 + 0.1); expect(Math.abs(t1 - tStar) / tStar).toBeLessThan(0.01); - // Контракт длительности: держится и в зоне коэрсии (допуск — шаг - // численной сетки firstCrossing + запас). + // Контракт длительности: первое касание = Tv (допуск — шаг сетки) expect(Math.abs(t1 - Tv) / Tv).toBeLessThan(0.01); } } @@ -161,13 +192,17 @@ describe('spring-ergonomics: fromVisualDuration', () => { } }); - it('результат проходит validateSpringParams на краях', () => { + it('результат проходит validateSpringPhysics на краях (#218: физика ≠ бюджет)', () => { + // fromVisualDuration — ТОЧНОЕ преобразование; результат всегда физически + // валиден. Но validateSpringParams (=validateSpringForFrameLoop) проверяет + // ещё и бюджет оседания — медленные/незатухающие системы могут его не пройти. + // Поэтому проверяем ФИЗИЧЕСКУЮ валидность отдельно. for (const opts of [ - { visualDuration: 0.05, bounce: 1 }, - { visualDuration: 50, bounce: 0.5 }, - { visualDuration: 1, bounce: -1 }, + { visualDuration: 0.05, bounce: 1 }, // ζ=0 → незатухающая + { visualDuration: 50, bounce: 0.5 }, // очень медленная + { visualDuration: 1, bounce: -1 }, // ζ=2 → передемпфированная ]) { - expect(() => validateSpringParams(fromVisualDuration(opts))).not.toThrow(); + expect(() => validateSpringPhysics(fromVisualDuration(opts))).not.toThrow(); } }); @@ -280,9 +315,17 @@ describe('spring-ergonomics: полы движка = зеркало конста describe('spring-ergonomics-api-surface-pin', () => { it('ровно запиненный набор runtime-экспортов', () => { - expect(Object.keys(ergo).sort()).toEqual( - ['fromBounce', 'fromVisualDuration', 'springAsEasing', 'springPresets'], - ); + expect(Object.keys(ergo).sort()).toEqual([ + 'fromBounce', + 'fromVisualDuration', + 'springAsEasing', + 'springFromOscillation', + 'springFromPeak', + 'springPresets', + // Пара валидаторов #218 публикуется этим субпутём (root не растёт). + 'validateSpringForFrameLoop', + 'validateSpringPhysics', + ]); }); it('SSR: node env — не бросает', () => { diff --git a/test/spring-low-omega0-wall-clock.test.ts b/test/spring-low-omega0-wall-clock.test.ts index d445f4db..18f12154 100644 --- a/test/spring-low-omega0-wall-clock.test.ts +++ b/test/spring-low-omega0-wall-clock.test.ts @@ -131,8 +131,11 @@ describe('convergence-class guard — wall-clock-stall class (regression lock)', ).toThrow(MotionParamError); }); - it('spring() also throws for low-ω₀ configs', () => { - expect(() => spring({ mass: 1, stiffness: 0.01, damping: 0.08 }, 0.5)).toThrow(MotionParamError); + it('spring() accepts low-ω₀ configs — physics-valid, executor rejects (#218)', () => { + // #218 architecture: spring() validates ONLY physics domain (mass>0, + // stiffness>0, damping≥0). Low-ω₀ systems are mathematically valid + // and computable in closed form. Budget check is at executor boundary. + expect(() => spring({ mass: 1, stiffness: 0.01, damping: 0.08 }, 0.5)).not.toThrow(); }); it('медленная натуральная частота имеет код LM091', () => { @@ -268,8 +271,11 @@ describe('convergence-class guard — wall-clock-stall class (regression lock)', expect(msg).toBe('LM091'); }); - it('spring() also throws for near-undamped configs', () => { - expect(() => spring({ mass: 1, stiffness: 100, damping: 0 }, 0.5)).toThrow(MotionParamError); + it('spring() accepts near-undamped configs — physics-valid, executor rejects (#218)', () => { + // #218 architecture: spring() validates ONLY physics domain (mass>0, + // stiffness>0, damping≥0). damping=0 is physically valid (undamped + // harmonic oscillator). Budget check is at executor boundary. + expect(() => spring({ mass: 1, stiffness: 100, damping: 0 }, 0.5)).not.toThrow(); }); /** diff --git a/test/spring-observable-constructors.test.ts b/test/spring-observable-constructors.test.ts new file mode 100644 index 00000000..0861a779 --- /dev/null +++ b/test/spring-observable-constructors.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { + springFromPeak, + springFromOscillation, + validateSpringPhysics, + validateSpringForFrameLoop, +} from '../src/spring/index.js'; +import { + spring, + validateSpringParams, + MotionParamError, + type SpringParams, +} from '../src/index.js'; + +describe('springFromPeak — inverse constructor from observable peak (#230)', () => { + it('reconstructs spring where velocity is 0 and position equals peak at timeToPeak', () => { + const timeToPeak = 0.5; + const overshoot = 0.2; // 20% overshoot -> peak value = 1.2 + const params = springFromPeak({ timeToPeak, overshoot }); + + expect(() => validateSpringParams(params)).not.toThrow(); + + const atPeak = spring(params, timeToPeak); + expect(atPeak.value).toBeCloseTo(1.2, 3); + expect(atPeak.velocity).toBeCloseTo(0, 3); + }); + + it('supports peak specified as absolute peak value (> 1)', () => { + const timeToPeak = 0.4; + const peak = 1.15; // 15% overshoot + const params = springFromPeak({ timeToPeak, peak }); + + expect(() => validateSpringParams(params)).not.toThrow(); + + const atPeak = spring(params, timeToPeak); + expect(atPeak.value).toBeCloseTo(1.15, 3); + expect(atPeak.velocity).toBeCloseTo(0, 3); + }); + + it('passes mass option through correctly', () => { + const p1 = springFromPeak({ timeToPeak: 0.5, overshoot: 0.1, mass: 1 }); + const p2 = springFromPeak({ timeToPeak: 0.5, overshoot: 0.1, mass: 2 }); + + expect(p2.mass).toBe(2); + expect(p2.stiffness).toBeCloseTo(p1.stiffness * 2, 4); + expect(p2.damping).toBeCloseTo(p1.damping * 2, 4); + }); + + it('throws MotionParamError for invalid timeToPeak or peak/overshoot', () => { + expect(() => springFromPeak({ timeToPeak: 0, overshoot: 0.1 })).toThrow(MotionParamError); + expect(() => springFromPeak({ timeToPeak: -1, overshoot: 0.1 })).toThrow(MotionParamError); + expect(() => springFromPeak({ timeToPeak: NaN, overshoot: 0.1 })).toThrow(MotionParamError); + + expect(() => springFromPeak({ timeToPeak: 0.5, overshoot: 0 })).toThrow(MotionParamError); + expect(() => springFromPeak({ timeToPeak: 0.5, overshoot: 1 })).toThrow(MotionParamError); + expect(() => springFromPeak({ timeToPeak: 0.5, overshoot: -0.1 })).toThrow(MotionParamError); + + expect(() => springFromPeak({ timeToPeak: 0.5, peak: 1 })).toThrow(MotionParamError); + expect(() => springFromPeak({ timeToPeak: 0.5, peak: 2.5 })).toThrow(MotionParamError); + }); +}); + +describe('springFromOscillation — inverse constructor from observable oscillation (#230)', () => { + it('reconstructs spring with specified damped period and halfLife', () => { + const period = 0.8; + const halfLife = 0.4; + const params = springFromOscillation({ period, halfLife }); + + expect(() => validateSpringParams(params)).not.toThrow(); + + // At t = period, damped phase is 2*pi (1 full cycle) + // Envelope ratio at t = halfLife (0.4s) should be 0.5 relative to initial displacement + const omega0 = Math.sqrt(params.stiffness / params.mass); + const zeta = params.damping / (2 * Math.sqrt(params.stiffness * params.mass)); + const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); + + expect((2 * Math.PI) / omegaD).toBeCloseTo(period, 3); + const decayRate = zeta * omega0; + expect(Math.exp(-decayRate * halfLife)).toBeCloseTo(0.5, 3); + }); + + it('supports frequency and decayTime parameterizations', () => { + const frequency = 2; // 2 Hz -> period = 0.5s + const decayTime = 0.3; // tau = 0.3s + const params = springFromOscillation({ frequency, decayTime }); + + expect(() => validateSpringParams(params)).not.toThrow(); + + const omega0 = Math.sqrt(params.stiffness / params.mass); + const zeta = params.damping / (2 * Math.sqrt(params.stiffness * params.mass)); + const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); + + expect(omegaD / (2 * Math.PI)).toBeCloseTo(frequency, 3); + const decayRate = zeta * omega0; + expect(decayRate * decayTime).toBeCloseTo(1, 3); + }); + + it('supports dampingRatio / zeta input', () => { + const period = 1.0; + const dampingRatio = 0.25; + const params = springFromOscillation({ period, dampingRatio }); + + expect(() => validateSpringParams(params)).not.toThrow(); + + const omega0 = Math.sqrt(params.stiffness / params.mass); + const zeta = params.damping / (2 * Math.sqrt(params.stiffness * params.mass)); + + expect(zeta).toBeCloseTo(0.25, 3); + const omegaD = omega0 * Math.sqrt(1 - zeta * zeta); + expect((2 * Math.PI) / omegaD).toBeCloseTo(period, 3); + }); + + it('throws MotionParamError for missing or invalid parameters', () => { + // Missing period/frequency or missing decay spec + expect(() => springFromOscillation({ period: 1 } as any)).toThrow(MotionParamError); + expect(() => springFromOscillation({ halfLife: 0.5 } as any)).toThrow(MotionParamError); + + // Non-positive period/frequency + expect(() => springFromOscillation({ period: 0, halfLife: 0.5 })).toThrow(MotionParamError); + expect(() => springFromOscillation({ frequency: -1, halfLife: 0.5 })).toThrow(MotionParamError); + + // Invalid damping specs + expect(() => springFromOscillation({ period: 1, halfLife: -0.1 })).toThrow(MotionParamError); + expect(() => springFromOscillation({ period: 1, dampingRatio: 0 })).toThrow(MotionParamError); + expect(() => springFromOscillation({ period: 1, dampingRatio: 1 })).toThrow(MotionParamError); + }); +}); + +/** + * Пин эквивалентности дубликата (#218, size-инвариант): физические проверки + * в validateSpringForFrameLoop продублированы ТЕЛОМ (не вызовом + * validateSpringPhysics) — esbuild инлайнил вызов IIFE-обёрткой (+24 B gz + * в mixed-гейте). Дубликаты обязаны отвергать один и тот же физический + * домен с теми же кодами; рассинхрон обязан краснить этот корпус. + */ +describe('validateSpringPhysics ≡ физическая часть validateSpringForFrameLoop', () => { + const HOSTILE: readonly [label: string, p: SpringParams][] = [ + ['mass 0', { mass: 0, stiffness: 100, damping: 10 }], + ['mass −1', { mass: -1, stiffness: 100, damping: 10 }], + ['mass NaN', { mass: Number.NaN, stiffness: 100, damping: 10 }], + ['mass Infinity', { mass: Number.POSITIVE_INFINITY, stiffness: 100, damping: 10 }], + ['stiffness 0', { mass: 1, stiffness: 0, damping: 10 }], + ['stiffness −1', { mass: 1, stiffness: -1, damping: 10 }], + ['stiffness NaN', { mass: 1, stiffness: Number.NaN, damping: 10 }], + ['stiffness Infinity', { mass: 1, stiffness: Number.POSITIVE_INFINITY, damping: 10 }], + ['damping −1', { mass: 1, stiffness: 100, damping: -1 }], + ['damping NaN', { mass: 1, stiffness: 100, damping: Number.NaN }], + ['damping Infinity', { mass: 1, stiffness: 100, damping: Number.POSITIVE_INFINITY }], + ]; + + for (const [label, p] of HOSTILE) { + it(`${label}: оба валидатора бросают одинаковый код`, () => { + let physicsCode = ''; + let frameCode = ''; + try { + validateSpringPhysics(p); + } catch (e) { + physicsCode = (e as MotionParamError).code; + } + try { + validateSpringForFrameLoop(p); + } catch (e) { + frameCode = (e as MotionParamError).code; + } + expect(physicsCode).not.toBe(''); + expect(frameCode).toBe(physicsCode); + }); + } + + it('ζ²-overflow: оба валидатора fail-closed, каждый своим кодом', () => { + // Вырожденные полюса: physics отвергает доменом (LM090), frame-loop — + // бюджетом (settle=Infinity → LM091). Разные коды легальны, «молча + // неверно» не проходит ни одну границу. + for (const p of [ + { mass: 1e-300, stiffness: 1, damping: 1e10 }, + { mass: 1, stiffness: 1e-100, damping: 2e106 }, + ]) { + expect(() => validateSpringPhysics(p)).toThrow( + expect.objectContaining({ code: 'LM090' }), + ); + expect(() => validateSpringForFrameLoop(p)).toThrow( + expect.objectContaining({ code: 'LM091' }), + ); + } + }); + + it('физически валидная, но за бюджетом кадра: physics молчит, frame-loop бросает LM091', () => { + const slow: SpringParams = { mass: 100, stiffness: 100, damping: 2 }; + expect(() => validateSpringPhysics(slow)).not.toThrow(); + expect(() => validateSpringForFrameLoop(slow)).toThrow( + expect.objectContaining({ code: 'LM091' }), + ); + }); +}); diff --git a/test/spring-overdamped-slow-pole.test.ts b/test/spring-overdamped-slow-pole.test.ts index fc52a360..25602d7f 100644 --- a/test/spring-overdamped-slow-pole.test.ts +++ b/test/spring-overdamped-slow-pole.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { makeSpringValueSampler, solveSpring } from '../src/internal/solver.js'; -import { settleTimeAtRestUpperBound, validateSpringParams } from '../src/spring.js'; +import { settleTimeAtRestUpperBound, spring, validateSpringParams } from '../src/spring.js'; /** * Issue #226. При ζ > 1/√ε (≈6.7e7) выражение √(ζ²−1) округляется ровно в ζ, @@ -212,3 +212,23 @@ describe('ставка точна и до вырождения разности' } }); }); + +/** + * После сплита #218 (spring() = чистая физика) граница представимости обязана + * жить в самом физическом домене, а не в бюджете исполнителя: без гарда + * spring({m:1e-300, k:1, c:1e10}, 1e9) молча возвращал 0 вместо 0.095 + * (полюса вырождены, ζ² = Infinity). RED-доказательство: гард снят → тест красный. + */ +describe('ζ²-overflow отвергается физическим доменом, а не только бюджетом', () => { + it('spring() бросает LM090 на вырожденных полюсах, а не врёт нулём', () => { + for (const outOfDomain of [ + { mass: 1e-300, stiffness: 1, damping: 1e10 }, + { mass: 1, stiffness: 1e-100, damping: 2e106 }, + { mass: 1, stiffness: 1, damping: 4e154 }, + ]) { + expect(() => spring(outOfDomain, 1)).toThrow( + expect.objectContaining({ code: 'LM090' }), + ); + } + }); +});