@czap/quantizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/animated-quantizer.d.ts +108 -0
  4. package/dist/animated-quantizer.d.ts.map +1 -0
  5. package/dist/animated-quantizer.js +196 -0
  6. package/dist/animated-quantizer.js.map +1 -0
  7. package/dist/evaluate.d.ts +79 -0
  8. package/dist/evaluate.d.ts.map +1 -0
  9. package/dist/evaluate.js +128 -0
  10. package/dist/evaluate.js.map +1 -0
  11. package/dist/index.d.ts +18 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +16 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/memo-cache.d.ts +35 -0
  16. package/dist/memo-cache.d.ts.map +1 -0
  17. package/dist/memo-cache.js +39 -0
  18. package/dist/memo-cache.js.map +1 -0
  19. package/dist/quantizer.d.ts +223 -0
  20. package/dist/quantizer.d.ts.map +1 -0
  21. package/dist/quantizer.js +260 -0
  22. package/dist/quantizer.js.map +1 -0
  23. package/dist/schemas.d.ts +44 -0
  24. package/dist/schemas.d.ts.map +1 -0
  25. package/dist/schemas.js +46 -0
  26. package/dist/schemas.js.map +1 -0
  27. package/dist/testing.d.ts +15 -0
  28. package/dist/testing.d.ts.map +1 -0
  29. package/dist/testing.js +15 -0
  30. package/dist/testing.js.map +1 -0
  31. package/dist/transition.d.ts +67 -0
  32. package/dist/transition.d.ts.map +1 -0
  33. package/dist/transition.js +49 -0
  34. package/dist/transition.js.map +1 -0
  35. package/package.json +58 -0
  36. package/src/animated-quantizer.ts +272 -0
  37. package/src/evaluate.ts +160 -0
  38. package/src/index.ts +26 -0
  39. package/src/memo-cache.ts +58 -0
  40. package/src/quantizer.ts +503 -0
  41. package/src/schemas.ts +50 -0
  42. package/src/testing.ts +15 -0
  43. package/src/transition.ts +97 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Test-only entrypoint for `@czap/quantizer`. Imported as
3
+ * `@czap/quantizer/testing`.
4
+ *
5
+ * `MemoCache` and `TIER_TARGETS` are implementation primitives that
6
+ * power the public `Q.from()` builder internally. Consumers don't need
7
+ * direct access; tests do (for content-address cache assertions and
8
+ * tier-gating verification). Partitioning them off the main entry keeps
9
+ * the public surface focused on the builder.
10
+ *
11
+ * @module
12
+ */
13
+ export { MemoCache } from './memo-cache.js';
14
+ export { TIER_TARGETS } from './quantizer.js';
15
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Transition configuration for state crossings.
3
+ * Maps `from->to` state pairs to duration/easing/delay configs.
4
+ *
5
+ * @module
6
+ */
7
+ import type { Boundary, StateUnion, Quantizer, Easing, Millis } from '@czap/core';
8
+ /**
9
+ * Per-transition animation parameters.
10
+ *
11
+ * Used by {@link AnimatedQuantizer} to drive interpolation between two
12
+ * state output records. `duration` of `0` produces an instantaneous snap.
13
+ */
14
+ export interface TransitionConfig {
15
+ /** Animation duration in milliseconds (branded via {@link Millis}). */
16
+ readonly duration: Millis;
17
+ /** Easing function applied to progress; defaults to linear. */
18
+ readonly easing?: Easing.Fn;
19
+ /** Delay before the animation begins, in milliseconds. */
20
+ readonly delay?: Millis;
21
+ }
22
+ /**
23
+ * State-transition map keyed by `"from->to"` literal or `"*"` wildcard.
24
+ *
25
+ * Lookup resolves exact keys first, then the wildcard, then falls back to
26
+ * an instantaneous transition (duration: 0).
27
+ */
28
+ export interface TransitionMap<_S extends string = string> {
29
+ /** Wildcard fallback applied when no exact `from->to` key matches. */
30
+ readonly '*'?: TransitionConfig;
31
+ /** Exact `"from->to"` transition key. */
32
+ readonly [key: `${string}->${string}`]: TransitionConfig;
33
+ }
34
+ /**
35
+ * Resolver that maps a boundary crossing to its {@link TransitionConfig}.
36
+ *
37
+ * Produced by {@link Transition.for}; consumed by {@link AnimatedQuantizer}
38
+ * during animation loop setup.
39
+ */
40
+ export interface Transition<B extends Boundary.Shape> {
41
+ /** The raw transition map used to create this resolver. */
42
+ readonly config: TransitionMap<StateUnion<B> & string>;
43
+ /** Resolve the transition config for a specific `from -> to` state pair. */
44
+ getTransition(from: StateUnion<B>, to: StateUnion<B>): TransitionConfig;
45
+ }
46
+ /**
47
+ * Build a Transition resolver for a given quantizer and transition map.
48
+ *
49
+ * Resolution order:
50
+ * 1. Exact match: `"stateA->stateB"`
51
+ * 2. Wildcard: `"*"`
52
+ * 3. Fallback: instant transition (duration: 0)
53
+ */
54
+ declare function createTransition<B extends Boundary.Shape>(_quantizer: Quantizer<B>, transitionConfig: TransitionMap<StateUnion<B> & string>): Transition<B>;
55
+ /**
56
+ * Transition resolver namespace.
57
+ *
58
+ * `Transition.for(quantizer, map)` produces a {@link Transition} that looks
59
+ * up animation parameters by `from->to` state pairs. Consumed by
60
+ * {@link AnimatedQuantizer} for interpolation setup.
61
+ */
62
+ export declare const Transition: {
63
+ /** Build a {@link Transition} resolver for the given quantizer and transition map. */
64
+ readonly for: typeof createTransition;
65
+ };
66
+ export {};
67
+ //# sourceMappingURL=transition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transition.d.ts","sourceRoot":"","sources":["../src/transition.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGlF;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa,CAAC,EAAE,SAAS,MAAM,GAAG,MAAM;IACvD,sEAAsE;IACtE,QAAQ,CAAC,GAAG,CAAC,EAAE,gBAAgB,CAAC;IAChC,yCAAyC;IACzC,QAAQ,EAAE,GAAG,EAAE,GAAG,MAAM,KAAK,MAAM,EAAE,GAAG,gBAAgB,CAAC;CAC1D;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC,SAAS,QAAQ,CAAC,KAAK;IAClD,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC;IACvD,4EAA4E;IAC5E,aAAa,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,gBAAgB,CAAC;CACzE;AAMD;;;;;;;GAOG;AACH,iBAAS,gBAAgB,CAAC,CAAC,SAAS,QAAQ,CAAC,KAAK,EAChD,UAAU,EAAE,SAAS,CAAC,CAAC,CAAC,EACxB,gBAAgB,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,GACtD,UAAU,CAAC,CAAC,CAAC,CAkBf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,UAAU;IACrB,sFAAsF;;CAE9E,CAAC"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Transition configuration for state crossings.
3
+ * Maps `from->to` state pairs to duration/easing/delay configs.
4
+ *
5
+ * @module
6
+ */
7
+ import { Millis as mkMillis } from '@czap/core';
8
+ const DEFAULT_TRANSITION = {
9
+ duration: mkMillis(0),
10
+ };
11
+ /**
12
+ * Build a Transition resolver for a given quantizer and transition map.
13
+ *
14
+ * Resolution order:
15
+ * 1. Exact match: `"stateA->stateB"`
16
+ * 2. Wildcard: `"*"`
17
+ * 3. Fallback: instant transition (duration: 0)
18
+ */
19
+ function createTransition(_quantizer, transitionConfig) {
20
+ return {
21
+ config: transitionConfig,
22
+ getTransition(from, to) {
23
+ // Exact match first. The key is typed as the template-literal pattern
24
+ // declared on TransitionMap, so we can index directly.
25
+ const exactKey = `${from}->${to}`;
26
+ const exact = transitionConfig[exactKey];
27
+ if (exact !== undefined)
28
+ return exact;
29
+ // Wildcard fallback
30
+ const wildcard = transitionConfig['*'];
31
+ if (wildcard !== undefined)
32
+ return wildcard;
33
+ // No transition configured -- instant
34
+ return DEFAULT_TRANSITION;
35
+ },
36
+ };
37
+ }
38
+ /**
39
+ * Transition resolver namespace.
40
+ *
41
+ * `Transition.for(quantizer, map)` produces a {@link Transition} that looks
42
+ * up animation parameters by `from->to` state pairs. Consumed by
43
+ * {@link AnimatedQuantizer} for interpolation setup.
44
+ */
45
+ export const Transition = {
46
+ /** Build a {@link Transition} resolver for the given quantizer and transition map. */
47
+ for: createTransition,
48
+ };
49
+ //# sourceMappingURL=transition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transition.js","sourceRoot":"","sources":["../src/transition.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,MAAM,IAAI,QAAQ,EAAE,MAAM,YAAY,CAAC;AA2ChD,MAAM,kBAAkB,GAAqB;IAC3C,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC;CACtB,CAAC;AAEF;;;;;;;GAOG;AACH,SAAS,gBAAgB,CACvB,UAAwB,EACxB,gBAAuD;IAEvD,OAAO;QACL,MAAM,EAAE,gBAAgB;QACxB,aAAa,CAAC,IAAmB,EAAE,EAAiB;YAClD,sEAAsE;YACtE,uDAAuD;YACvD,MAAM,QAAQ,GAAG,GAAG,IAAc,KAAK,EAAY,EAAW,CAAC;YAC/D,MAAM,KAAK,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAC;YACzC,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,KAAK,CAAC;YAEtC,oBAAoB;YACpB,MAAM,QAAQ,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC;YACvC,IAAI,QAAQ,KAAK,SAAS;gBAAE,OAAO,QAAQ,CAAC;YAE5C,sCAAsC;YACtC,OAAO,kBAAkB,CAAC;QAC5B,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,sFAAsF;IACtF,GAAG,EAAE,gBAAgB;CACb,CAAC"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@czap/quantizer",
3
+ "version": "0.1.0",
4
+ "description": "Boundary quantizer with animated transitions",
5
+ "license": "MIT",
6
+ "author": "Eassa Ayoub <eassa@heyoub.dev>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/heyoub/LiteShip",
10
+ "directory": "packages/quantizer"
11
+ },
12
+ "bugs": "https://github.com/heyoub/LiteShip/issues",
13
+ "homepage": "https://github.com/heyoub/LiteShip#readme",
14
+ "keywords": [
15
+ "czap",
16
+ "quantizer",
17
+ "boundary",
18
+ "state-machine",
19
+ "adaptive-rendering",
20
+ "typescript"
21
+ ],
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js",
30
+ "development": "./src/index.ts"
31
+ },
32
+ "./testing": {
33
+ "types": "./dist/testing.d.ts",
34
+ "import": "./dist/testing.js",
35
+ "development": "./src/testing.ts"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "LICENSE"
42
+ ],
43
+ "dependencies": {
44
+ "@czap/core": "0.1.0"
45
+ },
46
+ "peerDependencies": {
47
+ "effect": ">=4.0.0-beta.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=22.0.0"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc"
57
+ }
58
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * AnimatedQuantizer -- wraps a Quantizer with Transitions.
3
+ * On boundary crossing, interpolates between old and new output values
4
+ * over the configured transition duration/easing.
5
+ */
6
+
7
+ import type { Scope } from 'effect';
8
+ import { Effect, Stream, SubscriptionRef, Queue, Fiber, Ref, Duration } from 'effect';
9
+ import type { Boundary, StateUnion, BoundaryCrossing, Quantizer, Easing } from '@czap/core';
10
+ import type { Transition, TransitionMap } from './transition.js';
11
+ import { Transition as TransitionFactory } from './transition.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Animated quantizer interface
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Quantizer augmented with transition-aware output interpolation.
19
+ *
20
+ * The `interpolated` stream emits a frame on each animation tick containing
21
+ * the target state, normalized progress (0-1), and the current lerped
22
+ * output record. Non-numeric values snap at the 50% mark.
23
+ */
24
+ export interface AnimatedQuantizerShape<B extends Boundary.Shape> extends Quantizer<B> {
25
+ /** Resolver that maps `from -> to` crossings to {@link TransitionConfig}. */
26
+ readonly transition: Transition<B>;
27
+ /** Stream of interpolated animation frames during crossings. */
28
+ readonly interpolated: Stream.Stream<{
29
+ /** Target state of the in-flight transition. */
30
+ readonly state: StateUnion<B>;
31
+ /** Progress in `[0, 1]`, where `1` means the animation has landed. */
32
+ readonly progress: number;
33
+ /** Interpolated output record for the current frame. */
34
+ readonly outputs: Record<string, number | string>;
35
+ }>;
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Linear easing fallback
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const linearEasing: Easing.Fn = (t: number) => t;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Interpolate numeric values between two output records
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function lerpOutputs(
49
+ from: Record<string, number | string>,
50
+ to: Record<string, number | string>,
51
+ t: number,
52
+ ): Record<string, number | string> {
53
+ const result: Record<string, number | string> = {};
54
+ const allKeys = new Set([...Object.keys(from), ...Object.keys(to)]);
55
+ for (const key of allKeys) {
56
+ const a = from[key];
57
+ const b = to[key];
58
+ if (typeof a === 'number' && typeof b === 'number') {
59
+ result[key] = a + (b - a) * t;
60
+ } else {
61
+ // Non-numeric values snap to target at progress >= 0.5
62
+ result[key] = (t < 0.5 ? (a ?? b) : (b ?? a)) as number | string;
63
+ }
64
+ }
65
+ return result;
66
+ }
67
+
68
+ function nowMs(): number {
69
+ // performance.now() is standard in browsers and Node ≥ 16.
70
+ // Optional chaining guards against stripped worker/SSR environments.
71
+ if (typeof globalThis.performance?.now === 'function') {
72
+ return globalThis.performance.now();
73
+ }
74
+ return Date.now();
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Factory (internal impl)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Create an animated quantizer that interpolates outputs during transitions.
83
+ *
84
+ * Wraps an existing {@link Quantizer} and applies easing/duration-based
85
+ * interpolation between old and new output values when a boundary crossing
86
+ * occurs. Produces an `interpolated` stream of frames with progress and
87
+ * lerped numeric outputs at ~60fps.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { Boundary } from '@czap/core';
92
+ * import { Q, AnimatedQuantizer } from '@czap/quantizer';
93
+ * import { Effect, Stream } from 'effect';
94
+ *
95
+ * const boundary = Boundary.make({
96
+ * input: 'scroll', states: ['top', 'bottom'] as const,
97
+ * thresholds: [0, 500],
98
+ * });
99
+ * const config = Q.from(boundary).outputs({
100
+ * css: { top: { opacity: '1' }, bottom: { opacity: '0.5' } },
101
+ * });
102
+ * const program = Effect.scoped(Effect.gen(function* () {
103
+ * const live = yield* config.create();
104
+ * const animated = yield* AnimatedQuantizer.make(
105
+ * live,
106
+ * { '*->*': { duration: 300 } },
107
+ * { top: { opacity: 1 }, bottom: { opacity: 0.5 } },
108
+ * );
109
+ * live.evaluate(600); // triggers interpolation
110
+ * return animated;
111
+ * }));
112
+ * ```
113
+ *
114
+ * @param quantizer - The base quantizer to wrap
115
+ * @param transitions - Map of state transition configs keyed by `from->to` pattern
116
+ * @param outputs - Per-state numeric output maps for interpolation
117
+ * @returns An Effect yielding an {@link AnimatedQuantizerShape} (scoped)
118
+ */
119
+ function makeAnimatedQuantizer<B extends Boundary.Shape>(
120
+ quantizer: Quantizer<B>,
121
+ transitions: TransitionMap<StateUnion<B> & string>,
122
+ outputs?: Record<string, Record<string, number | string>>,
123
+ ): Effect.Effect<AnimatedQuantizerShape<B>, never, Scope.Scope> {
124
+ return Effect.gen(function* () {
125
+ const boundary = quantizer.boundary;
126
+ const transitionResolver = TransitionFactory.for(quantizer, transitions);
127
+
128
+ const initialState: StateUnion<B> = yield* quantizer.state;
129
+ const stateRef = yield* SubscriptionRef.make<StateUnion<B>>(initialState);
130
+
131
+ type InterpolatedFrame = {
132
+ readonly state: StateUnion<B>;
133
+ readonly progress: number;
134
+ readonly outputs: Record<string, number | string>;
135
+ };
136
+
137
+ const currentOutputsRef = yield* Ref.make<Record<string, number | string>>(outputs?.[initialState as string] ?? {});
138
+ const currentFiberRef = yield* Ref.make<Fiber.Fiber<void> | null>(null);
139
+
140
+ const interpolatedStream: Stream.Stream<InterpolatedFrame> = Stream.callback<InterpolatedFrame>((queue) =>
141
+ Effect.gen(function* () {
142
+ yield* Effect.addFinalizer(() =>
143
+ Effect.gen(function* () {
144
+ const currentFiber = yield* Ref.get(currentFiberRef);
145
+ if (currentFiber !== null) {
146
+ yield* Fiber.interrupt(currentFiber);
147
+ }
148
+ }),
149
+ );
150
+
151
+ yield* Stream.runForEach(quantizer.changes, (crossing: BoundaryCrossing<StateUnion<B> & string>) =>
152
+ Effect.gen(function* () {
153
+ const existingFiber = yield* Ref.get(currentFiberRef);
154
+ if (existingFiber !== null) {
155
+ yield* Fiber.interrupt(existingFiber);
156
+ }
157
+
158
+ // crossing.from/to are StateName<StateUnion<B> & string>, which is a branded
159
+ // subtype of StateUnion<B>; assignable directly without a cast.
160
+ const { from, to } = crossing;
161
+ const config = transitionResolver.getTransition(from, to);
162
+ const duration = config.duration;
163
+ const easing = config.easing ?? linearEasing;
164
+ const delay = config.delay ?? 0;
165
+
166
+ const fromOutputs = { ...(yield* Ref.get(currentOutputsRef)) };
167
+ const toOutputs: Record<string, number | string> = outputs?.[crossing.to as string] ?? {};
168
+
169
+ const animationLoop = Effect.gen(function* () {
170
+ if (delay > 0) {
171
+ yield* Effect.sleep(Duration.millis(delay));
172
+ }
173
+
174
+ if (duration <= 0) {
175
+ Queue.offerUnsafe(queue, { state: to, progress: 1, outputs: toOutputs });
176
+ yield* Ref.set(currentOutputsRef, toOutputs);
177
+ yield* SubscriptionRef.set(stateRef, to);
178
+ return;
179
+ }
180
+
181
+ // Time-sliced animation loop (~60fps via 16ms sleep)
182
+ const startTime = nowMs();
183
+ let progress = 0;
184
+ while (progress < 1) {
185
+ const elapsed = nowMs() - startTime;
186
+ progress = Math.min(elapsed / duration, 1);
187
+ const eased = easing(progress);
188
+ const interpolated = lerpOutputs(fromOutputs, toOutputs, eased);
189
+ yield* Ref.set(currentOutputsRef, interpolated);
190
+ Queue.offerUnsafe(queue, { state: to, progress, outputs: interpolated });
191
+
192
+ if (progress < 1) {
193
+ yield* Effect.sleep(Duration.millis(16));
194
+ }
195
+ }
196
+
197
+ yield* Ref.set(currentOutputsRef, toOutputs);
198
+ yield* SubscriptionRef.set(stateRef, to);
199
+ });
200
+
201
+ const fiber = yield* Effect.forkChild(animationLoop);
202
+ yield* Ref.set(currentFiberRef, fiber);
203
+ }),
204
+ );
205
+
206
+ const finalFiber = yield* Ref.get(currentFiberRef);
207
+ const fibers = [finalFiber].filter((fiber): fiber is Fiber.Fiber<void> => fiber !== null);
208
+ yield* Effect.forEach(fibers, Fiber.join, { discard: true });
209
+ yield* Ref.set(currentFiberRef, null);
210
+ }),
211
+ );
212
+
213
+ const animatedQuantizer: AnimatedQuantizerShape<B> = {
214
+ _tag: 'Quantizer',
215
+ boundary,
216
+ transition: transitionResolver,
217
+ state: SubscriptionRef.get(stateRef),
218
+ stateSync: quantizer.stateSync ? () => quantizer.stateSync!() : undefined,
219
+ changes: quantizer.changes,
220
+ evaluate(value: number): StateUnion<B> {
221
+ return quantizer.evaluate(value);
222
+ },
223
+ interpolated: interpolatedStream,
224
+ };
225
+
226
+ return animatedQuantizer;
227
+ });
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // AnimatedQuantizer module object
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Animated quantizer namespace.
236
+ *
237
+ * Wraps a base quantizer with transition-aware interpolation. When a boundary
238
+ * crossing occurs, numeric output values are lerped over a configurable
239
+ * duration and easing curve. Non-numeric values snap at the 50% mark.
240
+ * The `interpolated` stream emits frames containing progress (0-1) and
241
+ * the current interpolated output record.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * import { Boundary } from '@czap/core';
246
+ * import { Q, AnimatedQuantizer } from '@czap/quantizer';
247
+ * import { Effect } from 'effect';
248
+ *
249
+ * const boundary = Boundary.make({
250
+ * input: 'scroll', states: ['top', 'bottom'] as const,
251
+ * thresholds: [0, 500],
252
+ * });
253
+ * const config = Q.from(boundary).outputs({});
254
+ * const program = Effect.scoped(Effect.gen(function* () {
255
+ * const live = yield* config.create();
256
+ * const animated = yield* AnimatedQuantizer.make(
257
+ * live,
258
+ * { '*->*': { duration: 200 } },
259
+ * );
260
+ * return animated.transition; // TransitionResolver
261
+ * }));
262
+ * ```
263
+ */
264
+ export const AnimatedQuantizer = {
265
+ /** Wrap a quantizer with transition-aware output interpolation. */
266
+ make: makeAnimatedQuantizer,
267
+ } as const;
268
+
269
+ export declare namespace AnimatedQuantizer {
270
+ /** Shape of an animated quantizer parameterized by boundary `B`. */
271
+ export type Shape<B extends Boundary.Shape> = AnimatedQuantizerShape<B>;
272
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Binary search evaluation of a value against boundary thresholds.
3
+ * Supports hysteresis to prevent state jitter at threshold edges.
4
+ */
5
+
6
+ import type { Boundary, StateUnion } from '@czap/core';
7
+
8
+ /**
9
+ * Result of quantizing a single numeric value against a boundary.
10
+ *
11
+ * `crossed` is true only when `previousState` was supplied and differs
12
+ * from the resolved state; it is the signal consumers use to emit
13
+ * transition events and route side effects.
14
+ */
15
+ export interface EvaluateResult<S extends string = string> {
16
+ /** The resolved state literal. */
17
+ readonly state: S;
18
+ /** Index of `state` within the boundary's states tuple. */
19
+ readonly index: number;
20
+ /** The input value that was evaluated. */
21
+ readonly value: number;
22
+ /** Whether evaluation produced a change from `previousState`. */
23
+ readonly crossed: boolean;
24
+ }
25
+
26
+ /**
27
+ * Find which state a value maps to via binary search over sorted thresholds.
28
+ * With hysteresis: if previousState is provided and the value is within the
29
+ * hysteresis dead zone of a threshold, transition is suppressed.
30
+ *
31
+ * BoundaryDef contract: `thresholds[i]` = lower bound of `states[i]`.
32
+ * Binary search finds the largest index `i` where `thresholds[i] <= value`.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { Boundary } from '@czap/core';
37
+ * import { evaluate } from '@czap/quantizer';
38
+ *
39
+ * const boundary = Boundary.make({
40
+ * input: 'width', states: ['sm', 'md', 'lg'] as const,
41
+ * thresholds: [0, 640, 1024], hysteresis: 20,
42
+ * });
43
+ * const result = evaluate(boundary, 800);
44
+ * // result => { state: 'md', index: 1, value: 800, crossed: false }
45
+ *
46
+ * const cross = evaluate(boundary, 1100, 'md');
47
+ * // cross => { state: 'lg', index: 2, value: 1100, crossed: true }
48
+ * ```
49
+ *
50
+ * @param boundary - The boundary definition with states and thresholds
51
+ * @param value - The numeric value to evaluate
52
+ * @param previousState - Optional previous state for hysteresis and crossing detection
53
+ * @returns An {@link EvaluateResult} with the resolved state, index, and crossing flag
54
+ */
55
+ export function evaluate<B extends Boundary.Shape>(
56
+ boundary: B,
57
+ value: number,
58
+ previousState?: StateUnion<B>,
59
+ ): EvaluateResult<StateUnion<B> & string> {
60
+ const { thresholds, states, hysteresis } = boundary;
61
+ // Boundary.make guarantees states is non-empty; index access below yields StateUnion<B>.
62
+ // `& string` is structurally satisfied because every state literal is a string.
63
+ const stateAt = (index: number): StateUnion<B> & string => states[index] as StateUnion<B> & string;
64
+
65
+ if (thresholds.length === 0) {
66
+ return {
67
+ state: stateAt(0),
68
+ index: 0,
69
+ value,
70
+ crossed: false,
71
+ };
72
+ }
73
+
74
+ // Binary search: find largest index i where thresholds[i] <= value
75
+ // This gives the state whose lower bound is satisfied.
76
+ let lo = 0;
77
+ let hi = thresholds.length - 1;
78
+ let rawIndex = 0; // default: first state (value below all thresholds)
79
+ while (lo <= hi) {
80
+ const mid = (lo + hi) >>> 1;
81
+ if ((thresholds[mid] as number) <= value) {
82
+ rawIndex = mid;
83
+ lo = mid + 1;
84
+ } else {
85
+ hi = mid - 1;
86
+ }
87
+ }
88
+
89
+ const state = stateAt(rawIndex);
90
+
91
+ // Without hysteresis or no previous state, return raw result
92
+ if (!hysteresis || hysteresis <= 0 || previousState === undefined) {
93
+ const crossed = previousState !== undefined && previousState !== state;
94
+ return { state, index: rawIndex, value, crossed };
95
+ }
96
+
97
+ // Find previous state index
98
+ const prevIndex = (states as readonly string[]).indexOf(previousState as string);
99
+ if (prevIndex === -1) {
100
+ return { state, index: rawIndex, value, crossed: true };
101
+ }
102
+
103
+ // No crossing needed
104
+ if (rawIndex === prevIndex) {
105
+ return { state, index: rawIndex, value, crossed: false };
106
+ }
107
+
108
+ // Half-width hysteresis: dead zone of h/2 each side of threshold
109
+ const half = hysteresis / 2;
110
+
111
+ // Check ALL intermediate thresholds for dead zone suppression
112
+ if (rawIndex > prevIndex) {
113
+ // Crossing upward -- check thresholds from prevIndex+1 to rawIndex
114
+ for (let i = prevIndex + 1; i <= rawIndex; i++) {
115
+ const threshold = thresholds[i] as number | undefined;
116
+ if (threshold !== undefined && value < threshold + half) {
117
+ // In dead zone -- settle at state just below this threshold
118
+ const settleIndex = i - 1;
119
+ return { state: stateAt(settleIndex), index: settleIndex, value, crossed: settleIndex !== prevIndex };
120
+ }
121
+ }
122
+ } else {
123
+ // Crossing downward -- check thresholds from prevIndex down to rawIndex+1
124
+ for (let i = prevIndex; i > rawIndex; i--) {
125
+ const threshold = thresholds[i] as number | undefined;
126
+ if (threshold !== undefined && value > threshold - half) {
127
+ // In dead zone -- settle at this state
128
+ return { state: stateAt(i), index: i, value, crossed: i !== prevIndex };
129
+ }
130
+ }
131
+ }
132
+
133
+ // Cleared all dead zones -- full transition
134
+ return { state, index: rawIndex, value, crossed: true };
135
+ }
136
+
137
+ /**
138
+ * Boundary evaluation namespace.
139
+ *
140
+ * Provides `evaluate()` for mapping a numeric value to a discrete state
141
+ * via binary search over boundary thresholds with optional hysteresis
142
+ * to prevent jitter at threshold edges.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * import { Boundary } from '@czap/core';
147
+ * import { Evaluate } from '@czap/quantizer';
148
+ *
149
+ * const boundary = Boundary.make({
150
+ * input: 'width', states: ['sm', 'lg'] as const,
151
+ * thresholds: [0, 768], hysteresis: 10,
152
+ * });
153
+ * const r1 = Evaluate.evaluate(boundary, 500);
154
+ * // r1.state => 'sm', r1.crossed => false
155
+ *
156
+ * const r2 = Evaluate.evaluate(boundary, 900, 'sm');
157
+ * // r2.state => 'lg', r2.crossed => true
158
+ * ```
159
+ */
160
+ export const Evaluate = { evaluate } as const;
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@czap/quantizer` — **LiteShip** quantizer: **rigged** boundary evaluation,
3
+ * live state, animated transitions between bearings, and motion-tier gating on
4
+ * the working line.
5
+ *
6
+ * @module
7
+ */
8
+
9
+ export { evaluate, Evaluate } from './evaluate.js';
10
+ export type { EvaluateResult } from './evaluate.js';
11
+
12
+ export { Q } from './quantizer.js';
13
+ export type { OutputTarget, QuantizerOutputs, QuantizerConfig, LiveQuantizer, QuantizerBuilder } from './quantizer.js';
14
+
15
+ export { Transition } from './transition.js';
16
+ export type { TransitionConfig, TransitionMap, Transition as TransitionType } from './transition.js';
17
+
18
+ export { AnimatedQuantizer } from './animated-quantizer.js';
19
+ export type { AnimatedQuantizerShape } from './animated-quantizer.js';
20
+
21
+ export { TransitionConfigSchema, TransitionMapSchema, OutputTargetSchema, QuantizerOutputsSchema } from './schemas.js';
22
+
23
+ export type { MotionTier, SpringConfig, QuantizerFromOptions } from './quantizer.js';
24
+ // `MemoCache` and `TIER_TARGETS` ship via `@czap/quantizer/testing` —
25
+ // implementation primitives that power the public `Q.from()` builder
26
+ // internally but are not consumer-facing API.