xplainable-preprocessing 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (35) hide show
  1. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/PKG-INFO +1 -1
  2. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/pyproject.toml +1 -1
  3. xplainable_preprocessing-0.2.0/scripts/release.sh +292 -0
  4. xplainable_preprocessing-0.2.0/src/xplainable_preprocessing/schema.py +372 -0
  5. xplainable_preprocessing-0.1.0/src/xplainable_preprocessing/schema.py +0 -71
  6. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/dag-pipeline-proposal.md +0 -0
  7. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/feature-pipeline-architectures.md +0 -0
  8. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/docs/feature-store-proposal.md +0 -0
  9. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/__init__.py +0 -0
  10. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/compiler.py +0 -0
  11. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/pipeline.py +0 -0
  12. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/preview.py +0 -0
  13. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/registry.py +0 -0
  14. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/sandbox.py +0 -0
  15. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/serialization.py +0 -0
  16. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/__init__.py +0 -0
  17. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/category_condense.py +0 -0
  18. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/datetime_extract.py +0 -0
  19. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/drop_columns.py +0 -0
  20. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/expression.py +0 -0
  21. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/fill_missing.py +0 -0
  22. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/grouped_lag.py +0 -0
  23. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/rename_columns.py +0 -0
  24. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/rolling_agg.py +0 -0
  25. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/text_clean.py +0 -0
  26. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/src/xplainable_preprocessing/transformers/type_cast.py +0 -0
  27. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/__init__.py +0 -0
  28. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_compiler.py +0 -0
  29. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_preview.py +0 -0
  30. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_sandbox.py +0 -0
  31. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_schema.py +0 -0
  32. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_serialization.py +0 -0
  33. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/__init__.py +0 -0
  34. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/test_all_transformers.py +0 -0
  35. {xplainable_preprocessing-0.1.0 → xplainable_preprocessing-0.2.0}/tests/test_transformers/test_expression.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xplainable-preprocessing
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Shared preprocessing pipeline package for xplainable
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: cloudpickle>=3.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xplainable-preprocessing"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Shared preprocessing pipeline package for xplainable"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -0,0 +1,292 @@
1
+ #!/bin/bash
2
+ # Simple release script for xplainable-preprocessing
3
+
4
+ set -e
5
+
6
+ # Colors for output
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ BLUE='\033[0;34m'
11
+ NC='\033[0m' # No Color
12
+
13
+ # Functions
14
+ print_header() {
15
+ echo -e "${BLUE}$1${NC}"
16
+ echo "=================================="
17
+ }
18
+
19
+ print_success() {
20
+ echo -e "${GREEN}✅ $1${NC}"
21
+ }
22
+
23
+ print_warning() {
24
+ echo -e "${YELLOW}⚠️ $1${NC}"
25
+ }
26
+
27
+ print_error() {
28
+ echo -e "${RED}❌ $1${NC}"
29
+ }
30
+
31
+ # Parse arguments
32
+ VERSION=""
33
+ INCREMENT=""
34
+ DRY_RUN=false
35
+ PUBLISH_PYPI=false
36
+
37
+ while [[ $# -gt 0 ]]; do
38
+ case $1 in
39
+ -v|--version)
40
+ VERSION="$2"
41
+ shift 2
42
+ ;;
43
+ -i|--increment)
44
+ INCREMENT="$2"
45
+ shift 2
46
+ ;;
47
+ --dry-run)
48
+ DRY_RUN=true
49
+ shift
50
+ ;;
51
+ --publish)
52
+ PUBLISH_PYPI=true
53
+ shift
54
+ ;;
55
+ -h|--help)
56
+ echo "Usage: $0 [OPTIONS]"
57
+ echo ""
58
+ echo "Options:"
59
+ echo " -v, --version VERSION Specific version (e.g., 1.2.3)"
60
+ echo " -i, --increment TYPE Auto-increment: major, minor, or patch"
61
+ echo " --dry-run Show what would be done"
62
+ echo " --publish Publish to PyPI after building"
63
+ echo " -h, --help Show this help"
64
+ echo ""
65
+ echo "Examples:"
66
+ echo " $0 --version 0.2.0"
67
+ echo " $0 --increment patch"
68
+ echo " $0 --increment minor --publish"
69
+ echo " $0 --increment minor --dry-run"
70
+ exit 0
71
+ ;;
72
+ *)
73
+ print_error "Unknown option: $1"
74
+ exit 1
75
+ ;;
76
+ esac
77
+ done
78
+
79
+ # Validate arguments
80
+ if [[ -z "$VERSION" && -z "$INCREMENT" ]]; then
81
+ print_error "Must specify either --version or --increment"
82
+ echo "Run $0 --help for usage"
83
+ exit 1
84
+ fi
85
+
86
+ if [[ -n "$VERSION" && -n "$INCREMENT" ]]; then
87
+ print_error "Cannot specify both --version and --increment"
88
+ exit 1
89
+ fi
90
+
91
+ print_header "🚀 xplainable-preprocessing Release"
92
+
93
+ # Check if we're in the right directory
94
+ if [[ ! -f "pyproject.toml" ]]; then
95
+ print_error "Not in project root (no pyproject.toml found)"
96
+ exit 1
97
+ fi
98
+
99
+ # Check git status
100
+ print_header "📋 Pre-release Checks"
101
+
102
+ if ! git rev-parse --git-dir > /dev/null 2>&1; then
103
+ print_error "Not in a git repository"
104
+ exit 1
105
+ fi
106
+
107
+ if [[ -n $(git status --porcelain) ]]; then
108
+ print_error "Uncommitted changes detected"
109
+ git status --short
110
+ exit 1
111
+ fi
112
+
113
+ CURRENT_BRANCH=$(git branch --show-current)
114
+ if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then
115
+ print_warning "Currently on branch '$CURRENT_BRANCH'"
116
+ read -p "Continue anyway? (y/N): " -n 1 -r
117
+ echo
118
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
119
+ exit 1
120
+ fi
121
+ fi
122
+
123
+ print_success "Git repository is clean"
124
+
125
+ # Run tests before release
126
+ print_header "🧪 Running Tests"
127
+ if python -m pytest tests/ -q; then
128
+ print_success "All tests passed"
129
+ else
130
+ print_error "Tests failed — aborting release"
131
+ exit 1
132
+ fi
133
+
134
+ # Get current version from pyproject.toml
135
+ CURRENT_VERSION=$(grep '^version =' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
136
+
137
+ if [[ -z "$CURRENT_VERSION" ]]; then
138
+ print_error "Could not determine current version"
139
+ exit 1
140
+ fi
141
+
142
+ print_success "Current version: $CURRENT_VERSION"
143
+
144
+ # Determine new version
145
+ if [[ -n "$VERSION" ]]; then
146
+ NEW_VERSION="${VERSION#v}" # Remove 'v' prefix if present
147
+ else
148
+ # Auto-increment
149
+ IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
150
+ MAJOR=${VERSION_PARTS[0]}
151
+ MINOR=${VERSION_PARTS[1]}
152
+ PATCH=${VERSION_PARTS[2]}
153
+
154
+ case $INCREMENT in
155
+ major)
156
+ MAJOR=$((MAJOR + 1))
157
+ MINOR=0
158
+ PATCH=0
159
+ ;;
160
+ minor)
161
+ MINOR=$((MINOR + 1))
162
+ PATCH=0
163
+ ;;
164
+ patch)
165
+ PATCH=$((PATCH + 1))
166
+ ;;
167
+ *)
168
+ print_error "Invalid increment type: $INCREMENT"
169
+ exit 1
170
+ ;;
171
+ esac
172
+
173
+ NEW_VERSION="$MAJOR.$MINOR.$PATCH"
174
+ fi
175
+
176
+ TAG="v$NEW_VERSION"
177
+
178
+ print_header "📦 Release Plan"
179
+ echo "Current: $CURRENT_VERSION"
180
+ echo "New: $NEW_VERSION"
181
+ echo "Tag: $TAG"
182
+
183
+ if [[ "$DRY_RUN" == true ]]; then
184
+ print_header "🔍 DRY RUN - Would perform:"
185
+ echo "1. Clean build artifacts"
186
+ echo "2. Update version in pyproject.toml"
187
+ echo "3. Commit version changes"
188
+ echo "4. Create git tag $TAG"
189
+ echo "5. Build package (wheel and sdist)"
190
+ echo "6. Push changes and tag"
191
+ if [[ "$PUBLISH_PYPI" == true ]]; then
192
+ echo "7. Publish package to PyPI"
193
+ echo "8. Create GitHub release (if gh CLI available)"
194
+ else
195
+ echo "7. Create GitHub release (if gh CLI available)"
196
+ fi
197
+ exit 0
198
+ fi
199
+
200
+ # Confirm
201
+ read -p "Create release v$NEW_VERSION? (y/N): " -n 1 -r
202
+ echo
203
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
204
+ print_error "Release cancelled"
205
+ exit 1
206
+ fi
207
+
208
+ print_header "🔨 Creating Release"
209
+
210
+ # Clean up build artifacts
211
+ print_success "Cleaning up build artifacts..."
212
+ rm -rf build/ dist/ *.egg-info/ src/*.egg-info/ || true
213
+
214
+ # Update version in pyproject.toml
215
+ print_success "Updating version..."
216
+ sed -i.bak "s/^version = .*/version = \"$NEW_VERSION\"/" pyproject.toml && rm pyproject.toml.bak
217
+ print_success "Updated pyproject.toml"
218
+
219
+ # Commit changes
220
+ print_success "Committing version changes..."
221
+ git add .
222
+ git commit -m "chore: bump version to $NEW_VERSION"
223
+
224
+ # Create tag
225
+ print_success "Creating tag $TAG..."
226
+ RELEASE_DATE=$(date +%Y-%m-%d)
227
+ git tag -a "$TAG" -m "Release $TAG — $RELEASE_DATE"
228
+
229
+ # Build package
230
+ print_success "Building package..."
231
+ python -m build
232
+
233
+ # Push changes
234
+ print_success "Pushing changes..."
235
+ git push
236
+ git push --tags
237
+
238
+ # Publish to PyPI if requested
239
+ if [[ "$PUBLISH_PYPI" == true ]]; then
240
+ print_success "Publishing to PyPI..."
241
+
242
+ if ! command -v twine &> /dev/null; then
243
+ print_error "twine not found"
244
+ echo "Install with: pip install twine"
245
+ exit 1
246
+ fi
247
+
248
+ if [[ -z "$TWINE_PASSWORD" && ! -f ~/.pypirc ]]; then
249
+ print_error "PyPI credentials not configured"
250
+ echo "Set up credentials with one of:"
251
+ echo " 1. Environment: export TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-..."
252
+ echo " 2. Config file: ~/.pypirc"
253
+ exit 1
254
+ fi
255
+
256
+ if twine upload dist/*; then
257
+ print_success "Package published to PyPI!"
258
+ echo "🔗 View at: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"
259
+ else
260
+ print_error "PyPI upload failed"
261
+ exit 1
262
+ fi
263
+ fi
264
+
265
+ # Create GitHub release if gh CLI is available
266
+ if command -v gh &> /dev/null; then
267
+ if gh auth status &> /dev/null; then
268
+ print_success "Creating GitHub release..."
269
+
270
+ gh release create "$TAG" \
271
+ --title "v$NEW_VERSION" \
272
+ --notes "## xplainable-preprocessing v$NEW_VERSION — $RELEASE_DATE
273
+
274
+ ### Installation
275
+
276
+ \`\`\`bash
277
+ pip install xplainable-preprocessing==$NEW_VERSION
278
+ \`\`\`" \
279
+ --latest
280
+
281
+ print_success "GitHub release created!"
282
+ else
283
+ print_warning "GitHub CLI not authenticated, skipping release creation"
284
+ fi
285
+ else
286
+ print_warning "GitHub CLI not found, skipping release creation"
287
+ fi
288
+
289
+ print_header "🎉 RELEASE COMPLETE!"
290
+ echo "Version: $NEW_VERSION"
291
+ echo "Tag: $TAG"
292
+ echo "PyPI: https://pypi.org/project/xplainable-preprocessing/$NEW_VERSION/"
@@ -0,0 +1,372 @@
1
+ """Pydantic models for pipeline specification with mutation support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ from pydantic import BaseModel, field_validator
8
+
9
+
10
+ class StepSpec(BaseModel):
11
+ """A single preprocessing step in the pipeline.
12
+
13
+ The ``outputs``, ``modifies``, and ``drops`` fields form the step's
14
+ **column contract**. They are populated after the first fit via
15
+ ``PipelineSpec.enrich_from_deltas()`` and enable cascade analysis
16
+ for step mutations (remove, reorder) without recompiling the pipeline.
17
+ """
18
+
19
+ id: str
20
+ type: str
21
+ columns: Optional[List[str]] = None
22
+ params: Dict = {}
23
+ description: Optional[str] = None
24
+
25
+ # Column contract — populated after first fit via enrich_from_deltas()
26
+ outputs: Optional[List[str]] = None # columns this step CREATES
27
+ modifies: Optional[List[str]] = None # columns this step CHANGES in place
28
+ drops: Optional[List[str]] = None # columns this step REMOVES
29
+
30
+ @field_validator("id")
31
+ @classmethod
32
+ def id_must_be_non_empty(cls, v: str) -> str:
33
+ if not v.strip():
34
+ raise ValueError("Step id must be non-empty")
35
+ return v
36
+
37
+ @field_validator("type")
38
+ @classmethod
39
+ def type_must_be_non_empty(cls, v: str) -> str:
40
+ if not v.strip():
41
+ raise ValueError("Step type must be non-empty")
42
+ return v
43
+
44
+
45
+ class RemovalAnalysis(BaseModel):
46
+ """Result of analyzing what happens when a step is removed."""
47
+
48
+ step_id: str
49
+ cascade_remove: List[str] = [] # step IDs that must also be removed
50
+ columns_lost: List[str] = [] # columns that would disappear
51
+ columns_restored: List[str] = [] # columns that were dropped and would reappear
52
+ safe: bool = True # True if no downstream steps are affected
53
+
54
+
55
+ class PipelineSpec(BaseModel):
56
+ """Full pipeline specification containing ordered steps.
57
+
58
+ Supports mutation methods (``remove_step``, ``insert_step``,
59
+ ``reorder_step``) that return new ``PipelineSpec`` instances.
60
+ Cascade analysis via ``analyze_removal`` works from the column
61
+ contract without needing to recompile the pipeline.
62
+ """
63
+
64
+ version: str = "2.0"
65
+ steps: List[StepSpec] = []
66
+
67
+ @field_validator("steps")
68
+ @classmethod
69
+ def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
70
+ ids = [step.id for step in v]
71
+ if len(ids) != len(set(ids)):
72
+ duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
73
+ raise ValueError(f"Duplicate step ids: {set(duplicates)}")
74
+ return v
75
+
76
+ # ------------------------------------------------------------------
77
+ # Helpers
78
+ # ------------------------------------------------------------------
79
+
80
+ def get_step(self, step_id: str) -> Optional[StepSpec]:
81
+ """Return a step by ID, or None."""
82
+ for step in self.steps:
83
+ if step.id == step_id:
84
+ return step
85
+ return None
86
+
87
+ def step_index(self, step_id: str) -> int:
88
+ """Return the index of a step by ID. Raises ValueError if not found."""
89
+ for i, step in enumerate(self.steps):
90
+ if step.id == step_id:
91
+ return i
92
+ raise ValueError(f"Step '{step_id}' not found in spec")
93
+
94
+ def steps_after(self, step_id: str) -> List[StepSpec]:
95
+ """Return all steps that come after the given step."""
96
+ idx = self.step_index(step_id)
97
+ return self.steps[idx + 1:]
98
+
99
+ @property
100
+ def is_enriched(self) -> bool:
101
+ """True if column contracts have been populated on all steps."""
102
+ return all(
103
+ step.outputs is not None
104
+ for step in self.steps
105
+ )
106
+
107
+ # ------------------------------------------------------------------
108
+ # Enrichment — write column contracts from deltas
109
+ # ------------------------------------------------------------------
110
+
111
+ def enrich_from_deltas(self, step_deltas: list[dict]) -> "PipelineSpec":
112
+ """Return a new spec with column contracts populated from step deltas.
113
+
114
+ Parameters
115
+ ----------
116
+ step_deltas : list[dict]
117
+ Output of ``preview.compute_step_deltas()``. Each entry has
118
+ ``step_id`` and ``delta: {added, updated, dropped}``.
119
+
120
+ Returns
121
+ -------
122
+ PipelineSpec
123
+ A new spec instance with ``outputs``/``modifies``/``drops``
124
+ populated on each step.
125
+ """
126
+ delta_map = {d["step_id"]: d for d in step_deltas}
127
+ enriched_steps = []
128
+ for step in self.steps:
129
+ entry = delta_map.get(step.id, {})
130
+ delta = entry.get("delta", {})
131
+ updates = {
132
+ "outputs": delta.get("added", []),
133
+ "modifies": delta.get("updated", []),
134
+ "drops": delta.get("dropped", []),
135
+ }
136
+ # columns (inputs) is left as-is — only the LLM-declared value
137
+ # is used for dependency resolution. Outputs/modifies/drops are
138
+ # sufficient for cascade analysis.
139
+ enriched_steps.append(step.model_copy(update=updates))
140
+ return PipelineSpec(version=self.version, steps=enriched_steps)
141
+
142
+ # ------------------------------------------------------------------
143
+ # Cascade analysis
144
+ # ------------------------------------------------------------------
145
+
146
+ def analyze_removal(self, step_id: str) -> RemovalAnalysis:
147
+ """Analyze what happens if a step is removed.
148
+
149
+ Uses the column contract (``outputs``/``drops``) to determine
150
+ which downstream steps would lose their input columns and must
151
+ also be removed (cascade).
152
+
153
+ Works without recompiling the pipeline — purely from the spec
154
+ metadata. Requires the spec to be enriched first.
155
+
156
+ Parameters
157
+ ----------
158
+ step_id : str
159
+ The ID of the step to analyze removing.
160
+
161
+ Returns
162
+ -------
163
+ RemovalAnalysis
164
+ """
165
+ step = self.get_step(step_id)
166
+ if step is None:
167
+ raise ValueError(f"Step '{step_id}' not found")
168
+
169
+ # Columns that would disappear if this step is removed
170
+ lost_columns = set(step.outputs or [])
171
+
172
+ # Walk downstream and find cascade
173
+ cascade = []
174
+ for other in self.steps_after(step_id):
175
+ inputs = set(other.columns or [])
176
+ if inputs & lost_columns:
177
+ cascade.append(other.id)
178
+ # This step's outputs are also lost in the cascade
179
+ lost_columns.update(other.outputs or [])
180
+
181
+ # Columns that this step drops would be restored
182
+ restored = list(step.drops or [])
183
+
184
+ return RemovalAnalysis(
185
+ step_id=step_id,
186
+ cascade_remove=cascade,
187
+ columns_lost=sorted(lost_columns),
188
+ columns_restored=sorted(restored),
189
+ safe=len(cascade) == 0,
190
+ )
191
+
192
+ # ------------------------------------------------------------------
193
+ # Mutations — all return NEW PipelineSpec instances (immutable pattern)
194
+ # ------------------------------------------------------------------
195
+
196
+ def remove_step(self, step_id: str, cascade: bool = True) -> "PipelineSpec":
197
+ """Return a new spec with the step (and optionally its cascade) removed.
198
+
199
+ Parameters
200
+ ----------
201
+ step_id : str
202
+ The step to remove.
203
+ cascade : bool
204
+ If True, also remove downstream steps that depend on columns
205
+ created by this step. If False, only remove this single step
206
+ (caller is responsible for ensuring validity).
207
+
208
+ Returns
209
+ -------
210
+ PipelineSpec
211
+ """
212
+ analysis = self.analyze_removal(step_id)
213
+ remove_ids = {step_id}
214
+ if cascade:
215
+ remove_ids.update(analysis.cascade_remove)
216
+ return PipelineSpec(
217
+ version=self.version,
218
+ steps=[s for s in self.steps if s.id not in remove_ids],
219
+ )
220
+
221
+ def insert_step(
222
+ self,
223
+ step: StepSpec,
224
+ after: Optional[str] = None,
225
+ ) -> "PipelineSpec":
226
+ """Return a new spec with a step inserted.
227
+
228
+ Parameters
229
+ ----------
230
+ step : StepSpec
231
+ The step to insert.
232
+ after : str or None
233
+ Insert after this step ID. If None, append to the end.
234
+
235
+ Returns
236
+ -------
237
+ PipelineSpec
238
+ """
239
+ new_steps = list(self.steps)
240
+ if after is None:
241
+ new_steps.append(step)
242
+ else:
243
+ idx = self.step_index(after)
244
+ new_steps.insert(idx + 1, step)
245
+ return PipelineSpec(version=self.version, steps=new_steps)
246
+
247
+ def reorder_step(self, step_id: str, new_index: int) -> "PipelineSpec":
248
+ """Return a new spec with a step moved to a new position.
249
+
250
+ Parameters
251
+ ----------
252
+ step_id : str
253
+ The step to move.
254
+ new_index : int
255
+ The target index (0-based).
256
+
257
+ Returns
258
+ -------
259
+ PipelineSpec
260
+ """
261
+ idx = self.step_index(step_id)
262
+ step = self.steps[idx]
263
+ remaining = [s for s in self.steps if s.id != step_id]
264
+ clamped = max(0, min(new_index, len(remaining)))
265
+ remaining.insert(clamped, step)
266
+ return PipelineSpec(version=self.version, steps=remaining)
267
+
268
+ def update_step_params(self, step_id: str, params: Dict) -> "PipelineSpec":
269
+ """Return a new spec with a step's params updated.
270
+
271
+ Parameters
272
+ ----------
273
+ step_id : str
274
+ The step to update.
275
+ params : dict
276
+ New params to merge into the step's existing params.
277
+
278
+ Returns
279
+ -------
280
+ PipelineSpec
281
+ """
282
+ new_steps = []
283
+ for step in self.steps:
284
+ if step.id == step_id:
285
+ merged = {**step.params, **params}
286
+ new_steps.append(step.model_copy(update={"params": merged}))
287
+ else:
288
+ new_steps.append(step)
289
+ return PipelineSpec(version=self.version, steps=new_steps)
290
+
291
+
292
+ def optimize(self) -> "PipelineSpec":
293
+ """Return a new spec with steps in optimal execution order.
294
+
295
+ Topologically sorts steps so that any step producing a column
296
+ comes before steps that consume it. Independent steps retain
297
+ their relative order.
298
+
299
+ Requires column contracts (``outputs``) and declared ``columns``
300
+ (inputs) on each step. If the spec is not enriched, returns
301
+ a copy with the original order.
302
+ """
303
+ if not self.is_enriched:
304
+ return self.model_copy()
305
+
306
+ from collections import defaultdict
307
+
308
+ step_map = {s.id: s for s in self.steps}
309
+ ids = [s.id for s in self.steps]
310
+
311
+ # Build producer map: column → step that creates it
312
+ producers: dict[str, str] = {}
313
+ for s in self.steps:
314
+ for col in s.outputs or []:
315
+ if col not in producers:
316
+ producers[col] = s.id
317
+
318
+ # Build adjacency from declared inputs
319
+ adj: dict[str, set[str]] = defaultdict(set)
320
+ in_deg: dict[str, int] = {sid: 0 for sid in ids}
321
+ for s in self.steps:
322
+ for col in s.columns or []:
323
+ prod = producers.get(col)
324
+ if prod and prod != s.id and prod in step_map:
325
+ if prod not in adj or s.id not in adj[prod]:
326
+ adj[prod].add(s.id)
327
+ in_deg[s.id] += 1
328
+
329
+ # Kahn's algorithm — stable (preserves original order within tiers)
330
+ ordered: list[str] = []
331
+ remaining = set(ids)
332
+ while remaining:
333
+ ready = [sid for sid in ids if sid in remaining and in_deg.get(sid, 0) == 0]
334
+ if not ready:
335
+ ordered.extend(sorted(remaining))
336
+ break
337
+ ordered.extend(ready)
338
+ for sid in ready:
339
+ remaining.discard(sid)
340
+ for nb in adj.get(sid, set()):
341
+ in_deg[nb] = max(in_deg.get(nb, 1) - 1, 0)
342
+
343
+ return PipelineSpec(
344
+ version=self.version,
345
+ steps=[step_map[sid] for sid in ordered if sid in step_map],
346
+ )
347
+
348
+
349
+ def validate_spec(spec: PipelineSpec) -> None:
350
+ """Validate a PipelineSpec. Raises ValueError if invalid.
351
+
352
+ Checks beyond Pydantic validation:
353
+ - All non-custom types must exist in the registry
354
+ - Custom steps must have 'code' and 'class_name' in params
355
+ """
356
+ from xplainable_preprocessing.registry import REGISTRY
357
+
358
+ for step in spec.steps:
359
+ if step.type == "custom":
360
+ if "code" not in step.params:
361
+ raise ValueError(
362
+ f"Step '{step.id}': custom type requires 'code' in params"
363
+ )
364
+ if "class_name" not in step.params:
365
+ raise ValueError(
366
+ f"Step '{step.id}': custom type requires 'class_name' in params"
367
+ )
368
+ elif step.type not in REGISTRY:
369
+ raise ValueError(
370
+ f"Step '{step.id}': unknown type '{step.type}'. "
371
+ f"Available: {sorted(REGISTRY.keys())}"
372
+ )
@@ -1,71 +0,0 @@
1
- """Pydantic models for pipeline specification."""
2
-
3
- from typing import Dict, List, Optional
4
-
5
- from pydantic import BaseModel, field_validator
6
-
7
-
8
- class StepSpec(BaseModel):
9
- """A single preprocessing step in the pipeline."""
10
-
11
- id: str
12
- type: str
13
- columns: Optional[List[str]] = None
14
- params: Dict = {}
15
- description: Optional[str] = None
16
-
17
- @field_validator("id")
18
- @classmethod
19
- def id_must_be_non_empty(cls, v: str) -> str:
20
- if not v.strip():
21
- raise ValueError("Step id must be non-empty")
22
- return v
23
-
24
- @field_validator("type")
25
- @classmethod
26
- def type_must_be_non_empty(cls, v: str) -> str:
27
- if not v.strip():
28
- raise ValueError("Step type must be non-empty")
29
- return v
30
-
31
-
32
- class PipelineSpec(BaseModel):
33
- """Full pipeline specification containing ordered steps."""
34
-
35
- version: str = "2.0"
36
- steps: List[StepSpec] = []
37
-
38
- @field_validator("steps")
39
- @classmethod
40
- def step_ids_must_be_unique(cls, v: List[StepSpec]) -> List[StepSpec]:
41
- ids = [step.id for step in v]
42
- if len(ids) != len(set(ids)):
43
- duplicates = [id_ for id_ in ids if ids.count(id_) > 1]
44
- raise ValueError(f"Duplicate step ids: {set(duplicates)}")
45
- return v
46
-
47
-
48
- def validate_spec(spec: PipelineSpec) -> None:
49
- """Validate a PipelineSpec. Raises ValueError if invalid.
50
-
51
- Checks beyond Pydantic validation:
52
- - All non-custom types must exist in the registry
53
- - Custom steps must have 'code' and 'class_name' in params
54
- """
55
- from xplainable_preprocessing.registry import REGISTRY
56
-
57
- for step in spec.steps:
58
- if step.type == "custom":
59
- if "code" not in step.params:
60
- raise ValueError(
61
- f"Step '{step.id}': custom type requires 'code' in params"
62
- )
63
- if "class_name" not in step.params:
64
- raise ValueError(
65
- f"Step '{step.id}': custom type requires 'class_name' in params"
66
- )
67
- elif step.type not in REGISTRY:
68
- raise ValueError(
69
- f"Step '{step.id}': unknown type '{step.type}'. "
70
- f"Available: {sorted(REGISTRY.keys())}"
71
- )