prism-engine 1.0.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.
prism/__init__.py ADDED
@@ -0,0 +1,156 @@
1
+ """Prism Compute Engine — speculative-redundant-compute elimination for ML workloads.
2
+
3
+ Public API:
4
+ from prism import PrismEngine, WorkloadClassifier, WasteReport, PrismConfig
5
+ """
6
+ import contextlib
7
+
8
+ from prism.hashing import hash_object, hash_call, hash_numpy, fingerprint_array
9
+ from prism.config import PrismConfig, CacheConfig, HashingConfig, PredictorConfig, AdapterConfig, LoggingConfig, load_config
10
+
11
+ __version__ = "1.0.0"
12
+
13
+
14
+ # ─── Default engine + decorator API ──────────────────────────────────────────
15
+ _default_engine = None
16
+
17
+
18
+ def get_default_engine():
19
+ """Return (creating if needed) the module-global default PrismEngine.
20
+
21
+ The default engine is in ACTIVE mode with the default config. Use
22
+ ``set_default_engine`` to override it (e.g. with a disk-backed or observe
23
+ engine).
24
+ """
25
+ global _default_engine
26
+ if _default_engine is None:
27
+ from prism.core import PrismEngine, EngineMode
28
+ _default_engine = PrismEngine(mode=EngineMode.ACTIVE)
29
+ return _default_engine
30
+
31
+
32
+ def set_default_engine(engine) -> None:
33
+ """Override the module-global default engine used by ``@prism.cache``."""
34
+ global _default_engine
35
+ _default_engine = engine
36
+
37
+
38
+ def cached(arg=None):
39
+ """Decorator that routes a function's calls through a Prism engine.
40
+
41
+ Two usage forms::
42
+
43
+ @prism.cached # uses the default active engine
44
+ def train(X, y):
45
+ ...
46
+
47
+ @prism.cached(my_engine) # use an explicit engine
48
+ def train(X, y):
49
+ ...
50
+
51
+ In ACTIVE mode the wrapped function only runs on a cache miss; in OBSERVE
52
+ mode it always runs and the would-have-been-hit is recorded.
53
+
54
+ (Named ``prism.cached`` rather than ``prism.cache`` to avoid clashing with
55
+ the ``prism.cache`` submodule that holds the cache backends.)
56
+ """
57
+ # `arg` is a plain function -> used as `@prism.cached` with no engine arg.
58
+ if arg is None or not hasattr(arg, "call"):
59
+ engine = get_default_engine()
60
+ if arg is None:
61
+ # `@prism.cached(engine)` form: return the decorator bound to engine.
62
+ return engine.cached
63
+ # `@prism.cached` direct form: arg is the function.
64
+ return engine.cached(arg)
65
+ # `arg` is an engine -> `@prism.cached(engine)` returns its cache decorator.
66
+ return arg.cached
67
+
68
+
69
+ @contextlib.contextmanager
70
+ def scope(engine, *adapter_classes):
71
+ """Context manager that wraps a set of adapters around an engine and
72
+ guarantees they are unwrapped on exit (even on exception).
73
+
74
+ Example::
75
+
76
+ from prism import PrismEngine, EngineMode, scope
77
+ from prism.adapters.sklearn_adapter import SklearnAdapter
78
+
79
+ engine = PrismEngine(mode=EngineMode.ACTIVE)
80
+ with scope(engine, SklearnAdapter):
81
+ run_pipeline() # sklearn ops routed through Prism
82
+ # adapters restored on exit
83
+ """
84
+ adapters = []
85
+ try:
86
+ for cls in adapter_classes:
87
+ a = cls(engine)
88
+ a.wrap()
89
+ adapters.append(a)
90
+ yield engine
91
+ finally:
92
+ for a in adapters:
93
+ try:
94
+ a.unwrap()
95
+ except Exception:
96
+ pass
97
+
98
+
99
+ def __getattr__(name: str):
100
+ """Lazy imports — modules are loaded on first attribute access."""
101
+ _lazy = {
102
+ "PrismEngine": ("prism.core", "PrismEngine"),
103
+ "EngineMode": ("prism.core", "EngineMode"),
104
+ "WorkloadClassifier": ("prism.classifier", "WorkloadClassifier"),
105
+ "ClassificationReport": ("prism.classifier", "ClassificationReport"),
106
+ "Observer": ("prism.observer", "Observer"),
107
+ "WasteReport": ("prism.report", "WasteReport"),
108
+ "StatisticalPredictor": ("prism.predictor", "StatisticalPredictor"),
109
+ "LSTMPredictor": ("prism.predictor_lstm", "LSTMPredictor"),
110
+ "MetricsExporter": ("prism.metrics", "MetricsExporter"),
111
+ "RedisCache": ("prism.redis_cache", "RedisCache"),
112
+ "Serializer": ("prism.serialization", "Serializer"),
113
+ "SemanticCache": ("prism.semantic_cache", "SemanticCache"),
114
+ "TelemetryHook": ("prism.telemetry", "TelemetryHook"),
115
+ "CostConfig": ("prism.config", "CostConfig"),
116
+ }
117
+ if name in _lazy:
118
+ import importlib
119
+
120
+ mod_name, attr = _lazy[name]
121
+ mod = importlib.import_module(mod_name)
122
+ return getattr(mod, attr)
123
+ raise AttributeError(f"module 'prism' has no attribute {name!r}")
124
+
125
+
126
+ __all__ = [
127
+ "PrismEngine",
128
+ "EngineMode",
129
+ "WorkloadClassifier",
130
+ "ClassificationReport",
131
+ "Observer",
132
+ "WasteReport",
133
+ "StatisticalPredictor",
134
+ "LSTMPredictor",
135
+ "MetricsExporter",
136
+ "RedisCache",
137
+ "Serializer",
138
+ "SemanticCache",
139
+ "TelemetryHook",
140
+ "CostConfig",
141
+ "PrismConfig",
142
+ "CacheConfig",
143
+ "HashingConfig",
144
+ "PredictorConfig",
145
+ "AdapterConfig",
146
+ "LoggingConfig",
147
+ "load_config",
148
+ "hash_object",
149
+ "hash_call",
150
+ "hash_numpy",
151
+ "fingerprint_array",
152
+ "cached",
153
+ "scope",
154
+ "get_default_engine",
155
+ "set_default_engine",
156
+ ]
@@ -0,0 +1,5 @@
1
+ """Prism adapters — framework-specific wrappers that route ML ops through PrismEngine.
2
+
3
+ Each adapter patches framework methods to route through a PrismEngine, and
4
+ provides unwrap() to restore original behavior.
5
+ """
@@ -0,0 +1,146 @@
1
+ """Data pipeline adapter — routes pandas/numpy data transforms through Prism.
2
+
3
+ Validated workload: repeated transform() / fit_transform() on identical
4
+ DataFrames or arrays — dedup the transform when input data is unchanged.
5
+
6
+ Patches:
7
+ - numpy: np.sort, np.unique, np.argsort, np.partition, np.searchsorted,
8
+ np.histogram, np.bincount, np.digitize, np.percentile, np.quantile
9
+ - pandas: DataFrame.apply, DataFrame.groupby, DataFrame.merge,
10
+ DataFrame.pivot_table, DataFrame.resample, Series.map,
11
+ Series.value_counts
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Optional
16
+
17
+ import numpy as np
18
+
19
+ from prism.core import PrismEngine
20
+
21
+
22
+ # numpy functions to patch
23
+ _NUMPY_FUNCS = (
24
+ "sort", "unique", "argsort", "partition", "searchsorted",
25
+ "histogram", "bincount", "digitize", "percentile", "quantile",
26
+ )
27
+
28
+ # pandas DataFrame methods to patch
29
+ _PANDAS_DF_METHODS = (
30
+ "apply", "groupby", "merge", "pivot_table", "resample",
31
+ )
32
+
33
+ # pandas Series methods to patch
34
+ _PANDAS_SERIES_METHODS = (
35
+ "map", "value_counts",
36
+ )
37
+
38
+
39
+ class DataPipelineAdapter:
40
+ """Patch common data-transform functions to route through Prism.
41
+
42
+ Usage:
43
+ adapter = DataPipelineAdapter(engine)
44
+ adapter.wrap()
45
+ ...run your data pipeline...
46
+ adapter.unwrap()
47
+ """
48
+
49
+ def __init__(self, engine: PrismEngine) -> None:
50
+ self.engine = engine
51
+ self._originals: dict[str, Any] = {}
52
+
53
+ def wrap(self) -> "DataPipelineAdapter":
54
+ # numpy functions
55
+ for fn_name in _NUMPY_FUNCS:
56
+ key = f"np.{fn_name}"
57
+ if key in self._originals:
58
+ continue
59
+ original = getattr(np, fn_name, None)
60
+ if original is None:
61
+ continue
62
+ self._originals[key] = original
63
+ setattr(np, fn_name, self._make_np_wrapper(original, fn_name))
64
+
65
+ # pandas methods (optional)
66
+ try:
67
+ import pandas as pd
68
+
69
+ for method_name in _PANDAS_DF_METHODS:
70
+ key = f"DataFrame.{method_name}"
71
+ if key in self._originals:
72
+ continue
73
+ if hasattr(pd.DataFrame, method_name):
74
+ original = getattr(pd.DataFrame, method_name)
75
+ self._originals[key] = original
76
+ setattr(
77
+ pd.DataFrame,
78
+ method_name,
79
+ self._make_method_wrapper(original, f"DataFrame.{method_name}"),
80
+ )
81
+
82
+ for method_name in _PANDAS_SERIES_METHODS:
83
+ key = f"Series.{method_name}"
84
+ if key in self._originals:
85
+ continue
86
+ if hasattr(pd.Series, method_name):
87
+ original = getattr(pd.Series, method_name)
88
+ self._originals[key] = original
89
+ setattr(
90
+ pd.Series,
91
+ method_name,
92
+ self._make_method_wrapper(original, f"Series.{method_name}"),
93
+ )
94
+ except ImportError:
95
+ pass
96
+
97
+ return self
98
+
99
+ def unwrap(self) -> None:
100
+ # Restore numpy
101
+ for key, original in list(self._originals.items()):
102
+ if key.startswith("np."):
103
+ setattr(np, key[3:], original)
104
+ del self._originals[key]
105
+ # Restore pandas
106
+ try:
107
+ import pandas as pd
108
+
109
+ for key, original in list(self._originals.items()):
110
+ if key.startswith("DataFrame."):
111
+ setattr(pd.DataFrame, key[len("DataFrame."):], original)
112
+ del self._originals[key]
113
+ elif key.startswith("Series."):
114
+ setattr(pd.Series, key[len("Series."):], original)
115
+ del self._originals[key]
116
+ except ImportError:
117
+ pass
118
+ self._originals.clear()
119
+
120
+ # ─── Context-manager protocol ─────────────────────────────────────────
121
+ def __enter__(self) -> "DataPipelineAdapter":
122
+ return self.wrap()
123
+
124
+ def __exit__(self, *exc) -> bool:
125
+ self.unwrap()
126
+ return False
127
+
128
+ def _make_np_wrapper(self, original, name: str):
129
+ engine = self.engine
130
+
131
+ def wrapped(*args, **kwargs):
132
+ fn_name = f"np.{name}"
133
+ return engine.call(original, args, kwargs, fn_name=fn_name)
134
+
135
+ wrapped.__name__ = name
136
+ return wrapped
137
+
138
+ def _make_method_wrapper(self, original, name: str):
139
+ engine = self.engine
140
+
141
+ def wrapped(self_obj, *args, **kwargs):
142
+ fn_name = name
143
+ return engine.call(original, (self_obj,) + args, kwargs, fn_name=fn_name)
144
+
145
+ wrapped.__name__ = name.split(".")[-1]
146
+ return wrapped
@@ -0,0 +1,77 @@
1
+ """JAX adapter — routes jit-compiled training steps through Prism.
2
+
3
+ Validated workload: training sweep with repeated (params + data) — dedup
4
+ jit-compiled steps. Optional dependency: skips gracefully when jax is absent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from prism.core import PrismEngine
11
+
12
+
13
+ class JAXAdapter:
14
+ """Patch jax.jit (and jax.pmap) to route through Prism.
15
+
16
+ Usage:
17
+ adapter = JAXAdapter(engine)
18
+ adapter.wrap()
19
+ ...run your jax workload...
20
+ adapter.unwrap()
21
+
22
+ If jax is not installed, wrap()/unwrap() are no-ops.
23
+ """
24
+
25
+ def __init__(self, engine: PrismEngine) -> None:
26
+ self.engine = engine
27
+ self._originals: dict[str, Any] = {}
28
+ self._jax_available = self._check_jax()
29
+
30
+ def _check_jax(self) -> bool:
31
+ try:
32
+ import jax # noqa: F401
33
+
34
+ return True
35
+ except ImportError:
36
+ return False
37
+
38
+ def wrap(self) -> "JAXAdapter":
39
+ if not self._jax_available:
40
+ return self
41
+ import jax
42
+
43
+ if "jit" in self._originals:
44
+ return self
45
+ self._originals["jit"] = jax.jit
46
+ engine = self.engine
47
+ original_jit = jax.jit
48
+
49
+ def prism_jit(fun, *args, **kwargs):
50
+ # Wrap the jitted function so each call routes through Prism
51
+ jitted = original_jit(fun, *args, **kwargs)
52
+
53
+ def prism_call(*call_args, **call_kwargs):
54
+ fn_name = getattr(fun, "__qualname__", repr(fun))
55
+ return engine.call(jitted, call_args, call_kwargs, fn_name=fn_name)
56
+
57
+ return prism_call
58
+
59
+ jax.jit = prism_jit
60
+ return self
61
+
62
+ def unwrap(self) -> None:
63
+ if not self._jax_available or not self._originals:
64
+ return
65
+ import jax
66
+
67
+ for name, original in self._originals.items():
68
+ setattr(jax, name, original)
69
+ self._originals.clear()
70
+
71
+ # ─── Context-manager protocol ─────────────────────────────────────────
72
+ def __enter__(self) -> "JAXAdapter":
73
+ return self.wrap()
74
+
75
+ def __exit__(self, *exc) -> bool:
76
+ self.unwrap()
77
+ return False
@@ -0,0 +1,167 @@
1
+ """PyTorch adapter — routes forward passes and eval loops through Prism.
2
+
3
+ Validated workload: eval loop over a model on the same val set across epochs —
4
+ forward passes on unchanged (model + input) are deduped.
5
+
6
+ Patching strategy:
7
+ - `nn.Module.__call__` is patched to intercept all forward passes.
8
+ - A re-entrancy guard prevents intercepting nested submodule calls.
9
+ - In training mode (`model.training == True`), the model's parameters change
10
+ between calls, so we include `model.training` in the hash key and also
11
+ hash the model's parameter state — this means training-step forward passes
12
+ are only deduped if the parameters haven't changed (rare in training, common
13
+ in eval).
14
+ - In eval mode (`model.training == False`), parameters are frozen, so
15
+ repeated forward passes on the same input are always deduped.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ from typing import Any, Optional
21
+
22
+ from prism.core import PrismEngine
23
+ from prism.hashing import hash_object
24
+
25
+
26
+ class PyTorchAdapter:
27
+ """Patch torch.nn.Module.__call__ to route through Prism.
28
+
29
+ Usage:
30
+ adapter = PyTorchAdapter(engine)
31
+ adapter.wrap()
32
+ ...run your torch workload...
33
+ adapter.unwrap()
34
+
35
+ Options:
36
+ hash_params: If True (default), include model parameter state in the
37
+ cache key. This is essential for correctness in training mode —
38
+ without it, a forward pass after a parameter update would return
39
+ the stale cached result. In eval-only workloads, set to False for
40
+ faster hashing.
41
+ dedup_training: If False (default), don't dedup forward passes when
42
+ model.training == True (training steps almost always change params,
43
+ so dedup is pointless and the param hash is expensive). If True,
44
+ attempt dedup in training mode too (uses param hashing).
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ engine: PrismEngine,
50
+ hash_params: bool = True,
51
+ dedup_training: bool = False,
52
+ ) -> None:
53
+ self.engine = engine
54
+ self.hash_params = hash_params
55
+ self.dedup_training = dedup_training
56
+ self._original_call = None
57
+ self._original_state_dict = None
58
+ self._torch_available = self._check_torch()
59
+ self._in_prism_call = threading.local()
60
+
61
+ def _check_torch(self) -> bool:
62
+ try:
63
+ import torch # noqa: F401
64
+
65
+ return True
66
+ except ImportError:
67
+ return False
68
+
69
+ def wrap(self) -> "PyTorchAdapter":
70
+ if not self._torch_available:
71
+ return self
72
+ import torch.nn as nn
73
+
74
+ if self._original_call is not None:
75
+ return self
76
+ self._original_call = nn.Module.__call__
77
+ engine = self.engine
78
+ original = self._original_call
79
+ hash_params = self.hash_params
80
+ dedup_training = self.dedup_training
81
+
82
+ def patched_call(self_mod, *args, **kwargs):
83
+ # Re-entrancy guard
84
+ if getattr(self._in_prism_call, "active", False):
85
+ return original(self_mod, *args, **kwargs)
86
+
87
+ # In training mode, skip dedup unless explicitly enabled
88
+ if self_mod.training and not dedup_training:
89
+ return original(self_mod, *args, **kwargs)
90
+
91
+ self._in_prism_call.active = True
92
+ try:
93
+ fn_name = f"{type(self_mod).__qualname__}.forward"
94
+
95
+ if hash_params and hasattr(self_mod, "state_dict"):
96
+ try:
97
+ param_hash = _hash_model_params(self_mod)
98
+ except Exception:
99
+ param_hash = None
100
+ if param_hash is not None:
101
+ # Pass param_hash as a kwarg to the engine so it's
102
+ # included in the cache key. The wrapper strips it
103
+ # before calling the original function.
104
+ _orig = original
105
+
106
+ def _param_aware_wrapper(*a, **kw):
107
+ kw.pop("__prism_param_hash__", None)
108
+ return _orig(*a, **kw)
109
+
110
+ return engine.call(
111
+ _param_aware_wrapper,
112
+ (self_mod,) + args,
113
+ {**kwargs, "__prism_param_hash__": param_hash},
114
+ fn_name=fn_name,
115
+ )
116
+
117
+ return engine.call(
118
+ original,
119
+ (self_mod,) + args,
120
+ kwargs,
121
+ fn_name=fn_name,
122
+ )
123
+ finally:
124
+ self._in_prism_call.active = False
125
+
126
+ nn.Module.__call__ = patched_call
127
+ return self
128
+
129
+ def unwrap(self) -> None:
130
+ if not self._torch_available or self._original_call is None:
131
+ return
132
+ import torch.nn as nn
133
+
134
+ nn.Module.__call__ = self._original_call
135
+ self._original_call = None
136
+
137
+ # ─── Context-manager protocol ─────────────────────────────────────────
138
+ def __enter__(self) -> "PyTorchAdapter":
139
+ return self.wrap()
140
+
141
+ def __exit__(self, *exc) -> bool:
142
+ self.unwrap()
143
+ return False
144
+
145
+
146
+ def _hash_model_params(model: Any) -> str:
147
+ """Hash a model's parameters — used to detect parameter changes between calls.
148
+
149
+ Sums all parameter tensors and hashes the sum + count. This is O(n) in
150
+ parameters but much cheaper than hashing every tensor's full content.
151
+ For large models, this could be replaced with a sampled fingerprint.
152
+ """
153
+ import torch
154
+
155
+ parts = []
156
+ total_params = 0
157
+ param_sum = torch.tensor(0.0)
158
+ for name, param in model.named_parameters():
159
+ total_params += param.numel()
160
+ parts.append(f"{name}:{param.shape}")
161
+ param_sum = param_sum + param.detach().float().sum()
162
+ # Include buffers (e.g. BatchNorm running stats) — they change in eval too
163
+ for name, buf in model.named_buffers():
164
+ total_params += buf.numel()
165
+ parts.append(f"buf:{name}:{buf.shape}")
166
+ param_sum = param_sum + buf.detach().float().sum()
167
+ return f"params={total_params}|sum={float(param_sum):.10f}|shapes={','.join(parts)}"
@@ -0,0 +1,140 @@
1
+ """sklearn adapter — routes fit / fit_transform / transform / predict through Prism.
2
+
3
+ Validated workload: GridSearchCV with repeated param combos — fit on identical
4
+ (estimator + params + data) is deduped. Also covers StandardScaler, PCA, etc.
5
+
6
+ Patching strategy: sklearn estimators define `fit` on their own concrete class
7
+ (shadowing BaseEstimator), so we walk all loaded BaseEstimator subclasses and
8
+ patch each class that defines the target method in its own __dict__.
9
+
10
+ fit/fit_transform special handling: sklearn's `fit` mutates `self` in place AND
11
+ returns `self`. On a cache hit, the engine returns a cached deep copy of a
12
+ previously-fitted estimator — but the target estimator is never mutated. So
13
+ the wrapper must copy fitted attributes (those ending in `_`) from the cached
14
+ result onto the target estimator on a hit.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ from typing import Any
20
+
21
+ from prism.core import PrismEngine
22
+ from prism.hashing import hash_call
23
+
24
+
25
+ class SklearnAdapter:
26
+ _PATCHED_METHODS = ("fit", "fit_transform", "transform", "predict", "score")
27
+
28
+ def __init__(self, engine: PrismEngine) -> None:
29
+ self.engine = engine
30
+ self._originals: dict[tuple[type, str], Any] = {}
31
+
32
+ def wrap(self) -> "SklearnAdapter":
33
+ from sklearn.base import BaseEstimator
34
+
35
+ classes = self._all_subclasses(BaseEstimator)
36
+ classes.add(BaseEstimator)
37
+ for cls in classes:
38
+ for method_name in self._PATCHED_METHODS:
39
+ key = (cls, method_name)
40
+ if key in self._originals:
41
+ continue
42
+ if method_name not in cls.__dict__:
43
+ continue
44
+ original = cls.__dict__[method_name]
45
+ if getattr(original, "_prism_wrapped", False):
46
+ continue
47
+ self._originals[key] = original
48
+ if method_name in ("fit", "fit_transform"):
49
+ wrapped = self._make_fit_wrapper(original, method_name)
50
+ else:
51
+ wrapped = self._make_simple_wrapper(original, method_name)
52
+ wrapped._prism_wrapped = True
53
+ setattr(cls, method_name, wrapped)
54
+ return self
55
+
56
+ def unwrap(self) -> None:
57
+ for (cls, method_name), original in self._originals.items():
58
+ setattr(cls, method_name, original)
59
+ self._originals.clear()
60
+
61
+ # ─── Context-manager protocol ─────────────────────────────────────────
62
+ def __enter__(self) -> "SklearnAdapter":
63
+ return self.wrap()
64
+
65
+ def __exit__(self, *exc) -> bool:
66
+ self.unwrap()
67
+ return False
68
+
69
+ def _all_subclasses(self, cls: type) -> set[type]:
70
+ result = set()
71
+ for sub in cls.__subclasses__():
72
+ result.add(sub)
73
+ result.update(self._all_subclasses(sub))
74
+ return result
75
+
76
+ def _make_simple_wrapper(self, original, method_name: str):
77
+ """For methods that just return a value (transform, predict, score)."""
78
+ engine = self.engine
79
+
80
+ def wrapped(self_est, *args, **kwargs):
81
+ fn_name = f"{type(self_est).__qualname__}.{method_name}"
82
+ return engine.call(original, (self_est,) + args, kwargs, fn_name=fn_name)
83
+
84
+ wrapped.__name__ = method_name
85
+ wrapped.__qualname__ = f"PrismAdapter.{method_name}"
86
+ return wrapped
87
+
88
+ def _make_fit_wrapper(self, original, method_name: str):
89
+ """For fit / fit_transform — handles in-place mutation + state restoration."""
90
+ engine = self.engine
91
+
92
+ def wrapped(self_est, *args, **kwargs):
93
+ fn_name = f"{type(self_est).__qualname__}.{method_name}"
94
+ # Use the engine, but we need to know if it was a hit to restore state.
95
+ # Strategy: check engine.stats.misses before and after the call.
96
+ misses_before = engine.stats.misses
97
+ result = engine.call(original, (self_est,) + args, kwargs, fn_name=fn_name)
98
+ was_hit = engine.stats.misses == misses_before # no new miss = hit
99
+
100
+ if was_hit and result is not self_est:
101
+ # Cache hit — `result` is a deep copy of a previously-fitted
102
+ # estimator. Copy its fitted attributes onto `self_est`.
103
+ _restore_fitted_state(result, self_est)
104
+ # For fit_transform, also return the cached transformed data
105
+ # (which is `result`'s transformed output, already returned by
106
+ # engine.call). For fit, return self_est (the now-restored estimator).
107
+ if method_name == "fit":
108
+ return self_est
109
+ # fit_transform: result is the cached transformed array — return it
110
+ return result
111
+ # Miss (real fit ran, self_est is mutated) — return as normal
112
+ return result
113
+
114
+ wrapped.__name__ = method_name
115
+ wrapped.__qualname__ = f"PrismAdapter.{method_name}"
116
+ return wrapped
117
+
118
+
119
+ def _restore_fitted_state(source: Any, target: Any) -> None:
120
+ """Copy fitted attributes (ending in `_`) from source estimator onto target.
121
+
122
+ This is the key to correct fit caching: on a cache hit, the target estimator
123
+ hasn't been mutated by a real fit, so we must transfer the fitted state from
124
+ the cached copy.
125
+ """
126
+ # Copy all attributes ending in `_` (sklearn convention for fitted attrs)
127
+ # plus any attribute that exists on source but not target.
128
+ for attr in dir(source):
129
+ if attr.startswith("_"):
130
+ continue
131
+ if not attr.endswith("_"):
132
+ continue
133
+ try:
134
+ val = getattr(source, attr)
135
+ except AttributeError:
136
+ continue
137
+ try:
138
+ setattr(target, attr, copy.deepcopy(val))
139
+ except (AttributeError, TypeError):
140
+ pass