tabdat-synth 0.1.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.
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal, TypeAlias
6
+
7
+
8
+ ColumnDataType = Literal["categorical", "integer", "float"]
9
+ ColumnRole = Literal["feature", "outcome", "identifier"]
10
+ MissingValuePolicy = Literal["forbid", "as_category", "mean_impute"]
11
+ SourceFormat = Literal["parquet", "csv", "zip_csv"]
12
+ GenerationStepKind = Literal[
13
+ "empirical",
14
+ "truncated_normal",
15
+ "categorical_model",
16
+ "coefficient_sum",
17
+ "sigmoid_probability",
18
+ "bernoulli_response",
19
+ ]
20
+ BackendParamValue: TypeAlias = str | int | float | bool
21
+ BackendScalarValue: TypeAlias = str | int | float
22
+ MetricScalar: TypeAlias = str | int | float | bool | None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FreqShift:
27
+ """Categorical frequency shift in percentage points."""
28
+
29
+ source: str
30
+ dest: str
31
+ shift_pct: float
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ModelBackendSpec:
36
+ """Configuration for the categorical-model backend used by a spec."""
37
+
38
+ kind: str = "lightgbm"
39
+ params: dict[str, BackendParamValue] = field(default_factory=dict)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class CoefficientTerm:
44
+ """One term in a coefficient-based weighted sum."""
45
+
46
+ feature_name: str
47
+ coefficient: float
48
+ value: str | None = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CoefficientSumSpec:
53
+ """Typed configuration for coefficient-based weighted sums."""
54
+
55
+ intercept: float = 0.0
56
+ reference_value: str = "99"
57
+ terms: tuple[CoefficientTerm, ...] = field(default_factory=tuple)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class BernoulliResponseSpec:
62
+ """Typed response-adjustment configuration for Bernoulli sampling."""
63
+
64
+ intercept: float = 0.0
65
+ slope: float = 1.0
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ColumnSpec:
70
+ """Declarative schema metadata for one source or generated column."""
71
+
72
+ name: str
73
+ data_type: ColumnDataType
74
+ role: ColumnRole = "feature"
75
+ nullable: bool = False
76
+ missing_value_policy: MissingValuePolicy = "forbid"
77
+ allowed_values: tuple[str, ...] = field(default_factory=tuple)
78
+ minimum: float | None = None
79
+ maximum: float | None = None
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DatasetSchema:
84
+ """Schema contract for a configurable source dataset."""
85
+
86
+ columns: tuple[ColumnSpec, ...]
87
+
88
+ @property
89
+ def columns_by_name(self) -> dict[str, ColumnSpec]:
90
+ return {column.name: column for column in self.columns}
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class GenerationStep:
95
+ """Declarative description of one synthetic feature-generation step."""
96
+
97
+ name: str
98
+ kind: GenerationStepKind
99
+ output_column: str
100
+ source_column: str | None = None
101
+ parent_columns: tuple[str, ...] = field(default_factory=tuple)
102
+ input_column: str | None = None
103
+ mean_shift: float = 0.0
104
+ coefficient_sum: CoefficientSumSpec | None = None
105
+ response: BernoulliResponseSpec | None = None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class GenerationSpec:
110
+ """Top-level specification for config-driven synthetic generation."""
111
+
112
+ name: str
113
+ source_path: Path
114
+ source_format: SourceFormat
115
+ schema: DatasetSchema
116
+ steps: tuple[GenerationStep, ...]
117
+ model_backend: ModelBackendSpec = field(default_factory=ModelBackendSpec)
118
+ n_samples: int = 100_000
119
+ random_seed: int = 343
120
+ source_member: str | None = None
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class EmpiricalStepArtifact:
125
+ """Persisted empirical distribution for a fitted step."""
126
+
127
+ step: GenerationStep
128
+ values: tuple[BackendScalarValue, ...]
129
+ probabilities: tuple[float, ...]
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class TruncatedNormalStepArtifact:
134
+ """Persisted numeric summary statistics for a fitted step."""
135
+
136
+ step: GenerationStep
137
+ observed_mean: float
138
+ observed_std: float
139
+ minimum: float
140
+ maximum: float
141
+ round_to_int: bool
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class CategoricalModelStepArtifact:
146
+ """Persisted model state for a fitted categorical-model step."""
147
+
148
+ step: GenerationStep
149
+ backend_kind: str
150
+ input_columns: tuple[str, ...]
151
+ feature_order: tuple[str, ...]
152
+ learned_categories: tuple[str, ...]
153
+ fitted_model: Any
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class ConfiguredStepArtifact:
158
+ """Persisted configuration-only step with no source-side fitted state."""
159
+
160
+ step: GenerationStep
161
+
162
+
163
+ FittedStepArtifact: TypeAlias = (
164
+ EmpiricalStepArtifact
165
+ | TruncatedNormalStepArtifact
166
+ | CategoricalModelStepArtifact
167
+ | ConfiguredStepArtifact
168
+ )
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class FittedSynthesizer:
173
+ """Fitted synthesizer bundle that can generate without source data."""
174
+
175
+ spec: GenerationSpec
176
+ step_artifacts: tuple[FittedStepArtifact, ...]
177
+ package_version: str
178
+ bundle_format_version: int = 2
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class MetricResult:
183
+ """One quality or disclosure-risk metric in an evaluation report."""
184
+
185
+ category: str
186
+ name: str
187
+ column: str | None = None
188
+ values: dict[str, MetricScalar] = field(default_factory=dict)
189
+ details: dict[str, Any] = field(default_factory=dict)
190
+
191
+
192
+ @dataclass(frozen=True)
193
+ class EvaluationReport:
194
+ """Evaluation report comparing source and synthetic tabular data."""
195
+
196
+ source_row_count: int
197
+ synthetic_row_count: int
198
+ evaluated_columns: tuple[str, ...]
199
+ skipped_columns: tuple[str, ...] = field(default_factory=tuple)
200
+ quality_metrics: tuple[MetricResult, ...] = field(default_factory=tuple)
201
+ disclosure_risk_metrics: tuple[MetricResult, ...] = field(default_factory=tuple)
@@ -0,0 +1,35 @@
1
+ """Boundary adapters for IO and model backends."""
2
+
3
+ from tabdat_synth.interfaces.bundles import (
4
+ load_fitted_synthesizer,
5
+ save_fitted_synthesizer,
6
+ )
7
+ from tabdat_synth.interfaces.io import (
8
+ generation_spec_to_dict,
9
+ load_generation_spec,
10
+ load_generation_spec_mapping,
11
+ load_source_dataframe,
12
+ )
13
+ from tabdat_synth.interfaces.models import (
14
+ list_model_backends,
15
+ register_model_backend,
16
+ unregister_model_backend,
17
+ )
18
+ from tabdat_synth.interfaces.reports import (
19
+ evaluation_report_to_dict,
20
+ save_evaluation_report,
21
+ )
22
+
23
+ __all__ = [
24
+ "evaluation_report_to_dict",
25
+ "generation_spec_to_dict",
26
+ "list_model_backends",
27
+ "load_fitted_synthesizer",
28
+ "load_generation_spec",
29
+ "load_generation_spec_mapping",
30
+ "load_source_dataframe",
31
+ "register_model_backend",
32
+ "save_evaluation_report",
33
+ "save_fitted_synthesizer",
34
+ "unregister_model_backend",
35
+ ]
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ import hashlib
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ import json
7
+ from pathlib import Path
8
+ import pickle
9
+ import platform
10
+ import re
11
+
12
+ import yaml # type: ignore[import-untyped]
13
+
14
+ from tabdat_synth.domain.contracts import (
15
+ BernoulliResponseSpec,
16
+ CategoricalModelStepArtifact,
17
+ CoefficientSumSpec,
18
+ CoefficientTerm,
19
+ ConfiguredStepArtifact,
20
+ EmpiricalStepArtifact,
21
+ FittedStepArtifact,
22
+ FittedSynthesizer,
23
+ GenerationStep,
24
+ TruncatedNormalStepArtifact,
25
+ )
26
+ from tabdat_synth.interfaces.io import (
27
+ generation_spec_to_dict,
28
+ load_generation_spec_mapping,
29
+ )
30
+ from tabdat_synth.interfaces.models import require_model_backend
31
+ from tabdat_synth.version import __version__
32
+
33
+ BUNDLE_FORMAT_VERSION = 2
34
+ _METADATA_FILENAME = "metadata.json"
35
+ _SPEC_FILENAME = "spec.yaml"
36
+ _STEPS_DIRNAME = "steps"
37
+ _SOURCE_REQUIRED_STEP_KINDS = {"empirical", "truncated_normal", "categorical_model"}
38
+ _METADATA_DEPENDENCIES = (
39
+ "lightgbm",
40
+ "numpy",
41
+ "pandas",
42
+ "polars",
43
+ "pyarrow",
44
+ "PyYAML",
45
+ "scikit-learn",
46
+ "scipy",
47
+ )
48
+
49
+
50
+ def save_fitted_synthesizer(
51
+ fitted: FittedSynthesizer,
52
+ path: Path | str,
53
+ ) -> Path:
54
+ """Persist a fitted synthesizer bundle directory to disk."""
55
+ bundle_path = Path(path).resolve()
56
+ _prepare_bundle_directory(bundle_path)
57
+
58
+ steps_dir = bundle_path / _STEPS_DIRNAME
59
+ steps_dir.mkdir()
60
+
61
+ metadata = _build_bundle_metadata(fitted)
62
+ (bundle_path / _METADATA_FILENAME).write_text(
63
+ json.dumps(metadata, indent=2, sort_keys=True)
64
+ )
65
+ (bundle_path / _SPEC_FILENAME).write_text(
66
+ yaml.safe_dump(generation_spec_to_dict(fitted.spec), sort_keys=False)
67
+ )
68
+
69
+ for index, artifact in enumerate(fitted.step_artifacts):
70
+ _write_step_artifact(steps_dir, index, artifact)
71
+
72
+ return bundle_path
73
+
74
+
75
+ def _build_bundle_metadata(fitted: FittedSynthesizer) -> dict[str, object]:
76
+ spec_payload = generation_spec_to_dict(fitted.spec)
77
+ return {
78
+ "bundle_format_version": fitted.bundle_format_version,
79
+ "package_version": fitted.package_version,
80
+ "created_at": datetime.now(UTC).isoformat(),
81
+ "step_count": len(fitted.step_artifacts),
82
+ "artifact_counts": _artifact_counts(fitted.step_artifacts),
83
+ "dependency_versions": _dependency_versions(),
84
+ "model_backend": {
85
+ "kind": fitted.spec.model_backend.kind,
86
+ "params": dict(fitted.spec.model_backend.params),
87
+ },
88
+ "platform": {
89
+ "machine": platform.machine(),
90
+ "platform": platform.platform(),
91
+ "release": platform.release(),
92
+ "system": platform.system(),
93
+ },
94
+ "python": {
95
+ "implementation": platform.python_implementation(),
96
+ "version": platform.python_version(),
97
+ },
98
+ "schema_hash": _stable_payload_hash(spec_payload["schema"]),
99
+ "source": {
100
+ "format": fitted.spec.source_format,
101
+ "member": fitted.spec.source_member,
102
+ },
103
+ "spec_hash": _stable_payload_hash(spec_payload),
104
+ }
105
+
106
+
107
+ def _dependency_versions() -> dict[str, str | None]:
108
+ versions: dict[str, str | None] = {}
109
+ for package_name in _METADATA_DEPENDENCIES:
110
+ try:
111
+ versions[package_name] = version(package_name)
112
+ except PackageNotFoundError:
113
+ versions[package_name] = None
114
+ return versions
115
+
116
+
117
+ def _stable_payload_hash(payload: object) -> str:
118
+ encoded = json.dumps(
119
+ payload,
120
+ default=str,
121
+ separators=(",", ":"),
122
+ sort_keys=True,
123
+ ).encode("utf-8")
124
+ return hashlib.sha256(encoded).hexdigest()
125
+
126
+
127
+ def _artifact_counts(artifacts: tuple[FittedStepArtifact, ...]) -> dict[str, int]:
128
+ counts: dict[str, int] = {}
129
+ for artifact in artifacts:
130
+ artifact_kind = _artifact_kind(artifact)
131
+ counts[artifact_kind] = counts.get(artifact_kind, 0) + 1
132
+ return counts
133
+
134
+
135
+ def _artifact_kind(artifact: FittedStepArtifact) -> str:
136
+ if isinstance(artifact, EmpiricalStepArtifact):
137
+ return "empirical"
138
+ if isinstance(artifact, TruncatedNormalStepArtifact):
139
+ return "truncated_normal"
140
+ if isinstance(artifact, CategoricalModelStepArtifact):
141
+ return "categorical_model"
142
+ if isinstance(artifact, ConfiguredStepArtifact):
143
+ return "configured"
144
+ raise ValueError(f"unsupported fitted step artifact type: {type(artifact)}")
145
+
146
+
147
+ def load_fitted_synthesizer(path: Path | str) -> FittedSynthesizer:
148
+ """Load a persisted fitted synthesizer bundle from disk."""
149
+ bundle_path = Path(path).resolve()
150
+ metadata_path = bundle_path / _METADATA_FILENAME
151
+ spec_path = bundle_path / _SPEC_FILENAME
152
+ steps_dir = bundle_path / _STEPS_DIRNAME
153
+
154
+ for required_path in (metadata_path, spec_path, steps_dir):
155
+ if not required_path.exists():
156
+ raise ValueError(f"fitted bundle is missing required path: {required_path}")
157
+
158
+ metadata = json.loads(metadata_path.read_text())
159
+ bundle_format_version = int(metadata["bundle_format_version"])
160
+ if bundle_format_version != BUNDLE_FORMAT_VERSION:
161
+ raise ValueError(
162
+ "fitted bundle format version mismatch: "
163
+ f"expected {BUNDLE_FORMAT_VERSION}, got {bundle_format_version}"
164
+ )
165
+
166
+ package_version = str(metadata["package_version"])
167
+ if package_version != __version__:
168
+ raise ValueError(
169
+ "fitted bundle package version mismatch: "
170
+ f"expected {__version__}, got {package_version}"
171
+ )
172
+
173
+ raw_spec = yaml.safe_load(spec_path.read_text())
174
+ spec = load_generation_spec_mapping(raw_spec)
175
+ step_payload_paths = sorted(steps_dir.glob("*.json"))
176
+ expected_step_count = int(metadata["step_count"])
177
+ if len(step_payload_paths) != expected_step_count:
178
+ raise ValueError(
179
+ "fitted bundle step metadata count mismatch: "
180
+ f"expected {expected_step_count}, found {len(step_payload_paths)}"
181
+ )
182
+
183
+ step_artifacts = tuple(
184
+ _read_step_artifact(steps_dir, payload_path)
185
+ for payload_path in step_payload_paths
186
+ )
187
+ return FittedSynthesizer(
188
+ spec=spec,
189
+ step_artifacts=step_artifacts,
190
+ package_version=package_version,
191
+ bundle_format_version=bundle_format_version,
192
+ )
193
+
194
+
195
+ def _prepare_bundle_directory(bundle_path: Path) -> None:
196
+ if bundle_path.exists():
197
+ if not bundle_path.is_dir():
198
+ raise ValueError(f"bundle path is not a directory: {bundle_path}")
199
+ if any(bundle_path.iterdir()):
200
+ raise ValueError(f"bundle path must be empty before saving: {bundle_path}")
201
+ return
202
+
203
+ bundle_path.mkdir(parents=True)
204
+
205
+
206
+ def _write_step_artifact(
207
+ steps_dir: Path,
208
+ index: int,
209
+ artifact: FittedStepArtifact,
210
+ ) -> None:
211
+ stem = f"{index:03d}_{_safe_token(artifact.step.output_column)}"
212
+ payload_path = steps_dir / f"{stem}.json"
213
+
214
+ if isinstance(artifact, EmpiricalStepArtifact):
215
+ empirical_payload: dict[str, object] = {
216
+ "artifact_kind": "empirical",
217
+ "step": _generation_step_to_dict(artifact.step),
218
+ "values": list(artifact.values),
219
+ "probabilities": list(artifact.probabilities),
220
+ }
221
+ payload_path.write_text(json.dumps(empirical_payload, indent=2, sort_keys=True))
222
+ return
223
+
224
+ if isinstance(artifact, TruncatedNormalStepArtifact):
225
+ truncated_payload: dict[str, object] = {
226
+ "artifact_kind": "truncated_normal",
227
+ "step": _generation_step_to_dict(artifact.step),
228
+ "observed_mean": artifact.observed_mean,
229
+ "observed_std": artifact.observed_std,
230
+ "minimum": artifact.minimum,
231
+ "maximum": artifact.maximum,
232
+ "round_to_int": artifact.round_to_int,
233
+ }
234
+ payload_path.write_text(json.dumps(truncated_payload, indent=2, sort_keys=True))
235
+ return
236
+
237
+ if isinstance(artifact, CategoricalModelStepArtifact):
238
+ model_filename = f"{stem}.pkl"
239
+ model_payload: dict[str, object] = {
240
+ "artifact_kind": "categorical_model",
241
+ "step": _generation_step_to_dict(artifact.step),
242
+ "backend_kind": artifact.backend_kind,
243
+ "input_columns": list(artifact.input_columns),
244
+ "feature_order": list(artifact.feature_order),
245
+ "learned_categories": list(artifact.learned_categories),
246
+ "model_filename": model_filename,
247
+ }
248
+ payload_path.write_text(json.dumps(model_payload, indent=2, sort_keys=True))
249
+ with (steps_dir / model_filename).open("wb") as handle:
250
+ pickle.dump(artifact.fitted_model, handle)
251
+ return
252
+
253
+ if not isinstance(artifact, ConfiguredStepArtifact):
254
+ raise ValueError(
255
+ f"unsupported step artifact type for bundle persistence: {type(artifact)}"
256
+ )
257
+
258
+ configured_payload: dict[str, object] = {
259
+ "artifact_kind": "configured",
260
+ "step": _generation_step_to_dict(artifact.step),
261
+ }
262
+ payload_path.write_text(json.dumps(configured_payload, indent=2, sort_keys=True))
263
+
264
+
265
+ def _read_step_artifact(steps_dir: Path, payload_path: Path) -> FittedStepArtifact:
266
+ payload = json.loads(payload_path.read_text())
267
+ artifact_kind = payload["artifact_kind"]
268
+ step = _load_generation_step(payload["step"])
269
+
270
+ if artifact_kind == "empirical":
271
+ return EmpiricalStepArtifact(
272
+ step=step,
273
+ values=tuple(payload["values"]),
274
+ probabilities=tuple(float(value) for value in payload["probabilities"]),
275
+ )
276
+
277
+ if artifact_kind == "truncated_normal":
278
+ return TruncatedNormalStepArtifact(
279
+ step=step,
280
+ observed_mean=float(payload["observed_mean"]),
281
+ observed_std=float(payload["observed_std"]),
282
+ minimum=float(payload["minimum"]),
283
+ maximum=float(payload["maximum"]),
284
+ round_to_int=bool(payload["round_to_int"]),
285
+ )
286
+
287
+ if artifact_kind == "configured":
288
+ return ConfiguredStepArtifact(step=step)
289
+
290
+ if artifact_kind != "categorical_model":
291
+ raise ValueError(
292
+ f"fitted bundle contains unsupported step artifact kind: {artifact_kind}"
293
+ )
294
+
295
+ backend_kind = str(payload["backend_kind"])
296
+ require_model_backend(backend_kind)
297
+ model_path = steps_dir / str(payload["model_filename"])
298
+ if not model_path.exists():
299
+ raise ValueError(f"fitted bundle is missing required model file: {model_path}")
300
+
301
+ with model_path.open("rb") as handle:
302
+ fitted_model = pickle.load(handle)
303
+
304
+ return CategoricalModelStepArtifact(
305
+ step=step,
306
+ backend_kind=backend_kind,
307
+ input_columns=tuple(str(value) for value in payload["input_columns"]),
308
+ feature_order=tuple(str(value) for value in payload["feature_order"]),
309
+ learned_categories=tuple(str(value) for value in payload["learned_categories"]),
310
+ fitted_model=fitted_model,
311
+ )
312
+
313
+
314
+ def _load_generation_step(raw_step: object) -> GenerationStep:
315
+ if not isinstance(raw_step, dict):
316
+ raise ValueError("bundle step payload must be a mapping")
317
+
318
+ kind = raw_step["kind"]
319
+ source_column = raw_step.get("source_column")
320
+ if source_column is None and kind in _SOURCE_REQUIRED_STEP_KINDS:
321
+ source_column = raw_step["output_column"]
322
+
323
+ return GenerationStep(
324
+ name=raw_step["name"],
325
+ kind=kind,
326
+ output_column=raw_step["output_column"],
327
+ source_column=source_column,
328
+ parent_columns=tuple(raw_step.get("parent_columns", [])),
329
+ input_column=raw_step.get("input_column"),
330
+ mean_shift=float(raw_step.get("mean_shift", 0.0)),
331
+ coefficient_sum=_load_coefficient_sum(raw_step.get("coefficient_sum")),
332
+ response=_load_response(raw_step.get("response")),
333
+ )
334
+
335
+
336
+ def _generation_step_to_dict(step: GenerationStep) -> dict[str, object]:
337
+ payload: dict[str, object] = {
338
+ "name": step.name,
339
+ "kind": step.kind,
340
+ "output_column": step.output_column,
341
+ }
342
+ if step.source_column is not None and step.source_column != step.output_column:
343
+ payload["source_column"] = step.source_column
344
+ if step.parent_columns:
345
+ payload["parent_columns"] = list(step.parent_columns)
346
+ if step.input_column is not None:
347
+ payload["input_column"] = step.input_column
348
+ if step.mean_shift != 0.0:
349
+ payload["mean_shift"] = step.mean_shift
350
+ if step.coefficient_sum is not None:
351
+ payload["coefficient_sum"] = _coefficient_sum_to_dict(step.coefficient_sum)
352
+ if step.response is not None:
353
+ payload["response"] = _response_to_dict(step.response)
354
+ return payload
355
+
356
+
357
+ def _load_coefficient_sum(raw_coefficient_sum: object) -> CoefficientSumSpec | None:
358
+ if raw_coefficient_sum is None:
359
+ return None
360
+ if not isinstance(raw_coefficient_sum, dict):
361
+ raise ValueError("bundle step coefficient_sum must be a mapping")
362
+
363
+ raw_terms = raw_coefficient_sum.get("terms", [])
364
+ if not isinstance(raw_terms, list):
365
+ raise ValueError("bundle step coefficient_sum.terms must be a list")
366
+
367
+ terms = []
368
+ for raw_term in raw_terms:
369
+ if not isinstance(raw_term, dict):
370
+ raise ValueError("bundle step coefficient_sum terms must be mappings")
371
+ terms.append(
372
+ CoefficientTerm(
373
+ feature_name=raw_term["feature_name"],
374
+ coefficient=float(raw_term["coefficient"]),
375
+ value=str(raw_term["value"]) if "value" in raw_term else None,
376
+ )
377
+ )
378
+
379
+ return CoefficientSumSpec(
380
+ intercept=float(raw_coefficient_sum.get("intercept", 0.0)),
381
+ reference_value=str(raw_coefficient_sum.get("reference_value", "99")),
382
+ terms=tuple(terms),
383
+ )
384
+
385
+
386
+ def _coefficient_sum_to_dict(coefficient_sum: CoefficientSumSpec) -> dict[str, object]:
387
+ return {
388
+ "intercept": coefficient_sum.intercept,
389
+ "reference_value": coefficient_sum.reference_value,
390
+ "terms": [
391
+ _coefficient_term_to_dict(term) for term in coefficient_sum.terms
392
+ ],
393
+ }
394
+
395
+
396
+ def _coefficient_term_to_dict(term: CoefficientTerm) -> dict[str, object]:
397
+ payload: dict[str, object] = {
398
+ "feature_name": term.feature_name,
399
+ "coefficient": term.coefficient,
400
+ }
401
+ if term.value is not None:
402
+ payload["value"] = term.value
403
+ return payload
404
+
405
+
406
+ def _load_response(raw_response: object) -> BernoulliResponseSpec | None:
407
+ if raw_response is None:
408
+ return None
409
+ if not isinstance(raw_response, dict):
410
+ raise ValueError("bundle step response must be a mapping")
411
+ return BernoulliResponseSpec(
412
+ intercept=float(raw_response.get("intercept", 0.0)),
413
+ slope=float(raw_response.get("slope", 1.0)),
414
+ )
415
+
416
+
417
+ def _response_to_dict(response: BernoulliResponseSpec) -> dict[str, object]:
418
+ return {
419
+ "intercept": response.intercept,
420
+ "slope": response.slope,
421
+ }
422
+
423
+
424
+ def _safe_token(value: str) -> str:
425
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", value)