colscan 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.
colscan/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """colscan — tell what is inside a table's columns, offline, with rules only.
2
+
3
+ Public API:
4
+
5
+ from colscan import scan_column, scan_table
6
+
7
+ `scan_column(values, name=None)` classifies one column; `scan_table(source)`
8
+ opens a CSV, a Parquet file or a database table and classifies every column.
9
+
10
+ Everything here is deterministic: a value is an IBAN because it passes the mod-97
11
+ check, not because a model thinks so. Nothing is sent anywhere.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.2.0"
17
+
18
+ from .scan import scan_column, scan_table # noqa: E402,F401
19
+
20
+ __all__ = ["scan_column", "scan_table", "__version__"]
colscan/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """`python -m colscan …`, for whoever cannot put a console script on PATH.
2
+
3
+ Same entry point as the `colscan` command: a locked-down machine where `pip
4
+ install --user` puts the script somewhere that is not in PATH is exactly the kind
5
+ of machine this tool is for.
6
+ """
7
+
8
+ from .cli import main
9
+
10
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ {
2
+ "files": {
3
+ "colname.py": {
4
+ "commit": "11651c91b579ae7c3d00eefb71e78f1af7a5b7e8",
5
+ "sha256": "b4c7ce39025926eed19edcdb7ac0593993916752b000d724e74cf011128d881b"
6
+ },
7
+ "taxonomy.py": {
8
+ "commit": "f3fe522b09244d013d59de7ebd5a6af38a143de1",
9
+ "sha256": "b2c2c387e97edbb4d471c702da25c8ac9072bb9856f7eff527c6ad344e0e8938"
10
+ },
11
+ "validators.py": {
12
+ "commit": "c42f863d42b4f1d20ffa3230e65e3e7f6d1e1937",
13
+ "sha256": "c4559e6b726e223756680505b8db2513d2c9b8db35448ce45a7dac05e4027b07"
14
+ },
15
+ "validators_it.py": {
16
+ "commit": "11651c91b579ae7c3d00eefb71e78f1af7a5b7e8",
17
+ "sha256": "9a13f858896a838178a7bf2adf4218b51361bafb788ba83a92eed6da70345b98"
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,7 @@
1
+ """Vendored deterministic engine — generated by tools/sync_engine.py.
2
+
3
+ Do not edit these files here: edit backend/colscan/ in the dbtool-platform repo
4
+ and re-run the sync script. `PROVENANCE.json` records the source commit and the
5
+ sha256 of every file, and `tools/sync_engine.py` (run by the test suite) fails if
6
+ the copy drifts from the backend.
7
+ """
@@ -0,0 +1,276 @@
1
+ # ---------------------------------------------------------------------------
2
+ # VENDORED FILE — DO NOT EDIT HERE.
3
+ # Source of truth: backend/colscan/colname.py in the private repo
4
+ # sheppard94g/dbtool-platform, commit 11651c91b579ae7c3d00eefb71e78f1af7a5b7e8.
5
+ # Regenerate with: python tools/sync_engine.py --write
6
+ # ---------------------------------------------------------------------------
7
+ """Canale sul NOME della colonna.
8
+
9
+ Perché esiste, con la prova. Alla prima misura su 190 colonne di open data veri
10
+ il sistema a sole celle sbagliava così:
11
+
12
+ measurement → sequential_id 18 colonne
13
+ enum_code → sequential_id 22 colonne
14
+
15
+ Non è un difetto del modello: è che l'informazione **non è nella cella**. Nelle
16
+ tabelle vere l'unità di misura sta nell'intestazione (`superficie_mq`,
17
+ `consumo_kwh`, `importo_euro`) e la cella contiene `1234`. Nessun classificatore
18
+ di celle, per quanto addestrato, può decidere se `1234` è una superficie, un
19
+ codice interno o una chiave: l'informazione è stata separata dal dato a monte,
20
+ da chi ha progettato la tabella.
21
+
22
+ Come è costruito, e perché non è una rete. Un lessico esplicito è **verificabile
23
+ da chi firma il registro dei trattamenti**: se lo strumento dice "questa colonna
24
+ contiene codici fiscali", il DPO può leggere la regola che l'ha detto. In uno
25
+ strumento di conformità questo vale più di qualche punto di accuratezza.
26
+
27
+ La cautela che rende il tutto onesto: **il nome non può inventare un tipo
28
+ incompatibile con i valori osservati.** Se l'intestazione dice `nome` ma la
29
+ colonna contiene IBAN, vincono gli IBAN. Il nome può promuovere un tipo solo se
30
+ la FORMA delle celle è compatibile — è la contromisura al rischio, dichiarato
31
+ nello spec, che il sistema impari a leggere solo l'etichetta e resti cieco sui
32
+ dati (che è poi il caso `f_12`, quello per cui il prodotto esiste).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ import unicodedata
39
+
40
+ # Forma grossolana delle celle: serve solo a dire se una promozione da nome è
41
+ # plausibile. Volutamente a maglie larghe.
42
+ FORME = {
43
+ "numerico": {"sequential_id", "amount", "measurement", "year", "coordinate",
44
+ "postcode", "phone", "national_id", "boolean", "gender_label"},
45
+ "codice": {"enum_code", "fiscal_code_it", "vat_number", "iban", "bic",
46
+ "credit_card", "uuid", "hash_hex", "icd_code", "atc_code",
47
+ "country_code", "currency_code", "national_id", "postcode",
48
+ "mac_address", "boolean", "gender_label", "sequential_id"},
49
+ "testo": {"person_name_full", "person_given", "person_surname", "city",
50
+ "company_name", "institution_name", "street_address", "free_text",
51
+ "country_name", "region_admin", "job_title", "legal_form",
52
+ "gender_label", "enum_code"},
53
+ "misto": set(), # url, email, path… hanno già regole proprie
54
+ }
55
+
56
+
57
+ def forma_di(valori: list[str]) -> str:
58
+ """Forma grossolana dei valori, per decidere se una promozione da nome è plausibile.
59
+
60
+ ⚠️ La versione precedente aveva un difetto che annullava il canale proprio
61
+ dove serviva di più: classificava come CODICE qualunque valore senza spazi e
62
+ sotto i 24 caratteri. Ma `Milano`, `Roma`, `Rossi`, `Verdi` sono esattamente
63
+ così — quindi una colonna di città risultava «codice», `city` non è
64
+ compatibile con «codice», e il suggerimento dell'intestazione `Comune`
65
+ veniva **scartato**. Risultato: la colonna finiva `person_surname`, che è un
66
+ tipo personale, e gonfiava i falsi allarmi sui dati personali.
67
+
68
+ La discriminante giusta non è la lunghezza ma la COMPOSIZIONE: una parola di
69
+ sole lettere è testo anche se corta; un codice contiene cifre, oppure è una
70
+ sigla breve tutta maiuscola.
71
+
72
+ ⚠️ Secondo difetto, stessa famiglia del primo e misurato sulle anagrafiche
73
+ italiane, dove i cognomi si scrivono TUTTI MAIUSCOLI. `ROSSI`, `RUSSO`,
74
+ `GRECO`, `CONTI`, `GALLO` sono cinque lettere maiuscole, cioè la definizione
75
+ di "sigla breve" data qui sopra: su dieci cognomi veri sei finivano fra i
76
+ codici e la colonna risultava «codice». Con `person_surname` incompatibile
77
+ con «codice», il suggerimento dell'intestazione `Cognome` veniva scartato di
78
+ nuovo — lo stesso guasto che questa funzione dichiara di aver corretto, sulla
79
+ forma più frequente del paese.
80
+ Una parola di cinque lettere maiuscole è però ambigua per davvero (`ACEM` è
81
+ una sigla, `ROSSI` un cognome): quindi ora **non vota**, e decidono i valori
82
+ non ambigui. Se sono ambigui tutti — una colonna di sole sigle brevi — vale
83
+ ancora «codice», che era il comportamento giusto per quel caso.
84
+ """
85
+ if not valori:
86
+ return "misto"
87
+ n_num = n_cod = n_txt = n_amb = 0
88
+ for v in valori[:200]:
89
+ s = v.strip()
90
+ if not s:
91
+ continue
92
+ if re.fullmatch(r"[-+]?\d+([.,]\d+)?", s):
93
+ n_num += 1
94
+ elif re.fullmatch(r"[^\W\d_][\w'’\-. ]*", s, re.UNICODE) and not any(c.isdigit() for c in s):
95
+ # sole lettere (con spazi, apostrofi, trattini): è testo, corto o lungo
96
+ # che sia — a meno che non sia una sigla breve tutta maiuscola, che è
97
+ # indistinguibile da un cognome maiuscolo e quindi si astiene
98
+ if len(s) <= 5 and s.isupper():
99
+ n_amb += 1
100
+ else:
101
+ n_txt += 1
102
+ else:
103
+ n_cod += 1
104
+ deciding = n_num + n_cod + n_txt
105
+ tot = max(deciding, 1)
106
+ if n_num / tot > 0.7:
107
+ return "numerico"
108
+ if n_txt / tot > 0.5:
109
+ return "testo"
110
+ if n_cod / tot > 0.5:
111
+ return "codice"
112
+ if n_amb and deciding == 0:
113
+ return "codice"
114
+ return "misto"
115
+
116
+
117
+ # Lessico multilingua: parola chiave -> tipo. Le chiavi sono confrontate sui
118
+ # TOKEN del nome normalizzato, non per sottostringa, altrimenti `id` pesca
119
+ # dentro `identificativo`, `residenza` e mezza tabella.
120
+ LESSICO: dict[str, tuple[str, ...]] = {
121
+ "person_name_full": ("nominativo", "nome_cognome", "nomecognome", "fullname",
122
+ "full_name", "denominazione_persona", "intestatario",
123
+ "richiedente", "titolare", "contatto", "persona"),
124
+ # ⚠️ QUI SI SOTTRAE, NON SI AGGIUNGE, ed è la correzione più redditizia del
125
+ # lessico. Misurato su 35.955 intestazioni reali: `nom*` mappato su
126
+ # `person_surname` dichiarava COGNOMI **647 colonne su 268 tabelle**, e quasi
127
+ # nessuna lo era. In francese e in catalano `nom` è il nome *di una cosa*, e
128
+ # `nom_<entità>` è lo schema di denominazione standard dei portali francesi e
129
+ # barcellonesi: `Nom_Barri` = "la Trinitat Nova", `nom_voie` = "Rue de
130
+ # Normandie", `nom_commune` = "Le Grès".
131
+ # Gemello opposto: in spagnolo `nombre` è "nome", ma in **francese è
132
+ # "numero"** — `nombre_de_reponses` con valori 58, 351 finiva fra i nomi
133
+ # propri. Due lingue, la stessa stringa, due significati incompatibili.
134
+ # La guardia sulla forma delle celle non ferma nulla, perché `person_surname`
135
+ # è compatibile con la forma `testo`: l'unico rimedio è togliere la voce.
136
+ # `prenom` resta (è inequivocabile), `nom_de_famille` copre il cognome francese.
137
+ "person_given": ("nome", "firstname", "first_name", "givenname", "given_name",
138
+ "prenom", "prénom", "vorname"),
139
+ "person_surname": ("cognome", "lastname", "last_name", "surname", "familyname",
140
+ "nom_de_famille", "nom_famille", "nachname", "apellido",
141
+ "apellidos", "cognom", "cognoms"),
142
+ "gender_label": ("sesso", "genere", "gender", "sex", "geschlecht", "sexe"),
143
+ "fiscal_code_it": ("codice_fiscale", "codicefiscale", "cod_fisc", "codfisc",
144
+ "cf", "c_f", "cfisc"),
145
+ "vat_number": ("partita_iva", "partitaiva", "piva", "p_iva", "vat", "iva",
146
+ "ust_id", "umsatzsteuer", "nif", "cif"),
147
+ "iban": ("iban", "conto_corrente", "coordinate_bancarie"),
148
+ "email": ("email", "e_mail", "mail", "pec", "posta_elettronica", "indirizzo_mail",
149
+ "courriel"),
150
+ "phone": ("telefono", "tel", "cellulare", "cell", "fax", "phone", "telefon",
151
+ "recapito_telefonico", "numero_telefono", "tel_fax"),
152
+ "street_address": ("indirizzo", "via", "ubicazione", "address", "strasse",
153
+ "adresse", "sede", "domicilio", "residenza", "toponimo",
154
+ "denominazione_via", "localizzazione", "strasse", "straße",
155
+ "nom_voie", "nom_carrer", "nom_rue", "carrer", "voie",
156
+ "adreca", "direccion"),
157
+ "city": ("citta", "comune", "city", "localita", "paese_citta", "stadt",
158
+ "ville", "municipio", "town", "comune_sede", "gemeinde", "ort",
159
+ "nom_commune", "nom_de_la_commune", "nom_com", "commune",
160
+ "ciudad", "poblacio", "municipi"),
161
+ "postcode": ("cap", "codice_postale", "zip", "postcode", "postal_code", "plz"),
162
+ "country_name": ("nazione", "paese", "stato", "country", "land", "pays",
163
+ "nazionalita", "cittadinanza"),
164
+ "country_code": ("cod_nazione", "country_code", "iso_paese", "sigla_nazione"),
165
+ "region_admin": ("regione", "provincia", "prov", "sigla_provincia", "distretto",
166
+ "region", "bundesland", "departement", "circoscrizione",
167
+ "municipio_zona", "area_statistica", "quartiere", "zona",
168
+ "sigla_prov", "provincia_sigla", "kreis", "landkreis",
169
+ "bezirk", "nom_reg", "nom_dept", "nom_dep", "nom_districte",
170
+ "nom_barri", "nom_epci", "districte", "barri", "departement",
171
+ "comarca", "provincia_nome"),
172
+ "company_name": ("ragione_sociale", "ragionesociale", "denominazione",
173
+ "impresa", "azienda", "ditta", "societa", "company",
174
+ "firmenname", "raison_sociale", "beneficiario", "fornitore",
175
+ "operatore_economico", "aggiudicatario", "insegna"),
176
+ "institution_name": ("ente", "istituto", "istituzione", "struttura", "scuola",
177
+ "universita", "ospedale", "amministrazione", "affiliazione",
178
+ "organizzazione", "organization", "einrichtung",
179
+ "nom_etablissement", "nom_equipament", "nom_du_lieu",
180
+ "equipament", "etablissement", "denominazione_ente"),
181
+ "job_title": ("qualifica", "mansione", "ruolo", "incarico", "carica",
182
+ "professione", "job_title", "position", "funzione"),
183
+ "legal_form": ("forma_giuridica", "natura_giuridica", "tipo_societa",
184
+ "legal_form", "rechtsform"),
185
+ "amount": ("importo", "valore", "costo", "prezzo", "spesa", "compenso",
186
+ "ammontare", "totale", "euro", "imponibile", "budget", "amount",
187
+ "betrag", "montant", "impegno", "pagato", "stanziamento",
188
+ "entrate", "uscite", "saldo", "retribuzione", "stipendio"),
189
+ "currency_code": ("valuta", "divisa", "currency"),
190
+ "date": ("data", "date", "datum", "giorno", "data_nascita", "datanascita",
191
+ "data_inizio", "data_fine", "scadenza", "decorrenza", "dat"),
192
+ "datetime": ("timestamp", "data_ora", "datetime", "rilevazione", "ora"),
193
+ "year": ("anno", "year", "esercizio", "annualita", "jahr", "annee"),
194
+ "url": ("url", "sito", "sito_web", "link", "web", "homepage", "pagina"),
195
+ # Il canale era capovolto: riconosceva `nord` ed `est` — che nelle tavole
196
+ # statistiche italiane sono MACRO-AREE (86 tabelle), non assi cartesiani — e
197
+ # non conosceva `latitude` e `longitude`, presenti in 88 tabelle ciascuna.
198
+ "coordinate": ("latitudine", "longitudine", "latitude", "longitude", "lat",
199
+ "lon", "lng", "long", "coordinata", "coord", "coordinates",
200
+ "x_coord", "y_coord", "breitengrad", "laengengrad",
201
+ "geo_point", "geo_point_2d", "punto_geo"),
202
+ "measurement": ("superficie", "area", "lunghezza", "larghezza", "altezza",
203
+ "peso", "volume", "distanza", "consumo", "potenza", "quantita",
204
+ "temperatura", "concentrazione", "valore_misura", "mq", "kmq",
205
+ "mc", "kwh", "kw", "metri", "posti", "capienza", "numero_posti",
206
+ "abitanti", "popolazione", "densita", "perimetro",
207
+ "anzahl", "ergebnis", "wert", "menge", "flaeche", "fläche",
208
+ "hoehe", "höhe", "gewicht", "verbrauch", "nombre_de",
209
+ "nombre_d", "effectif", "effectifs", "cantidad"),
210
+ "sequential_id": ("id", "codice_id", "progressivo", "numero", "num", "n",
211
+ "chiave", "key", "pk", "record", "riga", "seq", "objectid",
212
+ "fid", "gid", "cod_id"),
213
+ # `code`, `type`, `label` valgono ~3.270 colonne su ~700 tabelle: erano
214
+ # coperte solo nelle forme italiane. `name` NON entra qui e NON va su
215
+ # `person_*`: nei portali è il nome di una cosa, esattamente come `nom`.
216
+ "enum_code": ("code", "type", "label", "codigo", "codi", "tipus", "art",
217
+ "kategorie", "klasse", "schluessel", "schlüssel", "merkmal",
218
+ "auspraegung", "ausprägung", "statistik",
219
+ "codice", "cod", "sigla", "tipo", "tipologia", "categoria",
220
+ "classe", "stato", "status", "livello", "grado", "settore",
221
+ "ateco", "cpv", "capitolo", "voce", "gruppo"),
222
+ "boolean": ("flag", "attivo", "abilitato", "presente", "disponibile",
223
+ "is_", "ha_", "si_no"),
224
+ "icd_code": ("diagnosi", "icd", "patologia", "codice_diagnosi"),
225
+ "atc_code": ("atc", "farmaco", "principio_attivo"),
226
+ "free_text": ("beschreibung", "bezeichnung", "bemerkung", "hinweis",
227
+ "description", "descripcion", "descripcio", "commentaire",
228
+ "note", "descrizione", "descr", "oggetto", "annotazioni",
229
+ "commento", "osservazioni", "motivazione", "dettaglio",
230
+ "testo", "avvertenze", "note_agg"),
231
+ }
232
+
233
+ # indice inverso token -> tipi
234
+ _IDX: dict[str, list[str]] = {}
235
+ for _t, _kk in LESSICO.items():
236
+ for _k in _kk:
237
+ _IDX.setdefault(_k, []).append(_t)
238
+
239
+
240
+ def normalizza(nome: str) -> list[str]:
241
+ s = unicodedata.normalize("NFKD", str(nome or ""))
242
+ s = "".join(c for c in s if not unicodedata.combining(c)).casefold()
243
+ s = re.sub(r"[^a-z0-9]+", "_", s).strip("_")
244
+ if not s:
245
+ return []
246
+ tok = [t for t in s.split("_") if t]
247
+ # oltre ai token singoli, le coppie adiacenti: `data_nascita`, `ragione_sociale`
248
+ coppie = ["_".join(tok[i:i + 2]) for i in range(len(tok) - 1)]
249
+ triple = ["_".join(tok[i:i + 3]) for i in range(len(tok) - 2)]
250
+ return [s] + triple + coppie + tok
251
+
252
+
253
+ def prior(nome: str) -> dict[str, float]:
254
+ """Punteggio per tipo a partire dal nome della colonna.
255
+
256
+ Le corrispondenze più lunghe pesano di più: `data_nascita` deve battere
257
+ `data`, e `codice_fiscale` deve battere `codice`.
258
+ """
259
+ out: dict[str, float] = {}
260
+ if not nome:
261
+ return out
262
+ for frammento in normalizza(nome):
263
+ for t in _IDX.get(frammento, ()):
264
+ peso = 1.0 + 0.6 * frammento.count("_")
265
+ out[t] = max(out.get(t, 0.0), peso)
266
+ if not out:
267
+ return out
268
+ top = max(out.values())
269
+ return {k: v / top for k, v in out.items()}
270
+
271
+
272
+ def compatibile(tipo: str, forma: str) -> bool:
273
+ """Il nome può promuovere `tipo` solo se la forma delle celle lo consente."""
274
+ if forma == "misto":
275
+ return True
276
+ return tipo in FORME.get(forma, set())
@@ -0,0 +1,157 @@
1
+ # ---------------------------------------------------------------------------
2
+ # VENDORED FILE — DO NOT EDIT HERE.
3
+ # Source of truth: backend/colscan/taxonomy.py in the private repo
4
+ # sheppard94g/dbtool-platform, commit f3fe522b09244d013d59de7ebd5a6af38a143de1.
5
+ # Regenerate with: python tools/sync_engine.py --write
6
+ # ---------------------------------------------------------------------------
7
+ """Tassonomia dei tipi semantici di cella per colscan.
8
+
9
+ Un solo posto in cui i tipi sono definiti: generatori, validatori, addestramento
10
+ e valutazione importano tutti da qui. Se un tipo non è in questa lista, non
11
+ esiste — è la contromisura al rischio "la tassonomia esplode" dichiarato nello
12
+ spec (docs/superpowers/specs/2026-08-16-column-classifier-design.md, §4).
13
+
14
+ Ogni tipo dichiara:
15
+ family famiglia di appartenenza (per il rendiconto aggregato)
16
+ personal se un valore di questo tipo è di per sé un dato personale
17
+ (art. 4(1) GDPR) — vedi la nota sotto
18
+ special se ricade nelle categorie particolari (art. 9 GDPR)
19
+ regular se il tipo ha una forma verificabile in modo deterministico.
20
+ I tipi `regular=True` sono di competenza dei VALIDATORI, non
21
+ della rete: per loro una regola con cifra di controllo ha
22
+ precisione praticamente perfetta ed è spiegabile a un auditor.
23
+ La rete serve dove `regular=False`.
24
+
25
+ NOTA sul campo `personal`. È una proprietà del *valore isolato*, non della
26
+ colonna nel suo contesto: un IBAN è un dato personale se riferito a una persona
27
+ fisica e non lo è se riferito a una società. Il campo qui dice "questo tipo può
28
+ identificare o riferirsi a una persona fisica", cioè fa scattare la verifica, e
29
+ NON è un giudizio giuridico. Lo strumento segnala; la qualificazione la fa chi
30
+ tiene il registro dei trattamenti.
31
+ """
32
+
33
+ from dataclasses import dataclass
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TypeSpec:
38
+ name: str
39
+ family: str
40
+ personal: bool
41
+ special: bool
42
+ regular: bool
43
+ desc: str
44
+
45
+
46
+ _T = [
47
+ # ---- identity -----------------------------------------------------------
48
+ TypeSpec("person_name_full", "identity", True, False, False, "Nome e cognome insieme"),
49
+ TypeSpec("person_given", "identity", True, False, False, "Solo il nome proprio"),
50
+ TypeSpec("person_surname", "identity", True, False, False, "Solo il cognome"),
51
+ TypeSpec("gender_label", "identity", True, False, True, "M/F/maschio/female/1/2..."),
52
+ TypeSpec("fiscal_code_it", "identity", True, False, True, "Codice fiscale italiano"),
53
+ TypeSpec("national_id", "identity", True, False, True, "Documento/ID nazionale non italiano"),
54
+ # ---- contact ------------------------------------------------------------
55
+ TypeSpec("email", "contact", True, False, True, "Indirizzo email"),
56
+ TypeSpec("phone", "contact", True, False, True, "Numero di telefono"),
57
+ TypeSpec("street_address", "contact", True, False, False, "Via e civico"),
58
+ # Le anagrafiche vere separano spessissimo la via dal civico in due colonne
59
+ # (`Civico`, `CIVICO`, `civico`: 4 colonne su 700 nel banco reale). Senza
60
+ # questo tipo il civico cade in `sequential_id`, cioè in un tipo NON
61
+ # personale — e una colonna di civici accostata a una di vie è parte di un
62
+ # indirizzo, quindi dato personale. Era l'errore più costoso dei cinque.
63
+ TypeSpec("house_number", "contact", True, False, True, "Numero civico isolato"),
64
+ TypeSpec("city", "contact", False, False, False, "Nome di città"),
65
+ TypeSpec("postcode", "contact", False, False, True, "CAP / codice postale"),
66
+ TypeSpec("country_name", "contact", False, False, False, "Nome di paese"),
67
+ TypeSpec("country_code", "contact", False, False, True, "ISO 3166 alpha-2/3"),
68
+ TypeSpec("region_admin", "contact", False, False, False, "Regione/provincia/stato"),
69
+ # Continente e macro-area (ripartizione geografica) non sono una regione:
70
+ # confonderli fa sbagliare il livello di aggregazione a chi legge il dato.
71
+ # Vocabolario chiuso di qualche decina di voci, quindi competenza della regola.
72
+ TypeSpec("continent", "contact", False, False, True, "Continente o macro-area geografica"),
73
+ TypeSpec("coordinate", "contact", False, False, True, "Latitudine/longitudine"),
74
+ # ---- financial ----------------------------------------------------------
75
+ TypeSpec("iban", "financial", True, False, True, "IBAN (checksum mod-97)"),
76
+ TypeSpec("bic", "financial", False, False, True, "BIC/SWIFT"),
77
+ TypeSpec("credit_card", "financial", True, False, True, "Carta di pagamento (Luhn)"),
78
+ TypeSpec("vat_number", "financial", False, False, True, "Partita IVA / VAT"),
79
+ TypeSpec("amount", "financial", False, False, True, "Importo monetario"),
80
+ TypeSpec("currency_code", "financial", False, False, True, "ISO 4217"),
81
+ # ---- org ----------------------------------------------------------------
82
+ TypeSpec("company_name", "org", False, False, False, "Ragione sociale"),
83
+ TypeSpec("institution_name", "org", False, False, False, "Ente/istituzione/affiliazione"),
84
+ TypeSpec("job_title", "org", False, False, False, "Qualifica professionale"),
85
+ TypeSpec("legal_form", "org", False, False, False, "Forma giuridica (S.p.A., GmbH...)"),
86
+ # ---- health (art. 9) ----------------------------------------------------
87
+ TypeSpec("icd_code", "health", True, True, True, "Codice diagnosi ICD-9/ICD-10"),
88
+ TypeSpec("atc_code", "health", True, True, True, "Codice farmaco ATC"),
89
+ # ---- temporal -----------------------------------------------------------
90
+ TypeSpec("date", "temporal", False, False, True, "Data"),
91
+ TypeSpec("datetime", "temporal", False, False, True, "Data e ora"),
92
+ TypeSpec("year", "temporal", False, False, True, "Anno"),
93
+ # `2026-W33`, `2026Q3`, `2026-08`: un PERIODO, non un istante. In
94
+ # epidemiologia e nelle serie storiche è la chiave temporale normale (la
95
+ # colonna `year_week` del banco reale è settimana ISO). Chiamarlo `date`
96
+ # è un errore di semantica, non di formato: una data si ordina e si
97
+ # sottrae, un periodo si aggrega.
98
+ TypeSpec("period", "temporal", False, False, True, "Settimana ISO, trimestre, mese"),
99
+ # POI, farmacie, musei, sportelli: 7 colonne su 700 nel banco reale, una
100
+ # per giorno della settimana. Nessun tipo esistente ci si avvicinava.
101
+ TypeSpec("opening_hours", "temporal", False, False, True, "Orario di apertura"),
102
+ # ---- technical ----------------------------------------------------------
103
+ TypeSpec("url", "technical", False, False, True, "URL"),
104
+ TypeSpec("domain", "technical", False, False, True, "Nome a dominio"),
105
+ TypeSpec("ip_address", "technical", False, False, True, "IPv4 o IPv6"),
106
+ TypeSpec("mac_address", "technical", False, False, True, "Indirizzo MAC"),
107
+ TypeSpec("uuid", "technical", False, False, True, "UUID"),
108
+ TypeSpec("hash_hex", "technical", False, False, True, "Digest esadecimale"),
109
+ TypeSpec("sequential_id", "technical", False, False, True, "Contatore/chiave numerica"),
110
+ TypeSpec("enum_code", "technical", False, False, False, "Codice breve da vocabolario chiuso"),
111
+ # Il tipo che svuota la discarica. `enum_code` è il tipo più frequente del
112
+ # banco reale (174 colonne su 700) e ci finisce dentro tutto ciò che è
113
+ # "corto e non riconosciuto". Ma CIG, CUP, codice catastale, codice ISTAT,
114
+ # codice meccanografico, codice IPA e ATECO NON sono vocabolari chiusi
115
+ # arbitrari: hanno una forma propria e un elenco ufficiale pubblicato, cioè
116
+ # sono competenza dei validatori. Stessa famiglia di `enum_code` di
117
+ # proposito, perché è da lì che vengono e perché sbagliare fra i due resta
118
+ # un errore veniale nel rendiconto per famiglia.
119
+ TypeSpec("domain_identifier", "technical", False, False, True,
120
+ "Identificativo di dominio verificabile (CIG, CUP, catastale, ISTAT, ATECO…)"),
121
+ TypeSpec("boolean", "technical", False, False, True, "Vero/falso"),
122
+ TypeSpec("file_path", "technical", False, False, True, "Percorso o nome di file"),
123
+ # ---- other --------------------------------------------------------------
124
+ TypeSpec("measurement", "other", False, False, True, "Quantità con o senza unità"),
125
+ TypeSpec("free_text", "other", False, False, False, "Testo libero"),
126
+ TypeSpec("unknown", "other", False, False, False, "Vuoto, segnaposto o non riconosciuto"),
127
+ ]
128
+
129
+ TYPES = {t.name: t for t in _T}
130
+ LABELS = [t.name for t in _T]
131
+ LABEL_TO_ID = {n: i for i, n in enumerate(LABELS)}
132
+ N_LABELS = len(LABELS)
133
+
134
+ FAMILIES = sorted({t.family for t in _T})
135
+ REGULAR = [t.name for t in _T if t.regular]
136
+ IRREGULAR = [t.name for t in _T if not t.regular]
137
+ PERSONAL = [t.name for t in _T if t.personal]
138
+ SPECIAL = [t.name for t in _T if t.special]
139
+
140
+ # I tipi su cui si decide se il modello merita di esistere. Sui tipi `regular`
141
+ # la linea di base deterministica vince per costruzione e va usata quella: un
142
+ # modello che li reimpara spende settimane per peggiorare il risultato.
143
+ DECIDING = [t for t in IRREGULAR if t != "unknown"]
144
+
145
+
146
+ def describe() -> str:
147
+ out = [f"{N_LABELS} tipi, {len(FAMILIES)} famiglie",
148
+ f" regolari (competenza dei validatori): {len(REGULAR)}",
149
+ f" irregolari (competenza della rete): {len(IRREGULAR)}",
150
+ f" dato personale: {len(PERSONAL)} · categoria particolare art.9: {len(SPECIAL)}",
151
+ "", "Tipi su cui si giudica il modello (criterio di arresto):",
152
+ " " + ", ".join(DECIDING)]
153
+ return "\n".join(out)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ print(describe())