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.
- dataforge/__init__.py +204 -0
- dataforge/__main__.py +5 -0
- dataforge/agent/__init__.py +16 -0
- dataforge/agent/providers.py +259 -0
- dataforge/agent/scratchpad.py +183 -0
- dataforge/agent/tool_actions.py +343 -0
- dataforge/bench/__init__.py +31 -0
- dataforge/bench/core.py +426 -0
- dataforge/bench/groq_client.py +386 -0
- dataforge/bench/methods.py +443 -0
- dataforge/bench/report.py +309 -0
- dataforge/bench/runner.py +247 -0
- dataforge/causal/__init__.py +21 -0
- dataforge/causal/dag.py +174 -0
- dataforge/causal/pc.py +232 -0
- dataforge/causal/root_cause.py +193 -0
- dataforge/cli/__init__.py +50 -0
- dataforge/cli/audit.py +70 -0
- dataforge/cli/bench.py +154 -0
- dataforge/cli/common.py +267 -0
- dataforge/cli/constraints.py +407 -0
- dataforge/cli/profile.py +147 -0
- dataforge/cli/release.py +166 -0
- dataforge/cli/repair.py +407 -0
- dataforge/cli/revert.py +139 -0
- dataforge/cli/watch.py +144 -0
- dataforge/datasets/__init__.py +25 -0
- dataforge/datasets/embedded/hospital/clean.csv +11 -0
- dataforge/datasets/embedded/hospital/dirty.csv +11 -0
- dataforge/datasets/real_world.py +290 -0
- dataforge/datasets/registry.py +103 -0
- dataforge/detectors/__init__.py +80 -0
- dataforge/detectors/base.py +145 -0
- dataforge/detectors/decimal_shift.py +166 -0
- dataforge/detectors/fd_violation.py +157 -0
- dataforge/detectors/type_mismatch.py +173 -0
- dataforge/engine/__init__.py +39 -0
- dataforge/engine/repair.py +905 -0
- dataforge/env/__init__.py +22 -0
- dataforge/env/environment.py +883 -0
- dataforge/env/observation.py +61 -0
- dataforge/env/openenv_core.py +161 -0
- dataforge/env/reward.py +128 -0
- dataforge/env/server.py +176 -0
- dataforge/evaluation_contract.py +76 -0
- dataforge/fixtures/hospital_10rows.csv +11 -0
- dataforge/fixtures/hospital_schema.yaml +17 -0
- dataforge/http/__init__.py +1 -0
- dataforge/http/problem.py +103 -0
- dataforge/integrations/__init__.py +1 -0
- dataforge/integrations/dbt.py +164 -0
- dataforge/observability.py +76 -0
- dataforge/py.typed +1 -0
- dataforge/release/__init__.py +1 -0
- dataforge/release/doctor.py +367 -0
- dataforge/release/full_vision.py +702 -0
- dataforge/release/gate.py +861 -0
- dataforge/release/playground_check.py +411 -0
- dataforge/repair_contract.py +468 -0
- dataforge/repairers/__init__.py +88 -0
- dataforge/repairers/base.py +77 -0
- dataforge/repairers/decimal_shift.py +43 -0
- dataforge/repairers/fd_violation.py +225 -0
- dataforge/repairers/type_mismatch.py +73 -0
- dataforge/safety/__init__.py +5 -0
- dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
- dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
- dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
- dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
- dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
- dataforge/safety/constitution.py +307 -0
- dataforge/safety/constitutions/default.yaml +40 -0
- dataforge/safety/filter.py +134 -0
- dataforge/schema_inference.py +620 -0
- dataforge/stores/__init__.py +46 -0
- dataforge/stores/base.py +73 -0
- dataforge/stores/cloud.py +78 -0
- dataforge/stores/csv.py +94 -0
- dataforge/stores/duckdb.py +313 -0
- dataforge/stores/patch_plan.py +178 -0
- dataforge/stores/registry.py +82 -0
- dataforge/stores/repair.py +121 -0
- dataforge/stores/revert.py +22 -0
- dataforge/stores/sql.py +27 -0
- dataforge/table.py +228 -0
- dataforge/transactions/__init__.py +34 -0
- dataforge/transactions/files.py +96 -0
- dataforge/transactions/log.py +613 -0
- dataforge/transactions/revert.py +102 -0
- dataforge/transactions/txn.py +104 -0
- dataforge/ui/__init__.py +1 -0
- dataforge/ui/profile_view.py +136 -0
- dataforge/ui/repair_diff.py +91 -0
- dataforge/verifier/__init__.py +55 -0
- dataforge/verifier/constraint_ir.py +155 -0
- dataforge/verifier/explain.py +47 -0
- dataforge/verifier/gate.py +5 -0
- dataforge/verifier/schema.py +111 -0
- dataforge/verifier/smt.py +433 -0
- dataforge_07-0.1.0.dist-info/METADATA +436 -0
- dataforge_07-0.1.0.dist-info/RECORD +150 -0
- dataforge_07-0.1.0.dist-info/WHEEL +5 -0
- dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
- dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
- 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
|
+
)
|