flakepl 0.3.1__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.
flake/epistemic.py ADDED
@@ -0,0 +1,2068 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable, Iterable, Mapping
5
+
6
+ from .agent import Agent
7
+ from .dynamic import (
8
+ ActionModel,
9
+ DynamicEvent,
10
+ PublicAnnouncement,
11
+ )
12
+ from .explorer import StateSpace
13
+ from .formula import (
14
+ And,
15
+ Atom,
16
+ Formula,
17
+ Not,
18
+ Or,
19
+ )
20
+ from .observation import (
21
+ Observation,
22
+ ObservationBuilder,
23
+ )
24
+ from .propositions import Proposition
25
+ from .runtime import Configuration
26
+ from .temporal import (
27
+ Always,
28
+ Eventually,
29
+ Next,
30
+ Until,
31
+ )
32
+
33
+
34
+ # ============================================================================
35
+ # World types
36
+ # ============================================================================
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ProductWorld:
41
+ """
42
+ World generated by a dynamic epistemic product update.
43
+
44
+ A ProductWorld is a pair:
45
+
46
+ (base world, executed event)
47
+
48
+ The base Configuration represents the physical world.
49
+ The event represents the epistemic action that occurred.
50
+ """
51
+
52
+ world: Configuration
53
+ event: str
54
+
55
+ def __repr__(self) -> str:
56
+ return (
57
+ f"ProductWorld("
58
+ f"world={self.world!r}, "
59
+ f"event={self.event!r}"
60
+ f")"
61
+ )
62
+
63
+
64
+ World = Configuration | ProductWorld
65
+
66
+
67
+ def _configuration(
68
+ world: World,
69
+ ) -> Configuration:
70
+ """
71
+ Extract the underlying physical configuration.
72
+ """
73
+
74
+ if isinstance(
75
+ world,
76
+ ProductWorld,
77
+ ):
78
+ return world.world
79
+
80
+ return world
81
+
82
+
83
+ # ============================================================================
84
+ # Epistemic operators
85
+ # ============================================================================
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class Knowledge(Formula):
90
+ """
91
+ K_i φ
92
+
93
+ Agent i knows φ.
94
+ """
95
+
96
+ agent: str
97
+ operand: Formula
98
+
99
+ def __post_init__(self) -> None:
100
+ if not self.agent.strip():
101
+ raise ValueError(
102
+ "Knowledge agent must not be empty"
103
+ )
104
+
105
+ def __repr__(self) -> str:
106
+ return (
107
+ f"K_{self.agent}({self.operand!r})"
108
+ )
109
+
110
+
111
+ def K(
112
+ agent: str | Agent,
113
+ operand: Formula,
114
+ ) -> Knowledge:
115
+ """
116
+ Factory for K_i φ.
117
+ """
118
+
119
+ name = (
120
+ agent.name
121
+ if isinstance(
122
+ agent,
123
+ Agent,
124
+ )
125
+ else agent
126
+ )
127
+
128
+ return Knowledge(
129
+ agent=name,
130
+ operand=operand,
131
+ )
132
+
133
+
134
+ # ============================================================================
135
+ # Group operators
136
+ # ============================================================================
137
+
138
+
139
+ def _normalize_group(
140
+ agents: (
141
+ str
142
+ | Agent
143
+ | Iterable[str | Agent]
144
+ ),
145
+ ) -> tuple[str, ...]:
146
+ if isinstance(
147
+ agents,
148
+ Agent,
149
+ ):
150
+ values = (
151
+ agents.name,
152
+ )
153
+
154
+ elif isinstance(
155
+ agents,
156
+ str,
157
+ ):
158
+ values = (
159
+ agents,
160
+ )
161
+
162
+ else:
163
+ values = tuple(
164
+ agent.name
165
+ if isinstance(
166
+ agent,
167
+ Agent,
168
+ )
169
+ else agent
170
+ for agent in agents
171
+ )
172
+
173
+ if not values:
174
+ raise ValueError(
175
+ "An epistemic group must not be empty"
176
+ )
177
+
178
+ result: list[str] = []
179
+
180
+ for name in values:
181
+ if not isinstance(
182
+ name,
183
+ str,
184
+ ):
185
+ raise TypeError(
186
+ "Agent names must be strings or Agent objects"
187
+ )
188
+
189
+ name = name.strip()
190
+
191
+ if not name:
192
+ raise ValueError(
193
+ "Agent name must not be empty"
194
+ )
195
+
196
+ if name not in result:
197
+ result.append(
198
+ name
199
+ )
200
+
201
+ return tuple(result)
202
+
203
+
204
+ @dataclass(frozen=True, slots=True)
205
+ class EveryoneKnows(Formula):
206
+ """
207
+ E_A φ
208
+
209
+ Every agent in A knows φ.
210
+ """
211
+
212
+ agents: tuple[str, ...]
213
+ operand: Formula
214
+
215
+ def __repr__(self) -> str:
216
+ group = ",".join(
217
+ self.agents
218
+ )
219
+
220
+ return (
221
+ f"E_{{{group}}}({self.operand!r})"
222
+ )
223
+
224
+
225
+ def E(
226
+ agents: (
227
+ str
228
+ | Agent
229
+ | Iterable[str | Agent]
230
+ ),
231
+ operand: Formula,
232
+ ) -> EveryoneKnows:
233
+ return EveryoneKnows(
234
+ agents=_normalize_group(
235
+ agents
236
+ ),
237
+ operand=operand,
238
+ )
239
+
240
+
241
+ @dataclass(frozen=True, slots=True)
242
+ class DistributedKnowledge(Formula):
243
+ """
244
+ D_A φ
245
+
246
+ Distributed knowledge of a group.
247
+ """
248
+
249
+ agents: tuple[str, ...]
250
+ operand: Formula
251
+
252
+ def __repr__(self) -> str:
253
+ group = ",".join(
254
+ self.agents
255
+ )
256
+
257
+ return (
258
+ f"D_{{{group}}}({self.operand!r})"
259
+ )
260
+
261
+
262
+ def D(
263
+ agents: (
264
+ str
265
+ | Agent
266
+ | Iterable[str | Agent]
267
+ ),
268
+ operand: Formula,
269
+ ) -> DistributedKnowledge:
270
+ return DistributedKnowledge(
271
+ agents=_normalize_group(
272
+ agents
273
+ ),
274
+ operand=operand,
275
+ )
276
+
277
+
278
+ @dataclass(frozen=True, slots=True)
279
+ class CommonKnowledge(Formula):
280
+ """
281
+ C_A φ
282
+
283
+ Common knowledge in a group.
284
+ """
285
+
286
+ agents: tuple[str, ...]
287
+ operand: Formula
288
+
289
+ def __repr__(self) -> str:
290
+ group = ",".join(
291
+ self.agents
292
+ )
293
+
294
+ return (
295
+ f"C_{{{group}}}({self.operand!r})"
296
+ )
297
+
298
+
299
+ def C(
300
+ agents: (
301
+ str
302
+ | Agent
303
+ | Iterable[str | Agent]
304
+ ),
305
+ operand: Formula,
306
+ ) -> CommonKnowledge:
307
+ return CommonKnowledge(
308
+ agents=_normalize_group(
309
+ agents
310
+ ),
311
+ operand=operand,
312
+ )
313
+
314
+
315
+ # ============================================================================
316
+ # Observation mapping
317
+ # ============================================================================
318
+
319
+
320
+ ObservationMap = Mapping[
321
+ str | Agent,
322
+ Observation,
323
+ ]
324
+
325
+
326
+ def _normalize_observations(
327
+ observations: (
328
+ ObservationMap
329
+ | ObservationBuilder
330
+ ),
331
+ agent_names: Iterable[str],
332
+ ) -> dict[
333
+ str,
334
+ Observation,
335
+ ]:
336
+ names = tuple(
337
+ agent_names
338
+ )
339
+
340
+ if isinstance(
341
+ observations,
342
+ ObservationBuilder,
343
+ ):
344
+ return observations.build(
345
+ names
346
+ )
347
+
348
+ result: dict[
349
+ str,
350
+ Observation,
351
+ ] = {}
352
+
353
+ for key, observation in observations.items():
354
+ name = (
355
+ key.name
356
+ if isinstance(
357
+ key,
358
+ Agent,
359
+ )
360
+ else key
361
+ )
362
+
363
+ if not isinstance(
364
+ name,
365
+ str,
366
+ ):
367
+ raise TypeError(
368
+ "Observation keys must be strings or Agent objects"
369
+ )
370
+
371
+ name = name.strip()
372
+
373
+ if not name:
374
+ raise ValueError(
375
+ "Observation agent name must not be empty"
376
+ )
377
+
378
+ if name in result:
379
+ raise ValueError(
380
+ f"Duplicate observation "
381
+ f"for agent {name!r}"
382
+ )
383
+
384
+ result[name] = observation
385
+
386
+ expected = set(
387
+ names
388
+ )
389
+ actual = set(
390
+ result
391
+ )
392
+
393
+ missing = expected - actual
394
+ extra = actual - expected
395
+
396
+ if missing:
397
+ missing_text = ", ".join(
398
+ sorted(missing)
399
+ )
400
+
401
+ raise ValueError(
402
+ f"Missing observations for agents: "
403
+ f"{missing_text}"
404
+ )
405
+
406
+ if extra:
407
+ extra_text = ", ".join(
408
+ sorted(extra)
409
+ )
410
+
411
+ raise ValueError(
412
+ f"Observations defined for unknown agents: "
413
+ f"{extra_text}"
414
+ )
415
+
416
+ return result
417
+
418
+
419
+ # ============================================================================
420
+ # Epistemic relation overrides
421
+ # ============================================================================
422
+
423
+
424
+ RelationMap = Mapping[
425
+ str,
426
+ Mapping[
427
+ World,
428
+ Iterable[World],
429
+ ],
430
+ ]
431
+
432
+
433
+ def _normalize_relation_overrides(
434
+ relations: RelationMap | None,
435
+ worlds: tuple[World, ...],
436
+ agent_names: tuple[str, ...],
437
+ ) -> dict[
438
+ str,
439
+ dict[
440
+ World,
441
+ tuple[World, ...],
442
+ ],
443
+ ]:
444
+ if relations is None:
445
+ return {}
446
+
447
+ world_set = set(
448
+ worlds
449
+ )
450
+
451
+ agent_set = set(
452
+ agent_names
453
+ )
454
+
455
+ result: dict[
456
+ str,
457
+ dict[
458
+ World,
459
+ tuple[World, ...],
460
+ ],
461
+ ] = {}
462
+
463
+ for agent, relation in relations.items():
464
+ if agent not in agent_set:
465
+ raise ValueError(
466
+ f"Unknown agent {agent!r} "
467
+ f"in relation override"
468
+ )
469
+
470
+ source_map: dict[
471
+ World,
472
+ tuple[World, ...],
473
+ ] = {}
474
+
475
+ for source, targets in relation.items():
476
+ if source not in world_set:
477
+ raise ValueError(
478
+ "Relation override contains "
479
+ "an unknown source world"
480
+ )
481
+
482
+ target_tuple = tuple(
483
+ targets
484
+ )
485
+
486
+ for target in target_tuple:
487
+ if target not in world_set:
488
+ raise ValueError(
489
+ "Relation override contains "
490
+ "an unknown target world"
491
+ )
492
+
493
+ source_map[source] = target_tuple
494
+
495
+ result[agent] = source_map
496
+
497
+ return result
498
+
499
+
500
+ # ============================================================================
501
+ # Epistemic model
502
+ # ============================================================================
503
+
504
+
505
+ class EpistemicModel:
506
+ """
507
+ Finite epistemic model.
508
+
509
+ Standard models derive R_i from observations:
510
+
511
+ w R_i v iff h_i(w) = h_i(v)
512
+
513
+ Dynamic epistemic product updates can install explicit
514
+ relation overrides:
515
+
516
+ (w,e) R_i' (v,f)
517
+
518
+ iff
519
+
520
+ w R_i v
521
+
522
+ and
523
+
524
+ e R_i^A f
525
+
526
+ Temporal operators require a transition structure when
527
+ one is available.
528
+
529
+ If no StateSpace is attached, the model is treated as a
530
+ static model with implicit stuttering:
531
+
532
+ w -> w
533
+
534
+ This makes temporal queries such as G(phi) meaningful for
535
+ a purely epistemic snapshot.
536
+ """
537
+
538
+ def __init__(
539
+ self,
540
+ worlds: Iterable[World],
541
+ observations: (
542
+ ObservationMap
543
+ | ObservationBuilder
544
+ ),
545
+ actual: World,
546
+ propositions: (
547
+ Iterable[Proposition]
548
+ | Mapping[str, Proposition]
549
+ | None
550
+ ) = None,
551
+ state_space: StateSpace | None = None,
552
+ relations: RelationMap | None = None,
553
+ ) -> None:
554
+ world_tuple = tuple(
555
+ worlds
556
+ )
557
+
558
+ if not world_tuple:
559
+ raise ValueError(
560
+ "EpistemicModel requires at least one world"
561
+ )
562
+
563
+ if actual not in world_tuple:
564
+ raise ValueError(
565
+ "Actual world must belong to worlds"
566
+ )
567
+
568
+ if (
569
+ state_space is not None
570
+ and any(
571
+ not isinstance(
572
+ world,
573
+ Configuration,
574
+ )
575
+ for world in world_tuple
576
+ )
577
+ ):
578
+ raise ValueError(
579
+ "StateSpace can only be attached "
580
+ "to Configuration worlds"
581
+ )
582
+
583
+ reference_configuration = (
584
+ _configuration(
585
+ world_tuple[0]
586
+ )
587
+ )
588
+
589
+ reference_agents = tuple(
590
+ reference_configuration.agents
591
+ )
592
+
593
+ reference_names = tuple(
594
+ agent.name
595
+ for agent in reference_agents
596
+ )
597
+
598
+ reference_name_set = set(
599
+ reference_names
600
+ )
601
+
602
+ for world in world_tuple:
603
+ current_names = {
604
+ agent.name
605
+ for agent in _configuration(
606
+ world
607
+ ).agents
608
+ }
609
+
610
+ if current_names != reference_name_set:
611
+ raise ValueError(
612
+ "All epistemic worlds must contain "
613
+ "the same agents"
614
+ )
615
+
616
+ if state_space is not None:
617
+ state_space_states = set(
618
+ state_space.states
619
+ )
620
+
621
+ if not set(
622
+ world_tuple
623
+ ).issubset(
624
+ state_space_states
625
+ ):
626
+ raise ValueError(
627
+ "All epistemic worlds must belong "
628
+ "to the attached StateSpace"
629
+ )
630
+
631
+ self._worlds = world_tuple
632
+ self._actual = actual
633
+
634
+ self._agents = reference_agents
635
+ self._agent_names = reference_names
636
+
637
+ self._observations = (
638
+ _normalize_observations(
639
+ observations,
640
+ self._agent_names,
641
+ )
642
+ )
643
+
644
+ self._propositions = (
645
+ self._normalize_propositions(
646
+ propositions
647
+ )
648
+ )
649
+
650
+ self._state_space = state_space
651
+
652
+ self._relation_overrides = (
653
+ _normalize_relation_overrides(
654
+ relations,
655
+ world_tuple,
656
+ self._agent_names,
657
+ )
658
+ )
659
+
660
+ self._successors_map = (
661
+ self._build_successors_map(
662
+ state_space
663
+ )
664
+ if state_space is not None
665
+ else {}
666
+ )
667
+
668
+ # ---------------------------------------------------------------------
669
+ # Construction
670
+ # ---------------------------------------------------------------------
671
+
672
+ @classmethod
673
+ def from_state_space(
674
+ cls,
675
+ state_space: StateSpace,
676
+ observations: (
677
+ ObservationMap
678
+ | ObservationBuilder
679
+ ),
680
+ actual: Configuration | None = None,
681
+ propositions: (
682
+ Iterable[Proposition]
683
+ | Mapping[str, Proposition]
684
+ | None
685
+ ) = None,
686
+ ) -> "EpistemicModel":
687
+ if not state_space.initials:
688
+ raise ValueError(
689
+ "StateSpace must contain at least one initial"
690
+ )
691
+
692
+ if actual is None:
693
+ if len(
694
+ state_space.initials
695
+ ) != 1:
696
+ raise ValueError(
697
+ "actual must be specified when the state "
698
+ "space has multiple initial worlds"
699
+ )
700
+
701
+ actual = state_space.initials[0]
702
+
703
+ return cls(
704
+ worlds=state_space.states,
705
+ observations=observations,
706
+ actual=actual,
707
+ propositions=propositions,
708
+ state_space=state_space,
709
+ )
710
+
711
+ # ---------------------------------------------------------------------
712
+ # Public properties
713
+ # ---------------------------------------------------------------------
714
+
715
+ @property
716
+ def worlds(
717
+ self,
718
+ ) -> tuple[World, ...]:
719
+ return self._worlds
720
+
721
+ @property
722
+ def actual(
723
+ self,
724
+ ) -> World:
725
+ return self._actual
726
+
727
+ @property
728
+ def agents(
729
+ self,
730
+ ) -> tuple[Agent, ...]:
731
+ return self._agents
732
+
733
+ @property
734
+ def propositions(
735
+ self,
736
+ ) -> Mapping[str, Proposition]:
737
+ return self._propositions
738
+
739
+ @property
740
+ def state_space(
741
+ self,
742
+ ) -> StateSpace | None:
743
+ return self._state_space
744
+
745
+ # ---------------------------------------------------------------------
746
+ # Agent helpers
747
+ # ---------------------------------------------------------------------
748
+
749
+ def _require_agent(
750
+ self,
751
+ agent: str | Agent,
752
+ ) -> str:
753
+ name = (
754
+ agent.name
755
+ if isinstance(
756
+ agent,
757
+ Agent,
758
+ )
759
+ else agent
760
+ )
761
+
762
+ if name not in self._agent_names:
763
+ raise ValueError(
764
+ f"Unknown agent {name!r}"
765
+ )
766
+
767
+ return name
768
+
769
+ # ---------------------------------------------------------------------
770
+ # Observation
771
+ # ---------------------------------------------------------------------
772
+
773
+ def observe(
774
+ self,
775
+ agent: str | Agent,
776
+ world: World,
777
+ ):
778
+ name = self._require_agent(
779
+ agent
780
+ )
781
+
782
+ if world not in self._worlds:
783
+ raise ValueError(
784
+ "World does not belong "
785
+ "to the epistemic model"
786
+ )
787
+
788
+ runtime_agent = (
789
+ _configuration(
790
+ world
791
+ ).agent(
792
+ name
793
+ )
794
+ )
795
+
796
+ value = self._observations[name](
797
+ runtime_agent,
798
+ _configuration(
799
+ world
800
+ ),
801
+ )
802
+
803
+ hash(value)
804
+
805
+ return value
806
+
807
+ # ---------------------------------------------------------------------
808
+ # Accessibility
809
+ # ---------------------------------------------------------------------
810
+
811
+ def accessible(
812
+ self,
813
+ agent: str | Agent,
814
+ world: World,
815
+ ) -> tuple[World, ...]:
816
+ """
817
+ Return worlds accessible for an agent.
818
+
819
+ Ordinary models use observation equality.
820
+
821
+ Product-update models use explicit product relations.
822
+ """
823
+
824
+ name = self._require_agent(
825
+ agent
826
+ )
827
+
828
+ if world not in self._worlds:
829
+ raise ValueError(
830
+ "World does not belong "
831
+ "to the epistemic model"
832
+ )
833
+
834
+ override = (
835
+ self._relation_overrides.get(
836
+ name
837
+ )
838
+ )
839
+
840
+ if override is not None:
841
+ return override.get(
842
+ world,
843
+ (),
844
+ )
845
+
846
+ target_observation = (
847
+ self.observe(
848
+ name,
849
+ world,
850
+ )
851
+ )
852
+
853
+ return tuple(
854
+ candidate
855
+ for candidate in self._worlds
856
+ if self.observe(
857
+ name,
858
+ candidate,
859
+ )
860
+ == target_observation
861
+ )
862
+
863
+ # ---------------------------------------------------------------------
864
+ # Distributed knowledge
865
+ # ---------------------------------------------------------------------
866
+
867
+ def distributed_accessible(
868
+ self,
869
+ agents: (
870
+ str
871
+ | Agent
872
+ | Iterable[str | Agent]
873
+ ),
874
+ world: World,
875
+ ) -> tuple[World, ...]:
876
+ """
877
+ Return the intersection of individual epistemic
878
+ accessibility relations:
879
+
880
+ R_DA = ⋂ R_i
881
+
882
+ This uses accessible() so product-update relations
883
+ remain fully respected.
884
+ """
885
+
886
+ group = _normalize_group(
887
+ agents
888
+ )
889
+
890
+ for agent in group:
891
+ self._require_agent(
892
+ agent
893
+ )
894
+
895
+ if world not in self._worlds:
896
+ raise ValueError(
897
+ "World does not belong "
898
+ "to the epistemic model"
899
+ )
900
+
901
+ accessible_sets = [
902
+ set(
903
+ self.accessible(
904
+ agent,
905
+ world,
906
+ )
907
+ )
908
+ for agent in group
909
+ ]
910
+
911
+ if not accessible_sets:
912
+ return ()
913
+
914
+ intersection = accessible_sets[0]
915
+
916
+ for accessible_set in accessible_sets[1:]:
917
+ intersection &= accessible_set
918
+
919
+ return tuple(
920
+ candidate
921
+ for candidate in self._worlds
922
+ if candidate in intersection
923
+ )
924
+
925
+ # ---------------------------------------------------------------------
926
+ # Common knowledge
927
+ # ---------------------------------------------------------------------
928
+
929
+ def common_knowledge_closure(
930
+ self,
931
+ agents: (
932
+ str
933
+ | Agent
934
+ | Iterable[str | Agent]
935
+ ),
936
+ world: World,
937
+ ) -> tuple[World, ...]:
938
+ """
939
+ Reflexive-transitive closure:
940
+
941
+ (⋃ R_i)*
942
+ """
943
+
944
+ group = _normalize_group(
945
+ agents
946
+ )
947
+
948
+ for agent in group:
949
+ self._require_agent(
950
+ agent
951
+ )
952
+
953
+ if world not in self._worlds:
954
+ raise ValueError(
955
+ "World does not belong "
956
+ "to the epistemic model"
957
+ )
958
+
959
+ visited: set[World] = {
960
+ world
961
+ }
962
+
963
+ queue: list[World] = [
964
+ world
965
+ ]
966
+
967
+ while queue:
968
+ current = queue.pop(
969
+ 0
970
+ )
971
+
972
+ for agent in group:
973
+ for candidate in self.accessible(
974
+ agent,
975
+ current,
976
+ ):
977
+ if candidate not in visited:
978
+ visited.add(
979
+ candidate
980
+ )
981
+ queue.append(
982
+ candidate
983
+ )
984
+
985
+ return tuple(
986
+ candidate
987
+ for candidate in self._worlds
988
+ if candidate in visited
989
+ )
990
+
991
+ # ---------------------------------------------------------------------
992
+ # Dynamic update API
993
+ # ---------------------------------------------------------------------
994
+
995
+ def update(
996
+ self,
997
+ event: DynamicEvent,
998
+ ) -> "EpistemicModel":
999
+ """
1000
+ Apply a dynamic epistemic event.
1001
+
1002
+ The current model is never mutated.
1003
+ """
1004
+
1005
+ return event.apply(
1006
+ self
1007
+ )
1008
+
1009
+ def announce(
1010
+ self,
1011
+ formula: Formula,
1012
+ ) -> "EpistemicModel":
1013
+ """
1014
+ Apply a public announcement.
1015
+ """
1016
+
1017
+ return self.update(
1018
+ PublicAnnouncement(
1019
+ formula
1020
+ )
1021
+ )
1022
+
1023
+ def product_update(
1024
+ self,
1025
+ action_model: ActionModel,
1026
+ ) -> "EpistemicModel":
1027
+ """
1028
+ Apply an epistemic ActionModel.
1029
+ """
1030
+
1031
+ return self._apply_action_model(
1032
+ action_model
1033
+ )
1034
+
1035
+ # ---------------------------------------------------------------------
1036
+ # Product update
1037
+ # ---------------------------------------------------------------------
1038
+
1039
+ def _apply_action_model(
1040
+ self,
1041
+ action_model: ActionModel,
1042
+ ) -> "EpistemicModel":
1043
+ """
1044
+ Construct the product model:
1045
+
1046
+ M ⊗ A
1047
+
1048
+ with worlds:
1049
+
1050
+ (w, e)
1051
+
1052
+ for every executable world/event pair.
1053
+
1054
+ Product accessibility is:
1055
+
1056
+ (w,e) R_i' (v,f)
1057
+
1058
+ iff
1059
+
1060
+ w R_i v
1061
+
1062
+ and
1063
+
1064
+ e R_i^A f
1065
+ """
1066
+
1067
+ enabled_pairs: list[
1068
+ tuple[World, str]
1069
+ ] = []
1070
+
1071
+ enabled_pair_set: set[
1072
+ tuple[World, str]
1073
+ ] = set()
1074
+
1075
+ for world in self._worlds:
1076
+ for action_event in action_model.events:
1077
+ if action_event.enabled(
1078
+ self,
1079
+ world,
1080
+ ):
1081
+ pair = (
1082
+ world,
1083
+ action_event.name,
1084
+ )
1085
+
1086
+ enabled_pairs.append(
1087
+ pair
1088
+ )
1089
+
1090
+ enabled_pair_set.add(
1091
+ pair
1092
+ )
1093
+
1094
+ if not enabled_pairs:
1095
+ raise ValueError(
1096
+ "ActionModel has no executable "
1097
+ "world/event pairs"
1098
+ )
1099
+
1100
+ actual_pair = (
1101
+ self._actual,
1102
+ action_model.actual,
1103
+ )
1104
+
1105
+ if actual_pair not in enabled_pair_set:
1106
+ raise ValueError(
1107
+ "Actual action event is not executable "
1108
+ "in the actual world"
1109
+ )
1110
+
1111
+ product_worlds = tuple(
1112
+ ProductWorld(
1113
+ world=world,
1114
+ event=event_name,
1115
+ )
1116
+ for world, event_name
1117
+ in enabled_pairs
1118
+ )
1119
+
1120
+ pair_to_product: dict[
1121
+ tuple[World, str],
1122
+ ProductWorld,
1123
+ ] = {
1124
+ (
1125
+ product.world,
1126
+ product.event,
1127
+ ): product
1128
+ for product in product_worlds
1129
+ }
1130
+
1131
+ actual_product = pair_to_product[
1132
+ actual_pair
1133
+ ]
1134
+
1135
+ relation_overrides: dict[
1136
+ str,
1137
+ dict[
1138
+ ProductWorld,
1139
+ tuple[
1140
+ ProductWorld,
1141
+ ...,
1142
+ ],
1143
+ ],
1144
+ ] = {}
1145
+
1146
+ for agent in self._agent_names:
1147
+ source_map: dict[
1148
+ ProductWorld,
1149
+ tuple[
1150
+ ProductWorld,
1151
+ ...,
1152
+ ],
1153
+ ] = {}
1154
+
1155
+ for product_world in product_worlds:
1156
+ base_world = (
1157
+ product_world.world
1158
+ )
1159
+
1160
+ event_name = (
1161
+ product_world.event
1162
+ )
1163
+
1164
+ base_accessible = (
1165
+ self.accessible(
1166
+ agent,
1167
+ base_world,
1168
+ )
1169
+ )
1170
+
1171
+ event_accessible = (
1172
+ action_model.accessible(
1173
+ agent,
1174
+ event_name,
1175
+ )
1176
+ )
1177
+
1178
+ targets: list[
1179
+ ProductWorld
1180
+ ] = []
1181
+
1182
+ for candidate_base in base_accessible:
1183
+ for candidate_event in event_accessible:
1184
+ candidate_pair = (
1185
+ candidate_base,
1186
+ candidate_event,
1187
+ )
1188
+
1189
+ if (
1190
+ candidate_pair
1191
+ not in enabled_pair_set
1192
+ ):
1193
+ continue
1194
+
1195
+ candidate_product = (
1196
+ pair_to_product[
1197
+ candidate_pair
1198
+ ]
1199
+ )
1200
+
1201
+ if (
1202
+ candidate_product
1203
+ not in targets
1204
+ ):
1205
+ targets.append(
1206
+ candidate_product
1207
+ )
1208
+
1209
+ source_map[
1210
+ product_world
1211
+ ] = tuple(
1212
+ targets
1213
+ )
1214
+
1215
+ relation_overrides[
1216
+ agent
1217
+ ] = source_map
1218
+
1219
+ return EpistemicModel(
1220
+ worlds=product_worlds,
1221
+ observations=self._observations,
1222
+ actual=actual_product,
1223
+ propositions=self._propositions,
1224
+ state_space=None,
1225
+ relations=relation_overrides,
1226
+ )
1227
+
1228
+ # ---------------------------------------------------------------------
1229
+ # Public announcement
1230
+ # ---------------------------------------------------------------------
1231
+
1232
+ def _apply_public_announcement(
1233
+ self,
1234
+ formula: Formula,
1235
+ ) -> "EpistemicModel":
1236
+ """
1237
+ Restrict the current model to worlds where formula holds.
1238
+
1239
+ M | φ
1240
+ """
1241
+
1242
+ retained_worlds = tuple(
1243
+ world
1244
+ for world in self._worlds
1245
+ if self.check(
1246
+ formula,
1247
+ at=world,
1248
+ )
1249
+ )
1250
+
1251
+ if self._actual not in retained_worlds:
1252
+ raise ValueError(
1253
+ "Public announcement is false "
1254
+ "in the actual world"
1255
+ )
1256
+
1257
+ if not retained_worlds:
1258
+ raise ValueError(
1259
+ "Public announcement removed all worlds"
1260
+ )
1261
+
1262
+ updated_state_space = (
1263
+ self._restrict_state_space(
1264
+ retained_worlds
1265
+ )
1266
+ if self._state_space is not None
1267
+ else None
1268
+ )
1269
+
1270
+ updated_relations = (
1271
+ self._restrict_relations(
1272
+ retained_worlds
1273
+ )
1274
+ if self._relation_overrides
1275
+ else None
1276
+ )
1277
+
1278
+ return EpistemicModel(
1279
+ worlds=retained_worlds,
1280
+ observations=self._observations,
1281
+ actual=self._actual,
1282
+ propositions=self._propositions,
1283
+ state_space=updated_state_space,
1284
+ relations=updated_relations,
1285
+ )
1286
+
1287
+ def _restrict_state_space(
1288
+ self,
1289
+ retained_worlds: tuple[World, ...],
1290
+ ) -> StateSpace:
1291
+ """
1292
+ Restrict attached StateSpace to retained worlds.
1293
+ """
1294
+
1295
+ if self._state_space is None:
1296
+ raise ValueError(
1297
+ "Cannot restrict a missing StateSpace"
1298
+ )
1299
+
1300
+ retained = set(
1301
+ retained_worlds
1302
+ )
1303
+
1304
+ initials = tuple(
1305
+ initial
1306
+ for initial in self._state_space.initials
1307
+ if initial in retained
1308
+ )
1309
+
1310
+ if not initials:
1311
+ raise ValueError(
1312
+ "Public announcement removed all "
1313
+ "initial worlds from the StateSpace"
1314
+ )
1315
+
1316
+ transitions = tuple(
1317
+ transition
1318
+ for transition
1319
+ in self._state_space.transitions
1320
+ if (
1321
+ transition.source in retained
1322
+ and transition.target in retained
1323
+ )
1324
+ )
1325
+
1326
+ return StateSpace(
1327
+ initials=initials,
1328
+ states=retained_worlds,
1329
+ transitions=transitions,
1330
+ )
1331
+
1332
+ def _restrict_relations(
1333
+ self,
1334
+ retained_worlds: tuple[World, ...],
1335
+ ) -> dict[
1336
+ str,
1337
+ dict[
1338
+ World,
1339
+ tuple[World, ...],
1340
+ ],
1341
+ ]:
1342
+ retained = set(
1343
+ retained_worlds
1344
+ )
1345
+
1346
+ result: dict[
1347
+ str,
1348
+ dict[
1349
+ World,
1350
+ tuple[World, ...],
1351
+ ],
1352
+ ] = {}
1353
+
1354
+ for agent, relation in (
1355
+ self._relation_overrides.items()
1356
+ ):
1357
+ filtered: dict[
1358
+ World,
1359
+ tuple[World, ...],
1360
+ ] = {}
1361
+
1362
+ for source, targets in (
1363
+ relation.items()
1364
+ ):
1365
+ if source not in retained:
1366
+ continue
1367
+
1368
+ filtered[
1369
+ source
1370
+ ] = tuple(
1371
+ target
1372
+ for target in targets
1373
+ if target in retained
1374
+ )
1375
+
1376
+ result[
1377
+ agent
1378
+ ] = filtered
1379
+
1380
+ return result
1381
+
1382
+ # ---------------------------------------------------------------------
1383
+ # State-space helpers
1384
+ # ---------------------------------------------------------------------
1385
+
1386
+ @staticmethod
1387
+ def _build_successors_map(
1388
+ state_space: StateSpace,
1389
+ ) -> dict[
1390
+ Configuration,
1391
+ tuple[Configuration, ...],
1392
+ ]:
1393
+ states = tuple(
1394
+ state_space.states
1395
+ )
1396
+
1397
+ state_set = set(
1398
+ states
1399
+ )
1400
+
1401
+ result_lists: dict[
1402
+ Configuration,
1403
+ list[Configuration],
1404
+ ] = {
1405
+ state: []
1406
+ for state in states
1407
+ }
1408
+
1409
+ for transition in (
1410
+ state_space.transitions
1411
+ ):
1412
+ source = transition.source
1413
+ target = transition.target
1414
+
1415
+ if source not in state_set:
1416
+ raise ValueError(
1417
+ "StateSpace transition source is not "
1418
+ "present in StateSpace.states"
1419
+ )
1420
+
1421
+ if target not in state_set:
1422
+ raise ValueError(
1423
+ "StateSpace transition target is not "
1424
+ "present in StateSpace.states"
1425
+ )
1426
+
1427
+ if target not in result_lists[
1428
+ source
1429
+ ]:
1430
+ result_lists[
1431
+ source
1432
+ ].append(
1433
+ target
1434
+ )
1435
+
1436
+ return {
1437
+ source: tuple(
1438
+ targets
1439
+ )
1440
+ for source, targets
1441
+ in result_lists.items()
1442
+ }
1443
+
1444
+ def successors(
1445
+ self,
1446
+ world: World,
1447
+ ) -> tuple[World, ...]:
1448
+ """
1449
+ Return temporal successors.
1450
+
1451
+ For a model with StateSpace, return actual transition
1452
+ successors.
1453
+
1454
+ For a static epistemic snapshot without StateSpace,
1455
+ use implicit stuttering:
1456
+
1457
+ w -> w
1458
+ """
1459
+
1460
+ if world not in self._worlds:
1461
+ raise ValueError(
1462
+ "World does not belong "
1463
+ "to the epistemic model"
1464
+ )
1465
+
1466
+ # Static epistemic model:
1467
+ #
1468
+ # w -> w -> w -> ...
1469
+ #
1470
+ # This allows temporal formulas such as G(phi)
1471
+ # to express state-invariant properties over a
1472
+ # snapshot model.
1473
+ if self._state_space is None:
1474
+ return (
1475
+ world,
1476
+ )
1477
+
1478
+ if not isinstance(
1479
+ world,
1480
+ Configuration,
1481
+ ):
1482
+ raise ValueError(
1483
+ "ProductWorld does not currently "
1484
+ "have StateSpace temporal successors"
1485
+ )
1486
+
1487
+ return self._successors_map[
1488
+ world
1489
+ ]
1490
+
1491
+ def _temporal_successors(
1492
+ self,
1493
+ world: World,
1494
+ ) -> tuple[World, ...]:
1495
+ successors = self.successors(
1496
+ world
1497
+ )
1498
+
1499
+ if successors:
1500
+ return successors
1501
+
1502
+ # Terminal state:
1503
+ #
1504
+ # w -> w
1505
+ #
1506
+ return (
1507
+ world,
1508
+ )
1509
+
1510
+ # ---------------------------------------------------------------------
1511
+ # Propositions
1512
+ # ---------------------------------------------------------------------
1513
+
1514
+ @staticmethod
1515
+ def _normalize_propositions(
1516
+ propositions: (
1517
+ Iterable[Proposition]
1518
+ | Mapping[
1519
+ str,
1520
+ Proposition
1521
+ | Callable[[Configuration], bool],
1522
+ ]
1523
+ | None
1524
+ ),
1525
+ ) -> dict[
1526
+ str,
1527
+ Proposition,
1528
+ ]:
1529
+ if propositions is None:
1530
+ return {}
1531
+
1532
+ if isinstance(
1533
+ propositions,
1534
+ Mapping,
1535
+ ):
1536
+ result: dict[str, Proposition] = {}
1537
+
1538
+ for name, proposition in propositions.items():
1539
+ if not isinstance(name, str) or not name:
1540
+ raise ValueError(
1541
+ "Proposition names must be non-empty strings"
1542
+ )
1543
+
1544
+ if isinstance(
1545
+ proposition,
1546
+ Proposition,
1547
+ ):
1548
+ result[name] = proposition
1549
+ continue
1550
+
1551
+ if callable(proposition):
1552
+ result[name] = Proposition(
1553
+ name,
1554
+ proposition,
1555
+ )
1556
+ continue
1557
+
1558
+ raise TypeError(
1559
+ "Proposition mappings must contain Proposition"
1560
+ " objects or callables"
1561
+ )
1562
+
1563
+ return result
1564
+
1565
+ result = {}
1566
+
1567
+ for proposition in propositions:
1568
+ if not isinstance(
1569
+ proposition,
1570
+ Proposition,
1571
+ ):
1572
+ raise TypeError(
1573
+ "Iterable propositions must contain Proposition objects"
1574
+ )
1575
+
1576
+ result[
1577
+ proposition.name
1578
+ ] = proposition
1579
+
1580
+ return result
1581
+
1582
+ def proposition_holds(
1583
+ self,
1584
+ name: str,
1585
+ world: World,
1586
+ ) -> bool:
1587
+ proposition = self._propositions.get(
1588
+ name
1589
+ )
1590
+
1591
+ configuration = _configuration(
1592
+ world
1593
+ )
1594
+
1595
+ if proposition is not None:
1596
+ return proposition.holds(
1597
+ configuration
1598
+ )
1599
+
1600
+ return (
1601
+ configuration.state.get(
1602
+ name
1603
+ )
1604
+ is True
1605
+ )
1606
+
1607
+ # ---------------------------------------------------------------------
1608
+ # Temporal fixed points
1609
+ # ---------------------------------------------------------------------
1610
+
1611
+ def _eventually_set(
1612
+ self,
1613
+ operand: Formula,
1614
+ cache: dict[
1615
+ tuple[Formula, World],
1616
+ bool,
1617
+ ],
1618
+ ) -> set[World]:
1619
+ satisfying = {
1620
+ world
1621
+ for world in self._worlds
1622
+ if self._check(
1623
+ operand,
1624
+ world,
1625
+ cache,
1626
+ )
1627
+ }
1628
+
1629
+ changed = True
1630
+
1631
+ while changed:
1632
+ changed = False
1633
+
1634
+ for world in self._worlds:
1635
+ if world in satisfying:
1636
+ continue
1637
+
1638
+ successors = (
1639
+ self._temporal_successors(
1640
+ world
1641
+ )
1642
+ )
1643
+
1644
+ if all(
1645
+ successor in satisfying
1646
+ for successor in successors
1647
+ ):
1648
+ satisfying.add(
1649
+ world
1650
+ )
1651
+
1652
+ changed = True
1653
+
1654
+ return satisfying
1655
+
1656
+ def _always_set(
1657
+ self,
1658
+ operand: Formula,
1659
+ cache: dict[
1660
+ tuple[Formula, World],
1661
+ bool,
1662
+ ],
1663
+ ) -> set[World]:
1664
+ satisfying = set(
1665
+ self._worlds
1666
+ )
1667
+
1668
+ changed = True
1669
+
1670
+ while changed:
1671
+ changed = False
1672
+
1673
+ for world in tuple(
1674
+ satisfying
1675
+ ):
1676
+ if not self._check(
1677
+ operand,
1678
+ world,
1679
+ cache,
1680
+ ):
1681
+ satisfying.remove(
1682
+ world
1683
+ )
1684
+
1685
+ changed = True
1686
+
1687
+ continue
1688
+
1689
+ successors = (
1690
+ self._temporal_successors(
1691
+ world
1692
+ )
1693
+ )
1694
+
1695
+ if any(
1696
+ successor
1697
+ not in satisfying
1698
+ for successor
1699
+ in successors
1700
+ ):
1701
+ satisfying.remove(
1702
+ world
1703
+ )
1704
+
1705
+ changed = True
1706
+
1707
+ return satisfying
1708
+
1709
+ def _until_set(
1710
+ self,
1711
+ left: Formula,
1712
+ right: Formula,
1713
+ cache: dict[
1714
+ tuple[Formula, World],
1715
+ bool,
1716
+ ],
1717
+ ) -> set[World]:
1718
+ satisfying = {
1719
+ world
1720
+ for world in self._worlds
1721
+ if self._check(
1722
+ right,
1723
+ world,
1724
+ cache,
1725
+ )
1726
+ }
1727
+
1728
+ changed = True
1729
+
1730
+ while changed:
1731
+ changed = False
1732
+
1733
+ for world in self._worlds:
1734
+ if world in satisfying:
1735
+ continue
1736
+
1737
+ if not self._check(
1738
+ left,
1739
+ world,
1740
+ cache,
1741
+ ):
1742
+ continue
1743
+
1744
+ successors = (
1745
+ self._temporal_successors(
1746
+ world
1747
+ )
1748
+ )
1749
+
1750
+ if all(
1751
+ successor in satisfying
1752
+ for successor in successors
1753
+ ):
1754
+ satisfying.add(
1755
+ world
1756
+ )
1757
+
1758
+ changed = True
1759
+
1760
+ return satisfying
1761
+
1762
+ # ---------------------------------------------------------------------
1763
+ # Model checking
1764
+ # ---------------------------------------------------------------------
1765
+
1766
+ def check(
1767
+ self,
1768
+ formula: Formula,
1769
+ at: World | None = None,
1770
+ ) -> bool:
1771
+ world = (
1772
+ self._actual
1773
+ if at is None
1774
+ else at
1775
+ )
1776
+
1777
+ if world not in self._worlds:
1778
+ raise ValueError(
1779
+ "World does not belong "
1780
+ "to the epistemic model"
1781
+ )
1782
+
1783
+ cache: dict[
1784
+ tuple[Formula, World],
1785
+ bool,
1786
+ ] = {}
1787
+
1788
+ return self._check(
1789
+ formula,
1790
+ world,
1791
+ cache,
1792
+ )
1793
+
1794
+ def _check(
1795
+ self,
1796
+ formula: Formula,
1797
+ world: World,
1798
+ cache: dict[
1799
+ tuple[Formula, World],
1800
+ bool,
1801
+ ],
1802
+ ) -> bool:
1803
+ key = (
1804
+ formula,
1805
+ world,
1806
+ )
1807
+
1808
+ if key in cache:
1809
+ return cache[key]
1810
+
1811
+ # -----------------------------------------------------------------
1812
+ # Atom
1813
+ # -----------------------------------------------------------------
1814
+
1815
+ if isinstance(
1816
+ formula,
1817
+ Atom,
1818
+ ):
1819
+ result = self.proposition_holds(
1820
+ formula.name,
1821
+ world,
1822
+ )
1823
+
1824
+ # -----------------------------------------------------------------
1825
+ # Boolean
1826
+ # -----------------------------------------------------------------
1827
+
1828
+ elif isinstance(
1829
+ formula,
1830
+ Not,
1831
+ ):
1832
+ result = not self._check(
1833
+ formula.operand,
1834
+ world,
1835
+ cache,
1836
+ )
1837
+
1838
+ elif isinstance(
1839
+ formula,
1840
+ And,
1841
+ ):
1842
+ result = (
1843
+ self._check(
1844
+ formula.left,
1845
+ world,
1846
+ cache,
1847
+ )
1848
+ and self._check(
1849
+ formula.right,
1850
+ world,
1851
+ cache,
1852
+ )
1853
+ )
1854
+
1855
+ elif isinstance(
1856
+ formula,
1857
+ Or,
1858
+ ):
1859
+ result = (
1860
+ self._check(
1861
+ formula.left,
1862
+ world,
1863
+ cache,
1864
+ )
1865
+ or self._check(
1866
+ formula.right,
1867
+ world,
1868
+ cache,
1869
+ )
1870
+ )
1871
+
1872
+ # -----------------------------------------------------------------
1873
+ # Temporal
1874
+ # -----------------------------------------------------------------
1875
+
1876
+ elif isinstance(
1877
+ formula,
1878
+ Next,
1879
+ ):
1880
+ successors = (
1881
+ self._temporal_successors(
1882
+ world
1883
+ )
1884
+ )
1885
+
1886
+ result = all(
1887
+ self._check(
1888
+ formula.operand,
1889
+ successor,
1890
+ cache,
1891
+ )
1892
+ for successor in successors
1893
+ )
1894
+
1895
+ elif isinstance(
1896
+ formula,
1897
+ Eventually,
1898
+ ):
1899
+ satisfying = (
1900
+ self._eventually_set(
1901
+ formula.operand,
1902
+ cache,
1903
+ )
1904
+ )
1905
+
1906
+ for candidate in self._worlds:
1907
+ cache[
1908
+ (
1909
+ formula,
1910
+ candidate,
1911
+ )
1912
+ ] = (
1913
+ candidate
1914
+ in satisfying
1915
+ )
1916
+
1917
+ result = (
1918
+ world
1919
+ in satisfying
1920
+ )
1921
+
1922
+ elif isinstance(
1923
+ formula,
1924
+ Always,
1925
+ ):
1926
+ satisfying = (
1927
+ self._always_set(
1928
+ formula.operand,
1929
+ cache,
1930
+ )
1931
+ )
1932
+
1933
+ for candidate in self._worlds:
1934
+ cache[
1935
+ (
1936
+ formula,
1937
+ candidate,
1938
+ )
1939
+ ] = (
1940
+ candidate
1941
+ in satisfying
1942
+ )
1943
+
1944
+ result = (
1945
+ world
1946
+ in satisfying
1947
+ )
1948
+
1949
+ elif isinstance(
1950
+ formula,
1951
+ Until,
1952
+ ):
1953
+ satisfying = (
1954
+ self._until_set(
1955
+ formula.left,
1956
+ formula.right,
1957
+ cache,
1958
+ )
1959
+ )
1960
+
1961
+ for candidate in self._worlds:
1962
+ cache[
1963
+ (
1964
+ formula,
1965
+ candidate,
1966
+ )
1967
+ ] = (
1968
+ candidate
1969
+ in satisfying
1970
+ )
1971
+
1972
+ result = (
1973
+ world
1974
+ in satisfying
1975
+ )
1976
+
1977
+ # -----------------------------------------------------------------
1978
+ # Knowledge
1979
+ # -----------------------------------------------------------------
1980
+
1981
+ elif isinstance(
1982
+ formula,
1983
+ Knowledge,
1984
+ ):
1985
+ result = all(
1986
+ self._check(
1987
+ formula.operand,
1988
+ candidate,
1989
+ cache,
1990
+ )
1991
+ for candidate in self.accessible(
1992
+ formula.agent,
1993
+ world,
1994
+ )
1995
+ )
1996
+
1997
+ # -----------------------------------------------------------------
1998
+ # Everyone knows
1999
+ # -----------------------------------------------------------------
2000
+
2001
+ elif isinstance(
2002
+ formula,
2003
+ EveryoneKnows,
2004
+ ):
2005
+ result = all(
2006
+ self._check(
2007
+ Knowledge(
2008
+ agent=agent,
2009
+ operand=formula.operand,
2010
+ ),
2011
+ world,
2012
+ cache,
2013
+ )
2014
+ for agent
2015
+ in formula.agents
2016
+ )
2017
+
2018
+ # -----------------------------------------------------------------
2019
+ # Distributed knowledge
2020
+ # -----------------------------------------------------------------
2021
+
2022
+ elif isinstance(
2023
+ formula,
2024
+ DistributedKnowledge,
2025
+ ):
2026
+ result = all(
2027
+ self._check(
2028
+ formula.operand,
2029
+ candidate,
2030
+ cache,
2031
+ )
2032
+ for candidate
2033
+ in self.distributed_accessible(
2034
+ formula.agents,
2035
+ world,
2036
+ )
2037
+ )
2038
+
2039
+ # -----------------------------------------------------------------
2040
+ # Common knowledge
2041
+ # -----------------------------------------------------------------
2042
+
2043
+ elif isinstance(
2044
+ formula,
2045
+ CommonKnowledge,
2046
+ ):
2047
+ result = all(
2048
+ self._check(
2049
+ formula.operand,
2050
+ candidate,
2051
+ cache,
2052
+ )
2053
+ for candidate
2054
+ in self.common_knowledge_closure(
2055
+ formula.agents,
2056
+ world,
2057
+ )
2058
+ )
2059
+
2060
+ else:
2061
+ raise TypeError(
2062
+ f"Unsupported formula type: "
2063
+ f"{type(formula).__name__}"
2064
+ )
2065
+
2066
+ cache[key] = result
2067
+
2068
+ return result