Upload analysis/trajectory_extractor.py

#1
Files changed (1) hide show
  1. analysis/trajectory_extractor.py +915 -0
analysis/trajectory_extractor.py ADDED
@@ -0,0 +1,915 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ analysis.trajectory_extractor
3
+ =============================
4
+
5
+ Convert raw WorldSmithAI simulation history into structured trajectory data.
6
+
7
+ This module is intentionally placed above the simulation engine. It consumes
8
+ world history, metric logs, and event logs without mutating runtime agents,
9
+ resources, behaviors, policies, schedulers, or worlds.
10
+
11
+ The extractor is designed to be tolerant of several history formats because the
12
+ core engine may evolve independently. It accepts either:
13
+
14
+ - a world-like object with ``history``
15
+ - a raw sequence of snapshot mappings
16
+ - a single snapshot mapping
17
+
18
+ The output is a ``TrajectoryData`` dataclass consumed by later analysis modules.
19
+
20
+ Example
21
+ -------
22
+
23
+ extractor = TrajectoryExtractor()
24
+ trajectory = extractor.extract(world)
25
+
26
+ print(trajectory.step_count)
27
+ print(trajectory.agent_type_counts)
28
+ print(trajectory.population_timeseries)
29
+
30
+ Architecture
31
+ ------------
32
+
33
+ world.history
34
+
35
+ TrajectoryExtractor.extract(...)
36
+
37
+ TrajectoryData
38
+
39
+ simulation_analyzer / report_generator / scenario comparator
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import copy
45
+ import logging
46
+ import math
47
+ from collections import defaultdict
48
+ from collections.abc import Mapping, Sequence
49
+ from dataclasses import dataclass, field
50
+ from typing import Any, Protocol
51
+
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ class WorldHistoryProtocol(Protocol):
57
+ """Structural protocol for world-like objects with history."""
58
+
59
+ history: Any
60
+ step_count: int
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class TrajectoryExtractionConfig:
65
+ """Configuration for trajectory extraction.
66
+
67
+ Attributes:
68
+ count_alive_only:
69
+ If true, population counts exclude agents with ``alive=False``.
70
+ include_snapshot_events:
71
+ If true, event-like records inside each snapshot are added to the
72
+ normalized event log.
73
+ include_snapshot_metrics:
74
+ If true, metric-like records inside each snapshot are added to the
75
+ normalized metric history.
76
+ preserve_unknown_snapshot_fields:
77
+ If true, selected unrecognized top-level snapshot keys are preserved
78
+ in metadata for debugging.
79
+ max_preserved_unknown_values:
80
+ Maximum number of unknown snapshot values preserved in metadata.
81
+ """
82
+
83
+ count_alive_only: bool = True
84
+ include_snapshot_events: bool = True
85
+ include_snapshot_metrics: bool = True
86
+ preserve_unknown_snapshot_fields: bool = False
87
+ max_preserved_unknown_values: int = 20
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class TrajectoryData:
92
+ """Structured simulation trajectory data.
93
+
94
+ Attributes:
95
+ population_timeseries:
96
+ Mapping from step to counts by agent type.
97
+ resource_timeseries:
98
+ Mapping from step to total resource amount by resource type.
99
+ event_log:
100
+ Normalized event records. Each record should include a ``step`` key
101
+ when the step is known.
102
+ metric_history:
103
+ Mapping from metric name to a list of per-step metric records.
104
+ agent_type_counts:
105
+ Final observed agent counts by type.
106
+ step_count:
107
+ Number of simulated steps represented by the trajectory. This is
108
+ the highest observed step plus one when possible.
109
+ first_step:
110
+ First observed step, or ``None`` when there is no history.
111
+ last_step:
112
+ Last observed step, or ``None`` when there is no history.
113
+ snapshot_count:
114
+ Number of snapshots consumed.
115
+ metadata:
116
+ Additional extraction diagnostics.
117
+ """
118
+
119
+ population_timeseries: Mapping[int, Mapping[str, int]] = field(default_factory=dict)
120
+ resource_timeseries: Mapping[int, Mapping[str, float]] = field(default_factory=dict)
121
+ event_log: tuple[Mapping[str, Any], ...] = field(default_factory=tuple)
122
+ metric_history: Mapping[str, tuple[Mapping[str, Any], ...]] = field(default_factory=dict)
123
+ agent_type_counts: Mapping[str, int] = field(default_factory=dict)
124
+ step_count: int = 0
125
+ first_step: int | None = None
126
+ last_step: int | None = None
127
+ snapshot_count: int = 0
128
+ metadata: Mapping[str, Any] = field(default_factory=dict)
129
+
130
+ @property
131
+ def is_empty(self) -> bool:
132
+ """Return whether this trajectory contains no snapshots."""
133
+
134
+ return self.snapshot_count == 0
135
+
136
+ @property
137
+ def final_population(self) -> Mapping[str, int]:
138
+ """Return the final population count by agent type."""
139
+
140
+ if self.last_step is None:
141
+ return {}
142
+
143
+ return dict(self.population_timeseries.get(self.last_step, {}))
144
+
145
+ @property
146
+ def final_resources(self) -> Mapping[str, float]:
147
+ """Return the final resource totals by resource type."""
148
+
149
+ if self.last_step is None:
150
+ return {}
151
+
152
+ return dict(self.resource_timeseries.get(self.last_step, {}))
153
+
154
+ def population_delta(self) -> dict[str, int]:
155
+ """Return final-minus-initial population delta by agent type."""
156
+
157
+ if self.first_step is None or self.last_step is None:
158
+ return {}
159
+
160
+ initial = self.population_timeseries.get(self.first_step, {})
161
+ final = self.population_timeseries.get(self.last_step, {})
162
+ keys = set(initial.keys()) | set(final.keys())
163
+
164
+ return {
165
+ key: int(final.get(key, 0)) - int(initial.get(key, 0))
166
+ for key in sorted(keys)
167
+ }
168
+
169
+ def resource_delta(self) -> dict[str, float]:
170
+ """Return final-minus-initial resource amount delta by resource type."""
171
+
172
+ if self.first_step is None or self.last_step is None:
173
+ return {}
174
+
175
+ initial = self.resource_timeseries.get(self.first_step, {})
176
+ final = self.resource_timeseries.get(self.last_step, {})
177
+ keys = set(initial.keys()) | set(final.keys())
178
+
179
+ return {
180
+ key: float(final.get(key, 0.0)) - float(initial.get(key, 0.0))
181
+ for key in sorted(keys)
182
+ }
183
+
184
+ def to_dict(self) -> dict[str, Any]:
185
+ """Return a JSON-friendly representation of the trajectory."""
186
+
187
+ return {
188
+ "population_timeseries": {
189
+ str(step): dict(counts)
190
+ for step, counts in sorted(self.population_timeseries.items())
191
+ },
192
+ "resource_timeseries": {
193
+ str(step): dict(amounts)
194
+ for step, amounts in sorted(self.resource_timeseries.items())
195
+ },
196
+ "event_log": [dict(event) for event in self.event_log],
197
+ "metric_history": {
198
+ metric_name: [dict(record) for record in records]
199
+ for metric_name, records in self.metric_history.items()
200
+ },
201
+ "agent_type_counts": dict(self.agent_type_counts),
202
+ "step_count": self.step_count,
203
+ "first_step": self.first_step,
204
+ "last_step": self.last_step,
205
+ "snapshot_count": self.snapshot_count,
206
+ "population_delta": self.population_delta(),
207
+ "resource_delta": self.resource_delta(),
208
+ "metadata": copy.deepcopy(dict(self.metadata)),
209
+ }
210
+
211
+
212
+ @dataclass
213
+ class TrajectoryExtractor:
214
+ """Extract structured trajectory data from completed simulations."""
215
+
216
+ config: TrajectoryExtractionConfig = field(default_factory=TrajectoryExtractionConfig)
217
+
218
+ def extract(
219
+ self,
220
+ world_or_history: Any,
221
+ *,
222
+ metric_history: Mapping[str, Any] | Sequence[Any] | None = None,
223
+ event_log: Sequence[Any] | Mapping[str, Any] | None = None,
224
+ ) -> TrajectoryData:
225
+ """Extract trajectory data from a world-like object or raw history.
226
+
227
+ Args:
228
+ world_or_history:
229
+ Either a world-like object with ``history`` or a raw history
230
+ object.
231
+ metric_history:
232
+ Optional external metric history to merge with snapshot metrics.
233
+ event_log:
234
+ Optional external event log to merge with snapshot events.
235
+
236
+ Returns:
237
+ Normalized ``TrajectoryData``.
238
+ """
239
+
240
+ world = self._as_world_like(world_or_history)
241
+ raw_history = self._history_from_input(world_or_history)
242
+ snapshots = self._normalize_history(raw_history)
243
+
244
+ if not snapshots and world is not None:
245
+ current_snapshot = self._snapshot_from_world(world)
246
+ if current_snapshot:
247
+ snapshots = (current_snapshot,)
248
+
249
+ population_timeseries: dict[int, dict[str, int]] = {}
250
+ resource_timeseries: dict[int, dict[str, float]] = {}
251
+ normalized_event_log: list[dict[str, Any]] = []
252
+ normalized_metric_history: dict[str, list[dict[str, Any]]] = defaultdict(list)
253
+ unknown_snapshot_metadata: list[dict[str, Any]] = []
254
+
255
+ for index, raw_snapshot in enumerate(snapshots):
256
+ snapshot = self._mapping_from_any(raw_snapshot)
257
+ step = self._step_from_snapshot(snapshot, fallback=index)
258
+
259
+ population_counts = self._population_counts_from_snapshot(snapshot)
260
+ resource_amounts = self._resource_amounts_from_snapshot(snapshot)
261
+
262
+ population_timeseries[step] = population_counts
263
+ resource_timeseries[step] = resource_amounts
264
+
265
+ if self.config.include_snapshot_events:
266
+ self._append_snapshot_events(
267
+ snapshot=snapshot,
268
+ step=step,
269
+ output=normalized_event_log,
270
+ )
271
+
272
+ if self.config.include_snapshot_metrics:
273
+ self._append_snapshot_metrics(
274
+ snapshot=snapshot,
275
+ step=step,
276
+ output=normalized_metric_history,
277
+ )
278
+
279
+ if self.config.preserve_unknown_snapshot_fields:
280
+ preserved = self._preserve_unknown_snapshot_fields(snapshot, step=step)
281
+ if preserved:
282
+ unknown_snapshot_metadata.append(preserved)
283
+
284
+ self._append_external_events(
285
+ event_log=event_log,
286
+ world=world,
287
+ output=normalized_event_log,
288
+ )
289
+
290
+ self._append_external_metrics(
291
+ metric_history=metric_history,
292
+ world=world,
293
+ output=normalized_metric_history,
294
+ )
295
+
296
+ observed_steps = sorted(population_timeseries.keys() | resource_timeseries.keys())
297
+ first_step = observed_steps[0] if observed_steps else None
298
+ last_step = observed_steps[-1] if observed_steps else None
299
+
300
+ if last_step is None:
301
+ step_count = int(getattr(world, "step_count", 0)) if world is not None else 0
302
+ else:
303
+ step_count = max(last_step + 1, int(getattr(world, "step_count", 0)) if world is not None else 0)
304
+
305
+ final_counts = (
306
+ population_timeseries.get(last_step, {})
307
+ if last_step is not None
308
+ else {}
309
+ )
310
+
311
+ metadata: dict[str, Any] = {
312
+ "source": "world" if world is not None else "history",
313
+ "history_length": len(snapshots),
314
+ "count_alive_only": self.config.count_alive_only,
315
+ }
316
+
317
+ if unknown_snapshot_metadata:
318
+ metadata["unknown_snapshot_fields"] = unknown_snapshot_metadata[
319
+ : self.config.max_preserved_unknown_values
320
+ ]
321
+
322
+ return TrajectoryData(
323
+ population_timeseries={
324
+ step: dict(counts)
325
+ for step, counts in sorted(population_timeseries.items())
326
+ },
327
+ resource_timeseries={
328
+ step: dict(amounts)
329
+ for step, amounts in sorted(resource_timeseries.items())
330
+ },
331
+ event_log=tuple(normalized_event_log),
332
+ metric_history={
333
+ metric_name: tuple(records)
334
+ for metric_name, records in sorted(normalized_metric_history.items())
335
+ },
336
+ agent_type_counts=dict(final_counts),
337
+ step_count=step_count,
338
+ first_step=first_step,
339
+ last_step=last_step,
340
+ snapshot_count=len(snapshots),
341
+ metadata=metadata,
342
+ )
343
+
344
+ def extract_from_world(self, world: WorldHistoryProtocol) -> TrajectoryData:
345
+ """Extract trajectory data directly from a world-like object."""
346
+
347
+ return self.extract(world)
348
+
349
+ def extract_from_history(self, history: Sequence[Any] | Mapping[str, Any]) -> TrajectoryData:
350
+ """Extract trajectory data directly from a raw history object."""
351
+
352
+ return self.extract(history)
353
+
354
+ @staticmethod
355
+ def _as_world_like(value: Any) -> Any | None:
356
+ """Return value when it appears to be a world-like object."""
357
+
358
+ if hasattr(value, "history"):
359
+ return value
360
+
361
+ return None
362
+
363
+ @staticmethod
364
+ def _history_from_input(value: Any) -> Any:
365
+ """Return raw history from a world-like object or raw history input."""
366
+
367
+ if hasattr(value, "history"):
368
+ return getattr(value, "history")
369
+
370
+ return value
371
+
372
+ def _normalize_history(self, raw_history: Any) -> tuple[Any, ...]:
373
+ """Normalize raw history into a tuple of snapshots."""
374
+
375
+ if raw_history is None:
376
+ return ()
377
+
378
+ if isinstance(raw_history, Mapping):
379
+ if "history" in raw_history:
380
+ return self._normalize_history(raw_history["history"])
381
+
382
+ if "snapshots" in raw_history:
383
+ return self._normalize_history(raw_history["snapshots"])
384
+
385
+ return (raw_history,)
386
+
387
+ if isinstance(raw_history, Sequence) and not isinstance(raw_history, (str, bytes)):
388
+ return tuple(raw_history)
389
+
390
+ logger.warning(
391
+ "TrajectoryExtractor received unsupported history type: %s",
392
+ raw_history.__class__.__name__,
393
+ )
394
+ return ()
395
+
396
+ def _snapshot_from_world(self, world: Any) -> dict[str, Any]:
397
+ """Build a single best-effort snapshot from a world-like object."""
398
+
399
+ snapshot_method = getattr(world, "snapshot", None)
400
+ if callable(snapshot_method):
401
+ try:
402
+ snapshot = snapshot_method()
403
+ if isinstance(snapshot, Mapping):
404
+ return dict(snapshot)
405
+ except Exception:
406
+ logger.debug("world.snapshot() failed during trajectory extraction", exc_info=True)
407
+
408
+ to_dict = getattr(world, "to_dict", None)
409
+ if callable(to_dict):
410
+ try:
411
+ snapshot = to_dict()
412
+ if isinstance(snapshot, Mapping):
413
+ return dict(snapshot)
414
+ except Exception:
415
+ logger.debug("world.to_dict() failed during trajectory extraction", exc_info=True)
416
+
417
+ snapshot: dict[str, Any] = {
418
+ "step": getattr(world, "step_count", 0),
419
+ "agents": getattr(world, "agents", {}),
420
+ "resources": getattr(world, "resources", {}),
421
+ }
422
+
423
+ metrics = getattr(world, "metrics", None)
424
+ if metrics is not None:
425
+ snapshot["metrics"] = metrics
426
+
427
+ return snapshot
428
+
429
+ def _population_counts_from_snapshot(self, snapshot: Mapping[str, Any]) -> dict[str, int]:
430
+ """Extract population counts by agent type from a snapshot."""
431
+
432
+ if "agent_type_counts" in snapshot and isinstance(snapshot["agent_type_counts"], Mapping):
433
+ return {
434
+ str(agent_type): int(count)
435
+ for agent_type, count in snapshot["agent_type_counts"].items()
436
+ if self._is_numeric(count)
437
+ }
438
+
439
+ if "population" in snapshot and isinstance(snapshot["population"], Mapping):
440
+ return {
441
+ str(agent_type): int(count)
442
+ for agent_type, count in snapshot["population"].items()
443
+ if self._is_numeric(count)
444
+ }
445
+
446
+ agents = self._collection_values(
447
+ self._first_present(
448
+ snapshot,
449
+ ("agents", "agent_states", "agent_snapshots"),
450
+ default=(),
451
+ )
452
+ )
453
+
454
+ counts: dict[str, int] = defaultdict(int)
455
+
456
+ for agent in agents:
457
+ agent_type = self._field(agent, "type", "agent_type", "kind", default="unknown")
458
+ alive = self._field(agent, "alive", default=True)
459
+
460
+ if self.config.count_alive_only and alive is False:
461
+ continue
462
+
463
+ counts[str(agent_type)] += 1
464
+
465
+ return dict(sorted(counts.items()))
466
+
467
+ def _resource_amounts_from_snapshot(self, snapshot: Mapping[str, Any]) -> dict[str, float]:
468
+ """Extract resource total amounts by resource type from a snapshot."""
469
+
470
+ for key in ("resource_timeseries", "resource_type_amounts", "resource_amounts"):
471
+ value = snapshot.get(key)
472
+ if isinstance(value, Mapping):
473
+ return {
474
+ str(resource_type): float(amount)
475
+ for resource_type, amount in value.items()
476
+ if self._is_numeric(amount)
477
+ }
478
+
479
+ resources = self._collection_values(
480
+ self._first_present(
481
+ snapshot,
482
+ ("resources", "resource_states", "resource_snapshots"),
483
+ default=(),
484
+ )
485
+ )
486
+
487
+ amounts: dict[str, float] = defaultdict(float)
488
+
489
+ for resource in resources:
490
+ resource_type = self._field(resource, "type", "resource_type", "kind", default="unknown")
491
+ amount = self._field(resource, "amount", "quantity", "value", default=0.0)
492
+
493
+ if self._is_numeric(amount):
494
+ amounts[str(resource_type)] += float(amount)
495
+
496
+ return dict(sorted(amounts.items()))
497
+
498
+ def _append_snapshot_events(
499
+ self,
500
+ *,
501
+ snapshot: Mapping[str, Any],
502
+ step: int,
503
+ output: list[dict[str, Any]],
504
+ ) -> None:
505
+ """Append normalized event records from a snapshot."""
506
+
507
+ raw_events = self._first_present(
508
+ snapshot,
509
+ ("event_log", "events_processed", "events_fired", "events"),
510
+ default=(),
511
+ )
512
+
513
+ for record in self._collection_values(raw_events):
514
+ normalized = self._normalize_event_record(record, default_step=step)
515
+ if normalized:
516
+ output.append(normalized)
517
+
518
+ def _append_snapshot_metrics(
519
+ self,
520
+ *,
521
+ snapshot: Mapping[str, Any],
522
+ step: int,
523
+ output: dict[str, list[dict[str, Any]]],
524
+ ) -> None:
525
+ """Append metric records from a snapshot."""
526
+
527
+ raw_metrics = self._first_present(
528
+ snapshot,
529
+ ("metrics", "metric_values", "metric_history"),
530
+ default=None,
531
+ )
532
+
533
+ if raw_metrics is None:
534
+ return
535
+
536
+ self._append_metric_records(raw_metrics, default_step=step, output=output)
537
+
538
+ def _append_external_events(
539
+ self,
540
+ *,
541
+ event_log: Sequence[Any] | Mapping[str, Any] | None,
542
+ world: Any | None,
543
+ output: list[dict[str, Any]],
544
+ ) -> None:
545
+ """Append externally supplied or world-level event log records."""
546
+
547
+ raw_event_log = event_log
548
+
549
+ if raw_event_log is None and world is not None:
550
+ raw_event_log = self._first_present_from_object(
551
+ world,
552
+ ("event_log", "events_processed", "events_fired"),
553
+ default=None,
554
+ )
555
+
556
+ if raw_event_log is None:
557
+ return
558
+
559
+ for record in self._collection_values(raw_event_log):
560
+ normalized = self._normalize_event_record(record, default_step=None)
561
+ if normalized:
562
+ output.append(normalized)
563
+
564
+ def _append_external_metrics(
565
+ self,
566
+ *,
567
+ metric_history: Mapping[str, Any] | Sequence[Any] | None,
568
+ world: Any | None,
569
+ output: dict[str, list[dict[str, Any]]],
570
+ ) -> None:
571
+ """Append externally supplied or world-level metric history."""
572
+
573
+ raw_metric_history = metric_history
574
+
575
+ if raw_metric_history is None and world is not None:
576
+ raw_metric_history = self._first_present_from_object(
577
+ world,
578
+ ("metric_history", "metrics_history", "metrics"),
579
+ default=None,
580
+ )
581
+
582
+ if raw_metric_history is None:
583
+ return
584
+
585
+ self._append_metric_records(raw_metric_history, default_step=None, output=output)
586
+
587
+ def _append_metric_records(
588
+ self,
589
+ raw_metrics: Any,
590
+ *,
591
+ default_step: int | None,
592
+ output: dict[str, list[dict[str, Any]]],
593
+ ) -> None:
594
+ """Normalize and append metric records."""
595
+
596
+ if isinstance(raw_metrics, Mapping):
597
+ for metric_name, metric_value in raw_metrics.items():
598
+ if isinstance(metric_value, Sequence) and not isinstance(metric_value, (str, bytes, Mapping)):
599
+ for item in metric_value:
600
+ output[str(metric_name)].append(
601
+ self._normalize_metric_record(
602
+ item,
603
+ metric_name=str(metric_name),
604
+ default_step=default_step,
605
+ )
606
+ )
607
+ else:
608
+ output[str(metric_name)].append(
609
+ self._normalize_metric_record(
610
+ metric_value,
611
+ metric_name=str(metric_name),
612
+ default_step=default_step,
613
+ )
614
+ )
615
+ return
616
+
617
+ if isinstance(raw_metrics, Sequence) and not isinstance(raw_metrics, (str, bytes)):
618
+ for item in raw_metrics:
619
+ item_mapping = self._mapping_from_any(item)
620
+
621
+ metric_name = str(
622
+ self._first_present(
623
+ item_mapping,
624
+ ("name", "metric", "metric_name"),
625
+ default="unknown_metric",
626
+ )
627
+ )
628
+ output[metric_name].append(
629
+ self._normalize_metric_record(
630
+ item_mapping,
631
+ metric_name=metric_name,
632
+ default_step=default_step,
633
+ )
634
+ )
635
+
636
+ def _normalize_event_record(
637
+ self,
638
+ record: Any,
639
+ *,
640
+ default_step: int | None,
641
+ ) -> dict[str, Any]:
642
+ """Normalize one event record."""
643
+
644
+ mapping = self._mapping_from_any(record)
645
+
646
+ if not mapping:
647
+ return {}
648
+
649
+ if default_step is not None:
650
+ mapping.setdefault("step", default_step)
651
+
652
+ event_name = self._first_present(
653
+ mapping,
654
+ ("name", "event", "event_name", "type"),
655
+ default=None,
656
+ )
657
+ if event_name is not None:
658
+ mapping.setdefault("name", str(event_name))
659
+
660
+ return self._json_safe_mapping(mapping)
661
+
662
+ def _normalize_metric_record(
663
+ self,
664
+ record: Any,
665
+ *,
666
+ metric_name: str,
667
+ default_step: int | None,
668
+ ) -> dict[str, Any]:
669
+ """Normalize one metric record."""
670
+
671
+ if isinstance(record, Mapping):
672
+ output = self._json_safe_mapping(record)
673
+ else:
674
+ output = {"value": self._json_safe(record)}
675
+
676
+ output.setdefault("name", metric_name)
677
+
678
+ if default_step is not None:
679
+ output.setdefault("step", default_step)
680
+
681
+ return output
682
+
683
+ def _preserve_unknown_snapshot_fields(
684
+ self,
685
+ snapshot: Mapping[str, Any],
686
+ *,
687
+ step: int,
688
+ ) -> dict[str, Any]:
689
+ """Preserve selected unknown fields from a snapshot for diagnostics."""
690
+
691
+ known_keys = {
692
+ "step",
693
+ "step_count",
694
+ "time",
695
+ "tick",
696
+ "agents",
697
+ "agent_states",
698
+ "agent_snapshots",
699
+ "agent_type_counts",
700
+ "population",
701
+ "resources",
702
+ "resource_states",
703
+ "resource_snapshots",
704
+ "resource_timeseries",
705
+ "resource_type_amounts",
706
+ "resource_amounts",
707
+ "events",
708
+ "event_log",
709
+ "events_processed",
710
+ "events_fired",
711
+ "metrics",
712
+ "metric_values",
713
+ "metric_history",
714
+ }
715
+
716
+ unknown: dict[str, Any] = {}
717
+
718
+ for key, value in snapshot.items():
719
+ if key in known_keys:
720
+ continue
721
+ unknown[str(key)] = self._json_safe(value)
722
+
723
+ if not unknown:
724
+ return {}
725
+
726
+ return {
727
+ "step": step,
728
+ "fields": unknown,
729
+ }
730
+
731
+ @staticmethod
732
+ def _step_from_snapshot(snapshot: Mapping[str, Any], *, fallback: int) -> int:
733
+ """Return step value from snapshot with a fallback index."""
734
+
735
+ for key in ("step", "step_count", "time", "tick"):
736
+ if key in snapshot and TrajectoryExtractor._is_numeric(snapshot[key]):
737
+ return int(snapshot[key])
738
+
739
+ return int(fallback)
740
+
741
+ @staticmethod
742
+ def _collection_values(value: Any) -> tuple[Any, ...]:
743
+ """Return collection values from mapping or sequence."""
744
+
745
+ if value is None:
746
+ return ()
747
+
748
+ if isinstance(value, Mapping):
749
+ return tuple(value.values())
750
+
751
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
752
+ return tuple(value)
753
+
754
+ return (value,)
755
+
756
+ @staticmethod
757
+ def _mapping_from_any(value: Any) -> dict[str, Any]:
758
+ """Return a mapping representation of an arbitrary value."""
759
+
760
+ if value is None:
761
+ return {}
762
+
763
+ if isinstance(value, Mapping):
764
+ return dict(value)
765
+
766
+ for method_name in ("to_dict", "snapshot", "model_dump"):
767
+ method = getattr(value, method_name, None)
768
+ if callable(method):
769
+ try:
770
+ result = method()
771
+ if isinstance(result, Mapping):
772
+ return dict(result)
773
+ except Exception:
774
+ logger.debug(
775
+ "Could not convert %s using %s()",
776
+ value.__class__.__name__,
777
+ method_name,
778
+ exc_info=True,
779
+ )
780
+
781
+ output: dict[str, Any] = {}
782
+
783
+ for attribute in ("id", "name", "type", "amount", "position", "alive", "state", "metadata"):
784
+ if hasattr(value, attribute):
785
+ output[attribute] = getattr(value, attribute)
786
+
787
+ return output
788
+
789
+ @staticmethod
790
+ def _field(value: Any, *names: str, default: Any = None) -> Any:
791
+ """Read the first present field from mapping or object."""
792
+
793
+ if isinstance(value, Mapping):
794
+ for name in names:
795
+ if name in value:
796
+ return value[name]
797
+ return default
798
+
799
+ for name in names:
800
+ if hasattr(value, name):
801
+ return getattr(value, name)
802
+
803
+ return default
804
+
805
+ @staticmethod
806
+ def _first_present(mapping: Mapping[str, Any], keys: Sequence[str], *, default: Any) -> Any:
807
+ """Return first present mapping value."""
808
+
809
+ for key in keys:
810
+ if key in mapping and mapping[key] is not None:
811
+ return mapping[key]
812
+
813
+ return default
814
+
815
+ @staticmethod
816
+ def _first_present_from_object(value: Any, keys: Sequence[str], *, default: Any) -> Any:
817
+ """Return first present object attribute value."""
818
+
819
+ for key in keys:
820
+ if hasattr(value, key):
821
+ candidate = getattr(value, key)
822
+ if candidate is not None:
823
+ return candidate
824
+
825
+ return default
826
+
827
+ @staticmethod
828
+ def _is_numeric(value: Any) -> bool:
829
+ """Return whether value is a finite int or float but not bool."""
830
+
831
+ if isinstance(value, bool):
832
+ return False
833
+
834
+ if not isinstance(value, (int, float)):
835
+ return False
836
+
837
+ return math.isfinite(float(value))
838
+
839
+ @classmethod
840
+ def _json_safe_mapping(cls, mapping: Mapping[str, Any]) -> dict[str, Any]:
841
+ """Return JSON-safe copy of a mapping."""
842
+
843
+ return {
844
+ str(key): cls._json_safe(value)
845
+ for key, value in mapping.items()
846
+ }
847
+
848
+ @classmethod
849
+ def _json_safe(cls, value: Any) -> Any:
850
+ """Return a JSON-friendly representation."""
851
+
852
+ if value is None or isinstance(value, (str, bool)):
853
+ return value
854
+
855
+ if isinstance(value, int) and not isinstance(value, bool):
856
+ return value
857
+
858
+ if isinstance(value, float):
859
+ return value if math.isfinite(value) else None
860
+
861
+ if isinstance(value, Mapping):
862
+ return {
863
+ str(key): cls._json_safe(item)
864
+ for key, item in value.items()
865
+ }
866
+
867
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
868
+ return [cls._json_safe(item) for item in value]
869
+
870
+ for method_name in ("to_dict", "snapshot", "model_dump"):
871
+ method = getattr(value, method_name, None)
872
+ if callable(method):
873
+ try:
874
+ return cls._json_safe(method())
875
+ except Exception:
876
+ logger.debug(
877
+ "Could not JSON-normalize %s using %s()",
878
+ value.__class__.__name__,
879
+ method_name,
880
+ exc_info=True,
881
+ )
882
+
883
+ if hasattr(value, "tolist") and callable(value.tolist):
884
+ try:
885
+ return cls._json_safe(value.tolist())
886
+ except Exception:
887
+ logger.debug("Could not JSON-normalize tolist() value", exc_info=True)
888
+
889
+ return repr(value)
890
+
891
+
892
+ def extract_trajectory(
893
+ world_or_history: Any,
894
+ *,
895
+ metric_history: Mapping[str, Any] | Sequence[Any] | None = None,
896
+ event_log: Sequence[Any] | Mapping[str, Any] | None = None,
897
+ config: TrajectoryExtractionConfig | None = None,
898
+ ) -> TrajectoryData:
899
+ """Convenience function for extracting trajectory data."""
900
+
901
+ extractor = TrajectoryExtractor(config=config or TrajectoryExtractionConfig())
902
+ return extractor.extract(
903
+ world_or_history,
904
+ metric_history=metric_history,
905
+ event_log=event_log,
906
+ )
907
+
908
+
909
+ __all__ = [
910
+ "TrajectoryData",
911
+ "TrajectoryExtractionConfig",
912
+ "TrajectoryExtractor",
913
+ "WorldHistoryProtocol",
914
+ "extract_trajectory",
915
+ ]