sieve-crowd 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ """Sieve Crowd: make every record one of a crowd, and measure it.
2
+
3
+ Deterministic, offline, zero-dependency k-anonymity (with optional l-diversity)
4
+ for CSV, JSON and XML tables.
5
+ """
6
+
7
+ __version__ = "0.2.0"
8
+
9
+ from .config import Config, parse_config, parse_key # noqa: E402
10
+ from .engine import Result, anonymize, inspect_table # noqa: E402
11
+ from .model import ConfigError, InputError, Role # noqa: E402
12
+
13
+ __all__ = [
14
+ "__version__", "anonymize", "inspect_table", "Result", "Config", "parse_config", "parse_key",
15
+ "ConfigError", "InputError", "Role",
16
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
sieve_crowd/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ """Command line: inspect / keygen / run. Cell values never appear in messages or logs."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import secrets
8
+ import sys
9
+ import tempfile
10
+ from typing import Optional, Sequence
11
+
12
+ from . import __version__
13
+ from .config import parse_config, parse_key
14
+ from .engine import anonymize, inspect_table
15
+ from .hints import hint
16
+ from .model import ConfigError, InputError
17
+ from .tableio import FORMATS, detect_format, read_any, write_any
18
+
19
+ EXIT_OK, EXIT_IO, EXIT_CONFIG, EXIT_REFUSED = 0, 1, 2, 3
20
+
21
+
22
+ def _atomic_write(path: str, data: bytes, private: bool = False) -> None:
23
+ directory = os.path.dirname(os.path.abspath(path))
24
+ fd, tmp = tempfile.mkstemp(dir=directory, prefix=".sieve-crowd-")
25
+ try:
26
+ with os.fdopen(fd, "wb") as fh:
27
+ fh.write(data)
28
+ if private:
29
+ os.chmod(tmp, 0o600)
30
+ os.replace(tmp, path)
31
+ except BaseException:
32
+ try:
33
+ os.unlink(tmp)
34
+ except OSError:
35
+ pass
36
+ raise
37
+
38
+
39
+ def _parser() -> argparse.ArgumentParser:
40
+ p = argparse.ArgumentParser(prog="sieve-crowd",
41
+ description="Make every record one of a crowd of at least k, and measure it.")
42
+ p.add_argument("--version", action="version", version=f"sieve-crowd {__version__}")
43
+ sub = p.add_subparsers(dest="cmd", required=True)
44
+
45
+ i = sub.add_parser("inspect", help="describe the columns of a table (csv, json or xml) and write a config template")
46
+ i.add_argument("input")
47
+ i.add_argument("--delimiter", help="CSV only")
48
+ i.add_argument("--in-format", choices=FORMATS,
49
+ help="force the input format (default: from the file extension)")
50
+ i.add_argument("--lang", choices=["en", "ja"], default="en")
51
+ i.add_argument("--config-out", metavar="FILE", help="write a template with role '?' for every column")
52
+ i.add_argument("--force", action="store_true")
53
+
54
+ k = sub.add_parser("keygen", help="create a key for pseudonyms")
55
+ k.add_argument("-o", "--output", required=True)
56
+ k.add_argument("--force", action="store_true")
57
+
58
+ r = sub.add_parser("run", help="anonymize a table (csv, json or xml)")
59
+ r.add_argument("input")
60
+ r.add_argument("-c", "--config", required=True)
61
+ r.add_argument("-o", "--output")
62
+ r.add_argument("--dry-run", action="store_true", help="measure only; write no table")
63
+ r.add_argument("--delimiter", help="CSV only")
64
+ r.add_argument("--in-format", choices=FORMATS,
65
+ help="force the input format (default: from the file extension)")
66
+ r.add_argument("--out-format", choices=FORMATS,
67
+ help="force the output format (default: from the output extension)")
68
+ r.add_argument("--key-file")
69
+ r.add_argument("--k", type=int)
70
+ r.add_argument("--l", type=int)
71
+ r.add_argument("--max-suppression", type=float)
72
+ r.add_argument("--keep-order", action="store_true", help="relaxation: do not shuffle the rows (recorded)")
73
+ r.add_argument("--shuffle-seed", type=int, default=0)
74
+ r.add_argument("--lang", choices=["en", "ja"], default="en")
75
+ r.add_argument("--max-rows", type=int, default=200_000)
76
+ r.add_argument("--max-evals", type=int, default=20_000)
77
+ r.add_argument("--report-json", metavar="FILE")
78
+ r.add_argument("--quiet", action="store_true")
79
+ r.add_argument("--force", action="store_true")
80
+ return p
81
+
82
+
83
+ def _read(path: str) -> bytes:
84
+ if path == "-":
85
+ return sys.stdin.buffer.read()
86
+ with open(path, "rb") as fh:
87
+ return fh.read()
88
+
89
+
90
+ def _resolve_format(path: str, explicit: Optional[str], *, dash_default: str) -> str:
91
+ if explicit:
92
+ return explicit
93
+ if path == "-":
94
+ return dash_default
95
+ return detect_format(os.path.splitext(path)[1])
96
+
97
+
98
+ def _delimiter(given: Optional[str], sides: set[str]) -> Optional[str]:
99
+ if given is None:
100
+ return None
101
+ if sides - {"csv"}:
102
+ raise InputError("--delimiter applies to csv only (the table is json/xml)")
103
+ return given
104
+
105
+
106
+ def _cmd_inspect(a: argparse.Namespace) -> int:
107
+ try:
108
+ in_fmt = _resolve_format(a.input, a.in_format, dash_default="csv")
109
+ delim = _delimiter(a.delimiter, {in_fmt})
110
+ header, rows = read_any(_read(a.input), in_fmt, delim)
111
+ except InputError as exc:
112
+ print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
113
+ except OSError as exc:
114
+ print(f"error: cannot read the input ({exc.strerror})", file=sys.stderr); return EXIT_IO
115
+ info = inspect_table(header, rows)
116
+ en = a.lang == "en"
117
+ print(f"records: {len(rows)}" if en else f"レコード数: {len(rows)}")
118
+ hints = {}
119
+ for c in info:
120
+ role, text_en, text_ja = hint(c["name"], c, len(rows))
121
+ hints[c["name"]] = (role, text_en if en else text_ja)
122
+ shown = f" {c['name']}: {c['distinct']} distinct, {c['empty']} empty, {c['unique_ratio']:.0%} unique values, looks like {c['shape']}" \
123
+ if en else f" {c['name']}: 異なる値 {c['distinct']}、空 {c['empty']}、一意な値 {c['unique_ratio']:.0%}、形は {c['shape']}"
124
+ print(shown)
125
+ if role:
126
+ print(f" -> {hints[c['name']][1]}")
127
+ if a.config_out:
128
+ if os.path.exists(a.config_out) and not a.force:
129
+ print("error: the config file exists (use --force)", file=sys.stderr); return EXIT_IO
130
+ template = {"k": 5, "l": 1, "max_suppression": 0.05,
131
+ "columns": {c["name"]: ({"role": "?", "_hint": hints[c["name"]][1]} if hints[c["name"]][0]
132
+ else {"role": "?"}) for c in info}}
133
+ try:
134
+ _atomic_write(a.config_out, (json.dumps(template, ensure_ascii=False, indent=2) + "\n").encode())
135
+ except OSError as exc:
136
+ print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
137
+ return EXIT_OK
138
+
139
+
140
+ def _cmd_keygen(a: argparse.Namespace) -> int:
141
+ if os.path.exists(a.output) and not a.force:
142
+ print("error: the key file exists (use --force)", file=sys.stderr); return EXIT_IO
143
+ try:
144
+ _atomic_write(a.output, (secrets.token_hex(32) + "\n").encode(), private=True)
145
+ except OSError as exc:
146
+ print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
147
+ return EXIT_OK
148
+
149
+
150
+ def _cmd_run(a: argparse.Namespace) -> int:
151
+ if not a.dry_run and not a.output:
152
+ print("error: give -o/--output or --dry-run", file=sys.stderr); return EXIT_CONFIG
153
+ if a.output and a.input != "-" and os.path.realpath(a.output) == os.path.realpath(a.input):
154
+ print("error: the output must not be the input file", file=sys.stderr); return EXIT_CONFIG
155
+ if a.output and os.path.exists(a.output) and not a.force:
156
+ print("error: output exists (use --force to replace it)", file=sys.stderr); return EXIT_IO
157
+ try:
158
+ in_fmt = _resolve_format(a.input, a.in_format, dash_default="csv")
159
+ out_fmt = _resolve_format(a.output, a.out_format, dash_default="csv") if a.output else None
160
+ delim = _delimiter(a.delimiter, {in_fmt} | (set() if out_fmt is None else {out_fmt}))
161
+ except InputError as exc:
162
+ print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
163
+ try:
164
+ with open(a.config, "rb") as fh:
165
+ raw = json.loads(fh.read().decode("utf-8-sig"))
166
+ key = parse_key(open(a.key_file, encoding="utf-8").read()) if a.key_file else None
167
+ cfg = parse_config(raw, k=a.k, l=a.l, max_suppression=a.max_suppression,
168
+ shuffle=not a.keep_order, shuffle_seed=a.shuffle_seed, lang=a.lang,
169
+ max_rows=a.max_rows, max_evals=a.max_evals, key=key)
170
+ except ConfigError as exc:
171
+ print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
172
+ except (json.JSONDecodeError, UnicodeDecodeError):
173
+ print("error: the configuration is not valid JSON", file=sys.stderr); return EXIT_CONFIG
174
+ except OSError as exc:
175
+ print(f"error: cannot read the configuration or key ({exc.strerror})", file=sys.stderr); return EXIT_IO
176
+ try:
177
+ header, rows = read_any(_read(a.input), in_fmt, delim)
178
+ except InputError as exc:
179
+ print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
180
+ except OSError as exc:
181
+ print(f"error: cannot read the input ({exc.strerror})", file=sys.stderr); return EXIT_IO
182
+ try:
183
+ result = anonymize(header, rows, cfg, io=(in_fmt, out_fmt))
184
+ except ConfigError as exc:
185
+ print(f"error: {exc}", file=sys.stderr); return EXIT_CONFIG
186
+ if not a.quiet:
187
+ print(result.report.text(cfg.lang), file=sys.stderr)
188
+ try:
189
+ if a.report_json:
190
+ _atomic_write(a.report_json, (result.report.to_json() + "\n").encode("utf-8"))
191
+ if result.status == "refused":
192
+ return EXIT_REFUSED
193
+ if not a.dry_run:
194
+ data = write_any(result.header, result.rows, out_fmt, delim)
195
+ if a.output == "-":
196
+ sys.stdout.buffer.write(data); sys.stdout.buffer.flush()
197
+ else:
198
+ _atomic_write(a.output, data)
199
+ except OSError as exc:
200
+ print(f"error: cannot write ({exc.strerror})", file=sys.stderr); return EXIT_IO
201
+ return EXIT_OK
202
+
203
+
204
+ def main(argv: Optional[Sequence[str]] = None) -> int:
205
+ a = _parser().parse_args(argv)
206
+ return {"inspect": _cmd_inspect, "keygen": _cmd_keygen, "run": _cmd_run}[a.cmd](a)
sieve_crowd/config.py ADDED
@@ -0,0 +1,118 @@
1
+ """Configuration: which column plays which role, and how much to protect."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping, Optional
7
+
8
+ from .generalize import make_generalizer
9
+ from .model import ConfigError, Role
10
+
11
+ _TOP_KEYS = {"k", "l", "max_suppression", "columns"}
12
+ _COL_KEYS = {"role", "action", "type", "widths", "length", "hierarchy"}
13
+ _HEX = re.compile(r"^[0-9a-fA-F]{32,}$")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ColumnSpec:
18
+ name: str
19
+ role: Role
20
+ action: str = "drop" # role id: "drop" | "pseudonymize"
21
+ gen: Optional[Mapping[str, Any]] = None # role qi: generalizer settings
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Config:
26
+ columns: Mapping[str, ColumnSpec]
27
+ k: int = 5
28
+ l: int = 1
29
+ max_suppression: float = 0.05
30
+ shuffle: bool = True # keep-order is a relaxation, recorded in the report
31
+ shuffle_seed: int = 0
32
+ lang: str = "en"
33
+ max_rows: int = 200_000
34
+ max_evals: int = 20_000
35
+ key: Optional[bytes] = None
36
+
37
+ def names(self, role: Role) -> list[str]:
38
+ return [n for n, c in self.columns.items() if c.role is role]
39
+
40
+
41
+ def parse_key(text: str) -> bytes:
42
+ t = text.strip()
43
+ if not _HEX.match(t):
44
+ raise ConfigError("the key must be at least 32 hexadecimal characters (see 'sieve-crowd keygen')")
45
+ return bytes.fromhex(t if len(t) % 2 == 0 else t[:-1])
46
+
47
+
48
+ def parse_config(obj: Mapping[str, Any], **overrides: Any) -> Config:
49
+ if not isinstance(obj, Mapping):
50
+ raise ConfigError("the configuration must be a JSON object")
51
+ obj = {k: v for k, v in obj.items() if not str(k).startswith("_")}
52
+ unknown = set(obj) - _TOP_KEYS
53
+ if unknown:
54
+ raise ConfigError(f"unknown configuration key(s): {', '.join(sorted(unknown))}")
55
+ cols_raw = obj.get("columns")
56
+ if not isinstance(cols_raw, Mapping) or not cols_raw:
57
+ raise ConfigError("'columns' must list every column of the table with a role")
58
+
59
+ columns: dict[str, ColumnSpec] = {}
60
+ for name, spec in cols_raw.items():
61
+ if not isinstance(spec, Mapping):
62
+ raise ConfigError(f"column {name!r}: expected an object")
63
+ spec = {k: v for k, v in spec.items() if not str(k).startswith("_")}
64
+ extra = set(spec) - _COL_KEYS
65
+ if extra:
66
+ raise ConfigError(f"column {name!r}: unknown key(s): {', '.join(sorted(extra))}")
67
+ role_text = spec.get("role")
68
+ if role_text == "?" or role_text is None:
69
+ raise ConfigError(f"column {name!r} has no role yet (choose id, qi, sensitive, keep or drop)")
70
+ try:
71
+ role = Role(role_text)
72
+ except ValueError:
73
+ raise ConfigError(f"column {name!r}: unknown role {role_text!r}") from None
74
+ action, gen = "drop", None
75
+ if role is Role.ID:
76
+ action = spec.get("action", "drop")
77
+ if action not in ("drop", "pseudonymize"):
78
+ raise ConfigError(f"column {name!r}: action must be 'drop' or 'pseudonymize'")
79
+ if role is Role.QI:
80
+ gen = {k: v for k, v in spec.items() if k not in ("role", "action")}
81
+ make_generalizer(gen) # validate now
82
+ columns[name] = ColumnSpec(name, role, action, gen)
83
+
84
+ def pick(key: str, default: Any) -> Any:
85
+ v = overrides.get(key)
86
+ return obj.get(key, default) if v is None else v
87
+
88
+ k = pick("k", 5)
89
+ l = pick("l", 1)
90
+ ms = pick("max_suppression", 0.05)
91
+ if not isinstance(k, int) or isinstance(k, bool) or k < 2:
92
+ raise ConfigError("k must be an integer >= 2")
93
+ if not isinstance(l, int) or isinstance(l, bool) or l < 1:
94
+ raise ConfigError("l must be an integer >= 1")
95
+ if not isinstance(ms, (int, float)) or isinstance(ms, bool) or not 0 <= ms < 1:
96
+ raise ConfigError("max_suppression must be a number in [0, 1)")
97
+
98
+ cfg = Config(
99
+ columns=columns, k=k, l=l, max_suppression=float(ms),
100
+ shuffle=overrides.get("shuffle", True),
101
+ shuffle_seed=overrides.get("shuffle_seed", 0),
102
+ lang=overrides.get("lang", "en"),
103
+ max_rows=overrides.get("max_rows", 200_000),
104
+ max_evals=overrides.get("max_evals", 20_000),
105
+ key=overrides.get("key"),
106
+ )
107
+ if not cfg.names(Role.QI):
108
+ raise ConfigError("at least one column needs the role 'qi' (what an outsider could already know)")
109
+ if cfg.l > 1 and not cfg.names(Role.SENSITIVE):
110
+ raise ConfigError("l > 1 needs at least one column with the role 'sensitive'")
111
+ if cfg.lang not in ("en", "ja"):
112
+ raise ConfigError("lang must be 'en' or 'ja'")
113
+ if any(c.role is Role.ID and c.action == "pseudonymize" for c in columns.values()) and cfg.key is None:
114
+ raise ConfigError("pseudonymize needs a key (create one with 'sieve-crowd keygen')")
115
+ for field, value in (("max_rows", cfg.max_rows), ("max_evals", cfg.max_evals)):
116
+ if not isinstance(value, int) or value < 1:
117
+ raise ConfigError(f"{field} must be a positive integer")
118
+ return cfg
sieve_crowd/csvio.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import io
5
+
6
+ from .model import InputError
7
+
8
+
9
+ def read_table(data: bytes, delimiter: str = ",") -> tuple[list[str], list[list[str]]]:
10
+ """UTF-8 (BOM allowed) CSV with a header row."""
11
+ try:
12
+ text = data.decode("utf-8-sig")
13
+ except UnicodeDecodeError:
14
+ raise InputError("the input is not valid UTF-8") from None
15
+ try:
16
+ rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter))
17
+ except csv.Error as exc:
18
+ raise InputError(f"the input is not valid CSV ({exc})") from None
19
+ if not rows or not rows[0]:
20
+ raise InputError("the input has no header row")
21
+ header, body = rows[0], rows[1:]
22
+ body = [r for r in body if r != []] # blank lines
23
+ for i, r in enumerate(body, start=2):
24
+ if len(r) != len(header):
25
+ raise InputError(f"row {i} has {len(r)} fields, the header has {len(header)}")
26
+ return header, body
27
+
28
+
29
+ def write_table(header: list[str], rows: list[list[str]], delimiter: str = ",") -> bytes:
30
+ buf = io.StringIO(newline="")
31
+ w = csv.writer(buf, delimiter=delimiter, lineterminator="\n")
32
+ w.writerow(header)
33
+ w.writerows(rows)
34
+ return buf.getvalue().encode("utf-8")
sieve_crowd/engine.py ADDED
@@ -0,0 +1,205 @@
1
+ """Orchestration: read roles -> generalize -> search -> suppress -> shuffle -> measure -> receipt."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ import unicodedata
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Sequence
9
+
10
+ from .config import Config
11
+ from .generalize import Generalizer, make_generalizer
12
+ from .measure import measure
13
+ from .model import ConfigError, Role
14
+ from .prng import SplitMix64
15
+ from .pseudonym import pseudonym
16
+ from .report import Report, base
17
+ from .search import Infeasible, Searcher, SearchLimitExceeded
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Result:
22
+ status: str # "anonymized" | "refused"
23
+ header: Optional[list[str]]
24
+ rows: Optional[list[list[str]]]
25
+ report: Report
26
+
27
+
28
+ def _norm(v: str) -> str:
29
+ return unicodedata.normalize("NFC", v).strip()
30
+
31
+
32
+ def _refuse(reason: str, n_in: int, cfg: Config,
33
+ io: Optional[tuple[str, Optional[str]]] = None) -> Result:
34
+ d = base("refused", reason=reason)
35
+ d["input"] = {"records": n_in}
36
+ _add_io(d, io)
37
+ return Result("refused", None, None, Report(d))
38
+
39
+
40
+ def _add_io(d: dict, io: Optional[tuple[str, Optional[str]]]) -> None:
41
+ if io:
42
+ entry: dict = {"input_format": io[0]}
43
+ if io[1]:
44
+ entry["output_format"] = io[1]
45
+ d["io"] = entry
46
+
47
+
48
+ def _check_columns(header: Sequence[str], cfg: Config) -> None:
49
+ if len(set(header)) != len(header):
50
+ raise ConfigError("the table has duplicate column names")
51
+ missing = [h for h in header if h not in cfg.columns]
52
+ if missing:
53
+ raise ConfigError(f"column(s) without a role: {', '.join(missing)}")
54
+ extra = [c for c in cfg.columns if c not in header]
55
+ if extra:
56
+ raise ConfigError(f"configuration names column(s) not in the table: {', '.join(extra)}")
57
+
58
+
59
+ def anonymize(header: Sequence[str], rows: Sequence[Sequence[str]], cfg: Config,
60
+ *, io: Optional[tuple[str, Optional[str]]] = None) -> Result:
61
+ """io, when given, is (input_format, output_format or None) and is recorded in the receipt."""
62
+ header = list(header)
63
+ _check_columns(header, cfg)
64
+ n = len(rows)
65
+ if n == 0:
66
+ return _refuse("no_records", n, cfg, io)
67
+ if n > cfg.max_rows:
68
+ return _refuse("resource_limit_rows", n, cfg, io)
69
+
70
+ pos = {name: i for i, name in enumerate(header)}
71
+ qi_cols = [h for h in header if cfg.columns[h].role is Role.QI]
72
+ sens_cols = [h for h in header if cfg.columns[h].role is Role.SENSITIVE]
73
+
74
+ # --- generalization tables ---------------------------------------------------------
75
+ gens: list[Generalizer] = []
76
+ values: list[list[str]] = []
77
+ codes: list[list[list[int]]] = []
78
+ cards: list[list[int]] = []
79
+ for c in qi_cols:
80
+ col = [_norm(r[pos[c]]) for r in rows]
81
+ distinct = list(dict.fromkeys(col))
82
+ g = make_generalizer(cfg.columns[c].gen or {})
83
+ g.prepare(distinct)
84
+ per_level, per_card = [], []
85
+ for lvl in range(g.levels + 1):
86
+ ids: dict[str, int] = {}
87
+ of_value = {v: ids.setdefault(g.label(v, lvl), len(ids)) for v in distinct}
88
+ per_level.append([of_value[v] for v in col])
89
+ per_card.append(len(ids))
90
+ gens.append(g); values.append(col); codes.append(per_level); cards.append(per_card)
91
+
92
+ sens_codes: list[list[int]] = []
93
+ for c in sens_cols:
94
+ ids2: dict[str, int] = {}
95
+ sens_codes.append([ids2.setdefault(_norm(r[pos[c]]), len(ids2)) for r in rows])
96
+
97
+ # --- search ------------------------------------------------------------------------
98
+ budget = min(n - 1, int(math.floor(cfg.max_suppression * n)))
99
+ searcher = Searcher(codes, cards, sens_codes, n, cfg.k, cfg.l, budget, cfg.max_evals)
100
+ try:
101
+ found = searcher.run()
102
+ except Infeasible:
103
+ return _refuse("infeasible", n, cfg, io)
104
+ except SearchLimitExceeded:
105
+ return _refuse("resource_limit_search", n, cfg, io)
106
+
107
+ # --- build the output table --------------------------------------------------------
108
+ dropped = set(found.suppressed)
109
+ chosen = dict(zip(qi_cols, found.levels))
110
+ qi_labels = {}
111
+ for c, g, col in zip(qi_cols, gens, values):
112
+ lvl = chosen[c]
113
+ qi_labels[c] = {v: g.label(v, lvl) for v in dict.fromkeys(col)}
114
+ qi_index = {c: i for i, c in enumerate(qi_cols)}
115
+
116
+ out_header = [h for h in header
117
+ if cfg.columns[h].role not in (Role.DROP,)
118
+ and not (cfg.columns[h].role is Role.ID and cfg.columns[h].action == "drop")]
119
+ out_rows: list[list[str]] = []
120
+ for ri, r in enumerate(rows):
121
+ if ri in dropped:
122
+ continue
123
+ out = []
124
+ for h in out_header:
125
+ spec = cfg.columns[h]
126
+ if spec.role is Role.QI:
127
+ out.append(qi_labels[h][values[qi_index[h]][ri]])
128
+ elif spec.role is Role.ID:
129
+ out.append(pseudonym(cfg.key, h, r[pos[h]]))
130
+ else:
131
+ out.append(r[pos[h]])
132
+ out_rows.append(out)
133
+
134
+ if cfg.shuffle:
135
+ SplitMix64(cfg.shuffle_seed).shuffle(out_rows)
136
+
137
+ # --- verify on the output itself ---------------------------------------------------
138
+ m = measure(out_header, out_rows, qi_cols, sens_cols)
139
+ if m["k"] < cfg.k or any(v < cfg.l for v in m["diversity"].values()) or m["records"] == 0:
140
+ return _refuse("verification_failed", n, cfg, io)
141
+
142
+ # --- receipt -----------------------------------------------------------------------
143
+ before = measure(header, rows, qi_cols, [])
144
+ tops = searcher.tops
145
+ out_pos = {h: i for i, h in enumerate(out_header)}
146
+ cols_report = []
147
+ for h in header:
148
+ spec = cfg.columns[h]
149
+ entry = {"name": h, "role": spec.role.value}
150
+ if spec.role is Role.ID:
151
+ entry["action"] = spec.action
152
+ elif spec.role is Role.QI:
153
+ g, lvl = gens[qi_index[h]], chosen[h]
154
+ entry.update(level=lvl, top_level=g.levels,
155
+ description_en=g.describe(lvl, "en"), description_ja=g.describe(lvl, "ja"))
156
+ elif spec.role is Role.KEEP:
157
+ col_vals = [r[out_pos[h]] for r in out_rows]
158
+ entry["unique_ratio"] = round(len(set(col_vals)) / len(col_vals), 4) if col_vals else 0.0
159
+ cols_report.append(entry)
160
+
161
+ d = base("anonymized")
162
+ d["input"] = {"records": n, "columns": len(header)}
163
+ d["settings"] = {"k": cfg.k, "l": cfg.l, "max_suppression": cfg.max_suppression}
164
+ d["result"] = {"records_out": len(out_rows), "suppressed": len(dropped),
165
+ "suppression_rate": len(dropped) / n}
166
+ d["privacy"] = {
167
+ "quasi_identifiers": qi_cols,
168
+ "k_requested": cfg.k, "k_achieved": m["k"], "crowds": m["crowds"],
169
+ "smallest_crowds": m["smallest_crowds"], "records_alone_in_crowd": m["records_in_crowd_of_1"],
170
+ "max_risk": m["max_risk"], "average_risk": m["average_risk"],
171
+ "l_requested": cfg.l, "diversity": m["diversity"],
172
+ "before": {"records_alone": before["records_in_crowd_of_1"],
173
+ "records_alone_ratio": before["records_in_crowd_of_1"] / n,
174
+ "smallest_crowd": before["k"]},
175
+ }
176
+ d["information_loss"] = {"height": sum(found.levels), "max_height": sum(tops),
177
+ "precision_loss": float(searcher.loss(found.levels) / len(tops))}
178
+ d["columns"] = cols_report
179
+ d["order"] = {"shuffled": cfg.shuffle, "seed": cfg.shuffle_seed if cfg.shuffle else None}
180
+ d["relaxations"] = [] if cfg.shuffle else ["keep_order"]
181
+ d["search"] = {"evaluations": found.evaluations, "refinement_truncated": found.truncated}
182
+ d["verification"] = "passed"
183
+ _add_io(d, io)
184
+ return Result("anonymized", out_header, out_rows, Report(d))
185
+
186
+
187
+ _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
188
+
189
+
190
+ def inspect_table(header: Sequence[str], rows: Sequence[Sequence[str]]) -> list[dict]:
191
+ """Observations per column (no roles are guessed)."""
192
+ n = len(rows)
193
+ out = []
194
+ for i, h in enumerate(header):
195
+ col = [r[i].strip() for r in rows]
196
+ nonempty = [v for v in col if v != ""]
197
+ if nonempty and all(re.match(r"^[+-]?\d+$", v) for v in nonempty):
198
+ shape = "integer"
199
+ elif nonempty and all(_DATE.match(v) for v in nonempty):
200
+ shape = "date"
201
+ else:
202
+ shape = "text"
203
+ out.append({"name": h, "distinct": len(set(col)), "empty": len(col) - len(nonempty),
204
+ "unique_ratio": round(len(set(col)) / n, 4) if n else 0.0, "shape": shape})
205
+ return out