Repository: sdv-dev/SDV

Issue:
Allow me to add a custom constraint to PARSynthesizer
### Problem Description
PARSynthesizer does not currently allow me to add a custom constraint. If I try to do this, I currently receive an error:

```python
from sdv.sequential import PARSynthesizer

my_synthesizer = PARSynthesizer(metadata)

# create a custom constraint object from a custom-defined class
my_constraint = MyCustomConstraintClass(parameters=...)

# try to add to the PARSynthesizer
my_synthesizer.add_constraints([my_constraint])
```
```
SynthesizerInputError:  The PARSynthesizer cannot accommodate with programmable constraints.
```

### Workaround
This problem currently forces me to use the constraint _outside_ of the synthesizer by transforming the data and reverse transforming it. This is shown in the workaround of #2637 and below for illustration's purposes:

```python
# create a custom constraint object from a custom-defined class
my_constraint = MyCustomConstraintClass(parameters=...)

# allow the constraint to transform the input data and metadata
new_data = my_constraint.transform(data)
new_metadata = my_constraint.get_updated_metadata(metadata)

# now fit the PARSynthesizer with the updated data/metadata
synth = PARSynthesizer(new_metadata, epochs=2, context_columns=['patient_birthday', 'patient_sex'])
synth.fit(new_data)
synthetic_data = synth.sample(2)

# finally allow the constraint to reverse transform the outputted synthetic data
post_synthetic_data = my_constraint.reverse_transform(synthetic_data)
```

This is cumbersome to do, because I now have to remember to pre- and post-process my data every time. I also have to save a separate object (the constraint) in addition to the synthesizer. It would be better if I could just add the constraint to the synthesizer.

### Expected behavior
Allow me to apply a custom `SingleTableProgrammableConstraint` to the PARSynthesizer. The constraint should be applied first thing to the original data, and then should be applied at the end after creating synthetic data -- just as what the workaround is able to achieve. There should be _no restrictions_ on whether the constraint is allowed to touch context columns, non-context columns, sequence index column, etc.

### AdditionalContext
Note that reject sampling will still happen on a row-by-row basis; so it might be the case that the custom constraint drops invalid rows. This is why it is still a `SingleTableProgrammableConstraint`. In the future, we may have a sequence-specific logic for sampling/reject sampling; at that future point, we would consider adding a new `SequentialProgrammableConstraint`.


Discussion:

Patch:
diff --git a/sdv/sequential/par.py b/sdv/sequential/par.py
index 70e8bccd7..e0663cd2b 100644
--- a/sdv/sequential/par.py
+++ b/sdv/sequential/par.py
@@ -203,12 +203,13 @@ def get_parameters(self):
     def add_constraints(self, constraints):
         """Add constraints to the synthesizer.
 
-        For PARSynthesizers only allow a list of constraints that follow these rules:
+        For PARSynthesizers allow SingleTableProgrammableConstraints and built-in constraints
+        that follow these rules:
 
         1) All constraints must be either for all contextual columns or non-contextual column.
            No mixing constraints that cover both contextual and non-contextual columns
         2) No overlapping constraints (there are no constraints that act on the same column)
-        3) No custom constraints
+        3) Any custom constraint is allowed, as long as it is a SingleTableProgrammableConstraint
 
         Args:
             constraints (list):
@@ -223,9 +224,8 @@ def add_constraints(self, constraints):
         constraint_cols = []
         for constraint in constraints:
             if isinstance(constraint, ProgrammableConstraint):
-                raise SynthesizerInputError(
-                    'The PARSynthesizer cannot accommodate with programmable constraints.'
-                )
+                super().add_constraints([constraint])
+                continue
 
             columns = []
             init_signature = inspect.signature(constraint.__class__.__init__)
@@ -270,10 +270,6 @@ def add_constraints(self, constraints):
 
         self._data_processor._transformers_by_sdtype = transformer_by_sdtype
 
-    def load_custom_constraint_classes(self, filepath, class_names):
-        """Error that tells the user custom constraints can't be used in the ``PARSynthesizer``."""
-        raise SynthesizerInputError('The PARSynthesizer cannot accommodate custom constraints.')
-
     def _validate_sequence_key_and_context_columns(self):
         """Check that the sequence key is not present in the context colums.
 


Test patch:
diff --git a/tests/integration/sequential/test_par_programmable_constraints.py b/tests/integration/sequential/test_par_programmable_constraints.py
new file mode 100644
index 000000000..6c3d633f9
--- /dev/null
+++ b/tests/integration/sequential/test_par_programmable_constraints.py
@@ -0,0 +1,304 @@
+"""Integration tests for PARSynthesizer with programmable constraints."""
+
+import pandas as pd
+import pytest
+
+from sdv.cag import ProgrammableConstraint, SingleTableProgrammableConstraint
+from sdv.metadata import Metadata
+from sdv.sequential import PARSynthesizer
+
+
+class SimpleCustomConstraint(SingleTableProgrammableConstraint):
+    """Simple custom constraint for testing."""
+
+    def __init__(self, column_name):
+        self.column_name = column_name
+
+    def validate(self, metadata):
+        """Validate that the column exists in metadata."""
+        table_name = list(metadata.tables.keys())[0]
+        if self.column_name not in metadata.tables[table_name].columns:
+            raise ValueError(f'Column {self.column_name} not found in metadata')
+
+    def validate_input_data(self, data):
+        """Validate the input data."""
+        if self.column_name not in data.columns:
+            raise ValueError(f'Column {self.column_name} not found in data')
+
+    def fit(self, data, metadata):
+        """Fit the constraint."""
+        self._median = data[self.column_name].median()
+
+    def transform(self, data):
+        """Transform the data by capping values at the median."""
+        data = data.copy()
+        data[self.column_name] = data[self.column_name].clip(upper=self._median)
+        return data
+
+    def reverse_transform(self, transformed_data):
+        """Reverse transform - no changes needed."""
+        return transformed_data
+
+    def get_updated_metadata(self, metadata):
+        """Return the same metadata."""
+        return metadata
+
+    def is_valid(self, synthetic_data):
+        """Check if synthetic data is valid."""
+        return synthetic_data[self.column_name] <= self._median
+
+
+class ConditionalConstraint(SingleTableProgrammableConstraint):
+    """Constraint that enforces conditional logic between columns."""
+
+    def __init__(self, flag_column, target_column):
+        self.flag_column = flag_column
+        self.target_column = target_column
+
+    def validate(self, metadata):
+        """Validate that both columns exist."""
+        table_name = list(metadata.tables.keys())[0]
+        columns = metadata.tables[table_name].columns
+        if self.flag_column not in columns:
+            raise ValueError(f'Column {self.flag_column} not found in metadata')
+        if self.target_column not in columns:
+            raise ValueError(f'Column {self.target_column} not found in metadata')
+
+    def validate_input_data(self, data):
+        """Validate the input data."""
+        if self.flag_column not in data.columns:
+            raise ValueError(f'Column {self.flag_column} not found in data')
+        if self.target_column not in data.columns:
+            raise ValueError(f'Column {self.target_column} not found in data')
+
+    def fit(self, data, metadata):
+        """Fit the constraint."""
+        self._typical_value = data[self.target_column].median()
+
+    def transform(self, data):
+        """Transform: when flag is True, set target to typical value."""
+        data = data.copy()
+        mask = data[self.flag_column] == True  # noqa: E712
+        data.loc[mask, self.target_column] = self._typical_value
+        return data
+
+    def reverse_transform(self, transformed_data):
+        """Reverse transform: when flag is True, set target to 0."""
+        transformed_data = transformed_data.copy()
+        mask = transformed_data[self.flag_column] == True  # noqa: E712
+        transformed_data.loc[mask, self.target_column] = 0.0
+        return transformed_data
+
+    def get_updated_metadata(self, metadata):
+        """Return the same metadata."""
+        return metadata
+
+    def is_valid(self, synthetic_data):
+        """Check if synthetic data is valid."""
+        flag_true = synthetic_data[self.flag_column] == True  # noqa: E712
+        target_zero = synthetic_data[self.target_column] == 0.0
+        flag_false = synthetic_data[self.flag_column] == False  # noqa: E712
+
+        return (flag_true & target_zero) | flag_false
+
+
+class MultiTableConstraint(ProgrammableConstraint):
+    """Multi-table constraint that should be rejected."""
+
+    def __init__(self, column_name):
+        self.column_name = column_name
+
+    def transform(self, data):
+        return data
+
+    def reverse_transform(self, transformed_data):
+        return transformed_data
+
+    def get_updated_metadata(self, metadata):
+        return metadata
+
+    def is_valid(self, synthetic_data):
+        return pd.Series([True] * len(list(synthetic_data.values())[0]))
+
+
+@pytest.fixture
+def sample_sequential_data():
+    """Create sample sequential data for testing."""
+    data = pd.DataFrame({
+        'patient_id': [1, 1, 1, 2, 2, 2, 3, 3, 3],
+        'visit_number': [1, 2, 3, 1, 2, 3, 1, 2, 3],
+        'measurement': [10.5, 12.3, 15.1, 8.2, 9.7, 11.4, 13.8, 16.2, 18.9],
+        'has_condition': [False, False, False, True, True, True, False, False, False],
+        'treatment_score': [5.0, 6.2, 7.5, 0.0, 0.0, 0.0, 7.1, 8.0, 9.2],
+    })
+    return data
+
+
+@pytest.fixture
+def sample_metadata():
+    """Create sample metadata for testing."""
+    metadata = Metadata.load_from_dict({
+        'tables': {
+            'patients': {
+                'columns': {
+                    'patient_id': {'sdtype': 'id'},
+                    'visit_number': {'sdtype': 'numerical'},
+                    'measurement': {'sdtype': 'numerical'},
+                    'has_condition': {'sdtype': 'boolean'},
+                    'treatment_score': {'sdtype': 'numerical'},
+                },
+                'sequence_key': 'patient_id',
+                'sequence_index': 'visit_number',
+            }
+        }
+    })
+    return metadata
+
+
+def test_add_single_table_programmable_constraint(sample_sequential_data, sample_metadata):
+    """Test that SingleTableProgrammableConstraint can be added to PARSynthesizer."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'])
+    constraint = SimpleCustomConstraint('measurement')
+
+    # Run
+    synthesizer.add_constraints([constraint])
+
+    # Assert
+    constraints = synthesizer.get_constraints()
+    assert len(constraints) == 1
+    assert isinstance(constraints[0], SimpleCustomConstraint)
+    assert constraints[0].column_name == 'measurement'
+
+
+def test_end_to_end_with_simple_constraint(sample_sequential_data, sample_metadata):
+    """Test end-to-end workflow with a simple constraint."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+    constraint = SimpleCustomConstraint('measurement')
+    synthesizer.add_constraints([constraint])
+
+    # Run
+    synthesizer.fit(sample_sequential_data)
+    synthetic_data = synthesizer.sample(num_sequences=2)
+
+    # Assert
+    assert len(synthetic_data) > 0
+    assert 'measurement' in synthetic_data.columns
+    assert 'patient_id' in synthetic_data.columns
+    assert 'has_condition' in synthetic_data.columns
+
+    # Check constraint is satisfied
+    original_median = sample_sequential_data['measurement'].median()
+    assert all(synthetic_data['measurement'] <= original_median)
+
+
+def test_end_to_end_with_conditional_constraint(sample_sequential_data, sample_metadata):
+    """Test end-to-end workflow with a conditional constraint."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+    constraint = ConditionalConstraint('has_condition', 'treatment_score')
+    synthesizer.add_constraints([constraint])
+
+    # Run
+    synthesizer.fit(sample_sequential_data)
+    synthetic_data = synthesizer.sample(num_sequences=2)
+
+    # Assert
+    assert len(synthetic_data) > 0
+
+    condition_true = synthetic_data['has_condition'] == True  # noqa: E712
+    if condition_true.any():
+        assert all(synthetic_data.loc[condition_true, 'treatment_score'] == 0.0)
+
+
+def test_constraint_with_context_columns(sample_sequential_data, sample_metadata):
+    """Test constraint that operates on context columns."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+
+    class BooleanConstraint(SingleTableProgrammableConstraint):
+        def __init__(self, column_name):
+            self.column_name = column_name
+
+        def validate(self, metadata):
+            pass
+
+        def validate_input_data(self, data):
+            pass
+
+        def fit(self, data, metadata):
+            pass
+
+        def transform(self, data):
+            return data
+
+        def reverse_transform(self, transformed_data):
+            return transformed_data
+
+        def get_updated_metadata(self, metadata):
+            return metadata
+
+        def is_valid(self, synthetic_data):
+            return pd.Series([True] * len(synthetic_data))
+
+    constraint = BooleanConstraint('has_condition')
+    synthesizer.add_constraints([constraint])
+
+    # Run
+    synthesizer.fit(sample_sequential_data)
+    synthetic_data = synthesizer.sample(num_sequences=2)
+
+    # Assert
+    assert len(synthetic_data) > 0
+    assert 'has_condition' in synthetic_data.columns
+
+
+def test_constraint_with_non_context_columns(sample_sequential_data, sample_metadata):
+    """Test constraint that operates on non-context columns."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+    constraint = SimpleCustomConstraint('measurement')
+    synthesizer.add_constraints([constraint])
+
+    # Run
+    synthesizer.fit(sample_sequential_data)
+    synthetic_data = synthesizer.sample(num_sequences=2)
+
+    # Assert
+    assert len(synthetic_data) > 0
+    assert 'measurement' in synthetic_data.columns
+
+
+def test_multiple_constraints_same_type(sample_sequential_data, sample_metadata):
+    """Test adding multiple constraints of the same type."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+    constraint1 = SimpleCustomConstraint('measurement')
+    constraint2 = SimpleCustomConstraint('treatment_score')
+    synthesizer.add_constraints([constraint1, constraint2])
+
+    # Run
+    synthesizer.fit(sample_sequential_data)
+    synthetic_data = synthesizer.sample(num_sequences=2)
+
+    # Assert
+    assert len(synthetic_data) > 0
+    constraints = synthesizer.get_constraints()
+    assert len(constraints) == 2
+
+
+def test_validate_constraints(sample_sequential_data, sample_metadata):
+    """Test that constraints are properly validated during synthesis."""
+    # Setup
+    synthesizer = PARSynthesizer(sample_metadata, context_columns=['has_condition'], epochs=1)
+    constraint = ConditionalConstraint('has_condition', 'treatment_score')
+    synthesizer.add_constraints([constraint])
+    synthesizer.fit(sample_sequential_data)
+
+    # Run
+    synthetic_data = synthesizer.sample(num_sequences=3)
+
+    # Assert
+    is_valid = constraint.is_valid(synthetic_data)
+    assert all(is_valid)
diff --git a/tests/unit/sequential/test_par.py b/tests/unit/sequential/test_par.py
index 1c3f60986..c7bb4e332 100644
--- a/tests/unit/sequential/test_par.py
+++ b/tests/unit/sequential/test_par.py
@@ -6,7 +6,7 @@
 import pytest
 from rdt.transformers import FloatFormatter, UnixTimestampEncoder
 
-from sdv.cag import ProgrammableConstraint
+from sdv.cag import ProgrammableConstraint, SingleTableProgrammableConstraint
 from sdv.cag.base import BaseConstraint
 from sdv.data_processing.data_processor import DataProcessor
 from sdv.errors import InvalidDataError, NotFittedError, SamplingError, SynthesizerInputError
@@ -131,7 +131,7 @@ def test___init___no_torch(self, mock_raise_error, mock_import_error):
 
     @patch.object(BaseSynthesizer, 'add_constraints')
     def test_add_constraints(self, add_constraints_mock):
-        """Test that that only simple constraints can be added to PARSynthesizer."""
+        """Test that constraints can be added to PARSynthesizer with proper validation."""
 
         # Setup
         class MockConstraint(BaseConstraint):
@@ -142,11 +142,15 @@ def __init__(self, column_names):
 
         metadata = self.get_metadata(add_sequence_key=True)
         synthesizer = PARSynthesizer(metadata=metadata, context_columns=['gender', 'measurement'])
-        programmable_constraint = ProgrammableConstraint()
-        programmable_constraint._is_single_table = True
-        programmable_constraint_error_msg = re.escape(
-            'The PARSynthesizer cannot accommodate with programmable constraints.'
+
+        multi_table_programmable_constraint = ProgrammableConstraint()
+        multi_table_programmable_constraint._is_single_table = False
+        multi_table_error_msg = re.escape(
+            'Constraint `ProgrammableConstraint` is not compatible '
+            'with the single table synthesizers.'
         )
+
+        single_table_programmable_constraint = SingleTableProgrammableConstraint()
         constraint_1 = MockConstraint(column_names=['time'])
         constraint_2 = MockConstraint(column_names=['time', 'gender'])
         overlapping_error_msg = re.escape(
@@ -159,8 +163,10 @@ def __init__(self, column_names):
         )
 
         # Run and Assert
-        with pytest.raises(SynthesizerInputError, match=programmable_constraint_error_msg):
-            synthesizer.add_constraints([programmable_constraint])
+        with pytest.raises(SynthesizerInputError, match=multi_table_error_msg):
+            synthesizer.add_constraints([multi_table_programmable_constraint])
+
+        synthesizer.add_constraints([single_table_programmable_constraint])
 
         with pytest.raises(SynthesizerInputError, match=overlapping_error_msg):
             synthesizer.add_constraints([constraint_1, constraint_2])
@@ -168,16 +174,18 @@ def __init__(self, column_names):
         with pytest.raises(SynthesizerInputError, match=mixed_constraint_error_msg):
             synthesizer.add_constraints([constraint_2])
 
-    def test_load_custom_constraint_classes(self):
-        """Test that if custom constraint is being added, an error is raised."""
+    @patch.object(BaseSynthesizer, 'load_custom_constraint_classes')
+    def test_load_custom_constraint_classes(self, base_load_mock):
+        """Test that custom constraint classes can be loaded (delegates to base class)."""
         # Setup
         metadata = self.get_metadata()
         synthesizer = PARSynthesizer(metadata=metadata)
 
-        # Run and Assert
-        error_message = re.escape('The PARSynthesizer cannot accommodate custom constraints.')
-        with pytest.raises(SynthesizerInputError, match=error_message):
-            synthesizer.load_custom_constraint_classes(filepath='test', class_names=[])
+        # Run
+        synthesizer.load_custom_constraint_classes(filepath='test', class_names=['TestClass'])
+
+        # Assert
+        base_load_mock.assert_called_once_with(filepath='test', class_names=['TestClass'])
 
     def test_get_parameters(self):
         """Test that it returns every ``init`` parameter without the ``metadata``."""