initium-deeplearning 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. initium/LNN_controller/cfc_backbone_parallel.py +265 -0
  2. initium/LNN_controller/cfc_controller.py +325 -0
  3. initium/LNN_controller/chained_controller.py +83 -0
  4. initium/LNN_controller/hybrid_controller.py +90 -0
  5. initium/MoE/README.md +1 -0
  6. initium/MoE/moe_layer.py +552 -0
  7. initium/__init__.py +1 -0
  8. initium/config/controller_config.py +86 -0
  9. initium/config/train_config.py +64 -0
  10. initium/core_training.py +2182 -0
  11. initium/data/__init__.py +0 -0
  12. initium/data/audio/__init__.py +0 -0
  13. initium/data/audio/audio_dataset.py +96 -0
  14. initium/data/base_dataset.py +75 -0
  15. initium/data/common/__init__.py +0 -0
  16. initium/data/common/chain_task.py +542 -0
  17. initium/data/common/digit_codec.py +85 -0
  18. initium/data/common/graph_io.py +123 -0
  19. initium/data/common/real_data.py +747 -0
  20. initium/data/dataset_registry.py +59 -0
  21. initium/data/graph_traversal/__init__.py +0 -0
  22. initium/data/graph_traversal/graph_traversal.py +746 -0
  23. initium/data/multimodal/__init__.py +0 -0
  24. initium/data/multimodal/multimodal_dataset.py +401 -0
  25. initium/data/text/__init__.py +0 -0
  26. initium/data/text/text_dataset.py +79 -0
  27. initium/data/video/__init__.py +0 -0
  28. initium/data/video/video_dataset.py +85 -0
  29. initium/inference/__init__.py +1 -0
  30. initium/inference/cache/__init__.py +1 -0
  31. initium/inference/cache/base_cache.py +112 -0
  32. initium/inference/cache/cache_config.py +21 -0
  33. initium/inference/cache/cache_registry.py +99 -0
  34. initium/inference/cache/cached_engine.py +264 -0
  35. initium/inference/cache/keys.py +77 -0
  36. initium/inference/cache/prefix_cache.py +59 -0
  37. initium/inference/cache/result_cache.py +57 -0
  38. initium/inference/cache/state_utils.py +74 -0
  39. initium/inference/cache/stores.py +167 -0
  40. initium/inference/capabilities.py +86 -0
  41. initium/inference/checkpoint_io.py +63 -0
  42. initium/inference/engine.py +88 -0
  43. initium/inference/inference_config.py +22 -0
  44. initium/inference/metrics.py +103 -0
  45. initium/inference/model_loader.py +270 -0
  46. initium/inference/run_inference.py +526 -0
  47. initium/inference/run_logging.py +173 -0
  48. initium/inference/tasks/__init__.py +1 -0
  49. initium/inference/tasks/audio_task.py +114 -0
  50. initium/inference/tasks/base_task.py +60 -0
  51. initium/inference/tasks/graph_traversal_task.py +235 -0
  52. initium/inference/tasks/multimodal_task.py +74 -0
  53. initium/inference/tasks/task_registry.py +68 -0
  54. initium/inference/tasks/text_task.py +114 -0
  55. initium/inference/tasks/video_task.py +114 -0
  56. initium/mamba_controller/mamba2_controller.py +433 -0
  57. initium/mamba_controller/mamba3_controller.py +242 -0
  58. initium/mamba_controller/mamba_backbone_parallel.py +291 -0
  59. initium/mamba_controller/mamba_controller.py +983 -0
  60. initium/mamba_controller/split_graph_dnc.py +623 -0
  61. initium/memory_manipulation/dynamic_memory_resize.py +157 -0
  62. initium/memory_manipulation/dynamic_n_controller.py +152 -0
  63. initium/memory_manipulation/link_matrix_ablation.py +130 -0
  64. initium/memory_manipulation/nvrtc_compat.py +143 -0
  65. initium/memory_manipulation/stochastic_write_head_v2.py +545 -0
  66. initium/setup_wheels.py +175 -0
  67. initium_deeplearning-0.1.0.dist-info/METADATA +253 -0
  68. initium_deeplearning-0.1.0.dist-info/RECORD +71 -0
  69. initium_deeplearning-0.1.0.dist-info/WHEEL +4 -0
  70. initium_deeplearning-0.1.0.dist-info/entry_points.txt +3 -0
  71. initium_deeplearning-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,167 @@
1
+ """
2
+ file: inference/cache/stores.py
3
+
4
+ Storage back-ends shared by all cache types: a byte-capped RAM LRU, an optional
5
+ on-disk store (survives across CLI invocations), and a tiered wrapper. Entries are
6
+ plain dicts {"payload", "compute_ms", "nbytes", "kind"} made only of tensors /
7
+ lists / tuples / dicts / numbers, so the disk tier can use torch.load(weights_only=True)
8
+ (no arbitrary pickle execution).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import glob
14
+ import os
15
+ from collections import OrderedDict
16
+ from dataclasses import asdict, dataclass
17
+
18
+ import torch
19
+
20
+
21
+ @dataclass
22
+ class CacheStats:
23
+ lookups: int = 0
24
+ hits: int = 0
25
+ misses: int = 0
26
+ stores: int = 0
27
+ ram_hits: int = 0
28
+ disk_hits: int = 0
29
+ bytes_stored: int = 0
30
+ est_saved_ms: float = 0.0 # sum of recorded cold-compute time of every reused entry
31
+
32
+ def hit(self, tier: str, saved_ms: float) -> None:
33
+ self.hits += 1
34
+ self.est_saved_ms += float(saved_ms)
35
+ if tier == "disk":
36
+ self.disk_hits += 1
37
+ else:
38
+ self.ram_hits += 1
39
+
40
+ def to_dict(self) -> dict:
41
+ d = asdict(self)
42
+ d["hit_rate"] = 100.0 * self.hits / max(self.lookups, 1)
43
+ return d
44
+
45
+
46
+ class MemoryStore:
47
+ def __init__(self, max_bytes: int):
48
+ self.max_bytes = max_bytes
49
+ self._d: OrderedDict[str, dict] = OrderedDict()
50
+ self.bytes = 0
51
+ self.evictions = 0
52
+
53
+ def get(self, key: str) -> dict | None:
54
+ e = self._d.get(key)
55
+ if e is not None:
56
+ self._d.move_to_end(key)
57
+ return e
58
+
59
+ def put(self, key: str, entry: dict) -> None:
60
+ nb = int(entry.get("nbytes", 0))
61
+ if nb > self.max_bytes:
62
+ return
63
+ old = self._d.pop(key, None)
64
+ if old is not None:
65
+ self.bytes -= int(old.get("nbytes", 0))
66
+ self._d[key] = entry
67
+ self.bytes += nb
68
+ while self.bytes > self.max_bytes and self._d:
69
+ _, ev = self._d.popitem(last=False)
70
+ self.bytes -= int(ev.get("nbytes", 0))
71
+ self.evictions += 1
72
+
73
+ def __len__(self) -> int:
74
+ return len(self._d)
75
+
76
+
77
+ class DiskStore:
78
+ def __init__(self, root: str, max_bytes: int):
79
+ self.root, self.max_bytes = root, max_bytes
80
+ os.makedirs(root, exist_ok=True)
81
+
82
+ def _path(self, key: str) -> str:
83
+ return os.path.join(self.root, key + ".pt")
84
+
85
+ def get(self, key: str) -> dict | None:
86
+ p = self._path(key)
87
+ if not os.path.isfile(p):
88
+ return None
89
+ try:
90
+ entry = torch.load(p, map_location="cpu", weights_only=True)
91
+ os.utime(p, None) # recency for eviction
92
+ return entry
93
+ except Exception: # corrupt / incompatible -> treat as miss
94
+ try:
95
+ os.remove(p)
96
+ except OSError:
97
+ pass
98
+ return None
99
+
100
+ def put(self, key: str, entry: dict) -> None:
101
+ p = self._path(key)
102
+ tmp = f"{p}.tmp{os.getpid()}"
103
+ try:
104
+ torch.save(entry, tmp)
105
+ os.replace(tmp, p) # atomic
106
+ except Exception as e:
107
+ print(f"[cache] disk write failed for {key}: {e}")
108
+ try:
109
+ os.remove(tmp)
110
+ except OSError:
111
+ pass
112
+ return
113
+ self._evict()
114
+
115
+ def _evict(self) -> None:
116
+ files = [
117
+ (os.path.getmtime(f), os.path.getsize(f), f)
118
+ for f in glob.glob(os.path.join(self.root, "*.pt"))
119
+ ]
120
+ total = sum(s for _, s, _ in files)
121
+ for _, size, f in sorted(files):
122
+ if total <= self.max_bytes:
123
+ break
124
+ try:
125
+ os.remove(f)
126
+ total -= size
127
+ except OSError:
128
+ pass
129
+
130
+ def clear(self) -> None:
131
+ for f in glob.glob(os.path.join(self.root, "*.pt")):
132
+ try:
133
+ os.remove(f)
134
+ except OSError:
135
+ pass
136
+
137
+
138
+ class TieredStore:
139
+ """RAM first, disk second (disk hits are promoted to RAM). Shared by every cache
140
+ of a run, so --cache-ram-mb / --cache-disk-mb are TOTAL budgets."""
141
+
142
+ def __init__(self, mem: MemoryStore, disk: DiskStore | None = None):
143
+ self.mem, self.disk = mem, disk
144
+
145
+ def get(self, key: str) -> tuple[dict, str] | None:
146
+ e = self.mem.get(key)
147
+ if e is not None:
148
+ return e, "ram"
149
+ if self.disk is not None:
150
+ e = self.disk.get(key)
151
+ if e is not None:
152
+ self.mem.put(key, e)
153
+ return e, "disk"
154
+ return None
155
+
156
+ def put(self, key: str, entry: dict) -> None:
157
+ self.mem.put(key, entry)
158
+ if self.disk is not None:
159
+ self.disk.put(key, entry)
160
+
161
+ def info(self) -> dict:
162
+ return {
163
+ "ram_mb": self.mem.bytes / 1e6,
164
+ "ram_entries": len(self.mem),
165
+ "ram_evictions": self.mem.evictions,
166
+ "disk_dir": self.disk.root if self.disk else None,
167
+ }
@@ -0,0 +1,86 @@
1
+ """
2
+ file: inference/capabilities.py
3
+
4
+ Decides, FROM THE CHECKPOINT ITSELF, which dataset types a model can run, and
5
+ refuses to continue when the requested type/dimensions don't match.
6
+
7
+ Sources of truth (in order):
8
+ 1. model_config["supported_dataset_types"] (written by core_training v19+).
9
+ May contain "*" = any type whose dims match the weights.
10
+ 2. Legacy checkpoints (no such key): infer from the tensor dims - every
11
+ implemented task type whose (input_dim, output_dim) equals the model's.
12
+ Dims that match nothing -> supported list is empty -> run refuses.
13
+ Independently of (1)/(2), the task's dims must ALWAYS equal the model's dims.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from initium.inference.inference_config import WILDCARD_TYPE
21
+ from initium.inference.tasks.task_registry import IMPLEMENTED_TYPES, canonical_type, get_task_class
22
+
23
+
24
+ class IncompatibleModelError(Exception):
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class ModelCapabilities:
30
+ supported_types: list[str]
31
+ input_dim: int
32
+ output_dim: int
33
+ source: str # "recorded" | "inferred-legacy" | "unknown"
34
+ controller_type: str
35
+
36
+ def supports(self, canon_type: str) -> bool:
37
+ return WILDCARD_TYPE in self.supported_types or canon_type in self.supported_types
38
+
39
+ def describe(self) -> str:
40
+ return (
41
+ f"supported types={self.supported_types} (source: {self.source}) | "
42
+ f"input_dim={self.input_dim} output_dim={self.output_dim} | "
43
+ f"controller={self.controller_type}"
44
+ )
45
+
46
+
47
+ def detect_capabilities(ckpt: dict) -> ModelCapabilities:
48
+ cfg = ckpt["model_config"]
49
+ out_dim, proj_in = ckpt["output_proj_state_dict"]["weight"].shape
50
+ input_dim = int(cfg.get("input_dim", cfg.get("input_size", proj_in)))
51
+ output_dim = int(out_dim)
52
+
53
+ recorded = cfg.get("supported_dataset_types")
54
+ if recorded:
55
+ types = [WILDCARD_TYPE if t == WILDCARD_TYPE else canonical_type(t) for t in recorded]
56
+ source = "recorded"
57
+ else:
58
+ types = []
59
+ for t in IMPLEMENTED_TYPES:
60
+ cls = get_task_class(t)
61
+ if (cls.input_dim, cls.output_dim) == (input_dim, output_dim):
62
+ types.append(t)
63
+ source = "inferred-legacy" if types else "unknown"
64
+
65
+ return ModelCapabilities(
66
+ types, input_dim, output_dim, source, cfg.get("controller_type", "lstm")
67
+ )
68
+
69
+
70
+ def require_type_supported(caps: ModelCapabilities, dataset_type: str, ckpt_path: str) -> str:
71
+ canon = canonical_type(dataset_type)
72
+ if not caps.supports(canon):
73
+ raise IncompatibleModelError(
74
+ f"checkpoint '{ckpt_path}' cannot run dataset type '{canon}'. "
75
+ f"It supports {caps.supported_types} ({caps.source}). Not continuing."
76
+ )
77
+ return canon
78
+
79
+
80
+ def require_dims_match(caps: ModelCapabilities, task, ckpt_path: str) -> None:
81
+ if (task.input_dim, task.output_dim) != (caps.input_dim, caps.output_dim):
82
+ raise IncompatibleModelError(
83
+ f"task '{task.name}' produces input_dim={task.input_dim}/output_dim={task.output_dim} "
84
+ f"but checkpoint '{ckpt_path}' expects input_dim={caps.input_dim}/"
85
+ f"output_dim={caps.output_dim}. Not continuing."
86
+ )
@@ -0,0 +1,63 @@
1
+ """
2
+ file: inference/checkpoint_io.py
3
+
4
+ Load a checkpoint written by core_training.save_checkpoint() (periodic or
5
+ final - same format) and describe it. Loads to CPU; weights_only=False for the
6
+ same reason as core_training.load_checkpoint_for_resume (self-produced file
7
+ that also stores python/numpy RNG state).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+
14
+ import torch
15
+
16
+ REQUIRED_KEYS = ("rnn_state_dict", "output_proj_state_dict", "model_config")
17
+
18
+
19
+ def load_checkpoint(path: str, model_config_override: dict | None = None) -> dict:
20
+ if not os.path.isfile(path):
21
+ raise FileNotFoundError(f"checkpoint not found: {path}")
22
+ ckpt = torch.load(path, map_location="cpu", weights_only=False)
23
+ if not isinstance(ckpt, dict):
24
+ raise ValueError(f"{path}: not a core_training checkpoint (expected a dict)")
25
+ missing = [k for k in REQUIRED_KEYS if k not in ckpt]
26
+ if missing:
27
+ raise ValueError(f"{path}: not a usable checkpoint, missing keys {missing}")
28
+ cfg = dict(ckpt["model_config"])
29
+ if model_config_override:
30
+ cfg.update(model_config_override)
31
+ print(f"[checkpoint_io] model_config overridden: {model_config_override}")
32
+ ckpt["model_config"] = cfg
33
+ return ckpt
34
+
35
+
36
+ def describe_checkpoint(ckpt: dict) -> str:
37
+ c = ckpt["model_config"]
38
+ lines = [
39
+ f"run_id={ckpt.get('run_id')} step={ckpt.get('step')} beta_target={ckpt.get('beta_target')}",
40
+ f"controller={c.get('controller_type', 'lstm')} hidden={c.get('hidden_size')} "
41
+ f"nr_cells={c.get('nr_cells')} cell_size={c.get('cell_size')} read_heads={c.get('read_heads')}",
42
+ f"link_matrix={c.get('link_matrix_mode', 'dense')} moe={c.get('moe_enabled', False)} "
43
+ f"split_graph={c.get('split_graph_enabled', False)}"
44
+ + (f"({c.get('split_graph_variant')})" if c.get("split_graph_enabled") else "")
45
+ + f" dynamic_n={c.get('dynamic_n_mode', False)}",
46
+ ]
47
+ combiner_variant = c.get("split_graph_combiner_variant")
48
+ if (
49
+ c.get("split_graph_enabled")
50
+ and c.get("split_graph_combiner_mode") == "controller"
51
+ and combiner_variant
52
+ ):
53
+ if "+" in combiner_variant:
54
+ lines.append(
55
+ f"combiner=controller stages={list(enumerate(combiner_variant.split('+')))} "
56
+ f"(use --ablate-combiner-stage <name|index>)"
57
+ )
58
+ else:
59
+ lines.append(
60
+ f"combiner=controller variant={combiner_variant!r} (single stage -- "
61
+ f"multi-source MoE routing applies only here, not to hybrid chains)"
62
+ )
63
+ return "\n".join(lines)
@@ -0,0 +1,88 @@
1
+ """
2
+ file: inference/engine.py
3
+
4
+ Runs episodes through a loaded model. Knows nothing about any specific task;
5
+ it only calls task.score_episode().
6
+
7
+ Two regimes (the reset_experience switch):
8
+ reset_experience=True : every episode starts from hidden=(None,None,None) with
9
+ reset_experience=True -> independent episodes, exactly
10
+ like the periodic OOD eval in training.
11
+ reset_experience=False : the (controller state, memory, last read) returned by
12
+ episode i is fed into episode i+1 with
13
+ reset_experience=False -> memory content and recurrent
14
+ state persist across the whole run. NO weights change;
15
+ any "learning" is in-context via memory + controller state.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from typing import Any
22
+
23
+ import torch
24
+
25
+ from initium.inference.metrics import EpisodeResult
26
+
27
+ FRESH_HIDDEN = (None, None, None)
28
+
29
+
30
+ class InferenceEngine:
31
+ def __init__(
32
+ self,
33
+ model,
34
+ output_proj,
35
+ task,
36
+ device,
37
+ ablate_memory: bool = False,
38
+ combiner_skip_stages: frozenset | None = None,
39
+ ):
40
+ self.model, self.output_proj, self.task = model, output_proj, task
41
+ self.device, self.ablate_memory = device, ablate_memory
42
+ self.combiner_skip_stages = combiner_skip_stages
43
+ self.model.eval()
44
+ self.output_proj.eval()
45
+
46
+ @torch.no_grad()
47
+ def _forward(self, episode, hidden, reset_experience: bool):
48
+ x = episode.input_seq.unsqueeze(0).to(self.device) # (1, T, input_dim)
49
+ kw: dict[str, Any] = dict(
50
+ reset_experience=reset_experience, pass_through_memory=not self.ablate_memory
51
+ )
52
+ if self.combiner_skip_stages:
53
+ kw["combiner_skip_stages"] = self.combiner_skip_stages
54
+ output, new_hidden = self.model(x, hidden, **kw)
55
+ output = output.transpose(0, 1).contiguous().squeeze(0) # (T, input_dim)
56
+ output = self.output_proj(output) # (T, output_dim)
57
+ return output.float().cpu(), new_hidden
58
+
59
+ def run(
60
+ self,
61
+ episodes,
62
+ reset_experience: bool,
63
+ verbose_n: int = 0,
64
+ progress_every: int = 0,
65
+ label: str = "",
66
+ ) -> list[EpisodeResult]:
67
+ hidden = FRESH_HIDDEN
68
+ results: list[EpisodeResult] = []
69
+ run_items = run_correct = 0
70
+ for i, ep in enumerate(episodes):
71
+ if reset_experience:
72
+ hidden = FRESH_HIDDEN
73
+ t0 = time.perf_counter()
74
+ out, hidden = self._forward(ep, hidden, reset_experience)
75
+ if self.device.type == "cuda":
76
+ torch.cuda.synchronize(self.device)
77
+ elapsed_ms = (time.perf_counter() - t0) * 1000.0
78
+
79
+ score = self.task.score_episode(out, ep, verbose=(i < verbose_n))
80
+ results.append(EpisodeResult(i, score, elapsed_ms, reset_experience))
81
+ run_items += score.n_items
82
+ run_correct += score.n_correct
83
+ if progress_every and ((i + 1) % progress_every == 0 or i + 1 == len(episodes)):
84
+ print(
85
+ f"[engine{label}] episode {i + 1}/{len(episodes)} | "
86
+ f"running item acc {100.0 * run_correct / max(run_items, 1):.2f}%"
87
+ )
88
+ return results
@@ -0,0 +1,22 @@
1
+ """
2
+ file: inference/inference_config.py
3
+
4
+ Inference-side constants. Pure constants, imported explicitly by the other
5
+ inference modules (same convention as config/train_config.py).
6
+ """
7
+
8
+ INFERENCE_LOG_DIR = "./inference_logs"
9
+
10
+ # Default task: graph traversal on the London Underground graph.
11
+ DEFAULT_DATASET_TYPE = "graph"
12
+ DEFAULT_DATASET_LINK = None # None -> built-in London Underground graph
13
+
14
+ DEFAULT_NUM_EPISODES = 200 # fresh mode (and persistent mode when --loop is omitted)
15
+ DEFAULT_WINDOW = 10 # episodes per window in the persistent-mode adaptation table
16
+ DEFAULT_SEED = 0
17
+ DEFAULT_VERBOSE_N = 3 # print predictions of the first N episodes
18
+ PROGRESS_EVERY = 25 # running-accuracy console line every N episodes (0 = off)
19
+
20
+ # A checkpoint whose supported_dataset_types contains this accepts ANY task
21
+ # whose input/output dims match its weights.
22
+ WILDCARD_TYPE = "*"
@@ -0,0 +1,103 @@
1
+ """
2
+ file: inference/metrics.py
3
+
4
+ Task-agnostic scoring containers and aggregation. A task's score_episode()
5
+ returns an EpisodeScore; everything downstream (breakdowns, windows,
6
+ adaptation trend, logging) only ever sees EpisodeScore, so a new task type
7
+ needs no changes here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+
18
+ @dataclass
19
+ class EpisodeScore:
20
+ n_items: int # scored items in the episode (graph: answer triples)
21
+ n_correct: int
22
+ perfect: bool # every item correct
23
+ group: Any = None # bucket key for breakdowns (graph: walk length / hop count)
24
+ fields: dict[str, tuple[int, int]] = field(default_factory=dict) # name -> (correct, total)
25
+
26
+ @property
27
+ def item_acc(self) -> float:
28
+ return 100.0 * self.n_correct / max(self.n_items, 1)
29
+
30
+
31
+ @dataclass
32
+ class EpisodeResult:
33
+ index: int
34
+ score: EpisodeScore
35
+ elapsed_ms: float
36
+ reset_experience: bool
37
+ cache_event: str = "" # "", "miss", "hit:result", "hit:prefix@22", ...
38
+
39
+
40
+ def aggregate(scores: list[EpisodeScore]) -> dict:
41
+ n_eps = len(scores)
42
+ n_items = sum(s.n_items for s in scores)
43
+ n_correct = sum(s.n_correct for s in scores)
44
+ n_perfect = sum(int(s.perfect) for s in scores)
45
+
46
+ by_group: dict[Any, list[int]] = {}
47
+ field_tot: dict[str, list[int]] = {}
48
+ for s in scores:
49
+ if s.group is not None:
50
+ g = by_group.setdefault(s.group, [0, 0, 0, 0])
51
+ g[0] += s.n_items
52
+ g[1] += s.n_correct
53
+ g[2] += 1
54
+ g[3] += int(s.perfect)
55
+ for name, (c, t) in s.fields.items():
56
+ f = field_tot.setdefault(name, [0, 0])
57
+ f[0] += c
58
+ f[1] += t
59
+
60
+ return {
61
+ "n_episodes": n_eps,
62
+ "item_acc": 100.0 * n_correct / max(n_items, 1),
63
+ "perfect_frac": 100.0 * n_perfect / max(n_eps, 1),
64
+ "by_group": {
65
+ g: {
66
+ "item_acc": 100.0 * v[1] / max(v[0], 1),
67
+ "perfect_frac": 100.0 * v[3] / max(v[2], 1),
68
+ "n_episodes": v[2],
69
+ }
70
+ for g, v in sorted(by_group.items())
71
+ },
72
+ "field_acc": {n: 100.0 * c / max(t, 1) for n, (c, t) in field_tot.items()},
73
+ }
74
+
75
+
76
+ def windowed(scores: list[EpisodeScore], window: int) -> list[dict]:
77
+ """Aggregate consecutive chunks of `window` episodes (persistent-mode adaptation view)."""
78
+ window = max(1, window)
79
+ out = []
80
+ for w, start in enumerate(range(0, len(scores), window)):
81
+ chunk = scores[start : start + window]
82
+ agg = aggregate(chunk)
83
+ agg.update(window_index=w, start_episode=start, end_episode=start + len(chunk) - 1)
84
+ out.append(agg)
85
+ return out
86
+
87
+
88
+ def adaptation_trend(scores: list[EpisodeScore], window: int) -> dict:
89
+ """First-window vs last-window accuracy and a least-squares slope of
90
+ per-episode accuracy -- a compact 'did it adapt mid-inference' read."""
91
+ if len(scores) < 2:
92
+ return {}
93
+ w = max(1, min(window, len(scores) // 2))
94
+ first, last = aggregate(scores[:w]), aggregate(scores[-w:])
95
+ accs = np.array([s.item_acc for s in scores], dtype=np.float64)
96
+ slope = float(np.polyfit(np.arange(len(accs)), accs, 1)[0])
97
+ return {
98
+ "window": w,
99
+ "first_window_item_acc": first["item_acc"],
100
+ "last_window_item_acc": last["item_acc"],
101
+ "delta_item_acc": last["item_acc"] - first["item_acc"],
102
+ "slope_acc_per_episode": slope,
103
+ }