Skip to content

packages/core/src/lib/animation/program.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { getSignalNode, isSignal } from '../signals/graph.js';
2 import type { WritableSignal } from '../signals/types.js';
3 import { getDefinitionRecord } from './definition.js';
4 import type {
5   PibblAnimationDefinition,
6   PibblAnimationProgram,
7   PibblAnimationRepeatDirection,
8   PibblAnimationRepeatOptions,
9 } from './types.js';
10 
11 export type ProgramDuration =
12   | { readonly kind: 'finite'; readonly milliseconds: number }
13   | { readonly kind: 'infinite' }
14   | { readonly kind: 'unresolved-stepper' };
15 
16 export interface WriterSpan {
17   readonly output: WritableSignal<unknown>;
18   readonly start: number;
19   readonly end: number;
20   readonly path: readonly number[];
21 }
22 
23 interface WriterRepeatFrame {
24   readonly period: number;
25   readonly iterations: number;
26 }
27 
28 /** @internal Activation-resolved symbolic writer schedule. */
29 export interface ResolvedWriterSchedule {
30   readonly output: WritableSignal<unknown>;
31   readonly start: number;
32   readonly end: number;
33   readonly path: readonly number[];
34   readonly repeats: readonly WriterRepeatFrame[];
35 }
36 
37 interface WriterSchedule {
38   readonly span: WriterSpan;
39   readonly repeats: readonly WriterRepeatFrame[];
40 }
41 
42 interface BaseProgramRecord {
43   readonly kind: 'track' | 'sequence' | 'parallel' | 'wait' | 'marker' | 'repeat';
44   readonly children: readonly PibblAnimationProgram[];
45   readonly duration: ProgramDuration;
46   readonly hasReachableWork: boolean;
47   readonly outputs: readonly WritableSignal<unknown>[];
48   readonly writerSpans: readonly WriterSpan[];
49   readonly writerSchedules: readonly WriterSchedule[];
50 }
51 
52 export interface TrackProgramRecord extends BaseProgramRecord {
53   readonly kind: 'track';
54   readonly output: WritableSignal<unknown>;
55   readonly definition: PibblAnimationDefinition<unknown>;
56 }
57 
58 export interface SequenceProgramRecord extends BaseProgramRecord {
59   readonly kind: 'sequence';
60 }
61 
62 export interface ParallelProgramRecord extends BaseProgramRecord {
63   readonly kind: 'parallel';
64 }
65 
66 export interface WaitProgramRecord extends BaseProgramRecord {
67   readonly kind: 'wait';
68 }
69 
70 export interface MarkerProgramRecord extends BaseProgramRecord {
71   readonly kind: 'marker';
72   readonly name: string;
73 }
74 
75 export interface RepeatProgramRecord extends BaseProgramRecord {
76   readonly kind: 'repeat';
77   readonly iterations: number;
78   readonly direction: PibblAnimationRepeatDirection;
79 }
80 
81 export type ProgramRecord =
82   | TrackProgramRecord
83   | SequenceProgramRecord
84   | ParallelProgramRecord
85   | WaitProgramRecord
86   | MarkerProgramRecord
87   | RepeatProgramRecord;
88 
89 const programRecords = new WeakMap<PibblAnimationProgram, ProgramRecord>();
90 const exactSeekableDurations = new WeakMap<PibblAnimationProgram, number | undefined>();
91 const activationDependentWriterOutputs = new WeakMap<
92   PibblAnimationProgram,
93   ReadonlySet<WritableSignal<unknown>>
94 >();
95 
96 const finite = (milliseconds: number): ProgramDuration => Object.freeze({
97   kind: 'finite' as const,
98   milliseconds,
99 });
100 const infinite: ProgramDuration = Object.freeze({ kind: 'infinite' as const });
101 const unresolved: ProgramDuration = Object.freeze({ kind: 'unresolved-stepper' as const });
102 
103 function freezePath(path: readonly number[]): readonly number[] {
104   return Object.freeze([...path]);
105 }
106 
107 function freezeSpan(
108   output: WritableSignal<unknown>,
109   start: number,
110   end: number,
111   path: readonly number[],
112 ): WriterSpan {
113   return Object.freeze({ output, start, end, path: freezePath(path) });
114 }
115 
116 function freezeSchedule(
117   span: WriterSpan,
118   repeats: readonly WriterRepeatFrame[] = [],
119 ): WriterSchedule {
120   return Object.freeze({
121     span,
122     repeats: Object.freeze(repeats.map(repeat => Object.freeze({ ...repeat }))),
123   });
124 }
125 
126 function finiteWriterSpans(schedules: readonly WriterSchedule[]): readonly WriterSpan[] {
127   return Object.freeze(schedules.map(schedule => schedule.span));
128 }
129 
130 function outputsFor(schedules: readonly WriterSchedule[]): readonly WritableSignal<unknown>[] {
131   const outputs: WritableSignal<unknown>[] = [];
132   for (const schedule of schedules) {
133     if (!outputs.includes(schedule.span.output)) outputs.push(schedule.span.output);
134   }
135   return Object.freeze(outputs);
136 }
137 
138 function createProgram<T extends ProgramRecord>(record: T): PibblAnimationProgram {
139   const program = Object.freeze({}) as PibblAnimationProgram;
140   programRecords.set(program, Object.freeze(record) as ProgramRecord);
141   return program;
142 }
143 
144 function programLabel(path: readonly number[]): string {
145   return `[${path.join(', ')}]`;
146 }
147 
148 function definitionDuration(definition: PibblAnimationDefinition<unknown>): ProgramDuration {
149   const record = getDefinitionRecord(definition);
150   if (record.kind !== 'stepper') return finite(record.duration);
151   return record.done === undefined ? infinite : unresolved;
152 }
153 
154 function assertWritableSignal<T>(output: WritableSignal<T>): WritableSignal<unknown> {
155   if (!isSignal(output) || typeof output.set !== 'function' || typeof output.update !== 'function') {
156     throw new TypeError('Expected a writable signal created by signal().');
157   }
158   return output as WritableSignal<unknown>;
159 }
160 
161 function prefixedSchedules(
162   schedules: readonly WriterSchedule[],
163   childIndex: number,
164   offset: number,
165 ): readonly WriterSchedule[] {
166   return Object.freeze(schedules.map(schedule => freezeSchedule(
167     freezeSpan(
168       schedule.span.output,
169       schedule.span.start + offset,
170       schedule.span.end + offset,
171       [childIndex, ...schedule.span.path],
172     ),
173     schedule.repeats,
174   )));
175 }
176 
177 function normalizedRepeats(
178   repeats: readonly WriterRepeatFrame[],
179 ): readonly WriterRepeatFrame[] {
180   const normalized: WriterRepeatFrame[] = [];
181   for (const repeat of repeats) {
182     if (repeat.iterations === 1) continue;
183     const previous = normalized.at(-1);
184     const combinedIterations = previous === undefined ? Number.NaN :
185       previous.iterations * repeat.iterations;
186     if (previous !== undefined &&
187       repeat.period === previous.period * previous.iterations &&
188       Number.isFinite(combinedIterations) && Number.isInteger(combinedIterations)) {
189       normalized[normalized.length - 1] = { period: previous.period, iterations: combinedIterations };
190     } else {
191       normalized.push({ period: repeat.period, iterations: repeat.iterations });
192     }
193   }
194   return Object.freeze(normalized.map(repeat => Object.freeze(repeat)));
195 }
196 
197 function withRepeat(
198   schedules: readonly WriterSchedule[],
199   period: number,
200   iterations: number,
201 ): readonly WriterSchedule[] {
202   return Object.freeze(schedules.map(schedule => freezeSchedule(schedule.span, normalizedRepeats([
203     ...schedule.repeats,
204     { period, iterations },
205   ]))));
206 }
207 
208 interface Dyadic {
209   readonly exponent: number;
210   readonly numerator: bigint;
211 }
212 
213 interface ExactWriterSpan {
214   readonly start: bigint;
215   readonly end: bigint;
216 }
217 
218 interface ExactWriterRepeatFrame {
219   readonly period: bigint;
220   readonly iterations: number;
221 }
222 
223 interface ExactWriterSchedule {
224   readonly span: ExactWriterSpan;
225   readonly repeats: readonly ExactWriterRepeatFrame[];
226 }
227 
228 interface ExactArithmeticSchedule {
229   readonly span: ExactWriterSpan;
230   readonly period: bigint;
231   readonly iterations: number;
232 }
233 
234 interface OverlapProofBudget {
235   remainingAlignments: number;
236 }
237 
238 /**
239  * Caps exact recursive alignment descents during one writer-pair proof. This
240  * is an availability boundary, not an acceptance shortcut: exhaustion rejects
241  * the composition instead of claiming that the schedules are disjoint.
242  */
243 const maximumNestedAlignmentDescents = 512;
244 
245 class OverlapProofBudgetExceeded extends Error {}
246 
247 const numberBits = new DataView(new ArrayBuffer(8));
248 
249 function dyadic(value: number): Dyadic {
250   numberBits.setFloat64(0, value, false);
251   const high = numberBits.getUint32(0, false);
252   const low = numberBits.getUint32(4, false);
253   const fraction = BigInt(high & 0x000f_ffff) << 32n | BigInt(low);
254   const exponentBits = (high >>> 20) & 0x7ff;
255   if (exponentBits === 0 && fraction === 0n) return { numerator: 0n, exponent: 0 };
256   if (exponentBits === 0) return { numerator: fraction, exponent: -1074 };
257   return {
258     numerator: (1n << 52n) | fraction,
259     exponent: exponentBits - 1023 - 52,
260   };
261 }
262 
263 function exactScheduleValues(schedule: WriterSchedule): readonly number[] {
264   return [
265     schedule.span.start,
266     schedule.span.end,
267     ...schedule.repeats.map(repeat => repeat.period),
268   ];
269 }
270 
271 function commonDyadicExponent(
272   first: WriterSchedule,
273   second: WriterSchedule,
274 ): number {
275   const values = [...exactScheduleValues(first), ...exactScheduleValues(second)]
276     .map(dyadic)
277     .filter(value => value.numerator !== 0n);
278   return values.length === 0 ? 0 : Math.min(...values.map(value => value.exponent));
279 }
280 
281 function scaleDyadic(value: number, exponent: number): bigint {
282   const exact = dyadic(value);
283   return exact.numerator << BigInt(exact.exponent - exponent);
284 }
285 
286 function exactSchedule(schedule: WriterSchedule, exponent: number): ExactWriterSchedule {
287   return {
288     span: {
289       start: scaleDyadic(schedule.span.start, exponent),
290       end: scaleDyadic(schedule.span.end, exponent),
291     },
292     repeats: schedule.repeats.map(repeat => ({
293       period: scaleDyadic(repeat.period, exponent),
294       iterations: repeat.iterations,
295     })),
296   };
297 }
298 
299 function floorDivide(value: bigint, divisor: bigint): bigint {
300   if (value >= 0n) return value / divisor;
301   return -((-value + divisor - 1n) / divisor);
302 }
303 
304 function ceilDivide(value: bigint, divisor: bigint): bigint {
305   return -floorDivide(-value, divisor);
306 }
307 
308 function clamp(value: bigint, minimum: bigint, maximum: bigint): bigint {
309   return value < minimum ? minimum : value > maximum ? maximum : value;
310 }
311 
312 function greatestCommonDivisor(first: bigint, second: bigint): bigint {
313   let left = first;
314   let right = second;
315   while (right !== 0n) {
316     const remainder = left % right;
317     left = right;
318     right = remainder;
319   }
320   return left;
321 }
322 
323 /** Returns sum(floor((multiplier * i + offset) / divisor), i = 0..count). */
324 function floorSum(
325   count: bigint,
326   divisor: bigint,
327   multiplier: bigint,
328   offset: bigint,
329 ): bigint {
330   let currentCount = count;
331   let currentDivisor = divisor;
332   let currentMultiplier = multiplier;
333   let currentOffset = offset;
334   let result = 0n;
335   while (true) {
336     if (currentMultiplier >= currentDivisor) {
337       result += (currentCount - 1n) * currentCount * (currentMultiplier / currentDivisor) / 2n;
338       currentMultiplier %= currentDivisor;
339     }
340     if (currentOffset >= currentDivisor) {
341       result += currentCount * (currentOffset / currentDivisor);
342       currentOffset %= currentDivisor;
343     }
344     const maximum = currentMultiplier * currentCount + currentOffset;
345     if (maximum < currentDivisor) return result;
346     currentCount = maximum / currentDivisor;
347     currentOffset = maximum % currentDivisor;
348     const previousDivisor = currentDivisor;
349     currentDivisor = currentMultiplier;
350     currentMultiplier = previousDivisor;
351   }
352 }
353 
354 /** Counts bounded nonnegative pairs where a*i + b*j is at most limit. */
355 function boundedLinearCount(
356   firstPeriod: bigint,
357   firstIterations: bigint,
358   secondPeriod: bigint,
359   secondIterations: bigint,
360   limit: bigint,
361 ): bigint {
362   if (limit < 0n) return 0n;
363   const nonemptyFirst = clamp(floorDivide(limit, firstPeriod) + 1n, 0n, firstIterations);
364   if (nonemptyFirst === 0n) return 0n;
365   const completeFirst = clamp(
366     floorDivide(limit - secondPeriod * (secondIterations - 1n), firstPeriod) + 1n,
367     0n,
368     nonemptyFirst,
369   );
370   const partialCount = nonemptyFirst - completeFirst;
371   if (partialCount === 0n) return completeFirst * secondIterations;
372   const reverseOffset = limit - firstPeriod * (nonemptyFirst - 1n);
373   return completeFirst * secondIterations + partialCount + floorSum(
374     partialCount,
375     secondPeriod,
376     firstPeriod,
377     reverseOffset,
378   );
379 }
380 
381 function boundedIndexRangeExists(
382   first: bigint,
383   last: bigint,
384   iterations: number,
385 ): boolean {
386   if (last < 0n) return false;
387   if (iterations === Number.POSITIVE_INFINITY) return first <= last;
388   const maximum = BigInt(iterations) - 1n;
389   return first <= maximum && clamp(first, 0n, maximum) <= clamp(last, 0n, maximum);
390 }
391 
392 /**
393  * Tests lower <= secondPeriod*j - firstPeriod*i <= upper without visiting
394  * either index domain. Infinite domains are first clipped to the finite band;
395  * the remaining bounded rectangle is counted with Euclidean floor sums.
396  */
397 function boundedDifferenceExists(
398   firstPeriod: bigint,
399   firstIterations: number,
400   secondPeriod: bigint,
401   secondIterations: number,
402   lower: bigint,
403   upper: bigint,
404 ): boolean {
405   if (lower > upper) return false;
406   if (firstPeriod === 0n && secondPeriod === 0n) return lower <= 0n && 0n <= upper;
407   if (firstPeriod === 0n) {
408     return boundedIndexRangeExists(
409       ceilDivide(lower, secondPeriod),
410       floorDivide(upper, secondPeriod),
411       secondIterations,
412     );
413   }
414   if (secondPeriod === 0n) {
415     return boundedIndexRangeExists(
416       ceilDivide(-upper, firstPeriod),
417       floorDivide(-lower, firstPeriod),
418       firstIterations,
419     );
420   }
421   if (firstIterations === Number.POSITIVE_INFINITY &&
422     secondIterations === Number.POSITIVE_INFINITY) {
423     const increment = greatestCommonDivisor(firstPeriod, secondPeriod);
424     return ceilDivide(lower, increment) * increment <= upper;
425   }
426 
427   const boundedFirstIterations = firstIterations === Number.POSITIVE_INFINITY ?
428     floorDivide(
429       secondPeriod * (BigInt(secondIterations) - 1n) - lower,
430       firstPeriod,
431     ) + 1n : BigInt(firstIterations);
432   const boundedSecondIterations = secondIterations === Number.POSITIVE_INFINITY ?
433     floorDivide(
434       upper + firstPeriod * (BigInt(firstIterations) - 1n),
435       secondPeriod,
436     ) + 1n : BigInt(secondIterations);
437   if (boundedFirstIterations <= 0n || boundedSecondIterations <= 0n) return false;
438 
439   const translatedUpper = upper + firstPeriod * (boundedFirstIterations - 1n);
440   const translatedBelowLower = lower - 1n +
441     firstPeriod * (boundedFirstIterations - 1n);
442   const atMostUpper = boundedLinearCount(
443     firstPeriod,
444     boundedFirstIterations,
445     secondPeriod,
446     boundedSecondIterations,
447     translatedUpper,
448   );
449   const belowLower = boundedLinearCount(
450     firstPeriod,
451     boundedFirstIterations,
452     secondPeriod,
453     boundedSecondIterations,
454     translatedBelowLower,
455   );
456   return atMostUpper > belowLower;
457 }
458 
459 function leastBoundedDifference(
460   firstPeriod: bigint,
461   firstIterations: number,
462   secondPeriod: bigint,
463   secondIterations: number,
464   lower: bigint,
465   upper: bigint,
466 ): bigint | undefined {
467   if (!boundedDifferenceExists(
468     firstPeriod,
469     firstIterations,
470     secondPeriod,
471     secondIterations,
472     lower,
473     upper,
474   )) return undefined;
475   let start = lower;
476   let end = upper;
477   while (start < end) {
478     const middle = floorDivide(start + end, 2n);
479     if (boundedDifferenceExists(
480       firstPeriod,
481       firstIterations,
482       secondPeriod,
483       secondIterations,
484       lower,
485       middle,
486     )) {
487       end = middle;
488     } else {
489       start = middle + 1n;
490     }
491   }
492   return start;
493 }
494 
495 function oneRepeatSchedule(
496   schedule: ExactWriterSchedule,
497 ): ExactArithmeticSchedule | undefined {
498   if (schedule.repeats.length === 0) {
499     return { span: schedule.span, period: 0n, iterations: 1 };
500   }
501   if (schedule.repeats.length !== 1) return undefined;
502   const repeat = schedule.repeats[0]!;
503   return { span: schedule.span, period: repeat.period, iterations: repeat.iterations };
504 }
505 
506 function fixedAgainstPeriodic(
507   fixed: ExactWriterSpan,
508   periodic: ExactArithmeticSchedule,
509 ): boolean {
510   return boundedIndexRangeExists(
511     ceilDivide(fixed.start - periodic.span.end, periodic.period),
512     floorDivide(fixed.end - periodic.span.start, periodic.period),
513     periodic.iterations,
514   );
515 }
516 
517 function arithmeticSchedulesOverlap(
518   first: ExactArithmeticSchedule,
519   second: ExactArithmeticSchedule,
520 ): boolean {
521   if (first.period === 0n && second.period === 0n) {
522     return first.span.start <= second.span.end && second.span.start <= first.span.end;
523   }
524   if (first.period === 0n) return fixedAgainstPeriodic(first.span, second);
525   if (second.period === 0n) return fixedAgainstPeriodic(second.span, first);
526   return boundedDifferenceExists(
527     first.period,
528     first.iterations,
529     second.period,
530     second.iterations,
531     first.span.start - second.span.end,
532     first.span.end - second.span.start,
533   );
534 }
535 
536 function scheduleRange(schedule: WriterSchedule): { readonly start: number; readonly end: number } {
537   const offset = schedule.repeats.reduce((total, repeat) =>
538     total + (repeat.iterations - 1) * repeat.period,
539   0);
540   return Object.freeze({ start: schedule.span.start, end: schedule.span.end + offset });
541 }
542 
543 function exactScheduleRange(
544   schedule: ExactWriterSchedule,
545 ): { readonly start: bigint; readonly end: bigint } {
546   let end = schedule.span.end;
547   for (const repeat of schedule.repeats) {
548     if (repeat.iterations === Number.POSITIVE_INFINITY) {
549       throw new Error('An infinite repeat frame cannot be nested inside another finite frame.');
550     }
551     end += (BigInt(repeat.iterations) - 1n) * repeat.period;
552   }
553   return { start: schedule.span.start, end };
554 }
555 
556 function withoutOuterRepeat(schedule: ExactWriterSchedule): ExactWriterSchedule {
557   return { span: schedule.span, repeats: schedule.repeats.slice(0, -1) };
558 }
559 
560 function shiftedSchedule(
561   schedule: ExactWriterSchedule,
562   offset: bigint,
563 ): ExactWriterSchedule {
564   return {
565     span: {
566       start: schedule.span.start + offset,
567       end: schedule.span.end + offset,
568     },
569     repeats: schedule.repeats,
570   };
571 }
572 
573 function symbolicOuterRepeatOverlap(
574   first: ExactWriterSchedule,
575   second: ExactWriterSchedule,
576   budget: OverlapProofBudget,
577 ): boolean {
578   const firstOuter = first.repeats.at(-1)!;
579   const secondOuter = second.repeats.at(-1)!;
580   const firstInner = withoutOuterRepeat(first);
581   const secondInner = withoutOuterRepeat(second);
582   const firstRange = exactScheduleRange(firstInner);
583   const secondRange = exactScheduleRange(secondInner);
584   const lower = firstRange.start - secondRange.end;
585   const upper = firstRange.end - secondRange.start;
586   let difference = leastBoundedDifference(
587     firstOuter.period,
588     firstOuter.iterations,
589     secondOuter.period,
590     secondOuter.iterations,
591     lower,
592     upper,
593   );
594   while (difference !== undefined) {
595     if (budget.remainingAlignments === 0) throw new OverlapProofBudgetExceeded();
596     budget.remainingAlignments--;
597     if (exactSchedulesOverlap(
598       firstInner,
599       shiftedSchedule(secondInner, difference),
600       budget,
601     )) return true;
602     if (difference === upper) return false;
603     difference = leastBoundedDifference(
604       firstOuter.period,
605       firstOuter.iterations,
606       secondOuter.period,
607       secondOuter.iterations,
608       difference + 1n,
609       upper,
610     );
611   }
612   return false;
613 }
614 
615 function nextScheduleTime(
616   schedule: ExactWriterSchedule,
617   target: bigint,
618 ): bigint | undefined {
619   const outer = schedule.repeats.at(-1);
620   if (outer === undefined) {
621     if (target > schedule.span.end) return undefined;
622     return target > schedule.span.start ? target : schedule.span.start;
623   }
624   const inner = withoutOuterRepeat(schedule);
625   const innerRange = exactScheduleRange(inner);
626   let iteration = ceilDivide(target - innerRange.end, outer.period);
627   if (iteration < 0n) iteration = 0n;
628   const maximum = outer.iterations === Number.POSITIVE_INFINITY ? undefined :
629     BigInt(outer.iterations) - 1n;
630   if (maximum !== undefined && iteration > maximum) return undefined;
631   const offset = iteration * outer.period;
632   const innerTime = nextScheduleTime(inner, target - offset);
633   if (innerTime !== undefined) return innerTime + offset;
634   iteration += 1n;
635   if (maximum !== undefined && iteration > maximum) return undefined;
636   return innerRange.start + iteration * outer.period;
637 }
638 
639 function repeatAgainstStaticOverlap(
640   repeated: ExactWriterSchedule,
641   staticSchedule: ExactWriterSchedule,
642 ): boolean {
643   const first = nextScheduleTime(repeated, staticSchedule.span.start);
644   return first !== undefined && first <= staticSchedule.span.end;
645 }
646 
647 function exactSchedulesOverlap(
648   first: ExactWriterSchedule,
649   second: ExactWriterSchedule,
650   budget: OverlapProofBudget,
651 ): boolean {
652   const firstArithmetic = oneRepeatSchedule(first);
653   const secondArithmetic = oneRepeatSchedule(second);
654   if (firstArithmetic !== undefined && secondArithmetic !== undefined) {
655     return arithmeticSchedulesOverlap(firstArithmetic, secondArithmetic);
656   }
657   const firstOuter = first.repeats.at(-1);
658   const secondOuter = second.repeats.at(-1);
659   if (firstOuter !== undefined && secondOuter !== undefined) {
660     return symbolicOuterRepeatOverlap(first, second, budget);
661   }
662   if (firstOuter !== undefined) return repeatAgainstStaticOverlap(first, second);
663   if (secondOuter !== undefined) return repeatAgainstStaticOverlap(second, first);
664   return false;
665 }
666 
667 function schedulesOverlap(first: WriterSchedule, second: WriterSchedule): boolean {
668   if (!Number.isFinite(first.span.end) || !Number.isFinite(second.span.end)) {
669     const firstRange = scheduleRange(first);
670     const secondRange = scheduleRange(second);
671     return firstRange.start <= secondRange.end && secondRange.start <= firstRange.end;
672   }
673   const exponent = commonDyadicExponent(first, second);
674   try {
675     return exactSchedulesOverlap(
676       exactSchedule(first, exponent),
677       exactSchedule(second, exponent),
678       { remainingAlignments: maximumNestedAlignmentDescents },
679     );
680   } catch (error) {
681     if (!(error instanceof OverlapProofBudgetExceeded)) throw error;
682     const name = getSignalNode(first.span.output).debugName ?? '<unnamed signal>';
683     throw new RangeError(
684       `Animation program cannot prove nested same-output writer schedules at paths ` +
685         `${programLabel(first.span.path)} and ${programLabel(second.span.path)} are disjoint ` +
686         `for signal "${name}" within its structural validation budget. Refactor the nested ` +
687         `repeats into sequential programs or use nonoverlapping output signals.`,
688       { cause: error },
689     );
690   }
691 }
692 
693 function throwWriterConflict(first: WriterSchedule, second: WriterSchedule): never {
694   const name = getSignalNode(first.span.output).debugName ?? '<unnamed signal>';
695   throw new RangeError(
696     `Animation program writers at paths ${programLabel(first.span.path)} and ` +
697       `${programLabel(second.span.path)} overlap for signal "${name}".`,
698   );
699 }
700 
701 function validateParallelChildren(children: readonly ProgramRecord[]): void {
702   for (let firstIndex = 0; firstIndex < children.length; firstIndex++) {
703     for (let secondIndex = firstIndex + 1; secondIndex < children.length; secondIndex++) {
704       const firstSchedules = prefixedSchedules(children[firstIndex]!.writerSchedules, firstIndex, 0);
705       const secondSchedules = prefixedSchedules(children[secondIndex]!.writerSchedules, secondIndex, 0);
706       for (const first of firstSchedules) {
707         for (const second of secondSchedules) {
708           if (first.span.output === second.span.output && schedulesOverlap(first, second)) {
709             throwWriterConflict(first, second);
710           }
711         }
712       }
713     }
714   }
715 }
716 
717 function validateRepeatSchedules(
718   schedules: readonly WriterSchedule[],
719   period: number,
720   iterations: number,
721 ): void {
722   if (iterations < 2) return;
723   for (const first of schedules) {
724     const firstRange = scheduleRange(first);
725     for (const second of schedules) {
726       if (first.span.output !== second.span.output) continue;
727       const secondRange = scheduleRange(second);
728       // Iterations are sequential: touching endpoints have source-order precedence.
729       // Zero-duration repeated writers retain the existing ambiguity guard.
730       if (firstRange.end > period + secondRange.start ||
731         (period === 0 && firstRange.end === period + secondRange.start)) {
732         throwWriterConflict(first, second);
733       }
734     }
735   }
736 }
737 
738 function sequenceDuration(records: readonly ProgramRecord[]): ProgramDuration {
739   let milliseconds = 0;
740   for (let index = 0; index < records.length; index++) {
741     const duration = records[index]!.duration;
742     if (duration.kind === 'finite') {
743       milliseconds += duration.milliseconds;
744       if (!Number.isFinite(milliseconds)) {
745         throw new RangeError('Pibbl animation program duration must be finite and representable.');
746       }
747       continue;
748     }
749     if (duration.kind === 'infinite') {
750       if (records.slice(index + 1).some(record => record.hasReachableWork)) {
751         throw new RangeError('A statically infinite animation program cannot precede reachable sequence content.');
752       }
753       return infinite;
754     }
755     return unresolved;
756   }
757   return finite(milliseconds);
758 }
759 
760 function parallelDuration(records: readonly ProgramRecord[]): ProgramDuration {
761   if (records.some(record => record.duration.kind === 'infinite')) return infinite;
762   if (records.some(record => record.duration.kind === 'unresolved-stepper')) return unresolved;
763   return finite(Math.max(0, ...records.map(record =>
764     (record.duration as Extract<ProgramDuration, { kind: 'finite' }>).milliseconds,
765   )));
766 }
767 
768 /**
769  * Binds one animation definition to one writable signal.
770  *
771  * @param output - Writable signal that receives sampled values. See {@link WritableSignal}.
772  * @param definition - Animation definition to sample into the signal. See
773  * {@link PibblAnimationDefinition} .
774  * @returns A program that animates the supplied output signal. See {@link PibblAnimationProgram}.
775  *
776  * @see {@link WritableSignal}
777  * @see {@link PibblAnimationDefinition}
778  * @see {@link PibblAnimationProgram}
779  */
780 export function drive<T>(
781   output: WritableSignal<T>,
782   definition: PibblAnimationDefinition<T>,
783 ): PibblAnimationProgram {
784   const writableOutput = assertWritableSignal(output);
785   const normalizedDefinition = definition as PibblAnimationDefinition<unknown>;
786   const duration = definitionDuration(normalizedDefinition);
787   const end = duration.kind === 'finite' ? duration.milliseconds : Number.POSITIVE_INFINITY;
788   const schedule = freezeSchedule(freezeSpan(writableOutput, 0, end, []));
789   return createProgram({
790     kind: 'track',
791     children: Object.freeze([]),
792     duration,
793     hasReachableWork: true,
794     output: writableOutput,
795     definition: normalizedDefinition,
796     outputs: Object.freeze([writableOutput]),
797     writerSchedules: Object.freeze([schedule]),
798     writerSpans: finiteWriterSpans([schedule]),
799   });
800 }
801 
802 /**
803  * Activates children in source order.
804  *
805  * @param children - Programs in execution order. See {@link PibblAnimationProgram}.
806  * @returns A program that runs each child after its predecessor completes. See
807  * {@link PibblAnimationProgram} .
808  *
809  * @see {@link PibblAnimationProgram}
810  */
811 export function sequence(...children: readonly PibblAnimationProgram[]): PibblAnimationProgram {
812   const records = children.map(getProgramRecord);
813   const duration = sequenceDuration(records);
814   let offset = 0;
815   const schedules: WriterSchedule[] = [];
816   for (let index = 0; index < records.length; index++) {
817     const record = records[index]!;
818     schedules.push(...prefixedSchedules(record.writerSchedules, index, offset));
819     if (record.duration.kind === 'finite') offset += record.duration.milliseconds;
820   }
821   const frozenSchedules = Object.freeze(schedules);
822   return createProgram({
823     kind: 'sequence',
824     children: Object.freeze([...children]),
825     duration,
826     hasReachableWork: records.some(record => record.hasReachableWork),
827     outputs: outputsFor(frozenSchedules),
828     writerSchedules: frozenSchedules,
829     writerSpans: finiteWriterSpans(frozenSchedules),
830   });
831 }
832 
833 /**
834  * Activates all children from one shared local origin.
835  *
836  * @param children - Programs to begin together. See {@link PibblAnimationProgram}.
837  * @returns A program whose children run concurrently. See {@link PibblAnimationProgram}.
838  *
839  * @see {@link PibblAnimationProgram}
840  */
841 export function parallel(...children: readonly PibblAnimationProgram[]): PibblAnimationProgram {
842   const records = children.map(getProgramRecord);
843   validateParallelChildren(records);
844   const schedules = Object.freeze(records.flatMap((record, index) =>
845     prefixedSchedules(record.writerSchedules, index, 0),
846   ));
847   return createProgram({
848     kind: 'parallel',
849     children: Object.freeze([...children]),
850     duration: parallelDuration(records),
851     hasReachableWork: records.some(record => record.hasReachableWork),
852     outputs: outputsFor(schedules),
853     writerSchedules: schedules,
854     writerSpans: finiteWriterSpans(schedules),
855   });
856 }
857 
858 /**
859  * Adds finite nonnegative local time without writing an output.
860  *
861  * @param duration - Delay in milliseconds.
862  * @returns A program that advances time without writing a value. See {@link PibblAnimationProgram}.
863  *
864  * @see {@link PibblAnimationProgram}
865  */
866 export function wait(duration: number): PibblAnimationProgram {
867   if (!Number.isFinite(duration) || duration < 0) {
868     throw new RangeError('Pibbl animation wait duration must be finite and nonnegative.');
869   }
870   return createProgram({
871     kind: 'wait',
872     children: Object.freeze([]),
873     duration: finite(duration),
874     hasReachableWork: duration > 0,
875     outputs: Object.freeze([]),
876     writerSchedules: Object.freeze([]),
877     writerSpans: Object.freeze([]),
878   });
879 }
880 
881 /**
882  * Queues one named playback milestone without invoking user code.
883  *
884  * @param name - Name delivered when playback reaches this milestone.
885  * @returns A zero-duration marker program. See {@link PibblAnimationProgram}.
886  *
887  * @see {@link PibblAnimationProgram}
888  */
889 export function marker(name: string): PibblAnimationProgram {
890   if (typeof name !== 'string' || name.length === 0) {
891     throw new TypeError('Pibbl animation marker name must be nonempty.');
892   }
893   return createProgram({
894     kind: 'marker',
895     name,
896     children: Object.freeze([]),
897     duration: finite(0),
898     hasReachableWork: true,
899     outputs: Object.freeze([]),
900     writerSchedules: Object.freeze([]),
901     writerSpans: Object.freeze([]),
902   });
903 }
904 
905 /**
906  * Repeats one child a total number of iterations.
907  *
908  * @param child - Program to repeat. See {@link PibblAnimationProgram}.
909  * @param options - Repeat count and repetition policy. See {@link PibblAnimationRepeatOptions}.
910  * @returns A program representing the requested repetitions. See {@link PibblAnimationProgram}.
911  *
912  * @see {@link PibblAnimationProgram}
913  * @see {@link PibblAnimationRepeatOptions}
914  */
915 export function repeat(
916   child: PibblAnimationProgram,
917   options: Readonly<PibblAnimationRepeatOptions>,
918 ): PibblAnimationProgram {
919   const childRecord = getProgramRecord(child);
920   const iterations = options?.iterations;
921   if (iterations !== Number.POSITIVE_INFINITY &&
922     (!Number.isInteger(iterations) || (iterations as number) < 1)) {
923     throw new RangeError('Pibbl animation repeat iterations must be an integer >= 1 or positive infinity.');
924   }
925   const direction = options?.direction ?? 'normal';
926   if (direction !== 'normal' && direction !== 'reverse' &&
927     direction !== 'alternate' && direction !== 'alternate-reverse') {
928     throw new TypeError('Pibbl animation repeat direction must be normal, reverse, alternate, or alternate-reverse.');
929   }
930   const duration = iterations === Number.POSITIVE_INFINITY ? infinite :
931     childRecord.duration.kind === 'infinite' ? infinite :
932       childRecord.duration.kind === 'unresolved-stepper' ? unresolved :
933         finiteProduct(childRecord.duration.milliseconds, iterations as number);
934   const childSchedules = prefixedSchedules(childRecord.writerSchedules, 0, 0);
935   if (childRecord.duration.kind === 'finite') {
936     validateRepeatSchedules(
937       childSchedules,
938       childRecord.duration.milliseconds,
939       iterations as number,
940     );
941   }
942   const schedules = childRecord.duration.kind === 'finite' ? withRepeat(
943     childSchedules,
944     childRecord.duration.milliseconds,
945     iterations as number,
946   ) : childSchedules;
947   return createProgram({
948     kind: 'repeat',
949     children: Object.freeze([child]),
950     duration,
951     hasReachableWork: childRecord.hasReachableWork,
952     iterations: iterations as number,
953     direction,
954     outputs: outputsFor(schedules),
955     writerSchedules: schedules,
956     writerSpans: finiteWriterSpans(schedules),
957   });
958 }
959 
960 function finiteProduct(milliseconds: number, iterations: number): ProgramDuration {
961   const result = milliseconds * iterations;
962   if (!Number.isFinite(result)) {
963     throw new RangeError('Pibbl animation program duration must be finite and representable.');
964   }
965   return finite(result);
966 }
967 
968 /** @internal Returns the private immutable record associated with a program. */
969 export function getProgramRecord(program: PibblAnimationProgram): ProgramRecord {
970   const record = programRecords.get(program);
971   if (record === undefined) throw new TypeError('Expected a Pibbl animation program.');
972   return record;
973 }
974 
975 /**
976  * @internal Returns a construction-time exact seekable duration when no track
977  * needs activation data to resolve it. Positive infinity is exact; `undefined`
978  * means activation or Task 5 state is required.
979  */
980 export function getExactSeekableProgramDuration(
981   program: PibblAnimationProgram,
982 ): number | undefined {
983   if (exactSeekableDurations.has(program)) return exactSeekableDurations.get(program);
984   const record = getProgramRecord(program);
985   let duration: number | undefined;
986   if (record.kind === 'track') {
987     const definition = getDefinitionRecord(record.definition);
988     duration = definition.kind === 'stepper' ||
989       definition.kind === 'spring' && !definition.hasFrom ?
990       undefined : definition.duration;
991   } else if (record.kind === 'wait') {
992     duration = record.duration.kind === 'finite' ? record.duration.milliseconds : undefined;
993   } else if (record.kind === 'marker') {
994     duration = 0;
995   } else {
996     const childDurations = record.children.map(getExactSeekableProgramDuration);
997     if (childDurations.some(childDuration => childDuration === undefined)) {
998       duration = undefined;
999     } else if (record.kind === 'sequence') {
1000       duration = (childDurations as number[]).reduce<number>((total, childDuration) =>
1001         total + childDuration, 0);
1002     } else if (record.kind === 'parallel') {
1003       duration = Math.max(0, ...(childDurations as number[]));
1004     } else {
1005       const childDuration = childDurations[0] as number;
1006       duration = record.iterations === Number.POSITIVE_INFINITY ?
1007         Number.POSITIVE_INFINITY : childDuration * record.iterations;
1008     }
1009   }
1010   exactSeekableDurations.set(program, duration);
1011   return duration;
1012 }
1013 
1014 function resolvedSchedule(schedule: ResolvedWriterSchedule): WriterSchedule {
1015   return freezeSchedule(
1016     freezeSpan(schedule.output, schedule.start, schedule.end, schedule.path),
1017     schedule.repeats,
1018   );
1019 }
1020 
1021 /** @internal Uses Task 3's bounded exact symbolic proof for resolved schedules. */
1022 export function resolvedWriterSchedulesOverlap(
1023   first: ResolvedWriterSchedule,
1024   second: ResolvedWriterSchedule,
1025 ): boolean {
1026   return schedulesOverlap(resolvedSchedule(first), resolvedSchedule(second));
1027 }
1028 
1029 /** @internal Adds one normalized symbolic repeat frame without expansion. */
1030 export function repeatResolvedWriterSchedules(
1031   schedules: readonly ResolvedWriterSchedule[],
1032   period: number,
1033   iterations: number,
1034 ): readonly ResolvedWriterSchedule[] {
1035   return Object.freeze(withRepeat(
1036     schedules.map(resolvedSchedule),
1037     period,
1038     iterations,
1039   ).map(schedule => Object.freeze({
1040     output: schedule.span.output,
1041     start: schedule.span.start,
1042     end: schedule.span.end,
1043     path: freezePath(schedule.span.path),
1044     repeats: schedule.repeats,
1045   })));
1046 }
1047 
1048 function dependentWriterOutputs(
1049   program: PibblAnimationProgram,
1050 ): ReadonlySet<WritableSignal<unknown>> {
1051   const cached = activationDependentWriterOutputs.get(program);
1052   if (cached !== undefined) return cached;
1053   const record = getProgramRecord(program);
1054   const dependent = new Set<WritableSignal<unknown>>();
1055   if (record.kind === 'track') {
1056     const definition = getDefinitionRecord(record.definition);
1057     if (definition.kind === 'spring' && !definition.hasFrom) {
1058       dependent.add(record.output);
1059     }
1060   } else if (record.kind === 'sequence') {
1061     let prefixDurationDependsOnActivation = false;
1062     for (const child of record.children) {
1063       for (const output of dependentWriterOutputs(child)) dependent.add(output);
1064       if (prefixDurationDependsOnActivation) {
1065         for (const output of getProgramRecord(child).outputs) dependent.add(output);
1066       }
1067       if (getExactSeekableProgramDuration(child) === undefined) {
1068         prefixDurationDependsOnActivation = true;
1069       }
1070     }
1071   } else {
1072     for (const child of record.children) {
1073       for (const output of dependentWriterOutputs(child)) dependent.add(output);
1074     }
1075     if (record.kind === 'repeat' &&
1076       getExactSeekableProgramDuration(record.children[0]!) === undefined &&
1077       (record.iterations > 1 || record.direction !== 'normal')) {
1078       for (const output of record.outputs) dependent.add(output);
1079     }
1080   }
1081   activationDependentWriterOutputs.set(program, dependent);
1082   return dependent;
1083 }
1084 
1085 function requiresActivationWriterSafetyAt(program: PibblAnimationProgram): boolean {
1086   const record = getProgramRecord(program);
1087   if (record.kind === 'parallel') {
1088     for (let firstIndex = 0; firstIndex < record.children.length; firstIndex++) {
1089       for (let secondIndex = firstIndex + 1;
1090         secondIndex < record.children.length;
1091         secondIndex++) {
1092         const first = record.children[firstIndex]!;
1093         const second = record.children[secondIndex]!;
1094         const secondOutputs = getProgramRecord(second).outputs;
1095         const firstDependent = dependentWriterOutputs(first);
1096         const secondDependent = dependentWriterOutputs(second);
1097         if (getProgramRecord(first).outputs.some(candidate =>
1098           secondOutputs.includes(candidate) &&
1099           (firstDependent.has(candidate) || secondDependent.has(candidate)),
1100         )) return true;
1101       }
1102     }
1103   }
1104   if (record.kind === 'repeat' && record.iterations > 1 &&
1105     getExactSeekableProgramDuration(record.children[0]!) === undefined &&
1106     record.outputs.length > 0) {
1107     return true;
1108   }
1109   return record.children.some(requiresActivationWriterSafetyAt);
1110 }
1111 
1112 /**
1113  * @internal Reports whether activation-resolved duration can invalidate a
1114  * construction-time parallel or repeat writer proof. The evaluator performs
1115  * the exact resolved proof against its private activation snapshot.
1116  */
1117 export function requiresProgramActivationWriterSafety(
1118   program: PibblAnimationProgram,
1119 ): boolean {
1120   return requiresActivationWriterSafetyAt(program);
1121 }
1122 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.