Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%
Expand Down
126 changes: 126 additions & 0 deletions docs/adr/0002-spring-physics-vs-budget.md
Original file line number Diff line number Diff line change
@@ -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 на границах исполнителей`
2 changes: 1 addition & 1 deletion docs/tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

// Дистанс-скейл: чем дальше путь, тем дольше движение (единообразная скорость).
Expand Down
4 changes: 2 additions & 2 deletions src/animate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -231,7 +231,7 @@ function resolveMode(options: AnimateOptions): MotionMode {
stiffness: source.stiffness,
damping: source.damping,
};
validateSpringParams(spring);
validateSpringForFrameLoop(spring);
return { _type: 'spring', _spring: spring };
}

Expand Down
12 changes: 6 additions & 6 deletions src/behaviors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 НЕ импортируется.
*
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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]!;
Expand Down Expand Up @@ -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<DismissState>(
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -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<PullState>(
Expand Down
16 changes: 8 additions & 8 deletions src/compositor/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -149,7 +149,7 @@ export function createSpringLinearCache(capacity: number = DEFAULT_CACHE_CAPACIT
const cache = createSpringLinearCacheState<SpringExecutionArtifactTuple>(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)) {
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/compositor/handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion src/compositor/segmenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading