@@ -153,6 +153,8 @@ class BalanceFrame:
153153 _sf_target : SampleFrame | None
154154 # pyre-fixme[13]: Attributes are initialized in _create() / from_frame()
155155 _adjustment_model : dict [str , Any ] | None
156+ # pyre-fixme[13]: Attributes are initialized in _create() / from_frame()
157+ _adjustment_history : list [dict [str , Any ]]
156158 # pyre-fixme[4]: Attributes are initialized in from_frame() / _create()
157159 # _links is a defaultdict(list) but by convention stores single objects
158160 # (not lists) for the "target" and "unadjusted" keys. The defaultdict
@@ -182,6 +184,67 @@ def _sync_sampleframe_state_from_responder(self, responder: SampleFrame) -> None
182184 # pyrefly: ignore [missing-attribute]
183185 self ._df_dtypes = responder ._df_dtypes
184186
187+ @staticmethod
188+ def _copy_adjustment_history_from (
189+ source : Any , * , deep : bool = False
190+ ) -> list [dict [str , Any ]]:
191+ """Return adjustment history copied from *source*.
192+
193+ Older objects, tests, or deserialized instances may not have the
194+ private attribute yet; those are treated as having no history.
195+
196+ ``deep=False`` (default) performs an inexpensive structural copy of the
197+ history list and per-entry dictionaries while preserving model object
198+ references. This is suitable for internal object construction paths and
199+ avoids eagerly duplicating large fitted artifacts.
200+
201+ ``deep=True`` is intended for public read boundaries. It tries to deep
202+ copy each entry, but gracefully falls back to a shallow per-entry copy
203+ when model artifacts are not deepcopy-safe.
204+ """
205+ history = getattr (source , "_adjustment_history" , [])
206+ if not isinstance (history , list ):
207+ return []
208+
209+ copied : list [dict [str , Any ]] = []
210+ for entry in history :
211+ if not isinstance (entry , dict ):
212+ continue
213+ if deep :
214+ try :
215+ copied .append (cast (dict [str , Any ], copy .deepcopy (entry )))
216+ continue
217+ except (TypeError , RuntimeError , copy .Error ):
218+ # Best-effort fallback: copy the entry dict and also
219+ # copy nested model mappings to keep caller mutations
220+ # from leaking back into internal state.
221+ fallback = dict (entry )
222+ model_obj = fallback .get ("model" )
223+ if isinstance (model_obj , dict ):
224+ fallback ["model" ] = dict (model_obj )
225+ copied .append (fallback )
226+ continue
227+ copied .append (dict (entry ))
228+ return copied
229+
230+ def _clear_adjustment_state (self ) -> None :
231+ """Clear fitted adjustment state and its chronological history."""
232+ self ._adjustment_model = None
233+ self ._adjustment_history = []
234+ # pyrefly: ignore [missing-attribute]
235+ self ._links .pop ("unadjusted" , None )
236+
237+ def _append_adjustment_history_entry (self , method : str , model : Any ) -> None :
238+ """Append one adjustment-history entry.
239+
240+ The model payload is stored by reference to avoid expensive deep copies
241+ of large fitted artifacts (estimators/matrices). Public callers should
242+ use :attr:`adjustment_history`, which returns a copied view.
243+ """
244+ if not hasattr (self , "_adjustment_history" ):
245+ self ._adjustment_history = []
246+ self ._adjustment_history .append ({"method" : method , "model" : model })
247+
185248 @property
186249 def _df_dtypes (self ) -> pd .Series | None :
187250 """Original dtypes, delegated to ``_sf_sample._df_dtypes``."""
@@ -365,6 +428,7 @@ def _create(
365428 instance ._sf_sample = sample # same object initially
366429 instance ._sf_target = target
367430 instance ._adjustment_model = None
431+ instance ._adjustment_history = []
368432 instance ._links = collections .defaultdict (list )
369433 if target is not None :
370434 # pyrefly: ignore [unsupported-operation]
@@ -523,12 +587,21 @@ def set_target(
523587 if isinstance (target , BalanceFrame ):
524588 # BalanceFrame / Sample path: return a deep copy (immutable)
525589 new_copy = deepcopy (self )
526- # pyrefly: ignore [unsupported-operation]
527- new_copy ._links ["target" ] = target
528590 BalanceFrame ._validate_covariate_overlap (
529- new_copy ._sf_sample , target ._sf_sample
591+ new_copy ._sf_sample_pre_adjust , target ._sf_sample
530592 )
593+ if new_copy .is_adjusted :
594+ logger .warning (
595+ "Replacing target on an adjusted object resets responder "
596+ "weights to pre-adjust values and discards current "
597+ "adjustment results on the returned copy."
598+ )
599+ new_copy ._sf_sample = new_copy ._sf_sample_pre_adjust
600+ new_copy ._clear_adjustment_state ()
601+ # pyrefly: ignore [unsupported-operation]
602+ new_copy ._links ["target" ] = target
531603 new_copy ._sf_target = target ._sf_sample
604+ new_copy ._sync_sampleframe_state_from_responder (new_copy ._sf_sample )
532605 return new_copy
533606
534607 if isinstance (target , SampleFrame ):
@@ -551,9 +624,7 @@ def set_target(
551624 self ._links ["target" ] = target
552625 # Reset adjustment state — old adjustment is no longer valid.
553626 self ._sf_sample = self ._sf_sample_pre_adjust
554- self ._adjustment_model = None
555- # pyrefly: ignore [missing-attribute]
556- self ._links .pop ("unadjusted" , None )
627+ self ._clear_adjustment_state ()
557628 self ._sync_sampleframe_state_from_responder (self ._sf_sample )
558629 return self
559630 else :
@@ -615,9 +686,7 @@ def set_as_pre_adjust(self, *, inplace: bool = False) -> Self:
615686 bf ._links ["target" ] = self ._links ["target" ]
616687 bf ._sf_sample_pre_adjust = frozen
617688 bf ._sf_sample = frozen
618- bf ._adjustment_model = None
619- # pyrefly: ignore [missing-attribute]
620- bf ._links .pop ("unadjusted" , None )
689+ bf ._clear_adjustment_state ()
621690 bf ._sync_sampleframe_state_from_responder (frozen )
622691 return bf
623692
@@ -774,9 +843,6 @@ def _build_adjusted_frame(
774843 raw_model = result .get ("model" )
775844 # Defensive copy: the weighting function may retain a reference to the
776845 # dict it returned, so mutating it here could cause surprising side effects.
777- # TODO: Track adjustment history — currently only the latest model is
778- # stored. A future enhancement should maintain a list of
779- # (method, model_dict) tuples for each adjustment step.
780846 new_bf ._adjustment_model = (
781847 dict (raw_model ) if isinstance (raw_model , dict ) else raw_model
782848 )
@@ -801,6 +867,15 @@ def _build_adjusted_frame(
801867 "training_target_weights" ,
802868 fit_target_weights ,
803869 )
870+ effective_method_name = (
871+ str (adj_model .get ("method" )) if isinstance (adj_model , dict ) else method_name
872+ )
873+ new_bf ._adjustment_history = self ._copy_adjustment_history_from (
874+ self , deep = False
875+ )
876+ new_bf ._append_adjustment_history_entry (
877+ effective_method_name , new_bf ._adjustment_model
878+ )
804879 return new_bf
805880
806881 def adjust (
@@ -1145,6 +1220,9 @@ def fit(
11451220 self ._sf_sample_pre_adjust = result ._sf_sample_pre_adjust
11461221 self ._sf_target = result ._sf_target
11471222 self ._adjustment_model = result ._adjustment_model
1223+ self ._adjustment_history = self ._copy_adjustment_history_from (
1224+ result , deep = False
1225+ )
11481226 self ._links = result ._links
11491227 self ._sync_sampleframe_state_from_responder (self ._sf_sample )
11501228 return self
@@ -1310,6 +1388,9 @@ def set_fitted_model(self, fitted: BalanceFrame, *, inplace: bool = True) -> Sel
13101388
13111389 # Store the model and set adjustment state
13121390 bf ._adjustment_model = dict (model )
1391+ method_name = str (model .get ("method" , "set_fitted_model" ))
1392+ bf ._adjustment_history = self ._copy_adjustment_history_from (self , deep = False )
1393+ bf ._append_adjustment_history_entry (method_name , bf ._adjustment_model )
13131394 # pyrefly: ignore [unsupported-operation]
13141395 bf ._links ["unadjusted" ] = type (self )._create (
13151396 sample = bf ._sf_sample_pre_adjust ,
@@ -2592,6 +2673,33 @@ def model(self) -> dict[str, Any] | None:
25922673 """
25932674 return self ._adjustment_model
25942675
2676+ @property
2677+ def adjustment_history (self ) -> list [dict [str , Any ]]:
2678+ """Chronological adjustment model history.
2679+
2680+ Returns a best-effort read-only copy of each recorded adjustment step
2681+ so callers can inspect compound reweighting workflows without mutating
2682+ internal state. The :attr:`model` property remains the latest
2683+ adjustment model for backward compatibility.
2684+
2685+ Deep-copy is attempted per entry; when a payload is not deepcopy-safe,
2686+ the method falls back to copying the step dictionary and nested model
2687+ mapping (when present).
2688+
2689+ Examples:
2690+ >>> import pandas as pd
2691+ >>> from balance.sample_frame import SampleFrame
2692+ >>> from balance.balance_frame import BalanceFrame
2693+ >>> resp = SampleFrame.from_frame(
2694+ ... pd.DataFrame({"id": [1, 2], "x": [1.0, 2.0], "weight": [1.0, 1.0]}))
2695+ >>> tgt = SampleFrame.from_frame(
2696+ ... pd.DataFrame({"id": [3, 4], "x": [1.5, 2.5], "weight": [1.0, 1.0]}))
2697+ >>> bf = BalanceFrame(sample=resp, target=tgt).adjust(method="null")
2698+ >>> len(bf.adjustment_history)
2699+ 1
2700+ """
2701+ return self ._copy_adjustment_history_from (self , deep = True )
2702+
25952703 # --- Conversion ---
25962704
25972705 @classmethod
@@ -2652,6 +2760,9 @@ def from_sample(cls, sample: Any) -> BalanceFrame:
26522760 sample ._links ["unadjusted" ]
26532761 )
26542762 bf ._adjustment_model = sample .model
2763+ bf ._adjustment_history = cls ._copy_adjustment_history_from (
2764+ sample , deep = False
2765+ )
26552766
26562767 return bf
26572768
@@ -2723,6 +2834,9 @@ def to_sample(self) -> Any:
27232834 # pyrefly: ignore [unsupported-operation]
27242835 result ._links ["unadjusted" ] = unadj_sf
27252836 result ._adjustment_model = self ._adjustment_model
2837+ result ._adjustment_history = self ._copy_adjustment_history_from (
2838+ self , deep = False
2839+ )
27262840
27272841 return result
27282842
@@ -3506,6 +3620,9 @@ def trim(
35063620 )
35073621 new_bf ._sf_sample_pre_adjust = self ._sf_sample_pre_adjust
35083622 new_bf ._adjustment_model = self ._adjustment_model
3623+ new_bf ._adjustment_history = self ._copy_adjustment_history_from (
3624+ self , deep = False
3625+ )
35093626 # Preserve existing links (target, unadjusted).
35103627 # pyrefly: ignore [missing-attribute]
35113628 for key , val in self ._links .items ():
0 commit comments