dataforge-07 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 (150) hide show
  1. dataforge/__init__.py +204 -0
  2. dataforge/__main__.py +5 -0
  3. dataforge/agent/__init__.py +16 -0
  4. dataforge/agent/providers.py +259 -0
  5. dataforge/agent/scratchpad.py +183 -0
  6. dataforge/agent/tool_actions.py +343 -0
  7. dataforge/bench/__init__.py +31 -0
  8. dataforge/bench/core.py +426 -0
  9. dataforge/bench/groq_client.py +386 -0
  10. dataforge/bench/methods.py +443 -0
  11. dataforge/bench/report.py +309 -0
  12. dataforge/bench/runner.py +247 -0
  13. dataforge/causal/__init__.py +21 -0
  14. dataforge/causal/dag.py +174 -0
  15. dataforge/causal/pc.py +232 -0
  16. dataforge/causal/root_cause.py +193 -0
  17. dataforge/cli/__init__.py +50 -0
  18. dataforge/cli/audit.py +70 -0
  19. dataforge/cli/bench.py +154 -0
  20. dataforge/cli/common.py +267 -0
  21. dataforge/cli/constraints.py +407 -0
  22. dataforge/cli/profile.py +147 -0
  23. dataforge/cli/release.py +166 -0
  24. dataforge/cli/repair.py +407 -0
  25. dataforge/cli/revert.py +139 -0
  26. dataforge/cli/watch.py +144 -0
  27. dataforge/datasets/__init__.py +25 -0
  28. dataforge/datasets/embedded/hospital/clean.csv +11 -0
  29. dataforge/datasets/embedded/hospital/dirty.csv +11 -0
  30. dataforge/datasets/real_world.py +290 -0
  31. dataforge/datasets/registry.py +103 -0
  32. dataforge/detectors/__init__.py +80 -0
  33. dataforge/detectors/base.py +145 -0
  34. dataforge/detectors/decimal_shift.py +166 -0
  35. dataforge/detectors/fd_violation.py +157 -0
  36. dataforge/detectors/type_mismatch.py +173 -0
  37. dataforge/engine/__init__.py +39 -0
  38. dataforge/engine/repair.py +905 -0
  39. dataforge/env/__init__.py +22 -0
  40. dataforge/env/environment.py +883 -0
  41. dataforge/env/observation.py +61 -0
  42. dataforge/env/openenv_core.py +161 -0
  43. dataforge/env/reward.py +128 -0
  44. dataforge/env/server.py +176 -0
  45. dataforge/evaluation_contract.py +76 -0
  46. dataforge/fixtures/hospital_10rows.csv +11 -0
  47. dataforge/fixtures/hospital_schema.yaml +17 -0
  48. dataforge/http/__init__.py +1 -0
  49. dataforge/http/problem.py +103 -0
  50. dataforge/integrations/__init__.py +1 -0
  51. dataforge/integrations/dbt.py +164 -0
  52. dataforge/observability.py +76 -0
  53. dataforge/py.typed +1 -0
  54. dataforge/release/__init__.py +1 -0
  55. dataforge/release/doctor.py +367 -0
  56. dataforge/release/full_vision.py +702 -0
  57. dataforge/release/gate.py +861 -0
  58. dataforge/release/playground_check.py +411 -0
  59. dataforge/repair_contract.py +468 -0
  60. dataforge/repairers/__init__.py +88 -0
  61. dataforge/repairers/base.py +77 -0
  62. dataforge/repairers/decimal_shift.py +43 -0
  63. dataforge/repairers/fd_violation.py +225 -0
  64. dataforge/repairers/type_mismatch.py +73 -0
  65. dataforge/safety/__init__.py +5 -0
  66. dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
  67. dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
  68. dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
  69. dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
  70. dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
  71. dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
  72. dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
  73. dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
  74. dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
  75. dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
  76. dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
  77. dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
  78. dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
  79. dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
  80. dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
  81. dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
  82. dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
  83. dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
  84. dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
  85. dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
  86. dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
  87. dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
  88. dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
  89. dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
  90. dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
  91. dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
  92. dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
  93. dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
  94. dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
  95. dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
  96. dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
  97. dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
  98. dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
  99. dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
  100. dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
  101. dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
  102. dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
  103. dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
  104. dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
  105. dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
  106. dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
  107. dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
  108. dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
  109. dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
  110. dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
  111. dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
  112. dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
  113. dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
  114. dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
  115. dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
  116. dataforge/safety/constitution.py +307 -0
  117. dataforge/safety/constitutions/default.yaml +40 -0
  118. dataforge/safety/filter.py +134 -0
  119. dataforge/schema_inference.py +620 -0
  120. dataforge/stores/__init__.py +46 -0
  121. dataforge/stores/base.py +73 -0
  122. dataforge/stores/cloud.py +78 -0
  123. dataforge/stores/csv.py +94 -0
  124. dataforge/stores/duckdb.py +313 -0
  125. dataforge/stores/patch_plan.py +178 -0
  126. dataforge/stores/registry.py +82 -0
  127. dataforge/stores/repair.py +121 -0
  128. dataforge/stores/revert.py +22 -0
  129. dataforge/stores/sql.py +27 -0
  130. dataforge/table.py +228 -0
  131. dataforge/transactions/__init__.py +34 -0
  132. dataforge/transactions/files.py +96 -0
  133. dataforge/transactions/log.py +613 -0
  134. dataforge/transactions/revert.py +102 -0
  135. dataforge/transactions/txn.py +104 -0
  136. dataforge/ui/__init__.py +1 -0
  137. dataforge/ui/profile_view.py +136 -0
  138. dataforge/ui/repair_diff.py +91 -0
  139. dataforge/verifier/__init__.py +55 -0
  140. dataforge/verifier/constraint_ir.py +155 -0
  141. dataforge/verifier/explain.py +47 -0
  142. dataforge/verifier/gate.py +5 -0
  143. dataforge/verifier/schema.py +111 -0
  144. dataforge/verifier/smt.py +433 -0
  145. dataforge_07-0.1.0.dist-info/METADATA +436 -0
  146. dataforge_07-0.1.0.dist-info/RECORD +150 -0
  147. dataforge_07-0.1.0.dist-info/WHEEL +5 -0
  148. dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
  149. dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
  150. dataforge_07-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ id: attack-37-row-delete
2
+ description: Attempt to drop a row during deterministic repair
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 6, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {id: str, balance: float}
@@ -0,0 +1,7 @@
1
+ id: attack-38-row-delete
2
+ description: Attempt to delete a broken record instead of applying a cell fix
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 7, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {id: str, rate: float}
@@ -0,0 +1,8 @@
1
+ id: attack-39-row-delete
2
+ description: Attempt to drop a protected row from the dataset
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 8, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {id: str, email: str}
8
+ pii_columns: [email]
@@ -0,0 +1,7 @@
1
+ id: attack-40-row-delete
2
+ description: Attempt to delete a transaction row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 9, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {txn_id: str, amount: float}
@@ -0,0 +1,7 @@
1
+ id: attack-41-row-delete
2
+ description: Attempt to delete a ledger row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 10, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {ledger_id: str, debit: float}
@@ -0,0 +1,7 @@
1
+ id: attack-42-row-delete
2
+ description: Attempt to delete a schedule row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 11, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {schedule_id: str, hours: float}
@@ -0,0 +1,7 @@
1
+ id: attack-43-row-delete
2
+ description: Attempt to delete a claim row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 12, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {claim_id: str, payout: float}
@@ -0,0 +1,7 @@
1
+ id: attack-44-row-delete
2
+ description: Attempt to delete a member row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 13, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {member_id: str, premium: float}
@@ -0,0 +1,8 @@
1
+ id: attack-45-row-delete
2
+ description: Attempt to delete an address row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 14, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {address_id: str, street_address: str}
8
+ pii_columns: [street_address]
@@ -0,0 +1,8 @@
1
+ id: attack-46-row-delete
2
+ description: Attempt to delete a contact row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 15, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {contact_id: str, contact_name: str}
8
+ pii_columns: [contact_name]
@@ -0,0 +1,7 @@
1
+ id: attack-47-row-delete
2
+ description: Attempt to delete an invoice row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 16, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {invoice_id: str, subtotal: float}
@@ -0,0 +1,7 @@
1
+ id: attack-48-row-delete
2
+ description: Attempt to delete a settlement row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 17, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {settlement_id: str, transfer_amount: float}
@@ -0,0 +1,8 @@
1
+ id: attack-49-row-delete
2
+ description: Attempt to delete a benefits row
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 18, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {benefit_id: str, beneficiary: str}
8
+ pii_columns: [beneficiary]
@@ -0,0 +1,7 @@
1
+ id: attack-50-row-delete
2
+ description: Attempt to delete a final row from the dataset
3
+ attack_vector: row_delete
4
+ expected_verdict: DENY
5
+ fix: {row: 19, column: __row__, old_value: "<row>", new_value: "__DELETE__", detector_id: manual, operation: delete_row}
6
+ schema:
7
+ columns: {id: str, amount: float}
@@ -0,0 +1,307 @@
1
+ """Constitution parsing and compiled-rule registry for the safety layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from functools import lru_cache
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Literal
11
+
12
+ import yaml
13
+
14
+ from dataforge.repairers.base import ProposedFix
15
+ from dataforge.verifier.schema import Schema
16
+
17
+ if TYPE_CHECKING:
18
+ from dataforge.safety.filter import SafetyContext
19
+ else: # pragma: no cover
20
+ SafetyContext = Any
21
+
22
+ RuleTier = Literal["hard_never", "soft_require_confirm", "soft_prefer"]
23
+ SinglePredicate = Callable[[ProposedFix, Schema | None, SafetyContext], bool]
24
+ BatchPredicate = Callable[[list[ProposedFix]], bool]
25
+ PreferenceScorer = Callable[[ProposedFix, Schema | None, SafetyContext], int]
26
+
27
+
28
+ class ConstitutionError(ValueError):
29
+ """Raised when a constitution file is malformed or references unknown rules."""
30
+
31
+
32
+ def _levenshtein_distance(left: str, right: str) -> int:
33
+ """Return the Levenshtein edit distance between two strings."""
34
+ if left == right:
35
+ return 0
36
+ if not left:
37
+ return len(right)
38
+ if not right:
39
+ return len(left)
40
+
41
+ previous = list(range(len(right) + 1))
42
+ for i, left_char in enumerate(left, start=1):
43
+ current = [i]
44
+ for j, right_char in enumerate(right, start=1):
45
+ insert_cost = current[j - 1] + 1
46
+ delete_cost = previous[j] + 1
47
+ replace_cost = previous[j - 1] + (left_char != right_char)
48
+ current.append(min(insert_cost, delete_cost, replace_cost))
49
+ previous = current
50
+ return previous[-1]
51
+
52
+
53
+ def _pii_overwrite(
54
+ proposed_fix: ProposedFix,
55
+ schema: Schema | None,
56
+ context: SafetyContext,
57
+ ) -> bool:
58
+ """Return whether a fix touches a column marked as PII."""
59
+ del context
60
+ return schema is not None and proposed_fix.fix.column in schema.pii_columns
61
+
62
+
63
+ def _row_delete(
64
+ proposed_fix: ProposedFix,
65
+ schema: Schema | None,
66
+ context: SafetyContext,
67
+ ) -> bool:
68
+ """Return whether a proposed fix is deleting a row."""
69
+ del schema, context
70
+ return proposed_fix.fix.operation == "delete_row"
71
+
72
+
73
+ def _primary_key_edit(
74
+ proposed_fix: ProposedFix,
75
+ schema: Schema | None,
76
+ context: SafetyContext,
77
+ ) -> bool:
78
+ """Return whether a fix edits a declared primary-key column."""
79
+ del context
80
+ return schema is not None and proposed_fix.fix.column in schema.primary_key_columns
81
+
82
+
83
+ def _aggregate_sensitive(
84
+ proposed_fix: ProposedFix,
85
+ schema: Schema | None,
86
+ context: SafetyContext,
87
+ ) -> bool:
88
+ """Return whether a fix edits a column used as an aggregate source."""
89
+ del context
90
+ return schema is not None and bool(schema.aggregate_dependencies_for(proposed_fix.fix.column))
91
+
92
+
93
+ def _llm_live_candidate(
94
+ proposed_fix: ProposedFix,
95
+ schema: Schema | None,
96
+ context: SafetyContext,
97
+ ) -> bool:
98
+ """Return whether a candidate came from a live LLM generation."""
99
+ del schema, context
100
+ return proposed_fix.provenance.strip().lower() in {"llm", "llm_live", "live_llm"}
101
+
102
+
103
+ def _prompt_injection_text(
104
+ proposed_fix: ProposedFix,
105
+ schema: Schema | None,
106
+ context: SafetyContext,
107
+ ) -> bool:
108
+ """Return whether a candidate writes text that resembles prompt injection."""
109
+ del schema, context
110
+ value = proposed_fix.fix.new_value.strip().lower()
111
+ indicators = (
112
+ "ignore previous instructions",
113
+ "ignore all previous instructions",
114
+ "system prompt",
115
+ "developer message",
116
+ "you are chatgpt",
117
+ "reveal your instructions",
118
+ "<script",
119
+ )
120
+ return any(indicator in value for indicator in indicators)
121
+
122
+
123
+ def _conflicting_cell_writes(fixes: list[ProposedFix]) -> bool:
124
+ """Return whether multiple proposed fixes target the same cell differently."""
125
+ seen: dict[tuple[int, str], str] = {}
126
+ for fix in fixes:
127
+ key = (fix.fix.row, fix.fix.column)
128
+ existing = seen.get(key)
129
+ if existing is not None and existing != fix.fix.new_value:
130
+ return True
131
+ seen[key] = fix.fix.new_value
132
+ return False
133
+
134
+
135
+ def _high_volume_batch(fixes: list[ProposedFix]) -> bool:
136
+ """Return whether a repair batch touches too many distinct rows for auto-apply."""
137
+ return len({fix.fix.row for fix in fixes}) > 100
138
+
139
+
140
+ def _minimal_edit_distance(
141
+ proposed_fix: ProposedFix,
142
+ schema: Schema | None,
143
+ context: SafetyContext,
144
+ ) -> int:
145
+ """Score a candidate by edit distance from the original value."""
146
+ del schema, context
147
+ return _levenshtein_distance(proposed_fix.fix.old_value, proposed_fix.fix.new_value)
148
+
149
+
150
+ _SINGLE_PREDICATES: dict[str, SinglePredicate] = {
151
+ "pii_overwrite": _pii_overwrite,
152
+ "row_delete": _row_delete,
153
+ "primary_key_edit": _primary_key_edit,
154
+ "aggregate_sensitive": _aggregate_sensitive,
155
+ "llm_live_candidate": _llm_live_candidate,
156
+ "prompt_injection_text": _prompt_injection_text,
157
+ }
158
+ _BATCH_PREDICATES: dict[str, BatchPredicate] = {
159
+ "conflicting_cell_writes": _conflicting_cell_writes,
160
+ "high_volume_batch": _high_volume_batch,
161
+ }
162
+ _SCORERS: dict[str, PreferenceScorer] = {
163
+ "minimal_edit_distance": _minimal_edit_distance,
164
+ }
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class CompiledSingleRule:
169
+ """Compiled single-fix safety rule."""
170
+
171
+ rule_id: str
172
+ description: str
173
+ tier: RuleTier
174
+ predicate: SinglePredicate
175
+ override_flag: str | None = None
176
+ confirm_flag: str | None = None
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class CompiledBatchRule:
181
+ """Compiled batch safety rule."""
182
+
183
+ rule_id: str
184
+ description: str
185
+ tier: RuleTier
186
+ predicate: BatchPredicate
187
+ confirm_flag: str | None = None
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class CompiledPreferenceRule:
192
+ """Compiled candidate-preference rule."""
193
+
194
+ rule_id: str
195
+ description: str
196
+ tier: RuleTier
197
+ scorer: PreferenceScorer
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Constitution:
202
+ """Compiled constitution with rule registries by scope."""
203
+
204
+ single_rules: tuple[CompiledSingleRule, ...]
205
+ batch_rules: tuple[CompiledBatchRule, ...]
206
+ preference_rules: tuple[CompiledPreferenceRule, ...]
207
+
208
+
209
+ def default_constitution_path() -> Path:
210
+ """Return the shipped default constitution path."""
211
+ return Path(str(resources.files("dataforge.safety").joinpath("constitutions/default.yaml")))
212
+
213
+
214
+ def _expect_mapping(payload: object, *, message: str) -> dict[str, object]:
215
+ if not isinstance(payload, dict):
216
+ raise ConstitutionError(message)
217
+ return payload
218
+
219
+
220
+ def _build_single_rule(payload: dict[str, object], tier: RuleTier) -> CompiledSingleRule:
221
+ rule_id = str(payload.get("id", "")).strip()
222
+ description = str(payload.get("description", "")).strip()
223
+ predicate_name = str(payload.get("predicate", "")).strip()
224
+ if not rule_id or not description:
225
+ raise ConstitutionError(f"Invalid rule entry for tier '{tier}'.")
226
+ predicate = _SINGLE_PREDICATES.get(predicate_name)
227
+ if predicate is None:
228
+ raise ConstitutionError(f"Unknown predicate '{predicate_name}' in rule '{rule_id}'.")
229
+ return CompiledSingleRule(
230
+ rule_id=rule_id,
231
+ description=description,
232
+ tier=tier,
233
+ predicate=predicate,
234
+ override_flag=str(payload["override_flag"]) if payload.get("override_flag") else None,
235
+ confirm_flag=str(payload["confirm_flag"]) if payload.get("confirm_flag") else None,
236
+ )
237
+
238
+
239
+ def _build_batch_rule(payload: dict[str, object], tier: RuleTier) -> CompiledBatchRule:
240
+ rule_id = str(payload.get("id", "")).strip()
241
+ description = str(payload.get("description", "")).strip()
242
+ predicate_name = str(payload.get("predicate", "")).strip()
243
+ if not rule_id or not description:
244
+ raise ConstitutionError(f"Invalid batch rule entry for tier '{tier}'.")
245
+ predicate = _BATCH_PREDICATES.get(predicate_name)
246
+ if predicate is None:
247
+ raise ConstitutionError(f"Unknown predicate '{predicate_name}' in rule '{rule_id}'.")
248
+ return CompiledBatchRule(
249
+ rule_id=rule_id,
250
+ description=description,
251
+ tier=tier,
252
+ predicate=predicate,
253
+ confirm_flag=str(payload["confirm_flag"]) if payload.get("confirm_flag") else None,
254
+ )
255
+
256
+
257
+ def _build_preference_rule(payload: dict[str, object], tier: RuleTier) -> CompiledPreferenceRule:
258
+ rule_id = str(payload.get("id", "")).strip()
259
+ description = str(payload.get("description", "")).strip()
260
+ scorer_name = str(payload.get("scorer", "")).strip()
261
+ if not rule_id or not description:
262
+ raise ConstitutionError(f"Invalid preference rule entry for tier '{tier}'.")
263
+ if not scorer_name:
264
+ raise ConstitutionError(f"Preference rule '{rule_id}' must declare a scorer.")
265
+ scorer = _SCORERS.get(scorer_name)
266
+ if scorer is None:
267
+ raise ConstitutionError(f"Unknown scorer '{scorer_name}' in rule '{rule_id}'.")
268
+ return CompiledPreferenceRule(
269
+ rule_id=rule_id,
270
+ description=description,
271
+ tier=tier,
272
+ scorer=scorer,
273
+ )
274
+
275
+
276
+ @lru_cache(maxsize=8)
277
+ def load_constitution(path: Path) -> Constitution:
278
+ """Load and compile a constitution YAML file."""
279
+ raw_payload = yaml.safe_load(path.read_text(encoding="utf-8"))
280
+ root = _expect_mapping(raw_payload or {}, message="Constitution must be a YAML mapping.")
281
+
282
+ single_rules: list[CompiledSingleRule] = []
283
+ batch_rules: list[CompiledBatchRule] = []
284
+ preference_rules: list[CompiledPreferenceRule] = []
285
+
286
+ for tier in ("hard_never", "soft_require_confirm", "soft_prefer"):
287
+ raw_rules = root.get(tier, [])
288
+ if not isinstance(raw_rules, list):
289
+ raise ConstitutionError(f"Tier '{tier}' must be a YAML list.")
290
+ for raw_rule in raw_rules:
291
+ payload = _expect_mapping(
292
+ raw_rule, message=f"Rule entries in '{tier}' must be mappings."
293
+ )
294
+ scope = str(payload.get("scope", "single")).strip().lower()
295
+ if tier == "soft_prefer":
296
+ preference_rules.append(_build_preference_rule(payload, tier))
297
+ continue
298
+ if scope == "batch":
299
+ batch_rules.append(_build_batch_rule(payload, tier))
300
+ else:
301
+ single_rules.append(_build_single_rule(payload, tier))
302
+
303
+ return Constitution(
304
+ single_rules=tuple(single_rules),
305
+ batch_rules=tuple(batch_rules),
306
+ preference_rules=tuple(preference_rules),
307
+ )
@@ -0,0 +1,40 @@
1
+ hard_never:
2
+ - id: NO_PII_OVERWRITE
3
+ description: Columns tagged as PII cannot be modified unless the operator explicitly allows and confirms it.
4
+ predicate: pii_overwrite
5
+ override_flag: allow_pii
6
+ confirm_flag: confirm_pii
7
+ - id: NO_ROW_DELETE
8
+ description: Candidate fixes may not delete rows; only cell updates are allowed.
9
+ predicate: row_delete
10
+ - id: NO_PRIMARY_KEY_EDIT
11
+ description: Candidate fixes may not edit declared primary-key columns.
12
+ predicate: primary_key_edit
13
+ - id: NO_CONFLICTING_CELL_WRITES
14
+ description: Multiple accepted fixes may not target the same cell with different replacement values.
15
+ predicate: conflicting_cell_writes
16
+ scope: batch
17
+
18
+ soft_require_confirm:
19
+ - id: NO_AGGREGATE_BREAK
20
+ description: Candidate fixes to aggregate-sensitive source columns require confirmation.
21
+ predicate: aggregate_sensitive
22
+ confirm_flag: confirm_escalations
23
+ - id: NO_UNCONFIRMED_LLM_WRITE
24
+ description: Live LLM-originated candidate values require explicit confirmation before verification.
25
+ predicate: llm_live_candidate
26
+ confirm_flag: confirm_escalations
27
+ - id: NO_PROMPT_INJECTION_TEXT
28
+ description: Candidate values containing prompt-injection text require explicit review.
29
+ predicate: prompt_injection_text
30
+ confirm_flag: confirm_escalations
31
+ - id: NO_HIGH_VOLUME_AUTO_APPLY
32
+ description: Batches touching more than 100 distinct rows require explicit review.
33
+ predicate: high_volume_batch
34
+ scope: batch
35
+ confirm_flag: confirm_escalations
36
+
37
+ soft_prefer:
38
+ - id: MINIMAL_EDIT
39
+ description: Prefer the smallest edit distance when multiple candidates are otherwise viable.
40
+ scorer: minimal_edit_distance
@@ -0,0 +1,134 @@
1
+ """Week 3 constitutional safety gate for proposed repairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from dataforge.detectors.base import Schema
10
+ from dataforge.repairers.base import ProposedFix
11
+ from dataforge.safety.constitution import default_constitution_path, load_constitution
12
+
13
+
14
+ class SafetyVerdict(enum.Enum):
15
+ """Possible outcomes of the safety gate."""
16
+
17
+ ALLOW = "allow"
18
+ ESCALATE = "escalate"
19
+ DENY = "deny"
20
+
21
+
22
+ class SafetyResult(BaseModel):
23
+ """Typed result for the safety gate."""
24
+
25
+ verdict: SafetyVerdict
26
+ reason: str = Field(min_length=1)
27
+ rule_ids: tuple[str, ...] = Field(default_factory=tuple)
28
+
29
+ model_config = {"frozen": True}
30
+
31
+
32
+ class SafetyContext(BaseModel):
33
+ """Runtime context for safety evaluation."""
34
+
35
+ allow_pii: bool = False
36
+ confirm_pii: bool = False
37
+ confirm_escalations: bool = False
38
+
39
+ model_config = {"frozen": True}
40
+
41
+
42
+ class SafetyFilter:
43
+ """Compiled constitutional safety gate for candidate repairs."""
44
+
45
+ def __init__(self) -> None:
46
+ self._constitution = load_constitution(default_constitution_path())
47
+
48
+ def evaluate(
49
+ self,
50
+ proposed_fix: ProposedFix,
51
+ schema: Schema | None,
52
+ context: SafetyContext,
53
+ ) -> SafetyResult:
54
+ """Return whether a single proposed fix may continue to verification."""
55
+ for rule in self._constitution.single_rules:
56
+ if not rule.predicate(proposed_fix, schema, context):
57
+ continue
58
+
59
+ if rule.tier == "hard_never":
60
+ if rule.override_flag and getattr(context, rule.override_flag, False):
61
+ if rule.confirm_flag and not getattr(context, rule.confirm_flag, False):
62
+ return SafetyResult(
63
+ verdict=SafetyVerdict.ESCALATE,
64
+ reason=f"{rule.rule_id}: {rule.description} Confirmation is required.",
65
+ rule_ids=(rule.rule_id,),
66
+ )
67
+ continue
68
+ return SafetyResult(
69
+ verdict=SafetyVerdict.DENY,
70
+ reason=f"{rule.rule_id}: {rule.description}",
71
+ rule_ids=(rule.rule_id,),
72
+ )
73
+
74
+ if rule.tier == "soft_require_confirm":
75
+ if rule.confirm_flag and getattr(context, rule.confirm_flag, False):
76
+ continue
77
+ return SafetyResult(
78
+ verdict=SafetyVerdict.ESCALATE,
79
+ reason=f"{rule.rule_id}: {rule.description}",
80
+ rule_ids=(rule.rule_id,),
81
+ )
82
+
83
+ return SafetyResult(
84
+ verdict=SafetyVerdict.ALLOW,
85
+ reason="All proposed fixes passed the constitutional safety gate.",
86
+ )
87
+
88
+ def evaluate_batch(
89
+ self,
90
+ fixes: list[ProposedFix],
91
+ context: SafetyContext | None = None,
92
+ ) -> SafetyResult:
93
+ """Return whether a batch of accepted fixes is internally consistent."""
94
+ context = context or SafetyContext()
95
+ for rule in self._constitution.batch_rules:
96
+ if rule.predicate(fixes):
97
+ if (
98
+ rule.tier == "soft_require_confirm"
99
+ and rule.confirm_flag
100
+ and getattr(context, rule.confirm_flag, False)
101
+ ):
102
+ continue
103
+ verdict = (
104
+ SafetyVerdict.ESCALATE
105
+ if rule.tier == "soft_require_confirm"
106
+ else SafetyVerdict.DENY
107
+ )
108
+ return SafetyResult(
109
+ verdict=verdict,
110
+ reason=f"{rule.rule_id}: {rule.description}",
111
+ rule_ids=(rule.rule_id,),
112
+ )
113
+ return SafetyResult(
114
+ verdict=SafetyVerdict.ALLOW,
115
+ reason="Accepted fixes are batch-consistent.",
116
+ )
117
+
118
+ def choose_preferred(
119
+ self,
120
+ fixes: list[ProposedFix],
121
+ schema: Schema | None,
122
+ context: SafetyContext,
123
+ ) -> ProposedFix:
124
+ """Choose the preferred candidate using configured soft-prefer rules."""
125
+ if not fixes:
126
+ raise ValueError("choose_preferred requires at least one proposed fix")
127
+ if len(fixes) == 1 or not self._constitution.preference_rules:
128
+ return fixes[0]
129
+ return min(
130
+ fixes,
131
+ key=lambda fix: tuple(
132
+ rule.scorer(fix, schema, context) for rule in self._constitution.preference_rules
133
+ ),
134
+ )