-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstep_machine.ts
More file actions
1856 lines (1789 loc) · 73.9 KB
/
Copy pathstep_machine.ts
File metadata and controls
1856 lines (1789 loc) · 73.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// src/scene_runtime/protocol/step_machine.ts
//
// Pure step machine for the typed protocol runtime. Owns step
// progression, interaction-index advancement, validator dispatch,
// scene-operation handoff, and ProtocolShellEvent emission via the
// injected RuntimeEmitterHandle.
//
// Public surface: create_step_machine() factory returning
// StepMachineHandle. No DOM, no globals, no window.
//
// References:
// - docs/PRIMARY_SPEC.md (entry_step, step structure, outcome
// resolution, retry semantics, walker rule)
// - docs/specs/PROTOCOL_VOCABULARY.md (gesture, interaction, step,
// sequence, response, scene_operations, validators)
// - docs/active_plans/active/web_ui/seam_interface.md (event lifecycle,
// snapshot derivation)
// - src/shell/adapter/types.ts (closed seam)
// - src/scene_runtime/protocol/emitter.ts (RuntimeEmitterHandle, SnapshotReducer)
// - src/scene_runtime/protocol/validators.ts (validator dispatch)
import type {
ActiveInteractionView,
Gesture,
InteractionRejectReason,
InteractionValidatorPreset,
Interaction,
ProtocolConfig,
ProtocolShellEvent,
ProtocolStep,
SceneOperation,
ShellViewSnapshot,
StepValidatorPreset,
ValidatorReference,
ValidatorPreset,
} from "../../shell/adapter/types";
import type { RuntimeEmitterHandle, SnapshotReducer } from "./emitter";
// SceneOpHandler is defined canonically in scene_operations.ts (the module that
// builds the handler). Import it here (type-only, allowed within protocol/) and
// re-export so this module's existing public surface is unchanged.
import type { SceneOpHandler } from "./scene_operations";
import {
dispatch_interaction_validator,
dispatch_step_validator,
type Interaction as ValidatorInteraction,
type ProtocolStep as ValidatorStep,
type ObjectStateSnapshot,
} from "./validators";
import { is_interaction_preset, is_step_preset } from "./preset_guards";
// Read-only schema-lookup seam. Type-only import: the protocol layer names the
// lookup shape but never imports scene_store/registry. The construction layer
// supplies the implementation through the options object.
import type { StateFieldLookup } from "./state_field_lookup";
// Load-time authored-value validation pass. Uses only the injected lookup plus
// ProtocolConfig; imports no store/registry.
import { validate_authored_validator_values } from "./authored_value_check";
// Load-time gesture-affordance invariant. Reads GESTURE_REGISTRY (the single
// source of registered/wired gestures) plus ProtocolConfig; imports no
// store/registry. It rejects unsupported gesture configurations during loading.
import { validate_gesture_affordances } from "./gesture_affordance_check";
// Load-time structure-derived pedagogy consistency invariant. Checks two
// narrow structured-claim shapes -- a "The N steps" learning-block claim and a
// prompt-named dotted target token -- against the authored structure; imports
// no store/registry.
import { validate_pedagogy_consistency } from "./pedagogy_consistency_check";
// Load-time target-existence invariant. Walks the reachable step
// graph, tracking scene transitions the same way the runtime does, and checks
// each authored target against a per-scene TargetAdapter; imports no
// store/registry.
import {
validate_target_existence,
validate_seeded_scene_operation_targets,
validate_authored_subpart_targets,
type SceneTargetAdapterResolver,
} from "./target_existence_check";
// Target-identity adapter seam. The step machine names the resolver shape
// but never builds it: the construction layer (protocol_host.tsx) supplies a
// scene-scoped adapter, so authored targets normalize to the unique DOM
// placement_name (equality, active_interaction.action.placement_name) and back to the object
// store key (state reads). IDENTITY_TARGET_ADAPTER is the adapter-less default.
import { IDENTITY_TARGET_ADAPTER, type TargetAdapter } from "./target_adapter";
import {
actionable_active_interaction_view,
unavailable_active_interaction_view,
} from "./active_interaction_view";
//============================================
// Public types
//============================================
// SceneOpHandler re-exported from its canonical home (scene_operations.ts).
export type { SceneOpHandler };
export interface PendingTimedWaitCheckpoint {
readonly step_name: string;
readonly interaction_index: number;
readonly target: string;
readonly next_operation_index: number;
}
interface PendingTimedWaitState extends PendingTimedWaitCheckpoint {
readonly operations: ReadonlyArray<SceneOperation>;
}
// Serializable domain checkpoint. The save-file boundary validates this shape
// again before handing it back to the step machine.
export interface StepMachineCheckpoint {
readonly active_step_name: string | null;
readonly interaction_index: number;
readonly completed_step_names: ReadonlyArray<string>;
readonly current_scene: string | null;
readonly is_complete: boolean;
readonly pending_timed_wait: PendingTimedWaitCheckpoint | null;
}
export interface StepMachineHandle {
start(): void;
get_checkpoint(): StepMachineCheckpoint;
handle_click(target: string, gesture: Gesture): void;
handle_modal_close(committed: boolean, choice_id: string | null): void;
handle_timer_elapsed(equipment_name: string): void;
// Commit a typed value for the active `type` interaction. The committed text
// is the raw string the student typed into the visible type-input affordance
// (src/shell/hud/type_input.tsx). It is validated by the active interaction's
// target_with_value preset: the typed text is coerced to the type of the
// single field declared in the validator's `value` block and compared. A
// match advances exactly like a validated click; a mismatch emits
// interaction_rejected (wrong_value) and does NOT advance.
// Returns true when the commit was accepted (validation passed) and false
// when the commit was rejected (wrong value, wrong target, or no active step).
handle_type_commit(target: string, typed_text: string): boolean;
// Commit a numeric set-point for the active `adjust` interaction. The
// committed_number is the value the student reached in the visible shared
// numeric set-point editor (src/shell/hud/set_point_editor.tsx), whether by
// stepper clicks or direct numeric entry. It is validated by the active
// interaction's target_with_value preset: the number is coerced to the type of
// the single field the validator's `value` block declares (the field's DECLARED
// type, mirroring handle_type_commit's coercion, so a float set-point compares
// as a float and an int as an int), then compared. A match advances exactly
// like a validated click; a mismatch emits interaction_rejected (wrong_value)
// and does NOT advance. Returns true on accept, false on reject.
handle_adjust_commit(target: string, committed_number: number): boolean;
// Commit a drag placement for the active `drag` interaction. `target` is the
// dragged source scene object; `destination_placement` is the drop target's
// placement_name (the destination scene object's data-item-id). The source is
// checked against the interaction target and the destination against the
// destination the interaction's authored response names (the `zone` of the
// first LayoutMove scene_operation). A match applies the interaction response
// and advances; the step's step_validator (for example final_state_matches)
// then confirms the accepted final state. Returns true on accept, false on
// reject.
handle_drag_commit(target: string, destination_placement: string): boolean;
}
function reachable_step_names(config: ProtocolConfig): string[] {
const steps_by_name = new Map<string, ProtocolStep>();
for (const step of config.steps ?? []) {
steps_by_name.set(step.step_name, step);
}
const names: string[] = [];
const seen = new Set<string>();
let cursor: string | null = config.entry_step;
while (cursor !== null && !seen.has(cursor)) {
const step = steps_by_name.get(cursor);
if (step === undefined) {
throw new Error(`Unknown reachable step_name in protocol: ${cursor}`);
}
seen.add(cursor);
names.push(cursor);
cursor = step.next_step;
}
return names;
}
export function validate_step_machine_checkpoint(
config: ProtocolConfig,
checkpoint: StepMachineCheckpoint,
): void {
const steps_by_name = new Map<string, ProtocolStep>();
for (const step of config.steps ?? []) {
steps_by_name.set(step.step_name, step);
}
const ordered_step_names = reachable_step_names(config);
const unique_completed = new Set(checkpoint.completed_step_names);
if (unique_completed.size !== checkpoint.completed_step_names.length) {
throw new Error("step_machine: restored completed_step_names contains duplicates");
}
if (checkpoint.is_complete) {
const exact_completion =
checkpoint.active_step_name === null &&
checkpoint.interaction_index === 0 &&
checkpoint.pending_timed_wait === null &&
checkpoint.completed_step_names.length === ordered_step_names.length &&
checkpoint.completed_step_names.every((name, index) => name === ordered_step_names[index]);
if (!exact_completion) {
throw new Error("step_machine: completed restore checkpoint does not match protocol flow");
}
return;
}
const active_name = checkpoint.active_step_name;
if (active_name === null) {
throw new Error("step_machine: incomplete restore checkpoint has no active step");
}
const active_position = ordered_step_names.indexOf(active_name);
const active_step = steps_by_name.get(active_name);
if (active_position < 0 || active_step === undefined) {
throw new Error(`step_machine: restored active step "${active_name}" is not reachable`);
}
const exact_prefix =
checkpoint.completed_step_names.length === active_position &&
checkpoint.completed_step_names.every((name, index) => name === ordered_step_names[index]);
if (!exact_prefix) {
throw new Error("step_machine: restored completed steps are not the active step prefix");
}
if (
!Number.isSafeInteger(checkpoint.interaction_index) ||
checkpoint.interaction_index < 0 ||
checkpoint.interaction_index >= active_step.sequence.length
) {
throw new Error("step_machine: restored interaction index is outside the active step");
}
const pending = checkpoint.pending_timed_wait;
if (pending === null) {
return;
}
const interaction = active_step.sequence[checkpoint.interaction_index];
const operations = interaction?.response.scene_operations;
const wait_operation = operations?.[pending.next_operation_index - 1];
if (
pending.step_name !== active_name ||
pending.interaction_index !== checkpoint.interaction_index ||
pending.next_operation_index < 1 ||
wait_operation?.type !== "TimedWait" ||
wait_operation.target !== pending.target
) {
throw new Error("step_machine: restored timed wait does not match the active response");
}
}
//============================================
// Snapshot reducer (exported for emitter wiring)
//============================================
// Empty starting snapshot. Fields populate as events arrive.
export function initial_snapshot(protocol_name: string): ShellViewSnapshot {
const snapshot: ShellViewSnapshot = {
protocol_name,
current_step_name: null,
current_prompt: null,
// No step is active yet; tip is null until step_started fires.
current_tip: null,
active_interaction: null,
progress: { completed_step_count: 0, total_step_count: 0 },
last_outcome: null,
last_rejection: null,
last_interaction_feedback: null,
pending_validator_kind: null,
modal: {
is_open: false,
kind: null,
prompt: null,
choices: [],
invoking_target: null,
},
help: { is_open: false, topic: null },
tray: { items: [] },
active_scene_name: null,
is_complete: false,
pending_timed_wait: null,
};
return snapshot;
}
function get_active_interaction(
config: ProtocolConfig,
step_name: string | null,
index: number,
): Interaction | null {
if (!step_name) {
return null;
}
// sequence_runner protocols have no steps list; this helper is mini_protocol only.
const steps = config.steps ?? [];
const step = steps.find((s) => s.step_name === step_name);
if (!step) {
return null;
}
if (index < 0 || index >= step.sequence.length) {
return null;
}
const interaction = step.sequence[index];
if (!interaction) {
return null;
}
return interaction;
}
// Pure reducer mapping each ProtocolShellEvent to the next snapshot.
// See seam_interface.md "Snapshot derivation".
// The config is captured in the factory closure, below.
//
// resolve_target_to_placement normalizes the active interaction's authored
// target to the unique DOM placement_name before it enters the snapshot, so
// every consumer of active_interaction.action.placement_name -- the walker's activeTarget
// projection, the select-promotion equality in protocol_host, and the scene
// item's affordance highlight -- sees the same placement_name the DOM stamps as
// data-item-id. Optional and defaulting to identity: pure unit tests and the
// config-only default reducer supply no scene adapter, and with no placements a
// target IS its own placement.
function create_snapshot_reducer(
config: ProtocolConfig,
resolve_target_to_placement: (target: string) => string = (target) => target,
resolve_target_label: (target: string) => string = (target) => target,
): SnapshotReducer {
const resolvers = {
to_placement: resolve_target_to_placement,
to_label: resolve_target_label,
};
function resolve_view(
step_name: string | null,
index: number,
count: number,
): ActiveInteractionView {
const interaction = get_active_interaction(config, step_name, index);
return interaction === null
? unavailable_active_interaction_view(index, count, "transition")
: actionable_active_interaction_view(index, count, interaction, resolvers);
}
return (prev, event) => {
switch (event.kind) {
case "protocol_loaded": {
const next: ShellViewSnapshot = {
...prev,
protocol_name: event.protocol_name,
progress: {
completed_step_count: 0,
total_step_count: event.total_step_count,
},
is_complete: false,
active_interaction: null,
last_rejection: null,
last_interaction_feedback: null,
pending_timed_wait: null,
};
return next;
}
case "session_restored": {
const restored_step =
event.step_name === null
? null
: (config.steps ?? []).find((step) => step.step_name === event.step_name);
const restored_tip = restored_step?.tip ?? null;
const restored_view = event.is_complete
? null
: event.pending_timed_wait === null
? resolve_view(event.step_name, event.interaction_index, event.interaction_count)
: unavailable_active_interaction_view(
event.interaction_index + 1,
event.interaction_count,
"timed_wait",
);
const next: ShellViewSnapshot = {
...prev,
current_step_name: event.step_name,
current_prompt: event.prompt,
current_tip: restored_tip,
active_interaction: restored_view,
progress: {
completed_step_count: event.completed_step_names.length,
total_step_count: prev.progress.total_step_count,
},
last_outcome: null,
last_rejection: null,
last_interaction_feedback: null,
pending_validator_kind: null,
active_scene_name: event.active_scene_name,
is_complete: event.is_complete,
pending_timed_wait: event.pending_timed_wait,
};
return next;
}
case "session_checkpoint_changed":
return prev;
case "step_started": {
// Resolve the step's tip from config; null when absent.
// sequence_runner protocols have no steps list; this path is mini_protocol only.
const steps = config.steps ?? [];
const started_step = steps.find((s) => s.step_name === event.step_name);
const step_tip = started_step?.tip ?? null;
const next: ShellViewSnapshot = {
...prev,
current_step_name: event.step_name,
current_prompt: event.prompt,
current_tip: step_tip,
active_interaction: resolve_view(event.step_name, 0, event.interaction_count),
// A completed-step acknowledgement is useful only during the
// transition itself. Once the next step becomes actionable, leave
// the student with one unambiguous next action rather than a stale
// success message from the preceding step.
last_outcome: null,
last_rejection: null,
pending_timed_wait: null,
};
return next;
}
case "interaction_validated": {
const next_index = event.interaction_index + 1;
const count = prev.active_interaction?.count ?? next_index;
const next: ShellViewSnapshot = {
...prev,
active_interaction: resolve_view(event.step_name, next_index, count),
pending_validator_kind: event.validator_preset,
last_rejection: null,
last_interaction_feedback:
event.feedback === null || event.feedback === undefined
? null
: { kind: "correct", message: event.feedback },
};
return next;
}
case "interaction_rejected": {
const active = get_active_interaction(config, event.step_name, event.interaction_index);
const is_rejected_choice =
event.reason_code === "wrong_target" && active?.gesture === "select";
const next: ShellViewSnapshot = {
...prev,
pending_validator_kind: event.validator_preset,
last_rejection: {
reason_code: event.reason_code,
target_name: event.target_name,
gesture: event.gesture,
selected_label: is_rejected_choice ? resolve_target_label(event.target_name) : null,
expected_label:
is_rejected_choice && active ? resolve_target_label(active.target) : null,
},
last_interaction_feedback:
event.feedback === null || event.feedback === undefined
? null
: { kind: "incorrect", message: event.feedback },
};
return next;
}
case "step_completed": {
const completed_delta = event.resolution === "complete" ? 1 : 0;
const next: ShellViewSnapshot = {
...prev,
progress: {
completed_step_count: prev.progress.completed_step_count + completed_delta,
total_step_count: prev.progress.total_step_count,
},
last_outcome: {
step_name: event.step_name,
resolution: event.resolution,
retry_count: 0,
},
active_interaction: null,
pending_timed_wait: null,
};
return next;
}
case "protocol_completed": {
const next: ShellViewSnapshot = {
...prev,
current_step_name: null,
current_prompt: null,
current_tip: null,
active_interaction: null,
last_rejection: null,
is_complete: true,
pending_timed_wait: null,
};
return next;
}
case "scene_changed": {
// Re-resolve the active interaction's target against the newly-mounted
// scene. A same-step SceneChange (authored in an interaction response)
// swaps the live scene adapter; the active interaction action computed by
// the preceding interaction_validated (or step_started) event was resolved
// against the OLD scene's adapter and is now stale. By the time this event
// fires, the scene-op handler has already rebound the adapter, so re-running
// the resolver here maps the same semantic target onto the NEW scene's
// placement. Without this, an adjust/type/click/select commit on the new
// scene's node is scored against the old scene's placement and rejected as
// out-of-order (the runtime's wrong-order counter increments and the step
// stalls). This fixes the whole scene-change-completion family, not any one
// protocol; it is data-driven off the active step + interaction index with
// no protocol/step-name branch. A step-entry scene render (sequence_runner
// boundary) also emits scene_changed, but the step_started that immediately
// follows recomputes these fields for the new step, so this recompute is a
// harmless transient there and the authoritative fix for a mid-step change.
const active = prev.active_interaction;
const next: ShellViewSnapshot = {
...prev,
active_scene_name: event.to_scene,
active_interaction:
active?.availability === "actionable"
? resolve_view(prev.current_step_name, active.index, active.count)
: active,
};
return next;
}
case "scene_operation_applied": {
return prev;
}
case "timed_wait_started": {
const next: ShellViewSnapshot = {
...prev,
active_interaction:
prev.active_interaction === null
? null
: unavailable_active_interaction_view(
prev.active_interaction.index,
prev.active_interaction.count,
"timed_wait",
),
pending_timed_wait: {
target_name: event.target_name,
display: event.display,
duration_min: event.duration_min,
},
};
return next;
}
case "timed_wait_elapsed": {
const active = prev.active_interaction;
const next: ShellViewSnapshot = {
...prev,
active_interaction:
active === null
? null
: resolve_view(prev.current_step_name, active.index, active.count),
pending_timed_wait: null,
};
return next;
}
case "modal_opened": {
const next: ShellViewSnapshot = {
...prev,
modal: {
is_open: true,
kind: event.modal_kind,
prompt: event.prompt,
choices: event.choices,
invoking_target: event.invoking_target,
},
};
return next;
}
case "modal_closed": {
const next: ShellViewSnapshot = {
...prev,
modal: {
is_open: false,
kind: null,
prompt: null,
choices: [],
invoking_target: null,
},
};
return next;
}
case "help_opened": {
const next: ShellViewSnapshot = {
...prev,
help: { is_open: true, topic: event.topic },
};
return next;
}
case "help_closed": {
const next: ShellViewSnapshot = {
...prev,
help: { is_open: false, topic: null },
};
return next;
}
case "tray_changed": {
const next: ShellViewSnapshot = {
...prev,
tray: { items: event.items },
};
return next;
}
default: {
// Compile-time exhaustiveness.
const exhaustion_check: never = event;
throw new Error(`Unhandled event kind in reducer: ${String(exhaustion_check)}`);
}
}
};
}
// Export a default reducer for use in tests or contexts without config access.
// This version has no active interaction until a step-start event is reduced.
// For full functionality, use create_snapshot_reducer with config.
const default_snapshot_reducer: SnapshotReducer = create_snapshot_reducer({
protocol_name: "",
protocol_type: "mini_protocol",
entry_step: "",
steps: [],
});
export { create_snapshot_reducer, default_snapshot_reducer as snapshot_reducer };
//============================================
// Internal helpers
//============================================
// Walk steps reachable from entry_step via next_step links. Returns
// the count of unique step_names reachable. Cycles (a step pointing
// back at an earlier step) are not counted twice.
function count_reachable_steps(config: ProtocolConfig): number {
if (config.protocol_type === "sequence_runner") {
const mini_protocols = config.mini_protocols ?? [];
return mini_protocols.length;
}
const steps = config.steps ?? [];
if (steps.length === 0) {
return 0;
}
const by_name: Map<string, ProtocolStep> = new Map();
for (const step of steps) {
by_name.set(step.step_name, step);
}
const seen: Set<string> = new Set();
let cursor: string | null = config.entry_step;
while (cursor !== null && !seen.has(cursor)) {
seen.add(cursor);
const step = by_name.get(cursor);
if (!step) {
break;
}
cursor = step.next_step;
}
return seen.size;
}
// Convert a config Interaction into the shape validators.ts expects.
function to_validator_interaction(
target: string,
gesture: Gesture,
preset: InteractionValidatorPreset,
params: Record<string, unknown> | undefined,
): ValidatorInteraction {
const validator_block: ValidatorInteraction["validator"] = params
? { preset, parameters: params }
: { preset };
const interaction: ValidatorInteraction = {
target,
gesture,
validator: validator_block,
};
return interaction;
}
function validator_parameters(ref: ValidatorReference): Record<string, unknown> | undefined {
// `value` is the sole authored spelling (vocabulary-closure patch; `params` alias removed).
const authored_value = ref.value;
if (authored_value === undefined) {
return undefined;
}
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(authored_value)) {
out[key] = val;
}
return out;
}
// Project the authored step_validator into the {object_name: {field: value}}
// parameters shape validate_final_state_matches compares against. A
// final_state_matches step is authored as { target, contains }: a single object
// name plus a flat {field: value} map. Nest `contains` under `target` so the
// validator reads the same nested shape it expects. Every other step preset
// (sequence_complete) declares no parameters, so this returns undefined for them.
//
// This projection is the fix for the runtime bug where the old code read
// step_validator.value (undefined for final_state_matches) and so handed the
// validator no parameters, forcing a perpetual retry. Authoring uses
// .target/.contains, mirroring authored_value_check.ts.
function step_validator_parameters(ref: ValidatorReference): Record<string, unknown> | undefined {
if (ref.preset !== "final_state_matches") {
return undefined;
}
const target = ref.target;
const contains = ref.contains;
if (target === undefined || contains === undefined) {
return undefined;
}
const fields: Record<string, unknown> = {};
for (const [field, value] of Object.entries(contains)) {
fields[field] = value;
}
const nested: Record<string, unknown> = {};
nested[target] = fields;
return nested;
}
// Convert a config ProtocolStep into the shape validators.ts expects.
function to_validator_step(step: ProtocolStep): ValidatorStep {
const preset: StepValidatorPreset = narrow_step_preset(step.step_validator.preset);
const sequence: ReadonlyArray<ValidatorInteraction> = step.sequence.map((interaction) =>
to_validator_interaction(
interaction.target,
interaction.gesture,
narrow_interaction_preset(interaction.validator.preset),
validator_parameters(interaction.validator),
),
);
// Project the step-validator parameters from the authored shape. For
// final_state_matches this nests `contains` under `target`; other step presets
// carry no parameters. (Previously this read step_validator.value, which is
// undefined for final_state_matches and starved the validator of parameters.)
const parameters = step_validator_parameters(step.step_validator);
const validator_block: ValidatorStep["step_validator"] = parameters
? {
preset,
parameters,
}
: { preset };
const out: ValidatorStep = {
step_name: step.step_name,
sequence,
step_validator: validator_block,
};
return out;
}
//============================================
// Load-time preset validation
//============================================
// Validate that every authored validator preset names a preset legal for the
// slot it occupies: a step's `step_validator` must use a step-family preset, and
// each interaction's `validator` must use an interaction-family preset. A
// violation throws once, at protocol load, with every locating field needed to
// find the offending YAML: the protocol name, the step name, the slot kind, the
// interaction index (when the slot is an interaction), the offending preset
// value, and the expected preset family for that slot.
function validate_protocol_presets(config: ProtocolConfig): void {
const protocol_name = config.protocol_name;
for (const step of config.steps ?? []) {
const step_name = step.step_name;
// Step slot: must be a step-family preset.
const step_preset = step.step_validator.preset;
if (!is_step_preset(step_preset)) {
let message = `Invalid validator preset in protocol "${protocol_name}",`;
message += ` step "${step_name}", slot "step_validator":`;
message += ` preset "${String(step_preset)}" is not a step-family preset.`;
message += ` Expected one of the step-family presets`;
message += ` (sequence_complete, final_state_matches).`;
throw new Error(message);
}
// Interaction slots: each must be an interaction-family preset.
step.sequence.forEach((interaction, interaction_index) => {
const interaction_preset = interaction.validator.preset;
if (!is_interaction_preset(interaction_preset)) {
let message = `Invalid validator preset in protocol "${protocol_name}",`;
message += ` step "${step_name}",`;
message += ` slot "interaction.validator" at interaction index ${interaction_index}:`;
message += ` preset "${String(interaction_preset)}" is not an interaction-family preset.`;
message += ` Expected one of the interaction-family presets`;
message += ` (correct_target, correct_choice, target_with_value).`;
throw new Error(message);
}
});
}
}
// Narrow an authored step-slot preset to the step-family type. validate_protocol_presets()
// has already proven, at protocol load, that every step_validator preset is a step-family
// member; this narrow restates that proof for the type system at the use site without a
// lateral down-cast. The throw is unreachable in a loaded protocol and exists only so the
// function has a non-`never` narrowed return on every path.
function narrow_step_preset(preset: ValidatorPreset): StepValidatorPreset {
if (!is_step_preset(preset)) {
throw new Error(`Non-step-family preset reached step slot: "${String(preset)}".`);
}
return preset;
}
// Narrow an authored interaction-slot preset to the interaction-family type.
// Same load-time guarantee and unreachable-throw rationale as narrow_step_preset above.
function narrow_interaction_preset(preset: ValidatorPreset): InteractionValidatorPreset {
if (!is_interaction_preset(preset)) {
throw new Error(`Non-interaction-family preset reached interaction slot: "${String(preset)}".`);
}
return preset;
}
//============================================
// Factory
//============================================
// Construction-time options for create_step_machine. Threaded as an options
// object (not a positional arg) so future load-time validators can inject more
// read-only dependencies without churning the call signature again.
export interface StepMachineOptions {
// Read-only declared-field lookup supplied by the construction layer. The
// load-time authored-value validation pass consumes this to check every authored
// validator value against the target's declared field type. This options object
// only threads it through; the value checks land in authored_value_check.ts.
lookup_state_field: StateFieldLookup;
// Read-only observed object-state reader supplied by the construction layer.
// Given a semantic target name, it returns that target's CURRENT declared-state
// fields as a flat {field: value} map (the live scene_store observed state), or
// an empty map when the target is not seeded. The pure protocol layer names the
// shape but never imports scene_store; the construction layer supplies the impl.
//
// This is the genuine observed source both state-touching validators read:
// - handle_click feeds it to target_with_value so a click is judged against
// real store state, not the authored expected value (which always matched).
// - emit_step_validator_outcome builds the final_state_matches snapshot from
// it so a step passes or fails on observed state instead of retrying forever.
read_object_state: ObjectStateReader;
// Scene-scoped target-identity adapter. Supplied by the construction
// layer, rebuilt per mounted scene. The equality path normalizes both the
// authored interaction.target and the clicked value to the unique DOM
// placement_name through resolve_to_placement; the state-read path normalizes
// to the object_name store key through resolve_to_object. Optional and
// defaulting to the identity adapter: pure unit tests supply no scene, and
// with no placements a target is its own placement and object.
target_adapter?: TargetAdapter;
// Per-scene target-adapter resolver for the load-time target-existence
// invariant. The construction layer eagerly builds a TargetAdapter
// for every scene the protocol's reachable step graph can visit (via
// collect_reachable_scene_names) and supplies this lookup so the check can
// verify each authored target against the SCENE actually active at that
// point in the flow, not only the entry scene. Optional and defaulting to a
// resolver that always returns the single `target_adapter` above (or
// IDENTITY): pure unit tests exercise one scene (or none) and see the same
// behavior as before this option existed.
resolve_scene_target_adapter?: SceneTargetAdapterResolver;
// Initially-mounted scene name. Seeds the step machine's current-scene tracker
// so the step-entry scene render fires ONLY on an actual scene change. The host
// resolves this the same way it resolves the initial mount scene, so entering
// the entry step (whose scene equals the mounted scene) causes no redundant
// re-render. Optional: pure unit tests omit it, and with no step declaring a
// scene the tracker is never consulted. This is the mechanism that plays
// sequence_runner mini-protocol boundaries (each mini's flattened entry step
// declares its resolved entry scene; entering it renders that scene).
initial_scene?: string;
// Optional validated browser-session checkpoint. The machine checks it
// against the current reachable flow before any event or scene operation.
restore_checkpoint?: StepMachineCheckpoint;
}
// Read-only observed object-state reader. See StepMachineOptions.read_object_state.
export type ObjectStateReader = (
target: string,
) => Readonly<Record<string, string | number | boolean>>;
export function create_step_machine(
config: ProtocolConfig,
emitter: RuntimeEmitterHandle,
scene_op_handler: SceneOpHandler,
options: StepMachineOptions,
): StepMachineHandle {
// Build step lookup once.
const steps_by_name: Map<string, ProtocolStep> = new Map();
for (const step of config.steps ?? []) {
steps_by_name.set(step.step_name, step);
}
// Load-time preset validation. Validate every authored preset against the
// slot family it occupies BEFORE any handler closure runs, so a misslotted or
// unknown preset (a step preset in an interaction slot, or vice versa) fails
// loud at protocol load with full locating fields, instead of surfacing as a
// nameless `never` throw deep inside the step machine at runtime.
validate_protocol_presets(config);
// Load-time authored-value validation. Run BEFORE any handler closure, beside
// validate_protocol_presets, so an authored validator value that targets an
// unknown object/subpart/field, or that mistypes a resolved field, fails loud
// at protocol load with full locating fields. Uses only the injected read-only
// lookup plus ProtocolConfig; no store/registry import here.
// The runtime numeric-coercion backstop in validators.ts remains as a backstop.
const lookup_state_field: StateFieldLookup = options.lookup_state_field;
validate_authored_validator_values({
protocol_config: config,
lookup_state_field,
});
// Load-time gesture-affordance invariant. Run BEFORE any handler closure,
// beside the two validators above, so an authored interaction whose gesture
// has no wired affordance in GESTURE_REGISTRY fails loud at protocol load with
// full locating fields, before the emitter/handlers build and before any
// browser session. The invariant is data-driven from the
// registry, so it hardcodes no gesture list and needs no per-protocol branch.
validate_gesture_affordances(config);
// Load-time structure-derived pedagogy consistency invariant. Run BEFORE any
// handler closure, beside the three checks above, so a "The N steps"
// learning-block claim or a prompt-named dotted target token that no longer
// matches the authored structure fails loud at protocol load, instead of
// silently drifting out of sync with the steps a student actually walks.
validate_pedagogy_consistency(config);
// Scene-scoped target-identity adapter. Defaults to identity for the
// adapter-less unit-test context. resolve_to_placement normalizes the equality
// path to the DOM key; resolve_to_object normalizes the state-read path to the
// object_name store key. Built here (moved above the target-existence check)
// so the existence pass below has a default adapter to fall back on.
const target_adapter: TargetAdapter = options.target_adapter ?? IDENTITY_TARGET_ADAPTER;
// Load-time target-existence invariant. Run BEFORE any handler
// closure, beside the three checks above, so an authored `target` that
// resolves to no known placement or object in the SCENE ACTIVE AT THAT
// POINT in the reachable step graph fails loud at protocol load with full
// locating fields, instead of trapping a student mid-walk on an
// unresolvable click target. Defaults to always resolving `target_adapter`
// regardless of scene name, which is exactly the prior (pre-multi-scene)
// behavior for the adapter-less/single-scene unit-test context.
const resolve_scene_target_adapter: SceneTargetAdapterResolver =
options.resolve_scene_target_adapter ?? ((): TargetAdapter => target_adapter);
validate_target_existence(config, options.initial_scene ?? null, resolve_scene_target_adapter);
// Load-time seeded scene-operation target invariant. Run BESIDE
// validate_target_existence, using the same reachable-graph scene tracking,
// so a store-writing scene operation whose target is not seeded in the scene
// active where the operation executes fails loud at protocol load, instead of
// degrading into a
// misleading mid-walk torn-snapshot "no_active_interaction" when the runtime
// op throws a `not seeded` error. A held tool that is only ever
// cursor-attached (never state-mutated or timed) stays exempt.
validate_seeded_scene_operation_targets(
config,
options.initial_scene ?? null,
resolve_scene_target_adapter,
);
// Load-time subpart-suffix invariant. Run BESIDE the two above, on the same
// reachable-graph scene tracking, so an authored "<object>.<suffix>" target
// whose suffix names no declared subpart or subpart_group of that object fails
// loud at load. Without it a group write (well_plate_96.all_wells) or a
// per-well write would silently address a non-rendered pseudo-node mid-walk
// with no visible change (has_target strips the suffix, so a typo'd group or
// well currently passes prefix-only existence).
validate_authored_subpart_targets(
config,
options.initial_scene ?? null,
resolve_scene_target_adapter,
);
// Read-only observed object-state reader. Captured in the factory closure so
// the click path and the step-validator snapshot both read the live scene
// store instead of the authored expected values.
const read_object_state: ObjectStateReader = options.read_object_state;
const ordered_step_names = reachable_step_names(config);
const restore_checkpoint = options.restore_checkpoint ?? null;
if (restore_checkpoint !== null) {
validate_step_machine_checkpoint(config, restore_checkpoint);
}
// Mutable machine state.
let active_step_name: string | null = restore_checkpoint?.active_step_name ?? null;
let interaction_index = restore_checkpoint?.interaction_index ?? 0;
let started = false;
let completed = restore_checkpoint?.is_complete ?? false;
const completed_step_names = new Set<string>(restore_checkpoint?.completed_step_names ?? []);
// A validated interaction pauses at its first TimedWait operation. Operations
// after the wait remain queued so authored response order is preserved; the
// timer callback resumes from next_operation_index without validating the
// interaction a second time.
let pending_timed_wait: PendingTimedWaitState | null = ((): PendingTimedWaitState | null => {
const pending = restore_checkpoint?.pending_timed_wait;
if (pending === null || pending === undefined) {
return null;
}
const step = steps_by_name.get(pending.step_name);
const interaction = step?.sequence[pending.interaction_index];
if (interaction === undefined) {
throw new Error("step_machine: restored timed wait interaction is missing");
}
return {
step_name: pending.step_name,
interaction_index: pending.interaction_index,
target: pending.target,
operations: interaction.response.scene_operations,
next_operation_index: pending.next_operation_index,
};
})();
// Current rendered scene, tracked so the step-entry scene render fires only on
// an actual change. Seeded from the initially-mounted scene and updated by both
// the step-entry render and every authored SceneChange scene_operation.
let current_scene: string | null =
restore_checkpoint?.current_scene ?? options.initial_scene ?? null;
function current_step(): ProtocolStep | null {
if (active_step_name === null) {
return null;
}
return steps_by_name.get(active_step_name) ?? null;
}
function enter_step(step_name: string): void {
const step = steps_by_name.get(step_name);
if (!step) {