Repository: sdv-dev/SDV

Issue:
Original metadata object passed to ProgrammableConstraint
### Environment Details

Please indicate the following details about the environment in which you found the bug:

* SDV version:
* Python version:
* Operating System:

### Error Description
Currently, the original metadata object is being passed directly to the `ProgrammableConstraint`. This may cause errors if the custom constraint updates the metadata, since it will no longer match the expected input data. 

### Steps to reproduce

```python
from sdv.cag import SingleTableProgrammableConstraint
from sdv.datasets.demo import download_demo
from sdv.metadata import Metadata
from sdv.single_table import GaussianCopulaSynthesizer

real_data, _ = download_demo(
    modality='single_table',
    dataset_name='fake_hotel_guests'
)

# for this demo, we'll slightly modify the dataset for educational purposes
real_data['amenities_fee'] = real_data['amenities_fee'].fillna(0)
real_data['total_rate'] = real_data[['amenities_fee', 'room_rate']].sum(axis=1)

metadata = Metadata.detect_from_dataframe(real_data)

class RoomRateCalculation(SingleTableProgrammableConstraint):
  """ The RoomRateCalculation constraint enforces that the
      total_rate = room_rate + amenities_fee """
  # PREPROCESS DATA: Remove the total_rate column
  def transform(self, data):
    preprocessed_data = data.drop(columns=['total_rate'])
    return preprocessed_data

  # PREPROCESS METADATA: Remove the total_rate column
  def get_updated_metadata(self, metadata):
    metadata.remove_column('total_rate')
    return metadata

  # POSTPROCESS DATA: Recreate the total_rate column
  def reverse_transform(self, synthetic_data):
    synthetic_data['total_rate'] = synthetic_data['room_rate'] + synthetic_data['amenities_fee']
    return synthetic_data

  # VALIDITY CHECK: total_rate = room_rate + amenities_fee for every row
  def is_valid(self, data):
    column_sum = data['room_rate'] + data['amenities_fee']
    return (data['total_rate'] == column_sum)


synthesizer = GaussianCopulaSynthesizer(metadata)

# create and add the constraint to the synthesizer
my_constraint = RoomRateCalculation()
synthesizer.add_constraints([my_constraint])

# fit and sample as usual
synthesizer.fit(real_data)
synthetic_data = synthesizer.sample(num_rows=500)
```




Discussion:

Patch:
diff --git a/sdv/cag/programmable_constraint.py b/sdv/cag/programmable_constraint.py
index 2a4f439e4..343bb51cc 100644
--- a/sdv/cag/programmable_constraint.py
+++ b/sdv/cag/programmable_constraint.py
@@ -1,5 +1,7 @@
 """Programmable constraints base classes."""
 
+from copy import deepcopy
+
 from sdv.cag._errors import ConstraintNotMetError
 from sdv.cag.base import BaseConstraint
 
@@ -154,6 +156,7 @@ def _validate_constraint_with_data(self, data, metadata):
         self.programmable_constraint.validate_input_data(data)
 
     def _get_updated_metadata(self, metadata):
+        metadata = deepcopy(metadata)
         return self.programmable_constraint.get_updated_metadata(metadata)
 
     def _fit(self, data, metadata):


Test patch:
diff --git a/tests/integration/cag/test_programmable_constraint.py b/tests/integration/cag/test_programmable_constraint.py
index 7e88de9da..f5109a0d4 100644
--- a/tests/integration/cag/test_programmable_constraint.py
+++ b/tests/integration/cag/test_programmable_constraint.py
@@ -4,6 +4,7 @@
 
 from sdv.cag import FixedCombinations, ProgrammableConstraint, SingleTableProgrammableConstraint
 from sdv.datasets.demo import download_demo
+from sdv.metadata import Metadata
 from sdv.multi_table import HMASynthesizer
 from sdv.single_table import GaussianCopulaSynthesizer
 
@@ -196,3 +197,46 @@ def test_end_to_end_simple_constraint_with_no_fit(programmable_constraint):
     true_values = (sampled_data['has_rewards']) & (sampled_data['amenities_fee'] == 0.0)
     false_values = ~sampled_data['has_rewards']
     assert all((true_values) | (false_values))
+
+
+def test_get_updated_metadata_is_passed_metadata_copy(single_table_programmable_constraint):
+    """Test that the ``get_updated_metadata`` is not given the original metadata."""
+    # Setup
+    data, metadata = download_demo('single_table', 'fake_hotel_guests')
+    original_metadata = Metadata.load_from_dict(metadata.to_dict())
+
+    def get_updated_metadata(self, metadata):
+        for column in self.column_names:
+            metadata.remove_column(column)
+
+        metadata.add_column(self._joint_column, sdtype='categorical')
+        return metadata
+
+    single_table_programmable_constraint.get_updated_metadata = get_updated_metadata
+    my_constraint = single_table_programmable_constraint(
+        column_names=['has_rewards', 'room_type'], table_name='fake_hotel_guests'
+    )
+
+    synthesizer = GaussianCopulaSynthesizer(metadata)
+
+    # Run
+    synthesizer.add_constraints([my_constraint])
+    synthesizer.fit(data)
+    updated_metadata = synthesizer.get_metadata('modified')
+
+    # Assert
+    assert metadata.to_dict() == original_metadata.to_dict()
+    expected_modified_metadata = Metadata.load_from_dict({
+        'tables': {
+            'fake_hotel_guests': {
+                'columns': {
+                    col: meta
+                    for col, meta in original_metadata.tables['fake_hotel_guests'].columns.items()
+                    if col not in ['has_rewards', 'room_type']
+                },
+                'primary_key': 'guest_email',
+            }
+        }
+    })
+    expected_modified_metadata.add_column('has_rewards#room_type', sdtype='categorical')
+    assert updated_metadata.to_dict() == expected_modified_metadata.to_dict()
diff --git a/tests/unit/cag/test_programmable_constraint.py b/tests/unit/cag/test_programmable_constraint.py
index 839b68e53..19847c25c 100644
--- a/tests/unit/cag/test_programmable_constraint.py
+++ b/tests/unit/cag/test_programmable_constraint.py
@@ -1,7 +1,7 @@
 """Test ProgrammableConstraint and ProgrammableConstraintHarness."""
 
 import re
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 import pandas as pd
 import pytest
@@ -158,12 +158,17 @@ def test__validate_constraint_with_data_single_table(self):
         # Assert
         programmable_constraint.validate_input_data.assert_called_once_with(data_dict['table'])
 
-    def test__get_updated_metadata(self):
+    @patch('sdv.cag.programmable_constraint.deepcopy')
+    def test__get_updated_metadata(self, mock_deepcopy):
         """Test the ``_get_updated_metadata`` method."""
         # Setup
         metadata = Metadata().load_from_dict({
             'tables': {'table': {'columns': {'col_A': {'sdtype': 'numerical'}}}}
         })
+        copied_metadata = Metadata().load_from_dict({
+            'tables': {'table': {'columns': {'col_A': {'sdtype': 'numerical'}}}}
+        })
+        mock_deepcopy.return_value = copied_metadata
         programmable_constraint = ProgrammableConstraint()
         programmable_constraint.get_updated_metadata = Mock()
         instance = ProgrammableConstraintHarness(programmable_constraint)
@@ -172,7 +177,7 @@ def test__get_updated_metadata(self):
         instance._get_updated_metadata(metadata)
 
         # Assert
-        programmable_constraint.get_updated_metadata.assert_called_once_with(metadata)
+        programmable_constraint.get_updated_metadata.assert_called_once_with(copied_metadata)
 
     def test__fit(self):
         """Test the ``_fit`` method."""