@viewscript/renderer 0.1.0-202605140721 → 0.1.0-202605141229

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.
@@ -0,0 +1,291 @@
1
+ /**
2
+ * FFI Dispatcher for ViewScript Runtime
3
+ *
4
+ * Evaluates pending FFI calls after frame commit (Phase 8) and buffers
5
+ * results for consumption in the next frame's QSnapshot merge (Phase 0-1).
6
+ *
7
+ * ## Design Rationale
8
+ *
9
+ * FFI function execution time is non-deterministic (user-defined functions).
10
+ * By executing after commitFrame(), we:
11
+ * 1. Keep the rendering critical path (Phase 2-7) free of unpredictable latency
12
+ * 2. Utilize the idle time between commitFrame() and the next rAF callback
13
+ * 3. Maintain 1-frame latency for FFI results (Axiom 2: Ouroboros Binding)
14
+ *
15
+ * ## Data Flow
16
+ *
17
+ * ```
18
+ * Frame N:
19
+ * Phase 2: tick() → TickResult { pending_ffi_calls }
20
+ * Phase 8: dispatch(pending_ffi_calls) → buffer results
21
+ *
22
+ * Frame N+1:
23
+ * Phase 0-1: drainResults() → QSnapshot.values merge
24
+ * Phase 2: solver sees FFI results as Q-dimension input
25
+ * ```
26
+ */
27
+ // =============================================================================
28
+ // FFI Dispatcher
29
+ // =============================================================================
30
+ export class FfiDispatcher {
31
+ /** Function registry: ffi_id -> entry */
32
+ registry = new Map();
33
+ /** Pending modules awaiting registration */
34
+ pendingModules = new Set();
35
+ /** Buffered results for next frame */
36
+ resultBuffer = [];
37
+ /** In-flight async calls (prevents duplicate dispatch while awaiting) */
38
+ inflight = new Set();
39
+ /** Entity coordinate resolver (injected) */
40
+ coordResolver = null;
41
+ /** Q-variable resolver (injected) */
42
+ qResolver = null;
43
+ // ===========================================================================
44
+ // Public API
45
+ // ===========================================================================
46
+ /**
47
+ * Load FFI manifest and build function registry.
48
+ *
49
+ * @param manifest - Parsed FfiManifest JSON
50
+ */
51
+ loadManifest(manifest) {
52
+ this.registry.clear();
53
+ this.pendingModules.clear();
54
+ // Register bindings
55
+ for (const binding of manifest.bindings) {
56
+ this.registry.set(binding.ffi_id, {
57
+ ffi_id: binding.ffi_id,
58
+ bind_name: binding.bind_name,
59
+ module_path: binding.module_path,
60
+ export_name: binding.export_name,
61
+ args: binding.args,
62
+ fn: null,
63
+ });
64
+ this.pendingModules.add(binding.module_path);
65
+ }
66
+ // Register triggers (each trigger has its own function reference)
67
+ for (const trigger of manifest.triggers) {
68
+ if (!this.registry.has(trigger.ffi_id)) {
69
+ this.registry.set(trigger.ffi_id, {
70
+ ffi_id: trigger.ffi_id,
71
+ bind_name: `trigger_${trigger.trigger_id}`, // Triggers don't have bind names
72
+ module_path: trigger.module_path,
73
+ export_name: trigger.export_name,
74
+ args: trigger.args,
75
+ fn: null,
76
+ });
77
+ this.pendingModules.add(trigger.module_path);
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * Register a dynamically imported ESM module.
83
+ *
84
+ * Call this after `import(modulePath)` resolves.
85
+ *
86
+ * @param modulePath - Resolved module path (must match manifest's module_path)
87
+ * @param module - The imported module object
88
+ */
89
+ registerModule(modulePath, module) {
90
+ for (const entry of this.registry.values()) {
91
+ if (entry.module_path === modulePath) {
92
+ const fn = module[entry.export_name];
93
+ if (typeof fn === 'function') {
94
+ entry.fn = fn;
95
+ }
96
+ else {
97
+ console.warn(`[FfiDispatcher] Export '${entry.export_name}' from '${modulePath}' is not a function`);
98
+ }
99
+ }
100
+ }
101
+ this.pendingModules.delete(modulePath);
102
+ }
103
+ /**
104
+ * Set the coordinate resolver for EntityCoord arguments.
105
+ */
106
+ setCoordinateResolver(resolver) {
107
+ this.coordResolver = resolver;
108
+ }
109
+ /**
110
+ * Set the Q-variable resolver for QRef arguments.
111
+ */
112
+ setQResolver(resolver) {
113
+ this.qResolver = resolver;
114
+ }
115
+ /**
116
+ * Get list of module paths that need to be imported.
117
+ */
118
+ getPendingModules() {
119
+ return [...this.pendingModules];
120
+ }
121
+ /**
122
+ * Check if all modules have been registered.
123
+ */
124
+ isReady() {
125
+ return this.pendingModules.size === 0;
126
+ }
127
+ /**
128
+ * Dispatch pending FFI calls (Phase 8).
129
+ *
130
+ * Evaluates JS functions and buffers results. Supports both sync and async
131
+ * functions. For async functions, results are buffered when the Promise
132
+ * resolves (may be multiple frames later).
133
+ *
134
+ * In-flight guard: If an async call for a given ffi_id is still pending,
135
+ * subsequent dispatch requests for the same ffi_id are skipped until the
136
+ * previous call completes.
137
+ *
138
+ * @param pendingCalls - Array of PendingFfiCall from TickResult
139
+ */
140
+ dispatch(pendingCalls) {
141
+ for (const call of pendingCalls) {
142
+ const entry = this.registry.get(call.ffi_id);
143
+ if (!entry) {
144
+ console.warn(`[FfiDispatcher] Unknown ffi_id: ${call.ffi_id}`);
145
+ continue;
146
+ }
147
+ if (!entry.fn) {
148
+ console.warn(`[FfiDispatcher] Function not registered for ffi_id ${call.ffi_id} ` +
149
+ `(${entry.module_path}::${entry.export_name})`);
150
+ continue;
151
+ }
152
+ // In-flight guard: skip if previous async call hasn't completed
153
+ if (this.inflight.has(call.ffi_id)) {
154
+ continue;
155
+ }
156
+ try {
157
+ // Resolve arguments
158
+ const resolvedArgs = this.resolveArgs(entry.args, call.args);
159
+ // Call the function
160
+ const result = entry.fn(...resolvedArgs);
161
+ // Handle async (Promise) results
162
+ if (result instanceof Promise) {
163
+ this.inflight.add(call.ffi_id);
164
+ result
165
+ .then((value) => {
166
+ this.bufferResult(entry.bind_name, value);
167
+ })
168
+ .catch((error) => {
169
+ console.error(`[FfiDispatcher] Async error in ${entry.export_name}:`, error);
170
+ })
171
+ .finally(() => {
172
+ this.inflight.delete(call.ffi_id);
173
+ });
174
+ }
175
+ else {
176
+ // Handle sync results
177
+ this.bufferResult(entry.bind_name, result);
178
+ }
179
+ }
180
+ catch (error) {
181
+ console.error(`[FfiDispatcher] Error calling ${entry.export_name}:`, error);
182
+ }
183
+ }
184
+ }
185
+ /**
186
+ * Buffer a result value if it's numeric.
187
+ */
188
+ bufferResult(bindName, result) {
189
+ const timestamp = performance.now();
190
+ if (typeof result === 'number') {
191
+ this.resultBuffer.push({
192
+ name: bindName,
193
+ value: result,
194
+ timestamp,
195
+ });
196
+ }
197
+ else if (result !== undefined && result !== null) {
198
+ // Attempt numeric coercion for non-number results
199
+ const numValue = Number(result);
200
+ if (!isNaN(numValue)) {
201
+ this.resultBuffer.push({
202
+ name: bindName,
203
+ value: numValue,
204
+ timestamp,
205
+ });
206
+ }
207
+ // Non-numeric results are silently ignored (side-effect only functions)
208
+ }
209
+ }
210
+ /**
211
+ * Drain buffered results for QSnapshot merge (Phase 0-1).
212
+ *
213
+ * Returns all buffered results and clears the buffer.
214
+ * Call this at the start of the next frame.
215
+ *
216
+ * @returns Array of FfiResult for QSnapshot.values merge
217
+ */
218
+ drainResults() {
219
+ const results = this.resultBuffer;
220
+ this.resultBuffer = [];
221
+ return results;
222
+ }
223
+ /**
224
+ * Get the number of buffered results (for diagnostics).
225
+ */
226
+ getBufferedCount() {
227
+ return this.resultBuffer.length;
228
+ }
229
+ /**
230
+ * Get the number of in-flight async calls (for diagnostics/testing).
231
+ */
232
+ getInflightCount() {
233
+ return this.inflight.size;
234
+ }
235
+ /**
236
+ * Check if a specific ffi_id has an in-flight async call.
237
+ */
238
+ isInflight(ffiId) {
239
+ return this.inflight.has(ffiId);
240
+ }
241
+ // ===========================================================================
242
+ // Internal Helpers
243
+ // ===========================================================================
244
+ /**
245
+ * Resolve FfiArg array to concrete values.
246
+ *
247
+ * @param argSpecs - Argument specifications from manifest
248
+ * @param runtimeArgs - Runtime argument overrides from PendingFfiCall
249
+ */
250
+ resolveArgs(argSpecs, runtimeArgs) {
251
+ // If runtime args are provided, use them directly (pre-resolved by WASM)
252
+ if (runtimeArgs.length > 0) {
253
+ return runtimeArgs;
254
+ }
255
+ // Otherwise, resolve from specs
256
+ return argSpecs.map((spec) => this.resolveArg(spec));
257
+ }
258
+ /**
259
+ * Resolve a single FfiArg to its concrete value.
260
+ */
261
+ resolveArg(spec) {
262
+ switch (spec.type) {
263
+ case 'static':
264
+ return spec.value;
265
+ case 'q_ref':
266
+ if (this.qResolver) {
267
+ return this.qResolver.getQValue(spec.name);
268
+ }
269
+ console.warn(`[FfiDispatcher] No Q resolver, cannot resolve ${spec.name}`);
270
+ return 0;
271
+ case 'entity_coord':
272
+ if (this.coordResolver) {
273
+ return this.coordResolver.getEntityCoord(spec.entity_id, spec.component);
274
+ }
275
+ console.warn(`[FfiDispatcher] No coord resolver, cannot resolve entity ${spec.entity_id}.${spec.component}`);
276
+ return 0;
277
+ default:
278
+ console.warn(`[FfiDispatcher] Unknown arg type: ${spec.type}`);
279
+ return 0;
280
+ }
281
+ }
282
+ }
283
+ // =============================================================================
284
+ // Factory Function
285
+ // =============================================================================
286
+ /**
287
+ * Create a new FFI dispatcher instance.
288
+ */
289
+ export function createFfiDispatcher() {
290
+ return new FfiDispatcher();
291
+ }
@@ -35,6 +35,8 @@
35
35
  * We NEVER touch: width, height, top, left, margin, padding (reflow triggers)
36
36
  */
37
37
  import type { EntityId, RenderableEntity, RasterBounds, PVectorBounds } from '../ast/types';
38
+ import type { FfiDispatcher, PendingFfiCall } from './ffi-dispatcher.js';
39
+ import type { WasmSolverBridge } from './wasm-solver-bridge.js';
38
40
  /**
39
41
  * Semantic T-vector state keys.
40
42
  * Mirrors the type from event-backpressure.ts.
@@ -91,6 +93,23 @@ export declare class AtomicRenderLoop {
91
93
  * Injected via setEventBuffer() to support mergeAsyncEvents() at tick start.
92
94
  */
93
95
  private eventBuffer;
96
+ /**
97
+ * FFI dispatcher for Phase 8 function evaluation.
98
+ *
99
+ * Injected via setFfiDispatcher() to support JS function calls after frame commit.
100
+ */
101
+ private ffiDispatcher;
102
+ /**
103
+ * Latest tick result from WASM solver (for Phase 8 FFI dispatch).
104
+ */
105
+ private latestTickResult;
106
+ /**
107
+ * Concrete solver bridge for FFI result injection (Phase 0.5).
108
+ *
109
+ * This is the concrete type of constraintSolver, used only for
110
+ * injectFfiResults(). The ConstraintSolver interface remains FFI-agnostic.
111
+ */
112
+ private solverBridge;
94
113
  constructor(config: Partial<RenderLoopConfig>, constraintSolver: ConstraintSolver, topologyRounder: TopologyRounder, canvasRenderer: CanvasRenderer, domLayer: DOMLayer);
95
114
  /**
96
115
  * Start the render loop.
@@ -112,6 +131,21 @@ export declare class AtomicRenderLoop {
112
131
  * at the start of each tick, ensuring deterministic event ordering.
113
132
  */
114
133
  setEventBuffer(buffer: EventBufferInterface): void;
134
+ /**
135
+ * Set the FFI dispatcher for Phase 8 function evaluation.
136
+ *
137
+ * The FFI dispatcher evaluates JS functions after frame commit and buffers
138
+ * results for the next frame's QSnapshot merge.
139
+ */
140
+ setFfiDispatcher(dispatcher: FfiDispatcher): void;
141
+ /**
142
+ * Set the concrete solver bridge for FFI result injection.
143
+ *
144
+ * The solver bridge is the concrete implementation of ConstraintSolver
145
+ * that supports injectFfiResults(). This must be the same instance
146
+ * as the constraintSolver passed to the constructor.
147
+ */
148
+ setSolverBridge(bridge: WasmSolverBridge): void;
115
149
  /**
116
150
  * Execute a single frame tick.
117
151
  *
@@ -176,8 +210,19 @@ export declare class AtomicRenderLoop {
176
210
  *
177
211
  * This ensures the constraint graph is the single source of truth.
178
212
  */
213
+ /**
214
+ * Result of constraint solver evaluation.
215
+ *
216
+ * Contains both P-dimension bounds and pending FFI calls from WASM tick().
217
+ */
218
+ interface SolverEvaluationResult {
219
+ /** P-dimension bounds for all entities */
220
+ bounds: Map<EntityId, PVectorBounds>;
221
+ /** Pending FFI calls from trigger evaluation (may be empty) */
222
+ pendingFfiCalls: PendingFfiCall[];
223
+ }
179
224
  interface ConstraintSolver {
180
- evaluate(mutations: TStateMutation[]): Map<EntityId, PVectorBounds>;
225
+ evaluate(mutations: TStateMutation[]): SolverEvaluationResult;
181
226
  }
182
227
  interface TopologyRounder {
183
228
  round(bounds: Map<EntityId, PVectorBounds>): {
@@ -54,6 +54,23 @@ export class AtomicRenderLoop {
54
54
  * Injected via setEventBuffer() to support mergeAsyncEvents() at tick start.
55
55
  */
56
56
  eventBuffer = null;
57
+ /**
58
+ * FFI dispatcher for Phase 8 function evaluation.
59
+ *
60
+ * Injected via setFfiDispatcher() to support JS function calls after frame commit.
61
+ */
62
+ ffiDispatcher = null;
63
+ /**
64
+ * Latest tick result from WASM solver (for Phase 8 FFI dispatch).
65
+ */
66
+ latestTickResult = null;
67
+ /**
68
+ * Concrete solver bridge for FFI result injection (Phase 0.5).
69
+ *
70
+ * This is the concrete type of constraintSolver, used only for
71
+ * injectFfiResults(). The ConstraintSolver interface remains FFI-agnostic.
72
+ */
73
+ solverBridge = null;
57
74
  constructor(config, constraintSolver, topologyRounder, canvasRenderer, domLayer) {
58
75
  this.config = {
59
76
  targetFPS: 60,
@@ -110,6 +127,25 @@ export class AtomicRenderLoop {
110
127
  setEventBuffer(buffer) {
111
128
  this.eventBuffer = buffer;
112
129
  }
130
+ /**
131
+ * Set the FFI dispatcher for Phase 8 function evaluation.
132
+ *
133
+ * The FFI dispatcher evaluates JS functions after frame commit and buffers
134
+ * results for the next frame's QSnapshot merge.
135
+ */
136
+ setFfiDispatcher(dispatcher) {
137
+ this.ffiDispatcher = dispatcher;
138
+ }
139
+ /**
140
+ * Set the concrete solver bridge for FFI result injection.
141
+ *
142
+ * The solver bridge is the concrete implementation of ConstraintSolver
143
+ * that supports injectFfiResults(). This must be the same instance
144
+ * as the constraintSolver passed to the constructor.
145
+ */
146
+ setSolverBridge(bridge) {
147
+ this.solverBridge = bridge;
148
+ }
113
149
  // ===========================================================================
114
150
  // Frame Execution (Atomic Commit)
115
151
  // ===========================================================================
@@ -140,13 +176,31 @@ export class AtomicRenderLoop {
140
176
  this.eventBuffer.mergeAsyncEvents();
141
177
  }
142
178
  // -------------------------------------------------------------------------
179
+ // Phase 0.5: Merge FFI Results from Previous Frame
180
+ // -------------------------------------------------------------------------
181
+ // FFI function results from Phase 8 of the previous frame are drained
182
+ // here and injected into the solver bridge. The bridge will include these
183
+ // values in the QSnapshot when evaluate() is called in Phase 2.
184
+ //
185
+ // This maintains the 1-frame latency required by Axiom 2 (Ouroboros Binding):
186
+ // FFI results flow Q→T→P, never directly into P-dimension.
187
+ if (this.ffiDispatcher && this.solverBridge) {
188
+ const ffiResults = this.ffiDispatcher.drainResults();
189
+ if (ffiResults.length > 0) {
190
+ this.solverBridge.injectFfiResults(ffiResults);
191
+ }
192
+ }
193
+ // -------------------------------------------------------------------------
143
194
  // Phase 1: Flush Pending Mutations (Backpressure-Limited)
144
195
  // -------------------------------------------------------------------------
145
196
  const mutations = this.flushMutations();
146
197
  // -------------------------------------------------------------------------
147
198
  // Phase 2: Evaluate Constraint Graph
148
199
  // -------------------------------------------------------------------------
149
- const pVectorBounds = this.constraintSolver.evaluate(mutations);
200
+ const solverResult = this.constraintSolver.evaluate(mutations);
201
+ const pVectorBounds = solverResult.bounds;
202
+ // Store pending FFI calls for Phase 8 (after commit)
203
+ this.latestTickResult = { pending_ffi_calls: solverResult.pendingFfiCalls };
150
204
  // -------------------------------------------------------------------------
151
205
  // Phase 3: Topology-Preserving Rounding (with Error Distribution)
152
206
  // -------------------------------------------------------------------------
@@ -168,6 +222,18 @@ export class AtomicRenderLoop {
168
222
  // -------------------------------------------------------------------------
169
223
  this.commitFrame(drawCommands, domMutations);
170
224
  // -------------------------------------------------------------------------
225
+ // Phase 8: FFI Dispatch (Post-Commit)
226
+ // -------------------------------------------------------------------------
227
+ // FFI functions are evaluated AFTER frame commit to keep the rendering
228
+ // critical path (Phase 2-7) free of unpredictable latency. Results are
229
+ // buffered for consumption in the next frame's Phase 0.5.
230
+ //
231
+ // This utilizes the idle time between commitFrame() and the next rAF.
232
+ if (this.ffiDispatcher && this.latestTickResult) {
233
+ this.ffiDispatcher.dispatch(this.latestTickResult.pending_ffi_calls);
234
+ this.latestTickResult = null;
235
+ }
236
+ // -------------------------------------------------------------------------
171
237
  // Swap Buffers
172
238
  // -------------------------------------------------------------------------
173
239
  this.swapBuffers();
@@ -0,0 +1,122 @@
1
+ /**
2
+ * WASM Solver Bridge
3
+ *
4
+ * Implements ConstraintSolver by delegating to WasmViewScriptEngine.tick().
5
+ * Handles QSnapshot construction and TickResult parsing.
6
+ *
7
+ * ## Responsibilities
8
+ *
9
+ * 1. Convert TStateMutation[] to QSnapshot JSON format
10
+ * 2. Inject FFI results into QSnapshot.values (via injectFfiResults)
11
+ * 3. Call WasmViewScriptEngine.tick() with JSON payload
12
+ * 4. Parse TickResult and extract bounds + pendingFfiCalls
13
+ *
14
+ * ## Data Flow
15
+ *
16
+ * ```
17
+ * TStateMutation[] + FfiResult[]
18
+ * │
19
+ * ▼
20
+ * buildQSnapshot()
21
+ * │
22
+ * ▼
23
+ * QSnapshot JSON ─────► WasmViewScriptEngine.tick()
24
+ * │
25
+ * ▼
26
+ * TickResult JSON
27
+ * │
28
+ * ▼
29
+ * SolverEvaluationResult
30
+ * ```
31
+ */
32
+ import type { EntityId, PVectorBounds } from '../ast/types.js';
33
+ import type { FfiResult, PendingFfiCall } from './ffi-dispatcher.js';
34
+ /**
35
+ * Semantic T-vector state keys.
36
+ */
37
+ type TStateKey = 'hover' | 'pressed' | 'focused' | 'scroll_x' | 'scroll_y' | 'drag_progress' | 'animation_t' | 'gesture_phase';
38
+ /**
39
+ * T-vector state mutation from Q-dimension event.
40
+ */
41
+ export interface TStateMutation {
42
+ entityId: EntityId;
43
+ state: TStateKey;
44
+ value: number;
45
+ timestamp: number;
46
+ }
47
+ /**
48
+ * Result of constraint solver evaluation.
49
+ */
50
+ export interface SolverEvaluationResult {
51
+ bounds: Map<EntityId, PVectorBounds>;
52
+ pendingFfiCalls: PendingFfiCall[];
53
+ }
54
+ /**
55
+ * ConstraintSolver interface.
56
+ *
57
+ * Note: This interface is FFI-agnostic. FFI result injection
58
+ * is handled by the concrete WasmSolverBridge implementation.
59
+ */
60
+ export interface ConstraintSolver {
61
+ evaluate(mutations: TStateMutation[]): SolverEvaluationResult;
62
+ }
63
+ /**
64
+ * WASM engine interface (subset used by bridge).
65
+ */
66
+ export interface WasmEngine {
67
+ tick(inputJson: string): string;
68
+ }
69
+ export declare class WasmSolverBridge implements ConstraintSolver {
70
+ private engine;
71
+ /** Pending FFI results to inject into next QSnapshot */
72
+ private pendingFfiResults;
73
+ /** Entity name to ID mapping (for mutation conversion) */
74
+ private entityNameToId;
75
+ /** Q-variable name to entity+state mapping */
76
+ private qVarMapping;
77
+ constructor(engine: WasmEngine);
78
+ /**
79
+ * Inject FFI results for the next evaluate() call.
80
+ *
81
+ * Called from render-loop Phase 0.5. Results are consumed
82
+ * and cleared when evaluate() runs.
83
+ *
84
+ * @param results - FFI function results from previous frame
85
+ */
86
+ injectFfiResults(results: FfiResult[]): void;
87
+ /**
88
+ * Register entity name to ID mapping.
89
+ *
90
+ * Called during initialization to enable mutation conversion.
91
+ */
92
+ registerEntity(name: string, id: EntityId): void;
93
+ /**
94
+ * Register Q-variable mapping.
95
+ *
96
+ * Maps Q-variable names to entity+state for mutation routing.
97
+ */
98
+ registerQVariable(name: string, entityId: EntityId, state: TStateKey): void;
99
+ /**
100
+ * Evaluate constraints and return solver result.
101
+ *
102
+ * Implements ConstraintSolver interface.
103
+ */
104
+ evaluate(mutations: TStateMutation[]): SolverEvaluationResult;
105
+ /**
106
+ * Build QSnapshot from mutations and FFI results.
107
+ */
108
+ private buildQSnapshot;
109
+ /**
110
+ * Get Q-variable name for an entity's T-state.
111
+ *
112
+ * Convention: `{entityId}_{state}` (e.g., "1_hover", "2_scroll_y")
113
+ */
114
+ private getQVarName;
115
+ }
116
+ /**
117
+ * Create a WASM solver bridge instance.
118
+ *
119
+ * @param engine - WasmViewScriptEngine instance
120
+ */
121
+ export declare function createWasmSolverBridge(engine: WasmEngine): WasmSolverBridge;
122
+ export {};