diff --git a/.github/seed-issues/00_kickoff.md b/.github/seed-issues/00_kickoff.md
new file mode 100644
index 0000000..5671614
--- /dev/null
+++ b/.github/seed-issues/00_kickoff.md
@@ -0,0 +1,48 @@
+---
+title: "[Kickoff] Definire domanda civica, perimetro e contratto iniziale del dataset"
+labels: ["LEAD", "METODO"]
+assignees: []
+---
+## Perche questa fase conta
+
+Qui si decide se il progetto ha una domanda utile, comprensibile e davvero sostenibile nel tempo.
+Un kickoff fatto bene evita di costruire dati che poi non rispondono alla domanda iniziale.
+
+## Output visibile al pubblico
+
+Una spiegazione chiara di cosa vuole capire il progetto e di quali dati usera.
+
+## Obiettivo
+
+Avviare il progetto dataset con perimetro chiaro, domanda civica misurabile e contratto toolkit-first coerente con il template.
+
+## Checklist
+
+- [ ] Definire una sola domanda civica, chiara e misurabile
+- [ ] Compilare `dataset.yml` con `dataset.name`, `dataset.years` e `root`
+- [ ] Verificare che i path in config siano root-relative POSIX
+- [ ] Confermare la struttura canonica `sql/clean.sql` e `sql/mart/
.sql`
+- [ ] Allineare ruoli e ownership con `docs/lab_links.md`
+- [ ] Verificare che `tests/test_contract.py` sia verde in locale
+
+## Output atteso
+
+Progetto inizializzato con contratto di base valido e documentazione minima pronta per il source onboarding.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/00_quickstart.ipynb`
+- comando minimo: `py -m pytest tests/test_contract.py`
+
+## File da toccare
+
+- `dataset.yml`
+- `README.md`
+- `docs/lab_links.md`
+
+## Acceptance criteria
+
+- `dataset.yml` esiste in root ed e coerente con il contratto smoke reale
+- il perimetro del progetto e documentato in modo comprensibile
+- `pytest tests/test_contract.py` passa
+- il progetto puo passare alla fase Source onboarding senza ambiguita su nome dataset, anni e scope
diff --git a/.github/seed-issues/01_civic_questions.md b/.github/seed-issues/01_civic_questions.md
new file mode 100644
index 0000000..3a0aeaf
--- /dev/null
+++ b/.github/seed-issues/01_civic_questions.md
@@ -0,0 +1,47 @@
+---
+title: "[Questions] Esplicitare domande civiche, metriche e ipotesi iniziali"
+labels: ["METODO", "LEAD"]
+assignees: []
+---
+## Perche questa fase conta
+
+Un progetto dati utile nasce da domande chiare, non da una pipeline costruita nel vuoto.
+Questa fase serve a collegare il lavoro tecnico a un problema pubblico leggibile.
+
+## Output visibile al pubblico
+
+Un set di domande guida che spiega cosa il progetto prova a capire e con quali misure.
+
+## Obiettivo
+
+Definire le domande civiche che guideranno fonti, metriche, unità di analisi e output del progetto.
+
+## Checklist
+
+- [ ] 3-5 domande civiche esplicite
+- [ ] 3-5 metriche collegate
+- [ ] Ipotesi dichiarate
+- [ ] Coerenza con unità di analisi
+
+## Output atteso
+
+Una base pubblica e metodologica chiara da cui far discendere le scelte su fonti, CLEAN, MART e output finali.
+
+## Supporto operativo
+
+- notebook consigliato: nessuno, il lavoro e principalmente metodologico e documentale
+- file guida: `README.md`, `docs/overview.md`, `docs/decisions.md`
+
+## File da toccare
+
+- `README.md`
+- `docs/overview.md`
+- `docs/lab_links.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- le domande guida sono scritte in linguaggio semplice
+- le metriche proposte sono coerenti con le domande
+- le ipotesi iniziali sono esplicitate
+- c'e coerenza fra domande, metriche e unità di analisi
diff --git a/.github/seed-issues/01_setup.md b/.github/seed-issues/01_setup.md
deleted file mode 100644
index b57ede7..0000000
--- a/.github/seed-issues/01_setup.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-01 — Setup progetto (repo, ruoli, board, convenzioni)"
-labels: ["LEAD"]
-assignees: []
----
-## 🎯 Obiettivo
-Rendere il progetto operabile in modo replicabile.
-
-## ✅ Task
-- [ ] Verificare struttura cartelle (data/, notebooks/, docs/, src/ se serve)
-- [ ] Collegare repo alla Board (Roadmap/Open) e creare views minime
-- [ ] Definire ruoli (Data/Method/Viz/QA/Doc) e owners
-- [ ] Allineare naming: dataset_id, paths, convenzioni file
-- [ ] Inserire link a WORKFLOW / CONTRIBUTING / GOVERNANCE (Lab)
-
-## 📦 Output atteso
-Repo pronta per partire con la pipeline e PR cadence.
\ No newline at end of file
diff --git a/.github/seed-issues/02_dataset_scoping.md b/.github/seed-issues/02_dataset_scoping.md
deleted file mode 100644
index 661e846..0000000
--- a/.github/seed-issues/02_dataset_scoping.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-02 — Scoping dataset (fonte, perimetro, limiti, chiavi)"
-labels: ["METODO"]
-assignees: []
----
-## 🎯 Obiettivo
-Mettere nero su bianco cosa stiamo usando e cosa NON stiamo facendo.
-
-## ✅ Task
-- [ ] Link fonte ufficiale + eventuali mirror
-- [ ] Definire unità di analisi (comune/provincia/anno/voce ecc.)
-- [ ] Identificare chiavi e campi core (ID, anno, territorio, misura)
-- [ ] Elencare limiti noti (mancanti, revisioni, definizioni ambigue)
-- [ ] Definire assunzioni minime (es. normalizzazioni, conversioni)
-
-## 📦 Output atteso
-Bozza `docs/dataset.md` o sezione README con scoping chiaro.
\ No newline at end of file
diff --git a/.github/seed-issues/02_sources.md b/.github/seed-issues/02_sources.md
new file mode 100644
index 0000000..725c0e0
--- /dev/null
+++ b/.github/seed-issues/02_sources.md
@@ -0,0 +1,49 @@
+---
+title: "[Sources] Onboarding fonti, licenza, refresh e decisioni di ingestione"
+labels: ["DATA", "METODO"]
+assignees: []
+---
+## Perche questa fase conta
+
+Se la fonte non e chiara, tutto il resto del progetto diventa fragile.
+Questa fase serve a capire da dove arrivano i dati e con quali limiti.
+
+## Output visibile al pubblico
+
+Una scheda semplice delle fonti usate, con link e note di contesto.
+
+## Obiettivo
+
+Qualificare la fonte e codificare in modo riproducibile come il toolkit deve leggerla.
+
+## Checklist
+
+- [ ] Identificare fonte primaria, URL canonico e frequenza di aggiornamento
+- [ ] Aggiornare `raw.sources[].type` e `raw.sources[].args` in `dataset.yml`
+- [ ] Documentare licenza, coverage, refresh cadence e note in `docs/sources.md`
+- [ ] Registrare trade-off e assunzioni di ingestione in `docs/decisions.md`
+- [ ] Verificare che non esistano path assoluti o riferimenti locali
+- [ ] Rieseguire i contract tests
+
+## Output atteso
+
+Fonte verificata e configurata in `dataset.yml`, con documentazione sufficiente per procedere al layer RAW.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/01_inspect_raw.ipynb`
+- comando minimo: `toolkit run raw --config dataset.yml`
+- dopo il run usa: `toolkit inspect paths --config dataset.yml --year --json`
+
+## File da toccare
+
+- `dataset.yml`
+- `docs/sources.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- la fonte e verificabile e documentata
+- `raw.sources` e compilato con campi sufficienti all'esecuzione
+- `docs/sources.md` contiene note su licenza, refresh e limiti noti
+- `pytest tests/test_contract.py` passa
diff --git a/.github/seed-issues/03_raw.md b/.github/seed-issues/03_raw.md
new file mode 100644
index 0000000..7f62545
--- /dev/null
+++ b/.github/seed-issues/03_raw.md
@@ -0,0 +1,49 @@
+---
+title: "[RAW] Rendere riproducibile l'ingestione RAW con il toolkit"
+labels: ["DATA"]
+assignees: []
+---
+## Perche questa fase conta
+
+Questa e la base del progetto: se il dato in ingresso non e stabile o tracciabile, anche le analisi finali diventano deboli.
+
+## Output visibile al pubblico
+
+Una fonte acquisita in modo ripetibile, con traccia di cosa e stato usato e quando.
+
+## Obiettivo
+
+Ottenere un layer RAW eseguibile e ripetibile, senza committare output in repo.
+
+## Checklist
+
+- [ ] Verificare `raw.sources[]`, `primary` ed eventuale extractor in `dataset.yml`
+- [ ] Eseguire `toolkit run raw --config dataset.yml`
+- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare gli artifact RAW
+- [ ] Controllare `manifest.json`, `metadata.json` e `raw_validation.json`
+- [ ] Controllare metadata, manifest e validation report del RAW
+- [ ] Confermare che `data/` non contenga output committati
+- [ ] Aggiornare `docs/decisions.md` con eventuali eccezioni o failure modes
+
+## Output atteso
+
+RAW eseguibile con report minimi di validazione e metadata disponibili negli artifact di run.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/01_inspect_raw.ipynb`
+- path attesi: `root/data/raw///`
+- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json`
+
+## File da toccare
+
+- `dataset.yml`
+- `docs/decisions.md`
+- `docs/sources.md`
+
+## Acceptance criteria
+
+- il run RAW completa o il blocco e documentato in modo riproducibile
+- nessun output RAW viene aggiunto sotto `data/`
+- gli artifact minimi del RAW sono attesi sotto `root/data/raw///`
+- il progetto puo passare a CLEAN con input RAW deterministico
diff --git a/.github/seed-issues/03_raw_ingestion.md b/.github/seed-issues/03_raw_ingestion.md
deleted file mode 100644
index 5e50dfa..0000000
--- a/.github/seed-issues/03_raw_ingestion.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-03 — RAW ingestion (download, snapshot, metadata)"
-labels: ["DATA"]
-assignees: []
----
-## 🎯 Obiettivo
-Acquisire RAW in modo ripetibile e tracciabile.
-
-## ✅ Task
-- [ ] Definire path RAW standard (Drive/FS) + naming files
-- [ ] Script/notebook per download o import (no trasformazioni)
-- [ ] Salvare metadata: url, timestamp, checksum/size, note versione
-- [ ] Gestire encoding e separatore (senza “pulire” i dati)
-- [ ] Log minimo (righe, colonne, errori)
-
-## 📦 Output atteso
-RAW disponibile + manifest/metadata (anche solo JSON/MD).
\ No newline at end of file
diff --git a/.github/seed-issues/04_clean.md b/.github/seed-issues/04_clean.md
new file mode 100644
index 0000000..fc40d99
--- /dev/null
+++ b/.github/seed-issues/04_clean.md
@@ -0,0 +1,53 @@
+---
+title: "[CLEAN] Implementare normalizzazione, required columns e validazioni CLEAN"
+labels: ["DATA"]
+assignees: []
+---
+## Perche questa fase conta
+
+Qui il dato diventa davvero leggibile e confrontabile.
+Una buona fase CLEAN riduce errori, ambiguita e lavoro manuale futuro.
+
+## Output visibile al pubblico
+
+Un dataset piu chiaro, con colonne coerenti e significato documentato.
+
+## Obiettivo
+
+Portare il dataset da RAW a CLEAN con SQL esplicita, schema documentato e validazioni minime.
+
+## Checklist
+
+- [ ] Implementare o aggiornare `sql/clean.sql`
+- [ ] Allineare `clean.read`, `clean.required_columns` e `clean.validate` in `dataset.yml`
+- [ ] Verificare chiavi logiche, `not_null`, `min_rows` e duplicati
+- [ ] Eseguire `toolkit run clean --config dataset.yml`
+- [ ] Eseguire `toolkit validate clean --config dataset.yml`
+- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare il layer CLEAN
+- [ ] Aggiornare `docs/data_dictionary.md` per il layer CLEAN
+- [ ] Loggare assunzioni e mapping in `docs/decisions.md`
+
+## Output atteso
+
+Layer CLEAN riproducibile, con schema e regole di validazione sufficienti per alimentare i mart.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/02_inspect_clean.ipynb`
+- path attesi: `root/data/clean///`
+- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json`
+
+## File da toccare
+
+- `sql/clean.sql`
+- `dataset.yml`
+- `docs/data_dictionary.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- `sql/clean.sql` legge da `raw_input`
+- `clean.required_columns` e aggiornato
+- `clean.validate` copre almeno chiavi, `not_null` e `min_rows` quando applicabile
+- rowcount sanity e duplicate check sono stati eseguiti
+- il progetto puo passare a MART senza ambiguita sullo schema CLEAN
diff --git a/.github/seed-issues/04_raw_to_clean.md b/.github/seed-issues/04_raw_to_clean.md
deleted file mode 100644
index 60a20cc..0000000
--- a/.github/seed-issues/04_raw_to_clean.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-04 — RAW → CLEAN (standard colonne, parsing, validazioni base)"
-labels: ["DATA"]
-assignees: []
----
-## 🎯 Obiettivo
-Produrre CLEAN coerente multi-anno e pronto per analisi.
-
-## ✅ Task
-- [ ] Standardizzare nomi colonne (snake_case) + dizionario
-- [ ] Parsing numeri IT (., , , %, -, null)
-- [ ] Tipi coerenti (string/int/float/date) e regole di casting
-- [ ] Gestire valori speciali (n.d., 0, vuoti) con policy esplicita
-- [ ] Validazioni base: righe attese, colonne obbligatorie, duplicati chiave
-
-## 📦 Output atteso
-File CLEAN (parquet/csv) + mini data dictionary.
\ No newline at end of file
diff --git a/.github/seed-issues/05_clean_to_mart.md b/.github/seed-issues/05_clean_to_mart.md
deleted file mode 100644
index 377a9fb..0000000
--- a/.github/seed-issues/05_clean_to_mart.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-05 — CLEAN → MART (metriche, aggregazioni, modelli per dashboard)"
-labels: ["DATA"]
-assignees: []
----
-## 🎯 Obiettivo
-Creare dataset MART orientato a domande e dashboard.
-
-## ✅ Task
-- [ ] Definire “domande base” (3–5) che il MART deve supportare
-- [ ] Selezionare metriche e dimensioni (tempo, territorio, categoria)
-- [ ] Creare tabelle MART (fact + dimensioni se serve) o dataset wide
-- [ ] Documentare formule (KPI, percentuali, normalizzazioni)
-- [ ] Export su Drive / BigQuery (se previsto) con naming standard
-
-## 📦 Output atteso
-MART pronto per Looker/PowerBI + note KPI.
\ No newline at end of file
diff --git a/.github/seed-issues/05_mart.md b/.github/seed-issues/05_mart.md
new file mode 100644
index 0000000..ff39aea
--- /dev/null
+++ b/.github/seed-issues/05_mart.md
@@ -0,0 +1,51 @@
+---
+title: "[MART] Costruire tabelle analitiche e regole di validazione MART"
+labels: ["DATA", "METODO"]
+assignees: []
+---
+## Perche questa fase conta
+
+Qui il progetto inizia a produrre risposte utilizzabili.
+I mart sono la parte che alimenta analisi, dashboard e insight condivisibili.
+
+## Output visibile al pubblico
+
+Tabelle finali leggibili, da cui ricavare indicatori e confronti.
+
+## Obiettivo
+
+Produrre uno o piu mart orientati a KPI e output finali, con tabella/e e validation rules esplicite.
+
+## Checklist
+
+- [ ] Creare o aggiornare `sql/mart/.sql` per ogni tabella dichiarata
+- [ ] Allineare `mart.tables` in `dataset.yml` e aggiungere eventuali regole di validazione supportate dal toolkit
+- [ ] Eseguire `toolkit run mart --config dataset.yml --year `
+- [ ] Eseguire `toolkit validate --config dataset.yml --year `
+- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare i mart
+- [ ] Verificare required columns, chiavi, `not_null`, `min_rows` e KPI sanity
+- [ ] Aggiornare `docs/data_dictionary.md` con granularita, KPI e semantica dei mart
+
+## Output atteso
+
+Mart pronti per dashboard o report, con SQL separata per tabella e regole di validazione chiare.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/03_explore_mart.ipynb`
+- comando minimo: `toolkit run mart --config dataset.yml`
+- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json`
+
+## File da toccare
+
+- `sql/mart/.sql`
+- `dataset.yml`
+- `docs/data_dictionary.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- ogni tabella dichiarata in `mart.tables[]` ha un file SQL dedicato
+- eventuali regole MART dichiarate in `dataset.yml` sono coerenti con le tabelle pubblicate
+- rowcount sanity e duplicate check sono stati eseguiti
+- il progetto puo passare a QA con mart leggibili e validabili
diff --git a/.github/seed-issues/06_qa_checks.md b/.github/seed-issues/06_qa_checks.md
deleted file mode 100644
index 65423ab..0000000
--- a/.github/seed-issues/06_qa_checks.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-06 — QA (controlli qualità, coerenza, regressioni)"
-labels: ["QA"]
-assignees: []
----
-## 🎯 Obiettivo
-Evitare che il progetto “sembri ok” ma sia sbagliato.
-
-## ✅ Task
-- [ ] Check coerenza per anno/territorio (buchi, outlier grossi)
-- [ ] Check somme e percentuali (range 0–100, totali coerenti)
-- [ ] Confronto RAW vs CLEAN: righe perse/aggiunte spiegate
-- [ ] Regole di regressione (se rifacciamo pipeline, cosa non deve cambiare)
-- [ ] Output QA: report breve (MD) con esito e anomalie note
-
-## 📦 Output atteso
-`docs/qa.md` + checklist QA riusabile per dataset futuri.
\ No newline at end of file
diff --git a/.github/seed-issues/06_validation_qa.md b/.github/seed-issues/06_validation_qa.md
new file mode 100644
index 0000000..50232e1
--- /dev/null
+++ b/.github/seed-issues/06_validation_qa.md
@@ -0,0 +1,49 @@
+---
+title: "[QA] Chiudere validazioni, contract tests e smoke opzionale"
+labels: ["QA", "DATA"]
+assignees: []
+---
+## Perche questa fase conta
+
+E il momento in cui si verifica se il progetto regge davvero.
+Serve a evitare output convincenti ma fragili o poco affidabili.
+
+## Output visibile al pubblico
+
+Un dataset piu affidabile, con controlli espliciti e anomalie residue tracciate.
+
+## Obiettivo
+
+Chiudere il gate tecnico di qualita con contract tests verdi, validazioni dataset e smoke opzionale documentato.
+
+## Checklist
+
+- [ ] Eseguire `py -m pytest tests/test_contract.py`
+- [ ] Verificare che la CI `contract` sia verde
+- [ ] Verificare che la CI `smoke` sia documentata e attivabile con `RUN_SMOKE=1`
+- [ ] Rieseguire `toolkit validate all --config dataset.yml`
+- [ ] Controllare outlier, rowcount sanity, duplicates e coerenza dei KPI
+- [ ] Aprire issue residue per anomalie non bloccanti
+
+## Output atteso
+
+Gate QA superato, con standard minimo del Lab rispettato e stato di qualita esplicito.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/04_quality_checks.ipynb`
+- comando di stato: `toolkit status --dataset --year --latest --config dataset.yml`
+
+## File da toccare
+
+- `tests/test_contract.py`
+- `.github/workflows/ci.yml`
+- `README.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- i contract tests passano
+- il job `contract` in CI e sempre eseguibile senza dipendere da toolkit su PyPI
+- il job `smoke` resta opzionale e documentato
+- le anomalie residue sono documentate o trasformate in issue
diff --git a/.github/seed-issues/07_dashboard.md b/.github/seed-issues/07_dashboard.md
new file mode 100644
index 0000000..f0cd351
--- /dev/null
+++ b/.github/seed-issues/07_dashboard.md
@@ -0,0 +1,46 @@
+---
+title: "[Release] Dashboard o output pubblico collegato ai MART"
+labels: ["VIZ", "OPTIONAL"]
+assignees: []
+---
+## Perche questa fase conta
+
+E la fase in cui il lavoro diventa leggibile anche fuori dal team tecnico.
+Serve a trasformare tabelle finali in un output che aiuti davvero chi legge.
+
+## Output visibile al pubblico
+
+Una dashboard, un report o una pagina che spiega cosa emerge dai dati.
+
+## Obiettivo
+
+Preparare un output pubblico che consumi i mart prodotti dal progetto.
+
+## Checklist
+
+- [ ] Identificare il mart sorgente e i KPI che alimentano l'output
+- [ ] Documentare limiti, assunzioni e ultimo aggiornamento in `dashboard/README.md`
+- [ ] Verificare coerenza fra dashboard e `docs/data_dictionary.md`
+- [ ] Esplicitare cosa emerge e cosa non emerge dall'output
+- [ ] Collegare l'output nel `README.md`
+
+## Output atteso
+
+Dashboard, report o pagina pubblica leggibile e coerente con i mart del progetto.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/05_dashboard_export.ipynb`
+
+## File da toccare
+
+- `dashboard/README.md`
+- `README.md`
+- `docs/data_dictionary.md`
+
+## Acceptance criteria
+
+- l'output usa solo mart documentati
+- KPI e limiti sono spiegati in modo leggibile
+- il collegamento con il dataset rilasciato e tracciabile
+- l'issue non blocca la release del dataset se l'output pubblico non e previsto
diff --git a/.github/seed-issues/07_viz_dashboard.md b/.github/seed-issues/07_viz_dashboard.md
deleted file mode 100644
index 8cd5cad..0000000
--- a/.github/seed-issues/07_viz_dashboard.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-07 — Visualizzazione (dashboard MVP + scelte di comunicazione)"
-labels: ["VIZ"]
-assignees: []
----
-## 🎯 Obiettivo
-Trasformare il MART in una dashboard MVP utile e condivisibile.
-
-## ✅ Task
-- [ ] Definire pubblico target + 1 frase “cosa ci portiamo a casa”
-- [ ] Wireframe: 1 pagina (KPI, trend, mappa/tabella, filtri)
-- [ ] Scelte viz: colori, scale, note interpretative, unità misura
-- [ ] Implementazione MVP (Looker/PowerBI/altro) con filtri minimi
-- [ ] Sezione “Come leggere” (3 bullet) + limiti dati
-
-## 📦 Output atteso
-Link dashboard + screenshot + note di lettura.
\ No newline at end of file
diff --git a/.github/seed-issues/08_docs_release.md b/.github/seed-issues/08_docs_release.md
deleted file mode 100644
index 43b7f6e..0000000
--- a/.github/seed-issues/08_docs_release.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-title: "M1-08 — Documentazione & Release (README, riproducibilità, share)"
-labels: ["DOCS"]
-assignees: []
----
-## 🎯 Obiettivo
-Rendere il progetto replicabile e “pubblicabile” in 10 minuti.
-
-## ✅ Task
-- [ ] README: obiettivo, dataset, output, come eseguire
-- [ ] Struttura “metodo”: assunzioni, limiti, scelte, non-coperto
-- [ ] Link a notebook/script in ordine di esecuzione
-- [ ] Dataset catalog (input/output paths) + versione
-- [ ] Tag/release v0.1 + changelog breve (cosa c’è / cosa manca)
-
-## 📦 Output atteso
-Repo presentabile + release v0.1 pronta per essere condivisa.
\ No newline at end of file
diff --git a/.github/seed-issues/08_release.md b/.github/seed-issues/08_release.md
new file mode 100644
index 0000000..2fcf1af
--- /dev/null
+++ b/.github/seed-issues/08_release.md
@@ -0,0 +1,62 @@
+---
+title: "[Release] Preparare release del dataset, README e artifacts minimi"
+labels: ["DOCS", "LEAD"]
+assignees: []
+---
+## Perche questa fase conta
+
+Questa fase rende il progetto condivisibile anche con chi arriva da fuori.
+Una buona release rende chiaro cosa esiste, cosa si puo usare e con quali limiti.
+
+## Output visibile al pubblico
+
+Una homepage chiara, una overview leggibile e una release spiegata bene.
+
+## Obiettivo
+
+Portare il progetto a una release riproducibile, spiegabile e pronta per handoff.
+
+## Checklist
+
+- [ ] Aggiornare `README.md` con scopo, metodo, output e limiti
+- [ ] Verificare `docs/lab_links.md` per hub DataCivicLab, policy comuni e riferimenti al toolkit
+- [ ] Confermare che `output.artifacts` resti su `minimal` o motivare eccezioni
+- [ ] Collegare eventuale dashboard o report ai mart corretti
+- [ ] Verificare che documentazione e artifact minimi siano coerenti
+- [ ] Preparare PR o release notes interne
+
+## Public-facing readiness
+
+- [ ] README pubblico-first ok
+- [ ] `docs/overview.md` presente
+- [ ] almeno 3 domande o insight presenti nel README
+- [ ] `docs/data_dictionary.md` aggiornato almeno al minimo
+
+## Civic clarity check
+
+- [ ] Le domande guida sono ancora rilevanti?
+- [ ] Le metriche rispondono davvero alle domande?
+- [ ] Ci sono insight scritti in linguaggio semplice?
+
+## Output atteso
+
+Release interna o pubblica con documentazione finale coerente con i dati e con i mart prodotti.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/00_quickstart.ipynb`
+- comandi minimi: `py -m pytest tests/test_contract.py` e `toolkit validate all --config dataset.yml`
+
+## File da toccare
+
+- `README.md`
+- `docs/overview.md`
+- `docs/lab_links.md`
+- `docs/data_dictionary.md`
+
+## Acceptance criteria
+
+- README e docs descrivono chiaramente cosa e stato rilasciato
+- il progetto e riprendibile da terzi senza conoscenza implicita
+- gli artifact minimi attesi sono coerenti con la policy del template
+- i gate precedenti sono chiusi o le eccezioni sono documentate
diff --git a/.github/seed-issues/09_docs_decisions_dictionary.md b/.github/seed-issues/09_docs_decisions_dictionary.md
new file mode 100644
index 0000000..ed4e8b5
--- /dev/null
+++ b/.github/seed-issues/09_docs_decisions_dictionary.md
@@ -0,0 +1,48 @@
+---
+title: "[Docs] Aggiornare decision log e data dictionary del progetto"
+labels: ["DOCS", "METODO"]
+assignees: []
+---
+## Perche questa fase conta
+
+Un progetto dati e utile solo se altre persone riescono a capirlo e riprenderlo.
+Questa fase rende visibili scelte, significato dei campi e limiti.
+
+## Output visibile al pubblico
+
+Documentazione leggibile che spiega cosa significano i dati e come interpretarli.
+
+## Obiettivo
+
+Tenere allineata la documentazione strutturata del dataset durante tutto il lifecycle del progetto.
+
+## Checklist
+
+- [ ] Aggiornare `docs/decisions.md` con decisioni, eccezioni e trade-off
+- [ ] Aggiornare `docs/data_dictionary.md` per RAW, CLEAN e MART
+- [ ] Verificare coerenza con `docs/sources.md`
+- [ ] Verificare coerenza con `docs/lab_links.md`
+- [ ] Allineare esempi e naming in README e workflow dataset
+
+## Output atteso
+
+Decision log e data dictionary completi, utili per review, handoff e manutenzione futura.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/02_inspect_clean.ipynb` e `notebooks/03_explore_mart.ipynb`
+
+## File da toccare
+
+- `docs/decisions.md`
+- `docs/data_dictionary.md`
+- `docs/sources.md`
+- `docs/lab_links.md`
+- `README.md`
+
+## Acceptance criteria
+
+- il decision log spiega le scelte non ovvie
+- il data dictionary descrive i campi essenziali di CLEAN e MART
+- la documentazione e coerente con `dataset.yml` e con la SQL canonica
+- il progetto puo passare review metodologica senza knowledge transfer verbale
diff --git a/.github/seed-issues/10_maintenance.md b/.github/seed-issues/10_maintenance.md
new file mode 100644
index 0000000..5fffb28
--- /dev/null
+++ b/.github/seed-issues/10_maintenance.md
@@ -0,0 +1,51 @@
+---
+title: "[Maintenance] Gestire nuove annualita, cambi schema e regressioni"
+labels: ["DATA", "MAINTENANCE"]
+assignees: []
+---
+## Perche questa fase conta
+
+I dataset non restano fermi: cambiano fonti, anni, regole e definizioni.
+Questa fase serve a mantenere il progetto utile anche dopo la prima release.
+
+## Output visibile al pubblico
+
+Un progetto che resta aggiornabile e non si rompe al primo cambiamento di fonte.
+
+## Obiettivo
+
+Definire il lavoro necessario per mantenere il dataset nel tempo quando cambiano fonte, schema o regole.
+
+## Checklist
+
+- [ ] Aggiornare `dataset.yml` per nuove annualita o sorgenti
+- [ ] Verificare impatto su `sql/clean.sql` e `sql/mart/*.sql`
+- [ ] Rieseguire contract tests
+- [ ] Rieseguire smoke opzionale in caso di cambio sostanziale
+- [ ] Aggiornare `docs/sources.md`, `docs/data_dictionary.md` e `docs/decisions.md`
+- [ ] Documentare regressioni o incompatibilita
+
+## Output atteso
+
+Piano di manutenzione chiaro e procedimento ripetibile per evolvere il dataset senza rompere il contratto del template.
+
+## Supporto operativo
+
+- notebook consigliato: `notebooks/01_inspect_raw.ipynb`, `notebooks/02_inspect_clean.ipynb`, `notebooks/03_explore_mart.ipynb`
+- comandi minimi: `py -m pytest tests/test_contract.py`, `toolkit run all --config dataset.yml`, `toolkit validate all --config dataset.yml`
+
+## File da toccare
+
+- `dataset.yml`
+- `sql/clean.sql`
+- `sql/mart/.sql`
+- `docs/sources.md`
+- `docs/data_dictionary.md`
+- `docs/decisions.md`
+
+## Acceptance criteria
+
+- i cambi sono tracciati nei documenti corretti
+- i contract tests restano verdi
+- la manutenzione non introduce path assoluti o output committati in `data/`
+- il progetto puo essere rieseguito per un nuovo anno o schema senza lavoro manuale implicito
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..7933694
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,86 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ run_smoke:
+ description: "Run optional smoke e2e job"
+ required: false
+ type: boolean
+ default: false
+
+env:
+ PYTHON_VERSION: "3.11"
+ TOOLKIT_PIP_PACKAGE: ""
+ TOOLKIT_BIN: "toolkit"
+
+jobs:
+ contract:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install contract test dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install pytest pyyaml
+
+ - name: Verify required files
+ run: |
+ test -f dataset.yml
+ test -f sql/clean.sql
+ test -f scripts/smoke.sh
+
+ - name: Run contract tests
+ run: pytest tests/test_contract.py
+
+ smoke:
+ if: ${{ vars.RUN_SMOKE == '1' || (github.event_name == 'workflow_dispatch' && inputs.run_smoke) }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install smoke dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install pytest pyyaml
+ if [ -n "${TOOLKIT_PIP_PACKAGE}" ]; then
+ python -m pip install "${TOOLKIT_PIP_PACKAGE}"
+ else
+ git clone --depth 1 https://github.com/dataciviclab/toolkit.git .toolkit-src
+ python -m pip install -e ./.toolkit-src
+ fi
+
+ - name: Export DCL_ROOT
+ run: echo "DCL_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}"
+
+ - name: Smoke run all validate all status
+ run: sh scripts/smoke.sh
+
+ - name: Upload minimal artifacts
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: toolkit-artifacts
+ path: |
+ _smoke_out/data/_runs/**/*.json
+ _smoke_out/data/raw/**/*.json
+ _smoke_out/data/clean/**/*.json
+ _smoke_out/data/mart/**/*.json
+ if-no-files-found: warn
diff --git a/.gitignore b/.gitignore
index 860a1f2..32a1477 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,21 +1,28 @@
# =========================
-# 🔒 DATI (NON VERSIONARE)
+# DATI (NON VERSIONARE)
# =========================
-data/raw/**
-data/clean/**
-data/mart/**
+data/**
+!data/**/
!data/**/README.md
+!data/_examples/
+!data/_examples/**
-# eventuali export temporanei
-*.csv
-*.tsv
-*.xlsx
-*.xls
-*.parquet
-*.json
+# output di run e artifact locali
+_runs/
+_runs/**
+!_runs/.gitkeep
+_smoke_out/
+_smoke_out/**
+_test_out/
+_test_out/**
+
+# eventuali export temporanei generati dal toolkit
+_runs/**/*.csv
+_runs/**/*.tsv
+_runs/**/*.parquet
# =========================
-# 🧠 NOTEBOOK / PYTHON
+# NOTEBOOK / PYTHON
# =========================
__pycache__/
*.py[cod]
@@ -26,17 +33,19 @@ venv/
.envrc
# =========================
-# 🧪 TOOL / TEMP
+# TOOL / TEMP
# =========================
.tmp/
.tmp/**
+_tmp/
+_tmp/**
.cache/
.cache/**
logs/
*.log
# =========================
-# 💻 IDE / EDITOR
+# IDE / EDITOR
# =========================
.vscode/*
!.vscode/settings.json
@@ -46,14 +55,14 @@ logs/
*.code-workspace
# =========================
-# 🖥️ OS
+# OS
# =========================
.DS_Store
Thumbs.db
desktop.ini
# =========================
-# 🔑 CREDENTIALS (MAI!)
+# CREDENTIALS (MAI)
# =========================
credentials.json
service_account.json
@@ -61,13 +70,13 @@ service_account.json
*.pem
# =========================
-# 📦 ARCHIVI
+# ARCHIVI
# =========================
*.zip
*.tar
*.gz
# =========================
-# 🔍 ALTRO
+# ALTRO
# =========================
nohup.out
diff --git a/README.md b/README.md
index 3dd4bed..9298498 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,200 @@
-# 📊 Titolo del progetto
+# [Nome dataset] - DataCivicLab
-## Domanda civica
-(Una sola frase, chiara, misurabile)
+Questo progetto analizza **[fenomeno pubblico]** per rispondere a una domanda semplice:
+**cosa sta succedendo, dove e con quali differenze nel tempo?**
-## Perché questo progetto
-(2–3 righe: perché questa domanda è rilevante ora)
+E pensato per chi vuole orientarsi in fretta:
+capire cosa mostrano i dati, dove sono solidi, quali limiti hanno e quali domande aiutano ad approfondire.
-## Ruoli
-- Project Lead:
-- Data:
-- Metodo:
-- Viz:
-- QA:
-- Docs:
+- **Stato:** [alpha | beta | stable]
+- **Copertura:** [anni], [territorio]
+- **Unita di analisi:** [Comune / ASL / Provincia / ...]
-## Dataset utilizzati
-- Nome dataset — Fonte ufficiale — Periodo — Livello
-- Nome dataset — Fonte ufficiale — Periodo — Livello
+## 🎯 La domanda civica
-## Output pubblico
-- Tipo: (dashboard / report / pagina)
-- Link: (quando disponibile)
+**[Scrivi qui la domanda chiave in una frase chiara.]**
-## Stato progetto
-🟢 Attivo | 🟡 In revisione | 🔴 Bloccato | ✅ Chiuso
+Esempi:
-## Come si contribuisce
+- Come varia [fenomeno] tra territori?
+- Dove si osservano miglioramenti o peggioramenti?
+- Il mio territorio e sopra o sotto la media?
-1. **Discussion** per idee / contesto
-2. **Issue** per task concreti
-3. **Branch** per lavorare
-4. **Pull Request** per revisione e merge
+Questa repo dovrebbe avere **una domanda civica principale**.
-Dettagli in `WORKFLOW.md`.
+Dallo stesso dataset possono nascere anche altre domande utili, ma vanno tenute distinte:
-## Link utili
-- Spreadsheet progetto ([Template](https://docs.google.com/spreadsheets/d/17EmTUVLzimppd70kckX2r2UxPQIwGDMU_Fzsqec4idg/edit?gid=1775469119#gid=1775469119))
-- [Metodo DataCivicLab](https://github.com/dataciviclab/dataciviclab/blob/main/METHOD.md)
\ No newline at end of file
+- la domanda principale orienta README, notebook e output pubblici
+- le domande secondarie o complementari possono emergere in Discussions e trasformarsi in issue operative
+
+In questo modo il repository resta leggibile e non diventa un contenitore indistinto di analisi.
+
+## 🔎 Cosa puoi capire con questi dati
+
+- come cambia il fenomeno nel tempo
+- quali territori mostrano differenze significative
+- se il tuo territorio e sopra o sotto la media
+- se emergono anomalie o salti improvvisi
+- quali aree meritano un approfondimento mirato
+
+Non e solo un dataset: e una base per confronto e monitoraggio.
+
+## 📦 Output disponibili
+
+Le tabelle finali sono pronte per dashboard, grafici e analisi.
+
+- `mart.[tabella_1]` - confronti territoriali o temporali
+- `mart.[tabella_2]` - indicatori sintetici, ranking o riepiloghi
+
+Definizioni dettagliate di colonne e metriche:
+`docs/data_dictionary.md`
+
+## ✅ Perche fidarsi
+
+La fiducia si costruisce su trasparenza e metodo.
+
+- fonti ufficiali o verificabili (`docs/sources.md`)
+- trasformazioni documentate (`docs/decisions.md`)
+- controlli automatici prima della pubblicazione
+- standard condivisi del DataCivicLab
+
+Ogni scelta che cambia il significato dei dati viene esplicitata.
+
+## 💬 Partecipa
+
+Questo repository distingue chiaramente:
+
+- **Discussions** -> domande civiche, interpretazioni, proposte di metriche
+- **Issues** -> bug, problemi tecnici, miglioramenti della pipeline
+
+Flusso consigliato:
+
+`domanda civica -> Discussion -> Issue -> analisi / notebook / output`
+
+Se non sei tecnico, parti da una **Discussion** in questa repo:
+spiega il contesto, il territorio o l'anno che ti interessa e cosa vuoi capire.
+
+Se la domanda richiede lavoro concreto, va trasformata in una **Issue** nella repo giusta:
+
+- issue dataset-specifiche in questa repo
+- issue di runtime o pipeline nel `toolkit`
+- issue di governance o processo nelle repo di ecosistema
+
+## 📚 Documentazione del dataset
+
+- `docs/overview.md` - contesto, copertura, limiti
+- `docs/sources.md` - fonti ufficiali
+- `docs/data_dictionary.md` - colonne e metriche
+- `docs/decisions.md` - scelte progettuali
+- `docs/contributing.md` - come contribuire
+
+## 🧩 Cos'e questa repo
+
+Questo repository e il template operativo da cui nascono i repo dataset DataCivicLab.
+
+Qui trovi il minimo necessario per far partire un progetto concreto:
+
+- `dataset.yml` come contratto del dataset
+- `sql/` per CLEAN e MART
+- `docs/` per documentazione locale del dataset
+- `tests/` per i contract tests minimi
+- `notebooks/` per leggere gli output reali della pipeline
+
+Un repo nato da questo template non serve solo a "ospitare dati".
+Serve a rispondere in modo verificabile a una domanda civica centrale, lasciando spazio anche a domande complementari ben tracciate.
+
+## 🛠️ Confine con il toolkit
+
+Il motore della pipeline vive nel repository `toolkit`.
+Questa repo non replica la logica di esecuzione del motore: definisce input, regole e output attesi per questo dataset.
+
+In pratica:
+
+- bug o feature di CLI, runner, validazioni runtime e run metadata -> repo `toolkit`
+- bug o modifiche a fonti, mapping, SQL, mart, docs e notebook di dataset -> questa repo
+
+## 🔁 Da dove partire
+
+Se stai clonando il template per un nuovo progetto:
+
+1. aggiorna la domanda civica e gli esempi di insight
+2. sostituisci fonti, copertura e unita di analisi
+3. definisci metriche e tabelle finali
+4. documenta le decisioni specifiche del dataset
+5. esegui `py -m pytest tests/test_contract.py`
+
+La struttura resta invariata. Non serve capire tutto subito: qui trovi la base pratica da cui partire.
+
+## 🧪 Esecuzione tecnica
+
+```bash
+pip install dataciviclab-toolkit
+toolkit run all --config dataset.yml
+toolkit validate all --config dataset.yml
+toolkit status --dataset --year --latest --config dataset.yml
+```
+
+I notebook del template usano anche:
+
+```bash
+toolkit inspect paths --config dataset.yml --year --json
+```
+
+Nota di contratto:
+
+- i path relativi in `dataset.yml` sono risolti rispetto alla directory del file `dataset.yml`, non rispetto al `cwd`
+- i notebook non devono ricostruire a mano `root/data/raw|clean|mart|_runs`
+- `metadata.json` e il payload ricco del layer
+- `manifest.json` e il summary stabile del layer
+- `data/_runs/.../.json` e il run record letto da `status`
+
+Per dettagli piu profondi su CLI, contratti stabili, workflow advanced e feature stability, il posto giusto e `toolkit`.
+
+## 🧭 Dove andare per il resto
+
+Questa repo resta focalizzata sul progetto dataset.
+
+Per il resto:
+
+- contesto del Lab, mappa delle repo e catalogo dataset: `dataciviclab`
+- policy comuni, onboarding GitHub, issue/PR template e community health: `.github`
+- motore tecnico della pipeline e documentazione del runtime: `toolkit`
+
+I riferimenti rapidi sono raccolti in `docs/lab_links.md`.
+
+## 🌍 Archivio pubblico
+
+Se il progetto pubblica artifact in un archivio pubblico DataCivicLab su Drive, il flusso consigliato e:
+
+1. eseguire e validare la pipeline in locale
+2. verificare gli output sotto `root/data/...`
+3. pubblicare solo gli artifact pubblici con uno script separato
+
+Questo passaggio e `maintainer-only`.
+
+Esempio:
+
+```bash
+py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicLab" --dry-run
+py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicLab" --year 2022
+```
+
+La destinazione su Drive mantiene lo stesso path relativo degli output del toolkit sotto `root`.
+
+## Ritmo operativo consigliato
+
+Quando un repo dataset entra in ritmo, conviene mantenere una sequenza semplice:
+
+1. una domanda civica principale sempre visibile nel README
+2. domande complementari che emergono e si chiariscono in Discussions
+3. issue piccole per trasformare le domande mature in lavoro concreto
+4. output condivisibili pubblicati con continuita
+
+L'output non deve essere sempre una dashboard completa.
+Puo essere anche:
+
+- una risposta breve con una tabella
+- un notebook che chiude una domanda precisa
+- un aggiornamento intermedio su limiti, dati mancanti o primi pattern
+
+Questo aiuta a mantenere il repository vivo senza trasformarlo in un backlog confuso.
diff --git a/WORKFLOW.md b/WORKFLOW.md
index 4ca385e..77127ab 100644
--- a/WORKFLOW.md
+++ b/WORKFLOW.md
@@ -1,116 +1,46 @@
-# 🔁 WORKFLOW – come si lavora
+# Workflow
-Questo documento descrive il workflow standard per lavorare su un progetto DataCivicLab.
+Come contribuire in modo semplice a un progetto dataset DataCivicLab.
-Obiettivo: **collaborazione semplice, asincrona, scalabile**.
+## Percorsi
----
+- feedback o idee: usa le Discussions della repo se vuoi lasciare una traccia ragionata
+- avanzamento operativo: usa issue, project board o milestone della repo
+- insight o visual: parti da `sql/` o `dashboard/` se il progetto li prevede
-## 🧭 Principio base
+## Dove andare
-```text
-Tutto parte da una Discussion.
-Tutto finisce in una Pull Request.
-```
+- setup e contributo rapido: [docs/contributing.md](docs/contributing.md)
+- contesto DataCivicLab, policy comuni e motore tecnico: [docs/lab_links.md](docs/lab_links.md)
+- indice docs locali: [docs/README.md](docs/README.md)
----
+## Confine tecnico
-## 1) Discussion → idee, domande, contesto
+- questa repo contiene config dataset, SQL, docs, test di contratto e notebook
+- il motore di esecuzione della pipeline sta nel repo toolkit
+- se il problema riguarda run, CLI o comportamento interno del motore, aprilo nel toolkit
+- se il problema riguarda fonti, mapping, mart o documentazione del dataset, aprilo qui
-Le **Discussions** servono per:
-- proporre una domanda civica
-- discutere dataset e fonti
-- fare scelte metodologiche (KPI, perimetro, definizioni)
-- allineare rapidamente il team
+## Flusso minimo
-👉 Nessun lavoro “pesante” parte senza almeno **una discussion iniziale**.
+1. apri una domanda, un feedback o una correzione
+2. scegli una issue o aprine una nuova
+3. lavora su branch dedicato
+4. apri una PR piccola e leggibile
----
+GitHub resta il posto dove deve restare la traccia utile.
-## 2) Issue → task concreti
+## Flusso tecnico minimo
-Le **Issue** rappresentano lavoro reale.
+1. valida la config con `py -m pytest tests/test_contract.py`
+2. esegui `toolkit run all --config dataset.yml`
+3. esegui `toolkit validate all --config dataset.yml`
+4. esegui `toolkit status --dataset --year --latest --config dataset.yml`
+5. usa `toolkit inspect paths --config dataset.yml --year --json`
+6. usa i notebook per ispezionare RAW, CLEAN, MART e QA
-Una issue dovrebbe:
-- avere un obiettivo chiaro
-- essere limitata (no mega-task)
-- avere criteri di chiusura (Definition of Done)
+## Maintainers
-Esempi:
-- ingestione dataset X (raw)
-- pulizia e normalizzazione (clean)
-- costruzione mart + KPI base
-- prima dashboard (MVP)
-
-👉 Una issue = una cosa fatta.
-
----
-
-## 3) Branch → lavorare senza rompere `main`
-
-Ogni issue si lavora su un **branch dedicato**.
-
-Naming consigliato:
-```text
-issue-12-clean-dataset-rifiuti
-```
-
-Regole:
-- non lavorare direttamente su `main`
-- PR piccole e frequenti > PR gigante
-
----
-
-## 4) Pull Request → revisione e qualità
-
-La **Pull Request (PR)** serve a:
-- mostrare cosa è stato fatto
-- permettere review e miglioramenti
-- lasciare traccia delle decisioni
-
-Una PR è pronta quando:
-- il task è completo
-- README / docs coinvolte sono aggiornate
-- non sono stati caricati dati sul repo
-
----
-
-## 5) Review → Merge
-
-La review verifica:
-- coerenza metodologica
-- qualità dei notebook / query
-- chiarezza della documentazione
-
-Chi può approvare:
-- **Project Lead** o **Maintainer** (vedi `GOVERNANCE.md`)
-
-Dopo approvazione → merge su `main`.
-
----
-
-## 📦 Dati e Drive (importante)
-
-```text
-GitHub = codice + metodo + documentazione
-Drive = dati
-```
-
-Nelle PR:
-- **non** caricare CSV/XLS/Parquet “pesanti”
-- inserire **link Drive** e schema/README aggiornati
-- spiegare cosa è cambiato e perché
-
----
-
-## ✅ Definition of Done (DoD)
-
-Per chiudere un task, in generale:
-- output prodotto (notebook/query/dashboard)
-- documentazione aggiornata (almeno README rilevanti)
-- link Drive inseriti correttamente
-- controlli qualità minimi eseguiti
-
-(Se vuoi una DoD più dettagliata: `docs/definition-of-done.md`.)
-
----
+1. revisiona PR e stato del dataset
+2. verifica `status` e output finali
+3. se il progetto ha un archivio pubblico, pubblica gli artifact con `py scripts/publish_to_drive.py`
diff --git a/dashboard/README.md b/dashboard/README.md
index 093711b..1f64be5 100644
--- a/dashboard/README.md
+++ b/dashboard/README.md
@@ -1,41 +1,18 @@
-# 📊 /dashboards – Output pubblico
+# /dashboard - Output pubblico opzionale
-Questa cartella raccoglie le informazioni sugli **output pubblici** del progetto (dashboard, report, mappe).
+Questa cartella raccoglie materiali per output pubblici del progetto, come dashboard, report o mappe.
+Qui vanno link, note di lettura, screenshot e limiti dell'output, non i dati.
-Qui non ci sono “file dati”: qui ci sono **link, screenshot e spiegazioni**.
+## Cosa inserire qui
----
-
-## ✅ Cosa inserire qui
-
-- **Link pubblico** alla dashboard (Looker Studio / Superset / altro)
+- link pubblico alla dashboard
- breve descrizione dei KPI principali
- note su come leggere i grafici
-- limiti e assunzioni (cosa NON si può dedurre)
-- data ultimo aggiornamento
-
----
-
-## 🧾 Template consigliato
-
-```text
-Tipo: Dashboard (Looker Studio)
-Link: https://...
-KPI principali:
-- ...
-Come leggere:
-- ...
-Limiti:
-- ...
-Ultimo aggiornamento: YYYY-MM-DD
-```
-
----
-
-## 🔁 Coerenza con i mart
-
-Ogni dashboard dovrebbe essere legata a uno o più dataset in `/data/mart`
-(con schema documentato e link Drive aggiornato).
+- limiti e assunzioni
+- data di ultimo aggiornamento
----
+## Coerenza con i mart
+Ogni dashboard dovrebbe essere collegata ai mart documentati e aggiornati del progetto.
+Se il progetto usa un archivio pubblico su Drive, documenta qui quali file pubblicati alimentano la dashboard.
+La pubblicazione su Drive resta una operazione `maintainer-only`.
diff --git a/dataset.yml b/dataset.yml
new file mode 100644
index 0000000..07b4636
--- /dev/null
+++ b/dataset.yml
@@ -0,0 +1,70 @@
+schema_version: 1
+root: "./_smoke_out"
+
+dataset:
+ name: "bdap_http_csv"
+ years: [2022]
+
+raw:
+ output_policy: "versioned"
+ sources:
+ - name: "bdap_csv"
+ type: "http_file"
+ args:
+ url: "https://bdap-opendata.rgs.mef.gov.it/export/csv/Rendiconto-Pubblicato---Serie-storica---Saldi.csv"
+ filename: "bdap_rendiconto_saldi_{year}.csv"
+ primary: true
+
+clean:
+ sql: "sql/clean.sql"
+ read_mode: "fallback"
+ read:
+ source: "config_only"
+ mode: "explicit"
+ include:
+ - "bdap_rendiconto_saldi_*.csv"
+ delim: ";"
+ decimal: "."
+ encoding: "utf-8"
+ header: true
+ columns: null
+ required_columns:
+ - "anno"
+ - "saldo_netto"
+ - "indebitamento_netto"
+ - "avanzo_primario"
+ - "entrate_finali"
+ - "spese_finali"
+ validate:
+ primary_key:
+ - "anno"
+ not_null:
+ - "anno"
+ min_rows: 1
+
+mart:
+ tables:
+ - name: "mart_ok"
+ sql: "sql/mart/mart_ok.sql"
+ required_tables:
+ - "mart_ok"
+ validate:
+ table_rules:
+ mart_ok:
+ required_columns:
+ - "anno"
+ - "saldo_netto"
+ - "entrate_finali"
+ - "spese_finali"
+ primary_key:
+ - "anno"
+ not_null:
+ - "anno"
+ min_rows: 1
+
+validation:
+ fail_on_error: true
+
+output:
+ artifacts: "minimal"
+ legacy_aliases: true
diff --git a/docs/METHOD.md b/docs/METHOD.md
deleted file mode 100644
index 4ea39b5..0000000
--- a/docs/METHOD.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# 🧠 Metodo del progetto
-
-## Obiettivo
-(Perché questo progetto esiste)
-
-## Assunzioni
-- Assunzione 1
-- Assunzione 2
-
-## Limiti dei dati
-- Limite 1
-- Limite 2
-
-## Scelte metodologiche
-- Perché abbiamo fatto X invece di Y
-
-## Cosa NON copre questo progetto
-(Esplicito)
-
-## Come replicare
-- Dataset
-- Query / notebook
-- Passaggi principali
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..af43896
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,22 @@
+# Docs
+
+Questa cartella contiene i documenti locali, specifici di questo dataset.
+Per standard del Lab e riferimenti organizzativi vedi [lab_links.md](lab_links.md).
+
+Il motore della pipeline non vive qui: questa documentazione descrive il dataset e il suo contratto verso il toolkit.
+Le decisioni operative sul run reale devono restare coerenti con la CLI e con lo schema config del toolkit.
+
+Se ti serve contesto generale su DataCivicLab, parti da `dataciviclab`.
+Se ti servono policy comuni o istruzioni GitHub valide per tutta l'organizzazione, parti da `.github`.
+
+## Essenziali
+
+- [overview.md](overview.md)
+- [sources.md](sources.md)
+- [data_dictionary.md](data_dictionary.md)
+- [decisions.md](decisions.md)
+- [contributing.md](contributing.md)
+
+## Archive
+
+- [Archived Lab-wide docs](_archive/INDEX.md)
diff --git a/docs/_archive/INDEX.md b/docs/_archive/INDEX.md
new file mode 100644
index 0000000..249a970
--- /dev/null
+++ b/docs/_archive/INDEX.md
@@ -0,0 +1,4 @@
+# Archive
+
+Questa cartella raccoglie documenti storici o di riferimento che non fanno parte del set minimo del template.
+Usala solo come archivio leggero: i documenti locali correnti restano indicizzati in [../README.md](../README.md).
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..80c85a8
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,145 @@
+# Contributing
+
+Guida rapida per contribuire a un repo dataset senza dover capire tutto l'ecosistema in un colpo solo.
+
+Le policy comuni dell'organizzazione non vengono duplicate qui: per quelle, il posto giusto e `.github`.
+Questo documento resta pratico e locale al repo dataset.
+
+## Setup minimo
+
+Prerequisiti:
+
+- avere accesso al repo
+- avere Python e il toolkit disponibili nel proprio ambiente, oppure un checkout locale del toolkit
+- lavorare sempre dalla root del progetto
+
+Questa repo contiene configurazione dataset, SQL, documentazione e test di contratto.
+Il motore della pipeline sta nel repo `toolkit`.
+
+## Contract tests
+
+Esegui sempre prima:
+
+```sh
+py -m pytest tests/test_contract.py
+```
+
+Questi test non verificano il motore del toolkit.
+Verificano che questa repo esponga un contratto coerente per il dataset: file dichiarati, path, tabelle mart e struttura minima della config.
+
+## Smoke locale
+
+Per uno smoke test end-to-end:
+
+```sh
+sh scripts/smoke.sh
+```
+
+Se il toolkit non e nel `PATH`, usa il fallback documentato nello script.
+Se lo smoke fallisce per un problema del motore, apri il bug nel repo `toolkit`.
+Se fallisce per config, SQL o assunzioni sul dato, correggi questa repo.
+
+Su Windows, se `sh` non e disponibile nel `PATH`, usa una shell POSIX come Git Bash oppure esegui i comandi toolkit equivalenti:
+
+```powershell
+toolkit run all --config dataset.yml
+toolkit validate all --config dataset.yml
+toolkit status --dataset --year --latest --config dataset.yml
+```
+
+## Dove scrivere cosa
+
+- Discussions della repo: domande, interpretazioni, proposte e contesto
+- Issues della repo: bug, task e blocchi operativi
+- Project board o milestone della repo, se presenti: avanzamento e priorita
+- Discord o altri canali veloci del team: utili per scambio rapido, non come fonte canonica
+
+## Publish su Drive
+
+Se il progetto usa un archivio pubblico su Drive, la pubblicazione va fatta dopo `run all` e `validate all`, non durante il run.
+Questo passaggio e `maintainer-only`: non e richiesto ai contributor per lavorare su SQL, docs, test o notebook.
+
+Dry-run:
+
+```powershell
+py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\DataCivicLab" --dry-run
+```
+
+Publish di un anno:
+
+```powershell
+py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\DataCivicLab" --year 2022
+```
+
+Lo script pubblica per default payload RAW, metadata, manifest e validation di `raw`, `clean`, `mart`, i parquet CLEAN/MART e l'ultimo run record.
+La destinazione su Drive mantiene gli stessi path relativi sotto `root`, quindi pubblica sotto `/data/...`.
+
+## Comandi canonici toolkit
+
+```sh
+toolkit run all --config dataset.yml
+toolkit validate all --config dataset.yml
+toolkit status --dataset --year --latest --config dataset.yml
+toolkit inspect paths --config dataset.yml --year --json
+```
+
+Per workflow avanzati come `run raw|clean|mart`, `resume` o `profile raw`, vedi la documentazione advanced del toolkit.
+Per il contratto stabile dei notebook e la matrice di stabilita delle feature, vedi anche:
+
+- `docs/notebook-contract.md`
+- `docs/feature-stability.md`
+
+Quando lavori per layer invece che con `run all`, usa questa regola semplice:
+
+- `toolkit run raw|clean|mart ...` produce gli artifact
+- `toolkit inspect paths --config dataset.yml --year --json` ti dice dove leggerli
+- notebook e controlli manuali devono leggere i path restituiti, non ricostruirli a mano
+
+## Fasi operative
+
+- kickoff e contratto: `dataset.yml`, `README.md`, `tests/test_contract.py`
+- sources e raw: `dataset.yml`, `docs/sources.md`, `docs/decisions.md`, `notebooks/01_inspect_raw.ipynb`
+- clean: `sql/clean.sql`, `dataset.yml`, `notebooks/02_inspect_clean.ipynb`
+- mart: `sql/mart/*.sql`, `dataset.yml`, `notebooks/03_explore_mart.ipynb`
+- qa: `tests/test_contract.py`, `.github/workflows/ci.yml`, `notebooks/04_quality_checks.ipynb`
+- dashboard/export: `dashboard/`, `README.md`, `notebooks/05_dashboard_export.ipynb`
+
+## Checklist lifecycle
+
+| Fase | File principali | Comando minimo | Notebook |
+|---|---|---|---|
+| Kickoff | `dataset.yml`, `README.md` | `py -m pytest tests/test_contract.py` | `00_quickstart.ipynb` |
+| Sources/RAW | `dataset.yml`, `docs/sources.md`, `docs/decisions.md` | `toolkit run raw --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `01_inspect_raw.ipynb` |
+| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit run clean --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `02_inspect_clean.ipynb` |
+| MART | `sql/mart/*.sql`, `dataset.yml` | `toolkit run mart --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `03_explore_mart.ipynb` |
+| QA | `tests/test_contract.py`, `.github/workflows/ci.yml` | `toolkit validate all --config dataset.yml` | `04_quality_checks.ipynb` |
+| Output pubblico | `dashboard/`, `README.md`, `scripts/publish_to_drive.py` | `maintainer-only: py scripts/publish_to_drive.py --config dataset.yml --drive-root "" --dry-run` | `05_dashboard_export.ipynb` |
+| Release | `README.md`, `docs/overview.md`, `docs/data_dictionary.md` | `toolkit status --dataset --year --latest --config dataset.yml` | `00_quickstart.ipynb` |
+
+I notebook usano `toolkit inspect paths --config dataset.yml --year --json` come contratto stabile per localizzare gli output.
+
+Ruoli minimi da tenere distinti nei notebook:
+
+- `metadata.json` = payload ricco del layer
+- `manifest.json` = summary stabile del layer con puntatori a metadata e validation
+- `data/_runs/.../.json` = stato del run usato da `status` e `resume`
+
+## Regole veloci
+
+- non committare output sotto `data/`, salvo sample piccoli in `data/_examples`
+- aggiorna `docs/decisions.md` quando cambi scelte o trade-off
+- aggiorna `docs/data_dictionary.md` quando cambia il significato dei campi
+- usa path root-relative e POSIX nella documentazione tecnica
+
+## Come aiutare in 15 minuti
+
+- controlla che `docs/sources.md` e `docs/overview.md` siano coerenti
+- migliora una descrizione in `docs/data_dictionary.md`
+- esegui `py -m pytest tests/test_contract.py` e segnala eventuali problemi
+- apri i notebook per ispezionare gli output generati dal toolkit, senza aggiungere logica di pipeline qui
+
+## Poi dove vado?
+
+- workflow umano: [../WORKFLOW.md](../WORKFLOW.md)
+- docs locali: [README.md](README.md)
+- contesto DataCivicLab, policy comuni e motore: [lab_links.md](lab_links.md)
diff --git a/docs/data_dictionary.md b/docs/data_dictionary.md
new file mode 100644
index 0000000..dcdb30b
--- /dev/null
+++ b/docs/data_dictionary.md
@@ -0,0 +1,30 @@
+# Data Dictionary
+
+Audience: Maintainers
+
+Schema sintetico dei dataset gestiti dal progetto.
+
+## Raw
+
+| Column | Type | Description | Nullable | Notes |
+| --- | --- | --- | --- | --- |
+| year | int | Reference year | no | Example placeholder |
+| entity_id | string | Stable entity identifier | no | Replace with project key |
+| metric_value | float | Raw metric value | yes | Replace with actual meaning |
+
+## Clean
+
+| Column | Type | Description | Nullable | Validation |
+| --- | --- | --- | --- | --- |
+| year | int | Reference year | no | not_null |
+| entity_id | string | Stable entity identifier | no | not_null, unique_key |
+| metric_value | float | Normalized metric value | yes | range TBD |
+
+## Mart
+
+| Column | Type | Description | Nullable | Consumer |
+| --- | --- | --- | --- | --- |
+| year | int | Reference year | no | dashboard/report |
+| entity_id | string | Stable entity identifier | no | dashboard/report |
+| metric_value | float | Final metric | yes | dashboard/report |
+| rows_in_year | int | Record count by year | no | QA/dashboard |
diff --git a/docs/decisions.md b/docs/decisions.md
new file mode 100644
index 0000000..16928b6
--- /dev/null
+++ b/docs/decisions.md
@@ -0,0 +1,22 @@
+# Decision log - [Nome dataset]
+
+Qui registriamo scelte tecniche che cambiano il significato dei dati, come mapping, filtri o definizioni delle metriche.
+
+## D-001 - Standardizzazione chiavi territoriali
+
+- decisione: [...]
+- motivo: [...]
+- impatto: [...]
+- alternative valutate: [...]
+
+## D-002 - Policy valori mancanti
+
+- decisione: [...]
+- motivo: [...]
+- impatto: [...]
+
+## D-003 - Definizione metrica [X]
+
+- decisione: [...]
+- formula: [...]
+- motivo: [...]
diff --git a/docs/definition-of-done.md b/docs/definition-of-done.md
deleted file mode 100644
index 2a49b5b..0000000
--- a/docs/definition-of-done.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Definition of Done (DoD)
-
-Un progetto è “Done” quando:
-
-## Domanda civica
-- La domanda è una sola, chiara e misurabile
-- L’output risponde davvero alla domanda (anche con limiti espliciti)
-
-## Dati
-- Raw immutato
-- Clean/Mart riproducibili
-- Tipi coerenti e naming standard (snake_case)
-- Nessuna logica “magica” nascosta
-
-## Metodo
-- Assunzioni dichiarate
-- Limiti dichiarati
-- Confronti omogenei (perimetro, periodo, definizioni)
-
-## Viz
-- Dashboard leggibile da non tecnico
-- Titoli “parlanti”
-- Unità di misura sempre visibili
-- Filtri essenziali (no overload)
-
-## QA
-- Sanity check superati (totali, outlier, ordini di grandezza)
-- Cross-check con fonte o riferimenti dove possibile
-- Issue aperte per anomalie residue + severità
-
-## Doc
-- README aggiornato con: scopo, dataset, metodo, output, limiti
-- Link a dashboard + dataset + query/notebook principali
-- “Cosa emerge” e “cosa non emerge” scritto chiaramente
-
-
-> Nota: una sintesi è in `WORKFLOW.md`.
\ No newline at end of file
diff --git a/docs/lab_links.md b/docs/lab_links.md
new file mode 100644
index 0000000..ed48ca9
--- /dev/null
+++ b/docs/lab_links.md
@@ -0,0 +1,21 @@
+# Lab Links
+
+Gli standard del Lab sono centralizzati e non vengono duplicati in questo template.
+Usa questa pagina come ponte verso i repository organizzativi corretti.
+
+## Hub del Lab
+
+- [dataciviclab](https://github.com/dataciviclab/dataciviclab): hub pubblico del Lab, mappa delle repo, catalogo dataset, governance alta e canali community
+
+## Policy organizzative
+
+- [.github](https://github.com/dataciviclab/.github): policy condivise, issue template, PR template e community health files
+
+## Motore tecnico
+
+- [toolkit](https://github.com/dataciviclab/toolkit): workflow tecnico canonico, CLI, contratti stabili e documentazione del motore
+
+## Canali pubblici
+
+- usa i canali della singola repo dataset per lasciare traccia utile del lavoro
+- se ti serve contesto org-wide, parti da `dataciviclab` e da `.github`
diff --git a/docs/overview.md b/docs/overview.md
new file mode 100644
index 0000000..3d8b0ce
--- /dev/null
+++ b/docs/overview.md
@@ -0,0 +1,36 @@
+# Overview - [Nome dataset]
+
+## Cos'è il dataset
+
+Questo dataset organizza informazioni pubbliche per aiutare a leggere un fenomeno civico in modo chiaro, confrontabile e verificabile. Serve a trasformare dati sparsi o poco leggibili in una base utile per capire meglio cosa succede e come cambia nel tempo.
+
+## Unità di analisi e chiavi
+
+- unità di analisi: [Comune / ASL / Provincia / Regione / altro]
+- granularità temporale: [anno / mese / trimestre / altro]
+- chiavi principali: [es. codice_istat, anno]
+
+## Copertura
+
+- anni coperti: [...]
+- territorio coperto: [...]
+- note sulla copertura: [...]
+
+## Domande civiche che puoi esplorare
+
+- come varia questo fenomeno tra territori diversi
+- quali aree mostrano i cambiamenti più forti nel tempo
+- dove emergono possibili squilibri o criticità
+- quali territori risultano più esposti o più resilienti
+- dove servono approfondimenti o verifiche ulteriori
+
+## Limiti e caveat
+
+- il dataset dipende dalla qualità e dalla tempestività delle fonti ufficiali
+- alcune definizioni possono cambiare nel tempo
+- copertura e granularità non sempre coincidono per tutti gli anni
+- i dati descrivono il fenomeno solo entro i limiti delle fonti disponibili
+
+## Metodo
+
+Il progetto organizza i dati in tre passaggi semplici: raccolta del dato di partenza, pulizia e normalizzazione, produzione di tabelle finali per analisi e output pubblici. Per dettagli tecnici, vedi il repository Toolkit DataCivicLab.
diff --git a/docs/roles.md b/docs/roles.md
deleted file mode 100644
index e3bcf02..0000000
--- a/docs/roles.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Ruoli del progetto
-
-Questi ruoli servono a rendere il lavoro replicabile, verificabile e chiudibile.
-
-## Project Lead
-Facilita, coordina, mantiene la direzione, decide quando chiudere.
-
-## Data
-Pulizia dati, query, metriche, output clean/mart.
-
-## Metodo
-Assunzioni, limiti, coerenza definizioni, perimetri e confronti.
-
-## Viz
-Dashboard/visual: struttura, leggibilità, filtri, UX.
-
-## QA
-Controllo qualità: sanity check, cross-check, anomalie, regressioni.
-
-## Doc
-Spiegazioni pubbliche: README, note metodologiche, cosa emerge / non emerge.
diff --git a/docs/sources.md b/docs/sources.md
new file mode 100644
index 0000000..d3d9aa6
--- /dev/null
+++ b/docs/sources.md
@@ -0,0 +1,23 @@
+# Fonti ufficiali - [Nome dataset]
+
+## Fonte primaria
+
+- ente: [...]
+- pagina ufficiale: [...]
+- download: [...]
+- licenza o note d'uso: [...]
+
+## Mirror
+
+- [mirror 1]
+- [mirror 2]
+
+## Versioning
+
+- logica versioni: [per anno / per release / per aggiornamenti]
+- campi che cambiano nel tempo: [se noti]
+
+## Note su qualità e pubblicazione
+
+- [encoding, delimitatore, header, se rilevante]
+- [note su valori mancanti]
diff --git a/notebooks/00_quickstart.ipynb b/notebooks/00_quickstart.ipynb
new file mode 100644
index 0000000..20d11ab
--- /dev/null
+++ b/notebooks/00_quickstart.ipynb
@@ -0,0 +1,103 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 00 Quickstart\n",
+ "\n",
+ "- legge `../dataset.yml`\n",
+ "- mostra i path reali attesi dal toolkit\n",
+ "- esegue il run solo se abiliti esplicitamente `RUN_TOOLKIT = True`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "BASE_DIR = DATASET_YML.parent\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "MART_TABLES = [table['name'] for table in CFG.get('mart', {}).get('tables', [])]\n",
+ "RUN_TOOLKIT = False\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "RUN_CMD = CLI_PREFIX + ['run', 'all', '--config', str(DATASET_YML)]\n",
+ "VALIDATE_CMD = CLI_PREFIX + ['validate', 'all', '--config', str(DATASET_YML)]\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT_RESULT = subprocess.run(INSPECT_CMD, capture_output=True, text=True)\n",
+ "if INSPECT_RESULT.returncode != 0:\n",
+ " raise RuntimeError(INSPECT_RESULT.stderr.strip() or INSPECT_RESULT.stdout.strip() or 'toolkit inspect paths failed')\n",
+ "INSPECT = json.loads(INSPECT_RESULT.stdout)\n",
+ "\n",
+ "{\n",
+ " 'DATASET_YML': str(DATASET_YML),\n",
+ " 'ROOT': INSPECT.get('root'),\n",
+ " 'DATASET': DATASET,\n",
+ " 'YEARS': YEARS,\n",
+ " 'YEAR_INDEX': YEAR_INDEX,\n",
+ " 'YEAR': YEAR,\n",
+ " 'MART_TABLES': MART_TABLES,\n",
+ " 'CLI_PREFIX': CLI_PREFIX,\n",
+ " 'INSPECT_CMD': INSPECT_CMD,\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "{\n",
+ " 'raw_dir': INSPECT['paths']['raw']['dir'],\n",
+ " 'clean_dir': INSPECT['paths']['clean']['dir'],\n",
+ " 'mart_dir': INSPECT['paths']['mart']['dir'],\n",
+ " 'run_dir': INSPECT['paths']['run_dir'],\n",
+ " 'latest_run': INSPECT.get('latest_run'),\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "print('Run command:', ' '.join(RUN_CMD))\n",
+ "print('Validate command:', ' '.join(VALIDATE_CMD))\n",
+ "\n",
+ "if RUN_TOOLKIT:\n",
+ " subprocess.run(RUN_CMD, check=True)\n",
+ " subprocess.run(VALIDATE_CMD, check=True)\n",
+ "else:\n",
+ " print('Toolkit run disabled. Set RUN_TOOLKIT = True to execute the pipeline.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/01_inspect_raw.ipynb b/notebooks/01_inspect_raw.ipynb
new file mode 100644
index 0000000..a3577fd
--- /dev/null
+++ b/notebooks/01_inspect_raw.ipynb
@@ -0,0 +1,123 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 01 Inspect RAW\n",
+ "\n",
+ "- apre il layer RAW reale del toolkit\n",
+ "- legge `manifest.json`, `metadata.json` e `raw_validation.json`\n",
+ "- prova a mostrare un sample del file primario dichiarato nel manifest"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import duckdb\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n",
+ "RAW_DIR = Path(INSPECT['paths']['raw']['dir'])\n",
+ "MANIFEST_PATH = Path(INSPECT['paths']['raw']['manifest'])\n",
+ "METADATA_PATH = Path(INSPECT['paths']['raw']['metadata'])\n",
+ "VALIDATION_PATH = Path(INSPECT['paths']['raw']['validation'])\n",
+ "PROFILE_DIR = RAW_DIR / '_profile'\n",
+ "\n",
+ "{\n",
+ " 'YEARS': YEARS,\n",
+ " 'YEAR_INDEX': YEAR_INDEX,\n",
+ " 'RAW_DIR': str(RAW_DIR),\n",
+ " 'INSPECT_CMD': INSPECT_CMD,\n",
+ " 'MANIFEST_EXISTS': MANIFEST_PATH.exists(),\n",
+ " 'METADATA_EXISTS': METADATA_PATH.exists(),\n",
+ " 'VALIDATION_EXISTS': VALIDATION_PATH.exists(),\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "manifest = json.loads(MANIFEST_PATH.read_text(encoding='utf-8')) if MANIFEST_PATH.exists() else {}\n",
+ "metadata = json.loads(METADATA_PATH.read_text(encoding='utf-8')) if METADATA_PATH.exists() else {}\n",
+ "validation = json.loads(VALIDATION_PATH.read_text(encoding='utf-8')) if VALIDATION_PATH.exists() else {}\n",
+ "\n",
+ "display(manifest)\n",
+ "display(metadata)\n",
+ "display(validation)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "primary_rel = manifest.get('primary_output_file') if manifest else None\n",
+ "primary_path = (RAW_DIR / primary_rel).resolve() if primary_rel else None\n",
+ "primary_path"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if primary_path and primary_path.exists():\n",
+ " suffix = primary_path.suffix.lower()\n",
+ " if suffix == '.csv':\n",
+ " con = duckdb.connect()\n",
+ " preview = con.execute(\n",
+ " f\"SELECT * FROM read_csv_auto('{primary_path.as_posix()}', SAMPLE_SIZE=-1) LIMIT 20\"\n",
+ " ).df()\n",
+ " display(preview)\n",
+ " elif suffix == '.parquet':\n",
+ " con = duckdb.connect()\n",
+ " preview = con.execute(f\"SELECT * FROM read_parquet('{primary_path.as_posix()}') LIMIT 20\").df()\n",
+ " display(preview)\n",
+ " else:\n",
+ " print({'primary_output_file': str(primary_path), 'bytes': primary_path.stat().st_size, 'suffix': suffix})\n",
+ "else:\n",
+ " print('Primary output file not found. Inspect manifest manually.')\n",
+ "\n",
+ "if PROFILE_DIR.exists():\n",
+ " display(sorted(path.name for path in PROFILE_DIR.iterdir()))\n",
+ "else:\n",
+ " print('No _profile directory found for this RAW run.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/02_inspect_clean.ipynb b/notebooks/02_inspect_clean.ipynb
new file mode 100644
index 0000000..029fef3
--- /dev/null
+++ b/notebooks/02_inspect_clean.ipynb
@@ -0,0 +1,107 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 02 Inspect CLEAN\n",
+ "\n",
+ "- apre il parquet CLEAN prodotto dal toolkit\n",
+ "- mostra schema, preview e sanity checks minimi\n",
+ "- aiuta a verificare `clean.required_columns` e `clean.validate`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import duckdb\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n",
+ "CLEAN_DIR = Path(INSPECT['paths']['clean']['dir'])\n",
+ "CLEAN_PATH = Path(INSPECT['paths']['clean']['output'])\n",
+ "REQUIRED_COLUMNS = CFG.get('clean', {}).get('required_columns', [])\n",
+ "PRIMARY_KEY = CFG.get('clean', {}).get('validate', {}).get('primary_key', [])\n",
+ "\n",
+ "{\n",
+ " 'YEARS': YEARS,\n",
+ " 'YEAR_INDEX': YEAR_INDEX,\n",
+ " 'CLEAN_PATH': str(CLEAN_PATH),\n",
+ " 'INSPECT_CMD': INSPECT_CMD,\n",
+ " 'REQUIRED_COLUMNS': REQUIRED_COLUMNS,\n",
+ " 'PRIMARY_KEY': PRIMARY_KEY,\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "con = duckdb.connect()\n",
+ "\n",
+ "if CLEAN_PATH.exists():\n",
+ " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}')\").df()\n",
+ " preview_df = con.execute(f\"SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}') LIMIT 20\").df()\n",
+ " display(schema_df)\n",
+ " display(preview_df)\n",
+ "else:\n",
+ " print('CLEAN parquet not found. Run toolkit run clean --config dataset.yml first.')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if CLEAN_PATH.exists():\n",
+ " row_count = con.execute(f\"SELECT COUNT(*) AS row_count FROM read_parquet('{CLEAN_PATH.as_posix()}')\").df()\n",
+ " display(row_count)\n",
+ "\n",
+ " if REQUIRED_COLUMNS:\n",
+ " available = {row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}')\").fetchall()}\n",
+ " print({'missing_required_columns': [col for col in REQUIRED_COLUMNS if col not in available]})\n",
+ "\n",
+ " if PRIMARY_KEY:\n",
+ " keys = ', '.join(PRIMARY_KEY)\n",
+ " dup_df = con.execute(\n",
+ " f\"SELECT {keys}, COUNT(*) AS dup_count FROM read_parquet('{CLEAN_PATH.as_posix()}') GROUP BY {keys} HAVING COUNT(*) > 1 LIMIT 20\"\n",
+ " ).df()\n",
+ " display(dup_df)\n",
+ "else:\n",
+ " print('No CLEAN output available.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/03_explore_mart.ipynb b/notebooks/03_explore_mart.ipynb
new file mode 100644
index 0000000..0b698d1
--- /dev/null
+++ b/notebooks/03_explore_mart.ipynb
@@ -0,0 +1,105 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 03 Explore MART\n",
+ "\n",
+ "- apre il primo mart dichiarato in `dataset.yml`\n",
+ "- mostra schema, preview e aggregazioni esplorative\n",
+ "- aiuta a collegare i mart alle domande civiche"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import duckdb\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "TABLES = CFG.get('mart', {}).get('tables', [])\n",
+ "TABLE_INDEX = 0\n",
+ "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n",
+ "TABLE_NAME = SELECTED_TABLE['name']\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n",
+ "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n",
+ "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n",
+ "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "con = duckdb.connect()\n",
+ "YEAR_COL = None\n",
+ "METRIC_COL = None\n",
+ "\n",
+ "def choose_columns(schema_rows):\n",
+ " year_col = next((row[0] for row in schema_rows if str(row[0]).lower() == 'year' or 'anno' in str(row[0]).lower()), None)\n",
+ " numeric_rows = [row for row in schema_rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n",
+ " metric_col = next((row[0] for row in numeric_rows if any(token in str(row[0]).lower() for token in ['value', 'tot', 'importo', 'ammontare', 'saldo', 'spese', 'entrate', 'pct', 'percent'])), None)\n",
+ " if metric_col is None and numeric_rows:\n",
+ " metric_col = numeric_rows[0][0]\n",
+ " return year_col, metric_col\n",
+ "\n",
+ "if MART_PATH and MART_PATH.exists():\n",
+ " schema_rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").fetchall()\n",
+ " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").df()\n",
+ " preview_df = con.execute(f\"SELECT * FROM read_parquet('{MART_PATH.as_posix()}') LIMIT 20\").df()\n",
+ " YEAR_COL, METRIC_COL = choose_columns(schema_rows)\n",
+ " display(schema_df)\n",
+ " display(preview_df)\n",
+ " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n",
+ "else:\n",
+ " print('MART parquet not found. Run toolkit run mart --config dataset.yml first.')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if MART_PATH and MART_PATH.exists() and YEAR_COL and METRIC_COL:\n",
+ " by_year = con.execute(\n",
+ " f\"SELECT {YEAR_COL} AS year_like, COUNT(*) AS rows, SUM({METRIC_COL}) AS metric_total FROM read_parquet('{MART_PATH.as_posix()}') GROUP BY 1 ORDER BY 1\"\n",
+ " ).df()\n",
+ " display(by_year)\n",
+ "else:\n",
+ " print('No year-like column or metric column detected.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/04_quality_checks.ipynb b/notebooks/04_quality_checks.ipynb
new file mode 100644
index 0000000..6cb3c89
--- /dev/null
+++ b/notebooks/04_quality_checks.ipynb
@@ -0,0 +1,111 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 04 Quality Checks\n",
+ "\n",
+ "- esegue controlli ripetibili sul primo mart dichiarato in config\n",
+ "- usa le chiavi di validazione del mart quando disponibili\n",
+ "- aiuta a investigare anomalie residue dopo `toolkit validate all`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import duckdb\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "TABLES = CFG.get('mart', {}).get('tables', [])\n",
+ "TABLE_INDEX = 0\n",
+ "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n",
+ "TABLE_NAME = SELECTED_TABLE['name']\n",
+ "TABLE_RULES = CFG.get('mart', {}).get('validate', {}).get('table_rules', {}).get(TABLE_NAME, {})\n",
+ "KEY_COLUMNS = TABLE_RULES.get('primary_key', [])\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n",
+ "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n",
+ "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n",
+ "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "con = duckdb.connect()\n",
+ "NUMERIC_COLUMNS = []\n",
+ "\n",
+ "def detect_numeric_columns(path):\n",
+ " rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path.as_posix()}')\").fetchall()\n",
+ " return [row[0] for row in rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n",
+ "\n",
+ "if MART_PATH and MART_PATH.exists():\n",
+ " NUMERIC_COLUMNS = detect_numeric_columns(MART_PATH)[:3]\n",
+ " print({'KEY_COLUMNS': KEY_COLUMNS, 'NUMERIC_COLUMNS': NUMERIC_COLUMNS})\n",
+ "else:\n",
+ " print('MART parquet not found.')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if MART_PATH and MART_PATH.exists():\n",
+ " if KEY_COLUMNS:\n",
+ " keys = ', '.join(KEY_COLUMNS)\n",
+ " dup_df = con.execute(\n",
+ " f\"SELECT {keys}, COUNT(*) AS dup_count FROM read_parquet('{MART_PATH.as_posix()}') GROUP BY {keys} HAVING COUNT(*) > 1 ORDER BY dup_count DESC LIMIT 20\"\n",
+ " ).df()\n",
+ " display(dup_df)\n",
+ " else:\n",
+ " print('Duplicate-key check skipped: no primary key declared for this mart.')\n",
+ "\n",
+ " columns = [row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").fetchall()]\n",
+ " null_expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in columns])\n",
+ " null_df = con.execute(f\"SELECT {null_expr} FROM read_parquet('{MART_PATH.as_posix()}')\").df().T.reset_index()\n",
+ " display(null_df)\n",
+ "\n",
+ " if NUMERIC_COLUMNS:\n",
+ " range_expr = ', '.join([f\"MIN({col}) AS {col}_min, MAX({col}) AS {col}_max\" for col in NUMERIC_COLUMNS])\n",
+ " range_df = con.execute(f\"SELECT {range_expr} FROM read_parquet('{MART_PATH.as_posix()}')\").df().T.reset_index()\n",
+ " display(range_df)\n",
+ "else:\n",
+ " print('No MART output available.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/05_dashboard_export.ipynb b/notebooks/05_dashboard_export.ipynb
new file mode 100644
index 0000000..c29b947
--- /dev/null
+++ b/notebooks/05_dashboard_export.ipynb
@@ -0,0 +1,128 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# 05 Dashboard Export\n",
+ "\n",
+ "- prepara un export leggero partendo dal primo mart dichiarato in config\n",
+ "- non scrive file finche `EXPORT = False`\n",
+ "- usa `_tmp/` per evitare output committati nel repo"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import json\n",
+ "import shutil\n",
+ "import subprocess\n",
+ "import duckdb\n",
+ "import yaml\n",
+ "\n",
+ "ROOT = Path('.').resolve()\n",
+ "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n",
+ "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n",
+ "BASE_DIR = DATASET_YML.parent\n",
+ "DATASET = CFG['dataset']['name']\n",
+ "YEARS = CFG['dataset']['years']\n",
+ "YEAR_INDEX = 0\n",
+ "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n",
+ "TABLES = CFG.get('mart', {}).get('tables', [])\n",
+ "TABLE_INDEX = 0\n",
+ "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n",
+ "TABLE_NAME = SELECTED_TABLE['name']\n",
+ "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n",
+ "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n",
+ "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n",
+ "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n",
+ "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n",
+ "OUT_DIR = (BASE_DIR / '_tmp').resolve()\n",
+ "EXPORT = False\n",
+ "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "con = duckdb.connect()\n",
+ "YEAR_COL = None\n",
+ "METRIC_COL = None\n",
+ "export_df = None\n",
+ "\n",
+ "def choose_columns(path):\n",
+ " rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path.as_posix()}')\").fetchall()\n",
+ " year_col = next((row[0] for row in rows if str(row[0]).lower() == 'year' or 'anno' in str(row[0]).lower()), None)\n",
+ " numeric_rows = [row[0] for row in rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n",
+ " metric_col = next((col for col in numeric_rows if any(token in col.lower() for token in ['value', 'tot', 'importo', 'ammontare', 'saldo', 'spese', 'entrate', 'pct', 'percent'])), None)\n",
+ " if metric_col is None and numeric_rows:\n",
+ " metric_col = numeric_rows[0]\n",
+ " return year_col, metric_col\n",
+ "\n",
+ "if MART_PATH and MART_PATH.exists():\n",
+ " YEAR_COL, METRIC_COL = choose_columns(MART_PATH)\n",
+ " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n",
+ "else:\n",
+ " print('MART parquet not found.')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if MART_PATH and MART_PATH.exists() and YEAR_COL and METRIC_COL:\n",
+ " export_df = con.execute(\n",
+ " f\"SELECT {YEAR_COL} AS year_like, {METRIC_COL} AS metric_value FROM read_parquet('{MART_PATH.as_posix()}') ORDER BY 1\"\n",
+ " ).df()\n",
+ "elif MART_PATH and MART_PATH.exists():\n",
+ " export_df = con.execute(f\"SELECT * FROM read_parquet('{MART_PATH.as_posix()}') LIMIT 1000\").df()\n",
+ "else:\n",
+ " export_df = None\n",
+ "\n",
+ "if export_df is not None:\n",
+ " display(export_df.head())"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "if EXPORT and export_df is not None:\n",
+ " OUT_DIR.mkdir(parents=True, exist_ok=True)\n",
+ " csv_path = OUT_DIR / f'{TABLE_NAME}_dashboard.csv'\n",
+ " parquet_path = OUT_DIR / f'{TABLE_NAME}_dashboard.parquet'\n",
+ " export_df.to_csv(csv_path, index=False)\n",
+ " con.register('export_df_view', export_df)\n",
+ " con.execute(f\"COPY (SELECT * FROM export_df_view) TO '{parquet_path.as_posix()}' (FORMAT PARQUET)\")\n",
+ " print(csv_path)\n",
+ " print(parquet_path)\n",
+ "else:\n",
+ " print('Export disabled. Set EXPORT = True to write files into ../_tmp/.')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/notebooks/README.md b/notebooks/README.md
index 8d0842d..0223d3e 100644
--- a/notebooks/README.md
+++ b/notebooks/README.md
@@ -1,32 +1,32 @@
-# 📓 /notebooks – Pipeline e analisi
+# /notebooks - notebook standard per il dataset
-Questa cartella contiene notebook (Colab / Jupyter) per:
-- ingestione dati (raw)
-- pulizia/normalizzazione (clean)
-- aggregazioni e KPI (mart)
-- analisi esplorative (quando servono)
+Questa cartella contiene notebook leggeri e clonabili per tutto il lifecycle operativo del dataset.
+Usano Python standard, `duckdb` e il contratto stabile `toolkit inspect paths --json` per scoprire gli output reali del progetto.
----
+I notebook non reimplementano il motore della pipeline.
+Usano `toolkit inspect paths --json` come fonte primaria per localizzare RAW, CLEAN, MART e run record, e servono a ispezionare gli output dal punto di vista del dataset.
+Il comando puo essere disponibile come `toolkit ...` oppure come fallback `py -m toolkit.cli.app ...`.
+Per i dettagli stabili lato toolkit, vedi `docs/notebook-contract.md` e `docs/feature-stability.md` nel repo toolkit.
-## ✅ Regole minime
+Contratto minimo degli output:
-- notebook numerati: `01_...`, `02_...`, `03_...`
-- eseguibili dall’inizio alla fine (no “celle magiche”)
-- commenti brevi: **cosa** fai e **perché**
-- niente path locali: usare riferimenti chiari al Drive / cartelle di progetto
+- `metadata.json` = payload ricco del layer
+- `manifest.json` = summary stabile del layer con puntatori a metadata e validation
+- `data/_runs/.../.json` = stato del run letto da `status` e `resume`
----
+## Notebook inclusi
-## 🔁 Collegamento con `/data`
+- `00_quickstart.ipynb` - setup, command preview, run opzionale e localizzazione output reali del toolkit
+- `01_inspect_raw.ipynb` - ispezione del layer RAW tramite `manifest.json`, file primario e sample di output
+- `02_inspect_clean.ipynb` - ispezione del parquet CLEAN con schema e sanity checks minimi
+- `03_explore_mart.ipynb` - esplorazione del mart selezionato nella config
+- `04_quality_checks.ipynb` - controlli ripetibili su chiavi, missingness e range del mart selezionato
+- `05_dashboard_export.ipynb` - export opzionali derivati dal mart selezionato nella config
-Ogni notebook dovrebbe aggiornare (o citare) i README di:
-- `/data/raw`
-- `/data/clean`
-- `/data/mart`
+## Regole
-Così chi arriva dopo capisce:
-- da dove arrivano i dati
-- cosa è stato fatto
-- dove trovare i file su Drive
-
----
+- non salvare output pesanti nel repo
+- se serve esportare file, usa `../_tmp/`
+- mantieni i notebook generici: preferisci leggere `dataset.yml` e usa i parametri iniziali per scegliere anno/tabella
+- non ricostruire a mano i path degli output del toolkit: usa sempre i path restituiti da `inspect paths --json`
+- per dettagli tecnici della pipeline, vedi il repository Toolkit DataCivicLab
diff --git a/queries/README.md b/queries/README.md
deleted file mode 100644
index ae558a0..0000000
--- a/queries/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# 🧮 Query
-
-Questa cartella contiene le query utilizzate per:
-- pulizia
-- aggregazione
-- calcolo metriche
-
-Ogni query deve:
-- avere un nome descrittivo
-- essere commentata
-- indicare input e output
diff --git a/scripts/publish_to_drive.py b/scripts/publish_to_drive.py
new file mode 100644
index 0000000..8da2614
--- /dev/null
+++ b/scripts/publish_to_drive.py
@@ -0,0 +1,155 @@
+from __future__ import annotations
+
+import argparse
+import shutil
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+
+@dataclass(frozen=True)
+class PublishItem:
+ source: Path
+ destination: Path
+
+
+def load_config(config_path: Path) -> dict:
+ return yaml.safe_load(config_path.read_text(encoding="utf-8"))
+
+
+def resolve_output_root(config_path: Path, cfg: dict) -> Path:
+ root_value = cfg.get("root", ".")
+ return (config_path.parent / root_value).resolve()
+
+
+def year_values(cfg: dict, selected_year: int | None) -> list[int]:
+ years = cfg["dataset"]["years"]
+ if selected_year is None:
+ return years
+ if selected_year not in years:
+ raise ValueError(f"Year {selected_year} not found in dataset.yml years: {years}")
+ return [selected_year]
+
+
+def latest_run_record(run_dir: Path) -> Path | None:
+ if not run_dir.exists():
+ return None
+ candidates = sorted(run_dir.glob("*.json"))
+ return candidates[-1] if candidates else None
+
+
+def build_publish_items(
+ *,
+ output_root: Path,
+ dataset: str,
+ years: list[int],
+ drive_root: Path,
+) -> list[PublishItem]:
+ items: list[PublishItem] = []
+
+ for year in years:
+ year_text = str(year)
+ mart_dir = output_root / "data" / "mart" / dataset / year_text
+ clean_dir = output_root / "data" / "clean" / dataset / year_text
+ raw_dir = output_root / "data" / "raw" / dataset / year_text
+ runs_dir = output_root / "data" / "_runs" / dataset / year_text
+
+ for path in sorted(raw_dir.glob("*")):
+ if path.is_file():
+ items.append(PublishItem(path, drive_root / path.relative_to(output_root)))
+
+ for path in sorted(mart_dir.glob("*.parquet")):
+ items.append(PublishItem(path, drive_root / path.relative_to(output_root)))
+
+ clean_parquet = clean_dir / f"{dataset}_{year_text}_clean.parquet"
+ if clean_parquet.exists():
+ items.append(PublishItem(clean_parquet, drive_root / clean_parquet.relative_to(output_root)))
+
+ for layer_dir, validation_rel in (
+ (raw_dir, "raw_validation.json"),
+ (clean_dir, "_validate/clean_validation.json"),
+ (mart_dir, "_validate/mart_validation.json"),
+ ):
+ for name in ("metadata.json", "manifest.json"):
+ path = layer_dir / name
+ if path.exists():
+ items.append(PublishItem(path, drive_root / path.relative_to(output_root)))
+
+ validation_path = layer_dir / validation_rel
+ if validation_path.exists():
+ items.append(PublishItem(validation_path, drive_root / validation_path.relative_to(output_root)))
+
+ latest_run = latest_run_record(runs_dir)
+ if latest_run is not None:
+ items.append(PublishItem(latest_run, drive_root / latest_run.relative_to(output_root)))
+
+ return items
+
+
+def copy_items(items: list[PublishItem], *, dry_run: bool) -> tuple[int, int]:
+ copied = 0
+ missing = 0
+
+ for item in items:
+ if not item.source.exists():
+ print(f"MISSING {item.source}")
+ missing += 1
+ continue
+
+ print(f"{'DRY-RUN' if dry_run else 'COPY'} {item.source} -> {item.destination}")
+ if not dry_run:
+ item.destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(item.source, item.destination)
+ copied += 1
+
+ return copied, missing
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Publish dataset artifacts to Drive preserving toolkit output paths under root."
+ )
+ parser.add_argument("--config", default="dataset.yml", help="Path to dataset.yml")
+ parser.add_argument("--drive-root", required=True, help="Destination root in Drive")
+ parser.add_argument("--year", type=int, help="Publish a single year")
+ parser.add_argument("--dry-run", action="store_true", help="Show what would be copied without writing")
+ return parser
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+ config_path = Path(args.config).resolve()
+ cfg = load_config(config_path)
+
+ dataset = cfg["dataset"]["name"]
+ years = year_values(cfg, args.year)
+ output_root = resolve_output_root(config_path, cfg)
+ drive_root = Path(args.drive_root).resolve()
+
+ items = build_publish_items(
+ output_root=output_root,
+ dataset=dataset,
+ years=years,
+ drive_root=drive_root,
+ )
+
+ print(
+ {
+ "dataset": dataset,
+ "years": years,
+ "output_root": str(output_root),
+ "drive_root": str(drive_root),
+ "dry_run": args.dry_run,
+ "items": len(items),
+ }
+ )
+
+ copied, missing = copy_items(items, dry_run=args.dry_run)
+ print({"copied": copied, "missing": missing})
+ return 0 if missing == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/smoke.sh b/scripts/smoke.sh
new file mode 100644
index 0000000..f984a6c
--- /dev/null
+++ b/scripts/smoke.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env sh
+
+set -eu
+
+DATASET_FILE="${DATASET_FILE:-dataset.yml}"
+TOOLKIT_BIN="${TOOLKIT_BIN:-toolkit}"
+DCL_ROOT="${DCL_ROOT:-$(pwd)}"
+
+export DCL_ROOT
+
+detect_python() {
+ if command -v python >/dev/null 2>&1; then
+ echo python
+ return 0
+ fi
+ if command -v python3 >/dev/null 2>&1; then
+ echo python3
+ return 0
+ fi
+ return 1
+}
+
+detect_toolkit_module() {
+ if [ -z "${PYTHON_BIN:-}" ]; then
+ return 1
+ fi
+ if "${PYTHON_BIN}" -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('toolkit.cli.app') else 1)" >/dev/null 2>&1; then
+ echo toolkit.cli.app
+ return 0
+ fi
+ return 1
+}
+
+detect_year() {
+ if [ -n "${YEAR:-}" ]; then
+ echo "${YEAR}"
+ return 0
+ fi
+ if [ -n "${1:-}" ]; then
+ echo "${1}"
+ return 0
+ fi
+ if [ -f "${DATASET_FILE}" ]; then
+ parsed_year="$(sed -n 's/^[[:space:]]*years:[[:space:]]*\[\([0-9][0-9][0-9][0-9]\).*/\1/p' "${DATASET_FILE}" | head -n 1)"
+ if [ -n "${parsed_year}" ]; then
+ echo "${parsed_year}"
+ return 0
+ fi
+ fi
+ echo 2023
+}
+
+detect_dataset() {
+ if [ -n "${DATASET_NAME:-}" ]; then
+ echo "${DATASET_NAME}"
+ return 0
+ fi
+ if [ -f "${DATASET_FILE}" ]; then
+ parsed_dataset="$(sed -n 's/^[[:space:]]*name:[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "${DATASET_FILE}" | head -n 1)"
+ if [ -n "${parsed_dataset}" ]; then
+ echo "${parsed_dataset}"
+ return 0
+ fi
+ fi
+ echo "dataset_unknown"
+}
+
+run_toolkit() {
+ if [ -n "${TOOLKIT_COMMAND:-}" ]; then
+ "${TOOLKIT_COMMAND}" "$@"
+ return 0
+ fi
+ if [ -n "${TOOLKIT_MODULE:-}" ]; then
+ "${PYTHON_BIN}" -m "${TOOLKIT_MODULE}" "$@"
+ return 0
+ fi
+ echo "Toolkit non disponibile: imposta TOOLKIT_BIN oppure installa il modulo Python del toolkit." >&2
+ exit 2
+}
+
+PYTHON_BIN="$(detect_python || true)"
+TOOLKIT_COMMAND=""
+TOOLKIT_MODULE=""
+
+if command -v "${TOOLKIT_BIN}" >/dev/null 2>&1; then
+ TOOLKIT_COMMAND="${TOOLKIT_BIN}"
+else
+ TOOLKIT_MODULE="$(detect_toolkit_module || true)"
+fi
+
+if [ -z "${TOOLKIT_COMMAND}" ] && [ -z "${TOOLKIT_MODULE}" ]; then
+ echo "Toolkit non trovato. Provati: comando '${TOOLKIT_BIN}', modulo 'toolkit.cli.app'." >&2
+ exit 2
+fi
+
+YEAR="$(detect_year "${1:-}")"
+DATASET_NAME="$(detect_dataset)"
+
+echo "DCL_ROOT=${DCL_ROOT}"
+echo "DATASET_FILE=${DATASET_FILE}"
+echo "TOOLKIT_BIN=${TOOLKIT_BIN}"
+echo "TOOLKIT_COMMAND=${TOOLKIT_COMMAND:-}"
+echo "TOOLKIT_MODULE=${TOOLKIT_MODULE:-}"
+echo "DATASET_NAME=${DATASET_NAME}"
+echo "YEAR=${YEAR}"
+
+run_toolkit run all --config "${DATASET_FILE}"
+run_toolkit validate all --config "${DATASET_FILE}"
+run_toolkit status --dataset "${DATASET_NAME}" --year "${YEAR}" --latest --config "${DATASET_FILE}"
+run_toolkit inspect paths --config "${DATASET_FILE}" --year "${YEAR}" --json
diff --git a/sql/README.md b/sql/README.md
new file mode 100644
index 0000000..a97570e
--- /dev/null
+++ b/sql/README.md
@@ -0,0 +1,15 @@
+# Query
+
+Questa cartella contiene query ad hoc, esplorative o materiale legacy.
+
+## Regole
+
+- usare nomi descrittivi
+- commentare input e output
+- evitare dipendenze implicite da path assoluti
+
+## Nota
+
+La pipeline toolkit-first usa come riferimento canonico la cartella `sql/`.
+
+Mantieni `queries/` solo per analisi non canoniche o work-in-progress.
diff --git a/sql/clean.sql b/sql/clean.sql
new file mode 100644
index 0000000..22585a2
--- /dev/null
+++ b/sql/clean.sql
@@ -0,0 +1,33 @@
+WITH base AS (
+ SELECT
+ TRY_CAST(TRIM(CAST("ANNO" AS VARCHAR)) AS INTEGER) AS anno,
+
+ TRY_CAST(TRIM(CAST("RISPARMIO_PUBBLICO" AS VARCHAR)) AS DOUBLE) AS risparmio_pubblico,
+ TRY_CAST(TRIM(CAST("SALDO_NETTO" AS VARCHAR)) AS DOUBLE) AS saldo_netto,
+ TRY_CAST(TRIM(CAST("INDEBITAMENTO_NETTO" AS VARCHAR)) AS DOUBLE) AS indebitamento_netto,
+ TRY_CAST(TRIM(CAST("RICORSO_MERCATO" AS VARCHAR)) AS DOUBLE) AS ricorso_mercato,
+ TRY_CAST(TRIM(CAST("AVANZO_PRIMARIO" AS VARCHAR)) AS DOUBLE) AS avanzo_primario,
+
+ TRY_CAST(TRIM(CAST("SPESE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS spese_correnti,
+ TRY_CAST(TRIM(CAST("SPESE_INTERESSI" AS VARCHAR)) AS DOUBLE) AS spese_interessi,
+ TRY_CAST(TRIM(CAST("SPESE_CONTO_CAPITALE" AS VARCHAR)) AS DOUBLE) AS spese_conto_capitale,
+ TRY_CAST(TRIM(CAST("SPESE_ACQ_ATT_FINE" AS VARCHAR)) AS DOUBLE) AS spese_acq_att_fin,
+ TRY_CAST(TRIM(CAST("SPESE_RIMBORSO_PRESTITI" AS VARCHAR)) AS DOUBLE) AS spese_rimborso_prestiti,
+ TRY_CAST(TRIM(CAST("SPESE_COMPLESSIVE" AS VARCHAR)) AS DOUBLE) AS spese_complessive,
+ TRY_CAST(TRIM(CAST("SPESE_FINALI" AS VARCHAR)) AS DOUBLE) AS spese_finali,
+ TRY_CAST(TRIM(CAST("SPESE_FIN_NETTO_ATT_FIN" AS VARCHAR)) AS DOUBLE) AS spese_fin_netto_att_fin,
+
+ TRY_CAST(TRIM(CAST("ENTRATE_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_tributarie,
+ TRY_CAST(TRIM(CAST("ENTRATE_EXTRA_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_extra_tributarie,
+ TRY_CAST(TRIM(CAST("ENTR_ALIEN_PATR_RISCOS" AS VARCHAR)) AS DOUBLE) AS entr_alien_patr_riscos,
+ TRY_CAST(TRIM(CAST("RISCOSSIONE_CREDITI" AS VARCHAR)) AS DOUBLE) AS riscossione_crediti,
+ TRY_CAST(TRIM(CAST("ENTR_ACCENSIONE_PRESTITI" AS VARCHAR)) AS DOUBLE) AS entr_accensione_prestiti,
+ TRY_CAST(TRIM(CAST("ENTRATE_FINALI" AS VARCHAR)) AS DOUBLE) AS entrate_finali,
+ TRY_CAST(TRIM(CAST("ENTR_FIN_NETTO_RISCO_CRED" AS VARCHAR)) AS DOUBLE) AS entr_fin_netto_risco_cred,
+ TRY_CAST(TRIM(CAST("ENTRATE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS entrate_correnti
+ FROM raw_input
+)
+
+SELECT *
+FROM base
+WHERE anno IS NOT NULL;
diff --git a/sql/mart/mart_ok.sql b/sql/mart/mart_ok.sql
new file mode 100644
index 0000000..8893846
--- /dev/null
+++ b/sql/mart/mart_ok.sql
@@ -0,0 +1,15 @@
+SELECT
+ anno,
+ saldo_netto,
+ indebitamento_netto,
+ avanzo_primario,
+ entrate_finali,
+ spese_finali,
+ entrate_finali - spese_finali AS differenza_entrate_spese,
+ CASE
+ WHEN spese_finali IS NULL OR spese_finali = 0 THEN NULL
+ ELSE entrate_finali / spese_finali
+ END AS rapporto_entrate_spese
+FROM clean_input
+WHERE anno IS NOT NULL
+ORDER BY anno
diff --git a/tests/test_contract.py b/tests/test_contract.py
new file mode 100644
index 0000000..d4e2a68
--- /dev/null
+++ b/tests/test_contract.py
@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+import yaml
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+DATASET_FILE = REPO_ROOT / "dataset.yml"
+DATA_DIR = REPO_ROOT / "data"
+NOTEBOOKS_DIR = REPO_ROOT / "notebooks"
+BLOCKED_DATA_EXTENSIONS = {".parquet", ".csv", ".jsonl", ".zip", ".xlsx", ".tsv"}
+REQUIRED_FILES = [
+ REPO_ROOT / "dataset.yml",
+ REPO_ROOT / "sql" / "clean.sql",
+ REPO_ROOT / "docs" / "sources.md",
+ REPO_ROOT / "docs" / "decisions.md",
+ REPO_ROOT / "docs" / "data_dictionary.md",
+ REPO_ROOT / "scripts" / "smoke.sh",
+ REPO_ROOT / ".github" / "workflows" / "ci.yml",
+]
+
+
+def _load_dataset() -> dict:
+ return yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8"))
+
+
+def _iter_path_values(node: object):
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if isinstance(value, str) and key in {"root", "source", "sql", "target", "dir", "path", "filename"}:
+ yield key, value
+ yield from _iter_path_values(value)
+ elif isinstance(node, list):
+ for item in node:
+ yield from _iter_path_values(item)
+
+
+def test_required_files_exist() -> None:
+ missing = [str(path.relative_to(REPO_ROOT)) for path in REQUIRED_FILES if not path.exists()]
+ assert not missing, f"Missing required template files: {missing}"
+
+
+def test_dataset_declares_minimum_contract() -> None:
+ dataset = _load_dataset()
+
+ assert dataset.get("schema_version") == 1
+ assert "root" in dataset
+ assert "dataset" in dataset
+ assert "name" in dataset["dataset"]
+ assert "years" in dataset["dataset"]
+ assert isinstance(dataset["dataset"]["years"], list)
+ assert dataset["dataset"]["years"]
+ assert "raw" in dataset
+ assert "sources" in dataset["raw"]
+ assert isinstance(dataset["raw"]["sources"], list)
+ assert dataset["raw"]["sources"]
+ assert dataset["raw"]["sources"][0]["primary"] is True
+ assert "clean" in dataset
+ assert dataset["clean"]["sql"]
+ assert dataset["clean"]["read_mode"] in {"strict", "fallback", "robust"}
+ assert "read" in dataset["clean"]
+ assert isinstance(dataset["clean"]["read"], dict)
+ assert dataset["clean"]["read"]["source"] in {"auto", "config_only"}
+ assert dataset["clean"]["read"]["mode"] in {"explicit", "latest", "largest", "all"}
+ assert "header" in dataset["clean"]["read"]
+ assert "columns" in dataset["clean"]["read"]
+ assert dataset["clean"]["required_columns"]
+ assert dataset["clean"]["validate"]["primary_key"]
+ assert dataset["clean"]["validate"]["not_null"]
+ assert dataset["clean"]["validate"]["min_rows"] == 1
+ assert "mart" in dataset
+ assert "tables" in dataset["mart"]
+ assert isinstance(dataset["mart"]["tables"], list)
+ assert dataset["mart"]["tables"]
+ assert dataset["mart"]["required_tables"]
+ assert "table_rules" in dataset["mart"]["validate"]
+ assert dataset["validation"]["fail_on_error"] is True
+ assert dataset["output"]["artifacts"] in {"minimal", "standard", "debug"}
+
+
+def test_dataset_avoids_legacy_clean_read_shape() -> None:
+ dataset = _load_dataset()
+
+ assert "csv" not in dataset["clean"]["read"]
+
+
+def test_dataset_paths_are_relative_and_posix() -> None:
+ dataset = _load_dataset()
+
+ for key, value in _iter_path_values(dataset):
+ if value.startswith("http://") or value.startswith("https://"):
+ continue
+ assert value, f"Empty path value for key '{key}'"
+ assert "\\" not in value, f"Path for key '{key}' must use POSIX separators: {value}"
+ assert not value.startswith("/"), f"Absolute POSIX path found for key '{key}': {value}"
+ assert not value.startswith("~"), f"Home-relative path found for key '{key}': {value}"
+ assert not re.match(r"^[A-Za-z]:[\\/]", value), f"Absolute Windows path found for key '{key}': {value}"
+
+
+def test_declared_sql_files_exist() -> None:
+ dataset = _load_dataset()
+
+ clean_sql = REPO_ROOT / dataset["clean"]["sql"]
+ assert clean_sql.exists(), f"Missing clean SQL file declared in dataset.yml: {dataset['clean']['sql']}"
+
+ mart_tables = dataset["mart"]["tables"]
+ for table in mart_tables:
+ assert "name" in table and table["name"], "Each mart table must declare a non-empty name"
+ assert "sql" in table and table["sql"], f"Mart table '{table['name']}' must declare an SQL path"
+ sql_path = REPO_ROOT / table["sql"]
+ assert sql_path.exists(), f"Missing mart SQL file declared in dataset.yml: {table['sql']}"
+
+
+def test_mart_table_names_are_unique() -> None:
+ dataset = _load_dataset()
+ names = [table["name"] for table in dataset["mart"]["tables"]]
+ assert len(names) == len(set(names)), f"Duplicate mart table names found: {names}"
+
+
+def test_required_tables_and_rules_match_declared_marts() -> None:
+ dataset = _load_dataset()
+
+ names = {table["name"] for table in dataset["mart"]["tables"]}
+ required_tables = set(dataset["mart"]["required_tables"])
+ table_rules = set(dataset["mart"]["validate"]["table_rules"].keys())
+
+ assert required_tables <= names, "mart.required_tables must reference declared mart.tables"
+ assert table_rules <= names, "mart.validate.table_rules must reference declared mart.tables"
+
+
+def test_data_directory_does_not_contain_committed_outputs() -> None:
+ offenders: list[str] = []
+
+ if not DATA_DIR.exists():
+ return
+
+ for path in DATA_DIR.rglob("*"):
+ if not path.is_file():
+ continue
+ if "_examples" in path.parts:
+ continue
+ if path.name == "README.md":
+ continue
+ if path.suffix.lower() not in BLOCKED_DATA_EXTENSIONS:
+ continue
+ offenders.append(str(path.relative_to(REPO_ROOT)).replace("\\", "/"))
+
+ assert not offenders, (
+ "Non committare output in data/: usa data/_examples per sample piccoli. "
+ f"Found: {offenders}"
+ )
+
+
+def test_notebooks_do_not_rebuild_runtime_output_paths() -> None:
+ forbidden_patterns = [
+ "OUT_ROOT =",
+ "/ 'data' / 'raw' /",
+ "/ 'data' / 'clean' /",
+ "/ 'data' / 'mart' /",
+ "/ 'data' / '_runs' /",
+ "Path(INSPECT['paths']['mart']['dir']) /",
+ ]
+
+ offenders: list[str] = []
+
+ for path in sorted(NOTEBOOKS_DIR.glob("*.ipynb")):
+ notebook = json.loads(path.read_text(encoding="utf-8"))
+ for cell in notebook.get("cells", []):
+ if cell.get("cell_type") != "code":
+ continue
+ source = "".join(cell.get("source", []))
+ for pattern in forbidden_patterns:
+ if pattern in source:
+ offenders.append(f"{path.relative_to(REPO_ROOT)} -> {pattern}")
+
+ assert not offenders, (
+ "I notebook devono usare `toolkit inspect paths --json` come fonte di verita` "
+ "e non ricostruire a mano i path del runtime. "
+ f"Found: {offenders}"
+ )