File size: 6,761 Bytes
6342806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""Content-addressed intermediate-representation cache.

Keying: sha256 over the canonical JSON of
    (scenario_id, sensor_version, preprocessing_version,
     backbone_version, feature_schema_version)
plus an artifact kind. ANY version bump changes the key, so a version
mismatch is a MISS by construction — cross-version reuse is impossible, not
merely discouraged (tested in tests/test_nextgen/test_cache.py).

Invalidation strategy (documented, deliberate):
* No explicit invalidation exists or is needed: keys are content-addressed
  over everything the artifact depends on. Producing new sensor data, a new
  preprocessing pipeline, a new backbone or a new feature schema produces
  new keys; stale entries simply stop being referenced.
* Disk growth is bounded by an LRU-style entry cap (`max_entries`): oldest
  by mtime are evicted when the cap is exceeded.
* A deploy that changes semantics WITHOUT bumping a version is the one
  hazard; lineage records (lineage.py COMPONENT_VERSIONS) make the versions
  part of every evaluation run so that mistake is visible in audits.

REUSE NOTE: sensorflow.seqeval.paired.PredictionCache uses the same
fingerprint-keyed design for full-population success vectors. It is bound to
megaeval populations and npz success arrays, while this cache stores
arbitrary JSON-serializable feature artifacts for the bevfusion pipeline —
shared design, different artifact domain (documented reuse decision:
pattern reused, code not force-fit).
"""

from __future__ import annotations

import hashlib
import json
import os
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, Optional

from sensorflow.nextgen import store


@dataclass
class CacheKeyVersions:
    """Everything a shared intermediate representation depends on."""

    sensor_version: str = "bevfusion-sensors-1.0"
    preprocessing_version: str = "nextgen-preproc-1.0"
    backbone_version: str = "bevfusion-fusion-1.0"
    feature_schema_version: str = "nextgen-features-1.0"

    def key_for(self, scenario_id: str, kind: str = "features") -> str:
        blob = json.dumps({
            "scenario_id": scenario_id, "kind": kind,
            "sensor": self.sensor_version,
            "preprocessing": self.preprocessing_version,
            "backbone": self.backbone_version,
            "schema": self.feature_schema_version,
        }, sort_keys=True)
        return hashlib.sha256(blob.encode()).hexdigest()[:24]


@dataclass
class CacheStats:
    hits: int = 0
    misses: int = 0
    compute_time_s: float = 0.0     # time spent on misses (real compute)
    lookup_time_s: float = 0.0      # time spent serving hits
    per_kind: Dict[str, Dict[str, int]] = field(default_factory=dict)

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total else 0.0

    def as_dict(self) -> Dict:
        return {"hits": self.hits, "misses": self.misses,
                "hit_rate": round(self.hit_rate, 4),
                "compute_time_s": round(self.compute_time_s, 4),
                "lookup_time_s": round(self.lookup_time_s, 4),
                "per_kind": self.per_kind}


class FeatureCache:
    """Memory + disk cache of shared intermediate representations."""

    def __init__(self, subdir: str = "cache", max_entries: int = 4096,
                 max_memory_entries: int = 256):
        self.subdir = subdir
        self.max_entries = max_entries
        self.max_memory_entries = max_memory_entries
        self._mem: Dict[str, Any] = {}
        self._lock = threading.Lock()
        self.stats = CacheStats()

    def _dir(self) -> Path:
        return Path(store.nextgen_root()) / self.subdir

    def _path(self, key: str) -> Path:
        return self._dir() / f"{key}.json"

    def get_or_compute(self, key: str, compute: Callable[[], Any],
                       kind: str = "features") -> Any:
        pk = self.stats.per_kind.setdefault(kind, {"hits": 0, "misses": 0})
        with self._lock:
            if key in self._mem:
                t0 = time.perf_counter()
                value = self._mem[key]
                self.stats.hits += 1
                pk["hits"] += 1
                self.stats.lookup_time_s += time.perf_counter() - t0
                return value
        path = self._path(key)
        if path.exists():
            t0 = time.perf_counter()
            with open(path) as f:
                value = json.load(f)
            os.utime(path)  # LRU recency
            with self._lock:
                self.stats.hits += 1
                pk["hits"] += 1
                self.stats.lookup_time_s += time.perf_counter() - t0
                self._remember(key, value)
            return value

        t0 = time.perf_counter()
        value = compute()
        elapsed = time.perf_counter() - t0
        self._dir().mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        with open(tmp, "w") as f:
            json.dump(value, f)
        os.replace(tmp, path)
        with self._lock:
            self.stats.misses += 1
            pk["misses"] += 1
            self.stats.compute_time_s += elapsed
            self._remember(key, value)
        self._evict_if_needed()
        return value

    def _remember(self, key: str, value: Any) -> None:
        if len(self._mem) >= self.max_memory_entries:
            self._mem.clear()
        self._mem[key] = value

    def _evict_if_needed(self) -> None:
        d = self._dir()
        if not d.exists():
            return
        entries = sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime)
        for p in entries[:max(0, len(entries) - self.max_entries)]:
            try:
                p.unlink()
            except OSError:
                pass

    def contains(self, key: str) -> bool:
        with self._lock:
            if key in self._mem:
                return True
        return self._path(key).exists()

    def clear(self) -> None:
        with self._lock:
            self._mem.clear()
            self.stats = CacheStats()
        d = self._dir()
        if d.exists():
            for p in d.glob("*.json"):
                try:
                    p.unlink()
                except OSError:
                    pass

    def reset_stats(self) -> None:
        with self._lock:
            self.stats = CacheStats()


_CACHE: Optional[FeatureCache] = None
_CACHE_LOCK = threading.Lock()


def get_feature_cache() -> FeatureCache:
    global _CACHE
    with _CACHE_LOCK:
        if _CACHE is None:
            _CACHE = FeatureCache()
        return _CACHE


def reset_feature_cache() -> None:
    global _CACHE
    with _CACHE_LOCK:
        _CACHE = None