Skip to content

Commit 9688401

Browse files
authored
Merge pull request #179 from DataFog/feature/core-capability-adapter
feat: discover Rust entity capabilities in the Python adapter
2 parents 660e3f5 + 16c2300 commit 9688401

19 files changed

Lines changed: 1238 additions & 77 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
- name: Verify installed package outside checkout imports
4141
run: python -I scripts/check_rust_install.py
4242
- name: Test compatibility, routing, preview and native parity
43-
run: python -m pytest tests/test_contract_481.py tests/test_rust_backend.py tests/test_api_bridge_49.py tests/test_rust_contract.py -q
43+
run: python -m pytest tests/test_contract_481.py tests/test_rust_backend.py tests/test_api_bridge_49.py tests/test_rust_contract.py tests/test_core_capability_adapter.py tests/test_core04_integration.py -q
4444
- name: Generate native parity report
4545
run: python -m tests.rust_contract --output rust-parity.json
4646
- uses: actions/upload-artifact@v4

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ docs/*
6262
!docs/optional-surfaces.rst
6363
!docs/migration-4.8.1-contract.md
6464
!docs/migration-4.9.md
65+
!docs/core-0.4-integration.md
6566
!docs/agents/
6667
!docs/agents/**
6768
!docs/audit/

‎CHANGELOG.MD‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@
55
#### Added
66

77
- Experimental `datafog[rust]` detection with keyword-only `backend="rust"`
8-
on scan/redact entry points; Python remains the default. Core 0.3.1 is pinned,
9-
German requests are explicitly unsupported, and ML composition is unchanged.
8+
on scan/redact entry points; Python remains the default. The follow-up requires
9+
`datafog-core>=0.4.0,<0.5` and capability contract 1. Core 0.4.0 is published
10+
and registry-wheel validation passed; DataFog Python remains unreleased.
11+
ML composition is unchanged.
12+
- Capability-driven entity and locale discovery, German locale activation,
13+
explicit UUID activation, and forward-compatible finding labels. Core's
14+
structured-only PERSON is rejected for explicit Rust text selection.
15+
- Core 0.4.0 adds JWT, private-key, contextual routing-number, and contextual NPI
16+
detection, plus stricter explicit locale validation. The legacy overlap policy
17+
still prefers PHONE over a same-span NPI; NPI filtering can return no entities.
18+
Use native scanning/transformation when NPI must be retained.
1019
- `datafog.compat.v4` preserves the existing scan/redact facade and result classes;
1120
`datafog.v5` previews actual Core types and transformation APIs.
1221
- Native parity tests, installed-wheel CI checks, and public-call benchmarks.

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,17 @@ The [4.9 migration guide](docs/migration-4.9.md) explains opt-in Rust detection,
261261
the native `datafog.v5` preview, and the revised 5.0 retirement schedule for
262262
`detect`/`process`, OCR, and Spark. The Python detector remains the default.
263263

264+
The unreleased Rust adapter requires Core `>=0.4.0,<0.5` and capability contract 1.
265+
Core 0.4.0 is available on PyPI; install the development checkout with
266+
`python -m pip install -e ".[rust]"` to evaluate this unreleased Python adapter.
267+
Entity labels, locales, and activation settings come from the installed
268+
Core, allowing compatible releases to add detectors without a Python update.
269+
German detection is opt-in through locale or entity selection; UUID is opt-in
270+
through `entity_types=["UUID"]`. Core's structured-only `PERSON` is unavailable
271+
for explicit Rust text selection. The legacy overlap policy can suppress NPI in
272+
favor of PHONE; the migration guide explains native alternatives. Lock the Core
273+
version if detection output must remain reproducible.
274+
264275
The [4.8.1 compatibility contract](docs/migration-4.8.1-contract.md) records
265276
published Python behavior for the Rust migration, with frozen fixtures and
266277
instructions for independently reproducing them from the release wheel.

‎benchmarks/results-core-0.4.json‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
{
2+
"python": "3.12.13",
3+
"platform": "macOS-26.6.2-arm64-arm-64bit",
4+
"core_version": "0.4.0",
5+
"method": "One warmup call, median repeated calls; no ML. Cold = process+import+first scan.",
6+
"warm": [
7+
{
8+
"payload": "short",
9+
"operation": "scan",
10+
"utf8_bytes": 25,
11+
"python": {
12+
"median_us": 20.800204947590828,
13+
"samples_us": [
14+
20.712295081466436, 20.800204947590828, 20.514580537565053,
15+
23.7722898600623, 20.953959901817143
16+
],
17+
"entities": 1,
18+
"iterations_per_sample": 200
19+
},
20+
"rust": {
21+
"median_us": 3.2310403184965253,
22+
"samples_us": [
23+
3.405414754524827, 3.2291648676618934, 3.268750151619315,
24+
3.2310403184965253, 3.2225000904873013
25+
],
26+
"entities": 1,
27+
"iterations_per_sample": 200
28+
}
29+
},
30+
{
31+
"payload": "short",
32+
"operation": "redact",
33+
"utf8_bytes": 25,
34+
"python": {
35+
"median_us": 21.760420058853924,
36+
"samples_us": [
37+
21.941669983789325, 21.712500019930303, 21.760420058853924,
38+
22.200000239536166, 21.4781251270324
39+
],
40+
"entities": 1,
41+
"iterations_per_sample": 200
42+
},
43+
"rust": {
44+
"median_us": 4.631250048987567,
45+
"samples_us": [
46+
4.536875057965517, 5.366874975152314, 4.631250048987567,
47+
4.643334541469812, 4.628329770639539
48+
],
49+
"entities": 1,
50+
"iterations_per_sample": 200
51+
}
52+
},
53+
{
54+
"payload": "mixed",
55+
"operation": "scan",
56+
"utf8_bytes": 108,
57+
"python": {
58+
"median_us": 42.857080698013306,
59+
"samples_us": [
60+
42.857080698013306, 43.618750059977174, 41.11749934963882,
61+
43.416660046204925, 40.88166053406894
62+
],
63+
"entities": 5,
64+
"iterations_per_sample": 100
65+
},
66+
"rust": {
67+
"median_us": 9.399170521646738,
68+
"samples_us": [
69+
9.652090957388282, 9.399170521646738, 9.429160272702575,
70+
9.3812495470047, 9.250829461961985
71+
],
72+
"entities": 5,
73+
"iterations_per_sample": 100
74+
}
75+
},
76+
{
77+
"payload": "mixed",
78+
"operation": "redact",
79+
"utf8_bytes": 108,
80+
"python": {
81+
"median_us": 45.040000695735216,
82+
"samples_us": [
83+
44.95292087085545, 45.765830436721444, 44.35917013324797,
84+
45.040000695735216, 45.48249999061227
85+
],
86+
"entities": 5,
87+
"iterations_per_sample": 100
88+
},
89+
"rust": {
90+
"median_us": 13.997500063851476,
91+
"samples_us": [
92+
14.225839404389262, 13.675830559805036, 13.945830287411809,
93+
13.997500063851476, 14.635410625487566
94+
],
95+
"entities": 5,
96+
"iterations_per_sample": 100
97+
}
98+
},
99+
{
100+
"payload": "large_sparse",
101+
"operation": "scan",
102+
"utf8_bytes": 1050018,
103+
"python": {
104+
"median_us": 127264.26400089017,
105+
"samples_us": [
106+
126637.02767652771, 126413.45833738646, 133560.56968526295,
107+
127264.26400089017, 131677.19434325895
108+
],
109+
"entities": 1,
110+
"iterations_per_sample": 3
111+
},
112+
"rust": {
113+
"median_us": 7252.249983139336,
114+
"samples_us": [
115+
7574.055343866348, 7437.528033430378, 7189.485981749992,
116+
7252.249983139336, 7154.236353623371
117+
],
118+
"entities": 1,
119+
"iterations_per_sample": 3
120+
}
121+
},
122+
{
123+
"payload": "large_sparse",
124+
"operation": "redact",
125+
"utf8_bytes": 1050018,
126+
"python": {
127+
"median_us": 125197.09730986506,
128+
"samples_us": [
129+
124213.70833180845, 125561.3473476842, 124531.3056667025,
130+
127014.16663670292, 125197.09730986506
131+
],
132+
"entities": 1,
133+
"iterations_per_sample": 3
134+
},
135+
"rust": {
136+
"median_us": 7177.152670919895,
137+
"samples_us": [
138+
7177.152670919895, 7076.22233312577, 7222.194341011345,
139+
7156.6526700432105, 7303.208306742211
140+
],
141+
"entities": 1,
142+
"iterations_per_sample": 3
143+
}
144+
}
145+
],
146+
"cold": {
147+
"python": {
148+
"median_ms": 81.64816698990762,
149+
"samples_ms": [
150+
84.22841690480709, 81.64816698990762, 81.9787080399692,
151+
81.22462499886751, 80.63562505412847
152+
]
153+
},
154+
"rust": {
155+
"median_ms": 83.1466669915244,
156+
"samples_ms": [
157+
84.22383293509483, 82.70379202440381, 83.1466669915244,
158+
84.2408339958638, 82.79358292929828
159+
]
160+
}
161+
}
162+
}

‎datafog/_core_capabilities.py‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Translate installed Core capabilities into native text scan configuration."""
2+
3+
from __future__ import annotations
4+
5+
from copy import deepcopy
6+
from functools import lru_cache
7+
from typing import Any
8+
9+
_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
10+
11+
12+
def _locale_key(value: str) -> str:
13+
return value.strip(" \t\n\r\v\f").translate(_ASCII_LOWER)
14+
15+
16+
def _invalid(detail: str) -> RuntimeError:
17+
return RuntimeError(f"Incompatible datafog-core capabilities: {detail}")
18+
19+
20+
def _labels(value: Any, field: str) -> set[str]:
21+
if not isinstance(value, list) or any(
22+
not isinstance(label, str) or not label for label in value
23+
):
24+
raise _invalid(f"{field} must be a list of entity labels")
25+
return set(value)
26+
27+
28+
@lru_cache(maxsize=4)
29+
def _read_capabilities(capabilities: Any) -> tuple[set[str], dict, dict]:
30+
"""Snapshot one installed build; replacing its reader invalidates the cache.
31+
32+
The private snapshot never escapes into native scan configuration. Failed
33+
capability reads or validation are not cached by lru_cache.
34+
"""
35+
payload = deepcopy(capabilities())
36+
if not isinstance(payload, dict):
37+
raise _invalid("capabilities() must return a dictionary")
38+
version = payload.get("contract_version")
39+
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
40+
raise _invalid(f"unsupported contract_version {version!r}; expected 1")
41+
supported = _labels(payload.get("supported_entities"), "supported_entities")
42+
defaults = _labels(payload.get("default_entities"), "default_entities")
43+
if not defaults <= supported:
44+
raise _invalid("default_entities contains unsupported labels")
45+
advertised_locales = payload.get("locales")
46+
metadata = payload.get("entities")
47+
if not isinstance(advertised_locales, dict) or not isinstance(metadata, dict):
48+
raise _invalid("locales and entities must be dictionaries")
49+
50+
return supported, _locale_lookup(advertised_locales, supported), metadata
51+
52+
53+
def _locale_lookup(advertised_locales: dict, supported: set[str]) -> dict[str, str]:
54+
locale_lookup: dict[str, str] = {}
55+
for locale, details in advertised_locales.items():
56+
if not isinstance(locale, str) or not _locale_key(locale):
57+
raise _invalid("locale identifiers must be nonempty strings")
58+
if (
59+
not isinstance(details, dict)
60+
or not _labels(
61+
details.get("enabled_entities"), f"locales[{locale!r}].enabled_entities"
62+
)
63+
<= supported
64+
):
65+
raise _invalid(f"invalid locale metadata for {locale!r}")
66+
locale_lookup[_locale_key(locale)] = locale
67+
68+
return locale_lookup
69+
70+
71+
def _resolve_locale(value: Any, locale_lookup: dict[str, str]) -> str:
72+
if not isinstance(value, str) or _locale_key(value) not in locale_lookup:
73+
raise ValueError(f"Unsupported locale for the Rust backend: {value!r}")
74+
return value
75+
76+
77+
def _requested_locales(locales: Any, lookup: dict[str, str]) -> set[str]:
78+
if isinstance(locales, str):
79+
locales = [locales]
80+
if locales is not None and (
81+
not isinstance(locales, (list, tuple))
82+
or any(not isinstance(locale, str) for locale in locales)
83+
):
84+
raise ValueError("locales must be a list of locale identifiers")
85+
return {_resolve_locale(locale, lookup) for locale in locales or []}
86+
87+
88+
def _activation_config(label: str, metadata: dict) -> dict:
89+
details = metadata.get(label)
90+
if not isinstance(details, dict):
91+
raise _invalid(f"missing entity metadata for {label!r}")
92+
scopes = details.get("scopes")
93+
if not isinstance(scopes, list) or any(
94+
not isinstance(scope, str) for scope in scopes
95+
):
96+
raise _invalid(f"invalid scopes for {label!r}")
97+
if "text" not in scopes:
98+
raise ValueError(
99+
f"Entity {label!r} is not available for Rust text scanning "
100+
"(structured-only entity)"
101+
)
102+
activation = details.get("activation")
103+
if not isinstance(activation, dict):
104+
raise _invalid(f"missing activation metadata for {label!r}")
105+
kind = activation.get("kind")
106+
if kind == "default":
107+
return {}
108+
if kind not in {"locale", "config"}:
109+
raise _invalid(f"unsupported activation kind {kind!r} for {label!r}")
110+
config = activation.get("scan_config")
111+
if not isinstance(config, dict) or not config:
112+
raise _invalid(f"missing scan_config for {label!r}")
113+
if kind == "locale" and "locale" not in config:
114+
raise _invalid(f"missing activation locale for {label!r}")
115+
return deepcopy(config)
116+
117+
118+
def scan_configs(core: Any, requested: set[str], locales: Any) -> list[dict]:
119+
"""Build a union of singular-locale scans without duplicating inventories."""
120+
capabilities = getattr(core, "capabilities", None)
121+
if not callable(capabilities):
122+
raise _invalid(
123+
"contract version 1 is required; install a compatible Core 0.4.x"
124+
)
125+
supported, lookup, metadata = _read_capabilities(capabilities)
126+
selected_locales = _requested_locales(locales, lookup)
127+
common: dict[str, Any] = {}
128+
for label in sorted(requested & supported):
129+
config = _activation_config(label, metadata)
130+
for key, value in config.items():
131+
if key == "locale":
132+
selected_locales.add(_resolve_locale(value, lookup))
133+
elif key in common and common[key] != value:
134+
raise _invalid(f"conflicting activation values for {key!r}")
135+
else:
136+
common[key] = value
137+
if not selected_locales:
138+
return [common]
139+
return [
140+
deepcopy(dict(common, locale=locale)) for locale in sorted(selected_locales)
141+
]

0 commit comments

Comments
 (0)