Skip to content

Commit 623b944

Browse files
committed
fix(graph): label reconstructed edges by their actual provenance
1 parent c76bb3f commit 623b944

9 files changed

Lines changed: 269 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
test:
16+
runs-on: ubuntu-latest
17+
name: Test and build
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: "22"
24+
cache: "npm"
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Run model tests
30+
run: npm test
31+
32+
- name: Build
33+
run: npm run build

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ dist/
77
Thumbs.db
88
.vite/
99
.cache-catalog.csv
10+
11+
# Artefatto transitorio della pipeline (rigenerato a ogni run)
12+
pipeline/last-run-report.md

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,13 @@ Ogni arco generato dalla pipeline porta un campo `origine` che ne dichiara la fo
317317
|---|---|
318318
| `documentata` | Override da fonti ufficiali pubbliche (circolari, manuali, presentazioni) |
319319
| `certificata` | Derivata dal campo `attributes.certified` del catalogo |
320+
| `ricostruita` | Ricostruita da documentazione pubblica, senza provenienza per singolo arco |
320321
| `inferita` | Stima prodotta dal motore di inferenza, con punteggio di confidenza |
321322

322323
Gli archi inferiti sono dichiarati come stime e non come fatti documentati. Il progetto si basa esclusivamente su informazioni già pubbliche.
323324

325+
Il grafo attualmente pubblicato (`src/data/pdnd-data.json`, modello v1) non porta la provenienza per singolo arco: i suoi archi sono ricostruiti da documentazione pubblica secondo la [metodologia](METODOLOGIA.md) e vengono resi con origine `ricostruita`. Le origini `certificata` e `inferita` per singolo arco sono prodotte dalla pipeline nell'anteprima v2, non ancora promossa.
326+
324327
## Come aggiornare i dati
325328

326329
### Aggiungere un nuovo ente

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"update-data": "node scripts/update-data.mjs",
1212
"audit": "node scripts/audit-model.mjs",
1313
"compare-catalog": "node scripts/compare-catalog.mjs",
14-
"paper-metrics": "node scripts/compute-paper-metrics.mjs"
14+
"paper-metrics": "node scripts/compute-paper-metrics.mjs",
15+
"test": "node scripts/test-model.mjs"
1516
},
1617
"dependencies": {
1718
"react": "^19.2.5",

pipeline/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Pipeline di aggiornamento automatico
22

3+
> **Quale dato è canonico.** Il grafo pubblicato su pdndgraph.it e citato dal
4+
> report Zenodo (DOI 10.5281/zenodo.19989954) è `src/data/pdnd-data.json`: il
5+
> modello curato **v1**, i cui archi sono ricostruiti da documentazione pubblica.
6+
> Questa pipeline produce `pipeline/pdnd-data.preview.json`, un modello **v2**
7+
> ancorato al campo `attributes.certified` del catalogo, con provenienza
8+
> dichiarata per singolo arco. La v2 **non è ancora promossa**: ha numeri
9+
> sensibilmente diversi dalla v1 e la sua pubblicazione richiede una nuova
10+
> versione del report. Non sostituire il dato live senza quella decisione.
11+
12+
313
Questa cartella contiene la pipeline che mantiene aggiornato il grafo rispetto
414
allo stato corrente del catalogo PDND, in modo automatico e senza intervento manuale.
515

scripts/test-model.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env node
2+
/**
3+
* test-model.mjs
4+
* ──────────────
5+
* Test di integrità del dato e di comportamento di buildGraph.
6+
* Non scarica nulla: gira offline su src/data/pdnd-data.json.
7+
*
8+
* Uso: npm test
9+
* Exit: 0 se tutti i test passano, 1 al primo fallimento.
10+
*
11+
* Copre in particolare la regressione della provenienza: il dato curato (v1)
12+
* non porta il campo `archi`, quindi buildGraph applica un fallback. Quel
13+
* fallback NON deve dichiarare gli archi come "certificata", perché non sono
14+
* derivati dal campo attributes del catalogo ma ricostruiti da documentazione.
15+
*/
16+
17+
import fs from "fs";
18+
import path from "path";
19+
import { fileURLToPath } from "url";
20+
21+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
22+
const ROOT = path.join(__dirname, "..");
23+
24+
const DATA = JSON.parse(
25+
fs.readFileSync(path.join(ROOT, "src/data/pdnd-data.json"), "utf8")
26+
);
27+
28+
// buildGraph è un modulo ES con export nominato: lo importiamo direttamente.
29+
const { buildGraph } = await import(
30+
path.join(ROOT, "src/utils/buildGraph.js")
31+
);
32+
33+
let passed = 0;
34+
const failures = [];
35+
36+
function check(name, fn) {
37+
try {
38+
const res = fn();
39+
if (res === true || res === undefined) {
40+
passed++;
41+
console.log(` ok ${name}`);
42+
} else {
43+
failures.push(`${name}: ${res}`);
44+
console.log(` FAIL ${name}: ${res}`);
45+
}
46+
} catch (err) {
47+
failures.push(`${name}: ${err.message}`);
48+
console.log(` FAIL ${name}: ${err.message}`);
49+
}
50+
}
51+
52+
console.log("\nIntegrità del dato (src/data/pdnd-data.json)");
53+
54+
const ids = new Set(DATA.enti.map((e) => e.id));
55+
56+
check("ogni ente ha id, name e categoria", () => {
57+
const bad = DATA.enti.filter((e) => !e.id || !e.name || !e.categoria);
58+
return bad.length === 0 || `${bad.length} enti incompleti`;
59+
});
60+
61+
check("gli id degli enti sono unici", () => {
62+
return ids.size === DATA.enti.length || "id duplicati fra gli enti";
63+
});
64+
65+
check("gli id degli e-service sono unici", () => {
66+
const seen = new Set();
67+
const dup = DATA.eservices.filter((s) => (seen.has(s.id) ? true : (seen.add(s.id), false)));
68+
return dup.length === 0 || `${dup.length} id duplicati`;
69+
});
70+
71+
check("ogni erogatore risolve a un ente esistente", () => {
72+
const bad = DATA.eservices.filter((s) => !ids.has(s.erogatore));
73+
return bad.length === 0 || `${bad.length} e-service con erogatore ignoto`;
74+
});
75+
76+
check("ogni fruitore risolve a un ente esistente", () => {
77+
const bad = [];
78+
DATA.eservices.forEach((s) =>
79+
(s.fruitori || []).forEach((f) => { if (!ids.has(f)) bad.push(`${s.id}${f}`); })
80+
);
81+
return bad.length === 0 || `${bad.length} riferimenti pendenti: ${bad.slice(0, 3).join(", ")}`;
82+
});
83+
84+
check("nessun auto-anello (erogatore uguale a fruitore)", () => {
85+
const bad = [];
86+
DATA.eservices.forEach((s) =>
87+
(s.fruitori || []).forEach((f) => { if (f === s.erogatore) bad.push(s.id); })
88+
);
89+
return bad.length === 0 || `${bad.length} auto-anelli: ${bad.slice(0, 3).join(", ")}`;
90+
});
91+
92+
check("nessun fruitore ripetuto nello stesso e-service", () => {
93+
const bad = DATA.eservices.filter(
94+
(s) => new Set(s.fruitori || []).size !== (s.fruitori || []).length
95+
);
96+
return bad.length === 0 || `${bad.length} e-service con fruitori duplicati`;
97+
});
98+
99+
check("nessun nodo orfano (ogni ente compare in almeno un arco)", () => {
100+
const used = new Set();
101+
DATA.eservices.forEach((s) => {
102+
used.add(s.erogatore);
103+
(s.fruitori || []).forEach((f) => used.add(f));
104+
});
105+
const orfani = DATA.enti.filter((e) => !used.has(e.id)).map((e) => e.id);
106+
return orfani.length === 0 || `${orfani.length} orfani: ${orfani.join(", ")}`;
107+
});
108+
109+
check("il meta dichiara la fonte delle connessioni", () => {
110+
return (
111+
(DATA.meta && DATA.meta.fonte_connessioni && DATA.meta.note_connessioni)
112+
? true
113+
: "meta.fonte_connessioni o meta.note_connessioni mancante"
114+
);
115+
});
116+
117+
console.log("\nComportamento di buildGraph");
118+
119+
const graph = buildGraph(DATA);
120+
const ORIGINI_VALIDE = new Set(["documentata", "certificata", "ricostruita", "inferita"]);
121+
122+
check("restituisce nodes, links e linkCounts", () => {
123+
return (
124+
Array.isArray(graph.nodes) && Array.isArray(graph.links) && graph.linkCounts
125+
? true
126+
: "struttura di ritorno inattesa"
127+
);
128+
});
129+
130+
check("un nodo per ogni ente", () => {
131+
return graph.nodes.length === DATA.enti.length ||
132+
`${graph.nodes.length} nodi contro ${DATA.enti.length} enti`;
133+
});
134+
135+
check("un link per ogni istanza erogatore→fruitore", () => {
136+
const atteso = DATA.eservices.reduce((a, s) => a + (s.fruitori || []).length, 0);
137+
return graph.links.length === atteso ||
138+
`${graph.links.length} link contro ${atteso} attesi`;
139+
});
140+
141+
check("ogni link porta un'origine valida", () => {
142+
const bad = graph.links.filter((l) => !ORIGINI_VALIDE.has(l.origine));
143+
return bad.length === 0 ||
144+
`${bad.length} link con origine non prevista (es. "${bad[0]?.origine}")`;
145+
});
146+
147+
// Regressione: il dato v1 non ha `archi`, quindi tutti i link nascono dal
148+
// fallback. Etichettarli "certificata" affermerebbe una derivazione dal campo
149+
// attributes del catalogo che non è mai avvenuta.
150+
check('il fallback non dichiara gli archi "certificata"', () => {
151+
const senzaArchi = DATA.eservices.filter((s) => !s.archi);
152+
if (senzaArchi.length === 0) return true; // il dato porta già la provenienza
153+
const nomi = new Set(senzaArchi.map((s) => s.nome));
154+
const daFallback = graph.links.filter((l) => nomi.has(l.eservice));
155+
const certificati = daFallback.filter((l) => l.origine === "certificata");
156+
return certificati.length === 0 ||
157+
`${certificati.length} archi ricostruiti dichiarati "certificata"`;
158+
});
159+
160+
check("nessun link punta a un nodo inesistente", () => {
161+
const bad = graph.links.filter((l) => !ids.has(l.source) || !ids.has(l.target));
162+
return bad.length === 0 || `${bad.length} link pendenti`;
163+
});
164+
165+
check("il peso è simmetrico per la coppia non ordinata", () => {
166+
const bad = graph.links.filter((l) => {
167+
const key = [l.source, l.target].sort().join("--");
168+
return l.weight !== graph.linkCounts[key];
169+
});
170+
return bad.length === 0 || `${bad.length} link con peso incoerente`;
171+
});
172+
173+
check("ogni peso è un intero positivo", () => {
174+
const bad = graph.links.filter(
175+
(l) => !Number.isInteger(l.weight) || l.weight < 1
176+
);
177+
return bad.length === 0 || `${bad.length} pesi non validi`;
178+
});
179+
180+
console.log("\nCoerenza con le metriche pubblicate");
181+
182+
check("la topologia corrisponde al report (51 nodi, 86 e-service, 343 archi)", () => {
183+
const distinti = new Set();
184+
DATA.eservices.forEach((s) =>
185+
(s.fruitori || []).forEach((f) => distinti.add(`${s.erogatore}->${f}`))
186+
);
187+
const err = [];
188+
if (DATA.enti.length !== 51) err.push(`nodi ${DATA.enti.length}≠51`);
189+
if (DATA.eservices.length !== 86) err.push(`e-service ${DATA.eservices.length}≠86`);
190+
if (distinti.size !== 343) err.push(`archi distinti ${distinti.size}≠343`);
191+
return err.length === 0 || err.join(", ");
192+
});
193+
194+
const totale = passed + failures.length;
195+
console.log(
196+
`\n${failures.length === 0 ? "Tutti i test superati" : "Test falliti"}: ${passed}/${totale}\n`
197+
);
198+
process.exit(failures.length === 0 ? 0 : 1);

src/components/GraphView.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,11 @@ export default function GraphView() {
9797
if (hl) { ctx.strokeStyle = `rgba(200,220,255,${.4 + w * .06})`; ctx.lineWidth = 1.5 + w * .5; ctx.shadowColor = "rgba(100,180,255,.3)"; ctx.shadowBlur = 6; }
9898
else if (dim2) { ctx.strokeStyle = "rgba(100,120,140,.04)"; ctx.lineWidth = .3; }
9999
else { ctx.strokeStyle = `rgba(100,160,220,${.08 + w * .03})`; ctx.lineWidth = .5 + w * .3; }
100-
// Archi inferiti dall'AI: tratteggiati per distinguerli da quelli documentati/certificati
101-
if (l.origine === "inferita") ctx.setLineDash([6, 5]); else ctx.setLineDash([]);
100+
// Stile per provenienza: continuo = ancorato a fonte (documentata/certificata),
101+
// tratto lungo = ricostruito da documentazione, punteggiato = inferito dall'AI.
102+
if (l.origine === "inferita") ctx.setLineDash([6, 5]);
103+
else if (l.origine === "ricostruita") ctx.setLineDash([12, 4]);
104+
else ctx.setLineDash([]);
102105
ctx.stroke(); ctx.setLineDash([]); ctx.shadowBlur = 0;
103106

104107
if (hl) {
@@ -273,6 +276,7 @@ export default function GraphView() {
273276
<div style={{ position: "absolute", bottom: 12, right: 12, background: "rgba(10,14,26,.88)", borderRadius: 8, border: "1px solid rgba(100,160,220,.1)", padding: "8px 12px", display: "flex", flexDirection: "column", gap: 5, fontSize: 9, color: "#94a3b8" }}>
274277
<div style={{ fontSize: 8, textTransform: "uppercase", letterSpacing: .6, color: "#64748b", marginBottom: 1 }}>Provenienza archi</div>
275278
<div style={{ display: "flex", alignItems: "center", gap: 6 }}><svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="rgba(100,160,220,.9)" strokeWidth="1.6" /></svg><span>Documentata / Certificata</span></div>
279+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}><svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="rgba(100,160,220,.9)" strokeWidth="1.6" strokeDasharray="10,3" /></svg><span>Ricostruita (documentazione)</span></div>
276280
<div style={{ display: "flex", alignItems: "center", gap: 6 }}><svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="rgba(100,160,220,.9)" strokeWidth="1.6" strokeDasharray="5,4" /></svg><span>Inferita (AI)</span></div>
277281
</div>
278282
)}

src/components/StatsView.jsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,15 @@ export default function StatsView({ graphData }) {
4444
const topEservices = useMemo(() =>
4545
[...PDND_DATA.eservices].sort((a, b) => b.fruitori.length - a.fruitori.length).slice(0, 10), []);
4646

47+
// Densità calcolata sugli archi DISTINTI erogatore→fruitore, coerente con la
48+
// definizione del paper (|E| = coppie dirette distinte, non istanze per e-service).
4749
const density = useMemo(() => {
4850
const n = nodes.length;
49-
const e = PDND_DATA.eservices.reduce((a, es) => a + es.fruitori.length, 0);
50-
return (e / (n * (n - 1))).toFixed(3);
51+
const distinct = new Set();
52+
PDND_DATA.eservices.forEach((es) =>
53+
(es.fruitori || []).forEach((f) => distinct.add(`${es.erogatore}->${f}`))
54+
);
55+
return (distinct.size / (n * (n - 1))).toFixed(4);
5156
}, [nodes]);
5257

5358
return (

src/utils/buildGraph.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ export function buildGraph(data) {
2222
const linkCounts = {};
2323

2424
data.eservices.forEach((es) => {
25-
// Usa `archi` (con provenienza) se presente, altrimenti `fruitori` (legacy)
26-
const archi = es.archi || es.fruitori.map((f) => ({ fruitore: f, origine: "certificata" }));
25+
// Usa `archi` (con provenienza) se presente, altrimenti `fruitori` (legacy).
26+
// Il dato curato (v1) non porta provenienza per singolo arco: gli archi sono
27+
// ricostruiti da documentazione pubblica (vedi meta.note_connessioni e
28+
// METODOLOGIA.md), non certificati dal campo `attributes` del catalogo.
29+
// L'origine di fallback è quindi "ricostruita", non "certificata".
30+
const archi = es.archi || es.fruitori.map((f) => ({ fruitore: f, origine: "ricostruita" }));
2731
archi.forEach(({ fruitore, origine }) => {
2832
links.push({
2933
source: es.erogatore,
@@ -33,7 +37,7 @@ export function buildGraph(data) {
3337
versione: es.versione,
3438
stato: es.stato,
3539
descrizione: es.descrizione,
36-
origine: origine || "certificata",
40+
origine: origine || "ricostruita",
3741
});
3842
const key = [es.erogatore, fruitore].sort().join("--");
3943
linkCounts[key] = (linkCounts[key] || 0) + 1;

0 commit comments

Comments
 (0)