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/observation.py ADDED
@@ -0,0 +1,500 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Hashable, Iterable, Protocol, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from .agent import Agent
8
+ from .runtime import Configuration
9
+
10
+
11
+ class Observation(Protocol):
12
+ """
13
+ Describes what an agent can observe in a configuration.
14
+
15
+ The returned value must be hashable because observations
16
+ induce epistemic equivalence classes over worlds.
17
+ """
18
+
19
+ def __call__(
20
+ self,
21
+ agent: "Agent",
22
+ configuration: "Configuration",
23
+ ) -> Hashable:
24
+ ...
25
+
26
+
27
+ def _normalize_keys(
28
+ keys: str | Iterable[str],
29
+ ) -> tuple[str, ...]:
30
+ if isinstance(keys, str):
31
+ return (keys,)
32
+
33
+ return tuple(keys)
34
+
35
+
36
+ def _ensure_hashable(value: Any) -> Hashable:
37
+ hash(value)
38
+ return value
39
+
40
+
41
+ # ============================================================================
42
+ # Declarative observation components
43
+ # ============================================================================
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class LocalObservation:
48
+ """
49
+ Observation of one particular agent's local information.
50
+
51
+ Examples:
52
+
53
+ local("bob", state=("x",))
54
+
55
+ local("bob", state=("x",), process=True)
56
+
57
+ The observation is represented as a tuple of tagged fragments:
58
+
59
+ (
60
+ ("state", ...),
61
+ ("process", ...),
62
+ )
63
+ """
64
+
65
+ agent_name: str
66
+ state_keys: tuple[str, ...] = ()
67
+ include_process: bool = False
68
+
69
+ def __post_init__(self) -> None:
70
+ if not self.agent_name.strip():
71
+ raise ValueError(
72
+ "agent_name must not be empty"
73
+ )
74
+
75
+ for key in self.state_keys:
76
+ if not isinstance(key, str) or not key.strip():
77
+ raise ValueError(
78
+ "state observation keys must be non-empty strings"
79
+ )
80
+
81
+ def __call__(
82
+ self,
83
+ agent: "Agent",
84
+ configuration: "Configuration",
85
+ ) -> Hashable:
86
+ if agent.name != self.agent_name:
87
+ raise ValueError(
88
+ f"Local observation for {self.agent_name!r} "
89
+ f"was applied to {agent.name!r}"
90
+ )
91
+
92
+ fragments: list[tuple[str, Hashable]] = []
93
+
94
+ if self.state_keys:
95
+ local_state = tuple(
96
+ (key, agent.get_state(key))
97
+ for key in self.state_keys
98
+ )
99
+
100
+ _ensure_hashable(local_state)
101
+
102
+ fragments.append(
103
+ ("state", local_state)
104
+ )
105
+
106
+ if self.include_process:
107
+ process = _ensure_hashable(
108
+ agent.process
109
+ )
110
+
111
+ fragments.append(
112
+ ("process", process)
113
+ )
114
+
115
+ return _ensure_hashable(
116
+ tuple(fragments)
117
+ )
118
+
119
+
120
+ def local(
121
+ agent_name: str,
122
+ *,
123
+ state: str | Iterable[str] = (),
124
+ process: bool = False,
125
+ ) -> LocalObservation:
126
+ """
127
+ Declare an observation of one agent's local information.
128
+
129
+ Example:
130
+
131
+ local(
132
+ "bob",
133
+ state=("x",),
134
+ process=True,
135
+ )
136
+ """
137
+
138
+ return LocalObservation(
139
+ agent_name=agent_name,
140
+ state_keys=_normalize_keys(state),
141
+ include_process=process,
142
+ )
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class GlobalObservation:
147
+ """
148
+ Observation of globally visible information.
149
+
150
+ Example:
151
+
152
+ public("round")
153
+ """
154
+
155
+ keys: tuple[str, ...]
156
+
157
+ def __post_init__(self) -> None:
158
+ for key in self.keys:
159
+ if not isinstance(key, str) or not key.strip():
160
+ raise ValueError(
161
+ "global observation keys must be non-empty strings"
162
+ )
163
+
164
+ def __call__(
165
+ self,
166
+ agent: "Agent",
167
+ configuration: "Configuration",
168
+ ) -> Hashable:
169
+ value = tuple(
170
+ (key, configuration.state.get(key))
171
+ for key in self.keys
172
+ )
173
+
174
+ _ensure_hashable(value)
175
+
176
+ return _ensure_hashable(
177
+ (
178
+ ("global", value),
179
+ )
180
+ )
181
+
182
+
183
+ def global_(
184
+ *keys: str,
185
+ ) -> GlobalObservation:
186
+ """
187
+ Declare globally observable variables.
188
+ """
189
+
190
+ return GlobalObservation(
191
+ tuple(keys)
192
+ )
193
+
194
+
195
+ def public(
196
+ *keys: str,
197
+ ) -> GlobalObservation:
198
+ """
199
+ Semantic alias for global_().
200
+ """
201
+
202
+ return global_(*keys)
203
+
204
+
205
+ # ============================================================================
206
+ # Combined observation
207
+ # ============================================================================
208
+
209
+
210
+ @dataclass(frozen=True, slots=True)
211
+ class _CombinedObservation:
212
+ """
213
+ Composition of declarative observation components.
214
+
215
+ Declarative components return tuples of tagged fragments.
216
+ We flatten those fragments here so that:
217
+
218
+ local(...) + public(...)
219
+
220
+ becomes:
221
+
222
+ (
223
+ ("global", ...),
224
+ ("state", ...),
225
+ )
226
+
227
+ rather than introducing accidental nested tuples.
228
+ """
229
+
230
+ components: tuple[Observation, ...]
231
+
232
+ def __call__(
233
+ self,
234
+ agent: "Agent",
235
+ configuration: "Configuration",
236
+ ) -> Hashable:
237
+ fragments: list[Hashable] = []
238
+
239
+ for component in self.components:
240
+ value = component(
241
+ agent,
242
+ configuration,
243
+ )
244
+
245
+ if isinstance(
246
+ component,
247
+ (
248
+ LocalObservation,
249
+ GlobalObservation,
250
+ ),
251
+ ):
252
+ # Both declarative components return
253
+ # tuples of tagged fragments.
254
+ fragments.extend(value)
255
+ else:
256
+ # Preserve arbitrary custom observations.
257
+ fragments.append(
258
+ (
259
+ "component",
260
+ _ensure_hashable(value),
261
+ )
262
+ )
263
+
264
+ return _ensure_hashable(
265
+ tuple(fragments)
266
+ )
267
+
268
+
269
+ @dataclass(frozen=True, slots=True)
270
+ class ObservationBuilder:
271
+ """
272
+ Declarative builder for per-agent observations.
273
+
274
+ Example:
275
+
276
+ observations = (
277
+ ObservationBuilder()
278
+ .for_agent(
279
+ "alice",
280
+ state=("secret",),
281
+ )
282
+ .for_agent(
283
+ "bob",
284
+ state=("x",),
285
+ process=True,
286
+ )
287
+ .public("round")
288
+ .build(("alice", "bob"))
289
+ )
290
+ """
291
+
292
+ components: tuple[Observation, ...] = ()
293
+
294
+ @classmethod
295
+ def create(
296
+ cls,
297
+ *components: Observation,
298
+ ) -> "ObservationBuilder":
299
+ return cls(
300
+ tuple(components)
301
+ )
302
+
303
+ def add(
304
+ self,
305
+ component: Observation,
306
+ ) -> "ObservationBuilder":
307
+ return ObservationBuilder(
308
+ self.components + (component,)
309
+ )
310
+
311
+ def for_agent(
312
+ self,
313
+ agent_name: str,
314
+ *,
315
+ state: str | Iterable[str] = (),
316
+ process: bool = False,
317
+ ) -> "ObservationBuilder":
318
+ return self.add(
319
+ local(
320
+ agent_name,
321
+ state=state,
322
+ process=process,
323
+ )
324
+ )
325
+
326
+ def global_(
327
+ self,
328
+ *keys: str,
329
+ ) -> "ObservationBuilder":
330
+ return self.add(
331
+ global_(*keys)
332
+ )
333
+
334
+ def public(
335
+ self,
336
+ *keys: str,
337
+ ) -> "ObservationBuilder":
338
+ return self.add(
339
+ public(*keys)
340
+ )
341
+
342
+ def build(
343
+ self,
344
+ agent_names: Iterable[str],
345
+ ) -> dict[str, Observation]:
346
+ names = tuple(agent_names)
347
+
348
+ if not names:
349
+ raise ValueError(
350
+ "ObservationBuilder requires at least one agent"
351
+ )
352
+
353
+ if len(names) != len(set(names)):
354
+ raise ValueError(
355
+ "agent_names must be unique"
356
+ )
357
+
358
+ global_components: list[Observation] = []
359
+
360
+ local_components: dict[
361
+ str,
362
+ list[Observation],
363
+ ] = {
364
+ name: []
365
+ for name in names
366
+ }
367
+
368
+ for component in self.components:
369
+ if isinstance(
370
+ component,
371
+ LocalObservation,
372
+ ):
373
+ if component.agent_name not in local_components:
374
+ raise ValueError(
375
+ f"Unknown agent "
376
+ f"{component.agent_name!r} "
377
+ f"in observation builder"
378
+ )
379
+
380
+ local_components[
381
+ component.agent_name
382
+ ].append(component)
383
+
384
+ else:
385
+ global_components.append(
386
+ component
387
+ )
388
+
389
+ result: dict[str, Observation] = {}
390
+
391
+ for name in names:
392
+ components = tuple(
393
+ global_components
394
+ + local_components[name]
395
+ )
396
+
397
+ if not components:
398
+ raise ValueError(
399
+ f"No observation defined for agent {name!r}"
400
+ )
401
+
402
+ result[name] = _CombinedObservation(
403
+ components
404
+ )
405
+
406
+ return result
407
+
408
+
409
+ # ============================================================================
410
+ # Compatibility helpers
411
+ # ============================================================================
412
+
413
+
414
+ def same_observation(
415
+ agent: "Agent",
416
+ first: "Configuration",
417
+ second: "Configuration",
418
+ observation: Observation,
419
+ ) -> bool:
420
+ return (
421
+ observation(agent, first)
422
+ == observation(agent, second)
423
+ )
424
+
425
+
426
+ def observe_global(
427
+ *keys: str,
428
+ ) -> Observation:
429
+ """
430
+ Backward-compatible global observation.
431
+
432
+ No keys means an empty public observation.
433
+ """
434
+
435
+ def observation(
436
+ agent: "Agent",
437
+ configuration: "Configuration",
438
+ ) -> Hashable:
439
+ value = tuple(
440
+ (key, configuration.state.get(key))
441
+ for key in keys
442
+ )
443
+
444
+ return _ensure_hashable(value)
445
+
446
+ return observation
447
+
448
+
449
+ def observe_local_state(
450
+ *keys: str,
451
+ ) -> Observation:
452
+ """
453
+ Backward-compatible local-state observation.
454
+
455
+ With explicit keys:
456
+
457
+ observe_local_state("x", "y")
458
+
459
+ observes exactly those variables.
460
+
461
+ Without keys:
462
+
463
+ observe_local_state()
464
+
465
+ observes the complete local agent state.
466
+ """
467
+
468
+ def observation(
469
+ agent: "Agent",
470
+ configuration: "Configuration",
471
+ ) -> Hashable:
472
+ if keys:
473
+ value = tuple(
474
+ (key, agent.get_state(key))
475
+ for key in keys
476
+ )
477
+
478
+ else:
479
+ # Agent.state is already a canonical immutable tuple.
480
+ value = tuple(agent.state)
481
+
482
+ return _ensure_hashable(value)
483
+
484
+ return observation
485
+
486
+
487
+ def observe_local_process() -> Observation:
488
+ """
489
+ Backward-compatible process observation.
490
+ """
491
+
492
+ def observation(
493
+ agent: "Agent",
494
+ configuration: "Configuration",
495
+ ) -> Hashable:
496
+ return _ensure_hashable(
497
+ agent.process
498
+ )
499
+
500
+ return observation