@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,58 @@
1
+ /**
2
+ * MemoCache -- content-address memoization layer.
3
+ *
4
+ * Boundaries and quantizer configs are already content-addressed via FNV-1a.
5
+ * This cache ensures identical configs never recompute. Content-addressed keys
6
+ * mean the cache is auto-invalidating: change definition → hash changes → miss.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { ContentAddress } from '@czap/core';
12
+
13
+ interface MemoCacheShape<V> {
14
+ get(key: ContentAddress): V | undefined;
15
+ set(key: ContentAddress, value: V): void;
16
+ has(key: ContentAddress): boolean;
17
+ readonly size: number;
18
+ }
19
+
20
+ function _make<V>(): MemoCacheShape<V> {
21
+ const store = new Map<ContentAddress, V>();
22
+
23
+ return {
24
+ get(key: ContentAddress): V | undefined {
25
+ return store.get(key);
26
+ },
27
+
28
+ set(key: ContentAddress, value: V): void {
29
+ store.set(key, value);
30
+ },
31
+
32
+ has(key: ContentAddress): boolean {
33
+ return store.has(key);
34
+ },
35
+
36
+ get size(): number {
37
+ return store.size;
38
+ },
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Content-address memoization cache.
44
+ *
45
+ * Keys are {@link ContentAddress} values, so the cache is auto-invalidating:
46
+ * any change to an upstream definition produces a new hash and a guaranteed
47
+ * miss. Backed by an unbounded {@link Map}; callers are responsible for
48
+ * lifetime and eviction if needed.
49
+ */
50
+ export const MemoCache = {
51
+ /** Construct a fresh cache with value type `V`. */
52
+ make: _make,
53
+ };
54
+
55
+ export declare namespace MemoCache {
56
+ /** Structural shape of a {@link MemoCache} with value type `V`. */
57
+ export type Shape<V> = MemoCacheShape<V>;
58
+ }
@@ -0,0 +1,503 @@
1
+ /**
2
+ * `Q.from(boundary).outputs({ ... })` builder API.
3
+ * Creates {@link QuantizerConfig} with content-addressed identity, and
4
+ * {@link LiveQuantizer} with reactive output streams.
5
+ *
6
+ * Wired: MotionTier-gated output routing, springToLinearCSS auto-generation,
7
+ * content-address memoization via {@link MemoCache}.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { Scope } from 'effect';
13
+ import { Effect, Stream, SubscriptionRef, Queue } from 'effect';
14
+ import type {
15
+ Boundary,
16
+ StateUnion,
17
+ BoundaryCrossing,
18
+ ContentAddress,
19
+ Quantizer,
20
+ OutputsFor,
21
+ HLCBrand,
22
+ } from '@czap/core';
23
+ import type { MotionTier } from '@czap/core';
24
+ import {
25
+ ContentAddress as mkContentAddress,
26
+ StateName as mkStateName,
27
+ TypedRef,
28
+ Easing,
29
+ fnv1aBytes,
30
+ } from '@czap/core';
31
+ import { evaluate } from './evaluate.js';
32
+ import type { EvaluateResult } from './evaluate.js';
33
+ import { MemoCache } from './memo-cache.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Internal helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Typed accessor for the initial state of a boundary. Boundary.make guarantees
41
+ * the states tuple is non-empty, so `states[0]` is always defined; this contains
42
+ * the one unavoidable cast where a generic index access meets noUncheckedIndexedAccess.
43
+ */
44
+ function firstState<B extends Boundary.Shape>(boundary: B): StateUnion<B> {
45
+ return boundary.states[0] as StateUnion<B>;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Output target literal type
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Compilation target for quantizer per-state outputs.
54
+ *
55
+ * `css` emits style declarations, `glsl`/`wgsl` emit shader uniforms,
56
+ * `aria` emits accessibility attributes, `ai` emits model-facing signals.
57
+ * MotionTier gates which targets a device is permitted to receive; see
58
+ * `TIER_TARGETS` (in `@czap/quantizer/testing`).
59
+ */
60
+ export type OutputTarget = 'css' | 'glsl' | 'wgsl' | 'aria' | 'ai';
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // MotionTier gating (canonical type from @czap/core)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export type { MotionTier } from '@czap/core';
67
+
68
+ /**
69
+ * MotionTier → allowed {@link OutputTarget} set.
70
+ *
71
+ * Higher tiers include lower-tier targets. `none` only allows ARIA; `compute`
72
+ * unlocks every target including WGSL and AI signal routing. `force()` can
73
+ * override this gating per-target for prototype and test scenarios.
74
+ */
75
+ export const TIER_TARGETS: Record<MotionTier, ReadonlySet<OutputTarget>> = {
76
+ none: new Set(['aria']),
77
+ transitions: new Set(['css', 'aria']),
78
+ animations: new Set(['css', 'aria']),
79
+ physics: new Set(['css', 'glsl', 'aria']),
80
+ compute: new Set(['css', 'glsl', 'wgsl', 'aria', 'ai']),
81
+ };
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Quantizer outputs shape
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Per-target output tables keyed by boundary state.
89
+ *
90
+ * Each optional field is a record mapping every state in `B` to a target-
91
+ * specific value shape: CSS allows `string | number`, GLSL/WGSL are numeric
92
+ * only, ARIA is string only, AI is unconstrained. Missing fields simply
93
+ * skip that target during dispatch.
94
+ */
95
+ export interface QuantizerOutputs<B extends Boundary.Shape> {
96
+ /** CSS property map per state (values are raw CSS, e.g. `'16px'` or `1`). */
97
+ readonly css?: OutputsFor<B, Record<string, string | number>>;
98
+ /** GLSL uniform values per state (numeric only). */
99
+ readonly glsl?: OutputsFor<B, Record<string, number>>;
100
+ /** WGSL uniform values per state (numeric only). */
101
+ readonly wgsl?: OutputsFor<B, Record<string, number>>;
102
+ /** ARIA attribute map per state (string values only). */
103
+ readonly aria?: OutputsFor<B, Record<string, string>>;
104
+ /** AI-facing signals per state (free-form; consumed by LLMAdapter). */
105
+ readonly ai?: OutputsFor<B, Record<string, unknown>>;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Spring config for CSS auto-generation
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Spring physics parameters for CSS easing auto-generation.
114
+ *
115
+ * When a {@link QuantizerConfig} carries a spring, its CSS outputs receive an
116
+ * injected `--czap-easing` custom property derived via `Easing.springToLinearCSS`
117
+ * so native `linear()` timing matches the physical spring response.
118
+ */
119
+ export interface SpringConfig {
120
+ /** Spring constant (force per unit displacement); higher = snappier. */
121
+ readonly stiffness: number;
122
+ /** Damping coefficient; higher = less oscillation. */
123
+ readonly damping: number;
124
+ /** Mass of the animated body; defaults to `1`. */
125
+ readonly mass?: number;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Builder options
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Options accepted by {@link Q.from}.
134
+ *
135
+ * `tier` gates which output targets get produced (see `TIER_TARGETS` (in `@czap/quantizer/testing`)).
136
+ * `spring` enables automatic CSS `--czap-easing` injection on CSS outputs.
137
+ */
138
+ export interface QuantizerFromOptions {
139
+ /** MotionTier for output gating; omit to allow all targets. */
140
+ readonly tier?: MotionTier;
141
+ /** Spring config that drives CSS easing generation for CSS outputs. */
142
+ readonly spring?: SpringConfig;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Quantizer config (immutable, content-addressed)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Immutable, content-addressed quantizer definition.
151
+ *
152
+ * The `id` is an FNV-1a hash over the boundary id and outputs, so two
153
+ * configs with identical definitions share the same address and are
154
+ * deduplicated by the internal memo cache. `create()` materializes a
155
+ * fresh {@link LiveQuantizer} within an Effect scope.
156
+ */
157
+ export interface QuantizerConfig<B extends Boundary.Shape, O extends QuantizerOutputs<B> = QuantizerOutputs<B>> {
158
+ /** Boundary this config quantizes against. */
159
+ readonly boundary: B;
160
+ /** Per-target output tables keyed by state. */
161
+ readonly outputs: O;
162
+ /** Content-addressed identity (FNV-1a of boundary id + outputs). */
163
+ readonly id: ContentAddress;
164
+ /** Motion tier gating active targets; see `TIER_TARGETS` (in `@czap/quantizer/testing`). */
165
+ readonly tier?: MotionTier;
166
+ /** Spring config driving CSS easing injection. */
167
+ readonly spring?: SpringConfig;
168
+ /** Instantiate a reactive {@link LiveQuantizer} scoped to an Effect fiber. */
169
+ create(): Effect.Effect<LiveQuantizer<B, O>, never, Scope.Scope>;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Live quantizer (extends base Quantizer with output dispatch)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Runtime-instantiated quantizer with reactive output dispatch.
178
+ *
179
+ * Extends the core {@link Quantizer} with a reactive outputs table: as
180
+ * boundary crossings are detected, `currentOutputs` updates and
181
+ * `outputChanges` streams the new per-target record. Consumers typically
182
+ * subscribe via `Stream.runForEach(liveQuantizer.outputChanges, …)`.
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * import { Boundary } from '@czap/core';
187
+ * import { Q } from '@czap/quantizer';
188
+ * import { Effect, Stream } from 'effect';
189
+ *
190
+ * const b = Boundary.make({
191
+ * input: 'w', states: ['sm', 'lg'] as const, thresholds: [0, 768],
192
+ * });
193
+ * const config = Q.from(b).outputs({
194
+ * css: { sm: { fontSize: '14px' }, lg: { fontSize: '18px' } },
195
+ * });
196
+ * Effect.runSync(Effect.scoped(Effect.gen(function* () {
197
+ * const live = yield* config.create();
198
+ * live.evaluate(900); // triggers crossing; outputs stream emits CSS
199
+ * })));
200
+ * ```
201
+ */
202
+ export interface LiveQuantizer<
203
+ B extends Boundary.Shape,
204
+ O extends QuantizerOutputs<B> = QuantizerOutputs<B>,
205
+ > extends Quantizer<B> {
206
+ /** The config this quantizer was created from. */
207
+ readonly config: QuantizerConfig<B, O>;
208
+ /** Read the currently-active per-target output record. */
209
+ readonly currentOutputs: Effect.Effect<Partial<{ [K in OutputTarget]: Record<string, unknown> }>>;
210
+ /** Stream of per-target output records emitted on each boundary crossing. */
211
+ readonly outputChanges: Stream.Stream<Partial<{ [K in OutputTarget]: Record<string, unknown> }>>;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Builder
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Fluent builder returned by {@link Q.from}.
220
+ *
221
+ * Call `.outputs({ ... })` to produce a content-addressed
222
+ * {@link QuantizerConfig}, optionally preceded by `.force(targets)` to
223
+ * override MotionTier gating for specific targets (e.g., enabling AI
224
+ * signals at the `none` tier for testing).
225
+ */
226
+ export interface QuantizerBuilder<B extends Boundary.Shape> {
227
+ /** Attach per-target output tables and produce a {@link QuantizerConfig}. */
228
+ outputs<O extends QuantizerOutputs<B>>(outputs: O): QuantizerConfig<B, O>;
229
+ /** Force-enable specific targets regardless of the current tier's gating set. */
230
+ force(...targets: OutputTarget[]): QuantizerBuilder<B>;
231
+ }
232
+
233
+ type CachedQuantizerConfig = QuantizerConfig<Boundary.Shape, QuantizerOutputs<Boundary.Shape>>;
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Content-address via CBOR canonical encoding + FNV-1a hash (matches @czap/core)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function contentAddress<B extends Boundary.Shape, O extends QuantizerOutputs<B>>(
240
+ boundary: B,
241
+ outputs: O,
242
+ ): ContentAddress {
243
+ const payload = { boundaryId: boundary.id, outputs };
244
+ return fnv1aBytes(TypedRef.canonicalize(payload));
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Memoization caches
249
+ // ---------------------------------------------------------------------------
250
+
251
+ const configCache = MemoCache.make<CachedQuantizerConfig>();
252
+ const outputCache = MemoCache.make<Partial<{ [K in OutputTarget]: Record<string, unknown> }>>();
253
+ const springCSSCache = new Map<string, string>();
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Resolve outputs for the current state, gated by tier
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Read `outputs[target][state]` through the target-agnostic shape
261
+ * `Record<string, Record<string, unknown>>`. Each QuantizerOutputs target
262
+ * has a different value type (CSS allows `string | number`, GLSL is
263
+ * number-only, etc.), so indexing at the `OutputTarget` union level
264
+ * produces a wide union that TS cannot collapse. This helper performs
265
+ * the one bridging cast so callers stay type-clean.
266
+ */
267
+ function readTargetState<B extends Boundary.Shape, O extends QuantizerOutputs<B>>(
268
+ outputs: O,
269
+ target: OutputTarget,
270
+ state: StateUnion<B>,
271
+ ): Record<string, unknown> | undefined {
272
+ const table = outputs[target] as Record<string, Record<string, unknown>> | undefined;
273
+ return table?.[state as string];
274
+ }
275
+
276
+ function resolveOutputs<B extends Boundary.Shape, O extends QuantizerOutputs<B>>(
277
+ outputs: O,
278
+ state: StateUnion<B>,
279
+ allowedTargets: ReadonlySet<OutputTarget> | null,
280
+ forcedTargets: ReadonlySet<OutputTarget> | null,
281
+ configId: ContentAddress,
282
+ springCSS: string | null,
283
+ ): Partial<{ [K in OutputTarget]: Record<string, unknown> }> {
284
+ // Check output cache
285
+ const cacheKey = mkContentAddress(`${configId}:${state as string}:${springCSS ? '1' : '0'}`);
286
+ const cached = outputCache.get(cacheKey);
287
+ if (cached) return cached;
288
+
289
+ const result: Partial<{ [K in OutputTarget]: Record<string, unknown> }> = {};
290
+ const targets: OutputTarget[] = ['css', 'glsl', 'wgsl', 'aria', 'ai'];
291
+
292
+ for (const target of targets) {
293
+ // Check tier gating
294
+ if (allowedTargets !== null && !allowedTargets.has(target)) {
295
+ // Check force escape hatch
296
+ if (forcedTargets === null || !forcedTargets.has(target)) {
297
+ continue;
298
+ }
299
+ }
300
+
301
+ const stateOutputs = readTargetState(outputs, target, state);
302
+ if (stateOutputs !== undefined) {
303
+ if (target === 'css' && springCSS) {
304
+ // Inject the spring easing CSS custom property alongside CSS outputs
305
+ result[target] = { ...stateOutputs, '--czap-easing': springCSS };
306
+ } else {
307
+ result[target] = stateOutputs;
308
+ }
309
+ }
310
+ }
311
+
312
+ outputCache.set(cacheKey, result);
313
+ return result;
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Monotonic HLC for sync evaluate() -- uses Date.now() + incrementing counter
318
+ // ---------------------------------------------------------------------------
319
+
320
+ let hlcCounter = 0;
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Spring CSS computation with caching
324
+ // ---------------------------------------------------------------------------
325
+
326
+ function getSpringCSS(spring: SpringConfig): string {
327
+ const key = `${spring.stiffness}:${spring.damping}:${spring.mass ?? 1}`;
328
+ let css = springCSSCache.get(key);
329
+ if (!css) {
330
+ css = Easing.springToLinearCSS(spring);
331
+ springCSSCache.set(key, css);
332
+ }
333
+ return css;
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Q.from(boundary) builder factory
338
+ // ---------------------------------------------------------------------------
339
+
340
+ /**
341
+ * Create a quantizer builder from a boundary definition.
342
+ *
343
+ * Starts a fluent chain: `Q.from(boundary).outputs({...})` produces a
344
+ * content-addressed `QuantizerConfig` whose `.create()` method yields a
345
+ * reactive `LiveQuantizer` inside an Effect scope.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * import { Boundary } from '@czap/core';
350
+ * import { Q } from '@czap/quantizer';
351
+ * import { Effect } from 'effect';
352
+ *
353
+ * const boundary = Boundary.make({
354
+ * input: 'width', states: ['sm', 'md', 'lg'] as const,
355
+ * thresholds: [0, 640, 1024],
356
+ * });
357
+ * const config = Q.from(boundary).outputs({
358
+ * css: { sm: { fontSize: '14px' }, md: { fontSize: '16px' }, lg: { fontSize: '18px' } },
359
+ * });
360
+ * const state = Effect.scoped(
361
+ * Effect.gen(function* () {
362
+ * const live = yield* config.create();
363
+ * return live.evaluate(800); // 'md'
364
+ * }),
365
+ * );
366
+ * const result = Effect.runSync(state);
367
+ * ```
368
+ *
369
+ * @param boundary - The boundary definition to quantize against
370
+ * @param options - Optional motion tier and spring configuration
371
+ * @returns A {@link QuantizerBuilder} for chaining `.outputs()` and `.force()`
372
+ */
373
+ function fromBoundary<B extends Boundary.Shape>(boundary: B, options?: QuantizerFromOptions): QuantizerBuilder<B> {
374
+ const tier = options?.tier;
375
+ const spring = options?.spring;
376
+ const allowedTargets = tier ? (TIER_TARGETS[tier] ?? null) : null;
377
+ let forcedTargets: Set<OutputTarget> | null = null;
378
+
379
+ const builder: QuantizerBuilder<B> = {
380
+ outputs<O extends QuantizerOutputs<B>>(outputs: O): QuantizerConfig<B, O> {
381
+ const id = contentAddress(boundary, outputs);
382
+
383
+ // Check config cache
384
+ const cachedConfig = configCache.get(id);
385
+ if (cachedConfig) return cachedConfig as QuantizerConfig<B, O>;
386
+
387
+ // Compute spring CSS if spring config present and CSS outputs exist
388
+ const springCSS = spring && outputs.css ? getSpringCSS(spring) : null;
389
+
390
+ const frozenForced = forcedTargets;
391
+
392
+ const config: QuantizerConfig<B, O> = {
393
+ boundary,
394
+ outputs,
395
+ id,
396
+ tier,
397
+ spring,
398
+ create(): Effect.Effect<LiveQuantizer<B, O>, never, Scope.Scope> {
399
+ return Effect.gen(function* () {
400
+ // Boundary.make guarantees non-empty states; head access widens to StateUnion<B>.
401
+ const initialState: StateUnion<B> = firstState(boundary);
402
+ const initialOutputs = resolveOutputs(outputs, initialState, allowedTargets, frozenForced, id, springCSS);
403
+
404
+ const stateRef = yield* SubscriptionRef.make(initialState);
405
+ const outputRef = yield* SubscriptionRef.make(initialOutputs);
406
+
407
+ const crossingQueue = yield* Queue.unbounded<BoundaryCrossing<StateUnion<B> & string>>();
408
+
409
+ let previousState: StateUnion<B> = initialState;
410
+ const crossingStream: Stream.Stream<BoundaryCrossing<StateUnion<B> & string>> =
411
+ Stream.fromQueue(crossingQueue);
412
+
413
+ const liveQuantizer: LiveQuantizer<B, O> = {
414
+ _tag: 'Quantizer',
415
+ boundary,
416
+ config,
417
+ state: SubscriptionRef.get(stateRef),
418
+ stateSync: () => previousState,
419
+ changes: crossingStream,
420
+
421
+ evaluate(value: number): StateUnion<B> {
422
+ const result: EvaluateResult<StateUnion<B> & string> = evaluate(boundary, value, previousState);
423
+
424
+ if (result.crossed) {
425
+ const crossing: BoundaryCrossing<StateUnion<B> & string> = {
426
+ from: mkStateName<StateUnion<B> & string>(previousState),
427
+ to: mkStateName(result.state),
428
+ timestamp: { wall_ms: Date.now(), counter: hlcCounter++, node_id: 'quantizer' } satisfies HLCBrand,
429
+ value,
430
+ };
431
+ previousState = result.state;
432
+
433
+ const newOutputs = resolveOutputs(outputs, result.state, allowedTargets, frozenForced, id, springCSS);
434
+ Effect.runSync(
435
+ Effect.all([
436
+ SubscriptionRef.set(stateRef, result.state),
437
+ SubscriptionRef.set(outputRef, newOutputs),
438
+ ]),
439
+ );
440
+ Queue.offerUnsafe(crossingQueue, crossing);
441
+ }
442
+
443
+ return result.state;
444
+ },
445
+
446
+ currentOutputs: SubscriptionRef.get(outputRef),
447
+ outputChanges: SubscriptionRef.changes(outputRef),
448
+ };
449
+
450
+ return liveQuantizer;
451
+ });
452
+ },
453
+ };
454
+
455
+ configCache.set(id, config);
456
+ forcedTargets = null;
457
+ return config;
458
+ },
459
+
460
+ force(...targets: OutputTarget[]): QuantizerBuilder<B> {
461
+ forcedTargets = new Set(targets);
462
+ return builder;
463
+ },
464
+ };
465
+
466
+ return builder;
467
+ }
468
+
469
+ /**
470
+ * Quantizer builder namespace.
471
+ *
472
+ * `Q.from(boundary)` starts a fluent builder that produces a content-addressed
473
+ * {@link QuantizerConfig}. Calling `config.create()` within an Effect scope
474
+ * yields a reactive {@link LiveQuantizer} that evaluates numeric input values
475
+ * against boundary thresholds, dispatches state transitions, and routes
476
+ * per-state outputs (CSS, GLSL, WGSL, ARIA, AI) gated by MotionTier.
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * import { Boundary } from '@czap/core';
481
+ * import { Q } from '@czap/quantizer';
482
+ * import { Effect } from 'effect';
483
+ *
484
+ * const boundary = Boundary.make({
485
+ * input: 'width', states: ['sm', 'lg'] as const,
486
+ * thresholds: [0, 768],
487
+ * });
488
+ * const config = Q.from(boundary).outputs({
489
+ * css: { sm: { display: 'block' }, lg: { display: 'grid' } },
490
+ * });
491
+ * const result = Effect.runSync(Effect.scoped(
492
+ * Effect.gen(function* () {
493
+ * const live = yield* config.create();
494
+ * live.evaluate(1024);
495
+ * return yield* live.currentOutputs;
496
+ * }),
497
+ * ));
498
+ * // result.css => { display: 'grid' }
499
+ * ```
500
+ */
501
+ export const Q = {
502
+ from: fromBoundary,
503
+ } as const;
package/src/schemas.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Effect Schema definitions for quantizer configuration types.
3
+ *
4
+ * Note: TransitionConfigSchema validates runtime shape (number fields).
5
+ * The branded Millis type is enforced at the TypeScript level via
6
+ * TransitionConfig interface. Decoded values should be wrapped with
7
+ * Millis() at the consumer site.
8
+ */
9
+
10
+ import { Schema } from 'effect';
11
+
12
+ /**
13
+ * Runtime schema for {@link TransitionConfig}.
14
+ *
15
+ * Validates numeric `duration` and optional `easing`/`delay`. The branded
16
+ * `Millis` type is not enforced here; wrap decoded durations with `Millis()`
17
+ * at the consumer site for type safety.
18
+ */
19
+ export const TransitionConfigSchema = Schema.Struct({
20
+ duration: Schema.Number,
21
+ easing: Schema.optionalKey(Schema.Any),
22
+ delay: Schema.optionalKey(Schema.Number),
23
+ });
24
+
25
+ /** Runtime schema for a {@link TransitionMap} record. */
26
+ export const TransitionMapSchema = Schema.Record(Schema.String, TransitionConfigSchema);
27
+
28
+ /** Runtime schema for the {@link OutputTarget} literal union. */
29
+ export const OutputTargetSchema = Schema.Union([
30
+ Schema.Literal('css'),
31
+ Schema.Literal('glsl'),
32
+ Schema.Literal('wgsl'),
33
+ Schema.Literal('aria'),
34
+ Schema.Literal('ai'),
35
+ ]);
36
+
37
+ /**
38
+ * Runtime schema for {@link QuantizerOutputs}.
39
+ *
40
+ * Each target is an optional record whose values are unchecked at the
41
+ * schema level; target-specific value constraints live in the TypeScript
42
+ * types on {@link QuantizerOutputs}.
43
+ */
44
+ export const QuantizerOutputsSchema = Schema.Struct({
45
+ css: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
46
+ glsl: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
47
+ wgsl: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
48
+ aria: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
49
+ ai: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
50
+ });
package/src/testing.ts ADDED
@@ -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
+
14
+ export { MemoCache } from './memo-cache.js';
15
+ export { TIER_TARGETS } from './quantizer.js';