querycleaner 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.
Files changed (78) hide show
  1. qc/__init__.py +4 -0
  2. qc/application/__init__.py +1 -0
  3. qc/application/cancellation.py +54 -0
  4. qc/application/exports.py +128 -0
  5. qc/application/generation.py +439 -0
  6. qc/application/job_runner.py +163 -0
  7. qc/application/jobs.py +221 -0
  8. qc/application/runtime.py +105 -0
  9. qc/application/sanitize.py +318 -0
  10. qc/application/sources.py +79 -0
  11. qc/artifacts/__init__.py +1 -0
  12. qc/artifacts/profiles.py +104 -0
  13. qc/cli/__init__.py +1 -0
  14. qc/cli/app.py +1222 -0
  15. qc/cli/present.py +102 -0
  16. qc/config/__init__.py +29 -0
  17. qc/config/loader.py +199 -0
  18. qc/config/policies.py +158 -0
  19. qc/connectors/__init__.py +1 -0
  20. qc/connectors/base.py +178 -0
  21. qc/connectors/fixture.py +364 -0
  22. qc/connectors/google_sheets.py +503 -0
  23. qc/connectors/lark_sheets.py +441 -0
  24. qc/connectors/maxcompute.py +421 -0
  25. qc/connectors/mysql.py +572 -0
  26. qc/connectors/pool.py +113 -0
  27. qc/connectors/postgres.py +596 -0
  28. qc/connectors/registry.py +76 -0
  29. qc/connectors/resilience.py +128 -0
  30. qc/connectors/sheet_normalize.py +315 -0
  31. qc/connectors/sql_common.py +235 -0
  32. qc/connectors/sqlite_conn.py +733 -0
  33. qc/domain/__init__.py +139 -0
  34. qc/domain/cancellation.py +50 -0
  35. qc/domain/enums.py +262 -0
  36. qc/domain/errors.py +116 -0
  37. qc/domain/models.py +551 -0
  38. qc/domain/predicate.py +174 -0
  39. qc/exports/__init__.py +1 -0
  40. qc/exports/csv_writer.py +65 -0
  41. qc/exports/db_writer.py +232 -0
  42. qc/exports/jsonl.py +70 -0
  43. qc/generation/__init__.py +1 -0
  44. qc/generation/strategies.py +410 -0
  45. qc/generation/validation.py +247 -0
  46. qc/jdbc_bridge/__init__.py +22 -0
  47. qc/jdbc_bridge/client.py +370 -0
  48. qc/jdbc_bridge/protocol.py +178 -0
  49. qc/jdbc_bridge/sidecar/BUILD.md +62 -0
  50. qc/jdbc_bridge/sidecar/driver-lock.json +11 -0
  51. qc/jdbc_bridge/sidecar/src/qc/jdbc/Main.java +535 -0
  52. qc/mcp/__init__.py +1 -0
  53. qc/mcp/__main__.py +40 -0
  54. qc/mcp/metadata.py +155 -0
  55. qc/mcp/schemas.py +257 -0
  56. qc/mcp/server.py +222 -0
  57. qc/mcp/service.py +666 -0
  58. qc/observability/__init__.py +5 -0
  59. qc/observability/logging.py +138 -0
  60. qc/observability/metrics.py +132 -0
  61. qc/profiling/__init__.py +1 -0
  62. qc/profiling/engine.py +902 -0
  63. qc/profiling/falsy.py +166 -0
  64. qc/profiling/relationships.py +98 -0
  65. qc/profiling/type_inference.py +371 -0
  66. qc/sanitization/__init__.py +1 -0
  67. qc/sanitization/actions.py +140 -0
  68. qc/security/__init__.py +45 -0
  69. qc/security/audit.py +106 -0
  70. qc/security/policy.py +264 -0
  71. qc/security/redaction.py +80 -0
  72. qc/security/secrets.py +158 -0
  73. qc/security/sensitivity.py +291 -0
  74. querycleaner-0.1.0.dist-info/METADATA +645 -0
  75. querycleaner-0.1.0.dist-info/RECORD +78 -0
  76. querycleaner-0.1.0.dist-info/WHEEL +4 -0
  77. querycleaner-0.1.0.dist-info/entry_points.txt +2 -0
  78. querycleaner-0.1.0.dist-info/licenses/LICENSE +202 -0
qc/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """QueryCleaner/QC - local-first data profiling, sanitization, and synthetic data."""
2
+
3
+ __version__ = "0.1.0"
4
+ TOOL_VERSION = __version__
@@ -0,0 +1 @@
1
+ """QC application package."""
@@ -0,0 +1,54 @@
1
+ """Process-wide registry of cancellable jobs (SPEC 12.6, 15).
2
+
3
+ Maps job ids to :class:`~qc.domain.cancellation.CancellationToken` so
4
+ MCP ``cancel_job`` and CLI signal handlers can stop a running operation
5
+ without holding a reference to its coroutine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+
12
+ from qc.domain.cancellation import CancellationToken
13
+
14
+
15
+ class CancellationRegistry:
16
+ """Thread-safe registry; a cancellation from any thread is observed."""
17
+
18
+ def __init__(self) -> None:
19
+ self._lock = threading.Lock()
20
+ self._tokens: dict[str, CancellationToken] = {}
21
+
22
+ def register(self, job_id: str) -> CancellationToken:
23
+ token = CancellationToken()
24
+ with self._lock:
25
+ self._tokens[job_id] = token
26
+ return token
27
+
28
+ def get(self, job_id: str) -> CancellationToken | None:
29
+ with self._lock:
30
+ return self._tokens.get(job_id)
31
+
32
+ def cancel(self, job_id: str, reason: str | None = None) -> bool:
33
+ """Cancel a registered job; returns False if it is not running."""
34
+ with self._lock:
35
+ token = self._tokens.get(job_id)
36
+ if token is None:
37
+ return False
38
+ token.cancel(reason)
39
+ return True
40
+
41
+ def unregister(self, job_id: str) -> None:
42
+ with self._lock:
43
+ self._tokens.pop(job_id, None)
44
+
45
+ def running(self) -> list[str]:
46
+ with self._lock:
47
+ return [jid for jid, tok in self._tokens.items() if not tok.cancelled]
48
+
49
+
50
+ #: Process-wide registry shared by the CLI and MCP server.
51
+ REGISTRY = CancellationRegistry()
52
+
53
+
54
+ __all__ = ["REGISTRY", "CancellationRegistry"]
@@ -0,0 +1,128 @@
1
+ """Database destination export (SPEC 10.3).
2
+
3
+ Separate source/destination configs; approved destinations only;
4
+ create-new-table by default; append/replace require explicit mode.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from qc.domain.enums import OperationKind
14
+ from qc.domain.errors import PolicyDeniedError, ValidationError, WriteError
15
+ from qc.security.policy import DecisionContext, Operation
16
+
17
+ if TYPE_CHECKING:
18
+ from qc.application.runtime import Runtime
19
+
20
+
21
+ async def export_to_database(
22
+ rt: Runtime,
23
+ *,
24
+ output: Path,
25
+ destination: str,
26
+ table: str,
27
+ policy_name: str | None = None,
28
+ mode: str = "create",
29
+ ) -> dict[str, Any]:
30
+ """Write a JSONL/CSV file to an explicitly approved destination."""
31
+ # argument validation first (cheap, no I/O), then policy gates
32
+ if mode not in ("create", "append", "replace"):
33
+ raise ValidationError(f"invalid mode '{mode}'; use create|append|replace")
34
+ if not table.isidentifier() and not all(p.isidentifier() for p in table.split(".")):
35
+ raise ValidationError(f"invalid table name: {table}")
36
+
37
+ engine = rt.engine(policy_name)
38
+ policy = engine.policy
39
+ ctx = DecisionContext(source=destination, dataset=table)
40
+ engine.require(Operation.WRITE, ctx)
41
+ engine.require(Operation.EXPORT, ctx)
42
+
43
+ if not policy.write_allowed:
44
+ raise PolicyDeniedError("policy does not allow writes (write_allowed=false)")
45
+
46
+ dest_cfg = rt.source(destination)
47
+ rows = _read_rows(output)
48
+ if not rows:
49
+ raise ValidationError("output file contains no rows")
50
+
51
+ # destination role is a *separate* writer (SPEC 6, 10.3): source
52
+ # connectors never gain write_rows by configuration
53
+ writer = _destination_writer(dest_cfg)
54
+ try:
55
+ columns = list(rows[0].keys())
56
+ written = await writer.write_rows(dest_cfg, table, columns, rows, mode=mode)
57
+ finally:
58
+ await writer.close()
59
+
60
+ rt.audit.record(
61
+ OperationKind.EXPORT_DATABASE,
62
+ source_id=dest_cfg.id,
63
+ dataset_id=table,
64
+ policy_version=policy.version,
65
+ decision="allow",
66
+ rows_affected=written,
67
+ result_status="ok",
68
+ detail={"mode": mode},
69
+ )
70
+ return {
71
+ "destination": destination,
72
+ "table": table,
73
+ "rows": written,
74
+ "mode": mode,
75
+ "columns": len(rows[0]),
76
+ }
77
+
78
+
79
+ def _destination_writer(config: Any) -> Any:
80
+ """Return the writable destination role for a source config."""
81
+ kind = config.connector
82
+ if kind == "postgres":
83
+ from qc.exports.db_writer import PostgresDestination
84
+
85
+ return PostgresDestination()
86
+ if kind == "mysql":
87
+ from qc.exports.db_writer import MySQLDestination
88
+
89
+ return MySQLDestination()
90
+ raise WriteError(
91
+ f"connector '{kind}' does not support database destination writes",
92
+ detail={"destination": config.name},
93
+ )
94
+
95
+
96
+ def _read_rows(path: Path) -> list[dict[str, Any]]:
97
+ if not path.is_file():
98
+ raise WriteError(f"output file not found: {path.name}")
99
+ rows: list[dict[str, Any]] = []
100
+ if path.suffix == ".csv":
101
+ import csv
102
+
103
+ with path.open(encoding="utf-8", newline="") as fh:
104
+ for raw in csv.DictReader(fh):
105
+ rows.append({k: _coerce(v) for k, v in raw.items()})
106
+ else:
107
+ for line in path.read_text(encoding="utf-8").splitlines():
108
+ line = line.strip()
109
+ if line:
110
+ obj = json.loads(line)
111
+ if isinstance(obj, dict):
112
+ rows.append(obj)
113
+ return rows
114
+
115
+
116
+ def _coerce(v: str) -> Any:
117
+ if v == "\\N":
118
+ return None
119
+ if v in ("true", "false"):
120
+ return v == "true"
121
+ try:
122
+ return int(v)
123
+ except ValueError:
124
+ pass
125
+ try:
126
+ return float(v)
127
+ except ValueError:
128
+ return v
@@ -0,0 +1,439 @@
1
+ """Generation use cases: plan, run, validate (SPEC sections 9, 3.3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from qc.artifacts.profiles import find_profile, load_profile
12
+ from qc.domain.cancellation import CancellationToken
13
+ from qc.domain.enums import OperationKind
14
+ from qc.domain.errors import GenerationError, NotFoundError, ValidationError
15
+ from qc.domain.models import GenerationPlan, GenerationReport, StrategyOverride
16
+ from qc.generation.strategies import GenerationContext, execute, resolve_strategy
17
+ from qc.generation.validation import validate_generation
18
+ from qc.observability.metrics import OperationMetrics
19
+ from qc.security.policy import DecisionContext, Operation
20
+
21
+ if TYPE_CHECKING:
22
+ from qc.application.runtime import Runtime
23
+
24
+
25
+ def create_plan(
26
+ rt: Runtime,
27
+ *,
28
+ profile_ref: str,
29
+ rows: int,
30
+ seed: int = 0,
31
+ locale: str = "en_US",
32
+ policy_name: str | None = None,
33
+ output: Path | None = None,
34
+ allow_fallbacks: bool = False,
35
+ strategy_overrides: dict[str, str] | None = None,
36
+ ) -> GenerationPlan:
37
+ """Build a GenerationPlan from a saved profile after policy checks.
38
+
39
+ ``strategy_overrides`` maps column -> strategy name (SPEC 9.1 step 1:
40
+ explicit user-defined strategies win over the registry).
41
+ """
42
+ engine = rt.engine(policy_name)
43
+ path = rt.state.find_profile_index(profile_ref) or find_profile(rt.paths.profiles, profile_ref)
44
+ artifact = load_profile(path)
45
+ # policy keys are source *names*; artifacts store opaque ids
46
+ src_cfg = rt.sources.get_by_id(artifact.source_id)
47
+ if src_cfg is None:
48
+ raise NotFoundError(
49
+ "profile's source is not configured in this project "
50
+ "(sources may have been re-created); re-run profiling"
51
+ )
52
+ ctx = DecisionContext(source=src_cfg.name, dataset=artifact.dataset.qualified_name)
53
+ engine.require(Operation.DATASET_READ, ctx)
54
+
55
+ if rows > rt.ceiling.max_rows:
56
+ raise ValidationError(
57
+ f"requested {rows} rows exceeds ceiling {rt.ceiling.max_rows}",
58
+ )
59
+ known_columns = {c.name for c in artifact.columns}
60
+ overrides = [
61
+ StrategyOverride(column=col, strategy=strat)
62
+ for col, strat in (strategy_overrides or {}).items()
63
+ ]
64
+ for ov in overrides:
65
+ if ov.column not in known_columns:
66
+ raise ValidationError(
67
+ f"strategy override names unknown column '{ov.column}'",
68
+ detail={"column": ov.column},
69
+ )
70
+ plan = GenerationPlan(
71
+ profile_ids=[artifact.profile_id],
72
+ dataset=artifact.dataset,
73
+ rows=rows,
74
+ seed=seed,
75
+ locale=locale,
76
+ output_format=(output.suffix.lstrip(".") if output else "jsonl"),
77
+ allow_fallbacks=allow_fallbacks,
78
+ strategy_overrides=overrides,
79
+ budget=artifact.budget,
80
+ )
81
+ plan_path = rt.paths.reports / f"{plan.plan_id}.plan.json"
82
+ plan_path.parent.mkdir(parents=True, exist_ok=True)
83
+ plan_path.write_text(json.dumps(plan.json_safe(), indent=2), encoding="utf-8")
84
+ rt.audit.record(
85
+ OperationKind.GENERATE,
86
+ source_id=artifact.source_id,
87
+ dataset_id=artifact.dataset.qualified_name,
88
+ policy_version=engine.policy.version,
89
+ decision="allow",
90
+ result_status="ok",
91
+ detail={"plan_id": plan.plan_id, "rows": str(rows)},
92
+ )
93
+ return plan
94
+
95
+
96
+ def plan_path(rt: Runtime, plan: GenerationPlan) -> Path:
97
+ return rt.paths.reports / f"{plan.plan_id}.plan.json"
98
+
99
+
100
+ def _ensure_unique(
101
+ value: Any,
102
+ column: str,
103
+ seen: set[str],
104
+ strat_plan: Any,
105
+ context: Any,
106
+ max_attempts: int = 100,
107
+ ) -> Any:
108
+ """Bounded retry for unique columns; raises when the strategy cannot
109
+ satisfy uniqueness (SPEC 9.6: never silently weaken a constraint)."""
110
+ key = f"{type(value).__name__}:{value}"
111
+ attempts = 0
112
+ while key in seen and attempts < max_attempts:
113
+ value = execute(strat_plan, context)
114
+ key = f"{type(value).__name__}:{value}"
115
+ attempts += 1
116
+ if key in seen:
117
+ raise GenerationError(
118
+ f"strategy '{strat_plan.strategy}' could not produce a unique "
119
+ f"value for column '{column}' within {max_attempts} attempts; "
120
+ "define a strategy override or reduce row count",
121
+ detail={"column": column, "strategy": strat_plan.strategy},
122
+ )
123
+ seen.add(key)
124
+ return value
125
+
126
+
127
+ def _check_unique_feasibility(resolved: dict[str, Any], unique_cols: set[str], rows: int) -> None:
128
+ """Reject impossible uniqueness before generating a single row.
129
+
130
+ A bounded numeric range that cannot yield ``rows`` distinct values
131
+ fails immediately with an actionable error instead of exhausting
132
+ per-row retries (SPEC 9.6).
133
+ """
134
+ for name in sorted(unique_cols):
135
+ plan = resolved[name]
136
+ col = plan.profile
137
+ if col is None or col.numeric is None:
138
+ continue
139
+ if plan.strategy != "numeric_distribution":
140
+ continue
141
+ if col.numeric.minimum is None or col.numeric.maximum is None:
142
+ continue
143
+ try:
144
+ lo = int(float(col.numeric.minimum))
145
+ hi = int(float(col.numeric.maximum))
146
+ except (TypeError, ValueError):
147
+ continue
148
+ space = hi - lo + 1
149
+ if space < rows:
150
+ raise GenerationError(
151
+ f"column '{name}' requires {rows} unique values but its "
152
+ f"observed range [{col.numeric.minimum}, {col.numeric.maximum}] "
153
+ f"holds only {space}; reduce --rows, override the strategy, "
154
+ "or drop the uniqueness requirement",
155
+ detail={
156
+ "column": name,
157
+ "value_space": str(space),
158
+ "rows_requested": str(rows),
159
+ },
160
+ )
161
+
162
+
163
+ async def run_generation(
164
+ rt: Runtime,
165
+ *,
166
+ plan_file: Path,
167
+ output: Path | None = None,
168
+ report_path: Path | None = None,
169
+ seed: int | None = None,
170
+ strict: bool = False,
171
+ allow_fallbacks: bool = False,
172
+ cancel: CancellationToken | None = None,
173
+ ) -> dict[str, Any]:
174
+ """Execute a generation plan: synthesize, validate, report.
175
+
176
+ ``cancel`` is polled between generated rows and before writing output
177
+ so a cancelled job never leaves a partial artifact marked complete.
178
+ """
179
+ from faker import Faker
180
+
181
+ from qc.exports.csv_writer import write_csv_rows
182
+
183
+ def check_cancel() -> None:
184
+ if cancel is not None:
185
+ cancel.raise_if_cancelled()
186
+
187
+ if not plan_file.is_file():
188
+ raise NotFoundError(f"plan not found: {plan_file.name}")
189
+ plan = GenerationPlan.model_validate(json.loads(plan_file.read_text(encoding="utf-8")))
190
+ if seed is not None:
191
+ plan.seed = seed
192
+ if allow_fallbacks:
193
+ plan.allow_fallbacks = True
194
+
195
+ # Load all referenced profiles
196
+ profiles: dict[str, Any] = {}
197
+ for pid in plan.profile_ids:
198
+ p = rt.state.find_profile_index(pid) or find_profile(rt.paths.profiles, pid)
199
+ artifact = load_profile(p)
200
+ profiles[artifact.dataset.qualified_name] = artifact
201
+ if not profiles:
202
+ raise NotFoundError("plan references no loadable profiles")
203
+ primary = next(iter(profiles.values()))
204
+
205
+ # Policy check: artifacts store opaque source ids, policy keys are names
206
+ src_cfg = rt.sources.get_by_id(primary.source_id)
207
+ engine = rt.engine()
208
+ engine.require(
209
+ Operation.DATASET_READ,
210
+ DecisionContext(
211
+ source=src_cfg.name if src_cfg else plan.dataset.source_id,
212
+ dataset=plan.dataset.qualified_name,
213
+ ),
214
+ )
215
+
216
+ started = time.monotonic()
217
+ metrics = OperationMetrics(operation="generate")
218
+ rng = random.Random(plan.seed)
219
+ fake = Faker(plan.locale)
220
+ Faker.seed(plan.seed)
221
+ context = GenerationContext(plan=plan, profiles=profiles, faker=fake, rng=rng)
222
+
223
+ # Resolve strategies per column (fail-closed before generating rows)
224
+ overrides = {o.column: o.strategy for o in plan.strategy_overrides}
225
+ resolved: dict[str, Any] = {}
226
+ fallbacks: list[str] = []
227
+ for col in primary.columns:
228
+ strat_plan = resolve_strategy(col, context, overrides)
229
+ strat_plan.column_profile = col
230
+ resolved[col.name] = strat_plan
231
+ if strat_plan.fallback:
232
+ fallbacks.append(col.name)
233
+
234
+ # Uniqueness requirements (SPEC 9.2 "declared and *approved* inferred
235
+ # uniqueness"): enforce exact uniqueness only where it is actually a
236
+ # key — a declared PK, an identifier-like type, or an id-ish name.
237
+ # Sample-uniqueness on measure columns (e.g. a qty column that
238
+ # happens to be distinct in a small table) is evidence, not a key
239
+ # constraint, and must not make generation fail (SPEC 9.2, 9.6).
240
+ declared_pk = set(primary.dataset_schema.primary_key)
241
+ unique_cols: set[str] = set()
242
+ unenforced_sample_unique: list[str] = []
243
+ for name, sp in resolved.items():
244
+ col = sp.profile
245
+ if col is None or col.unique is not True:
246
+ continue
247
+ if col.unique_measurement.value != "exact":
248
+ continue
249
+ lowered = name.lower()
250
+ keyish = (
251
+ name in declared_pk
252
+ or col.logical_type.value in ("identifier", "uuid")
253
+ or lowered in ("id", "uuid", "guid")
254
+ or lowered.endswith("_id")
255
+ or lowered.endswith("_uuid")
256
+ )
257
+ if keyish:
258
+ unique_cols.add(name)
259
+ else:
260
+ unenforced_sample_unique.append(name)
261
+ seen: dict[str, set[str]] = {c: set() for c in unique_cols}
262
+ pre_warnings: list[str] = []
263
+ if unenforced_sample_unique:
264
+ pre_warnings.append(
265
+ "sample-uniqueness not enforced for non-key columns: "
266
+ + ", ".join(sorted(unenforced_sample_unique))
267
+ )
268
+
269
+ # Fail fast when a unique key's value space cannot fit the request
270
+ # (SPEC 9.6: stop and report; never silently weaken the constraint).
271
+ _check_unique_feasibility(resolved, unique_cols, plan.rows)
272
+
273
+ # Observed null rates (SPEC 9.2): preserve within tolerance using the
274
+ # seeded RNG so output stays reproducible. Rate comes from the profile,
275
+ # not from re-reading the source. Unique columns never emit nulls here
276
+ # (their profile null_count is 0 by construction).
277
+ null_rates: dict[str, float] = {}
278
+ for col in primary.columns:
279
+ total = col.row_count_seen or 0
280
+ nulls = col.falsy.null_count
281
+ if total and nulls and col.name not in unique_cols:
282
+ null_rates[col.name] = nulls / total
283
+
284
+ rows: list[dict[str, Any]] = []
285
+ warnings: list[str] = list(pre_warnings)
286
+ for i in range(plan.rows):
287
+ check_cancel()
288
+ context.row_index = i
289
+ row: dict[str, Any] = {}
290
+ for col_name, strat_plan in resolved.items():
291
+ rate = null_rates.get(col_name, 0.0)
292
+ if rate > 0 and rng.random() < rate:
293
+ row[col_name] = None
294
+ continue
295
+ value = execute(strat_plan, context)
296
+ if col_name in unique_cols:
297
+ value = _ensure_unique(value, col_name, seen[col_name], strat_plan, context)
298
+ row[col_name] = value
299
+ rows.append(row)
300
+
301
+ # Validate against profile (SPEC 9.5)
302
+ fidelity = validate_generation(rows, primary, tolerances=None)
303
+ hard_failures = [m for m in fidelity if m.hard and not m.passed]
304
+ soft_failures = [m for m in fidelity if not m.hard and not m.passed]
305
+ metrics.rows_generated = len(rows)
306
+ metrics.validation_failures = len(hard_failures) + len(soft_failures)
307
+
308
+ if plan.preserve_relationships:
309
+ for rel in primary.relationships:
310
+ if rel.kind == "foreign_key" and rel.verified:
311
+ warnings.append(
312
+ f"relationship {rel.from_columns}->{rel.to_columns} "
313
+ "preservation is declarative in v1"
314
+ )
315
+
316
+ metrics.stop()
317
+ from qc.observability.metrics import RECORDER
318
+
319
+ RECORDER.record(metrics)
320
+ warnings.extend(
321
+ [
322
+ "synthetic data is NOT anonymous; empirical-distribution synthesis "
323
+ "can leak information (no differential privacy claimed)",
324
+ "phone-like values use fictional 555 ranges but are not guaranteed unassigned",
325
+ ]
326
+ )
327
+ if fallbacks:
328
+ warnings.append(f"fallback strategies used for: {', '.join(fallbacks)}")
329
+
330
+ # Write output
331
+ check_cancel()
332
+ out_path = output or (rt.paths.output / f"synthetic.{plan.output_format}")
333
+ out_path.parent.mkdir(parents=True, exist_ok=True)
334
+ if plan.output_format == "csv":
335
+ write_csv_rows(out_path, rows)
336
+ else:
337
+ from qc.exports.jsonl import write_jsonl
338
+
339
+ write_jsonl(out_path, rows)
340
+
341
+ report = GenerationReport(
342
+ plan_id=plan.plan_id,
343
+ rows_requested=plan.rows,
344
+ rows_generated=len(rows),
345
+ seed=plan.seed,
346
+ metrics=fidelity,
347
+ constraint_violations=[
348
+ f"{m.name}: generated {m.generated} vs observed {m.observed}" for m in hard_failures
349
+ ],
350
+ strategy_fallbacks=fallbacks,
351
+ warnings=warnings,
352
+ preserved_properties=[
353
+ m.name
354
+ for m in fidelity
355
+ if m.passed and m.name.endswith(("null_rate", "categorical_tvd"))
356
+ ],
357
+ approximated_properties=[m.name for m in soft_failures],
358
+ runtime_seconds=time.monotonic() - started,
359
+ status="failed" if hard_failures or (strict and soft_failures) else "completed",
360
+ output_path=str(out_path),
361
+ )
362
+ rpath = report_path or (rt.paths.reports / f"{report.report_id}.report.json")
363
+ rpath.parent.mkdir(parents=True, exist_ok=True)
364
+ rpath.write_text(json.dumps(report.json_safe(), indent=2), encoding="utf-8")
365
+
366
+ rt.audit.record(
367
+ OperationKind.GENERATE,
368
+ source_id=primary.source_id,
369
+ dataset_id=plan.dataset.qualified_name,
370
+ policy_version=engine.policy.version,
371
+ decision="allow",
372
+ rows_affected=len(rows),
373
+ duration_seconds=report.runtime_seconds,
374
+ result_status=report.status,
375
+ detail={"report_id": report.report_id, "hard_failures": str(len(hard_failures))},
376
+ )
377
+ ok = not hard_failures and not (strict and soft_failures)
378
+ return {
379
+ "ok": ok,
380
+ "report": report,
381
+ "output": str(out_path),
382
+ "report_path": str(rpath),
383
+ "hard_failures": len(hard_failures),
384
+ "soft_failures": len(soft_failures),
385
+ "metrics": metrics.as_dict(),
386
+ }
387
+
388
+
389
+ async def validate_output(rt: Runtime, *, output: Path, profile_ref: str) -> GenerationReport:
390
+ """Validate an existing generated file against a profile."""
391
+ if not output.is_file():
392
+ raise NotFoundError(f"output not found: {output.name}")
393
+ path = rt.state.find_profile_index(profile_ref) or find_profile(rt.paths.profiles, profile_ref)
394
+ artifact = load_profile(path)
395
+
396
+ rows: list[dict[str, Any]] = []
397
+ if output.suffix == ".csv":
398
+ import csv
399
+
400
+ with output.open(encoding="utf-8", newline="") as fh:
401
+ for raw in csv.DictReader(fh):
402
+ rows.append({k: _maybe_number(v) for k, v in raw.items()})
403
+ else:
404
+ for line in output.read_text(encoding="utf-8").splitlines():
405
+ if line.strip():
406
+ rows.append(json.loads(line))
407
+
408
+ metrics = validate_generation(rows, artifact)
409
+ hard = [m for m in metrics if m.hard and not m.passed]
410
+ soft = [m for m in metrics if not m.hard and not m.passed]
411
+ from qc import TOOL_VERSION
412
+
413
+ return GenerationReport(
414
+ plan_id="validation",
415
+ rows_requested=len(rows),
416
+ rows_generated=len(rows),
417
+ metrics=metrics,
418
+ constraint_violations=[m.name for m in hard],
419
+ approximated_properties=[m.name for m in soft],
420
+ status="failed" if hard else "completed",
421
+ tool_version=TOOL_VERSION,
422
+ output_path=str(output),
423
+ warnings=["validation run against saved profile; no source data accessed"],
424
+ )
425
+
426
+
427
+ def _maybe_number(v: str) -> Any:
428
+ if v == "\\N" or v == "":
429
+ return None
430
+ if v in ("true", "false"):
431
+ return v == "true"
432
+ try:
433
+ return int(v)
434
+ except ValueError:
435
+ pass
436
+ try:
437
+ return float(v)
438
+ except ValueError:
439
+ return v