viv-compiler 0.1.0__py3-none-any.whl

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,915 @@
1
+ """A library of functions to validate preliminary action definitions and compiled content bundles.
2
+
3
+ The entrypoint functions are as follows:
4
+ * `validate_join_directives()`: Invoked prior to handling of action inheritance.
5
+ * `validate_preliminary_action_definitions()`: Invoked in the middle of postprocessing.
6
+ * `validate_content_bundle()`: Invoked when the final compiled content bundle has been prepared.
7
+
8
+ Everything else is only meant to be invoked internally, i.e., within this module.
9
+ """
10
+
11
+ __all__ = ["validate_join_directives", "validate_preliminary_action_definitions", "validate_content_bundle"]
12
+
13
+ import viv_compiler.config
14
+ import viv_compiler.types
15
+ import viv_compiler.utils
16
+ from viv_compiler.types import ExpressionDiscriminator, TropeDefinition
17
+ from viv_compiler.utils import get_all_referenced_roles
18
+ from importlib import import_module
19
+ from pydantic import TypeAdapter
20
+
21
+
22
+ # todo do we already ensure all references are anchored in a (valid) role name?
23
+ # todo warn or error if precondition references scaled enum?
24
+ # todo validate time of day to ensure 0-23 and 0-59
25
+ # todo technically you could inject major side effects (e.g. reaction queueing) into trope-fit expressions
26
+ # todo ensure closing time period is always later than opening time period
27
+ # todo ensure embargo is either permanent or has a declared time period
28
+ # todo don't allow reaction to precast same entity multiple times
29
+ # todo build roles:
30
+ # - they can cast exactly one entity, with no chance and no mean
31
+ # - no pool declarations
32
+ # - can never be precast
33
+ # - preconditions cannot reference build roles
34
+ # - don't allow them in embargoes (embargo would not be violable)
35
+ # todo apparently the grammar allows e.g. multiple roles fields in an action definition
36
+ # - test what happens (presumably last in wins)
37
+ # todo make sure times like 88:76 aren't allowed (grammar allows them)
38
+ # todo role renaming (make sure new name being duplicate of existing one is already caught)
39
+ # - just detect duplicate role names right away post-visitor
40
+ # todo prohibit entity references anchored in symbol roles
41
+ # - not ideal, but interpreter enforces this at runtime currently
42
+ # todo assignments only allowed in scratch and effects
43
+
44
+
45
+ def validate_join_directives(raw_action_definitions: list[viv_compiler.types.RawActionDefinition]) -> None:
46
+ """Ensure that the given action definition makes proper use of `join` directives.
47
+
48
+ If this validation check passes, the given action definitions are ready for inheritance to be handled.
49
+
50
+ Args:
51
+ raw_action_definitions: A list of raw action definitions for which inheritance is about to be handled.
52
+
53
+ Returns:
54
+ None.
55
+
56
+ Raises:
57
+ Exception: The given raw action definitions did not pass validation.
58
+ """
59
+ for action_definition in raw_action_definitions:
60
+ if not action_definition["parent"]:
61
+ for key, _ in list(action_definition.items()):
62
+ if key.startswith("_join_"):
63
+ raise ValueError(
64
+ f"Action '{action_definition['name']}' uses 'join' operator but declares no parent"
65
+ )
66
+ for role_definition in action_definition["roles"]:
67
+ if "_role_renaming" in role_definition:
68
+ if "_join_roles" not in action_definition:
69
+ raise ValueError(
70
+ f"Action '{action_definition['name']}' uses role renaming but does not 'join roles'"
71
+ )
72
+
73
+
74
+ def validate_preliminary_action_definitions(
75
+ intermediate_action_definitions: list[viv_compiler.types.IntermediateActionDefinition]
76
+ ) -> None:
77
+ """Validate the given preliminary action definitions, to catch potential major issues that
78
+ must be rectified prior to the final postprocessing steps.
79
+
80
+ Args:
81
+ intermediate_action_definitions: A list of action definitions for which includes have been honored,
82
+ elided optional fields have been filled in, and the 'initiator' field has been set.
83
+
84
+ Returns:
85
+ Args
86
+
87
+ Raises:
88
+ Exception: At least one action definition did not pass validation.
89
+ """
90
+ for action_definition in intermediate_action_definitions:
91
+ # Ensure that a 'roles' field is present
92
+ if "roles" not in action_definition:
93
+ raise KeyError(f"Action '{action_definition['name']}' is missing a 'roles' field, which is required")
94
+ # Ensure that there is a single initiator role. Note that during initial validation,
95
+ # the 'roles' field is still a list.
96
+ _detect_wrong_number_of_initiators(action_definition=action_definition)
97
+ # Detect duplicated role names
98
+ _detect_duplicate_role(action_definition=action_definition)
99
+ # Detect any reference to an undefined role. This validation *must* occur prior to
100
+ # precondition attribution and construction of the role-dependency tree.
101
+ _detect_reference_to_undefined_role(action_definition=action_definition)
102
+
103
+
104
+ def _detect_wrong_number_of_initiators(action_definition: viv_compiler.types.IntermediateActionDefinition) -> None:
105
+ """Ensure that the given action definition has a single initiator role.
106
+
107
+ Args:
108
+ action_definition: An action definitions for which includes have been honored, elided
109
+ optional fields have been filled in, and the 'initiator' field has been set.
110
+
111
+ Returns:
112
+ None.
113
+
114
+ Raises:
115
+ Exception: The action definition did not pass validation.
116
+ """
117
+ all_initiator_roles = [role for role in action_definition['roles'] if role['initiator']]
118
+ if len(all_initiator_roles) == 0:
119
+ raise ValueError(f"Action '{action_definition['name']}' has no initiator role (must have exactly one)")
120
+ elif len(all_initiator_roles) > 1:
121
+ raise ValueError(f"Action '{action_definition['name']}' has multiple initiator roles (must have exactly one)")
122
+
123
+
124
+ def _detect_duplicate_role(action_definition: viv_compiler.types.IntermediateActionDefinition) -> None:
125
+ """Ensure that the given action definition has no duplicated role names.
126
+
127
+ Args:
128
+ action_definition: An action definitions for which includes have been honored, elided
129
+ optional fields have been filled in, and the 'initiator' field has been set.
130
+
131
+ Returns:
132
+ None.
133
+
134
+ Raises:
135
+ Exception: The action definition did not pass validation.
136
+ """
137
+ for role_definition in action_definition["roles"]:
138
+ n_roles_with_that_name = 0
139
+ for other_role_definition in action_definition["roles"]:
140
+ if other_role_definition['name'] == role_definition["name"]:
141
+ n_roles_with_that_name += 1
142
+ if n_roles_with_that_name > 1:
143
+ raise ValueError(
144
+ f"Action '{action_definition['name']}' has duplicate role: "
145
+ f"'{role_definition['name']}'"
146
+ )
147
+
148
+
149
+ def _detect_reference_to_undefined_role(action_definition: viv_compiler.types.IntermediateActionDefinition) -> None:
150
+ """Ensure that the given action definition has no references to any undefined role.
151
+
152
+ Args:
153
+ action_definition: An action definitions for which includes have been honored, elided
154
+ optional fields have been filled in, and the 'initiator' field has been set.
155
+
156
+ Returns:
157
+ None.
158
+
159
+ Raises:
160
+ Exception: The action definition did not pass validation.
161
+ """
162
+ # Retrieve the names of all defined roles ('roles' is still a list at this point in postprocessing)
163
+ all_defined_role_names = {role['name'] for role in action_definition['roles']} | {'hearer', 'this', 'default'}
164
+ # Validate report references
165
+ if action_definition["report"]:
166
+ for reference in viv_compiler.utils.get_all_referenced_roles(action_definition["report"]):
167
+ if reference not in all_defined_role_names:
168
+ raise KeyError(
169
+ f"Action '{action_definition['name']}' has report that references "
170
+ f"undefined role '{reference}'"
171
+ )
172
+ # Validation pool-directive references
173
+ for role_definition in action_definition["roles"]:
174
+ if not role_definition['pool']:
175
+ continue
176
+ for reference in viv_compiler.utils.get_all_referenced_roles(role_definition['pool']):
177
+ if reference not in all_defined_role_names:
178
+ raise KeyError(
179
+ f"Pool directive for role '{role_definition['name']}' in action "
180
+ f"'{action_definition['name']}' references undefined role: '{reference}'"
181
+ )
182
+ # Validation precondition references
183
+ for precondition in action_definition["preconditions"]:
184
+ for reference in precondition['references']:
185
+ if reference not in all_defined_role_names:
186
+ raise KeyError(
187
+ f"Action '{action_definition['name']}' has precondition that references "
188
+ f"undefined role: '{reference}'"
189
+ )
190
+ # Validation effect references
191
+ for effect in action_definition["effects"]:
192
+ for reference in effect['references']:
193
+ if reference not in all_defined_role_names:
194
+ raise KeyError(
195
+ f"Action '{action_definition['name']}' has effect that references "
196
+ f"undefined role '{reference}'"
197
+ )
198
+ # Validate reaction references
199
+ for reaction_declaration in action_definition['reactions']:
200
+ for reference in reaction_declaration['references']:
201
+ if reference not in all_defined_role_names:
202
+ raise KeyError(
203
+ f"Action '{action_definition['name']}' has reaction that references "
204
+ f"undefined role '{reference}'"
205
+ )
206
+ # Validate salience references
207
+ for role_name in get_all_referenced_roles(ast_chunk=action_definition['saliences']):
208
+ if role_name not in all_defined_role_names:
209
+ raise KeyError(
210
+ f"Action '{action_definition['name']}' has salience expression that "
211
+ f"references undefined role: '{role_name}'"
212
+ )
213
+ # Validate association references
214
+ for role_name in get_all_referenced_roles(ast_chunk=action_definition['associations']):
215
+ if role_name not in all_defined_role_names:
216
+ raise KeyError(
217
+ f"Action '{action_definition['name']}' has association declaration that "
218
+ f"references undefined role: '{role_name}'"
219
+ )
220
+ # Validate embargo references
221
+ for embargo in action_definition['embargoes']:
222
+ for role_name in embargo['roles'] or []:
223
+ if role_name not in all_defined_role_names:
224
+ raise KeyError(
225
+ f"Action '{action_definition['name']}' has embargo that "
226
+ f"references undefined role: '{role_name}'"
227
+ )
228
+
229
+
230
+ def validate_content_bundle(content_bundle: viv_compiler.types.CompiledContentBundle) -> None:
231
+ """Validate the given compiled content bundle.
232
+
233
+ Args:
234
+ content_bundle: A compiled content bundle.
235
+
236
+ Returns:
237
+ None: If no issue was detected.
238
+
239
+ Raises:
240
+ Exception: If an issue was detected.
241
+ """
242
+ # Carry out semantic validation on the trope definitions
243
+ _validate_trope_definitions(trope_definitions=content_bundle["tropes"])
244
+ # Carry out semantic validation on the action definitions
245
+ _validate_action_definitions(
246
+ action_definitions=content_bundle["actions"],
247
+ trope_definitions=content_bundle["tropes"],
248
+ )
249
+ # Finally, carry out structural validation by comparing the entire content bundle against our schema
250
+ _validate_compiled_content_bundle_against_schema(content_bundle=content_bundle)
251
+
252
+
253
+ def _validate_trope_definitions(trope_definitions: dict[str, viv_compiler.types.TropeDefinition]) -> None:
254
+ """Validate the given trope definitions.
255
+
256
+ Args:
257
+ trope_definitions: Map from trope name to trope definition, for all definitions in the compiled content bundle.
258
+
259
+ Returns:
260
+ None.
261
+
262
+ Raises:
263
+ Exception: The trope definitions did not pass validation.
264
+ """
265
+ for trope_definition in trope_definitions.values():
266
+ # Detect any reference to an undefined param
267
+ all_referenced_params = viv_compiler.utils.get_all_referenced_roles(ast_chunk=trope_definition)
268
+ for referenced_param in all_referenced_params:
269
+ if referenced_param not in trope_definition["params"]:
270
+ raise KeyError(
271
+ f"Trope '{trope_definition['name']}' references undefined parameter: "
272
+ f"'{referenced_param}'"
273
+ )
274
+
275
+
276
+ def _validate_action_definitions(
277
+ action_definitions: dict[str, viv_compiler.types.ActionDefinition],
278
+ trope_definitions: dict[str, viv_compiler.types.TropeDefinition],
279
+ ) -> None:
280
+ """Validate the given action definitions.
281
+
282
+ Args:
283
+ action_definitions: Map from action name to action definition, for all action
284
+ definitions in the compiled content bundle.
285
+ trope_definitions: Map from trope name to trope definition, for all definitions in the compiled content bundle.
286
+
287
+ Returns:
288
+ None.
289
+
290
+ Raises:
291
+ Exception: The action definitions did not pass validation.
292
+ """
293
+ # First, let's operate over the set of definitions to detect any duplicated action names
294
+ _detect_duplicate_action_names(action_definitions=action_definitions)
295
+ # Now let's validate each action in turn
296
+ for action_definition in action_definitions.values():
297
+ # Validate role definitions
298
+ _validate_action_roles(action_definition=action_definition)
299
+ # Validate preconditions
300
+ _validate_action_preconditions(action_definition=action_definition)
301
+ # Validate effects
302
+ _validate_action_effects(action_definition=action_definition)
303
+ # Validate reactions
304
+ _validate_action_reactions(action_definition=action_definition, all_action_definitions=action_definitions)
305
+ # Validate associations
306
+ # Validate trope-fit expressions
307
+ _validate_action_trope_fit_expressions(
308
+ action_definition=action_definition,
309
+ trope_definitions=trope_definitions
310
+ )
311
+ # Validate role unpackings
312
+ _validate_action_role_unpackings(action_definition=action_definition)
313
+ # Validate loops
314
+ _validate_action_loops(action_definition=action_definition)
315
+ # Validate assignments
316
+ _validate_action_assignments(action_definition=action_definition)
317
+ # Validate negated expressions
318
+ _validate_negated_expressions(action_definition=action_definition)
319
+ # Validate chance expressions
320
+ _validate_chance_expressions(action_definition=action_definition)
321
+
322
+
323
+ def _detect_duplicate_action_names(action_definitions: dict[str, viv_compiler.types.ActionDefinition]) -> None:
324
+ """Ensure that there are no duplicate names in use among the given action definitions.
325
+
326
+ Args:
327
+ action_definitions: Map from action name to action definition, for all action
328
+ definitions in the compiled content bundle.
329
+
330
+ Returns:
331
+ None.
332
+
333
+ Raises:
334
+ Exception: The action definitions did not pass validation.
335
+ """
336
+ action_names = [action_definition['name'] for action_definition in action_definitions.values()]
337
+ for action_name in action_names:
338
+ if action_names.count(action_name) > 1:
339
+ raise ValueError(f"Duplicate action name: '{action_name}'")
340
+
341
+
342
+ def _validate_action_roles(action_definition: viv_compiler.types.ActionDefinition) -> None:
343
+ """Validate the 'roles' field of the given action definition.
344
+
345
+ Args:
346
+ action_definition: An action definition from a compiled content bundle.
347
+
348
+ Returns:
349
+ None.
350
+
351
+ Raises:
352
+ Exception: The action definition did not pass validation.
353
+ """
354
+ # Validate the initiator role
355
+ _validate_action_initiator_role(action_definition=action_definition)
356
+ # Ensure all roles have proper 'min' and 'max' values
357
+ _validate_action_role_min_and_max_values(action_definition=action_definition)
358
+ # Ensure all roles have proper 'chance' and 'mean' values
359
+ _validate_action_role_chance_and_mean_values(action_definition=action_definition)
360
+ # Ensure all roles with binding pools have proper ones
361
+ _validate_action_role_pool_directives(action_definition=action_definition)
362
+ # Ensure that the 'precast' label is only used if this is a special action
363
+ _validate_action_role_precast_label_usages(action_definition=action_definition)
364
+
365
+
366
+ def _validate_action_initiator_role(action_definition: viv_compiler.types.ActionDefinition) -> None:
367
+ """Ensure that the given action definition has a valid initiator role definition.
368
+
369
+ Args:
370
+ action_definition: An action definition from a compiled content bundle.
371
+
372
+ Returns:
373
+ None.
374
+
375
+ Raises:
376
+ Exception: The action definition did not pass validation.
377
+ """
378
+ # Retrieve the definition for the action's initiator role
379
+ initiator_role_definition = action_definition['initiator']
380
+ # Ensure that the initiator role has no pool directive
381
+ if initiator_role_definition["pool"]:
382
+ raise ValueError(
383
+ f"Action '{action_definition['name']}' has initiator role '{initiator_role_definition['name']}' "
384
+ f"with a pool directive, which is not allowed for initiator roles"
385
+ )
386
+ # Ensure that the initiator role casts exactly one entity
387
+ if initiator_role_definition['min'] != 1:
388
+ raise ValueError(
389
+ f"Action '{action_definition['name']}' has initiator role '{initiator_role_definition['name']}' "
390
+ f"with min other than 1 (there must be a single initiator)"
391
+ )
392
+ if initiator_role_definition['max'] != 1:
393
+ raise ValueError(
394
+ f"Action '{action_definition['name']}' has initiator role '{initiator_role_definition['name']}' "
395
+ f"with max other than 1 (there must be a single initiator)"
396
+ )
397
+ # Ensure that the initiator role has no specified binding mean
398
+ if initiator_role_definition['mean'] is not None:
399
+ raise ValueError(
400
+ f"Action '{action_definition['name']}' has initiator role '{initiator_role_definition['name']}' "
401
+ f"with declared casting mean (there must be a single initiator)"
402
+ )
403
+ # Ensure that the initiator role has no specified binding chance
404
+ if initiator_role_definition['chance'] is not None:
405
+ raise ValueError(
406
+ f"Action '{action_definition['name']}' has initiator role '{initiator_role_definition['name']}' "
407
+ f"with declared casting chance (there must be a single initiator)"
408
+ )
409
+
410
+
411
+ def _validate_action_role_min_and_max_values(action_definition: viv_compiler.types.ActionDefinition) -> None:
412
+ """Ensure that the given action definition has no role definition with invalid 'min' or 'max' value.
413
+
414
+ Args:
415
+ action_definition: An action definition from a compiled content bundle.
416
+
417
+ Returns:
418
+ None.
419
+
420
+ Raises:
421
+ Exception: The action definition did not pass validation.
422
+ """
423
+ for role_definition in action_definition["roles"].values():
424
+ # Detect minimums less than 0
425
+ if role_definition['min'] < 0:
426
+ raise ValueError(
427
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' "
428
+ f"with negative min (must be 0 or greater)"
429
+ )
430
+ # Detect maximums of 0 or less
431
+ if role_definition['max'] < 1:
432
+ raise ValueError(
433
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' "
434
+ f"with max less than 1 (to turn off role, comment it out or use chance of [0%])"
435
+ )
436
+ # Detect role-count minimums that are greater than role-count maximums
437
+ if role_definition['min'] > role_definition['max']:
438
+ raise ValueError(
439
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' with min > max"
440
+ )
441
+
442
+
443
+ def _validate_action_role_chance_and_mean_values(action_definition: viv_compiler.types.ActionDefinition) -> None:
444
+ """Ensure that the given action definition has no role definition with invalid 'chance' or 'mean' value.
445
+
446
+ Args:
447
+ action_definition: An action definition from a compiled content bundle.
448
+
449
+ Returns:
450
+ None.
451
+
452
+ Raises:
453
+ Exception: The action definition did not pass validation.
454
+ """
455
+ for role_definition in action_definition["roles"].values():
456
+ # Confirm that 'chance', if present, is between 0.0 and 1.0
457
+ if role_definition['chance'] is not None:
458
+ if role_definition['chance'] < 0.0 or role_definition['chance'] > 1.0:
459
+ raise ValueError(
460
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' "
461
+ f"with invalid chance (must be between 0-100%)"
462
+ )
463
+ # Confirm that 'mean', if present, is between min and max
464
+ if role_definition['mean'] is not None:
465
+ role_min, role_max = role_definition['min'], role_definition['max']
466
+ if role_definition['mean'] < role_min or role_definition['mean'] > role_max:
467
+ raise ValueError(
468
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' "
469
+ f"with invalid mean (must be between min and max)"
470
+ )
471
+
472
+
473
+ def _validate_action_role_pool_directives(action_definition: viv_compiler.types.ActionDefinition) -> None:
474
+ """Ensure that the given action definition does not have an invalid pool directive.
475
+
476
+ Args:
477
+ action_definition: An action definition from a compiled content bundle.
478
+
479
+ Returns:
480
+ None.
481
+
482
+ Raises:
483
+ Exception: The action definition did not pass validation.
484
+ """
485
+ for role_definition in action_definition["roles"].values():
486
+ # Force pool directives for all roles except present characters and items. One exception here is
487
+ # that an absent item can be built by an action, in which case its location should be specified.
488
+ requires_pool_directive = False
489
+ if role_definition['absent']:
490
+ if not role_definition['build']:
491
+ requires_pool_directive = True
492
+ elif role_definition['action']:
493
+ requires_pool_directive = True
494
+ elif role_definition['location']:
495
+ requires_pool_directive = True
496
+ elif role_definition['symbol']:
497
+ requires_pool_directive = True
498
+ if role_definition['precast']:
499
+ requires_pool_directive = False
500
+ if requires_pool_directive and not role_definition["pool"]:
501
+ raise KeyError(
502
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}' "
503
+ f"that requires a pool declaration but does not have one"
504
+ )
505
+ # Ensure that all pool directives reference at most one other (valid) role
506
+ if role_definition["pool"]:
507
+ all_pool_references = viv_compiler.utils.get_all_referenced_roles(role_definition["pool"])
508
+ if len(all_pool_references) > 1:
509
+ non_initiator_pool_references = (
510
+ [role for role in all_pool_references if role != action_definition['initiator']['name']]
511
+ )
512
+ if len(non_initiator_pool_references) > 1:
513
+ raise ValueError(
514
+ f"Pool directive for role '{role_definition['name']}' in action '{action_definition['name']}' "
515
+ f"references multiple other non-initiator roles ({', '.join(non_initiator_pool_references)}), "
516
+ f"but pool directives may only reference a single other non-initiator role"
517
+ )
518
+ # Force "is" pool directives to correspond to a max of exactly 1, since this is the "is" semantics.
519
+ # Note that we don't actually retain any data marking that the 'is' operator was used, but rather we
520
+ # create a binding-pool expression that is a list containing a single element, that being the reference
521
+ # for the entity associated with the 'is' usage. We could end up with this same shape if the author uses
522
+ # 'from' with a literal singleton array -- e.g. "hat from ["hat"]: symbol" -- so the error message will
523
+ # refer to both potential causes.
524
+ if role_definition["pool"]["body"]["type"] == ExpressionDiscriminator.LIST:
525
+ if len(role_definition["pool"]["body"]["value"]) == 1:
526
+ # If we're in here, this is either an 'is' usage or a case of 'from' with a literal singleton array
527
+ if role_definition['max'] != 1:
528
+ raise ValueError(
529
+ f"Action '{action_definition['name']}' has role '{role_definition['name']}'that uses "
530
+ f"'is' pool directive (or 'from' with a singleton array) with a max other than 1"
531
+ )
532
+
533
+
534
+ def _validate_action_role_precast_label_usages(action_definition: viv_compiler.types.ActionDefinition) -> None:
535
+ """Ensure that the given action definition only uses the 'precast' role label if it's special.
536
+
537
+ The 'precast' role label allows an author to specify that a role can only be cast using
538
+ the bindings asserted by a reaction. A 'special' action is one that can only be targeted
539
+ as a reaction, which is necessary for usage of the 'precast' label, because otherwise there
540
+ would be no way to cast the role.
541
+
542
+ Args:
543
+ action_definition: An action definition from a compiled content bundle.
544
+
545
+ Returns:
546
+ None.
547
+
548
+ Raises:
549
+ Exception: The action definition did not pass validation.
550
+ """
551
+ if action_definition['special']:
552
+ return
553
+ for role_definition in action_definition["roles"].values():
554
+ if role_definition['precast']:
555
+ raise ValueError(
556
+ f"Action '{action_definition['name']}' has precast role '{role_definition['name']}', "
557
+ f"but is not marked special (only special actions can have precast roles)"
558
+ )
559
+
560
+
561
+ def _validate_action_preconditions(action_definition: viv_compiler.types.ActionDefinition) -> None:
562
+ """Validate the given action definition's 'preconditions' field.
563
+
564
+ Args:
565
+ action_definition: An action definition from a compiled content bundle.
566
+
567
+ Returns:
568
+ None.
569
+
570
+ Raises:
571
+ Exception: The action definition did not pass validation.
572
+ """
573
+ # Collect all preconditions
574
+ all_preconditions = []
575
+ for precondition_group in action_definition['preconditions'].values(): # Grouped by role
576
+ all_preconditions += precondition_group
577
+ # Validate each one
578
+ for precondition in all_preconditions:
579
+ # Currently, it's not possible to evaluate a precondition referencing two optional roles
580
+ optional_role_references = []
581
+ for reference in precondition['references']:
582
+ if action_definition['roles'][reference]['min'] < 1:
583
+ optional_role_references.append(reference)
584
+ if len(optional_role_references) > 1:
585
+ raise ValueError(
586
+ f"Action '{action_definition['name']}' has precondition that references multiple optional "
587
+ f"roles ({', '.join(optional_role_references)}), but currently a precondition may only "
588
+ f"reference at most one optional role"
589
+ )
590
+
591
+
592
+ def _validate_action_effects(action_definition: viv_compiler.types.ActionDefinition) -> None:
593
+ """Validate the given action definition's 'effects' field.
594
+
595
+ Args:
596
+ action_definition: An action definition from a compiled content bundle.
597
+
598
+ Returns:
599
+ None.
600
+
601
+ Raises:
602
+ Exception: The action definition did not pass validation.
603
+ """
604
+ for effect in action_definition['effects']:
605
+ # Confirm that the effect contains no eval fail-safe operator
606
+ if viv_compiler.utils.contains_eval_fail_safe_operator(ast_chunk=effect):
607
+ raise ValueError(
608
+ f"Action '{action_definition['name']}' has effect that uses the eval fail-safe "
609
+ f"operator (?), which is not allowed in effects"
610
+ )
611
+
612
+
613
+ def _validate_action_reactions(
614
+ action_definition: viv_compiler.types.ActionDefinition,
615
+ all_action_definitions: dict[str, viv_compiler.types.ActionDefinition]
616
+ ) -> None:
617
+ """Validate the given action definition's 'reactions' field.
618
+
619
+ Args:
620
+ action_definition: An action definition from a compiled content bundle.
621
+ all_action_definitions: Map from action name to action definition, for all action
622
+ definitions in the compiled content bundle.
623
+
624
+ Returns:
625
+ None.
626
+
627
+ Raises:
628
+ Exception: The action definition did not pass validation.
629
+ """
630
+ # Ensure that all reactions are housed in the proper fields
631
+ fields_to_check = (
632
+ ("preconditions", action_definition["preconditions"]),
633
+ ("scratch", action_definition["scratch"]),
634
+ ("effects", action_definition["effects"]),
635
+ )
636
+ for field_name, field_value in fields_to_check:
637
+ reactions_in_this_field = viv_compiler.utils.get_all_expressions_of_type(
638
+ expression_type="reaction",
639
+ ast_chunk=field_value
640
+ )
641
+ if reactions_in_this_field:
642
+ raise ValueError(
643
+ f"Action '{action_definition['name']}' has reaction in '{field_name}' field "
644
+ f"(only allowed in 'reactions' field)"
645
+ )
646
+ # Collect all reactions
647
+ all_reactions = viv_compiler.utils.get_all_expressions_of_type(
648
+ expression_type="reaction",
649
+ ast_chunk=action_definition['reactions']
650
+ )
651
+ # Validate each reaction in turn
652
+ for reaction in all_reactions:
653
+ # Make sure the reaction references a valid action
654
+ action_names = [action_definition['name'] for action_definition in all_action_definitions.values()]
655
+ queued_action_name = reaction['actionName']
656
+ if queued_action_name not in action_names:
657
+ raise KeyError(
658
+ f"Action '{action_definition['name']}' has reaction that queues "
659
+ f"undefined action '{queued_action_name}'"
660
+ )
661
+ # Make sure the reaction binds an initiator role
662
+ queued_action_definition = all_action_definitions[queued_action_name]
663
+ queued_action_initiator_role_name = queued_action_definition['initiator']['name']
664
+ initiator_is_bound = False
665
+ for binding in reaction['bindings']:
666
+ if binding['value']['role'] == queued_action_initiator_role_name:
667
+ initiator_is_bound = True
668
+ break
669
+ if not initiator_is_bound:
670
+ raise ValueError(
671
+ f"Action '{action_definition['name']}' has '{queued_action_name}' reaction that "
672
+ f"fails to precast its initiator role '{queued_action_initiator_role_name}'"
673
+ )
674
+ # Make sure the reaction references only roles defined in the queued actions
675
+ all_queued_action_role_names = queued_action_definition['roles'].keys()
676
+ for binding in reaction['bindings']:
677
+ bound_role_name = binding['value']['role']
678
+ if bound_role_name not in all_queued_action_role_names:
679
+ raise KeyError(
680
+ f"Action '{action_definition['name']}' has '{queued_action_name}' reaction that "
681
+ f"references a role that is undefined for '{queued_action_name}': '{bound_role_name}'"
682
+ )
683
+ # Make sure the reaction precasts all 'precast' roles
684
+ for role_object in queued_action_definition['roles'].values():
685
+ if not role_object['precast']:
686
+ continue
687
+ precast_role_name = role_object['name']
688
+ role_is_precast = False
689
+ for binding in reaction['bindings']:
690
+ bound_role_name = binding['value']['role']
691
+ if bound_role_name == precast_role_name:
692
+ role_is_precast = True
693
+ break
694
+ if not role_is_precast:
695
+ raise KeyError(
696
+ f"Action '{action_definition['name']}' has '{queued_action_name}' reaction "
697
+ f"that fails to precast one of its 'precast' roles: '{precast_role_name}'"
698
+ )
699
+
700
+
701
+ def _validate_action_trope_fit_expressions(
702
+ action_definition: viv_compiler.types.ActionDefinition,
703
+ trope_definitions: dict[str, TropeDefinition]
704
+ ) -> None:
705
+ """Validate the given action definition's usage of trope-fit expressions.
706
+
707
+ Args:
708
+ action_definition: An action definition from a compiled content bundle.
709
+ trope_definitions: Map from trope name to trope definition, for all definitions in the compiled content bundle.
710
+
711
+ Returns:
712
+ None.
713
+
714
+ Raises:
715
+ Exception: The action definition did not pass validation.
716
+ """
717
+ all_trope_fit_expressions = viv_compiler.utils.get_all_expressions_of_type(
718
+ expression_type="tropeFitExpression",
719
+ ast_chunk=action_definition
720
+ )
721
+ for trope_fit_expression in all_trope_fit_expressions:
722
+ # Retrieve the name of the trope referenced in the expression
723
+ trope_name = trope_fit_expression['tropeName']
724
+ # Detect reference to undefined trope
725
+ if trope_name not in trope_definitions:
726
+ raise KeyError(f"Action '{action_definition['name']}' references undefined trope: '{trope_name}'")
727
+ # Detect cases of missing arguments or extra arguments
728
+ trope_definition = trope_definitions[trope_name]
729
+ if len(trope_fit_expression['args']) != len(trope_definition['params']):
730
+ n_args = len(trope_fit_expression['args'])
731
+ n_params = len(trope_definition['params'])
732
+ relative_quantity = "too few" if n_args < n_params else "too many"
733
+ raise ValueError(
734
+ f"Action '{action_definition['name']}' invokes trope '{trope_name}' with "
735
+ f"{relative_quantity} arguments (expected {n_params}, got {n_args})"
736
+ )
737
+
738
+
739
+ def _validate_action_role_unpackings(action_definition: viv_compiler.types.ActionDefinition) -> None:
740
+ """Validate the given action definition's usage of role unpackings.
741
+
742
+ Args:
743
+ action_definition: An action definition from a compiled content bundle.
744
+
745
+ Returns:
746
+ None.
747
+
748
+ Raises:
749
+ Exception: The action definition did not pass validation.
750
+ """
751
+ # Ensure all role unpackings unpack roles that can cast multiple entities
752
+ all_unpacked_role_names = viv_compiler.utils.get_all_expressions_of_type(
753
+ expression_type="roleUnpacking",
754
+ ast_chunk=action_definition
755
+ )
756
+ for unpacked_role_name in all_unpacked_role_names:
757
+ for role_definition in action_definition['roles'].values():
758
+ if role_definition['name'] == unpacked_role_name:
759
+ if role_definition['min'] == role_definition['max'] == 1:
760
+ raise ValueError(
761
+ f"Action '{action_definition['name']}' unpacks a singleton role (one that is "
762
+ f"always bound to a single entity): '{unpacked_role_name}'"
763
+ )
764
+ break
765
+ # Ensure that all other references to roles that can cast multiple entities use role unpackings
766
+ single_entity_role_references = viv_compiler.utils.get_all_referenced_roles( # I.e., using the '@role' notation
767
+ ast_chunk=action_definition,
768
+ ignore_role_unpackings=True
769
+ )
770
+ for referenced_role_name in single_entity_role_references:
771
+ for role_definition in action_definition['roles'].values():
772
+ if role_definition['name'] == referenced_role_name:
773
+ if not (role_definition['min'] == role_definition['max'] == 1):
774
+ raise ValueError(
775
+ f"Action '{action_definition['name']}' fails to unpack group "
776
+ f"role: '{referenced_role_name}' (use * instead of @)"
777
+ )
778
+ break
779
+
780
+
781
+ def _validate_action_loops(action_definition: viv_compiler.types.ActionDefinition) -> None:
782
+ """Validate the given action definition's usage of loops.
783
+
784
+ Note: the grammar already enforces that loop bodies may not be empty.
785
+
786
+ Args:
787
+ action_definition: An action definition from a compiled content bundle.
788
+
789
+ Returns:
790
+ None.
791
+
792
+ Raises:
793
+ Exception: The action definition did not pass validation.
794
+ """
795
+ all_loops = viv_compiler.utils.get_all_expressions_of_type(expression_type="loop", ast_chunk=action_definition)
796
+ for loop in all_loops:
797
+ # Detect attempts to loop over single-entity role references (i.e., ones using '@role' notation)
798
+ if loop['iterable']['type'] == ExpressionDiscriminator.ENTITY_REFERENCE:
799
+ if not loop['iterable']['value']['path']:
800
+ role_name = loop['iterable']['value']['anchor']
801
+ raise ValueError(
802
+ f"Action '{action_definition['name']}' attempts to loop over a non-unpacked "
803
+ f"role: '{role_name}' (perhaps use * instead of @)"
804
+ )
805
+ # Detect cases of a loop variable shadowing the name of a role from the same action
806
+ if loop['variable'] in viv_compiler.utils.get_all_role_names(action_definition=action_definition):
807
+ raise ValueError(
808
+ f"Action '{action_definition['name']}' has loop with variable name that "
809
+ f"shadows role name '{loop['variable']}' (this is not allowed)"
810
+ )
811
+
812
+
813
+ def _validate_action_assignments(action_definition: viv_compiler.types.ActionDefinition) -> None:
814
+ """Validate the given action definition's usage of assignments.
815
+
816
+ Args:
817
+ action_definition: An action definition from a compiled content bundle.
818
+
819
+ Returns:
820
+ None.
821
+
822
+ Raises:
823
+ Exception: The action definition did not pass validation.
824
+ """
825
+ all_assignments = viv_compiler.utils.get_all_expressions_of_type(
826
+ expression_type="assignment",
827
+ ast_chunk=action_definition
828
+ )
829
+ for assignment in all_assignments:
830
+ anchor = assignment['left']['value']['anchor']
831
+ path = assignment['left']['value']['path']
832
+ if assignment['left']['type'] == ExpressionDiscriminator.ENTITY_REFERENCE:
833
+ # Detect attempts to recast a role via assignment (not allowed)
834
+ if not path:
835
+ raise ValueError(
836
+ f"Assignment expression in action '{action_definition['name']}' recasts "
837
+ f"role '{anchor}' (this is prohibited)"
838
+ )
839
+ # Detect attempts to set data on the "entity" associated with a symbol role
840
+ if anchor != "this":
841
+ if action_definition['roles'][anchor]['symbol']:
842
+ raise ValueError(
843
+ f"Assignment expression in action '{action_definition['name']}' has "
844
+ f"symbol role on its left-hand side: '{anchor}'"
845
+ )
846
+ # Detect a trailing eval fail-safe marker, which is bizarre and probably an authoring error
847
+ if path and path[-1].get('failSafe'):
848
+ raise ValueError(
849
+ f"LHS of assignment expression in action '{action_definition['name']}' has "
850
+ f"a trailing eval fail-safe marker '?' (only allowed within, and not following, the LHS)"
851
+ )
852
+
853
+
854
+ def _validate_chance_expressions(action_definition: viv_compiler.types.ActionDefinition) -> None:
855
+ """Validate the given action definition's usage of chance expressions.
856
+
857
+ Args:
858
+ action_definition: An action definition from a compiled content bundle.
859
+
860
+ Returns:
861
+ None.
862
+
863
+ Raises:
864
+ Exception: The action definition did not pass validation.
865
+ """
866
+ all_chance_expression_values = viv_compiler.utils.get_all_expressions_of_type(
867
+ expression_type="chanceExpression",
868
+ ast_chunk=action_definition
869
+ )
870
+ for chance_value in all_chance_expression_values:
871
+ if chance_value < 0.0 or chance_value > 1.0:
872
+ raise ValueError(
873
+ f"Chance expression in action '{action_definition['name']}' has "
874
+ f"chance value outside of the range [0, 1]: '{chance_value * 100}%'"
875
+ )
876
+
877
+
878
+ def _validate_negated_expressions(action_definition: viv_compiler.types.ActionDefinition) -> None:
879
+ """Validate the given action definition's usage of chance expressions.
880
+
881
+ Args:
882
+ action_definition: An action definition from a compiled content bundle.
883
+
884
+ Returns:
885
+ None.
886
+
887
+ Raises:
888
+ Exception: The action definition did not pass validation.
889
+ """
890
+ for negated_expression in viv_compiler.utils.get_all_negated_expressions(ast_chunk=action_definition):
891
+ if negated_expression['type'] not in viv_compiler.config.NEGATABLE_EXPRESSION_TYPES:
892
+ raise ValueError(
893
+ f"Expression of type '{negated_expression['type']}' in action '{action_definition['name']}' is "
894
+ f" negated, but this is not allowed (only the following expression types support negation: "
895
+ f"{', '.join(viv_compiler.config.NEGATABLE_EXPRESSION_TYPES)}):\n\n{negated_expression}"
896
+ )
897
+
898
+
899
+ def _validate_compiled_content_bundle_against_schema(content_bundle: viv_compiler.types.CompiledContentBundle) -> None:
900
+ """Validate a compiled content bundle against its public schema.
901
+
902
+ Uses Pydantic v2's TypeAdapter to validate the *shape* of the compiled bundle produced by the compiler
903
+ against the public schema. This catches structural drift between the compiler output and runtime contracts.
904
+
905
+ Args:
906
+ content_bundle: The compiled content bundle to validate.
907
+
908
+ Raises:
909
+ pydantic.ValidationError: If validation fails, detailing schema mismatches.
910
+ """
911
+ content_schema = import_module("viv_compiler.types.content_public_schemas")
912
+ dsl_schema = import_module("viv_compiler.types.dsl_public_schemas")
913
+ type_adapter = TypeAdapter(content_schema.CompiledContentBundle)
914
+ type_adapter.rebuild(_types_namespace={**content_schema.__dict__, **dsl_schema.__dict__})
915
+ type_adapter.validate_python(content_bundle)