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/execution.py ADDED
@@ -0,0 +1,892 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import (
5
+ Callable,
6
+ Hashable,
7
+ Iterable,
8
+ Mapping,
9
+ Protocol,
10
+ )
11
+
12
+ from .actions import (
13
+ Action,
14
+ Receive,
15
+ Send,
16
+ Tau,
17
+ )
18
+ from .agent import Agent
19
+ from .epistemic import (
20
+ EpistemicModel,
21
+ ProductWorld,
22
+ )
23
+ from .runtime import (
24
+ Configuration,
25
+ SystemTransition,
26
+ )
27
+
28
+
29
+ class TransitionObservation(Protocol):
30
+ """
31
+ Describes what an agent observes about a runtime transition.
32
+
33
+ The returned value must be hashable.
34
+
35
+ The observation is intentionally separate from the agent's
36
+ ordinary world observation. It describes the event itself.
37
+ """
38
+
39
+ def __call__(
40
+ self,
41
+ agent: Agent,
42
+ transition: SystemTransition,
43
+ ) -> Hashable:
44
+ ...
45
+
46
+
47
+ # ============================================================================
48
+ # Action observations
49
+ # ============================================================================
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ActionObservation:
54
+ """
55
+ Declarative observation of a process-calculus action.
56
+
57
+ By default an agent observes:
58
+
59
+ action kind
60
+ channel
61
+
62
+ but not the payload.
63
+
64
+ Examples:
65
+
66
+ send("c", "secret")
67
+ -> ("kind", "send"), ("channel", "c")
68
+
69
+ recv("c", "x")
70
+ -> ("kind", "receive"), ("channel", "c")
71
+
72
+ tau()
73
+ -> ("kind", "tau")
74
+ """
75
+
76
+ include_kind: bool = True
77
+ include_channel: bool = True
78
+ include_value: bool = False
79
+
80
+ def __call__(
81
+ self,
82
+ agent: Agent,
83
+ transition: SystemTransition,
84
+ ) -> Hashable:
85
+ action = transition.action
86
+
87
+ if isinstance(action, Send):
88
+ kind = "send"
89
+
90
+ elif isinstance(action, Receive):
91
+ kind = "receive"
92
+
93
+ elif isinstance(action, Tau):
94
+ kind = "tau"
95
+
96
+ else:
97
+ kind = type(action).__name__.lower()
98
+
99
+ parts: list[tuple[str, Hashable]] = []
100
+
101
+ if self.include_kind:
102
+ parts.append(
103
+ (
104
+ "kind",
105
+ kind,
106
+ )
107
+ )
108
+
109
+ if self.include_channel and isinstance(
110
+ action,
111
+ (
112
+ Send,
113
+ Receive,
114
+ ),
115
+ ):
116
+ parts.append(
117
+ (
118
+ "channel",
119
+ action.channel,
120
+ )
121
+ )
122
+
123
+ if self.include_value:
124
+ if isinstance(
125
+ action,
126
+ Send,
127
+ ):
128
+ parts.append(
129
+ (
130
+ "value",
131
+ action.value,
132
+ )
133
+ )
134
+
135
+ elif isinstance(
136
+ action,
137
+ Receive,
138
+ ):
139
+ parts.append(
140
+ (
141
+ "variable",
142
+ action.variable,
143
+ )
144
+ )
145
+
146
+ result = tuple(parts)
147
+
148
+ hash(result)
149
+
150
+ return result
151
+
152
+
153
+ def observe_action(
154
+ *,
155
+ kind: bool = True,
156
+ channel: bool = True,
157
+ value: bool = False,
158
+ ) -> ActionObservation:
159
+ """
160
+ Create an action observation policy.
161
+
162
+ Example:
163
+
164
+ observe_action(
165
+ kind=True,
166
+ channel=True,
167
+ value=False,
168
+ )
169
+ """
170
+
171
+ return ActionObservation(
172
+ include_kind=kind,
173
+ include_channel=channel,
174
+ include_value=value,
175
+ )
176
+
177
+
178
+ @dataclass(frozen=True, slots=True)
179
+ class BlindActionObservation:
180
+ """
181
+ Observation policy where the agent cannot distinguish
182
+ runtime actions based on the event itself.
183
+
184
+ Information may still be obtained from the resulting world.
185
+ """
186
+
187
+ def __call__(
188
+ self,
189
+ agent: Agent,
190
+ transition: SystemTransition,
191
+ ) -> Hashable:
192
+ return (
193
+ "blind",
194
+ )
195
+
196
+
197
+ def blind_action() -> BlindActionObservation:
198
+ """
199
+ Construct a completely blind action observation.
200
+ """
201
+
202
+ return BlindActionObservation()
203
+
204
+
205
+ # ============================================================================
206
+ # Observation mapping
207
+ # ============================================================================
208
+
209
+
210
+ EventObservationMap = Mapping[
211
+ str | Agent,
212
+ TransitionObservation,
213
+ ]
214
+
215
+
216
+ def _normalize_event_observations(
217
+ observations: (
218
+ EventObservationMap
219
+ | TransitionObservation
220
+ | None
221
+ ),
222
+ agent_names: Iterable[str],
223
+ ) -> dict[
224
+ str,
225
+ TransitionObservation,
226
+ ]:
227
+ names = tuple(agent_names)
228
+
229
+ if observations is None:
230
+ default = observe_action()
231
+
232
+ return {
233
+ name: default
234
+ for name in names
235
+ }
236
+
237
+ if callable(observations) and not isinstance(
238
+ observations,
239
+ Mapping,
240
+ ):
241
+ return {
242
+ name: observations
243
+ for name in names
244
+ }
245
+
246
+ if not isinstance(
247
+ observations,
248
+ Mapping,
249
+ ):
250
+ raise TypeError(
251
+ "Event observations must be a mapping, "
252
+ "callable, or None"
253
+ )
254
+
255
+ result: dict[
256
+ str,
257
+ TransitionObservation,
258
+ ] = {}
259
+
260
+ for key, observation in observations.items():
261
+ name = (
262
+ key.name
263
+ if isinstance(
264
+ key,
265
+ Agent,
266
+ )
267
+ else key
268
+ )
269
+
270
+ if not isinstance(
271
+ name,
272
+ str,
273
+ ):
274
+ raise TypeError(
275
+ "Event observation keys must be "
276
+ "strings or Agent objects"
277
+ )
278
+
279
+ name = name.strip()
280
+
281
+ if not name:
282
+ raise ValueError(
283
+ "Event observation agent name "
284
+ "must not be empty"
285
+ )
286
+
287
+ if not callable(observation):
288
+ raise TypeError(
289
+ f"Event observation for {name!r} "
290
+ f"must be callable"
291
+ )
292
+
293
+ result[name] = observation
294
+
295
+ expected = set(names)
296
+ actual = set(result)
297
+
298
+ missing = expected - actual
299
+ extra = actual - expected
300
+
301
+ if missing:
302
+ missing_text = ", ".join(
303
+ sorted(missing)
304
+ )
305
+
306
+ raise ValueError(
307
+ f"Missing event observations for agents: "
308
+ f"{missing_text}"
309
+ )
310
+
311
+ if extra:
312
+ extra_text = ", ".join(
313
+ sorted(extra)
314
+ )
315
+
316
+ raise ValueError(
317
+ f"Event observations defined for unknown "
318
+ f"agents: {extra_text}"
319
+ )
320
+
321
+ return result
322
+
323
+
324
+ # ============================================================================
325
+ # Helpers
326
+ # ============================================================================
327
+
328
+
329
+ def _observe_configuration(
330
+ model: EpistemicModel,
331
+ agent_name: str,
332
+ configuration: Configuration,
333
+ ) -> Hashable:
334
+ """
335
+ Evaluate an ordinary world observation on an arbitrary
336
+ Configuration.
337
+
338
+ This is used for successor states that do not yet belong
339
+ to the old epistemic model.
340
+ """
341
+
342
+ runtime_agent = configuration.agent(
343
+ agent_name
344
+ )
345
+
346
+ value = model._observations[
347
+ agent_name
348
+ ](
349
+ runtime_agent,
350
+ configuration,
351
+ )
352
+
353
+ hash(value)
354
+
355
+ return value
356
+
357
+
358
+ def _action_kind(
359
+ action: Action,
360
+ ) -> str:
361
+ if isinstance(
362
+ action,
363
+ Send,
364
+ ):
365
+ return "send"
366
+
367
+ if isinstance(
368
+ action,
369
+ Receive,
370
+ ):
371
+ return "receive"
372
+
373
+ if isinstance(
374
+ action,
375
+ Tau,
376
+ ):
377
+ return "tau"
378
+
379
+ return type(action).__name__.lower()
380
+
381
+
382
+ def _event_name(
383
+ transition_index: int,
384
+ transition: SystemTransition,
385
+ ) -> str:
386
+ """
387
+ Produce a stable human-readable event identifier.
388
+
389
+ The transition index makes otherwise identical transitions
390
+ distinguishable.
391
+ """
392
+
393
+ return (
394
+ f"transition:"
395
+ f"{transition_index}:"
396
+ f"{_action_kind(transition.action)}"
397
+ )
398
+
399
+
400
+ # ============================================================================
401
+ # Process execution event
402
+ # ============================================================================
403
+
404
+
405
+ @dataclass(frozen=True, slots=True)
406
+ class ProcessExecutionEvent:
407
+ """
408
+ Dynamic epistemic event generated from an actual
409
+ process-calculus transition.
410
+
411
+ This is the bridge between:
412
+
413
+ Process calculus
414
+ ↓
415
+ runtime transition
416
+ ↓
417
+ epistemic update
418
+
419
+ The resulting epistemic worlds are ProductWorld objects:
420
+
421
+ ProductWorld(
422
+ world=target_configuration,
423
+ event=transition_id,
424
+ )
425
+
426
+ The event relation combines:
427
+
428
+ 1. source-world epistemic indistinguishability
429
+ 2. event indistinguishability
430
+ 3. target-world observations
431
+ """
432
+
433
+ transition: SystemTransition
434
+
435
+ observations: (
436
+ EventObservationMap
437
+ | TransitionObservation
438
+ | None
439
+ ) = None
440
+
441
+ def apply(
442
+ self,
443
+ model: EpistemicModel,
444
+ ) -> EpistemicModel:
445
+ return _apply_process_execution(
446
+ model=model,
447
+ transition=self.transition,
448
+ observations=self.observations,
449
+ )
450
+
451
+ def __repr__(self) -> str:
452
+ return (
453
+ f"ProcessExecutionEvent("
454
+ f"action={self.transition.action!r}, "
455
+ f"source={self.transition.source!r}, "
456
+ f"target={self.transition.target!r}"
457
+ f")"
458
+ )
459
+
460
+
461
+ # ============================================================================
462
+ # Execution
463
+ # ============================================================================
464
+
465
+
466
+ def execute_transition(
467
+ transition: SystemTransition,
468
+ observations: (
469
+ EventObservationMap
470
+ | TransitionObservation
471
+ | None
472
+ ) = None,
473
+ ) -> ProcessExecutionEvent:
474
+ """
475
+ Convert a runtime SystemTransition into a dynamic event.
476
+
477
+ Example:
478
+
479
+ event = execute_transition(
480
+ communication,
481
+ )
482
+
483
+ updated = model.update(event)
484
+ """
485
+
486
+ return ProcessExecutionEvent(
487
+ transition=transition,
488
+ observations=observations,
489
+ )
490
+
491
+
492
+ def execute(
493
+ model: EpistemicModel,
494
+ transition: SystemTransition,
495
+ observations: (
496
+ EventObservationMap
497
+ | TransitionObservation
498
+ | None
499
+ ) = None,
500
+ ) -> EpistemicModel:
501
+ """
502
+ Execute a process-calculus transition and update
503
+ the epistemic model.
504
+
505
+ Equivalent to:
506
+
507
+ model.update(
508
+ execute_transition(
509
+ transition,
510
+ observations,
511
+ )
512
+ )
513
+ """
514
+
515
+ return model.update(
516
+ execute_transition(
517
+ transition,
518
+ observations,
519
+ )
520
+ )
521
+
522
+
523
+ def step(
524
+ model: EpistemicModel,
525
+ transition: SystemTransition,
526
+ observations: (
527
+ EventObservationMap
528
+ | TransitionObservation
529
+ | None
530
+ ) = None,
531
+ ) -> EpistemicModel:
532
+ """
533
+ Short alias for execute().
534
+ """
535
+
536
+ return execute(
537
+ model,
538
+ transition,
539
+ observations,
540
+ )
541
+
542
+
543
+ # ============================================================================
544
+ # Internal implementation
545
+ # ============================================================================
546
+
547
+
548
+ def _apply_process_execution(
549
+ model: EpistemicModel,
550
+ transition: SystemTransition,
551
+ observations: (
552
+ EventObservationMap
553
+ | TransitionObservation
554
+ | None
555
+ ),
556
+ ) -> EpistemicModel:
557
+ if model.state_space is None:
558
+ raise ValueError(
559
+ "Process execution requires an attached StateSpace"
560
+ )
561
+
562
+ if not isinstance(
563
+ model.actual,
564
+ Configuration,
565
+ ):
566
+ raise ValueError(
567
+ "Process execution currently requires "
568
+ "a Configuration as the actual world"
569
+ )
570
+
571
+ transitions = tuple(
572
+ model.state_space.transitions
573
+ )
574
+
575
+ try:
576
+ actual_index = transitions.index(
577
+ transition
578
+ )
579
+ except ValueError as exc:
580
+ raise ValueError(
581
+ "The transition does not belong "
582
+ "to the model StateSpace"
583
+ ) from exc
584
+
585
+ if transition.source != model.actual:
586
+ raise ValueError(
587
+ "The executed transition must start "
588
+ "at the actual world"
589
+ )
590
+
591
+ event_observations = (
592
+ _normalize_event_observations(
593
+ observations,
594
+ model._agent_names,
595
+ )
596
+ )
597
+
598
+ actual_source = transition.source
599
+ actual_target = transition.target
600
+
601
+ # ---------------------------------------------------------------------
602
+ # Generate candidate histories.
603
+ #
604
+ # A transition can remain possible for at least one agent when:
605
+ #
606
+ # source is epistemically possible
607
+ # AND
608
+ # event is observationally equivalent
609
+ # AND
610
+ # resulting observation is equivalent
611
+ #
612
+ # ---------------------------------------------------------------------
613
+
614
+ candidates: list[
615
+ tuple[
616
+ int,
617
+ SystemTransition,
618
+ ]
619
+ ] = []
620
+
621
+ for index, candidate in enumerate(
622
+ transitions
623
+ ):
624
+ # Only transitions whose source is itself
625
+ # an epistemically possible current world matter.
626
+ if candidate.source not in model.worlds:
627
+ continue
628
+
629
+ possible_for_some_agent = False
630
+
631
+ for agent_name in model._agent_names:
632
+ source_accessible = model.accessible(
633
+ agent_name,
634
+ actual_source,
635
+ )
636
+
637
+ if candidate.source not in source_accessible:
638
+ continue
639
+
640
+ actual_event_observation = (
641
+ event_observations[
642
+ agent_name
643
+ ](
644
+ actual_source.agent(
645
+ agent_name
646
+ ),
647
+ transition,
648
+ )
649
+ )
650
+
651
+ candidate_event_observation = (
652
+ event_observations[
653
+ agent_name
654
+ ](
655
+ candidate.source.agent(
656
+ agent_name
657
+ ),
658
+ candidate,
659
+ )
660
+ )
661
+
662
+ hash(actual_event_observation)
663
+ hash(candidate_event_observation)
664
+
665
+ if (
666
+ actual_event_observation
667
+ != candidate_event_observation
668
+ ):
669
+ continue
670
+
671
+ actual_target_observation = (
672
+ _observe_configuration(
673
+ model,
674
+ agent_name,
675
+ actual_target,
676
+ )
677
+ )
678
+
679
+ candidate_target_observation = (
680
+ _observe_configuration(
681
+ model,
682
+ agent_name,
683
+ candidate.target,
684
+ )
685
+ )
686
+
687
+ if (
688
+ actual_target_observation
689
+ != candidate_target_observation
690
+ ):
691
+ continue
692
+
693
+ possible_for_some_agent = True
694
+ break
695
+
696
+ if possible_for_some_agent:
697
+ candidates.append(
698
+ (
699
+ index,
700
+ candidate,
701
+ )
702
+ )
703
+
704
+ actual_candidate = next(
705
+ (
706
+ item
707
+ for item in candidates
708
+ if item[0] == actual_index
709
+ ),
710
+ None,
711
+ )
712
+
713
+ if actual_candidate is None:
714
+ raise ValueError(
715
+ "The actual transition is not "
716
+ "epistemically executable"
717
+ )
718
+
719
+ # ---------------------------------------------------------------------
720
+ # Materialize ProductWorld histories.
721
+ # ---------------------------------------------------------------------
722
+
723
+ product_worlds: list[
724
+ ProductWorld
725
+ ] = []
726
+
727
+ by_index: dict[
728
+ int,
729
+ ProductWorld,
730
+ ] = {}
731
+
732
+ transition_by_index: dict[
733
+ int,
734
+ SystemTransition,
735
+ ] = {}
736
+
737
+ for index, candidate in candidates:
738
+ product = ProductWorld(
739
+ world=candidate.target,
740
+ event=_event_name(
741
+ index,
742
+ candidate,
743
+ ),
744
+ )
745
+
746
+ product_worlds.append(
747
+ product
748
+ )
749
+
750
+ by_index[index] = product
751
+ transition_by_index[index] = candidate
752
+
753
+ product_worlds_tuple = tuple(
754
+ product_worlds
755
+ )
756
+
757
+ actual_product = by_index[
758
+ actual_index
759
+ ]
760
+
761
+ # ---------------------------------------------------------------------
762
+ # Build post-event epistemic relations.
763
+ # ---------------------------------------------------------------------
764
+
765
+ relation_overrides: dict[
766
+ str,
767
+ dict[
768
+ ProductWorld,
769
+ tuple[
770
+ ProductWorld,
771
+ ...,
772
+ ],
773
+ ],
774
+ ] = {}
775
+
776
+ for agent_name in model._agent_names:
777
+ source_map: dict[
778
+ ProductWorld,
779
+ tuple[
780
+ ProductWorld,
781
+ ...,
782
+ ],
783
+ ] = {}
784
+
785
+ for source_index, source_candidate in (
786
+ transition_by_index.items()
787
+ ):
788
+ source_product = by_index[
789
+ source_index
790
+ ]
791
+
792
+ source_accessible = set(
793
+ model.accessible(
794
+ agent_name,
795
+ source_candidate.source,
796
+ )
797
+ )
798
+
799
+ source_event_observation = (
800
+ event_observations[
801
+ agent_name
802
+ ](
803
+ source_candidate.source.agent(
804
+ agent_name
805
+ ),
806
+ source_candidate,
807
+ )
808
+ )
809
+
810
+ hash(
811
+ source_event_observation
812
+ )
813
+
814
+ source_target_observation = (
815
+ _observe_configuration(
816
+ model,
817
+ agent_name,
818
+ source_candidate.target,
819
+ )
820
+ )
821
+
822
+ targets: list[
823
+ ProductWorld
824
+ ] = []
825
+
826
+ for candidate_index, candidate in (
827
+ transition_by_index.items()
828
+ ):
829
+ if (
830
+ candidate.source
831
+ not in source_accessible
832
+ ):
833
+ continue
834
+
835
+ candidate_event_observation = (
836
+ event_observations[
837
+ agent_name
838
+ ](
839
+ candidate.source.agent(
840
+ agent_name
841
+ ),
842
+ candidate,
843
+ )
844
+ )
845
+
846
+ if (
847
+ candidate_event_observation
848
+ != source_event_observation
849
+ ):
850
+ continue
851
+
852
+ candidate_target_observation = (
853
+ _observe_configuration(
854
+ model,
855
+ agent_name,
856
+ candidate.target,
857
+ )
858
+ )
859
+
860
+ if (
861
+ candidate_target_observation
862
+ != source_target_observation
863
+ ):
864
+ continue
865
+
866
+ target_product = by_index[
867
+ candidate_index
868
+ ]
869
+
870
+ if target_product not in targets:
871
+ targets.append(
872
+ target_product
873
+ )
874
+
875
+ source_map[
876
+ source_product
877
+ ] = tuple(
878
+ targets
879
+ )
880
+
881
+ relation_overrides[
882
+ agent_name
883
+ ] = source_map
884
+
885
+ return EpistemicModel(
886
+ worlds=product_worlds_tuple,
887
+ observations=model._observations,
888
+ actual=actual_product,
889
+ propositions=model._propositions,
890
+ state_space=None,
891
+ relations=relation_overrides,
892
+ )